Skip to main content

ljos_cli/
lib.rs

1//! One seat over the habitats. Each habitat keeps its own crate.
2//!
3//! Cards are read-only. Remember/Prefer POST `/v1/atoms` and never extract
4//! on write. Consensus is a different crate, then the tracker verb. Policyd
5//! is argv law: this process does not reload a pack as a check.
6
7use std::path::{Path, PathBuf};
8
9use anyhow::{bail, Context, Result};
10use packset_client::{Hit, PacksetClient};
11use serde_json::Value;
12
13pub mod hud;
14pub mod jev;
15pub mod sync;
16
17/// Working-core files this seat will print. Nothing else, and never write.
18pub const CARD_NAMES: &[&str] = &["USER.md", "MEMORY.md"];
19
20/// The sitting protocol: which store answers which question, the order of
21/// verbs before, during and after the work, and the refusals worth knowing.
22/// `ljos protocol` prints it, `ljos onboard` installs it as a skill, and the
23/// server serves it at `ljos://protocol`. Harness agnostic on purpose.
24pub const PROTOCOL: &str = include_str!("../doc/protocol.md");
25
26/// The skill file a harness loads: front matter, then the protocol.
27#[must_use]
28pub fn skill_text() -> String {
29    format!(
30        "---\nname: ljos\ndescription: >\n  The seat protocol for vissue, packset, deedar, claimdag and \
31consensus through ljos: which store answers which question, the order of verbs in a \
32sitting, and the refusals worth knowing. Load before any work that touches an issue, \
33a memory, a deed, a claim or a vote.\n---\n\n{PROTOCOL}"
34    )
35}
36
37/// One step an onboarding took, or would take.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Step {
40    pub what: String,
41    pub detail: String,
42    pub ok: bool,
43}
44
45/// One agent runner, as the seat's own configuration describes it. The seat
46/// ships no runner's name: the file at [`harnesses_path`] names them, one
47/// table each, and `onboard` and `doctor` read it.
48///
49/// A runner registers MCP servers one of two ways. `register` is a command
50/// that does it (`{server}` is replaced by the path to `ljos-mcp`) and
51/// `registered` a command that exits 0 once it is done. Or `config` is a
52/// file the runner reads, `marker` a line that means the entry is present,
53/// and `snippet` what to append when it is not. `skills` is the directory
54/// the runner loads skills from; the protocol goes to `<skills>/ljos/SKILL.md`.
55#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
56pub struct Harness {
57    pub name: String,
58    #[serde(default)]
59    pub register: Vec<String>,
60    #[serde(default)]
61    pub registered: Vec<String>,
62    #[serde(default)]
63    pub config: Option<String>,
64    #[serde(default)]
65    pub marker: Option<String>,
66    #[serde(default)]
67    pub snippet: Option<String>,
68    /// A JSON config file the runner reads its MCP servers from, for a
69    /// runner an appended snippet cannot serve.
70    pub config_json: Option<String>,
71    /// Where in that file the entry goes, as a JSON pointer (`/mcp/ljos`).
72    pub json_pointer: Option<String>,
73    /// The entry to set there, as JSON text; `{server}` and `{name}` are
74    /// replaced.
75    pub json_entry: Option<String>,
76    #[serde(default)]
77    pub skills: Option<String>,
78    /// A JSON settings file the runner reads hooks from, in the shape
79    /// `{"hooks": {"<Event>": [{"matcher": "...", "hooks": [{"type":
80    /// "command", "command": "..."}]}]}}`. `onboard` merges the seat's
81    /// memory hook into it, so what the seat knows about a command or a
82    /// prompt reaches the agent at the point of action.
83    #[serde(default)]
84    pub hooks: Option<String>,
85    /// The events the memory hook fires on. Empty means [`HOOK_EVENTS`],
86    /// the prompt event alone: a panel of this seat's personas settled on
87    /// prompts over tool calls, because a turn issues many shell commands
88    /// and one prompt. `["UserPromptSubmit", "PreToolUse"]` injects on both.
89    #[serde(default)]
90    pub hook_events: Vec<String>,
91    /// Where a runner whose hooks are code loads a plugin from, for a
92    /// runner with no hooks file: the plugin carries the memory hook and
93    /// argv law and shells to `ljos hook`.
94    #[serde(default)]
95    pub plugin: Option<String>,
96    /// Which bundled plugin goes there: a name in [`PLUGIN_TEMPLATES`].
97    #[serde(default)]
98    pub plugin_template: Option<String>,
99    /// A command that proves the runner loads the ljos tools, not only that
100    /// its config names them: it must exit 0 and print `ljos_sitting`. A
101    /// runner installed without its MCP support lists the entry and loads
102    /// nothing.
103    #[serde(default)]
104    pub probe: Vec<String>,
105    /// The names this runner's MCP client sends at initialize, when they are
106    /// not the runner's name: the seat is then the harness's name, so one
107    /// runner's memory, ballots and trust rows stay one voter instead of
108    /// scattering over `acme` and `acme-mcp-client`.
109    #[serde(default)]
110    pub clients: Vec<String>,
111}
112
113/// The plugins `ljos` carries for runners whose hooks are code, by name.
114/// `{ljos}` in each is filled with the absolute path at onboard.
115pub const PLUGIN_TEMPLATES: &[(&str, &str)] = &[
116    ("opencode", include_str!("../assets/opencode/ljos.ts")),
117    ("omp", include_str!("../assets/omp/ljos.ts")),
118];
119
120/// A runner's plugin as it is written: the template, `{ljos}` filled.
121fn plugin_text(h: &Harness, ljos: &Path) -> Option<String> {
122    let name = h.plugin_template.as_deref()?;
123    PLUGIN_TEMPLATES
124        .iter()
125        .find(|(n, _)| *n == name)
126        .map(|(_, t)| t.replace("{ljos}", &ljos.display().to_string()))
127}
128
129fn plugin_step(h: &Harness, dest: &Path, dry: bool) -> Step {
130    let what = "plugin".to_string();
131    let ljos = match ljos_path() {
132        Ok(l) => l,
133        Err(e) => {
134            return Step {
135                what,
136                detail: format!("{e:#}"),
137                ok: false,
138            };
139        }
140    };
141    let Some(text) = plugin_text(h, &ljos) else {
142        return Step {
143            what,
144            detail: format!(
145                "plugin_template {:?} is not one of {}",
146                h.plugin_template.as_deref().unwrap_or(""),
147                PLUGIN_TEMPLATES
148                    .iter()
149                    .map(|(n, _)| *n)
150                    .collect::<Vec<_>>()
151                    .join(", ")
152            ),
153            ok: false,
154        };
155    };
156    if std::fs::read_to_string(dest).is_ok_and(|have| have == text) {
157        return Step {
158            what,
159            detail: format!("{} is current", dest.display()),
160            ok: true,
161        };
162    }
163    if dry {
164        return Step {
165            what,
166            detail: format!("would write {}", dest.display()),
167            ok: true,
168        };
169    }
170    let written = dest
171        .parent()
172        .map_or(Ok(()), std::fs::create_dir_all)
173        .and_then(|()| std::fs::write(dest, text));
174    match written {
175        Ok(()) => Step {
176            what,
177            detail: format!("wrote {}", dest.display()),
178            ok: true,
179        },
180        Err(e) => Step {
181            what,
182            detail: format!("{}: {e}", dest.display()),
183            ok: false,
184        },
185    }
186}
187
188/// The whole file: `[[harness]]` tables.
189#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
190pub struct Harnesses {
191    #[serde(default)]
192    pub harness: Vec<Harness>,
193}
194
195/// An example of the file, with placeholder names. `ljos onboard --example`
196/// prints it; the two shapes are a registering command and a config file.
197pub const HARNESSES_EXAMPLE: &str = r#"# ~/.config/ljos/harnesses.toml: runners this machine registers by command.
198# Optional: `ljos onboard` alone prints the one entry any runner takes.
199# {server} is replaced by the path to ljos-mcp, {name} by the runner's name.
200# Paths may start with ~. The seat names itself after the client that
201# connects; nothing is passed in env.
202
203[[harness]]
204name = "runner-with-a-command"
205register = ["runner", "mcp", "add", "-s", "user", "ljos", "--", "{server}"]
206registered = ["runner", "mcp", "get", "ljos"]
207skills = "~/.runner/skills"
208hooks = "~/.runner/settings.json"
209# hook_events = ["UserPromptSubmit", "PreToolUse"]   # the default is the prompt alone
210
211[[harness]]
212name = "runner-with-a-config-file"
213config = "~/.other/config.toml"
214marker = "[mcp_servers.ljos]"
215# A runner that rebuilds its servers' environment from a short list must be
216# told to pass XDG_RUNTIME_DIR, where the seat records live.
217snippet = "\n[mcp_servers.ljos]\ncommand = \"{server}\"\nargs = []\nenv_vars = [\"XDG_RUNTIME_DIR\"]\n"
218skills = "~/.other/skills"
219hooks = "~/.other/hooks.json"
220# A runner with no SessionEnd event takes the prompt and the tool call.
221hook_events = ["UserPromptSubmit", "PreToolUse"]
222
223[[harness]]
224name = "runner-with-a-json-config"
225config_json = "~/.config/runner/runner.json"
226json_pointer = "/mcp/ljos"
227json_entry = '{"type": "local", "command": ["{server}"], "enabled": true, "environment": {"LJOS_SEAT": "{name}"}}'
228skills = "~/.config/runner/skills"
229
230# Runners this seat has carried through the same work, as they take the
231# server on this machine: a runner with an `mcp add` of its own is the
232# first shape above, a runner with a TOML config the second. Copy the
233# ones you run.
234
235[[harness]]
236name = "opencode"
237config_json = "~/.config/opencode/opencode.json"
238json_pointer = "/mcp/ljos"
239json_entry = '{"type": "local", "command": ["{server}"], "enabled": true, "timeout": 30000}'
240skills = "~/.config/opencode/skills"
241# opencode's hooks are a plugin: the memory hook on each prompt, argv law
242# on each bash call, the session id in every shell it opens.
243plugin = "~/.config/opencode/plugins/ljos.ts"
244plugin_template = "opencode"
245
246[[harness]]
247name = "hermes"
248# `hermes mcp add` asks which tools to enable; the answer is all of them.
249register = ["sh", "-c", "printf 'Y\\n' | hermes mcp add ljos --command {server}"]
250config = "~/.hermes/config.yaml"
251marker = "\n  ljos:\n    command:"
252skills = "~/.hermes/skills"
253# A hermes installed without its MCP extra lists ljos and loads nothing.
254probe = ["hermes", "mcp", "test", "ljos"]
255
256[[harness]]
257name = "omp"
258config_json = "~/.omp/agent/mcp.json"
259json_pointer = "/mcpServers/ljos"
260json_entry = '{"type": "stdio", "command": "{server}", "args": []}'
261# A host whose omp config sets enablePiUser false reads skills from its
262# skills.customDirectories instead; name that directory here.
263skills = "~/.omp/agent/skills"
264plugin = "~/.omp/agent/extensions/ljos.ts"
265plugin_template = "omp"
266
267[[harness]]
268name = "grok"
269config = "~/.grok/config.toml"
270marker = "[mcp_servers.ljos]"
271snippet = "\n[mcp_servers.ljos]\ncommand = \"{server}\"\nargs = []\nenabled = true\n"
272skills = "~/.grok/skills"
273"#;
274
275fn home() -> Result<PathBuf> {
276    std::env::var_os("HOME")
277        .map(PathBuf::from)
278        .context("HOME unset; onboard needs a home directory")
279}
280
281/// `~` at the start of a configured path is the home directory.
282fn expand(path: &str) -> PathBuf {
283    match path.strip_prefix("~/") {
284        Some(rest) => home().map_or_else(|_| PathBuf::from(path), |h| h.join(rest)),
285        None => PathBuf::from(path),
286    }
287}
288
289/// Where the runners are described: `$XDG_CONFIG_HOME/ljos/harnesses.toml`.
290#[must_use]
291pub fn harnesses_path() -> PathBuf {
292    std::env::var_os("XDG_CONFIG_HOME")
293        .filter(|r| !r.is_empty())
294        .map(PathBuf::from)
295        .or_else(|| home().ok().map(|h| h.join(".config")))
296        .unwrap_or_else(|| PathBuf::from(".config"))
297        .join("ljos")
298        .join("harnesses.toml")
299}
300
301/// Parse the runners file. An absent file is no runners, not an error.
302///
303/// # Errors
304///
305/// A file that is present and not this shape.
306pub fn harnesses_from(path: &Path) -> Result<Harnesses> {
307    match std::fs::read_to_string(path) {
308        Ok(text) => toml::from_str(&text).with_context(|| format!("{}", path.display())),
309        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Harnesses::default()),
310        Err(e) => Err(e).with_context(|| format!("{}", path.display())),
311    }
312}
313
314/// Where `ljos-mcp` is, as the runner will start it.
315fn server_path() -> Result<PathBuf> {
316    which::which("ljos-mcp").context("ljos-mcp not on PATH; install it beside ljos")
317}
318
319/// The MCP server entry any runner that reads JSON accepts.
320pub fn server_entry() -> Result<Value> {
321    Ok(serde_json::json!({
322        "mcpServers": {
323            "ljos": {
324                "type": "stdio",
325                "command": server_path()?.display().to_string(),
326                "args": [],
327                "env": {}
328            }
329        }
330    }))
331}
332
333fn write_skill(dir: &Path, dry: bool) -> Step {
334    let path = dir.join("ljos").join("SKILL.md");
335    let text = skill_text();
336    if std::fs::read_to_string(&path).is_ok_and(|have| have == text) {
337        return Step {
338            what: "skill".into(),
339            detail: format!("{} is current", path.display()),
340            ok: true,
341        };
342    }
343    if dry {
344        return Step {
345            what: "skill".into(),
346            detail: format!("would write {}", path.display()),
347            ok: true,
348        };
349    }
350    let written = std::fs::create_dir_all(path.parent().unwrap_or(dir))
351        .and_then(|()| std::fs::write(&path, text));
352    match written {
353        Ok(()) => Step {
354            what: "skill".into(),
355            detail: format!("wrote {}", path.display()),
356            ok: true,
357        },
358        Err(e) => Step {
359            what: "skill".into(),
360            detail: format!("{}: {e}", path.display()),
361            ok: false,
362        },
363    }
364}
365
366/// `{server}` is the path to `ljos-mcp`, `{name}` the runner's name from
367/// the runners file, for a registering command that wants either.
368fn filled(argv: &[String], server: &Path, name: &str) -> Vec<String> {
369    argv.iter()
370        .map(|a| a.replace("{server}", &server.display().to_string()))
371        .map(|a| a.replace("{name}", name))
372        .collect()
373}
374
375/// Pronouns and defaults, not product names. A runner's own `LJOS_SEAT`
376/// is treated the same way in [`resolve_assignee`]: the process naming
377/// itself is omitted, so occupancy falls through to the session.
378fn omitted_actor_name(name: &str) -> bool {
379    matches!(
380        name.trim().to_ascii_lowercase().as_str(),
381        "seat" | "you" | "agent"
382    )
383}
384
385/// The process naming itself: its `LJOS_SEAT`, or the seat it resolved
386/// to, passed back as an assignee. Omitted, so occupancy stays the
387/// conversation's.
388fn own_seat(name: &str) -> bool {
389    let n = name.trim();
390    std::env::var("LJOS_SEAT")
391        .ok()
392        .is_some_and(|s| s.trim() == n)
393        || whoami().seat == n
394}
395
396/// The conversation this process belongs to: every `*_SESSION_ID` the
397/// runner stamped, one occupancy name and the keys it came from. No
398/// product list.
399fn session_actor() -> Option<(String, String)> {
400    let mut parts: Vec<(String, String)> = std::env::vars()
401        .filter(|(k, v)| runner_session_var(k, v))
402        .collect();
403    if parts.is_empty() {
404        return None;
405    }
406    parts.sort_by(|a, b| a.0.cmp(&b.0));
407    if parts.len() == 1 {
408        return Some(session_from_value(&parts[0].0, &parts[0].1));
409    }
410    let joined = parts
411        .iter()
412        .map(|(k, v)| format!("{k}={}", v.trim()))
413        .collect::<Vec<_>>()
414        .join(";");
415    let id = work_id(&joined);
416    let keys = parts
417        .iter()
418        .map(|(k, _)| k.as_str())
419        .collect::<Vec<_>>()
420        .join("+");
421    Some((format!("sess-{id}"), keys))
422}
423
424/// A conversation id the runner stamped, not the login (`XDG_SESSION_ID`
425/// is a small integer): a `*_SESSION_ID`, or a `*_THREAD_ID` from a runner
426/// that names its conversations threads. Values shorter than eight
427/// characters are ignored.
428fn runner_session_var(key: &str, val: &str) -> bool {
429    (key.ends_with("_SESSION_ID") || key.ends_with("_THREAD_ID"))
430        && key != "XDG_SESSION_ID"
431        && val.trim().len() >= 8
432}
433
434fn session_from_value(key: &str, raw: &str) -> (String, String) {
435    (raw.trim().to_string(), key.to_string())
436}
437
438/// Who is sitting. The seat is the program that connected: the name a
439/// runner remembers, votes and earns trust under, the same across its
440/// conversations. The holder is that seat in one conversation: the name
441/// its claims are held under, so two conversations of one runner hold two
442/// tickets while a vote from either counts for the one voter.
443#[derive(Debug, Clone, PartialEq, Eq)]
444pub struct Seat {
445    pub seat: String,
446    pub holder: String,
447    /// Where the name came from, for `ljos seat` and the doctor.
448    pub source: String,
449}
450
451impl Seat {
452    fn whole(name: &str, source: &str) -> Self {
453        Self {
454            seat: name.to_string(),
455            holder: name.to_string(),
456            source: source.to_string(),
457        }
458    }
459
460    fn tagged(seat: String, tag: &str, source: String) -> Self {
461        Self {
462            holder: format!("{seat}-{tag}"),
463            seat,
464            source,
465        }
466    }
467}
468
469/// What the MCP client said at initialize, kept for every tool call after.
470static ANNOUNCED: std::sync::OnceLock<Seat> = std::sync::OnceLock::new();
471
472/// A name as a seat: lower case, runs of letters and digits joined by one
473/// hyphen. `Acme CLI`, `acme-cli` and `acme_cli/1.2` are one seat.
474#[must_use]
475pub fn seat_slug(name: &str) -> String {
476    let mut out = String::new();
477    for c in name.trim().chars() {
478        if c.is_ascii_alphanumeric() {
479            out.push(c.to_ascii_lowercase());
480        } else if !out.is_empty() && !out.ends_with('-') {
481            out.push('-');
482        }
483    }
484    let out = out.trim_end_matches('-').to_string();
485    if out.is_empty() {
486        "runner".to_string()
487    } else {
488        out
489    }
490}
491
492/// A short tag for one conversation from the process that runs it: the pid
493/// in base 36, so `acme-cli-39u` reads as a name and not a number.
494#[must_use]
495pub fn conversation_tag(pid: u32) -> String {
496    const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
497    let mut n = u64::from(pid);
498    let mut out = Vec::new();
499    loop {
500        out.push(DIGITS[(n % 36) as usize]);
501        n /= 36;
502        if n == 0 {
503            break;
504        }
505    }
506    out.reverse();
507    String::from_utf8(out).unwrap_or_default()
508}
509
510/// The login's runtime directory, where what belongs to a session and never
511/// to the pack is kept.
512fn runtime_dir() -> PathBuf {
513    std::env::var_os("XDG_RUNTIME_DIR")
514        .filter(|r| !r.is_empty())
515        .map(PathBuf::from)
516        .unwrap_or_else(std::env::temp_dir)
517        .join("ljos")
518}
519
520/// The record a server leaves for the shells the same runner opens.
521fn seat_record_path(runner_pid: u32) -> PathBuf {
522    runtime_dir().join(format!("seat-{runner_pid}"))
523}
524
525/// The process that started this one. For `ljos-mcp` that is the runner,
526/// and the runner is also above every shell it opens.
527#[must_use]
528pub fn runner_pid() -> u32 {
529    // SAFETY: getppid reads one field of the calling process and cannot fail.
530    let ppid = unsafe { libc::getppid() };
531    u32::try_from(ppid).unwrap_or(0)
532}
533
534/// One tool call answered by a fresh `ljos-mcp`: start `program` with
535/// `marker` set, send it the client's initialize (`init`, or a plain one),
536/// the initialized notification and `tools/call` with `params`, and return
537/// the JSON-RPC answer to the call, `result` or `error`.
538///
539/// # Errors
540///
541/// The program not starting, or closing before it answers.
542pub fn mcp_forward(
543    program: &Path,
544    marker: &str,
545    init: Option<Value>,
546    params: Value,
547) -> Result<Value> {
548    use std::io::{BufRead, Write};
549    use std::process::{Command, Stdio};
550    let mut child = Command::new(program)
551        .env(marker, "1")
552        .stdin(Stdio::piped())
553        .stdout(Stdio::piped())
554        .stderr(Stdio::inherit())
555        .spawn()
556        .with_context(|| format!("{}: spawn", program.display()))?;
557    let init = init.unwrap_or_else(|| {
558        serde_json::json!({"protocolVersion": "2025-06-18", "capabilities": {},
559            "clientInfo": {"name": "runner", "version": "0"}})
560    });
561    let lines = [
562        serde_json::json!({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": init}),
563        serde_json::json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
564        serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params}),
565    ];
566    {
567        let stdin = child.stdin.as_mut().context("forward: stdin closed")?;
568        for line in &lines {
569            writeln!(stdin, "{line}")?;
570        }
571    }
572    let stdout = child.stdout.take().context("forward: stdout closed")?;
573    let mut answer = None;
574    for line in std::io::BufReader::new(stdout).lines() {
575        let Ok(v) = serde_json::from_str::<Value>(&line?) else {
576            continue;
577        };
578        if v["id"] == serde_json::json!(1) {
579            answer = Some(v);
580            break;
581        }
582    }
583    drop(child.stdin.take());
584    let _ = child.wait();
585    answer.with_context(|| format!("{}: closed without answering the call", program.display()))
586}
587
588/// The conversation ids a runner stamped into this environment, by key:
589/// every `*_SESSION_ID` but the login's, sorted so two processes with the
590/// same variables agree on the first.
591fn stamped_sessions() -> Vec<(String, String)> {
592    let mut found: Vec<(String, String)> = std::env::vars()
593        .filter(|(k, v)| runner_session_var(k, v))
594        .map(|(k, v)| (k, v.trim().to_string()))
595        .collect();
596    found.sort();
597    found
598}
599
600/// A conversation tag from a stamped id: ten base-36 digits of FNV-1a over
601/// the whole id. A prefix of the id would not do: a UUID v7 opens with its
602/// timestamp, so two conversations started in one window share it.
603#[must_use]
604pub fn session_tag(id: &str) -> String {
605    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
606    for b in id.trim().bytes() {
607        h ^= u64::from(b);
608        h = h.wrapping_mul(0x0100_0000_01b3);
609    }
610    const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
611    let mut out = Vec::new();
612    for _ in 0..10 {
613        out.push(DIGITS[(h % 36) as usize]);
614        h /= 36;
615    }
616    String::from_utf8(out).unwrap_or_default()
617}
618
619/// The record a server leaves under a conversation's stamped id, for the
620/// shells that carry the same id and whatever else their line editor adds.
621fn session_record_path(id: &str) -> PathBuf {
622    runtime_dir().join(format!("session-{}", session_tag(id)))
623}
624
625/// A record is the seat, the holder, and the conversation ids its writer
626/// carried. A shell's line editor stamps one id into every conversation
627/// started from that terminal; the ids line is how a reader tells its own
628/// conversation's record from another's filed under the same shared id.
629fn write_record(path: &Path, seat: &Seat) {
630    let ids: Vec<String> = stamped_sessions().into_iter().map(|(_, id)| id).collect();
631    write_record_ids(path, seat, &ids);
632}
633
634fn write_record_ids(path: &Path, seat: &Seat, ids: &[String]) {
635    if let Some(dir) = path.parent() {
636        let _ = std::fs::create_dir_all(dir);
637    }
638    let _ = std::fs::write(
639        path,
640        format!("{}\n{}\nids\t{}\n", seat.seat, seat.holder, ids.join("\t")),
641    );
642}
643
644fn read_record(path: &Path, source: String) -> Option<Seat> {
645    let text = std::fs::read_to_string(path).ok()?;
646    let mine: Vec<String> = stamped_sessions().into_iter().map(|(_, id)| id).collect();
647    record_for(&text, &mine, source)
648}
649
650/// The seat in a record's text, unless its writer carried a conversation id
651/// this process does not: that record is another conversation's, filed
652/// under an id both happen to share. A record without an ids line predates
653/// the check and is taken as it stands.
654fn record_for(text: &str, mine: &[String], source: String) -> Option<Seat> {
655    let mut lines = text.lines();
656    let (seat, holder) = (lines.next()?, lines.next()?);
657    if let Some(ids) = lines.next().and_then(|l| l.strip_prefix("ids")) {
658        let foreign = ids
659            .split('\t')
660            .map(str::trim)
661            .filter(|id| !id.is_empty())
662            .any(|id| !mine.iter().any(|m| m == id));
663        if foreign {
664            return None;
665        }
666    }
667    Some(Seat {
668        seat: seat.to_string(),
669        holder: holder.to_string(),
670        source,
671    })
672}
673
674/// Names an MCP library sends when the runner gives none. They name the
675/// library, not the runner, and every runner built on it would share one
676/// seat.
677const LIBRARY_CLIENT_NAMES: &[&str] = &["mcp", "mcp-client", "client", "runner"];
678
679/// The seat a connecting client names: its own name, unless that is a
680/// library's default; then the program above this server, else `runner`.
681fn seat_for_client(client: &str) -> String {
682    let name = seat_slug(client);
683    if let Some(runner) = runner_for_client(&harnesses_path(), &name) {
684        return runner;
685    }
686    if !LIBRARY_CLIENT_NAMES.contains(&name.as_str()) {
687        return name;
688    }
689    ancestry()
690        .into_iter()
691        .find(|(_, comm)| !WRAPPERS.contains(&comm.as_str()))
692        .map(|(pid, comm)| seat_slug(&program_name(pid, &comm)))
693        .unwrap_or(name)
694}
695
696/// The harness a client name belongs to, by its `clients` list in the
697/// runners file.
698fn runner_for_client(file: &Path, slug: &str) -> Option<String> {
699    harnesses_from(file)
700        .ok()?
701        .harness
702        .into_iter()
703        .find_map(|h| {
704            h.clients
705                .iter()
706                .any(|c| seat_slug(c) == slug)
707                .then(|| seat_slug(&h.name))
708        })
709}
710
711/// The seat of a record another seat left under one of this process's
712/// conversation ids. A runner started from a shell of another runner
713/// inherits that runner's ids; the record they find is the parent's.
714fn inherited_record(name: &str) -> Option<Seat> {
715    stamped_sessions().into_iter().find_map(|(_, id)| {
716        read_record(&session_record_path(&id), String::new()).filter(|s| s.seat != name)
717    })
718}
719
720tokio::task_local! {
721    /// The seat of one MCP call whose runner named its thread on the call.
722    static CALL_SEAT: Seat;
723}
724
725/// Run `f` as the thread a runner named on this call, when it named one.
726/// A runner that spawns one server for many conversations names each in
727/// the call's metadata rather than in the server's environment.
728pub async fn as_thread<F: std::future::Future>(thread: Option<String>, f: F) -> F::Output {
729    match thread.filter(|t| t.trim().len() >= 8) {
730        Some(t) => CALL_SEAT.scope(seat_for_thread(&t), f).await,
731        None => f.await,
732    }
733}
734
735/// The seat for a thread a runner named on a call. The holder is the one a
736/// shell of that thread already took, found by the thread's record; else
737/// the thread id whole, recorded so the thread's shells find it.
738#[must_use]
739pub fn seat_for_thread(thread: &str) -> Seat {
740    let thread = thread.trim();
741    let seat = named_var("LJOS_SEAT")
742        .or_else(|| ANNOUNCED.get().map(|s| s.seat.clone()))
743        .unwrap_or_else(login_user);
744    let path = session_record_path(thread);
745    if let Some(holder) = std::fs::read_to_string(&path)
746        .ok()
747        .and_then(|t| holder_naming(&t, thread))
748    {
749        return Seat {
750            seat,
751            holder,
752            source: "the thread the runner named on this call, as its shells hold it".into(),
753        };
754    }
755    let found = Seat {
756        seat,
757        holder: thread.to_string(),
758        source: "the thread the runner named on this call".into(),
759    };
760    write_record_ids(&path, &found, &[thread.to_string()]);
761    found
762}
763
764/// The holder in a record whose ids line names `id`.
765fn holder_naming(text: &str, id: &str) -> Option<String> {
766    let mut lines = text.lines();
767    let (_, holder) = (lines.next()?, lines.next()?);
768    let ids = lines.next()?.strip_prefix("ids")?;
769    ids.split('\t')
770        .any(|i| i.trim() == id)
771        .then(|| holder.to_string())
772}
773
774/// The MCP server, once a client has said who it is: the seat is the
775/// client's name. The holder is any `*_SESSION_ID` the runner stamped,
776/// else that seat tagged with the runner's process. The record under the
777/// runtime directory is how `ljos` in a shell the same runner opened
778/// names the same seat and holder. A runner started from another runner's
779/// shell carries that runner's ids; it holds under its own process and
780/// leaves the parent's records alone.
781pub fn announce_seat(client: &str, runner_pid: u32) -> Seat {
782    let name = seat_for_client(client);
783    if let Some(parent) = inherited_record(&name) {
784        let seat = Seat::tagged(
785            name,
786            &conversation_tag(runner_pid),
787            format!(
788                "the client that connected, process {runner_pid}, inside {}",
789                parent.seat
790            ),
791        );
792        write_record(&seat_record_path(runner_pid), &seat);
793        let _ = ANNOUNCED.set(seat.clone());
794        return seat;
795    }
796    let seat = if let Some((holder, keys)) = session_actor() {
797        Seat {
798            seat: name,
799            holder,
800            source: format!("the client that connected, process {runner_pid}; session {keys}"),
801        }
802    } else {
803        Seat::tagged(
804            name,
805            &conversation_tag(runner_pid),
806            format!("the client that connected, process {runner_pid}"),
807        )
808    };
809    // One record by the runner's process, one by each conversation id the
810    // runner stamped: a shell whose line editor stamps an id of its own
811    // still shares one with the server, and finds this seat by it.
812    write_record(&seat_record_path(runner_pid), &seat);
813    for (_, id) in stamped_sessions() {
814        write_record(&session_record_path(&id), &seat);
815    }
816    let _ = ANNOUNCED.set(seat.clone());
817    seat
818}
819
820/// Drop the records [`announce_seat`] wrote, when the server ends.
821pub fn retire_seat(runner_pid: u32) {
822    let mine = read_record(&seat_record_path(runner_pid), String::new());
823    let _ = std::fs::remove_file(seat_record_path(runner_pid));
824    for (_, id) in stamped_sessions() {
825        let path = session_record_path(&id);
826        // Another seat's record under an inherited id stays for its owner.
827        let theirs = read_record(&path, String::new())
828            .is_some_and(|r| mine.as_ref().is_some_and(|m| m.holder != r.holder));
829        if !theirs {
830            let _ = std::fs::remove_file(path);
831        }
832    }
833}
834
835/// The seat a server announced for one of the conversation ids this
836/// process carries. A shell's line editor may add a session id of its
837/// own; any one shared id is enough.
838fn seat_from_session_records() -> Option<Seat> {
839    stamped_sessions().into_iter().find_map(|(key, id)| {
840        read_record(
841            &session_record_path(&id),
842            format!("this conversation's record, session {key}"),
843        )
844    })
845}
846
847/// A process's parent and its own short name, from procfs.
848#[cfg(target_os = "linux")]
849fn parent_and_comm(pid: u32) -> Option<(u32, String)> {
850    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
851    let open = stat.find('(')?;
852    let close = stat.rfind(')')?;
853    let comm = stat.get(open + 1..close)?.to_string();
854    let ppid = stat
855        .get(close + 2..)?
856        .split_whitespace()
857        .nth(1)?
858        .parse()
859        .ok()?;
860    Some((ppid, comm))
861}
862
863#[cfg(not(target_os = "linux"))]
864fn parent_and_comm(_pid: u32) -> Option<(u32, String)> {
865    None
866}
867
868/// The processes above this one, nearest first, as (pid, name); stops
869/// below init.
870fn ancestry() -> Vec<(u32, String)> {
871    let mut out = Vec::new();
872    let mut pid = std::process::id();
873    for _ in 0..32 {
874        let Some((ppid, _)) = parent_and_comm(pid) else {
875            break;
876        };
877        if ppid <= 1 {
878            break;
879        }
880        let Some((_, comm)) = parent_and_comm(ppid) else {
881            break;
882        };
883        out.push((ppid, comm));
884        pid = ppid;
885    }
886    out
887}
888
889/// Programs that run other programs and are nobody's seat.
890const WRAPPERS: &[&str] = &[
891    "sh", "bash", "zsh", "fish", "dash", "ksh", "tcsh", "csh", "nu", "env", "sudo", "doas",
892    "timeout", "nohup", "xargs", "script", "uv", "direnv", "ljos", "ljos-mcp",
893];
894
895/// Where a process tree stops being a program and becomes the session
896/// itself: above these, nobody ran the shell but the person.
897const SESSION: &[&str] = &[
898    "tmux", "screen", "zellij", "systemd", "init", "sshd", "login",
899];
900
901/// Path components that name a place, not a program.
902const PLACES: &[&str] = &[
903    "bin",
904    "sbin",
905    "versions",
906    "current",
907    "dist",
908    "build",
909    "target",
910    "release",
911    "debug",
912    "node_modules",
913    ".bin",
914    "lib",
915    "libexec",
916    "app",
917    "resources",
918];
919
920/// Interpreters run a program named by their first argument.
921const INTERPRETERS: &[&str] = &[
922    "node", "nodejs", "bun", "deno", "python", "python3", "ruby", "perl", "java",
923];
924
925fn version_like(s: &str) -> bool {
926    let t = s.strip_prefix('v').unwrap_or(s);
927    t.chars().next().is_some_and(|c| c.is_ascii_digit())
928}
929
930/// A program's name from how it was started: the last path component of
931/// what ran that is neither a version (`2.1.266`) nor a place (`bin`,
932/// `versions`); for an interpreter, the script it was handed. Falls back
933/// to the kernel's short name.
934#[cfg(target_os = "linux")]
935fn program_name(pid: u32, comm: &str) -> String {
936    let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default();
937    let args: Vec<String> = cmdline
938        .split(|b| *b == 0)
939        .filter(|a| !a.is_empty())
940        .map(|a| String::from_utf8_lossy(a).into_owned())
941        .collect();
942    let mut candidates: Vec<&str> = Vec::new();
943    if let Some(first) = args.first() {
944        let base = Path::new(first)
945            .file_name()
946            .and_then(|f| f.to_str())
947            .unwrap_or(first);
948        if INTERPRETERS.contains(&base) {
949            if let Some(script) = args.iter().skip(1).find(|a| !a.starts_with('-')) {
950                candidates.push(script);
951            }
952        }
953        candidates.push(first);
954    }
955    for path in candidates {
956        let mut parts: Vec<&str> = Path::new(path)
957            .components()
958            .filter_map(|c| c.as_os_str().to_str())
959            .collect();
960        while let Some(last) = parts.pop() {
961            let name = last.rsplit_once('.').map_or(last, |(stem, ext)| {
962                if ["js", "mjs", "cjs", "py", "rb", "pl", "jar", "exe"].contains(&ext) {
963                    stem
964                } else {
965                    last
966                }
967            });
968            if name.is_empty() || version_like(name) || PLACES.contains(&name) || name == "/" {
969                continue;
970            }
971            if name.starts_with('.') || name.contains(std::path::MAIN_SEPARATOR) {
972                continue;
973            }
974            return name.to_string();
975        }
976    }
977    comm.to_string()
978}
979
980#[cfg(not(target_os = "linux"))]
981fn program_name(_pid: u32, comm: &str) -> String {
982    comm.to_string()
983}
984
985/// The seat from the process tree: the record a server left for the runner
986/// above this shell, else the nearest ancestor that is neither a shell nor
987/// a wrapper, named from how it was started and tagged with its pid. None
988/// when the tree ends in the session itself, which is a person at a
989/// terminal.
990fn seat_from_tree() -> Option<Seat> {
991    if let Some(seat) = seat_from_tree_records() {
992        return Some(seat);
993    }
994    let chain = ancestry();
995    for (pid, comm) in &chain {
996        let name = comm.as_str();
997        if WRAPPERS.contains(&name) {
998            continue;
999        }
1000        if SESSION.iter().any(|s| name.starts_with(s)) {
1001            return None;
1002        }
1003        let program = program_name(*pid, name);
1004        return Some(Seat::tagged(
1005            seat_slug(&program),
1006            &conversation_tag(*pid),
1007            format!("the process tree, {program} {pid}"),
1008        ));
1009    }
1010    None
1011}
1012
1013/// The record a server left for the nearest runner above this shell. It
1014/// names the runner that opened the shell, which a conversation id in the
1015/// environment does not when one runner started another.
1016fn seat_from_tree_records() -> Option<Seat> {
1017    ancestry().into_iter().find_map(|(pid, _)| {
1018        read_record(
1019            &seat_record_path(pid),
1020            format!("the server the runner opened, process {pid}"),
1021        )
1022    })
1023}
1024
1025fn named_var(key: &str) -> Option<String> {
1026    std::env::var(key)
1027        .ok()
1028        .map(|v| v.trim().to_string())
1029        .filter(|v| !v.is_empty() && !omitted_actor_name(v))
1030}
1031
1032/// Who is sitting, with nothing set. The seat: `LJOS_SEAT` or the
1033/// tracker's `VISSUE_AGENT` when someone set one; else what the MCP client
1034/// said at initialize; else the process tree above this shell, which is
1035/// the runner that opened it or the server that runner opened; else the
1036/// login user, who is the seat when no program is. The holder is any
1037/// `*_SESSION_ID` the runner stamped, ahead of the process tag, so MCP
1038/// sitting and CLI sitting of one conversation are one occupancy name;
1039/// else the seat tagged with the conversation's process.
1040#[must_use]
1041pub fn whoami() -> Seat {
1042    if let Ok(seat) = CALL_SEAT.try_with(Clone::clone) {
1043        return seat;
1044    }
1045    let session = session_actor();
1046    // Both variables are a person naming the seat: the seat's own, and the
1047    // tracker's name for the same thing. Either beats what the tree says.
1048    let named = named_var("LJOS_SEAT")
1049        .map(|n| (n, "LJOS_SEAT"))
1050        .or_else(|| named_var("VISSUE_AGENT").map(|n| (n, "VISSUE_AGENT")));
1051    // The record filed under a conversation id this shell carries, unless
1052    // the nearest runner above left one for another seat: a runner started
1053    // from another runner's shell inherits the other's ids, and its own
1054    // record is the one above it.
1055    let record = seat_from_session_records().map(|by_id| {
1056        seat_from_tree_records()
1057            .filter(|above| above.seat != by_id.seat)
1058            .unwrap_or(by_id)
1059    });
1060    let program = ANNOUNCED
1061        .get()
1062        .cloned()
1063        .or_else(|| record.clone())
1064        .or_else(seat_from_tree);
1065    let agent = named_var("VISSUE_AGENT");
1066    let seat_name = named
1067        .as_ref()
1068        .map(|(n, _)| n.clone())
1069        .or_else(|| program.as_ref().map(|p| p.seat.clone()))
1070        .or_else(|| agent.clone())
1071        .unwrap_or_else(login_user);
1072    // The server's record first: it carries the holder the server took,
1073    // whatever else this shell's environment adds.
1074    if let Some(record) = record {
1075        return Seat {
1076            seat: seat_name,
1077            holder: record.holder,
1078            source: record.source,
1079        };
1080    }
1081    if let Some((holder, keys)) = session {
1082        let seat = Seat {
1083            seat: seat_name,
1084            holder,
1085            source: keys,
1086        };
1087        // The first resolution in a conversation leaves a record under
1088        // every id stamped so far; a later process carrying one of them and
1089        // more finds this holder by the shared id rather than hashing the
1090        // larger set into a new name. The tests stamp ids of their own
1091        // into one process and must not leave records for each other.
1092        #[cfg(not(test))]
1093        for (_, id) in stamped_sessions() {
1094            write_record(&session_record_path(&id), &seat);
1095        }
1096        return seat;
1097    }
1098    match (&named, &program) {
1099        (Some((name, key)), Some(p)) => Seat {
1100            seat: name.clone(),
1101            holder: p.holder.replacen(&p.seat, name, 1),
1102            source: format!("{key}, held by {}", p.source),
1103        },
1104        (Some((name, key)), None) => Seat::whole(name, key),
1105        (None, Some(p)) => p.clone(),
1106        (None, None) => {
1107            if let Some(name) = agent {
1108                Seat::whole(&name, "VISSUE_AGENT")
1109            } else {
1110                Seat::whole(&login_user(), "the login user")
1111            }
1112        }
1113    }
1114}
1115
1116/// The person at the terminal, when no program is the seat.
1117fn login_user() -> String {
1118    std::env::var("USER")
1119        .ok()
1120        .map(|u| u.trim().to_string())
1121        .filter(|u| !u.is_empty())
1122        .unwrap_or_else(|| "seat".to_string())
1123}
1124
1125/// The name this seat remembers, votes and earns trust under.
1126#[must_use]
1127pub fn seat_name() -> String {
1128    whoami().seat
1129}
1130
1131/// The name this conversation's claims are held under.
1132#[must_use]
1133pub fn holder_name() -> String {
1134    whoami().holder
1135}
1136
1137/// Resolve an `--assignee` / MCP field for a claim. Empty, a pronoun
1138/// (`seat`, `you`, `agent`), or this process naming itself is omitted:
1139/// occupancy is the conversation's holder, not the product name on the
1140/// box. A named worker is taken as given.
1141#[must_use]
1142pub fn resolve_assignee(passed: Option<&str>) -> String {
1143    match passed.map(str::trim).filter(|s| !s.is_empty()) {
1144        Some(n) if !omitted_actor_name(n) && !own_seat(n) => n.to_string(),
1145        _ => holder_name(),
1146    }
1147}
1148
1149/// Occupancy is always `{name}:{issue}`. One live claim per name is what
1150/// made two conversations unseat each other; the issue is already
1151/// exclusive. Already-scoped names (they contain `:`) are left alone.
1152#[must_use]
1153pub fn occupancy_assignee(passed: Option<&str>, issue: &str) -> String {
1154    occupancy_scope(&resolve_assignee(passed), issue)
1155}
1156
1157fn occupancy_scope(assignee: &str, issue: &str) -> String {
1158    let issue = issue.trim();
1159    if issue.is_empty() || assignee.contains(':') {
1160        assignee.to_string()
1161    } else {
1162        format!("{assignee}:{issue}")
1163    }
1164}
1165
1166/// The doctor's `seat` row: who votes, who holds, and where the names came
1167/// from.
1168#[must_use]
1169pub fn format_seat_row() -> String {
1170    let who = whoami();
1171    format!(
1172        "{}, holding as {} (from {})",
1173        who.seat, who.holder, who.source
1174    )
1175}
1176
1177/// `ljos seat`: who is sitting, one field a line.
1178#[must_use]
1179pub fn format_seat(seat: &Seat) -> String {
1180    format!(
1181        "seat\t{}\nholder\t{}\nsource\t{}\n",
1182        seat.seat, seat.holder, seat.source
1183    )
1184}
1185
1186/// Whether a runner with a `registered` command already has the server.
1187fn is_registered(h: &Harness, server: &Path) -> Option<bool> {
1188    if !h.registered.is_empty() {
1189        let argv = filled(&h.registered, server, &h.name);
1190        return Some(
1191            argv.first().is_some_and(|bin| on_path(bin)) && {
1192                let (bin, rest) = (&argv[0], &argv[1..]);
1193                run_captured(bin, rest).is_ok()
1194            },
1195        );
1196    }
1197    if let (Some(config), Some(marker)) = (&h.config, &h.marker) {
1198        return Some(std::fs::read_to_string(expand(config)).is_ok_and(|t| t.contains(marker)));
1199    }
1200    if let (Some(config), Some(pointer)) = (&h.config_json, &h.json_pointer) {
1201        return Some(
1202            std::fs::read_to_string(expand(config))
1203                .ok()
1204                .and_then(|t| serde_json::from_str::<Value>(&t).ok())
1205                .is_some_and(|doc| doc.pointer(pointer).is_some()),
1206        );
1207    }
1208    None
1209}
1210
1211/// Set `pointer` in the JSON document at `config` to `entry`, making the
1212/// objects on the way; a missing file starts as `{}`.
1213fn set_json_entry(config: &Path, pointer: &str, entry: &Value) -> Result<()> {
1214    let mut doc: Value = match std::fs::read_to_string(config) {
1215        Ok(t) if !t.trim().is_empty() => {
1216            serde_json::from_str(&t).with_context(|| format!("{}: not JSON", config.display()))?
1217        }
1218        _ => serde_json::json!({}),
1219    };
1220    let mut at = &mut doc;
1221    let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
1222    let (last, path) = parts
1223        .split_last()
1224        .context("onboard: an empty JSON pointer")?;
1225    for key in path {
1226        at = at
1227            .as_object_mut()
1228            .context("onboard: the pointer crosses a value that is not an object")?
1229            .entry((*key).to_string())
1230            .or_insert_with(|| serde_json::json!({}));
1231    }
1232    at.as_object_mut()
1233        .context("onboard: the pointer's parent is not an object")?
1234        .insert((*last).to_string(), entry.clone());
1235    if let Some(parent) = config.parent() {
1236        std::fs::create_dir_all(parent)?;
1237    }
1238    let mut text = serde_json::to_string_pretty(&doc)?;
1239    text.push('\n');
1240    std::fs::write(config, text)?;
1241    Ok(())
1242}
1243
1244/// Grok watches `[mcp_servers.ljos.env]`. Changing `LJOS_MCP_GENERATION`
1245/// respawns the server; a session restart is not required.
1246fn bump_ljos_mcp_generation(config: &Path, version: &str, dry: bool) -> Result<Option<String>> {
1247    let text = match std::fs::read_to_string(config) {
1248        Ok(t) => t,
1249        Err(_) => return Ok(None),
1250    };
1251    let mut changed = false;
1252    let mut out = String::new();
1253    for line in text.lines() {
1254        let trimmed = line.trim_start();
1255        if let Some(rhs) = trimmed.strip_prefix("LJOS_MCP_GENERATION") {
1256            let rhs = rhs.trim_start().strip_prefix('=').unwrap_or("").trim();
1257            let val = rhs.trim_matches(|c| c == '"' || c == '\'');
1258            if val == version {
1259                out.push_str(line);
1260            } else {
1261                let indent_len = line.len() - trimmed.len();
1262                out.push_str(&line[..indent_len]);
1263                out.push_str("LJOS_MCP_GENERATION = \"");
1264                out.push_str(version);
1265                out.push('"');
1266                changed = true;
1267            }
1268        } else {
1269            out.push_str(line);
1270        }
1271        out.push('\n');
1272    }
1273    if !changed {
1274        return Ok(None);
1275    }
1276    if dry {
1277        return Ok(Some(version.to_string()));
1278    }
1279    std::fs::write(config, out).with_context(|| config.display().to_string())?;
1280    Ok(Some(version.to_string()))
1281}
1282
1283fn register_step(h: &Harness, server: &Path, dry: bool) -> Step {
1284    let what = format!("{} mcp", h.name);
1285    match is_registered(h, server) {
1286        Some(true) => {
1287            let config = expand(h.config.as_deref().unwrap_or_default());
1288            match bump_ljos_mcp_generation(&config, env!("CARGO_PKG_VERSION"), dry) {
1289                Ok(Some(v)) => Step {
1290                    what,
1291                    detail: format!("ljos registered; MCP generation {v}"),
1292                    ok: true,
1293                },
1294                Ok(None) => Step {
1295                    what,
1296                    detail: "ljos registered".into(),
1297                    ok: true,
1298                },
1299                Err(e) => Step {
1300                    what,
1301                    detail: format!("ljos registered; generation {e}"),
1302                    ok: false,
1303                },
1304            }
1305        }
1306        None => Step {
1307            what,
1308            detail: "no register or config in harnesses.toml; paste `ljos onboard --harness json`"
1309                .into(),
1310            ok: false,
1311        },
1312        Some(false) if !h.register.is_empty() => {
1313            let argv = filled(&h.register, server, &h.name);
1314            if !on_path(&argv[0]) {
1315                return Step {
1316                    what,
1317                    detail: format!("{} not on PATH", argv[0]),
1318                    ok: false,
1319                };
1320            }
1321            if dry {
1322                return Step {
1323                    what,
1324                    detail: format!("would run {}", argv.join(" ")),
1325                    ok: true,
1326                };
1327            }
1328            match run_captured(&argv[0], &argv[1..]) {
1329                Ok(_) => Step {
1330                    what,
1331                    detail: format!("ran {}", argv.join(" ")),
1332                    ok: true,
1333                },
1334                Err(e) => Step {
1335                    what,
1336                    detail: e.to_string().lines().next().unwrap_or("").to_string(),
1337                    ok: false,
1338                },
1339            }
1340        }
1341        Some(false) if h.config_json.is_some() => {
1342            let config = expand(h.config_json.as_deref().unwrap_or_default());
1343            let pointer = h.json_pointer.clone().unwrap_or_default();
1344            let entry_text = h
1345                .json_entry
1346                .as_deref()
1347                .unwrap_or_default()
1348                .replace("{server}", &server.display().to_string())
1349                .replace("{name}", &h.name);
1350            let entry: Value = match serde_json::from_str(&entry_text) {
1351                Ok(v) => v,
1352                Err(e) => {
1353                    return Step {
1354                        what,
1355                        detail: format!("json_entry is not JSON: {e}"),
1356                        ok: false,
1357                    }
1358                }
1359            };
1360            if dry {
1361                return Step {
1362                    what,
1363                    detail: format!("would set {pointer} in {}", config.display()),
1364                    ok: true,
1365                };
1366            }
1367            match set_json_entry(&config, &pointer, &entry) {
1368                Ok(()) => Step {
1369                    what,
1370                    detail: format!("set {pointer} in {}", config.display()),
1371                    ok: true,
1372                },
1373                Err(e) => Step {
1374                    what,
1375                    detail: format!("{}: {e}", config.display()),
1376                    ok: false,
1377                },
1378            }
1379        }
1380        Some(false) => {
1381            let config = expand(h.config.as_deref().unwrap_or_default());
1382            let snippet = h
1383                .snippet
1384                .as_deref()
1385                .unwrap_or_default()
1386                .replace("{server}", &server.display().to_string())
1387                .replace("{name}", &h.name);
1388            if snippet.is_empty() {
1389                return Step {
1390                    what,
1391                    detail: format!("no snippet to append to {}", config.display()),
1392                    ok: false,
1393                };
1394            }
1395            if dry {
1396                return Step {
1397                    what,
1398                    detail: format!("would append the entry to {}", config.display()),
1399                    ok: true,
1400                };
1401            }
1402            let mut text = std::fs::read_to_string(&config).unwrap_or_default();
1403            if !text.is_empty() && !text.ends_with('\n') {
1404                text.push('\n');
1405            }
1406            text.push_str(&snippet);
1407            let written = config
1408                .parent()
1409                .map_or(Ok(()), std::fs::create_dir_all)
1410                .and_then(|()| std::fs::write(&config, text));
1411            match written {
1412                Ok(()) => Step {
1413                    what,
1414                    detail: format!("appended the entry to {}", config.display()),
1415                    ok: true,
1416                },
1417                Err(e) => Step {
1418                    what,
1419                    detail: format!("{}: {e}", config.display()),
1420                    ok: false,
1421                },
1422            }
1423        }
1424    }
1425}
1426
1427/// Register the server and install the skill for one runner named in the
1428/// runners file. `json` registers nothing and returns the entry to paste.
1429/// `dry` reports without writing.
1430///
1431/// # Errors
1432///
1433/// No such runner in the file, no home directory, or `ljos-mcp` not on `PATH`.
1434pub fn onboard(harness: &str, dry: bool) -> Result<Vec<Step>> {
1435    onboard_from(&harnesses_path(), harness, dry)
1436}
1437
1438/// Frozen Grok hook file. Copied to `~/.grok/hooks/ljos.json`.
1439const GROK_HOOKS_JSON: &str = include_str!("../assets/grok/ljos.json");
1440
1441/// The `ljos` a runner's hook runs: the one beside `ljos-mcp`, by absolute
1442/// path, since a runner started outside a login shell has no `~/.local/bin`
1443/// on its PATH.
1444fn ljos_path() -> Result<PathBuf> {
1445    let beside = server_path()?.with_file_name("ljos");
1446    if beside.is_file() {
1447        return Ok(beside);
1448    }
1449    which::which("ljos").context("ljos not on PATH")
1450}
1451
1452/// The grok hooks file with `{ljos}` filled in.
1453fn grok_hooks_json(ljos: &Path) -> String {
1454    GROK_HOOKS_JSON.replace("{ljos}", &ljos.display().to_string())
1455}
1456
1457fn write_grok_hooks(dry: bool) -> Result<Step> {
1458    let dest = home()?.join(".grok/hooks/ljos.json");
1459    if dry {
1460        return Ok(Step {
1461            what: "hook".into(),
1462            detail: format!("would write {}", dest.display()),
1463            ok: true,
1464        });
1465    }
1466    if let Some(dir) = dest.parent() {
1467        std::fs::create_dir_all(dir)?;
1468    }
1469    std::fs::write(&dest, grok_hooks_json(&ljos_path()?))?;
1470    Ok(Step {
1471        what: "hook".into(),
1472        detail: format!("wrote {}", dest.display()),
1473        ok: true,
1474    })
1475}
1476
1477pub fn onboard_from(file: &Path, harness: &str, dry: bool) -> Result<Vec<Step>> {
1478    if harness == "json" {
1479        return Ok(vec![Step {
1480            what: "json".into(),
1481            detail: serde_json::to_string_pretty(&server_entry()?)?,
1482            ok: true,
1483        }]);
1484    }
1485    if harness == "grok" {
1486        let mut steps = vec![write_grok_hooks(dry)?];
1487        if let Ok(all) = harnesses_from(file) {
1488            if let Some(h) = all.harness.iter().find(|h| h.name == "grok") {
1489                let server = server_path()?;
1490                steps.push(register_step(h, &server, dry));
1491                if let Some(dir) = &h.skills {
1492                    steps.push(write_skill(&expand(dir), dry));
1493                }
1494            }
1495        }
1496        return Ok(steps);
1497    }
1498    let all = harnesses_from(file)?;
1499    let Some(h) = all.harness.iter().find(|h| h.name == harness) else {
1500        let names: Vec<&str> = all.harness.iter().map(|h| h.name.as_str()).collect();
1501        bail!(
1502            "onboard: no runner {harness:?} in {}; it names {}. `ljos onboard --example` \
1503             prints the file's shape, and `--harness json` prints the entry to paste anywhere.",
1504            file.display(),
1505            if names.is_empty() {
1506                "none".to_string()
1507            } else {
1508                names.join(", ")
1509            }
1510        );
1511    };
1512    let server = server_path()?;
1513    let dependencies = [pack_step(dry), host_key_step(dry)];
1514    let mut steps = vec![register_step(h, &server, dry)];
1515    if let Some(file) = &h.hooks {
1516        steps.push(hook_step(&expand(file), &hook_events_of(h), dry));
1517    }
1518    if let Some(dest) = &h.plugin {
1519        steps.push(plugin_step(h, &expand(dest), dry));
1520    }
1521    match &h.skills {
1522        Some(dir) => steps.push(write_skill(&expand(dir), dry)),
1523        None => steps.push(Step {
1524            what: "skill".into(),
1525            detail: "no skills directory in harnesses.toml; `ljos protocol` prints the text".into(),
1526            ok: false,
1527        }),
1528    }
1529    steps.extend(dependencies);
1530    Ok(steps)
1531}
1532
1533/// The events the memory hook fires on when a runner's table names none:
1534/// the prompt, which carries the task in the person's words. A tool call
1535/// carries the command about to run and is a cue too; a runner asks for it
1536/// with `hook_events`. The default came out of a panel of this seat's
1537/// personas: a turn issues many shell commands and one prompt.
1538pub const HOOK_EVENTS: &[&str] = &["UserPromptSubmit", "SessionEnd"];
1539
1540/// The events the hook knows a matcher for; any other event takes `*`.
1541pub const HOOK_MATCHERS: &[(&str, &str)] = &[
1542    ("PreToolUse", "Bash"),
1543    ("PostToolUse", "*"),
1544    ("UserPromptSubmit", "*"),
1545    ("Stop", "*"),
1546    ("SessionEnd", "*"),
1547    ("SubagentStop", "*"),
1548];
1549
1550/// One runner sends snake_case `hookEventName`; another sends
1551/// PascalCase `hook_event_name`. One name in the seat.
1552fn normalize_hook_event(raw: &str) -> &str {
1553    match raw {
1554        "pre_llm_call" => "UserPromptSubmit",
1555        "pre_tool_call" => "PreToolUse",
1556        "post_tool_call" => "PostToolUse",
1557        // One runner fires on_session_end after every turn; its session
1558        // ends on finalize or reset.
1559        "on_session_finalize" | "on_session_reset" => "SessionEnd",
1560        "on_session_end" => "TurnEnd",
1561        "pre_tool_use" | "PreToolUse" => "PreToolUse",
1562        "post_tool_use" | "PostToolUse" => "PostToolUse",
1563        "user_prompt_submit" | "UserPromptSubmit" => "UserPromptSubmit",
1564        "session_end" | "SessionEnd" => "SessionEnd",
1565        "session_start" | "SessionStart" => "SessionStart",
1566        "subagent_stop" | "SubagentStop" | "SubagentEnd" | "subagentStop" => "SubagentStop",
1567        "stop" | "Stop" => "Stop",
1568        other => other,
1569    }
1570}
1571
1572fn hook_matcher(event: &str) -> &'static str {
1573    HOOK_MATCHERS
1574        .iter()
1575        .find(|(e, _)| *e == event)
1576        .map_or("*", |(_, m)| m)
1577}
1578
1579/// The events a runner's table asks for, or the default.
1580fn hook_events_of(h: &Harness) -> Vec<String> {
1581    if h.name == "grok" {
1582        return [
1583            "UserPromptSubmit",
1584            "PostToolUse",
1585            "PreToolUse",
1586            "Stop",
1587            "SessionEnd",
1588            "SubagentStop",
1589        ]
1590        .into_iter()
1591        .map(str::to_string)
1592        .collect();
1593    }
1594    if h.hook_events.is_empty() {
1595        HOOK_EVENTS.iter().map(|e| (*e).to_string()).collect()
1596    } else {
1597        h.hook_events.clone()
1598    }
1599}
1600
1601fn is_seat_hook(h: &Value) -> bool {
1602    h["command"]
1603        .as_str()
1604        .is_some_and(|c| c.contains("ljos") && c.ends_with(" hook"))
1605}
1606
1607/// The command the runner's hook runs.
1608fn hook_command() -> String {
1609    which::which("ljos").map_or_else(
1610        |_| "ljos hook".to_string(),
1611        |p| format!("{} hook", p.display()),
1612    )
1613}
1614
1615/// Merge the seat's memory hook into a runner's hooks file, once per event.
1616/// The file is JSON with a `hooks` object of event name to matcher groups;
1617/// a group whose command is the seat's is left alone, so the step is
1618/// idempotent.
1619fn hook_step(file: &Path, events: &[String], dry: bool) -> Step {
1620    let what = "hook".to_string();
1621    let mut root: Value = match std::fs::read_to_string(file) {
1622        Ok(text) if !text.trim().is_empty() => match serde_json::from_str(&text) {
1623            Ok(v) => v,
1624            Err(e) => {
1625                return Step {
1626                    what,
1627                    detail: format!("{}: not JSON: {e}", file.display()),
1628                    ok: false,
1629                }
1630            }
1631        },
1632        _ => serde_json::json!({}),
1633    };
1634    let command = hook_command();
1635    let Some(obj) = root.as_object_mut() else {
1636        return Step {
1637            what,
1638            detail: format!("{}: not a JSON object", file.display()),
1639            ok: false,
1640        };
1641    };
1642    let hooks = obj.entry("hooks").or_insert_with(|| serde_json::json!({}));
1643    let Some(hooks) = hooks.as_object_mut() else {
1644        return Step {
1645            what,
1646            detail: format!("{}: hooks is not an object", file.display()),
1647            ok: false,
1648        };
1649    };
1650    // Reconcile: the seat's hook is on the events asked for and on no
1651    // other, and every group that is not the seat's is left alone.
1652    let mut added = Vec::new();
1653    let mut removed = Vec::new();
1654    for event in events {
1655        let groups = hooks
1656            .entry(event.clone())
1657            .or_insert_with(|| serde_json::json!([]));
1658        let Some(groups) = groups.as_array_mut() else {
1659            continue;
1660        };
1661        let present = groups.iter().any(|g| {
1662            g["hooks"]
1663                .as_array()
1664                .into_iter()
1665                .flatten()
1666                .any(is_seat_hook)
1667        });
1668        if present {
1669            continue;
1670        }
1671        groups.push(serde_json::json!({
1672            "matcher": hook_matcher(event),
1673            "hooks": [{"type": "command", "command": command, "timeout": 20}]
1674        }));
1675        added.push(event.clone());
1676    }
1677    for (event, groups) in hooks.iter_mut() {
1678        if events.contains(event) {
1679            continue;
1680        }
1681        let Some(groups) = groups.as_array_mut() else {
1682            continue;
1683        };
1684        let before = groups.len();
1685        groups.retain(|g| {
1686            !g["hooks"]
1687                .as_array()
1688                .into_iter()
1689                .flatten()
1690                .any(is_seat_hook)
1691        });
1692        if groups.len() != before {
1693            removed.push(event.clone());
1694        }
1695    }
1696    if added.is_empty() && removed.is_empty() {
1697        return Step {
1698            what,
1699            detail: format!(
1700                "{} carries the memory hook on {}",
1701                file.display(),
1702                events.join(", ")
1703            ),
1704            ok: true,
1705        };
1706    }
1707    let mut change = Vec::new();
1708    if !added.is_empty() {
1709        change.push(format!("add it on {}", added.join(", ")));
1710    }
1711    if !removed.is_empty() {
1712        change.push(format!("drop it from {}", removed.join(", ")));
1713    }
1714    let change = change.join(" and ");
1715    if dry {
1716        return Step {
1717            what,
1718            detail: format!("would {change} in {}", file.display()),
1719            ok: true,
1720        };
1721    }
1722    let written = file
1723        .parent()
1724        .map_or(Ok(()), std::fs::create_dir_all)
1725        .and_then(|()| serde_json::to_string_pretty(&root).map_err(std::io::Error::other))
1726        .and_then(|text| std::fs::write(file, text + "\n"));
1727    match written {
1728        Ok(()) => Step {
1729            what,
1730            detail: format!("memory hook: {change} in {}", file.display()),
1731            ok: true,
1732        },
1733        Err(e) => Step {
1734            what,
1735            detail: format!("{}: {e}", file.display()),
1736            ok: false,
1737        },
1738    }
1739}
1740
1741/// Whether a runner's hooks file carries the memory hook on every event.
1742fn hook_installed(file: &Path, events: &[String]) -> bool {
1743    let Ok(text) = std::fs::read_to_string(file) else {
1744        return false;
1745    };
1746    let Ok(root) = serde_json::from_str::<Value>(&text) else {
1747        return false;
1748    };
1749    events.iter().all(|event| {
1750        root["hooks"][event.as_str()]
1751            .as_array()
1752            .into_iter()
1753            .flatten()
1754            .any(|g| {
1755                g["hooks"]
1756                    .as_array()
1757                    .into_iter()
1758                    .flatten()
1759                    .any(is_seat_hook)
1760            })
1761    })
1762}
1763
1764/// What the runner's hook hands the seat: the event, and the text worth
1765/// asking the pack about. From a tool call, the command about to run; from
1766/// a prompt, the prompt.
1767#[derive(Debug, Clone, PartialEq, Eq)]
1768pub struct HookCall {
1769    pub event: String,
1770    pub cue: String,
1771    /// The runner's session, when it says: each memory is injected once
1772    /// per session, so the same lesson does not arrive on every command.
1773    pub session: Option<String>,
1774    /// The hook contract the call arrived in; it decides how a
1775    /// verdict is written back.
1776    pub shape: HookShape,
1777}
1778
1779/// The hook contract a call arrived in, told apart by its stdin. The
1780/// runners share one name for the answer, `permissionDecision`, but not
1781/// what they do with it.
1782#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1783pub enum HookShape {
1784    /// snake_case stdin; `permissionDecision` takes `deny` or `ask`.
1785    #[default]
1786    Asks,
1787    /// snake_case stdin carrying `turn_id`; `deny` only, and an `ask` is
1788    /// rejected as unsupported and the tool runs.
1789    DenyOnly,
1790    /// camelCase stdin (`hookEventName`, `toolInput`); a top-level
1791    /// `decision` blocks, and there is no `ask`.
1792    CamelCase,
1793    /// lower-case event names (`pre_llm_call`, `pre_tool_call`) with the
1794    /// prompt under `extra.user_message`; a top-level `context` is
1795    /// injected, `decision: block` blocks, and there is no `ask`.
1796    Context,
1797}
1798
1799impl HookShape {
1800    /// Whether the runner can stop and ask the person on a verdict.
1801    #[must_use]
1802    pub fn asks(self) -> bool {
1803        self == Self::Asks
1804    }
1805}
1806
1807/// Read a hook call from the runner's JSON, or from plain text (an argv
1808/// under argv law). Fields: `hook_event_name`, `tool_name`, `tool_input`
1809/// (its `command`, else every string value joined), `prompt`; grok's
1810/// camelCase `hookEventName`, `sessionId` and `toolInput` read the same.
1811#[must_use]
1812pub fn hook_call(input: &str) -> HookCall {
1813    let trimmed = input.trim();
1814    let Ok(v) = serde_json::from_str::<Value>(trimmed) else {
1815        return HookCall {
1816            event: "argv".into(),
1817            cue: trimmed.to_string(),
1818            session: None,
1819            shape: HookShape::Asks,
1820        };
1821    };
1822    let raw_event = v["hook_event_name"].as_str().unwrap_or("");
1823    let shape = if v.get("hookEventName").is_some() || v.get("toolInput").is_some() {
1824        HookShape::CamelCase
1825    } else if raw_event.starts_with("pre_")
1826        || raw_event.starts_with("post_")
1827        || raw_event.starts_with("on_")
1828    {
1829        HookShape::Context
1830    } else if v.get("turn_id").is_some() {
1831        HookShape::DenyOnly
1832    } else {
1833        HookShape::Asks
1834    };
1835    let input = if v["tool_input"].is_null() {
1836        &v["toolInput"]
1837    } else {
1838        &v["tool_input"]
1839    };
1840    let session = v["session_id"]
1841        .as_str()
1842        .or_else(|| v["sessionId"].as_str())
1843        .filter(|s| !s.is_empty())
1844        .map(str::to_string);
1845    let raw = v["hook_event_name"]
1846        .as_str()
1847        .or_else(|| v["hookEventName"].as_str())
1848        .unwrap_or("PreToolUse");
1849    let event = normalize_hook_event(raw).to_string();
1850    let cue = if let Some(p) = v["prompt"].as_str() {
1851        p.to_string()
1852    } else if let Some(p) = v["extra"]["user_message"].as_str() {
1853        p.to_string()
1854    } else if let Some(c) = input["command"].as_str() {
1855        c.to_string()
1856    } else if let Some(map) = input.as_object() {
1857        map.values()
1858            .filter_map(Value::as_str)
1859            .collect::<Vec<_>>()
1860            .join(" ")
1861    } else {
1862        String::new()
1863    };
1864    HookCall {
1865        event,
1866        cue,
1867        session,
1868        shape,
1869    }
1870}
1871
1872/// Where the ids already injected in a session are kept: the runtime
1873/// directory, so they go with the login and never into the pack.
1874fn seen_path(session: &str) -> Option<PathBuf> {
1875    let safe: String = session
1876        .chars()
1877        .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
1878        .collect();
1879    if safe.is_empty() {
1880        return None;
1881    }
1882    let dir = std::env::var_os("XDG_RUNTIME_DIR")
1883        .filter(|r| !r.is_empty())
1884        .map(PathBuf::from)
1885        .unwrap_or_else(std::env::temp_dir)
1886        .join("ljos");
1887    Some(dir.join(format!("hook-seen-{safe}")))
1888}
1889
1890pub fn seen_ids(session: Option<&str>) -> std::collections::BTreeSet<String> {
1891    session
1892        .and_then(seen_path)
1893        .and_then(|p| std::fs::read_to_string(p).ok())
1894        .map(|t| t.lines().map(str::to_string).collect())
1895        .unwrap_or_default()
1896}
1897
1898/// The memories injected during a session, in the order they arrived, and
1899/// the file they were kept in. The nudge marker is not a memory.
1900fn injected_ids(session: &str) -> (Vec<String>, Option<PathBuf>) {
1901    let path = seen_path(session);
1902    let ids: Vec<String> = path
1903        .as_ref()
1904        .and_then(|p| std::fs::read_to_string(p).ok())
1905        .map(|t| {
1906            t.lines()
1907                .map(str::trim)
1908                .filter(|l| !l.is_empty() && *l != "due-nudge")
1909                .map(str::to_string)
1910                .collect()
1911        })
1912        .unwrap_or_default();
1913    (ids, path)
1914}
1915
1916/// When a session ends, the memories injected during it fire together:
1917/// they served one sitting, so their links gain weight and the next
1918/// sitting like it walks a heavier path (Hebb, through the pack's `fire`).
1919/// The seen file goes with the session. Returns how many fired; nothing to
1920/// fire, or no pack, is zero and not an error, since a hook must not stop
1921/// a runner from ending.
1922pub fn session_end(session: Option<&str>) -> usize {
1923    let Some(session) = session else {
1924        return 0;
1925    };
1926    let (ids, path) = injected_ids(session);
1927    let fired = if ids.len() >= 2 {
1928        let top: Vec<String> = ids.into_iter().take(8).collect();
1929        pack()
1930            .ok()
1931            .and_then(|c| c.fire(&c.workspace(), &top).ok())
1932            .map_or(0, |_| top.len())
1933    } else {
1934        0
1935    };
1936    if let Some(p) = path {
1937        let _ = std::fs::remove_file(p);
1938    }
1939    fired
1940}
1941
1942/// Where a prompt's pack note waits. One runner discards prompt-hook
1943/// stdout and reads `Stop` feedback, so the note stays here until then.
1944fn hook_hold_path(session: Option<&str>) -> Option<PathBuf> {
1945    let dir = std::env::var_os("XDG_RUNTIME_DIR")
1946        .map(PathBuf::from)
1947        .or_else(|| std::env::var_os("TMPDIR").map(PathBuf::from))
1948        .unwrap_or_else(|| PathBuf::from("/tmp"));
1949    let name = session
1950        .filter(|s| !s.is_empty())
1951        .map(|s| {
1952            s.chars()
1953                .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
1954                .take(32)
1955                .collect::<String>()
1956        })
1957        .filter(|s| !s.is_empty())
1958        .unwrap_or_else(|| "default".into());
1959    Some(dir.join(format!("ljos-hook-hold-{name}")))
1960}
1961
1962fn hook_hold_ids_path(session: Option<&str>) -> Option<PathBuf> {
1963    hook_hold_path(session).map(|p| {
1964        let mut os = p.into_os_string();
1965        os.push(".ids");
1966        PathBuf::from(os)
1967    })
1968}
1969
1970/// Remember the prompt's pack text and the memory ids it names.
1971/// An empty note leaves a note already held: a later prompt that matches
1972/// nothing must not erase one the runner has not delivered yet.
1973pub fn hold_hook_context(session: Option<&str>, context: &str) {
1974    hold_hook_note(session, context, &[]);
1975}
1976
1977/// Hold `context` with the ids to mark seen when a runner delivers it.
1978pub fn hold_hook_note(session: Option<&str>, context: &str, ids: &[String]) {
1979    let Some(path) = hook_hold_path(session) else {
1980        return;
1981    };
1982    if context.is_empty() {
1983        return;
1984    }
1985    let _ = std::fs::write(&path, context);
1986    if let Some(ids_path) = hook_hold_ids_path(session) {
1987        let _ = std::fs::write(ids_path, ids.join("\n"));
1988    }
1989}
1990
1991/// The held pack text, left in place.
1992#[must_use]
1993pub fn peek_hook_context(session: Option<&str>) -> String {
1994    hook_hold_path(session)
1995        .and_then(|p| std::fs::read_to_string(p).ok())
1996        .unwrap_or_default()
1997}
1998
1999/// Take the held pack text once. Empty if nothing was held.
2000#[must_use]
2001pub fn take_hook_context(session: Option<&str>) -> String {
2002    take_hook_note(session).0
2003}
2004
2005/// Take the held note and its ids, and remove both files.
2006#[must_use]
2007pub fn take_hook_note(session: Option<&str>) -> (String, Vec<String>) {
2008    let Some(path) = hook_hold_path(session) else {
2009        return (String::new(), Vec::new());
2010    };
2011    let text = std::fs::read_to_string(&path).unwrap_or_default();
2012    let _ = std::fs::remove_file(&path);
2013    let ids = hook_hold_ids_path(session)
2014        .and_then(|p| std::fs::read_to_string(p).ok())
2015        .map(|t| {
2016            let _ = hook_hold_ids_path(session).map(std::fs::remove_file);
2017            t.lines()
2018                .map(str::trim)
2019                .filter(|l| !l.is_empty())
2020                .map(str::to_string)
2021                .collect()
2022        })
2023        .unwrap_or_default();
2024    (text, ids)
2025}
2026
2027/// Stdout for a prompt hook. A camel-case runner discards that stdout, so
2028/// the note is held and the stdout is empty. Any other runner is handed
2029/// the note directly.
2030#[must_use]
2031pub fn prompt_hook_stdout(
2032    shape: HookShape,
2033    session: Option<&str>,
2034    text: &str,
2035    ids: &[String],
2036) -> String {
2037    if shape == HookShape::CamelCase {
2038        hold_hook_note(session, text, ids);
2039        String::new()
2040    } else {
2041        hold_hook_context(session, text);
2042        text.to_string()
2043    }
2044}
2045
2046/// Stdout for a tool-result hook, and the ids to mark now that the note
2047/// was delivered. A camel-case runner takes the note on the first tool
2048/// result. `Stop` additionalContext would start another round, so the
2049/// hold is cleared here and `Stop` finds nothing. Any other runner takes
2050/// it the same way. A turn with no tool leaves the hold for `Stop`.
2051#[must_use]
2052pub fn post_hook_stdout(shape: HookShape, session: Option<&str>) -> (String, Vec<String>) {
2053    if shape == HookShape::CamelCase {
2054        let key = "hold-echoed".to_string();
2055        if seen_ids(session).contains(&key) {
2056            return (String::new(), Vec::new());
2057        }
2058        let (text, ids) = take_hook_note(session);
2059        if !text.is_empty() {
2060            mark_seen(session, &[key]);
2061        }
2062        (text, ids)
2063    } else {
2064        (take_hook_context(session), Vec::new())
2065    }
2066}
2067
2068/// Stdout for `Stop`, and the ids to mark now that the note is delivered.
2069/// A continuation (`stop_active`) says nothing: the first `Stop` already
2070/// delivered the note.
2071#[must_use]
2072pub fn stop_hook_stdout(session: Option<&str>, stop_active: bool) -> (String, Vec<String>) {
2073    if stop_active {
2074        return (String::new(), Vec::new());
2075    }
2076    take_hook_note(session)
2077}
2078
2079pub fn mark_seen(session: Option<&str>, ids: &[String]) {
2080    let Some(path) = session.and_then(seen_path) else {
2081        return;
2082    };
2083    if let Some(dir) = path.parent() {
2084        let _ = std::fs::create_dir_all(dir);
2085    }
2086    let mut text = std::fs::read_to_string(&path).unwrap_or_default();
2087    for id in ids {
2088        text.push_str(id);
2089        text.push('\n');
2090    }
2091    let _ = std::fs::write(path, text);
2092}
2093
2094/// The floor a hit must reach, as a share of the strongest hit's score, to
2095/// be injected. A command line matches many claims weakly; only the ones
2096/// that match it as well as the best does are worth the agent's context.
2097/// The floor is not relevance: a vague sentence scores high on unrelated
2098/// lessons, so a hit must also name a content word of the cue.
2099pub const HOOK_SCORE_FLOOR: f64 = 0.6;
2100
2101/// Words that sit in almost every sentence and almost every lesson.
2102/// A cue word on this list does not make a lesson about the prompt.
2103const CUE_STOP: &[&str] = &[
2104    "about",
2105    "after",
2106    "also",
2107    "anything",
2108    "because",
2109    "been",
2110    "before",
2111    "being",
2112    "both",
2113    "could",
2114    "does",
2115    "doing",
2116    "each",
2117    "everything",
2118    "from",
2119    "have",
2120    "having",
2121    "into",
2122    "just",
2123    "like",
2124    "making",
2125    "more",
2126    "most",
2127    "need",
2128    "nothing",
2129    "only",
2130    "other",
2131    "over",
2132    "please",
2133    "really",
2134    "same",
2135    "should",
2136    "some",
2137    "something",
2138    "still",
2139    "such",
2140    "than",
2141    "that",
2142    "their",
2143    "them",
2144    "then",
2145    "there",
2146    "these",
2147    "they",
2148    "this",
2149    "those",
2150    "through",
2151    "using",
2152    "very",
2153    "want",
2154    "were",
2155    "what",
2156    "when",
2157    "where",
2158    "which",
2159    "while",
2160    "will",
2161    "with",
2162    "would",
2163    "your",
2164];
2165
2166/// Content words of a cue: four letters or more, not [CUE_STOP].
2167/// Shorter tokens are how a sentence matches every lesson.
2168fn cue_content_words(text: &str) -> Vec<String> {
2169    let mut words: Vec<String> = text
2170        .split(|c: char| !c.is_alphanumeric())
2171        .filter(|w| w.len() >= 4)
2172        .map(str::to_lowercase)
2173        .filter(|w| !CUE_STOP.contains(&w.as_str()))
2174        .collect();
2175    words.sort_unstable();
2176    words.dedup();
2177    words
2178}
2179
2180/// Whether a lesson names something the cue names.
2181/// A high search score on a vague sentence is not that.
2182fn names_the_cue(text: &str, cue: &str) -> bool {
2183    let want = cue_content_words(cue);
2184    if want.is_empty() {
2185        return false;
2186    }
2187    let have = cue_content_words(text);
2188    want.iter().any(|w| have.binary_search(w).is_ok())
2189}
2190
2191#[cfg(test)]
2192/// A claim about one numbered pull request is a snapshot of that review.
2193/// "A PR branch must contain main" is a rule and stays. "PR 32 replays PR 36" does not.
2194fn names_a_numbered_pr(text: &str) -> bool {
2195    let t = text.to_lowercase();
2196    let b = t.as_bytes();
2197    let mut i = 0;
2198    while i < b.len() {
2199        if (i == 0 || !b[i - 1].is_ascii_alphanumeric())
2200            && (pr_number_at(&t[i..]) || hash_number_at(&t[i..]))
2201        {
2202            return true;
2203        }
2204        i += 1;
2205    }
2206    false
2207}
2208
2209#[cfg(test)]
2210/// `rest` begins at a pull-request word. True when a number follows it.
2211fn pr_number_at(rest: &str) -> bool {
2212    let after = if let Some(s) = rest.strip_prefix("pull requests") {
2213        s
2214    } else if let Some(s) = rest.strip_prefix("pull request") {
2215        s
2216    } else if let Some(s) = rest.strip_prefix("prs") {
2217        if s.starts_with(|c: char| c.is_ascii_alphanumeric()) {
2218            return false;
2219        }
2220        s
2221    } else if let Some(s) = rest.strip_prefix("pr") {
2222        if s.starts_with(|c: char| c.is_ascii_alphabetic()) {
2223            return false;
2224        }
2225        s
2226    } else {
2227        return false;
2228    };
2229    let after = after.trim_start();
2230    let after = after.strip_prefix('#').unwrap_or(after).trim_start();
2231    after.starts_with(|c: char| c.is_ascii_digit())
2232}
2233
2234#[cfg(test)]
2235/// `#80` names one pull request even when the word PR is not in front of it.
2236fn hash_number_at(rest: &str) -> bool {
2237    let Some(after) = rest.strip_prefix('#') else {
2238        return false;
2239    };
2240    after.starts_with(|c: char| c.is_ascii_digit())
2241}
2242
2243#[cfg(test)]
2244/// A claim about one artifact: a numbered pull request, a ticket id, or a commit.
2245/// That is a snapshot of one review. A rule that names no artifact is standing.
2246fn is_transient(text: &str) -> bool {
2247    names_a_numbered_pr(text) || names_a_ticket(text) || names_a_commit(text)
2248}
2249
2250#[cfg(test)]
2251/// `project-ab12`, the tracker's id shape. A hyphenated English word is longer.
2252fn names_a_ticket(text: &str) -> bool {
2253    text.split(|c: char| !c.is_ascii_alphanumeric() && c != '-')
2254        .any(|tok| {
2255            let Some((head, tail)) = tok.split_once('-') else {
2256                return false;
2257            };
2258            head.len() >= 2
2259                && head.chars().all(|c| c.is_ascii_alphabetic())
2260                && tail.len() == 4
2261                && tail.chars().all(|c| c.is_ascii_alphanumeric())
2262                && !tail.contains('-')
2263        })
2264}
2265
2266#[cfg(test)]
2267/// A hex token with a digit in it. Plain words that happen to be hex have none.
2268fn names_a_commit(text: &str) -> bool {
2269    text.split(|c: char| !c.is_ascii_alphanumeric()).any(|tok| {
2270        (7..=40).contains(&tok.len())
2271            && tok.chars().all(|c| c.is_ascii_hexdigit())
2272            && tok.chars().any(|c| c.is_ascii_digit())
2273    })
2274}
2275
2276/// A standing claim is a refresher. An episode is not, and neither is a
2277/// lesson written before the tag: rehearsal promotes it.
2278fn is_refresher(hit: &Hit) -> bool {
2279    if hit.kind == "preference" {
2280        return true;
2281    }
2282    if hit.entities.iter().any(|e| e == "horizon:transient") {
2283        return false;
2284    }
2285    hit.entities.iter().any(|e| e == "horizon:standing")
2286}
2287
2288/// The pack note for a prompt, and the memory ids named in it.
2289/// The ids are not marked seen here: the caller marks them when the runner
2290/// delivers the note. A camel-case prompt hook's stdout is discarded, so
2291/// marking here would burn the note before the model read it.
2292#[must_use]
2293pub fn hook_note(call: &HookCall, limit: usize) -> (String, Vec<String>) {
2294    let cue = call.cue.trim();
2295    if cue.len() < 3 {
2296        return (String::new(), Vec::new());
2297    }
2298    // The nudges answer what the prompt says, not what the pack holds, so
2299    // a prompt the pack knows nothing about still gets them. Their keys
2300    // travel with the note and are marked seen when a runner delivers it.
2301    let (mut nudge, due_key) = due_nudge(call);
2302    let mut pending = Vec::new();
2303    if let Some(key) = due_key {
2304        pending.push(key);
2305    }
2306    // With Jev on for this machine, one call judges which candidates bear on
2307    // the prompt and whether it corrects or puts a choice. Without it, or
2308    // when it does not answer in time, the local path below runs.
2309    let judged = judged_prompt(call, cue);
2310    let (correction, choice) = judged.as_ref().map_or((None, None), |(_, j)| {
2311        (Some(j.correction >= j.cue_at), Some(j.choice >= j.cue_at))
2312    });
2313    for (key, extra) in [
2314        correction_nudge_as(call, correction),
2315        decision_nudge_as(call, choice),
2316    ]
2317    .into_iter()
2318    .flatten()
2319    {
2320        pending.push(key);
2321        if !nudge.is_empty() {
2322            nudge.push('\n');
2323        }
2324        nudge.push_str(&extra);
2325    }
2326    // The cross-encoder reads the prompt and the claim together. The lexical
2327    // search is the fallback when that stage is down, and it still refuses
2328    // an episode.
2329    // The rerank gets a budget inside the runner's hook timeout; past it the
2330    // lexical search answers, which takes a fraction of a second.
2331    let seen = seen_ids(call.session.as_deref());
2332    let hits: Vec<Hit>;
2333    let mut rows: Vec<&Hit> = if let Some((candidates, j)) = &judged {
2334        // Jev read the prompt and each claim together; what it says bears
2335        // is what goes in, with no score floor or word test on top.
2336        candidates
2337            .iter()
2338            .enumerate()
2339            .filter(|(i, _)| j.bears(*i))
2340            .map(|(_, h)| h)
2341            .filter(|h| h.id.as_ref().is_none_or(|id| !seen.contains(id)))
2342            .collect()
2343    } else {
2344        // A machine that turned Jev on keeps the cross-encoder unloaded; a
2345        // prompt Jev was not asked about gets the lexical search.
2346        let rerank = !jev::enabled();
2347        let reranked = with_pack_timeout(HOOK_RERANK_BUDGET_MS, || {
2348            packset_search_opts(cue, 10, rerank)
2349        });
2350        let Ok(found) = reranked.or_else(|_| packset_search(cue)) else {
2351            return (nudge, pending);
2352        };
2353        hits = found;
2354        let top = hits.iter().map(|h| h.score).fold(0.0_f64, f64::max);
2355        if top <= 0.0 {
2356            return (nudge, pending);
2357        }
2358        hits.iter()
2359            .filter(|h| !UNREVIEWED_KINDS.contains(&h.kind.as_str()))
2360            .filter(|h| h.score >= top * HOOK_SCORE_FLOOR)
2361            .filter(|h| agreed(h))
2362            .filter(|h| names_the_cue(&h.text, cue))
2363            .filter(|h| is_refresher(h))
2364            .filter(|h| h.id.as_ref().is_none_or(|id| !seen.contains(id)))
2365            .collect()
2366    };
2367    // Jev's probability ranks what it judged; the search score ranks the rest.
2368    let weight = |h: &Hit| -> f64 {
2369        judged
2370            .as_ref()
2371            .and_then(|(c, j)| {
2372                let i = c.iter().position(|x| x.id == h.id && x.text == h.text)?;
2373                j.bears.get(i).copied()
2374            })
2375            .unwrap_or(h.score)
2376    };
2377    rows.sort_by(|a, b| {
2378        let pa = a.kind == "preference";
2379        let pb = b.kind == "preference";
2380        pb.cmp(&pa).then(
2381            weight(b)
2382                .partial_cmp(&weight(a))
2383                .unwrap_or(std::cmp::Ordering::Equal),
2384        )
2385    });
2386    let mut rows: Vec<&Hit> = rows.into_iter().take(limit).collect();
2387    // Preferences stay in front by score; the lessons behind them run
2388    // oldest to newest, so what was learnt last is read last and nearest
2389    // the action, and a later lesson that revises an earlier one reads as
2390    // a revision.
2391    let now = now_utc();
2392    let split = rows.iter().filter(|h| h.kind == "preference").count();
2393    rows[split..].sort_by_key(|h| days_of_stamp(h.ts.as_deref()).unwrap_or(i64::MAX));
2394    let lines: Vec<String> = rows.iter().map(|h| hit_line(h, &now)).collect();
2395    let mut ids: Vec<String> = rows.iter().filter_map(|h| h.id.clone()).collect();
2396    ids.extend(pending);
2397    if lines.is_empty() {
2398        return (nudge, ids);
2399    }
2400    let mut out = format!(
2401        "What this seat already knows that bears on this (from the pack, each with its age, lessons oldest first; `ljos search` for more):\n{}",
2402        lines.join("\n")
2403    );
2404    if !nudge.is_empty() {
2405        out.push('\n');
2406        out.push_str(&nudge);
2407    }
2408    (out, ids)
2409}
2410
2411/// The prompt's candidates and Jev's judgment of them, when this machine
2412/// turned Jev on and the prompt is worth a call: enough words to judge,
2413/// at least `min_candidates` claims to choose between after the local
2414/// kind, refresher and seen filters, and the month's spend under its cap.
2415/// Candidates come from the search without the local cross-encoder, which
2416/// Jev replaces.
2417fn judged_prompt(call: &HookCall, cue: &str) -> Option<(Vec<Hit>, jev::Judgment)> {
2418    if call.event != "UserPromptSubmit" {
2419        return None;
2420    }
2421    let (cfg, _) = jev::config()?;
2422    if cue.split_whitespace().count() < cfg.min_words {
2423        return None;
2424    }
2425    let seen = seen_ids(call.session.as_deref());
2426    let hits = packset_search_opts(cue, 10, false).ok()?;
2427    let candidates: Vec<Hit> = hits
2428        .into_iter()
2429        .filter(|h| !UNREVIEWED_KINDS.contains(&h.kind.as_str()))
2430        .filter(is_refresher)
2431        .filter(|h| h.id.as_ref().is_none_or(|id| !seen.contains(id)))
2432        .take(10)
2433        .collect();
2434    if candidates.len() < cfg.min_candidates {
2435        return None;
2436    }
2437    let texts: Vec<&str> = candidates.iter().map(|h| h.text.as_str()).collect();
2438    let judged = jev::judge(cue, &texts)?;
2439    Some((candidates, judged))
2440}
2441
2442/// The context the hook injects. A camel-case runner does not see prompt
2443/// stdout, so the ids stay unmarked until the first tool result, or `Stop`
2444/// when the turn ran no tool, delivers them. Every other runner is shown
2445/// this string and the ids are marked now.
2446#[must_use]
2447pub fn hook_context(call: &HookCall, limit: usize) -> String {
2448    let (text, ids) = hook_note(call, limit);
2449    if call.shape != HookShape::CamelCase {
2450        mark_seen(call.session.as_deref(), &ids);
2451    }
2452    text
2453}
2454
2455/// Whether the pack's scorers agreed on a hit: named by at least two of
2456/// the ballots that ran. When one ballot ran, or the hit carries no
2457/// count, it stands. A command line matches many claims weakly on one
2458/// scorer; what reaches the agent unasked should be what two scorers
2459/// found.
2460fn agreed(h: &Hit) -> bool {
2461    match (h.ballots, h.of) {
2462        (Some(named), Some(of)) if of >= 2 => named >= 2,
2463        _ => true,
2464    }
2465}
2466
2467/// What a hook call says about a subagent: its type when the call fired
2468/// inside one (`subagentType`, or `agent_type`), and whether a stop gate
2469/// already held it this turn (`stopHookActive`), and the agent's id when
2470/// the runner shares one session between a parent and its subagents.
2471#[must_use]
2472pub fn hook_subagent(input: &str) -> (Option<String>, bool, String) {
2473    let Ok(v) = serde_json::from_str::<Value>(input.trim()) else {
2474        return (None, false, String::new());
2475    };
2476    let kind = v["subagentType"]
2477        .as_str()
2478        .or_else(|| v["subagent_type"].as_str())
2479        .or_else(|| v["agent_type"].as_str())
2480        .filter(|s| !s.is_empty())
2481        .map(str::to_string);
2482    let active = v["stopHookActive"]
2483        .as_bool()
2484        .or_else(|| v["stop_hook_active"].as_bool())
2485        .unwrap_or(false);
2486    let agent = v["agent_id"]
2487        .as_str()
2488        .or_else(|| v["agentId"].as_str())
2489        .unwrap_or("")
2490        .to_string();
2491    (kind, active, agent)
2492}
2493
2494/// A command line that runs a test suite. Exact, so it is code, not a
2495/// judgment.
2496#[must_use]
2497pub fn runs_tests(command: &str) -> bool {
2498    const RUNNERS: &[&str] = &[
2499        "cargo test",
2500        "cargo nextest",
2501        "pytest",
2502        "ctest",
2503        "meson test",
2504        "npm test",
2505        "npm run test",
2506        "pnpm test",
2507        "go test",
2508        "make check",
2509        "make test",
2510        "repo-test",
2511        "tox",
2512        "bats ",
2513        "prove ",
2514        "mix test",
2515        "gradle test",
2516        "mvn test",
2517    ];
2518    RUNNERS.iter().any(|r| command.contains(r))
2519}
2520
2521/// The turn a stop ends, read from the runner's transcript: the person's
2522/// last request, the shell commands since it, the output of the latest
2523/// test run (or of the last commands when none ran), and the final
2524/// message.
2525#[derive(Debug, Clone, Default, PartialEq)]
2526pub struct StopTurn {
2527    pub request: String,
2528    pub commands: Vec<String>,
2529    pub test_ran: bool,
2530    pub outputs: Vec<String>,
2531    pub final_message: String,
2532}
2533
2534fn tail_chars(s: &str, n: usize) -> String {
2535    let count = s.chars().count();
2536    s.chars().skip(count.saturating_sub(n)).collect()
2537}
2538
2539fn block_text(content: &Value) -> String {
2540    match content {
2541        Value::String(t) => t.clone(),
2542        Value::Array(parts) => parts
2543            .iter()
2544            .filter_map(|p| p["text"].as_str())
2545            .collect::<Vec<_>>()
2546            .join("\n"),
2547        _ => String::new(),
2548    }
2549}
2550
2551/// Read a JSONL transcript of `user` and
2552/// `assistant` entries whose `message.content` is text or blocks
2553/// (`text`, `tool_use`, `tool_result`).
2554#[must_use]
2555pub fn stop_turn_from_transcript(text: &str) -> StopTurn {
2556    let entries: Vec<Value> = text
2557        .lines()
2558        .filter_map(|l| serde_json::from_str::<Value>(l).ok())
2559        .collect();
2560    let is_prompt = |e: &Value| {
2561        e["type"] == "user"
2562            && !e["isMeta"].as_bool().unwrap_or(false)
2563            && match &e["message"]["content"] {
2564                Value::String(t) => !t.trim_start().starts_with('<'),
2565                Value::Array(parts) => {
2566                    parts.iter().any(|p| p["type"] == "text")
2567                        && !parts.iter().any(|p| p["type"] == "tool_result")
2568                }
2569                _ => false,
2570            }
2571    };
2572    let start = entries.iter().rposition(is_prompt).unwrap_or(0);
2573    let mut turn = StopTurn {
2574        request: entries
2575            .get(start)
2576            .map(|e| block_text(&e["message"]["content"]))
2577            .unwrap_or_default(),
2578        ..StopTurn::default()
2579    };
2580    let mut pending: std::collections::BTreeMap<String, String> = Default::default();
2581    let mut outputs: Vec<(bool, String)> = Vec::new();
2582    for e in entries.iter().skip(start + 1) {
2583        let Value::Array(parts) = &e["message"]["content"] else {
2584            if e["type"] == "assistant" {
2585                turn.final_message = block_text(&e["message"]["content"]);
2586            }
2587            continue;
2588        };
2589        for part in parts {
2590            match part["type"].as_str() {
2591                Some("tool_use") => {
2592                    if let Some(cmd) = part["input"]["command"].as_str() {
2593                        let cmd: String = cmd.chars().take(200).collect();
2594                        if let Some(id) = part["id"].as_str() {
2595                            pending.insert(id.to_string(), cmd.clone());
2596                        }
2597                        turn.test_ran |= runs_tests(&cmd);
2598                        turn.commands.push(cmd);
2599                    }
2600                }
2601                Some("tool_result") => {
2602                    let id = part["tool_use_id"].as_str().unwrap_or("");
2603                    if let Some(cmd) = pending.remove(id) {
2604                        let out = tail_chars(&block_text(&part["content"]), 1500);
2605                        outputs.push((runs_tests(&cmd), format!("$ {cmd}\n{out}")));
2606                    }
2607                }
2608                Some("text") if e["type"] == "assistant" => {
2609                    turn.final_message = part["text"].as_str().unwrap_or("").to_string();
2610                }
2611                _ => {}
2612            }
2613        }
2614    }
2615    let tests: Vec<String> = outputs
2616        .iter()
2617        .filter(|o| o.0)
2618        .map(|o| o.1.clone())
2619        .collect();
2620    let chosen = if tests.is_empty() {
2621        outputs.into_iter().map(|o| o.1).collect::<Vec<_>>()
2622    } else {
2623        tests
2624    };
2625    turn.outputs = chosen.into_iter().rev().take(2).rev().collect();
2626    let n = turn.commands.len();
2627    turn.commands = turn.commands.split_off(n.saturating_sub(30));
2628    turn
2629}
2630
2631impl StopTurn {
2632    /// The audit state, bounded to a few thousand tokens.
2633    #[must_use]
2634    pub fn state(&self) -> String {
2635        format!(
2636            "The person asked:\n{}\n\nShell commands the agent ran since:\n{}\n\nLatest output:\n{}\n\nThe agent's final message:\n{}\n",
2637            tail_chars(&self.request, 1500),
2638            self.commands.join("\n"),
2639            self.outputs.join("\n---\n"),
2640            tail_chars(&self.final_message, 3000)
2641        )
2642    }
2643}
2644
2645/// Why an agent about to stop is held for one more round, from a Jev
2646/// audit of the turn; `None` lets it stop. Only a runner's first attempt
2647/// is audited, only with Jev on, and only a final message long enough to
2648/// claim anything.
2649#[must_use]
2650pub fn stop_audit(input: &str, stop_active: bool) -> Option<String> {
2651    if stop_active {
2652        return None;
2653    }
2654    jev::config()?;
2655    let v: Value = serde_json::from_str(input.trim()).ok()?;
2656    let path = v["transcript_path"]
2657        .as_str()
2658        .or_else(|| v["transcriptPath"].as_str());
2659    let mut turn = path
2660        .and_then(|p| std::fs::read_to_string(p).ok())
2661        .map(|t| stop_turn_from_transcript(&t))
2662        .unwrap_or_default();
2663    if let Some(last) = v["last_assistant_message"]
2664        .as_str()
2665        .or_else(|| v["lastAssistantMessage"].as_str())
2666    {
2667        turn.final_message = last.to_string();
2668    }
2669    if turn.final_message.chars().count() < 80 {
2670        return None;
2671    }
2672    let a = jev::audit(&turn.state())?;
2673    jev::audit_reason(&a, turn.test_ran)
2674}
2675
2676/// Tool calls a conversation may make without a word to the seat before the
2677/// hook reminds it. A sitting opened at the start and nothing after it is
2678/// how long work went unrecorded.
2679pub const WORK_NUDGE_EVERY: u64 = 40;
2680
2681/// Whether a hook call's cue is the seat's own verbs or tools.
2682#[must_use]
2683pub fn touches_seat(cue: &str) -> bool {
2684    cue.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2685        .any(|w| w == "ljos" || w == "vissue" || w.starts_with("ljos_") || w.starts_with("vissue_"))
2686}
2687
2688/// Count this conversation's tool calls since it last touched the seat, and
2689/// on a `PostToolUse` that reaches [`WORK_NUDGE_EVERY`] say what to record:
2690/// a note, a lesson or a deed on the issue it holds, or an issue to open
2691/// when it holds none. A subagent is left to its brief.
2692pub fn work_nudge(call: &HookCall, subagent: bool) -> Option<String> {
2693    let session = call.session.as_deref()?;
2694    let safe: String = session
2695        .chars()
2696        .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
2697        .collect();
2698    if safe.is_empty() || subagent {
2699        return None;
2700    }
2701    let path = runtime_dir().join(format!("work-{safe}"));
2702    if touches_seat(&call.cue) {
2703        let _ = std::fs::write(&path, "0");
2704        return None;
2705    }
2706    if call.event != "PostToolUse" {
2707        return None;
2708    }
2709    let count = std::fs::read_to_string(&path)
2710        .ok()
2711        .and_then(|t| t.trim().parse::<u64>().ok())
2712        .unwrap_or(0)
2713        + 1;
2714    if count < WORK_NUDGE_EVERY {
2715        let _ = std::fs::create_dir_all(runtime_dir());
2716        let _ = std::fs::write(&path, count.to_string());
2717        return None;
2718    }
2719    let _ = std::fs::write(&path, "0");
2720    Some(match held_issue() {
2721        Some(issue) => format!(
2722            "{count} tool calls on {issue} since the seat last heard from this conversation. \
2723             Record what the work has shown: progress is `vissue note {issue} \"...\"`, a lesson \
2724             that holds next time is `ljos remember \"...\"`, an artifact is `ljos deed {issue} \
2725             --add ACCESSION`; the work closes with `ljos finish {issue} --lesson \"...\"`."
2726        ),
2727        None => format!(
2728            "{count} tool calls in this conversation with no issue held. Work goes on an issue: \
2729             `vissue q -p PROJECT \"TITLE\"` prints an id, then `ljos sitting ID` opens it."
2730        ),
2731    })
2732}
2733
2734/// With `$XDG_RUNTIME_DIR/ljos/hook-trace` present, one line per hook call
2735/// to `hook-trace.jsonl` beside it: the event as sent and as read, the
2736/// payload's top-level key names, the session and subagent type. Key names
2737/// only, never values, so a runner's hook contract can be read off a live
2738/// session without storing what it said.
2739pub fn hook_trace(input: &str, call: &HookCall, subagent: Option<&str>) {
2740    let dir = runtime_dir();
2741    if !dir.join("hook-trace").exists() {
2742        return;
2743    }
2744    let v: Value = serde_json::from_str(input.trim()).unwrap_or(Value::Null);
2745    let keys: Vec<&str> = v
2746        .as_object()
2747        .map(|m| m.keys().map(String::as_str).collect())
2748        .unwrap_or_default();
2749    let raw = v["hook_event_name"]
2750        .as_str()
2751        .or_else(|| v["hookEventName"].as_str())
2752        .unwrap_or("");
2753    let line = serde_json::json!({
2754        "ts": now_utc(),
2755        "event": call.event,
2756        "raw": raw,
2757        "keys": keys,
2758        "session": call.session,
2759        "subagent": subagent,
2760        "holder": holder_name(),
2761        "tree_holder": runner_record_holders().first().cloned(),
2762        "held": subagent.and_then(|_| held_issue()),
2763    });
2764    use std::io::Write as _;
2765    if let Ok(mut f) = std::fs::OpenOptions::new()
2766        .create(true)
2767        .append(true)
2768        .open(dir.join("hook-trace.jsonl"))
2769    {
2770        let _ = writeln!(f, "{line}");
2771    }
2772}
2773
2774/// The holders the seat records above this process name, nearest first,
2775/// read without the conversation check `read_record` makes. A subagent's
2776/// hooks run under its own session id inside its parent's runner, so the
2777/// parent's record always looks like another conversation's there, and it
2778/// is exactly the one a subagent needs.
2779fn runner_record_holders() -> Vec<String> {
2780    let mut out = Vec::new();
2781    for (pid, _) in ancestry() {
2782        let Ok(text) = std::fs::read_to_string(seat_record_path(pid)) else {
2783            continue;
2784        };
2785        if let Some(holder) = text.lines().nth(1).map(str::trim).filter(|h| !h.is_empty()) {
2786            if !out.iter().any(|h| h == holder) {
2787                out.push(holder.to_string());
2788            }
2789        }
2790    }
2791    out
2792}
2793
2794/// The issue this conversation's holder claimed last and still works: a
2795/// subagent's hook runs under its parent's holder, so this is the work
2796/// the subagent is a slice of.
2797#[must_use]
2798pub fn held_issue() -> Option<String> {
2799    // The record the runner's own server left names the holder its claims
2800    // were made under. A hook's environment can carry session variables
2801    // the server's did not, which hash to another holder that holds
2802    // nothing, so the record is asked first.
2803    let mut holders: Vec<String> = runner_record_holders();
2804    let own = holder_name();
2805    if !holders.contains(&own) {
2806        holders.push(own);
2807    }
2808    // The hold records answer in milliseconds; the tracker walk below takes
2809    // seconds on a large tracker, past what a runner lets a hook run.
2810    if let Some(node) = held_from_records(&holders) {
2811        return Some(node);
2812    }
2813    if std::env::var_os("LJOS_IN_HOOK").is_some() {
2814        return None;
2815    }
2816    holders.iter().find_map(|holder| {
2817        let out = run_captured("vissue", &["claims", "--by", holder, "--json"]).ok()?;
2818        let rows: Value = serde_json::from_str(&out.stdout).ok()?;
2819        rows.as_array()?
2820            .iter()
2821            .rfind(|c| c["state"].as_str() == Some("STARTED"))?["id"]
2822            .as_str()
2823            .map(str::to_string)
2824    })
2825}
2826
2827/// What a subagent is told on its first tool result: the issue its parent
2828/// holds and how its result joins it. A subagent that is not told the
2829/// issue cannot cast a ballot on it, and a sitting of its own would
2830/// contend with its parent's.
2831#[must_use]
2832pub fn subagent_brief(kind: &str, issue: &str, decision: bool) -> String {
2833    let judge = if decision {
2834        format!("{issue} is a decision: end with your ballot, `ljos vote {issue} --for OPTION --expect OPTION --as ROLE`.")
2835    } else {
2836        format!(
2837            "A judgement between options is a ballot: `ljos vote {issue} --for OPTION --expect OPTION --as ROLE`."
2838        )
2839    };
2840    format!(
2841        "You are a subagent ({kind}) working under {issue}, which your parent holds. Do not open a sitting \
2842         on it. {judge} A lesson that will hold next time is `ljos remember \"...\" --as ROLE`; a \
2843         finding is `vissue note {issue} \"...\"`. ROLE is a persona from `ljos personas` when one fits \
2844         your task, else `{kind}`."
2845    )
2846}
2847
2848/// The stop gate for a subagent: once, when its parent holds an issue,
2849/// the reason the subagent is kept working one more round. A gate that
2850/// already held it this turn, or a parent holding nothing, lets it stop.
2851#[must_use]
2852pub fn subagent_stop_reason(
2853    kind: &str,
2854    issue: Option<&str>,
2855    decision: bool,
2856    active: bool,
2857) -> Option<String> {
2858    if active {
2859        return None;
2860    }
2861    let issue = issue?;
2862    Some(if decision {
2863        format!(
2864            "{issue} is a decision your parent holds. Before you stop, cast your ballot: \
2865             `ljos vote {issue} --for OPTION --expect OPTION --as ROLE` (ROLE: your persona, else `{kind}`)."
2866        )
2867    } else {
2868        format!(
2869            "You worked under {issue}. Before you stop: if your result settles a choice, \
2870             `ljos vote {issue} --for OPTION --expect OPTION --as ROLE`; if it taught something that holds next time, \
2871             `ljos remember \"...\" --as ROLE`. Otherwise stop."
2872        )
2873    })
2874}
2875
2876/// How long a context hook may take before it answers with nothing. The
2877/// shortest runner cut-off seen is grok's 15 s on a prompt; this leaves it
2878/// room on a loaded host.
2879pub const HOOK_DEADLINE_MS: u64 = 8000;
2880
2881/// Whether an identical call (event, session, text) started in the last 20
2882/// seconds. A runner that loads another runner's hook file runs the same
2883/// hook twice for one event, and both queue on the pack's one reranker.
2884/// The first call makes the marker and answers; the second returns at once.
2885pub fn hook_already_running(call: &HookCall) -> bool {
2886    let key = work_id(&format!(
2887        "{}|{}|{}",
2888        call.event,
2889        call.session.as_deref().unwrap_or(""),
2890        call.cue
2891    ));
2892    let dir = runtime_dir();
2893    let _ = std::fs::create_dir_all(&dir);
2894    // About one call in sixteen sweeps markers older than a minute.
2895    if key.starts_with('0') {
2896        if let Ok(entries) = std::fs::read_dir(&dir) {
2897            for e in entries.flatten() {
2898                let old = e.file_name().to_string_lossy().starts_with("hook-once-")
2899                    && e.metadata()
2900                        .and_then(|m| m.modified())
2901                        .ok()
2902                        .and_then(|t| t.elapsed().ok())
2903                        .is_some_and(|age| age > std::time::Duration::from_secs(60));
2904                if old {
2905                    let _ = std::fs::remove_file(e.path());
2906                }
2907            }
2908        }
2909    }
2910    let path = dir.join(format!("hook-once-{key}"));
2911    match std::fs::OpenOptions::new()
2912        .write(true)
2913        .create_new(true)
2914        .open(&path)
2915    {
2916        Ok(_) => false,
2917        Err(_) => {
2918            let fresh = std::fs::metadata(&path)
2919                .and_then(|m| m.modified())
2920                .ok()
2921                .and_then(|t| t.elapsed().ok())
2922                .is_some_and(|age| age < std::time::Duration::from_secs(20));
2923            if !fresh {
2924                let _ = std::fs::write(&path, "");
2925            }
2926            fresh
2927        }
2928    }
2929}
2930
2931/// How long the prompt hook waits for the reranked search. Runners cut a
2932/// hook off at 10 to 20 s, and a loaded host has made the rerank alone take
2933/// longer than that.
2934pub const HOOK_RERANK_BUDGET_MS: u64 = 2500;
2935
2936/// Run `f` with the pack client's request timeout set to `ms`, then put
2937/// back whatever it was.
2938fn with_pack_timeout<R>(ms: u64, f: impl FnOnce() -> R) -> R {
2939    let before = std::env::var_os("PACKSET_TIMEOUT_MS");
2940    // SAFETY: the hook reads and sets this on one thread, before and after
2941    // the one request it bounds.
2942    unsafe { std::env::set_var("PACKSET_TIMEOUT_MS", ms.to_string()) };
2943    let out = f();
2944    match before {
2945        Some(v) => unsafe { std::env::set_var("PACKSET_TIMEOUT_MS", v) },
2946        None => unsafe { std::env::remove_var("PACKSET_TIMEOUT_MS") },
2947    }
2948    out
2949}
2950
2951/// Phrases a person uses when the agent has forgotten something it was
2952/// told. A prompt that opens this way is a preference or a lesson the
2953/// pack does not hold yet, and the moment to write it is now, before the
2954/// work that follows.
2955pub const CORRECTION_CUES: &[&str] = &[
2956    "do you not remember",
2957    "don't you remember",
2958    "dont you remember",
2959    "you should have",
2960    "why did you not",
2961    "why didn't you",
2962    "why havent you",
2963    "why haven't you",
2964    "you forgot",
2965    "i told you",
2966    "i've told you",
2967    "as i said",
2968    "again you",
2969    "still not",
2970    "not even able",
2971    "you never",
2972    "you keep",
2973];
2974
2975#[cfg(test)]
2976/// On a prompt that reads as a correction, the one line that turns it
2977/// into memory: the agent writes the preference or lesson with `ljos
2978/// prefer` or `ljos remember` before it goes on. Once a session for the
2979/// same cue, so a run of corrections does not repeat it.
2980fn correction_nudge(call: &HookCall) -> Option<(String, String)> {
2981    correction_nudge_as(call, None)
2982}
2983
2984/// [`correction_nudge`] with a verdict from elsewhere: `Some` is Jev's
2985/// answer and replaces the phrase list, `None` keeps the list.
2986fn correction_nudge_as(call: &HookCall, verdict: Option<bool>) -> Option<(String, String)> {
2987    if call.event != "UserPromptSubmit" {
2988        return None;
2989    }
2990    let key = match verdict {
2991        Some(false) => return None,
2992        Some(true) => "correction:judged".to_string(),
2993        None => {
2994            let lower = call.cue.to_lowercase();
2995            let hit = CORRECTION_CUES.iter().find(|c| lower.contains(*c))?;
2996            format!("correction:{hit}")
2997        }
2998    };
2999    if seen_ids(call.session.as_deref()).contains(&key) {
3000        return None;
3001    }
3002    Some((
3003        key,
3004        "This prompt reads as a correction. Before the work: write what it corrects as one \
3005         `ljos prefer \"...\"` (a standing choice) or `ljos remember \"...\"` (a lesson), \
3006         so the pack holds it and the hook can raise it next time."
3007            .to_string(),
3008    ))
3009}
3010
3011/// Phrases that put a choice to the agent. A choice with more than one
3012/// defensible answer is a ballot, and a ballot needs an issue to sit on.
3013pub const DECISION_CUES: &[&str] = &[
3014    "should we",
3015    "should i ",
3016    "or should",
3017    "which is better",
3018    "which one",
3019    "which approach",
3020    "which option",
3021    "pros and cons",
3022    "trade-off",
3023    "tradeoff",
3024    " versus ",
3025    " vs ",
3026    " vs. ",
3027    "what do you recommend",
3028    "do you think we",
3029    "option 1",
3030    "option 2",
3031    "option a",
3032    "option b",
3033];
3034
3035/// How much of a prompt the decision cues are looked for in.
3036pub const DECISION_OPENING: usize = 400;
3037
3038/// Whether `cue` occurs in `text` ending at a word boundary, so `option a`
3039/// does not fire on `option about`.
3040fn cue_at_word_end(text: &str, cue: &str) -> bool {
3041    text.match_indices(cue).any(|(i, _)| {
3042        text[i + cue.len()..]
3043            .chars()
3044            .next()
3045            .is_none_or(|c| !c.is_alphanumeric())
3046    })
3047}
3048
3049#[cfg(test)]
3050/// On a prompt that puts a choice, the lines that take it to a panel
3051/// instead of one agent's opinion. Once a session, since one decision
3052/// is usually argued over several prompts.
3053fn decision_nudge(call: &HookCall) -> Option<(String, String)> {
3054    decision_nudge_as(call, None)
3055}
3056
3057/// [`decision_nudge`] with a verdict from elsewhere, as for corrections.
3058fn decision_nudge_as(call: &HookCall, verdict: Option<bool>) -> Option<(String, String)> {
3059    if call.event != "UserPromptSubmit" {
3060        return None;
3061    }
3062    match verdict {
3063        Some(false) => return None,
3064        Some(true) => {}
3065        None => {
3066            // A question is put in the prompt's opening; a long pasted report
3067            // that mentions options further down is not a choice put to the
3068            // agent.
3069            let opening: String = call.cue.chars().take(DECISION_OPENING).collect();
3070            let lower = format!(" {} ", opening.to_lowercase());
3071            DECISION_CUES.iter().find(|c| cue_at_word_end(&lower, c))?;
3072        }
3073    }
3074    let key = "decision-nudge".to_string();
3075    if seen_ids(call.session.as_deref()).contains(&key) {
3076        return None;
3077    }
3078    Some((
3079        key,
3080        "This prompt puts a choice. Before choosing: put it on an issue whose body has an \
3081         `Options: A, B` line, then `ljos sitting ISSUE` writes one brief per persona the \
3082         title names; start one subagent per brief, each casting `ljos vote ISSUE --for \
3083         OPTION --expect OPTION --as NAME`, and settle with `ljos consensus ISSUE`."
3084            .to_string(),
3085    ))
3086}
3087
3088/// On a prompt, once per session: how many claims are due for review. The
3089/// review loop runs only when somebody grades, and nobody grades what they
3090/// were not told about.
3091fn due_nudge(call: &HookCall) -> (String, Option<String>) {
3092    if call.event != "UserPromptSubmit" {
3093        return (String::new(), None);
3094    }
3095    let key = "due-nudge".to_string();
3096    if seen_ids(call.session.as_deref()).contains(&key) {
3097        return (String::new(), None);
3098    }
3099    let Ok(client) = pack() else {
3100        return (String::new(), None);
3101    };
3102    let Ok(atoms) = atoms_lean(&client, &client.workspace()) else {
3103        return (String::new(), None);
3104    };
3105    let due = due_of(&atoms, &now_utc()).len();
3106    // A quiet seat has nothing to show, so it is counted once here. A seat
3107    // with claims due names the key and the caller marks it when the note
3108    // is delivered. Do not call consolidate here: that walk is a sitting,
3109    // not a hook, and it is what made PreToolUse time out at 20s.
3110    if due == 0 {
3111        mark_seen(call.session.as_deref(), &[key]);
3112        return (String::new(), None);
3113    }
3114    (
3115        format!(
3116            "{due} claim{} due for review in this seat: `ljos due`, read each, then `ljos graded ID` (or `--lapsed`).",
3117            if due == 1 { " is" } else { "s are" }
3118        ),
3119        Some(key),
3120    )
3121}
3122
3123/// The hook's answer in the runner's JSON: `additionalContext` under the
3124/// event that fired. Empty context is no output, which the runner reads as
3125/// no opinion.
3126#[must_use]
3127pub fn hook_output(call: &HookCall, context: &str) -> String {
3128    hook_output_ruled(call, context, None)
3129}
3130
3131/// [`hook_output`] carrying a rule's verdict on a tool call: `deny` or
3132/// `ask` as the runner's permission decision, with the rule's reason. On a
3133/// prompt or an argv line the verdict is a line of text.
3134#[must_use]
3135pub fn hook_output_ruled(call: &HookCall, context: &str, verdict: Option<&Rule>) -> String {
3136    if context.is_empty() && verdict.is_none() {
3137        return String::new();
3138    }
3139    if call.event == "argv" {
3140        let mut out = String::new();
3141        if let Some(r) = verdict {
3142            out.push_str(&format!(
3143                "{}: {} (rule `{}`)\n",
3144                r.verdict, r.reason, r.pattern
3145            ));
3146        }
3147        if !context.is_empty() {
3148            out.push_str(context);
3149            out.push('\n');
3150        }
3151        return out;
3152    }
3153    if call.shape == HookShape::Context && verdict.is_none() {
3154        return if context.is_empty() {
3155            String::new()
3156        } else {
3157            serde_json::json!({ "context": context }).to_string() + "\n"
3158        };
3159    }
3160    let mut specific = serde_json::json!({ "hookEventName": call.event });
3161    if !context.is_empty() {
3162        specific["additionalContext"] = Value::String(context.to_string());
3163    }
3164    let mut top = serde_json::Map::new();
3165    if let Some(r) = verdict {
3166        if call.event == "PreToolUse" {
3167            // A runner that cannot ask runs the tool on an `ask`; the
3168            // seat stops it and tells the agent to ask the person.
3169            let (decision, reason) = if r.verdict == "ask" && !call.shape.asks() {
3170                (
3171                    "deny",
3172                    format!(
3173                        "ask the person before running this: {} (seat rule `{}`)",
3174                        r.reason, r.pattern
3175                    ),
3176                )
3177            } else {
3178                (
3179                    r.verdict.as_str(),
3180                    format!("{} (seat rule `{}`)", r.reason, r.pattern),
3181                )
3182            };
3183            if call.shape == HookShape::Context {
3184                // `block` is the one verb there; context rides along.
3185                let mut out = serde_json::json!({ "decision": "block", "reason": reason });
3186                if !context.is_empty() {
3187                    out["context"] = Value::String(context.to_string());
3188                }
3189                return out.to_string() + "\n";
3190            }
3191            specific["permissionDecision"] = Value::String(decision.to_string());
3192            specific["permissionDecisionReason"] = Value::String(reason.clone());
3193            if call.shape == HookShape::CamelCase {
3194                top.insert("decision".into(), Value::String(decision.to_string()));
3195                top.insert("reason".into(), Value::String(reason));
3196            }
3197        }
3198    }
3199    top.insert("hookSpecificOutput".into(), specific);
3200    Value::Object(top).to_string() + "\n"
3201}
3202
3203pub fn format_steps(steps: &[Step]) -> String {
3204    steps
3205        .iter()
3206        .map(|s| {
3207            format!(
3208                "{}\t{}\t{}\n",
3209                if s.ok { "ok" } else { "no" },
3210                s.what,
3211                s.detail
3212            )
3213        })
3214        .collect()
3215}
3216
3217/// The runner rows for `doctor`, one pair per runner the file names.
3218fn harness_rows() -> Vec<Habitat> {
3219    let path = harnesses_path();
3220    let all = match harnesses_from(&path) {
3221        Ok(all) => all,
3222        Err(e) => {
3223            return vec![Habitat {
3224                name: "runners",
3225                state: format!("{e:#}"),
3226                ok: false,
3227            }]
3228        }
3229    };
3230    if all.harness.is_empty() {
3231        return vec![Habitat {
3232            name: "runners",
3233            state: format!(
3234                "none named in {}; `ljos onboard --example` prints the shape",
3235                path.display()
3236            ),
3237            ok: false,
3238        }];
3239    }
3240    let server = server_path().unwrap_or_else(|_| PathBuf::from("ljos-mcp"));
3241    let mut rows = Vec::new();
3242    for h in &all.harness {
3243        let registered = is_registered(h, &server) == Some(true);
3244        let probed = (registered && !h.probe.is_empty()).then(|| probe_lists_ljos(&h.probe));
3245        rows.push(Habitat {
3246            name: "runner mcp",
3247            state: match (registered, &probed) {
3248                (false, _) => format!(
3249                    "{}: not registered; ljos onboard --harness {}",
3250                    h.name, h.name
3251                ),
3252                (true, Some(Err(why))) => format!(
3253                    "{}: registered, but `{}` does not list ljos_sitting: {why}",
3254                    h.name,
3255                    h.probe.join(" ")
3256                ),
3257                (true, Some(Ok(()))) => format!("{}: ljos registered and loads", h.name),
3258                (true, None) => format!("{}: ljos registered", h.name),
3259            },
3260            ok: registered && !matches!(probed, Some(Err(_))),
3261        });
3262        let skill = h
3263            .skills
3264            .as_deref()
3265            .map(|d| expand(d).join("ljos").join("SKILL.md"));
3266        let current = skill
3267            .as_ref()
3268            .is_some_and(|p| std::fs::read_to_string(p).is_ok_and(|t| t == skill_text()));
3269        if let Some(file) = &h.hooks {
3270            let path = expand(file);
3271            let installed = hook_installed(&path, &hook_events_of(h));
3272            rows.push(Habitat {
3273                name: "runner hook",
3274                state: if installed {
3275                    format!("{}: memory hook on {}", h.name, path.display())
3276                } else {
3277                    format!(
3278                        "{}: no memory hook; ljos onboard --harness {}",
3279                        h.name, h.name
3280                    )
3281                },
3282                ok: installed,
3283            });
3284        } else if h.plugin.is_none() {
3285            if let Some(cfg) = &h.config {
3286                let path = expand(cfg);
3287                let installed =
3288                    std::fs::read_to_string(&path).is_ok_and(|t| t.contains("ljos hook"));
3289                rows.push(Habitat {
3290                    name: "runner hook",
3291                    state: if installed {
3292                        format!("{}: memory hook in {}", h.name, path.display())
3293                    } else {
3294                        format!(
3295                            "{}: no memory hook in {}; ljos onboard --harness {}",
3296                            h.name,
3297                            path.display(),
3298                            h.name
3299                        )
3300                    },
3301                    ok: installed,
3302                });
3303            }
3304        }
3305        if let Some(dest) = &h.plugin {
3306            let path = expand(dest);
3307            let want = ljos_path().ok().and_then(|l| plugin_text(h, &l));
3308            let current = want
3309                .as_ref()
3310                .is_some_and(|w| std::fs::read_to_string(&path).is_ok_and(|t| &t == w));
3311            rows.push(Habitat {
3312                name: "runner hook",
3313                state: if current {
3314                    format!("{}: plugin {}", h.name, path.display())
3315                } else if path.is_file() {
3316                    format!(
3317                        "{}: plugin {} is stale; ljos onboard --harness {}",
3318                        h.name,
3319                        path.display(),
3320                        h.name
3321                    )
3322                } else {
3323                    format!("{}: no plugin; ljos onboard --harness {}", h.name, h.name)
3324                },
3325                ok: current,
3326            });
3327        }
3328        rows.push(Habitat {
3329            name: "runner skill",
3330            state: match (&skill, current) {
3331                (Some(p), true) => format!("{}: {}", h.name, p.display()),
3332                (Some(p), false) if p.is_file() => {
3333                    format!(
3334                        "{}: {} is stale; ljos onboard --harness {}",
3335                        h.name,
3336                        p.display(),
3337                        h.name
3338                    )
3339                }
3340                (Some(_), false) => {
3341                    format!("{}: absent; ljos onboard --harness {}", h.name, h.name)
3342                }
3343                (None, _) => format!("{}: no skills directory named", h.name),
3344            },
3345            ok: current,
3346        });
3347    }
3348    rows
3349}
3350
3351/// Run a runner's probe with a thirty-second limit; it passes when it
3352/// exits 0 and its output names `ljos_sitting`.
3353fn probe_lists_ljos(argv: &[String]) -> std::result::Result<(), String> {
3354    use std::io::Read;
3355    use std::process::{Command, Stdio};
3356    let (bin, args) = argv.split_first().ok_or("empty probe")?;
3357    let mut child = Command::new(expand(bin))
3358        .args(args)
3359        .stdin(Stdio::null())
3360        .stdout(Stdio::piped())
3361        .stderr(Stdio::piped())
3362        .spawn()
3363        .map_err(|e| format!("{bin}: {e}"))?;
3364    let started = std::time::Instant::now();
3365    let status = loop {
3366        match child.try_wait() {
3367            Ok(Some(status)) => break status,
3368            Ok(None) if started.elapsed() > std::time::Duration::from_secs(30) => {
3369                let _ = child.kill();
3370                let _ = child.wait();
3371                return Err("no answer in 30 s".into());
3372            }
3373            Ok(None) => std::thread::sleep(std::time::Duration::from_millis(100)),
3374            Err(e) => return Err(e.to_string()),
3375        }
3376    };
3377    let mut out = String::new();
3378    if let Some(mut o) = child.stdout.take() {
3379        let _ = o.read_to_string(&mut out);
3380    }
3381    if let Some(mut e) = child.stderr.take() {
3382        let _ = e.read_to_string(&mut out);
3383    }
3384    if !status.success() {
3385        return Err(format!("exit {}", status.code().unwrap_or(-1)));
3386    }
3387    if out.contains("ljos_sitting") {
3388        Ok(())
3389    } else {
3390        Err("its output names no ljos tool".into())
3391    }
3392}
3393
3394/// Have a pack writer up before anything else is wired: a runner onboarded
3395/// to a seat with no writer would meet every memory verb failing. `packset
3396/// ensure` starts one when none answers and is idempotent when one does.
3397fn pack_step(dry: bool) -> Step {
3398    let what = "pack".to_string();
3399    if let Ok(client) = pack() {
3400        if client.health().is_ok() {
3401            return Step {
3402                what,
3403                detail: format!("writer up at {}", client.base()),
3404                ok: true,
3405            };
3406        }
3407    } else {
3408        return Step {
3409            what,
3410            detail: "PACKSET_URL=off; no pack on purpose".into(),
3411            ok: true,
3412        };
3413    }
3414    if !on_path("packset") {
3415        return Step {
3416            what,
3417            detail: "no writer answers and packset is not on PATH".into(),
3418            ok: false,
3419        };
3420    }
3421    if dry {
3422        return Step {
3423            what,
3424            detail: "would run packset ensure".into(),
3425            ok: true,
3426        };
3427    }
3428    match run_captured("packset", &["ensure"]) {
3429        Ok(said) => Step {
3430            what,
3431            detail: format!(
3432                "started a writer: {}",
3433                said.stdout.lines().next().unwrap_or("").trim()
3434            ),
3435            ok: true,
3436        },
3437        Err(e) => Step {
3438            what,
3439            detail: e.to_string().lines().next().unwrap_or("").to_string(),
3440            ok: false,
3441        },
3442    }
3443}
3444
3445/// Make the seat's host key at `~/.config/deedar/host.key` when there is
3446/// none, so handovers go out signed from the first one. An existing key, or
3447/// one named by `DEEDAR_HOST_SIGNING_KEY`, is left alone.
3448fn host_key_step(dry: bool) -> Step {
3449    if let Some(path) = host_key_path() {
3450        return Step {
3451            what: "host key".into(),
3452            detail: format!("{} exists", path.display()),
3453            ok: true,
3454        };
3455    }
3456    if std::env::var_os("DEEDAR_HOST_SIGNING_KEY").is_some_and(|r| r == "off") {
3457        return Step {
3458            what: "host key".into(),
3459            detail: "DEEDAR_HOST_SIGNING_KEY=off; handovers go out unsigned on purpose".into(),
3460            ok: true,
3461        };
3462    }
3463    let Some(path) = default_host_key_path() else {
3464        return Step {
3465            what: "host key".into(),
3466            detail: "no home directory to keep a key in".into(),
3467            ok: false,
3468        };
3469    };
3470    if dry {
3471        return Step {
3472            what: "host key".into(),
3473            detail: format!("would write a 32-byte seed to {}", path.display()),
3474            ok: true,
3475        };
3476    }
3477    let made = (|| -> std::io::Result<()> {
3478        use std::io::Read;
3479        let mut seed = [0u8; 32];
3480        std::fs::File::open("/dev/urandom")?.read_exact(&mut seed)?;
3481        if let Some(dir) = path.parent() {
3482            std::fs::create_dir_all(dir)?;
3483        }
3484        std::fs::write(&path, seed)?;
3485        #[cfg(unix)]
3486        {
3487            use std::os::unix::fs::PermissionsExt;
3488            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
3489        }
3490        Ok(())
3491    })();
3492    match made {
3493        Ok(()) => Step {
3494            what: "host key".into(),
3495            detail: format!("wrote a 32-byte seed to {}", path.display()),
3496            ok: true,
3497        },
3498        Err(e) => Step {
3499            what: "host key".into(),
3500            detail: format!("{}: {e}", path.display()),
3501            ok: false,
3502        },
3503    }
3504}
3505
3506/// `$XDG_CONFIG_HOME/deedar/host.key`, whether or not it exists.
3507fn default_host_key_path() -> Option<PathBuf> {
3508    let config = std::env::var_os("XDG_CONFIG_HOME")
3509        .filter(|r| !r.is_empty())
3510        .map(PathBuf::from)
3511        .or_else(|| home().ok().map(|h| h.join(".config")))?;
3512    Some(config.join("deedar").join("host.key"))
3513}
3514
3515/// The host key `deedar` will sign with: `DEEDAR_HOST_SIGNING_KEY`, else
3516/// `~/.config/deedar/host.key` when it exists. `off` is no key on purpose.
3517fn host_key_path() -> Option<PathBuf> {
3518    if let Some(raw) = std::env::var_os("DEEDAR_HOST_SIGNING_KEY").filter(|r| !r.is_empty()) {
3519        return (raw != "off").then(|| PathBuf::from(raw));
3520    }
3521    let path = default_host_key_path()?;
3522    path.is_file().then_some(path)
3523}
3524
3525/// `raw` with a leading `~` or `~/` put against `home`; `None` when there is
3526/// nothing to expand.
3527pub fn expand_leading_tilde(raw: &str, home: &str) -> Option<String> {
3528    let home = home.trim_end_matches('/');
3529    if raw == "~" {
3530        return Some(home.to_string());
3531    }
3532    raw.strip_prefix("~/").map(|rest| format!("{home}/{rest}"))
3533}
3534
3535/// Expand a leading `~` in `ISSUE_ROOT` and `VISSUE_ROOT` once, at start.
3536/// environment.d and MCP `env` blocks pass `~/...` through unexpanded; a
3537/// tracker crate that predates the fix then resolves it against the working
3538/// directory, and every child `vissue` inherits the same relative root.
3539pub fn normalize_tracker_env() {
3540    let Some(home) = std::env::var_os("HOME").filter(|h| !h.is_empty()) else {
3541        return;
3542    };
3543    let home = home.to_string_lossy().to_string();
3544    for var in ["ISSUE_ROOT", "VISSUE_ROOT"] {
3545        if let Ok(raw) = std::env::var(var) {
3546            if let Some(expanded) = expand_leading_tilde(&raw, &home) {
3547                std::env::set_var(var, expanded);
3548            }
3549        }
3550    }
3551}
3552
3553/// Printed on stderr. `ljos-policyd` is the TCB when it exists.
3554pub const POLICY_TCB: &str =
3555    "argv law. ljos-policyd is the TCB when present. Reloading a pack is not a check.";
3556
3557/// The workspace the seat's memory lives in when nothing names one. The
3558/// pack's command line keys a workspace to the repository it stands in;
3559/// a seat is one memory across every repository it works in, so the seat
3560/// pins one. `PACKSET_WORKSPACE` overrides it.
3561pub const SEAT_WORKSPACE: &str = "seat";
3562
3563/// The pack client. With nothing set it speaks to `127.0.0.1:8761` about
3564/// the `seat` workspace; `PACKSET_URL` points elsewhere, `PACKSET_WORKSPACE`
3565/// names another workspace, and `PACKSET_URL=off` is the one way to have no
3566/// pack.
3567/// Load `~/.config/ljos/env` (KEY=VALUE) when the process has not set
3568/// those keys. The shell and the MCP seat then share one pack.
3569fn load_seat_env() {
3570    let Ok(home) = home() else {
3571        return;
3572    };
3573    let path = home.join(".config/ljos/env");
3574    let Ok(text) = std::fs::read_to_string(path) else {
3575        return;
3576    };
3577    for line in text.lines() {
3578        let line = line.trim();
3579        if line.is_empty() || line.starts_with('#') {
3580            continue;
3581        }
3582        let Some((k, v)) = line.split_once('=') else {
3583            continue;
3584        };
3585        let k = k.trim();
3586        if k.is_empty() || std::env::var_os(k).is_some() {
3587            continue;
3588        }
3589        std::env::set_var(k, v.trim());
3590    }
3591}
3592
3593/// A transport failure, as distinct from a writer that answered and refused.
3594fn writer_unreachable(err: &anyhow::Error) -> bool {
3595    err.chain().any(|cause| {
3596        cause
3597            .downcast_ref::<packset_client::Error>()
3598            .is_some_and(|inner| matches!(inner, packset_client::Error::Http(_)))
3599    })
3600}
3601
3602/// Start the default writer when a memory verb could not connect.
3603/// `PACKSET_URL=off` is left alone. A URL pointed somewhere else is not
3604/// replaced with the default writer.
3605fn ensure_writer() -> Result<()> {
3606    if std::env::var("PACKSET_URL").ok().as_deref() == Some("off") {
3607        return Ok(());
3608    }
3609    if std::env::var("PACKSET_URL")
3610        .ok()
3611        .is_some_and(|url| !url.is_empty())
3612    {
3613        bail!(
3614            "the pack writer at PACKSET_URL is not answering. This seat is not pointed at the default writer, so it was not started"
3615        );
3616    }
3617    if !on_path("packset") {
3618        bail!("no pack writer is answering, and packset is not on PATH. cargo binstall packset");
3619    }
3620    run_captured("packset", &["ensure"]).context("packset ensure")?;
3621    Ok(())
3622}
3623
3624fn with_writer<T>(op: impl Fn() -> Result<T>) -> Result<T> {
3625    match op() {
3626        Ok(value) => Ok(value),
3627        Err(err) if writer_unreachable(&err) => {
3628            ensure_writer()?;
3629            op()
3630        }
3631        Err(err) => Err(err),
3632    }
3633}
3634
3635/// The pack's live atoms without their dense vectors. Every reader here
3636/// wants texts, kinds, review clocks, trust or rules; the vectors are nine
3637/// tenths of the listing, and parsing them grew one ljos-mcp from 10 to
3638/// 66 MB and kept it. A writer older than `embedding=omit` sends them
3639/// anyway, and the answer is the same.
3640///
3641/// # Errors
3642///
3643/// The pack not answering, or an answer that is not atoms.
3644pub fn atoms_lean(client: &PacksetClient, workspace: &str) -> Result<Vec<Value>> {
3645    let url = format!("{}/v1/atoms", client.base());
3646    let mut body: Value = ureq::get(&url)
3647        .query("workspace", workspace)
3648        .query("embedding", "omit")
3649        .timeout(std::time::Duration::from_secs(30))
3650        .call()
3651        .map_err(|e| anyhow::anyhow!("{url}: {e}"))?
3652        .into_json()?;
3653    let atoms = body
3654        .get_mut("atoms")
3655        .map(Value::take)
3656        .unwrap_or(Value::Array(Vec::new()));
3657    Ok(serde_json::from_value(atoms)?)
3658}
3659
3660pub fn pack() -> Result<PacksetClient> {
3661    load_seat_env();
3662    let workspace = std::env::var("PACKSET_WORKSPACE")
3663        .ok()
3664        .filter(|w| !w.is_empty())
3665        .unwrap_or_else(|| SEAT_WORKSPACE.to_string());
3666    Ok(PacksetClient::from_env()
3667        .context("PACKSET_URL=off: this seat has no pack on purpose")?
3668        .with_workspace(workspace))
3669}
3670
3671/// The pack's last write, RFC 3339, for a HUD watch. `None` when the
3672/// status has no stamp yet.
3673///
3674/// # Errors
3675///
3676/// The pack not answering.
3677pub fn pack_last_write_ts() -> Result<Option<String>> {
3678    let client = pack()?;
3679    let status = client
3680        .status(Some(&client.workspace()))
3681        .context("pack: GET /v1/status failed")?;
3682    Ok(status
3683        .get("last_write_ts")
3684        .and_then(Value::as_str)
3685        .filter(|s| !s.is_empty())
3686        .map(str::to_string))
3687}
3688
3689pub fn join(parts: &[String]) -> String {
3690    parts.join(" ")
3691}
3692
3693/// Remember → lesson, Prefer → preference. Trust rows go through [`trust_atom`].
3694pub fn atom_kind(label: &str) -> Result<&'static str> {
3695    match label {
3696        "Remember" => Ok("lesson"),
3697        "Prefer" => Ok("preference"),
3698        other => bail!("unknown write kind {other}"),
3699    }
3700}
3701
3702/// The entity every write carries: which seat wrote it. Many seats share
3703/// one pack, and a reader can then see whose lesson it is reading.
3704pub const SEAT_ENTITY: &str = "seat:";
3705
3706/// Explicit claim body. The text is stored as given; never harvested. The
3707/// entities open with the seat that wrote it.
3708pub fn atom_body(kind: &str, text: &str, workspace: &str) -> Value {
3709    serde_json::json!({
3710        "schema": "inside.atom/v1",
3711        "kind": kind,
3712        "level": "explicit",
3713        "text": text,
3714        "workspace": workspace,
3715        "entities": [format!("{SEAT_ENTITY}{}", seat_name())],
3716        "source": atom_source(),
3717    })
3718}
3719
3720/// Where a claim was written: the runner, the conversation, the host and,
3721/// when the runner stamped one, the turn. An audit reads a claim's lineage
3722/// here instead of guessing it from its entities.
3723#[must_use]
3724pub fn atom_source() -> Value {
3725    let seat = whoami();
3726    let mut source = serde_json::json!({
3727        "harness": seat.seat,
3728        "session": seat.holder,
3729        "host": sync::host(),
3730        "via": "ljos",
3731    });
3732    let turn = std::env::vars()
3733        .filter(|(k, v)| k.ends_with("_TURN_ID") && !v.trim().is_empty())
3734        .map(|(_, v)| v.trim().to_string())
3735        .next();
3736    if let Some(turn) = turn {
3737        source["turn"] = Value::String(turn);
3738    }
3739    source
3740}
3741
3742/// Add entities to a body without losing the seat's.
3743pub fn add_entities(atom: &mut Value, more: impl IntoIterator<Item = String>) {
3744    let list = atom["entities"]
3745        .as_array_mut()
3746        .map(std::mem::take)
3747        .unwrap_or_default();
3748    let mut list = list;
3749    for e in more {
3750        let v = Value::String(e);
3751        if !list.contains(&v) {
3752            list.push(v);
3753        }
3754    }
3755    atom["entities"] = Value::Array(list);
3756}
3757
3758/// POST one explicit claim. Callers pass Remember/Prefer only.
3759pub fn post_claim(
3760    client: &PacksetClient,
3761    label: &str,
3762    text: &str,
3763    workspace: &str,
3764) -> Result<Value> {
3765    post_claim_horizon(client, label, text, workspace, None)
3766}
3767
3768fn post_claim_horizon(
3769    client: &PacksetClient,
3770    label: &str,
3771    text: &str,
3772    workspace: &str,
3773    transient: Option<bool>,
3774) -> Result<Value> {
3775    let trimmed = text.trim();
3776    if trimmed.is_empty() {
3777        bail!("{label}: empty text is not a claim");
3778    }
3779    let kind = atom_kind(label)?;
3780    let mut atom = atom_body(kind, trimmed, workspace);
3781    stamp_horizon(&mut atom, kind, trimmed, transient);
3782    with_writer(|| {
3783        client
3784            .post_atom(&atom)
3785            .with_context(|| format!("{label}: POST /v1/atoms failed"))
3786    })
3787}
3788
3789/// `horizon:standing` or `horizon:transient` on a claim as it is written.
3790/// A preference is a rule. A lesson is an episode until a recalled review
3791/// or a consolidation promotes it, unless the caller said which it is.
3792fn stamp_horizon(atom: &mut Value, kind: &str, _text: &str, force: Option<bool>) {
3793    let transient = match (kind, force) {
3794        ("preference", _) => false,
3795        (_, Some(flag)) => flag,
3796        _ => true,
3797    };
3798    let tag = if transient {
3799        "horizon:transient"
3800    } else {
3801        "horizon:standing"
3802    };
3803    add_entities(atom, [tag.to_string()]);
3804}
3805
3806pub fn packset_write(label: &str, text: &str) -> Result<Value> {
3807    packset_write_as(label, text, None, None)
3808}
3809
3810/// [`packset_write`] for a lesson learned on an issue: it carries an
3811/// `issue:ID` entity naming where it was learned, and a `scope:NAME`
3812/// entity when one is given, so the claim travels with that scope's log
3813/// rather than the machine's default.
3814///
3815/// # Errors
3816///
3817/// An empty text, an unknown label, or the pack refusing the claim.
3818pub fn packset_write_scoped(
3819    label: &str,
3820    text: &str,
3821    issue: &str,
3822    scope: Option<&str>,
3823) -> Result<Value> {
3824    let client = pack()?;
3825    let workspace = client.workspace();
3826    let trimmed = text.trim();
3827    if trimmed.is_empty() {
3828        bail!("{label}: empty text is not a claim");
3829    }
3830    let kind = atom_kind(label)?;
3831    let mut atom = atom_body(kind, trimmed, &workspace);
3832    let mut tags = vec![format!("issue:{}", issue.trim())];
3833    if let Some(scope) = scope.map(str::trim).filter(|s| !s.is_empty()) {
3834        tags.push(format!("scope:{scope}"));
3835    }
3836    add_entities(&mut atom, tags);
3837    stamp_horizon(&mut atom, kind, trimmed, None);
3838    with_writer(|| {
3839        client
3840            .post_atom(&atom)
3841            .with_context(|| format!("{label}: POST /v1/atoms failed"))
3842    })
3843}
3844
3845/// The entity a persona's own claims carry, so a brief can find them.
3846#[must_use]
3847pub fn persona_entity(name: &str) -> String {
3848    format!("persona:{}", name.trim().to_lowercase())
3849}
3850
3851/// The set a persona's own conclusions live in: `persona-<name>`, in the
3852/// pack's set alphabet. A set is its own tree for the duplicate and
3853/// replacement rules, so a persona's lesson never closes the seat's or
3854/// another persona's, and the seat still reads them all.
3855#[must_use]
3856pub fn persona_set(name: &str) -> String {
3857    let mut out = String::from("persona-");
3858    for c in name.trim().to_lowercase().chars() {
3859        if c.is_ascii_lowercase() || c.is_ascii_digit() {
3860            out.push(c);
3861        } else if !out.ends_with('-') {
3862            out.push('-');
3863        }
3864    }
3865    out.trim_end_matches('-').chars().take(32).collect()
3866}
3867
3868/// [`packset_write`] as a persona: the claim carries the persona's entity,
3869/// so what a persona learned comes back to it first in its next brief and
3870/// stays in the seat's one pack. A persona accumulates its own lessons the
3871/// way a reviewer does; the seat still reads them all.
3872pub fn packset_write_as(
3873    label: &str,
3874    text: &str,
3875    persona: Option<&str>,
3876    transient: Option<bool>,
3877) -> Result<Value> {
3878    let client = pack()?;
3879    let workspace = client.workspace();
3880    let Some(name) = persona.map(str::trim).filter(|n| !n.is_empty()) else {
3881        return post_claim_horizon(&client, label, text, &workspace, transient);
3882    };
3883    let trimmed = text.trim();
3884    if trimmed.is_empty() {
3885        bail!("{label}: empty text is not a claim");
3886    }
3887    let kind = atom_kind(label)?;
3888    let mut atom = atom_body(kind, trimmed, &workspace);
3889    add_entities(&mut atom, [persona_entity(name)]);
3890    stamp_horizon(&mut atom, kind, trimmed, transient);
3891    // Its own tree: the persona's conclusions replace and duplicate among
3892    // themselves, not against the seat's or another persona's.
3893    atom["set"] = Value::String(persona_set(name));
3894    with_writer(|| {
3895        client
3896            .post_atom(&atom)
3897            .with_context(|| format!("{label}: POST /v1/atoms failed"))
3898    })
3899}
3900
3901/// Retire one atom from the workspace the cwd resolves to, optionally naming
3902/// the deed that withdrew it.
3903///
3904/// The daemon tombstones rather than erases: the atom stops being recalled and
3905/// the pack still records that it was held and withdrawn. That is the right
3906/// shape for standing knowledge, where "we no longer believe this" is itself
3907/// worth keeping.
3908///
3909/// `why` is a deed accession and the pack refuses free text in its place. It
3910/// runs the same join as a remembered claim's `entities`, in the same
3911/// direction: the pack cites the deed store, never the other way round. A
3912/// retraction the work justified is therefore checkable with `deedar evidence`
3913/// like any other citation, and one nothing justified simply carries no `why`.
3914///
3915/// # Errors
3916///
3917/// An unset `PACKSET_URL`, an id the workspace does not hold, a `why` that is
3918/// not an accession, or the request's.
3919pub fn packset_forget(id: &str, why: Option<&str>) -> Result<Value> {
3920    let trimmed = id.trim();
3921    if trimmed.is_empty() {
3922        bail!("forget: an atom id is required");
3923    }
3924    let why = why.map(str::trim).filter(|w| !w.is_empty());
3925    let client = pack()?;
3926    let workspace = client.workspace();
3927    client
3928        .delete_atom(&workspace, trimmed, why)
3929        .with_context(|| format!("forget: POST /v1/atoms/delete failed for {trimmed}"))
3930}
3931
3932/// One row of the influence graph: `from` listens to `to` with `weight`.
3933/// `about` scopes the row to the domains it speaks to: a row with none
3934/// applies everywhere, a row with some applies when one of them meets the
3935/// issue at hand (its title, or the entities of the island it activates).
3936#[derive(Debug, Clone, PartialEq, Default)]
3937pub struct Trust {
3938    pub from: String,
3939    pub to: String,
3940    pub weight: f64,
3941    pub about: Vec<String>,
3942}
3943
3944/// A voter with a view of its own: a persona. `anchor` in `[0, 1]` is how
3945/// far it moves off its ballot in a settle; 0 never moves, 1 is a plain
3946/// DeGroot voter. `entities` are the domains it speaks to.
3947#[derive(Debug, Clone, PartialEq)]
3948pub struct Persona {
3949    pub name: String,
3950    pub anchor: f64,
3951    pub view: String,
3952    pub entities: Vec<String>,
3953}
3954
3955/// The `persona` atom for the pack: kind `persona`, the view as text.
3956///
3957/// # Errors
3958///
3959/// An empty name, an anchor outside `[0, 1]`, or an empty view.
3960pub fn persona_atom(p: &Persona, workspace: &str) -> Result<Value> {
3961    let name = p.name.trim();
3962    if name.is_empty() {
3963        bail!("persona: a name is required");
3964    }
3965    if !(0.0..=1.0).contains(&p.anchor) {
3966        bail!("persona: anchor {} is not in [0, 1]", p.anchor);
3967    }
3968    let view = p.view.trim();
3969    if view.is_empty() {
3970        bail!("persona: say in a sentence or two how {name} reads the work");
3971    }
3972    let mut atom = atom_body("persona", view, workspace);
3973    atom["name"] = Value::String(name.into());
3974    atom["anchor"] = serde_json::json!(p.anchor);
3975    if !p.entities.is_empty() {
3976        add_entities(&mut atom, p.entities.iter().map(|e| e.to_lowercase()));
3977    }
3978    Ok(atom)
3979}
3980
3981/// POST one persona. A persona of the same name already in the pack is
3982/// superseded, so a rewrite moves the roster without leaving the old view
3983/// live. Every persona is owed one unscoped inbound trust row; `--about`
3984/// on a later trust row only adds weight, it does not replace that floor.
3985pub fn write_persona(p: &Persona) -> Result<Value> {
3986    let client = pack()?;
3987    let workspace = client.workspace();
3988    let mut atom = persona_atom(p, &workspace)?;
3989    let previous: Vec<Value> = client
3990        .atoms_of_kind(&workspace, "persona")
3991        .unwrap_or_default()
3992        .into_iter()
3993        .filter(|a| a.get("name").and_then(Value::as_str) == Some(p.name.trim()))
3994        .filter_map(|a| {
3995            a.get("id")
3996                .and_then(Value::as_str)
3997                .map(|id| Value::String(id.to_string()))
3998        })
3999        .collect();
4000    if !previous.is_empty() {
4001        atom["supersedes"] = Value::Array(previous);
4002    }
4003    let posted = client
4004        .post_atom(&atom)
4005        .context("persona: POST /v1/atoms failed")?;
4006    ensure_unscoped_inbound(p)?;
4007    Ok(posted)
4008}
4009
4010/// The unscoped inbound row a persona is owed: the seat weighs it at 1,
4011/// everywhere. None when the seat and the persona are the same name
4012/// (a row cannot weigh itself).
4013#[must_use]
4014pub fn inbound_floor(p: &Persona, seat: &str) -> Option<Trust> {
4015    let to = p.name.trim();
4016    let from = seat.trim();
4017    if to.is_empty() || from.is_empty() || from == to {
4018        return None;
4019    }
4020    Some(Trust {
4021        from: from.to_string(),
4022        to: to.to_string(),
4023        weight: 1.0,
4024        about: Vec::new(),
4025    })
4026}
4027
4028/// Whether `name` already has the seat's unscoped inbound row in `rows`.
4029/// A third-party unscoped row does not seat this persona.
4030#[must_use]
4031pub fn has_unscoped_inbound(rows: &[Trust], name: &str, seat: &str) -> bool {
4032    let name = name.trim();
4033    let seat = seat.trim();
4034    rows.iter()
4035        .any(|r| r.from == seat && r.to == name && r.about.is_empty() && r.weight > 0.0)
4036}
4037
4038fn ensure_unscoped_inbound(p: &Persona) -> Result<()> {
4039    let name = p.name.trim();
4040    let seat = seat_name();
4041    if has_unscoped_inbound(&trust_from_pack().unwrap_or_default(), name, &seat) {
4042        return Ok(());
4043    }
4044    let Some(row) = inbound_floor(p, &seat) else {
4045        return Ok(());
4046    };
4047    write_trust(&row, &[]).map(|_| ())
4048}
4049
4050/// The live personas: the latest `persona` atom per name.
4051pub fn personas_of(atoms: &[Value]) -> Vec<Persona> {
4052    let mut latest: std::collections::BTreeMap<String, (String, Persona)> =
4053        std::collections::BTreeMap::new();
4054    for atom in atoms {
4055        if atom.get("kind").and_then(Value::as_str) != Some("persona") {
4056            continue;
4057        }
4058        let (Some(name), Some(anchor)) = (
4059            atom.get("name").and_then(Value::as_str),
4060            atom.get("anchor").and_then(Value::as_f64),
4061        ) else {
4062            continue;
4063        };
4064        let ts = atom
4065            .get("ts")
4066            .and_then(Value::as_str)
4067            .unwrap_or("")
4068            .to_string();
4069        let p = Persona {
4070            name: name.to_string(),
4071            anchor,
4072            view: atom
4073                .get("text")
4074                .and_then(Value::as_str)
4075                .unwrap_or("")
4076                .to_string(),
4077            entities: domains_of(atom.get("entities")),
4078        };
4079        match latest.get(name) {
4080            Some((seen, _)) if *seen > ts => {}
4081            _ => {
4082                latest.insert(name.to_string(), (ts, p));
4083            }
4084        }
4085    }
4086    latest.into_values().map(|(_, p)| p).collect()
4087}
4088
4089/// The personas in the seat's pack.
4090pub fn personas_from_pack() -> Result<Vec<Persona>> {
4091    let client = pack()?;
4092    // One kind, not the pack: a roster of a dozen does not carry every
4093    // lesson's embedding across the socket.
4094    let atoms = client
4095        .atoms_of_kind(&client.workspace(), "persona")
4096        .context("persona: GET /v1/atoms?kind=persona failed")?;
4097    Ok(personas_of(&atoms))
4098}
4099
4100/// A recipe a sitting copies before personas enter. `models` are optional
4101/// spawn hints; every panel still ends in `ljos vote --as` then
4102/// `ljos consensus`.
4103#[derive(Debug, Clone, PartialEq, Eq)]
4104pub struct Playbook {
4105    pub name: String,
4106    pub body: String,
4107    pub models: Vec<String>,
4108}
4109
4110/// The closed set. Write, list, bind, and copy refuse any other name.
4111pub const PLAYBOOK_NAMES: &[&str] = &["sit", "arena", "land", "company-panel", "overnight"];
4112
4113/// The five shipped recipes. Kind `playbook`, weighed not recalled.
4114pub const SHIPPED_PLAYBOOK_NAMES: &[&str] = PLAYBOOK_NAMES;
4115
4116/// Five named principles, invocable mid-sitting, mapped onto existing law.
4117pub const PRINCIPLES: &str = "\
4118== principles
4119split-fence: independent implementers, independent trees. A's fence stays: no second plugin, no poteto-mode, no Benny, musl CLI iced-free, `ljos vote --as` and DeGroot stay.
4120prove-on-real-surface: measure on the host the users run. A cheaper substitute is not the result.
4121open-sibling-first: a second implementer opens a sibling leftover, not a rewrite of the first tree.
4122arena-then-compose: designs write scratch; the host writes a rubric on a compose child; personas vote the compose `--as`.
4123one-step-delegate: a subagent is one playbook step. No resume across phases. A new task is a new sitting.
4124";
4125
4126/// The scoring sheet a compose is voted on. Personas vote the compose, not
4127/// accept-at-most-one on the designs.
4128pub const RUBRIC: &str = "\
4129== rubric
41301. Ledger intact. `ljos vote --as` and DeGroot stay. No schema_yes, no BARMA, no host for-loop of accepts.
41312. Playbook before panel. Sitting names one recipe and copies it before personas enter.
41323. Rubric in brief. `ljos brief` carries the playbook step, these principles, and this sheet.
41334. One-step delegate. Subagent = one playbook step. No resume across phases.
41345. Unscoped inbound trust. Every panel persona has one unscoped inbound row; `--about` only adds weight.
41356. No second plugin. Do not copy 47 skills, poteto-mode, Benny, or Cursor model files.
41367. Small surface. Prefer pack atoms and brief fields over a new crate. Musl CLI stays iced-free.
41378. Named principles. Five families, invocable mid-sitting, mapped onto existing law (split-fence, prove-on-real-surface, open-sibling-first, arena-then-compose, one-step-delegate).
4138";
4139
4140const SIT_BODY: &str = "\
4141A sitting on one issue. Name this recipe at open (`ljos sitting ISSUE --playbook sit` or `ljos playbook ISSUE sit`). The sitting prints this body before recall and holds the name until finish or release.
4142
41431. Open with `ljos sitting ISSUE --playbook sit`. Read doctor, cards, due, island, this recipe, recall, timeline, claim.
41442. Grade due claims (`ljos graded ID`).
41453. Do the work on this claim only. Artefacts are deeds, then `ljos deed ISSUE --add ACCESSION`. Lessons are `ljos remember` in two sentences.
41464. One playbook step is the whole sitting. A subagent takes this recipe and this issue; it does not resume a later phase.
41475. Close with `ljos finish ISSUE --lesson \"...\"`. Completing the node does not close the ticket. `ljos finish ISSUE --close` does, when the work is accepted.
4148";
4149
4150const ARENA_BODY: &str = "\
4151Designs compete; the host writes a rubric; personas vote a compose, not the designs.
4152
41531. Bind this recipe: `ljos sitting ISSUE --playbook arena` or `ljos playbook ISSUE arena`.
41542. Each design writes scratch (summary and body). Do not vote the design children as accept-at-most-one.
41553. The host writes a compose child and a rubric with named axes. Personas vote the compose `--as`.
41564. Spawn hints are optional model-family names on this atom. Each subagent still ends with `ljos vote ISSUE --for accept|reject --as NAME`. No graft. PASS on an axis is not GREEN.
41575. `ljos consensus ISSUE` settles under trust rows and DeGroot. `ljos vote --as` stays.
4158";
4159
4160const LAND_BODY: &str = "\
4161Land a chosen design on the real surface.
4162
41631. Bind `land`. Sitting copies this body before recall.
41642. Prove on the real surface: the host the users run, the crate they install. A cheaper substitute is not the result.
41653. Keep A's fence: no 47 skills, no poteto-mode, no Benny, musl iced-free, `ljos vote --as` and DeGroot stay.
41664. One step per subagent. Open a sibling first when a second implementer is in flight.
41675. Close with finish. Do not ship a count as consensus.
4168";
4169
4170const COMPANY_PANEL_BODY: &str = "\
4171A panel of personas on one bound recipe.
4172
41731. Bind `company-panel` before any persona enters. `ljos panel` refuses if none is bound.
41742. Every persona has one unscoped inbound trust row; `--about` only adds weight.
41753. `ljos brief NAME ISSUE` reprints this recipe in full, the five named principles, and the arena rubric.
41764. One subagent per persona, optional model-family spawn hints. Each casts `ljos vote ISSUE --for OPTION --expect OPTION --as NAME`. `--expect` is the private forecast of the others, for the surprisingly popular reading. Then `ljos consensus ISSUE`.
41775. Do not resume across phases. A new task is a new sitting.
4178";
4179
4180const OVERNIGHT_BODY: &str = "\
4181Drive work while unattended, still one sitting.
4182
41831. Bind `overnight`. Name a checkable finish condition on the issue.
41842. One playbook step per subagent. No session-pickup, no resume across phases.
41853. Isolated worktree. Prove on the real surface before claiming done.
41864. Decision log is tracker notes and deeds, not a second ledger.
41875. `ljos finish` when the condition holds; otherwise `ljos release` and a new sitting.
4188";
4189
4190/// The five shipped playbooks, bodies in full, model roles as spawn hints.
4191#[must_use]
4192pub fn shipped_playbooks() -> Vec<Playbook> {
4193    vec![
4194        Playbook {
4195            name: "sit".into(),
4196            body: SIT_BODY.trim().into(),
4197            models: Vec::new(),
4198        },
4199        Playbook {
4200            name: "arena".into(),
4201            body: ARENA_BODY.trim().into(),
4202            models: vec!["judgment".into(), "instruction".into(), "fast".into()],
4203        },
4204        Playbook {
4205            name: "land".into(),
4206            body: LAND_BODY.trim().into(),
4207            models: Vec::new(),
4208        },
4209        Playbook {
4210            name: "company-panel".into(),
4211            body: COMPANY_PANEL_BODY.trim().into(),
4212            models: vec!["judgment".into(), "instruction".into()],
4213        },
4214        Playbook {
4215            name: "overnight".into(),
4216            body: OVERNIGHT_BODY.trim().into(),
4217            models: Vec::new(),
4218        },
4219    ]
4220}
4221
4222/// Refuse a name that is not in [`PLAYBOOK_NAMES`].
4223///
4224/// # Errors
4225///
4226/// An unknown name.
4227pub fn parse_playbook_name(name: &str) -> Result<&'static str> {
4228    let n = name.trim();
4229    if n.is_empty() {
4230        bail!(
4231            "playbook: a name is required ({})",
4232            PLAYBOOK_NAMES.join(", ")
4233        );
4234    }
4235    PLAYBOOK_NAMES
4236        .iter()
4237        .copied()
4238        .find(|k| *k == n)
4239        .ok_or_else(|| {
4240            anyhow::anyhow!(
4241                "playbook: unknown name {n:?}; the closed set is {}",
4242                PLAYBOOK_NAMES.join(", ")
4243            )
4244        })
4245}
4246
4247/// The `playbook` atom: kind `playbook`, the recipe as text.
4248///
4249/// # Errors
4250///
4251/// An unknown name or an empty body.
4252pub fn playbook_atom(p: &Playbook, workspace: &str) -> Result<Value> {
4253    let name = parse_playbook_name(&p.name)?;
4254    let body = p.body.trim();
4255    if body.is_empty() {
4256        bail!("playbook: {name} needs a recipe body");
4257    }
4258    let mut atom = atom_body("playbook", body, workspace);
4259    atom["name"] = Value::String(name.into());
4260    if !p.models.is_empty() {
4261        atom["models"] = Value::Array(
4262            p.models
4263                .iter()
4264                .map(|m| m.trim())
4265                .filter(|m| !m.is_empty())
4266                .map(|m| Value::String(m.to_string()))
4267                .collect(),
4268        );
4269    }
4270    Ok(atom)
4271}
4272
4273/// POST one playbook. A playbook of the same name already in the pack is
4274/// superseded, so a rewrite moves the recipe without leaving the old body
4275/// live.
4276pub fn write_playbook(p: &Playbook) -> Result<Value> {
4277    let client = pack()?;
4278    let workspace = client.workspace();
4279    let mut atom = playbook_atom(p, &workspace)?;
4280    let previous: Vec<Value> = client
4281        .atoms_of_kind(&workspace, "playbook")
4282        .unwrap_or_default()
4283        .into_iter()
4284        .filter(|a| a.get("name").and_then(Value::as_str) == Some(p.name.trim()))
4285        .filter_map(|a| {
4286            a.get("id")
4287                .and_then(Value::as_str)
4288                .map(|id| Value::String(id.to_string()))
4289        })
4290        .collect();
4291    if !previous.is_empty() {
4292        atom["supersedes"] = Value::Array(previous);
4293    }
4294    client
4295        .post_atom(&atom)
4296        .context("playbook: POST /v1/atoms failed")
4297}
4298
4299/// The live playbooks: the latest `playbook` atom per name.
4300pub fn playbooks_of(atoms: &[Value]) -> Vec<Playbook> {
4301    let mut latest: std::collections::BTreeMap<String, (String, Playbook)> =
4302        std::collections::BTreeMap::new();
4303    for atom in atoms {
4304        if atom.get("kind").and_then(Value::as_str) != Some("playbook") {
4305            continue;
4306        }
4307        let Some(name) = atom.get("name").and_then(Value::as_str) else {
4308            continue;
4309        };
4310        if parse_playbook_name(name).is_err() {
4311            continue;
4312        }
4313        let ts = atom
4314            .get("ts")
4315            .and_then(Value::as_str)
4316            .unwrap_or("")
4317            .to_string();
4318        let p = Playbook {
4319            name: name.to_string(),
4320            body: atom
4321                .get("text")
4322                .and_then(Value::as_str)
4323                .unwrap_or("")
4324                .to_string(),
4325            models: atom
4326                .get("models")
4327                .and_then(Value::as_array)
4328                .into_iter()
4329                .flatten()
4330                .filter_map(Value::as_str)
4331                .map(str::to_string)
4332                .collect(),
4333        };
4334        match latest.get(name) {
4335            Some((seen, _)) if *seen > ts => {}
4336            _ => {
4337                latest.insert(name.to_string(), (ts, p));
4338            }
4339        }
4340    }
4341    latest.into_values().map(|(_, p)| p).collect()
4342}
4343
4344fn ensure_shipped_playbooks() {
4345    let have = pack()
4346        .ok()
4347        .and_then(|c| c.atoms_of_kind(&c.workspace(), "playbook").ok())
4348        .map(|atoms| playbooks_of(&atoms))
4349        .unwrap_or_default();
4350    for p in shipped_playbooks() {
4351        if have.iter().any(|h| h.name == p.name) {
4352            continue;
4353        }
4354        let _ = write_playbook(&p);
4355    }
4356}
4357
4358/// The roster: pack atoms, with the five shipped filled in when missing.
4359pub fn playbooks_from_pack() -> Result<Vec<Playbook>> {
4360    ensure_shipped_playbooks();
4361    let client = pack()?;
4362    let atoms = client
4363        .atoms_of_kind(&client.workspace(), "playbook")
4364        .context("playbook: GET /v1/atoms?kind=playbook failed")?;
4365    let mut got = playbooks_of(&atoms);
4366    for p in shipped_playbooks() {
4367        if !got.iter().any(|g| g.name == p.name) {
4368            got.push(p);
4369        }
4370    }
4371    got.sort_by(|a, b| a.name.cmp(&b.name));
4372    Ok(got)
4373}
4374
4375/// Pack latest for `name`, else the shipped seed. Unknown names are refused
4376/// even when the pack holds them.
4377///
4378/// # Errors
4379///
4380/// An unknown name; the error lists the closed set.
4381pub fn playbook_among(name: &str, pack: &[Playbook]) -> Result<Playbook> {
4382    let name = parse_playbook_name(name)?;
4383    if let Some(p) = pack.iter().find(|p| p.name == name) {
4384        return Ok(p.clone());
4385    }
4386    shipped_playbooks()
4387        .into_iter()
4388        .find(|p| p.name == name)
4389        .ok_or_else(|| {
4390            anyhow::anyhow!(
4391                "playbook: unknown name {name:?}; the closed set is {}",
4392                PLAYBOOK_NAMES.join(", ")
4393            )
4394        })
4395}
4396
4397/// Look up one playbook by name: pack latest first, shipped seed only when
4398/// the pack has no live atom of that name.
4399///
4400/// # Errors
4401///
4402/// Unknown name; the error lists the closed set.
4403pub fn playbook_named(name: &str) -> Result<Playbook> {
4404    let pack = playbooks_from_pack().unwrap_or_default();
4405    playbook_among(name, &pack)
4406}
4407
4408/// The recipe body a sitting copies, including optional spawn hints.
4409#[must_use]
4410pub fn format_playbook_copy(p: &Playbook) -> String {
4411    let mut out = format!("{}\n{}\n", p.name, p.body.trim());
4412    if !p.models.is_empty() {
4413        out.push_str("spawn hints (optional): ");
4414        out.push_str(&p.models.join(", "));
4415        out.push_str("; each subagent still ends with `ljos vote --as` then `ljos consensus`.\n");
4416    }
4417    out
4418}
4419
4420/// The roster, one playbook per line: name, spawn hints, first sentence.
4421#[must_use]
4422pub fn format_playbooks(playbooks: &[Playbook]) -> String {
4423    if playbooks.is_empty() {
4424        return "no playbooks; the shipped recipes are sit, arena, land, company-panel, overnight\n"
4425            .to_string();
4426    }
4427    let width = playbooks.iter().map(|p| p.name.len()).max().unwrap_or(0);
4428    playbooks
4429        .iter()
4430        .map(|p| {
4431            let first = p
4432                .body
4433                .split_once('.')
4434                .map(|(s, _)| s.trim())
4435                .unwrap_or(p.body.trim());
4436            format!(
4437                "{:width$}  {}  {}\n",
4438                p.name,
4439                if p.models.is_empty() {
4440                    "no spawn hints".to_string()
4441                } else {
4442                    format!("hints {}", p.models.join(", "))
4443                },
4444                first
4445            )
4446        })
4447        .collect()
4448}
4449
4450/// A tracker logbook note that binds a playbook name to an issue. Latest
4451/// such note wins; empty rest is the sitting-scoped drop finish/release write.
4452pub const PLAYBOOK_NOTE_PREFIX: &str = "playbook:";
4453
4454fn playbook_key(issue: &str) -> String {
4455    issue
4456        .trim()
4457        .chars()
4458        .map(|c| {
4459            if c.is_ascii_alphanumeric() || c == '-' {
4460                c
4461            } else {
4462                '_'
4463            }
4464        })
4465        .collect()
4466}
4467
4468fn playbook_bind_path(issue: &str) -> PathBuf {
4469    runtime_dir().join(format!("playbook-{}", playbook_key(issue)))
4470}
4471
4472fn cached_playbook(issue: &str) -> Option<String> {
4473    let text = std::fs::read_to_string(playbook_bind_path(issue)).ok()?;
4474    let name = text.trim();
4475    if name.is_empty() {
4476        None
4477    } else {
4478        Some(name.to_string())
4479    }
4480}
4481
4482fn write_playbook_cache(issue: &str, name: &str) -> Result<()> {
4483    let path = playbook_bind_path(issue);
4484    if let Some(dir) = path.parent() {
4485        let _ = std::fs::create_dir_all(dir);
4486    }
4487    std::fs::write(&path, format!("{name}\n"))
4488        .with_context(|| format!("playbook: could not bind {name} on {issue}"))
4489}
4490
4491/// The playbook name bound on an issue JSON: the latest logbook note that
4492/// opens with [`PLAYBOOK_NOTE_PREFIX`]. Empty rest means this sitting dropped
4493/// it; do not walk back to an earlier bind.
4494#[must_use]
4495pub fn playbook_name_from_issue(v: &Value) -> Option<String> {
4496    let mut dated: Vec<(String, Option<String>)> = Vec::new();
4497    for e in v["logbook"].as_array().into_iter().flatten() {
4498        let Some(note) = e["note"].as_str() else {
4499            continue;
4500        };
4501        let Some(rest) = note.trim().strip_prefix(PLAYBOOK_NOTE_PREFIX) else {
4502            continue;
4503        };
4504        let name = rest.trim();
4505        let live = if name.is_empty() {
4506            None
4507        } else {
4508            Some(name.to_string())
4509        };
4510        let ts = e["timestamp"].as_str().unwrap_or("").to_string();
4511        dated.push((ts, live));
4512    }
4513    if dated.iter().any(|(ts, _)| !ts.is_empty()) {
4514        dated
4515            .into_iter()
4516            .max_by_key(|(ts, _)| ts.clone())
4517            .and_then(|(_, n)| n)
4518    } else {
4519        dated.into_iter().next().and_then(|(_, n)| n)
4520    }
4521}
4522
4523/// The playbook name bound on a tracker issue, if any.
4524///
4525/// # Errors
4526///
4527/// The tracker not answering.
4528pub fn playbook_named_on(issue: &str) -> Result<Option<String>> {
4529    let said = run_captured("vissue", &["show", issue, "--json"])?;
4530    let v: Value = serde_json::from_str(&said.stdout).context("vissue show --json")?;
4531    Ok(playbook_name_from_issue(&v))
4532}
4533
4534/// The playbook name this sitting holds, if one was bound. Tracker note is
4535/// the bind that survives the process; the runtime cache is only when the
4536/// tracker does not answer.
4537#[must_use]
4538pub fn bound_playbook(issue: &str) -> Option<String> {
4539    match playbook_named_on(issue) {
4540        Ok(name) => name,
4541        Err(_) => cached_playbook(issue),
4542    }
4543}
4544
4545/// Drop the sticky name. Finish and release call this; a new task is a
4546/// new sitting. Writes an empty `playbook:` note so the next sitting does
4547/// not reprint the previous recipe, and unlinks the runtime cache.
4548pub fn drop_playbook(issue: &str) {
4549    if bound_playbook(issue).is_some() {
4550        let _ = run_captured("vissue", &["note", issue, PLAYBOOK_NOTE_PREFIX]);
4551    }
4552    let _ = std::fs::remove_file(playbook_bind_path(issue));
4553}
4554
4555/// Hold `name` on `issue` until finish or release. A different name while
4556/// one is held is refused: mid-sitting turns re-read the same note.
4557///
4558/// # Errors
4559///
4560/// Empty issue or name, or a different recipe already bound.
4561pub fn bind_playbook(issue: &str, name: &str) -> Result<()> {
4562    let issue = issue.trim();
4563    let name = name.trim();
4564    if issue.is_empty() {
4565        bail!("playbook: an issue is required");
4566    }
4567    if name.is_empty() {
4568        bail!("playbook: a name is required");
4569    }
4570    let name = parse_playbook_name(name)?;
4571    if let Some(have) = bound_playbook(issue) {
4572        if have != name {
4573            bail!(
4574                "playbook: {issue} is bound to {have} until finish or release; \
4575                 a new task is a new sitting"
4576            );
4577        }
4578        let _ = write_playbook_cache(issue, name);
4579        return Ok(());
4580    }
4581    let note = format!("{PLAYBOOK_NOTE_PREFIX} {name}");
4582    match run_captured("vissue", &["note", issue, &note]) {
4583        Ok(_) => {
4584            let _ = write_playbook_cache(issue, name);
4585            Ok(())
4586        }
4587        Err(_) => write_playbook_cache(issue, name),
4588    }
4589}
4590
4591/// Bind `name` to `issue` and return the full recipe body. This is the
4592/// copy into the working set; sitting prints it before recall.
4593pub fn copy_playbook(issue: &str, name: &str) -> Result<String> {
4594    let p = playbook_named(name)?;
4595    bind_playbook(issue, &p.name)?;
4596    Ok(format_playbook_copy(&p))
4597}
4598
4599/// A closed-set name the issue title names, else `sit`. Longer names win
4600/// (`company-panel` before a stray `sit` token); `sitting` is not `sit`.
4601#[must_use]
4602pub fn playbook_from_title(title: &str) -> &'static str {
4603    let tokens: Vec<String> = title
4604        .to_lowercase()
4605        .split(|c: char| !c.is_ascii_alphanumeric() && c != '-')
4606        .filter(|s| !s.is_empty())
4607        .map(str::to_string)
4608        .collect();
4609    let mut names: Vec<&'static str> = PLAYBOOK_NAMES.to_vec();
4610    names.sort_by_key(|n| std::cmp::Reverse(n.len()));
4611    for name in names {
4612        if tokens.iter().any(|t| t == name) {
4613            return name;
4614        }
4615    }
4616    "sit"
4617}
4618
4619/// Which playbook a sitting copies: an explicit name, else the name already
4620/// bound on the issue (sticky until finish/release), else a closed-set
4621/// token in the title, else `sit`.
4622///
4623/// # Errors
4624///
4625/// An unknown explicit name.
4626pub fn resolve_sitting_playbook(issue: &str, title: &str, asked: Option<&str>) -> Result<String> {
4627    if let Some(name) = asked.map(str::trim).filter(|n| !n.is_empty()) {
4628        return Ok(playbook_named(name)?.name);
4629    }
4630    if let Some(name) = bound_playbook(issue) {
4631        return Ok(name);
4632    }
4633    Ok(playbook_from_title(title).to_string())
4634}
4635
4636/// The `== playbook` section of a sitting: bind when a name is given,
4637/// else reprint the sticky body, else say none is bound.
4638pub fn playbook_opening(issue: &str, name: Option<&str>) -> Result<String> {
4639    match name.map(str::trim).filter(|n| !n.is_empty()) {
4640        Some(n) => copy_playbook(issue, n),
4641        None => match bound_playbook(issue) {
4642            Some(have) => {
4643                let p = playbook_named(&have)?;
4644                Ok(format_playbook_copy(&p))
4645            }
4646            None => Ok("none bound; `ljos sitting ISSUE --playbook NAME` or \
4647                 `ljos playbook ISSUE NAME` names one. A panel is refused until then.\n"
4648                .to_string()),
4649        },
4650    }
4651}
4652
4653/// The three blocks a brief carries: playbook step (full body), named
4654/// principles, arena rubric.
4655#[must_use]
4656pub fn brief_playbook_blocks(issue: &str) -> String {
4657    let copy = match bound_playbook(issue) {
4658        Some(name) => playbook_named(&name)
4659            .map(|p| format_playbook_copy(&p))
4660            .unwrap_or_else(|e| format!("{e}\n")),
4661        None => {
4662            "none bound; `ljos playbook ISSUE NAME` names one before personas enter.\n".to_string()
4663        }
4664    };
4665    format!("== playbook\n{copy}\n{PRINCIPLES}\n{RUBRIC}")
4666}
4667
4668/// The brief a subagent playing a persona starts from: the persona's view
4669/// and domains, what the seat knows on those domains (preferences first),
4670/// and the issue's working set. One text, so a panel member reads the
4671/// same seat the rest do and still reads it its own way.
4672///
4673/// # Errors
4674///
4675/// No such persona in the pack, or the tracker or pack not answering.
4676pub fn brief(name: &str, issue: &str) -> Result<String> {
4677    let personas = personas_from_pack()?;
4678    let Some(p) = personas.iter().find(|p| p.name == name) else {
4679        let names: Vec<&str> = personas.iter().map(|p| p.name.as_str()).collect();
4680        bail!(
4681            "brief: no persona {name:?} in the pack; the pack holds {}",
4682            if names.is_empty() {
4683                "none".to_string()
4684            } else {
4685                names.join(", ")
4686            }
4687        );
4688    };
4689    let mut out = format!(
4690        "You are {}. {}\nYou hold your ballot at anchor {:.2}{}.\n\n{}",
4691        p.name,
4692        p.view,
4693        p.anchor,
4694        if p.entities.is_empty() {
4695            String::new()
4696        } else {
4697            format!("; you speak to {}", p.entities.join(", "))
4698        },
4699        brief_playbook_blocks(issue)
4700    );
4701    let mut seen = std::collections::BTreeSet::new();
4702    let mut lines = Vec::new();
4703    let now = now_utc();
4704    // What this persona remembered itself comes first: its own lessons,
4705    // written with `remember --as`, carry its entity.
4706    let client = pack()?;
4707    let own_tag = persona_entity(&p.name);
4708    // Its own set first; lessons written before sets carry the entity alone.
4709    let mut pool = client
4710        .atoms_in_set(&client.workspace(), &persona_set(&p.name))
4711        .unwrap_or_default();
4712    if let Ok(all) = client.atoms_of_kind(&client.workspace(), "lesson") {
4713        pool.extend(
4714            all.into_iter()
4715                .filter(|a| words_of(a.get("entities")).contains(&own_tag))
4716                .filter(|a| a.get("set").is_none()),
4717        );
4718    }
4719    {
4720        let atoms = pool;
4721        let mut own: Vec<&Value> = atoms.iter().filter(|a| reviewable(a)).collect();
4722        own.sort_by(|a, b| b["ts"].as_str().cmp(&a["ts"].as_str()));
4723        if !own.is_empty() {
4724            out.push_str("\nWhat you remembered yourself:\n");
4725            for a in own.iter().take(8) {
4726                if let Some(id) = a["id"].as_str() {
4727                    seen.insert(id.to_string());
4728                }
4729                out.push_str(&format!(
4730                    "- [{}{}] {}\n",
4731                    a["kind"].as_str().unwrap_or("claim"),
4732                    age_tag(a["ts"].as_str(), &now),
4733                    a["text"].as_str().unwrap_or("").trim()
4734                ));
4735            }
4736        }
4737    }
4738    let cues: Vec<String> = if p.entities.is_empty() {
4739        vec![issue_title(issue)?]
4740    } else {
4741        p.entities.clone()
4742    };
4743    for cue in &cues {
4744        let Ok(hits) = packset_search(cue) else {
4745            continue;
4746        };
4747        for h in hits.into_iter().take(5) {
4748            if UNREVIEWED_KINDS.contains(&h.kind.as_str()) {
4749                continue;
4750            }
4751            if let Some(id) = &h.id {
4752                if !seen.insert(id.clone()) {
4753                    continue;
4754                }
4755            }
4756            lines.push((h.kind == "preference", hit_line(&h, &now)));
4757        }
4758    }
4759    lines.sort_by_key(|row| std::cmp::Reverse(row.0));
4760    if !lines.is_empty() {
4761        out.push_str("\nWhat this seat knows on your domains:\n");
4762        for (_, l) in lines.iter().take(8) {
4763            out.push_str(l);
4764            out.push('\n');
4765        }
4766    }
4767    out.push_str("\nThe work:\n");
4768    out.push_str(&run_captured("vissue", &["recall", issue])?.stdout);
4769    out.push_str(&format!(
4770        "\nWalk the island as yourself before the ballot: `ljos island` on the work with `--as {}`. \
4771         The number on a row is spread along your links, not a rank of what is true. \
4772         Pass `--fire` only after you have used that island. Fire rewrites your weights, not the seat's, and the next walk of the same cue follows them. \
4773         End with one ballot: `ljos vote {{issue}} --for OPTION --expect OPTION --confidence P --used deed-... --as {}`. \
4774         --expect is what you think the others will pick, or a JSON object of option to share; the surprisingly popular reading needs that forecast on the same command. \
4775         P is the probability you give that your own choice is the outcome. \
4776         --used none records that the ballot drew on no deed. \
4777         The line it prints is a count. `ljos consensus {{issue}}` is the settle. \
4778         A lesson of your own goes in with `ljos remember --as {} \"...\"`.\n",
4779        p.name, p.name, p.name
4780    ));
4781    Ok(out)
4782}
4783
4784/// A panel for a runner with no MCP: one brief per persona written to
4785/// `out`, named `<persona>.md`, and the lines that run it. A runner starts
4786/// one subagent per file, each ends with the ballot its brief names, and
4787/// `ljos consensus ISSUE` settles.
4788///
4789/// # Errors
4790///
4791/// No personas in the pack, or a brief that cannot be written.
4792/// The personas that speak to an issue: those whose domains meet the
4793/// words of its title or the entities of the island it activates. A pack
4794/// shared by many projects holds reviewers for all of them, and a panel on
4795/// a docs ticket does not want the CUDA reviewer. None matching, all sit.
4796#[must_use]
4797/// The roster, one persona per line: name, anchor, the domains it speaks
4798/// to, its view. Empty pack: one line saying how to write the first one.
4799pub fn format_personas(personas: &[Persona]) -> String {
4800    if personas.is_empty() {
4801        return "no personas; `ljos persona NAME --anchor A --view \"...\" --about DOMAIN` writes one\n"
4802            .to_string();
4803    }
4804    let width = personas.iter().map(|p| p.name.len()).max().unwrap_or(0);
4805    personas
4806        .iter()
4807        .map(|p| {
4808            format!(
4809                "{:width$}  anchor {:.2}  {}  {}\n",
4810                p.name,
4811                p.anchor,
4812                if p.entities.is_empty() {
4813                    "about anything".to_string()
4814                } else {
4815                    format!("about {}", p.entities.join(", "))
4816                },
4817                p.view
4818            )
4819        })
4820        .collect()
4821}
4822
4823/// A sync scope stamped on a persona, not a topic it speaks to.
4824/// Matching on it seats the whole roster, because the scope is shared.
4825fn is_scope_marker(word: &str) -> bool {
4826    word.to_lowercase().starts_with("sync:")
4827}
4828
4829pub fn personas_speaking_to(personas: &[Persona], words: &[String]) -> Vec<Persona> {
4830    let words: Vec<String> = words
4831        .iter()
4832        .map(|w| w.to_lowercase())
4833        .filter(|w| !is_scope_marker(w))
4834        .collect();
4835    let speaking: Vec<Persona> = personas
4836        .iter()
4837        .filter(|p| {
4838            p.entities.iter().any(|d| {
4839                let d = d.to_lowercase();
4840                !is_scope_marker(&d) && words.iter().any(|w| w == &d)
4841            })
4842        })
4843        .cloned()
4844        .collect();
4845    if !speaking.is_empty() {
4846        return speaking;
4847    }
4848    // No domain matched. Personas with no domains speak to every issue.
4849    // Specialists stay seated out: seating the whole pack is a count.
4850    let general: Vec<Persona> = personas
4851        .iter()
4852        .filter(|p| p.entities.is_empty())
4853        .cloned()
4854        .collect();
4855    if !general.is_empty() {
4856        return general;
4857    }
4858    // A pack of specialists only: seat the few whose own view uses the
4859    // issue's words most, so a decision still has voters with a view on it.
4860    let mut ranked: Vec<(usize, &Persona)> = personas
4861        .iter()
4862        .map(|p| {
4863            let view = p.view.to_lowercase();
4864            let hits = words
4865                .iter()
4866                .filter(|w| w.chars().count() > 3 && view.contains(w.as_str()))
4867                .count();
4868            (hits, p)
4869        })
4870        .filter(|(hits, _)| *hits > 0)
4871        .collect();
4872    ranked.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name)));
4873    ranked
4874        .into_iter()
4875        .take(PANEL_BY_VIEW)
4876        .map(|(_, p)| p.clone())
4877        .collect()
4878}
4879
4880/// How many specialists a panel seats by their views when no domain and no
4881/// generalist speaks to the issue.
4882pub const PANEL_BY_VIEW: usize = 5;
4883
4884/// The words an issue speaks in: its title's topic words, its tags, and
4885/// the entities of the island its title activates when that island is not
4886/// weak.
4887pub fn issue_words(issue: &str) -> Vec<String> {
4888    let title = issue_title(issue).unwrap_or_default();
4889    let mut words = topic_words(&title);
4890    // The tags the issue's author chose name its domains outright.
4891    if let Ok(v) = tracker_show_json(issue) {
4892        words.extend(tags_of(&v));
4893    }
4894    // A weak island is the pack's best-connected cluster, not what the title
4895    // is about: its entities seated five course reviewers on a question
4896    // about syncing memory. Only an island two scorers agreed on speaks.
4897    if packset_island(&title, false).is_ok_and(|i| !i["weak"].as_bool().unwrap_or(false)) {
4898        words.extend(island_entities(issue).unwrap_or_default());
4899    }
4900    words
4901}
4902
4903/// An issue's tags from its tracker record, lower-cased.
4904fn tags_of(v: &Value) -> Vec<String> {
4905    v["tags"]
4906        .as_array()
4907        .into_iter()
4908        .flatten()
4909        .filter_map(Value::as_str)
4910        .map(str::to_lowercase)
4911        .collect()
4912}
4913
4914pub fn panel(issue: &str, out: &Path) -> Result<String> {
4915    if bound_playbook(issue).is_none() {
4916        bail!(
4917            "panel: no playbook bound on {issue}; `ljos playbook {issue} NAME` or \
4918             `ljos sitting {issue} --playbook NAME` names one before personas enter"
4919        );
4920    }
4921    let all = personas_from_pack()?;
4922    if all.is_empty() {
4923        bail!("panel: the pack holds no personas; `ljos persona NAME --anchor A --view ...` writes one");
4924    }
4925    let words = issue_words(issue);
4926    let personas = personas_speaking_to(&all, &words);
4927    if personas.is_empty() {
4928        bail!(
4929            "panel: none of the {} personas speaks to {issue}: none holds its words ({}) as a \
4930             domain or in its view. Tag the issue with a domain a persona holds, or write the \
4931             briefs by hand with `ljos brief NAME {issue}`",
4932            all.len(),
4933            words.join(", ")
4934        );
4935    }
4936    std::fs::create_dir_all(out)?;
4937    let mut lines = vec![format!(
4938        "{} of {} personas speak to {issue}; briefs in {}; start one subagent per file, each ends with its ballot, then:",
4939        personas.len(),
4940        all.len(),
4941        out.display()
4942    )];
4943    for p in &personas {
4944        let path = out.join(format!("{}.md", p.name));
4945        std::fs::write(&path, brief(&p.name, issue)?)?;
4946        lines.push(format!("  {}", path.display()));
4947    }
4948    lines.push(format!("ljos consensus {issue}"));
4949    Ok(lines.join("\n") + "\n")
4950}
4951
4952/// The options an issue puts to a vote: an `Options: A, B` line split on
4953/// commas, or the `- a` bullets under a bare `Options:` line.
4954#[must_use]
4955pub fn issue_options(body: &str) -> Vec<String> {
4956    let mut lines = body.lines().map(str::trim);
4957    while let Some(line) = lines.next() {
4958        let Some(rest) = line.strip_prefix("Options:") else {
4959            continue;
4960        };
4961        let rest = rest.trim();
4962        let options: Vec<String> = if rest.is_empty() {
4963            lines
4964                .by_ref()
4965                .map_while(|l| l.strip_prefix("- ").or_else(|| l.strip_prefix("+ ")))
4966                .map(|o| o.trim().to_string())
4967                .collect()
4968        } else {
4969            rest.split(',').map(|o| o.trim().to_string()).collect()
4970        };
4971        let options: Vec<String> = options.into_iter().filter(|o| !o.is_empty()).collect();
4972        if options.len() >= 2 {
4973            return options;
4974        }
4975    }
4976    Vec::new()
4977}
4978
4979/// Jev's answer for a persona on an issue, not yet cast: its brief, less
4980/// the closing instructions a subagent needs, is the state, and the
4981/// issue's options are the choices.
4982///
4983/// # Errors
4984///
4985/// No such persona, an issue without two options, or Jev off or not
4986/// answering.
4987pub fn jev_ballot(name: &str, issue: &str) -> Result<jev::Ballot> {
4988    let v = tracker_show_json(issue)?;
4989    let options = issue_options(v["body"].as_str().unwrap_or(""));
4990    if options.len() < 2 {
4991        bail!("vote --jev: {issue} has no `Options: A, B` line with two options or more");
4992    }
4993    let full = brief(name, issue)?;
4994    let state = full
4995        .split("\nWalk the island as yourself")
4996        .next()
4997        .unwrap_or(&full);
4998    let state: String = state.chars().take(JEV_BRIEF_CHARS).collect();
4999    let state = format!("{state}\nOptions: {}\n", options.join(", "));
5000    jev::ballot(name, issue, &state, &options).with_context(|| {
5001        format!(
5002            "vote --jev: Jev did not answer (off, no key, over the month's cap, or past its budget); \
5003             `ljos brief {name} {issue}` starts a subagent instead"
5004        )
5005    })
5006}
5007
5008fn odds(m: &std::collections::BTreeMap<String, f64>) -> String {
5009    m.iter()
5010        .map(|(k, p)| format!("{k} {p:.2}"))
5011        .collect::<Vec<_>>()
5012        .join(", ")
5013}
5014
5015/// Cast Jev's ballot as the persona: the chosen option's probability is
5016/// the ballot's confidence, the forecast is its prediction, and a note on
5017/// the issue says the ballot came from Jev. Jev's own `confidence` is a
5018/// spread over the options, not a probability, so it only decides
5019/// escalation.
5020///
5021/// # Errors
5022///
5023/// The tracker or the pack refusing the ballot or the forecast.
5024pub fn cast_jev(name: &str, issue: &str, b: &jev::Ballot) -> Result<()> {
5025    let p = b
5026        .probabilities
5027        .get(&b.choice)
5028        .copied()
5029        .unwrap_or(b.confidence);
5030    let p = format!("{:.3}", p.clamp(0.01, 1.0));
5031    run_captured_as(
5032        "vissue",
5033        &[
5034            "vote",
5035            issue,
5036            "--for",
5037            &b.choice,
5038            "--used",
5039            "none",
5040            "--confidence",
5041            &p,
5042        ],
5043        Some(name),
5044    )?;
5045    write_prediction(issue, name, &serde_json::to_string(&b.forecast)?)?;
5046    note_jev(
5047        issue,
5048        &format!(
5049            "{name}: ballot from Jev, {} ({}); forecast {}",
5050            b.choice,
5051            odds(&b.probabilities),
5052            odds(&b.forecast)
5053        ),
5054    );
5055    Ok(())
5056}
5057
5058fn note_jev(issue: &str, text: &str) {
5059    let _ = run_captured("vissue", &["note", issue, text]);
5060}
5061
5062/// What a Jev ballot did: cast under the persona's name, or handed to a
5063/// subagent because Jev was not sure enough.
5064#[derive(Debug, Clone, PartialEq)]
5065pub enum JevVote {
5066    Cast(jev::Ballot),
5067    Escalated(jev::Ballot),
5068}
5069
5070/// One persona's ballot through Jev: cast when Jev is sure, noted and left
5071/// for a subagent when it is not.
5072///
5073/// # Errors
5074///
5075/// As [`jev_ballot`] and [`cast_jev`].
5076pub fn jev_vote(name: &str, issue: &str) -> Result<JevVote> {
5077    let b = jev_ballot(name, issue)?;
5078    if b.escalates() {
5079        note_jev(
5080            issue,
5081            &format!(
5082                "{name}: Jev leaned {} at confidence {:.2} ({}), under the {:.2} cut; the ballot goes to a subagent",
5083                b.choice,
5084                b.confidence,
5085                odds(&b.probabilities),
5086                b.escalate_below
5087            ),
5088        );
5089        return Ok(JevVote::Escalated(b));
5090    }
5091    cast_jev(name, issue, &b)?;
5092    Ok(JevVote::Cast(b))
5093}
5094
5095/// Whether a panel's Jev answers may stand as its ballots: every seated
5096/// persona sure, and all on one option. Personas answered by one model are
5097/// correlated voters, so their agreement settles only a question it could
5098/// not change; a split or an unsure seat goes to subagents.
5099#[must_use]
5100pub fn jev_panel_stands(ballots: &[jev::Ballot]) -> bool {
5101    !ballots.is_empty()
5102        && ballots.iter().all(|b| !b.escalates())
5103        && ballots.iter().all(|b| b.choice == ballots[0].choice)
5104}
5105
5106/// The most of a brief a Jev ballot sends: about 2,000 input tokens.
5107const JEV_BRIEF_CHARS: usize = 8000;
5108
5109/// A panel through Jev: every seated persona's ballot is asked of Jev
5110/// first. When all are sure and agree ([`jev_panel_stands`]) they are
5111/// cast; otherwise none is, and every seat gets a brief in `out` for a
5112/// subagent, with Jev's lean noted on the issue.
5113///
5114/// # Errors
5115///
5116/// No persona speaking to the issue, and as [`jev_ballot`].
5117pub fn panel_jev(issue: &str, out: &Path) -> Result<String> {
5118    let all = personas_from_pack()?;
5119    let personas = personas_speaking_to(&all, &issue_words(issue));
5120    if personas.is_empty() {
5121        bail!("panel --jev: no persona speaks to {issue}");
5122    }
5123    let mut ballots = Vec::new();
5124    for p in &personas {
5125        ballots.push(jev_ballot(&p.name, issue)?);
5126    }
5127    let rows: Vec<String> = personas
5128        .iter()
5129        .zip(&ballots)
5130        .map(|(p, b)| {
5131            format!(
5132                "  {}  {} at confidence {:.2}",
5133                p.name, b.choice, b.confidence
5134            )
5135        })
5136        .collect();
5137    let mut lines = Vec::new();
5138    if jev_panel_stands(&ballots) {
5139        for (p, b) in personas.iter().zip(&ballots) {
5140            cast_jev(&p.name, issue, b)?;
5141        }
5142        lines.push(format!(
5143            "{} personas on {issue} through Jev: all sure, all {}; cast",
5144            personas.len(),
5145            ballots[0].choice
5146        ));
5147        lines.extend(rows);
5148    } else {
5149        std::fs::create_dir_all(out)?;
5150        lines.push(format!(
5151            "{} personas on {issue} through Jev: split or unsure, none cast; start one subagent per brief in {}",
5152            personas.len(),
5153            out.display()
5154        ));
5155        lines.extend(rows);
5156        for (p, b) in personas.iter().zip(&ballots) {
5157            let path = out.join(format!("{}.md", p.name));
5158            std::fs::write(&path, brief(&p.name, issue)?)?;
5159            lines.push(format!("  {}", path.display()));
5160            note_jev(
5161                issue,
5162                &format!(
5163                    "{}: Jev leaned {} ({}); panel split or unsure, ballot goes to a subagent",
5164                    p.name,
5165                    b.choice,
5166                    odds(&b.probabilities)
5167                ),
5168            );
5169        }
5170    }
5171    lines.push(format!("ljos consensus {issue}"));
5172    Ok(lines.join("\n") + "\n")
5173}
5174
5175/// One voter's forecast on one issue: what share the others give each
5176/// option, or the option it expects to win.
5177#[derive(Debug, Clone, PartialEq)]
5178pub struct Prediction {
5179    pub issue: String,
5180    pub agent: String,
5181    pub expect: Value,
5182}
5183
5184/// POST one forecast. `expect` is an option name or `{option: share}`.
5185pub fn write_prediction(issue: &str, agent: &str, expect: &str) -> Result<Value> {
5186    let (issue, agent, expect) = (issue.trim(), agent.trim(), expect.trim());
5187    if issue.is_empty() || agent.is_empty() || expect.is_empty() {
5188        bail!("predict: an issue, an identity and an expectation are required");
5189    }
5190    let expect_value: Value = match serde_json::from_str::<Value>(expect) {
5191        Ok(v @ Value::Object(_)) => v,
5192        _ => Value::String(expect.to_string()),
5193    };
5194    let client = pack()?;
5195    let workspace = client.workspace();
5196    let mut atom = atom_body(
5197        "prediction",
5198        &format!("{agent} expects {expect} on {issue}."),
5199        &workspace,
5200    );
5201    atom["issue"] = Value::String(issue.into());
5202    atom["agent"] = Value::String(agent.into());
5203    atom["expect"] = expect_value;
5204    client
5205        .post_atom(&atom)
5206        .context("predict: POST /v1/atoms failed")
5207}
5208
5209/// The latest forecast per agent on an issue.
5210pub fn predictions_of(atoms: &[Value], issue: &str) -> Vec<Prediction> {
5211    let mut latest: std::collections::BTreeMap<String, (String, Prediction)> =
5212        std::collections::BTreeMap::new();
5213    for atom in atoms {
5214        if atom.get("kind").and_then(Value::as_str) != Some("prediction")
5215            || atom.get("issue").and_then(Value::as_str) != Some(issue)
5216        {
5217            continue;
5218        }
5219        let (Some(agent), Some(expect)) = (
5220            atom.get("agent").and_then(Value::as_str),
5221            atom.get("expect"),
5222        ) else {
5223            continue;
5224        };
5225        let ts = atom
5226            .get("ts")
5227            .and_then(Value::as_str)
5228            .unwrap_or("")
5229            .to_string();
5230        let p = Prediction {
5231            issue: issue.to_string(),
5232            agent: agent.to_string(),
5233            expect: expect.clone(),
5234        };
5235        match latest.get(agent) {
5236            Some((seen, _)) if *seen > ts => {}
5237            _ => {
5238                latest.insert(agent.to_string(), (ts, p));
5239            }
5240        }
5241    }
5242    latest.into_values().map(|(_, p)| p).collect()
5243}
5244
5245/// Forecasts as `ljos-consensus surprising --predictions` takes them.
5246pub fn predictions_json(predictions: &[Prediction]) -> String {
5247    Value::Array(
5248        predictions
5249            .iter()
5250            .map(|p| serde_json::json!({"agent": p.agent, "expect": p.expect}))
5251            .collect(),
5252    )
5253    .to_string()
5254}
5255
5256/// Argv law kept in the pack: a glob over the command line, a verdict, and
5257/// the reason a reader sees when it fires. `deny` stops the action at the
5258/// runner and under `ljos policy`; `ask` hands it to the person.
5259#[derive(Debug, Clone, PartialEq, Eq)]
5260pub struct Rule {
5261    pub pattern: String,
5262    pub verdict: String,
5263    pub reason: String,
5264}
5265
5266/// POST one rule.
5267pub fn write_rule(rule: &Rule) -> Result<Value> {
5268    let pattern = rule.pattern.trim();
5269    if pattern.is_empty() {
5270        bail!("rule: a pattern over the command line is required");
5271    }
5272    if !matches!(rule.verdict.as_str(), "deny" | "ask") {
5273        bail!("rule: the verdict is deny or ask, not {:?}", rule.verdict);
5274    }
5275    let reason = rule.reason.trim();
5276    if reason.is_empty() {
5277        bail!("rule: say in a sentence why, so the reader who is stopped knows");
5278    }
5279    let client = pack()?;
5280    let workspace = client.workspace();
5281    let mut atom = atom_body("rule", reason, &workspace);
5282    atom["pattern"] = Value::String(pattern.into());
5283    atom["verdict"] = Value::String(rule.verdict.clone());
5284    client
5285        .post_atom(&atom)
5286        .context("rule: POST /v1/atoms failed")
5287}
5288
5289/// The live rules in a set of atoms.
5290pub fn rules_of(atoms: &[Value]) -> Vec<Rule> {
5291    atoms
5292        .iter()
5293        .filter(|a| a.get("kind").and_then(Value::as_str) == Some("rule"))
5294        .filter_map(|a| {
5295            Some(Rule {
5296                pattern: a.get("pattern")?.as_str()?.to_string(),
5297                verdict: a.get("verdict")?.as_str()?.to_string(),
5298                reason: a
5299                    .get("text")
5300                    .and_then(Value::as_str)
5301                    .unwrap_or("")
5302                    .to_string(),
5303            })
5304        })
5305        .collect()
5306}
5307
5308/// The rules in the seat's pack.
5309pub fn rules_from_pack() -> Result<Vec<Rule>> {
5310    let client = pack()?;
5311    let atoms = atoms_lean(&client, &client.workspace()).context("rules: GET /v1/atoms failed")?;
5312    Ok(rules_of(&atoms))
5313}
5314
5315/// A glob over a command line: `*` matches any run of characters, `?` one.
5316/// The match is on the whole line, so `rm -rf *` is `rm -rf ` and anything
5317/// after, and `*sudo*` is sudo anywhere.
5318#[must_use]
5319pub fn glob_matches(pattern: &str, line: &str) -> bool {
5320    fn go(p: &[char], l: &[char]) -> bool {
5321        match (p.first(), l.first()) {
5322            (None, None) => true,
5323            (Some('*'), _) => go(&p[1..], l) || (!l.is_empty() && go(p, &l[1..])),
5324            (Some('?'), Some(_)) => go(&p[1..], &l[1..]),
5325            (Some(a), Some(b)) if a == b => go(&p[1..], &l[1..]),
5326            _ => false,
5327        }
5328    }
5329    let p: Vec<char> = pattern.chars().collect();
5330    let l: Vec<char> = line.trim().chars().collect();
5331    go(&p, &l)
5332}
5333
5334/// The verdict the rules give a command line: the first `deny` wins, then
5335/// the first `ask`, else none. Returns the rule that fired.
5336#[must_use]
5337pub fn verdict_for<'a>(rules: &'a [Rule], line: &str) -> Option<&'a Rule> {
5338    rules
5339        .iter()
5340        .find(|r| r.verdict == "deny" && glob_matches(&r.pattern, line))
5341        .or_else(|| {
5342            rules
5343                .iter()
5344                .find(|r| r.verdict == "ask" && glob_matches(&r.pattern, line))
5345        })
5346}
5347
5348/// Anchors as the settles take them: `{"name": anchor, ...}`.
5349pub fn anchors_json(personas: &[Persona]) -> String {
5350    let map: serde_json::Map<String, Value> = personas
5351        .iter()
5352        .map(|p| (p.name.clone(), serde_json::json!(p.anchor)))
5353        .collect();
5354    Value::Object(map).to_string()
5355}
5356
5357/// The entities that name a domain: every entity but the seat that wrote
5358/// the atom, which says who, not what.
5359fn domains_of(v: Option<&Value>) -> Vec<String> {
5360    words_of(v)
5361        .into_iter()
5362        .filter(|e| !e.starts_with(SEAT_ENTITY))
5363        .collect()
5364}
5365
5366fn words_of(v: Option<&Value>) -> Vec<String> {
5367    v.and_then(Value::as_array)
5368        .into_iter()
5369        .flatten()
5370        .filter_map(Value::as_str)
5371        .map(str::to_lowercase)
5372        .collect()
5373}
5374
5375/// The domains an issue's island speaks to: the entities of the memories
5376/// its title activates, most frequent first, eight at most. What `learn`
5377/// scopes its rows to.
5378///
5379/// # Errors
5380///
5381/// The tracker or the pack not answering.
5382pub fn island_entities(issue: &str) -> Result<Vec<String>> {
5383    let title = issue_title(issue)?;
5384    let island = packset_island(&title, false)?;
5385    let ids: Vec<&str> = island["island"]
5386        .as_array()
5387        .into_iter()
5388        .flatten()
5389        .filter_map(|a| a["id"].as_str())
5390        .collect();
5391    if ids.is_empty() {
5392        return Ok(Vec::new());
5393    }
5394    let client = pack()?;
5395    let atoms = atoms_lean(&client, &client.workspace()).context("island: GET /v1/atoms failed")?;
5396    let mut count: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
5397    for atom in &atoms {
5398        if atom
5399            .get("id")
5400            .and_then(Value::as_str)
5401            .is_some_and(|id| ids.contains(&id))
5402        {
5403            for e in words_of(atom.get("entities")) {
5404                *count.entry(e).or_insert(0) += 1;
5405            }
5406        }
5407    }
5408    let mut ranked: Vec<(String, usize)> = count.into_iter().collect();
5409    ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
5410    Ok(ranked.into_iter().take(8).map(|(e, _)| e).collect())
5411}
5412
5413/// The words an issue is about, for scoping trust rows: its title, lower
5414/// case, three letters or longer.
5415pub fn topic_words(title: &str) -> Vec<String> {
5416    let mut words: Vec<String> = title
5417        .split(|c: char| !c.is_alphanumeric())
5418        .filter(|w| w.len() >= 3)
5419        .map(str::to_lowercase)
5420        .collect();
5421    words.sort_unstable();
5422    words.dedup();
5423    words
5424}
5425
5426/// The rows that apply to an issue about `topic`: every unscoped row, and
5427/// every scoped row one of whose domains is among the topic's words.
5428pub fn rows_about(rows: &[Trust], topic: &[String]) -> Vec<Trust> {
5429    // A scoped row that applies stands in for the unscoped row of the same
5430    // pair, so the settle sees one weight per pair and never a sum of two.
5431    let mut chosen: std::collections::BTreeMap<(String, String), Trust> =
5432        std::collections::BTreeMap::new();
5433    for r in rows {
5434        let applies = r.about.is_empty() || r.about.iter().any(|a| topic.contains(a));
5435        if !applies {
5436            continue;
5437        }
5438        let key = (r.from.clone(), r.to.clone());
5439        match chosen.get(&key) {
5440            Some(have) if !have.about.is_empty() && r.about.is_empty() => {}
5441            _ => {
5442                chosen.insert(key, r.clone());
5443            }
5444        }
5445    }
5446    chosen.into_values().collect()
5447}
5448
5449/// The personas after an outcome: one whose ballot the outcome refuted
5450/// moves its anchor toward one by `1 - beta` of the gap, so a persona that
5451/// keeps being wrong listens more; a vindicated one keeps its anchor. The
5452/// personas that voted are the only ones touched. Acemoglu, Como, Fagnani
5453/// and Ozdaglar (doi:10.1287/moor.1120.0570) show what a stubborn wrong
5454/// voter does to a pool; this is the seat's remedy.
5455#[must_use]
5456pub fn learn_anchors(
5457    personas: &[Persona],
5458    ballots: &[(String, String)],
5459    outcome: &str,
5460    beta: f64,
5461) -> Vec<Persona> {
5462    let outcome = outcome.trim();
5463    personas
5464        .iter()
5465        .filter(|p| {
5466            ballots
5467                .iter()
5468                .any(|(agent, choice)| *agent == p.name && choice != outcome)
5469        })
5470        .map(|p| Persona {
5471            anchor: (p.anchor + (1.0 - p.anchor) * (1.0 - beta)).min(1.0),
5472            ..p.clone()
5473        })
5474        .collect()
5475}
5476
5477/// [`learn_about`] and [`learn_anchors`] together, written to the pack:
5478/// the rows, then the personas the outcome moved. Returns what was written.
5479///
5480/// # Errors
5481///
5482/// The pack refusing a row or a persona.
5483/// A ballot as a forecast: the choice, and the probability the voter stated
5484/// for that choice. Absent confidence is not a claim of certainty.
5485#[derive(Debug, Clone, PartialEq)]
5486pub struct Forecast {
5487    pub agent: String,
5488    pub choice: String,
5489    pub confidence: Option<f64>,
5490}
5491
5492/// Quadratic score of a stated probability against the outcome.
5493///
5494/// `p` is the probability the voter assigned to its own choice being the
5495/// outcome. The outcome indicator is 1 when the choice matches and 0
5496/// otherwise. The score is `(p - o)^2` (Brier 1950; Gneiting and Raftery
5497/// 2007, doi:10.1198/016214506000001437). Lower is better. It is not a
5498/// trust weight.
5499#[must_use]
5500pub fn brier(choice: &str, outcome: &str, p: f64) -> f64 {
5501    let o = if choice == outcome { 1.0 } else { 0.0 };
5502    let d = p - o;
5503    d * d
5504}
5505
5506/// Logarithmic score of the probability assigned to the event that occurred.
5507///
5508/// Good 1952, doi:10.1111/j.2517-6161.1952.tb00104.x. The score is
5509/// `-ln` of the probability the forecast put on what happened. It is
5510/// unbounded when that probability is 0, which a stated certainty on the
5511/// wrong choice is. `None` in that case, rather than a stand-in number.
5512#[must_use]
5513pub fn log_score(choice: &str, outcome: &str, p: f64) -> Option<f64> {
5514    let assigned = if choice == outcome { p } else { 1.0 - p };
5515    if assigned <= 0.0 {
5516        None
5517    } else {
5518        Some(-assigned.ln())
5519    }
5520}
5521
5522/// Mean logarithmic score over the forecasts that stated a probability,
5523/// how many of those scores were finite, and how many were unbounded.
5524#[must_use]
5525pub fn mean_log(rows: &[Forecast], outcome: &str) -> (Option<f64>, usize, usize) {
5526    let mut sum = 0.0;
5527    let mut finite = 0usize;
5528    let mut unbounded = 0usize;
5529    for row in rows {
5530        let Some(p) = row.confidence else { continue };
5531        match log_score(&row.choice, outcome, p) {
5532            Some(score) => {
5533                sum += score;
5534                finite += 1;
5535            }
5536            None => unbounded += 1,
5537        }
5538    }
5539    let mean = (finite > 0).then_some(sum / finite as f64);
5540    (mean, finite, unbounded)
5541}
5542
5543/// One voter's forecast record. The bins are the probabilities actually
5544/// stated, in thousandths, each with how many times it was stated and how
5545/// many of those events occurred. Murphy's categories are those values,
5546/// not a grid this seat invented.
5547#[derive(Debug, Clone, Default, PartialEq)]
5548pub struct Calibration {
5549    pub n: u32,
5550    pub sum_p: f64,
5551    pub sum_o: f64,
5552    pub sum_brier: f64,
5553    pub sum_log: f64,
5554    pub log_n: u32,
5555    pub bins: std::collections::BTreeMap<u16, (u32, u32)>,
5556}
5557
5558/// Murphy's partition of the Brier score (1973,
5559/// doi:10.1175/1520-0450(1973)012<0595:ANVPOT>2.0.CO;2).
5560/// `brier = reliability - resolution + uncertainty`.
5561#[derive(Debug, Clone, Copy, PartialEq)]
5562pub struct Partition {
5563    pub reliability: f64,
5564    pub resolution: f64,
5565    pub uncertainty: f64,
5566}
5567
5568/// Add one stated probability to a voter's record.
5569#[must_use]
5570pub fn observe(cal: &Calibration, choice: &str, outcome: &str, p: f64) -> Calibration {
5571    let mut next = cal.clone();
5572    let occurred = choice == outcome;
5573    let o = if occurred { 1.0 } else { 0.0 };
5574    next.n += 1;
5575    next.sum_p += p;
5576    next.sum_o += o;
5577    next.sum_brier += brier(choice, outcome, p);
5578    if let Some(score) = log_score(choice, outcome, p) {
5579        next.sum_log += score;
5580        next.log_n += 1;
5581    }
5582    let key = (p.clamp(0.0, 1.0) * 1000.0).round() as u16;
5583    let slot = next.bins.entry(key).or_insert((0, 0));
5584    slot.0 += 1;
5585    if occurred {
5586        slot.1 += 1;
5587    }
5588    next
5589}
5590
5591/// Reliability, resolution, and uncertainty. `None` until the voter has
5592/// two forecasts: one forecast makes the partition the score itself.
5593#[must_use]
5594pub fn murphy(cal: &Calibration) -> Option<Partition> {
5595    if cal.n < 2 || cal.bins.is_empty() {
5596        return None;
5597    }
5598    let n = f64::from(cal.n);
5599    let base = cal.sum_o / n;
5600    let mut reliability = 0.0;
5601    let mut resolution = 0.0;
5602    for (thou, (count, occurred)) in &cal.bins {
5603        let nk = f64::from(*count);
5604        if nk == 0.0 {
5605            continue;
5606        }
5607        let forecast = f64::from(*thou) / 1000.0;
5608        let rate = f64::from(*occurred) / nk;
5609        reliability += nk * (forecast - rate) * (forecast - rate);
5610        resolution += nk * (rate - base) * (rate - base);
5611    }
5612    Some(Partition {
5613        reliability: reliability / n,
5614        resolution: resolution / n,
5615        uncertainty: base * (1.0 - base),
5616    })
5617}
5618
5619/// Mean Brier score over the forecasts that stated a probability, and how
5620/// many those were. `None` when nobody stated one.
5621#[must_use]
5622pub fn mean_brier(rows: &[Forecast], outcome: &str) -> Option<(f64, usize)> {
5623    let scores: Vec<f64> = rows
5624        .iter()
5625        .filter_map(|r| r.confidence.map(|p| brier(&r.choice, outcome, p)))
5626        .collect();
5627    if scores.is_empty() {
5628        None
5629    } else {
5630        Some((
5631            scores.iter().sum::<f64>() / scores.len() as f64,
5632            scores.len(),
5633        ))
5634    }
5635}
5636
5637/// `(agent, choice, confidence)` from a tracker's `vote --json`.
5638pub fn forecasts_from_json(raw: &str) -> Result<Vec<Forecast>> {
5639    let rows: Vec<Value> = serde_json::from_str(raw).context("ballots: not a JSON array")?;
5640    rows.iter()
5641        .map(|row| {
5642            let agent = row.get("agent").and_then(Value::as_str);
5643            let choice = row.get("choice").and_then(Value::as_str);
5644            let confidence = match row.get("confidence") {
5645                None | Some(Value::Null) => None,
5646                Some(value) => {
5647                    let probability = value
5648                        .as_f64()
5649                        .or_else(|| value.as_str()?.parse::<f64>().ok())
5650                        .context("ballots: confidence must be a probability in (0, 1]")?;
5651                    if !probability.is_finite() || probability <= 0.0 || probability > 1.0 {
5652                        bail!("ballots: confidence must be a probability in (0, 1]");
5653                    }
5654                    Some(probability)
5655                }
5656            };
5657            match (agent, choice) {
5658                (Some(a), Some(c)) => Ok(Forecast {
5659                    agent: a.to_string(),
5660                    choice: c.to_string(),
5661                    confidence,
5662                }),
5663                _ => bail!("ballots: a row without agent and choice"),
5664            }
5665        })
5666        .collect()
5667}
5668
5669/// What a learn did. The rows are the next settle's weights. This call is not a settle.
5670/// The scores, when any ballot stated a probability, are not trust weights.
5671/// `calibration` is each voter's record after this outcome is folded in.
5672#[must_use]
5673pub fn learn_reading(
5674    rows: usize,
5675    moved: usize,
5676    forecasts: &[Forecast],
5677    outcome: &str,
5678    calibration: &std::collections::BTreeMap<String, Calibration>,
5679) -> String {
5680    let mut out = format!(
5681        "Learned. {rows} trust rows rewritten. A voter the outcome refuted shrinks; a vindicated one keeps its weight. {moved} persona anchors moved. This is not a new settle; the next ljos consensus uses these rows."
5682    );
5683    match mean_brier(forecasts, outcome) {
5684        Some((mean, n)) => {
5685            let silent = forecasts.len().saturating_sub(n);
5686            out.push_str(&format!(
5687                " Brier {mean:.3} over {n} stated probabilities (doi:10.1198/016214506000001437). {silent} ballots stated none and were not scored. The score is not a trust weight."
5688            ));
5689        }
5690        None => out.push_str(
5691            " No stated probability, so there is no Brier score. A hard vote is not a claim of certainty.",
5692        ),
5693    }
5694    let (mean_log, finite, unbounded) = mean_log(forecasts, outcome);
5695    if let Some(mean) = mean_log {
5696        out.push_str(&format!(
5697            " Logarithmic score {mean:.3} over {finite} (doi:10.1111/j.2517-6161.1952.tb00104.x)."
5698        ));
5699    }
5700    if unbounded > 0 {
5701        out.push_str(&format!(
5702            " {unbounded} assigned probability 0 to the event that occurred, so those logarithmic scores are unbounded."
5703        ));
5704    }
5705    let mut named: Vec<(&str, &Calibration)> = forecasts
5706        .iter()
5707        .filter(|f| f.confidence.is_some())
5708        .filter_map(|f| calibration.get(&f.agent).map(|cal| (f.agent.as_str(), cal)))
5709        .collect();
5710    named.sort_by(|a, b| {
5711        let gap = |c: &Calibration| {
5712            if c.n == 0 {
5713                0.0
5714            } else {
5715                (c.sum_p / f64::from(c.n) - c.sum_o / f64::from(c.n)).abs()
5716            }
5717        };
5718        gap(b.1)
5719            .partial_cmp(&gap(a.1))
5720            .unwrap_or(std::cmp::Ordering::Equal)
5721            .then(a.0.cmp(b.0))
5722    });
5723    named.dedup_by_key(|row| row.0);
5724    for (name, cal) in named.into_iter().take(8) {
5725        if cal.n == 0 {
5726            continue;
5727        }
5728        let n = f64::from(cal.n);
5729        let mean_p = cal.sum_p / n;
5730        let rate = cal.sum_o / n;
5731        out.push_str(&format!(
5732            " {name}: {} forecasts, mean probability {mean_p:.3}, event rate {rate:.3} (doi:10.1080/01621459.1982.10477856)",
5733            cal.n
5734        ));
5735        if let Some(part) = murphy(cal) {
5736            out.push_str(&format!(
5737                "; reliability {:.3}, resolution {:.3}, uncertainty {:.3} (doi:10.1175/1520-0450(1973)012<0595:ANVPOT>2.0.CO;2)",
5738                part.reliability, part.resolution, part.uncertainty
5739            ));
5740        }
5741        out.push('.');
5742    }
5743    out
5744}
5745
5746/// Trust rows, personas, and each voter's forecast calibration.
5747pub type LearnedState = (
5748    Vec<Trust>,
5749    Vec<Persona>,
5750    std::collections::BTreeMap<String, Calibration>,
5751);
5752
5753pub fn learn_and_write(
5754    ballots: &[(String, String)],
5755    outcome: &str,
5756    beta: f64,
5757    about: &[String],
5758    forecasts: &[Forecast],
5759) -> Result<LearnedState> {
5760    let client = pack()?;
5761    let atoms = atoms_lean(&client, &client.workspace()).context("learn: GET /v1/atoms failed")?;
5762    let (rows, records) = learn_record(ballots, outcome, &records_from_atoms(&atoms), about)?;
5763    let mut calibration = calibration_from_atoms(&atoms);
5764    for forecast in forecasts {
5765        let Some(p) = forecast.confidence else {
5766            continue;
5767        };
5768        let slot = calibration.entry(forecast.agent.clone()).or_default();
5769        *slot = observe(slot, &forecast.choice, outcome, p);
5770    }
5771    let moved = learn_anchors(&personas_from_pack()?, ballots, outcome, beta);
5772    // Every row lands before anything is printed, so a closed pipe cannot
5773    // leave the graph half written.
5774    for row in &rows {
5775        write_trust_record(
5776            row,
5777            &[],
5778            records.get(&row.to).copied(),
5779            calibration.get(&row.to),
5780        )?;
5781    }
5782    for p in &moved {
5783        write_persona(p)?;
5784    }
5785    Ok((rows, moved, calibration))
5786}
5787
5788/// A voter's record: how often the outcome agreed with its ballot, and
5789/// how often not, carried on every trust row into that voter.
5790pub type Standing = (f64, f64);
5791
5792/// The latest record per voter among the trust atoms that carry one.
5793#[must_use]
5794pub fn records_from_atoms(atoms: &[Value]) -> std::collections::BTreeMap<String, Standing> {
5795    let mut latest: std::collections::BTreeMap<String, (String, Standing)> =
5796        std::collections::BTreeMap::new();
5797    for atom in atoms {
5798        if atom.get("kind").and_then(Value::as_str) != Some("trust") {
5799            continue;
5800        }
5801        let (Some(to), Some(hits), Some(misses)) = (
5802            atom.get("to").and_then(Value::as_str),
5803            atom.get("hits").and_then(Value::as_f64),
5804            atom.get("misses").and_then(Value::as_f64),
5805        ) else {
5806            continue;
5807        };
5808        let ts = atom
5809            .get("ts")
5810            .and_then(Value::as_str)
5811            .unwrap_or("")
5812            .to_string();
5813        match latest.get(to) {
5814            Some((seen, _)) if *seen > ts => {}
5815            _ => {
5816                latest.insert(to.to_string(), (ts, (hits, misses)));
5817            }
5818        }
5819    }
5820    latest.into_iter().map(|(k, (_, r))| (k, r)).collect()
5821}
5822
5823/// Learn from an outcome by the record: each voter's hits and misses so
5824/// far, this outcome added, give its accuracy with one of each smoothed
5825/// in, and the rows are the log odds of that scaled to the best voter at
5826/// one ([`calibration_weights`]). Measured against multiplicative
5827/// shrinking (Hedge) on voters of known accuracy, the record reaches the
5828/// batch calibration and the shrink does not: a voter is weighed by what
5829/// it got right, not by how many times it has been punished. Rows are
5830/// complete over the voters and scoped to `about`.
5831///
5832/// # Errors
5833///
5834/// No outcome, or fewer than two voters.
5835pub fn learn_record(
5836    ballots: &[(String, String)],
5837    outcome: &str,
5838    records: &std::collections::BTreeMap<String, Standing>,
5839    about: &[String],
5840) -> Result<(Vec<Trust>, std::collections::BTreeMap<String, Standing>)> {
5841    let outcome = outcome.trim();
5842    if outcome.is_empty() {
5843        bail!("learn: an outcome is required");
5844    }
5845    let mut agents: Vec<&str> = ballots.iter().map(|(a, _)| a.as_str()).collect();
5846    agents.sort_unstable();
5847    agents.dedup();
5848    if agents.len() < 2 {
5849        bail!("learn: fewer than two voters, nothing to weigh");
5850    }
5851    let mut next = records.clone();
5852    for (agent, choice) in ballots {
5853        let r = next.entry(agent.clone()).or_insert((0.0, 0.0));
5854        if choice == outcome {
5855            r.0 += 1.0;
5856        } else {
5857            r.1 += 1.0;
5858        }
5859    }
5860    let accuracy: Vec<(String, f64)> = agents
5861        .iter()
5862        .map(|a| {
5863            let (h, m) = next.get(*a).copied().unwrap_or((0.0, 0.0));
5864            ((*a).to_string(), (h + 1.0) / (h + m + 2.0))
5865        })
5866        .collect();
5867    let weights = calibration_weights(&accuracy);
5868    let mut out = Vec::new();
5869    for from in &agents {
5870        for (to, weight) in &weights {
5871            if *from == to {
5872                continue;
5873            }
5874            out.push(Trust {
5875                from: (*from).to_string(),
5876                to: to.clone(),
5877                weight: *weight,
5878                about: about.to_vec(),
5879            });
5880        }
5881    }
5882    Ok((out, next))
5883}
5884
5885/// [`write_trust`] carrying the voter's record on the row.
5886pub fn write_trust_record(
5887    row: &Trust,
5888    why: &[String],
5889    record: Option<Standing>,
5890    calibration: Option<&Calibration>,
5891) -> Result<Value> {
5892    let client = pack()?;
5893    let workspace = client.workspace();
5894    let mut atom = trust_atom(row, why, &workspace)?;
5895    if let Some((hits, misses)) = record {
5896        atom["hits"] = serde_json::json!(hits);
5897        atom["misses"] = serde_json::json!(misses);
5898    }
5899    if let Some(cal) = calibration.filter(|c| c.n > 0) {
5900        atom["forecast_n"] = serde_json::json!(cal.n);
5901        atom["forecast_sum_p"] = serde_json::json!(cal.sum_p);
5902        atom["forecast_sum_o"] = serde_json::json!(cal.sum_o);
5903        atom["forecast_sum_brier"] = serde_json::json!(cal.sum_brier);
5904        atom["forecast_sum_log"] = serde_json::json!(cal.sum_log);
5905        atom["forecast_log_n"] = serde_json::json!(cal.log_n);
5906        let mut bins = serde_json::Map::new();
5907        for (key, (count, occurred)) in &cal.bins {
5908            bins.insert(key.to_string(), serde_json::json!([count, occurred]));
5909        }
5910        atom["forecast_bins"] = Value::Object(bins);
5911    }
5912    client
5913        .post_atom(&atom)
5914        .context("trust: POST /v1/atoms failed")
5915}
5916
5917/// The latest forecast record per voter, from the trust rows that carry one.
5918#[must_use]
5919pub fn calibration_from_atoms(atoms: &[Value]) -> std::collections::BTreeMap<String, Calibration> {
5920    let mut latest: std::collections::BTreeMap<String, (String, Calibration)> =
5921        std::collections::BTreeMap::new();
5922    for atom in atoms {
5923        if atom.get("kind").and_then(Value::as_str) != Some("trust") {
5924            continue;
5925        }
5926        let Some(to) = atom.get("to").and_then(Value::as_str) else {
5927            continue;
5928        };
5929        let Some(n) = atom.get("forecast_n").and_then(Value::as_u64) else {
5930            continue;
5931        };
5932        let ts = atom
5933            .get("ts")
5934            .and_then(Value::as_str)
5935            .unwrap_or("")
5936            .to_string();
5937        let cal = Calibration {
5938            n: n as u32,
5939            sum_p: atom
5940                .get("forecast_sum_p")
5941                .and_then(Value::as_f64)
5942                .unwrap_or(0.0),
5943            sum_o: atom
5944                .get("forecast_sum_o")
5945                .and_then(Value::as_f64)
5946                .unwrap_or(0.0),
5947            sum_brier: atom
5948                .get("forecast_sum_brier")
5949                .and_then(Value::as_f64)
5950                .unwrap_or(0.0),
5951            sum_log: atom
5952                .get("forecast_sum_log")
5953                .and_then(Value::as_f64)
5954                .unwrap_or(0.0),
5955            log_n: atom
5956                .get("forecast_log_n")
5957                .and_then(Value::as_u64)
5958                .unwrap_or(0) as u32,
5959            bins: bins_of(atom.get("forecast_bins")),
5960        };
5961        match latest.get(to) {
5962            Some((seen, _)) if *seen > ts => {}
5963            _ => {
5964                latest.insert(to.to_string(), (ts, cal));
5965            }
5966        }
5967    }
5968    latest.into_iter().map(|(k, (_, cal))| (k, cal)).collect()
5969}
5970
5971fn bins_of(value: Option<&Value>) -> std::collections::BTreeMap<u16, (u32, u32)> {
5972    let mut out = std::collections::BTreeMap::new();
5973    let Some(obj) = value.and_then(Value::as_object) else {
5974        return out;
5975    };
5976    for (key, row) in obj {
5977        let Ok(thou) = key.parse::<u16>() else {
5978            continue;
5979        };
5980        let Some(pair) = row.as_array() else { continue };
5981        let count = pair.first().and_then(Value::as_u64).unwrap_or(0) as u32;
5982        let occurred = pair.get(1).and_then(Value::as_u64).unwrap_or(0) as u32;
5983        out.insert(thou, (count, occurred));
5984    }
5985    out
5986}
5987
5988/// The factor a refuted voter's rows shrink by (Hedge, doi:10.1006/jcss.1997.1504).
5989pub const LEARN_BETA: f64 = 0.5;
5990
5991/// The least a row can fall to, so a voter who is right again is heard again.
5992pub const TRUST_FLOOR: f64 = 0.01;
5993
5994/// A `trust` atom for one row. `why` are deed accessions it cites.
5995pub fn trust_atom(row: &Trust, why: &[String], workspace: &str) -> Result<Value> {
5996    let (from, to) = (row.from.trim(), row.to.trim());
5997    if from.is_empty() || to.is_empty() {
5998        bail!("trust: from and to are required");
5999    }
6000    if from == to {
6001        bail!("trust: {from} cannot weigh itself; self weight is the settle's");
6002    }
6003    if !(row.weight > 0.0 && row.weight <= 1.0) {
6004        bail!("trust: weight {} is not in (0, 1]", row.weight);
6005    }
6006    let mut atom = atom_body(
6007        "trust",
6008        &format!("{from} weighs {to} at {:.3}.", row.weight),
6009        workspace,
6010    );
6011    atom["from"] = Value::String(from.into());
6012    atom["to"] = Value::String(to.into());
6013    atom["weight"] = serde_json::json!(row.weight);
6014    // A trust row's entities are the deeds it stands on. The pack refuses
6015    // an entity that is not an accession. Who wrote the row is `from`.
6016    for w in why {
6017        if !w.starts_with("deed-") && !w.starts_with("sha256:") {
6018            bail!("trust: {w} is not a deed accession");
6019        }
6020    }
6021    atom["entities"] = Value::Array(why.iter().map(|w| Value::String(w.clone())).collect());
6022    if !row.about.is_empty() {
6023        atom["about"] = Value::Array(
6024            row.about
6025                .iter()
6026                .map(|w| Value::String(w.to_lowercase()))
6027                .collect(),
6028        );
6029    }
6030    Ok(atom)
6031}
6032
6033/// The live rows in a set of atoms: the latest `trust` atom per `(from, to)`.
6034pub fn trust_rows(atoms: &[Value]) -> Vec<Trust> {
6035    // The latest row per (from, to, scope): an unscoped row and a scoped one
6036    // for the same pair are different rows, and a later row of the same
6037    // scope supersedes.
6038    let mut latest: std::collections::BTreeMap<(String, String, Vec<String>), (String, f64)> =
6039        std::collections::BTreeMap::new();
6040    for atom in atoms {
6041        if atom.get("kind").and_then(Value::as_str) != Some("trust") {
6042            continue;
6043        }
6044        let (Some(from), Some(to), Some(weight)) = (
6045            atom.get("from").and_then(Value::as_str),
6046            atom.get("to").and_then(Value::as_str),
6047            atom.get("weight").and_then(Value::as_f64),
6048        ) else {
6049            continue;
6050        };
6051        let ts = atom
6052            .get("ts")
6053            .and_then(Value::as_str)
6054            .unwrap_or("")
6055            .to_string();
6056        let mut about = words_of(atom.get("about"));
6057        about.sort_unstable();
6058        let key = (from.to_string(), to.to_string(), about);
6059        match latest.get(&key) {
6060            Some((seen, _)) if *seen > ts => {}
6061            _ => {
6062                latest.insert(key, (ts, weight));
6063            }
6064        }
6065    }
6066    latest
6067        .into_iter()
6068        .map(|((from, to, about), (_, weight))| Trust {
6069            from,
6070            to,
6071            weight,
6072            about,
6073        })
6074        .collect()
6075}
6076
6077/// Rows as the consensus takes them: `[[from, to, weight], ...]`.
6078pub fn trust_json(rows: &[Trust]) -> String {
6079    let tuples: Vec<Value> = rows
6080        .iter()
6081        .map(|r| serde_json::json!([r.from, r.to, r.weight]))
6082        .collect();
6083    Value::Array(tuples).to_string()
6084}
6085
6086/// `(agent, choice)` pairs from a tracker's `vote --json`.
6087pub fn ballots_from_json(raw: &str) -> Result<Vec<(String, String)>> {
6088    let rows: Vec<Value> = serde_json::from_str(raw).context("ballots: not a JSON array")?;
6089    rows.iter()
6090        .map(|row| {
6091            let agent = row.get("agent").and_then(Value::as_str);
6092            let choice = row.get("choice").and_then(Value::as_str);
6093            match (agent, choice) {
6094                (Some(a), Some(c)) => Ok((a.to_string(), c.to_string())),
6095                _ => bail!("ballots: a row without agent and choice"),
6096            }
6097        })
6098        .collect()
6099}
6100
6101/// The rows every voter holds on every other after `outcome` is known: a
6102/// voter whose ballot was refuted shrinks by `beta`, floored at
6103/// [`TRUST_FLOOR`]; a missing row starts at one. Complete, so the settle
6104/// sees the whole graph.
6105pub fn learn(
6106    ballots: &[(String, String)],
6107    outcome: &str,
6108    rows: &[Trust],
6109    beta: f64,
6110) -> Result<Vec<Trust>> {
6111    learn_about(ballots, outcome, rows, beta, &[])
6112}
6113
6114/// [`learn`] writing rows scoped to `about`: the domains the issue's island
6115/// speaks to, so that being wrong about one topic does not cost a voter its
6116/// standing on every other. An empty `about` is the unscoped rule.
6117pub fn learn_about(
6118    ballots: &[(String, String)],
6119    outcome: &str,
6120    rows: &[Trust],
6121    beta: f64,
6122    about: &[String],
6123) -> Result<Vec<Trust>> {
6124    learn_shared(ballots, outcome, rows, beta, about, 0.0)
6125}
6126
6127/// [`learn_about`] with a fixed share of recovery: after the Hedge step
6128/// every row moves toward one by `share` of the gap, so a voter refuted
6129/// long ago is not held down forever and the best voter can change
6130/// (Herbster and Warmuth, doi:10.1023/A:1007424614876). Zero is plain
6131/// Hedge; the seat's default.
6132pub fn learn_shared(
6133    ballots: &[(String, String)],
6134    outcome: &str,
6135    rows: &[Trust],
6136    beta: f64,
6137    about: &[String],
6138    share: f64,
6139) -> Result<Vec<Trust>> {
6140    if !(beta > 0.0 && beta < 1.0) {
6141        bail!("learn: beta {beta} is not in (0, 1)");
6142    }
6143    if !(0.0..1.0).contains(&share) {
6144        bail!("learn: share {share} is not in [0, 1)");
6145    }
6146    let outcome = outcome.trim();
6147    if outcome.is_empty() {
6148        bail!("learn: an outcome is required");
6149    }
6150    let mut agents: Vec<&str> = ballots.iter().map(|(a, _)| a.as_str()).collect();
6151    agents.sort_unstable();
6152    agents.dedup();
6153    if agents.len() < 2 {
6154        bail!("learn: fewer than two voters, nothing to weigh");
6155    }
6156    let refuted = |agent: &str| {
6157        ballots
6158            .iter()
6159            .any(|(a, choice)| a == agent && choice != outcome)
6160    };
6161    let mut out = Vec::new();
6162    for from in &agents {
6163        for to in &agents {
6164            if from == to {
6165                continue;
6166            }
6167            // The row being moved is the one of this scope; a scoped learn
6168            // starts from the unscoped row when it has none of its own.
6169            let current = rows
6170                .iter()
6171                .find(|r| r.from == *from && r.to == *to && r.about == about)
6172                .or_else(|| {
6173                    rows.iter()
6174                        .find(|r| r.from == *from && r.to == *to && r.about.is_empty())
6175                })
6176                .map_or(1.0, |r| r.weight);
6177            let stepped = if refuted(to) {
6178                (current * beta).max(TRUST_FLOOR)
6179            } else {
6180                current
6181            };
6182            let next = stepped + (1.0 - stepped) * share;
6183            out.push(Trust {
6184                from: (*from).to_string(),
6185                to: (*to).to_string(),
6186                weight: next,
6187                about: about.to_vec(),
6188            });
6189        }
6190    }
6191    Ok(out)
6192}
6193
6194/// The live trust rows in the seat's pack.
6195pub fn trust_from_pack() -> Result<Vec<Trust>> {
6196    let client = pack()?;
6197    let workspace = client.workspace();
6198    let atoms = atoms_lean(&client, &workspace).context("trust: GET /v1/atoms failed")?;
6199    Ok(trust_rows(&atoms))
6200}
6201
6202/// POST one trust row.
6203pub fn write_trust(row: &Trust, why: &[String]) -> Result<Value> {
6204    let client = pack()?;
6205    let workspace = client.workspace();
6206    client
6207        .post_atom(&trust_atom(row, why, &workspace)?)
6208        .context("trust: POST /v1/atoms failed")
6209}
6210
6211/// One habitat and whether it answers.
6212#[derive(Debug, Clone, PartialEq, Eq)]
6213pub struct Habitat {
6214    pub name: &'static str,
6215    pub state: String,
6216    pub ok: bool,
6217}
6218
6219/// One line after a pack write: id, kind, due, text. Not the embedding.
6220#[must_use]
6221pub fn format_write_ack(body: &serde_json::Value) -> String {
6222    format!(
6223        "{}\t{}\tdue {}\t{}",
6224        body["id"].as_str().unwrap_or("?"),
6225        body["kind"].as_str().unwrap_or("?"),
6226        body["due_at"].as_str().unwrap_or("-"),
6227        body["text"].as_str().unwrap_or("").replace('\n', " "),
6228    )
6229}
6230
6231/// The habitats the seat needs. Encoder and policyd move with the rest.
6232pub const REQUIRED: &[&str] = &[
6233    "ljos",
6234    "ljos-mcp",
6235    "ljos-policyd",
6236    "vissue",
6237    "deedar",
6238    "claimdag",
6239    "packset",
6240    "packsetd",
6241    "packset-embed",
6242    "pack",
6243    "encoder",
6244];
6245
6246/// Binary on PATH and the crates.io name it should track.
6247const SEAT_BINS: &[(&str, &str)] = &[
6248    ("ljos", "ljos"),
6249    // The published `ljos` crate ships this binary. The crates.io name
6250    // `ljos-mcp` stopped at 0.14.0 and is not the binary's version line.
6251    ("ljos-mcp", "ljos"),
6252    ("ljos-policyd", "ljos-policyd"),
6253    ("ljos-consensus", "ljos-consensus"),
6254    ("vissue", "vissue-cli"),
6255    ("deedar", "deedar-cli"),
6256    ("claimdag", "claimdag-cli"),
6257    ("packset", "packset"),
6258    ("packsetd", "packset"),
6259    ("packset-embed", "packset-embed"),
6260    ("packset-mcp", "packset"),
6261    ("ljos-hud", "ljos-hud"),
6262];
6263
6264/// First `N.N.N` in a `--version` line.
6265#[must_use]
6266pub fn parse_semver(text: &str) -> Option<&str> {
6267    let bytes = text.as_bytes();
6268    let mut i = 0;
6269    while i + 4 < bytes.len() {
6270        if bytes[i].is_ascii_digit() {
6271            let start = i;
6272            let mut dots = 0;
6273            while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
6274                if bytes[i] == b'.' {
6275                    dots += 1;
6276                }
6277                i += 1;
6278            }
6279            if dots >= 2 {
6280                return Some(&text[start..i]);
6281            }
6282        }
6283        i += 1;
6284    }
6285    None
6286}
6287
6288fn bin_version(bin: &str) -> Option<String> {
6289    use std::process::{Command, Stdio};
6290    let path = which::which(bin).ok()?;
6291    // MCP servers that do not implement --version sit on stdio.
6292    // Cap the wait so doctor cannot hang the seat.
6293    let mut cmd = if bin.ends_with("-mcp") {
6294        let mut c = Command::new("timeout");
6295        c.args(["0.4", path.to_str()?, "--version"]);
6296        c
6297    } else {
6298        let mut c = Command::new(&path);
6299        c.arg("--version");
6300        c
6301    };
6302    let said = cmd
6303        .stdin(Stdio::null())
6304        .stdout(Stdio::piped())
6305        .stderr(Stdio::piped())
6306        .output()
6307        .ok()?;
6308    let stdout = String::from_utf8_lossy(&said.stdout);
6309    let stderr = String::from_utf8_lossy(&said.stderr);
6310    parse_semver(&stdout)
6311        .or_else(|| parse_semver(&stderr))
6312        .map(str::to_string)
6313}
6314
6315/// A day, in seconds: how long a crates.io answer is kept on disk.
6316const CRATE_VERSION_TTL_S: u64 = 86_400;
6317
6318/// Where a crates.io answer is kept between processes, so a herd of seats
6319/// opening sittings asks the registry once a day for each binary rather
6320/// than once a sitting each.
6321fn crate_version_cache(name: &str) -> Option<PathBuf> {
6322    let dir = std::env::var_os("XDG_CACHE_HOME")
6323        .filter(|r| !r.is_empty())
6324        .map(PathBuf::from)
6325        .or_else(|| home().ok().map(|h| h.join(".cache")))?
6326        .join("ljos");
6327    Some(dir.join(format!("crate-{name}")))
6328}
6329
6330/// A registry answer and where it came from: the day cache on disk, or
6331/// the registry itself.
6332#[derive(Debug, Clone, PartialEq, Eq)]
6333pub struct CrateVersion {
6334    pub version: String,
6335    pub cached: bool,
6336}
6337
6338/// The newest version crates.io lists for `name`, from the day cache when
6339/// it holds one. `refresh` skips the cache: a binary on `PATH` ahead of
6340/// the cached answer proves the cache stale.
6341fn crate_max_version(name: &str, refresh: bool) -> Option<CrateVersion> {
6342    use std::collections::HashMap;
6343    use std::sync::{Mutex, OnceLock};
6344    static CACHE: OnceLock<Mutex<HashMap<String, Option<CrateVersion>>>> = OnceLock::new();
6345    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
6346    if !refresh {
6347        if let Ok(guard) = cache.lock() {
6348            if let Some(hit) = guard.get(name) {
6349                return hit.clone();
6350            }
6351        }
6352    }
6353    let on_disk = crate_version_cache(name);
6354    if let Some(path) = on_disk.as_ref().filter(|_| !refresh) {
6355        let fresh = std::fs::metadata(path)
6356            .and_then(|m| m.modified())
6357            .ok()
6358            .and_then(|t| t.elapsed().ok())
6359            .is_some_and(|age| age.as_secs() < CRATE_VERSION_TTL_S);
6360        if fresh {
6361            if let Ok(text) = std::fs::read_to_string(path) {
6362                let v = text.trim();
6363                let got = (!v.is_empty()).then(|| CrateVersion {
6364                    version: v.to_string(),
6365                    cached: true,
6366                });
6367                if let Ok(mut guard) = cache.lock() {
6368                    guard.insert(name.to_string(), got.clone());
6369                }
6370                return got;
6371            }
6372        }
6373    }
6374    let url = format!("https://crates.io/api/v1/crates/{name}");
6375    let said = std::process::Command::new("curl")
6376        .args(["-sS", "-A", "ljos-doctor", "--max-time", "3", &url])
6377        .output()
6378        .ok();
6379    let got = said.and_then(|said| {
6380        if !said.status.success() {
6381            return None;
6382        }
6383        let v: serde_json::Value = serde_json::from_slice(&said.stdout).ok()?;
6384        v["crate"]["max_version"].as_str().map(|v| CrateVersion {
6385            version: v.to_string(),
6386            cached: false,
6387        })
6388    });
6389    if let (Some(path), Some(v)) = (&on_disk, &got) {
6390        if let Some(dir) = path.parent() {
6391            let _ = std::fs::create_dir_all(dir);
6392        }
6393        let _ = std::fs::write(path, format!("{}\n", v.version));
6394    }
6395    if let Ok(mut guard) = cache.lock() {
6396        guard.insert(name.to_string(), got.clone());
6397    }
6398    got
6399}
6400
6401fn cmp_semver(a: &str, b: &str) -> Option<std::cmp::Ordering> {
6402    let parse = |s: &str| -> Option<[u64; 3]> {
6403        let mut it = s.split('.');
6404        Some([
6405            it.next()?.parse().ok()?,
6406            it.next()?.parse().ok()?,
6407            it.next()?.parse().ok()?,
6408        ])
6409    };
6410    Some(parse(a)?.cmp(&parse(b)?))
6411}
6412
6413/// Which habitats answer: binaries on `PATH`, the pack over `PACKSET_URL`, the
6414/// deed store, the tracker, the claim graph.
6415pub fn doctor() -> Vec<Habitat> {
6416    // The runner rows ask the runners' own command lines, which start slowly;
6417    // they run beside the seat's rows rather than after them.
6418    let (mut out, runners) = std::thread::scope(|s| {
6419        let runners = s.spawn(harness_rows);
6420        let seat = doctor_seat();
6421        (seat, runners.join().unwrap_or_default())
6422    });
6423    out.extend(runners);
6424    out.extend(jev::doctor_row());
6425    out
6426}
6427
6428/// A binary on PATH answers even when crates.io is ahead. Sitting refuses
6429/// a missing required habitat, not a stale one. Behind and ahead are both
6430/// said; a registry answer read from the day cache says so.
6431fn bin_health(path: &str, have: Option<&str>, latest: Option<&CrateVersion>) -> (String, bool) {
6432    use std::cmp::Ordering;
6433    let ver = have.unwrap_or("?");
6434    let Some(cr) = latest else {
6435        return (format!("{path}  {ver}"), true);
6436    };
6437    let source = if cr.cached {
6438        "crates.io (cached)"
6439    } else {
6440        "crates.io"
6441    };
6442    let word = match have.and_then(|v| cmp_semver(v, &cr.version)) {
6443        Some(Ordering::Less) => "behind ",
6444        Some(Ordering::Greater) => "ahead of ",
6445        _ => "",
6446    };
6447    (
6448        format!("{path}  {ver}  {word}{source} {}", cr.version),
6449        true,
6450    )
6451}
6452
6453/// The registry answer for a seat binary. A cached answer the binary on
6454/// `PATH` is already ahead of is stale by construction, so the registry
6455/// is asked again before the row is written.
6456fn crate_version_for(crate_name: &str, have: Option<&str>) -> Option<CrateVersion> {
6457    let first = crate_max_version(crate_name, false)?;
6458    let ahead = first.cached
6459        && have.is_some_and(|v| cmp_semver(v, &first.version) == Some(std::cmp::Ordering::Greater));
6460    if ahead {
6461        crate_max_version(crate_name, true).or(Some(first))
6462    } else {
6463        Some(first)
6464    }
6465}
6466
6467/// Evidence citations and forecast confidence are part of the ballot protocol.
6468/// A version line alone does not establish that the tracker accepts them.
6469fn check_vissue_ballot_protocol(path: &Path) -> Result<()> {
6470    use std::process::{Command, Stdio};
6471    let said = Command::new("timeout")
6472        .arg("2")
6473        .arg(path)
6474        .args(["vote", "--help"])
6475        .stdin(Stdio::null())
6476        .output()
6477        .context("could not check vissue vote --help")?;
6478    if !said.status.success() {
6479        bail!("vissue vote --help failed ({})", said.status);
6480    }
6481    let help = String::from_utf8_lossy(&said.stdout);
6482    let missing: Vec<_> = ["--used", "--confidence"]
6483        .into_iter()
6484        .filter(|flag| !help.split_whitespace().any(|word| word == *flag))
6485        .collect();
6486    if !missing.is_empty() {
6487        bail!(
6488            "incompatible ballot protocol: missing {}; install vissue-cli >= 0.16.2",
6489            missing.join(", ")
6490        );
6491    }
6492    Ok(())
6493}
6494
6495/// The seat's own rows: binaries, pack, host key, deed store, tracker,
6496/// claim graph. What a sitting checks; the runner rows are onboarding.
6497pub fn doctor_seat() -> Vec<Habitat> {
6498    let mut out = Vec::new();
6499    for (bin, crate_name) in SEAT_BINS {
6500        let found = which::which(bin).ok();
6501        let have = found.as_ref().and_then(|_| bin_version(bin));
6502        let latest = crate_version_for(crate_name, have.as_deref());
6503        let ballot_protocol = found
6504            .as_deref()
6505            .filter(|_| *bin == "vissue")
6506            .map(check_vissue_ballot_protocol);
6507        let (mut state, mut ok) = match (found, have.as_deref(), latest.as_ref()) {
6508            (None, _, Some(cr)) => (
6509                format!(
6510                    "not on PATH; cargo binstall {crate_name} (crates.io {})",
6511                    cr.version
6512                ),
6513                false,
6514            ),
6515            (None, _, None) => ("not on PATH".into(), false),
6516            (Some(path), have, Some(cr)) => bin_health(&path.display().to_string(), have, Some(cr)),
6517            (Some(path), have, None) => {
6518                let ver = have.unwrap_or("?");
6519                (format!("{}  {ver}", path.display()), true)
6520            }
6521        };
6522        if let Some(protocol) = ballot_protocol {
6523            match protocol {
6524                Ok(()) => state.push_str("; evidence ballots supported"),
6525                Err(error) => {
6526                    state.push_str(&format!("; {error:#}"));
6527                    ok = false;
6528                }
6529            }
6530        }
6531        out.push(Habitat {
6532            name: bin,
6533            state,
6534            ok,
6535        });
6536    }
6537    // The host the seat runs on: a kernel that OOM-kills keeps killing the
6538    // encoder, the runners and the desktop, and every other row stays green.
6539    out.push(host_row());
6540    // Who is sitting: the name this runner votes under, the name this
6541    // conversation claims under, and where they came from.
6542    out.push(Habitat {
6543        name: "seat",
6544        state: format_seat_row(),
6545        ok: true,
6546    });
6547    load_seat_env();
6548    // The dense ballot: without it the pack ranks by words alone, and an
6549    // island's seeds are weaker than the agent may assume.
6550    out.push(
6551        match PacksetClient::from_env().and_then(|c| c.status(None)) {
6552            Ok(status) => {
6553                let available = status["embedder"]["available"].as_bool().unwrap_or(false);
6554                let answering = status["embedder"]["answering"].as_bool();
6555                Habitat {
6556                    name: "encoder",
6557                    state: if available {
6558                        "dense ballot on".to_string()
6559                    } else if answering == Some(false) {
6560                        "packset-embed did not answer its last call (killed or crashed); \
6561                         ranking is lexical until packsetd restarts it on the next search"
6562                            .to_string()
6563                    } else {
6564                        "down; cargo binstall packset-embed and put it beside packsetd".to_string()
6565                    },
6566                    ok: available,
6567                }
6568            }
6569            Err(e) => Habitat {
6570                name: "encoder",
6571                state: format!("pack does not answer: {e}"),
6572                ok: false,
6573            },
6574        },
6575    );
6576    out.push(match pack() {
6577        Ok(client) => match client.health() {
6578            Ok(_) => Habitat {
6579                name: "pack",
6580                state: format!("{} workspace {}", client.base(), client.workspace()),
6581                ok: true,
6582            },
6583            Err(e) => Habitat {
6584                name: "pack",
6585                state: format!("{} does not answer: {e}", client.base()),
6586                ok: false,
6587            },
6588        },
6589        Err(_) => Habitat {
6590            name: "pack",
6591            state: "PACKSET_URL=off: no pack on purpose".into(),
6592            ok: false,
6593        },
6594    });
6595    // What the pack holds and what it let go: the seat that lets a pack
6596    // grow or forget under it reads it here rather than in `packset status`.
6597    if let Ok(client) = pack() {
6598        if let Ok(status) = client.status(Some(&client.workspace())) {
6599            let live = status["live"].as_u64().unwrap_or(0);
6600            let cap = status["live_cap"].as_u64().unwrap_or(0);
6601            let forgotten: Vec<String> = status["forgotten_by_reason"]
6602                .as_object()
6603                .map(|m| {
6604                    m.iter()
6605                        .map(|(why, n)| format!("{} by {why}", n.as_u64().unwrap_or(0)))
6606                        .collect()
6607                })
6608                .unwrap_or_default();
6609            let mut state = if cap > 0 {
6610                format!("{live} live of {cap}")
6611            } else {
6612                format!("{live} live, no cap")
6613            };
6614            if !forgotten.is_empty() {
6615                state.push_str(&format!("; forgotten {}", forgotten.join(", ")));
6616            }
6617            out.push(Habitat {
6618                name: "memory",
6619                state,
6620                ok: cap == 0 || live <= cap,
6621            });
6622        }
6623    }
6624    out.push(match host_key_path() {
6625        Some(path) => {
6626            let seed = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0) == 32;
6627            // A key the deed store does not list signs deeds that evidence
6628            // refuses. deedar says so; one without the verb is not asked.
6629            let unlisted = if seed {
6630                run_captured("deedar", &["host"])
6631                    .err()
6632                    .map(|e| e.to_string())
6633                    .filter(|e| e.contains("is not a signer"))
6634            } else {
6635                None
6636            };
6637            Habitat {
6638                name: "host key",
6639                state: match (&unlisted, seed) {
6640                    (Some(why), _) => format!(
6641                        "{} (32-byte seed); {}",
6642                        path.display(),
6643                        why.lines().next().unwrap_or("").trim()
6644                    ),
6645                    (None, true) => format!("{} (32-byte seed)", path.display()),
6646                    (None, false) => format!("{} is not a 32-byte seed", path.display()),
6647                },
6648                ok: seed && unlisted.is_none(),
6649            }
6650        }
6651        None => Habitat {
6652            name: "host key",
6653            state: "none at ~/.config/deedar/host.key and DEEDAR_HOST_SIGNING_KEY unset; \
6654                    handovers go out unsigned"
6655                .into(),
6656            ok: false,
6657        },
6658    });
6659    for (name, bin, args) in [
6660        ("deed store", "deedar", &["log", "head"][..]),
6661        ("tracker", "vissue", &["identity"][..]),
6662        ("claim graph", "claimdag", &["list"][..]),
6663    ] {
6664        out.push(match run_captured(bin, args) {
6665            Ok(said) if name == "tracker" => {
6666                let (state, ok) = tracker_state(&said.stdout, &root_source());
6667                Habitat { name, state, ok }
6668            }
6669            Ok(said) => Habitat {
6670                name,
6671                state: said.stdout.lines().next().unwrap_or("").to_string(),
6672                ok: true,
6673            },
6674            Err(e) if name == "claim graph" && claim_graph_absent(&e.to_string()).is_some() => {
6675                let dir = claim_graph_absent(&e.to_string()).unwrap_or_default();
6676                Habitat {
6677                    name,
6678                    state: format!("none yet; the first claim creates it at {dir}"),
6679                    ok: true,
6680                }
6681            }
6682            Err(e) => Habitat {
6683                name,
6684                state: e.to_string().lines().next().unwrap_or("").to_string(),
6685                ok: false,
6686            },
6687        });
6688    }
6689    out
6690}
6691
6692/// The directory claimdag would create, when its refusal says the seat has
6693/// no work graph yet because nothing was ever claimed. A fresh host is not a
6694/// fault: the sitting's first claim creates the graph.
6695pub fn claim_graph_absent(said: &str) -> Option<String> {
6696    let rest = said.split("no work graph at ").nth(1)?;
6697    let (dir, why) = rest.split_once(": ")?;
6698    why.starts_with("the directory does not exist")
6699        .then(|| dir.trim().to_string())
6700}
6701
6702/// Where the tracker root came from, in the order vissue decides it.
6703fn root_source() -> String {
6704    for var in ["ISSUE_ROOT", "VISSUE_ROOT"] {
6705        if let Some(v) = std::env::var_os(var).filter(|v| !v.is_empty()) {
6706            return format!("{var}={}", v.to_string_lossy());
6707        }
6708    }
6709    "seat config or working directory".into()
6710}
6711
6712/// The tracker row from `vissue identity`: version, the root and prefix it
6713/// resolved, and where the root came from. A root that is relative, missing,
6714/// or holds no prefix directory fails the row: tickets filed there are
6715/// invisible to every other seat. When the root is a git checkout with an
6716/// upstream, the row also names how many commits origin lacks.
6717pub fn tracker_state(identity: &str, source: &str) -> (String, bool) {
6718    let version = identity.lines().next().unwrap_or("").trim();
6719    let field = |key: &str| {
6720        identity
6721            .lines()
6722            .find_map(|l| l.strip_prefix(key))
6723            .map(str::trim)
6724            .filter(|v| !v.is_empty())
6725    };
6726    let (Some(root), Some(prefix)) = (field("root="), field("prefix=")) else {
6727        return (format!("{version}; no root in vissue identity"), false);
6728    };
6729    let path = std::path::Path::new(root);
6730    let problem = if !path.is_absolute() {
6731        Some("relative root: tickets land under the working directory")
6732    } else if !path.is_dir() {
6733        Some("root is not a directory")
6734    } else if !path.join(prefix).is_dir() {
6735        Some("no prefix directory under the root")
6736    } else {
6737        None
6738    };
6739    let base = format!("{version} root={root} prefix={prefix} from {source}");
6740    match problem {
6741        Some(why) => (format!("{base}; {why}"), false),
6742        None => match tracker_git_drift(path) {
6743            Some((extra, git_ok)) => (format!("{base}; {extra}"), git_ok),
6744            None => (base, true),
6745        },
6746    }
6747}
6748
6749fn git_in(dir: &Path, args: &[&str]) -> Option<std::process::Output> {
6750    std::process::Command::new("git")
6751        .arg("-C")
6752        .arg(dir)
6753        .args(args)
6754        .stdin(std::process::Stdio::null())
6755        .output()
6756        .ok()
6757}
6758
6759fn git_ok_stdout(dir: &Path, args: &[&str]) -> Option<String> {
6760    let o = git_in(dir, args)?;
6761    o.status
6762        .success()
6763        .then(|| String::from_utf8_lossy(&o.stdout).to_string())
6764}
6765
6766/// Upstream of the tracker checkout: the configured `@{upstream}`, else
6767/// `origin/HEAD`. Absent when the root is not a git checkout, or has no
6768/// remote the doctor can count against.
6769pub(crate) fn tracker_upstream(root: &Path) -> Option<String> {
6770    let inside = git_ok_stdout(root, &["rev-parse", "--is-inside-work-tree"])?;
6771    if inside.trim() != "true" {
6772        return None;
6773    }
6774    if let Some(up) = git_ok_stdout(
6775        root,
6776        &[
6777            "rev-parse",
6778            "--abbrev-ref",
6779            "--symbolic-full-name",
6780            "@{upstream}",
6781        ],
6782    ) {
6783        let up = up.trim().to_string();
6784        if !up.is_empty() {
6785            return Some(up);
6786        }
6787    }
6788    git_ok_stdout(root, &["rev-parse", "--verify", "origin/HEAD"]).map(|_| "origin/HEAD".into())
6789}
6790
6791/// Whether a leftover `tracker-push-<pid>.log` still has that pid running.
6792fn pid_alive(pid: u32) -> bool {
6793    // SAFETY: kill with signal 0 only probes existence; it does not deliver.
6794    unsafe { libc::kill(pid as i32, 0) == 0 }
6795}
6796
6797/// Newest leftover tracker-push log whose process has exited, and whether
6798/// any log's process is still running. persist_tracker removes the log on
6799/// a foreground success and leaves it on a refusal or a background push.
6800fn tracker_push_logs() -> (bool, Option<(std::time::SystemTime, PathBuf)>) {
6801    let Ok(entries) = std::fs::read_dir(runtime_dir()) else {
6802        return (false, None);
6803    };
6804    let mut running = false;
6805    let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
6806    for ent in entries.flatten() {
6807        let name = ent.file_name();
6808        let name = name.to_string_lossy();
6809        let Some(rest) = name
6810            .strip_prefix("tracker-push-")
6811            .and_then(|s| s.strip_suffix(".log"))
6812        else {
6813            continue;
6814        };
6815        let Ok(pid) = rest.parse::<u32>() else {
6816            continue;
6817        };
6818        if pid_alive(pid) {
6819            running = true;
6820            continue;
6821        }
6822        let mtime = ent
6823            .metadata()
6824            .and_then(|m| m.modified())
6825            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
6826        let path = ent.path();
6827        if newest.as_ref().is_none_or(|(t, _)| mtime >= *t) {
6828            newest = Some((mtime, path));
6829        }
6830    }
6831    (running, newest)
6832}
6833
6834fn last_push_refusal() -> Option<String> {
6835    let path = tracker_push_logs().1?.1;
6836    let said = std::fs::read(path).ok()?;
6837    let line = first_line(&said);
6838    (!line.is_empty()).then_some(line)
6839}
6840
6841/// Commits the tracker checkout holds that origin does not. The count is
6842/// always named. A live background push, or commits younger than the push
6843/// wait, stay healthy: the sitting already waited that long. Older drift
6844/// fails the row, and a leftover refused-push log names the reason.
6845pub fn tracker_git_drift(root: &Path) -> Option<(String, bool)> {
6846    let up = tracker_upstream(root)?;
6847    let (mut state, mut ok) = unpushed_drift(root, &up)?;
6848    if let Some(split) = tracker_remote_split(root, &up) {
6849        state = format!("{state}; {split}");
6850        ok = false;
6851    }
6852    if let Some(missing) = tracker_merge_driver_missing(root) {
6853        state = format!("{state}; {missing}");
6854        ok = false;
6855    }
6856    Some((state, ok))
6857}
6858
6859/// A tracker whose .gitattributes merges issues.org with vissue, in a clone
6860/// that has no such driver configured. git then merges the file as text
6861/// without a word, which is the failure the driver exists to prevent: the
6862/// attribute travels with the repository, the driver's command does not.
6863fn tracker_merge_driver_missing(root: &Path) -> Option<String> {
6864    let top = git_ok_stdout(root, &["rev-parse", "--show-toplevel"])?;
6865    let attrs = std::fs::read_to_string(Path::new(top.trim()).join(".gitattributes")).ok()?;
6866    let named = attrs
6867        .lines()
6868        .any(|l| l.split_whitespace().any(|w| w == "merge=vissue"));
6869    if !named {
6870        return None;
6871    }
6872    let driver = git_ok_stdout(root, &["config", "--get", "merge.vissue.driver"]);
6873    driver.filter(|d| !d.trim().is_empty()).is_none().then(|| {
6874        ".gitattributes merges issues.org with vissue and this clone has no merge.vissue.driver; \
6875         `vissue merge-driver --install` in the tracker registers it"
6876            .to_string()
6877    })
6878}
6879
6880/// The remotes of the tracker whose head of the upstream's branch differs
6881/// from the upstream's, as of the last fetch. Two seats that push to two
6882/// remotes of one tracker each read only their own writes, and every other
6883/// row stays green while they do.
6884fn tracker_remote_split(root: &Path, up: &str) -> Option<String> {
6885    let (_, branch) = up.split_once('/')?;
6886    let refs = git_ok_stdout(
6887        root,
6888        &[
6889            "for-each-ref",
6890            "--format=%(refname:short) %(objectname)",
6891            "refs/remotes",
6892        ],
6893    )?;
6894    let heads: Vec<(&str, &str)> = refs
6895        .lines()
6896        .filter_map(|l| l.trim().split_once(' '))
6897        .filter(|(r, _)| r.split_once('/').is_some_and(|(_, b)| b == branch))
6898        .collect();
6899    let tip = heads.iter().find(|(r, _)| *r == up)?.1;
6900    let off: Vec<&str> = heads
6901        .iter()
6902        .filter(|(_, o)| *o != tip)
6903        .map(|(r, _)| *r)
6904        .collect();
6905    (!off.is_empty()).then(|| {
6906        format!(
6907            "{} differs from {up}; pull and push every remote until they agree",
6908            off.join(", ")
6909        )
6910    })
6911}
6912
6913/// The remotes other than the upstream's that carry its branch, as
6914/// (remote, branch). Names that would need quoting are left out.
6915pub(crate) fn tracker_mirrors(root: &Path, up: &str) -> Option<Vec<(String, String)>> {
6916    let (upstream, branch) = up.split_once('/')?;
6917    let plain = |s: &str| {
6918        !s.is_empty()
6919            && s.chars()
6920                .all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
6921    };
6922    let refs = git_ok_stdout(
6923        root,
6924        &["for-each-ref", "--format=%(refname:short)", "refs/remotes"],
6925    )?;
6926    Some(
6927        refs.lines()
6928            .filter_map(|r| r.trim().split_once('/'))
6929            .filter(|(r, b)| *r != upstream && *b == branch && plain(r) && plain(b))
6930            .map(|(r, b)| (r.to_string(), b.to_string()))
6931            .collect(),
6932    )
6933}
6934
6935fn unpushed_drift(root: &Path, up: &str) -> Option<(String, bool)> {
6936    let range = format!("{up}..HEAD");
6937    let count: u64 = git_ok_stdout(root, &["rev-list", "--count", &range])?
6938        .trim()
6939        .parse()
6940        .ok()?;
6941    if count == 0 {
6942        return Some(("0 unpushed".into(), true));
6943    }
6944    let (running, _) = tracker_push_logs();
6945    let oldest = git_ok_stdout(root, &["log", "--format=%ct", "--reverse", &range])
6946        .and_then(|s| {
6947            s.lines()
6948                .find(|l| !l.trim().is_empty())
6949                .map(|l| l.trim().to_string())
6950        })
6951        .and_then(|s| s.parse::<u64>().ok());
6952    let now = std::time::SystemTime::now()
6953        .duration_since(std::time::UNIX_EPOCH)
6954        .unwrap_or_default()
6955        .as_secs();
6956    let stuck = oldest.is_some_and(|t| now.saturating_sub(t) >= push_wait().as_secs());
6957    let unpushed = if count == 1 {
6958        "1 unpushed".to_string()
6959    } else {
6960        format!("{count} unpushed")
6961    };
6962    if running {
6963        return Some((format!("{unpushed}; push still running"), true));
6964    }
6965    if let Some(why) = last_push_refusal() {
6966        return Some((format!("{unpushed}; last push refused: {why}"), false));
6967    }
6968    Some((unpushed, !stuck))
6969}
6970
6971/// The kernel, its OOM kills since boot, and the ljos-mcp servers this
6972/// login runs with their resident memory. Fails on any OOM kill: one kill
6973/// took the encoder, the next the compositor.
6974fn host_row() -> Habitat {
6975    let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease")
6976        .map(|s| s.trim().to_string())
6977        .unwrap_or_else(|_| "unknown kernel".into());
6978    let kills = oom_kills();
6979    let (servers, rss_kb) = ljos_mcp_servers();
6980    let mcp = format!("{servers} ljos-mcp, {} MB resident", rss_kb / 1024);
6981    match kills {
6982        Some(0) => Habitat {
6983            name: "host",
6984            state: format!("{kernel}; no OOM kills since boot; {mcp}"),
6985            ok: true,
6986        },
6987        Some(n) => Habitat {
6988            name: "host",
6989            state: format!(
6990                "{kernel}; {n} OOM kills since boot (/proc/vmstat oom_kill); {mcp}; \
6991                 the kernel is killing processes, read `journalctl -k -b` before the load"
6992            ),
6993            ok: false,
6994        },
6995        None => Habitat {
6996            name: "host",
6997            state: format!("{kernel}; {mcp}"),
6998            ok: true,
6999        },
7000    }
7001}
7002
7003/// OOM kills since boot, from `/proc/vmstat`; none where it is not.
7004fn oom_kills() -> Option<u64> {
7005    parse_oom_kills(&std::fs::read_to_string("/proc/vmstat").ok()?)
7006}
7007
7008fn parse_oom_kills(vmstat: &str) -> Option<u64> {
7009    vmstat
7010        .lines()
7011        .find_map(|l| l.strip_prefix("oom_kill "))
7012        .and_then(|n| n.trim().parse().ok())
7013}
7014
7015/// The ljos-mcp processes of this user and their summed resident size in
7016/// kB, from procfs.
7017fn ljos_mcp_servers() -> (usize, u64) {
7018    let uid = std::fs::read_to_string("/proc/self/status")
7019        .ok()
7020        .and_then(|s| status_field(&s, "Uid:"));
7021    let Ok(dir) = std::fs::read_dir("/proc") else {
7022        return (0, 0);
7023    };
7024    let mut count = 0;
7025    let mut rss = 0;
7026    for entry in dir.flatten() {
7027        let path = entry.path();
7028        if std::fs::read_to_string(path.join("comm")).map_or(true, |c| c.trim() != "ljos-mcp") {
7029            continue;
7030        }
7031        let Ok(status) = std::fs::read_to_string(path.join("status")) else {
7032            continue;
7033        };
7034        if status_field(&status, "Uid:") != uid {
7035            continue;
7036        }
7037        count += 1;
7038        rss += status_field(&status, "VmRSS:")
7039            .and_then(|v| v.parse::<u64>().ok())
7040            .unwrap_or(0);
7041    }
7042    (count, rss)
7043}
7044
7045/// The first number on a `/proc/*/status` line.
7046fn status_field(status: &str, key: &str) -> Option<String> {
7047    status
7048        .lines()
7049        .find_map(|l| l.strip_prefix(key))
7050        .and_then(|rest| rest.split_whitespace().next())
7051        .map(str::to_string)
7052}
7053
7054/// Whether every required habitat answers.
7055pub fn healthy(rows: &[Habitat]) -> bool {
7056    rows.iter()
7057        .all(|h| h.ok || !REQUIRED.contains(&h.name) && h.name != "pack")
7058}
7059
7060pub fn format_doctor(rows: &[Habitat]) -> String {
7061    rows.iter()
7062        .map(|h| {
7063            format!(
7064                "{}	{}	{}
7065",
7066                if h.ok { "ok" } else { "no" },
7067                h.name,
7068                h.state
7069            )
7070        })
7071        .collect()
7072}
7073
7074/// The accessions a satchel's description says it needs.
7075pub fn needs_of(satchel_json: &str) -> Result<Vec<String>> {
7076    let v: Value = serde_json::from_str(satchel_json).context("satchel.json")?;
7077    Ok(v.get("needs")
7078        .and_then(Value::as_array)
7079        .map(|a| {
7080            a.iter()
7081                .filter_map(Value::as_str)
7082                .map(str::to_string)
7083                .collect()
7084        })
7085        .unwrap_or_default())
7086}
7087
7088/// Deeds to enclose: the satchel's `needs` plus what the pack cites, once each.
7089pub fn enclose(needs: Vec<String>, cited: &str) -> Vec<String> {
7090    let mut all: Vec<String> = needs
7091        .into_iter()
7092        .chain(cited.lines().map(str::trim).map(str::to_string))
7093        .filter(|s| !s.is_empty())
7094        .collect();
7095    all.sort();
7096    all.dedup();
7097    all
7098}
7099
7100/// Pack a slice of the seat into `out`: the tracker's satchel, the pack's
7101/// atoms, the deeds both cite, sealed, and signed when a host key is set.
7102pub fn handover(out: &Path, projects: &[String], issues: &[String]) -> Result<Vec<String>> {
7103    if projects.is_empty() && issues.is_empty() {
7104        bail!("handover: name a project or an issue");
7105    }
7106    let mut lines = Vec::new();
7107    let mut args = vec![
7108        "satchel".to_string(),
7109        "--out".into(),
7110        out.display().to_string(),
7111    ];
7112    for p in projects {
7113        args.push("--project".into());
7114        args.push(p.clone());
7115    }
7116    for i in issues {
7117        args.push("--issue".into());
7118        args.push(i.clone());
7119    }
7120    lines.push(run_captured("vissue", &args)?.stdout.trim_end().to_string());
7121
7122    let mut cited = String::new();
7123    match PacksetClient::from_env() {
7124        Ok(client) => {
7125            let atoms_dir = out.join("data").join("atoms");
7126            match run_captured(
7127                "packset",
7128                &[
7129                    "export",
7130                    "--into",
7131                    &atoms_dir.display().to_string(),
7132                    &client.workspace(),
7133                ],
7134            ) {
7135                Ok(said) => {
7136                    cited = said.stdout;
7137                    lines.push(said.stderr.trim_end().to_string());
7138                }
7139                Err(e) => lines.push(format!("atoms not enclosed: {e}")),
7140            }
7141        }
7142        Err(_) => lines.push("no pack: PACKSET_URL=off, atoms not enclosed".into()),
7143    }
7144
7145    let description = std::fs::read_to_string(out.join("data").join("satchel.json"))
7146        .context("handover: the satchel has no description")?;
7147    let deeds = enclose(needs_of(&description)?, &cited);
7148    if deeds.is_empty() {
7149        lines.push("no deeds cited".into());
7150    } else {
7151        let deeds_dir = out.join("data").join("deeds");
7152        let said = run_fed(
7153            "deedar",
7154            &["export", "--into", &deeds_dir.display().to_string(), "-"],
7155            &format!(
7156                "{}
7157",
7158                deeds.join(
7159                    "
7160"
7161                )
7162            ),
7163        )?;
7164        lines.push(said.stdout.trim_end().to_string());
7165    }
7166
7167    lines.push(
7168        run_captured("vissue", &["satchel", "--seal", &out.display().to_string()])?
7169            .stdout
7170            .trim_end()
7171            .to_string(),
7172    );
7173    // The key deedar signs with is the one doctor reports: the variable, or
7174    // the seat's own at ~/.config/deedar/host.key. `off` signs nothing.
7175    if host_key_path().is_some() {
7176        let manifest = out.join("manifest-sha256.txt");
7177        let said = run_captured(
7178            "deedar",
7179            &["vouch", "sign", &manifest.display().to_string()],
7180        )?;
7181        lines.push(said.stdout.trim_end().to_string());
7182    } else {
7183        lines.push(
7184            "unsigned: no host key at ~/.config/deedar/host.key and DEEDAR_HOST_SIGNING_KEY unset; \
7185             `ljos onboard` writes one"
7186                .into(),
7187        );
7188    }
7189    Ok(lines)
7190}
7191
7192/// Check a satchel that arrived: manifest, deed receipts, signature, and what
7193/// the atoms hold; with `import`, POST the atoms into this seat's pack.
7194pub fn receive(dir: &Path, since: Option<&Path>, import: bool) -> Result<Vec<String>> {
7195    let mut lines = Vec::new();
7196    lines.push(
7197        run_captured(
7198            "vissue",
7199            &["satchel", "--verify", &dir.display().to_string()],
7200        )?
7201        .stdout
7202        .trim_end()
7203        .to_string(),
7204    );
7205    if dir.join("data").join("deeds").is_dir() {
7206        let mut args = vec!["check".to_string(), dir.display().to_string()];
7207        if let Some(bridge) = since {
7208            args.push("--since".into());
7209            args.push(bridge.display().to_string());
7210        }
7211        lines.push(run_captured("deedar", &args)?.stdout.trim_end().to_string());
7212    } else {
7213        lines.push("no deeds enclosed".into());
7214    }
7215    let manifest = dir.join("manifest-sha256.txt");
7216    // Who sent it, for the atoms' provenance: the signing key when the bag
7217    // is signed, else the fact of a handover. An imported claim then says
7218    // where it came from, and a search can ask for what one seat taught.
7219    let mut sender = "from:handover".to_string();
7220    if manifest.with_extension("txt.sig").is_file() {
7221        let said = run_captured(
7222            "deedar",
7223            &["vouch", "check", &manifest.display().to_string()],
7224        )?
7225        .stdout
7226        .trim_end()
7227        .to_string();
7228        if !said.starts_with("signed by ") {
7229            bail!("receive: satchel is not signed by an accepted key: {said}");
7230        }
7231        if let Some(hex) = said
7232            .strip_prefix("signed by ")
7233            .and_then(|rest| rest.split(|c: char| !c.is_ascii_hexdigit()).next())
7234            .filter(|h| h.len() >= 12)
7235        {
7236            sender = format!("from:{}", &hex[..12]);
7237        }
7238        lines.push(said);
7239    } else if import {
7240        bail!("receive: unsigned satchel; will not import");
7241    } else {
7242        lines.push("unsigned".into());
7243    }
7244
7245    let atoms = enclosed_atoms(dir)?;
7246    let rows = trust_rows(&atoms);
7247    lines.push(format!(
7248        "{} atoms enclosed, {} trust rows",
7249        atoms.len(),
7250        rows.len()
7251    ));
7252    if import {
7253        let client = pack()?;
7254        let workspace = client.workspace();
7255        let (mut kept, mut refused) = (0usize, Vec::new());
7256        for atom in &atoms {
7257            // The atoms arrive stamped with the sender's workspace; they join
7258            // this seat's, or the import lands in a workspace nobody reads.
7259            let mut atom = atom.clone();
7260            if let Some(map) = atom.as_object_mut() {
7261                map.insert("workspace".into(), Value::String(workspace.clone()));
7262                let mut entities: Vec<Value> = map
7263                    .get("entities")
7264                    .and_then(Value::as_array)
7265                    .cloned()
7266                    .unwrap_or_default();
7267                if !entities.iter().any(|e| e.as_str() == Some(sender.as_str())) {
7268                    entities.push(Value::String(sender.clone()));
7269                }
7270                map.insert("entities".into(), Value::Array(entities));
7271            }
7272            match client.post_atom(&atom) {
7273                Ok(_) => kept += 1,
7274                Err(e) => refused.push(e.to_string()),
7275            }
7276        }
7277        lines.push(format!("{kept} atoms imported, {} refused", refused.len()));
7278        lines.extend(refused.into_iter().take(5));
7279        if kept > 0 {
7280            lines.push(
7281                "imported claims may rewrite held ones; `ljos consolidate` reports the pairs, `--apply` closes them"
7282                    .to_string(),
7283            );
7284        }
7285    }
7286    Ok(lines)
7287}
7288
7289/// Every atom in a satchel's `data/atoms/*.jsonl`.
7290pub fn enclosed_atoms(dir: &Path) -> Result<Vec<Value>> {
7291    let atoms_dir = dir.join("data").join("atoms");
7292    let Ok(entries) = std::fs::read_dir(&atoms_dir) else {
7293        return Ok(Vec::new());
7294    };
7295    let mut out = Vec::new();
7296    for entry in entries.flatten() {
7297        let text = std::fs::read_to_string(entry.path())?;
7298        for line in text.lines().filter(|l| !l.trim().is_empty()) {
7299            out.push(
7300                serde_json::from_str(line).with_context(|| entry.path().display().to_string())?,
7301            );
7302        }
7303    }
7304    Ok(out)
7305}
7306
7307/// Kinds that are weighed, not recalled, and so never come up for review.
7308const UNREVIEWED_KINDS: &[&str] = &["trust", "persona", "playbook"];
7309
7310/// Whether an atom is a claim the review clock should hold at all.
7311fn reviewable(a: &Value) -> bool {
7312    !UNREVIEWED_KINDS.contains(&a.get("kind").and_then(Value::as_str).unwrap_or(""))
7313}
7314
7315/// The live atoms whose review is due at `now` (RFC 3339 UTC), soonest first.
7316/// A claim that has never entered the review clock has no `due_at`; it is
7317/// due now, and grading it puts it on the clock. Trust and persona rows are
7318/// weighed, not recalled, and never come up.
7319pub fn due_of(atoms: &[Value], now: &str) -> Vec<Value> {
7320    let mut due: Vec<Value> = atoms
7321        .iter()
7322        .filter(|a| reviewable(a))
7323        .filter(|a| {
7324            a.get("due_at")
7325                .and_then(Value::as_str)
7326                .is_none_or(|d| d.is_empty() || d <= now)
7327        })
7328        .cloned()
7329        .collect();
7330    due.sort_by(|a, b| {
7331        a["due_at"]
7332            .as_str()
7333            .unwrap_or("")
7334            .cmp(b["due_at"].as_str().unwrap_or(""))
7335    });
7336    due
7337}
7338
7339/// One line on the state of the review clock: how many are due, how many
7340/// are scheduled, and when the next one comes up. An empty `due` with a
7341/// next date is a clock that is running; an empty `due` with nothing
7342/// scheduled is a seat that has remembered nothing.
7343pub fn review_summary(atoms: &[Value], now: &str) -> String {
7344    let due = due_of(atoms, now).len();
7345    let mut later: Vec<&str> = atoms
7346        .iter()
7347        .filter(|a| reviewable(a))
7348        .filter_map(|a| a.get("due_at").and_then(Value::as_str))
7349        .filter(|d| !d.is_empty() && *d > now)
7350        .collect();
7351    later.sort_unstable();
7352    match later.first() {
7353        Some(next) => format!("{due} due; {} scheduled, next at {next}", later.len()),
7354        None if due == 0 => "0 due; nothing scheduled: this seat has remembered nothing yet".into(),
7355        None => format!("{due} due; nothing else scheduled"),
7356    }
7357}
7358
7359/// The due claims with the island's first, keeping each group's due
7360/// order: the claims a sitting's work bears on are the ones its agent can
7361/// grade from what it is about to read, rather than the oldest in the pack.
7362#[must_use]
7363pub fn due_on_island_first(due: Vec<Value>, island: &Value) -> Vec<Value> {
7364    // A weak island is the pack's best-connected cluster, not the issue's.
7365    if island["weak"].as_bool().unwrap_or(false) {
7366        return due;
7367    }
7368    let on: std::collections::BTreeSet<&str> = island["island"]
7369        .as_array()
7370        .into_iter()
7371        .flatten()
7372        .filter_map(|a| a["id"].as_str())
7373        .collect();
7374    let (mut first, rest): (Vec<Value>, Vec<Value>) = due
7375        .into_iter()
7376        .partition(|a| a["id"].as_str().is_some_and(|id| on.contains(id)));
7377    first.extend(rest);
7378    first
7379}
7380
7381/// How many due rows a sitting prints before the summary line.
7382pub const SITTING_DUE: usize = 8;
7383
7384/// How many dated events a sitting's timeline prints. Protocol: last twelve.
7385pub const SITTING_TIMELINE: usize = 12;
7386
7387/// The review clock as a sitting prints it: a short prefix, then the summary.
7388pub fn sitting_due_report(island: &Value) -> Result<String> {
7389    let client = pack()?;
7390    // The same sweep `ljos due` runs. A sitting is the clock's ordinary
7391    // opening; a review left due past twice its interval lapses here.
7392    let swept = client.sweep(&client.workspace()).ok();
7393    let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7394    let now = now_utc();
7395    let due = due_on_island_first(due_of(&atoms, &now), island);
7396    let shown = due.len().min(SITTING_DUE);
7397    Ok(format!(
7398        "{}{}{}\n",
7399        format_due(&due[..shown]),
7400        review_summary(&atoms, &now),
7401        format_sweep(swept.as_ref())
7402    ))
7403}
7404
7405/// The review clock as `ljos due` prints it: the due atoms, then the summary.
7406pub fn due_report() -> Result<String> {
7407    let client = pack()?;
7408    // The sweep runs first, so a review left due past twice its interval is
7409    // lapsed or forgotten before the list is read, and the report says so.
7410    let swept = client.sweep(&client.workspace()).ok();
7411    let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7412    let now = now_utc();
7413    Ok(format!(
7414        "{}{}{}\n",
7415        format_due(&due_of(&atoms, &now)),
7416        review_summary(&atoms, &now),
7417        format_sweep(swept.as_ref())
7418    ))
7419}
7420
7421/// One line on what the sweep did, or nothing when it found nothing.
7422pub fn format_sweep(report: Option<&Value>) -> String {
7423    let Some(report) = report else {
7424        return String::new();
7425    };
7426    let lapsed = report.get("lapsed").and_then(Value::as_u64).unwrap_or(0);
7427    let forgotten = report.get("forgotten").and_then(Value::as_u64).unwrap_or(0);
7428    if lapsed == 0 && forgotten == 0 {
7429        return String::new();
7430    }
7431    format!(
7432        "\nswept: {lapsed} review{} lapsed past twice {} interval, {forgotten} never-recalled claim{} forgotten by neglect",
7433        if lapsed == 1 { "" } else { "s" },
7434        if lapsed == 1 { "its" } else { "their" },
7435        if forgotten == 1 { "" } else { "s" }
7436    )
7437}
7438
7439/// What the pack holds for review now.
7440pub fn due() -> Result<Vec<Value>> {
7441    let client = pack()?;
7442    let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7443    Ok(due_of(&atoms, &now_utc()))
7444}
7445
7446/// The soonest [`SITTING_DUE`] claims, how many are due in all, and the
7447/// clock line. Read-only: the sweep stays on `ljos due` and on a sitting.
7448pub fn due_page() -> Result<(Vec<Value>, usize, String)> {
7449    let client = pack()?;
7450    let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7451    let now = now_utc();
7452    let all = due_of(&atoms, &now);
7453    let total = all.len();
7454    let shown: Vec<Value> = all.into_iter().take(SITTING_DUE).collect();
7455    Ok((shown, total, review_summary(&atoms, &now)))
7456}
7457
7458// ---- habits ----------------------------------------------------------------
7459
7460/// The entity a habit's readings carry, so a name finds them.
7461pub const HABIT_ENTITY: &str = "habit:";
7462/// A habit's cadence when none is given: a week, in seconds.
7463pub const HABIT_EVERY_S: i64 = 7 * 86_400;
7464
7465/// One reading of a habit: a number the seat keeps measuring, with the
7466/// cadence it is measured at. A reading is a claim of kind `habit` that
7467/// supersedes the reading before it, so the pack holds one live value a
7468/// habit and `search --as-of` still answers what it stood at then; its
7469/// review clock is the cadence, so `due` and the hook say when the next
7470/// reading is late.
7471#[derive(Debug, Clone, PartialEq, serde::Serialize)]
7472pub struct Reading {
7473    pub name: String,
7474    pub value: f64,
7475    pub unit: String,
7476    pub source: String,
7477    /// Seconds between readings.
7478    pub every_s: i64,
7479    /// The reading before this one, when there was one.
7480    pub was: Option<f64>,
7481    pub was_ts: Option<String>,
7482    pub id: Option<String>,
7483    pub ts: Option<String>,
7484    pub due_at: Option<String>,
7485}
7486
7487/// `7d`, `24h`, `2w`, `30m`, or bare seconds.
7488pub fn parse_every(text: &str) -> Result<i64> {
7489    let t = text.trim();
7490    let split = t.trim_end_matches(|c: char| c.is_ascii_alphabetic()).len();
7491    let (num, unit) = t.split_at(split);
7492    let n: i64 = num
7493        .trim()
7494        .parse()
7495        .with_context(|| format!("habit: --every {t:?} is not a span; write 7d, 24h, 2w or 30m"))?;
7496    let each = match unit {
7497        "" | "s" => 1,
7498        "m" => 60,
7499        "h" => 3_600,
7500        "d" => 86_400,
7501        "w" => 7 * 86_400,
7502        other => bail!("habit: unknown unit {other:?} in --every; write d, h, w, m or s"),
7503    };
7504    if n <= 0 {
7505        bail!("habit: --every must be positive");
7506    }
7507    Ok(n * each)
7508}
7509
7510/// An RFC 3339 stamp `secs` after `now` (`YYYY-MM-DDTHH:MM:SSZ`, to the
7511/// second). None when `now` does not read as a stamp.
7512fn stamp_after(now: &str, secs: i64) -> Option<String> {
7513    let days = days_of_stamp(Some(now))?;
7514    let clock = now.get(11..19)?;
7515    let mut it = clock.split(':');
7516    let h: i64 = it.next()?.parse().ok()?;
7517    let m: i64 = it.next()?.parse().ok()?;
7518    let s: i64 = it.next()?.parse().ok()?;
7519    let total = days * 86_400 + h * 3_600 + m * 60 + s + secs;
7520    let day = total.div_euclid(86_400);
7521    let rem = total.rem_euclid(86_400);
7522    Some(format!(
7523        "{}T{:02}:{:02}:{:02}.000Z",
7524        civil_of_days(day),
7525        rem / 3_600,
7526        rem % 3_600 / 60,
7527        rem % 60
7528    ))
7529}
7530
7531/// A number as a person writes it: up to four decimals, no trailing zeros.
7532#[must_use]
7533pub fn trim_num(v: f64) -> String {
7534    let s = format!("{v:.4}");
7535    let s = s.trim_end_matches('0').trim_end_matches('.');
7536    if s.is_empty() || s == "-" {
7537        "0".to_string()
7538    } else {
7539        s.to_string()
7540    }
7541}
7542
7543/// The claim a reading is stored as. The words are for a reader; the
7544/// numbers travel in the atom's `habit` field.
7545#[must_use]
7546pub fn habit_text(name: &str, value: f64, unit: &str, source: &str) -> String {
7547    let unit = unit.trim();
7548    let source = source.trim();
7549    let mut text = format!("habit {} stands at {}", name.trim(), trim_num(value));
7550    if !unit.is_empty() {
7551        text.push(' ');
7552        text.push_str(unit);
7553    }
7554    if !source.is_empty() {
7555        text.push_str(&format!(" ({source})"));
7556    }
7557    text.push('.');
7558    text
7559}
7560
7561fn reading_of(atom: &Value) -> Option<Reading> {
7562    if atom.get("kind").and_then(Value::as_str) != Some("habit") {
7563        return None;
7564    }
7565    let h = atom.get("habit")?;
7566    Some(Reading {
7567        name: h.get("name")?.as_str()?.to_string(),
7568        value: h.get("value")?.as_f64()?,
7569        unit: h
7570            .get("unit")
7571            .and_then(Value::as_str)
7572            .unwrap_or("")
7573            .to_string(),
7574        source: h
7575            .get("source")
7576            .and_then(Value::as_str)
7577            .unwrap_or("")
7578            .to_string(),
7579        every_s: h
7580            .get("every_s")
7581            .and_then(Value::as_i64)
7582            .unwrap_or(HABIT_EVERY_S),
7583        was: h.get("was").and_then(Value::as_f64),
7584        was_ts: h.get("was_ts").and_then(Value::as_str).map(str::to_string),
7585        id: atom.get("id").and_then(Value::as_str).map(str::to_string),
7586        ts: atom.get("ts").and_then(Value::as_str).map(str::to_string),
7587        due_at: atom
7588            .get("due_at")
7589            .and_then(Value::as_str)
7590            .map(str::to_string),
7591    })
7592}
7593
7594/// The live readings among `atoms`, one a habit, by name.
7595#[must_use]
7596pub fn readings_of(atoms: &[Value]) -> Vec<Reading> {
7597    let mut rows: Vec<Reading> = atoms.iter().filter_map(reading_of).collect();
7598    rows.sort_by(|a, b| a.name.cmp(&b.name).then(b.ts.cmp(&a.ts)));
7599    rows.dedup_by(|a, b| a.name == b.name);
7600    rows
7601}
7602
7603/// The live readings in the seat's pack.
7604pub fn habits() -> Result<Vec<Reading>> {
7605    let client = pack()?;
7606    let atoms = atoms_lean(&client, &client.workspace()).context("habit: GET /v1/atoms failed")?;
7607    Ok(readings_of(&atoms))
7608}
7609
7610/// Take a reading: write it as a claim that supersedes the habit's earlier
7611/// reading, carrying that reading as `was`, with its review due one
7612/// cadence from now. Returns the pack's answer and the reading it closed.
7613pub fn habit(
7614    name: &str,
7615    value: f64,
7616    unit: &str,
7617    every_s: i64,
7618    source: &str,
7619) -> Result<(Value, Option<Reading>)> {
7620    let name = name.trim();
7621    if name.is_empty() {
7622        bail!("habit: a reading needs a name");
7623    }
7624    if !value.is_finite() {
7625        bail!("habit: {value} is not a reading");
7626    }
7627    let client = pack()?;
7628    let workspace = client.workspace();
7629    let atoms = atoms_lean(&client, &workspace).context("habit: GET /v1/atoms failed")?;
7630    let prev = readings_of(&atoms).into_iter().find(|r| r.name == name);
7631    let now = now_utc();
7632    let mut atom = atom_body("habit", &habit_text(name, value, unit, source), &workspace);
7633    add_entities(&mut atom, [format!("{HABIT_ENTITY}{name}")]);
7634    if let Some(due) = stamp_after(&now, every_s) {
7635        atom["due_at"] = Value::String(due);
7636    }
7637    atom["habit"] = serde_json::json!({
7638        "name": name,
7639        "value": value,
7640        "unit": unit.trim(),
7641        "source": source.trim(),
7642        "every_s": every_s,
7643        "was": prev.as_ref().map(|p| p.value),
7644        "was_ts": prev.as_ref().and_then(|p| p.ts.clone()),
7645    });
7646    if let Some(id) = prev.as_ref().and_then(|p| p.id.clone()) {
7647        atom["supersedes"] = Value::Array(vec![Value::String(id)]);
7648    }
7649    let body = client
7650        .post_atom(&atom)
7651        .context("habit: POST /v1/atoms failed")?;
7652    Ok((body, prev))
7653}
7654
7655/// The change since the reading before, signed, or nothing for a first
7656/// reading.
7657#[must_use]
7658pub fn format_change(r: &Reading, now: &str) -> String {
7659    match r.was {
7660        Some(was) => {
7661            let d = r.value - was;
7662            let sign = if d >= 0.0 { "+" } else { "" };
7663            format!(
7664                "{sign}{} since {} ({})",
7665                trim_num(d),
7666                trim_num(was),
7667                age_of(r.was_ts.as_deref(), now)
7668            )
7669        }
7670        None => "first reading".to_string(),
7671    }
7672}
7673
7674/// `ljos habit`: one line a habit: name, value with unit, the change since
7675/// the last reading, the age of this one, when the next is due, source.
7676#[must_use]
7677pub fn format_readings(rows: &[Reading], now: &str) -> String {
7678    rows.iter()
7679        .map(|r| {
7680            let due = match r.due_at.as_deref() {
7681                Some(d) if d <= now => format!("next reading late ({})", age_of(Some(d), now)),
7682                Some(d) => format!("next reading {}", age_of(Some(d), now)),
7683                None => "no cadence".to_string(),
7684            };
7685            format!(
7686                "{}\t{}{}{}\t{}\t{}\t{}\t{}\n",
7687                r.name,
7688                trim_num(r.value),
7689                if r.unit.is_empty() { "" } else { " " },
7690                r.unit,
7691                format_change(r, now),
7692                age_of(r.ts.as_deref(), now),
7693                due,
7694                r.source
7695            )
7696        })
7697        .collect()
7698}
7699
7700pub fn format_due(atoms: &[Value]) -> String {
7701    atoms
7702        .iter()
7703        .map(|a| {
7704            format!(
7705                "{}	{}	{}	{}
7706",
7707                a["due_at"]
7708                    .as_str()
7709                    .filter(|d| !d.is_empty())
7710                    .unwrap_or("unreviewed"),
7711                a["kind"].as_str().unwrap_or(""),
7712                a["id"].as_str().unwrap_or("-"),
7713                a["text"].as_str().unwrap_or("")
7714            )
7715        })
7716        .collect()
7717}
7718
7719/// Grade one review: recalled moves the atom out, lapsed brings it back sooner.
7720pub fn graded(id: &str, recalled: bool) -> Result<Value> {
7721    let id = id.trim();
7722    if id.is_empty() {
7723        bail!("graded: an atom id is required");
7724    }
7725    let client = pack()?;
7726    client
7727        .grade(&client.workspace(), id, recalled)
7728        .with_context(|| format!("graded: POST /v1/grade failed for {id}"))
7729}
7730
7731/// Now, RFC 3339 UTC to the second, the stamp the pack writes.
7732#[must_use]
7733pub fn now_utc() -> String {
7734    let secs = std::time::SystemTime::now()
7735        .duration_since(std::time::UNIX_EPOCH)
7736        .map(|d| d.as_secs())
7737        .unwrap_or(0);
7738    let days = secs / 86_400;
7739    let rem = secs % 86_400;
7740    // Civil date from days since the epoch (Howard Hinnant's algorithm).
7741    let z = days as i64 + 719_468;
7742    let era = z.div_euclid(146_097);
7743    let doe = z.rem_euclid(146_097);
7744    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
7745    let y = yoe + era * 400;
7746    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
7747    let mp = (5 * doy + 2) / 153;
7748    let d = doy - (153 * mp + 2) / 5 + 1;
7749    let m = if mp < 10 { mp + 3 } else { mp - 9 };
7750    let y = if m <= 2 { y + 1 } else { y };
7751    format!(
7752        "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
7753        rem / 3600,
7754        rem % 3600 / 60,
7755        rem % 60
7756    )
7757}
7758
7759/// Run a habitat's verb with `input` on stdin.
7760pub fn run_fed(bin: &str, args: &[impl AsRef<str>], input: &str) -> Result<Said> {
7761    use std::io::Write;
7762    use std::process::{Command, Stdio};
7763    let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
7764    let mut cmd = Command::new(path);
7765    for a in args {
7766        cmd.arg(a.as_ref());
7767    }
7768    let mut child = cmd
7769        .stdin(Stdio::piped())
7770        .stdout(Stdio::piped())
7771        .stderr(Stdio::piped())
7772        .spawn()
7773        .with_context(|| format!("{bin}: could not start"))?;
7774    if let Some(mut stdin) = child.stdin.take() {
7775        stdin.write_all(input.as_bytes())?;
7776    }
7777    let out = child.wait_with_output()?;
7778    let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
7779    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
7780    if !out.status.success() {
7781        let why = if stderr.trim().is_empty() {
7782            stdout.trim().to_string()
7783        } else {
7784            stderr.trim().to_string()
7785        };
7786        bail!("{bin} exited {}: {why}", out.status);
7787    }
7788    Ok(Said { stdout, stderr })
7789}
7790
7791/// A claimdag id for a name: the name itself when it is already 32 hex, else
7792/// FNV-1a 128 of it. One tracker id maps to one node; one assignee to one actor.
7793pub fn work_id(name: &str) -> String {
7794    let name = name.trim();
7795    if name.len() == 32 && name.bytes().all(|b| b.is_ascii_hexdigit()) {
7796        return name.to_ascii_lowercase();
7797    }
7798    const OFFSET: u128 = 0x6c62_272e_07bb_0142_62b8_2175_6295_c58d;
7799    const PRIME: u128 = 0x0000_0000_0100_0000_0000_0000_0000_013b;
7800    let mut h = OFFSET;
7801    for b in name.bytes() {
7802        h ^= u128::from(b);
7803        h = h.wrapping_mul(PRIME);
7804    }
7805    format!("{h:032x}")
7806}
7807
7808/// The claimdag node standing for `issue`, minted with the tracker id as its
7809/// summary when the graph does not hold it yet.
7810pub fn node_for(issue: &str) -> Result<String> {
7811    let id = work_id(issue);
7812    if id != issue.trim() && run_captured("claimdag", &["get", &id]).is_err() {
7813        run_captured(
7814            "claimdag",
7815            &["upsert", "--id", &id, "--summary", issue.trim()],
7816        )
7817        .with_context(|| format!("claim: could not mint a node for {issue}"))?;
7818    }
7819    Ok(id)
7820}
7821
7822/// The memories a task activates: the pack's island around the cue. With
7823/// `fire`, the strongest of them fire together and their links gain weight.
7824pub fn packset_island(cue: &str, fire: bool) -> Result<Value> {
7825    packset_island_as(cue, fire, None)
7826}
7827
7828/// [`packset_island`] through a persona's lens: the spread follows the
7829/// weights that persona fired, and a fire writes its weights and not the
7830/// seat's. The seat's own island is the one with no lens.
7831pub fn packset_island_as(cue: &str, fire: bool, lens: Option<&str>) -> Result<Value> {
7832    let cue = cue.trim();
7833    if cue.is_empty() {
7834        bail!("island: pass the task or question at hand");
7835    }
7836    let client = pack()?;
7837    let workspace = client.workspace();
7838    let lens = lens
7839        .map(str::trim)
7840        .filter(|l| !l.is_empty())
7841        .map(str::to_lowercase);
7842    let mut body = client
7843        .activate_as(&workspace, cue, 24, fire, lens.as_deref())
7844        .context("island: GET /v1/activate failed")?;
7845    if body["fired"].as_u64().unwrap_or(0) > 0 {
7846        match record_fire(cue, lens.as_deref(), &body) {
7847            Ok(id) => body["trace"] = Value::String(id),
7848            Err(err) => body["trace_error"] = Value::String(err.to_string()),
7849        }
7850    }
7851    Ok(body)
7852}
7853
7854/// Record a fire as why-provenance: which links were strengthened, under
7855/// whose weights. A trace does not replace another trace.
7856fn record_fire(cue: &str, lens: Option<&str>, body: &Value) -> Result<String> {
7857    let fired = body["fired"].as_u64().unwrap_or(0);
7858    let who = lens.unwrap_or("seat");
7859    let ids: Vec<String> = body["island"]
7860        .as_array()
7861        .into_iter()
7862        .flatten()
7863        .filter_map(|row| row.get("id").and_then(Value::as_str).map(str::to_string))
7864        .take(8)
7865        .collect();
7866    let mut nonce = 0xcbf29ce484222325u64;
7867    for part in [cue, who].into_iter().chain(ids.iter().map(String::as_str)) {
7868        for byte in part.as_bytes() {
7869            nonce ^= u64::from(*byte);
7870            nonce = nonce.wrapping_mul(0x100000001b3);
7871        }
7872    }
7873    let text = format!(
7874        "Fire {:08x} under {who} strengthened {fired} links.",
7875        nonce as u32
7876    );
7877    let client = pack()?;
7878    let workspace = client.workspace();
7879    let mut atom = atom_body("trace", &text, &workspace);
7880    add_entities(&mut atom, ids);
7881    let posted = client
7882        .post_atom(&atom)
7883        .context("trace: POST /v1/atoms failed")?;
7884    Ok(posted
7885        .get("id")
7886        .and_then(Value::as_str)
7887        .unwrap_or("")
7888        .to_string())
7889}
7890
7891/// The claims the pack's link graph turns on, highest first: what matters
7892/// in this seat's memory by its own connections, before any query.
7893pub fn packset_hubs(limit: usize) -> Result<Value> {
7894    let client = pack()?;
7895    let workspace = client.workspace();
7896    client
7897        .hubs(&workspace, limit)
7898        .context("hubs: GET /v1/hubs failed")
7899}
7900
7901/// Consolidate the seat's memory: every claim that replaces an earlier
7902/// one (a rewrite, a new object under the same head, a correction, an
7903/// explicit supersedes) closes the earlier one's window and names it.
7904/// Candidate contradictions from the geometry of the seat's memory: the
7905/// `landscape` binary reads the pack's embeddings at the point scale and
7906/// prints the lowest passes between single memories, which on a record of
7907/// planted contradictions were the contradictions nine times in ten. The
7908/// replacement rule reads words; this reads distance, in any language.
7909/// A candidate is for a person or `consolidate` to judge; nothing is
7910/// written here. `landscape` is an optional habitat: absent, this says so.
7911///
7912/// # Errors
7913///
7914/// The binary absent or refusing, or the pack not answering.
7915pub fn conflicts(limit: usize) -> Result<String> {
7916    if which::which("landscape").is_err() {
7917        bail!(
7918            "conflicts: `landscape` is not on PATH; it is the optional habitat that reads the pack's geometry (leidarljos/landscape)"
7919        );
7920    }
7921    let client = pack()?;
7922    let said = match run_captured(
7923        "landscape",
7924        &[
7925            "--atoms",
7926            client.base(),
7927            "--workspace",
7928            &client.workspace(),
7929            "--conflicts",
7930        ],
7931    ) {
7932        Ok(said) => said,
7933        // A pack whose memories carry no embeddings has no landscape to
7934        // read; that is a fact about the pack, not a refusal.
7935        Err(e) if e.to_string().contains("at least two") => {
7936            return Ok(
7937                "fewer than two memories with embeddings in the pack; conflicts by geometry need the encoder (`packset doctor` shows it)\n"
7938                    .to_string(),
7939            );
7940        }
7941        Err(e) => return Err(e),
7942    };
7943    let v: Value =
7944        serde_json::from_str(&said.stdout).context("conflicts: landscape printed no JSON")?;
7945    let now = now_utc();
7946    let atoms = atoms_lean(&client, &client.workspace()).unwrap_or_default();
7947    let stamp_of = |id: &str| -> Option<String> {
7948        atoms
7949            .iter()
7950            .find(|a| a["id"].as_str() == Some(id))
7951            .and_then(|a| a["ts"].as_str().map(str::to_string))
7952    };
7953    // Trust rows, personas, forecasts and rules are weighed, not recalled;
7954    // a pass between two of them is not a contradiction to judge.
7955    let recalled = |id: &str| -> bool {
7956        atoms
7957            .iter()
7958            .find(|a| a["id"].as_str() == Some(id))
7959            .is_none_or(reviewable)
7960    };
7961    let mut out = String::new();
7962    for pair in v["pairs"]
7963        .as_array()
7964        .into_iter()
7965        .flatten()
7966        .filter(|p| {
7967            recalled(p["a"].as_str().unwrap_or("")) && recalled(p["b"].as_str().unwrap_or(""))
7968        })
7969        .take(limit)
7970    {
7971        let a = pair["a"].as_str().unwrap_or("-");
7972        let b = pair["b"].as_str().unwrap_or("-");
7973        out.push_str(&format!(
7974            "pass {:.3}\n  {a} {}  {}\n  {b} {}  {}\n",
7975            pair["barrier"].as_f64().unwrap_or(0.0),
7976            age_of(stamp_of(a).as_deref(), &now),
7977            pair["a_text"].as_str().unwrap_or("").trim(),
7978            age_of(stamp_of(b).as_deref(), &now),
7979            pair["b_text"].as_str().unwrap_or("").trim()
7980        ));
7981    }
7982    let n = v["pairs"].as_array().map_or(0, Vec::len);
7983    out.push_str(&format!(
7984        "{n} passes between single memories at kernel width {:.3}; the lowest are the likeliest contradictions. `ljos forget ID --why DEED` retires one, `ljos remember` a rewrite closes it.\n",
7985        v["sigma"].as_f64().unwrap_or(0.0)
7986    ));
7987    Ok(out)
7988}
7989
7990/// The rule a write applies on arrival, run over what the pack already
7991/// holds. Without `apply` nothing is written; the pairs are reported.
7992pub fn packset_consolidate(apply: bool) -> Result<Value> {
7993    let client = pack()?;
7994    let workspace = client.workspace();
7995    client
7996        .consolidate(&workspace, apply)
7997        .context("consolidate: POST /v1/consolidate failed")
7998}
7999
8000/// The pairs a consolidation closed or would close, one a line, then the
8001/// count and whether it was applied.
8002pub fn format_consolidation(body: &Value) -> String {
8003    let mut out = String::new();
8004    for pair in body["pairs"].as_array().into_iter().flatten() {
8005        out.push_str(&format!(
8006            "closes {}  {}\n    for {}  {}\n",
8007            pair["old"].as_str().unwrap_or("-"),
8008            pair["old_text"].as_str().unwrap_or("").trim(),
8009            pair["new"].as_str().unwrap_or("-"),
8010            pair["new_text"].as_str().unwrap_or("").trim()
8011        ));
8012    }
8013    let closed = body["closed"].as_u64().unwrap_or(0);
8014    let live = body["live"].as_u64().unwrap_or(0);
8015    if body["applied"].as_bool().unwrap_or(false) {
8016        out.push_str(&format!("{closed} of {live} live memories closed\n"));
8017    } else {
8018        out.push_str(&format!(
8019            "{closed} of {live} live memories would close; `ljos consolidate --apply` closes them\n"
8020        ));
8021    }
8022    out
8023}
8024
8025/// One line per hub: score, links, id, text.
8026pub fn format_hubs(body: &Value) -> String {
8027    let mut out = String::new();
8028    for hub in body["hubs"]
8029        .as_array()
8030        .into_iter()
8031        .flatten()
8032        .filter(|a| reviewable(a))
8033    {
8034        out.push_str(&format!(
8035            "{:.4}\t{}\t{}\t{}\n",
8036            hub["score"].as_f64().unwrap_or(0.0),
8037            hub["links"].as_u64().unwrap_or(0),
8038            hub["id"].as_str().unwrap_or("-"),
8039            hub["text"].as_str().unwrap_or("")
8040        ));
8041    }
8042    out
8043}
8044
8045/// What an activation number is, and whether this call rewrote weights.
8046///
8047/// The number on a row is spread from the search seeds along the pack's
8048/// links. It is not a relevance rank. `fire` strengthens the links of the
8049/// strongest rows under the lens that walked them, so the next walk of the
8050/// same cue follows those links. A weak island does not fire.
8051#[must_use]
8052pub fn island_reading(body: &Value) -> String {
8053    let lens = body["as"].as_str().unwrap_or("").trim();
8054    let fired = body["fired"].as_u64().unwrap_or(0);
8055    let held = body["held"].as_bool().unwrap_or(false);
8056    let weak = body["weak"].as_bool().unwrap_or(false);
8057    let rows = body["island"].as_array().is_some_and(|a| !a.is_empty());
8058    if !rows && !weak && fired == 0 && !held && lens.is_empty() {
8059        return String::new();
8060    }
8061    let mut out = String::new();
8062    if lens.is_empty() {
8063        out.push_str(
8064            "Seat island. Activation is spread from search seeds along links. It is not a relevance rank.\n",
8065        );
8066    } else {
8067        out.push_str(&format!(
8068            "Persona {lens} island. The spread follows the weights that persona fired, not the seat's. It is not a relevance rank.\n"
8069        ));
8070    }
8071    if weak {
8072        out.push_str(
8073            "Not fired: fewer than two seeds that two scorers agreed on, so firing would wire the wrong links.\n",
8074        );
8075    } else if held {
8076        out.push_str(
8077            "Not fired: this cue already fired inside the hour, so the weights were left as they were.\n",
8078        );
8079    } else if fired > 0 {
8080        let who = if lens.is_empty() { "the seat" } else { lens };
8081        out.push_str(&format!(
8082            "Fired: {fired} links gained weight under {who}. The next walk of this cue follows those links. Fire only after the island was used.\n"
8083        ));
8084        if let Some(id) = body["trace"].as_str().filter(|s| !s.is_empty()) {
8085            out.push_str(&format!(
8086                "Recorded as trace {id}: the links this fire strengthened.\n"
8087            ));
8088        } else if let Some(err) = body["trace_error"].as_str() {
8089            out.push_str(&format!("The fire was not recorded: {err}\n"));
8090        }
8091    } else {
8092        out.push_str(
8093            "Not fired. Pass fire after the island is used, so the links that served gain weight. Firing on the first look wires whatever the spread touched.\n",
8094        );
8095    }
8096    out
8097}
8098
8099/// One line per activated memory: activation, seed mark, id, text.
8100pub fn format_island(body: &Value) -> String {
8101    let mut out = island_reading(body);
8102    let now = now_utc();
8103    if body["weak"].as_bool().unwrap_or(false) {
8104        out.push_str(&format!(
8105            "weak island: {} seed{} two scorers agreed on{}; read it as the pack's best-connected cluster, not as what the cue is about; it will not fire\n",
8106            body["agreed_seeds"].as_u64().unwrap_or(0),
8107            if body["agreed_seeds"].as_u64().unwrap_or(0) == 1 { "" } else { "s" },
8108            if body["dense"].as_bool().unwrap_or(true) { "" } else { "; the encoder is down, ranking is lexical only" }
8109        ));
8110    }
8111    for atom in body["island"]
8112        .as_array()
8113        .into_iter()
8114        .flatten()
8115        .filter(|a| reviewable(a))
8116    {
8117        out.push_str(&format!(
8118            "{:.3}\t{}\t{}\t{}\t{}\n",
8119            atom["activation"].as_f64().unwrap_or(0.0),
8120            if atom["seed"].as_bool().unwrap_or(false) {
8121                "seed"
8122            } else {
8123                "    "
8124            },
8125            atom["id"].as_str().unwrap_or("-"),
8126            age_of(atom["ts"].as_str(), &now),
8127            atom["text"].as_str().unwrap_or("")
8128        ));
8129    }
8130    out
8131}
8132
8133pub fn packset_search(query: &str) -> Result<Vec<Hit>> {
8134    packset_search_opts(query, 10, false)
8135}
8136
8137/// [`packset_search`] with a limit and the cross-encoder rerank: the
8138/// writer scores the top hits against the query with its reranker, which
8139/// costs a model call and buys precision. For a brief or a person reading,
8140/// not for the hook.
8141pub fn packset_search_opts(query: &str, limit: u32, rerank: bool) -> Result<Vec<Hit>> {
8142    packset_search_as_of(query, limit, None, rerank)
8143}
8144
8145/// [`packset_search_opts`] asked of the pack as it stood at `as_of` (RFC
8146/// 3339; a date alone reads as its start): only memories live then answer,
8147/// what was withdrawn since included and what was learnt since left out.
8148/// `None` is now. This is the question "what did the seat know when it
8149/// decided that", and the pack keeps every record so it can be asked.
8150pub fn packset_search_as_of(
8151    query: &str,
8152    limit: u32,
8153    as_of: Option<&str>,
8154    rerank: bool,
8155) -> Result<Vec<Hit>> {
8156    let q = query.trim();
8157    if q.is_empty() {
8158        bail!("search: empty query");
8159    }
8160    let as_of = as_of.map(str::trim).filter(|s| !s.is_empty());
8161    let stamp = match as_of {
8162        Some(at) if days_of_stamp(Some(at)).is_none() => {
8163            bail!("search: --as-of {at:?} is not a date; write YYYY-MM-DD or RFC 3339")
8164        }
8165        // A date alone is its start; the pack wants the instant spelt out.
8166        Some(at) if at.len() == 10 => Some(format!("{at}T00:00:00.000Z")),
8167        Some(at) => Some(at.to_string()),
8168        None => None,
8169    };
8170    with_writer(|| {
8171        let client = pack()?;
8172        let workspace = client.workspace();
8173        client
8174            .search_opts(&workspace, q, limit, stamp.as_deref(), rerank)
8175            .context("search: GET /v1/search failed")
8176    })
8177}
8178
8179/// The actor id in a `claimdag get` line (`assignee=HEX`), if any.
8180/// The live generation on a `claimdag get` line: the `gen=N` field.
8181fn gen_of(get_output: &str) -> Option<u64> {
8182    get_output
8183        .split_whitespace()
8184        .find_map(|w| w.strip_prefix("gen="))
8185        .and_then(|g| g.parse().ok())
8186}
8187
8188/// The generation a finish or complete acts on: the one given, else the live
8189/// one read off the claim graph, so a sitting need not carry a number the
8190/// graph already holds. A stale explicit gen is still refused by the graph.
8191fn live_gen(id: &str, gen: Option<u64>) -> Result<u64> {
8192    if let Some(g) = gen {
8193        return Ok(g);
8194    }
8195    let got = run_captured("claimdag", &["get", id])?.stdout;
8196    gen_of(&got).ok_or_else(|| {
8197        anyhow::anyhow!("complete: no generation on the claim graph's line for {id}: {got}")
8198    })
8199}
8200
8201/// Refusal when another conversation holds the node: names that holder
8202/// and still says `held by another`, so a concurrent sitting can match it.
8203#[must_use]
8204pub fn held_by_another_message(node: &str, assignee: &str, hold: &Hold, running: &str) -> String {
8205    format!(
8206        "claim: {node} is held by another ({}, seat {}, {running}, since {}), not by {assignee} (this one). That conversation frees it with `ljos release {node}` or `ljos complete {node} --gen` from its sitting; when it is gone, `ljos release {node} --assignee {}` releases it under the name it held",
8207        hold.assignee,
8208        hold.seat,
8209        hold.since,
8210        hold.assignee
8211    )
8212}
8213
8214fn holder_of(get_output: &str) -> Option<String> {
8215    get_output
8216        .split_whitespace()
8217        .find_map(|w| w.strip_prefix("assignee="))
8218        .filter(|h| h.len() == 32 && *h != "00000000000000000000000000000000")
8219        .map(str::to_string)
8220}
8221
8222/// Stamp the tracker to match the claim graph. The claim graph holds
8223/// occupancy; the tracker answers who holds what, and a sitting that takes
8224/// one without the other leaves `vissue claims` blind to a held issue.
8225/// `vissue claim ISSUE` moves the issue to STARTED under `assignee` and is
8226/// idempotent for the name that already holds it. A node the tracker does
8227/// not know (a raw claim-graph id) has nothing to stamp and gives `None`.
8228///
8229/// # Errors
8230///
8231/// The tracker refusing the name. The claim graph already holds the node
8232/// by then, so the message names the verb that frees it.
8233fn tracker_claim_needs_force(text: &str) -> bool {
8234    text.contains("pass --force") || text.contains("claimed by")
8235}
8236
8237fn stamp_tracker_claim(node: &str, assignee: &str, force: bool) -> Result<Said> {
8238    if force {
8239        run_captured_as("vissue", &["claim", node, "--force"], Some(assignee))
8240    } else {
8241        run_captured_as("vissue", &["claim", node], Some(assignee))
8242    }
8243}
8244
8245fn stamp_tracker(node: &str, assignee: &str) -> Result<Option<String>> {
8246    if run_captured("vissue", &["show", node, "--json"]).is_err() {
8247        return Ok(None);
8248    }
8249    let claimed = match stamp_tracker_claim(node, assignee, false) {
8250        Ok(said) => Ok(said),
8251        Err(e) => {
8252            let text = e.to_string();
8253            // A new sitting on work the tracker already closed: reopen the
8254            // heading to STARTED, then stamp occupancy. The claim graph
8255            // already took the node.
8256            let after_reopen = if text.contains("already DONE")
8257                || text.contains("already CANCELLED")
8258            {
8259                run_captured("vissue", &["update", node, "-s", "STARTED"]).with_context(|| {
8260                    format!(
8261                        "claim: the claim graph took {node} but the tracker would not reopen {node} to STARTED under {assignee}"
8262                    )
8263                })?;
8264                stamp_tracker_claim(node, assignee, false)
8265            } else {
8266                Err(e)
8267            };
8268            match after_reopen {
8269                Ok(said) => Ok(said),
8270                Err(e2) if tracker_claim_needs_force(&e2.to_string()) => {
8271                    stamp_tracker_claim(node, assignee, true)
8272                }
8273                Err(e2) => Err(e2),
8274            }
8275        }
8276    };
8277    claimed
8278        .map(|_| Some(format!("tracker: {node} STARTED under {assignee}")))
8279        .with_context(|| {
8280            format!(
8281                "claim: the claim graph took {node} but the tracker refused to stamp it under {assignee}; `ljos release {node} --assignee {assignee}` frees the graph, or `vissue claim {node} --force` takes the tracker over"
8282            )
8283        })
8284}
8285
8286/// What the claim graph said, followed by the tracker's line when the node
8287/// is an issue.
8288fn with_tracker(said: String, node: &str, assignee: &str) -> Result<String> {
8289    let mut out = said;
8290    if let Some(line) = stamp_tracker(node, assignee)? {
8291        if !out.is_empty() && !out.ends_with('\n') {
8292            out.push('\n');
8293        }
8294        out.push_str(&line);
8295        out.push('\n');
8296    }
8297    Ok(out)
8298}
8299
8300/// Take a session node, and when the claim graph refuses because the
8301/// assignee still holds another node, say which tracker id that is and the
8302/// two verbs that free it. The bare refusal names a 32-hex id nobody can
8303/// act on.
8304///
8305/// # Errors
8306///
8307/// The refusal, explained, or any other failure of the claim graph.
8308pub fn claim(node: &str, assignee: &str) -> Result<String> {
8309    let id = node_for(node)?;
8310    let actor = work_id(&occupancy_scope(assignee, node));
8311    match run_captured("claimdag", &["claim", &id, "--assignee", &actor]) {
8312        Ok(said) => {
8313            write_hold(&actor, assignee, node);
8314            with_tracker(said.stdout, node, assignee)
8315        }
8316        Err(e) => {
8317            let text = e.to_string();
8318            // A tracker id maps to one node. When an earlier sitting finished
8319            // it, this is a new sitting on the same work: reopen, then claim.
8320            if ["status done", "status failed", "status cancelled"]
8321                .iter()
8322                .any(|s| text.contains(s))
8323            {
8324                run_captured("claimdag", &["reopen", &id, "--actor", &actor])?;
8325                let said = run_captured("claimdag", &["claim", &id, "--assignee", &actor])?;
8326                write_hold(&actor, assignee, node);
8327                return with_tracker(
8328                    format!("reopened a finished session node\n{}", said.stdout),
8329                    node,
8330                    assignee,
8331                );
8332            }
8333            // The node is already claimed. By this name it is a sitting
8334            // resumed: renew the lease and go on. By another it is theirs.
8335            if text.contains("status claimed") {
8336                let got = run_captured("claimdag", &["get", &id])?.stdout;
8337                return match holder_of(&got) {
8338                    Some(holder) if holder == actor => {
8339                        let renewed = run_captured("claimdag", &["renew", &id, "--actor", &actor])
8340                            .map(|s| s.stdout)
8341                            .unwrap_or_default();
8342                        write_hold(&actor, assignee, node);
8343                        with_tracker(
8344                            format!("already held by {assignee}; the sitting resumes\n{renewed}"),
8345                            node,
8346                            assignee,
8347                        )
8348                    }
8349                    Some(holder) => match read_hold(&holder) {
8350                        // This seat's own conversation, and it is gone: a
8351                        // runner that exited without finishing. The seat
8352                        // owns its conversations, so the sitting takes the
8353                        // node over rather than waiting on nobody.
8354                        Some(h) if h.seat == seat_name() && !hold_alive(&h) => {
8355                            run_captured("claimdag", &["release", &id, "--actor", &holder])?;
8356                            drop_hold(&holder);
8357                            let said =
8358                                run_captured("claimdag", &["claim", &id, "--assignee", &actor])?;
8359                            write_hold(&actor, assignee, node);
8360                            with_tracker(
8361                                format!(
8362                                    "took over from {}, this seat's conversation, gone (held since {})\n{}",
8363                                    h.assignee, h.since, said.stdout
8364                                ),
8365                                node,
8366                                assignee,
8367                            )
8368                        }
8369                        Some(h) => bail!(
8370                            "{}",
8371                            held_by_another_message(
8372                                node,
8373                                assignee,
8374                                &h,
8375                                if hold_alive(&h) {
8376                                    "still running"
8377                                } else {
8378                                    "its runner is gone"
8379                                }
8380                            )
8381                        ),
8382                        None => bail!(
8383                            "claim: {node} is held by another conversation, not by {assignee} (this one; `ljos seat` says where the name came from), and no record on this host names it. That conversation frees it with `ljos release {node}` or `ljos complete {node} --gen` from its sitting; a conversation that is gone is released with `ljos release {node} --assignee NAME` under the name it held"
8384                        ),
8385                    },
8386                    None => Err(e),
8387                };
8388            }
8389            if !text.contains("assignee busy") {
8390                return Err(e);
8391            }
8392            let held: Vec<String> = text
8393                .split_whitespace()
8394                .filter(|w| w.len() == 32 && w.chars().all(|c| c.is_ascii_hexdigit()))
8395                .map(str::to_string)
8396                .collect();
8397            let mut lines = vec![format!(
8398                "claim: {assignee} already holds a live node; one live claim per assignee."
8399            )];
8400            for hex in &held {
8401                let name = run_captured("claimdag", &["get", hex])
8402                    .ok()
8403                    .and_then(|s| {
8404                        s.stdout
8405                            .lines()
8406                            .next()
8407                            .and_then(|l| l.split_whitespace().last())
8408                            .map(str::to_string)
8409                    })
8410                    .unwrap_or_else(|| hex.clone());
8411                lines.push(format!(
8412                    "  holds {name}: `ljos complete {name} --status done` finishes it, \
8413                     `ljos release {name} --assignee {assignee}` hands it back"
8414                ));
8415            }
8416            bail!("{}", lines.join("\n"))
8417        }
8418    }
8419}
8420
8421/// Hand a session node back before it is terminal: ready again, assignee
8422/// cleared, generation moved.
8423///
8424/// # Errors
8425///
8426/// The claim graph's refusal: not held, or held by somebody else.
8427pub fn release(node: &str, assignee: &str) -> Result<String> {
8428    let id = node_for(node)?;
8429    let actor = work_id(&occupancy_scope(assignee, node));
8430    let said = run_captured("claimdag", &["release", &id, "--actor", &actor])?;
8431    drop_hold(&actor);
8432    drop_playbook(node);
8433    Ok(said.stdout)
8434}
8435
8436/// What a conversation left beside the claim graph when it took a node:
8437/// the name it held under, its seat, the runner process, and when. The
8438/// claim graph keeps only the hashed actor; this is how a later
8439/// conversation that finds the node held learns who holds it, and whether
8440/// that conversation is still running.
8441#[derive(Debug, Clone, PartialEq, Eq)]
8442pub struct Hold {
8443    pub assignee: String,
8444    pub seat: String,
8445    pub pid: u32,
8446    pub comm: String,
8447    pub since: String,
8448}
8449
8450fn hold_record_path(actor: &str) -> PathBuf {
8451    runtime_dir().join(format!("hold-{actor}"))
8452}
8453
8454/// The process that owns this conversation: the first ancestor that is
8455/// not a shell or a wrapper. For the MCP server that is the runner; for
8456/// the command line it is the runner above the shell, else the shell the
8457/// person types into.
8458fn conversation_process() -> (u32, String) {
8459    let chain = ancestry();
8460    chain
8461        .iter()
8462        .skip(1)
8463        .find(|(_, comm)| !WRAPPERS.contains(&comm.as_str()))
8464        .or_else(|| chain.get(1))
8465        .cloned()
8466        .unwrap_or((std::process::id(), String::new()))
8467}
8468
8469fn write_hold(actor: &str, assignee: &str, node: &str) {
8470    let (pid, comm) = conversation_process();
8471    let path = hold_record_path(actor);
8472    if let Some(dir) = path.parent() {
8473        let _ = std::fs::create_dir_all(dir);
8474    }
8475    // The issue is the sixth line: a subagent reads what its parent holds
8476    // from here, since asking the tracker takes longer than a hook may run.
8477    let _ = std::fs::write(
8478        path,
8479        format!(
8480            "{assignee}\n{}\n{pid}\n{comm}\n{}\n{node}\n",
8481            seat_name(),
8482            now_utc()
8483        ),
8484    );
8485}
8486
8487/// The issue the newest hold record of this conversation names: a record
8488/// whose holder is one of `holders`, or whose conversation process is an
8489/// ancestor of this one. File reads only, so a hook can afford it.
8490fn held_from_records(holders: &[String]) -> Option<String> {
8491    let pids: Vec<String> = ancestry().iter().map(|(p, _)| p.to_string()).collect();
8492    let mut best: Option<(String, String)> = None;
8493    for entry in std::fs::read_dir(runtime_dir()).ok()?.flatten() {
8494        if !entry.file_name().to_string_lossy().starts_with("hold-") {
8495            continue;
8496        }
8497        let Ok(text) = std::fs::read_to_string(entry.path()) else {
8498            continue;
8499        };
8500        let lines: Vec<&str> = text.lines().map(str::trim).collect();
8501        let (Some(holder), Some(pid), Some(at), Some(node)) =
8502            (lines.first(), lines.get(2), lines.get(4), lines.get(5))
8503        else {
8504            continue;
8505        };
8506        let ours = holders.iter().any(|h| h == holder) || pids.iter().any(|p| p == pid);
8507        if ours && !node.is_empty() && best.as_ref().is_none_or(|(t, _)| *at > t.as_str()) {
8508            best = Some(((*at).to_string(), (*node).to_string()));
8509        }
8510    }
8511    best.map(|(_, node)| node)
8512}
8513
8514fn drop_hold(actor: &str) {
8515    let _ = std::fs::remove_file(hold_record_path(actor));
8516}
8517
8518fn read_hold(actor: &str) -> Option<Hold> {
8519    let text = std::fs::read_to_string(hold_record_path(actor)).ok()?;
8520    let mut lines = text.lines();
8521    Some(Hold {
8522        assignee: lines.next()?.to_string(),
8523        seat: lines.next()?.to_string(),
8524        pid: lines.next()?.trim().parse().ok()?,
8525        comm: lines.next()?.to_string(),
8526        since: lines.next()?.to_string(),
8527    })
8528}
8529
8530/// Whether the conversation that wrote a hold is still running: its
8531/// process exists and is still the program it was. Off Linux nothing can
8532/// be read, and an unknown conversation is taken as running.
8533fn hold_alive(hold: &Hold) -> bool {
8534    match parent_and_comm(hold.pid) {
8535        Some((_, comm)) => comm == hold.comm,
8536        None => !cfg!(target_os = "linux"),
8537    }
8538}
8539
8540/// `; revises N earlier` when the pack closed earlier memories' windows
8541/// for this one (same kind, a rewrite of the same claim or an explicit
8542/// `supersedes`), else empty. The revision is the pack's; this names it.
8543fn revision_note(body: &Value) -> String {
8544    match body["supersedes"].as_array().map(Vec::len).unwrap_or(0) {
8545        0 => String::new(),
8546        1 => "; revises 1 earlier memory, now closed".to_string(),
8547        n => format!("; revises {n} earlier memories, now closed"),
8548    }
8549}
8550
8551/// One issue as JSON from the tracker library. Same card as `vissue show --json`.
8552///
8553/// # Errors
8554///
8555/// The tracker root cannot be resolved, or `id` is not in it.
8556pub fn tracker_show_json(id: &str) -> Result<Value> {
8557    let layout = vissue_core::Layout::resolve(None, None).map_err(anyhow::Error::from)?;
8558    let found = vissue_core::Router::load(layout)
8559        .map_err(anyhow::Error::from)?
8560        .find_by_id(id)
8561        .map_err(anyhow::Error::from)?;
8562    vissue_core::agent::show_json(&found.layout, id).map_err(anyhow::Error::from)
8563}
8564
8565/// Whether an issue asks for a decision: a `decision` tag, a `decision`
8566/// type, or a body line opening `Options:`.
8567#[must_use]
8568pub fn is_decision(v: &Value) -> bool {
8569    let tagged = v["tags"]
8570        .as_array()
8571        .is_some_and(|t| t.iter().any(|x| x.as_str() == Some("decision")));
8572    let typed = v["properties"]["TYPE"].as_str() == Some("decision");
8573    let listed = v["body"]
8574        .as_str()
8575        .is_some_and(|b| b.lines().any(|l| l.trim_start().starts_with("Options:")));
8576    tagged || typed || listed
8577}
8578
8579/// The issue's title, for a cue, from the tracker.
8580fn issue_title(issue: &str) -> Result<String> {
8581    let v = tracker_show_json(issue)?;
8582    Ok(v.get("title")
8583        .and_then(Value::as_str)
8584        .unwrap_or(issue)
8585        .to_string())
8586}
8587
8588/// One dated event on an issue's timeline, from whichever store holds it.
8589#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
8590pub struct Event {
8591    /// Days since the epoch of the event's date.
8592    pub days: i64,
8593    /// `HH:MM` when the stamp carries a time, else empty; sorts after the
8594    /// day.
8595    pub clock: String,
8596    /// `tracker`, `deed` or `memory`: the store the event came from.
8597    pub source: &'static str,
8598    /// The event in one line.
8599    pub text: String,
8600}
8601
8602/// The issue's timeline as dated rows. The HUD paints this; it does not
8603/// parse `ljos timeline` stdout. Tracker rows come from
8604/// [`vissue_core::agent::show_json`]. Deed rows still shell `deedar evidence`,
8605/// a named gap (`deedar::Store::evidence`).
8606///
8607/// # Errors
8608///
8609/// The tracker not answering. A deed store or pack that does not answer
8610/// leaves its rows out; the tracker's rows are the spine.
8611pub fn timeline_events(issue: &str, limit: usize) -> Result<Vec<Event>> {
8612    Ok(timeline_of(issue, limit)?.1)
8613}
8614
8615fn timeline_of(issue: &str, limit: usize) -> Result<(String, Vec<Event>)> {
8616    let v = tracker_show_json(issue)?;
8617    let title = v["title"].as_str().unwrap_or(issue).to_string();
8618    let mut events = tracker_events(&v);
8619    for accession in v["deeds"].as_array().into_iter().flatten() {
8620        let Some(accession) = accession.as_str() else {
8621            continue;
8622        };
8623        if let Ok(said) = run_captured("deedar", &["evidence", accession]) {
8624            if let Some(ev) = deed_event(accession, &said.stdout, local_offset) {
8625                events.push(ev);
8626            }
8627        }
8628    }
8629    if let Ok(island) = packset_island(&title, false) {
8630        for atom in island["island"]
8631            .as_array()
8632            .into_iter()
8633            .flatten()
8634            .filter(|a| reviewable(a))
8635            .take(8)
8636        {
8637            if let Some((days, clock)) = stamp_key(atom["ts"].as_str().map(local_stamp).as_deref())
8638            {
8639                events.push(Event {
8640                    days,
8641                    clock,
8642                    source: "memory",
8643                    text: format!(
8644                        "[{}] {}",
8645                        atom["kind"].as_str().unwrap_or("claim"),
8646                        atom["text"].as_str().unwrap_or("").trim()
8647                    ),
8648                });
8649            }
8650        }
8651    }
8652    events.sort_by(|a, b| (a.days, &a.clock).cmp(&(b.days, &b.clock)));
8653    let skip = events.len().saturating_sub(limit);
8654    Ok((title, events[skip..].to_vec()))
8655}
8656
8657/// The issue's timeline, the three stores read as one dated list, oldest
8658/// first: the tracker's logbook (creation, state changes, claims, notes),
8659/// the deeds the issue cites with the time each was produced, and the
8660/// memories the issue's title activates with the time each was written.
8661/// The reader gets time as data, not as stamps to do arithmetic on: each
8662/// line carries its age and the gap since the line before it, and a later
8663/// line supersedes an earlier one on the same matter.
8664///
8665/// # Errors
8666///
8667/// The tracker not answering. A deed store or pack that does not answer
8668/// leaves its rows out; the tracker's rows are the spine.
8669pub fn timeline(issue: &str, limit: usize) -> Result<String> {
8670    let (title, events) = timeline_of(issue, limit)?;
8671    Ok(format!(
8672        "timeline of {issue}: {title}
8673{}",
8674        format_events(&events, &now_local())
8675    ))
8676}
8677
8678/// The reader's seconds east of UTC at the instant `secs`. The tracker
8679/// writes org stamps in local wall time; a timeline reads every store in it.
8680fn local_offset(secs: i64) -> i64 {
8681    use chrono::{Local, Offset, TimeZone};
8682    Local
8683        .timestamp_opt(secs, 0)
8684        .single()
8685        .map_or(0, |t| i64::from(t.offset().fix().local_minus_utc()))
8686}
8687
8688/// Now in local wall time, `YYYY-MM-DDTHH:MM:SS`, the zone of the tracker's
8689/// org stamps.
8690fn now_local() -> String {
8691    chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string()
8692}
8693
8694/// An RFC 3339 stamp as local wall time, `YYYY-MM-DDTHH:MM`; any other shape
8695/// comes back unchanged.
8696fn local_stamp(ts: &str) -> String {
8697    chrono::DateTime::parse_from_rfc3339(ts.trim()).map_or_else(
8698        |_| ts.to_string(),
8699        |t| {
8700            t.with_timezone(&chrono::Local)
8701                .format("%Y-%m-%dT%H:%M")
8702                .to_string()
8703        },
8704    )
8705}
8706
8707/// The tracker's own events on an issue: created, each state change, the
8708/// claim, each note.
8709fn tracker_events(v: &Value) -> Vec<Event> {
8710    let mut events = Vec::new();
8711    let mut push = |stamp: Option<&str>, source: &'static str, text: String| {
8712        if let Some((days, clock)) = stamp_key(stamp) {
8713            events.push(Event {
8714                days,
8715                clock,
8716                source,
8717                text,
8718            });
8719        }
8720    };
8721    push(
8722        v["properties"]["CREATED"].as_str(),
8723        "tracker",
8724        "created".to_string(),
8725    );
8726    if let Some(by) = v["claimed_by"].as_str() {
8727        push(
8728            v["claimed_at"].as_str(),
8729            "tracker",
8730            format!("claimed by {by}"),
8731        );
8732    }
8733    if let Some(d) = v["properties"]["DEADLINE"].as_str() {
8734        push(
8735            v["properties"]["DEADLINE"].as_str(),
8736            "tracker",
8737            format!("DEADLINE {d}"),
8738        );
8739    }
8740    if let Some(s) = v["properties"]["SCHEDULED"].as_str() {
8741        push(
8742            v["properties"]["SCHEDULED"].as_str(),
8743            "tracker",
8744            format!("SCHEDULED {s}"),
8745        );
8746    }
8747    // The logbook is newest first; the timeline reads oldest first.
8748    for e in v["logbook"].as_array().into_iter().flatten().rev() {
8749        let stamp = e["timestamp"].as_str();
8750        if let Some(note) = e["note"].as_str() {
8751            push(stamp, "tracker", format!("note: {}", note.trim()));
8752        } else if let Some(to) = e["to_state"].as_str() {
8753            push(
8754                stamp,
8755                "tracker",
8756                format!("{} -> {to}", e["from_state"].as_str().unwrap_or("-")),
8757            );
8758        }
8759    }
8760    events
8761}
8762
8763/// A deed's event from `deedar evidence`: the time it was produced, by
8764/// whom.
8765/// `offset_of` gives the reader's seconds east of UTC at that instant, so
8766/// the deed lands on the same wall-clock day as the tracker's org stamps.
8767fn deed_event(accession: &str, evidence: &str, offset_of: fn(i64) -> i64) -> Option<Event> {
8768    let utc: i64 = evidence
8769        .lines()
8770        .find_map(|l| l.strip_prefix("time="))?
8771        .trim()
8772        .parse()
8773        .ok()?;
8774    let secs = utc + offset_of(utc);
8775    let by = evidence
8776        .lines()
8777        .find_map(|l| l.strip_prefix("producedBy="))
8778        .map(str::trim)
8779        .unwrap_or("-");
8780    Some(Event {
8781        days: secs.div_euclid(86_400),
8782        clock: format!(
8783            "{:02}:{:02}",
8784            secs.rem_euclid(86_400) / 3600,
8785            secs.rem_euclid(86_400) % 3600 / 60
8786        ),
8787        source: "deed",
8788        text: format!("{accession} produced by {by}"),
8789    })
8790}
8791
8792/// The sort key of a stamp in any of the three stores' shapes: RFC 3339
8793/// (`2026-09-12T21:54:00Z`), an org stamp (`[2026-09-12 Sat 21:54]`), or a
8794/// date alone. Day, then `HH:MM` when the stamp has one.
8795fn stamp_key(stamp: Option<&str>) -> Option<(i64, String)> {
8796    let s = stamp?
8797        .trim()
8798        .trim_start_matches(['[', '<'])
8799        .trim_end_matches([']', '>']);
8800    let days = days_of_stamp(Some(s))?;
8801    let rest = &s[10..];
8802    let clock = rest
8803        .split(['T', ' '])
8804        .find(|t| t.len() >= 5 && t.as_bytes()[2] == b':')
8805        .map(|t| t[..5].to_string())
8806        .unwrap_or_default();
8807    Some((days, clock))
8808}
8809
8810/// One line per event: date, age, gap since the line before, store, text.
8811fn format_events(events: &[Event], now: &str) -> String {
8812    let today = days_of_stamp(Some(now)).unwrap_or(0);
8813    let mut out = String::new();
8814    let mut last: Option<i64> = None;
8815    for e in events {
8816        let gap = match last {
8817            None => String::new(),
8818            Some(d) if e.days == d => "same day".to_string(),
8819            Some(d) => format!("+{} d", e.days - d),
8820        };
8821        last = Some(e.days);
8822        out.push_str(&format!(
8823            "{} {}	{}	{}	{}	{}
8824",
8825            civil_of_days(e.days),
8826            e.clock,
8827            age_of(Some(&civil_of_days(e.days)), &civil_of_days(today)),
8828            gap,
8829            e.source,
8830            e.text
8831        ));
8832    }
8833    out
8834}
8835
8836/// `YYYY-MM-DD` of a day count since the epoch.
8837fn civil_of_days(days: i64) -> String {
8838    let z = days + 719_468;
8839    let era = z.div_euclid(146_097);
8840    let doe = z.rem_euclid(146_097);
8841    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
8842    let y = yoe + era * 400;
8843    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
8844    let mp = (5 * doy + 2) / 153;
8845    let d = doy - (153 * mp + 2) / 5 + 1;
8846    let m = if mp < 10 { mp + 3 } else { mp - 9 };
8847    let y = if m <= 2 { y + 1 } else { y };
8848    format!("{y:04}-{m:02}-{d:02}")
8849}
8850
8851/// Open a sitting on an issue, in the protocol's order, and stop at the
8852/// first habitat that does not answer: doctor, cards, the review clock,
8853/// the island the issue's title activates, the working set, the timeline,
8854/// the claim.
8855/// One verb, so the loop that makes the seat a memory runs every time and
8856/// not only when somebody remembers to run it.
8857///
8858/// # Errors
8859///
8860/// A required habitat down, or the claim refused (the refusal names what
8861/// the assignee still holds).
8862pub fn sitting(issue: &str, assignee: &str, cards_dir: &Path) -> Result<String> {
8863    sitting_gated(issue, assignee, cards_dir, false, None)
8864}
8865
8866/// The blockers of an issue that are still open, as `id (STATE)`, read
8867/// from the tracker. Empty when the issue is workable, or when the tracker
8868/// does not answer (the sitting's doctor already said so).
8869pub fn open_blockers(issue: &str) -> Vec<String> {
8870    let Ok(shown) = tracker_show_json(issue) else {
8871        return Vec::new();
8872    };
8873    let mut out = Vec::new();
8874    for id in shown["blocked_by"]
8875        .as_array()
8876        .into_iter()
8877        .flatten()
8878        .filter_map(Value::as_str)
8879    {
8880        let state = tracker_show_json(id)
8881            .ok()
8882            .and_then(|v| v["state"].as_str().map(str::to_string))
8883            .unwrap_or_else(|| "?".to_string());
8884        if !matches!(state.as_str(), "DONE" | "CANCELLED") {
8885            out.push(format!("{id} ({state})"));
8886        }
8887    }
8888    out
8889}
8890
8891/// [`sitting`], and with `anyway` the claim goes through even when the
8892/// issue's blockers are open. Without it a blocked issue is refused before
8893/// anything is claimed: the tracker's graph says what is workable, and a
8894/// seat that sits on blocked work sits on nothing it can finish.
8895/// `playbook` names the recipe copied into `== playbook` before recall;
8896/// absent, a name already bound, else a closed-set token in the title,
8897/// else `sit`. Sitting always binds one of the five before claim. Finish
8898/// and release drop the sticky name.
8899pub fn sitting_gated(
8900    issue: &str,
8901    assignee: &str,
8902    cards_dir: &Path,
8903    anyway: bool,
8904    playbook: Option<&str>,
8905) -> Result<String> {
8906    let mut out = String::new();
8907    let rows = doctor_seat();
8908    out.push_str("== doctor\n");
8909    out.push_str(&format_doctor(&rows));
8910    if !healthy(&rows) {
8911        bail!("{out}sitting: a required habitat does not answer; nothing was claimed");
8912    }
8913    // Other machines' memories of this scope arrive before the island is
8914    // walked, or the sitting orients on half the seat.
8915    out.push_str("== sync\n");
8916    out.push_str(&sync::sync_repo(true, false).unwrap_or_else(|e| format!("sync: {e:#}\n")));
8917    out.push_str("== cards\n");
8918    out.push_str(&cards(cards_dir)?);
8919    let title = issue_title(issue)?;
8920    let island = packset_island(&title, false)?;
8921    out.push_str("== due\n");
8922    out.push_str(&sitting_due_report(&island)?);
8923    out.push_str(&format!("== island: {title}\n"));
8924    // The strongest eight: a sitting wants orientation, not the whole
8925    // cluster; `ljos island` prints it all.
8926    let mut top = island.clone();
8927    if let Some(rows) = top["island"].as_array_mut() {
8928        rows.truncate(8);
8929    }
8930    out.push_str(&format_island(&top));
8931    out.push_str("== blockers\n");
8932    let blockers = open_blockers(issue);
8933    if blockers.is_empty() {
8934        out.push_str("none open; the issue is workable\n");
8935    } else {
8936        out.push_str(&format!("open: {}\n", blockers.join(", ")));
8937        if !anyway {
8938            bail!(
8939                "{out}sitting: {issue} is blocked by {}; finish those first, or `ljos sitting {issue} --anyway` to sit on it regardless. Nothing was claimed",
8940                blockers.join(", ")
8941            );
8942        }
8943        out.push_str("sitting anyway, as asked\n");
8944    }
8945    // A decision is handed to the panel by the sitting itself: agents ran
8946    // only the verbs the loop put in front of them, never an optional
8947    // `ljos panel`, so the sitting binds the panel recipe and writes the
8948    // briefs.
8949    let decision = tracker_show_json(issue).is_ok_and(|v| is_decision(&v));
8950    let name = match (playbook, decision) {
8951        (None, true) if bound_playbook(issue).is_none() => "company-panel".to_string(),
8952        _ => resolve_sitting_playbook(issue, &title, playbook)?,
8953    };
8954    out.push_str("== playbook\n");
8955    out.push_str(&copy_playbook(issue, &name)?);
8956    if decision {
8957        out.push_str("== panel\n");
8958        let dir = runtime_dir().join(format!("panel-{issue}"));
8959        match panel(issue, &dir) {
8960            Ok(said) => out.push_str(&format!(
8961                "{issue} is a decision. Run the panel before the work: one subagent per brief, each casts its ballot, then `ljos consensus {issue}`. `ljos finish {issue} --close` refuses with fewer than two ballots.\n{said}"
8962            )),
8963            Err(e) => out.push_str(&format!("{issue} is a decision, and the panel could not be written: {e:#}\n")),
8964        }
8965    }
8966    out.push_str("== recall\n");
8967    out.push_str(&run_captured("vissue", &["recall", issue])?.stdout);
8968    // The last twelve dated events across the three stores; `ljos
8969    // timeline` prints them all.
8970    out.push_str("== timeline\n");
8971    out.push_str(&timeline(issue, SITTING_TIMELINE)?);
8972    out.push_str("== claim\n");
8973    out.push_str(&claim(issue, assignee)?);
8974    out.push_str(&persist_tracker(issue, "claimed"));
8975    Ok(out)
8976}
8977
8978/// Close a sitting: remember the lesson when there is one, fire the island
8979/// the issue's title activates, complete the session node, and learn from
8980/// the outcome when one is named. Without a lesson the report says so,
8981/// because a sitting that taught nothing worth two sentences is rare and
8982/// worth noticing.
8983///
8984/// # Errors
8985///
8986/// Any habitat refusing; the pack refuses a lesson longer than two
8987/// sentences, the claim graph a status that is not terminal.
8988/// Finish a session node only if `gen` is still the live lease.
8989///
8990/// # Errors
8991///
8992/// The claim graph refuses a stale generation, a missing actor, or a
8993/// status that is not terminal.
8994pub fn complete(
8995    node: &str,
8996    status: Option<&str>,
8997    assignee: &str,
8998    gen: Option<u64>,
8999) -> Result<String> {
9000    let id = node_for(node)?;
9001    let actor = work_id(&occupancy_scope(assignee, node));
9002    let gen_s = live_gen(&id, gen)?.to_string();
9003    let mut args = vec![
9004        "complete",
9005        id.as_str(),
9006        "--actor",
9007        actor.as_str(),
9008        "--gen",
9009        gen_s.as_str(),
9010    ];
9011    if let Some(s) = status {
9012        args.push("--status");
9013        args.push(s);
9014    }
9015    let said = run_captured("claimdag", &args)?;
9016    drop_hold(&actor);
9017    drop_playbook(node);
9018    Ok(said.stdout)
9019}
9020
9021#[expect(
9022    clippy::too_many_arguments,
9023    reason = "The public finish signature preserves its independent command options"
9024)]
9025pub fn finish(
9026    issue: &str,
9027    status: &str,
9028    lesson: Option<&str>,
9029    outcome: Option<&str>,
9030    beta: f64,
9031    assignee: &str,
9032    gen: Option<u64>,
9033    close: bool,
9034) -> Result<String> {
9035    // A decision closes on ballots, not on the say of the seat that sat on
9036    // it; refused before anything is written, so nothing half-happens.
9037    if close && tracker_show_json(issue).is_ok_and(|v| is_decision(&v)) {
9038        let said = run_captured("vissue", &["vote", issue, "--json"])?;
9039        let ballots = forecasts_from_json(&said.stdout)?.len();
9040        if ballots < 2 {
9041            bail!(
9042                "finish: {issue} is a decision and holds {ballots} ballot{}; run the panel \
9043                 (`ljos panel {issue}`), have each persona cast `ljos vote {issue} --for OPTION --expect OPTION --as NAME`, \
9044                 settle with `ljos consensus {issue}`, then --close. Nothing was written",
9045                if ballots == 1 { "" } else { "s" }
9046            );
9047        }
9048    }
9049    let mut out = String::new();
9050    match lesson.map(str::trim).filter(|l| !l.is_empty()) {
9051        Some(text) => {
9052            // A lesson learned on an issue belongs to the scope of the
9053            // repository that holds the issue, wherever it was written.
9054            let scope = sync::scope_for_issue(issue);
9055            let body = packset_write_scoped("Remember", text, issue, scope.as_deref())?;
9056            out.push_str(&format!(
9057                "remembered {}{}\n",
9058                body.get("id").and_then(Value::as_str).unwrap_or("-"),
9059                revision_note(&body)
9060            ));
9061        }
9062        None => out.push_str(
9063            "no lesson remembered this sitting; `ljos remember` takes one in two sentences\n",
9064        ),
9065    }
9066    let title = issue_title(issue)?;
9067    let island = packset_island(&title, true)?;
9068    if island["weak"].as_bool().unwrap_or(false) {
9069        out.push_str(&format!(
9070            "did not fire the island for {title:?}: its seeds are hits no two scorers agreed on{}; wiring them would tighten the wrong links\n",
9071            if island["dense"].as_bool().unwrap_or(true) { "" } else { " (the encoder is down, ranking is lexical only)" }
9072        ));
9073    } else if island["held"].as_bool().unwrap_or(false) {
9074        // Another sitting on this issue, or another persona's, fired the
9075        // same claims within the hour; the pack tightened them once.
9076        out.push_str(&format!(
9077            "the island for {title:?} fired within the hour; not fired again\n"
9078        ));
9079    } else {
9080        let fired = island["island"].as_array().map_or(0, Vec::len);
9081        out.push_str(&format!(
9082            "fired the island for {title:?}: {fired} memories. Those links gained weight under the seat, not under a persona. The next walk of this title follows them.\n"
9083        ));
9084    }
9085    let terminal = ["done", "failed", "cancelled"];
9086    if !terminal.contains(&status) {
9087        bail!("finish: status {status:?} is not one of done, failed, cancelled");
9088    }
9089    complete(issue, Some(status), assignee, gen)?;
9090    out.push_str(&format!(
9091        "completed the session node for {issue} as {status}\n"
9092    ));
9093    if let Some(option) = outcome.map(str::trim).filter(|o| !o.is_empty()) {
9094        let said = run_captured("vissue", &["vote", issue, "--json"])?;
9095        let forecasts = forecasts_from_json(&said.stdout)?;
9096        if forecasts.len() < 2 {
9097            out.push_str("outcome named but fewer than two ballots; nothing to learn from\n");
9098        } else {
9099            let ballots: Vec<(String, String)> = forecasts
9100                .iter()
9101                .map(|f| (f.agent.clone(), f.choice.clone()))
9102                .collect();
9103            let about = island_entities(issue).unwrap_or_default();
9104            let (rows, moved, calibration) =
9105                learn_and_write(&ballots, option, beta, &about, &forecasts)?;
9106            out.push_str(&learn_reading(
9107                rows.len(),
9108                moved.len(),
9109                &forecasts,
9110                option,
9111                &calibration,
9112            ));
9113            out.push('\n');
9114        }
9115    }
9116    // A sitting ending is not the work being accepted: a review can be
9117    // posted and still be open, a build can be green and still unmerged.
9118    // The ticket closes only when asked, so a blocker on it stays a blocker.
9119    if close && status.eq_ignore_ascii_case("done") {
9120        run_as("vissue", &["update", issue, "-s", "DONE"], None)
9121            .with_context(|| format!("finish: could not close the ticket {issue}"))?;
9122        out.push_str(&format!("closed the ticket {issue}\n"));
9123    } else {
9124        out.push_str(&format!(
9125            "the ticket {issue} keeps its state; `ljos finish {issue} --close` or `vissue update {issue} -s DONE` closes it when the work is accepted\n"
9126        ));
9127    }
9128    out.push_str(&persist_tracker(issue, "finished"));
9129    // What this sitting taught leaves the machine with the tracker.
9130    out.push_str(&sync::sync_repo(false, true).unwrap_or_else(|e| format!("sync: {e:#}\n")));
9131    Ok(out)
9132}
9133
9134/// An exclusive advisory lock on a file, held until dropped. Taking it
9135/// blocks; a lock that cannot be opened is no lock, and the commit goes on
9136/// as it would have without one.
9137pub struct CommitLock(Option<std::fs::File>);
9138
9139impl CommitLock {
9140    #[must_use]
9141    pub fn acquire(path: &std::path::Path) -> Self {
9142        use std::os::unix::io::AsRawFd;
9143        let Ok(file) = std::fs::OpenOptions::new()
9144            .create(true)
9145            .append(true)
9146            .open(path)
9147        else {
9148            return Self(None);
9149        };
9150        // SAFETY: flock on a descriptor this struct owns until drop.
9151        let ok = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } == 0;
9152        Self(ok.then_some(file))
9153    }
9154}
9155
9156impl Drop for CommitLock {
9157    fn drop(&mut self) {
9158        use std::os::unix::io::AsRawFd;
9159        if let Some(file) = &self.0 {
9160            // SAFETY: the descriptor is still open; unlocking it cannot fail
9161            // in a way that matters, since close releases it too.
9162            unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
9163        }
9164    }
9165}
9166
9167/// Commit the tracker file that holds `issue` and push it, when the tracker
9168/// is a git checkout. A write that stays in one working tree is lost to
9169/// every other host and to a rebuilt one; closures made on one laptop and
9170/// never committed were how tickets came back open. Only that file is
9171/// committed (`--only`), so another seat's staged work is left alone. Never
9172/// an error: the verb already happened, and the line says what did not.
9173/// `LJOS_TRACKER_GIT=off` skips it; `=commit` commits without pushing.
9174pub fn persist_tracker(issue: &str, verb: &str) -> String {
9175    let mode = std::env::var("LJOS_TRACKER_GIT").unwrap_or_default();
9176    if matches!(mode.as_str(), "off" | "0" | "false") {
9177        return "tracker git: off (LJOS_TRACKER_GIT)\n".into();
9178    }
9179    let path = match vissue_core::Layout::resolve(None, None)
9180        .and_then(vissue_core::Router::load)
9181        .and_then(|router| router.find_by_id(issue))
9182    {
9183        Ok(hit) => hit.path,
9184        Err(e) => return format!("tracker git: could not find {issue}: {e}\n"),
9185    };
9186    let Some(dir) = path.parent() else {
9187        return format!("tracker git: {} has no directory\n", path.display());
9188    };
9189    let git = |args: &[&str]| {
9190        std::process::Command::new("git")
9191            .arg("-C")
9192            .arg(dir)
9193            .args(args)
9194            .stdin(std::process::Stdio::null())
9195            .output()
9196    };
9197    let file = path.to_string_lossy().to_string();
9198    match git(&["rev-parse", "--is-inside-work-tree"]) {
9199        Ok(o) if o.status.success() => {}
9200        _ => return "tracker git: the tracker is not a git checkout\n".into(),
9201    }
9202    match git(&["status", "--porcelain", "--", &file]) {
9203        Ok(o) if o.status.success() && o.stdout.is_empty() => {
9204            return "tracker git: nothing to commit\n".into();
9205        }
9206        Ok(o) if o.status.success() => {}
9207        Ok(o) => return format!("tracker git: {}\n", first_line(&o.stderr)),
9208        Err(e) => return format!("tracker git: {e}\n"),
9209    }
9210    let message = format!("chore(issues): {issue} {verb}");
9211    // Every seat on the host commits this one checkout. The add and the
9212    // commit run under one lock in the git directory, so ljos writers queue
9213    // instead of meeting on index.lock; a git process outside ljos that
9214    // holds the index is waited out a few times before the line says so.
9215    let common = git(&["rev-parse", "--git-common-dir"])
9216        .ok()
9217        .filter(|o| o.status.success())
9218        .map(|o| dir.join(String::from_utf8_lossy(&o.stdout).trim()))
9219        .unwrap_or_else(|| dir.join(".git"));
9220    let _held = CommitLock::acquire(&common.join("ljos-commit.lock"));
9221    let mut committed = git(&["add", "--", &file])
9222        .and_then(|_| git(&["commit", "-q", "--only", "-m", &message, "--", &file]));
9223    for wait_ms in [200_u64, 400, 800, 1600, 3200] {
9224        let busy = matches!(&committed, Ok(o) if !o.status.success()
9225            && String::from_utf8_lossy(&o.stderr).contains("index.lock"));
9226        if !busy {
9227            break;
9228        }
9229        std::thread::sleep(std::time::Duration::from_millis(wait_ms));
9230        committed = git(&["add", "--", &file])
9231            .and_then(|_| git(&["commit", "-q", "--only", "-m", &message, "--", &file]));
9232    }
9233    drop(_held);
9234    match committed {
9235        Ok(o) if o.status.success() => {}
9236        Ok(o) => {
9237            return format!(
9238                "tracker git: commit refused: {}\n",
9239                first_line(if o.stderr.is_empty() {
9240                    &o.stdout
9241                } else {
9242                    &o.stderr
9243                })
9244            );
9245        }
9246        Err(e) => return format!("tracker git: {e}\n"),
9247    }
9248    if mode == "commit" {
9249        return format!("tracker git: committed {message}; not pushed (LJOS_TRACKER_GIT=commit)\n");
9250    }
9251    // A push can run a repository's pre-push hook that publishes data first
9252    // and takes minutes. The sitting waits a bounded time; a push still going
9253    // after that finishes on its own and writes its log where the line says.
9254    let log = runtime_dir().join(format!("tracker-push-{}.log", std::process::id()));
9255    let _ = std::fs::create_dir_all(runtime_dir());
9256    let Ok(out) = std::fs::File::create(&log) else {
9257        return format!("tracker git: committed {message}; push not started: no log file\n");
9258    };
9259    let err = out.try_clone();
9260    // Every other remote that carries the branch gets it too: seats that
9261    // read a tracker through different remotes see each other's claims
9262    // only when every push reaches all of them.
9263    let mirrors = tracker_upstream(dir)
9264        .and_then(|up| tracker_mirrors(dir, &up))
9265        .unwrap_or_default();
9266    // A push another host beat is merged, not left ahead: the next catch-up
9267    // only fast-forwards, so a clone left diverged never recovered. A merge
9268    // rather than a rebase, because other seats keep uncommitted edits in
9269    // the same worktree; issues.org merges by heading through vissue.
9270    let mut script =
9271        String::from("git push -q || { git pull -q --no-rebase --no-edit && git push -q; }; rc=$?");
9272    for (remote, branch) in &mirrors {
9273        script.push_str(&format!(
9274            "; git push -q '{remote}' 'HEAD:refs/heads/{branch}' || rc=1"
9275        ));
9276    }
9277    script.push_str("; exit $rc");
9278    let mut push = std::process::Command::new("sh");
9279    push.current_dir(dir)
9280        .args(["-c", &script])
9281        .stdin(std::process::Stdio::null())
9282        .stdout(out);
9283    if let Ok(err) = err {
9284        push.stderr(err);
9285    }
9286    let mut child = match push.spawn() {
9287        Ok(c) => c,
9288        Err(e) => return format!("tracker git: committed {message}; push failed: {e}\n"),
9289    };
9290    let wait = push_wait();
9291    let started = std::time::Instant::now();
9292    loop {
9293        match child.try_wait() {
9294            Ok(Some(status)) if status.success() => {
9295                let _ = std::fs::remove_file(&log);
9296                return format!("tracker git: committed and pushed {message}\n");
9297            }
9298            Ok(Some(_)) => {
9299                let said = std::fs::read(&log).unwrap_or_default();
9300                return format!(
9301                    "tracker git: committed {message}; push refused: {}\n",
9302                    first_line(&said)
9303                );
9304            }
9305            Ok(None) if started.elapsed() < wait => {
9306                std::thread::sleep(std::time::Duration::from_millis(200));
9307            }
9308            Ok(None) => {
9309                return format!(
9310                    "tracker git: committed {message}; push still running after {}s, finishing in the background (log {})\n",
9311                    wait.as_secs(),
9312                    log.display()
9313                );
9314            }
9315            Err(e) => return format!("tracker git: committed {message}; push failed: {e}\n"),
9316        }
9317    }
9318}
9319
9320/// How long a sitting waits for the tracker push: `LJOS_TRACKER_PUSH_WAIT`
9321/// seconds, else 5: agents wrap a finish in a timeout of about ten seconds.
9322fn push_wait() -> std::time::Duration {
9323    let secs = std::env::var("LJOS_TRACKER_PUSH_WAIT")
9324        .ok()
9325        .and_then(|v| v.trim().parse::<u64>().ok())
9326        .unwrap_or(5);
9327    std::time::Duration::from_secs(secs)
9328}
9329
9330fn first_line(bytes: &[u8]) -> String {
9331    String::from_utf8_lossy(bytes)
9332        .lines()
9333        .find(|l| !l.trim().is_empty())
9334        .unwrap_or("")
9335        .trim()
9336        .to_string()
9337}
9338
9339/// The weight a voter of estimated accuracy `p` earns: the log odds
9340/// `ln(p / (1 - p))`, the optimal weight for independent voters on a
9341/// two-way choice (Nitzan and Paroush, doi:10.2307/2526438; a weighted
9342/// majority under these weights is the maximum-likelihood decision), with
9343/// `p` held inside `[0.01, 0.99]` so a perfect record does not become an
9344/// infinite vote, and a voter at or under chance at [`TRUST_FLOOR`]. The
9345/// weights are scaled so the most reliable voter stands at one, which is
9346/// the scale the trust rows live on; the ratios between voters are the
9347/// rule's.
9348#[must_use]
9349pub fn calibration_weights(accuracy: &[(String, f64)]) -> Vec<(String, f64)> {
9350    let logit = |p: f64| {
9351        let p = p.clamp(0.01, 0.99);
9352        (p / (1.0 - p)).ln()
9353    };
9354    let raw: Vec<(String, f64)> = accuracy
9355        .iter()
9356        .map(|(who, p)| (who.clone(), logit(*p).max(0.0)))
9357        .collect();
9358    let top = raw.iter().map(|(_, w)| *w).fold(0.0_f64, f64::max);
9359    raw.into_iter()
9360        .map(|(who, w)| {
9361            let scaled = if top > 0.0 { w / top } else { 0.0 };
9362            (who, scaled.clamp(TRUST_FLOOR, 1.0))
9363        })
9364        .collect()
9365}
9366
9367/// Turn a project's voting history into trust rows without anyone naming
9368/// an outcome: Dawid and Skene's accuracy per voter
9369/// (doi:10.2307/2346806), from `ljos-consensus reliability`, turned into
9370/// the weight every other voter gives that voter by
9371/// [`calibration_weights`]: log odds, so a voter right nine times in ten
9372/// outweighs one right six times in ten by five to one, not three to two.
9373/// Rows are complete and floored at [`TRUST_FLOOR`], so the settle sees
9374/// the whole graph.
9375///
9376/// # Errors
9377///
9378/// No issue with two or more ballots, the consensus binary absent, or the
9379/// pack refusing a row.
9380pub fn calibrate(project: &str, rounds: usize) -> Result<Vec<Trust>> {
9381    let said = run_captured(
9382        "ljos-consensus",
9383        &[
9384            "reliability",
9385            "--project",
9386            project,
9387            "--rounds",
9388            &rounds.to_string(),
9389        ],
9390    )?;
9391    let v: Value = serde_json::from_str(&said.stdout).context("reliability: not JSON")?;
9392    let accuracy = v
9393        .get("accuracy")
9394        .and_then(Value::as_object)
9395        .context("reliability: no accuracy object")?;
9396    let mut voters: Vec<(String, f64)> = accuracy
9397        .iter()
9398        .filter_map(|(k, val)| val.as_f64().map(|a| (k.clone(), a)))
9399        .collect();
9400    voters.sort_by(|a, b| a.0.cmp(&b.0));
9401    if voters.len() < 2 {
9402        bail!("calibrate: fewer than two voters in {project}");
9403    }
9404    let weights = calibration_weights(&voters);
9405    let mut rows = Vec::new();
9406    for (from, _) in &voters {
9407        for (to, weight) in &weights {
9408            if from == to {
9409                continue;
9410            }
9411            rows.push(Trust {
9412                from: from.clone(),
9413                to: to.clone(),
9414                weight: *weight,
9415                about: Vec::new(),
9416            });
9417        }
9418    }
9419    for row in &rows {
9420        write_trust(row, &[])?;
9421    }
9422    Ok(rows)
9423}
9424
9425/// What a search score is. Empty and nonempty are different facts from a
9426/// writer that did not answer.
9427#[must_use]
9428pub fn search_reading(n: usize) -> &'static str {
9429    if n == 0 {
9430        "No hits. The pack holds nothing on this query. A failure would say the writer did not answer."
9431    } else {
9432        "Score is how the scorers ranked this query. The fraction is how many of them named the hit. Neither is whether the claim is true. A later line on the same matter supersedes an earlier one."
9433    }
9434}
9435
9436/// One line per hit: score, how many scorers named it out of how many
9437/// ran, kind, id, age, text. The age is the one column a reader needs to
9438/// lay the hits on a timeline; the count is what the hook keys on.
9439pub fn format_hits(hits: &[Hit]) -> String {
9440    let now = now_utc();
9441    let mine = seat_name();
9442    let mut out = format!("{}\n", search_reading(hits.len()));
9443    for h in hits {
9444        let id = h.id.as_deref().unwrap_or("-");
9445        let named = match (h.ballots, h.of) {
9446            (Some(b), Some(of)) => format!("{b}/{of}"),
9447            _ => "-".to_string(),
9448        };
9449        let from = other_seat(&h.entities, &mine)
9450            .map(|s| format!(" (from {s})"))
9451            .unwrap_or_default();
9452        out.push_str(&format!(
9453            "{:.4}\t{}\t{}\t{}\t{}{}\t{}\n",
9454            h.score,
9455            named,
9456            h.kind,
9457            id,
9458            age_of(h.ts.as_deref(), &now),
9459            from,
9460            h.text
9461        ));
9462    }
9463    out
9464}
9465
9466/// The seat that wrote a hit, when it was another than this one. Many
9467/// seats share a pack; a reader is told whose lesson it is reading only
9468/// when that is news.
9469#[must_use]
9470pub fn other_seat(entities: &[String], mine: &str) -> Option<String> {
9471    entities
9472        .iter()
9473        .filter_map(|e| e.strip_prefix(SEAT_ENTITY))
9474        .find(|s| !s.is_empty() && *s != mine)
9475        .map(str::to_string)
9476}
9477
9478/// The line a hit takes in injected context and in a brief: kind, age and,
9479/// when another seat wrote it, that seat in the bracket, then the text.
9480fn hit_line(h: &Hit, now: &str) -> String {
9481    let from = other_seat(&h.entities, &seat_name())
9482        .map(|s| format!(", from {s}"))
9483        .unwrap_or_default();
9484    format!(
9485        "- [{}{}{}] {}",
9486        if h.kind.is_empty() { "claim" } else { &h.kind },
9487        age_tag(h.ts.as_deref(), now),
9488        from,
9489        h.text.trim()
9490    )
9491}
9492
9493/// `, N days ago` for a bracket, empty when the stamp is missing.
9494fn age_tag(ts: Option<&str>, now: &str) -> String {
9495    let age = age_of(ts, now);
9496    if age.is_empty() {
9497        age
9498    } else {
9499        format!(", {age}")
9500    }
9501}
9502
9503/// How long ago a stamp was, in words a reader can place: `today`,
9504/// `yesterday`, `N days ago`, then weeks, months and years once the count
9505/// stops fitting the smaller unit. Empty when the stamp is missing or
9506/// unreadable, `in N days` for a stamp ahead of `now`.
9507#[must_use]
9508pub fn age_of(ts: Option<&str>, now: &str) -> String {
9509    let (Some(then), Some(today)) = (days_of_stamp(ts), days_of_stamp(Some(now))) else {
9510        return String::new();
9511    };
9512    let days = today - then;
9513    match days {
9514        d if d < 0 => format!("in {} day{}", -d, if d == -1 { "" } else { "s" }),
9515        0 => "today".into(),
9516        1 => "yesterday".into(),
9517        d if d < 14 => format!("{d} days ago"),
9518        d if d < 61 => format!("{} weeks ago", d / 7),
9519        d if d < 730 => format!("{} months ago", d / 30),
9520        d => format!("{} years ago", d / 365),
9521    }
9522}
9523
9524/// Days since the epoch of an RFC 3339 stamp's date, or none when the
9525/// first ten characters do not read as `YYYY-MM-DD`.
9526fn days_of_stamp(ts: Option<&str>) -> Option<i64> {
9527    let ts = ts?;
9528    let date = ts.get(..10)?;
9529    let mut it = date.split('-');
9530    let y: i64 = it.next()?.parse().ok()?;
9531    let m: i64 = it.next()?.parse().ok()?;
9532    let d: i64 = it.next()?.parse().ok()?;
9533    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
9534        return None;
9535    }
9536    // Civil date to days since the epoch (Howard Hinnant's algorithm).
9537    let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
9538    let era = y.div_euclid(400);
9539    let yoe = y - era * 400;
9540    let doy = (153 * m + 2) / 5 + d - 1;
9541    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
9542    Some(era * 146_097 + doe - 719_468)
9543}
9544
9545/// Read-only cards. Only [`CARD_NAMES`], never created, never written.
9546pub fn cards(dir: &Path) -> Result<String> {
9547    let mut out = String::new();
9548    for name in CARD_NAMES {
9549        let p = dir.join(name);
9550        if p.is_file() {
9551            out.push_str(&format!("--- {} ---\n", p.display()));
9552            out.push_str(&std::fs::read_to_string(&p)?);
9553        }
9554    }
9555    Ok(out)
9556}
9557
9558pub fn policy_line(argv: &[String]) -> Result<String> {
9559    if argv.is_empty() {
9560        bail!("policy: pass the argv to check");
9561    }
9562    Ok(argv.join(" "))
9563}
9564
9565/// The argv line, then what the pack knows that bears on it: the memory a
9566/// policy layer injects beside its verdict. The line prints even when the
9567/// pack is down; the memory is the part that may be empty.
9568pub fn policy_with_memory(argv: &[String]) -> Result<String> {
9569    let line = policy_line(argv)?;
9570    let call = HookCall {
9571        event: "argv".into(),
9572        cue: line.clone(),
9573        session: None,
9574        shape: HookShape::Asks,
9575    };
9576    let context = hook_context(&call, 5);
9577    // The rules are the law's memory: a deny or an ask fires before the
9578    // context, so a reader sees the verdict first.
9579    let rules = rules_from_pack().unwrap_or_default();
9580    let ruled = hook_output_ruled(&call, &context, verdict_for(&rules, &line));
9581    match tcb_check(argv) {
9582        Some(tcb) if !tcb.is_empty() => Ok(format!("{line}\n{tcb}\n{ruled}")),
9583        None if policyd_required() => Ok(format!("{line}\ndeny\tTCB required\n{ruled}")),
9584        _ => Ok(format!("{line}\n{ruled}")),
9585    }
9586}
9587
9588/// Operator switch: missing TCB is a deny. Unset, absence stays open.
9589pub fn policyd_required() -> bool {
9590    matches!(
9591        std::env::var("POLICYD_REQUIRED").as_deref(),
9592        Ok("1") | Ok("true") | Ok("TRUE")
9593    )
9594}
9595
9596/// `POLICYD_BIN`, else `ljos-policyd` on PATH.
9597pub fn policyd_bin() -> Option<std::path::PathBuf> {
9598    std::env::var_os("POLICYD_BIN")
9599        .filter(|s| !s.is_empty())
9600        .map(std::path::PathBuf::from)
9601        .or_else(|| which::which("ljos-policyd").ok())
9602}
9603
9604/// One line from `ljos-policyd check -- argv`. None if the binary is absent
9605/// or failed to start. Absence is not a deny.
9606pub fn tcb_check(argv: &[String]) -> Option<String> {
9607    let bin = policyd_bin()?;
9608    let out = std::process::Command::new(bin)
9609        .arg("check")
9610        .arg("--")
9611        .args(argv)
9612        .output()
9613        .ok()?;
9614    let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
9615    (!text.is_empty()).then_some(text)
9616}
9617
9618#[derive(Debug, Clone, PartialEq, Eq)]
9619pub struct ConsensusStep {
9620    pub bin: &'static str,
9621    pub args: Vec<String>,
9622}
9623
9624/// `ljos-consensus` first, then `vissue consensus`, both under the pack's
9625/// trust rows when there are any. Missing bins are skipped.
9626pub fn consensus_steps(
9627    id: &str,
9628    have_ljos: bool,
9629    have_vissue: bool,
9630    trust: &[Trust],
9631) -> Result<Vec<ConsensusStep>> {
9632    consensus_steps_anchored(id, have_ljos, have_vissue, trust, &[])
9633}
9634
9635/// The tag on an issue that asks for bounded confidence: a panel for a
9636/// broad audience is allowed to settle into clusters, and the settle says
9637/// how far apart they are, where a single-position model would average
9638/// them away. Without it the anchored model runs.
9639pub const BROAD_TAG: &str = "broad";
9640
9641/// The confidence bound a `broad` issue settles under: voters within this
9642/// L1 distance of each other's opinion listen to each other.
9643pub const BROAD_EPSILON: f64 = 1.0;
9644
9645/// The model flags an issue's tags ask for, beside the rows and anchors.
9646/// The kind of work sets the dynamics: `broad` runs bounded confidence.
9647#[must_use]
9648pub fn settle_flags_for(tags: &[String]) -> Vec<String> {
9649    if tags.iter().any(|t| t == BROAD_TAG) {
9650        vec!["--epsilon".into(), BROAD_EPSILON.to_string()]
9651    } else {
9652        Vec::new()
9653    }
9654}
9655
9656/// [`consensus_steps_anchored`] with the model flags the issue's tags ask
9657/// for on the model crate's settle.
9658pub fn consensus_steps_for(
9659    id: &str,
9660    have_ljos: bool,
9661    have_vissue: bool,
9662    trust: &[Trust],
9663    personas: &[Persona],
9664    tags: &[String],
9665) -> Result<Vec<ConsensusStep>> {
9666    let mut steps = consensus_steps_anchored(id, have_ljos, have_vissue, trust, personas)?;
9667    let flags = settle_flags_for(tags);
9668    if !flags.is_empty() {
9669        for step in steps.iter_mut().filter(|s| s.bin == "ljos-consensus") {
9670            step.args.extend(flags.iter().cloned());
9671        }
9672    }
9673    Ok(steps)
9674}
9675
9676/// The two readings beside a settle, when the pack holds what they need:
9677/// the surprisingly popular answer when two or more voters forecast the
9678/// others (`predict`), and the EigenTrust standing of the voters when
9679/// trust rows exist. Both are the model crate's verbs.
9680pub fn panel_steps(
9681    id: &str,
9682    have_ljos: bool,
9683    trust: &[Trust],
9684    predictions: &[Prediction],
9685) -> Vec<ConsensusStep> {
9686    let mut steps = Vec::new();
9687    if !have_ljos {
9688        return steps;
9689    }
9690    if predictions.len() >= 2 {
9691        steps.push(ConsensusStep {
9692            bin: "ljos-consensus",
9693            args: vec![
9694                "surprising".into(),
9695                "--issue".into(),
9696                id.into(),
9697                "--predictions".into(),
9698                predictions_json(predictions),
9699            ],
9700        });
9701    }
9702    if !trust.is_empty() {
9703        steps.push(ConsensusStep {
9704            bin: "ljos-consensus",
9705            args: vec!["reputation".into(), "--trust".into(), trust_json(trust)],
9706        });
9707    }
9708    steps
9709}
9710
9711/// [`consensus_steps`] passing the personas' anchors to both settles as
9712/// `--susceptibility-of`, so a persona holds its ballot as much as it says.
9713pub fn consensus_steps_anchored(
9714    id: &str,
9715    have_ljos: bool,
9716    have_vissue: bool,
9717    trust: &[Trust],
9718    personas: &[Persona],
9719) -> Result<Vec<ConsensusStep>> {
9720    if !have_ljos && !have_vissue {
9721        bail!("neither ljos-consensus nor vissue is on PATH");
9722    }
9723    let mut steps = Vec::new();
9724    if have_ljos {
9725        let mut args = vec!["settle".to_string(), "--issue".into(), id.into()];
9726        if !trust.is_empty() {
9727            args.push("--trust".into());
9728            args.push(trust_json(trust));
9729        }
9730        if !personas.is_empty() {
9731            args.push("--susceptibility-of".into());
9732            args.push(anchors_json(personas));
9733        }
9734        steps.push(ConsensusStep {
9735            bin: "ljos-consensus",
9736            args,
9737        });
9738    }
9739    if have_vissue {
9740        let mut args = vec!["consensus".to_string(), id.into()];
9741        if !trust.is_empty() {
9742            args.push("--trust".into());
9743            args.push(trust_json(trust));
9744        }
9745        if !personas.is_empty() {
9746            args.push("--susceptibility-of".into());
9747            args.push(anchors_json(personas));
9748        }
9749        steps.push(ConsensusStep {
9750            bin: "vissue",
9751            args,
9752        });
9753    }
9754    Ok(steps)
9755}
9756
9757pub fn on_path(bin: &str) -> bool {
9758    which::which(bin).is_ok()
9759}
9760
9761pub fn run(bin: &str, args: &[impl AsRef<str>]) -> Result<()> {
9762    run_as(bin, args, None)
9763}
9764
9765/// The identity a ballot is cast under: the persona named, else the seat
9766/// ([`whoami`]), the same name across a runner's conversations so its
9767/// record accrues to one voter.
9768#[must_use]
9769pub fn identity_or_seat(identity: Option<&str>) -> Option<String> {
9770    identity
9771        .map(str::trim)
9772        .filter(|w| !w.is_empty())
9773        .map(str::to_string)
9774        .or_else(|| Some(seat_name()))
9775}
9776
9777/// [`run`] with `VISSUE_AGENT` set to `identity`, so a ballot or a claim is
9778/// recorded under a persona's name rather than the seat's.
9779pub fn run_as(bin: &str, args: &[impl AsRef<str>], identity: Option<&str>) -> Result<()> {
9780    use std::process::{Command, Stdio};
9781    let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
9782    let mut cmd = Command::new(path);
9783    if let Some(who) = identity_or_seat(identity) {
9784        cmd.env("VISSUE_AGENT", who);
9785    }
9786    for a in args {
9787        cmd.arg(a.as_ref());
9788    }
9789    let st = cmd
9790        .stdin(Stdio::inherit())
9791        .stdout(Stdio::inherit())
9792        .stderr(Stdio::inherit())
9793        .status()?;
9794    // A child that died of a closed pipe was cut off by our own reader
9795    // going away (`ljos consensus ID | head`); that is not the habitat
9796    // refusing.
9797    #[cfg(unix)]
9798    {
9799        use std::os::unix::process::ExitStatusExt;
9800        if st.signal() == Some(libc::SIGPIPE) {
9801            return Ok(());
9802        }
9803    }
9804    if !st.success() {
9805        bail!("{bin} exited {st}");
9806    }
9807    Ok(())
9808}
9809
9810/// What a habitat printed, kept for a caller that has to hand it on. A
9811/// non-zero exit is an error carrying stderr.
9812#[derive(Debug, Clone, PartialEq, Eq)]
9813pub struct Said {
9814    pub stdout: String,
9815    pub stderr: String,
9816}
9817
9818pub fn run_captured(bin: &str, args: &[impl AsRef<str>]) -> Result<Said> {
9819    run_captured_as(bin, args, None)
9820}
9821
9822/// [`run_captured`] with `VISSUE_AGENT` set to `identity`, for a tracker
9823/// write whose output the caller has to hand on. `None` leaves the
9824/// environment as it is.
9825pub fn run_captured_as(
9826    bin: &str,
9827    args: &[impl AsRef<str>],
9828    identity: Option<&str>,
9829) -> Result<Said> {
9830    use std::process::{Command, Stdio};
9831    let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
9832    let mut cmd = Command::new(path);
9833    if let Some(who) = identity {
9834        cmd.env("VISSUE_AGENT", who);
9835    }
9836    for a in args {
9837        cmd.arg(a.as_ref());
9838    }
9839    let out = cmd
9840        .stdin(Stdio::null())
9841        .stdout(Stdio::piped())
9842        .stderr(Stdio::piped())
9843        .output()
9844        .with_context(|| format!("{bin}: could not start"))?;
9845    let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
9846    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
9847    if !out.status.success() {
9848        let why = if stderr.trim().is_empty() {
9849            stdout.trim().to_string()
9850        } else {
9851            stderr.trim().to_string()
9852        };
9853        bail!("{bin} exited {}: {why}", out.status);
9854    }
9855    Ok(Said { stdout, stderr })
9856}
9857
9858pub fn card_paths(dir: &Path) -> Vec<PathBuf> {
9859    CARD_NAMES.iter().map(|n| dir.join(n)).collect()
9860}
9861
9862/// One typed finding from an eb-stack campaign state file, flattened to
9863/// what a seat reads and remembers.
9864#[derive(Debug, Clone, PartialEq, Eq)]
9865pub struct Finding {
9866    pub id: String,
9867    pub status: String,
9868    pub class: String,
9869    pub disposition: String,
9870    pub stage: String,
9871    /// The recipe the campaign drives, as its file stem:
9872    /// `eOn-2.17.10-foss-2026.1`.
9873    pub recipe: String,
9874    /// The module whose build failed, when the evidence names one:
9875    /// `GCCcore-15.2.0`, `gettext-0.26-GCCcore-15.2.0`. A campaign fails in
9876    /// its dependencies far more often than in the recipe it drives.
9877    pub module: String,
9878    pub summary: String,
9879    /// The last error line the evidence carries, else the summary.
9880    pub error: String,
9881    /// The resolution's action, when it is resolved.
9882    pub action: String,
9883    pub changes: Vec<String>,
9884}
9885
9886/// A campaign state file: the package it builds, the target, its findings.
9887#[derive(Debug, Clone, PartialEq, Eq)]
9888pub struct Campaign {
9889    pub package: String,
9890    pub version: String,
9891    pub target: String,
9892    pub status: String,
9893    pub attempts: u64,
9894    pub findings: Vec<Finding>,
9895}
9896
9897fn recipe_stem(path: &str) -> String {
9898    Path::new(path)
9899        .file_stem()
9900        .map(|s| s.to_string_lossy().into_owned())
9901        .unwrap_or_else(|| path.to_string())
9902}
9903
9904/// The line a reader recognises the failure by: the last line of the
9905/// evidence that names an error, else the summary.
9906fn error_line(evidence: &str, summary: &str) -> String {
9907    let lower = |l: &str| l.to_ascii_lowercase();
9908    evidence
9909        .lines()
9910        .map(str::trim)
9911        .filter(|l| !l.is_empty())
9912        .filter(|l| {
9913            let l = lower(l);
9914            l.contains("error") || l.contains("fatal") || l.contains("failed")
9915        })
9916        .rfind(|l| !l.starts_with("srun:"))
9917        .map(str::to_string)
9918        .unwrap_or_else(|| summary.to_string())
9919}
9920
9921/// The module EasyBuild was installing when it stopped: `ERROR:
9922/// Installation of X.eb failed` names it; else the last `== building and
9923/// installing NAME/VERSION...` line does.
9924fn failed_module(evidence: &str) -> Option<String> {
9925    let installation = evidence.lines().rev().find_map(|l| {
9926        let rest = l.split("Installation of ").nth(1)?;
9927        let eb = rest.split(".eb failed").next()?;
9928        // `.eb` is already off; a stem call here would take a version's
9929        // last component for an extension.
9930        let name = eb.rsplit('/').next()?;
9931        (!name.is_empty() && !name.contains(' ')).then(|| name.to_string())
9932    });
9933    installation.or_else(|| {
9934        evidence.lines().rev().find_map(|l| {
9935            let rest = l.trim().strip_prefix("== building and installing ")?;
9936            let name = rest.trim_end_matches('.').trim();
9937            (!name.is_empty()).then(|| name.replacen('/', "-", 1))
9938        })
9939    })
9940}
9941
9942/// What EasyBuild said after naming the module, else the whole line.
9943fn error_reason(error: &str) -> &str {
9944    error
9945        .split(".eb failed: ")
9946        .nth(1)
9947        .unwrap_or(error)
9948        .trim_start_matches("ERROR: ")
9949}
9950
9951fn text_of(v: &Value, key: &str) -> String {
9952    v.get(key)
9953        .and_then(Value::as_str)
9954        .unwrap_or_default()
9955        .to_string()
9956}
9957
9958/// Read an eb-stack campaign state (`campaign.json`).
9959///
9960/// # Errors
9961///
9962/// The file is missing, not JSON, or not a campaign state.
9963pub fn read_campaign(state: &Path) -> Result<Campaign> {
9964    let text = std::fs::read_to_string(state)
9965        .with_context(|| format!("findings: cannot read {}", state.display()))?;
9966    let doc: Value = serde_json::from_str(&text)
9967        .with_context(|| format!("findings: {} is not JSON", state.display()))?;
9968    let rows = doc
9969        .get("findings")
9970        .and_then(Value::as_array)
9971        .with_context(|| format!("findings: {} has no findings list", state.display()))?;
9972    let findings = rows
9973        .iter()
9974        .map(|f| {
9975            let summary = text_of(f, "summary");
9976            let resolution = f.get("resolution");
9977            let evidence = text_of(f, "evidence");
9978            Finding {
9979                id: text_of(f, "id"),
9980                status: text_of(f, "status"),
9981                class: text_of(f, "class"),
9982                disposition: text_of(f, "disposition"),
9983                stage: text_of(f, "stage"),
9984                recipe: recipe_stem(&text_of(f, "recipe")),
9985                module: failed_module(&evidence).unwrap_or_default(),
9986                error: error_line(&evidence, &summary),
9987                summary,
9988                action: resolution.map(|r| text_of(r, "action")).unwrap_or_default(),
9989                changes: resolution
9990                    .and_then(|r| r.get("changes"))
9991                    .and_then(Value::as_array)
9992                    .map(|c| {
9993                        c.iter()
9994                            .filter_map(Value::as_str)
9995                            .map(str::to_string)
9996                            .collect()
9997                    })
9998                    .unwrap_or_default(),
9999            }
10000        })
10001        .collect();
10002    Ok(Campaign {
10003        package: text_of(&doc, "package"),
10004        version: text_of(&doc, "version"),
10005        target: text_of(&doc, "target"),
10006        status: text_of(&doc, "status"),
10007        attempts: doc.get("attempts").and_then(Value::as_u64).unwrap_or(0),
10008        findings,
10009    })
10010}
10011
10012/// The automatic resolution a campaign writes when a later attempt got
10013/// past the stage: not a lesson, nothing was learned about the recipe.
10014fn superseded_by_retry(f: &Finding) -> bool {
10015    f.status == "superseded" || f.action.contains("superseded this finding")
10016}
10017
10018/// At most `n` words, with the pack's sentence marks taken out so the
10019/// lesson stays two sentences.
10020fn clip_words(text: &str, n: usize) -> String {
10021    // A stop inside a word (`scc.h`, `2.17.10`) is not a sentence mark; an
10022    // ellipsis (`'make ...'`) is EasyBuild eliding a command and goes.
10023    let text = text.replace(" ...", "").replace("...", "");
10024    let chars: Vec<char> = text.chars().collect();
10025    let mut flat = String::with_capacity(text.len());
10026    for (i, &c) in chars.iter().enumerate() {
10027        let ends_word = chars.get(i + 1).is_none_or(|n| n.is_whitespace());
10028        flat.push(match c {
10029            '.' | '!' | '?' | ';' if ends_word => ',',
10030            '\n' | '\t' => ' ',
10031            c => c,
10032        });
10033    }
10034    let words: Vec<&str> = flat.split_whitespace().collect();
10035    let mut out = words[..words.len().min(n)].join(" ");
10036    while out.ends_with([',', ':', ' ']) {
10037        out.pop();
10038    }
10039    out
10040}
10041
10042/// The lesson a finding leaves: what failed where, then the fix, or that a
10043/// later attempt got past it. Two short sentences; the pack refuses more,
10044/// and refuses hard prose.
10045#[must_use]
10046pub fn finding_lesson(campaign: &Campaign, f: &Finding) -> String {
10047    let what = clip_words(error_reason(&f.error), 10);
10048    let subject = if f.module.is_empty() {
10049        f.recipe.clone()
10050    } else if f.module == f.recipe {
10051        f.module.clone()
10052    } else {
10053        format!("{} for {}", f.module, f.recipe)
10054    };
10055    let mut first = format!(
10056        "{subject} on {}: {} failed in the {} step",
10057        campaign.target, f.class, f.stage
10058    );
10059    if !what.is_empty() && what != f.summary {
10060        first.push_str(&format!(" with {what}"));
10061    }
10062    first.push('.');
10063    if superseded_by_retry(f) {
10064        return format!("{first} A later attempt got past it.");
10065    }
10066    let mut fix = clip_words(&f.action, 14);
10067    if !f.changes.is_empty() {
10068        let files: Vec<String> = f
10069            .changes
10070            .iter()
10071            .map(String::as_str)
10072            .map(recipe_stem)
10073            .collect();
10074        fix.push_str(&format!(" in {}", files.join(", ")));
10075    }
10076    if fix.is_empty() {
10077        first
10078    } else {
10079        format!("{first} Fix: {fix}.")
10080    }
10081}
10082
10083/// The entities a finding's lesson is about, so a later cue on the
10084/// recipe, the package or the failure class activates it.
10085fn finding_entities(campaign: &Campaign, f: &Finding) -> Vec<String> {
10086    let mut out: Vec<String> = Vec::new();
10087    for stem in [&f.module, &f.recipe] {
10088        if stem.is_empty() || out.contains(stem) {
10089            continue;
10090        }
10091        out.push(stem.clone());
10092        if let Some(name) = stem.split('-').next() {
10093            if !name.is_empty() && name != stem && !out.iter().any(|e| e == name) {
10094                out.push(name.to_string());
10095            }
10096        }
10097    }
10098    if !campaign.package.is_empty() {
10099        out.push(campaign.package.clone());
10100    }
10101    out.push(f.class.clone());
10102    out.dedup();
10103    out
10104}
10105
10106/// One line per finding: id, status, class, stage, recipe, then the fix
10107/// or the summary.
10108#[must_use]
10109pub fn format_findings(campaign: &Campaign) -> String {
10110    let mut out = format!(
10111        "{} {} on {}: {} after {} attempt{}, {} finding{}\n",
10112        campaign.package,
10113        campaign.version,
10114        campaign.target,
10115        campaign.status,
10116        campaign.attempts,
10117        if campaign.attempts == 1 { "" } else { "s" },
10118        campaign.findings.len(),
10119        if campaign.findings.len() == 1 {
10120            ""
10121        } else {
10122            "s"
10123        },
10124    );
10125    for f in &campaign.findings {
10126        let tail = if f.action.is_empty() {
10127            f.summary.clone()
10128        } else {
10129            format!("fix: {}", f.action)
10130        };
10131        out.push_str(&format!(
10132            "{}\t{}\t{}/{}\t{}\t{}\t{}\n",
10133            f.id,
10134            f.status,
10135            f.class,
10136            f.disposition,
10137            f.stage,
10138            if f.module.is_empty() {
10139                &f.recipe
10140            } else {
10141                &f.module
10142            },
10143            tail
10144        ));
10145    }
10146    out
10147}
10148
10149/// What `remember_findings` did with one finding.
10150#[derive(Debug, Clone, PartialEq, Eq)]
10151pub struct Remembered {
10152    pub id: String,
10153    pub lesson: String,
10154    /// The pack's answer: the atom id, `held` when the pack already had
10155    /// it, `skipped` for a retry supersession, else the refusal.
10156    pub result: String,
10157}
10158
10159/// Write one lesson per finding a person or a seat resolved (every
10160/// finding with `all`), cite the state file on the issue when one is
10161/// named, and say what happened to each.
10162///
10163/// # Errors
10164///
10165/// The state cannot be read, or the pack is down. A refusal of one lesson
10166/// is reported in its row, not returned.
10167pub fn remember_findings(state: &Path, issue: Option<&str>, all: bool) -> Result<Vec<Remembered>> {
10168    let campaign = read_campaign(state)?;
10169    let client = pack()?;
10170    let workspace = client.workspace();
10171    let mut out = Vec::new();
10172    for f in &campaign.findings {
10173        if !all && superseded_by_retry(f) {
10174            out.push(Remembered {
10175                id: f.id.clone(),
10176                lesson: String::new(),
10177                result: "skipped: a later attempt got past it, nothing was learned".into(),
10178            });
10179            continue;
10180        }
10181        if !all && f.status != "resolved" {
10182            out.push(Remembered {
10183                id: f.id.clone(),
10184                lesson: String::new(),
10185                result: format!("skipped: {}", f.status),
10186            });
10187            continue;
10188        }
10189        let lesson = finding_lesson(&campaign, f);
10190        let mut atom = atom_body("lesson", &lesson, &workspace);
10191        add_entities(&mut atom, finding_entities(&campaign, f));
10192        let result = match client.post_atom(&atom) {
10193            Ok(body) => format!(
10194                "{}{}",
10195                body["id"].as_str().unwrap_or("written"),
10196                revision_note(&body)
10197            ),
10198            Err(e) => format!("refused: {e}"),
10199        };
10200        out.push(Remembered {
10201            id: f.id.clone(),
10202            lesson,
10203            result,
10204        });
10205    }
10206    if let Some(issue) = issue.map(str::trim).filter(|i| !i.is_empty()) {
10207        let name = format!(
10208            "{} {} campaign state on {}, {} after {} attempts",
10209            campaign.package, campaign.version, campaign.target, campaign.status, campaign.attempts
10210        );
10211        let seat = seat_name();
10212        // The same state file under the same name is the same deed: a
10213        // second run finds it frozen, and the refusal names the accession.
10214        let said = match run_captured(
10215            "deedar",
10216            &[
10217                "create",
10218                "file",
10219                "--name",
10220                &name,
10221                "--path",
10222                &state.display().to_string(),
10223                "--agent",
10224                &seat,
10225            ],
10226        ) {
10227            Ok(said) => said.stdout,
10228            Err(e) if e.to_string().contains("deed frozen") => e.to_string(),
10229            Err(e) => return Err(e),
10230        };
10231        // `deedar create` prints `id=deed-...` on its first line; an older
10232        // build printed the accession bare.
10233        let accession = said
10234            .split_whitespace()
10235            .find_map(|w| {
10236                let at = w.find("deed-")?;
10237                let tail = &w[at..];
10238                let end = tail
10239                    .find(|c: char| !c.is_ascii_alphanumeric() && c != '-')
10240                    .unwrap_or(tail.len());
10241                Some(tail[..end].to_string())
10242            })
10243            .filter(|a| a.len() > "deed-".len())
10244            .context("findings: deedar create printed no accession")?;
10245        run_captured("vissue", &["deed", issue, "--add", &accession])?;
10246        let _ = persist_tracker(issue, "cited the campaign state");
10247        out.push(Remembered {
10248            id: "state".into(),
10249            lesson: name,
10250            result: format!("cited on {issue} as {accession}"),
10251        });
10252    }
10253    Ok(out)
10254}
10255
10256#[must_use]
10257pub fn format_remembered(rows: &[Remembered]) -> String {
10258    rows.iter()
10259        .map(|r| {
10260            if r.lesson.is_empty() {
10261                format!("{}\t{}\n", r.id, r.result)
10262            } else {
10263                format!("{}\t{}\n\t{}\n", r.id, r.result, r.lesson)
10264            }
10265        })
10266        .collect()
10267}
10268
10269/// One module of a bump bundle as the tracker will hold it.
10270#[derive(Debug, Clone, PartialEq, Eq)]
10271pub struct BumpRow {
10272    /// The issue id, the same on every run: a hash of the module and the
10273    /// generation under the project.
10274    pub id: String,
10275    /// The module as EasyBuild names it: `CMake-4.2.1-GCCcore-15.2.0`.
10276    pub module: String,
10277    /// The recipe path the lock names, when it does.
10278    pub recipe: String,
10279    /// The modules this one is built after, by issue id.
10280    pub blockers: Vec<String>,
10281    /// What this run did: `made`, `held` (it existed), or `would make`.
10282    pub result: String,
10283}
10284
10285/// The stem of an EasyBuild module: `name-version[-toolchain-version]`.
10286fn module_stem(name: &str, version: &str, toolchain: Option<(&str, &str)>) -> String {
10287    match toolchain {
10288        Some((tn, tv)) if !tn.is_empty() && tn != "system" => {
10289            format!("{name}-{version}-{tn}-{tv}")
10290        }
10291        _ => format!("{name}-{version}"),
10292    }
10293}
10294
10295/// A deterministic issue id for a module of a generation: the project,
10296/// then eight base-36 digits of the module and generation hashed.
10297#[must_use]
10298pub fn bump_issue_id(project: &str, module: &str, generation: &str) -> String {
10299    let hex = work_id(&format!("bump:{module}:{generation}"));
10300    let mut n = u128::from_str_radix(&hex[..24], 16).unwrap_or(0);
10301    const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
10302    let mut out = Vec::new();
10303    for _ in 0..8 {
10304        out.push(DIGITS[(n % 36) as usize]);
10305        n /= 36;
10306    }
10307    format!("{project}-{}", String::from_utf8(out).unwrap_or_default())
10308}
10309
10310/// The name behind a CycloneDX purl `pkg:generic/NAME@==VERSION`.
10311fn purl_name(purl: &str) -> String {
10312    purl.rsplit('/')
10313        .next()
10314        .unwrap_or(purl)
10315        .split('@')
10316        .next()
10317        .unwrap_or(purl)
10318        .to_string()
10319}
10320
10321/// The plan a bundle implies for the tracker: one row per module the lock
10322/// builds, blockers along the SBOM's dependency edges. Nothing is written.
10323///
10324/// # Errors
10325///
10326/// The bundle lacks `locks/default.lock.json` or `package.sbom.cdx.json`,
10327/// or either is not what eb-stack writes.
10328pub fn bump_rows(
10329    bundle: &Path,
10330    project: &str,
10331    generation: Option<&str>,
10332) -> Result<(String, Vec<BumpRow>)> {
10333    let lock_path = bundle.join("locks").join("default.lock.json");
10334    let sbom_path = bundle.join("package.sbom.cdx.json");
10335    let lock: Value = serde_json::from_str(
10336        &std::fs::read_to_string(&lock_path)
10337            .with_context(|| format!("bump-plan: cannot read {}", lock_path.display()))?,
10338    )
10339    .with_context(|| format!("bump-plan: {} is not JSON", lock_path.display()))?;
10340    let sbom: Value = serde_json::from_str(
10341        &std::fs::read_to_string(&sbom_path)
10342            .with_context(|| format!("bump-plan: cannot read {}", sbom_path.display()))?,
10343    )
10344    .with_context(|| format!("bump-plan: {} is not JSON", sbom_path.display()))?;
10345    let tc = &lock["toolchain"];
10346    let generation = generation.map(str::to_string).unwrap_or_else(|| {
10347        format!(
10348            "{}/{}",
10349            tc["name"].as_str().unwrap_or("system"),
10350            tc["version"].as_str().unwrap_or("")
10351        )
10352        .trim_end_matches('/')
10353        .to_string()
10354    });
10355    // Every module the lock names, the root package first.
10356    let mut modules: Vec<(String, String, String)> = Vec::new(); // name, stem, recipe
10357    let root_name = lock["package"].as_str().unwrap_or("").to_string();
10358    let root_stem = module_stem(
10359        &root_name,
10360        lock["version"].as_str().unwrap_or(""),
10361        Some((
10362            tc["name"].as_str().unwrap_or(""),
10363            tc["version"].as_str().unwrap_or(""),
10364        )),
10365    ) + lock["versionsuffix"].as_str().unwrap_or("");
10366    modules.push((root_name.clone(), root_stem, String::new()));
10367    // `build` on a lock entry says whether it is a build dependency, not
10368    // whether it is built: every entry is a module the generation needs.
10369    for dep in lock["dependencies"].as_array().into_iter().flatten() {
10370        let name = dep["name"].as_str().unwrap_or("").to_string();
10371        let dtc = &dep["toolchain"];
10372        let stem = module_stem(
10373            &name,
10374            dep["version"].as_str().unwrap_or(""),
10375            Some((
10376                dtc["name"].as_str().unwrap_or(""),
10377                dtc["version"].as_str().unwrap_or(""),
10378            )),
10379        );
10380        let recipe = dep["easyconfig_path"].as_str().unwrap_or("").to_string();
10381        if !name.is_empty() && !modules.iter().any(|(n, _, _)| *n == name) {
10382            modules.push((name, stem, recipe));
10383        }
10384    }
10385    let id_of = |name: &str| -> Option<String> {
10386        modules
10387            .iter()
10388            .find(|(n, _, _)| n == name)
10389            .map(|(_, stem, _)| bump_issue_id(project, stem, &generation))
10390    };
10391    // Edges from the SBOM, by name; only edges between modules the lock builds.
10392    let mut edges: std::collections::BTreeMap<String, Vec<String>> = Default::default();
10393    for d in sbom["dependencies"].as_array().into_iter().flatten() {
10394        let from = purl_name(d["ref"].as_str().unwrap_or(""));
10395        for on in d["dependsOn"].as_array().into_iter().flatten() {
10396            let to = purl_name(on.as_str().unwrap_or(""));
10397            if let Some(id) = id_of(&to) {
10398                edges.entry(from.clone()).or_default().push(id);
10399            }
10400        }
10401    }
10402    let rows = modules
10403        .iter()
10404        .map(|(name, stem, recipe)| BumpRow {
10405            id: bump_issue_id(project, stem, &generation),
10406            module: stem.clone(),
10407            recipe: recipe.clone(),
10408            blockers: edges.get(name).cloned().unwrap_or_default(),
10409            result: "would make".into(),
10410        })
10411        .collect();
10412    Ok((generation, rows))
10413}
10414
10415/// Put a bundle's modules on the tracker: one child issue per module under
10416/// `parent`, blockers along the dependency edges, ids the same on every run
10417/// so a rerun holds what exists and adds what is missing. `vissue ready`
10418/// then lists the modules a seat can build now, and a sitting refuses the
10419/// rest until their blockers close.
10420///
10421/// # Errors
10422///
10423/// The bundle is not readable, or the tracker refuses a create or an edge.
10424pub fn bump_plan(
10425    bundle: &Path,
10426    project: &str,
10427    parent: &str,
10428    generation: Option<&str>,
10429    dry: bool,
10430) -> Result<(String, Vec<BumpRow>)> {
10431    let (generation, mut rows) = bump_rows(bundle, project, generation)?;
10432    if dry {
10433        return Ok((generation, rows));
10434    }
10435    for row in &mut rows {
10436        let exists = tracker_show_json(&row.id).is_ok();
10437        if exists {
10438            row.result = "held".into();
10439        } else {
10440            let title = format!("Bump {} onto {generation}", row.module);
10441            let body = if row.recipe.is_empty() {
10442                format!("The bundle at {} names this module. Ladder: recipe check, package bump, lint, then the campaign.", bundle.display())
10443            } else {
10444                format!("Recipe {} in the bundle at {}. Ladder: recipe check, package bump, lint, then the campaign.", row.recipe, bundle.display())
10445            };
10446            run_captured(
10447                "vissue",
10448                &[
10449                    "create", "-p", project, "--id", &row.id, "--parent", parent, "-t", "task",
10450                    "--quiet", "--body", &body, &title,
10451                ],
10452            )
10453            .with_context(|| format!("bump-plan: create {} ({})", row.id, row.module))?;
10454            row.result = "made".into();
10455        }
10456    }
10457    // Edges after every node exists; an edge already held is not an error.
10458    for row in &rows {
10459        let held: Vec<String> = tracker_show_json(&row.id)
10460            .ok()
10461            .and_then(|v| v["blocked_by"].as_array().cloned())
10462            .into_iter()
10463            .flatten()
10464            .filter_map(|v| v.as_str().map(str::to_string))
10465            .collect();
10466        for dep in &row.blockers {
10467            if held.iter().any(|h| h == dep) {
10468                continue;
10469            }
10470            run_captured("vissue", &["update", &row.id, "--block", dep])
10471                .with_context(|| format!("bump-plan: {} --block {dep}", row.id))?;
10472        }
10473    }
10474    // Every module lands in one project file; one persist carries them all.
10475    if let Some(first) = rows.first() {
10476        let _ = persist_tracker(&first.id, "planned the bump");
10477    }
10478    Ok((generation, rows))
10479}
10480
10481#[must_use]
10482pub fn format_bump_rows(generation: &str, rows: &[BumpRow]) -> String {
10483    let mut out = format!(
10484        "{} module{} onto {generation}\n",
10485        rows.len(),
10486        if rows.len() == 1 { "" } else { "s" }
10487    );
10488    for r in rows {
10489        out.push_str(&format!(
10490            "{}\t{}\t{}\tafter {}\n",
10491            r.id,
10492            r.result,
10493            r.module,
10494            if r.blockers.is_empty() {
10495                "nothing".to_string()
10496            } else {
10497                r.blockers.join(" ")
10498            }
10499        ));
10500    }
10501    out
10502}
10503
10504#[cfg(test)]
10505mod tests {
10506    /// The tests that set or read the process environment take this lock:
10507    /// cargo runs tests on threads, and one process has one environment.
10508    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
10509        static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
10510        ENV.lock().unwrap_or_else(|e| e.into_inner())
10511    }
10512
10513    /// A root that kept its tilde is the home one.
10514    #[test]
10515    fn a_tilde_tracker_root_expands_against_home() {
10516        use super::expand_leading_tilde as x;
10517        assert_eq!(x("~/vault", "/home/s"), Some("/home/s/vault".into()));
10518        assert_eq!(x("~", "/home/s/"), Some("/home/s".into()));
10519        assert_eq!(x("/abs/vault", "/home/s"), None);
10520        assert_eq!(x("~other/vault", "/home/s"), None);
10521    }
10522
10523    /// A slow pre-push hook does not hold the sitting: the push outlives the
10524    /// wait and the line says so; a quick one reports the push.
10525    #[test]
10526    fn a_slow_tracker_push_finishes_in_the_background() {
10527        let _env = env_guard();
10528        let dir = tempfile::tempdir().unwrap();
10529        let (root, remote, hooks) = (
10530            dir.path().join("work"),
10531            dir.path().join("remote.git"),
10532            dir.path().join("hooks"),
10533        );
10534        let git = |cwd: &std::path::Path, args: &[&str]| {
10535            let o = std::process::Command::new("git")
10536                .arg("-C")
10537                .arg(cwd)
10538                .args(args)
10539                .output()
10540                .unwrap();
10541            assert!(
10542                o.status.success(),
10543                "git {args:?}: {}",
10544                String::from_utf8_lossy(&o.stderr)
10545            );
10546        };
10547        std::fs::create_dir_all(root.join("Software/probe")).unwrap();
10548        std::fs::create_dir_all(&hooks).unwrap();
10549        git(
10550            dir.path(),
10551            &["init", "-q", "--bare", remote.to_str().unwrap()],
10552        );
10553        git(&root, &["init", "-q"]);
10554        for (k, v) in [
10555            ("user.email", "seat@example.invalid"),
10556            ("user.name", "seat"),
10557            ("core.hooksPath", hooks.to_str().unwrap()),
10558        ] {
10559            git(&root, &["config", k, v]);
10560        }
10561        let hook = hooks.join("pre-push");
10562        std::fs::write(&hook, "#!/bin/sh\nsleep 4\n").unwrap();
10563        use std::os::unix::fs::PermissionsExt;
10564        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
10565        let issues = root.join("Software/probe/issues.org");
10566        let heading = "* TODO [#C] Probe\n:PROPERTIES:\n:ID:         probe-c3d4\n:END:\n";
10567        std::fs::write(&issues, heading).unwrap();
10568        git(&root, &["add", "."]);
10569        git(&root, &["commit", "-q", "-m", "seed"]);
10570        git(
10571            &root,
10572            &["remote", "add", "origin", remote.to_str().unwrap()],
10573        );
10574        std::fs::write(&hook, "#!/bin/sh\nexit 0\n").unwrap();
10575        git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
10576        std::fs::write(&hook, "#!/bin/sh\nsleep 4\n").unwrap();
10577        std::env::set_var("VISSUE_ROOT", &root);
10578        std::env::set_var("VISSUE_NO_ROUTE", "1");
10579        std::env::remove_var("ISSUE_ROOT");
10580        std::env::remove_var("LJOS_TRACKER_GIT");
10581        std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "1");
10582        std::env::set_var("XDG_RUNTIME_DIR", dir.path());
10583
10584        std::fs::write(&issues, heading.replace("TODO", "STARTED")).unwrap();
10585        let started = std::time::Instant::now();
10586        let said = super::persist_tracker("probe-c3d4", "claimed");
10587        assert!(
10588            started.elapsed() < std::time::Duration::from_secs(3),
10589            "{said}"
10590        );
10591        assert!(said.contains("still running after 1s"), "{said}");
10592
10593        std::thread::sleep(std::time::Duration::from_secs(5));
10594        std::fs::write(&hook, "#!/bin/sh\nexit 0\n").unwrap();
10595        std::fs::write(&issues, heading.replace("TODO", "DONE")).unwrap();
10596        std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "10");
10597        let said = super::persist_tracker("probe-c3d4", "finished");
10598        assert!(said.contains("committed and pushed"), "{said}");
10599        for var in [
10600            "VISSUE_ROOT",
10601            "VISSUE_NO_ROUTE",
10602            "LJOS_TRACKER_PUSH_WAIT",
10603            "XDG_RUNTIME_DIR",
10604        ] {
10605            std::env::remove_var(var);
10606        }
10607    }
10608
10609    /// A tracker write reaches git: the ticket's file alone is committed, a
10610    /// clean file is left alone, and the switch turns it off.
10611    #[test]
10612    fn a_tracker_write_is_committed_alone() {
10613        let _env = env_guard();
10614        let dir = tempfile::tempdir().unwrap();
10615        let root = dir.path();
10616        let run = |args: &[&str]| {
10617            let o = std::process::Command::new("git")
10618                .arg("-C")
10619                .arg(root)
10620                .args(args)
10621                .output()
10622                .unwrap();
10623            assert!(
10624                o.status.success(),
10625                "git {args:?}: {}",
10626                String::from_utf8_lossy(&o.stderr)
10627            );
10628            String::from_utf8_lossy(&o.stdout).to_string()
10629        };
10630        run(&["init", "-q"]);
10631        run(&["config", "user.email", "seat@example.invalid"]);
10632        run(&["config", "user.name", "seat"]);
10633        run(&["config", "core.hooksPath", "/dev/null"]);
10634        std::fs::create_dir_all(root.join("Software/probe")).unwrap();
10635        let issues = root.join("Software/probe/issues.org");
10636        let heading = "* TODO [#C] Probe\n:PROPERTIES:\n:ID:         probe-a1b2\n:END:\n";
10637        std::fs::write(&issues, heading).unwrap();
10638        std::fs::write(root.join("other.org"), "one\n").unwrap();
10639        run(&["add", "."]);
10640        run(&["commit", "-q", "-m", "seed"]);
10641        std::env::set_var("VISSUE_ROOT", root);
10642        std::env::set_var("VISSUE_NO_ROUTE", "1");
10643        std::env::remove_var("ISSUE_ROOT");
10644        std::env::set_var("LJOS_TRACKER_GIT", "commit");
10645        assert!(super::persist_tracker("probe-a1b2", "claimed").contains("nothing to commit"));
10646
10647        std::fs::write(&issues, heading.replace("TODO", "STARTED")).unwrap();
10648        std::fs::write(root.join("other.org"), "two\n").unwrap();
10649        run(&["add", "other.org"]);
10650        let said = super::persist_tracker("probe-a1b2", "claimed");
10651        assert!(
10652            said.contains("committed chore(issues): probe-a1b2 claimed"),
10653            "{said}"
10654        );
10655        assert_eq!(
10656            run(&["log", "-1", "--format=%s"]).trim(),
10657            "chore(issues): probe-a1b2 claimed"
10658        );
10659        // Another seat's staged file is not swept into the commit.
10660        assert_eq!(
10661            run(&["diff", "--cached", "--name-only"]).trim(),
10662            "other.org"
10663        );
10664
10665        std::fs::write(&issues, heading.replace("TODO", "DONE")).unwrap();
10666        std::env::set_var("LJOS_TRACKER_GIT", "off");
10667        assert!(super::persist_tracker("probe-a1b2", "finished").contains("off"));
10668        for var in ["VISSUE_ROOT", "VISSUE_NO_ROUTE", "LJOS_TRACKER_GIT"] {
10669            std::env::remove_var(var);
10670        }
10671    }
10672
10673    /// A scratch tracker with no remote still reports the commit: the
10674    /// default path pushes, and a refused push is a suffix, not silence.
10675    #[test]
10676    fn a_tracker_commit_with_no_remote_still_reports_the_commit() {
10677        let _env = env_guard();
10678        let dir = tempfile::tempdir().unwrap();
10679        let root = dir.path();
10680        let run = |args: &[&str]| {
10681            let o = std::process::Command::new("git")
10682                .arg("-C")
10683                .arg(root)
10684                .args(args)
10685                .output()
10686                .unwrap();
10687            assert!(
10688                o.status.success(),
10689                "git {args:?}: {}",
10690                String::from_utf8_lossy(&o.stderr)
10691            );
10692            String::from_utf8_lossy(&o.stdout).to_string()
10693        };
10694        run(&["init", "-q"]);
10695        run(&["config", "user.email", "seat@example.invalid"]);
10696        run(&["config", "user.name", "seat"]);
10697        run(&["config", "core.hooksPath", "/dev/null"]);
10698        std::fs::create_dir_all(root.join("Software/probe")).unwrap();
10699        let issues = root.join("Software/probe/issues.org");
10700        let heading = "* TODO [#C] Probe\n:PROPERTIES:\n:ID:         probe-a1b2\n:END:\n";
10701        std::fs::write(&issues, heading).unwrap();
10702        run(&["add", "."]);
10703        run(&["commit", "-q", "-m", "seed"]);
10704        std::fs::write(&issues, heading.replace("TODO", "STARTED")).unwrap();
10705        std::env::set_var("VISSUE_ROOT", root);
10706        std::env::set_var("VISSUE_NO_ROUTE", "1");
10707        std::env::remove_var("ISSUE_ROOT");
10708        std::env::remove_var("LJOS_TRACKER_GIT");
10709        let said = super::persist_tracker("probe-a1b2", "claimed");
10710        assert!(
10711            said.contains("tracker git: committed chore(issues): probe-a1b2 claimed"),
10712            "{said}"
10713        );
10714        assert!(
10715            said.contains("push refused") || said.contains("not pushed"),
10716            "a missing remote must still name the commit: {said}"
10717        );
10718        assert_eq!(
10719            run(&["log", "-1", "--format=%s"]).trim(),
10720            "chore(issues): probe-a1b2 claimed"
10721        );
10722        for var in ["VISSUE_ROOT", "VISSUE_NO_ROUTE", "LJOS_TRACKER_GIT"] {
10723            std::env::remove_var(var);
10724        }
10725    }
10726
10727    /// A fresh host's missing claim graph is a first sitting, not a fault;
10728    /// any other claimdag refusal still is.
10729    #[test]
10730    fn a_claim_graph_nobody_made_yet_is_not_a_fault() {
10731        let fresh = "claimdag exited exit status: 1: no work graph at /h/claims: the directory does not exist, so nothing has been claimed on this seat. Set CLAIMDAG_DIR";
10732        assert_eq!(
10733            super::claim_graph_absent(fresh),
10734            Some("/h/claims".to_string())
10735        );
10736        assert_eq!(
10737            super::claim_graph_absent("claimdag exited exit status: 1: work.bin is corrupt"),
10738            None
10739        );
10740        assert_eq!(
10741            super::claim_graph_absent("no work graph at /h/claims: permission denied"),
10742            None
10743        );
10744    }
10745
10746    /// The tracker row names the root and fails one other seats cannot see.
10747    #[test]
10748    fn tracker_row_names_the_root_and_refuses_a_private_one() {
10749        let dir = tempfile::tempdir().unwrap();
10750        std::fs::create_dir(dir.path().join("Software")).unwrap();
10751        let id = |root: &str| format!("vissue 0.16.2\nprotocol: 1\nroot={root}\nprefix=Software\n");
10752        let root = dir.path().display().to_string();
10753
10754        let (state, ok) = super::tracker_state(&id(&root), "VISSUE_ROOT=x");
10755        assert!(ok, "{state}");
10756        assert!(state.contains(&format!("root={root}")), "{state}");
10757        assert!(state.contains("from VISSUE_ROOT=x"), "{state}");
10758
10759        let (state, ok) = super::tracker_state(&id("~/Git/vault"), "VISSUE_ROOT=~/Git/vault");
10760        assert!(!ok);
10761        assert!(state.contains("relative root"), "{state}");
10762
10763        let missing = dir.path().join("gone").display().to_string();
10764        assert!(!super::tracker_state(&id(&missing), "cwd").1);
10765
10766        std::fs::remove_dir(dir.path().join("Software")).unwrap();
10767        let (state, ok) = super::tracker_state(&id(&root), "cwd");
10768        assert!(!ok);
10769        assert!(state.contains("no prefix directory"), "{state}");
10770
10771        assert!(!super::tracker_state("vissue 0.16.1\n", "cwd").1);
10772    }
10773
10774    fn git_scratch(root: &std::path::Path) {
10775        let run = |args: &[&str]| {
10776            let o = std::process::Command::new("git")
10777                .arg("-C")
10778                .arg(root)
10779                .args(args)
10780                .output()
10781                .unwrap();
10782            assert!(
10783                o.status.success(),
10784                "git {args:?}: {}",
10785                String::from_utf8_lossy(&o.stderr)
10786            );
10787        };
10788        run(&["init", "-q"]);
10789        run(&["config", "user.email", "seat@example.invalid"]);
10790        run(&["config", "user.name", "seat"]);
10791        run(&["config", "core.hooksPath", "/dev/null"]);
10792    }
10793
10794    /// Two remotes of one tracker with different heads fail the row, and
10795    /// agreeing again clears it.
10796    #[test]
10797    fn tracker_row_fails_when_two_remotes_disagree() {
10798        let _env = env_guard();
10799        let dir = tempfile::tempdir().unwrap();
10800        let root = dir.path().join("work");
10801        std::fs::create_dir_all(root.join("Software")).unwrap();
10802        let git = |cwd: &std::path::Path, args: &[&str]| {
10803            let o = std::process::Command::new("git")
10804                .arg("-C")
10805                .arg(cwd)
10806                .args(args)
10807                .output()
10808                .unwrap();
10809            assert!(
10810                o.status.success(),
10811                "git {args:?}: {}",
10812                String::from_utf8_lossy(&o.stderr)
10813            );
10814        };
10815        for bare in ["origin.git", "mirror.git"] {
10816            git(dir.path(), &["init", "-q", "--bare", bare]);
10817        }
10818        git_scratch(&root);
10819        std::fs::write(root.join("Software/.keep"), "").unwrap();
10820        git(&root, &["add", "."]);
10821        git(&root, &["commit", "-q", "-m", "seed"]);
10822        for name in ["origin", "mirror"] {
10823            let url = dir.path().join(format!("{name}.git"));
10824            git(&root, &["remote", "add", name, url.to_str().unwrap()]);
10825            git(&root, &["push", "-q", name, "HEAD:refs/heads/main"]);
10826        }
10827        git(&root, &["branch", "-q", "-M", "main"]);
10828        git(&root, &["fetch", "-q", "--all"]);
10829        git(&root, &["branch", "-q", "-u", "origin/main"]);
10830        let (state, ok) = super::tracker_git_drift(&root).unwrap();
10831        assert!(ok, "{state}");
10832        assert_eq!(
10833            super::tracker_mirrors(&root, "origin/main").unwrap(),
10834            vec![("mirror".to_string(), "main".to_string())],
10835            "a tracker push reaches the mirror too"
10836        );
10837
10838        std::fs::write(root.join("Software/.keep"), "one side\n").unwrap();
10839        git(&root, &["commit", "-qam", "only origin"]);
10840        git(&root, &["push", "-q", "origin", "main"]);
10841        git(&root, &["fetch", "-q", "--all"]);
10842        let (state, ok) = super::tracker_git_drift(&root).unwrap();
10843        assert!(!ok, "{state}");
10844        assert!(
10845            state.contains("mirror/main differs from origin/main"),
10846            "{state}"
10847        );
10848
10849        git(&root, &["push", "-q", "mirror", "main"]);
10850        git(&root, &["fetch", "-q", "--all"]);
10851        let (state, ok) = super::tracker_git_drift(&root).unwrap();
10852        assert!(ok, "{state}");
10853    }
10854
10855    /// The tracker row names how many commits origin lacks, and fails when
10856    /// they have sat through the push wait or the last push was refused.
10857    #[test]
10858    fn tracker_row_fails_when_origin_never_got_the_commits() {
10859        let _env = env_guard();
10860        let dir = tempfile::tempdir().unwrap();
10861        let (root, remote) = (dir.path().join("work"), dir.path().join("remote.git"));
10862        std::fs::create_dir_all(root.join("Software")).unwrap();
10863        let git = |cwd: &std::path::Path, args: &[&str]| {
10864            let o = std::process::Command::new("git")
10865                .arg("-C")
10866                .arg(cwd)
10867                .args(args)
10868                .output()
10869                .unwrap();
10870            assert!(
10871                o.status.success(),
10872                "git {args:?}: {}",
10873                String::from_utf8_lossy(&o.stderr)
10874            );
10875        };
10876        git(
10877            dir.path(),
10878            &["init", "-q", "--bare", remote.to_str().unwrap()],
10879        );
10880        git_scratch(&root);
10881        std::fs::write(root.join("Software/.keep"), "").unwrap();
10882        git(&root, &["add", "."]);
10883        git(&root, &["commit", "-q", "-m", "seed"]);
10884        git(
10885            &root,
10886            &["remote", "add", "origin", remote.to_str().unwrap()],
10887        );
10888        git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
10889
10890        let id = |r: &str| format!("vissue 0.16.2\nprotocol: 1\nroot={r}\nprefix=Software\n");
10891        let root_s = root.display().to_string();
10892        std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "5");
10893        std::env::set_var("XDG_RUNTIME_DIR", dir.path());
10894
10895        let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
10896        assert!(ok, "{state}");
10897        assert!(state.contains("0 unpushed"), "{state}");
10898
10899        std::fs::write(root.join("Software/.keep"), "local\n").unwrap();
10900        git(&root, &["add", "."]);
10901        git(&root, &["commit", "-q", "-m", "ahead"]);
10902        let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
10903        assert!(ok, "a commit younger than the wait stays healthy: {state}");
10904        assert!(state.contains("1 unpushed"), "{state}");
10905
10906        std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "0");
10907        let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
10908        assert!(!ok, "{state}");
10909        assert!(state.contains("1 unpushed"), "{state}");
10910
10911        let mut dead = std::process::Command::new("true").spawn().unwrap();
10912        let dead_pid = dead.id();
10913        let _ = dead.wait();
10914        let logs = dir.path().join("ljos");
10915        std::fs::create_dir_all(&logs).unwrap();
10916        std::fs::write(
10917            logs.join(format!("tracker-push-{dead_pid}.log")),
10918            "remote: pre-push hook declined\nerror: failed to push some refs\n",
10919        )
10920        .unwrap();
10921        let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
10922        assert!(!ok, "{state}");
10923        assert!(state.contains("1 unpushed"), "{state}");
10924        assert!(
10925            state.contains("last push refused: remote: pre-push hook declined"),
10926            "{state}"
10927        );
10928
10929        for var in ["LJOS_TRACKER_PUSH_WAIT", "XDG_RUNTIME_DIR"] {
10930            std::env::remove_var(var);
10931        }
10932    }
10933
10934    #[test]
10935    fn tracker_row_stays_healthy_while_a_background_push_runs() {
10936        let _env = env_guard();
10937        let dir = tempfile::tempdir().unwrap();
10938        let (root, remote) = (dir.path().join("work"), dir.path().join("remote.git"));
10939        std::fs::create_dir_all(root.join("Software")).unwrap();
10940        let git = |cwd: &std::path::Path, args: &[&str]| {
10941            let o = std::process::Command::new("git")
10942                .arg("-C")
10943                .arg(cwd)
10944                .args(args)
10945                .output()
10946                .unwrap();
10947            assert!(
10948                o.status.success(),
10949                "git {args:?}: {}",
10950                String::from_utf8_lossy(&o.stderr)
10951            );
10952        };
10953        git(
10954            dir.path(),
10955            &["init", "-q", "--bare", remote.to_str().unwrap()],
10956        );
10957        git_scratch(&root);
10958        std::fs::write(root.join("Software/.keep"), "").unwrap();
10959        git(&root, &["add", "."]);
10960        git(&root, &["commit", "-q", "-m", "seed"]);
10961        git(
10962            &root,
10963            &["remote", "add", "origin", remote.to_str().unwrap()],
10964        );
10965        git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
10966        std::fs::write(root.join("Software/.keep"), "local\n").unwrap();
10967        git(&root, &["add", "."]);
10968        git(&root, &["commit", "-q", "-m", "ahead"]);
10969
10970        let mut sleeper = std::process::Command::new("sleep")
10971            .arg("8")
10972            .spawn()
10973            .unwrap();
10974        let pid = sleeper.id();
10975        let logs = dir.path().join("ljos");
10976        std::fs::create_dir_all(&logs).unwrap();
10977        std::fs::write(logs.join(format!("tracker-push-{pid}.log")), "").unwrap();
10978        std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "0");
10979        std::env::set_var("XDG_RUNTIME_DIR", dir.path());
10980        let id = format!(
10981            "vissue 0.16.2\nprotocol: 1\nroot={}\nprefix=Software\n",
10982            root.display()
10983        );
10984        let (state, ok) = super::tracker_state(&id, "VISSUE_ROOT=x");
10985        let _ = sleeper.kill();
10986        let _ = sleeper.wait();
10987        assert!(ok, "{state}");
10988        assert!(state.contains("1 unpushed; push still running"), "{state}");
10989        for var in ["LJOS_TRACKER_PUSH_WAIT", "XDG_RUNTIME_DIR"] {
10990            std::env::remove_var(var);
10991        }
10992    }
10993
10994    #[test]
10995    fn a_session_id_occupies_not_the_product_name_on_the_box() {
10996        let _g = env_guard();
10997        unsafe {
10998            std::env::remove_var("VISSUE_AGENT");
10999            std::env::set_var("LJOS_SEAT", "runner-x");
11000            std::env::set_var("GROK_SESSION_ID", "01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11001        }
11002        let holder = resolve_assignee(None);
11003        assert_eq!(
11004            holder, "01a09b25-ffe9-7972-881a-3cee2ea6efd6",
11005            "the session is the occupancy, not a prefix and not the seat"
11006        );
11007        assert_eq!(resolve_assignee(Some("seat")), holder);
11008        assert_eq!(
11009            resolve_assignee(Some("runner-x")),
11010            holder,
11011            "the process naming itself is omitted"
11012        );
11013        assert_eq!(resolve_assignee(Some("alice")), "alice");
11014        assert_eq!(seat_name(), "runner-x");
11015        unsafe {
11016            std::env::remove_var("GROK_SESSION_ID");
11017            std::env::remove_var("LJOS_SEAT");
11018        }
11019    }
11020
11021    #[test]
11022    fn two_session_ids_that_share_a_prefix_occupy_different_slots() {
11023        let _g = env_guard();
11024        unsafe {
11025            std::env::remove_var("LJOS_SEAT");
11026            std::env::remove_var("VISSUE_AGENT");
11027            std::env::set_var("GROK_SESSION_ID", "01a09b25-aaaa-7972-881a-3cee2ea6efd6");
11028        }
11029        let a = resolve_assignee(None);
11030        unsafe {
11031            std::env::set_var("GROK_SESSION_ID", "01a09b25-bbbb-7972-881a-3cee2ea6efd6");
11032        }
11033        let b = resolve_assignee(None);
11034        assert_ne!(
11035            a, b,
11036            "a shared eight-character prefix is not one conversation"
11037        );
11038        assert_eq!(a, "01a09b25-aaaa-7972-881a-3cee2ea6efd6");
11039        assert_eq!(b, "01a09b25-bbbb-7972-881a-3cee2ea6efd6");
11040        unsafe {
11041            std::env::remove_var("GROK_SESSION_ID");
11042        }
11043    }
11044
11045    #[test]
11046    fn a_named_holder_refusal_still_says_held_by_another() {
11047        let hold = Hold {
11048            assignee: "acme".into(),
11049            seat: "acme".into(),
11050            pid: 1,
11051            comm: "ljos".into(),
11052            since: "2026-01-01T00:00:00.000Z".into(),
11053        };
11054        let said = super::held_by_another_message("demo-aaaa", "brio", &hold, "still running");
11055        assert!(said.contains("held by another"), "{said}");
11056        assert!(said.contains("acme"), "{said}");
11057        assert!(said.contains("not by brio"), "{said}");
11058    }
11059
11060    /// Two seats on one ticket: LJOS_SEAT plus a distinct session id each.
11061    #[test]
11062    fn two_seats_with_distinct_session_ids_are_distinct_holders() {
11063        let _g = env_guard();
11064        let dir = std::env::temp_dir().join(format!("ljos-rt-two-seat-{}", std::process::id()));
11065        std::fs::create_dir_all(&dir).unwrap();
11066        let session_keys: Vec<String> = std::env::vars()
11067            .map(|(k, _)| k)
11068            .filter(|k| k.ends_with("_SESSION_ID"))
11069            .collect();
11070        unsafe {
11071            std::env::set_var("XDG_RUNTIME_DIR", &dir);
11072            std::env::remove_var("VISSUE_AGENT");
11073            for k in &session_keys {
11074                std::env::remove_var(k);
11075            }
11076            std::env::set_var("LJOS_SEAT", "acme");
11077            std::env::set_var("ACME_SESSION_ID", "acme-sess-aaaaaa");
11078        }
11079        let a_seat = seat_name();
11080        let a_holder = resolve_assignee(None);
11081        unsafe {
11082            std::env::remove_var("ACME_SESSION_ID");
11083            std::env::set_var("LJOS_SEAT", "brio");
11084            std::env::set_var("BRIO_SESSION_ID", "brio-sess-bbbbbb");
11085        }
11086        let b_seat = seat_name();
11087        let b_holder = resolve_assignee(None);
11088        assert_eq!(a_seat, "acme");
11089        assert_eq!(b_seat, "brio");
11090        assert_eq!(a_holder, "acme-sess-aaaaaa");
11091        assert_eq!(b_holder, "brio-sess-bbbbbb");
11092        assert_ne!(a_holder, b_holder);
11093        unsafe {
11094            std::env::remove_var("LJOS_SEAT");
11095            std::env::remove_var("BRIO_SESSION_ID");
11096            std::env::remove_var("ACME_SESSION_ID");
11097            std::env::remove_var("XDG_RUNTIME_DIR");
11098        }
11099    }
11100
11101    #[test]
11102    fn occupancy_is_per_issue_so_two_sittings_do_not_unseat() {
11103        let _g = env_guard();
11104        unsafe {
11105            std::env::remove_var("LJOS_SEAT");
11106            std::env::remove_var("VISSUE_AGENT");
11107        }
11108        let holder = resolve_assignee(None);
11109        let a = occupancy_assignee(None, "ljos-aaaa");
11110        let b = occupancy_assignee(None, "ljos-bbbb");
11111        assert_ne!(
11112            a, b,
11113            "two issues under one conversation must not share a slot"
11114        );
11115        assert_eq!(a, format!("{holder}:ljos-aaaa"), "{a}");
11116        assert_eq!(b, format!("{holder}:ljos-bbbb"), "{b}");
11117        assert_eq!(
11118            occupancy_assignee(Some("alice"), "ljos-aaaa"),
11119            "alice:ljos-aaaa"
11120        );
11121        assert_eq!(
11122            occupancy_assignee(Some("alice"), "ljos-bbbb"),
11123            "alice:ljos-bbbb"
11124        );
11125    }
11126
11127    #[test]
11128    fn doctor_lists_ljos_hud_but_does_not_require_it() {
11129        assert!(SEAT_BINS
11130            .iter()
11131            .any(|(n, c)| *n == "ljos-hud" && *c == "ljos-hud"));
11132        assert!(!REQUIRED.contains(&"ljos-hud"));
11133    }
11134
11135    #[test]
11136    fn doctor_names_the_session_not_the_default_seat() {
11137        let _g = env_guard();
11138        // A runtime directory of its own: a record another process left for
11139        // this id would name its holder instead.
11140        let dir = std::env::temp_dir().join(format!("ljos-rt-doctor-{}", std::process::id()));
11141        std::fs::create_dir_all(&dir).unwrap();
11142        unsafe {
11143            std::env::set_var("XDG_RUNTIME_DIR", &dir);
11144            std::env::remove_var("LJOS_SEAT");
11145            std::env::remove_var("VISSUE_AGENT");
11146            std::env::set_var("GROK_SESSION_ID", "01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11147        }
11148        let row = format_seat_row();
11149        assert!(
11150            row.contains("01a09b25-ffe9-7972-881a-3cee2ea6efd6"),
11151            "doctor names the whole session: {row}"
11152        );
11153        assert!(
11154            row.contains("GROK_SESSION_ID"),
11155            "doctor names where the session came from: {row}"
11156        );
11157        assert!(!row.contains("the default"), "{row}");
11158        unsafe {
11159            std::env::remove_var("GROK_SESSION_ID");
11160            std::env::remove_var("XDG_RUNTIME_DIR");
11161        }
11162        let _ = std::fs::remove_dir_all(&dir);
11163    }
11164
11165    #[test]
11166    fn a_shared_name_does_not_occupy_the_whole_host() {
11167        let _g = env_guard();
11168        // A pronoun is treated as omitted: the holder is this conversation's,
11169        // whatever the tree above the test says the seat is. A name that is
11170        // not a pronoun is a named worker and stands as given.
11171        let holder = resolve_assignee(None);
11172        assert_eq!(resolve_assignee(Some("you")), holder);
11173        assert_eq!(resolve_assignee(Some("seat")), holder);
11174        assert_eq!(resolve_assignee(Some("agent")), holder);
11175        assert_ne!(holder, "seat");
11176        assert_eq!(resolve_assignee(Some("alice")), "alice");
11177    }
11178
11179    #[test]
11180    fn a_reading_supersedes_the_one_before_and_keeps_it_as_was() {
11181        assert_eq!(parse_every("7d").unwrap(), 7 * 86_400);
11182        assert_eq!(parse_every("24h").unwrap(), 86_400);
11183        assert_eq!(parse_every("2w").unwrap(), 14 * 86_400);
11184        assert_eq!(parse_every("90").unwrap(), 90);
11185        assert!(parse_every("soon").is_err());
11186        assert!(parse_every("0d").is_err());
11187        assert_eq!(
11188            stamp_after("2026-09-19T23:30:00.000Z", 3_600).as_deref(),
11189            Some("2026-09-20T00:30:00.000Z")
11190        );
11191        assert_eq!(trim_num(0.5790), "0.579");
11192        assert_eq!(trim_num(12.0), "12");
11193        assert_eq!(
11194            habit_text("mab cr all", 0.579, "acc", "job 11793"),
11195            "habit mab cr all stands at 0.579 acc (job 11793)."
11196        );
11197        let first = serde_json::json!({
11198            "id": "a1", "kind": "habit", "ts": "2026-09-12T10:00:00.000Z",
11199            "due_at": "2026-09-19T10:00:00.000Z",
11200            "habit": {"name": "mab cr all", "value": 0.535, "unit": "acc", "source": "11750", "every_s": 604800}
11201        });
11202        let second = serde_json::json!({
11203            "id": "a2", "kind": "habit", "ts": "2026-09-19T10:00:00.000Z",
11204            "due_at": "2026-09-26T10:00:00.000Z",
11205            "habit": {"name": "mab cr all", "value": 0.579, "unit": "acc", "source": "11793", "every_s": 604800,
11206                       "was": 0.535, "was_ts": "2026-09-12T10:00:00.000Z"}
11207        });
11208        let other = serde_json::json!({
11209            "id": "l1", "kind": "lesson", "text": "not a habit", "ts": "2026-09-19T10:00:00.000Z"
11210        });
11211        // The pack hands back one live reading a habit; a stale copy sorts out.
11212        let rows = readings_of(&[first.clone(), other, second]);
11213        assert_eq!(rows.len(), 1);
11214        assert_eq!(rows[0].id.as_deref(), Some("a2"));
11215        assert_eq!(rows[0].was, Some(0.535));
11216        let now = "2026-09-20T09:00:00.000Z";
11217        let line = format_readings(&rows, now);
11218        assert!(line.starts_with("mab cr all\t0.579 acc\t+0.044 since 0.535 (8 days ago)\tyesterday\tnext reading in 6 days\t11793\n"), "{line}");
11219        let late = readings_of(&[first]);
11220        assert!(format_readings(&late, now).contains("next reading late (yesterday)"));
11221        assert_eq!(format_change(&late[0], now), "first reading");
11222    }
11223
11224    #[test]
11225    fn a_program_is_named_by_its_path_not_its_version() {
11226        assert!(version_like("2.1.266"));
11227        assert!(version_like("v18.2.0"));
11228        assert!(!version_like("acme"));
11229        // The kernel's short name of a binary installed under a versions
11230        // directory is the version; the program is the directory above.
11231        let me = program_name(std::process::id(), "comm");
11232        assert!(!me.is_empty() && !version_like(&me), "{me}");
11233    }
11234
11235    #[test]
11236    fn a_hit_names_the_seat_that_wrote_it_only_when_that_is_another() {
11237        let ents = vec!["seat:brio".to_string(), "habit:x".to_string()];
11238        assert_eq!(other_seat(&ents, "acme-cli").as_deref(), Some("brio"));
11239        assert_eq!(other_seat(&ents, "brio"), None);
11240        assert_eq!(other_seat(&["habit:x".to_string()], "brio"), None);
11241    }
11242
11243    #[test]
11244    fn two_session_ids_that_share_a_prefix_take_two_slots() {
11245        let a = session_tag("01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11246        let b = session_tag("01a09b25-ffe9-7972-881a-3cee2ea6efd7");
11247        assert_ne!(a, b);
11248        assert_eq!(a.len(), 10);
11249        assert_eq!(a, session_tag(" 01a09b25-ffe9-7972-881a-3cee2ea6efd6 "));
11250    }
11251
11252    /// Two conversations started from one terminal share the line editor's
11253    /// id; each finds its own server's record, never the other's.
11254    #[test]
11255    fn a_record_from_another_conversation_is_not_this_ones() {
11256        let ble = "1000000000.000001/4242".to_string();
11257        let me = "01a09b25-ffe9-7972-881a-000000000001".to_string();
11258        let other = "01a09b25-ffe9-7972-881a-000000000002".to_string();
11259        let mine = vec![ble.clone(), me.clone()];
11260        let theirs = format!("acme-cli\nsess-other\nids\t{ble}\t{other}\n");
11261        assert!(super::record_for(&theirs, &mine, "t".into()).is_none());
11262        let ours = format!("acme-cli\nsess-mine\nids\t{ble}\t{me}\n");
11263        assert_eq!(
11264            super::record_for(&ours, &mine, "t".into()).unwrap().holder,
11265            "sess-mine"
11266        );
11267        // A shell that adds an id of its own still finds its server's record.
11268        let shell = vec![ble.clone(), me.clone(), "9f9f9f9f-extra".into()];
11269        assert!(super::record_for(&ours, &shell, "t".into()).is_some());
11270        // A record from before the ids line is taken as it stands.
11271        assert!(super::record_for("acme-cli\nsess-old\n", &mine, "t".into()).is_some());
11272    }
11273
11274    #[test]
11275    fn the_host_row_reads_oom_kills_and_this_logins_servers() {
11276        assert_eq!(
11277            parse_oom_kills("pgfault 12\noom_kill 43\nnr_free_pages 1\n"),
11278            Some(43)
11279        );
11280        assert_eq!(parse_oom_kills("pgfault 12\n"), None);
11281        assert_eq!(
11282            status_field("Name:\tx\nVmRSS:\t  2692 kB\n", "VmRSS:").as_deref(),
11283            Some("2692")
11284        );
11285        let row = host_row();
11286        assert_eq!(row.name, "host");
11287        assert!(row.state.contains("ljos-mcp"), "{}", row.state);
11288    }
11289
11290    #[test]
11291    fn a_library_default_client_name_is_not_a_seat() {
11292        assert_eq!(seat_for_client("Acme CLI"), "acme-cli");
11293        for library in ["mcp", "MCP", "mcp-client"] {
11294            let seat = seat_for_client(library);
11295            assert!(
11296                !LIBRARY_CLIENT_NAMES.contains(&seat.as_str()) || ancestry().is_empty(),
11297                "{library} named the seat {seat}"
11298            );
11299        }
11300    }
11301
11302    #[test]
11303    fn a_runner_started_inside_another_keeps_its_own_holder() {
11304        let _g = env_guard();
11305        let dir = std::env::temp_dir().join(format!("ljos-nest-{}", std::process::id()));
11306        std::fs::create_dir_all(&dir).unwrap();
11307        unsafe {
11308            std::env::set_var("XDG_RUNTIME_DIR", &dir);
11309            std::env::set_var("ACME_SESSION_ID", "01a09b25-1111-7972-881a-3cee2ea6efd6");
11310        }
11311        let parent = announce_seat("Acme CLI", 5151);
11312        // The child inherits the parent's id and connects under its own name.
11313        let child = announce_seat("Brio Agent", 5252);
11314        assert_eq!(child.seat, "brio-agent");
11315        assert_ne!(child.holder, parent.holder);
11316        assert_eq!(
11317            seat_from_session_records()
11318                .expect("the parent's record")
11319                .holder,
11320            parent.holder,
11321            "the child leaves the parent's record alone"
11322        );
11323        retire_seat(5252);
11324        assert_eq!(
11325            seat_from_session_records()
11326                .expect("still the parent's")
11327                .holder,
11328            parent.holder,
11329            "the child's exit does not take the parent's record"
11330        );
11331        retire_seat(5151);
11332        assert!(seat_from_session_records().is_none());
11333        unsafe {
11334            std::env::remove_var("ACME_SESSION_ID");
11335            std::env::remove_var("XDG_RUNTIME_DIR");
11336        }
11337        let _ = std::fs::remove_dir_all(&dir);
11338    }
11339
11340    #[test]
11341    fn a_thread_named_on_a_call_holds_as_its_shells_do() {
11342        let _g = env_guard();
11343        let dir = std::env::temp_dir().join(format!("ljos-thread-{}", std::process::id()));
11344        std::fs::create_dir_all(&dir).unwrap();
11345        unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
11346        assert!(runner_session_var("ACME_THREAD_ID", "0199a1b2-c3d4"));
11347        assert!(!runner_session_var("ACME_THREAD_ID", "short"));
11348        // No shell has sat yet: the thread id is the holder, and recorded.
11349        let first = seat_for_thread("0199a1b2-aaaa-thread");
11350        assert_eq!(first.holder, "0199a1b2-aaaa-thread");
11351        let text = std::fs::read_to_string(session_record_path("0199a1b2-aaaa-thread")).unwrap();
11352        assert_eq!(
11353            holder_naming(&text, "0199a1b2-aaaa-thread").as_deref(),
11354            Some("0199a1b2-aaaa-thread")
11355        );
11356        // A shell of the thread sat first: the call takes the shell's holder.
11357        let shell = Seat {
11358            seat: "acme".into(),
11359            holder: "sess-shellfirst".into(),
11360            source: String::new(),
11361        };
11362        write_record_ids(
11363            &session_record_path("0199a1b2-bbbb-thread"),
11364            &shell,
11365            &["line-editor-id".into(), "0199a1b2-bbbb-thread".into()],
11366        );
11367        assert_eq!(
11368            seat_for_thread("0199a1b2-bbbb-thread").holder,
11369            "sess-shellfirst"
11370        );
11371        unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11372        let _ = std::fs::remove_dir_all(&dir);
11373    }
11374
11375    #[test]
11376    fn a_shell_with_one_more_session_variable_finds_the_servers_record() {
11377        let _g = env_guard();
11378        let dir = std::env::temp_dir().join(format!("ljos-rt-{}", std::process::id()));
11379        std::fs::create_dir_all(&dir).unwrap();
11380        unsafe {
11381            std::env::set_var("XDG_RUNTIME_DIR", &dir);
11382            std::env::set_var("ACME_SESSION_ID", "01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11383        }
11384        let server = announce_seat("Acme CLI", 4242);
11385        assert_eq!(server.seat, "acme-cli");
11386        // The shell's line editor stamps its own id; the shared one still
11387        // finds the record, and the holder is the server's.
11388        unsafe {
11389            std::env::set_var(
11390                "AAA_LINE_EDITOR_SESSION_ID",
11391                "9f9f9f9f-0000-0000-0000-000000000000",
11392            );
11393        }
11394        let shell = seat_from_session_records().expect("the shared id finds the record");
11395        assert_eq!(shell.holder, server.holder);
11396        assert_eq!(shell.seat, server.seat);
11397        retire_seat(4242);
11398        assert!(seat_from_session_records().is_none());
11399        unsafe {
11400            std::env::remove_var("ACME_SESSION_ID");
11401            std::env::remove_var("AAA_LINE_EDITOR_SESSION_ID");
11402            std::env::remove_var("XDG_RUNTIME_DIR");
11403        }
11404        let _ = std::fs::remove_dir_all(&dir);
11405        assert_ne!(session_tag("01a09b25-aaaa"), session_tag("01a09b25-bbbb"));
11406    }
11407
11408    #[test]
11409    fn a_panel_seats_the_personas_that_speak_to_the_issue() {
11410        let mk = |name: &str, about: &[&str]| Persona {
11411            name: name.into(),
11412            anchor: 0.5,
11413            view: String::new(),
11414            entities: about.iter().map(|s| (*s).to_string()).collect(),
11415        };
11416        let all = vec![
11417            mk("reviewer", &["docs"]),
11418            mk("cuda", &["gpu", "kernels"]),
11419            mk("reader", &[]),
11420        ];
11421        let docs = personas_speaking_to(&all, &["Docs".to_string(), "site".to_string()]);
11422        assert_eq!(
11423            docs.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11424            ["reviewer"]
11425        );
11426        let nobody = personas_speaking_to(&all, &["fortran".to_string()]);
11427        assert_eq!(
11428            nobody.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11429            ["reader"],
11430            "no domain match seats only personas with no domains"
11431        );
11432        let specialists = vec![mk("reviewer", &["docs"]), mk("cuda", &["gpu"])];
11433        assert!(personas_speaking_to(&specialists, &["fortran".to_string()]).is_empty());
11434        let scoped = vec![
11435            mk("seatkeeper", &["seat", "ballot", "sync:rgsurflat"]),
11436            mk("cuda", &["gpu", "sync:rgsurflat"]),
11437        ];
11438        let seated = personas_speaking_to(
11439            &scoped,
11440            &["ballot".to_string(), "sync:rgsurflat".to_string()],
11441        );
11442        assert_eq!(
11443            seated.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11444            ["seatkeeper"],
11445            "a shared sync scope does not seat the roster"
11446        );
11447        let mut merger = mk("merger", &["git"]);
11448        merger.view = "Reads a merge for the writer it silently drops.".into();
11449        let mut other = mk("other", &["gpu"]);
11450        other.view = "Wants the kernel to be fast.".into();
11451        let by_view = personas_speaking_to(
11452            &[merger, other],
11453            &["merge".to_string(), "writers".to_string()],
11454        );
11455        assert_eq!(
11456            by_view.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11457            ["merger"],
11458            "a specialist whose view uses the issue's words is seated"
11459        );
11460    }
11461
11462    #[test]
11463    fn a_client_name_is_one_seat_however_it_is_spelt() {
11464        assert_eq!(seat_slug("Acme CLI"), "acme-cli");
11465        assert_eq!(seat_slug("acme_cli/1.2"), "acme-cli-1-2");
11466        assert_eq!(seat_slug("  --  "), "runner");
11467        assert_eq!(conversation_tag(4242), "39u");
11468        assert_eq!(conversation_tag(0), "0");
11469    }
11470
11471    #[test]
11472    fn the_server_leaves_a_record_a_shell_below_the_runner_reads() {
11473        let dir = std::env::temp_dir().join(format!("ljos-seat-{}", std::process::id()));
11474        std::fs::create_dir_all(&dir).unwrap();
11475        // The record path is pure in the directory, so build it the way the
11476        // server does and read it back the way a shell does.
11477        let path = dir.join("ljos").join("seat-4242");
11478        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
11479        let seat = Seat::tagged(
11480            seat_slug("Acme CLI"),
11481            &conversation_tag(4242),
11482            "test".to_string(),
11483        );
11484        std::fs::write(&path, format!("{}\n{}\n", seat.seat, seat.holder)).unwrap();
11485        let text = std::fs::read_to_string(&path).unwrap();
11486        let mut lines = text.lines();
11487        assert_eq!(lines.next(), Some("acme-cli"));
11488        assert_eq!(lines.next(), Some("acme-cli-39u"));
11489        assert_eq!(
11490            format_seat(&seat),
11491            "seat\tacme-cli\nholder\tacme-cli-39u\nsource\ttest\n"
11492        );
11493        let _ = std::fs::remove_dir_all(&dir);
11494    }
11495
11496    #[test]
11497    fn the_record_weighs_a_voter_by_what_it_got_right() {
11498        let ballots = vec![
11499            ("a".to_string(), "ship".to_string()),
11500            ("b".to_string(), "ship".to_string()),
11501            ("c".to_string(), "hold".to_string()),
11502        ];
11503        let (rows, records) =
11504            learn_record(&ballots, "ship", &std::collections::BTreeMap::new(), &[]).unwrap();
11505        assert_eq!(records["a"], (1.0, 0.0));
11506        assert_eq!(records["c"], (0.0, 1.0));
11507        let w = |to: &str| rows.iter().find(|r| r.to == to).unwrap().weight;
11508        assert_eq!(w("a"), 1.0, "a right voter stands at one");
11509        assert!(w("c") < w("a"), "a wrong voter stands lower");
11510        assert_eq!(rows.len(), 6, "complete over the voters");
11511        // The record accumulates: a second outcome against c lowers it further.
11512        let (rows2, records2) = learn_record(&ballots, "ship", &records, &[]).unwrap();
11513        assert_eq!(records2["c"], (0.0, 2.0));
11514        let w2 = |to: &str| rows2.iter().find(|r| r.to == to).unwrap().weight;
11515        assert!(w2("c") <= w("c"));
11516        assert!(learn_record(&ballots, "  ", &records, &[]).is_err());
11517        // Records are read back off trust atoms, latest first.
11518        let atoms = vec![
11519            serde_json::json!({"kind": "trust", "from": "a", "to": "c", "weight": 0.2, "hits": 1.0, "misses": 3.0, "ts": "2026-09-13T01:00:00Z"}),
11520            serde_json::json!({"kind": "trust", "from": "b", "to": "c", "weight": 0.5, "hits": 1.0, "misses": 1.0, "ts": "2026-09-12T01:00:00Z"}),
11521        ];
11522        assert_eq!(records_from_atoms(&atoms)["c"], (1.0, 3.0));
11523    }
11524
11525    #[test]
11526    fn a_correction_is_nudged_once_a_session_and_only_on_a_prompt() {
11527        let _g = env_guard();
11528        // The seen file lives under the runtime directory.
11529        let dir = std::env::temp_dir().join(format!("ljos-corr-{}", std::process::id()));
11530        std::fs::create_dir_all(&dir).unwrap();
11531        unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
11532        let prompt = HookCall {
11533            event: "UserPromptSubmit".into(),
11534            cue: "Do you not remember to use uv for scripts?".into(),
11535            session: Some("corr-test".into()),
11536            shape: HookShape::Asks,
11537        };
11538        let (key, first) = correction_nudge(&prompt).expect("a correction is nudged");
11539        assert!(first.contains("ljos prefer"), "{first}");
11540        assert!(
11541            correction_nudge(&prompt).is_some(),
11542            "unmarked until delivered"
11543        );
11544        mark_seen(Some("corr-test"), &[key]);
11545        assert!(correction_nudge(&prompt).is_none(), "once delivered");
11546        let tool = HookCall {
11547            event: "PreToolUse".into(),
11548            cue: "you should have used uv".into(),
11549            session: Some("corr-test".into()),
11550            shape: HookShape::Asks,
11551        };
11552        assert!(
11553            correction_nudge(&tool).is_none(),
11554            "tool calls are not prompts"
11555        );
11556        let plain = HookCall {
11557            event: "UserPromptSubmit".into(),
11558            cue: "add the timeline verb".into(),
11559            session: Some("corr-test-2".into()),
11560            shape: HookShape::Asks,
11561        };
11562        assert!(correction_nudge(&plain).is_none());
11563    }
11564
11565    #[test]
11566    fn a_subagent_is_told_its_parents_issue_and_held_once_at_stop() {
11567        let grok = r#"{"hookEventName":"subagent_stop","sessionId":"child","subagentType":"explore","stopHookActive":false}"#;
11568        assert_eq!(
11569            hook_subagent(grok),
11570            (Some("explore".into()), false, String::new())
11571        );
11572        let shared = r#"{"hook_event_name":"SubagentStop","session_id":"p","agent_id":"a1","agent_type":"review","stop_hook_active":true}"#;
11573        assert_eq!(
11574            hook_subagent(shared),
11575            (Some("review".into()), true, "a1".into())
11576        );
11577        assert_eq!(hook_subagent(r#"{"hook_event_name":"Stop"}"#).0, None);
11578        let brief = subagent_brief("explore", "acme-12ab", true);
11579        assert!(
11580            brief.contains("Do not open a sitting")
11581                && brief.contains("ljos vote acme-12ab")
11582                && brief.contains("--expect"),
11583            "{brief}"
11584        );
11585        let decide = subagent_stop_reason("explore", Some("acme-12ab"), true, false).unwrap();
11586        assert!(
11587            decide.contains("decision")
11588                && decide.contains("--expect")
11589                && decide.contains("--as ROLE"),
11590            "{decide}"
11591        );
11592        let plain = subagent_stop_reason("explore", Some("acme-12ab"), false, false).unwrap();
11593        assert!(plain.contains("Otherwise stop"), "{plain}");
11594        assert!(
11595            subagent_stop_reason("explore", Some("acme-12ab"), true, true).is_none(),
11596            "held once"
11597        );
11598        assert!(
11599            subagent_stop_reason("explore", None, true, false).is_none(),
11600            "no issue, no gate"
11601        );
11602    }
11603
11604    #[test]
11605    fn a_clone_without_the_named_merge_driver_is_reported() {
11606        let dir = tempfile::tempdir().unwrap();
11607        let git = |args: &[&str]| {
11608            std::process::Command::new("git")
11609                .arg("-C")
11610                .arg(dir.path())
11611                .args(args)
11612                .output()
11613                .unwrap()
11614        };
11615        git(&["init", "-q"]);
11616        assert!(
11617            tracker_merge_driver_missing(dir.path()).is_none(),
11618            "no attribute, no row"
11619        );
11620        std::fs::write(
11621            dir.path().join(".gitattributes"),
11622            "issues.org merge=vissue\n",
11623        )
11624        .unwrap();
11625        let said = tracker_merge_driver_missing(dir.path()).expect("named and missing");
11626        assert!(said.contains("vissue merge-driver --install"), "{said}");
11627        git(&[
11628            "config",
11629            "merge.vissue.driver",
11630            "vissue merge-driver %O %A %B %P",
11631        ]);
11632        assert!(tracker_merge_driver_missing(dir.path()).is_none());
11633    }
11634
11635    #[test]
11636    fn a_subagent_reads_its_parents_issue_from_the_hold_records() {
11637        let _g = env_guard();
11638        let dir = tempfile::tempdir().unwrap();
11639        unsafe { std::env::set_var("XDG_RUNTIME_DIR", dir.path()) };
11640        let ljos = dir.path().join("ljos");
11641        std::fs::create_dir_all(&ljos).unwrap();
11642        let rec = |name: &str, holder: &str, at: &str, node: &str| {
11643            std::fs::write(
11644                ljos.join(format!("hold-{name}")),
11645                format!("{holder}\nacme\n1\nacme\n{at}\n{node}\n"),
11646            )
11647            .unwrap();
11648        };
11649        rec("a", "sess-parent", "2026-09-27T10:00:00Z", "acme-old1");
11650        rec("b", "sess-parent", "2026-09-27T12:00:00Z", "acme-new2");
11651        rec("c", "sess-other", "2026-09-27T13:00:00Z", "brio-3c4d");
11652        std::fs::write(
11653            ljos.join("hold-d"),
11654            "sess-parent\nacme\n1\nacme\n2026-09-27T14:00:00Z\n",
11655        )
11656        .unwrap();
11657        assert_eq!(
11658            held_from_records(&["sess-parent".to_string()]).as_deref(),
11659            Some("acme-new2")
11660        );
11661        assert_eq!(held_from_records(&["sess-nobody".to_string()]), None);
11662        unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11663    }
11664
11665    #[test]
11666    fn a_long_run_without_the_seat_is_reminded_once_per_stretch() {
11667        let _g = env_guard();
11668        let dir = tempfile::tempdir().unwrap();
11669        unsafe { std::env::set_var("XDG_RUNTIME_DIR", dir.path()) };
11670        let call = |cue: &str, event: &str| HookCall {
11671            event: event.into(),
11672            cue: cue.into(),
11673            session: Some("work-test".into()),
11674            shape: HookShape::Asks,
11675        };
11676        for _ in 1..WORK_NUDGE_EVERY {
11677            assert!(work_nudge(&call("cargo test", "PostToolUse"), false).is_none());
11678        }
11679        let said =
11680            work_nudge(&call("cargo test", "PostToolUse"), false).expect("nudged at the count");
11681        assert!(
11682            said.contains("no issue held") || said.contains("vissue note"),
11683            "{said}"
11684        );
11685        assert!(
11686            work_nudge(&call("cargo test", "PostToolUse"), false).is_none(),
11687            "count starts over"
11688        );
11689        assert!(work_nudge(&call("ljos remember x", "PreToolUse"), false).is_none());
11690        assert!(
11691            work_nudge(&call("rg foo", "PostToolUse"), true).is_none(),
11692            "a subagent has its brief"
11693        );
11694        assert!(touches_seat("use_tool ljos__ljos_sitting"));
11695        assert!(!touches_seat("cargo build --release"));
11696        unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11697    }
11698
11699    #[test]
11700    fn a_twin_hook_call_is_answered_once() {
11701        let _g = env_guard();
11702        let dir = tempfile::tempdir().unwrap();
11703        unsafe { std::env::set_var("XDG_RUNTIME_DIR", dir.path()) };
11704        let call = |cue: &str| HookCall {
11705            event: "UserPromptSubmit".into(),
11706            cue: cue.into(),
11707            session: Some("twin".into()),
11708            shape: HookShape::CamelCase,
11709        };
11710        assert!(
11711            !hook_already_running(&call("fix the ci")),
11712            "the first answers"
11713        );
11714        assert!(
11715            hook_already_running(&call("fix the ci")),
11716            "its twin returns"
11717        );
11718        assert!(
11719            !hook_already_running(&call("another prompt")),
11720            "another prompt answers"
11721        );
11722        unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11723    }
11724
11725    #[test]
11726    fn a_second_commit_lock_waits_for_the_first() {
11727        let dir = tempfile::tempdir().unwrap();
11728        let path = dir.path().join("ljos-commit.lock");
11729        let first = CommitLock::acquire(&path);
11730        assert!(first.0.is_some(), "the lock opens");
11731        let other = path.clone();
11732        let started = std::time::Instant::now();
11733        let waiter = std::thread::spawn(move || {
11734            let _second = CommitLock::acquire(&other);
11735            started.elapsed()
11736        });
11737        std::thread::sleep(std::time::Duration::from_millis(300));
11738        drop(first);
11739        let waited = waiter.join().unwrap();
11740        assert!(
11741            waited >= std::time::Duration::from_millis(250),
11742            "{waited:?}"
11743        );
11744    }
11745
11746    #[test]
11747    fn a_verdict_from_jev_replaces_the_phrase_lists() {
11748        let call = |cue: &str, session: &str| HookCall {
11749            event: "UserPromptSubmit".into(),
11750            cue: cue.into(),
11751            session: Some(session.into()),
11752            shape: HookShape::Asks,
11753        };
11754        let plain = call("add the timeline verb", "verdict-1");
11755        assert!(decision_nudge_as(&plain, None).is_none(), "no cue word");
11756        assert!(
11757            decision_nudge_as(&plain, Some(true)).is_some(),
11758            "judged a choice"
11759        );
11760        let asked = call("should we seal with age or gpg?", "verdict-2");
11761        assert!(
11762            decision_nudge_as(&asked, Some(false)).is_none(),
11763            "judged not a choice"
11764        );
11765        let (key, _) = correction_nudge_as(&plain, Some(true)).expect("judged a correction");
11766        assert_eq!(key, "correction:judged");
11767        assert!(correction_nudge_as(&plain, Some(false)).is_none());
11768    }
11769
11770    #[test]
11771    fn a_choice_is_sent_to_a_panel_once_a_session() {
11772        let _g = env_guard();
11773        let dir = std::env::temp_dir().join(format!("ljos-dec-{}", std::process::id()));
11774        std::fs::create_dir_all(&dir).unwrap();
11775        unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
11776        let call = |cue: &str, session: &str, event: &str| HookCall {
11777            event: event.into(),
11778            cue: cue.into(),
11779            session: Some(session.into()),
11780            shape: HookShape::Asks,
11781        };
11782        let prompt = call(
11783            "should we seal with age or gpg?",
11784            "dec-test",
11785            "UserPromptSubmit",
11786        );
11787        let (key, first) = decision_nudge(&prompt).expect("a choice is nudged");
11788        assert!(
11789            first.contains("Options:") && first.contains("--as NAME"),
11790            "{first}"
11791        );
11792        assert!(
11793            decision_nudge(&prompt).is_some(),
11794            "unmarked until delivered"
11795        );
11796        mark_seen(Some("dec-test"), &[key]);
11797        assert!(decision_nudge(&prompt).is_none(), "once delivered");
11798        assert!(decision_nudge(&call("age vs gpg", "dec-test-2", "PreToolUse")).is_none());
11799        assert!(decision_nudge(&call(
11800            "add the timeline verb",
11801            "dec-test-3",
11802            "UserPromptSubmit"
11803        ))
11804        .is_none());
11805        assert!(
11806            decision_nudge(&call("go with option 2", "dec-test-4", "UserPromptSubmit")).is_some()
11807        );
11808        assert!(
11809            decision_nudge(&call(
11810                "tell me the option about caching",
11811                "dec-test-5",
11812                "UserPromptSubmit"
11813            ))
11814            .is_none(),
11815            "a cue ends at a word boundary"
11816        );
11817        let report = format!(
11818            "{} should we keep it?",
11819            "a long pasted report line. ".repeat(40)
11820        );
11821        assert!(
11822            decision_nudge(&call(&report, "dec-test-6", "UserPromptSubmit")).is_none(),
11823            "a cue past the opening is not a choice put to the agent"
11824        );
11825    }
11826
11827    #[test]
11828    fn calibration_weights_are_log_odds_with_the_best_at_one() {
11829        let w = calibration_weights(&[
11830            ("a".to_string(), 0.9),
11831            ("b".to_string(), 0.6),
11832            ("c".to_string(), 0.5),
11833            ("d".to_string(), 1.0),
11834        ]);
11835        let of = |who: &str| w.iter().find(|(n, _)| n == who).unwrap().1;
11836        assert_eq!(of("d"), 1.0, "a perfect record is the top of the scale");
11837        // ln(9) / ln(99) = 0.478; ln(1.5) / ln(99) = 0.088
11838        assert!((of("a") - 0.478).abs() < 0.01, "{}", of("a"));
11839        assert!((of("b") - 0.088).abs() < 0.01, "{}", of("b"));
11840        assert!(
11841            of("a") / of("b") > 5.0,
11842            "nine in ten outweighs six in ten by more than five"
11843        );
11844        assert_eq!(of("c"), TRUST_FLOOR, "chance earns the floor");
11845    }
11846
11847    #[test]
11848    fn a_consolidation_report_names_the_pairs() {
11849        let body = serde_json::json!({"live": 5, "closed": 1, "applied": false, "pairs": [
11850            {"old": "a", "old_text": "The default fuse is Borda.", "new": "b", "new_text": "The default fuse is CombMNZ."}
11851        ]});
11852        let text = format_consolidation(&body);
11853        assert!(
11854            text.starts_with(
11855                "closes a  The default fuse is Borda.\n    for b  The default fuse is CombMNZ.\n"
11856            ),
11857            "{text}"
11858        );
11859        assert!(
11860            text.ends_with(
11861                "1 of 5 live memories would close; `ljos consolidate --apply` closes them\n"
11862            ),
11863            "{text}"
11864        );
11865        let applied = format_consolidation(
11866            &serde_json::json!({"live": 5, "closed": 0, "applied": true, "pairs": []}),
11867        );
11868        assert_eq!(applied, "0 of 5 live memories closed\n");
11869    }
11870
11871    #[test]
11872    fn the_hook_keeps_what_two_scorers_agreed_on() {
11873        let hit = |ballots, of| Hit {
11874            id: None,
11875            text: "x".into(),
11876            score: 1.0,
11877            kind: "lesson".into(),
11878            ts: None,
11879            entities: vec![],
11880            ballots,
11881            of,
11882        };
11883        assert!(agreed(&hit(Some(2), Some(3))));
11884        assert!(!agreed(&hit(Some(1), Some(3))));
11885        assert!(agreed(&hit(Some(1), Some(1))));
11886        assert!(agreed(&hit(None, None)));
11887        assert!(names_the_cue(
11888            "OpenCPMD Fortran calls the rgsaddle band API.",
11889            "plot the eon outputs with opencpmd and chemparseplot"
11890        ));
11891        assert!(!names_the_cue(
11892            "A submitted CQA packet uses the reviewer-edited Org quotes.",
11893            "plot the eon outputs with chemparseplot"
11894        ));
11895        assert!(!names_the_cue(
11896            "A doc comment states what an item does and one why.",
11897            "why are you not making real images"
11898        ));
11899        assert!(!names_the_cue("The fuse default is CombMNZ.", "why"));
11900        assert!(!names_a_numbered_pr(
11901            "A PR branch has to contain main before it merges."
11902        ));
11903        assert!(names_a_numbered_pr(
11904            "Pull requests 32 and 36 share one tree, and PR 32 replays PR 36."
11905        ));
11906        assert!(names_a_numbered_pr("rgpot #80 left a sibling behind main."));
11907        assert!(!names_a_numbered_pr(
11908            "The prompt hook holds the pack note until the first tool result."
11909        ));
11910        assert!(is_transient(
11911            "Pull requests 32 and 36 share one tree, and PR 32 replays PR 36."
11912        ));
11913        assert!(is_transient("The closure is on ljos-wgo8."));
11914        assert!(is_transient("The sweep was commit 80c73416c."));
11915        assert!(!is_transient(
11916            "A PR branch has to contain main before it merges."
11917        ));
11918        assert!(!is_transient("The prompt hook holds the pack note."));
11919        let standing = Hit {
11920            id: None,
11921            text: "Pull requests 32 and 36 share one tree.".into(),
11922            score: 1.0,
11923            kind: "lesson".into(),
11924            ts: None,
11925            entities: vec!["horizon:standing".into()],
11926            ballots: None,
11927            of: None,
11928        };
11929        assert!(is_refresher(&standing));
11930        let tagged = Hit {
11931            id: None,
11932            text: "A PR branch has to contain main.".into(),
11933            score: 1.0,
11934            kind: "lesson".into(),
11935            ts: None,
11936            entities: vec!["horizon:transient".into()],
11937            ballots: None,
11938            of: None,
11939        };
11940        assert!(!is_refresher(&tagged));
11941        let untagged = Hit {
11942            id: None,
11943            text: "A PR branch has to contain main.".into(),
11944            score: 1.0,
11945            kind: "lesson".into(),
11946            ts: None,
11947            entities: vec![],
11948            ballots: None,
11949            of: None,
11950        };
11951        assert!(!is_refresher(&untagged));
11952    }
11953
11954    #[test]
11955    fn the_generation_is_read_off_a_get_line() {
11956        let line = "a25a…  claimed  task  unset  gen=2  assignee=69f917124f757277b806e9a0f48c0318  parent=0  x-1";
11957        assert_eq!(gen_of(line), Some(2));
11958        assert_eq!(gen_of("deps  -"), None);
11959        assert_eq!(gen_of("a  ready  task  unset  gen=x"), None);
11960    }
11961
11962    #[test]
11963    fn the_holder_is_read_off_a_get_line() {
11964        let line = "a25a…  claimed  task  unset  gen=2  assignee=69f917124f757277b806e9a0f48c0318  parent=0  x-1";
11965        assert_eq!(
11966            holder_of(line).as_deref(),
11967            Some("69f917124f757277b806e9a0f48c0318")
11968        );
11969        assert_eq!(
11970            holder_of("a  ready  task  unset  gen=1  assignee=00000000000000000000000000000000"),
11971            None
11972        );
11973        assert_eq!(holder_of("deps  -"), None);
11974    }
11975
11976    #[test]
11977    fn a_registration_carries_the_runners_name() {
11978        let argv: Vec<String> = ["run", "-e", "LJOS_SEAT={name}", "{server}"]
11979            .iter()
11980            .map(|s| (*s).to_string())
11981            .collect();
11982        let filled = filled(&argv, Path::new("/x/ljos-mcp"), "runner-a");
11983        assert_eq!(filled, ["run", "-e", "LJOS_SEAT=runner-a", "/x/ljos-mcp"]);
11984        assert_eq!(
11985            identity_or_seat(Some(" reviewer ")).as_deref(),
11986            Some("reviewer")
11987        );
11988    }
11989
11990    #[test]
11991    fn a_timeline_reads_every_store_on_the_local_day() {
11992        let _g = env_guard();
11993        let before = std::env::var("TZ").ok();
11994        unsafe { std::env::set_var("TZ", "CET-1CEST,M3.5.0,M10.5.0/3") };
11995        // 22:28 UTC on the 26th is 00:28 on the 27th in Amsterdam, the day
11996        // the tracker stamps an issue created then.
11997        assert_eq!(local_stamp("2026-09-26T22:28:12.170Z"), "2026-09-27T00:28");
11998        assert_eq!(local_stamp("[2026-09-27 Sun]"), "[2026-09-27 Sun]");
11999        assert_eq!(local_offset(1_788_566_400), 7200);
12000        let deed = deed_event("deed-x", "time=1790461680\n", local_offset).unwrap();
12001        let v = serde_json::json!({"properties": {"CREATED": "[2026-09-27 Sun]"}});
12002        let mut events = tracker_events(&v);
12003        events.push(deed);
12004        let text = format_events(&events, "2026-09-27T00:30:00");
12005        assert!(text.lines().all(|l| l.contains("\ttoday\t")), "{text}");
12006        unsafe {
12007            match before {
12008                Some(tz) => std::env::set_var("TZ", tz),
12009                None => std::env::remove_var("TZ"),
12010            }
12011        }
12012    }
12013
12014    #[test]
12015    fn a_timeline_merges_the_three_stores_oldest_first() {
12016        let v = serde_json::json!({
12017            "properties": {
12018                "CREATED": "[2026-09-01 Tue]",
12019                "SCHEDULED": "<2026-02-10 Tue>"
12020            },
12021            "claimed_by": "seat",
12022            "claimed_at": "[2026-09-03 Thu 11:48]",
12023            "logbook": [
12024                {"note": "second", "timestamp": "[2026-09-10 Thu 09:00]"},
12025                {"from_state": "TODO", "to_state": "STARTED", "timestamp": "[2026-09-03 Thu 11:48]"}
12026            ]
12027        });
12028        let mut events = tracker_events(&v);
12029        events.push(
12030            deed_event(
12031                "deed-x",
12032                "id=deed-x ok\nproducedBy=seat -\ntime=1788566400\n",
12033                |_| 0,
12034            )
12035            .unwrap(),
12036        );
12037        events.sort_by(|a, b| (a.days, &a.clock).cmp(&(b.days, &b.clock)));
12038        let text = format_events(&events, "2026-09-12T00:00:00Z");
12039        let lines: Vec<&str> = text.lines().collect();
12040        assert_eq!(lines.len(), 6, "{text}");
12041        assert!(
12042            lines[0].contains("tracker\tSCHEDULED <2026-02-10 Tue>"),
12043            "{}",
12044            lines[0]
12045        );
12046        assert!(
12047            lines[1].starts_with("2026-09-01 \t11 days ago"),
12048            "{}",
12049            lines[1]
12050        );
12051        assert!(lines[1].contains("tracker\tcreated"), "{}", lines[1]);
12052        assert!(
12053            lines[2].contains("+2 d\ttracker\tclaimed by seat"),
12054            "{}",
12055            lines[2]
12056        );
12057        assert!(
12058            lines[3].contains("same day\ttracker\tTODO -> STARTED"),
12059            "{}",
12060            lines[3]
12061        );
12062        assert!(
12063            lines[4]
12064                .starts_with("2026-09-05 00:00\t7 days ago\t+2 d\tdeed\tdeed-x produced by seat -"),
12065            "{}",
12066            lines[4]
12067        );
12068        assert!(
12069            lines[5].contains("2 days ago\t+5 d\ttracker\tnote: second"),
12070            "{}",
12071            lines[5]
12072        );
12073    }
12074
12075    #[test]
12076    fn sitting_caps_are_the_protocol_numbers() {
12077        assert_eq!(SITTING_DUE, 8);
12078        assert_eq!(SITTING_TIMELINE, 12);
12079    }
12080
12081    #[test]
12082    fn policyd_required_is_the_operator_switch() {
12083        let _g = env_guard();
12084        let before = std::env::var_os("POLICYD_REQUIRED");
12085        std::env::remove_var("POLICYD_REQUIRED");
12086        assert!(!policyd_required());
12087        std::env::set_var("POLICYD_REQUIRED", "1");
12088        assert!(policyd_required());
12089        std::env::set_var("POLICYD_REQUIRED", "0");
12090        assert!(!policyd_required());
12091        match before {
12092            Some(v) => std::env::set_var("POLICYD_REQUIRED", v),
12093            None => std::env::remove_var("POLICYD_REQUIRED"),
12094        }
12095    }
12096
12097    #[test]
12098    fn stamps_of_every_shape_key_the_same() {
12099        assert_eq!(
12100            stamp_key(Some("[2026-09-12 Sat 21:54]")),
12101            stamp_key(Some("2026-09-12T21:54:00.000Z"))
12102        );
12103        assert_eq!(stamp_key(Some("[2026-09-12 Sat]")).unwrap().1, "");
12104        assert_eq!(
12105            stamp_key(Some("<2026-02-10 Tue>")).map(|k| k.0),
12106            stamp_key(Some("2026-02-10")).map(|k| k.0)
12107        );
12108        assert_eq!(stamp_key(Some("soon")), None);
12109        assert_eq!(
12110            civil_of_days(days_of_stamp(Some("2026-09-12")).unwrap()),
12111            "2026-09-12"
12112        );
12113    }
12114
12115    #[test]
12116    fn ages_read_as_a_timeline() {
12117        let now = "2026-09-12T14:00:00.000Z";
12118        assert_eq!(age_of(Some("2026-09-12T01:00:00.000Z"), now), "today");
12119        assert_eq!(age_of(Some("2026-09-11T23:59:00.000Z"), now), "yesterday");
12120        assert_eq!(age_of(Some("2026-09-01T00:00:00.000Z"), now), "11 days ago");
12121        assert_eq!(age_of(Some("2026-08-01T00:00:00.000Z"), now), "6 weeks ago");
12122        assert_eq!(
12123            age_of(Some("2026-03-01T00:00:00.000Z"), now),
12124            "6 months ago"
12125        );
12126        assert_eq!(age_of(Some("2023-09-12T00:00:00.000Z"), now), "3 years ago");
12127        assert_eq!(age_of(Some("2026-09-13T00:00:00.000Z"), now), "in 1 day");
12128        assert_eq!(age_of(None, now), "");
12129        assert_eq!(age_of(Some("card"), now), "");
12130    }
12131
12132    #[test]
12133    fn a_hit_line_carries_kind_and_age() {
12134        let h = Hit {
12135            id: Some("a".into()),
12136            text: " keep the smoke green ".into(),
12137            score: 1.0,
12138            kind: "lesson".into(),
12139            ts: Some("2026-09-10T00:00:00.000Z".into()),
12140            entities: vec![],
12141            ballots: None,
12142            of: None,
12143        };
12144        assert_eq!(
12145            hit_line(&h, "2026-09-12T00:00:00.000Z"),
12146            "- [lesson, 2 days ago] keep the smoke green"
12147        );
12148        let bare = Hit {
12149            id: None,
12150            text: "x".into(),
12151            score: 1.0,
12152            kind: String::new(),
12153            ts: None,
12154            entities: vec![],
12155            ballots: None,
12156            of: None,
12157        };
12158        assert_eq!(hit_line(&bare, "2026-09-12T00:00:00.000Z"), "- [claim] x");
12159    }
12160
12161    /// A hook call is read from the runner's JSON or from plain text, and
12162    /// the answer is the runner's shape only when there is something to say.
12163    #[test]
12164    fn hook_calls_are_read_and_answered_in_the_runners_shape() {
12165        let tool = hook_call(
12166            r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cargo test","description":"run"}}"#,
12167        );
12168        assert_eq!(tool.event, "PreToolUse");
12169        assert_eq!(tool.cue, "cargo test");
12170        let prompt = hook_call(r#"{"hook_event_name":"UserPromptSubmit","prompt":"fix the fuse"}"#);
12171        assert_eq!(prompt.cue, "fix the fuse");
12172        let grok = hook_call(r#"{"hookEventName":"post_tool_use","sessionId":"s1"}"#);
12173        assert_eq!(grok.event, "PostToolUse");
12174        assert_eq!(grok.session.as_deref(), Some("s1"));
12175        hold_hook_context(Some("s1"), "held pack");
12176        assert_eq!(take_hook_context(Some("s1")), "held pack");
12177        assert!(take_hook_context(Some("s1")).is_empty());
12178        let session = format!("hold-{}", std::process::id());
12179        hold_hook_note(Some(&session), "pack line", &["m1".to_string()]);
12180        hold_hook_context(Some(&session), "");
12181        assert_eq!(peek_hook_context(Some(&session)), "pack line");
12182        assert_eq!(
12183            prompt_hook_stdout(
12184                HookShape::CamelCase,
12185                Some(&session),
12186                "pack line",
12187                &["m1".to_string()]
12188            ),
12189            ""
12190        );
12191        let (echoed, echo_ids) = post_hook_stdout(HookShape::CamelCase, Some(&session));
12192        assert_eq!(echoed, "pack line");
12193        assert_eq!(echo_ids, ["m1"]);
12194        assert!(post_hook_stdout(HookShape::CamelCase, Some(&session))
12195            .0
12196            .is_empty());
12197        assert!(
12198            stop_hook_stdout(Some(&session), false).0.is_empty(),
12199            "a delivered tool result leaves Stop nothing to say"
12200        );
12201        let quiet = format!("quiet-{}", std::process::id());
12202        hold_hook_note(Some(&quiet), "no tool", &["m2".to_string()]);
12203        let (delivered, ids) = stop_hook_stdout(Some(&quiet), false);
12204        assert_eq!(delivered, "no tool");
12205        assert_eq!(ids, ["m2"]);
12206        assert!(stop_hook_stdout(Some(&quiet), true).0.is_empty());
12207        let argv = hook_call("rm -rf build");
12208        assert_eq!(argv.event, "argv");
12209        assert_eq!(argv.session, None);
12210        let with_session = hook_call(
12211            r#"{"session_id":"abc/../x 1","hook_event_name":"PreToolUse","tool_input":{"command":"ls"}}"#,
12212        );
12213        assert_eq!(with_session.session.as_deref(), Some("abc/../x 1"));
12214        assert!(seen_path("abc/../x 1")
12215            .unwrap()
12216            .file_name()
12217            .unwrap()
12218            .to_string_lossy()
12219            .ends_with("hook-seen-abcx1"));
12220        assert_eq!(seen_path("/../"), None);
12221        assert_eq!(hook_output(&argv, ""), "");
12222        assert_eq!(hook_output(&argv, "- [lesson] x"), "- [lesson] x\n");
12223        let out = hook_output(&tool, "- [preference] y");
12224        let v: Value = serde_json::from_str(out.trim()).unwrap();
12225        assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
12226        assert_eq!(
12227            v["hookSpecificOutput"]["additionalContext"],
12228            "- [preference] y"
12229        );
12230        assert!(
12231            hook_context(
12232                &HookCall {
12233                    event: "argv".into(),
12234                    cue: "ab".into(),
12235                    session: None,
12236                    shape: HookShape::Asks,
12237                },
12238                8
12239            )
12240            .is_empty(),
12241            "a cue too short asks nothing"
12242        );
12243    }
12244
12245    /// The injected ids of a session are read back without the nudge marker,
12246    /// and the seen file goes with the session.
12247    #[test]
12248    fn a_sessions_injected_memories_are_read_back_and_cleared() {
12249        let session = format!("end-test-{}", std::process::id());
12250        mark_seen(
12251            Some(&session),
12252            &["a".to_string(), "due-nudge".to_string(), "b".to_string()],
12253        );
12254        let (ids, path) = injected_ids(&session);
12255        assert_eq!(ids, ["a", "b"]);
12256        assert!(path.as_ref().is_some_and(|p| p.is_file()));
12257        // No pack in a unit test: nothing fires, the file still goes.
12258        let _ = session_end(Some(&session));
12259        assert!(!path.unwrap().is_file());
12260        assert_eq!(session_end(None), 0);
12261    }
12262
12263    /// The memory hook merges into a runner's hooks file once per event and
12264    /// is not added twice.
12265    #[test]
12266    fn the_memory_hook_is_merged_once() {
12267        let dir = std::env::temp_dir().join(format!("ljos-hook-{}", std::process::id()));
12268        let _ = std::fs::remove_dir_all(&dir);
12269        std::fs::create_dir_all(&dir).unwrap();
12270        let file = dir.join("settings.json");
12271        std::fs::write(
12272            &file,
12273            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"other"}]}]},"theme":"dark"}"#,
12274        )
12275        .unwrap();
12276        let both: Vec<String> = vec!["UserPromptSubmit".into(), "PreToolUse".into()];
12277        let prompts: Vec<String> = HOOK_EVENTS.iter().map(|e| (*e).to_string()).collect();
12278        assert_eq!(
12279            prompts,
12280            ["UserPromptSubmit", "SessionEnd"],
12281            "the panel's default, and the session end that wires what it used"
12282        );
12283        assert!(!hook_installed(&file, &both));
12284        let dry = hook_step(&file, &both, true);
12285        assert!(
12286            dry.ok && dry.detail.starts_with("would add it on"),
12287            "{dry:?}"
12288        );
12289        let step = hook_step(&file, &both, false);
12290        assert!(step.ok, "{step:?}");
12291        assert!(hook_installed(&file, &both));
12292        let again = hook_step(&file, &both, false);
12293        assert!(
12294            again.detail.contains("carries the memory hook on"),
12295            "{again:?}"
12296        );
12297        let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
12298        assert_eq!(v["theme"], "dark", "the rest of the file is kept");
12299        assert_eq!(
12300            v["hooks"]["PreToolUse"].as_array().unwrap().len(),
12301            2,
12302            "the other hook stays"
12303        );
12304        assert_eq!(v["hooks"]["UserPromptSubmit"].as_array().unwrap().len(), 1);
12305        // Narrowing to the default drops the seat's tool-call group and
12306        // leaves the other tool's group alone.
12307        let narrowed = hook_step(&file, &prompts, false);
12308        assert!(
12309            narrowed.detail.contains("drop it from PreToolUse"),
12310            "{narrowed:?}"
12311        );
12312        let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
12313        assert_eq!(v["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
12314        assert_eq!(v["hooks"]["PreToolUse"][0]["hooks"][0]["command"], "other");
12315        assert!(hook_installed(&file, &prompts));
12316        assert!(!hook_installed(&file, &both));
12317        let _ = std::fs::remove_dir_all(&dir);
12318    }
12319
12320    /// Rules are globs over the whole line; deny wins over ask; the hook
12321    /// carries the verdict as the runner's permission decision.
12322    #[test]
12323    fn rules_match_the_line_and_the_hook_carries_the_verdict() {
12324        assert!(glob_matches("rm -rf *", "rm -rf /tmp/x"));
12325        assert!(!glob_matches("rm -rf *", "ls -la"));
12326        assert!(glob_matches("*sudo*", "echo hi && sudo reboot"));
12327        assert!(glob_matches("git push*", "git push origin main"));
12328        assert!(!glob_matches("git push*", "git pull"));
12329        let rules = vec![
12330            Rule {
12331                pattern: "git push*".into(),
12332                verdict: "ask".into(),
12333                reason: "A push is the trust gate.".into(),
12334            },
12335            Rule {
12336                pattern: "*--force*".into(),
12337                verdict: "deny".into(),
12338                reason: "Never force push.".into(),
12339            },
12340        ];
12341        assert_eq!(
12342            verdict_for(&rules, "git push --force").unwrap().verdict,
12343            "deny"
12344        );
12345        assert_eq!(
12346            verdict_for(&rules, "git push origin x").unwrap().verdict,
12347            "ask"
12348        );
12349        assert!(verdict_for(&rules, "cargo test").is_none());
12350        let call = hook_call(
12351            r#"{"hook_event_name":"PreToolUse","tool_input":{"command":"git push --force"}}"#,
12352        );
12353        let out = hook_output_ruled(&call, "", verdict_for(&rules, &call.cue));
12354        let v: Value = serde_json::from_str(out.trim()).unwrap();
12355        assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
12356        assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
12357            .as_str()
12358            .unwrap()
12359            .contains("Never force push"));
12360        assert!(v["hookSpecificOutput"].get("additionalContext").is_none());
12361        let argv = HookCall {
12362            event: "argv".into(),
12363            cue: "git push origin x".into(),
12364            session: None,
12365            shape: HookShape::Asks,
12366        };
12367        assert!(
12368            hook_output_ruled(&argv, "", verdict_for(&rules, &argv.cue)).starts_with("ask: A push")
12369        );
12370        // grok: camelCase in, a top-level decision out.
12371        let grok = hook_call(
12372            r#"{"hookEventName":"pre_tool_use","sessionId":"g-1","toolName":"run_terminal_command","toolInput":{"command":"git push --force"}}"#,
12373        );
12374        assert_eq!(grok.shape, HookShape::CamelCase);
12375        assert_eq!(grok.event, "PreToolUse");
12376        assert_eq!(grok.cue, "git push --force");
12377        let v: Value = serde_json::from_str(
12378            hook_output_ruled(&grok, "", verdict_for(&rules, &grok.cue)).trim(),
12379        )
12380        .unwrap();
12381        assert_eq!(v["decision"], "deny");
12382        assert!(v["reason"].as_str().unwrap().contains("Never force push"));
12383        // Lower-case events: the prompt under extra, answers at the top.
12384        let turn = hook_call(
12385            r#"{"hook_event_name":"pre_llm_call","tool_name":null,"tool_input":null,"session_id":"h-1","extra":{"user_message":"fix the fuse"}}"#,
12386        );
12387        assert_eq!(turn.shape, HookShape::Context);
12388        assert_eq!(turn.event, "UserPromptSubmit");
12389        assert_eq!(turn.cue, "fix the fuse");
12390        let v: Value =
12391            serde_json::from_str(hook_output_ruled(&turn, "- [lesson] x", None).trim()).unwrap();
12392        assert_eq!(v["context"], "- [lesson] x");
12393        assert!(v.get("hookSpecificOutput").is_none());
12394        let tool = hook_call(
12395            r#"{"hook_event_name":"pre_tool_call","tool_name":"terminal","tool_input":{"command":"git push origin x"},"session_id":"h-1","extra":{}}"#,
12396        );
12397        assert_eq!(tool.event, "PreToolUse");
12398        let v: Value = serde_json::from_str(
12399            hook_output_ruled(&tool, "", verdict_for(&rules, &tool.cue)).trim(),
12400        )
12401        .unwrap();
12402        assert_eq!(v["decision"], "block");
12403        assert!(v["reason"]
12404            .as_str()
12405            .unwrap()
12406            .starts_with("ask the person before running this"));
12407        assert_eq!(
12408            hook_call(r#"{"hook_event_name":"on_session_end","session_id":"h-1","extra":{}}"#)
12409                .event,
12410            "TurnEnd"
12411        );
12412        assert_eq!(
12413            hook_call(r#"{"hook_event_name":"on_session_finalize","session_id":"h-1","extra":{}}"#)
12414                .event,
12415            "SessionEnd"
12416        );
12417        // An ask on a runner that cannot ask stops the tool.
12418        let deny_only = hook_call(
12419            r#"{"hook_event_name":"PreToolUse","session_id":"c-1","turn_id":"t-1","tool_name":"Bash","tool_input":{"command":"git push origin x"}}"#,
12420        );
12421        assert_eq!(deny_only.shape, HookShape::DenyOnly);
12422        let v: Value = serde_json::from_str(
12423            hook_output_ruled(&deny_only, "", verdict_for(&rules, &deny_only.cue)).trim(),
12424        )
12425        .unwrap();
12426        assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
12427        assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
12428            .as_str()
12429            .unwrap()
12430            .starts_with("ask the person before running this: A push"));
12431        assert!(v.get("decision").is_none());
12432        let asks = hook_call(
12433            r#"{"hook_event_name":"PreToolUse","session_id":"k-1","tool_name":"Bash","tool_input":{"command":"git push origin x"}}"#,
12434        );
12435        let v: Value = serde_json::from_str(
12436            hook_output_ruled(&asks, "", verdict_for(&rules, &asks.cue)).trim(),
12437        )
12438        .unwrap();
12439        assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "ask");
12440        let steps = panel_steps("x-1", true, &[], &[]);
12441        assert!(steps.is_empty());
12442        let preds = vec![
12443            Prediction {
12444                issue: "x-1".into(),
12445                agent: "a".into(),
12446                expect: Value::String("ship".into()),
12447            },
12448            Prediction {
12449                issue: "x-1".into(),
12450                agent: "b".into(),
12451                expect: serde_json::json!({"ship": 0.6, "hold": 0.4}),
12452            },
12453        ];
12454        let steps = panel_steps("x-1", true, &[row("a", "b", 0.5)], &preds);
12455        assert_eq!(steps.len(), 2);
12456        assert_eq!(steps[0].args[0], "surprising");
12457        assert_eq!(steps[1].args[0], "reputation");
12458    }
12459
12460    /// A scoped row applies when the issue is about one of its domains; an
12461    /// unscoped row applies everywhere; a scoped learn starts from the
12462    /// unscoped row and leaves it standing.
12463    #[test]
12464    fn scoped_rows_apply_to_their_topic_and_learn_writes_in_scope() {
12465        let everywhere = row("a", "b", 0.9);
12466        let mut on_docs = row("a", "b", 0.2);
12467        on_docs.about = vec!["docs".into()];
12468        let rows = vec![everywhere.clone(), on_docs.clone()];
12469        let topic = topic_words("Rewrite the docs site");
12470        assert_eq!(topic, ["docs", "rewrite", "site", "the"]);
12471        // On the docs topic the scoped row stands in for the unscoped one;
12472        // elsewhere the unscoped row is the one that applies.
12473        assert_eq!(rows_about(&rows, &topic), vec![on_docs.clone()]);
12474        assert_eq!(
12475            rows_about(&rows, &topic_words("Fix the fuse")),
12476            vec![everywhere.clone()]
12477        );
12478
12479        let ballots = vec![
12480            ("a".to_string(), "ship".to_string()),
12481            ("b".to_string(), "hold".to_string()),
12482        ];
12483        let learned = learn_about(&ballots, "ship", &rows, 0.5, &["fuse".to_string()]).unwrap();
12484        let ab = learned
12485            .iter()
12486            .find(|r| r.from == "a" && r.to == "b")
12487            .unwrap();
12488        assert_eq!(ab.about, ["fuse"]);
12489        assert!(
12490            (ab.weight - 0.45).abs() < 1e-9,
12491            "starts from the unscoped 0.9: {ab:?}"
12492        );
12493        let ba = learned
12494            .iter()
12495            .find(|r| r.from == "b" && r.to == "a")
12496            .unwrap();
12497        assert!((ba.weight - 1.0).abs() < 1e-9, "a was right: {ba:?}");
12498
12499        // Rows read back keep scoped and unscoped apart, latest per scope.
12500        let atoms = vec![
12501            trust_atom(&everywhere, &[], "ws").unwrap(),
12502            trust_atom(&on_docs, &[], "ws").unwrap(),
12503        ];
12504        let mut back = trust_rows(&atoms);
12505        back.sort_by(|x, y| x.about.cmp(&y.about));
12506        assert_eq!(back, vec![everywhere, on_docs]);
12507    }
12508
12509    /// A persona is a voter with an anchor; the latest atom per name wins and
12510    /// the anchors go to the settle as one object.
12511    #[test]
12512    fn personas_are_latest_per_name_and_anchor_the_settle() {
12513        let p = Persona {
12514            name: "reviewer".into(),
12515            anchor: 0.2,
12516            view: "Reads for what could break in production.".into(),
12517            entities: vec!["Release".into()],
12518        };
12519        let mut a = persona_atom(&p, "ws").unwrap();
12520        a["ts"] = Value::String("2026-01-01T00:00:00Z".into());
12521        let mut later = a.clone();
12522        later["anchor"] = serde_json::json!(0.4);
12523        later["ts"] = Value::String("2026-02-01T00:00:00Z".into());
12524        let got = personas_of(&[a, later]);
12525        assert_eq!(got.len(), 1);
12526        assert_eq!(got[0].anchor, 0.4);
12527        assert_eq!(got[0].entities, ["release"]);
12528        assert_eq!(anchors_json(&got), r#"{"reviewer":0.4}"#);
12529        // A refuted persona listens more next time; a vindicated one does
12530        // not move; one that did not vote is untouched.
12531        let ballots = vec![
12532            ("reviewer".to_string(), "hold".to_string()),
12533            ("reader".to_string(), "ship".to_string()),
12534        ];
12535        let moved = learn_anchors(&got, &ballots, "ship", 0.5);
12536        assert_eq!(moved.len(), 1);
12537        assert!(
12538            (moved[0].anchor - 0.7).abs() < 1e-9,
12539            "0.4 + 0.6 * 0.5: {moved:?}"
12540        );
12541        assert!(learn_anchors(&got, &ballots, "hold", 0.5).is_empty());
12542        assert!(persona_atom(
12543            &Persona {
12544                anchor: 1.5,
12545                ..p.clone()
12546            },
12547            "ws"
12548        )
12549        .is_err());
12550        let steps = consensus_steps_anchored("x-1", true, true, &[], &got).unwrap();
12551        for step in &steps {
12552            assert!(
12553                step.args.contains(&"--susceptibility-of".to_string()),
12554                "{step:?}"
12555            );
12556        }
12557        // The kind of work sets the dynamics: a broad-audience issue runs
12558        // bounded confidence on the model crate, and the tracker verb, which
12559        // has no such model, is left as it was.
12560        let broad =
12561            consensus_steps_for("x-1", true, true, &[], &got, &["broad".to_string()]).unwrap();
12562        assert!(
12563            broad[0].args.contains(&"--epsilon".to_string()),
12564            "{:?}",
12565            broad[0]
12566        );
12567        assert!(
12568            !broad[1].args.contains(&"--epsilon".to_string()),
12569            "{:?}",
12570            broad[1]
12571        );
12572        assert!(settle_flags_for(&["feature".to_string()]).is_empty());
12573    }
12574
12575    /// Playbooks are kind playbook, latest per name, unreviewed; sitting
12576    /// copies the full body; a second name on a live sitting is refused;
12577    /// the inbound floor is unscoped.
12578    #[test]
12579    fn playbooks_are_latest_per_name_and_stick_until_finish() {
12580        let _g = env_guard();
12581        let dir = std::env::temp_dir().join(format!("ljos-playbook-{}", std::process::id()));
12582        let _ = std::fs::remove_dir_all(&dir);
12583        std::fs::create_dir_all(&dir).unwrap();
12584        let before = std::env::var_os("XDG_RUNTIME_DIR");
12585        unsafe {
12586            std::env::set_var("XDG_RUNTIME_DIR", &dir);
12587        }
12588        let shipped = shipped_playbooks();
12589        let names: Vec<&str> = shipped.iter().map(|p| p.name.as_str()).collect();
12590        assert_eq!(names, SHIPPED_PLAYBOOK_NAMES);
12591        for p in shipped_playbooks() {
12592            assert!(!p.body.is_empty(), "{}", p.name);
12593            assert!(
12594                !p.body.contains("/poteto-mode") && !p.body.contains("poteto-agent"),
12595                "{}",
12596                p.name
12597            );
12598            let atom = playbook_atom(&p, "ws").unwrap();
12599            assert_eq!(atom["kind"], "playbook");
12600            assert_eq!(atom["name"], p.name);
12601            assert_eq!(atom["text"], p.body);
12602            assert!(!super::reviewable(&atom), "{}", p.name);
12603        }
12604        assert!(playbook_atom(
12605            &Playbook {
12606                name: "sit".into(),
12607                body: "  ".into(),
12608                models: vec![],
12609            },
12610            "ws"
12611        )
12612        .is_err());
12613        let mut a = playbook_atom(
12614            &Playbook {
12615                name: "sit".into(),
12616                body: "first body".into(),
12617                models: vec![],
12618            },
12619            "ws",
12620        )
12621        .unwrap();
12622        a["ts"] = Value::String("2026-01-01T00:00:00Z".into());
12623        let mut later = a.clone();
12624        later["text"] = Value::String("second body".into());
12625        later["ts"] = Value::String("2026-02-01T00:00:00Z".into());
12626        let got = playbooks_of(&[a, later]);
12627        assert_eq!(got.len(), 1);
12628        assert_eq!(got[0].body, "second body");
12629        let copy = copy_playbook("proj-1a2b", "sit").unwrap();
12630        assert!(copy.starts_with("sit\n"), "{copy}");
12631        assert!(copy.contains("Grade due claims"), "{copy}");
12632        assert_eq!(bound_playbook("proj-1a2b").as_deref(), Some("sit"));
12633        let err = bind_playbook("proj-1a2b", "arena").unwrap_err().to_string();
12634        assert!(err.contains("bound to sit"), "{err}");
12635        assert!(err.contains("new sitting"), "{err}");
12636        let again = playbook_opening("proj-1a2b", None).unwrap();
12637        assert!(again.contains("Grade due claims"), "{again}");
12638        let blocks = brief_playbook_blocks("proj-1a2b");
12639        assert!(blocks.contains("== playbook"), "{blocks}");
12640        assert!(blocks.contains("Grade due claims"), "{blocks}");
12641        assert!(blocks.contains("== principles"), "{blocks}");
12642        assert!(blocks.contains("split-fence"), "{blocks}");
12643        assert!(blocks.contains("== rubric"), "{blocks}");
12644        assert!(blocks.contains("Ledger intact"), "{blocks}");
12645        drop_playbook("proj-1a2b");
12646        assert_eq!(bound_playbook("proj-1a2b"), None);
12647        let none = playbook_opening("proj-1a2b", None).unwrap();
12648        assert!(none.contains("none bound"), "{none}");
12649        assert!(none.contains("panel is refused"), "{none}");
12650        let err = panel("proj-1a2b", &dir.join("panel"))
12651            .unwrap_err()
12652            .to_string();
12653        assert!(err.contains("no playbook bound"), "{err}");
12654        let p = Persona {
12655            name: "reviewer".into(),
12656            anchor: 0.2,
12657            view: "Reads for what could break.".into(),
12658            entities: vec!["docs".into()],
12659        };
12660        let floor = inbound_floor(&p, "seat").unwrap();
12661        assert_eq!(floor.from, "seat");
12662        assert_eq!(floor.to, "reviewer");
12663        assert!((floor.weight - 1.0).abs() < 1e-9);
12664        assert!(floor.about.is_empty());
12665        assert!(inbound_floor(&p, "reviewer").is_none());
12666        assert!(has_unscoped_inbound(
12667            std::slice::from_ref(&floor),
12668            "reviewer",
12669            "seat"
12670        ));
12671        let scoped = Trust {
12672            about: vec!["docs".into()],
12673            ..floor
12674        };
12675        assert!(!has_unscoped_inbound(
12676            std::slice::from_ref(&scoped),
12677            "reviewer",
12678            "seat"
12679        ));
12680        let other = Trust {
12681            from: "other".into(),
12682            to: "reviewer".into(),
12683            weight: 1.0,
12684            about: Vec::new(),
12685        };
12686        assert!(
12687            !has_unscoped_inbound(std::slice::from_ref(&other), "reviewer", "seat"),
12688            "a third-party unscoped row is not the seat floor"
12689        );
12690        let arena_pb = shipped_playbooks()
12691            .into_iter()
12692            .find(|p| p.name == "arena")
12693            .unwrap();
12694        let arena = format_playbook_copy(&arena_pb);
12695        assert!(
12696            arena.contains("spawn hints (optional): judgment, instruction, fast"),
12697            "{arena}"
12698        );
12699        assert!(arena.contains("ljos vote --as"), "{arena}");
12700        assert!(
12701            COMPANY_PANEL_BODY.contains("--expect"),
12702            "a panel ballot carries the private forecast: {COMPANY_PANEL_BODY}"
12703        );
12704        match before {
12705            Some(v) => unsafe { std::env::set_var("XDG_RUNTIME_DIR", v) },
12706            None => unsafe { std::env::remove_var("XDG_RUNTIME_DIR") },
12707        }
12708        let _ = std::fs::remove_dir_all(&dir);
12709    }
12710
12711    #[test]
12712    fn playbook_note_latest_wins_and_empty_rest_drops() {
12713        let v = serde_json::json!({
12714            "logbook": [
12715                {"note": "playbook: land", "timestamp": "2026-09-21"},
12716                {"note": "playbook: sit", "timestamp": "2026-09-20"},
12717                {"note": "progress", "timestamp": "2026-09-19"}
12718            ]
12719        });
12720        assert_eq!(playbook_name_from_issue(&v).as_deref(), Some("land"));
12721        let empty = serde_json::json!({"logbook": []});
12722        assert_eq!(playbook_name_from_issue(&empty), None);
12723        let dropped = serde_json::json!({
12724            "logbook": [
12725                {"note": "playbook:", "timestamp": "2026-09-22T00:00:00Z"},
12726                {"note": "playbook: sit", "timestamp": "2026-09-21T00:00:00Z"}
12727            ]
12728        });
12729        assert_eq!(playbook_name_from_issue(&dropped), None);
12730        let undated = serde_json::json!({
12731            "logbook": [
12732                {"note": "playbook:"},
12733                {"note": "playbook: sit"}
12734            ]
12735        });
12736        assert_eq!(
12737            playbook_name_from_issue(&undated),
12738            None,
12739            "newest-first empty rest drops without walking back"
12740        );
12741    }
12742
12743    #[test]
12744    fn playbook_from_title_matches_a_closed_name_else_sit() {
12745        assert_eq!(playbook_from_title("Seat playbooks: routing"), "sit");
12746        assert_eq!(playbook_from_title("x5jz compose: land B"), "land");
12747        assert_eq!(
12748            playbook_from_title("Run the company-panel overnight"),
12749            "company-panel"
12750        );
12751        assert_eq!(playbook_from_title("sitting on a ticket"), "sit");
12752        assert_eq!(playbook_from_title("arena then compose"), "arena");
12753        assert_eq!(
12754            playbook_from_title("Benny and poteto-mode"),
12755            "sit",
12756            "title-match binds only closed-set tokens"
12757        );
12758    }
12759
12760    #[test]
12761    fn playbook_among_pack_latest_wins_and_unknown_names_are_refused() {
12762        let rewritten = Playbook {
12763            name: "sit".into(),
12764            body: "rewritten sit body".into(),
12765            models: vec![],
12766        };
12767        let got = playbook_among("sit", std::slice::from_ref(&rewritten)).unwrap();
12768        assert_eq!(got.body, "rewritten sit body");
12769        let seed = playbook_among("sit", &[]).unwrap();
12770        assert!(
12771            seed.body.contains("Grade due claims"),
12772            "shipped seed when the pack has no live atom: {}",
12773            seed.body
12774        );
12775        let err = playbook_among("Benny", &[]).unwrap_err().to_string();
12776        assert!(err.contains("unknown"), "{err}");
12777        let sneaky = Playbook {
12778            name: "poteto-mode".into(),
12779            body: "second roster".into(),
12780            models: vec![],
12781        };
12782        let err = playbook_among("poteto-mode", std::slice::from_ref(&sneaky))
12783            .unwrap_err()
12784            .to_string();
12785        assert!(err.contains("unknown"), "{err}");
12786        assert!(playbook_atom(&sneaky, "ws").is_err());
12787        assert!(parse_playbook_name("overnight").is_ok());
12788        assert!(parse_playbook_name("company-panel").is_ok());
12789        let listed = playbooks_of(&[serde_json::json!({
12790            "kind": "playbook",
12791            "name": "Benny",
12792            "text": "no",
12793            "ts": "2026-01-01T00:00:00Z"
12794        })]);
12795        assert!(listed.is_empty(), "{listed:?}");
12796        let err = bind_playbook("proj-1a2b", "Benny").unwrap_err().to_string();
12797        assert!(err.contains("unknown"), "{err}");
12798    }
12799
12800    #[test]
12801    fn sitting_resolves_asked_else_bound_else_title_else_sit() {
12802        let _g = env_guard();
12803        let dir =
12804            std::env::temp_dir().join(format!("ljos-playbook-resolve-{}", std::process::id()));
12805        let _ = std::fs::remove_dir_all(&dir);
12806        std::fs::create_dir_all(&dir).unwrap();
12807        let before = std::env::var_os("XDG_RUNTIME_DIR");
12808        unsafe {
12809            std::env::set_var("XDG_RUNTIME_DIR", &dir);
12810        }
12811        assert_eq!(
12812            resolve_sitting_playbook("proj-1a2b", "Seat playbooks", Some("arena")).unwrap(),
12813            "arena"
12814        );
12815        assert_eq!(
12816            resolve_sitting_playbook("proj-1a2b", "x5jz compose: land B", None).unwrap(),
12817            "land"
12818        );
12819        assert_eq!(
12820            resolve_sitting_playbook("proj-1a2b", "Ship the fuse change?", None).unwrap(),
12821            "sit"
12822        );
12823        bind_playbook("proj-1a2b", "sit").unwrap();
12824        assert_eq!(
12825            resolve_sitting_playbook("proj-1a2b", "x5jz compose: land B", None).unwrap(),
12826            "sit",
12827            "sticky wins over title"
12828        );
12829        drop_playbook("proj-1a2b");
12830        assert_eq!(bound_playbook("proj-1a2b"), None);
12831        match before {
12832            Some(v) => unsafe { std::env::set_var("XDG_RUNTIME_DIR", v) },
12833            None => unsafe { std::env::remove_var("XDG_RUNTIME_DIR") },
12834        }
12835        let _ = std::fs::remove_dir_all(&dir);
12836    }
12837
12838    /// A claim that never entered the clock is due now; a scheduled one is
12839    /// not; trust rows never are; and the summary says whether the clock runs.
12840    #[test]
12841    fn unreviewed_claims_are_due_and_the_summary_says_if_the_clock_runs() {
12842        let atoms = vec![
12843            serde_json::json!({"id": "a", "kind": "conclusion", "text": "old", "due_at": ""}),
12844            serde_json::json!({"id": "b", "kind": "conclusion", "text": "older"}),
12845            serde_json::json!({"id": "c", "kind": "conclusion", "text": "later",
12846                "due_at": "2030-01-01T00:00:00Z"}),
12847            serde_json::json!({"id": "d", "kind": "conclusion", "text": "past",
12848                "due_at": "2020-01-01T00:00:00Z"}),
12849            serde_json::json!({"id": "t", "kind": "trust", "text": "x weighs y"}),
12850            serde_json::json!({"id": "p", "kind": "playbook", "text": "sit recipe", "name": "sit"}),
12851        ];
12852        let now = "2026-01-01T00:00:00Z";
12853        let due: Vec<String> = super::due_of(&atoms, now)
12854            .iter()
12855            .map(|a| a["id"].as_str().unwrap().to_string())
12856            .collect();
12857        assert_eq!(
12858            due,
12859            ["a", "b", "d"],
12860            "unreviewed first, then the past-due one"
12861        );
12862        assert_eq!(
12863            super::review_summary(&atoms, now),
12864            "3 due; 1 scheduled, next at 2030-01-01T00:00:00Z"
12865        );
12866        assert_eq!(
12867            super::review_summary(&[atoms[4].clone()], now),
12868            "0 due; nothing scheduled: this seat has remembered nothing yet"
12869        );
12870        assert!(super::format_due(&super::due_of(&atoms, now)).starts_with("unreviewed\t"));
12871    }
12872
12873    #[test]
12874    fn bumping_mcp_generation_respawns_without_rewriting_the_entry() {
12875        let dir = std::env::temp_dir().join(format!("ljos-gen-{}", std::process::id()));
12876        let _ = std::fs::remove_dir_all(&dir);
12877        std::fs::create_dir_all(&dir).expect("tempdir");
12878        let config = dir.join("config.toml");
12879        std::fs::write(
12880            &config,
12881            "[mcp_servers.ljos.env]\nLJOS_MCP_GENERATION = \"0.12.8\"\n",
12882        )
12883        .expect("write");
12884        let bumped = super::bump_ljos_mcp_generation(&config, "0.13.1", false)
12885            .expect("bumps")
12886            .expect("changed");
12887        assert_eq!(bumped, "0.13.1");
12888        let text = std::fs::read_to_string(&config).expect("read");
12889        assert!(text.contains("LJOS_MCP_GENERATION = \"0.13.1\""), "{text}");
12890        assert!(!text.contains("0.12.8"), "{text}");
12891        assert!(
12892            super::bump_ljos_mcp_generation(&config, "0.13.1", false)
12893                .expect("second")
12894                .is_none(),
12895            "a matching generation is left alone"
12896        );
12897        let _ = std::fs::remove_dir_all(&dir);
12898    }
12899
12900    #[test]
12901    fn a_client_name_listed_on_a_harness_is_that_runners_seat() {
12902        let dir = std::env::temp_dir().join(format!("ljos-clients-{}", std::process::id()));
12903        std::fs::create_dir_all(&dir).unwrap();
12904        let file = dir.join("harnesses.toml");
12905        std::fs::write(
12906            &file,
12907            "[[harness]]\nname = \"acme\"\nclients = [\"acme-mcp-client\"]\n\n[[harness]]\nname = \"brio\"\nclients = [\"brio-coding-agent\"]\n",
12908        )
12909        .unwrap();
12910        assert_eq!(
12911            runner_for_client(&file, "acme-mcp-client").as_deref(),
12912            Some("acme")
12913        );
12914        assert_eq!(
12915            runner_for_client(&file, &seat_slug("brio-coding-agent")).as_deref(),
12916            Some("brio")
12917        );
12918        assert!(runner_for_client(&file, "acme-cli").is_none());
12919        assert!(runner_for_client(&dir.join("absent.toml"), "acme-mcp-client").is_none());
12920        let _ = std::fs::remove_dir_all(&dir);
12921    }
12922
12923    #[test]
12924    fn an_issues_tags_are_words_it_speaks_in() {
12925        let v: Value = serde_json::from_str(r#"{"tags":["Decision","sharing","memory"]}"#).unwrap();
12926        assert_eq!(tags_of(&v), vec!["decision", "sharing", "memory"]);
12927        assert!(tags_of(&serde_json::json!({})).is_empty());
12928    }
12929
12930    #[test]
12931    fn a_jev_panel_stands_only_when_every_seat_is_sure_and_agrees() {
12932        let b = |choice: &str, confidence: f64| jev::Ballot {
12933            choice: choice.into(),
12934            confidence,
12935            probabilities: Default::default(),
12936            forecast: Default::default(),
12937            escalate_below: 0.8,
12938        };
12939        assert!(jev_panel_stands(&[b("age", 0.95), b("age", 0.9)]));
12940        assert!(!jev_panel_stands(&[b("age", 0.95), b("gpg", 0.9)]), "split");
12941        assert!(
12942            !jev_panel_stands(&[b("age", 0.95), b("age", 0.6)]),
12943            "one unsure"
12944        );
12945        assert!(!jev_panel_stands(&[]));
12946    }
12947
12948    #[test]
12949    fn a_turn_is_read_from_the_last_request_to_the_final_message() {
12950        let lines = [
12951            r#"{"type":"user","message":{"content":"old request"}}"#,
12952            r#"{"type":"user","message":{"content":"fix the parser and test it"}}"#,
12953            r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"cargo test -p brio"}}]}}"#,
12954            r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"test result: FAILED. 3 passed; 1 failed"}]}}"#,
12955            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"All done, the parser works."}]}}"#,
12956        ]
12957        .join("\n");
12958        let t = stop_turn_from_transcript(&lines);
12959        assert_eq!(t.request, "fix the parser and test it");
12960        assert!(t.test_ran);
12961        assert_eq!(t.commands, vec!["cargo test -p brio"]);
12962        assert!(t.outputs[0].contains("1 failed"));
12963        assert_eq!(t.final_message, "All done, the parser works.");
12964        assert!(t.state().contains("The agent's final message:\nAll done"));
12965        assert!(!runs_tests("git status"));
12966    }
12967
12968    #[test]
12969    fn options_come_from_a_line_or_its_bullets() {
12970        assert_eq!(
12971            issue_options("Why.\nOptions: age, gpg\n"),
12972            vec!["age", "gpg"]
12973        );
12974        assert_eq!(issue_options("Options:\n- a\n- b\n\nmore"), vec!["a", "b"]);
12975        assert!(
12976            issue_options("Options: only").is_empty(),
12977            "one option is no vote"
12978        );
12979        assert!(issue_options("no options").is_empty());
12980    }
12981
12982    #[test]
12983    fn a_decision_is_a_tag_a_type_or_an_options_line() {
12984        let v = |j: &str| -> Value { serde_json::from_str(j).unwrap() };
12985        assert!(is_decision(&v(r#"{"tags":["seat","decision"]}"#)));
12986        assert!(is_decision(&v(r#"{"properties":{"TYPE":"decision"}}"#)));
12987        assert!(is_decision(&v(
12988            r#"{"body":"Evidence.\n\nOptions:\n- a\n- b"}"#
12989        )));
12990        assert!(!is_decision(&v(
12991            r#"{"tags":["bug"],"properties":{"TYPE":"task"},"body":"no options here"}"#
12992        )));
12993        assert!(!is_decision(&v(
12994            r#"{"body":"We weighed the Options: none"}"#
12995        )));
12996    }
12997
12998    #[test]
12999    fn a_probe_passes_only_when_the_runner_lists_ljos() {
13000        let s = |v: &[&str]| v.iter().map(|x| (*x).to_string()).collect::<Vec<_>>();
13001        assert!(probe_lists_ljos(&s(&["sh", "-c", "echo '  ljos_sitting   Call this'"])).is_ok());
13002        assert!(probe_lists_ljos(&s(&["sh", "-c", "echo 'MCP SDK not installed'"])).is_err());
13003        assert!(probe_lists_ljos(&s(&["sh", "-c", "echo ljos_sitting; exit 3"])).is_err());
13004        assert!(probe_lists_ljos(&s(&["/nonexistent/runner"])).is_err());
13005        let all: super::Harnesses = toml::from_str(super::HARNESSES_EXAMPLE).expect("parses");
13006        let hermes = all.harness.iter().find(|h| h.name == "hermes").unwrap();
13007        assert_eq!(hermes.probe, s(&["hermes", "mcp", "test", "ljos"]));
13008    }
13009
13010    #[test]
13011    fn a_plugin_runner_gets_its_bundled_plugin_with_ljos_filled() {
13012        let all: super::Harnesses = toml::from_str(super::HARNESSES_EXAMPLE).expect("parses");
13013        for name in ["opencode", "omp"] {
13014            let h = all.harness.iter().find(|h| h.name == name).expect(name);
13015            assert!(h.plugin.is_some(), "{name} names a plugin path");
13016            let text = super::plugin_text(h, Path::new("/opt/seat/bin/ljos")).expect(name);
13017            assert!(text.contains("\"/opt/seat/bin/ljos\""), "{name}");
13018            assert!(!text.contains("{ljos}"), "{name}");
13019            assert!(
13020                text.contains("PreToolUse") && text.contains("UserPromptSubmit"),
13021                "{name}"
13022            );
13023        }
13024        let unknown = super::Harness {
13025            name: "x".into(),
13026            plugin: Some("/tmp/x.ts".into()),
13027            plugin_template: Some("nobody".into()),
13028            ..Default::default()
13029        };
13030        assert!(super::plugin_text(&unknown, Path::new("/l")).is_none());
13031        let step = super::plugin_step(&unknown, Path::new("/tmp/x.ts"), true);
13032        assert!(!step.ok, "an unknown template writes nothing: {step:?}");
13033    }
13034
13035    /// The example file parses, and onboarding a config-file runner from it
13036    /// appends the entry once and writes the skill once; a dry run writes
13037    /// nothing; an unnamed runner is refused with the names the file holds.
13038    #[test]
13039    fn onboarding_a_config_file_runner_writes_once() {
13040        let all: super::Harnesses = toml::from_str(super::HARNESSES_EXAMPLE).expect("parses");
13041        // Three shapes, then the four runners this seat has carried.
13042        assert_eq!(all.harness.len(), 7);
13043        assert!(all.harness[3..].iter().all(|h| h.register.len()
13044            + usize::from(h.config.is_some())
13045            + usize::from(h.config_json.is_some())
13046            > 0));
13047        assert_eq!(all.harness[1].marker.as_deref(), Some("[mcp_servers.ljos]"));
13048        assert_eq!(all.harness[2].json_pointer.as_deref(), Some("/mcp/ljos"));
13049
13050        let dir = std::env::temp_dir().join(format!("ljos-onboard-{}", std::process::id()));
13051        let _ = std::fs::remove_dir_all(&dir);
13052        std::fs::create_dir_all(&dir).expect("tempdir");
13053        let config = dir.join("config.toml");
13054        let skills = dir.join("skills");
13055        let file = dir.join("harnesses.toml");
13056        std::fs::write(
13057            &file,
13058            format!(
13059                "[[harness]]\nname = \"r\"\nconfig = {config:?}\nmarker = \"[mcp_servers.ljos]\"\n\
13060                 snippet = \"\\n[mcp_servers.ljos]\\ncommand = \\\"{{server}}\\\"\\n\"\nskills = {skills:?}\n",
13061                config = config.display().to_string(),
13062                skills = skills.display().to_string(),
13063            ),
13064        )
13065        .expect("write");
13066
13067        let refused = super::onboard_from(&file, "nobody", true)
13068            .unwrap_err()
13069            .to_string();
13070        assert!(
13071            refused.contains("no runner \"nobody\"") && refused.contains("names r"),
13072            "{refused}"
13073        );
13074
13075        let steps = match super::onboard_from(&file, "r", true) {
13076            Ok(steps) => steps,
13077            // Without ljos-mcp on PATH there is nothing to register; the
13078            // refusal says so and the rest of the check needs the binary.
13079            Err(e) => {
13080                assert!(e.to_string().contains("ljos-mcp not on PATH"), "{e}");
13081                return;
13082            }
13083        };
13084        assert!(steps.iter().all(|s| s.ok), "{steps:?}");
13085        assert!(
13086            steps[0].detail.starts_with("would append"),
13087            "{}",
13088            steps[0].detail
13089        );
13090        assert!(!config.exists() && !skills.exists(), "a dry run wrote");
13091
13092        let steps = super::onboard_from(&file, "r", false).expect("onboards");
13093        assert!(steps.iter().all(|s| s.ok), "{steps:?}");
13094        let written = std::fs::read_to_string(&config).expect("config written");
13095        assert_eq!(written.matches("[mcp_servers.ljos]").count(), 1);
13096        assert!(written.contains("ljos-mcp"), "{written}");
13097        let skill = std::fs::read_to_string(skills.join("ljos/SKILL.md")).expect("skill written");
13098        assert!(skill.starts_with("---\nname: ljos\n"));
13099        assert!(skill.contains("## Before the work"));
13100
13101        let again = super::onboard_from(&file, "r", false).expect("onboards again");
13102        assert_eq!(again[0].detail, "ljos registered");
13103        assert!(
13104            again[1].detail.ends_with("is current"),
13105            "{}",
13106            again[1].detail
13107        );
13108        assert_eq!(
13109            std::fs::read_to_string(&config)
13110                .expect("config")
13111                .matches("[mcp_servers.ljos]")
13112                .count(),
13113            1,
13114            "the entry was appended twice"
13115        );
13116        let _ = std::fs::remove_dir_all(&dir);
13117    }
13118
13119    #[test]
13120    fn grok_onboard_names_the_frozen_hook_file() {
13121        let file = std::env::temp_dir().join("ljos-missing-harnesses.toml");
13122        let steps = super::onboard_from(&file, "grok", true).expect("grok dry");
13123        assert!(steps[0].ok, "{steps:?}");
13124        assert!(
13125            steps[0].detail.contains(".grok/hooks/ljos.json"),
13126            "{}",
13127            steps[0].detail
13128        );
13129    }
13130
13131    #[test]
13132    fn the_grok_hook_file_runs_ljos_by_absolute_path() {
13133        let text = super::grok_hooks_json(Path::new("/opt/seat/bin/ljos"));
13134        let v: Value = serde_json::from_str(&text).expect("the hook file is JSON");
13135        let pre = &v["hooks"]["PreToolUse"][0]["hooks"][0];
13136        assert_eq!(pre["command"], "/opt/seat/bin/ljos hook");
13137        assert_eq!(pre["timeout"], 10);
13138        let stop = &v["hooks"]["Stop"][0]["hooks"][0];
13139        assert_eq!(stop["command"], "/opt/seat/bin/ljos hook");
13140        assert!(!text.contains("{ljos}"), "{text}");
13141        assert!(!text.contains("\"ljos hook\""), "{text}");
13142    }
13143
13144    use super::*;
13145    use std::io::{Read, Write};
13146    use std::net::TcpListener;
13147    use std::sync::{Arc, Mutex};
13148
13149    /// A non-zero exit is an error carrying what was said on stderr.
13150    #[test]
13151    fn a_refusal_is_an_error_not_an_answer() {
13152        let err = run_captured("false", &[] as &[&str]).unwrap_err();
13153        assert!(err.to_string().contains("false exited"), "{err}");
13154        let said = run_captured("sh", &["-c", "echo answered; echo aside >&2"]).unwrap();
13155        assert_eq!(said.stdout.trim(), "answered");
13156        assert_eq!(said.stderr.trim(), "aside");
13157        let said = run_captured("sh", &["-c", "echo reason >&2; exit 3"]).unwrap_err();
13158        assert!(said.to_string().contains("reason"), "{said}");
13159    }
13160
13161    #[test]
13162    fn join_keeps_spaces() {
13163        assert_eq!(
13164            join(&["the default fuse".into(), "is CombMNZ".into()]),
13165            "the default fuse is CombMNZ"
13166        );
13167    }
13168
13169    #[test]
13170    fn remember_is_lesson_prefer_is_preference() {
13171        assert_eq!(atom_kind("Remember").unwrap(), "lesson");
13172        assert_eq!(atom_kind("Prefer").unwrap(), "preference");
13173        assert!(atom_kind("extract").is_err());
13174    }
13175
13176    #[test]
13177    fn a_sitting_lists_the_due_claims_its_island_holds_first() {
13178        let due = vec![
13179            serde_json::json!({"id": "old", "due_at": "2026-09-01"}),
13180            serde_json::json!({"id": "here", "due_at": "2026-09-05"}),
13181            serde_json::json!({"id": "older", "due_at": "2026-08-01"}),
13182        ];
13183        let island = serde_json::json!({"island": [{"id": "here"}, {"id": "absent"}]});
13184        let ids: Vec<String> = due_on_island_first(due, &island)
13185            .iter()
13186            .map(|a| a["id"].as_str().unwrap().to_string())
13187            .collect();
13188        assert_eq!(ids, ["here", "old", "older"]);
13189        let weak = serde_json::json!({"weak": true, "island": [{"id": "older"}]});
13190        let kept = due_on_island_first(
13191            vec![
13192                serde_json::json!({"id": "a"}),
13193                serde_json::json!({"id": "older"}),
13194            ],
13195            &weak,
13196        );
13197        assert_eq!(kept[0]["id"], "a", "a weak island does not reorder");
13198    }
13199
13200    #[test]
13201    fn atom_body_is_explicit_and_unextracted() {
13202        let v = atom_body("lesson", "the default fuse is CombMNZ", "ws");
13203        assert_eq!(v["schema"], "inside.atom/v1");
13204        assert_eq!(v["kind"], "lesson");
13205        assert_eq!(v["level"], "explicit");
13206        assert_eq!(v["text"], "the default fuse is CombMNZ");
13207        assert_eq!(v["workspace"], "ws");
13208        // Every write says where it came from.
13209        assert_eq!(v["source"]["via"], "ljos");
13210        assert!(!v["source"]["host"].as_str().unwrap_or("").is_empty());
13211        assert!(!v["source"]["session"].as_str().unwrap_or("").is_empty());
13212        // Every write names the seat that wrote it, and other entities join it.
13213        let seat = v["entities"][0].as_str().unwrap();
13214        assert!(seat.starts_with(SEAT_ENTITY), "{seat}");
13215        let mut more = v.clone();
13216        add_entities(
13217            &mut more,
13218            ["persona:reviewer".to_string(), seat.to_string()],
13219        );
13220        assert_eq!(more["entities"].as_array().unwrap().len(), 2, "{more}");
13221        // Never harvest a transcript: the text is the claim, not a prefix parse.
13222        let raw = atom_body("lesson", "Remember: pin the review set", "ws");
13223        assert_eq!(raw["text"], "Remember: pin the review set");
13224    }
13225
13226    #[test]
13227    fn empty_claim_is_refused() {
13228        let client = PacksetClient::new("http://127.0.0.1:1");
13229        let err = post_claim(&client, "Remember", "   ", "ws").unwrap_err();
13230        assert!(err.to_string().contains("empty text"));
13231    }
13232
13233    #[test]
13234    fn cards_are_the_two_named_files_only() {
13235        assert_eq!(CARD_NAMES, &["USER.md", "MEMORY.md"]);
13236        let dir = std::env::temp_dir().join(format!("ljos-cards-{}", std::process::id()));
13237        let _ = std::fs::remove_dir_all(&dir);
13238        std::fs::create_dir_all(&dir).unwrap();
13239        std::fs::write(dir.join("USER.md"), "user card\n").unwrap();
13240        std::fs::write(dir.join("MEMORY.md"), "memory card\n").unwrap();
13241        std::fs::write(dir.join("NOTES.md"), "must not appear\n").unwrap();
13242        let out = cards(&dir).unwrap();
13243        assert!(out.contains("user card"));
13244        assert!(out.contains("memory card"));
13245        assert!(!out.contains("must not appear"));
13246        assert!(!out.contains("NOTES.md"));
13247        let _ = std::fs::remove_dir_all(&dir);
13248    }
13249
13250    #[test]
13251    fn policy_prints_argv_and_does_not_reload() {
13252        assert!(policy_line(&[]).is_err());
13253        assert_eq!(policy_line(&["ls".into(), "-la".into()]).unwrap(), "ls -la");
13254        let note = POLICY_TCB.to_ascii_lowercase();
13255        assert!(note.contains("ljos-policyd"));
13256        assert!(note.contains("not a check"));
13257        assert!(!note.contains("grokos policy reload"));
13258        assert!(!note.contains("policy reload"));
13259    }
13260
13261    #[test]
13262    fn consensus_is_ljos_then_vissue() {
13263        let steps = consensus_steps("vissue-1a5a", true, true, &[]).unwrap();
13264        assert_eq!(steps.len(), 2);
13265        assert_eq!(steps[0].bin, "ljos-consensus");
13266        assert_eq!(steps[0].args, vec!["settle", "--issue", "vissue-1a5a"]);
13267        assert_eq!(steps[1].bin, "vissue");
13268        assert_eq!(steps[1].args, vec!["consensus", "vissue-1a5a"]);
13269    }
13270
13271    #[test]
13272    fn consensus_carries_the_packs_trust() {
13273        let rows = vec![row("a", "b", 0.5)];
13274        let steps = consensus_steps("id", true, true, &rows).unwrap();
13275        assert_eq!(steps[0].args[3], "--trust");
13276        assert_eq!(steps[0].args[4], r#"[["a","b",0.5]]"#);
13277        assert_eq!(
13278            steps[1].args,
13279            vec!["consensus", "id", "--trust", r#"[["a","b",0.5]]"#]
13280        );
13281    }
13282
13283    #[test]
13284    fn consensus_skips_a_missing_bin() {
13285        let only_v = consensus_steps("id", false, true, &[]).unwrap();
13286        assert_eq!(only_v.len(), 1);
13287        assert_eq!(only_v[0].bin, "vissue");
13288        let only_l = consensus_steps("id", true, false, &[]).unwrap();
13289        assert_eq!(only_l[0].bin, "ljos-consensus");
13290        assert!(consensus_steps("id", false, false, &[]).is_err());
13291    }
13292
13293    fn row(from: &str, to: &str, weight: f64) -> Trust {
13294        Trust {
13295            about: Vec::new(),
13296            from: from.into(),
13297            to: to.into(),
13298            weight,
13299        }
13300    }
13301
13302    #[test]
13303    fn a_trust_atom_is_one_edge_with_its_evidence() {
13304        let atom = trust_atom(&row("a", "b", 0.25), &["deed-x-y".into()], "ws").unwrap();
13305        assert_eq!(atom["kind"], "trust");
13306        assert_eq!(atom["from"], "a");
13307        assert_eq!(atom["to"], "b");
13308        assert_eq!(atom["weight"], 0.25);
13309        assert_eq!(atom["entities"], serde_json::json!(["deed-x-y"]));
13310        assert_eq!(atom["text"], "a weighs b at 0.250.");
13311        assert!(trust_atom(&row("a", "a", 0.5), &[], "ws").is_err());
13312        assert!(trust_atom(&row("a", "b", 0.0), &[], "ws").is_err());
13313        assert!(trust_atom(&row("a", "b", 1.5), &[], "ws").is_err());
13314        assert!(trust_atom(&row("", "b", 0.5), &[], "ws").is_err());
13315    }
13316
13317    #[test]
13318    fn the_latest_row_per_pair_wins() {
13319        let atoms = vec![
13320            serde_json::json!({"kind": "trust", "from": "a", "to": "b", "weight": 0.9, "ts": "2026-01-01T00:00:00Z"}),
13321            serde_json::json!({"kind": "trust", "from": "a", "to": "b", "weight": 0.3, "ts": "2026-02-01T00:00:00Z"}),
13322            serde_json::json!({"kind": "trust", "from": "b", "to": "a", "weight": 0.7}),
13323            serde_json::json!({"kind": "lesson", "text": "not a row"}),
13324            serde_json::json!({"kind": "trust", "from": "b", "weight": 0.7}),
13325        ];
13326        let rows = trust_rows(&atoms);
13327        assert_eq!(rows, vec![row("a", "b", 0.3), row("b", "a", 0.7)]);
13328        assert_eq!(trust_json(&rows), r#"[["a","b",0.3],["b","a",0.7]]"#);
13329    }
13330
13331    #[test]
13332    fn ballots_are_agent_and_choice() {
13333        let rows =
13334            ballots_from_json(r#"[{"agent":"a","choice":"ship","stamp":"[2026-01-01]"}]"#).unwrap();
13335        assert_eq!(rows, vec![("a".to_string(), "ship".to_string())]);
13336        assert!(ballots_from_json(r#"[{"agent":"a"}]"#).is_err());
13337        assert!(ballots_from_json("{}").is_err());
13338    }
13339
13340    /// A refuted voter loses weight in every other voter's row; a vindicated
13341    /// one keeps it; the rows come back complete.
13342    #[test]
13343    fn learning_downweights_the_refuted_voter() {
13344        let ballots = vec![
13345            ("a".to_string(), "ship".to_string()),
13346            ("b".to_string(), "ship".to_string()),
13347            ("c".to_string(), "hold".to_string()),
13348        ];
13349        let rows = learn(&ballots, "ship", &[], 0.5).unwrap();
13350        assert_eq!(rows.len(), 6);
13351        let w = |from: &str, to: &str| {
13352            rows.iter()
13353                .find(|r| r.from == from && r.to == to)
13354                .unwrap()
13355                .weight
13356        };
13357        assert_eq!(w("a", "b"), 1.0);
13358        assert_eq!(w("a", "c"), 0.5);
13359        assert_eq!(w("b", "c"), 0.5);
13360        assert_eq!(w("c", "a"), 1.0);
13361
13362        let again = learn(&ballots, "ship", &rows, 0.5).unwrap();
13363        let w2 = |from: &str, to: &str| {
13364            again
13365                .iter()
13366                .find(|r| r.from == from && r.to == to)
13367                .unwrap()
13368                .weight
13369        };
13370        assert_eq!(w2("a", "c"), 0.25);
13371        assert_eq!(w2("a", "b"), 1.0);
13372
13373        let floored = learn(&ballots, "ship", &[row("a", "c", 0.015)], 0.5).unwrap();
13374        let low = floored
13375            .iter()
13376            .find(|r| r.from == "a" && r.to == "c")
13377            .unwrap();
13378        assert_eq!(low.weight, TRUST_FLOOR);
13379
13380        assert!(learn(&ballots, "ship", &[], 1.0).is_err());
13381        assert!(learn(&ballots, "  ", &[], 0.5).is_err());
13382        assert!(learn(&ballots[..1], "ship", &[], 0.5).is_err());
13383
13384        // A fixed share of recovery: the refuted row moves back toward one
13385        // by the share of the gap, the vindicated row stays at one.
13386        let shared = learn_shared(&ballots, "ship", &rows, 0.5, &[], 0.1).unwrap();
13387        let w3 = |from: &str, to: &str| {
13388            shared
13389                .iter()
13390                .find(|r| r.from == from && r.to == to)
13391                .unwrap()
13392                .weight
13393        };
13394        assert!((w3("a", "c") - (0.25 + 0.75 * 0.1)).abs() < 1e-12);
13395        assert_eq!(w3("a", "b"), 1.0);
13396        assert!(learn_shared(&ballots, "ship", &[], 0.5, &[], 1.0).is_err());
13397    }
13398
13399    #[test]
13400    fn a_name_is_one_work_id_and_hex_passes_through() {
13401        let a = work_id("demo-riml");
13402        assert_eq!(a.len(), 32);
13403        assert!(a.bytes().all(|b| b.is_ascii_hexdigit()));
13404        assert_eq!(a, work_id(" demo-riml "));
13405        assert_ne!(a, work_id("demo-rimm"));
13406        assert_eq!(work_id(&a.to_ascii_uppercase()), a);
13407        assert_ne!(work_id("seat"), work_id("reader"));
13408    }
13409
13410    #[test]
13411    fn a_refusal_is_not_a_writer_that_is_down() {
13412        let refused = anyhow::Error::from(packset_client::Error::Bad("no".into()));
13413        assert!(!writer_unreachable(&refused));
13414    }
13415
13416    #[test]
13417    fn a_stated_probability_has_a_brier_score_and_a_hard_vote_does_not() {
13418        let rows = vec![
13419            Forecast {
13420                agent: "a".into(),
13421                choice: "ship".into(),
13422                confidence: Some(0.8),
13423            },
13424            Forecast {
13425                agent: "b".into(),
13426                choice: "hold".into(),
13427                confidence: None,
13428            },
13429        ];
13430        assert!((brier("ship", "ship", 0.8) - 0.04).abs() < 1e-12);
13431        assert!((brier("hold", "ship", 0.8) - 0.64).abs() < 1e-12);
13432        let (mean, n) = mean_brier(&rows, "ship").unwrap();
13433        assert_eq!(n, 1);
13434        assert!((mean - 0.04).abs() < 1e-12);
13435        let said = learn_reading(2, 0, &rows, "ship", &std::collections::BTreeMap::new());
13436        assert!(said.contains("Brier 0.040"), "{said}");
13437        assert!(said.contains("not a trust weight"), "{said}");
13438        let silent = learn_reading(2, 0, &rows[1..], "ship", &std::collections::BTreeMap::new());
13439        assert!(silent.contains("No stated probability"), "{silent}");
13440        assert!(log_score("ship", "ship", 0.8).unwrap() > 0.0);
13441        assert!(log_score("hold", "ship", 1.0).is_none());
13442        let mut cal = Calibration::default();
13443        cal = observe(&cal, "ship", "ship", 0.8);
13444        cal = observe(&cal, "ship", "hold", 0.8);
13445        let part = murphy(&cal).unwrap();
13446        let mean_b = cal.sum_brier / f64::from(cal.n);
13447        assert!((part.reliability - part.resolution + part.uncertainty - mean_b).abs() < 1e-9);
13448        assert!((cal.sum_p / f64::from(cal.n) - 0.8).abs() < 1e-12);
13449        assert!((cal.sum_o / f64::from(cal.n) - 0.5).abs() < 1e-12);
13450    }
13451
13452    #[test]
13453    fn an_island_prints_one_memory_a_line() {
13454        let body = serde_json::json!({"island": [
13455            {"id": "a", "text": "one", "activation": 1.0, "seed": true, "ts": now_utc()},
13456            {"id": "b", "text": "two", "activation": 0.25, "seed": false}
13457        ]});
13458        let printed = format_island(&body);
13459        assert!(
13460            printed.contains("Seat island") && printed.contains("Not fired"),
13461            "{printed}"
13462        );
13463        assert!(
13464            printed.contains("1.000\tseed\ta\ttoday\tone\n"),
13465            "{printed}"
13466        );
13467        assert!(printed.contains("0.250\t    \tb\t\ttwo\n"), "{printed}");
13468        assert!(format_island(&serde_json::json!({})).is_empty());
13469        let persona = serde_json::json!({
13470            "as": "reviewer",
13471            "fired": 3,
13472            "island": [{"id": "a", "text": "one", "activation": 1.0, "seed": true, "ts": now_utc()}]
13473        });
13474        let walked = format_island(&persona);
13475        assert!(walked.contains("Persona reviewer"), "{walked}");
13476        assert!(walked.contains("Fired: 3"), "{walked}");
13477        assert!(!walked.contains("Seat island"), "{walked}");
13478    }
13479
13480    #[test]
13481    fn a_fed_verb_reads_its_stdin() {
13482        let said = run_fed("cat", &[] as &[&str], "one\ntwo\n").unwrap();
13483        assert_eq!(said.stdout, "one\ntwo\n");
13484        assert!(run_fed("sh", &["-c", "exit 2"], "").is_err());
13485    }
13486
13487    #[test]
13488    fn needs_and_cited_are_enclosed_once_each() {
13489        let needs = needs_of(r#"{"needs":["deed-b-2","deed-a-1"],"other":1}"#).unwrap();
13490        assert_eq!(needs, vec!["deed-b-2", "deed-a-1"]);
13491        assert_eq!(
13492            enclose(needs, "deed-a-1\n\ndeed-c-3\n"),
13493            vec!["deed-a-1", "deed-b-2", "deed-c-3"]
13494        );
13495        assert!(needs_of("{}").unwrap().is_empty());
13496        assert!(needs_of("not json").is_err());
13497    }
13498
13499    #[test]
13500    fn a_json_config_takes_the_entry_by_pointer() {
13501        let dir = std::env::temp_dir().join(format!("ljos-onboard-json-{}", std::process::id()));
13502        std::fs::create_dir_all(&dir).unwrap();
13503        let config = dir.join("runner.json");
13504        std::fs::write(&config, "{\"model\": \"x\"}\n").unwrap();
13505        let entry = serde_json::json!({"type": "local", "command": ["/bin/ljos-mcp"]});
13506        set_json_entry(&config, "/mcp/ljos", &entry).unwrap();
13507        let doc: Value = serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
13508        assert_eq!(doc["model"], "x", "the rest of the file stands");
13509        assert_eq!(doc["mcp"]["ljos"]["command"][0], "/bin/ljos-mcp");
13510        let h = Harness {
13511            name: "runner".into(),
13512            register: Vec::new(),
13513            registered: Vec::new(),
13514            config: None,
13515            marker: None,
13516            snippet: None,
13517            config_json: Some(config.display().to_string()),
13518            json_pointer: Some("/mcp/ljos".into()),
13519            json_entry: None,
13520            skills: None,
13521            hooks: None,
13522            hook_events: Vec::new(),
13523            plugin: None,
13524            plugin_template: None,
13525            probe: Vec::new(),
13526            clients: Vec::new(),
13527        };
13528        assert_eq!(is_registered(&h, Path::new("/bin/ljos-mcp")), Some(true));
13529        let _ = std::fs::remove_dir_all(&dir);
13530    }
13531
13532    #[test]
13533    fn a_persona_set_is_in_the_pack_alphabet() {
13534        assert_eq!(persona_set("Reviewer"), "persona-reviewer");
13535        assert_eq!(persona_set("first gpu:user"), "persona-first-gpu-user");
13536        assert!(persona_set("x".repeat(60).as_str()).len() <= 32);
13537    }
13538
13539    #[test]
13540    fn the_roster_lists_each_persona_on_one_line() {
13541        assert!(format_personas(&[]).starts_with("no personas;"));
13542        let roster = format_personas(&[
13543            Persona {
13544                name: "reviewer".into(),
13545                anchor: 0.2,
13546                view: "Reads for what breaks.".into(),
13547                entities: vec!["docs".into(), "release".into()],
13548            },
13549            Persona {
13550                name: "reader".into(),
13551                anchor: 0.8,
13552                view: "Reads as a first-time user.".into(),
13553                entities: Vec::new(),
13554            },
13555        ]);
13556        let lines: Vec<&str> = roster.lines().collect();
13557        assert_eq!(lines.len(), 2);
13558        assert!(
13559            lines[0].starts_with("reviewer  anchor 0.20  about docs, release  Reads"),
13560            "{}",
13561            lines[0]
13562        );
13563        assert!(lines[1].contains("about anything"), "{}", lines[1]);
13564    }
13565
13566    #[test]
13567    fn the_sweep_line_counts_what_moved_and_is_silent_otherwise() {
13568        assert_eq!(format_sweep(None), "");
13569        assert_eq!(
13570            format_sweep(Some(&serde_json::json!({"lapsed": 0, "forgotten": 0}))),
13571            ""
13572        );
13573        let line = format_sweep(Some(&serde_json::json!({"lapsed": 2, "forgotten": 1})));
13574        assert!(line.contains("2 reviews lapsed"), "{line}");
13575        assert!(line.contains("1 never-recalled claim forgotten"), "{line}");
13576        let one = format_sweep(Some(&serde_json::json!({"lapsed": 1, "forgotten": 0})));
13577        assert!(
13578            one.contains("1 review lapsed past twice its interval"),
13579            "{one}"
13580        );
13581    }
13582
13583    #[test]
13584    fn due_is_the_past_soonest_first() {
13585        let atoms = vec![
13586            serde_json::json!({"id": "late", "due_at": "2026-02-01T00:00:00.000Z"}),
13587            serde_json::json!({"id": "later", "due_at": "2026-03-01T00:00:00.000Z"}),
13588            serde_json::json!({"id": "future", "due_at": "2099-01-01T00:00:00.000Z"}),
13589            serde_json::json!({"id": "never"}),
13590            serde_json::json!({"id": "blank", "due_at": ""}),
13591        ];
13592        let due = due_of(&atoms, "2026-06-01T00:00:00.000Z");
13593        let ids: Vec<&str> = due.iter().map(|a| a["id"].as_str().unwrap()).collect();
13594        // A claim that never entered the clock is due now, ahead of the
13595        // past-due ones; the future one waits.
13596        assert_eq!(ids, ["never", "blank", "late", "later"]);
13597        assert!(now_utc().ends_with(".000Z"));
13598        assert!(now_utc().as_str() > "2026-01-01T00:00:00.000Z");
13599    }
13600
13601    #[test]
13602    fn timeline_exposes_event_rows() {
13603        let src = include_str!("lib.rs");
13604        assert!(src.contains("pub fn timeline_events"));
13605        assert!(src.contains("Result<Vec<Event>>"));
13606        assert!(src.contains("pub fn pack_last_write_ts"));
13607        assert!(src.contains("GET /v1/status"));
13608        assert!(src.contains("vissue_core::agent::show_json"));
13609    }
13610
13611    #[test]
13612    fn timeline_of_does_not_shell_vissue() {
13613        let src = include_str!("lib.rs");
13614        let start = src.find("fn timeline_of").expect("timeline_of");
13615        let end = src[start..]
13616            .find("\npub fn timeline(")
13617            .map(|i| start + i)
13618            .expect("timeline after timeline_of");
13619        let body = &src[start..end];
13620        assert!(
13621            !body.contains("run_captured(\"vissue\""),
13622            "timeline_of must not shell vissue"
13623        );
13624        assert!(
13625            !body.contains("Command::new(\"vissue\")"),
13626            "timeline_of must not Command::new vissue"
13627        );
13628        assert!(
13629            body.contains("tracker_show_json"),
13630            "timeline_of should call the tracker library"
13631        );
13632    }
13633
13634    #[test]
13635    fn timeline_events_reads_the_tracker_without_shelling_vissue() {
13636        let _g = env_guard();
13637        let dir = tempfile::tempdir().unwrap();
13638        let project = dir.path().join("Software/sample");
13639        std::fs::create_dir_all(&project).unwrap();
13640        std::fs::write(
13641            project.join("issues.org"),
13642            "#+TITLE: sample issues\n#+VISSUE: 1\n#+CATEGORY: sample\n#+TODO: TODO STARTED BLOCKED | DONE CANCELLED\n\n* TODO [#B] Deed rail library show\n:PROPERTIES:\n:ID:         sample-k2p2\n:CREATED:    [2026-09-20 Sat]\n:END:\n",
13643        )
13644        .unwrap();
13645        let old_issue_root = std::env::var_os("ISSUE_ROOT");
13646        let old_vissue_root = std::env::var_os("VISSUE_ROOT");
13647        let old_no_route = std::env::var_os("VISSUE_NO_ROUTE");
13648        let old_path = std::env::var_os("PATH");
13649        unsafe {
13650            std::env::set_var("ISSUE_ROOT", dir.path());
13651            std::env::set_var("VISSUE_ROOT", dir.path());
13652            std::env::set_var("VISSUE_NO_ROUTE", "1");
13653            std::env::set_var("PATH", "/usr/bin");
13654        }
13655        let events = timeline_events("sample-k2p2", 12);
13656        unsafe {
13657            match old_issue_root {
13658                Some(v) => std::env::set_var("ISSUE_ROOT", v),
13659                None => std::env::remove_var("ISSUE_ROOT"),
13660            }
13661            match old_vissue_root {
13662                Some(v) => std::env::set_var("VISSUE_ROOT", v),
13663                None => std::env::remove_var("VISSUE_ROOT"),
13664            }
13665            match old_no_route {
13666                Some(v) => std::env::set_var("VISSUE_NO_ROUTE", v),
13667                None => std::env::remove_var("VISSUE_NO_ROUTE"),
13668            }
13669            match old_path {
13670                Some(v) => std::env::set_var("PATH", v),
13671                None => std::env::remove_var("PATH"),
13672            }
13673        }
13674        let events = events.expect("timeline_events should read the tracker library");
13675        assert!(
13676            events
13677                .iter()
13678                .any(|e| e.source == "tracker" && e.text == "created"),
13679            "{events:?}"
13680        );
13681    }
13682
13683    const EVIDENCE: &str = "stdout:\n== building and installing GCCcore/15.2.0...\nstderr:\nERROR: Installation of GCCcore-15.2.0.eb failed: shell command 'make ...' failed with exit code 2 in build step for GCCcore-15.2.0.eb\nsrun: error: task 0 exited";
13684
13685    #[test]
13686    fn a_bundle_becomes_rows_with_edges_and_steady_ids() {
13687        let dir = std::env::temp_dir().join(format!("ljos-bump-{}", std::process::id()));
13688        let _ = std::fs::remove_dir_all(&dir);
13689        std::fs::create_dir_all(dir.join("locks")).unwrap();
13690        std::fs::write(
13691            dir.join("locks/default.lock.json"),
13692            r#"{"package":"eOn","version":"2.17.10","toolchain":{"name":"foss","version":"2026.1"},"versionsuffix":"",
13693                "dependencies":[
13694                 {"name":"CMake","version":"4.2.1","toolchain":{"name":"GCCcore","version":"15.2.0"},"easyconfig_path":"c/CMake/CMake-4.2.1-GCCcore-15.2.0.eb","build":true},
13695                 {"name":"Eigen","version":"5.0.0","toolchain":{"name":"GCCcore","version":"15.2.0"},"easyconfig_path":"e/Eigen/Eigen-5.0.0-GCCcore-15.2.0.eb","build":true},
13696                 {"name":"Python","version":"3.14.2","toolchain":{"name":"GCCcore","version":"15.2.0"},"easyconfig_path":"p/Python/Python-3.14.2-GCCcore-15.2.0.eb","build":false}]}"#,
13697        )
13698        .unwrap();
13699        std::fs::write(
13700            dir.join("package.sbom.cdx.json"),
13701            r#"{"components":[],"dependencies":[
13702                {"ref":"pkg:generic/eOn@2.17.10","dependsOn":["pkg:generic/CMake@==4.2.1","pkg:generic/Eigen@==5.0.0","pkg:generic/Python@==3.14.2"]},
13703                {"ref":"pkg:generic/Eigen@==5.0.0","dependsOn":["pkg:generic/CMake@==4.2.1"]},
13704                {"ref":"pkg:generic/CMake@==4.2.1"}]}"#,
13705        )
13706        .unwrap();
13707        let (generation, rows) = bump_rows(&dir, "ebstack", None).unwrap();
13708        assert_eq!(generation, "foss/2026.1");
13709        let modules: Vec<&str> = rows.iter().map(|r| r.module.as_str()).collect();
13710        assert_eq!(
13711            modules,
13712            [
13713                "eOn-2.17.10-foss-2026.1",
13714                "CMake-4.2.1-GCCcore-15.2.0",
13715                "Eigen-5.0.0-GCCcore-15.2.0",
13716                "Python-3.14.2-GCCcore-15.2.0"
13717            ],
13718            "the root first, then every module the lock names, build dependencies included"
13719        );
13720        let cmake = &rows[1];
13721        let eigen = &rows[2];
13722        let python = &rows[3];
13723        assert!(cmake.blockers.is_empty());
13724        assert_eq!(eigen.blockers, std::slice::from_ref(&cmake.id));
13725        assert_eq!(
13726            rows[0].blockers,
13727            [cmake.id.clone(), eigen.id.clone(), python.id.clone()],
13728            "the root is blocked by every module it depends on"
13729        );
13730        assert_eq!(
13731            rows[0].id,
13732            bump_issue_id("ebstack", "eOn-2.17.10-foss-2026.1", "foss/2026.1")
13733        );
13734        assert!(rows[0].id.starts_with("ebstack-") && rows[0].id.len() == "ebstack-".len() + 8);
13735        assert_ne!(
13736            rows[0].id,
13737            bump_issue_id("ebstack", "eOn-2.17.10-foss-2026.1", "foss/2027a")
13738        );
13739        assert!(rows.iter().all(|r| r.result == "would make"));
13740        let _ = std::fs::remove_dir_all(&dir);
13741    }
13742
13743    #[test]
13744    fn a_finding_lesson_is_two_short_sentences_about_the_recipe() {
13745        let campaign = Campaign {
13746            package: "eOn".into(),
13747            version: "2.17.10".into(),
13748            target: "terra".into(),
13749            status: "completed".into(),
13750            attempts: 29,
13751            findings: Vec::new(),
13752        };
13753        let f = Finding {
13754            id: "attempt:6:finding:6".into(),
13755            status: "resolved".into(),
13756            class: "compile".into(),
13757            disposition: "requires-judgment".into(),
13758            stage: "build".into(),
13759            recipe: recipe_stem("easyconfigs/e/eOn/eOn-2.17.10-foss-2026.1.eb"),
13760            module: failed_module(EVIDENCE).unwrap_or_default(),
13761            summary: "Compile failure from EasyBuild command (exit Some(1))".into(),
13762            error: error_line(EVIDENCE, "Compile failure"),
13763            action: "applied the GCC 14 libsanitizer kernel headers patch. Kept in the overlay"
13764                .into(),
13765            changes: vec!["overlay/g/GCCcore/GCCcore-15.2.0.eb".into()],
13766        };
13767        assert_eq!(f.module, "GCCcore-15.2.0");
13768        let lesson = finding_lesson(&campaign, &f);
13769        assert_eq!(
13770            lesson,
13771            "GCCcore-15.2.0 for eOn-2.17.10-foss-2026.1 on terra: compile failed in the build step \
13772             with shell command 'make' failed with exit code 2 in build. \
13773             Fix: applied the GCC 14 libsanitizer kernel headers patch, Kept in the overlay in GCCcore-15.2.0."
13774        );
13775        assert!(!lesson.contains("srun"));
13776        assert_eq!(
13777            finding_entities(&campaign, &f),
13778            [
13779                "GCCcore-15.2.0",
13780                "GCCcore",
13781                "eOn-2.17.10-foss-2026.1",
13782                "eOn",
13783                "compile"
13784            ]
13785        );
13786        let retry = Finding {
13787            action: "successful campaign retry superseded this finding".into(),
13788            ..f.clone()
13789        };
13790        assert!(superseded_by_retry(&retry));
13791        assert!(!superseded_by_retry(&f));
13792        assert!(finding_lesson(&campaign, &retry).ends_with("A later attempt got past it."));
13793        assert_eq!(
13794            failed_module("== building and installing gettext/0.26...\n== FAILED"),
13795            Some("gettext-0.26".into())
13796        );
13797    }
13798
13799    #[test]
13800    fn tracker_decimal_confidence_remains_a_scored_forecast() {
13801        let forecasts = super::forecasts_from_json(
13802            r#"[{"agent":"alice","choice":"accept","confidence":"0.8"},
13803                {"agent":"bob","choice":"reject","confidence":0.6},
13804                {"agent":"carol","choice":"accept","confidence":null},
13805                {"agent":"dana","choice":"accept"}]"#,
13806        )
13807        .unwrap();
13808        assert_eq!(forecasts[0].confidence, Some(0.8));
13809        assert_eq!(forecasts[1].confidence, Some(0.6));
13810        assert_eq!(forecasts[2].confidence, None);
13811        assert_eq!(forecasts[3].confidence, None);
13812        let (score, count) = super::mean_brier(&forecasts, "accept").unwrap();
13813        assert_eq!(count, 2);
13814        assert!((score - 0.2).abs() < 1e-14);
13815    }
13816
13817    #[test]
13818    fn invalid_tracker_confidence_is_not_silently_unscored() {
13819        for confidence in ["0", "-0.1", "1.1", "\"NaN\"", "\"oops\"", "true", "[]"] {
13820            let raw =
13821                format!(r#"[{{"agent":"alice","choice":"accept","confidence":{confidence}}}]"#);
13822            let error = super::forecasts_from_json(&raw).unwrap_err().to_string();
13823            assert!(error.contains("probability in (0, 1]"), "{error}");
13824        }
13825    }
13826
13827    #[test]
13828    fn ahead_of_a_cached_registry_answer_is_said() {
13829        let cached = super::CrateVersion {
13830            version: "0.12.16".into(),
13831            cached: true,
13832        };
13833        let (state, ok) = super::bin_health("/bin/ljos", Some("0.13.5"), Some(&cached));
13834        assert!(ok, "{state}");
13835        assert!(
13836            state.contains("ahead of crates.io (cached) 0.12.16"),
13837            "{state}"
13838        );
13839        let (same, _) = super::bin_health("/bin/ljos", Some("0.12.16"), Some(&cached));
13840        assert!(same.ends_with("crates.io (cached) 0.12.16"), "{same}");
13841    }
13842
13843    #[test]
13844    fn the_mcp_binary_tracks_the_ljos_crate() {
13845        let crate_name = super::SEAT_BINS
13846            .iter()
13847            .find(|(bin, _)| *bin == "ljos-mcp")
13848            .map(|(_, name)| *name);
13849        assert_eq!(crate_name, Some("ljos"));
13850    }
13851
13852    #[test]
13853    fn a_behind_required_bin_still_answers() {
13854        let latest = super::CrateVersion {
13855            version: "0.9.5".into(),
13856            cached: false,
13857        };
13858        let (state, ok) = super::bin_health("/bin/packsetd", Some("0.9.2"), Some(&latest));
13859        assert!(ok, "{state}");
13860        assert!(state.contains("behind crates.io 0.9.5"), "{state}");
13861        let rows = vec![Habitat {
13862            name: "packsetd",
13863            state,
13864            ok,
13865        }];
13866        assert!(
13867            healthy(&rows),
13868            "sitting must not refuse a stale but answering bin"
13869        );
13870    }
13871
13872    #[test]
13873    fn ballot_health_requires_both_evidence_and_confidence_arguments() {
13874        use std::os::unix::fs::PermissionsExt;
13875        let dir = tempfile::tempdir().unwrap();
13876        let path = dir.path().join("vissue");
13877        for (help, missing) in [
13878            ("--for OPTION --json", Some("--used, --confidence")),
13879            ("--for OPTION --used DEEDS", Some("--confidence")),
13880            ("--for OPTION --confidence P", Some("--used")),
13881            ("--for OPTION --used DEEDS --confidence P", None),
13882        ] {
13883            std::fs::write(
13884                &path,
13885                format!(
13886                    "#!/bin/sh\n[ \"$*\" = 'vote --help' ] || exit 3\nprintf '%s\\n' '{help}'\n"
13887                ),
13888            )
13889            .unwrap();
13890            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
13891            let result = super::check_vissue_ballot_protocol(&path);
13892            if let Some(missing) = missing {
13893                let error = result.unwrap_err().to_string();
13894                assert!(error.contains(&format!("missing {missing};")), "{error}");
13895                let rows = vec![Habitat {
13896                    name: "vissue",
13897                    state: error,
13898                    ok: false,
13899                }];
13900                assert!(!healthy(&rows));
13901            } else {
13902                result.unwrap();
13903            }
13904        }
13905    }
13906
13907    #[test]
13908    fn ballot_health_refuses_a_failed_help_command() {
13909        use std::os::unix::fs::PermissionsExt;
13910        let dir = tempfile::tempdir().unwrap();
13911        let path = dir.path().join("vissue");
13912        std::fs::write(
13913            &path,
13914            "#!/bin/sh\necho '--used DEEDS --confidence P'\nexit 2\n",
13915        )
13916        .unwrap();
13917        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
13918        let error = super::check_vissue_ballot_protocol(&path)
13919            .unwrap_err()
13920            .to_string();
13921        assert!(error.contains("vote --help failed"), "{error}");
13922    }
13923
13924    #[test]
13925    fn the_doctor_names_every_habitat_and_the_pack_gates_health() {
13926        let rows = doctor();
13927        let names: Vec<&str> = rows.iter().map(|h| h.name).collect();
13928        for want in [
13929            "ljos",
13930            "packset-embed",
13931            "vissue",
13932            "deedar",
13933            "packset",
13934            "pack",
13935            "encoder",
13936            "host key",
13937            "deed store",
13938            "tracker",
13939        ] {
13940            assert!(names.contains(&want), "{names:?}");
13941        }
13942        let table = format_doctor(&rows);
13943        assert_eq!(table.lines().count(), rows.len());
13944        let sick = vec![Habitat {
13945            name: "pack",
13946            state: "PACKSET_URL unset".into(),
13947            ok: false,
13948        }];
13949        assert!(!healthy(&sick));
13950        let fine = vec![Habitat {
13951            name: "landfold",
13952            state: "not on PATH".into(),
13953            ok: false,
13954        }];
13955        assert!(healthy(&fine));
13956        assert_eq!(
13957            super::format_write_ack(&serde_json::json!({
13958                "id": "ab",
13959                "kind": "lesson",
13960                "due_at": "2026-09-15T00:00:00Z",
13961                "text": "The encoder sits beside packsetd."
13962            })),
13963            "ab\tlesson\tdue 2026-09-15T00:00:00Z\tThe encoder sits beside packsetd."
13964        );
13965        assert_eq!(super::parse_semver("ljos 0.12.8"), Some("0.12.8"));
13966        assert_eq!(
13967            super::cmp_semver("0.4.1", "0.5.3"),
13968            Some(std::cmp::Ordering::Less)
13969        );
13970    }
13971
13972    #[test]
13973    fn enclosed_atoms_are_read_from_every_jsonl_in_the_bag() {
13974        let dir = std::env::temp_dir().join(format!("ljos-bag-{}", std::process::id()));
13975        let _ = std::fs::remove_dir_all(&dir);
13976        let atoms = dir.join("data").join("atoms");
13977        std::fs::create_dir_all(&atoms).unwrap();
13978        std::fs::write(
13979            atoms.join("a.jsonl"),
13980            "{\"kind\":\"lesson\",\"text\":\"one\"}\n\n{\"kind\":\"trust\",\"from\":\"a\",\"to\":\"b\",\"weight\":0.5}\n",
13981        )
13982        .unwrap();
13983        std::fs::write(
13984            atoms.join("b.jsonl"),
13985            "{\"kind\":\"preference\",\"text\":\"two\"}\n",
13986        )
13987        .unwrap();
13988        let read = enclosed_atoms(&dir).unwrap();
13989        assert_eq!(read.len(), 3);
13990        assert_eq!(trust_rows(&read).len(), 1);
13991        assert!(enclosed_atoms(&dir.join("nowhere")).unwrap().is_empty());
13992        std::fs::write(atoms.join("c.jsonl"), "not json\n").unwrap();
13993        assert!(enclosed_atoms(&dir).is_err());
13994        let _ = std::fs::remove_dir_all(&dir);
13995
13996        let table = format_due(&[serde_json::json!({
13997            "id": "x", "kind": "lesson", "text": "t", "due_at": "2026-01-01T00:00:00.000Z"
13998        })]);
13999        assert_eq!(table, "2026-01-01T00:00:00.000Z\tlesson\tx\tt\n");
14000    }
14001
14002    fn read_http(s: &mut impl Read) -> String {
14003        let mut buf = Vec::new();
14004        let mut tmp = [0u8; 1024];
14005        loop {
14006            let n = s.read(&mut tmp).unwrap_or(0);
14007            if n == 0 {
14008                break;
14009            }
14010            buf.extend_from_slice(&tmp[..n]);
14011            if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
14012                let headers = &buf[..at];
14013                let mut need = 0usize;
14014                for line in headers.split(|b| *b == b'\n') {
14015                    let line = std::str::from_utf8(line).unwrap_or("").trim();
14016                    if let Some(v) = line
14017                        .split_once(':')
14018                        .filter(|(k, _)| k.eq_ignore_ascii_case("content-length"))
14019                        .map(|(_, v)| v.trim())
14020                    {
14021                        need = v.parse().unwrap_or(0);
14022                    }
14023                }
14024                let have = buf.len().saturating_sub(at + 4);
14025                if have >= need {
14026                    break;
14027                }
14028            }
14029        }
14030        String::from_utf8_lossy(&buf).into_owned()
14031    }
14032
14033    fn serve_capture() -> (String, Arc<Mutex<String>>) {
14034        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14035        let addr = listener.local_addr().unwrap();
14036        let captured = Arc::new(Mutex::new(String::new()));
14037        let slot = captured.clone();
14038        std::thread::spawn(move || {
14039            if let Ok((mut s, _)) = listener.accept() {
14040                *slot.lock().unwrap() = read_http(&mut s);
14041                let body =
14042                    r#"{"id":"atom-1","kind":"lesson","text":"the default fuse is CombMNZ"}"#;
14043                let resp = format!(
14044                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
14045                    body.len()
14046                );
14047                let _ = s.write_all(resp.as_bytes());
14048            }
14049        });
14050        (format!("http://{addr}"), captured)
14051    }
14052
14053    #[test]
14054    fn remember_posts_v1_atoms() {
14055        let (url, captured) = serve_capture();
14056        let client = PacksetClient::new(&url);
14057        let body = post_claim(&client, "Remember", "the default fuse is CombMNZ", "ws").unwrap();
14058        assert_eq!(body["id"], "atom-1");
14059        let req = captured.lock().unwrap().clone();
14060        assert!(req.contains("POST"), "{req}");
14061        assert!(req.contains("/v1/atoms"), "{req}");
14062        assert!(req.contains("\"kind\":\"lesson\""), "{req}");
14063        assert!(req.contains("the default fuse is CombMNZ"), "{req}");
14064        assert!(req.contains("\"level\":\"explicit\""), "{req}");
14065        assert!(req.contains("horizon:transient"), "{req}");
14066        assert!(!req.contains("extract"), "{req}");
14067    }
14068
14069    #[test]
14070    fn forget_posts_the_id_and_workspace() {
14071        let (url, captured) = serve_capture();
14072        let client = PacksetClient::new(&url);
14073        let body = client.delete_atom("ws", "atom-1", None).unwrap();
14074        assert_eq!(body["id"], "atom-1");
14075        let req = captured.lock().unwrap().clone();
14076        assert!(req.contains("POST"), "{req}");
14077        assert!(req.contains("/v1/atoms/delete"), "{req}");
14078        assert!(req.contains("\"id\":\"atom-1\""), "{req}");
14079        assert!(req.contains("\"workspace\":\"ws\""), "{req}");
14080        // No deed named, no field: the pack should not have to tell an absent
14081        // citation from an empty one.
14082        assert!(!req.contains("\"why\""), "{req}");
14083    }
14084
14085    /// The deed rides with the retraction, so the pack can write it onto the
14086    /// tombstone in the same step the atom leaves the live set.
14087    #[test]
14088    fn forget_carries_the_deed_that_withdrew_the_claim() {
14089        let (url, captured) = serve_capture();
14090        let client = PacksetClient::new(&url);
14091        client
14092            .delete_atom("ws", "atom-1", Some("deed-patch-overlay"))
14093            .unwrap();
14094        let req = captured.lock().unwrap().clone();
14095        assert!(req.contains("\"why\":\"deed-patch-overlay\""), "{req}");
14096    }
14097
14098    /// An id is the whole of the request, so an empty one is a mistake worth
14099    /// naming rather than a delete of whatever the server decides that means.
14100    #[test]
14101    fn forget_refuses_an_empty_id() {
14102        let err = packset_forget("   ", None).unwrap_err();
14103        assert!(err.to_string().contains("atom id is required"), "{err}");
14104    }
14105
14106    /// A fake tracker on PATH: `show` answers as told, `claim` logs its
14107    /// argv and the identity it was given.
14108    fn fake_vissue(dir: &std::path::Path, show_ok: bool, claim_ok: bool) -> std::path::PathBuf {
14109        let log = dir.join("calls.log");
14110        let script = format!(
14111            "#!/bin/sh\necho \"$* VISSUE_AGENT=${{VISSUE_AGENT:-}}\" >> '{}'\ncase \"$1\" in\n  show) {} ;;\n  claim) {} ;;\nesac\nexit 0\n",
14112            log.display(),
14113            if show_ok { "echo '{}'" } else { "exit 1" },
14114            if claim_ok { "echo claimed" } else { "echo refused >&2; exit 1" },
14115        );
14116        let path = dir.join("vissue");
14117        std::fs::write(&path, script).unwrap();
14118        #[cfg(unix)]
14119        {
14120            use std::os::unix::fs::PermissionsExt;
14121            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
14122        }
14123        log
14124    }
14125
14126    /// Run `f` with `dir` first on PATH, then put PATH back.
14127    fn with_fake_on_path<T>(dir: &std::path::Path, f: impl FnOnce() -> T) -> T {
14128        let old = std::env::var_os("PATH").unwrap_or_default();
14129        let mut new = std::ffi::OsString::from(dir.as_os_str());
14130        new.push(":");
14131        new.push(&old);
14132        unsafe {
14133            std::env::set_var("PATH", &new);
14134        }
14135        let out = f();
14136        unsafe {
14137            std::env::set_var("PATH", old);
14138        }
14139        out
14140    }
14141
14142    #[test]
14143    fn a_claim_stamps_the_tracker_under_the_assignee() {
14144        let _g = env_guard();
14145        let dir = tempfile::tempdir().unwrap();
14146        let log = fake_vissue(dir.path(), true, true);
14147        let said = with_fake_on_path(dir.path(), || stamp_tracker("proj-1a2b", "alice")).unwrap();
14148        assert_eq!(
14149            said.as_deref(),
14150            Some("tracker: proj-1a2b STARTED under alice")
14151        );
14152        let calls = std::fs::read_to_string(log).unwrap();
14153        assert!(
14154            calls.contains("claim proj-1a2b VISSUE_AGENT=alice"),
14155            "{calls}"
14156        );
14157    }
14158
14159    #[test]
14160    fn a_node_the_tracker_does_not_know_stamps_nothing() {
14161        let _g = env_guard();
14162        let dir = tempfile::tempdir().unwrap();
14163        let log = fake_vissue(dir.path(), false, true);
14164        let said = with_fake_on_path(dir.path(), || stamp_tracker("deadbeef", "alice")).unwrap();
14165        assert_eq!(said, None);
14166        let calls = std::fs::read_to_string(log).unwrap();
14167        assert!(
14168            !calls.contains("claim"),
14169            "asked to claim a non-issue: {calls}"
14170        );
14171    }
14172
14173    #[test]
14174    fn a_closed_tracker_heading_is_reopened_when_the_graph_takes_it() {
14175        let _g = env_guard();
14176        let dir = tempfile::tempdir().unwrap();
14177        let log = dir.path().join("calls.log");
14178        let script = format!(
14179            "#!/bin/sh\necho \"$* VISSUE_AGENT=${{VISSUE_AGENT:-}}\" >> '{log}'\ncase \"$1\" in\n  show) echo '{{}}'; exit 0 ;;\n  update) echo updated; exit 0 ;;\n  claim)\n    echo \"$*\" | grep -q -- '--force' && {{ echo claimed; exit 0; }}\n    if grep -q '^update ' '{log}'; then echo 'vissue: proj-1a2b is claimed by you since [2026-01-01]; pass --force to take it over' >&2; exit 1; fi\n    echo 'vissue: proj-1a2b is already DONE; cannot claim' >&2\n    exit 1\n    ;;\nesac\nexit 1\n",
14180            log = log.display()
14181        );
14182        let path = dir.path().join("vissue");
14183        std::fs::write(&path, script).unwrap();
14184        #[cfg(unix)]
14185        {
14186            use std::os::unix::fs::PermissionsExt;
14187            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
14188        }
14189        let said = with_fake_on_path(dir.path(), || stamp_tracker("proj-1a2b", "alice")).unwrap();
14190        assert_eq!(
14191            said.as_deref(),
14192            Some("tracker: proj-1a2b STARTED under alice")
14193        );
14194        let calls = std::fs::read_to_string(&log).unwrap();
14195        assert!(
14196            calls.contains("update proj-1a2b -s STARTED"),
14197            "reopen the heading: {calls}"
14198        );
14199        assert!(
14200            calls.contains("claim proj-1a2b --force VISSUE_AGENT=alice"),
14201            "{calls}"
14202        );
14203    }
14204
14205    #[test]
14206    fn a_tracker_refusal_names_the_way_out() {
14207        let _g = env_guard();
14208        let dir = tempfile::tempdir().unwrap();
14209        let _log = fake_vissue(dir.path(), true, false);
14210        let err =
14211            with_fake_on_path(dir.path(), || stamp_tracker("proj-1a2b", "alice")).unwrap_err();
14212        let text = format!("{err:#}");
14213        assert!(text.contains("ljos release proj-1a2b"), "{text}");
14214        assert!(text.contains("refused"), "{text}");
14215    }
14216}