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", "herdr", "systemd", "init", "sshd", "login",
899];
900
901/// Whether a process is the person's session rather than a program in it:
902/// a multiplexer, a login, the init system. Many conversations share one.
903fn is_session(comm: &str) -> bool {
904    SESSION.iter().any(|s| comm.starts_with(s))
905}
906
907/// The ancestors that belong to this conversation alone: the chain up to,
908/// not including, the first session process. Above it every pane and every
909/// runner shares the same processes.
910fn own_ancestry() -> Vec<(u32, String)> {
911    ancestry()
912        .into_iter()
913        .take_while(|(_, comm)| !is_session(comm))
914        .collect()
915}
916
917/// Path components that name a place, not a program.
918const PLACES: &[&str] = &[
919    "bin",
920    "sbin",
921    "versions",
922    "current",
923    "dist",
924    "build",
925    "target",
926    "release",
927    "debug",
928    "node_modules",
929    ".bin",
930    "lib",
931    "libexec",
932    "app",
933    "resources",
934];
935
936/// Interpreters run a program named by their first argument.
937const INTERPRETERS: &[&str] = &[
938    "node", "nodejs", "bun", "deno", "python", "python3", "ruby", "perl", "java",
939];
940
941fn version_like(s: &str) -> bool {
942    let t = s.strip_prefix('v').unwrap_or(s);
943    t.chars().next().is_some_and(|c| c.is_ascii_digit())
944}
945
946/// A program's name from how it was started: the last path component of
947/// what ran that is neither a version (`2.1.266`) nor a place (`bin`,
948/// `versions`); for an interpreter, the script it was handed. Falls back
949/// to the kernel's short name.
950#[cfg(target_os = "linux")]
951fn program_name(pid: u32, comm: &str) -> String {
952    let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default();
953    let args: Vec<String> = cmdline
954        .split(|b| *b == 0)
955        .filter(|a| !a.is_empty())
956        .map(|a| String::from_utf8_lossy(a).into_owned())
957        .collect();
958    let mut candidates: Vec<&str> = Vec::new();
959    if let Some(first) = args.first() {
960        let base = Path::new(first)
961            .file_name()
962            .and_then(|f| f.to_str())
963            .unwrap_or(first);
964        if INTERPRETERS.contains(&base) {
965            if let Some(script) = args.iter().skip(1).find(|a| !a.starts_with('-')) {
966                candidates.push(script);
967            }
968        }
969        candidates.push(first);
970    }
971    for path in candidates {
972        let mut parts: Vec<&str> = Path::new(path)
973            .components()
974            .filter_map(|c| c.as_os_str().to_str())
975            .collect();
976        while let Some(last) = parts.pop() {
977            let name = last.rsplit_once('.').map_or(last, |(stem, ext)| {
978                if ["js", "mjs", "cjs", "py", "rb", "pl", "jar", "exe"].contains(&ext) {
979                    stem
980                } else {
981                    last
982                }
983            });
984            if name.is_empty() || version_like(name) || PLACES.contains(&name) || name == "/" {
985                continue;
986            }
987            if name.starts_with('.') || name.contains(std::path::MAIN_SEPARATOR) {
988                continue;
989            }
990            return name.to_string();
991        }
992    }
993    comm.to_string()
994}
995
996#[cfg(not(target_os = "linux"))]
997fn program_name(_pid: u32, comm: &str) -> String {
998    comm.to_string()
999}
1000
1001/// The seat from the process tree: the record a server left for the runner
1002/// above this shell, else the nearest ancestor that is neither a shell nor
1003/// a wrapper, named from how it was started and tagged with its pid. None
1004/// when the tree ends in the session itself, which is a person at a
1005/// terminal.
1006fn seat_from_tree() -> Option<Seat> {
1007    if let Some(seat) = seat_from_tree_records() {
1008        return Some(seat);
1009    }
1010    let chain = ancestry();
1011    for (pid, comm) in &chain {
1012        let name = comm.as_str();
1013        if WRAPPERS.contains(&name) {
1014            continue;
1015        }
1016        if is_session(name) {
1017            return None;
1018        }
1019        let program = program_name(*pid, name);
1020        return Some(Seat::tagged(
1021            seat_slug(&program),
1022            &conversation_tag(*pid),
1023            format!("the process tree, {program} {pid}"),
1024        ));
1025    }
1026    None
1027}
1028
1029/// The record a server left for the nearest runner above this shell. It
1030/// names the runner that opened the shell, which a conversation id in the
1031/// environment does not when one runner started another.
1032fn seat_from_tree_records() -> Option<Seat> {
1033    ancestry().into_iter().find_map(|(pid, _)| {
1034        read_record(
1035            &seat_record_path(pid),
1036            format!("the server the runner opened, process {pid}"),
1037        )
1038    })
1039}
1040
1041fn named_var(key: &str) -> Option<String> {
1042    std::env::var(key)
1043        .ok()
1044        .map(|v| v.trim().to_string())
1045        .filter(|v| !v.is_empty() && !omitted_actor_name(v))
1046}
1047
1048/// Who is sitting, with nothing set. The seat: `LJOS_SEAT` or the
1049/// tracker's `VISSUE_AGENT` when someone set one; else what the MCP client
1050/// said at initialize; else the process tree above this shell, which is
1051/// the runner that opened it or the server that runner opened; else the
1052/// login user, who is the seat when no program is. The holder is any
1053/// `*_SESSION_ID` the runner stamped, ahead of the process tag, so MCP
1054/// sitting and CLI sitting of one conversation are one occupancy name;
1055/// else the seat tagged with the conversation's process.
1056#[must_use]
1057pub fn whoami() -> Seat {
1058    if let Ok(seat) = CALL_SEAT.try_with(Clone::clone) {
1059        return seat;
1060    }
1061    let session = session_actor();
1062    // Both variables are a person naming the seat: the seat's own, and the
1063    // tracker's name for the same thing. Either beats what the tree says.
1064    let named = named_var("LJOS_SEAT")
1065        .map(|n| (n, "LJOS_SEAT"))
1066        .or_else(|| named_var("VISSUE_AGENT").map(|n| (n, "VISSUE_AGENT")));
1067    // The record filed under a conversation id this shell carries, unless
1068    // the nearest runner above left one for another seat: a runner started
1069    // from another runner's shell inherits the other's ids, and its own
1070    // record is the one above it.
1071    let record = seat_from_session_records().map(|by_id| {
1072        seat_from_tree_records()
1073            .filter(|above| above.seat != by_id.seat)
1074            .unwrap_or(by_id)
1075    });
1076    let program = ANNOUNCED
1077        .get()
1078        .cloned()
1079        .or_else(|| record.clone())
1080        .or_else(seat_from_tree);
1081    let agent = named_var("VISSUE_AGENT");
1082    let seat_name = named
1083        .as_ref()
1084        .map(|(n, _)| n.clone())
1085        .or_else(|| program.as_ref().map(|p| p.seat.clone()))
1086        .or_else(|| agent.clone())
1087        .unwrap_or_else(login_user);
1088    // The server's record first: it carries the holder the server took,
1089    // whatever else this shell's environment adds.
1090    if let Some(record) = record {
1091        return Seat {
1092            seat: seat_name,
1093            holder: record.holder,
1094            source: record.source,
1095        };
1096    }
1097    if let Some((holder, keys)) = session {
1098        let seat = Seat {
1099            seat: seat_name,
1100            holder,
1101            source: keys,
1102        };
1103        // The first resolution in a conversation leaves a record under
1104        // every id stamped so far; a later process carrying one of them and
1105        // more finds this holder by the shared id rather than hashing the
1106        // larger set into a new name. The tests stamp ids of their own
1107        // into one process and must not leave records for each other.
1108        #[cfg(not(test))]
1109        for (_, id) in stamped_sessions() {
1110            write_record(&session_record_path(&id), &seat);
1111        }
1112        return seat;
1113    }
1114    match (&named, &program) {
1115        (Some((name, key)), Some(p)) => Seat {
1116            seat: name.clone(),
1117            holder: p.holder.replacen(&p.seat, name, 1),
1118            source: format!("{key}, held by {}", p.source),
1119        },
1120        (Some((name, key)), None) => Seat::whole(name, key),
1121        (None, Some(p)) => p.clone(),
1122        (None, None) => {
1123            if let Some(name) = agent {
1124                Seat::whole(&name, "VISSUE_AGENT")
1125            } else {
1126                Seat::whole(&login_user(), "the login user")
1127            }
1128        }
1129    }
1130}
1131
1132/// The person at the terminal, when no program is the seat.
1133fn login_user() -> String {
1134    std::env::var("USER")
1135        .ok()
1136        .map(|u| u.trim().to_string())
1137        .filter(|u| !u.is_empty())
1138        .unwrap_or_else(|| "seat".to_string())
1139}
1140
1141/// The name this seat remembers, votes and earns trust under.
1142#[must_use]
1143pub fn seat_name() -> String {
1144    whoami().seat
1145}
1146
1147/// The name this conversation's claims are held under.
1148#[must_use]
1149pub fn holder_name() -> String {
1150    whoami().holder
1151}
1152
1153/// Resolve an `--assignee` / MCP field for a claim. Empty, a pronoun
1154/// (`seat`, `you`, `agent`), or this process naming itself is omitted:
1155/// occupancy is the conversation's holder, not the product name on the
1156/// box. A named worker is taken as given.
1157#[must_use]
1158pub fn resolve_assignee(passed: Option<&str>) -> String {
1159    match passed.map(str::trim).filter(|s| !s.is_empty()) {
1160        Some(n) if !omitted_actor_name(n) && !own_seat(n) => n.to_string(),
1161        _ => holder_name(),
1162    }
1163}
1164
1165/// Occupancy is always `{name}:{issue}`. One live claim per name is what
1166/// made two conversations unseat each other; the issue is already
1167/// exclusive. Already-scoped names (they contain `:`) are left alone.
1168#[must_use]
1169pub fn occupancy_assignee(passed: Option<&str>, issue: &str) -> String {
1170    occupancy_scope(&resolve_assignee(passed), issue)
1171}
1172
1173fn occupancy_scope(assignee: &str, issue: &str) -> String {
1174    let issue = issue.trim();
1175    if issue.is_empty() || assignee.contains(':') {
1176        assignee.to_string()
1177    } else {
1178        format!("{assignee}:{issue}")
1179    }
1180}
1181
1182/// The doctor's `seat` row: who votes, who holds, and where the names came
1183/// from.
1184#[must_use]
1185pub fn format_seat_row() -> String {
1186    let who = whoami();
1187    format!(
1188        "{}, holding as {} (from {})",
1189        who.seat, who.holder, who.source
1190    )
1191}
1192
1193/// `ljos seat`: who is sitting, one field a line.
1194#[must_use]
1195pub fn format_seat(seat: &Seat) -> String {
1196    format!(
1197        "seat\t{}\nholder\t{}\nsource\t{}\n",
1198        seat.seat, seat.holder, seat.source
1199    )
1200}
1201
1202/// Whether a runner with a `registered` command already has the server.
1203fn is_registered(h: &Harness, server: &Path) -> Option<bool> {
1204    if !h.registered.is_empty() {
1205        let argv = filled(&h.registered, server, &h.name);
1206        return Some(
1207            argv.first().is_some_and(|bin| on_path(bin)) && {
1208                let (bin, rest) = (&argv[0], &argv[1..]);
1209                run_captured(bin, rest).is_ok()
1210            },
1211        );
1212    }
1213    if let (Some(config), Some(marker)) = (&h.config, &h.marker) {
1214        return Some(std::fs::read_to_string(expand(config)).is_ok_and(|t| t.contains(marker)));
1215    }
1216    if let (Some(config), Some(pointer)) = (&h.config_json, &h.json_pointer) {
1217        return Some(
1218            std::fs::read_to_string(expand(config))
1219                .ok()
1220                .and_then(|t| serde_json::from_str::<Value>(&t).ok())
1221                .is_some_and(|doc| doc.pointer(pointer).is_some()),
1222        );
1223    }
1224    None
1225}
1226
1227/// Set `pointer` in the JSON document at `config` to `entry`, making the
1228/// objects on the way; a missing file starts as `{}`.
1229fn set_json_entry(config: &Path, pointer: &str, entry: &Value) -> Result<()> {
1230    let mut doc: Value = match std::fs::read_to_string(config) {
1231        Ok(t) if !t.trim().is_empty() => {
1232            serde_json::from_str(&t).with_context(|| format!("{}: not JSON", config.display()))?
1233        }
1234        _ => serde_json::json!({}),
1235    };
1236    let mut at = &mut doc;
1237    let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
1238    let (last, path) = parts
1239        .split_last()
1240        .context("onboard: an empty JSON pointer")?;
1241    for key in path {
1242        at = at
1243            .as_object_mut()
1244            .context("onboard: the pointer crosses a value that is not an object")?
1245            .entry((*key).to_string())
1246            .or_insert_with(|| serde_json::json!({}));
1247    }
1248    at.as_object_mut()
1249        .context("onboard: the pointer's parent is not an object")?
1250        .insert((*last).to_string(), entry.clone());
1251    if let Some(parent) = config.parent() {
1252        std::fs::create_dir_all(parent)?;
1253    }
1254    let mut text = serde_json::to_string_pretty(&doc)?;
1255    text.push('\n');
1256    std::fs::write(config, text)?;
1257    Ok(())
1258}
1259
1260/// Grok watches `[mcp_servers.ljos.env]`. Changing `LJOS_MCP_GENERATION`
1261/// respawns the server; a session restart is not required.
1262fn bump_ljos_mcp_generation(config: &Path, version: &str, dry: bool) -> Result<Option<String>> {
1263    let text = match std::fs::read_to_string(config) {
1264        Ok(t) => t,
1265        Err(_) => return Ok(None),
1266    };
1267    let mut changed = false;
1268    let mut out = String::new();
1269    for line in text.lines() {
1270        let trimmed = line.trim_start();
1271        if let Some(rhs) = trimmed.strip_prefix("LJOS_MCP_GENERATION") {
1272            let rhs = rhs.trim_start().strip_prefix('=').unwrap_or("").trim();
1273            let val = rhs.trim_matches(|c| c == '"' || c == '\'');
1274            if val == version {
1275                out.push_str(line);
1276            } else {
1277                let indent_len = line.len() - trimmed.len();
1278                out.push_str(&line[..indent_len]);
1279                out.push_str("LJOS_MCP_GENERATION = \"");
1280                out.push_str(version);
1281                out.push('"');
1282                changed = true;
1283            }
1284        } else {
1285            out.push_str(line);
1286        }
1287        out.push('\n');
1288    }
1289    if !changed {
1290        return Ok(None);
1291    }
1292    if dry {
1293        return Ok(Some(version.to_string()));
1294    }
1295    std::fs::write(config, out).with_context(|| config.display().to_string())?;
1296    Ok(Some(version.to_string()))
1297}
1298
1299fn register_step(h: &Harness, server: &Path, dry: bool) -> Step {
1300    let what = format!("{} mcp", h.name);
1301    match is_registered(h, server) {
1302        Some(true) => {
1303            let config = expand(h.config.as_deref().unwrap_or_default());
1304            match bump_ljos_mcp_generation(&config, env!("CARGO_PKG_VERSION"), dry) {
1305                Ok(Some(v)) => Step {
1306                    what,
1307                    detail: format!("ljos registered; MCP generation {v}"),
1308                    ok: true,
1309                },
1310                Ok(None) => Step {
1311                    what,
1312                    detail: "ljos registered".into(),
1313                    ok: true,
1314                },
1315                Err(e) => Step {
1316                    what,
1317                    detail: format!("ljos registered; generation {e}"),
1318                    ok: false,
1319                },
1320            }
1321        }
1322        None => Step {
1323            what,
1324            detail: "no register or config in harnesses.toml; paste `ljos onboard --harness json`"
1325                .into(),
1326            ok: false,
1327        },
1328        Some(false) if !h.register.is_empty() => {
1329            let argv = filled(&h.register, server, &h.name);
1330            if !on_path(&argv[0]) {
1331                return Step {
1332                    what,
1333                    detail: format!("{} not on PATH", argv[0]),
1334                    ok: false,
1335                };
1336            }
1337            if dry {
1338                return Step {
1339                    what,
1340                    detail: format!("would run {}", argv.join(" ")),
1341                    ok: true,
1342                };
1343            }
1344            match run_captured(&argv[0], &argv[1..]) {
1345                Ok(_) => Step {
1346                    what,
1347                    detail: format!("ran {}", argv.join(" ")),
1348                    ok: true,
1349                },
1350                Err(e) => Step {
1351                    what,
1352                    detail: e.to_string().lines().next().unwrap_or("").to_string(),
1353                    ok: false,
1354                },
1355            }
1356        }
1357        Some(false) if h.config_json.is_some() => {
1358            let config = expand(h.config_json.as_deref().unwrap_or_default());
1359            let pointer = h.json_pointer.clone().unwrap_or_default();
1360            let entry_text = h
1361                .json_entry
1362                .as_deref()
1363                .unwrap_or_default()
1364                .replace("{server}", &server.display().to_string())
1365                .replace("{name}", &h.name);
1366            let entry: Value = match serde_json::from_str(&entry_text) {
1367                Ok(v) => v,
1368                Err(e) => {
1369                    return Step {
1370                        what,
1371                        detail: format!("json_entry is not JSON: {e}"),
1372                        ok: false,
1373                    }
1374                }
1375            };
1376            if dry {
1377                return Step {
1378                    what,
1379                    detail: format!("would set {pointer} in {}", config.display()),
1380                    ok: true,
1381                };
1382            }
1383            match set_json_entry(&config, &pointer, &entry) {
1384                Ok(()) => Step {
1385                    what,
1386                    detail: format!("set {pointer} in {}", config.display()),
1387                    ok: true,
1388                },
1389                Err(e) => Step {
1390                    what,
1391                    detail: format!("{}: {e}", config.display()),
1392                    ok: false,
1393                },
1394            }
1395        }
1396        Some(false) => {
1397            let config = expand(h.config.as_deref().unwrap_or_default());
1398            let snippet = h
1399                .snippet
1400                .as_deref()
1401                .unwrap_or_default()
1402                .replace("{server}", &server.display().to_string())
1403                .replace("{name}", &h.name);
1404            if snippet.is_empty() {
1405                return Step {
1406                    what,
1407                    detail: format!("no snippet to append to {}", config.display()),
1408                    ok: false,
1409                };
1410            }
1411            if dry {
1412                return Step {
1413                    what,
1414                    detail: format!("would append the entry to {}", config.display()),
1415                    ok: true,
1416                };
1417            }
1418            let mut text = std::fs::read_to_string(&config).unwrap_or_default();
1419            if !text.is_empty() && !text.ends_with('\n') {
1420                text.push('\n');
1421            }
1422            text.push_str(&snippet);
1423            let written = config
1424                .parent()
1425                .map_or(Ok(()), std::fs::create_dir_all)
1426                .and_then(|()| std::fs::write(&config, text));
1427            match written {
1428                Ok(()) => Step {
1429                    what,
1430                    detail: format!("appended the entry to {}", config.display()),
1431                    ok: true,
1432                },
1433                Err(e) => Step {
1434                    what,
1435                    detail: format!("{}: {e}", config.display()),
1436                    ok: false,
1437                },
1438            }
1439        }
1440    }
1441}
1442
1443/// Register the server and install the skill for one runner named in the
1444/// runners file. `json` registers nothing and returns the entry to paste.
1445/// `dry` reports without writing.
1446///
1447/// # Errors
1448///
1449/// No such runner in the file, no home directory, or `ljos-mcp` not on `PATH`.
1450pub fn onboard(harness: &str, dry: bool) -> Result<Vec<Step>> {
1451    onboard_from(&harnesses_path(), harness, dry)
1452}
1453
1454/// Frozen Grok hook file. Copied to `~/.grok/hooks/ljos.json`.
1455const GROK_HOOKS_JSON: &str = include_str!("../assets/grok/ljos.json");
1456
1457/// The `ljos` a runner's hook runs: the one beside `ljos-mcp`, by absolute
1458/// path, since a runner started outside a login shell has no `~/.local/bin`
1459/// on its PATH.
1460fn ljos_path() -> Result<PathBuf> {
1461    let beside = server_path()?.with_file_name("ljos");
1462    if beside.is_file() {
1463        return Ok(beside);
1464    }
1465    which::which("ljos").context("ljos not on PATH")
1466}
1467
1468/// The grok hooks file with `{ljos}` filled in.
1469fn grok_hooks_json(ljos: &Path) -> String {
1470    GROK_HOOKS_JSON.replace("{ljos}", &ljos.display().to_string())
1471}
1472
1473fn write_grok_hooks(dry: bool) -> Result<Step> {
1474    let dest = home()?.join(".grok/hooks/ljos.json");
1475    if dry {
1476        return Ok(Step {
1477            what: "hook".into(),
1478            detail: format!("would write {}", dest.display()),
1479            ok: true,
1480        });
1481    }
1482    if let Some(dir) = dest.parent() {
1483        std::fs::create_dir_all(dir)?;
1484    }
1485    std::fs::write(&dest, grok_hooks_json(&ljos_path()?))?;
1486    Ok(Step {
1487        what: "hook".into(),
1488        detail: format!("wrote {}", dest.display()),
1489        ok: true,
1490    })
1491}
1492
1493pub fn onboard_from(file: &Path, harness: &str, dry: bool) -> Result<Vec<Step>> {
1494    if harness == "json" {
1495        return Ok(vec![Step {
1496            what: "json".into(),
1497            detail: serde_json::to_string_pretty(&server_entry()?)?,
1498            ok: true,
1499        }]);
1500    }
1501    if harness == "grok" {
1502        let mut steps = vec![write_grok_hooks(dry)?];
1503        if let Ok(all) = harnesses_from(file) {
1504            if let Some(h) = all.harness.iter().find(|h| h.name == "grok") {
1505                let server = server_path()?;
1506                steps.push(register_step(h, &server, dry));
1507                if let Some(dir) = &h.skills {
1508                    steps.push(write_skill(&expand(dir), dry));
1509                }
1510            }
1511        }
1512        return Ok(steps);
1513    }
1514    let all = harnesses_from(file)?;
1515    let Some(h) = all.harness.iter().find(|h| h.name == harness) else {
1516        let names: Vec<&str> = all.harness.iter().map(|h| h.name.as_str()).collect();
1517        bail!(
1518            "onboard: no runner {harness:?} in {}; it names {}. `ljos onboard --example` \
1519             prints the file's shape, and `--harness json` prints the entry to paste anywhere.",
1520            file.display(),
1521            if names.is_empty() {
1522                "none".to_string()
1523            } else {
1524                names.join(", ")
1525            }
1526        );
1527    };
1528    let server = server_path()?;
1529    let dependencies = [pack_step(dry), host_key_step(dry)];
1530    let mut steps = vec![register_step(h, &server, dry)];
1531    if let Some(file) = &h.hooks {
1532        steps.push(hook_step(&expand(file), &hook_events_of(h), dry));
1533    }
1534    if let Some(dest) = &h.plugin {
1535        steps.push(plugin_step(h, &expand(dest), dry));
1536    }
1537    match &h.skills {
1538        Some(dir) => steps.push(write_skill(&expand(dir), dry)),
1539        None => steps.push(Step {
1540            what: "skill".into(),
1541            detail: "no skills directory in harnesses.toml; `ljos protocol` prints the text".into(),
1542            ok: false,
1543        }),
1544    }
1545    steps.extend(dependencies);
1546    Ok(steps)
1547}
1548
1549/// The events the memory hook fires on when a runner's table names none:
1550/// the prompt, which carries the task in the person's words. A tool call
1551/// carries the command about to run and is a cue too; a runner asks for it
1552/// with `hook_events`. The default came out of a panel of this seat's
1553/// personas: a turn issues many shell commands and one prompt.
1554pub const HOOK_EVENTS: &[&str] = &["UserPromptSubmit", "SessionEnd"];
1555
1556/// The events the hook knows a matcher for; any other event takes `*`.
1557pub const HOOK_MATCHERS: &[(&str, &str)] = &[
1558    ("PreToolUse", "Bash"),
1559    ("PostToolUse", "*"),
1560    ("UserPromptSubmit", "*"),
1561    ("Stop", "*"),
1562    ("SessionEnd", "*"),
1563    ("SubagentStop", "*"),
1564];
1565
1566/// One runner sends snake_case `hookEventName`; another sends
1567/// PascalCase `hook_event_name`. One name in the seat.
1568fn normalize_hook_event(raw: &str) -> &str {
1569    match raw {
1570        "pre_llm_call" => "UserPromptSubmit",
1571        "pre_tool_call" => "PreToolUse",
1572        "post_tool_call" => "PostToolUse",
1573        // One runner fires on_session_end after every turn; its session
1574        // ends on finalize or reset.
1575        "on_session_finalize" | "on_session_reset" => "SessionEnd",
1576        "on_session_end" => "TurnEnd",
1577        "pre_tool_use" | "PreToolUse" => "PreToolUse",
1578        "post_tool_use" | "PostToolUse" => "PostToolUse",
1579        "user_prompt_submit" | "UserPromptSubmit" => "UserPromptSubmit",
1580        "session_end" | "SessionEnd" => "SessionEnd",
1581        "session_start" | "SessionStart" => "SessionStart",
1582        "subagent_stop" | "SubagentStop" | "SubagentEnd" | "subagentStop" => "SubagentStop",
1583        "stop" | "Stop" => "Stop",
1584        other => other,
1585    }
1586}
1587
1588fn hook_matcher(event: &str) -> &'static str {
1589    HOOK_MATCHERS
1590        .iter()
1591        .find(|(e, _)| *e == event)
1592        .map_or("*", |(_, m)| m)
1593}
1594
1595/// The events a runner's table asks for, or the default.
1596fn hook_events_of(h: &Harness) -> Vec<String> {
1597    if h.name == "grok" {
1598        return [
1599            "UserPromptSubmit",
1600            "PostToolUse",
1601            "PreToolUse",
1602            "Stop",
1603            "SessionEnd",
1604            "SubagentStop",
1605        ]
1606        .into_iter()
1607        .map(str::to_string)
1608        .collect();
1609    }
1610    if h.hook_events.is_empty() {
1611        HOOK_EVENTS.iter().map(|e| (*e).to_string()).collect()
1612    } else {
1613        h.hook_events.clone()
1614    }
1615}
1616
1617fn is_seat_hook(h: &Value) -> bool {
1618    h["command"]
1619        .as_str()
1620        .is_some_and(|c| c.contains("ljos") && c.ends_with(" hook"))
1621}
1622
1623/// The command the runner's hook runs.
1624fn hook_command() -> String {
1625    which::which("ljos").map_or_else(
1626        |_| "ljos hook".to_string(),
1627        |p| format!("{} hook", p.display()),
1628    )
1629}
1630
1631/// Merge the seat's memory hook into a runner's hooks file, once per event.
1632/// The file is JSON with a `hooks` object of event name to matcher groups;
1633/// a group whose command is the seat's is left alone, so the step is
1634/// idempotent.
1635fn hook_step(file: &Path, events: &[String], dry: bool) -> Step {
1636    let what = "hook".to_string();
1637    let mut root: Value = match std::fs::read_to_string(file) {
1638        Ok(text) if !text.trim().is_empty() => match serde_json::from_str(&text) {
1639            Ok(v) => v,
1640            Err(e) => {
1641                return Step {
1642                    what,
1643                    detail: format!("{}: not JSON: {e}", file.display()),
1644                    ok: false,
1645                }
1646            }
1647        },
1648        _ => serde_json::json!({}),
1649    };
1650    let command = hook_command();
1651    let Some(obj) = root.as_object_mut() else {
1652        return Step {
1653            what,
1654            detail: format!("{}: not a JSON object", file.display()),
1655            ok: false,
1656        };
1657    };
1658    let hooks = obj.entry("hooks").or_insert_with(|| serde_json::json!({}));
1659    let Some(hooks) = hooks.as_object_mut() else {
1660        return Step {
1661            what,
1662            detail: format!("{}: hooks is not an object", file.display()),
1663            ok: false,
1664        };
1665    };
1666    // Reconcile: the seat's hook is on the events asked for and on no
1667    // other, and every group that is not the seat's is left alone.
1668    let mut added = Vec::new();
1669    let mut removed = Vec::new();
1670    for event in events {
1671        let groups = hooks
1672            .entry(event.clone())
1673            .or_insert_with(|| serde_json::json!([]));
1674        let Some(groups) = groups.as_array_mut() else {
1675            continue;
1676        };
1677        let present = groups.iter().any(|g| {
1678            g["hooks"]
1679                .as_array()
1680                .into_iter()
1681                .flatten()
1682                .any(is_seat_hook)
1683        });
1684        if present {
1685            continue;
1686        }
1687        groups.push(serde_json::json!({
1688            "matcher": hook_matcher(event),
1689            "hooks": [{"type": "command", "command": command, "timeout": 20}]
1690        }));
1691        added.push(event.clone());
1692    }
1693    for (event, groups) in hooks.iter_mut() {
1694        if events.contains(event) {
1695            continue;
1696        }
1697        let Some(groups) = groups.as_array_mut() else {
1698            continue;
1699        };
1700        let before = groups.len();
1701        groups.retain(|g| {
1702            !g["hooks"]
1703                .as_array()
1704                .into_iter()
1705                .flatten()
1706                .any(is_seat_hook)
1707        });
1708        if groups.len() != before {
1709            removed.push(event.clone());
1710        }
1711    }
1712    if added.is_empty() && removed.is_empty() {
1713        return Step {
1714            what,
1715            detail: format!(
1716                "{} carries the memory hook on {}",
1717                file.display(),
1718                events.join(", ")
1719            ),
1720            ok: true,
1721        };
1722    }
1723    let mut change = Vec::new();
1724    if !added.is_empty() {
1725        change.push(format!("add it on {}", added.join(", ")));
1726    }
1727    if !removed.is_empty() {
1728        change.push(format!("drop it from {}", removed.join(", ")));
1729    }
1730    let change = change.join(" and ");
1731    if dry {
1732        return Step {
1733            what,
1734            detail: format!("would {change} in {}", file.display()),
1735            ok: true,
1736        };
1737    }
1738    let written = file
1739        .parent()
1740        .map_or(Ok(()), std::fs::create_dir_all)
1741        .and_then(|()| serde_json::to_string_pretty(&root).map_err(std::io::Error::other))
1742        .and_then(|text| std::fs::write(file, text + "\n"));
1743    match written {
1744        Ok(()) => Step {
1745            what,
1746            detail: format!("memory hook: {change} in {}", file.display()),
1747            ok: true,
1748        },
1749        Err(e) => Step {
1750            what,
1751            detail: format!("{}: {e}", file.display()),
1752            ok: false,
1753        },
1754    }
1755}
1756
1757/// Whether a runner's hooks file carries the memory hook on every event.
1758fn hook_installed(file: &Path, events: &[String]) -> bool {
1759    let Ok(text) = std::fs::read_to_string(file) else {
1760        return false;
1761    };
1762    let Ok(root) = serde_json::from_str::<Value>(&text) else {
1763        return false;
1764    };
1765    events.iter().all(|event| {
1766        root["hooks"][event.as_str()]
1767            .as_array()
1768            .into_iter()
1769            .flatten()
1770            .any(|g| {
1771                g["hooks"]
1772                    .as_array()
1773                    .into_iter()
1774                    .flatten()
1775                    .any(is_seat_hook)
1776            })
1777    })
1778}
1779
1780/// What the runner's hook hands the seat: the event, and the text worth
1781/// asking the pack about. From a tool call, the command about to run; from
1782/// a prompt, the prompt.
1783#[derive(Debug, Clone, PartialEq, Eq)]
1784pub struct HookCall {
1785    pub event: String,
1786    pub cue: String,
1787    /// The runner's session, when it says: each memory is injected once
1788    /// per session, so the same lesson does not arrive on every command.
1789    pub session: Option<String>,
1790    /// The hook contract the call arrived in; it decides how a
1791    /// verdict is written back.
1792    pub shape: HookShape,
1793}
1794
1795/// The hook contract a call arrived in, told apart by its stdin. The
1796/// runners share one name for the answer, `permissionDecision`, but not
1797/// what they do with it.
1798#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1799pub enum HookShape {
1800    /// snake_case stdin; `permissionDecision` takes `deny` or `ask`.
1801    #[default]
1802    Asks,
1803    /// snake_case stdin carrying `turn_id`; `deny` only, and an `ask` is
1804    /// rejected as unsupported and the tool runs.
1805    DenyOnly,
1806    /// camelCase stdin (`hookEventName`, `toolInput`); a top-level
1807    /// `decision` blocks, and there is no `ask`.
1808    CamelCase,
1809    /// lower-case event names (`pre_llm_call`, `pre_tool_call`) with the
1810    /// prompt under `extra.user_message`; a top-level `context` is
1811    /// injected, `decision: block` blocks, and there is no `ask`.
1812    Context,
1813}
1814
1815impl HookShape {
1816    /// Whether the runner can stop and ask the person on a verdict.
1817    #[must_use]
1818    pub fn asks(self) -> bool {
1819        self == Self::Asks
1820    }
1821}
1822
1823/// Read a hook call from the runner's JSON, or from plain text (an argv
1824/// under argv law). Fields: `hook_event_name`, `tool_name`, `tool_input`
1825/// (its `command`, else every string value joined), `prompt`; grok's
1826/// camelCase `hookEventName`, `sessionId` and `toolInput` read the same.
1827#[must_use]
1828pub fn hook_call(input: &str) -> HookCall {
1829    let trimmed = input.trim();
1830    let Ok(v) = serde_json::from_str::<Value>(trimmed) else {
1831        return HookCall {
1832            event: "argv".into(),
1833            cue: trimmed.to_string(),
1834            session: None,
1835            shape: HookShape::Asks,
1836        };
1837    };
1838    let raw_event = v["hook_event_name"].as_str().unwrap_or("");
1839    let shape = if v.get("hookEventName").is_some() || v.get("toolInput").is_some() {
1840        HookShape::CamelCase
1841    } else if raw_event.starts_with("pre_")
1842        || raw_event.starts_with("post_")
1843        || raw_event.starts_with("on_")
1844    {
1845        HookShape::Context
1846    } else if v.get("turn_id").is_some() {
1847        HookShape::DenyOnly
1848    } else {
1849        HookShape::Asks
1850    };
1851    let input = if v["tool_input"].is_null() {
1852        &v["toolInput"]
1853    } else {
1854        &v["tool_input"]
1855    };
1856    let session = v["session_id"]
1857        .as_str()
1858        .or_else(|| v["sessionId"].as_str())
1859        .filter(|s| !s.is_empty())
1860        .map(str::to_string);
1861    let raw = v["hook_event_name"]
1862        .as_str()
1863        .or_else(|| v["hookEventName"].as_str())
1864        .unwrap_or("PreToolUse");
1865    let event = normalize_hook_event(raw).to_string();
1866    let cue = if let Some(p) = v["prompt"].as_str() {
1867        p.to_string()
1868    } else if let Some(p) = v["extra"]["user_message"].as_str() {
1869        p.to_string()
1870    } else if let Some(c) = input["command"].as_str() {
1871        c.to_string()
1872    } else if let Some(map) = input.as_object() {
1873        map.values()
1874            .filter_map(Value::as_str)
1875            .collect::<Vec<_>>()
1876            .join(" ")
1877    } else {
1878        String::new()
1879    };
1880    HookCall {
1881        event,
1882        cue,
1883        session,
1884        shape,
1885    }
1886}
1887
1888/// Where the ids already injected in a session are kept: the runtime
1889/// directory, so they go with the login and never into the pack.
1890fn seen_path(session: &str) -> Option<PathBuf> {
1891    let safe: String = session
1892        .chars()
1893        .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
1894        .collect();
1895    if safe.is_empty() {
1896        return None;
1897    }
1898    let dir = std::env::var_os("XDG_RUNTIME_DIR")
1899        .filter(|r| !r.is_empty())
1900        .map(PathBuf::from)
1901        .unwrap_or_else(std::env::temp_dir)
1902        .join("ljos");
1903    Some(dir.join(format!("hook-seen-{safe}")))
1904}
1905
1906pub fn seen_ids(session: Option<&str>) -> std::collections::BTreeSet<String> {
1907    session
1908        .and_then(seen_path)
1909        .and_then(|p| std::fs::read_to_string(p).ok())
1910        .map(|t| t.lines().map(str::to_string).collect())
1911        .unwrap_or_default()
1912}
1913
1914/// The memories injected during a session, in the order they arrived, and
1915/// the file they were kept in. The nudge marker is not a memory.
1916fn injected_ids(session: &str) -> (Vec<String>, Option<PathBuf>) {
1917    let path = seen_path(session);
1918    let ids: Vec<String> = path
1919        .as_ref()
1920        .and_then(|p| std::fs::read_to_string(p).ok())
1921        .map(|t| {
1922            t.lines()
1923                .map(str::trim)
1924                .filter(|l| !l.is_empty() && *l != "due-nudge")
1925                .map(str::to_string)
1926                .collect()
1927        })
1928        .unwrap_or_default();
1929    (ids, path)
1930}
1931
1932/// When a session ends, the memories injected during it fire together:
1933/// they served one sitting, so their links gain weight and the next
1934/// sitting like it walks a heavier path (Hebb, through the pack's `fire`).
1935/// The seen file goes with the session. Returns how many fired; nothing to
1936/// fire, or no pack, is zero and not an error, since a hook must not stop
1937/// a runner from ending.
1938pub fn session_end(session: Option<&str>) -> usize {
1939    let Some(session) = session else {
1940        return 0;
1941    };
1942    let (ids, path) = injected_ids(session);
1943    let fired = if ids.len() >= 2 {
1944        let top: Vec<String> = ids.into_iter().take(8).collect();
1945        pack()
1946            .ok()
1947            .and_then(|c| c.fire(&c.workspace(), &top).ok())
1948            .map_or(0, |_| top.len())
1949    } else {
1950        0
1951    };
1952    if let Some(p) = path {
1953        let _ = std::fs::remove_file(p);
1954    }
1955    fired
1956}
1957
1958/// Where a prompt's pack note waits. One runner discards prompt-hook
1959/// stdout and reads `Stop` feedback, so the note stays here until then.
1960fn hook_hold_path(session: Option<&str>) -> Option<PathBuf> {
1961    let dir = std::env::var_os("XDG_RUNTIME_DIR")
1962        .map(PathBuf::from)
1963        .or_else(|| std::env::var_os("TMPDIR").map(PathBuf::from))
1964        .unwrap_or_else(|| PathBuf::from("/tmp"));
1965    let name = session
1966        .filter(|s| !s.is_empty())
1967        .map(|s| {
1968            s.chars()
1969                .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
1970                .take(32)
1971                .collect::<String>()
1972        })
1973        .filter(|s| !s.is_empty())
1974        .unwrap_or_else(|| "default".into());
1975    Some(dir.join(format!("ljos-hook-hold-{name}")))
1976}
1977
1978fn hook_hold_ids_path(session: Option<&str>) -> Option<PathBuf> {
1979    hook_hold_path(session).map(|p| {
1980        let mut os = p.into_os_string();
1981        os.push(".ids");
1982        PathBuf::from(os)
1983    })
1984}
1985
1986/// Remember the prompt's pack text and the memory ids it names.
1987/// An empty note leaves a note already held: a later prompt that matches
1988/// nothing must not erase one the runner has not delivered yet.
1989pub fn hold_hook_context(session: Option<&str>, context: &str) {
1990    hold_hook_note(session, context, &[]);
1991}
1992
1993/// Hold `context` with the ids to mark seen when a runner delivers it.
1994pub fn hold_hook_note(session: Option<&str>, context: &str, ids: &[String]) {
1995    let Some(path) = hook_hold_path(session) else {
1996        return;
1997    };
1998    if context.is_empty() {
1999        return;
2000    }
2001    let _ = std::fs::write(&path, context);
2002    if let Some(ids_path) = hook_hold_ids_path(session) {
2003        let _ = std::fs::write(ids_path, ids.join("\n"));
2004    }
2005}
2006
2007/// The held pack text, left in place.
2008#[must_use]
2009pub fn peek_hook_context(session: Option<&str>) -> String {
2010    hook_hold_path(session)
2011        .and_then(|p| std::fs::read_to_string(p).ok())
2012        .unwrap_or_default()
2013}
2014
2015/// Take the held pack text once. Empty if nothing was held.
2016#[must_use]
2017pub fn take_hook_context(session: Option<&str>) -> String {
2018    take_hook_note(session).0
2019}
2020
2021/// Take the held note and its ids, and remove both files.
2022#[must_use]
2023pub fn take_hook_note(session: Option<&str>) -> (String, Vec<String>) {
2024    let Some(path) = hook_hold_path(session) else {
2025        return (String::new(), Vec::new());
2026    };
2027    let text = std::fs::read_to_string(&path).unwrap_or_default();
2028    let _ = std::fs::remove_file(&path);
2029    let ids = hook_hold_ids_path(session)
2030        .and_then(|p| std::fs::read_to_string(p).ok())
2031        .map(|t| {
2032            let _ = hook_hold_ids_path(session).map(std::fs::remove_file);
2033            t.lines()
2034                .map(str::trim)
2035                .filter(|l| !l.is_empty())
2036                .map(str::to_string)
2037                .collect()
2038        })
2039        .unwrap_or_default();
2040    (text, ids)
2041}
2042
2043/// Stdout for a prompt hook. A camel-case runner discards that stdout, so
2044/// the note is held and the stdout is empty. Any other runner is handed
2045/// the note directly.
2046#[must_use]
2047pub fn prompt_hook_stdout(
2048    shape: HookShape,
2049    session: Option<&str>,
2050    text: &str,
2051    ids: &[String],
2052) -> String {
2053    if shape == HookShape::CamelCase {
2054        hold_hook_note(session, text, ids);
2055        String::new()
2056    } else {
2057        hold_hook_context(session, text);
2058        text.to_string()
2059    }
2060}
2061
2062/// Stdout for a tool-result hook, and the ids to mark now that the note
2063/// was delivered. A camel-case runner takes the note on the first tool
2064/// result. `Stop` additionalContext would start another round, so the
2065/// hold is cleared here and `Stop` finds nothing. Any other runner takes
2066/// it the same way. A turn with no tool leaves the hold for `Stop`.
2067#[must_use]
2068pub fn post_hook_stdout(shape: HookShape, session: Option<&str>) -> (String, Vec<String>) {
2069    if shape == HookShape::CamelCase {
2070        let key = "hold-echoed".to_string();
2071        if seen_ids(session).contains(&key) {
2072            return (String::new(), Vec::new());
2073        }
2074        let (text, ids) = take_hook_note(session);
2075        if !text.is_empty() {
2076            mark_seen(session, &[key]);
2077        }
2078        (text, ids)
2079    } else {
2080        (take_hook_context(session), Vec::new())
2081    }
2082}
2083
2084/// Stdout for `Stop`, and the ids to mark now that the note is delivered.
2085/// A continuation (`stop_active`) says nothing: the first `Stop` already
2086/// delivered the note.
2087#[must_use]
2088pub fn stop_hook_stdout(session: Option<&str>, stop_active: bool) -> (String, Vec<String>) {
2089    if stop_active {
2090        return (String::new(), Vec::new());
2091    }
2092    take_hook_note(session)
2093}
2094
2095pub fn mark_seen(session: Option<&str>, ids: &[String]) {
2096    let Some(path) = session.and_then(seen_path) else {
2097        return;
2098    };
2099    if let Some(dir) = path.parent() {
2100        let _ = std::fs::create_dir_all(dir);
2101    }
2102    let mut text = std::fs::read_to_string(&path).unwrap_or_default();
2103    for id in ids {
2104        text.push_str(id);
2105        text.push('\n');
2106    }
2107    let _ = std::fs::write(path, text);
2108}
2109
2110/// The floor a hit must reach, as a share of the strongest hit's score, to
2111/// be injected. A command line matches many claims weakly; only the ones
2112/// that match it as well as the best does are worth the agent's context.
2113/// The floor is not relevance: a vague sentence scores high on unrelated
2114/// lessons, so a hit must also name a content word of the cue.
2115pub const HOOK_SCORE_FLOOR: f64 = 0.6;
2116
2117/// Words that sit in almost every sentence and almost every lesson.
2118/// A cue word on this list does not make a lesson about the prompt.
2119const CUE_STOP: &[&str] = &[
2120    "about",
2121    "after",
2122    "also",
2123    "anything",
2124    "because",
2125    "been",
2126    "before",
2127    "being",
2128    "both",
2129    "could",
2130    "does",
2131    "doing",
2132    "each",
2133    "everything",
2134    "from",
2135    "have",
2136    "having",
2137    "into",
2138    "just",
2139    "like",
2140    "making",
2141    "more",
2142    "most",
2143    "need",
2144    "nothing",
2145    "only",
2146    "other",
2147    "over",
2148    "please",
2149    "really",
2150    "same",
2151    "should",
2152    "some",
2153    "something",
2154    "still",
2155    "such",
2156    "than",
2157    "that",
2158    "their",
2159    "them",
2160    "then",
2161    "there",
2162    "these",
2163    "they",
2164    "this",
2165    "those",
2166    "through",
2167    "using",
2168    "very",
2169    "want",
2170    "were",
2171    "what",
2172    "when",
2173    "where",
2174    "which",
2175    "while",
2176    "will",
2177    "with",
2178    "would",
2179    "your",
2180];
2181
2182/// Content words of a cue: four letters or more, not [CUE_STOP].
2183/// Shorter tokens are how a sentence matches every lesson.
2184fn cue_content_words(text: &str) -> Vec<String> {
2185    let mut words: Vec<String> = text
2186        .split(|c: char| !c.is_alphanumeric())
2187        .filter(|w| w.len() >= 4)
2188        .map(str::to_lowercase)
2189        .filter(|w| !CUE_STOP.contains(&w.as_str()))
2190        .collect();
2191    words.sort_unstable();
2192    words.dedup();
2193    words
2194}
2195
2196/// Whether a lesson names something the cue names.
2197/// A high search score on a vague sentence is not that.
2198fn names_the_cue(text: &str, cue: &str) -> bool {
2199    let want = cue_content_words(cue);
2200    if want.is_empty() {
2201        return false;
2202    }
2203    let have = cue_content_words(text);
2204    want.iter().any(|w| have.binary_search(w).is_ok())
2205}
2206
2207#[cfg(test)]
2208/// A claim about one numbered pull request is a snapshot of that review.
2209/// "A PR branch must contain main" is a rule and stays. "PR 32 replays PR 36" does not.
2210fn names_a_numbered_pr(text: &str) -> bool {
2211    let t = text.to_lowercase();
2212    let b = t.as_bytes();
2213    let mut i = 0;
2214    while i < b.len() {
2215        if (i == 0 || !b[i - 1].is_ascii_alphanumeric())
2216            && (pr_number_at(&t[i..]) || hash_number_at(&t[i..]))
2217        {
2218            return true;
2219        }
2220        i += 1;
2221    }
2222    false
2223}
2224
2225#[cfg(test)]
2226/// `rest` begins at a pull-request word. True when a number follows it.
2227fn pr_number_at(rest: &str) -> bool {
2228    let after = if let Some(s) = rest.strip_prefix("pull requests") {
2229        s
2230    } else if let Some(s) = rest.strip_prefix("pull request") {
2231        s
2232    } else if let Some(s) = rest.strip_prefix("prs") {
2233        if s.starts_with(|c: char| c.is_ascii_alphanumeric()) {
2234            return false;
2235        }
2236        s
2237    } else if let Some(s) = rest.strip_prefix("pr") {
2238        if s.starts_with(|c: char| c.is_ascii_alphabetic()) {
2239            return false;
2240        }
2241        s
2242    } else {
2243        return false;
2244    };
2245    let after = after.trim_start();
2246    let after = after.strip_prefix('#').unwrap_or(after).trim_start();
2247    after.starts_with(|c: char| c.is_ascii_digit())
2248}
2249
2250#[cfg(test)]
2251/// `#80` names one pull request even when the word PR is not in front of it.
2252fn hash_number_at(rest: &str) -> bool {
2253    let Some(after) = rest.strip_prefix('#') else {
2254        return false;
2255    };
2256    after.starts_with(|c: char| c.is_ascii_digit())
2257}
2258
2259#[cfg(test)]
2260/// A claim about one artifact: a numbered pull request, a ticket id, or a commit.
2261/// That is a snapshot of one review. A rule that names no artifact is standing.
2262fn is_transient(text: &str) -> bool {
2263    names_a_numbered_pr(text) || names_a_ticket(text) || names_a_commit(text)
2264}
2265
2266#[cfg(test)]
2267/// `project-ab12`, the tracker's id shape. A hyphenated English word is longer.
2268fn names_a_ticket(text: &str) -> bool {
2269    text.split(|c: char| !c.is_ascii_alphanumeric() && c != '-')
2270        .any(|tok| {
2271            let Some((head, tail)) = tok.split_once('-') else {
2272                return false;
2273            };
2274            head.len() >= 2
2275                && head.chars().all(|c| c.is_ascii_alphabetic())
2276                && tail.len() == 4
2277                && tail.chars().all(|c| c.is_ascii_alphanumeric())
2278                && !tail.contains('-')
2279        })
2280}
2281
2282#[cfg(test)]
2283/// A hex token with a digit in it. Plain words that happen to be hex have none.
2284fn names_a_commit(text: &str) -> bool {
2285    text.split(|c: char| !c.is_ascii_alphanumeric()).any(|tok| {
2286        (7..=40).contains(&tok.len())
2287            && tok.chars().all(|c| c.is_ascii_hexdigit())
2288            && tok.chars().any(|c| c.is_ascii_digit())
2289    })
2290}
2291
2292/// A standing claim is a refresher. An episode is not, and neither is a
2293/// lesson written before the tag: rehearsal promotes it.
2294fn is_refresher(hit: &Hit) -> bool {
2295    if hit.kind == "preference" {
2296        return true;
2297    }
2298    if hit.entities.iter().any(|e| e == "horizon:transient") {
2299        return false;
2300    }
2301    hit.entities.iter().any(|e| e == "horizon:standing")
2302}
2303
2304/// The pack note for a prompt, and the memory ids named in it.
2305/// The ids are not marked seen here: the caller marks them when the runner
2306/// delivers the note. A camel-case prompt hook's stdout is discarded, so
2307/// marking here would burn the note before the model read it.
2308#[must_use]
2309pub fn hook_note(call: &HookCall, limit: usize) -> (String, Vec<String>) {
2310    let cue = call.cue.trim();
2311    if cue.len() < 3 {
2312        return (String::new(), Vec::new());
2313    }
2314    // The nudges answer what the prompt says, not what the pack holds, so
2315    // a prompt the pack knows nothing about still gets them. Their keys
2316    // travel with the note and are marked seen when a runner delivers it.
2317    let (mut nudge, due_key) = due_nudge(call);
2318    let mut pending = Vec::new();
2319    if let Some(key) = due_key {
2320        pending.push(key);
2321    }
2322    // With Jev on for this machine, one call judges which candidates bear on
2323    // the prompt and whether it corrects or puts a choice. Without it, or
2324    // when it does not answer in time, the local path below runs.
2325    let judged = judged_prompt(call, cue);
2326    let (correction, choice) = judged.as_ref().map_or((None, None), |(_, j)| {
2327        (Some(j.correction >= j.cue_at), Some(j.choice >= j.cue_at))
2328    });
2329    for (key, extra) in [
2330        correction_nudge_as(call, correction),
2331        decision_nudge_as(call, choice),
2332    ]
2333    .into_iter()
2334    .flatten()
2335    {
2336        pending.push(key);
2337        if !nudge.is_empty() {
2338            nudge.push('\n');
2339        }
2340        nudge.push_str(&extra);
2341    }
2342    // The cross-encoder reads the prompt and the claim together. The lexical
2343    // search is the fallback when that stage is down, and it still refuses
2344    // an episode.
2345    // The rerank gets a budget inside the runner's hook timeout; past it the
2346    // lexical search answers, which takes a fraction of a second.
2347    let seen = seen_ids(call.session.as_deref());
2348    let hits: Vec<Hit>;
2349    let mut rows: Vec<&Hit> = if let Some((candidates, j)) = &judged {
2350        // Jev read the prompt and each claim together; what it says bears
2351        // is what goes in, with no score floor or word test on top.
2352        candidates
2353            .iter()
2354            .enumerate()
2355            .filter(|(i, _)| j.bears(*i))
2356            .map(|(_, h)| h)
2357            .filter(|h| h.id.as_ref().is_none_or(|id| !seen.contains(id)))
2358            .collect()
2359    } else {
2360        // A machine that turned Jev on keeps the cross-encoder unloaded; a
2361        // prompt Jev was not asked about gets the lexical search.
2362        let rerank = !jev::enabled();
2363        let reranked = with_pack_timeout(HOOK_RERANK_BUDGET_MS, || {
2364            packset_search_opts(cue, 10, rerank)
2365        });
2366        let Ok(found) = reranked.or_else(|_| packset_search(cue)) else {
2367            return (nudge, pending);
2368        };
2369        hits = found;
2370        let top = hits.iter().map(|h| h.score).fold(0.0_f64, f64::max);
2371        if top <= 0.0 {
2372            return (nudge, pending);
2373        }
2374        hits.iter()
2375            .filter(|h| !UNREVIEWED_KINDS.contains(&h.kind.as_str()))
2376            .filter(|h| h.score >= top * HOOK_SCORE_FLOOR)
2377            .filter(|h| agreed(h))
2378            .filter(|h| names_the_cue(&h.text, cue))
2379            .filter(|h| is_refresher(h))
2380            .filter(|h| h.id.as_ref().is_none_or(|id| !seen.contains(id)))
2381            .collect()
2382    };
2383    // Jev's probability ranks what it judged; the search score ranks the rest.
2384    let weight = |h: &Hit| -> f64 {
2385        judged
2386            .as_ref()
2387            .and_then(|(c, j)| {
2388                let i = c.iter().position(|x| x.id == h.id && x.text == h.text)?;
2389                j.bears.get(i).copied()
2390            })
2391            .unwrap_or(h.score)
2392    };
2393    rows.sort_by(|a, b| {
2394        let pa = a.kind == "preference";
2395        let pb = b.kind == "preference";
2396        pb.cmp(&pa).then(
2397            weight(b)
2398                .partial_cmp(&weight(a))
2399                .unwrap_or(std::cmp::Ordering::Equal),
2400        )
2401    });
2402    let mut rows: Vec<&Hit> = rows.into_iter().take(limit).collect();
2403    // Preferences stay in front by score; the lessons behind them run
2404    // oldest to newest, so what was learnt last is read last and nearest
2405    // the action, and a later lesson that revises an earlier one reads as
2406    // a revision.
2407    let now = now_utc();
2408    let split = rows.iter().filter(|h| h.kind == "preference").count();
2409    rows[split..].sort_by_key(|h| days_of_stamp(h.ts.as_deref()).unwrap_or(i64::MAX));
2410    let lines: Vec<String> = rows.iter().map(|h| hit_line(h, &now)).collect();
2411    let mut ids: Vec<String> = rows.iter().filter_map(|h| h.id.clone()).collect();
2412    ids.extend(pending);
2413    if lines.is_empty() {
2414        return (nudge, ids);
2415    }
2416    let mut out = format!(
2417        "What this seat already knows that bears on this (from the pack, each with its age, lessons oldest first; `ljos search` for more):\n{}",
2418        lines.join("\n")
2419    );
2420    if !nudge.is_empty() {
2421        out.push('\n');
2422        out.push_str(&nudge);
2423    }
2424    (out, ids)
2425}
2426
2427/// The prompt's candidates and Jev's judgment of them, when this machine
2428/// turned Jev on and the prompt is worth a call: enough words to judge,
2429/// at least `min_candidates` claims to choose between after the local
2430/// kind, refresher and seen filters, and the month's spend under its cap.
2431/// Candidates come from the search without the local cross-encoder, which
2432/// Jev replaces.
2433fn judged_prompt(call: &HookCall, cue: &str) -> Option<(Vec<Hit>, jev::Judgment)> {
2434    if call.event != "UserPromptSubmit" {
2435        return None;
2436    }
2437    let (cfg, _) = jev::config()?;
2438    if cue.split_whitespace().count() < cfg.min_words {
2439        return None;
2440    }
2441    let seen = seen_ids(call.session.as_deref());
2442    let hits = packset_search_opts(cue, 10, false).ok()?;
2443    let candidates: Vec<Hit> = hits
2444        .into_iter()
2445        .filter(|h| !UNREVIEWED_KINDS.contains(&h.kind.as_str()))
2446        .filter(is_refresher)
2447        .filter(|h| h.id.as_ref().is_none_or(|id| !seen.contains(id)))
2448        .take(10)
2449        .collect();
2450    if candidates.len() < cfg.min_candidates {
2451        return None;
2452    }
2453    let texts: Vec<&str> = candidates.iter().map(|h| h.text.as_str()).collect();
2454    let judged = jev::judge(cue, &texts)?;
2455    Some((candidates, judged))
2456}
2457
2458/// The context the hook injects. A camel-case runner does not see prompt
2459/// stdout, so the ids stay unmarked until the first tool result, or `Stop`
2460/// when the turn ran no tool, delivers them. Every other runner is shown
2461/// this string and the ids are marked now.
2462#[must_use]
2463pub fn hook_context(call: &HookCall, limit: usize) -> String {
2464    let (text, ids) = hook_note(call, limit);
2465    if call.shape != HookShape::CamelCase {
2466        mark_seen(call.session.as_deref(), &ids);
2467    }
2468    text
2469}
2470
2471/// Whether the pack's scorers agreed on a hit: named by at least two of
2472/// the ballots that ran. When one ballot ran, or the hit carries no
2473/// count, it stands. A command line matches many claims weakly on one
2474/// scorer; what reaches the agent unasked should be what two scorers
2475/// found.
2476fn agreed(h: &Hit) -> bool {
2477    match (h.ballots, h.of) {
2478        (Some(named), Some(of)) if of >= 2 => named >= 2,
2479        _ => true,
2480    }
2481}
2482
2483/// What a hook call says about a subagent: its type when the call fired
2484/// inside one (`subagentType`, or `agent_type`), and whether a stop gate
2485/// already held it this turn (`stopHookActive`), and the agent's id when
2486/// the runner shares one session between a parent and its subagents.
2487#[must_use]
2488pub fn hook_subagent(input: &str) -> (Option<String>, bool, String) {
2489    let Ok(v) = serde_json::from_str::<Value>(input.trim()) else {
2490        return (None, false, String::new());
2491    };
2492    let kind = v["subagentType"]
2493        .as_str()
2494        .or_else(|| v["subagent_type"].as_str())
2495        .or_else(|| v["agent_type"].as_str())
2496        .filter(|s| !s.is_empty())
2497        .map(str::to_string);
2498    let active = v["stopHookActive"]
2499        .as_bool()
2500        .or_else(|| v["stop_hook_active"].as_bool())
2501        .unwrap_or(false);
2502    let agent = v["agent_id"]
2503        .as_str()
2504        .or_else(|| v["agentId"].as_str())
2505        .unwrap_or("")
2506        .to_string();
2507    (kind, active, agent)
2508}
2509
2510/// A command line that runs a test suite. Exact, so it is code, not a
2511/// judgment.
2512#[must_use]
2513pub fn runs_tests(command: &str) -> bool {
2514    const RUNNERS: &[&str] = &[
2515        "cargo test",
2516        "cargo nextest",
2517        "pytest",
2518        "ctest",
2519        "meson test",
2520        "npm test",
2521        "npm run test",
2522        "pnpm test",
2523        "go test",
2524        "make check",
2525        "make test",
2526        "repo-test",
2527        "tox",
2528        "bats ",
2529        "prove ",
2530        "mix test",
2531        "gradle test",
2532        "mvn test",
2533    ];
2534    RUNNERS.iter().any(|r| command.contains(r))
2535}
2536
2537/// The turn a stop ends, read from the runner's transcript: the person's
2538/// last request, the shell commands since it, the output of the latest
2539/// test run (or of the last commands when none ran), and the final
2540/// message.
2541#[derive(Debug, Clone, Default, PartialEq)]
2542pub struct StopTurn {
2543    pub request: String,
2544    pub commands: Vec<String>,
2545    pub test_ran: bool,
2546    pub outputs: Vec<String>,
2547    pub final_message: String,
2548}
2549
2550fn tail_chars(s: &str, n: usize) -> String {
2551    let count = s.chars().count();
2552    s.chars().skip(count.saturating_sub(n)).collect()
2553}
2554
2555fn block_text(content: &Value) -> String {
2556    match content {
2557        Value::String(t) => t.clone(),
2558        Value::Array(parts) => parts
2559            .iter()
2560            .filter_map(|p| p["text"].as_str())
2561            .collect::<Vec<_>>()
2562            .join("\n"),
2563        _ => String::new(),
2564    }
2565}
2566
2567/// Read a JSONL transcript of `user` and
2568/// `assistant` entries whose `message.content` is text or blocks
2569/// (`text`, `tool_use`, `tool_result`).
2570#[must_use]
2571pub fn stop_turn_from_transcript(text: &str) -> StopTurn {
2572    let entries: Vec<Value> = text
2573        .lines()
2574        .filter_map(|l| serde_json::from_str::<Value>(l).ok())
2575        .collect();
2576    let is_prompt = |e: &Value| {
2577        e["type"] == "user"
2578            && !e["isMeta"].as_bool().unwrap_or(false)
2579            && match &e["message"]["content"] {
2580                Value::String(t) => !t.trim_start().starts_with('<'),
2581                Value::Array(parts) => {
2582                    parts.iter().any(|p| p["type"] == "text")
2583                        && !parts.iter().any(|p| p["type"] == "tool_result")
2584                }
2585                _ => false,
2586            }
2587    };
2588    let start = entries.iter().rposition(is_prompt).unwrap_or(0);
2589    let mut turn = StopTurn {
2590        request: entries
2591            .get(start)
2592            .map(|e| block_text(&e["message"]["content"]))
2593            .unwrap_or_default(),
2594        ..StopTurn::default()
2595    };
2596    let mut pending: std::collections::BTreeMap<String, String> = Default::default();
2597    let mut outputs: Vec<(bool, String)> = Vec::new();
2598    for e in entries.iter().skip(start + 1) {
2599        let Value::Array(parts) = &e["message"]["content"] else {
2600            if e["type"] == "assistant" {
2601                turn.final_message = block_text(&e["message"]["content"]);
2602            }
2603            continue;
2604        };
2605        for part in parts {
2606            match part["type"].as_str() {
2607                Some("tool_use") => {
2608                    if let Some(cmd) = part["input"]["command"].as_str() {
2609                        let cmd: String = cmd.chars().take(200).collect();
2610                        if let Some(id) = part["id"].as_str() {
2611                            pending.insert(id.to_string(), cmd.clone());
2612                        }
2613                        turn.test_ran |= runs_tests(&cmd);
2614                        turn.commands.push(cmd);
2615                    }
2616                }
2617                Some("tool_result") => {
2618                    let id = part["tool_use_id"].as_str().unwrap_or("");
2619                    if let Some(cmd) = pending.remove(id) {
2620                        let out = tail_chars(&block_text(&part["content"]), 1500);
2621                        outputs.push((runs_tests(&cmd), format!("$ {cmd}\n{out}")));
2622                    }
2623                }
2624                Some("text") if e["type"] == "assistant" => {
2625                    turn.final_message = part["text"].as_str().unwrap_or("").to_string();
2626                }
2627                _ => {}
2628            }
2629        }
2630    }
2631    let tests: Vec<String> = outputs
2632        .iter()
2633        .filter(|o| o.0)
2634        .map(|o| o.1.clone())
2635        .collect();
2636    let chosen = if tests.is_empty() {
2637        outputs.into_iter().map(|o| o.1).collect::<Vec<_>>()
2638    } else {
2639        tests
2640    };
2641    turn.outputs = chosen.into_iter().rev().take(2).rev().collect();
2642    let n = turn.commands.len();
2643    turn.commands = turn.commands.split_off(n.saturating_sub(30));
2644    turn
2645}
2646
2647impl StopTurn {
2648    /// The audit state, bounded to a few thousand tokens.
2649    #[must_use]
2650    pub fn state(&self) -> String {
2651        format!(
2652            "The person asked:\n{}\n\nShell commands the agent ran since:\n{}\n\nLatest output:\n{}\n\nThe agent's final message:\n{}\n",
2653            tail_chars(&self.request, 1500),
2654            self.commands.join("\n"),
2655            self.outputs.join("\n---\n"),
2656            tail_chars(&self.final_message, 3000)
2657        )
2658    }
2659}
2660
2661/// Why an agent about to stop is held for one more round, from a Jev
2662/// audit of the turn; `None` lets it stop. Only a runner's first attempt
2663/// is audited, only with Jev on, and only a final message long enough to
2664/// claim anything.
2665#[must_use]
2666pub fn stop_audit(input: &str, stop_active: bool) -> Option<String> {
2667    if stop_active {
2668        return None;
2669    }
2670    jev::config()?;
2671    let v: Value = serde_json::from_str(input.trim()).ok()?;
2672    let path = v["transcript_path"]
2673        .as_str()
2674        .or_else(|| v["transcriptPath"].as_str());
2675    let mut turn = path
2676        .and_then(|p| std::fs::read_to_string(p).ok())
2677        .map(|t| stop_turn_from_transcript(&t))
2678        .unwrap_or_default();
2679    if let Some(last) = v["last_assistant_message"]
2680        .as_str()
2681        .or_else(|| v["lastAssistantMessage"].as_str())
2682    {
2683        turn.final_message = last.to_string();
2684    }
2685    if turn.final_message.chars().count() < 80 {
2686        return None;
2687    }
2688    let a = jev::audit(&turn.state())?;
2689    jev::audit_reason(&a, turn.test_ran)
2690}
2691
2692/// Tool calls a conversation may make without a word to the seat before the
2693/// hook reminds it. A sitting opened at the start and nothing after it is
2694/// how long work went unrecorded.
2695pub const WORK_NUDGE_EVERY: u64 = 40;
2696
2697/// Whether a hook call's cue is the seat's own verbs or tools.
2698#[must_use]
2699pub fn touches_seat(cue: &str) -> bool {
2700    cue.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2701        .any(|w| w == "ljos" || w == "vissue" || w.starts_with("ljos_") || w.starts_with("vissue_"))
2702}
2703
2704/// Count this conversation's tool calls since it last touched the seat, and
2705/// on a `PostToolUse` that reaches [`WORK_NUDGE_EVERY`] say what to record:
2706/// a note, a lesson or a deed on the issue it holds, or an issue to open
2707/// when it holds none. A subagent is left to its brief.
2708pub fn work_nudge(call: &HookCall, subagent: bool) -> Option<String> {
2709    let session = call.session.as_deref()?;
2710    let safe: String = session
2711        .chars()
2712        .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
2713        .collect();
2714    if safe.is_empty() || subagent {
2715        return None;
2716    }
2717    let path = runtime_dir().join(format!("work-{safe}"));
2718    if touches_seat(&call.cue) {
2719        let _ = std::fs::write(&path, "0");
2720        return None;
2721    }
2722    if call.event != "PostToolUse" {
2723        return None;
2724    }
2725    let count = std::fs::read_to_string(&path)
2726        .ok()
2727        .and_then(|t| t.trim().parse::<u64>().ok())
2728        .unwrap_or(0)
2729        + 1;
2730    if count < WORK_NUDGE_EVERY {
2731        let _ = std::fs::create_dir_all(runtime_dir());
2732        let _ = std::fs::write(&path, count.to_string());
2733        return None;
2734    }
2735    let _ = std::fs::write(&path, "0");
2736    Some(match held_issue() {
2737        Some(issue) => format!(
2738            "{count} tool calls on {issue} since the seat last heard from this conversation. \
2739             Record what the work has shown: progress is `vissue note {issue} \"...\"`, a lesson \
2740             that holds next time is `ljos remember \"...\"`, an artifact is `ljos deed {issue} \
2741             --add ACCESSION`; the work closes with `ljos finish {issue} --lesson \"...\"`."
2742        ),
2743        None => format!(
2744            "{count} tool calls in this conversation with no issue held. Work goes on an issue: \
2745             `vissue q -p PROJECT \"TITLE\"` prints an id, then `ljos sitting ID` opens it."
2746        ),
2747    })
2748}
2749
2750/// With `$XDG_RUNTIME_DIR/ljos/hook-trace` present, one line per hook call
2751/// to `hook-trace.jsonl` beside it: the event as sent and as read, the
2752/// payload's top-level key names, the session and subagent type. Key names
2753/// only, never values, so a runner's hook contract can be read off a live
2754/// session without storing what it said.
2755pub fn hook_trace(input: &str, call: &HookCall, subagent: Option<&str>) {
2756    let dir = runtime_dir();
2757    if !dir.join("hook-trace").exists() {
2758        return;
2759    }
2760    let v: Value = serde_json::from_str(input.trim()).unwrap_or(Value::Null);
2761    let keys: Vec<&str> = v
2762        .as_object()
2763        .map(|m| m.keys().map(String::as_str).collect())
2764        .unwrap_or_default();
2765    let raw = v["hook_event_name"]
2766        .as_str()
2767        .or_else(|| v["hookEventName"].as_str())
2768        .unwrap_or("");
2769    let line = serde_json::json!({
2770        "ts": now_utc(),
2771        "event": call.event,
2772        "raw": raw,
2773        "keys": keys,
2774        "session": call.session,
2775        "subagent": subagent,
2776        "holder": holder_name(),
2777        "tree_holder": runner_record_holders().first().cloned(),
2778        "held": subagent.and_then(|_| held_issue()),
2779    });
2780    use std::io::Write as _;
2781    if let Ok(mut f) = std::fs::OpenOptions::new()
2782        .create(true)
2783        .append(true)
2784        .open(dir.join("hook-trace.jsonl"))
2785    {
2786        let _ = writeln!(f, "{line}");
2787    }
2788}
2789
2790/// The holders the seat records above this process name, nearest first,
2791/// read without the conversation check `read_record` makes. A subagent's
2792/// hooks run under its own session id inside its parent's runner, so the
2793/// parent's record always looks like another conversation's there, and it
2794/// is exactly the one a subagent needs.
2795fn runner_record_holders() -> Vec<String> {
2796    let mut out = Vec::new();
2797    // A record left for a multiplexer would hand its holder to every pane.
2798    for (pid, _) in own_ancestry() {
2799        let Ok(text) = std::fs::read_to_string(seat_record_path(pid)) else {
2800            continue;
2801        };
2802        if let Some(holder) = text.lines().nth(1).map(str::trim).filter(|h| !h.is_empty()) {
2803            if !out.iter().any(|h| h == holder) {
2804                out.push(holder.to_string());
2805            }
2806        }
2807    }
2808    out
2809}
2810
2811/// The issue this conversation's holder claimed last and still works: a
2812/// subagent's hook runs under its parent's holder, so this is the work
2813/// the subagent is a slice of.
2814#[must_use]
2815pub fn held_issue() -> Option<String> {
2816    // The record the runner's own server left names the holder its claims
2817    // were made under. A hook's environment can carry session variables
2818    // the server's did not, which hash to another holder that holds
2819    // nothing, so the record is asked first.
2820    let mut holders: Vec<String> = runner_record_holders();
2821    let own = holder_name();
2822    if !holders.contains(&own) {
2823        holders.push(own);
2824    }
2825    // The hold records answer in milliseconds; the tracker walk below takes
2826    // seconds on a large tracker, past what a runner lets a hook run.
2827    if let Some(node) = held_from_records(&holders) {
2828        return Some(node);
2829    }
2830    if std::env::var_os("LJOS_IN_HOOK").is_some() {
2831        return None;
2832    }
2833    holders.iter().find_map(|holder| {
2834        let out = run_captured("vissue", &["claims", "--by", holder, "--json"]).ok()?;
2835        let rows: Value = serde_json::from_str(&out.stdout).ok()?;
2836        rows.as_array()?
2837            .iter()
2838            .rfind(|c| c["state"].as_str() == Some("STARTED"))?["id"]
2839            .as_str()
2840            .map(str::to_string)
2841    })
2842}
2843
2844/// What a subagent is told on its first tool result: the issue its parent
2845/// holds and how its result joins it. A subagent that is not told the
2846/// issue cannot cast a ballot on it, and a sitting of its own would
2847/// contend with its parent's.
2848#[must_use]
2849pub fn subagent_brief(kind: &str, issue: &str, decision: bool) -> String {
2850    let judge = if decision {
2851        format!("{issue} is a decision: end with your ballot, `ljos vote {issue} --for OPTION --expect OPTION --as ROLE`.")
2852    } else {
2853        format!(
2854            "A judgement between options is a ballot: `ljos vote {issue} --for OPTION --expect OPTION --as ROLE`."
2855        )
2856    };
2857    format!(
2858        "You are a subagent ({kind}) working under {issue}, which your parent holds. Do not open a sitting \
2859         on it. {judge} A lesson that will hold next time is `ljos remember \"...\" --as ROLE`; a \
2860         finding is `vissue note {issue} \"...\"`. ROLE is a persona from `ljos personas` when one fits \
2861         your task, else `{kind}`."
2862    )
2863}
2864
2865/// The stop gate for a subagent: once, when its parent holds an issue,
2866/// the reason the subagent is kept working one more round. A gate that
2867/// already held it this turn, or a parent holding nothing, lets it stop.
2868#[must_use]
2869pub fn subagent_stop_reason(
2870    kind: &str,
2871    issue: Option<&str>,
2872    decision: bool,
2873    active: bool,
2874) -> Option<String> {
2875    if active {
2876        return None;
2877    }
2878    let issue = issue?;
2879    Some(if decision {
2880        format!(
2881            "{issue} is a decision your parent holds. Before you stop, cast your ballot: \
2882             `ljos vote {issue} --for OPTION --expect OPTION --as ROLE` (ROLE: your persona, else `{kind}`)."
2883        )
2884    } else {
2885        format!(
2886            "You worked under {issue}. Before you stop: if your result settles a choice, \
2887             `ljos vote {issue} --for OPTION --expect OPTION --as ROLE`; if it taught something that holds next time, \
2888             `ljos remember \"...\" --as ROLE`. Otherwise stop."
2889        )
2890    })
2891}
2892
2893/// How long a context hook may take before it answers with nothing. The
2894/// shortest runner cut-off seen is grok's 15 s on a prompt; this leaves it
2895/// room on a loaded host.
2896pub const HOOK_DEADLINE_MS: u64 = 8000;
2897
2898/// Whether an identical call (event, session, text) started in the last 20
2899/// seconds. A runner that loads another runner's hook file runs the same
2900/// hook twice for one event, and both queue on the pack's one reranker.
2901/// The first call makes the marker and answers; the second returns at once.
2902pub fn hook_already_running(call: &HookCall) -> bool {
2903    let key = work_id(&format!(
2904        "{}|{}|{}",
2905        call.event,
2906        call.session.as_deref().unwrap_or(""),
2907        call.cue
2908    ));
2909    let dir = runtime_dir();
2910    let _ = std::fs::create_dir_all(&dir);
2911    // About one call in sixteen sweeps markers older than a minute.
2912    if key.starts_with('0') {
2913        if let Ok(entries) = std::fs::read_dir(&dir) {
2914            for e in entries.flatten() {
2915                let old = e.file_name().to_string_lossy().starts_with("hook-once-")
2916                    && e.metadata()
2917                        .and_then(|m| m.modified())
2918                        .ok()
2919                        .and_then(|t| t.elapsed().ok())
2920                        .is_some_and(|age| age > std::time::Duration::from_secs(60));
2921                if old {
2922                    let _ = std::fs::remove_file(e.path());
2923                }
2924            }
2925        }
2926    }
2927    let path = dir.join(format!("hook-once-{key}"));
2928    match std::fs::OpenOptions::new()
2929        .write(true)
2930        .create_new(true)
2931        .open(&path)
2932    {
2933        Ok(_) => false,
2934        Err(_) => {
2935            let fresh = std::fs::metadata(&path)
2936                .and_then(|m| m.modified())
2937                .ok()
2938                .and_then(|t| t.elapsed().ok())
2939                .is_some_and(|age| age < std::time::Duration::from_secs(20));
2940            if !fresh {
2941                let _ = std::fs::write(&path, "");
2942            }
2943            fresh
2944        }
2945    }
2946}
2947
2948/// How long the prompt hook waits for the reranked search. Runners cut a
2949/// hook off at 10 to 20 s, and a loaded host has made the rerank alone take
2950/// longer than that.
2951pub const HOOK_RERANK_BUDGET_MS: u64 = 2500;
2952
2953/// Run `f` with the pack client's request timeout set to `ms`, then put
2954/// back whatever it was.
2955fn with_pack_timeout<R>(ms: u64, f: impl FnOnce() -> R) -> R {
2956    let before = std::env::var_os("PACKSET_TIMEOUT_MS");
2957    // SAFETY: the hook reads and sets this on one thread, before and after
2958    // the one request it bounds.
2959    unsafe { std::env::set_var("PACKSET_TIMEOUT_MS", ms.to_string()) };
2960    let out = f();
2961    match before {
2962        Some(v) => unsafe { std::env::set_var("PACKSET_TIMEOUT_MS", v) },
2963        None => unsafe { std::env::remove_var("PACKSET_TIMEOUT_MS") },
2964    }
2965    out
2966}
2967
2968/// Phrases a person uses when the agent has forgotten something it was
2969/// told. A prompt that opens this way is a preference or a lesson the
2970/// pack does not hold yet, and the moment to write it is now, before the
2971/// work that follows.
2972pub const CORRECTION_CUES: &[&str] = &[
2973    "do you not remember",
2974    "don't you remember",
2975    "dont you remember",
2976    "you should have",
2977    "why did you not",
2978    "why didn't you",
2979    "why havent you",
2980    "why haven't you",
2981    "you forgot",
2982    "i told you",
2983    "i've told you",
2984    "as i said",
2985    "again you",
2986    "still not",
2987    "not even able",
2988    "you never",
2989    "you keep",
2990];
2991
2992#[cfg(test)]
2993/// On a prompt that reads as a correction, the one line that turns it
2994/// into memory: the agent writes the preference or lesson with `ljos
2995/// prefer` or `ljos remember` before it goes on. Once a session for the
2996/// same cue, so a run of corrections does not repeat it.
2997fn correction_nudge(call: &HookCall) -> Option<(String, String)> {
2998    correction_nudge_as(call, None)
2999}
3000
3001/// [`correction_nudge`] with a verdict from elsewhere: `Some` is Jev's
3002/// answer and replaces the phrase list, `None` keeps the list.
3003fn correction_nudge_as(call: &HookCall, verdict: Option<bool>) -> Option<(String, String)> {
3004    if call.event != "UserPromptSubmit" {
3005        return None;
3006    }
3007    let key = match verdict {
3008        Some(false) => return None,
3009        Some(true) => "correction:judged".to_string(),
3010        None => {
3011            let lower = call.cue.to_lowercase();
3012            let hit = CORRECTION_CUES.iter().find(|c| lower.contains(*c))?;
3013            format!("correction:{hit}")
3014        }
3015    };
3016    if seen_ids(call.session.as_deref()).contains(&key) {
3017        return None;
3018    }
3019    Some((
3020        key,
3021        "This prompt reads as a correction. Before the work: write what it corrects as one \
3022         `ljos prefer \"...\"` (a standing choice) or `ljos remember \"...\"` (a lesson), \
3023         so the pack holds it and the hook can raise it next time."
3024            .to_string(),
3025    ))
3026}
3027
3028/// Phrases that put a choice to the agent. A choice with more than one
3029/// defensible answer is a ballot, and a ballot needs an issue to sit on.
3030pub const DECISION_CUES: &[&str] = &[
3031    "should we",
3032    "should i ",
3033    "or should",
3034    "which is better",
3035    "which one",
3036    "which approach",
3037    "which option",
3038    "pros and cons",
3039    "trade-off",
3040    "tradeoff",
3041    " versus ",
3042    " vs ",
3043    " vs. ",
3044    "what do you recommend",
3045    "do you think we",
3046    "option 1",
3047    "option 2",
3048    "option a",
3049    "option b",
3050];
3051
3052/// How much of a prompt the decision cues are looked for in.
3053pub const DECISION_OPENING: usize = 400;
3054
3055/// Whether `cue` occurs in `text` ending at a word boundary, so `option a`
3056/// does not fire on `option about`.
3057fn cue_at_word_end(text: &str, cue: &str) -> bool {
3058    text.match_indices(cue).any(|(i, _)| {
3059        text[i + cue.len()..]
3060            .chars()
3061            .next()
3062            .is_none_or(|c| !c.is_alphanumeric())
3063    })
3064}
3065
3066#[cfg(test)]
3067/// On a prompt that puts a choice, the lines that take it to a panel
3068/// instead of one agent's opinion. Once a session, since one decision
3069/// is usually argued over several prompts.
3070fn decision_nudge(call: &HookCall) -> Option<(String, String)> {
3071    decision_nudge_as(call, None)
3072}
3073
3074/// [`decision_nudge`] with a verdict from elsewhere, as for corrections.
3075fn decision_nudge_as(call: &HookCall, verdict: Option<bool>) -> Option<(String, String)> {
3076    if call.event != "UserPromptSubmit" {
3077        return None;
3078    }
3079    match verdict {
3080        Some(false) => return None,
3081        Some(true) => {}
3082        None => {
3083            // A question is put in the prompt's opening; a long pasted report
3084            // that mentions options further down is not a choice put to the
3085            // agent.
3086            let opening: String = call.cue.chars().take(DECISION_OPENING).collect();
3087            let lower = format!(" {} ", opening.to_lowercase());
3088            DECISION_CUES.iter().find(|c| cue_at_word_end(&lower, c))?;
3089        }
3090    }
3091    let key = "decision-nudge".to_string();
3092    if seen_ids(call.session.as_deref()).contains(&key) {
3093        return None;
3094    }
3095    Some((
3096        key,
3097        "This prompt puts a choice. Before choosing: put it on an issue whose body has an \
3098         `Options: A, B` line, then `ljos sitting ISSUE` writes one brief per persona the \
3099         title names; start one subagent per brief, each casting `ljos vote ISSUE --for \
3100         OPTION --expect OPTION --as NAME`, and settle with `ljos consensus ISSUE`."
3101            .to_string(),
3102    ))
3103}
3104
3105/// On a prompt, once per session: how many claims are due for review. The
3106/// review loop runs only when somebody grades, and nobody grades what they
3107/// were not told about.
3108fn due_nudge(call: &HookCall) -> (String, Option<String>) {
3109    if call.event != "UserPromptSubmit" {
3110        return (String::new(), None);
3111    }
3112    let key = "due-nudge".to_string();
3113    if seen_ids(call.session.as_deref()).contains(&key) {
3114        return (String::new(), None);
3115    }
3116    let Ok(client) = pack() else {
3117        return (String::new(), None);
3118    };
3119    let Ok(atoms) = atoms_lean(&client, &client.workspace()) else {
3120        return (String::new(), None);
3121    };
3122    let due = due_of(&atoms, &now_utc()).len();
3123    // A quiet seat has nothing to show, so it is counted once here. A seat
3124    // with claims due names the key and the caller marks it when the note
3125    // is delivered. Do not call consolidate here: that walk is a sitting,
3126    // not a hook, and it is what made PreToolUse time out at 20s.
3127    if due == 0 {
3128        mark_seen(call.session.as_deref(), &[key]);
3129        return (String::new(), None);
3130    }
3131    (
3132        format!(
3133            "{due} claim{} due for review in this seat: `ljos due`, read each, then `ljos graded ID` (or `--lapsed`).",
3134            if due == 1 { " is" } else { "s are" }
3135        ),
3136        Some(key),
3137    )
3138}
3139
3140/// The hook's answer in the runner's JSON: `additionalContext` under the
3141/// event that fired. Empty context is no output, which the runner reads as
3142/// no opinion.
3143#[must_use]
3144pub fn hook_output(call: &HookCall, context: &str) -> String {
3145    hook_output_ruled(call, context, None)
3146}
3147
3148/// [`hook_output`] carrying a rule's verdict on a tool call: `deny` or
3149/// `ask` as the runner's permission decision, with the rule's reason. On a
3150/// prompt or an argv line the verdict is a line of text.
3151#[must_use]
3152pub fn hook_output_ruled(call: &HookCall, context: &str, verdict: Option<&Rule>) -> String {
3153    if context.is_empty() && verdict.is_none() {
3154        return String::new();
3155    }
3156    if call.event == "argv" {
3157        let mut out = String::new();
3158        if let Some(r) = verdict {
3159            out.push_str(&format!(
3160                "{}: {} (rule `{}`)\n",
3161                r.verdict, r.reason, r.pattern
3162            ));
3163        }
3164        if !context.is_empty() {
3165            out.push_str(context);
3166            out.push('\n');
3167        }
3168        return out;
3169    }
3170    if call.shape == HookShape::Context && verdict.is_none() {
3171        return if context.is_empty() {
3172            String::new()
3173        } else {
3174            serde_json::json!({ "context": context }).to_string() + "\n"
3175        };
3176    }
3177    let mut specific = serde_json::json!({ "hookEventName": call.event });
3178    if !context.is_empty() {
3179        specific["additionalContext"] = Value::String(context.to_string());
3180    }
3181    let mut top = serde_json::Map::new();
3182    if let Some(r) = verdict {
3183        if call.event == "PreToolUse" {
3184            // A runner that cannot ask runs the tool on an `ask`; the
3185            // seat stops it and tells the agent to ask the person.
3186            let (decision, reason) = if r.verdict == "ask" && !call.shape.asks() {
3187                (
3188                    "deny",
3189                    format!(
3190                        "ask the person before running this: {} (seat rule `{}`)",
3191                        r.reason, r.pattern
3192                    ),
3193                )
3194            } else {
3195                (
3196                    r.verdict.as_str(),
3197                    format!("{} (seat rule `{}`)", r.reason, r.pattern),
3198                )
3199            };
3200            if call.shape == HookShape::Context {
3201                // `block` is the one verb there; context rides along.
3202                let mut out = serde_json::json!({ "decision": "block", "reason": reason });
3203                if !context.is_empty() {
3204                    out["context"] = Value::String(context.to_string());
3205                }
3206                return out.to_string() + "\n";
3207            }
3208            specific["permissionDecision"] = Value::String(decision.to_string());
3209            specific["permissionDecisionReason"] = Value::String(reason.clone());
3210            if call.shape == HookShape::CamelCase {
3211                top.insert("decision".into(), Value::String(decision.to_string()));
3212                top.insert("reason".into(), Value::String(reason));
3213            }
3214        }
3215    }
3216    top.insert("hookSpecificOutput".into(), specific);
3217    Value::Object(top).to_string() + "\n"
3218}
3219
3220pub fn format_steps(steps: &[Step]) -> String {
3221    steps
3222        .iter()
3223        .map(|s| {
3224            format!(
3225                "{}\t{}\t{}\n",
3226                if s.ok { "ok" } else { "no" },
3227                s.what,
3228                s.detail
3229            )
3230        })
3231        .collect()
3232}
3233
3234/// The runner rows for `doctor`, one pair per runner the file names.
3235fn harness_rows() -> Vec<Habitat> {
3236    let path = harnesses_path();
3237    let all = match harnesses_from(&path) {
3238        Ok(all) => all,
3239        Err(e) => {
3240            return vec![Habitat {
3241                name: "runners",
3242                state: format!("{e:#}"),
3243                ok: false,
3244            }]
3245        }
3246    };
3247    if all.harness.is_empty() {
3248        return vec![Habitat {
3249            name: "runners",
3250            state: format!(
3251                "none named in {}; `ljos onboard --example` prints the shape",
3252                path.display()
3253            ),
3254            ok: false,
3255        }];
3256    }
3257    let server = server_path().unwrap_or_else(|_| PathBuf::from("ljos-mcp"));
3258    let mut rows = Vec::new();
3259    for h in &all.harness {
3260        let registered = is_registered(h, &server) == Some(true);
3261        let probed = (registered && !h.probe.is_empty()).then(|| probe_lists_ljos(&h.probe));
3262        rows.push(Habitat {
3263            name: "runner mcp",
3264            state: match (registered, &probed) {
3265                (false, _) => format!(
3266                    "{}: not registered; ljos onboard --harness {}",
3267                    h.name, h.name
3268                ),
3269                (true, Some(Err(why))) => format!(
3270                    "{}: registered, but `{}` does not list ljos_sitting: {why}",
3271                    h.name,
3272                    h.probe.join(" ")
3273                ),
3274                (true, Some(Ok(()))) => format!("{}: ljos registered and loads", h.name),
3275                (true, None) => format!("{}: ljos registered", h.name),
3276            },
3277            ok: registered && !matches!(probed, Some(Err(_))),
3278        });
3279        let skill = h
3280            .skills
3281            .as_deref()
3282            .map(|d| expand(d).join("ljos").join("SKILL.md"));
3283        let current = skill
3284            .as_ref()
3285            .is_some_and(|p| std::fs::read_to_string(p).is_ok_and(|t| t == skill_text()));
3286        if let Some(file) = &h.hooks {
3287            let path = expand(file);
3288            let installed = hook_installed(&path, &hook_events_of(h));
3289            rows.push(Habitat {
3290                name: "runner hook",
3291                state: if installed {
3292                    format!("{}: memory hook on {}", h.name, path.display())
3293                } else {
3294                    format!(
3295                        "{}: no memory hook; ljos onboard --harness {}",
3296                        h.name, h.name
3297                    )
3298                },
3299                ok: installed,
3300            });
3301        } else if h.plugin.is_none() {
3302            if let Some(cfg) = &h.config {
3303                let path = expand(cfg);
3304                let installed =
3305                    std::fs::read_to_string(&path).is_ok_and(|t| t.contains("ljos hook"));
3306                rows.push(Habitat {
3307                    name: "runner hook",
3308                    state: if installed {
3309                        format!("{}: memory hook in {}", h.name, path.display())
3310                    } else {
3311                        format!(
3312                            "{}: no memory hook in {}; ljos onboard --harness {}",
3313                            h.name,
3314                            path.display(),
3315                            h.name
3316                        )
3317                    },
3318                    ok: installed,
3319                });
3320            }
3321        }
3322        if let Some(dest) = &h.plugin {
3323            let path = expand(dest);
3324            let want = ljos_path().ok().and_then(|l| plugin_text(h, &l));
3325            let current = want
3326                .as_ref()
3327                .is_some_and(|w| std::fs::read_to_string(&path).is_ok_and(|t| &t == w));
3328            rows.push(Habitat {
3329                name: "runner hook",
3330                state: if current {
3331                    format!("{}: plugin {}", h.name, path.display())
3332                } else if path.is_file() {
3333                    format!(
3334                        "{}: plugin {} is stale; ljos onboard --harness {}",
3335                        h.name,
3336                        path.display(),
3337                        h.name
3338                    )
3339                } else {
3340                    format!("{}: no plugin; ljos onboard --harness {}", h.name, h.name)
3341                },
3342                ok: current,
3343            });
3344        }
3345        rows.push(Habitat {
3346            name: "runner skill",
3347            state: match (&skill, current) {
3348                (Some(p), true) => format!("{}: {}", h.name, p.display()),
3349                (Some(p), false) if p.is_file() => {
3350                    format!(
3351                        "{}: {} is stale; ljos onboard --harness {}",
3352                        h.name,
3353                        p.display(),
3354                        h.name
3355                    )
3356                }
3357                (Some(_), false) => {
3358                    format!("{}: absent; ljos onboard --harness {}", h.name, h.name)
3359                }
3360                (None, _) => format!("{}: no skills directory named", h.name),
3361            },
3362            ok: current,
3363        });
3364    }
3365    rows
3366}
3367
3368/// Run a runner's probe with a thirty-second limit; it passes when it
3369/// exits 0 and its output names `ljos_sitting`.
3370fn probe_lists_ljos(argv: &[String]) -> std::result::Result<(), String> {
3371    use std::io::Read;
3372    use std::process::{Command, Stdio};
3373    let (bin, args) = argv.split_first().ok_or("empty probe")?;
3374    let mut child = Command::new(expand(bin))
3375        .args(args)
3376        .stdin(Stdio::null())
3377        .stdout(Stdio::piped())
3378        .stderr(Stdio::piped())
3379        .spawn()
3380        .map_err(|e| format!("{bin}: {e}"))?;
3381    let started = std::time::Instant::now();
3382    let status = loop {
3383        match child.try_wait() {
3384            Ok(Some(status)) => break status,
3385            Ok(None) if started.elapsed() > std::time::Duration::from_secs(30) => {
3386                let _ = child.kill();
3387                let _ = child.wait();
3388                return Err("no answer in 30 s".into());
3389            }
3390            Ok(None) => std::thread::sleep(std::time::Duration::from_millis(100)),
3391            Err(e) => return Err(e.to_string()),
3392        }
3393    };
3394    let mut out = String::new();
3395    if let Some(mut o) = child.stdout.take() {
3396        let _ = o.read_to_string(&mut out);
3397    }
3398    if let Some(mut e) = child.stderr.take() {
3399        let _ = e.read_to_string(&mut out);
3400    }
3401    if !status.success() {
3402        return Err(format!("exit {}", status.code().unwrap_or(-1)));
3403    }
3404    if out.contains("ljos_sitting") {
3405        Ok(())
3406    } else {
3407        Err("its output names no ljos tool".into())
3408    }
3409}
3410
3411/// Have a pack writer up before anything else is wired: a runner onboarded
3412/// to a seat with no writer would meet every memory verb failing. `packset
3413/// ensure` starts one when none answers and is idempotent when one does.
3414fn pack_step(dry: bool) -> Step {
3415    let what = "pack".to_string();
3416    if let Ok(client) = pack() {
3417        if client.health().is_ok() {
3418            return Step {
3419                what,
3420                detail: format!("writer up at {}", client.base()),
3421                ok: true,
3422            };
3423        }
3424    } else {
3425        return Step {
3426            what,
3427            detail: "PACKSET_URL=off; no pack on purpose".into(),
3428            ok: true,
3429        };
3430    }
3431    if !on_path("packset") {
3432        return Step {
3433            what,
3434            detail: "no writer answers and packset is not on PATH".into(),
3435            ok: false,
3436        };
3437    }
3438    if dry {
3439        return Step {
3440            what,
3441            detail: "would run packset ensure".into(),
3442            ok: true,
3443        };
3444    }
3445    match run_captured("packset", &["ensure"]) {
3446        Ok(said) => Step {
3447            what,
3448            detail: format!(
3449                "started a writer: {}",
3450                said.stdout.lines().next().unwrap_or("").trim()
3451            ),
3452            ok: true,
3453        },
3454        Err(e) => Step {
3455            what,
3456            detail: e.to_string().lines().next().unwrap_or("").to_string(),
3457            ok: false,
3458        },
3459    }
3460}
3461
3462/// Make the seat's host key at `~/.config/deedar/host.key` when there is
3463/// none, so handovers go out signed from the first one. An existing key, or
3464/// one named by `DEEDAR_HOST_SIGNING_KEY`, is left alone.
3465fn host_key_step(dry: bool) -> Step {
3466    if let Some(path) = host_key_path() {
3467        return Step {
3468            what: "host key".into(),
3469            detail: format!("{} exists", path.display()),
3470            ok: true,
3471        };
3472    }
3473    if std::env::var_os("DEEDAR_HOST_SIGNING_KEY").is_some_and(|r| r == "off") {
3474        return Step {
3475            what: "host key".into(),
3476            detail: "DEEDAR_HOST_SIGNING_KEY=off; handovers go out unsigned on purpose".into(),
3477            ok: true,
3478        };
3479    }
3480    let Some(path) = default_host_key_path() else {
3481        return Step {
3482            what: "host key".into(),
3483            detail: "no home directory to keep a key in".into(),
3484            ok: false,
3485        };
3486    };
3487    if dry {
3488        return Step {
3489            what: "host key".into(),
3490            detail: format!("would write a 32-byte seed to {}", path.display()),
3491            ok: true,
3492        };
3493    }
3494    let made = (|| -> std::io::Result<()> {
3495        use std::io::Read;
3496        let mut seed = [0u8; 32];
3497        std::fs::File::open("/dev/urandom")?.read_exact(&mut seed)?;
3498        if let Some(dir) = path.parent() {
3499            std::fs::create_dir_all(dir)?;
3500        }
3501        std::fs::write(&path, seed)?;
3502        #[cfg(unix)]
3503        {
3504            use std::os::unix::fs::PermissionsExt;
3505            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
3506        }
3507        Ok(())
3508    })();
3509    match made {
3510        Ok(()) => Step {
3511            what: "host key".into(),
3512            detail: format!("wrote a 32-byte seed to {}", path.display()),
3513            ok: true,
3514        },
3515        Err(e) => Step {
3516            what: "host key".into(),
3517            detail: format!("{}: {e}", path.display()),
3518            ok: false,
3519        },
3520    }
3521}
3522
3523/// `$XDG_CONFIG_HOME/deedar/host.key`, whether or not it exists.
3524fn default_host_key_path() -> Option<PathBuf> {
3525    let config = std::env::var_os("XDG_CONFIG_HOME")
3526        .filter(|r| !r.is_empty())
3527        .map(PathBuf::from)
3528        .or_else(|| home().ok().map(|h| h.join(".config")))?;
3529    Some(config.join("deedar").join("host.key"))
3530}
3531
3532/// The host key `deedar` will sign with: `DEEDAR_HOST_SIGNING_KEY`, else
3533/// `~/.config/deedar/host.key` when it exists. `off` is no key on purpose.
3534fn host_key_path() -> Option<PathBuf> {
3535    if let Some(raw) = std::env::var_os("DEEDAR_HOST_SIGNING_KEY").filter(|r| !r.is_empty()) {
3536        return (raw != "off").then(|| PathBuf::from(raw));
3537    }
3538    let path = default_host_key_path()?;
3539    path.is_file().then_some(path)
3540}
3541
3542/// `raw` with a leading `~` or `~/` put against `home`; `None` when there is
3543/// nothing to expand.
3544pub fn expand_leading_tilde(raw: &str, home: &str) -> Option<String> {
3545    let home = home.trim_end_matches('/');
3546    if raw == "~" {
3547        return Some(home.to_string());
3548    }
3549    raw.strip_prefix("~/").map(|rest| format!("{home}/{rest}"))
3550}
3551
3552/// Expand a leading `~` in `ISSUE_ROOT` and `VISSUE_ROOT` once, at start.
3553/// environment.d and MCP `env` blocks pass `~/...` through unexpanded; a
3554/// tracker crate that predates the fix then resolves it against the working
3555/// directory, and every child `vissue` inherits the same relative root.
3556pub fn normalize_tracker_env() {
3557    let Some(home) = std::env::var_os("HOME").filter(|h| !h.is_empty()) else {
3558        return;
3559    };
3560    let home = home.to_string_lossy().to_string();
3561    for var in ["ISSUE_ROOT", "VISSUE_ROOT"] {
3562        if let Ok(raw) = std::env::var(var) {
3563            if let Some(expanded) = expand_leading_tilde(&raw, &home) {
3564                std::env::set_var(var, expanded);
3565            }
3566        }
3567    }
3568}
3569
3570/// Printed on stderr. `ljos-policyd` is the TCB when it exists.
3571pub const POLICY_TCB: &str =
3572    "argv law. ljos-policyd is the TCB when present. Reloading a pack is not a check.";
3573
3574/// The workspace the seat's memory lives in when nothing names one. The
3575/// pack's command line keys a workspace to the repository it stands in;
3576/// a seat is one memory across every repository it works in, so the seat
3577/// pins one. `PACKSET_WORKSPACE` overrides it.
3578pub const SEAT_WORKSPACE: &str = "seat";
3579
3580/// The pack client. With nothing set it speaks to `127.0.0.1:8761` about
3581/// the `seat` workspace; `PACKSET_URL` points elsewhere, `PACKSET_WORKSPACE`
3582/// names another workspace, and `PACKSET_URL=off` is the one way to have no
3583/// pack.
3584/// Load `~/.config/ljos/env` (KEY=VALUE) when the process has not set
3585/// those keys. The shell and the MCP seat then share one pack.
3586fn load_seat_env() {
3587    let Ok(home) = home() else {
3588        return;
3589    };
3590    let path = home.join(".config/ljos/env");
3591    let Ok(text) = std::fs::read_to_string(path) else {
3592        return;
3593    };
3594    for line in text.lines() {
3595        let line = line.trim();
3596        if line.is_empty() || line.starts_with('#') {
3597            continue;
3598        }
3599        let Some((k, v)) = line.split_once('=') else {
3600            continue;
3601        };
3602        let k = k.trim();
3603        if k.is_empty() || std::env::var_os(k).is_some() {
3604            continue;
3605        }
3606        std::env::set_var(k, v.trim());
3607    }
3608}
3609
3610/// A transport failure, as distinct from a writer that answered and refused.
3611fn writer_unreachable(err: &anyhow::Error) -> bool {
3612    err.chain().any(|cause| {
3613        cause
3614            .downcast_ref::<packset_client::Error>()
3615            .is_some_and(|inner| matches!(inner, packset_client::Error::Http(_)))
3616    })
3617}
3618
3619/// Start the default writer when a memory verb could not connect.
3620/// `PACKSET_URL=off` is left alone. A URL pointed somewhere else is not
3621/// replaced with the default writer.
3622fn ensure_writer() -> Result<()> {
3623    if std::env::var("PACKSET_URL").ok().as_deref() == Some("off") {
3624        return Ok(());
3625    }
3626    if std::env::var("PACKSET_URL")
3627        .ok()
3628        .is_some_and(|url| !url.is_empty())
3629    {
3630        bail!(
3631            "the pack writer at PACKSET_URL is not answering. This seat is not pointed at the default writer, so it was not started"
3632        );
3633    }
3634    if !on_path("packset") {
3635        bail!("no pack writer is answering, and packset is not on PATH. cargo binstall packset");
3636    }
3637    run_captured("packset", &["ensure"]).context("packset ensure")?;
3638    Ok(())
3639}
3640
3641fn with_writer<T>(op: impl Fn() -> Result<T>) -> Result<T> {
3642    match op() {
3643        Ok(value) => Ok(value),
3644        Err(err) if writer_unreachable(&err) => {
3645            ensure_writer()?;
3646            op()
3647        }
3648        Err(err) => Err(err),
3649    }
3650}
3651
3652/// The pack's live atoms without their dense vectors. Every reader here
3653/// wants texts, kinds, review clocks, trust or rules; the vectors are nine
3654/// tenths of the listing, and parsing them grew one ljos-mcp from 10 to
3655/// 66 MB and kept it. A writer older than `embedding=omit` sends them
3656/// anyway, and the answer is the same.
3657///
3658/// # Errors
3659///
3660/// The pack not answering, or an answer that is not atoms.
3661pub fn atoms_lean(client: &PacksetClient, workspace: &str) -> Result<Vec<Value>> {
3662    let url = format!("{}/v1/atoms", client.base());
3663    let mut body: Value = ureq::get(&url)
3664        .query("workspace", workspace)
3665        .query("embedding", "omit")
3666        .timeout(std::time::Duration::from_secs(30))
3667        .call()
3668        .map_err(|e| anyhow::anyhow!("{url}: {e}"))?
3669        .into_json()?;
3670    let atoms = body
3671        .get_mut("atoms")
3672        .map(Value::take)
3673        .unwrap_or(Value::Array(Vec::new()));
3674    Ok(serde_json::from_value(atoms)?)
3675}
3676
3677pub fn pack() -> Result<PacksetClient> {
3678    load_seat_env();
3679    let workspace = std::env::var("PACKSET_WORKSPACE")
3680        .ok()
3681        .filter(|w| !w.is_empty())
3682        .unwrap_or_else(|| SEAT_WORKSPACE.to_string());
3683    Ok(PacksetClient::from_env()
3684        .context("PACKSET_URL=off: this seat has no pack on purpose")?
3685        .with_workspace(workspace))
3686}
3687
3688/// The pack's last write, RFC 3339, for a HUD watch. `None` when the
3689/// status has no stamp yet.
3690///
3691/// # Errors
3692///
3693/// The pack not answering.
3694pub fn pack_last_write_ts() -> Result<Option<String>> {
3695    let client = pack()?;
3696    let status = client
3697        .status(Some(&client.workspace()))
3698        .context("pack: GET /v1/status failed")?;
3699    Ok(status
3700        .get("last_write_ts")
3701        .and_then(Value::as_str)
3702        .filter(|s| !s.is_empty())
3703        .map(str::to_string))
3704}
3705
3706pub fn join(parts: &[String]) -> String {
3707    parts.join(" ")
3708}
3709
3710/// Remember → lesson, Prefer → preference. Trust rows go through [`trust_atom`].
3711pub fn atom_kind(label: &str) -> Result<&'static str> {
3712    match label {
3713        "Remember" => Ok("lesson"),
3714        "Prefer" => Ok("preference"),
3715        other => bail!("unknown write kind {other}"),
3716    }
3717}
3718
3719/// The entity every write carries: which seat wrote it. Many seats share
3720/// one pack, and a reader can then see whose lesson it is reading.
3721pub const SEAT_ENTITY: &str = "seat:";
3722
3723/// Explicit claim body. The text is stored as given; never harvested. The
3724/// entities open with the seat that wrote it.
3725pub fn atom_body(kind: &str, text: &str, workspace: &str) -> Value {
3726    serde_json::json!({
3727        "schema": "inside.atom/v1",
3728        "kind": kind,
3729        "level": "explicit",
3730        "text": text,
3731        "workspace": workspace,
3732        "entities": [format!("{SEAT_ENTITY}{}", seat_name())],
3733        "source": atom_source(),
3734    })
3735}
3736
3737/// Where a claim was written: the runner, the conversation, the host and,
3738/// when the runner stamped one, the turn. An audit reads a claim's lineage
3739/// here instead of guessing it from its entities.
3740#[must_use]
3741pub fn atom_source() -> Value {
3742    let seat = whoami();
3743    let mut source = serde_json::json!({
3744        "harness": seat.seat,
3745        "session": seat.holder,
3746        "host": sync::host(),
3747        "via": "ljos",
3748    });
3749    let turn = std::env::vars()
3750        .filter(|(k, v)| k.ends_with("_TURN_ID") && !v.trim().is_empty())
3751        .map(|(_, v)| v.trim().to_string())
3752        .next();
3753    if let Some(turn) = turn {
3754        source["turn"] = Value::String(turn);
3755    }
3756    source
3757}
3758
3759/// Add entities to a body without losing the seat's.
3760pub fn add_entities(atom: &mut Value, more: impl IntoIterator<Item = String>) {
3761    let list = atom["entities"]
3762        .as_array_mut()
3763        .map(std::mem::take)
3764        .unwrap_or_default();
3765    let mut list = list;
3766    for e in more {
3767        let v = Value::String(e);
3768        if !list.contains(&v) {
3769            list.push(v);
3770        }
3771    }
3772    atom["entities"] = Value::Array(list);
3773}
3774
3775/// POST one explicit claim. Callers pass Remember/Prefer only.
3776pub fn post_claim(
3777    client: &PacksetClient,
3778    label: &str,
3779    text: &str,
3780    workspace: &str,
3781) -> Result<Value> {
3782    post_claim_horizon(client, label, text, workspace, None)
3783}
3784
3785fn post_claim_horizon(
3786    client: &PacksetClient,
3787    label: &str,
3788    text: &str,
3789    workspace: &str,
3790    transient: Option<bool>,
3791) -> Result<Value> {
3792    let trimmed = text.trim();
3793    if trimmed.is_empty() {
3794        bail!("{label}: empty text is not a claim");
3795    }
3796    let kind = atom_kind(label)?;
3797    let mut atom = atom_body(kind, trimmed, workspace);
3798    stamp_horizon(&mut atom, kind, trimmed, transient);
3799    with_writer(|| {
3800        client
3801            .post_atom(&atom)
3802            .with_context(|| format!("{label}: POST /v1/atoms failed"))
3803    })
3804}
3805
3806/// `horizon:standing` or `horizon:transient` on a claim as it is written.
3807/// A preference is a rule. A lesson is an episode until a recalled review
3808/// or a consolidation promotes it, unless the caller said which it is.
3809fn stamp_horizon(atom: &mut Value, kind: &str, _text: &str, force: Option<bool>) {
3810    let transient = match (kind, force) {
3811        ("preference", _) => false,
3812        (_, Some(flag)) => flag,
3813        _ => true,
3814    };
3815    let tag = if transient {
3816        "horizon:transient"
3817    } else {
3818        "horizon:standing"
3819    };
3820    add_entities(atom, [tag.to_string()]);
3821}
3822
3823pub fn packset_write(label: &str, text: &str) -> Result<Value> {
3824    packset_write_as(label, text, None, None)
3825}
3826
3827/// [`packset_write`] for a lesson learned on an issue: it carries an
3828/// `issue:ID` entity naming where it was learned, and a `scope:NAME`
3829/// entity when one is given, so the claim travels with that scope's log
3830/// rather than the machine's default.
3831///
3832/// # Errors
3833///
3834/// An empty text, an unknown label, or the pack refusing the claim.
3835pub fn packset_write_scoped(
3836    label: &str,
3837    text: &str,
3838    issue: &str,
3839    scope: Option<&str>,
3840) -> Result<Value> {
3841    let client = pack()?;
3842    let workspace = client.workspace();
3843    let trimmed = text.trim();
3844    if trimmed.is_empty() {
3845        bail!("{label}: empty text is not a claim");
3846    }
3847    let kind = atom_kind(label)?;
3848    let mut atom = atom_body(kind, trimmed, &workspace);
3849    let mut tags = vec![format!("issue:{}", issue.trim())];
3850    if let Some(scope) = scope.map(str::trim).filter(|s| !s.is_empty()) {
3851        tags.push(format!("scope:{scope}"));
3852    }
3853    add_entities(&mut atom, tags);
3854    stamp_horizon(&mut atom, kind, trimmed, None);
3855    with_writer(|| {
3856        client
3857            .post_atom(&atom)
3858            .with_context(|| format!("{label}: POST /v1/atoms failed"))
3859    })
3860}
3861
3862/// The entity a persona's own claims carry, so a brief can find them.
3863#[must_use]
3864pub fn persona_entity(name: &str) -> String {
3865    format!("persona:{}", name.trim().to_lowercase())
3866}
3867
3868/// The set a persona's own conclusions live in: `persona-<name>`, in the
3869/// pack's set alphabet. A set is its own tree for the duplicate and
3870/// replacement rules, so a persona's lesson never closes the seat's or
3871/// another persona's, and the seat still reads them all.
3872#[must_use]
3873pub fn persona_set(name: &str) -> String {
3874    let mut out = String::from("persona-");
3875    for c in name.trim().to_lowercase().chars() {
3876        if c.is_ascii_lowercase() || c.is_ascii_digit() {
3877            out.push(c);
3878        } else if !out.ends_with('-') {
3879            out.push('-');
3880        }
3881    }
3882    out.trim_end_matches('-').chars().take(32).collect()
3883}
3884
3885/// [`packset_write`] as a persona: the claim carries the persona's entity,
3886/// so what a persona learned comes back to it first in its next brief and
3887/// stays in the seat's one pack. A persona accumulates its own lessons the
3888/// way a reviewer does; the seat still reads them all.
3889pub fn packset_write_as(
3890    label: &str,
3891    text: &str,
3892    persona: Option<&str>,
3893    transient: Option<bool>,
3894) -> Result<Value> {
3895    let client = pack()?;
3896    let workspace = client.workspace();
3897    let Some(name) = persona.map(str::trim).filter(|n| !n.is_empty()) else {
3898        return post_claim_horizon(&client, label, text, &workspace, transient);
3899    };
3900    let trimmed = text.trim();
3901    if trimmed.is_empty() {
3902        bail!("{label}: empty text is not a claim");
3903    }
3904    let kind = atom_kind(label)?;
3905    let mut atom = atom_body(kind, trimmed, &workspace);
3906    add_entities(&mut atom, [persona_entity(name)]);
3907    stamp_horizon(&mut atom, kind, trimmed, transient);
3908    // Its own tree: the persona's conclusions replace and duplicate among
3909    // themselves, not against the seat's or another persona's.
3910    atom["set"] = Value::String(persona_set(name));
3911    with_writer(|| {
3912        client
3913            .post_atom(&atom)
3914            .with_context(|| format!("{label}: POST /v1/atoms failed"))
3915    })
3916}
3917
3918/// Retire one atom from the workspace the cwd resolves to, optionally naming
3919/// the deed that withdrew it.
3920///
3921/// The daemon tombstones rather than erases: the atom stops being recalled and
3922/// the pack still records that it was held and withdrawn. That is the right
3923/// shape for standing knowledge, where "we no longer believe this" is itself
3924/// worth keeping.
3925///
3926/// `why` is a deed accession and the pack refuses free text in its place. It
3927/// runs the same join as a remembered claim's `entities`, in the same
3928/// direction: the pack cites the deed store, never the other way round. A
3929/// retraction the work justified is therefore checkable with `deedar evidence`
3930/// like any other citation, and one nothing justified simply carries no `why`.
3931///
3932/// # Errors
3933///
3934/// An unset `PACKSET_URL`, an id the workspace does not hold, a `why` that is
3935/// not an accession, or the request's.
3936pub fn packset_forget(id: &str, why: Option<&str>) -> Result<Value> {
3937    let trimmed = id.trim();
3938    if trimmed.is_empty() {
3939        bail!("forget: an atom id is required");
3940    }
3941    let why = why.map(str::trim).filter(|w| !w.is_empty());
3942    let client = pack()?;
3943    let workspace = client.workspace();
3944    client
3945        .delete_atom(&workspace, trimmed, why)
3946        .with_context(|| format!("forget: POST /v1/atoms/delete failed for {trimmed}"))
3947}
3948
3949/// One row of the influence graph: `from` listens to `to` with `weight`.
3950/// `about` scopes the row to the domains it speaks to: a row with none
3951/// applies everywhere, a row with some applies when one of them meets the
3952/// issue at hand (its title, or the entities of the island it activates).
3953#[derive(Debug, Clone, PartialEq, Default)]
3954pub struct Trust {
3955    pub from: String,
3956    pub to: String,
3957    pub weight: f64,
3958    pub about: Vec<String>,
3959}
3960
3961/// A voter with a view of its own: a persona. `anchor` in `[0, 1]` is how
3962/// far it moves off its ballot in a settle; 0 never moves, 1 is a plain
3963/// DeGroot voter. `entities` are the domains it speaks to.
3964#[derive(Debug, Clone, PartialEq)]
3965pub struct Persona {
3966    pub name: String,
3967    pub anchor: f64,
3968    pub view: String,
3969    pub entities: Vec<String>,
3970}
3971
3972/// The `persona` atom for the pack: kind `persona`, the view as text.
3973///
3974/// # Errors
3975///
3976/// An empty name, an anchor outside `[0, 1]`, or an empty view.
3977pub fn persona_atom(p: &Persona, workspace: &str) -> Result<Value> {
3978    let name = p.name.trim();
3979    if name.is_empty() {
3980        bail!("persona: a name is required");
3981    }
3982    if !(0.0..=1.0).contains(&p.anchor) {
3983        bail!("persona: anchor {} is not in [0, 1]", p.anchor);
3984    }
3985    let view = p.view.trim();
3986    if view.is_empty() {
3987        bail!("persona: say in a sentence or two how {name} reads the work");
3988    }
3989    let mut atom = atom_body("persona", view, workspace);
3990    atom["name"] = Value::String(name.into());
3991    atom["anchor"] = serde_json::json!(p.anchor);
3992    if !p.entities.is_empty() {
3993        add_entities(&mut atom, p.entities.iter().map(|e| e.to_lowercase()));
3994    }
3995    Ok(atom)
3996}
3997
3998/// POST one persona. A persona of the same name already in the pack is
3999/// superseded, so a rewrite moves the roster without leaving the old view
4000/// live. Every persona is owed one unscoped inbound trust row; `--about`
4001/// on a later trust row only adds weight, it does not replace that floor.
4002pub fn write_persona(p: &Persona) -> Result<Value> {
4003    let client = pack()?;
4004    let workspace = client.workspace();
4005    let mut atom = persona_atom(p, &workspace)?;
4006    let previous: Vec<Value> = client
4007        .atoms_of_kind(&workspace, "persona")
4008        .unwrap_or_default()
4009        .into_iter()
4010        .filter(|a| a.get("name").and_then(Value::as_str) == Some(p.name.trim()))
4011        .filter_map(|a| {
4012            a.get("id")
4013                .and_then(Value::as_str)
4014                .map(|id| Value::String(id.to_string()))
4015        })
4016        .collect();
4017    if !previous.is_empty() {
4018        atom["supersedes"] = Value::Array(previous);
4019    }
4020    let posted = client
4021        .post_atom(&atom)
4022        .context("persona: POST /v1/atoms failed")?;
4023    ensure_unscoped_inbound(p)?;
4024    Ok(posted)
4025}
4026
4027/// The unscoped inbound row a persona is owed: the seat weighs it at 1,
4028/// everywhere. None when the seat and the persona are the same name
4029/// (a row cannot weigh itself).
4030#[must_use]
4031pub fn inbound_floor(p: &Persona, seat: &str) -> Option<Trust> {
4032    let to = p.name.trim();
4033    let from = seat.trim();
4034    if to.is_empty() || from.is_empty() || from == to {
4035        return None;
4036    }
4037    Some(Trust {
4038        from: from.to_string(),
4039        to: to.to_string(),
4040        weight: 1.0,
4041        about: Vec::new(),
4042    })
4043}
4044
4045/// Whether `name` already has the seat's unscoped inbound row in `rows`.
4046/// A third-party unscoped row does not seat this persona.
4047#[must_use]
4048pub fn has_unscoped_inbound(rows: &[Trust], name: &str, seat: &str) -> bool {
4049    let name = name.trim();
4050    let seat = seat.trim();
4051    rows.iter()
4052        .any(|r| r.from == seat && r.to == name && r.about.is_empty() && r.weight > 0.0)
4053}
4054
4055fn ensure_unscoped_inbound(p: &Persona) -> Result<()> {
4056    let name = p.name.trim();
4057    let seat = seat_name();
4058    if has_unscoped_inbound(&trust_from_pack().unwrap_or_default(), name, &seat) {
4059        return Ok(());
4060    }
4061    let Some(row) = inbound_floor(p, &seat) else {
4062        return Ok(());
4063    };
4064    write_trust(&row, &[]).map(|_| ())
4065}
4066
4067/// The live personas: the latest `persona` atom per name.
4068pub fn personas_of(atoms: &[Value]) -> Vec<Persona> {
4069    let mut latest: std::collections::BTreeMap<String, (String, Persona)> =
4070        std::collections::BTreeMap::new();
4071    for atom in atoms {
4072        if atom.get("kind").and_then(Value::as_str) != Some("persona") {
4073            continue;
4074        }
4075        let (Some(name), Some(anchor)) = (
4076            atom.get("name").and_then(Value::as_str),
4077            atom.get("anchor").and_then(Value::as_f64),
4078        ) else {
4079            continue;
4080        };
4081        let ts = atom
4082            .get("ts")
4083            .and_then(Value::as_str)
4084            .unwrap_or("")
4085            .to_string();
4086        let p = Persona {
4087            name: name.to_string(),
4088            anchor,
4089            view: atom
4090                .get("text")
4091                .and_then(Value::as_str)
4092                .unwrap_or("")
4093                .to_string(),
4094            entities: domains_of(atom.get("entities")),
4095        };
4096        match latest.get(name) {
4097            Some((seen, _)) if *seen > ts => {}
4098            _ => {
4099                latest.insert(name.to_string(), (ts, p));
4100            }
4101        }
4102    }
4103    latest.into_values().map(|(_, p)| p).collect()
4104}
4105
4106/// The personas in the seat's pack.
4107pub fn personas_from_pack() -> Result<Vec<Persona>> {
4108    let client = pack()?;
4109    // One kind, not the pack: a roster of a dozen does not carry every
4110    // lesson's embedding across the socket.
4111    let atoms = client
4112        .atoms_of_kind(&client.workspace(), "persona")
4113        .context("persona: GET /v1/atoms?kind=persona failed")?;
4114    Ok(personas_of(&atoms))
4115}
4116
4117/// A recipe a sitting copies before personas enter. `models` are optional
4118/// spawn hints; every panel still ends in `ljos vote --as` then
4119/// `ljos consensus`.
4120#[derive(Debug, Clone, PartialEq, Eq)]
4121pub struct Playbook {
4122    pub name: String,
4123    pub body: String,
4124    pub models: Vec<String>,
4125}
4126
4127/// The closed set. Write, list, bind, and copy refuse any other name.
4128pub const PLAYBOOK_NAMES: &[&str] = &["sit", "arena", "land", "company-panel", "overnight"];
4129
4130/// The five shipped recipes. Kind `playbook`, weighed not recalled.
4131pub const SHIPPED_PLAYBOOK_NAMES: &[&str] = PLAYBOOK_NAMES;
4132
4133/// Five named principles, invocable mid-sitting, mapped onto existing law.
4134pub const PRINCIPLES: &str = "\
4135== principles
4136split-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.
4137prove-on-real-surface: measure on the host the users run. A cheaper substitute is not the result.
4138open-sibling-first: a second implementer opens a sibling leftover, not a rewrite of the first tree.
4139arena-then-compose: designs write scratch; the host writes a rubric on a compose child; personas vote the compose `--as`.
4140one-step-delegate: a subagent is one playbook step. No resume across phases. A new task is a new sitting.
4141";
4142
4143/// The scoring sheet a compose is voted on. Personas vote the compose, not
4144/// accept-at-most-one on the designs.
4145pub const RUBRIC: &str = "\
4146== rubric
41471. Ledger intact. `ljos vote --as` and DeGroot stay. No schema_yes, no BARMA, no host for-loop of accepts.
41482. Playbook before panel. Sitting names one recipe and copies it before personas enter.
41493. Rubric in brief. `ljos brief` carries the playbook step, these principles, and this sheet.
41504. One-step delegate. Subagent = one playbook step. No resume across phases.
41515. Unscoped inbound trust. Every panel persona has one unscoped inbound row; `--about` only adds weight.
41526. No second plugin. Do not copy 47 skills, poteto-mode, Benny, or Cursor model files.
41537. Small surface. Prefer pack atoms and brief fields over a new crate. Musl CLI stays iced-free.
41548. 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).
4155";
4156
4157const SIT_BODY: &str = "\
4158A 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.
4159
41601. Open with `ljos sitting ISSUE --playbook sit`. Read doctor, cards, due, island, this recipe, recall, timeline, claim.
41612. Grade due claims (`ljos graded ID`).
41623. Do the work on this claim only. Artefacts are deeds, then `ljos deed ISSUE --add ACCESSION`. Lessons are `ljos remember` in two sentences.
41634. One playbook step is the whole sitting. A subagent takes this recipe and this issue; it does not resume a later phase.
41645. Close with `ljos finish ISSUE --lesson \"...\"`. Completing the node does not close the ticket. `ljos finish ISSUE --close` does, when the work is accepted.
4165";
4166
4167const ARENA_BODY: &str = "\
4168Designs compete; the host writes a rubric; personas vote a compose, not the designs.
4169
41701. Bind this recipe: `ljos sitting ISSUE --playbook arena` or `ljos playbook ISSUE arena`.
41712. Each design writes scratch (summary and body). Do not vote the design children as accept-at-most-one.
41723. The host writes a compose child and a rubric with named axes. Personas vote the compose `--as`.
41734. 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.
41745. `ljos consensus ISSUE` settles under trust rows and DeGroot. `ljos vote --as` stays.
4175";
4176
4177const LAND_BODY: &str = "\
4178Land a chosen design on the real surface.
4179
41801. Bind `land`. Sitting copies this body before recall.
41812. Prove on the real surface: the host the users run, the crate they install. A cheaper substitute is not the result.
41823. Keep A's fence: no 47 skills, no poteto-mode, no Benny, musl iced-free, `ljos vote --as` and DeGroot stay.
41834. One step per subagent. Open a sibling first when a second implementer is in flight.
41845. Close with finish. Do not ship a count as consensus.
4185";
4186
4187const COMPANY_PANEL_BODY: &str = "\
4188A panel of personas on one bound recipe.
4189
41901. Bind `company-panel` before any persona enters. `ljos panel` refuses if none is bound.
41912. Every persona has one unscoped inbound trust row; `--about` only adds weight.
41923. `ljos brief NAME ISSUE` reprints this recipe in full, the five named principles, and the arena rubric.
41934. 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`.
41945. Do not resume across phases. A new task is a new sitting.
4195";
4196
4197const OVERNIGHT_BODY: &str = "\
4198Drive work while unattended, still one sitting.
4199
42001. Bind `overnight`. Name a checkable finish condition on the issue.
42012. One playbook step per subagent. No session-pickup, no resume across phases.
42023. Isolated worktree. Prove on the real surface before claiming done.
42034. Decision log is tracker notes and deeds, not a second ledger.
42045. `ljos finish` when the condition holds; otherwise `ljos release` and a new sitting.
4205";
4206
4207/// The five shipped playbooks, bodies in full, model roles as spawn hints.
4208#[must_use]
4209pub fn shipped_playbooks() -> Vec<Playbook> {
4210    vec![
4211        Playbook {
4212            name: "sit".into(),
4213            body: SIT_BODY.trim().into(),
4214            models: Vec::new(),
4215        },
4216        Playbook {
4217            name: "arena".into(),
4218            body: ARENA_BODY.trim().into(),
4219            models: vec!["judgment".into(), "instruction".into(), "fast".into()],
4220        },
4221        Playbook {
4222            name: "land".into(),
4223            body: LAND_BODY.trim().into(),
4224            models: Vec::new(),
4225        },
4226        Playbook {
4227            name: "company-panel".into(),
4228            body: COMPANY_PANEL_BODY.trim().into(),
4229            models: vec!["judgment".into(), "instruction".into()],
4230        },
4231        Playbook {
4232            name: "overnight".into(),
4233            body: OVERNIGHT_BODY.trim().into(),
4234            models: Vec::new(),
4235        },
4236    ]
4237}
4238
4239/// Refuse a name that is not in [`PLAYBOOK_NAMES`].
4240///
4241/// # Errors
4242///
4243/// An unknown name.
4244pub fn parse_playbook_name(name: &str) -> Result<&'static str> {
4245    let n = name.trim();
4246    if n.is_empty() {
4247        bail!(
4248            "playbook: a name is required ({})",
4249            PLAYBOOK_NAMES.join(", ")
4250        );
4251    }
4252    PLAYBOOK_NAMES
4253        .iter()
4254        .copied()
4255        .find(|k| *k == n)
4256        .ok_or_else(|| {
4257            anyhow::anyhow!(
4258                "playbook: unknown name {n:?}; the closed set is {}",
4259                PLAYBOOK_NAMES.join(", ")
4260            )
4261        })
4262}
4263
4264/// The `playbook` atom: kind `playbook`, the recipe as text.
4265///
4266/// # Errors
4267///
4268/// An unknown name or an empty body.
4269pub fn playbook_atom(p: &Playbook, workspace: &str) -> Result<Value> {
4270    let name = parse_playbook_name(&p.name)?;
4271    let body = p.body.trim();
4272    if body.is_empty() {
4273        bail!("playbook: {name} needs a recipe body");
4274    }
4275    let mut atom = atom_body("playbook", body, workspace);
4276    atom["name"] = Value::String(name.into());
4277    if !p.models.is_empty() {
4278        atom["models"] = Value::Array(
4279            p.models
4280                .iter()
4281                .map(|m| m.trim())
4282                .filter(|m| !m.is_empty())
4283                .map(|m| Value::String(m.to_string()))
4284                .collect(),
4285        );
4286    }
4287    Ok(atom)
4288}
4289
4290/// POST one playbook. A playbook of the same name already in the pack is
4291/// superseded, so a rewrite moves the recipe without leaving the old body
4292/// live.
4293pub fn write_playbook(p: &Playbook) -> Result<Value> {
4294    let client = pack()?;
4295    let workspace = client.workspace();
4296    let mut atom = playbook_atom(p, &workspace)?;
4297    let previous: Vec<Value> = client
4298        .atoms_of_kind(&workspace, "playbook")
4299        .unwrap_or_default()
4300        .into_iter()
4301        .filter(|a| a.get("name").and_then(Value::as_str) == Some(p.name.trim()))
4302        .filter_map(|a| {
4303            a.get("id")
4304                .and_then(Value::as_str)
4305                .map(|id| Value::String(id.to_string()))
4306        })
4307        .collect();
4308    if !previous.is_empty() {
4309        atom["supersedes"] = Value::Array(previous);
4310    }
4311    client
4312        .post_atom(&atom)
4313        .context("playbook: POST /v1/atoms failed")
4314}
4315
4316/// The live playbooks: the latest `playbook` atom per name.
4317pub fn playbooks_of(atoms: &[Value]) -> Vec<Playbook> {
4318    let mut latest: std::collections::BTreeMap<String, (String, Playbook)> =
4319        std::collections::BTreeMap::new();
4320    for atom in atoms {
4321        if atom.get("kind").and_then(Value::as_str) != Some("playbook") {
4322            continue;
4323        }
4324        let Some(name) = atom.get("name").and_then(Value::as_str) else {
4325            continue;
4326        };
4327        if parse_playbook_name(name).is_err() {
4328            continue;
4329        }
4330        let ts = atom
4331            .get("ts")
4332            .and_then(Value::as_str)
4333            .unwrap_or("")
4334            .to_string();
4335        let p = Playbook {
4336            name: name.to_string(),
4337            body: atom
4338                .get("text")
4339                .and_then(Value::as_str)
4340                .unwrap_or("")
4341                .to_string(),
4342            models: atom
4343                .get("models")
4344                .and_then(Value::as_array)
4345                .into_iter()
4346                .flatten()
4347                .filter_map(Value::as_str)
4348                .map(str::to_string)
4349                .collect(),
4350        };
4351        match latest.get(name) {
4352            Some((seen, _)) if *seen > ts => {}
4353            _ => {
4354                latest.insert(name.to_string(), (ts, p));
4355            }
4356        }
4357    }
4358    latest.into_values().map(|(_, p)| p).collect()
4359}
4360
4361fn ensure_shipped_playbooks() {
4362    let have = pack()
4363        .ok()
4364        .and_then(|c| c.atoms_of_kind(&c.workspace(), "playbook").ok())
4365        .map(|atoms| playbooks_of(&atoms))
4366        .unwrap_or_default();
4367    for p in shipped_playbooks() {
4368        if have.iter().any(|h| h.name == p.name) {
4369            continue;
4370        }
4371        let _ = write_playbook(&p);
4372    }
4373}
4374
4375/// The roster: pack atoms, with the five shipped filled in when missing.
4376pub fn playbooks_from_pack() -> Result<Vec<Playbook>> {
4377    ensure_shipped_playbooks();
4378    let client = pack()?;
4379    let atoms = client
4380        .atoms_of_kind(&client.workspace(), "playbook")
4381        .context("playbook: GET /v1/atoms?kind=playbook failed")?;
4382    let mut got = playbooks_of(&atoms);
4383    for p in shipped_playbooks() {
4384        if !got.iter().any(|g| g.name == p.name) {
4385            got.push(p);
4386        }
4387    }
4388    got.sort_by(|a, b| a.name.cmp(&b.name));
4389    Ok(got)
4390}
4391
4392/// Pack latest for `name`, else the shipped seed. Unknown names are refused
4393/// even when the pack holds them.
4394///
4395/// # Errors
4396///
4397/// An unknown name; the error lists the closed set.
4398pub fn playbook_among(name: &str, pack: &[Playbook]) -> Result<Playbook> {
4399    let name = parse_playbook_name(name)?;
4400    if let Some(p) = pack.iter().find(|p| p.name == name) {
4401        return Ok(p.clone());
4402    }
4403    shipped_playbooks()
4404        .into_iter()
4405        .find(|p| p.name == name)
4406        .ok_or_else(|| {
4407            anyhow::anyhow!(
4408                "playbook: unknown name {name:?}; the closed set is {}",
4409                PLAYBOOK_NAMES.join(", ")
4410            )
4411        })
4412}
4413
4414/// Look up one playbook by name: pack latest first, shipped seed only when
4415/// the pack has no live atom of that name.
4416///
4417/// # Errors
4418///
4419/// Unknown name; the error lists the closed set.
4420pub fn playbook_named(name: &str) -> Result<Playbook> {
4421    let pack = playbooks_from_pack().unwrap_or_default();
4422    playbook_among(name, &pack)
4423}
4424
4425/// The recipe body a sitting copies, including optional spawn hints.
4426#[must_use]
4427pub fn format_playbook_copy(p: &Playbook) -> String {
4428    let mut out = format!("{}\n{}\n", p.name, p.body.trim());
4429    if !p.models.is_empty() {
4430        out.push_str("spawn hints (optional): ");
4431        out.push_str(&p.models.join(", "));
4432        out.push_str("; each subagent still ends with `ljos vote --as` then `ljos consensus`.\n");
4433    }
4434    out
4435}
4436
4437/// The roster, one playbook per line: name, spawn hints, first sentence.
4438#[must_use]
4439pub fn format_playbooks(playbooks: &[Playbook]) -> String {
4440    if playbooks.is_empty() {
4441        return "no playbooks; the shipped recipes are sit, arena, land, company-panel, overnight\n"
4442            .to_string();
4443    }
4444    let width = playbooks.iter().map(|p| p.name.len()).max().unwrap_or(0);
4445    playbooks
4446        .iter()
4447        .map(|p| {
4448            let first = p
4449                .body
4450                .split_once('.')
4451                .map(|(s, _)| s.trim())
4452                .unwrap_or(p.body.trim());
4453            format!(
4454                "{:width$}  {}  {}\n",
4455                p.name,
4456                if p.models.is_empty() {
4457                    "no spawn hints".to_string()
4458                } else {
4459                    format!("hints {}", p.models.join(", "))
4460                },
4461                first
4462            )
4463        })
4464        .collect()
4465}
4466
4467/// A tracker logbook note that binds a playbook name to an issue. Latest
4468/// such note wins; empty rest is the sitting-scoped drop finish/release write.
4469pub const PLAYBOOK_NOTE_PREFIX: &str = "playbook:";
4470
4471fn playbook_key(issue: &str) -> String {
4472    issue
4473        .trim()
4474        .chars()
4475        .map(|c| {
4476            if c.is_ascii_alphanumeric() || c == '-' {
4477                c
4478            } else {
4479                '_'
4480            }
4481        })
4482        .collect()
4483}
4484
4485fn playbook_bind_path(issue: &str) -> PathBuf {
4486    runtime_dir().join(format!("playbook-{}", playbook_key(issue)))
4487}
4488
4489fn cached_playbook(issue: &str) -> Option<String> {
4490    let text = std::fs::read_to_string(playbook_bind_path(issue)).ok()?;
4491    let name = text.trim();
4492    if name.is_empty() {
4493        None
4494    } else {
4495        Some(name.to_string())
4496    }
4497}
4498
4499fn write_playbook_cache(issue: &str, name: &str) -> Result<()> {
4500    let path = playbook_bind_path(issue);
4501    if let Some(dir) = path.parent() {
4502        let _ = std::fs::create_dir_all(dir);
4503    }
4504    std::fs::write(&path, format!("{name}\n"))
4505        .with_context(|| format!("playbook: could not bind {name} on {issue}"))
4506}
4507
4508/// The playbook name bound on an issue JSON: the latest logbook note that
4509/// opens with [`PLAYBOOK_NOTE_PREFIX`]. Empty rest means this sitting dropped
4510/// it; do not walk back to an earlier bind.
4511#[must_use]
4512pub fn playbook_name_from_issue(v: &Value) -> Option<String> {
4513    let mut dated: Vec<(String, Option<String>)> = Vec::new();
4514    for e in v["logbook"].as_array().into_iter().flatten() {
4515        let Some(note) = e["note"].as_str() else {
4516            continue;
4517        };
4518        let Some(rest) = note.trim().strip_prefix(PLAYBOOK_NOTE_PREFIX) else {
4519            continue;
4520        };
4521        let name = rest.trim();
4522        let live = if name.is_empty() {
4523            None
4524        } else {
4525            Some(name.to_string())
4526        };
4527        let ts = e["timestamp"].as_str().unwrap_or("").to_string();
4528        dated.push((ts, live));
4529    }
4530    if dated.iter().any(|(ts, _)| !ts.is_empty()) {
4531        dated
4532            .into_iter()
4533            .max_by_key(|(ts, _)| ts.clone())
4534            .and_then(|(_, n)| n)
4535    } else {
4536        dated.into_iter().next().and_then(|(_, n)| n)
4537    }
4538}
4539
4540/// The playbook name bound on a tracker issue, if any.
4541///
4542/// # Errors
4543///
4544/// The tracker not answering.
4545pub fn playbook_named_on(issue: &str) -> Result<Option<String>> {
4546    let said = run_captured("vissue", &["show", issue, "--json"])?;
4547    let v: Value = serde_json::from_str(&said.stdout).context("vissue show --json")?;
4548    Ok(playbook_name_from_issue(&v))
4549}
4550
4551/// The playbook name this sitting holds, if one was bound. Tracker note is
4552/// the bind that survives the process; the runtime cache is only when the
4553/// tracker does not answer.
4554#[must_use]
4555pub fn bound_playbook(issue: &str) -> Option<String> {
4556    match playbook_named_on(issue) {
4557        Ok(name) => name,
4558        Err(_) => cached_playbook(issue),
4559    }
4560}
4561
4562/// Drop the sticky name. Finish and release call this; a new task is a
4563/// new sitting. Writes an empty `playbook:` note so the next sitting does
4564/// not reprint the previous recipe, and unlinks the runtime cache.
4565pub fn drop_playbook(issue: &str) {
4566    if bound_playbook(issue).is_some() {
4567        let _ = run_captured("vissue", &["note", issue, PLAYBOOK_NOTE_PREFIX]);
4568    }
4569    let _ = std::fs::remove_file(playbook_bind_path(issue));
4570}
4571
4572/// Hold `name` on `issue` until finish or release. A different name while
4573/// one is held is refused: mid-sitting turns re-read the same note.
4574///
4575/// # Errors
4576///
4577/// Empty issue or name, or a different recipe already bound.
4578pub fn bind_playbook(issue: &str, name: &str) -> Result<()> {
4579    let issue = issue.trim();
4580    let name = name.trim();
4581    if issue.is_empty() {
4582        bail!("playbook: an issue is required");
4583    }
4584    if name.is_empty() {
4585        bail!("playbook: a name is required");
4586    }
4587    let name = parse_playbook_name(name)?;
4588    if let Some(have) = bound_playbook(issue) {
4589        if have != name {
4590            bail!(
4591                "playbook: {issue} is bound to {have} until finish or release; \
4592                 a new task is a new sitting"
4593            );
4594        }
4595        let _ = write_playbook_cache(issue, name);
4596        return Ok(());
4597    }
4598    let note = format!("{PLAYBOOK_NOTE_PREFIX} {name}");
4599    match run_captured("vissue", &["note", issue, &note]) {
4600        Ok(_) => {
4601            let _ = write_playbook_cache(issue, name);
4602            Ok(())
4603        }
4604        Err(_) => write_playbook_cache(issue, name),
4605    }
4606}
4607
4608/// Bind `name` to `issue` and return the full recipe body. This is the
4609/// copy into the working set; sitting prints it before recall.
4610pub fn copy_playbook(issue: &str, name: &str) -> Result<String> {
4611    let p = playbook_named(name)?;
4612    bind_playbook(issue, &p.name)?;
4613    Ok(format_playbook_copy(&p))
4614}
4615
4616/// A closed-set name the issue title names, else `sit`. Longer names win
4617/// (`company-panel` before a stray `sit` token); `sitting` is not `sit`.
4618#[must_use]
4619pub fn playbook_from_title(title: &str) -> &'static str {
4620    let tokens: Vec<String> = title
4621        .to_lowercase()
4622        .split(|c: char| !c.is_ascii_alphanumeric() && c != '-')
4623        .filter(|s| !s.is_empty())
4624        .map(str::to_string)
4625        .collect();
4626    let mut names: Vec<&'static str> = PLAYBOOK_NAMES.to_vec();
4627    names.sort_by_key(|n| std::cmp::Reverse(n.len()));
4628    for name in names {
4629        if tokens.iter().any(|t| t == name) {
4630            return name;
4631        }
4632    }
4633    "sit"
4634}
4635
4636/// Which playbook a sitting copies: an explicit name, else the name already
4637/// bound on the issue (sticky until finish/release), else a closed-set
4638/// token in the title, else `sit`.
4639///
4640/// # Errors
4641///
4642/// An unknown explicit name.
4643pub fn resolve_sitting_playbook(issue: &str, title: &str, asked: Option<&str>) -> Result<String> {
4644    if let Some(name) = asked.map(str::trim).filter(|n| !n.is_empty()) {
4645        return Ok(playbook_named(name)?.name);
4646    }
4647    if let Some(name) = bound_playbook(issue) {
4648        return Ok(name);
4649    }
4650    Ok(playbook_from_title(title).to_string())
4651}
4652
4653/// The `== playbook` section of a sitting: bind when a name is given,
4654/// else reprint the sticky body, else say none is bound.
4655pub fn playbook_opening(issue: &str, name: Option<&str>) -> Result<String> {
4656    match name.map(str::trim).filter(|n| !n.is_empty()) {
4657        Some(n) => copy_playbook(issue, n),
4658        None => match bound_playbook(issue) {
4659            Some(have) => {
4660                let p = playbook_named(&have)?;
4661                Ok(format_playbook_copy(&p))
4662            }
4663            None => Ok("none bound; `ljos sitting ISSUE --playbook NAME` or \
4664                 `ljos playbook ISSUE NAME` names one. A panel is refused until then.\n"
4665                .to_string()),
4666        },
4667    }
4668}
4669
4670/// The three blocks a brief carries: playbook step (full body), named
4671/// principles, arena rubric.
4672#[must_use]
4673pub fn brief_playbook_blocks(issue: &str) -> String {
4674    let copy = match bound_playbook(issue) {
4675        Some(name) => playbook_named(&name)
4676            .map(|p| format_playbook_copy(&p))
4677            .unwrap_or_else(|e| format!("{e}\n")),
4678        None => {
4679            "none bound; `ljos playbook ISSUE NAME` names one before personas enter.\n".to_string()
4680        }
4681    };
4682    format!("== playbook\n{copy}\n{PRINCIPLES}\n{RUBRIC}")
4683}
4684
4685/// The brief a subagent playing a persona starts from: the persona's view
4686/// and domains, what the seat knows on those domains (preferences first),
4687/// and the issue's working set. One text, so a panel member reads the
4688/// same seat the rest do and still reads it its own way.
4689///
4690/// # Errors
4691///
4692/// No such persona in the pack, or the tracker or pack not answering.
4693pub fn brief(name: &str, issue: &str) -> Result<String> {
4694    let personas = personas_from_pack()?;
4695    let Some(p) = personas.iter().find(|p| p.name == name) else {
4696        let names: Vec<&str> = personas.iter().map(|p| p.name.as_str()).collect();
4697        bail!(
4698            "brief: no persona {name:?} in the pack; the pack holds {}",
4699            if names.is_empty() {
4700                "none".to_string()
4701            } else {
4702                names.join(", ")
4703            }
4704        );
4705    };
4706    let mut out = format!(
4707        "You are {}. {}\nYou hold your ballot at anchor {:.2}{}.\n\n{}",
4708        p.name,
4709        p.view,
4710        p.anchor,
4711        if p.entities.is_empty() {
4712            String::new()
4713        } else {
4714            format!("; you speak to {}", p.entities.join(", "))
4715        },
4716        brief_playbook_blocks(issue)
4717    );
4718    let mut seen = std::collections::BTreeSet::new();
4719    let mut lines = Vec::new();
4720    let now = now_utc();
4721    // What this persona remembered itself comes first: its own lessons,
4722    // written with `remember --as`, carry its entity.
4723    let client = pack()?;
4724    let own_tag = persona_entity(&p.name);
4725    // Its own set first; lessons written before sets carry the entity alone.
4726    let mut pool = client
4727        .atoms_in_set(&client.workspace(), &persona_set(&p.name))
4728        .unwrap_or_default();
4729    if let Ok(all) = client.atoms_of_kind(&client.workspace(), "lesson") {
4730        pool.extend(
4731            all.into_iter()
4732                .filter(|a| words_of(a.get("entities")).contains(&own_tag))
4733                .filter(|a| a.get("set").is_none()),
4734        );
4735    }
4736    {
4737        let atoms = pool;
4738        let mut own: Vec<&Value> = atoms.iter().filter(|a| reviewable(a)).collect();
4739        own.sort_by(|a, b| b["ts"].as_str().cmp(&a["ts"].as_str()));
4740        if !own.is_empty() {
4741            out.push_str("\nWhat you remembered yourself:\n");
4742            for a in own.iter().take(8) {
4743                if let Some(id) = a["id"].as_str() {
4744                    seen.insert(id.to_string());
4745                }
4746                out.push_str(&format!(
4747                    "- [{}{}] {}\n",
4748                    a["kind"].as_str().unwrap_or("claim"),
4749                    age_tag(a["ts"].as_str(), &now),
4750                    a["text"].as_str().unwrap_or("").trim()
4751                ));
4752            }
4753        }
4754    }
4755    let cues: Vec<String> = if p.entities.is_empty() {
4756        vec![issue_title(issue)?]
4757    } else {
4758        p.entities.clone()
4759    };
4760    for cue in &cues {
4761        let Ok(hits) = packset_search(cue) else {
4762            continue;
4763        };
4764        for h in hits.into_iter().take(5) {
4765            if UNREVIEWED_KINDS.contains(&h.kind.as_str()) {
4766                continue;
4767            }
4768            if let Some(id) = &h.id {
4769                if !seen.insert(id.clone()) {
4770                    continue;
4771                }
4772            }
4773            lines.push((h.kind == "preference", hit_line(&h, &now)));
4774        }
4775    }
4776    lines.sort_by_key(|row| std::cmp::Reverse(row.0));
4777    if !lines.is_empty() {
4778        out.push_str("\nWhat this seat knows on your domains:\n");
4779        for (_, l) in lines.iter().take(8) {
4780            out.push_str(l);
4781            out.push('\n');
4782        }
4783    }
4784    out.push_str("\nThe work:\n");
4785    out.push_str(&run_captured("vissue", &["recall", issue])?.stdout);
4786    out.push_str(&format!(
4787        "\nWalk the island as yourself before the ballot: `ljos island` on the work with `--as {}`. \
4788         The number on a row is spread along your links, not a rank of what is true. \
4789         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. \
4790         End with one ballot: `ljos vote {{issue}} --for OPTION --expect OPTION --confidence P --used deed-... --as {}`. \
4791         --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. \
4792         P is the probability you give that your own choice is the outcome. \
4793         --used none records that the ballot drew on no deed. \
4794         The line it prints is a count. `ljos consensus {{issue}}` is the settle. \
4795         A lesson of your own goes in with `ljos remember --as {} \"...\"`.\n",
4796        p.name, p.name, p.name
4797    ));
4798    Ok(out)
4799}
4800
4801/// A panel for a runner with no MCP: one brief per persona written to
4802/// `out`, named `<persona>.md`, and the lines that run it. A runner starts
4803/// one subagent per file, each ends with the ballot its brief names, and
4804/// `ljos consensus ISSUE` settles.
4805///
4806/// # Errors
4807///
4808/// No personas in the pack, or a brief that cannot be written.
4809/// The personas that speak to an issue: those whose domains meet the
4810/// words of its title or the entities of the island it activates. A pack
4811/// shared by many projects holds reviewers for all of them, and a panel on
4812/// a docs ticket does not want the CUDA reviewer. None matching, all sit.
4813#[must_use]
4814/// The roster, one persona per line: name, anchor, the domains it speaks
4815/// to, its view. Empty pack: one line saying how to write the first one.
4816pub fn format_personas(personas: &[Persona]) -> String {
4817    if personas.is_empty() {
4818        return "no personas; `ljos persona NAME --anchor A --view \"...\" --about DOMAIN` writes one\n"
4819            .to_string();
4820    }
4821    let width = personas.iter().map(|p| p.name.len()).max().unwrap_or(0);
4822    personas
4823        .iter()
4824        .map(|p| {
4825            format!(
4826                "{:width$}  anchor {:.2}  {}  {}\n",
4827                p.name,
4828                p.anchor,
4829                if p.entities.is_empty() {
4830                    "about anything".to_string()
4831                } else {
4832                    format!("about {}", p.entities.join(", "))
4833                },
4834                p.view
4835            )
4836        })
4837        .collect()
4838}
4839
4840/// A sync scope stamped on a persona, not a topic it speaks to.
4841/// Matching on it seats the whole roster, because the scope is shared.
4842fn is_scope_marker(word: &str) -> bool {
4843    word.to_lowercase().starts_with("sync:")
4844}
4845
4846/// Persona domains that are also everyday words of an issue title. A match
4847/// on one of these alone gives way to a match on a specific word.
4848const GENERIC_DOMAINS: &[&str] = &[
4849    "build",
4850    "test",
4851    "tests",
4852    "fix",
4853    "docs",
4854    "release",
4855    "review",
4856    "api",
4857    "ci",
4858    "performance",
4859    "design",
4860    "data",
4861    "web",
4862    "memory",
4863    "search",
4864    "sharing",
4865    "course",
4866    "training",
4867];
4868
4869pub fn personas_speaking_to(personas: &[Persona], words: &[String]) -> Vec<Persona> {
4870    let words: Vec<String> = words
4871        .iter()
4872        .map(|w| w.to_lowercase())
4873        .filter(|w| !is_scope_marker(w))
4874        .collect();
4875    let matched = |p: &Persona, generic: bool| {
4876        p.entities.iter().any(|d| {
4877            let d = d.to_lowercase();
4878            !is_scope_marker(&d)
4879                && GENERIC_DOMAINS.contains(&d.as_str()) == generic
4880                && words.iter().any(|w| w == &d)
4881        })
4882    };
4883    // A domain that is also an everyday word of a title ("build", "test")
4884    // seats its persona only when no persona speaks to a specific word: a
4885    // hook question that says "build next" is not a build question.
4886    let specific: Vec<Persona> = personas
4887        .iter()
4888        .filter(|p| matched(p, false))
4889        .cloned()
4890        .collect();
4891    if !specific.is_empty() {
4892        return specific;
4893    }
4894    let speaking: Vec<Persona> = personas
4895        .iter()
4896        .filter(|p| matched(p, true))
4897        .cloned()
4898        .collect();
4899    if !speaking.is_empty() {
4900        return speaking;
4901    }
4902    // No domain matched. Personas with no domains speak to every issue.
4903    // Specialists stay seated out: seating the whole pack is a count.
4904    let general: Vec<Persona> = personas
4905        .iter()
4906        .filter(|p| p.entities.is_empty())
4907        .cloned()
4908        .collect();
4909    if !general.is_empty() {
4910        return general;
4911    }
4912    // A pack of specialists only: seat the few whose own view uses the
4913    // issue's words most, so a decision still has voters with a view on it.
4914    let mut ranked: Vec<(usize, &Persona)> = personas
4915        .iter()
4916        .map(|p| {
4917            let view = p.view.to_lowercase();
4918            let hits = words
4919                .iter()
4920                .filter(|w| w.chars().count() > 3 && view.contains(w.as_str()))
4921                .count();
4922            (hits, p)
4923        })
4924        .filter(|(hits, _)| *hits > 0)
4925        .collect();
4926    ranked.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name)));
4927    ranked
4928        .into_iter()
4929        .take(PANEL_BY_VIEW)
4930        .map(|(_, p)| p.clone())
4931        .collect()
4932}
4933
4934/// How many specialists a panel seats by their views when no domain and no
4935/// generalist speaks to the issue.
4936pub const PANEL_BY_VIEW: usize = 5;
4937
4938/// The words an issue speaks in: its title's topic words, its tags, and
4939/// the entities of the island its title activates when that island is not
4940/// weak.
4941pub fn issue_words(issue: &str) -> Vec<String> {
4942    let title = issue_title(issue).unwrap_or_default();
4943    let mut words = topic_words(&title);
4944    // The tags the issue's author chose name its domains outright.
4945    if let Ok(v) = tracker_show_json(issue) {
4946        words.extend(tags_of(&v));
4947    }
4948    // A weak island is the pack's best-connected cluster, not what the title
4949    // is about: its entities seated five course reviewers on a question
4950    // about syncing memory. Only an island two scorers agreed on speaks.
4951    if packset_island(&title, false).is_ok_and(|i| !i["weak"].as_bool().unwrap_or(false)) {
4952        words.extend(island_entities(issue).unwrap_or_default());
4953    }
4954    words
4955}
4956
4957/// An issue's tags from its tracker record, lower-cased.
4958fn tags_of(v: &Value) -> Vec<String> {
4959    v["tags"]
4960        .as_array()
4961        .into_iter()
4962        .flatten()
4963        .filter_map(Value::as_str)
4964        .map(str::to_lowercase)
4965        .collect()
4966}
4967
4968pub fn panel(issue: &str, out: &Path) -> Result<String> {
4969    if bound_playbook(issue).is_none() {
4970        bail!(
4971            "panel: no playbook bound on {issue}; `ljos playbook {issue} NAME` or \
4972             `ljos sitting {issue} --playbook NAME` names one before personas enter"
4973        );
4974    }
4975    let all = personas_from_pack()?;
4976    if all.is_empty() {
4977        bail!("panel: the pack holds no personas; `ljos persona NAME --anchor A --view ...` writes one");
4978    }
4979    let words = issue_words(issue);
4980    let personas = personas_speaking_to(&all, &words);
4981    if personas.is_empty() {
4982        bail!(
4983            "panel: none of the {} personas speaks to {issue}: none holds its words ({}) as a \
4984             domain or in its view. Tag the issue with a domain a persona holds, or write the \
4985             briefs by hand with `ljos brief NAME {issue}`",
4986            all.len(),
4987            words.join(", ")
4988        );
4989    }
4990    std::fs::create_dir_all(out)?;
4991    let mut lines = vec![format!(
4992        "{} of {} personas speak to {issue}; briefs in {}; start one subagent per file, each ends with its ballot, then:",
4993        personas.len(),
4994        all.len(),
4995        out.display()
4996    )];
4997    for p in &personas {
4998        let path = out.join(format!("{}.md", p.name));
4999        std::fs::write(&path, brief(&p.name, issue)?)?;
5000        lines.push(format!("  {}", path.display()));
5001    }
5002    lines.push(format!("ljos consensus {issue}"));
5003    Ok(lines.join("\n") + "\n")
5004}
5005
5006/// The options an issue puts to a vote: an `Options: A, B` line split on
5007/// commas, or the `- a` bullets under a bare `Options:` line.
5008#[must_use]
5009pub fn issue_options(body: &str) -> Vec<String> {
5010    let mut lines = body.lines().map(str::trim);
5011    while let Some(line) = lines.next() {
5012        let Some(rest) = line.strip_prefix("Options:") else {
5013            continue;
5014        };
5015        let rest = rest.trim();
5016        let options: Vec<String> = if rest.is_empty() {
5017            lines
5018                .by_ref()
5019                .map_while(|l| l.strip_prefix("- ").or_else(|| l.strip_prefix("+ ")))
5020                .map(|o| o.trim().to_string())
5021                .collect()
5022        } else {
5023            rest.split(',').map(|o| o.trim().to_string()).collect()
5024        };
5025        let options: Vec<String> = options.into_iter().filter(|o| !o.is_empty()).collect();
5026        if options.len() >= 2 {
5027            return options;
5028        }
5029    }
5030    Vec::new()
5031}
5032
5033/// Jev's answer for a persona on an issue, not yet cast: its brief, less
5034/// the closing instructions a subagent needs, is the state, and the
5035/// issue's options are the choices.
5036///
5037/// # Errors
5038///
5039/// No such persona, an issue without two options, or Jev off or not
5040/// answering.
5041pub fn jev_ballot(name: &str, issue: &str) -> Result<jev::Ballot> {
5042    let v = tracker_show_json(issue)?;
5043    let options = issue_options(v["body"].as_str().unwrap_or(""));
5044    if options.len() < 2 {
5045        bail!("vote --jev: {issue} has no `Options: A, B` line with two options or more");
5046    }
5047    let full = brief(name, issue)?;
5048    let state = full
5049        .split("\nWalk the island as yourself")
5050        .next()
5051        .unwrap_or(&full);
5052    let state: String = state.chars().take(JEV_BRIEF_CHARS).collect();
5053    let state = format!("{state}\nOptions: {}\n", options.join(", "));
5054    jev::ballot(name, issue, &state, &options).with_context(|| {
5055        format!(
5056            "vote --jev: Jev did not answer (off, no key, over the month's cap, or past its budget); \
5057             `ljos brief {name} {issue}` starts a subagent instead"
5058        )
5059    })
5060}
5061
5062fn odds(m: &std::collections::BTreeMap<String, f64>) -> String {
5063    m.iter()
5064        .map(|(k, p)| format!("{k} {p:.2}"))
5065        .collect::<Vec<_>>()
5066        .join(", ")
5067}
5068
5069/// Cast Jev's ballot as the persona: the chosen option's probability is
5070/// the ballot's confidence, the forecast is its prediction, and a note on
5071/// the issue says the ballot came from Jev. Jev's own `confidence` is a
5072/// spread over the options, not a probability, so it only decides
5073/// escalation.
5074///
5075/// # Errors
5076///
5077/// The tracker or the pack refusing the ballot or the forecast.
5078pub fn cast_jev(name: &str, issue: &str, b: &jev::Ballot) -> Result<()> {
5079    let p = b
5080        .probabilities
5081        .get(&b.choice)
5082        .copied()
5083        .unwrap_or(b.confidence);
5084    let p = format!("{:.3}", p.clamp(0.01, 1.0));
5085    run_captured_as(
5086        "vissue",
5087        &[
5088            "vote",
5089            issue,
5090            "--for",
5091            &b.choice,
5092            "--used",
5093            "none",
5094            "--confidence",
5095            &p,
5096        ],
5097        Some(name),
5098    )?;
5099    write_prediction(issue, name, &serde_json::to_string(&b.forecast)?)?;
5100    note_jev(
5101        issue,
5102        &format!(
5103            "{name}: ballot from Jev, {} ({}); forecast {}",
5104            b.choice,
5105            odds(&b.probabilities),
5106            odds(&b.forecast)
5107        ),
5108    );
5109    Ok(())
5110}
5111
5112fn note_jev(issue: &str, text: &str) {
5113    let _ = run_captured("vissue", &["note", issue, text]);
5114}
5115
5116/// What a Jev ballot did: cast under the persona's name, or handed to a
5117/// subagent because Jev was not sure enough.
5118#[derive(Debug, Clone, PartialEq)]
5119pub enum JevVote {
5120    Cast(jev::Ballot),
5121    Escalated(jev::Ballot),
5122}
5123
5124/// One persona's ballot through Jev: cast when Jev is sure, noted and left
5125/// for a subagent when it is not.
5126///
5127/// # Errors
5128///
5129/// As [`jev_ballot`] and [`cast_jev`].
5130pub fn jev_vote(name: &str, issue: &str) -> Result<JevVote> {
5131    let b = jev_ballot(name, issue)?;
5132    if b.escalates() {
5133        note_jev(
5134            issue,
5135            &format!(
5136                "{name}: Jev leaned {} at confidence {:.2} ({}), under the {:.2} cut; the ballot goes to a subagent",
5137                b.choice,
5138                b.confidence,
5139                odds(&b.probabilities),
5140                b.escalate_below
5141            ),
5142        );
5143        return Ok(JevVote::Escalated(b));
5144    }
5145    cast_jev(name, issue, &b)?;
5146    Ok(JevVote::Cast(b))
5147}
5148
5149/// Whether a panel's Jev answers may stand as its ballots: every seated
5150/// persona sure, and all on one option. Personas answered by one model are
5151/// correlated voters, so their agreement settles only a question it could
5152/// not change; a split or an unsure seat goes to subagents.
5153#[must_use]
5154pub fn jev_panel_stands(ballots: &[jev::Ballot]) -> bool {
5155    !ballots.is_empty()
5156        && ballots.iter().all(|b| !b.escalates())
5157        && ballots.iter().all(|b| b.choice == ballots[0].choice)
5158}
5159
5160/// The most of a brief a Jev ballot sends: about 2,000 input tokens.
5161const JEV_BRIEF_CHARS: usize = 8000;
5162
5163/// A panel through Jev: every seated persona's ballot is asked of Jev
5164/// first. When all are sure and agree ([`jev_panel_stands`]) they are
5165/// cast; otherwise none is, and every seat gets a brief in `out` for a
5166/// subagent, with Jev's lean noted on the issue.
5167///
5168/// # Errors
5169///
5170/// No persona speaking to the issue, and as [`jev_ballot`].
5171pub fn panel_jev(issue: &str, out: &Path) -> Result<String> {
5172    let all = personas_from_pack()?;
5173    let personas = personas_speaking_to(&all, &issue_words(issue));
5174    if personas.is_empty() {
5175        bail!("panel --jev: no persona speaks to {issue}");
5176    }
5177    let mut ballots = Vec::new();
5178    for p in &personas {
5179        ballots.push(jev_ballot(&p.name, issue)?);
5180    }
5181    let rows: Vec<String> = personas
5182        .iter()
5183        .zip(&ballots)
5184        .map(|(p, b)| {
5185            format!(
5186                "  {}  {} at confidence {:.2}",
5187                p.name, b.choice, b.confidence
5188            )
5189        })
5190        .collect();
5191    let mut lines = Vec::new();
5192    if jev_panel_stands(&ballots) {
5193        for (p, b) in personas.iter().zip(&ballots) {
5194            cast_jev(&p.name, issue, b)?;
5195        }
5196        lines.push(format!(
5197            "{} personas on {issue} through Jev: all sure, all {}; cast",
5198            personas.len(),
5199            ballots[0].choice
5200        ));
5201        lines.extend(rows);
5202    } else {
5203        std::fs::create_dir_all(out)?;
5204        lines.push(format!(
5205            "{} personas on {issue} through Jev: split or unsure, none cast; start one subagent per brief in {}",
5206            personas.len(),
5207            out.display()
5208        ));
5209        lines.extend(rows);
5210        for (p, b) in personas.iter().zip(&ballots) {
5211            let path = out.join(format!("{}.md", p.name));
5212            std::fs::write(&path, brief(&p.name, issue)?)?;
5213            lines.push(format!("  {}", path.display()));
5214            note_jev(
5215                issue,
5216                &format!(
5217                    "{}: Jev leaned {} ({}); panel split or unsure, ballot goes to a subagent",
5218                    p.name,
5219                    b.choice,
5220                    odds(&b.probabilities)
5221                ),
5222            );
5223        }
5224    }
5225    lines.push(format!("ljos consensus {issue}"));
5226    Ok(lines.join("\n") + "\n")
5227}
5228
5229/// One voter's forecast on one issue: what share the others give each
5230/// option, or the option it expects to win.
5231#[derive(Debug, Clone, PartialEq)]
5232pub struct Prediction {
5233    pub issue: String,
5234    pub agent: String,
5235    pub expect: Value,
5236}
5237
5238/// POST one forecast. `expect` is an option name or `{option: share}`.
5239pub fn write_prediction(issue: &str, agent: &str, expect: &str) -> Result<Value> {
5240    let (issue, agent, expect) = (issue.trim(), agent.trim(), expect.trim());
5241    if issue.is_empty() || agent.is_empty() || expect.is_empty() {
5242        bail!("predict: an issue, an identity and an expectation are required");
5243    }
5244    let expect_value: Value = match serde_json::from_str::<Value>(expect) {
5245        Ok(v @ Value::Object(_)) => v,
5246        _ => Value::String(expect.to_string()),
5247    };
5248    let client = pack()?;
5249    let workspace = client.workspace();
5250    let mut atom = atom_body(
5251        "prediction",
5252        &format!("{agent} expects {expect} on {issue}."),
5253        &workspace,
5254    );
5255    atom["issue"] = Value::String(issue.into());
5256    atom["agent"] = Value::String(agent.into());
5257    atom["expect"] = expect_value;
5258    client
5259        .post_atom(&atom)
5260        .context("predict: POST /v1/atoms failed")
5261}
5262
5263/// The latest forecast per agent on an issue.
5264pub fn predictions_of(atoms: &[Value], issue: &str) -> Vec<Prediction> {
5265    let mut latest: std::collections::BTreeMap<String, (String, Prediction)> =
5266        std::collections::BTreeMap::new();
5267    for atom in atoms {
5268        if atom.get("kind").and_then(Value::as_str) != Some("prediction")
5269            || atom.get("issue").and_then(Value::as_str) != Some(issue)
5270        {
5271            continue;
5272        }
5273        let (Some(agent), Some(expect)) = (
5274            atom.get("agent").and_then(Value::as_str),
5275            atom.get("expect"),
5276        ) else {
5277            continue;
5278        };
5279        let ts = atom
5280            .get("ts")
5281            .and_then(Value::as_str)
5282            .unwrap_or("")
5283            .to_string();
5284        let p = Prediction {
5285            issue: issue.to_string(),
5286            agent: agent.to_string(),
5287            expect: expect.clone(),
5288        };
5289        match latest.get(agent) {
5290            Some((seen, _)) if *seen > ts => {}
5291            _ => {
5292                latest.insert(agent.to_string(), (ts, p));
5293            }
5294        }
5295    }
5296    latest.into_values().map(|(_, p)| p).collect()
5297}
5298
5299/// Take back `agent`'s forecasts on an issue: each prediction atom it wrote
5300/// there is deleted, leaving the pack's tombstone, so the settle reads the
5301/// voter as forecasting nothing. Returns how many went.
5302///
5303/// # Errors
5304///
5305/// The pack not answering, or refusing a delete.
5306pub fn withdraw_prediction(issue: &str, agent: &str) -> Result<usize> {
5307    let client = pack()?;
5308    let workspace = client.workspace();
5309    let atoms = client
5310        .atoms_of_kind(&workspace, "prediction")
5311        .context("predict: GET /v1/atoms failed")?;
5312    let mut gone = 0;
5313    for atom in atoms {
5314        if atom["issue"].as_str() != Some(issue) || atom["agent"].as_str() != Some(agent) {
5315            continue;
5316        }
5317        let Some(id) = atom["id"].as_str() else {
5318            continue;
5319        };
5320        client
5321            .delete_atom(&workspace, id, None)
5322            .with_context(|| format!("predict: delete {id} failed"))?;
5323        gone += 1;
5324    }
5325    Ok(gone)
5326}
5327
5328/// Forecasts as `ljos-consensus surprising --predictions` takes them.
5329pub fn predictions_json(predictions: &[Prediction]) -> String {
5330    Value::Array(
5331        predictions
5332            .iter()
5333            .map(|p| serde_json::json!({"agent": p.agent, "expect": p.expect}))
5334            .collect(),
5335    )
5336    .to_string()
5337}
5338
5339/// Argv law kept in the pack: a glob over the command line, a verdict, and
5340/// the reason a reader sees when it fires. `deny` stops the action at the
5341/// runner and under `ljos policy`; `ask` hands it to the person.
5342#[derive(Debug, Clone, PartialEq, Eq)]
5343pub struct Rule {
5344    pub pattern: String,
5345    pub verdict: String,
5346    pub reason: String,
5347}
5348
5349/// POST one rule.
5350pub fn write_rule(rule: &Rule) -> Result<Value> {
5351    let pattern = rule.pattern.trim();
5352    if pattern.is_empty() {
5353        bail!("rule: a pattern over the command line is required");
5354    }
5355    if !matches!(rule.verdict.as_str(), "deny" | "ask") {
5356        bail!("rule: the verdict is deny or ask, not {:?}", rule.verdict);
5357    }
5358    let reason = rule.reason.trim();
5359    if reason.is_empty() {
5360        bail!("rule: say in a sentence why, so the reader who is stopped knows");
5361    }
5362    let client = pack()?;
5363    let workspace = client.workspace();
5364    let mut atom = atom_body("rule", reason, &workspace);
5365    atom["pattern"] = Value::String(pattern.into());
5366    atom["verdict"] = Value::String(rule.verdict.clone());
5367    client
5368        .post_atom(&atom)
5369        .context("rule: POST /v1/atoms failed")
5370}
5371
5372/// The live rules in a set of atoms.
5373pub fn rules_of(atoms: &[Value]) -> Vec<Rule> {
5374    atoms
5375        .iter()
5376        .filter(|a| a.get("kind").and_then(Value::as_str) == Some("rule"))
5377        .filter_map(|a| {
5378            Some(Rule {
5379                pattern: a.get("pattern")?.as_str()?.to_string(),
5380                verdict: a.get("verdict")?.as_str()?.to_string(),
5381                reason: a
5382                    .get("text")
5383                    .and_then(Value::as_str)
5384                    .unwrap_or("")
5385                    .to_string(),
5386            })
5387        })
5388        .collect()
5389}
5390
5391/// The rules in the seat's pack.
5392pub fn rules_from_pack() -> Result<Vec<Rule>> {
5393    let client = pack()?;
5394    let atoms = atoms_lean(&client, &client.workspace()).context("rules: GET /v1/atoms failed")?;
5395    Ok(rules_of(&atoms))
5396}
5397
5398/// A glob over a command line: `*` matches any run of characters, `?` one.
5399/// The match is on the whole line, so `rm -rf *` is `rm -rf ` and anything
5400/// after, and `*sudo*` is sudo anywhere.
5401#[must_use]
5402pub fn glob_matches(pattern: &str, line: &str) -> bool {
5403    fn go(p: &[char], l: &[char]) -> bool {
5404        match (p.first(), l.first()) {
5405            (None, None) => true,
5406            (Some('*'), _) => go(&p[1..], l) || (!l.is_empty() && go(p, &l[1..])),
5407            (Some('?'), Some(_)) => go(&p[1..], &l[1..]),
5408            (Some(a), Some(b)) if a == b => go(&p[1..], &l[1..]),
5409            _ => false,
5410        }
5411    }
5412    let p: Vec<char> = pattern.chars().collect();
5413    let l: Vec<char> = line.trim().chars().collect();
5414    go(&p, &l)
5415}
5416
5417/// The verdict the rules give a command line: the first `deny` wins, then
5418/// the first `ask`, else none. Returns the rule that fired.
5419#[must_use]
5420pub fn verdict_for<'a>(rules: &'a [Rule], line: &str) -> Option<&'a Rule> {
5421    rules
5422        .iter()
5423        .find(|r| r.verdict == "deny" && glob_matches(&r.pattern, line))
5424        .or_else(|| {
5425            rules
5426                .iter()
5427                .find(|r| r.verdict == "ask" && glob_matches(&r.pattern, line))
5428        })
5429}
5430
5431/// Anchors as the settles take them: `{"name": anchor, ...}`.
5432pub fn anchors_json(personas: &[Persona]) -> String {
5433    let map: serde_json::Map<String, Value> = personas
5434        .iter()
5435        .map(|p| (p.name.clone(), serde_json::json!(p.anchor)))
5436        .collect();
5437    Value::Object(map).to_string()
5438}
5439
5440/// The entities that name a domain: every entity but the seat that wrote
5441/// the atom, which says who, not what.
5442fn domains_of(v: Option<&Value>) -> Vec<String> {
5443    words_of(v)
5444        .into_iter()
5445        .filter(|e| !e.starts_with(SEAT_ENTITY))
5446        .collect()
5447}
5448
5449fn words_of(v: Option<&Value>) -> Vec<String> {
5450    v.and_then(Value::as_array)
5451        .into_iter()
5452        .flatten()
5453        .filter_map(Value::as_str)
5454        .map(str::to_lowercase)
5455        .collect()
5456}
5457
5458/// The domains an issue's island speaks to: the entities of the memories
5459/// its title activates, most frequent first, eight at most. What `learn`
5460/// scopes its rows to.
5461///
5462/// # Errors
5463///
5464/// The tracker or the pack not answering.
5465pub fn island_entities(issue: &str) -> Result<Vec<String>> {
5466    let title = issue_title(issue)?;
5467    let island = packset_island(&title, false)?;
5468    let ids: Vec<&str> = island["island"]
5469        .as_array()
5470        .into_iter()
5471        .flatten()
5472        .filter_map(|a| a["id"].as_str())
5473        .collect();
5474    if ids.is_empty() {
5475        return Ok(Vec::new());
5476    }
5477    let client = pack()?;
5478    let atoms = atoms_lean(&client, &client.workspace()).context("island: GET /v1/atoms failed")?;
5479    let mut count: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
5480    for atom in &atoms {
5481        if atom
5482            .get("id")
5483            .and_then(Value::as_str)
5484            .is_some_and(|id| ids.contains(&id))
5485        {
5486            for e in words_of(atom.get("entities")) {
5487                *count.entry(e).or_insert(0) += 1;
5488            }
5489        }
5490    }
5491    let mut ranked: Vec<(String, usize)> = count.into_iter().collect();
5492    ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
5493    Ok(ranked.into_iter().take(8).map(|(e, _)| e).collect())
5494}
5495
5496/// The words an issue is about, for scoping trust rows: its title, lower
5497/// case, three letters or longer.
5498pub fn topic_words(title: &str) -> Vec<String> {
5499    let mut words: Vec<String> = title
5500        .split(|c: char| !c.is_alphanumeric())
5501        .filter(|w| w.len() >= 3)
5502        .map(str::to_lowercase)
5503        .collect();
5504    words.sort_unstable();
5505    words.dedup();
5506    words
5507}
5508
5509/// The rows that apply to an issue about `topic`: every unscoped row, and
5510/// every scoped row one of whose domains is among the topic's words.
5511pub fn rows_about(rows: &[Trust], topic: &[String]) -> Vec<Trust> {
5512    // A scoped row that applies stands in for the unscoped row of the same
5513    // pair, so the settle sees one weight per pair and never a sum of two.
5514    let mut chosen: std::collections::BTreeMap<(String, String), Trust> =
5515        std::collections::BTreeMap::new();
5516    for r in rows {
5517        let applies = r.about.is_empty() || r.about.iter().any(|a| topic.contains(a));
5518        if !applies {
5519            continue;
5520        }
5521        let key = (r.from.clone(), r.to.clone());
5522        match chosen.get(&key) {
5523            Some(have) if !have.about.is_empty() && r.about.is_empty() => {}
5524            _ => {
5525                chosen.insert(key, r.clone());
5526            }
5527        }
5528    }
5529    chosen.into_values().collect()
5530}
5531
5532/// The personas after an outcome: one whose ballot the outcome refuted
5533/// moves its anchor toward one by `1 - beta` of the gap, so a persona that
5534/// keeps being wrong listens more; a vindicated one keeps its anchor. The
5535/// personas that voted are the only ones touched. Acemoglu, Como, Fagnani
5536/// and Ozdaglar (doi:10.1287/moor.1120.0570) show what a stubborn wrong
5537/// voter does to a pool; this is the seat's remedy.
5538#[must_use]
5539pub fn learn_anchors(
5540    personas: &[Persona],
5541    ballots: &[(String, String)],
5542    outcome: &str,
5543    beta: f64,
5544) -> Vec<Persona> {
5545    let outcome = outcome.trim();
5546    personas
5547        .iter()
5548        .filter(|p| {
5549            ballots
5550                .iter()
5551                .any(|(agent, choice)| *agent == p.name && choice != outcome)
5552        })
5553        .map(|p| Persona {
5554            anchor: (p.anchor + (1.0 - p.anchor) * (1.0 - beta)).min(1.0),
5555            ..p.clone()
5556        })
5557        .collect()
5558}
5559
5560/// [`learn_about`] and [`learn_anchors`] together, written to the pack:
5561/// the rows, then the personas the outcome moved. Returns what was written.
5562///
5563/// # Errors
5564///
5565/// The pack refusing a row or a persona.
5566/// A ballot as a forecast: the choice, and the probability the voter stated
5567/// for that choice. Absent confidence is not a claim of certainty.
5568#[derive(Debug, Clone, PartialEq)]
5569pub struct Forecast {
5570    pub agent: String,
5571    pub choice: String,
5572    pub confidence: Option<f64>,
5573}
5574
5575/// Quadratic score of a stated probability against the outcome.
5576///
5577/// `p` is the probability the voter assigned to its own choice being the
5578/// outcome. The outcome indicator is 1 when the choice matches and 0
5579/// otherwise. The score is `(p - o)^2` (Brier 1950; Gneiting and Raftery
5580/// 2007, doi:10.1198/016214506000001437). Lower is better. It is not a
5581/// trust weight.
5582#[must_use]
5583pub fn brier(choice: &str, outcome: &str, p: f64) -> f64 {
5584    let o = if choice == outcome { 1.0 } else { 0.0 };
5585    let d = p - o;
5586    d * d
5587}
5588
5589/// Logarithmic score of the probability assigned to the event that occurred.
5590///
5591/// Good 1952, doi:10.1111/j.2517-6161.1952.tb00104.x. The score is
5592/// `-ln` of the probability the forecast put on what happened. It is
5593/// unbounded when that probability is 0, which a stated certainty on the
5594/// wrong choice is. `None` in that case, rather than a stand-in number.
5595#[must_use]
5596pub fn log_score(choice: &str, outcome: &str, p: f64) -> Option<f64> {
5597    let assigned = if choice == outcome { p } else { 1.0 - p };
5598    if assigned <= 0.0 {
5599        None
5600    } else {
5601        Some(-assigned.ln())
5602    }
5603}
5604
5605/// Mean logarithmic score over the forecasts that stated a probability,
5606/// how many of those scores were finite, and how many were unbounded.
5607#[must_use]
5608pub fn mean_log(rows: &[Forecast], outcome: &str) -> (Option<f64>, usize, usize) {
5609    let mut sum = 0.0;
5610    let mut finite = 0usize;
5611    let mut unbounded = 0usize;
5612    for row in rows {
5613        let Some(p) = row.confidence else { continue };
5614        match log_score(&row.choice, outcome, p) {
5615            Some(score) => {
5616                sum += score;
5617                finite += 1;
5618            }
5619            None => unbounded += 1,
5620        }
5621    }
5622    let mean = (finite > 0).then_some(sum / finite as f64);
5623    (mean, finite, unbounded)
5624}
5625
5626/// One voter's forecast record. The bins are the probabilities actually
5627/// stated, in thousandths, each with how many times it was stated and how
5628/// many of those events occurred. Murphy's categories are those values,
5629/// not a grid this seat invented.
5630#[derive(Debug, Clone, Default, PartialEq)]
5631pub struct Calibration {
5632    pub n: u32,
5633    pub sum_p: f64,
5634    pub sum_o: f64,
5635    pub sum_brier: f64,
5636    pub sum_log: f64,
5637    pub log_n: u32,
5638    pub bins: std::collections::BTreeMap<u16, (u32, u32)>,
5639}
5640
5641/// Murphy's partition of the Brier score (1973,
5642/// doi:10.1175/1520-0450(1973)012<0595:ANVPOT>2.0.CO;2).
5643/// `brier = reliability - resolution + uncertainty`.
5644#[derive(Debug, Clone, Copy, PartialEq)]
5645pub struct Partition {
5646    pub reliability: f64,
5647    pub resolution: f64,
5648    pub uncertainty: f64,
5649}
5650
5651/// Add one stated probability to a voter's record.
5652#[must_use]
5653pub fn observe(cal: &Calibration, choice: &str, outcome: &str, p: f64) -> Calibration {
5654    let mut next = cal.clone();
5655    let occurred = choice == outcome;
5656    let o = if occurred { 1.0 } else { 0.0 };
5657    next.n += 1;
5658    next.sum_p += p;
5659    next.sum_o += o;
5660    next.sum_brier += brier(choice, outcome, p);
5661    if let Some(score) = log_score(choice, outcome, p) {
5662        next.sum_log += score;
5663        next.log_n += 1;
5664    }
5665    let key = (p.clamp(0.0, 1.0) * 1000.0).round() as u16;
5666    let slot = next.bins.entry(key).or_insert((0, 0));
5667    slot.0 += 1;
5668    if occurred {
5669        slot.1 += 1;
5670    }
5671    next
5672}
5673
5674/// Reliability, resolution, and uncertainty. `None` until the voter has
5675/// two forecasts: one forecast makes the partition the score itself.
5676#[must_use]
5677pub fn murphy(cal: &Calibration) -> Option<Partition> {
5678    if cal.n < 2 || cal.bins.is_empty() {
5679        return None;
5680    }
5681    let n = f64::from(cal.n);
5682    let base = cal.sum_o / n;
5683    let mut reliability = 0.0;
5684    let mut resolution = 0.0;
5685    for (thou, (count, occurred)) in &cal.bins {
5686        let nk = f64::from(*count);
5687        if nk == 0.0 {
5688            continue;
5689        }
5690        let forecast = f64::from(*thou) / 1000.0;
5691        let rate = f64::from(*occurred) / nk;
5692        reliability += nk * (forecast - rate) * (forecast - rate);
5693        resolution += nk * (rate - base) * (rate - base);
5694    }
5695    Some(Partition {
5696        reliability: reliability / n,
5697        resolution: resolution / n,
5698        uncertainty: base * (1.0 - base),
5699    })
5700}
5701
5702/// Mean Brier score over the forecasts that stated a probability, and how
5703/// many those were. `None` when nobody stated one.
5704#[must_use]
5705pub fn mean_brier(rows: &[Forecast], outcome: &str) -> Option<(f64, usize)> {
5706    let scores: Vec<f64> = rows
5707        .iter()
5708        .filter_map(|r| r.confidence.map(|p| brier(&r.choice, outcome, p)))
5709        .collect();
5710    if scores.is_empty() {
5711        None
5712    } else {
5713        Some((
5714            scores.iter().sum::<f64>() / scores.len() as f64,
5715            scores.len(),
5716        ))
5717    }
5718}
5719
5720/// `(agent, choice, confidence)` from a tracker's `vote --json`.
5721pub fn forecasts_from_json(raw: &str) -> Result<Vec<Forecast>> {
5722    let rows: Vec<Value> = serde_json::from_str(raw).context("ballots: not a JSON array")?;
5723    rows.iter()
5724        .map(|row| {
5725            let agent = row.get("agent").and_then(Value::as_str);
5726            let choice = row.get("choice").and_then(Value::as_str);
5727            let confidence = match row.get("confidence") {
5728                None | Some(Value::Null) => None,
5729                Some(value) => {
5730                    let probability = value
5731                        .as_f64()
5732                        .or_else(|| value.as_str()?.parse::<f64>().ok())
5733                        .context("ballots: confidence must be a probability in (0, 1]")?;
5734                    if !probability.is_finite() || probability <= 0.0 || probability > 1.0 {
5735                        bail!("ballots: confidence must be a probability in (0, 1]");
5736                    }
5737                    Some(probability)
5738                }
5739            };
5740            match (agent, choice) {
5741                (Some(a), Some(c)) => Ok(Forecast {
5742                    agent: a.to_string(),
5743                    choice: c.to_string(),
5744                    confidence,
5745                }),
5746                _ => bail!("ballots: a row without agent and choice"),
5747            }
5748        })
5749        .collect()
5750}
5751
5752/// What a learn did. The rows are the next settle's weights. This call is not a settle.
5753/// The scores, when any ballot stated a probability, are not trust weights.
5754/// `calibration` is each voter's record after this outcome is folded in.
5755#[must_use]
5756pub fn learn_reading(
5757    rows: usize,
5758    moved: usize,
5759    forecasts: &[Forecast],
5760    outcome: &str,
5761    calibration: &std::collections::BTreeMap<String, Calibration>,
5762) -> String {
5763    let mut out = format!(
5764        "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."
5765    );
5766    match mean_brier(forecasts, outcome) {
5767        Some((mean, n)) => {
5768            let silent = forecasts.len().saturating_sub(n);
5769            out.push_str(&format!(
5770                " 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."
5771            ));
5772        }
5773        None => out.push_str(
5774            " No stated probability, so there is no Brier score. A hard vote is not a claim of certainty.",
5775        ),
5776    }
5777    let (mean_log, finite, unbounded) = mean_log(forecasts, outcome);
5778    if let Some(mean) = mean_log {
5779        out.push_str(&format!(
5780            " Logarithmic score {mean:.3} over {finite} (doi:10.1111/j.2517-6161.1952.tb00104.x)."
5781        ));
5782    }
5783    if unbounded > 0 {
5784        out.push_str(&format!(
5785            " {unbounded} assigned probability 0 to the event that occurred, so those logarithmic scores are unbounded."
5786        ));
5787    }
5788    let mut named: Vec<(&str, &Calibration)> = forecasts
5789        .iter()
5790        .filter(|f| f.confidence.is_some())
5791        .filter_map(|f| calibration.get(&f.agent).map(|cal| (f.agent.as_str(), cal)))
5792        .collect();
5793    named.sort_by(|a, b| {
5794        let gap = |c: &Calibration| {
5795            if c.n == 0 {
5796                0.0
5797            } else {
5798                (c.sum_p / f64::from(c.n) - c.sum_o / f64::from(c.n)).abs()
5799            }
5800        };
5801        gap(b.1)
5802            .partial_cmp(&gap(a.1))
5803            .unwrap_or(std::cmp::Ordering::Equal)
5804            .then(a.0.cmp(b.0))
5805    });
5806    named.dedup_by_key(|row| row.0);
5807    for (name, cal) in named.into_iter().take(8) {
5808        if cal.n == 0 {
5809            continue;
5810        }
5811        let n = f64::from(cal.n);
5812        let mean_p = cal.sum_p / n;
5813        let rate = cal.sum_o / n;
5814        out.push_str(&format!(
5815            " {name}: {} forecasts, mean probability {mean_p:.3}, event rate {rate:.3} (doi:10.1080/01621459.1982.10477856)",
5816            cal.n
5817        ));
5818        if let Some(part) = murphy(cal) {
5819            out.push_str(&format!(
5820                "; reliability {:.3}, resolution {:.3}, uncertainty {:.3} (doi:10.1175/1520-0450(1973)012<0595:ANVPOT>2.0.CO;2)",
5821                part.reliability, part.resolution, part.uncertainty
5822            ));
5823        }
5824        out.push('.');
5825    }
5826    out
5827}
5828
5829/// Trust rows, personas, and each voter's forecast calibration.
5830pub type LearnedState = (
5831    Vec<Trust>,
5832    Vec<Persona>,
5833    std::collections::BTreeMap<String, Calibration>,
5834);
5835
5836pub fn learn_and_write(
5837    ballots: &[(String, String)],
5838    outcome: &str,
5839    beta: f64,
5840    about: &[String],
5841    forecasts: &[Forecast],
5842) -> Result<LearnedState> {
5843    let client = pack()?;
5844    let atoms = atoms_lean(&client, &client.workspace()).context("learn: GET /v1/atoms failed")?;
5845    let (rows, records) = learn_record(ballots, outcome, &records_from_atoms(&atoms), about)?;
5846    let mut calibration = calibration_from_atoms(&atoms);
5847    for forecast in forecasts {
5848        let Some(p) = forecast.confidence else {
5849            continue;
5850        };
5851        let slot = calibration.entry(forecast.agent.clone()).or_default();
5852        *slot = observe(slot, &forecast.choice, outcome, p);
5853    }
5854    let moved = learn_anchors(&personas_from_pack()?, ballots, outcome, beta);
5855    // Every row lands before anything is printed, so a closed pipe cannot
5856    // leave the graph half written.
5857    for row in &rows {
5858        write_trust_record(
5859            row,
5860            &[],
5861            records.get(&row.to).copied(),
5862            calibration.get(&row.to),
5863        )?;
5864    }
5865    for p in &moved {
5866        write_persona(p)?;
5867    }
5868    Ok((rows, moved, calibration))
5869}
5870
5871/// A voter's record: how often the outcome agreed with its ballot, and
5872/// how often not, carried on every trust row into that voter.
5873pub type Standing = (f64, f64);
5874
5875/// The latest record per voter among the trust atoms that carry one.
5876#[must_use]
5877pub fn records_from_atoms(atoms: &[Value]) -> std::collections::BTreeMap<String, Standing> {
5878    let mut latest: std::collections::BTreeMap<String, (String, Standing)> =
5879        std::collections::BTreeMap::new();
5880    for atom in atoms {
5881        if atom.get("kind").and_then(Value::as_str) != Some("trust") {
5882            continue;
5883        }
5884        let (Some(to), Some(hits), Some(misses)) = (
5885            atom.get("to").and_then(Value::as_str),
5886            atom.get("hits").and_then(Value::as_f64),
5887            atom.get("misses").and_then(Value::as_f64),
5888        ) else {
5889            continue;
5890        };
5891        let ts = atom
5892            .get("ts")
5893            .and_then(Value::as_str)
5894            .unwrap_or("")
5895            .to_string();
5896        match latest.get(to) {
5897            Some((seen, _)) if *seen > ts => {}
5898            _ => {
5899                latest.insert(to.to_string(), (ts, (hits, misses)));
5900            }
5901        }
5902    }
5903    latest.into_iter().map(|(k, (_, r))| (k, r)).collect()
5904}
5905
5906/// Learn from an outcome by the record: each voter's hits and misses so
5907/// far, this outcome added, give its accuracy with one of each smoothed
5908/// in, and the rows are the log odds of that scaled to the best voter at
5909/// one ([`calibration_weights`]). Measured against multiplicative
5910/// shrinking (Hedge) on voters of known accuracy, the record reaches the
5911/// batch calibration and the shrink does not: a voter is weighed by what
5912/// it got right, not by how many times it has been punished. Rows are
5913/// complete over the voters and scoped to `about`.
5914///
5915/// # Errors
5916///
5917/// No outcome, or fewer than two voters.
5918pub fn learn_record(
5919    ballots: &[(String, String)],
5920    outcome: &str,
5921    records: &std::collections::BTreeMap<String, Standing>,
5922    about: &[String],
5923) -> Result<(Vec<Trust>, std::collections::BTreeMap<String, Standing>)> {
5924    let outcome = outcome.trim();
5925    if outcome.is_empty() {
5926        bail!("learn: an outcome is required");
5927    }
5928    let mut agents: Vec<&str> = ballots.iter().map(|(a, _)| a.as_str()).collect();
5929    agents.sort_unstable();
5930    agents.dedup();
5931    if agents.len() < 2 {
5932        bail!("learn: fewer than two voters, nothing to weigh");
5933    }
5934    let mut next = records.clone();
5935    for (agent, choice) in ballots {
5936        let r = next.entry(agent.clone()).or_insert((0.0, 0.0));
5937        if choice == outcome {
5938            r.0 += 1.0;
5939        } else {
5940            r.1 += 1.0;
5941        }
5942    }
5943    let accuracy: Vec<(String, f64)> = agents
5944        .iter()
5945        .map(|a| {
5946            let (h, m) = next.get(*a).copied().unwrap_or((0.0, 0.0));
5947            ((*a).to_string(), (h + 1.0) / (h + m + 2.0))
5948        })
5949        .collect();
5950    let weights = calibration_weights(&accuracy);
5951    let mut out = Vec::new();
5952    for from in &agents {
5953        for (to, weight) in &weights {
5954            if *from == to {
5955                continue;
5956            }
5957            out.push(Trust {
5958                from: (*from).to_string(),
5959                to: to.clone(),
5960                weight: *weight,
5961                about: about.to_vec(),
5962            });
5963        }
5964    }
5965    Ok((out, next))
5966}
5967
5968/// [`write_trust`] carrying the voter's record on the row.
5969pub fn write_trust_record(
5970    row: &Trust,
5971    why: &[String],
5972    record: Option<Standing>,
5973    calibration: Option<&Calibration>,
5974) -> Result<Value> {
5975    let client = pack()?;
5976    let workspace = client.workspace();
5977    let mut atom = trust_atom(row, why, &workspace)?;
5978    if let Some((hits, misses)) = record {
5979        atom["hits"] = serde_json::json!(hits);
5980        atom["misses"] = serde_json::json!(misses);
5981    }
5982    if let Some(cal) = calibration.filter(|c| c.n > 0) {
5983        atom["forecast_n"] = serde_json::json!(cal.n);
5984        atom["forecast_sum_p"] = serde_json::json!(cal.sum_p);
5985        atom["forecast_sum_o"] = serde_json::json!(cal.sum_o);
5986        atom["forecast_sum_brier"] = serde_json::json!(cal.sum_brier);
5987        atom["forecast_sum_log"] = serde_json::json!(cal.sum_log);
5988        atom["forecast_log_n"] = serde_json::json!(cal.log_n);
5989        let mut bins = serde_json::Map::new();
5990        for (key, (count, occurred)) in &cal.bins {
5991            bins.insert(key.to_string(), serde_json::json!([count, occurred]));
5992        }
5993        atom["forecast_bins"] = Value::Object(bins);
5994    }
5995    client
5996        .post_atom(&atom)
5997        .context("trust: POST /v1/atoms failed")
5998}
5999
6000/// The latest forecast record per voter, from the trust rows that carry one.
6001#[must_use]
6002pub fn calibration_from_atoms(atoms: &[Value]) -> std::collections::BTreeMap<String, Calibration> {
6003    let mut latest: std::collections::BTreeMap<String, (String, Calibration)> =
6004        std::collections::BTreeMap::new();
6005    for atom in atoms {
6006        if atom.get("kind").and_then(Value::as_str) != Some("trust") {
6007            continue;
6008        }
6009        let Some(to) = atom.get("to").and_then(Value::as_str) else {
6010            continue;
6011        };
6012        let Some(n) = atom.get("forecast_n").and_then(Value::as_u64) else {
6013            continue;
6014        };
6015        let ts = atom
6016            .get("ts")
6017            .and_then(Value::as_str)
6018            .unwrap_or("")
6019            .to_string();
6020        let cal = Calibration {
6021            n: n as u32,
6022            sum_p: atom
6023                .get("forecast_sum_p")
6024                .and_then(Value::as_f64)
6025                .unwrap_or(0.0),
6026            sum_o: atom
6027                .get("forecast_sum_o")
6028                .and_then(Value::as_f64)
6029                .unwrap_or(0.0),
6030            sum_brier: atom
6031                .get("forecast_sum_brier")
6032                .and_then(Value::as_f64)
6033                .unwrap_or(0.0),
6034            sum_log: atom
6035                .get("forecast_sum_log")
6036                .and_then(Value::as_f64)
6037                .unwrap_or(0.0),
6038            log_n: atom
6039                .get("forecast_log_n")
6040                .and_then(Value::as_u64)
6041                .unwrap_or(0) as u32,
6042            bins: bins_of(atom.get("forecast_bins")),
6043        };
6044        match latest.get(to) {
6045            Some((seen, _)) if *seen > ts => {}
6046            _ => {
6047                latest.insert(to.to_string(), (ts, cal));
6048            }
6049        }
6050    }
6051    latest.into_iter().map(|(k, (_, cal))| (k, cal)).collect()
6052}
6053
6054fn bins_of(value: Option<&Value>) -> std::collections::BTreeMap<u16, (u32, u32)> {
6055    let mut out = std::collections::BTreeMap::new();
6056    let Some(obj) = value.and_then(Value::as_object) else {
6057        return out;
6058    };
6059    for (key, row) in obj {
6060        let Ok(thou) = key.parse::<u16>() else {
6061            continue;
6062        };
6063        let Some(pair) = row.as_array() else { continue };
6064        let count = pair.first().and_then(Value::as_u64).unwrap_or(0) as u32;
6065        let occurred = pair.get(1).and_then(Value::as_u64).unwrap_or(0) as u32;
6066        out.insert(thou, (count, occurred));
6067    }
6068    out
6069}
6070
6071/// The factor a refuted voter's rows shrink by (Hedge, doi:10.1006/jcss.1997.1504).
6072pub const LEARN_BETA: f64 = 0.5;
6073
6074/// The least a row can fall to, so a voter who is right again is heard again.
6075pub const TRUST_FLOOR: f64 = 0.01;
6076
6077/// A `trust` atom for one row. `why` are deed accessions it cites.
6078pub fn trust_atom(row: &Trust, why: &[String], workspace: &str) -> Result<Value> {
6079    let (from, to) = (row.from.trim(), row.to.trim());
6080    if from.is_empty() || to.is_empty() {
6081        bail!("trust: from and to are required");
6082    }
6083    if from == to {
6084        bail!("trust: {from} cannot weigh itself; self weight is the settle's");
6085    }
6086    if !(row.weight > 0.0 && row.weight <= 1.0) {
6087        bail!("trust: weight {} is not in (0, 1]", row.weight);
6088    }
6089    let mut atom = atom_body(
6090        "trust",
6091        &format!("{from} weighs {to} at {:.3}.", row.weight),
6092        workspace,
6093    );
6094    atom["from"] = Value::String(from.into());
6095    atom["to"] = Value::String(to.into());
6096    atom["weight"] = serde_json::json!(row.weight);
6097    // A trust row's entities are the deeds it stands on. The pack refuses
6098    // an entity that is not an accession. Who wrote the row is `from`.
6099    for w in why {
6100        if !w.starts_with("deed-") && !w.starts_with("sha256:") {
6101            bail!("trust: {w} is not a deed accession");
6102        }
6103    }
6104    atom["entities"] = Value::Array(why.iter().map(|w| Value::String(w.clone())).collect());
6105    if !row.about.is_empty() {
6106        atom["about"] = Value::Array(
6107            row.about
6108                .iter()
6109                .map(|w| Value::String(w.to_lowercase()))
6110                .collect(),
6111        );
6112    }
6113    Ok(atom)
6114}
6115
6116/// The live rows in a set of atoms: the latest `trust` atom per `(from, to)`.
6117pub fn trust_rows(atoms: &[Value]) -> Vec<Trust> {
6118    // The latest row per (from, to, scope): an unscoped row and a scoped one
6119    // for the same pair are different rows, and a later row of the same
6120    // scope supersedes.
6121    let mut latest: std::collections::BTreeMap<(String, String, Vec<String>), (String, f64)> =
6122        std::collections::BTreeMap::new();
6123    for atom in atoms {
6124        if atom.get("kind").and_then(Value::as_str) != Some("trust") {
6125            continue;
6126        }
6127        let (Some(from), Some(to), Some(weight)) = (
6128            atom.get("from").and_then(Value::as_str),
6129            atom.get("to").and_then(Value::as_str),
6130            atom.get("weight").and_then(Value::as_f64),
6131        ) else {
6132            continue;
6133        };
6134        let ts = atom
6135            .get("ts")
6136            .and_then(Value::as_str)
6137            .unwrap_or("")
6138            .to_string();
6139        let mut about = words_of(atom.get("about"));
6140        about.sort_unstable();
6141        let key = (from.to_string(), to.to_string(), about);
6142        match latest.get(&key) {
6143            Some((seen, _)) if *seen > ts => {}
6144            _ => {
6145                latest.insert(key, (ts, weight));
6146            }
6147        }
6148    }
6149    latest
6150        .into_iter()
6151        .map(|((from, to, about), (_, weight))| Trust {
6152            from,
6153            to,
6154            weight,
6155            about,
6156        })
6157        .collect()
6158}
6159
6160/// Rows as the consensus takes them: `[[from, to, weight], ...]`.
6161pub fn trust_json(rows: &[Trust]) -> String {
6162    let tuples: Vec<Value> = rows
6163        .iter()
6164        .map(|r| serde_json::json!([r.from, r.to, r.weight]))
6165        .collect();
6166    Value::Array(tuples).to_string()
6167}
6168
6169/// `(agent, choice)` pairs from a tracker's `vote --json`.
6170pub fn ballots_from_json(raw: &str) -> Result<Vec<(String, String)>> {
6171    let rows: Vec<Value> = serde_json::from_str(raw).context("ballots: not a JSON array")?;
6172    rows.iter()
6173        .map(|row| {
6174            let agent = row.get("agent").and_then(Value::as_str);
6175            let choice = row.get("choice").and_then(Value::as_str);
6176            match (agent, choice) {
6177                (Some(a), Some(c)) => Ok((a.to_string(), c.to_string())),
6178                _ => bail!("ballots: a row without agent and choice"),
6179            }
6180        })
6181        .collect()
6182}
6183
6184/// The rows every voter holds on every other after `outcome` is known: a
6185/// voter whose ballot was refuted shrinks by `beta`, floored at
6186/// [`TRUST_FLOOR`]; a missing row starts at one. Complete, so the settle
6187/// sees the whole graph.
6188pub fn learn(
6189    ballots: &[(String, String)],
6190    outcome: &str,
6191    rows: &[Trust],
6192    beta: f64,
6193) -> Result<Vec<Trust>> {
6194    learn_about(ballots, outcome, rows, beta, &[])
6195}
6196
6197/// [`learn`] writing rows scoped to `about`: the domains the issue's island
6198/// speaks to, so that being wrong about one topic does not cost a voter its
6199/// standing on every other. An empty `about` is the unscoped rule.
6200pub fn learn_about(
6201    ballots: &[(String, String)],
6202    outcome: &str,
6203    rows: &[Trust],
6204    beta: f64,
6205    about: &[String],
6206) -> Result<Vec<Trust>> {
6207    learn_shared(ballots, outcome, rows, beta, about, 0.0)
6208}
6209
6210/// [`learn_about`] with a fixed share of recovery: after the Hedge step
6211/// every row moves toward one by `share` of the gap, so a voter refuted
6212/// long ago is not held down forever and the best voter can change
6213/// (Herbster and Warmuth, doi:10.1023/A:1007424614876). Zero is plain
6214/// Hedge; the seat's default.
6215pub fn learn_shared(
6216    ballots: &[(String, String)],
6217    outcome: &str,
6218    rows: &[Trust],
6219    beta: f64,
6220    about: &[String],
6221    share: f64,
6222) -> Result<Vec<Trust>> {
6223    if !(beta > 0.0 && beta < 1.0) {
6224        bail!("learn: beta {beta} is not in (0, 1)");
6225    }
6226    if !(0.0..1.0).contains(&share) {
6227        bail!("learn: share {share} is not in [0, 1)");
6228    }
6229    let outcome = outcome.trim();
6230    if outcome.is_empty() {
6231        bail!("learn: an outcome is required");
6232    }
6233    let mut agents: Vec<&str> = ballots.iter().map(|(a, _)| a.as_str()).collect();
6234    agents.sort_unstable();
6235    agents.dedup();
6236    if agents.len() < 2 {
6237        bail!("learn: fewer than two voters, nothing to weigh");
6238    }
6239    let refuted = |agent: &str| {
6240        ballots
6241            .iter()
6242            .any(|(a, choice)| a == agent && choice != outcome)
6243    };
6244    let mut out = Vec::new();
6245    for from in &agents {
6246        for to in &agents {
6247            if from == to {
6248                continue;
6249            }
6250            // The row being moved is the one of this scope; a scoped learn
6251            // starts from the unscoped row when it has none of its own.
6252            let current = rows
6253                .iter()
6254                .find(|r| r.from == *from && r.to == *to && r.about == about)
6255                .or_else(|| {
6256                    rows.iter()
6257                        .find(|r| r.from == *from && r.to == *to && r.about.is_empty())
6258                })
6259                .map_or(1.0, |r| r.weight);
6260            let stepped = if refuted(to) {
6261                (current * beta).max(TRUST_FLOOR)
6262            } else {
6263                current
6264            };
6265            let next = stepped + (1.0 - stepped) * share;
6266            out.push(Trust {
6267                from: (*from).to_string(),
6268                to: (*to).to_string(),
6269                weight: next,
6270                about: about.to_vec(),
6271            });
6272        }
6273    }
6274    Ok(out)
6275}
6276
6277/// The live trust rows in the seat's pack.
6278pub fn trust_from_pack() -> Result<Vec<Trust>> {
6279    let client = pack()?;
6280    let workspace = client.workspace();
6281    let atoms = atoms_lean(&client, &workspace).context("trust: GET /v1/atoms failed")?;
6282    Ok(trust_rows(&atoms))
6283}
6284
6285/// POST one trust row.
6286pub fn write_trust(row: &Trust, why: &[String]) -> Result<Value> {
6287    let client = pack()?;
6288    let workspace = client.workspace();
6289    client
6290        .post_atom(&trust_atom(row, why, &workspace)?)
6291        .context("trust: POST /v1/atoms failed")
6292}
6293
6294/// One habitat and whether it answers.
6295#[derive(Debug, Clone, PartialEq, Eq)]
6296pub struct Habitat {
6297    pub name: &'static str,
6298    pub state: String,
6299    pub ok: bool,
6300}
6301
6302/// One line after a pack write: id, kind, due, text. Not the embedding.
6303#[must_use]
6304pub fn format_write_ack(body: &serde_json::Value) -> String {
6305    format!(
6306        "{}\t{}\tdue {}\t{}",
6307        body["id"].as_str().unwrap_or("?"),
6308        body["kind"].as_str().unwrap_or("?"),
6309        body["due_at"].as_str().unwrap_or("-"),
6310        body["text"].as_str().unwrap_or("").replace('\n', " "),
6311    )
6312}
6313
6314/// The habitats the seat needs. Encoder and policyd move with the rest.
6315pub const REQUIRED: &[&str] = &[
6316    "ljos",
6317    "ljos-mcp",
6318    "ljos-policyd",
6319    "vissue",
6320    "deedar",
6321    "claimdag",
6322    "packset",
6323    "packsetd",
6324    "packset-embed",
6325    "pack",
6326    "encoder",
6327];
6328
6329/// Binary on PATH and the crates.io name it should track.
6330const SEAT_BINS: &[(&str, &str)] = &[
6331    ("ljos", "ljos"),
6332    // The published `ljos` crate ships this binary. The crates.io name
6333    // `ljos-mcp` stopped at 0.14.0 and is not the binary's version line.
6334    ("ljos-mcp", "ljos"),
6335    ("ljos-policyd", "ljos-policyd"),
6336    ("ljos-consensus", "ljos-consensus"),
6337    ("vissue", "vissue-cli"),
6338    ("deedar", "deedar-cli"),
6339    ("claimdag", "claimdag-cli"),
6340    ("packset", "packset"),
6341    ("packsetd", "packset"),
6342    ("packset-embed", "packset-embed"),
6343    ("packset-mcp", "packset"),
6344    ("ljos-hud", "ljos-hud"),
6345];
6346
6347/// First `N.N.N` in a `--version` line.
6348#[must_use]
6349pub fn parse_semver(text: &str) -> Option<&str> {
6350    let bytes = text.as_bytes();
6351    let mut i = 0;
6352    while i + 4 < bytes.len() {
6353        if bytes[i].is_ascii_digit() {
6354            let start = i;
6355            let mut dots = 0;
6356            while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
6357                if bytes[i] == b'.' {
6358                    dots += 1;
6359                }
6360                i += 1;
6361            }
6362            if dots >= 2 {
6363                return Some(&text[start..i]);
6364            }
6365        }
6366        i += 1;
6367    }
6368    None
6369}
6370
6371fn bin_version(bin: &str) -> Option<String> {
6372    use std::process::{Command, Stdio};
6373    let path = which::which(bin).ok()?;
6374    // MCP servers that do not implement --version sit on stdio.
6375    // Cap the wait so doctor cannot hang the seat.
6376    let mut cmd = if bin.ends_with("-mcp") {
6377        let mut c = Command::new("timeout");
6378        c.args(["0.4", path.to_str()?, "--version"]);
6379        c
6380    } else {
6381        let mut c = Command::new(&path);
6382        c.arg("--version");
6383        c
6384    };
6385    let said = cmd
6386        .stdin(Stdio::null())
6387        .stdout(Stdio::piped())
6388        .stderr(Stdio::piped())
6389        .output()
6390        .ok()?;
6391    let stdout = String::from_utf8_lossy(&said.stdout);
6392    let stderr = String::from_utf8_lossy(&said.stderr);
6393    parse_semver(&stdout)
6394        .or_else(|| parse_semver(&stderr))
6395        .map(str::to_string)
6396}
6397
6398/// A day, in seconds: how long a crates.io answer is kept on disk.
6399const CRATE_VERSION_TTL_S: u64 = 86_400;
6400
6401/// Where a crates.io answer is kept between processes, so a herd of seats
6402/// opening sittings asks the registry once a day for each binary rather
6403/// than once a sitting each.
6404fn crate_version_cache(name: &str) -> Option<PathBuf> {
6405    let dir = std::env::var_os("XDG_CACHE_HOME")
6406        .filter(|r| !r.is_empty())
6407        .map(PathBuf::from)
6408        .or_else(|| home().ok().map(|h| h.join(".cache")))?
6409        .join("ljos");
6410    Some(dir.join(format!("crate-{name}")))
6411}
6412
6413/// A registry answer and where it came from: the day cache on disk, or
6414/// the registry itself.
6415#[derive(Debug, Clone, PartialEq, Eq)]
6416pub struct CrateVersion {
6417    pub version: String,
6418    pub cached: bool,
6419}
6420
6421/// The newest version crates.io lists for `name`, from the day cache when
6422/// it holds one. `refresh` skips the cache: a binary on `PATH` ahead of
6423/// the cached answer proves the cache stale.
6424fn crate_max_version(name: &str, refresh: bool) -> Option<CrateVersion> {
6425    use std::collections::HashMap;
6426    use std::sync::{Mutex, OnceLock};
6427    static CACHE: OnceLock<Mutex<HashMap<String, Option<CrateVersion>>>> = OnceLock::new();
6428    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
6429    if !refresh {
6430        if let Ok(guard) = cache.lock() {
6431            if let Some(hit) = guard.get(name) {
6432                return hit.clone();
6433            }
6434        }
6435    }
6436    let on_disk = crate_version_cache(name);
6437    if let Some(path) = on_disk.as_ref().filter(|_| !refresh) {
6438        let fresh = std::fs::metadata(path)
6439            .and_then(|m| m.modified())
6440            .ok()
6441            .and_then(|t| t.elapsed().ok())
6442            .is_some_and(|age| age.as_secs() < CRATE_VERSION_TTL_S);
6443        if fresh {
6444            if let Ok(text) = std::fs::read_to_string(path) {
6445                let v = text.trim();
6446                let got = (!v.is_empty()).then(|| CrateVersion {
6447                    version: v.to_string(),
6448                    cached: true,
6449                });
6450                if let Ok(mut guard) = cache.lock() {
6451                    guard.insert(name.to_string(), got.clone());
6452                }
6453                return got;
6454            }
6455        }
6456    }
6457    let url = format!("https://crates.io/api/v1/crates/{name}");
6458    let said = std::process::Command::new("curl")
6459        .args(["-sS", "-A", "ljos-doctor", "--max-time", "3", &url])
6460        .output()
6461        .ok();
6462    let got = said.and_then(|said| {
6463        if !said.status.success() {
6464            return None;
6465        }
6466        let v: serde_json::Value = serde_json::from_slice(&said.stdout).ok()?;
6467        v["crate"]["max_version"].as_str().map(|v| CrateVersion {
6468            version: v.to_string(),
6469            cached: false,
6470        })
6471    });
6472    if let (Some(path), Some(v)) = (&on_disk, &got) {
6473        if let Some(dir) = path.parent() {
6474            let _ = std::fs::create_dir_all(dir);
6475        }
6476        let _ = std::fs::write(path, format!("{}\n", v.version));
6477    }
6478    if let Ok(mut guard) = cache.lock() {
6479        guard.insert(name.to_string(), got.clone());
6480    }
6481    got
6482}
6483
6484fn cmp_semver(a: &str, b: &str) -> Option<std::cmp::Ordering> {
6485    let parse = |s: &str| -> Option<[u64; 3]> {
6486        let mut it = s.split('.');
6487        Some([
6488            it.next()?.parse().ok()?,
6489            it.next()?.parse().ok()?,
6490            it.next()?.parse().ok()?,
6491        ])
6492    };
6493    Some(parse(a)?.cmp(&parse(b)?))
6494}
6495
6496/// Which habitats answer: binaries on `PATH`, the pack over `PACKSET_URL`, the
6497/// deed store, the tracker, the claim graph.
6498pub fn doctor() -> Vec<Habitat> {
6499    // The runner rows ask the runners' own command lines, which start slowly;
6500    // they run beside the seat's rows rather than after them.
6501    let (mut out, runners) = std::thread::scope(|s| {
6502        let runners = s.spawn(harness_rows);
6503        let seat = doctor_seat();
6504        (seat, runners.join().unwrap_or_default())
6505    });
6506    out.extend(runners);
6507    out.extend(jev::doctor_row());
6508    out
6509}
6510
6511/// A binary on PATH answers even when crates.io is ahead. Sitting refuses
6512/// a missing required habitat, not a stale one. Behind and ahead are both
6513/// said; a registry answer read from the day cache says so.
6514fn bin_health(path: &str, have: Option<&str>, latest: Option<&CrateVersion>) -> (String, bool) {
6515    use std::cmp::Ordering;
6516    let ver = have.unwrap_or("?");
6517    let Some(cr) = latest else {
6518        return (format!("{path}  {ver}"), true);
6519    };
6520    let source = if cr.cached {
6521        "crates.io (cached)"
6522    } else {
6523        "crates.io"
6524    };
6525    let word = match have.and_then(|v| cmp_semver(v, &cr.version)) {
6526        Some(Ordering::Less) => "behind ",
6527        Some(Ordering::Greater) => "ahead of ",
6528        _ => "",
6529    };
6530    (
6531        format!("{path}  {ver}  {word}{source} {}", cr.version),
6532        true,
6533    )
6534}
6535
6536/// The registry answer for a seat binary. A cached answer the binary on
6537/// `PATH` is already ahead of is stale by construction, so the registry
6538/// is asked again before the row is written.
6539fn crate_version_for(crate_name: &str, have: Option<&str>) -> Option<CrateVersion> {
6540    let first = crate_max_version(crate_name, false)?;
6541    let ahead = first.cached
6542        && have.is_some_and(|v| cmp_semver(v, &first.version) == Some(std::cmp::Ordering::Greater));
6543    if ahead {
6544        crate_max_version(crate_name, true).or(Some(first))
6545    } else {
6546        Some(first)
6547    }
6548}
6549
6550/// Evidence citations and forecast confidence are part of the ballot protocol.
6551/// A version line alone does not establish that the tracker accepts them.
6552fn check_vissue_ballot_protocol(path: &Path) -> Result<()> {
6553    use std::process::{Command, Stdio};
6554    let said = Command::new("timeout")
6555        .arg("2")
6556        .arg(path)
6557        .args(["vote", "--help"])
6558        .stdin(Stdio::null())
6559        .output()
6560        .context("could not check vissue vote --help")?;
6561    if !said.status.success() {
6562        bail!("vissue vote --help failed ({})", said.status);
6563    }
6564    let help = String::from_utf8_lossy(&said.stdout);
6565    let missing: Vec<_> = ["--used", "--confidence"]
6566        .into_iter()
6567        .filter(|flag| !help.split_whitespace().any(|word| word == *flag))
6568        .collect();
6569    if !missing.is_empty() {
6570        bail!(
6571            "incompatible ballot protocol: missing {}; install vissue-cli >= 0.16.2",
6572            missing.join(", ")
6573        );
6574    }
6575    Ok(())
6576}
6577
6578/// The seat's own rows: binaries, pack, host key, deed store, tracker,
6579/// claim graph. What a sitting checks; the runner rows are onboarding.
6580pub fn doctor_seat() -> Vec<Habitat> {
6581    let mut out = Vec::new();
6582    for (bin, crate_name) in SEAT_BINS {
6583        let found = which::which(bin).ok();
6584        let have = found.as_ref().and_then(|_| bin_version(bin));
6585        let latest = crate_version_for(crate_name, have.as_deref());
6586        let ballot_protocol = found
6587            .as_deref()
6588            .filter(|_| *bin == "vissue")
6589            .map(check_vissue_ballot_protocol);
6590        let (mut state, mut ok) = match (found, have.as_deref(), latest.as_ref()) {
6591            (None, _, Some(cr)) => (
6592                format!(
6593                    "not on PATH; cargo binstall {crate_name} (crates.io {})",
6594                    cr.version
6595                ),
6596                false,
6597            ),
6598            (None, _, None) => ("not on PATH".into(), false),
6599            (Some(path), have, Some(cr)) => bin_health(&path.display().to_string(), have, Some(cr)),
6600            (Some(path), have, None) => {
6601                let ver = have.unwrap_or("?");
6602                (format!("{}  {ver}", path.display()), true)
6603            }
6604        };
6605        if let Some(protocol) = ballot_protocol {
6606            match protocol {
6607                Ok(()) => state.push_str("; evidence ballots supported"),
6608                Err(error) => {
6609                    state.push_str(&format!("; {error:#}"));
6610                    ok = false;
6611                }
6612            }
6613        }
6614        out.push(Habitat {
6615            name: bin,
6616            state,
6617            ok,
6618        });
6619    }
6620    // The host the seat runs on: a kernel that OOM-kills keeps killing the
6621    // encoder, the runners and the desktop, and every other row stays green.
6622    out.push(host_row());
6623    // Who is sitting: the name this runner votes under, the name this
6624    // conversation claims under, and where they came from.
6625    out.push(Habitat {
6626        name: "seat",
6627        state: format_seat_row(),
6628        ok: true,
6629    });
6630    load_seat_env();
6631    // The dense ballot: without it the pack ranks by words alone, and an
6632    // island's seeds are weaker than the agent may assume.
6633    out.push(
6634        match PacksetClient::from_env().and_then(|c| c.status(None)) {
6635            Ok(status) => {
6636                let available = status["embedder"]["available"].as_bool().unwrap_or(false);
6637                let answering = status["embedder"]["answering"].as_bool();
6638                Habitat {
6639                    name: "encoder",
6640                    state: if available {
6641                        "dense ballot on".to_string()
6642                    } else if answering == Some(false) {
6643                        "packset-embed did not answer its last call (killed or crashed); \
6644                         ranking is lexical until packsetd restarts it on the next search"
6645                            .to_string()
6646                    } else {
6647                        "down; cargo binstall packset-embed and put it beside packsetd".to_string()
6648                    },
6649                    ok: available,
6650                }
6651            }
6652            Err(e) => Habitat {
6653                name: "encoder",
6654                state: format!("pack does not answer: {e}"),
6655                ok: false,
6656            },
6657        },
6658    );
6659    out.push(match pack() {
6660        Ok(client) => match client.health() {
6661            Ok(_) => Habitat {
6662                name: "pack",
6663                state: format!("{} workspace {}", client.base(), client.workspace()),
6664                ok: true,
6665            },
6666            Err(e) => Habitat {
6667                name: "pack",
6668                state: format!("{} does not answer: {e}", client.base()),
6669                ok: false,
6670            },
6671        },
6672        Err(_) => Habitat {
6673            name: "pack",
6674            state: "PACKSET_URL=off: no pack on purpose".into(),
6675            ok: false,
6676        },
6677    });
6678    // What the pack holds and what it let go: the seat that lets a pack
6679    // grow or forget under it reads it here rather than in `packset status`.
6680    if let Ok(client) = pack() {
6681        if let Ok(status) = client.status(Some(&client.workspace())) {
6682            let live = status["live"].as_u64().unwrap_or(0);
6683            let cap = status["live_cap"].as_u64().unwrap_or(0);
6684            let forgotten: Vec<String> = status["forgotten_by_reason"]
6685                .as_object()
6686                .map(|m| {
6687                    m.iter()
6688                        .map(|(why, n)| format!("{} by {why}", n.as_u64().unwrap_or(0)))
6689                        .collect()
6690                })
6691                .unwrap_or_default();
6692            let mut state = if cap > 0 {
6693                format!("{live} live of {cap}")
6694            } else {
6695                format!("{live} live, no cap")
6696            };
6697            if !forgotten.is_empty() {
6698                state.push_str(&format!("; forgotten {}", forgotten.join(", ")));
6699            }
6700            out.push(Habitat {
6701                name: "memory",
6702                state,
6703                ok: cap == 0 || live <= cap,
6704            });
6705        }
6706    }
6707    out.push(match host_key_path() {
6708        Some(path) => {
6709            let seed = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0) == 32;
6710            // A key the deed store does not list signs deeds that evidence
6711            // refuses. deedar says so; one without the verb is not asked.
6712            let unlisted = if seed {
6713                run_captured("deedar", &["host"])
6714                    .err()
6715                    .map(|e| e.to_string())
6716                    .filter(|e| e.contains("is not a signer"))
6717            } else {
6718                None
6719            };
6720            Habitat {
6721                name: "host key",
6722                state: match (&unlisted, seed) {
6723                    (Some(why), _) => format!(
6724                        "{} (32-byte seed); {}",
6725                        path.display(),
6726                        why.lines().next().unwrap_or("").trim()
6727                    ),
6728                    (None, true) => format!("{} (32-byte seed)", path.display()),
6729                    (None, false) => format!("{} is not a 32-byte seed", path.display()),
6730                },
6731                ok: seed && unlisted.is_none(),
6732            }
6733        }
6734        None => Habitat {
6735            name: "host key",
6736            state: "none at ~/.config/deedar/host.key and DEEDAR_HOST_SIGNING_KEY unset; \
6737                    handovers go out unsigned"
6738                .into(),
6739            ok: false,
6740        },
6741    });
6742    for (name, bin, args) in [
6743        ("deed store", "deedar", &["log", "head"][..]),
6744        ("tracker", "vissue", &["identity"][..]),
6745        ("claim graph", "claimdag", &["list"][..]),
6746    ] {
6747        out.push(match run_captured(bin, args) {
6748            Ok(said) if name == "tracker" => {
6749                let (state, ok) = tracker_state(&said.stdout, &root_source());
6750                Habitat { name, state, ok }
6751            }
6752            Ok(said) => Habitat {
6753                name,
6754                state: said.stdout.lines().next().unwrap_or("").to_string(),
6755                ok: true,
6756            },
6757            Err(e) if name == "claim graph" && claim_graph_absent(&e.to_string()).is_some() => {
6758                let dir = claim_graph_absent(&e.to_string()).unwrap_or_default();
6759                Habitat {
6760                    name,
6761                    state: format!("none yet; the first claim creates it at {dir}"),
6762                    ok: true,
6763                }
6764            }
6765            Err(e) => Habitat {
6766                name,
6767                state: e.to_string().lines().next().unwrap_or("").to_string(),
6768                ok: false,
6769            },
6770        });
6771    }
6772    out
6773}
6774
6775/// The directory claimdag would create, when its refusal says the seat has
6776/// no work graph yet because nothing was ever claimed. A fresh host is not a
6777/// fault: the sitting's first claim creates the graph.
6778pub fn claim_graph_absent(said: &str) -> Option<String> {
6779    let rest = said.split("no work graph at ").nth(1)?;
6780    let (dir, why) = rest.split_once(": ")?;
6781    why.starts_with("the directory does not exist")
6782        .then(|| dir.trim().to_string())
6783}
6784
6785/// Where the tracker root came from, in the order vissue decides it.
6786fn root_source() -> String {
6787    for var in ["ISSUE_ROOT", "VISSUE_ROOT"] {
6788        if let Some(v) = std::env::var_os(var).filter(|v| !v.is_empty()) {
6789            return format!("{var}={}", v.to_string_lossy());
6790        }
6791    }
6792    "seat config or working directory".into()
6793}
6794
6795/// The tracker row from `vissue identity`: version, the root and prefix it
6796/// resolved, and where the root came from. A root that is relative, missing,
6797/// or holds no prefix directory fails the row: tickets filed there are
6798/// invisible to every other seat. When the root is a git checkout with an
6799/// upstream, the row also names how many commits origin lacks.
6800pub fn tracker_state(identity: &str, source: &str) -> (String, bool) {
6801    let version = identity.lines().next().unwrap_or("").trim();
6802    let field = |key: &str| {
6803        identity
6804            .lines()
6805            .find_map(|l| l.strip_prefix(key))
6806            .map(str::trim)
6807            .filter(|v| !v.is_empty())
6808    };
6809    let (Some(root), Some(prefix)) = (field("root="), field("prefix=")) else {
6810        return (format!("{version}; no root in vissue identity"), false);
6811    };
6812    let path = std::path::Path::new(root);
6813    let problem = if !path.is_absolute() {
6814        Some("relative root: tickets land under the working directory")
6815    } else if !path.is_dir() {
6816        Some("root is not a directory")
6817    } else if !path.join(prefix).is_dir() {
6818        Some("no prefix directory under the root")
6819    } else {
6820        None
6821    };
6822    let base = format!("{version} root={root} prefix={prefix} from {source}");
6823    match problem {
6824        Some(why) => (format!("{base}; {why}"), false),
6825        None => match tracker_git_drift(path) {
6826            Some((extra, git_ok)) => (format!("{base}; {extra}"), git_ok),
6827            None => (base, true),
6828        },
6829    }
6830}
6831
6832fn git_in(dir: &Path, args: &[&str]) -> Option<std::process::Output> {
6833    std::process::Command::new("git")
6834        .arg("-C")
6835        .arg(dir)
6836        .args(args)
6837        .stdin(std::process::Stdio::null())
6838        .output()
6839        .ok()
6840}
6841
6842fn git_ok_stdout(dir: &Path, args: &[&str]) -> Option<String> {
6843    let o = git_in(dir, args)?;
6844    o.status
6845        .success()
6846        .then(|| String::from_utf8_lossy(&o.stdout).to_string())
6847}
6848
6849/// Upstream of the tracker checkout: the configured `@{upstream}`, else
6850/// `origin/HEAD`. Absent when the root is not a git checkout, or has no
6851/// remote the doctor can count against.
6852pub(crate) fn tracker_upstream(root: &Path) -> Option<String> {
6853    let inside = git_ok_stdout(root, &["rev-parse", "--is-inside-work-tree"])?;
6854    if inside.trim() != "true" {
6855        return None;
6856    }
6857    if let Some(up) = git_ok_stdout(
6858        root,
6859        &[
6860            "rev-parse",
6861            "--abbrev-ref",
6862            "--symbolic-full-name",
6863            "@{upstream}",
6864        ],
6865    ) {
6866        let up = up.trim().to_string();
6867        if !up.is_empty() {
6868            return Some(up);
6869        }
6870    }
6871    git_ok_stdout(root, &["rev-parse", "--verify", "origin/HEAD"]).map(|_| "origin/HEAD".into())
6872}
6873
6874/// Whether a leftover `tracker-push-<pid>.log` still has that pid running.
6875fn pid_alive(pid: u32) -> bool {
6876    // SAFETY: kill with signal 0 only probes existence; it does not deliver.
6877    unsafe { libc::kill(pid as i32, 0) == 0 }
6878}
6879
6880/// Newest leftover tracker-push log whose process has exited, and whether
6881/// any log's process is still running. persist_tracker removes the log on
6882/// a foreground success and leaves it on a refusal or a background push.
6883fn tracker_push_logs() -> (bool, Option<(std::time::SystemTime, PathBuf)>) {
6884    let Ok(entries) = std::fs::read_dir(runtime_dir()) else {
6885        return (false, None);
6886    };
6887    let mut running = false;
6888    let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
6889    for ent in entries.flatten() {
6890        let name = ent.file_name();
6891        let name = name.to_string_lossy();
6892        let Some(rest) = name
6893            .strip_prefix("tracker-push-")
6894            .and_then(|s| s.strip_suffix(".log"))
6895        else {
6896            continue;
6897        };
6898        let Ok(pid) = rest.parse::<u32>() else {
6899            continue;
6900        };
6901        if pid_alive(pid) {
6902            running = true;
6903            continue;
6904        }
6905        let mtime = ent
6906            .metadata()
6907            .and_then(|m| m.modified())
6908            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
6909        let path = ent.path();
6910        if newest.as_ref().is_none_or(|(t, _)| mtime >= *t) {
6911            newest = Some((mtime, path));
6912        }
6913    }
6914    (running, newest)
6915}
6916
6917fn last_push_refusal() -> Option<String> {
6918    let path = tracker_push_logs().1?.1;
6919    let said = std::fs::read(path).ok()?;
6920    let line = first_line(&said);
6921    (!line.is_empty()).then_some(line)
6922}
6923
6924/// Commits the tracker checkout holds that origin does not. The count is
6925/// always named. A live background push, or commits younger than the push
6926/// wait, stay healthy: the sitting already waited that long. Older drift
6927/// fails the row, and a leftover refused-push log names the reason.
6928pub fn tracker_git_drift(root: &Path) -> Option<(String, bool)> {
6929    let up = tracker_upstream(root)?;
6930    let (mut state, mut ok) = unpushed_drift(root, &up)?;
6931    if let Some(split) = tracker_remote_split(root, &up) {
6932        state = format!("{state}; {split}");
6933        ok = false;
6934    }
6935    if let Some(missing) = tracker_merge_driver_missing(root) {
6936        state = format!("{state}; {missing}");
6937        ok = false;
6938    }
6939    Some((state, ok))
6940}
6941
6942/// A tracker whose .gitattributes merges issues.org with vissue, in a clone
6943/// that has no such driver configured. git then merges the file as text
6944/// without a word, which is the failure the driver exists to prevent: the
6945/// attribute travels with the repository, the driver's command does not.
6946fn tracker_merge_driver_missing(root: &Path) -> Option<String> {
6947    let top = git_ok_stdout(root, &["rev-parse", "--show-toplevel"])?;
6948    let attrs = std::fs::read_to_string(Path::new(top.trim()).join(".gitattributes")).ok()?;
6949    let named = attrs
6950        .lines()
6951        .any(|l| l.split_whitespace().any(|w| w == "merge=vissue"));
6952    if !named {
6953        return None;
6954    }
6955    let driver = git_ok_stdout(root, &["config", "--get", "merge.vissue.driver"]);
6956    driver.filter(|d| !d.trim().is_empty()).is_none().then(|| {
6957        ".gitattributes merges issues.org with vissue and this clone has no merge.vissue.driver; \
6958         `vissue merge-driver --install` in the tracker registers it"
6959            .to_string()
6960    })
6961}
6962
6963/// The remotes of the tracker whose head of the upstream's branch differs
6964/// from the upstream's, as of the last fetch. Two seats that push to two
6965/// remotes of one tracker each read only their own writes, and every other
6966/// row stays green while they do.
6967fn tracker_remote_split(root: &Path, up: &str) -> Option<String> {
6968    let (_, branch) = up.split_once('/')?;
6969    let refs = git_ok_stdout(
6970        root,
6971        &[
6972            "for-each-ref",
6973            "--format=%(refname:short) %(objectname)",
6974            "refs/remotes",
6975        ],
6976    )?;
6977    let heads: Vec<(&str, &str)> = refs
6978        .lines()
6979        .filter_map(|l| l.trim().split_once(' '))
6980        .filter(|(r, _)| r.split_once('/').is_some_and(|(_, b)| b == branch))
6981        .collect();
6982    let tip = heads.iter().find(|(r, _)| *r == up)?.1;
6983    let off: Vec<&str> = heads
6984        .iter()
6985        .filter(|(_, o)| *o != tip)
6986        .map(|(r, _)| *r)
6987        .collect();
6988    (!off.is_empty()).then(|| {
6989        format!(
6990            "{} differs from {up}; pull and push every remote until they agree",
6991            off.join(", ")
6992        )
6993    })
6994}
6995
6996/// The remotes other than the upstream's that carry its branch, as
6997/// (remote, branch). Names that would need quoting are left out.
6998pub(crate) fn tracker_mirrors(root: &Path, up: &str) -> Option<Vec<(String, String)>> {
6999    let (upstream, branch) = up.split_once('/')?;
7000    let plain = |s: &str| {
7001        !s.is_empty()
7002            && s.chars()
7003                .all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
7004    };
7005    let refs = git_ok_stdout(
7006        root,
7007        &["for-each-ref", "--format=%(refname:short)", "refs/remotes"],
7008    )?;
7009    Some(
7010        refs.lines()
7011            .filter_map(|r| r.trim().split_once('/'))
7012            .filter(|(r, b)| *r != upstream && *b == branch && plain(r) && plain(b))
7013            .map(|(r, b)| (r.to_string(), b.to_string()))
7014            .collect(),
7015    )
7016}
7017
7018fn unpushed_drift(root: &Path, up: &str) -> Option<(String, bool)> {
7019    let range = format!("{up}..HEAD");
7020    let count: u64 = git_ok_stdout(root, &["rev-list", "--count", &range])?
7021        .trim()
7022        .parse()
7023        .ok()?;
7024    if count == 0 {
7025        return Some(("0 unpushed".into(), true));
7026    }
7027    let (running, _) = tracker_push_logs();
7028    let oldest = git_ok_stdout(root, &["log", "--format=%ct", "--reverse", &range])
7029        .and_then(|s| {
7030            s.lines()
7031                .find(|l| !l.trim().is_empty())
7032                .map(|l| l.trim().to_string())
7033        })
7034        .and_then(|s| s.parse::<u64>().ok());
7035    let now = std::time::SystemTime::now()
7036        .duration_since(std::time::UNIX_EPOCH)
7037        .unwrap_or_default()
7038        .as_secs();
7039    let stuck = oldest.is_some_and(|t| now.saturating_sub(t) >= push_wait().as_secs());
7040    let unpushed = if count == 1 {
7041        "1 unpushed".to_string()
7042    } else {
7043        format!("{count} unpushed")
7044    };
7045    if running {
7046        return Some((format!("{unpushed}; push still running"), true));
7047    }
7048    if let Some(why) = last_push_refusal() {
7049        return Some((format!("{unpushed}; last push refused: {why}"), false));
7050    }
7051    Some((unpushed, !stuck))
7052}
7053
7054/// The kernel, its OOM kills since boot, and the ljos-mcp servers this
7055/// login runs with their resident memory. Fails on any OOM kill: one kill
7056/// took the encoder, the next the compositor.
7057fn host_row() -> Habitat {
7058    let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease")
7059        .map(|s| s.trim().to_string())
7060        .unwrap_or_else(|_| "unknown kernel".into());
7061    let kills = oom_kills();
7062    let (servers, rss_kb) = ljos_mcp_servers();
7063    let mcp = format!("{servers} ljos-mcp, {} MB resident", rss_kb / 1024);
7064    match kills {
7065        Some(0) => Habitat {
7066            name: "host",
7067            state: format!("{kernel}; no OOM kills since boot; {mcp}"),
7068            ok: true,
7069        },
7070        Some(n) => Habitat {
7071            name: "host",
7072            state: format!(
7073                "{kernel}; {n} OOM kills since boot (/proc/vmstat oom_kill); {mcp}; \
7074                 the kernel is killing processes, read `journalctl -k -b` before the load"
7075            ),
7076            ok: false,
7077        },
7078        None => Habitat {
7079            name: "host",
7080            state: format!("{kernel}; {mcp}"),
7081            ok: true,
7082        },
7083    }
7084}
7085
7086/// OOM kills since boot, from `/proc/vmstat`; none where it is not.
7087fn oom_kills() -> Option<u64> {
7088    parse_oom_kills(&std::fs::read_to_string("/proc/vmstat").ok()?)
7089}
7090
7091fn parse_oom_kills(vmstat: &str) -> Option<u64> {
7092    vmstat
7093        .lines()
7094        .find_map(|l| l.strip_prefix("oom_kill "))
7095        .and_then(|n| n.trim().parse().ok())
7096}
7097
7098/// The ljos-mcp processes of this user and their summed resident size in
7099/// kB, from procfs.
7100fn ljos_mcp_servers() -> (usize, u64) {
7101    let uid = std::fs::read_to_string("/proc/self/status")
7102        .ok()
7103        .and_then(|s| status_field(&s, "Uid:"));
7104    let Ok(dir) = std::fs::read_dir("/proc") else {
7105        return (0, 0);
7106    };
7107    let mut count = 0;
7108    let mut rss = 0;
7109    for entry in dir.flatten() {
7110        let path = entry.path();
7111        if std::fs::read_to_string(path.join("comm")).map_or(true, |c| c.trim() != "ljos-mcp") {
7112            continue;
7113        }
7114        let Ok(status) = std::fs::read_to_string(path.join("status")) else {
7115            continue;
7116        };
7117        if status_field(&status, "Uid:") != uid {
7118            continue;
7119        }
7120        count += 1;
7121        rss += status_field(&status, "VmRSS:")
7122            .and_then(|v| v.parse::<u64>().ok())
7123            .unwrap_or(0);
7124    }
7125    (count, rss)
7126}
7127
7128/// The first number on a `/proc/*/status` line.
7129fn status_field(status: &str, key: &str) -> Option<String> {
7130    status
7131        .lines()
7132        .find_map(|l| l.strip_prefix(key))
7133        .and_then(|rest| rest.split_whitespace().next())
7134        .map(str::to_string)
7135}
7136
7137/// Whether every required habitat answers.
7138pub fn healthy(rows: &[Habitat]) -> bool {
7139    rows.iter()
7140        .all(|h| h.ok || !REQUIRED.contains(&h.name) && h.name != "pack")
7141}
7142
7143pub fn format_doctor(rows: &[Habitat]) -> String {
7144    rows.iter()
7145        .map(|h| {
7146            format!(
7147                "{}	{}	{}
7148",
7149                if h.ok { "ok" } else { "no" },
7150                h.name,
7151                h.state
7152            )
7153        })
7154        .collect()
7155}
7156
7157/// The accessions a satchel's description says it needs.
7158pub fn needs_of(satchel_json: &str) -> Result<Vec<String>> {
7159    let v: Value = serde_json::from_str(satchel_json).context("satchel.json")?;
7160    Ok(v.get("needs")
7161        .and_then(Value::as_array)
7162        .map(|a| {
7163            a.iter()
7164                .filter_map(Value::as_str)
7165                .map(str::to_string)
7166                .collect()
7167        })
7168        .unwrap_or_default())
7169}
7170
7171/// Deeds to enclose: the satchel's `needs` plus what the pack cites, once each.
7172pub fn enclose(needs: Vec<String>, cited: &str) -> Vec<String> {
7173    let mut all: Vec<String> = needs
7174        .into_iter()
7175        .chain(cited.lines().map(str::trim).map(str::to_string))
7176        .filter(|s| !s.is_empty())
7177        .collect();
7178    all.sort();
7179    all.dedup();
7180    all
7181}
7182
7183/// Pack a slice of the seat into `out`: the tracker's satchel, the pack's
7184/// atoms, the deeds both cite, sealed, and signed when a host key is set.
7185pub fn handover(out: &Path, projects: &[String], issues: &[String]) -> Result<Vec<String>> {
7186    if projects.is_empty() && issues.is_empty() {
7187        bail!("handover: name a project or an issue");
7188    }
7189    let mut lines = Vec::new();
7190    let mut args = vec![
7191        "satchel".to_string(),
7192        "--out".into(),
7193        out.display().to_string(),
7194    ];
7195    for p in projects {
7196        args.push("--project".into());
7197        args.push(p.clone());
7198    }
7199    for i in issues {
7200        args.push("--issue".into());
7201        args.push(i.clone());
7202    }
7203    lines.push(run_captured("vissue", &args)?.stdout.trim_end().to_string());
7204
7205    let mut cited = String::new();
7206    match PacksetClient::from_env() {
7207        Ok(client) => {
7208            let atoms_dir = out.join("data").join("atoms");
7209            match run_captured(
7210                "packset",
7211                &[
7212                    "export",
7213                    "--into",
7214                    &atoms_dir.display().to_string(),
7215                    &client.workspace(),
7216                ],
7217            ) {
7218                Ok(said) => {
7219                    cited = said.stdout;
7220                    lines.push(said.stderr.trim_end().to_string());
7221                }
7222                Err(e) => lines.push(format!("atoms not enclosed: {e}")),
7223            }
7224        }
7225        Err(_) => lines.push("no pack: PACKSET_URL=off, atoms not enclosed".into()),
7226    }
7227
7228    let description = std::fs::read_to_string(out.join("data").join("satchel.json"))
7229        .context("handover: the satchel has no description")?;
7230    let deeds = enclose(needs_of(&description)?, &cited);
7231    if deeds.is_empty() {
7232        lines.push("no deeds cited".into());
7233    } else {
7234        let deeds_dir = out.join("data").join("deeds");
7235        let said = run_fed(
7236            "deedar",
7237            &["export", "--into", &deeds_dir.display().to_string(), "-"],
7238            &format!(
7239                "{}
7240",
7241                deeds.join(
7242                    "
7243"
7244                )
7245            ),
7246        )?;
7247        lines.push(said.stdout.trim_end().to_string());
7248    }
7249
7250    lines.push(
7251        run_captured("vissue", &["satchel", "--seal", &out.display().to_string()])?
7252            .stdout
7253            .trim_end()
7254            .to_string(),
7255    );
7256    // The key deedar signs with is the one doctor reports: the variable, or
7257    // the seat's own at ~/.config/deedar/host.key. `off` signs nothing.
7258    if host_key_path().is_some() {
7259        let manifest = out.join("manifest-sha256.txt");
7260        let said = run_captured(
7261            "deedar",
7262            &["vouch", "sign", &manifest.display().to_string()],
7263        )?;
7264        lines.push(said.stdout.trim_end().to_string());
7265    } else {
7266        lines.push(
7267            "unsigned: no host key at ~/.config/deedar/host.key and DEEDAR_HOST_SIGNING_KEY unset; \
7268             `ljos onboard` writes one"
7269                .into(),
7270        );
7271    }
7272    Ok(lines)
7273}
7274
7275/// Check a satchel that arrived: manifest, deed receipts, signature, and what
7276/// the atoms hold; with `import`, POST the atoms into this seat's pack.
7277pub fn receive(dir: &Path, since: Option<&Path>, import: bool) -> Result<Vec<String>> {
7278    let mut lines = Vec::new();
7279    lines.push(
7280        run_captured(
7281            "vissue",
7282            &["satchel", "--verify", &dir.display().to_string()],
7283        )?
7284        .stdout
7285        .trim_end()
7286        .to_string(),
7287    );
7288    if dir.join("data").join("deeds").is_dir() {
7289        let mut args = vec!["check".to_string(), dir.display().to_string()];
7290        if let Some(bridge) = since {
7291            args.push("--since".into());
7292            args.push(bridge.display().to_string());
7293        }
7294        lines.push(run_captured("deedar", &args)?.stdout.trim_end().to_string());
7295    } else {
7296        lines.push("no deeds enclosed".into());
7297    }
7298    let manifest = dir.join("manifest-sha256.txt");
7299    // Who sent it, for the atoms' provenance: the signing key when the bag
7300    // is signed, else the fact of a handover. An imported claim then says
7301    // where it came from, and a search can ask for what one seat taught.
7302    let mut sender = "from:handover".to_string();
7303    if manifest.with_extension("txt.sig").is_file() {
7304        let said = run_captured(
7305            "deedar",
7306            &["vouch", "check", &manifest.display().to_string()],
7307        )?
7308        .stdout
7309        .trim_end()
7310        .to_string();
7311        if !said.starts_with("signed by ") {
7312            bail!("receive: satchel is not signed by an accepted key: {said}");
7313        }
7314        if let Some(hex) = said
7315            .strip_prefix("signed by ")
7316            .and_then(|rest| rest.split(|c: char| !c.is_ascii_hexdigit()).next())
7317            .filter(|h| h.len() >= 12)
7318        {
7319            sender = format!("from:{}", &hex[..12]);
7320        }
7321        lines.push(said);
7322    } else if import {
7323        bail!("receive: unsigned satchel; will not import");
7324    } else {
7325        lines.push("unsigned".into());
7326    }
7327
7328    let atoms = enclosed_atoms(dir)?;
7329    let rows = trust_rows(&atoms);
7330    lines.push(format!(
7331        "{} atoms enclosed, {} trust rows",
7332        atoms.len(),
7333        rows.len()
7334    ));
7335    if import {
7336        let client = pack()?;
7337        let workspace = client.workspace();
7338        let (mut kept, mut refused) = (0usize, Vec::new());
7339        for atom in &atoms {
7340            // The atoms arrive stamped with the sender's workspace; they join
7341            // this seat's, or the import lands in a workspace nobody reads.
7342            let mut atom = atom.clone();
7343            if let Some(map) = atom.as_object_mut() {
7344                map.insert("workspace".into(), Value::String(workspace.clone()));
7345                let mut entities: Vec<Value> = map
7346                    .get("entities")
7347                    .and_then(Value::as_array)
7348                    .cloned()
7349                    .unwrap_or_default();
7350                if !entities.iter().any(|e| e.as_str() == Some(sender.as_str())) {
7351                    entities.push(Value::String(sender.clone()));
7352                }
7353                map.insert("entities".into(), Value::Array(entities));
7354            }
7355            match client.post_atom(&atom) {
7356                Ok(_) => kept += 1,
7357                Err(e) => refused.push(e.to_string()),
7358            }
7359        }
7360        lines.push(format!("{kept} atoms imported, {} refused", refused.len()));
7361        lines.extend(refused.into_iter().take(5));
7362        if kept > 0 {
7363            lines.push(
7364                "imported claims may rewrite held ones; `ljos consolidate` reports the pairs, `--apply` closes them"
7365                    .to_string(),
7366            );
7367        }
7368    }
7369    Ok(lines)
7370}
7371
7372/// Every atom in a satchel's `data/atoms/*.jsonl`.
7373pub fn enclosed_atoms(dir: &Path) -> Result<Vec<Value>> {
7374    let atoms_dir = dir.join("data").join("atoms");
7375    let Ok(entries) = std::fs::read_dir(&atoms_dir) else {
7376        return Ok(Vec::new());
7377    };
7378    let mut out = Vec::new();
7379    for entry in entries.flatten() {
7380        let text = std::fs::read_to_string(entry.path())?;
7381        for line in text.lines().filter(|l| !l.trim().is_empty()) {
7382            out.push(
7383                serde_json::from_str(line).with_context(|| entry.path().display().to_string())?,
7384            );
7385        }
7386    }
7387    Ok(out)
7388}
7389
7390/// Kinds that are weighed, not recalled, and so never come up for review.
7391/// Kinds the review clock never holds and the hook never injects: trust
7392/// and persona rows are weighed, playbooks are copied, and a prediction is a
7393/// forecast on one ballot, with nothing in it to recall.
7394const UNREVIEWED_KINDS: &[&str] = &["trust", "persona", "playbook", "prediction"];
7395
7396/// Whether an atom is a claim the review clock should hold at all.
7397fn reviewable(a: &Value) -> bool {
7398    !UNREVIEWED_KINDS.contains(&a.get("kind").and_then(Value::as_str).unwrap_or(""))
7399}
7400
7401/// The live atoms whose review is due at `now` (RFC 3339 UTC), soonest first.
7402/// A claim that has never entered the review clock has no `due_at`; it is
7403/// due now, and grading it puts it on the clock. Trust and persona rows are
7404/// weighed, not recalled, and never come up.
7405pub fn due_of(atoms: &[Value], now: &str) -> Vec<Value> {
7406    let mut due: Vec<Value> = atoms
7407        .iter()
7408        .filter(|a| reviewable(a))
7409        .filter(|a| {
7410            a.get("due_at")
7411                .and_then(Value::as_str)
7412                .is_none_or(|d| d.is_empty() || d <= now)
7413        })
7414        .cloned()
7415        .collect();
7416    due.sort_by(|a, b| {
7417        a["due_at"]
7418            .as_str()
7419            .unwrap_or("")
7420            .cmp(b["due_at"].as_str().unwrap_or(""))
7421    });
7422    due
7423}
7424
7425/// One line on the state of the review clock: how many are due, how many
7426/// are scheduled, and when the next one comes up. An empty `due` with a
7427/// next date is a clock that is running; an empty `due` with nothing
7428/// scheduled is a seat that has remembered nothing.
7429pub fn review_summary(atoms: &[Value], now: &str) -> String {
7430    let due = due_of(atoms, now).len();
7431    let mut later: Vec<&str> = atoms
7432        .iter()
7433        .filter(|a| reviewable(a))
7434        .filter_map(|a| a.get("due_at").and_then(Value::as_str))
7435        .filter(|d| !d.is_empty() && *d > now)
7436        .collect();
7437    later.sort_unstable();
7438    match later.first() {
7439        Some(next) => format!("{due} due; {} scheduled, next at {next}", later.len()),
7440        None if due == 0 => "0 due; nothing scheduled: this seat has remembered nothing yet".into(),
7441        None => format!("{due} due; nothing else scheduled"),
7442    }
7443}
7444
7445/// The due claims with the island's first, keeping each group's due
7446/// order: the claims a sitting's work bears on are the ones its agent can
7447/// grade from what it is about to read, rather than the oldest in the pack.
7448#[must_use]
7449pub fn due_on_island_first(due: Vec<Value>, island: &Value) -> Vec<Value> {
7450    // A weak island is the pack's best-connected cluster, not the issue's.
7451    if island["weak"].as_bool().unwrap_or(false) {
7452        return due;
7453    }
7454    let on: std::collections::BTreeSet<&str> = island["island"]
7455        .as_array()
7456        .into_iter()
7457        .flatten()
7458        .filter_map(|a| a["id"].as_str())
7459        .collect();
7460    let (mut first, rest): (Vec<Value>, Vec<Value>) = due
7461        .into_iter()
7462        .partition(|a| a["id"].as_str().is_some_and(|id| on.contains(id)));
7463    first.extend(rest);
7464    first
7465}
7466
7467/// How many due rows a sitting prints before the summary line.
7468pub const SITTING_DUE: usize = 8;
7469
7470/// How many dated events a sitting's timeline prints. Protocol: last twelve.
7471pub const SITTING_TIMELINE: usize = 12;
7472
7473/// The review clock as a sitting prints it: a short prefix, then the summary.
7474pub fn sitting_due_report(island: &Value) -> Result<String> {
7475    let client = pack()?;
7476    // The same sweep `ljos due` runs. A sitting is the clock's ordinary
7477    // opening; a review left due past twice its interval lapses here.
7478    let swept = client.sweep(&client.workspace()).ok();
7479    let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7480    let now = now_utc();
7481    let due = due_on_island_first(due_of(&atoms, &now), island);
7482    let shown = due.len().min(SITTING_DUE);
7483    Ok(format!(
7484        "{}{}{}\n",
7485        format_due(&due[..shown]),
7486        review_summary(&atoms, &now),
7487        format_sweep(swept.as_ref())
7488    ))
7489}
7490
7491/// The review clock as `ljos due` prints it: the due atoms, then the summary.
7492pub fn due_report() -> Result<String> {
7493    let client = pack()?;
7494    // The sweep runs first, so a review left due past twice its interval is
7495    // lapsed or forgotten before the list is read, and the report says so.
7496    let swept = client.sweep(&client.workspace()).ok();
7497    let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7498    let now = now_utc();
7499    Ok(format!(
7500        "{}{}{}\n",
7501        format_due(&due_of(&atoms, &now)),
7502        review_summary(&atoms, &now),
7503        format_sweep(swept.as_ref())
7504    ))
7505}
7506
7507/// One line on what the sweep did, or nothing when it found nothing.
7508pub fn format_sweep(report: Option<&Value>) -> String {
7509    let Some(report) = report else {
7510        return String::new();
7511    };
7512    let lapsed = report.get("lapsed").and_then(Value::as_u64).unwrap_or(0);
7513    let forgotten = report.get("forgotten").and_then(Value::as_u64).unwrap_or(0);
7514    if lapsed == 0 && forgotten == 0 {
7515        return String::new();
7516    }
7517    format!(
7518        "\nswept: {lapsed} review{} lapsed past twice {} interval, {forgotten} never-recalled claim{} forgotten by neglect",
7519        if lapsed == 1 { "" } else { "s" },
7520        if lapsed == 1 { "its" } else { "their" },
7521        if forgotten == 1 { "" } else { "s" }
7522    )
7523}
7524
7525/// What the pack holds for review now.
7526pub fn due() -> Result<Vec<Value>> {
7527    let client = pack()?;
7528    let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7529    Ok(due_of(&atoms, &now_utc()))
7530}
7531
7532/// The soonest [`SITTING_DUE`] claims, how many are due in all, and the
7533/// clock line. Read-only: the sweep stays on `ljos due` and on a sitting.
7534pub fn due_page() -> Result<(Vec<Value>, usize, String)> {
7535    let client = pack()?;
7536    let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7537    let now = now_utc();
7538    let all = due_of(&atoms, &now);
7539    let total = all.len();
7540    let shown: Vec<Value> = all.into_iter().take(SITTING_DUE).collect();
7541    Ok((shown, total, review_summary(&atoms, &now)))
7542}
7543
7544// ---- habits ----------------------------------------------------------------
7545
7546/// The entity a habit's readings carry, so a name finds them.
7547pub const HABIT_ENTITY: &str = "habit:";
7548/// A habit's cadence when none is given: a week, in seconds.
7549pub const HABIT_EVERY_S: i64 = 7 * 86_400;
7550
7551/// One reading of a habit: a number the seat keeps measuring, with the
7552/// cadence it is measured at. A reading is a claim of kind `habit` that
7553/// supersedes the reading before it, so the pack holds one live value a
7554/// habit and `search --as-of` still answers what it stood at then; its
7555/// review clock is the cadence, so `due` and the hook say when the next
7556/// reading is late.
7557#[derive(Debug, Clone, PartialEq, serde::Serialize)]
7558pub struct Reading {
7559    pub name: String,
7560    pub value: f64,
7561    pub unit: String,
7562    pub source: String,
7563    /// Seconds between readings.
7564    pub every_s: i64,
7565    /// The reading before this one, when there was one.
7566    pub was: Option<f64>,
7567    pub was_ts: Option<String>,
7568    pub id: Option<String>,
7569    pub ts: Option<String>,
7570    pub due_at: Option<String>,
7571}
7572
7573/// `7d`, `24h`, `2w`, `30m`, or bare seconds.
7574pub fn parse_every(text: &str) -> Result<i64> {
7575    let t = text.trim();
7576    let split = t.trim_end_matches(|c: char| c.is_ascii_alphabetic()).len();
7577    let (num, unit) = t.split_at(split);
7578    let n: i64 = num
7579        .trim()
7580        .parse()
7581        .with_context(|| format!("habit: --every {t:?} is not a span; write 7d, 24h, 2w or 30m"))?;
7582    let each = match unit {
7583        "" | "s" => 1,
7584        "m" => 60,
7585        "h" => 3_600,
7586        "d" => 86_400,
7587        "w" => 7 * 86_400,
7588        other => bail!("habit: unknown unit {other:?} in --every; write d, h, w, m or s"),
7589    };
7590    if n <= 0 {
7591        bail!("habit: --every must be positive");
7592    }
7593    Ok(n * each)
7594}
7595
7596/// An RFC 3339 stamp `secs` after `now` (`YYYY-MM-DDTHH:MM:SSZ`, to the
7597/// second). None when `now` does not read as a stamp.
7598fn stamp_after(now: &str, secs: i64) -> Option<String> {
7599    let days = days_of_stamp(Some(now))?;
7600    let clock = now.get(11..19)?;
7601    let mut it = clock.split(':');
7602    let h: i64 = it.next()?.parse().ok()?;
7603    let m: i64 = it.next()?.parse().ok()?;
7604    let s: i64 = it.next()?.parse().ok()?;
7605    let total = days * 86_400 + h * 3_600 + m * 60 + s + secs;
7606    let day = total.div_euclid(86_400);
7607    let rem = total.rem_euclid(86_400);
7608    Some(format!(
7609        "{}T{:02}:{:02}:{:02}.000Z",
7610        civil_of_days(day),
7611        rem / 3_600,
7612        rem % 3_600 / 60,
7613        rem % 60
7614    ))
7615}
7616
7617/// A number as a person writes it: up to four decimals, no trailing zeros.
7618#[must_use]
7619pub fn trim_num(v: f64) -> String {
7620    let s = format!("{v:.4}");
7621    let s = s.trim_end_matches('0').trim_end_matches('.');
7622    if s.is_empty() || s == "-" {
7623        "0".to_string()
7624    } else {
7625        s.to_string()
7626    }
7627}
7628
7629/// The claim a reading is stored as. The words are for a reader; the
7630/// numbers travel in the atom's `habit` field.
7631#[must_use]
7632pub fn habit_text(name: &str, value: f64, unit: &str, source: &str) -> String {
7633    let unit = unit.trim();
7634    let source = source.trim();
7635    let mut text = format!("habit {} stands at {}", name.trim(), trim_num(value));
7636    if !unit.is_empty() {
7637        text.push(' ');
7638        text.push_str(unit);
7639    }
7640    if !source.is_empty() {
7641        text.push_str(&format!(" ({source})"));
7642    }
7643    text.push('.');
7644    text
7645}
7646
7647fn reading_of(atom: &Value) -> Option<Reading> {
7648    if atom.get("kind").and_then(Value::as_str) != Some("habit") {
7649        return None;
7650    }
7651    let h = atom.get("habit")?;
7652    Some(Reading {
7653        name: h.get("name")?.as_str()?.to_string(),
7654        value: h.get("value")?.as_f64()?,
7655        unit: h
7656            .get("unit")
7657            .and_then(Value::as_str)
7658            .unwrap_or("")
7659            .to_string(),
7660        source: h
7661            .get("source")
7662            .and_then(Value::as_str)
7663            .unwrap_or("")
7664            .to_string(),
7665        every_s: h
7666            .get("every_s")
7667            .and_then(Value::as_i64)
7668            .unwrap_or(HABIT_EVERY_S),
7669        was: h.get("was").and_then(Value::as_f64),
7670        was_ts: h.get("was_ts").and_then(Value::as_str).map(str::to_string),
7671        id: atom.get("id").and_then(Value::as_str).map(str::to_string),
7672        ts: atom.get("ts").and_then(Value::as_str).map(str::to_string),
7673        due_at: atom
7674            .get("due_at")
7675            .and_then(Value::as_str)
7676            .map(str::to_string),
7677    })
7678}
7679
7680/// The live readings among `atoms`, one a habit, by name.
7681#[must_use]
7682pub fn readings_of(atoms: &[Value]) -> Vec<Reading> {
7683    let mut rows: Vec<Reading> = atoms.iter().filter_map(reading_of).collect();
7684    rows.sort_by(|a, b| a.name.cmp(&b.name).then(b.ts.cmp(&a.ts)));
7685    rows.dedup_by(|a, b| a.name == b.name);
7686    rows
7687}
7688
7689/// The live readings in the seat's pack.
7690pub fn habits() -> Result<Vec<Reading>> {
7691    let client = pack()?;
7692    let atoms = atoms_lean(&client, &client.workspace()).context("habit: GET /v1/atoms failed")?;
7693    Ok(readings_of(&atoms))
7694}
7695
7696/// Take a reading: write it as a claim that supersedes the habit's earlier
7697/// reading, carrying that reading as `was`, with its review due one
7698/// cadence from now. Returns the pack's answer and the reading it closed.
7699pub fn habit(
7700    name: &str,
7701    value: f64,
7702    unit: &str,
7703    every_s: i64,
7704    source: &str,
7705) -> Result<(Value, Option<Reading>)> {
7706    let name = name.trim();
7707    if name.is_empty() {
7708        bail!("habit: a reading needs a name");
7709    }
7710    if !value.is_finite() {
7711        bail!("habit: {value} is not a reading");
7712    }
7713    let client = pack()?;
7714    let workspace = client.workspace();
7715    let atoms = atoms_lean(&client, &workspace).context("habit: GET /v1/atoms failed")?;
7716    let prev = readings_of(&atoms).into_iter().find(|r| r.name == name);
7717    let now = now_utc();
7718    let mut atom = atom_body("habit", &habit_text(name, value, unit, source), &workspace);
7719    add_entities(&mut atom, [format!("{HABIT_ENTITY}{name}")]);
7720    if let Some(due) = stamp_after(&now, every_s) {
7721        atom["due_at"] = Value::String(due);
7722    }
7723    atom["habit"] = serde_json::json!({
7724        "name": name,
7725        "value": value,
7726        "unit": unit.trim(),
7727        "source": source.trim(),
7728        "every_s": every_s,
7729        "was": prev.as_ref().map(|p| p.value),
7730        "was_ts": prev.as_ref().and_then(|p| p.ts.clone()),
7731    });
7732    if let Some(id) = prev.as_ref().and_then(|p| p.id.clone()) {
7733        atom["supersedes"] = Value::Array(vec![Value::String(id)]);
7734    }
7735    let body = client
7736        .post_atom(&atom)
7737        .context("habit: POST /v1/atoms failed")?;
7738    Ok((body, prev))
7739}
7740
7741/// The change since the reading before, signed, or nothing for a first
7742/// reading.
7743#[must_use]
7744pub fn format_change(r: &Reading, now: &str) -> String {
7745    match r.was {
7746        Some(was) => {
7747            let d = r.value - was;
7748            let sign = if d >= 0.0 { "+" } else { "" };
7749            format!(
7750                "{sign}{} since {} ({})",
7751                trim_num(d),
7752                trim_num(was),
7753                age_of(r.was_ts.as_deref(), now)
7754            )
7755        }
7756        None => "first reading".to_string(),
7757    }
7758}
7759
7760/// `ljos habit`: one line a habit: name, value with unit, the change since
7761/// the last reading, the age of this one, when the next is due, source.
7762#[must_use]
7763pub fn format_readings(rows: &[Reading], now: &str) -> String {
7764    rows.iter()
7765        .map(|r| {
7766            let due = match r.due_at.as_deref() {
7767                Some(d) if d <= now => format!("next reading late ({})", age_of(Some(d), now)),
7768                Some(d) => format!("next reading {}", age_of(Some(d), now)),
7769                None => "no cadence".to_string(),
7770            };
7771            format!(
7772                "{}\t{}{}{}\t{}\t{}\t{}\t{}\n",
7773                r.name,
7774                trim_num(r.value),
7775                if r.unit.is_empty() { "" } else { " " },
7776                r.unit,
7777                format_change(r, now),
7778                age_of(r.ts.as_deref(), now),
7779                due,
7780                r.source
7781            )
7782        })
7783        .collect()
7784}
7785
7786pub fn format_due(atoms: &[Value]) -> String {
7787    atoms
7788        .iter()
7789        .map(|a| {
7790            format!(
7791                "{}	{}	{}	{}
7792",
7793                a["due_at"]
7794                    .as_str()
7795                    .filter(|d| !d.is_empty())
7796                    .unwrap_or("unreviewed"),
7797                a["kind"].as_str().unwrap_or(""),
7798                a["id"].as_str().unwrap_or("-"),
7799                a["text"].as_str().unwrap_or("")
7800            )
7801        })
7802        .collect()
7803}
7804
7805/// Grade one review: recalled moves the atom out, lapsed brings it back sooner.
7806pub fn graded(id: &str, recalled: bool) -> Result<Value> {
7807    let id = id.trim();
7808    if id.is_empty() {
7809        bail!("graded: an atom id is required");
7810    }
7811    let client = pack()?;
7812    client
7813        .grade(&client.workspace(), id, recalled)
7814        .map_err(|e| {
7815            let said = e.to_string();
7816            if said.contains("no current atom") {
7817                // The due list was read before a later write closed it.
7818                anyhow::anyhow!(
7819                    "graded: {id} is no longer current: it was superseded, withdrawn or \
7820                     forgotten after the due list was read; nothing to grade, and \
7821                     `ljos due` shows what is due now"
7822                )
7823            } else {
7824                anyhow::Error::from(e).context(format!("graded: POST /v1/grade failed for {id}"))
7825            }
7826        })
7827}
7828
7829/// Now, RFC 3339 UTC to the second, the stamp the pack writes.
7830#[must_use]
7831pub fn now_utc() -> String {
7832    let secs = std::time::SystemTime::now()
7833        .duration_since(std::time::UNIX_EPOCH)
7834        .map(|d| d.as_secs())
7835        .unwrap_or(0);
7836    let days = secs / 86_400;
7837    let rem = secs % 86_400;
7838    // Civil date from days since the epoch (Howard Hinnant's algorithm).
7839    let z = days as i64 + 719_468;
7840    let era = z.div_euclid(146_097);
7841    let doe = z.rem_euclid(146_097);
7842    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
7843    let y = yoe + era * 400;
7844    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
7845    let mp = (5 * doy + 2) / 153;
7846    let d = doy - (153 * mp + 2) / 5 + 1;
7847    let m = if mp < 10 { mp + 3 } else { mp - 9 };
7848    let y = if m <= 2 { y + 1 } else { y };
7849    format!(
7850        "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
7851        rem / 3600,
7852        rem % 3600 / 60,
7853        rem % 60
7854    )
7855}
7856
7857/// Run a habitat's verb with `input` on stdin.
7858pub fn run_fed(bin: &str, args: &[impl AsRef<str>], input: &str) -> Result<Said> {
7859    use std::io::Write;
7860    use std::process::{Command, Stdio};
7861    let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
7862    let mut cmd = Command::new(path);
7863    for a in args {
7864        cmd.arg(a.as_ref());
7865    }
7866    let mut child = cmd
7867        .stdin(Stdio::piped())
7868        .stdout(Stdio::piped())
7869        .stderr(Stdio::piped())
7870        .spawn()
7871        .with_context(|| format!("{bin}: could not start"))?;
7872    if let Some(mut stdin) = child.stdin.take() {
7873        stdin.write_all(input.as_bytes())?;
7874    }
7875    let out = child.wait_with_output()?;
7876    let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
7877    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
7878    if !out.status.success() {
7879        let why = if stderr.trim().is_empty() {
7880            stdout.trim().to_string()
7881        } else {
7882            stderr.trim().to_string()
7883        };
7884        bail!("{bin} exited {}: {why}", out.status);
7885    }
7886    Ok(Said { stdout, stderr })
7887}
7888
7889/// A claimdag id for a name: the name itself when it is already 32 hex, else
7890/// FNV-1a 128 of it. One tracker id maps to one node; one assignee to one actor.
7891pub fn work_id(name: &str) -> String {
7892    let name = name.trim();
7893    if name.len() == 32 && name.bytes().all(|b| b.is_ascii_hexdigit()) {
7894        return name.to_ascii_lowercase();
7895    }
7896    const OFFSET: u128 = 0x6c62_272e_07bb_0142_62b8_2175_6295_c58d;
7897    const PRIME: u128 = 0x0000_0000_0100_0000_0000_0000_0000_013b;
7898    let mut h = OFFSET;
7899    for b in name.bytes() {
7900        h ^= u128::from(b);
7901        h = h.wrapping_mul(PRIME);
7902    }
7903    format!("{h:032x}")
7904}
7905
7906/// The claimdag node standing for `issue`, minted with the tracker id as its
7907/// summary when the graph does not hold it yet.
7908pub fn node_for(issue: &str) -> Result<String> {
7909    let id = work_id(issue);
7910    if id != issue.trim() && run_captured("claimdag", &["get", &id]).is_err() {
7911        run_captured(
7912            "claimdag",
7913            &["upsert", "--id", &id, "--summary", issue.trim()],
7914        )
7915        .with_context(|| format!("claim: could not mint a node for {issue}"))?;
7916    }
7917    Ok(id)
7918}
7919
7920/// The memories a task activates: the pack's island around the cue. With
7921/// `fire`, the strongest of them fire together and their links gain weight.
7922pub fn packset_island(cue: &str, fire: bool) -> Result<Value> {
7923    packset_island_as(cue, fire, None)
7924}
7925
7926/// [`packset_island`] through a persona's lens: the spread follows the
7927/// weights that persona fired, and a fire writes its weights and not the
7928/// seat's. The seat's own island is the one with no lens.
7929pub fn packset_island_as(cue: &str, fire: bool, lens: Option<&str>) -> Result<Value> {
7930    let cue = cue.trim();
7931    if cue.is_empty() {
7932        bail!("island: pass the task or question at hand");
7933    }
7934    let client = pack()?;
7935    let workspace = client.workspace();
7936    let lens = lens
7937        .map(str::trim)
7938        .filter(|l| !l.is_empty())
7939        .map(str::to_lowercase);
7940    let mut body = client
7941        .activate_as(&workspace, cue, 24, fire, lens.as_deref())
7942        .context("island: GET /v1/activate failed")?;
7943    if body["fired"].as_u64().unwrap_or(0) > 0 {
7944        match record_fire(cue, lens.as_deref(), &body) {
7945            Ok(id) => body["trace"] = Value::String(id),
7946            Err(err) => body["trace_error"] = Value::String(err.to_string()),
7947        }
7948    }
7949    Ok(body)
7950}
7951
7952/// Record a fire as why-provenance: which links were strengthened, under
7953/// whose weights. A trace does not replace another trace.
7954fn record_fire(cue: &str, lens: Option<&str>, body: &Value) -> Result<String> {
7955    let fired = body["fired"].as_u64().unwrap_or(0);
7956    let who = lens.unwrap_or("seat");
7957    let ids: Vec<String> = body["island"]
7958        .as_array()
7959        .into_iter()
7960        .flatten()
7961        .filter_map(|row| row.get("id").and_then(Value::as_str).map(str::to_string))
7962        .take(8)
7963        .collect();
7964    let mut nonce = 0xcbf29ce484222325u64;
7965    for part in [cue, who].into_iter().chain(ids.iter().map(String::as_str)) {
7966        for byte in part.as_bytes() {
7967            nonce ^= u64::from(*byte);
7968            nonce = nonce.wrapping_mul(0x100000001b3);
7969        }
7970    }
7971    let text = format!(
7972        "Fire {:08x} under {who} strengthened {fired} links.",
7973        nonce as u32
7974    );
7975    let client = pack()?;
7976    let workspace = client.workspace();
7977    let mut atom = atom_body("trace", &text, &workspace);
7978    add_entities(&mut atom, ids);
7979    let posted = client
7980        .post_atom(&atom)
7981        .context("trace: POST /v1/atoms failed")?;
7982    Ok(posted
7983        .get("id")
7984        .and_then(Value::as_str)
7985        .unwrap_or("")
7986        .to_string())
7987}
7988
7989/// The claims the pack's link graph turns on, highest first: what matters
7990/// in this seat's memory by its own connections, before any query.
7991pub fn packset_hubs(limit: usize) -> Result<Value> {
7992    let client = pack()?;
7993    let workspace = client.workspace();
7994    client
7995        .hubs(&workspace, limit)
7996        .context("hubs: GET /v1/hubs failed")
7997}
7998
7999/// Consolidate the seat's memory: every claim that replaces an earlier
8000/// one (a rewrite, a new object under the same head, a correction, an
8001/// explicit supersedes) closes the earlier one's window and names it.
8002/// Candidate contradictions from the geometry of the seat's memory: the
8003/// `landscape` binary reads the pack's embeddings at the point scale and
8004/// prints the lowest passes between single memories, which on a record of
8005/// planted contradictions were the contradictions nine times in ten. The
8006/// replacement rule reads words; this reads distance, in any language.
8007/// A candidate is for a person or `consolidate` to judge; nothing is
8008/// written here. `landscape` is an optional habitat: absent, this says so.
8009///
8010/// # Errors
8011///
8012/// The binary absent or refusing, or the pack not answering.
8013pub fn conflicts(limit: usize) -> Result<String> {
8014    if which::which("landscape").is_err() {
8015        bail!(
8016            "conflicts: `landscape` is not on PATH; it is the optional habitat that reads the pack's geometry (leidarljos/landscape)"
8017        );
8018    }
8019    let client = pack()?;
8020    let said = match run_captured(
8021        "landscape",
8022        &[
8023            "--atoms",
8024            client.base(),
8025            "--workspace",
8026            &client.workspace(),
8027            "--conflicts",
8028        ],
8029    ) {
8030        Ok(said) => said,
8031        // A pack whose memories carry no embeddings has no landscape to
8032        // read; that is a fact about the pack, not a refusal.
8033        Err(e) if e.to_string().contains("at least two") => {
8034            return Ok(
8035                "fewer than two memories with embeddings in the pack; conflicts by geometry need the encoder (`packset doctor` shows it)\n"
8036                    .to_string(),
8037            );
8038        }
8039        Err(e) => return Err(e),
8040    };
8041    let v: Value =
8042        serde_json::from_str(&said.stdout).context("conflicts: landscape printed no JSON")?;
8043    let now = now_utc();
8044    let atoms = atoms_lean(&client, &client.workspace()).unwrap_or_default();
8045    let stamp_of = |id: &str| -> Option<String> {
8046        atoms
8047            .iter()
8048            .find(|a| a["id"].as_str() == Some(id))
8049            .and_then(|a| a["ts"].as_str().map(str::to_string))
8050    };
8051    // Trust rows, personas, forecasts and rules are weighed, not recalled;
8052    // a pass between two of them is not a contradiction to judge.
8053    let recalled = |id: &str| -> bool {
8054        atoms
8055            .iter()
8056            .find(|a| a["id"].as_str() == Some(id))
8057            .is_none_or(reviewable)
8058    };
8059    let mut out = String::new();
8060    for pair in v["pairs"]
8061        .as_array()
8062        .into_iter()
8063        .flatten()
8064        .filter(|p| {
8065            recalled(p["a"].as_str().unwrap_or("")) && recalled(p["b"].as_str().unwrap_or(""))
8066        })
8067        .take(limit)
8068    {
8069        let a = pair["a"].as_str().unwrap_or("-");
8070        let b = pair["b"].as_str().unwrap_or("-");
8071        out.push_str(&format!(
8072            "pass {:.3}\n  {a} {}  {}\n  {b} {}  {}\n",
8073            pair["barrier"].as_f64().unwrap_or(0.0),
8074            age_of(stamp_of(a).as_deref(), &now),
8075            pair["a_text"].as_str().unwrap_or("").trim(),
8076            age_of(stamp_of(b).as_deref(), &now),
8077            pair["b_text"].as_str().unwrap_or("").trim()
8078        ));
8079    }
8080    let n = v["pairs"].as_array().map_or(0, Vec::len);
8081    out.push_str(&format!(
8082        "{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",
8083        v["sigma"].as_f64().unwrap_or(0.0)
8084    ));
8085    Ok(out)
8086}
8087
8088/// The rule a write applies on arrival, run over what the pack already
8089/// holds. Without `apply` nothing is written; the pairs are reported.
8090pub fn packset_consolidate(apply: bool) -> Result<Value> {
8091    let client = pack()?;
8092    let workspace = client.workspace();
8093    client
8094        .consolidate(&workspace, apply)
8095        .context("consolidate: POST /v1/consolidate failed")
8096}
8097
8098/// The pairs a consolidation closed or would close, one a line, then the
8099/// count and whether it was applied.
8100pub fn format_consolidation(body: &Value) -> String {
8101    let mut out = String::new();
8102    for pair in body["pairs"].as_array().into_iter().flatten() {
8103        out.push_str(&format!(
8104            "closes {}  {}\n    for {}  {}\n",
8105            pair["old"].as_str().unwrap_or("-"),
8106            pair["old_text"].as_str().unwrap_or("").trim(),
8107            pair["new"].as_str().unwrap_or("-"),
8108            pair["new_text"].as_str().unwrap_or("").trim()
8109        ));
8110    }
8111    let closed = body["closed"].as_u64().unwrap_or(0);
8112    let live = body["live"].as_u64().unwrap_or(0);
8113    if body["applied"].as_bool().unwrap_or(false) {
8114        out.push_str(&format!("{closed} of {live} live memories closed\n"));
8115    } else {
8116        out.push_str(&format!(
8117            "{closed} of {live} live memories would close; `ljos consolidate --apply` closes them\n"
8118        ));
8119    }
8120    out
8121}
8122
8123/// One line per hub: score, links, id, text.
8124pub fn format_hubs(body: &Value) -> String {
8125    let mut out = String::new();
8126    for hub in body["hubs"]
8127        .as_array()
8128        .into_iter()
8129        .flatten()
8130        .filter(|a| reviewable(a))
8131    {
8132        out.push_str(&format!(
8133            "{:.4}\t{}\t{}\t{}\n",
8134            hub["score"].as_f64().unwrap_or(0.0),
8135            hub["links"].as_u64().unwrap_or(0),
8136            hub["id"].as_str().unwrap_or("-"),
8137            hub["text"].as_str().unwrap_or("")
8138        ));
8139    }
8140    out
8141}
8142
8143/// What an activation number is, and whether this call rewrote weights.
8144///
8145/// The number on a row is spread from the search seeds along the pack's
8146/// links. It is not a relevance rank. `fire` strengthens the links of the
8147/// strongest rows under the lens that walked them, so the next walk of the
8148/// same cue follows those links. A weak island does not fire.
8149#[must_use]
8150pub fn island_reading(body: &Value) -> String {
8151    let lens = body["as"].as_str().unwrap_or("").trim();
8152    let fired = body["fired"].as_u64().unwrap_or(0);
8153    let held = body["held"].as_bool().unwrap_or(false);
8154    let weak = body["weak"].as_bool().unwrap_or(false);
8155    let rows = body["island"].as_array().is_some_and(|a| !a.is_empty());
8156    if !rows && !weak && fired == 0 && !held && lens.is_empty() {
8157        return String::new();
8158    }
8159    let mut out = String::new();
8160    if lens.is_empty() {
8161        out.push_str(
8162            "Seat island. Activation is spread from search seeds along links. It is not a relevance rank.\n",
8163        );
8164    } else {
8165        out.push_str(&format!(
8166            "Persona {lens} island. The spread follows the weights that persona fired, not the seat's. It is not a relevance rank.\n"
8167        ));
8168    }
8169    if weak {
8170        out.push_str(
8171            "Not fired: fewer than two seeds that two scorers agreed on, so firing would wire the wrong links.\n",
8172        );
8173    } else if held {
8174        out.push_str(
8175            "Not fired: this cue already fired inside the hour, so the weights were left as they were.\n",
8176        );
8177    } else if fired > 0 {
8178        let who = if lens.is_empty() { "the seat" } else { lens };
8179        out.push_str(&format!(
8180            "Fired: {fired} links gained weight under {who}. The next walk of this cue follows those links. Fire only after the island was used.\n"
8181        ));
8182        if let Some(id) = body["trace"].as_str().filter(|s| !s.is_empty()) {
8183            out.push_str(&format!(
8184                "Recorded as trace {id}: the links this fire strengthened.\n"
8185            ));
8186        } else if let Some(err) = body["trace_error"].as_str() {
8187            out.push_str(&format!("The fire was not recorded: {err}\n"));
8188        }
8189    } else {
8190        out.push_str(
8191            "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",
8192        );
8193    }
8194    out
8195}
8196
8197/// One line per activated memory: activation, seed mark, id, text.
8198pub fn format_island(body: &Value) -> String {
8199    let mut out = island_reading(body);
8200    let now = now_utc();
8201    if body["weak"].as_bool().unwrap_or(false) {
8202        out.push_str(&format!(
8203            "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",
8204            body["agreed_seeds"].as_u64().unwrap_or(0),
8205            if body["agreed_seeds"].as_u64().unwrap_or(0) == 1 { "" } else { "s" },
8206            if body["dense"].as_bool().unwrap_or(true) { "" } else { "; the encoder is down, ranking is lexical only" }
8207        ));
8208    }
8209    for atom in body["island"]
8210        .as_array()
8211        .into_iter()
8212        .flatten()
8213        .filter(|a| reviewable(a))
8214    {
8215        out.push_str(&format!(
8216            "{:.3}\t{}\t{}\t{}\t{}\n",
8217            atom["activation"].as_f64().unwrap_or(0.0),
8218            if atom["seed"].as_bool().unwrap_or(false) {
8219                "seed"
8220            } else {
8221                "    "
8222            },
8223            atom["id"].as_str().unwrap_or("-"),
8224            age_of(atom["ts"].as_str(), &now),
8225            atom["text"].as_str().unwrap_or("")
8226        ));
8227    }
8228    out
8229}
8230
8231pub fn packset_search(query: &str) -> Result<Vec<Hit>> {
8232    packset_search_opts(query, 10, false)
8233}
8234
8235/// [`packset_search`] with a limit and the cross-encoder rerank: the
8236/// writer scores the top hits against the query with its reranker, which
8237/// costs a model call and buys precision. For a brief or a person reading,
8238/// not for the hook.
8239pub fn packset_search_opts(query: &str, limit: u32, rerank: bool) -> Result<Vec<Hit>> {
8240    packset_search_as_of(query, limit, None, rerank)
8241}
8242
8243/// [`packset_search_opts`] asked of the pack as it stood at `as_of` (RFC
8244/// 3339; a date alone reads as its start): only memories live then answer,
8245/// what was withdrawn since included and what was learnt since left out.
8246/// `None` is now. This is the question "what did the seat know when it
8247/// decided that", and the pack keeps every record so it can be asked.
8248pub fn packset_search_as_of(
8249    query: &str,
8250    limit: u32,
8251    as_of: Option<&str>,
8252    rerank: bool,
8253) -> Result<Vec<Hit>> {
8254    let q = query.trim();
8255    if q.is_empty() {
8256        bail!("search: empty query");
8257    }
8258    let as_of = as_of.map(str::trim).filter(|s| !s.is_empty());
8259    let stamp = match as_of {
8260        Some(at) if days_of_stamp(Some(at)).is_none() => {
8261            bail!("search: --as-of {at:?} is not a date; write YYYY-MM-DD or RFC 3339")
8262        }
8263        // A date alone is its start; the pack wants the instant spelt out.
8264        Some(at) if at.len() == 10 => Some(format!("{at}T00:00:00.000Z")),
8265        Some(at) => Some(at.to_string()),
8266        None => None,
8267    };
8268    with_writer(|| {
8269        let client = pack()?;
8270        let workspace = client.workspace();
8271        client
8272            .search_opts(&workspace, q, limit, stamp.as_deref(), rerank)
8273            .context("search: GET /v1/search failed")
8274    })
8275}
8276
8277/// The actor id in a `claimdag get` line (`assignee=HEX`), if any.
8278/// The live generation on a `claimdag get` line: the `gen=N` field.
8279fn gen_of(get_output: &str) -> Option<u64> {
8280    get_output
8281        .split_whitespace()
8282        .find_map(|w| w.strip_prefix("gen="))
8283        .and_then(|g| g.parse().ok())
8284}
8285
8286/// The generation a finish or complete acts on: the one given, else the live
8287/// one read off the claim graph, so a sitting need not carry a number the
8288/// graph already holds. A stale explicit gen is still refused by the graph.
8289fn live_gen(id: &str, gen: Option<u64>) -> Result<u64> {
8290    if let Some(g) = gen {
8291        return Ok(g);
8292    }
8293    let got = run_captured("claimdag", &["get", id])?.stdout;
8294    gen_of(&got).ok_or_else(|| {
8295        anyhow::anyhow!("complete: no generation on the claim graph's line for {id}: {got}")
8296    })
8297}
8298
8299/// Refusal when another conversation holds the node: names that holder
8300/// and still says `held by another`, so a concurrent sitting can match it.
8301#[must_use]
8302pub fn held_by_another_message(node: &str, assignee: &str, hold: &Hold, running: &str) -> String {
8303    format!(
8304        "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",
8305        hold.assignee,
8306        hold.seat,
8307        hold.since,
8308        hold.assignee
8309    )
8310}
8311
8312fn holder_of(get_output: &str) -> Option<String> {
8313    get_output
8314        .split_whitespace()
8315        .find_map(|w| w.strip_prefix("assignee="))
8316        .filter(|h| h.len() == 32 && *h != "00000000000000000000000000000000")
8317        .map(str::to_string)
8318}
8319
8320/// Stamp the tracker to match the claim graph. The claim graph holds
8321/// occupancy; the tracker answers who holds what, and a sitting that takes
8322/// one without the other leaves `vissue claims` blind to a held issue.
8323/// `vissue claim ISSUE` moves the issue to STARTED under `assignee` and is
8324/// idempotent for the name that already holds it. A node the tracker does
8325/// not know (a raw claim-graph id) has nothing to stamp and gives `None`.
8326///
8327/// # Errors
8328///
8329/// The tracker refusing the name. The claim graph already holds the node
8330/// by then, so the message names the verb that frees it.
8331fn tracker_claim_needs_force(text: &str) -> bool {
8332    text.contains("pass --force") || text.contains("claimed by")
8333}
8334
8335fn stamp_tracker_claim(node: &str, assignee: &str, force: bool) -> Result<Said> {
8336    if force {
8337        run_captured_as("vissue", &["claim", node, "--force"], Some(assignee))
8338    } else {
8339        run_captured_as("vissue", &["claim", node], Some(assignee))
8340    }
8341}
8342
8343fn stamp_tracker(node: &str, assignee: &str) -> Result<Option<String>> {
8344    if run_captured("vissue", &["show", node, "--json"]).is_err() {
8345        return Ok(None);
8346    }
8347    let claimed = match stamp_tracker_claim(node, assignee, false) {
8348        Ok(said) => Ok(said),
8349        Err(e) => {
8350            let text = e.to_string();
8351            // A new sitting on work the tracker already closed: reopen the
8352            // heading to STARTED, then stamp occupancy. The claim graph
8353            // already took the node.
8354            let after_reopen = if text.contains("already DONE")
8355                || text.contains("already CANCELLED")
8356            {
8357                run_captured("vissue", &["update", node, "-s", "STARTED"]).with_context(|| {
8358                    format!(
8359                        "claim: the claim graph took {node} but the tracker would not reopen {node} to STARTED under {assignee}"
8360                    )
8361                })?;
8362                stamp_tracker_claim(node, assignee, false)
8363            } else {
8364                Err(e)
8365            };
8366            match after_reopen {
8367                Ok(said) => Ok(said),
8368                Err(e2) if tracker_claim_needs_force(&e2.to_string()) => {
8369                    stamp_tracker_claim(node, assignee, true)
8370                }
8371                Err(e2) => Err(e2),
8372            }
8373        }
8374    };
8375    claimed
8376        .map(|_| Some(format!("tracker: {node} STARTED under {assignee}")))
8377        .with_context(|| {
8378            format!(
8379                "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"
8380            )
8381        })
8382}
8383
8384/// What the claim graph said, followed by the tracker's line when the node
8385/// is an issue.
8386fn with_tracker(said: String, node: &str, assignee: &str) -> Result<String> {
8387    let mut out = said;
8388    if let Some(line) = stamp_tracker(node, assignee)? {
8389        if !out.is_empty() && !out.ends_with('\n') {
8390            out.push('\n');
8391        }
8392        out.push_str(&line);
8393        out.push('\n');
8394    }
8395    Ok(out)
8396}
8397
8398/// Take a session node, and when the claim graph refuses because the
8399/// assignee still holds another node, say which tracker id that is and the
8400/// two verbs that free it. The bare refusal names a 32-hex id nobody can
8401/// act on.
8402///
8403/// # Errors
8404///
8405/// The refusal, explained, or any other failure of the claim graph.
8406pub fn claim(node: &str, assignee: &str) -> Result<String> {
8407    let id = node_for(node)?;
8408    let actor = work_id(&occupancy_scope(assignee, node));
8409    match run_captured("claimdag", &["claim", &id, "--assignee", &actor]) {
8410        Ok(said) => {
8411            write_hold(&actor, assignee, node);
8412            with_tracker(said.stdout, node, assignee)
8413        }
8414        Err(e) => {
8415            let text = e.to_string();
8416            // A tracker id maps to one node. When an earlier sitting finished
8417            // it, this is a new sitting on the same work: reopen, then claim.
8418            if ["status done", "status failed", "status cancelled"]
8419                .iter()
8420                .any(|s| text.contains(s))
8421            {
8422                run_captured("claimdag", &["reopen", &id, "--actor", &actor])?;
8423                let said = run_captured("claimdag", &["claim", &id, "--assignee", &actor])?;
8424                write_hold(&actor, assignee, node);
8425                return with_tracker(
8426                    format!("reopened a finished session node\n{}", said.stdout),
8427                    node,
8428                    assignee,
8429                );
8430            }
8431            // The node is already claimed. By this name it is a sitting
8432            // resumed: renew the lease and go on. By another it is theirs.
8433            if text.contains("status claimed") {
8434                let got = run_captured("claimdag", &["get", &id])?.stdout;
8435                return match holder_of(&got) {
8436                    Some(holder) if holder == actor => {
8437                        let renewed = run_captured("claimdag", &["renew", &id, "--actor", &actor])
8438                            .map(|s| s.stdout)
8439                            .unwrap_or_default();
8440                        write_hold(&actor, assignee, node);
8441                        with_tracker(
8442                            format!("already held by {assignee}; the sitting resumes\n{renewed}"),
8443                            node,
8444                            assignee,
8445                        )
8446                    }
8447                    Some(holder) => match read_hold(&holder) {
8448                        // This seat's own conversation, and it is gone: a
8449                        // runner that exited without finishing. The seat
8450                        // owns its conversations, so the sitting takes the
8451                        // node over rather than waiting on nobody.
8452                        Some(h) if h.seat == seat_name() && !hold_alive(&h) => {
8453                            run_captured("claimdag", &["release", &id, "--actor", &holder])?;
8454                            drop_hold(&holder);
8455                            let said =
8456                                run_captured("claimdag", &["claim", &id, "--assignee", &actor])?;
8457                            write_hold(&actor, assignee, node);
8458                            with_tracker(
8459                                format!(
8460                                    "took over from {}, this seat's conversation, gone (held since {})\n{}",
8461                                    h.assignee, h.since, said.stdout
8462                                ),
8463                                node,
8464                                assignee,
8465                            )
8466                        }
8467                        Some(h) => bail!(
8468                            "{}",
8469                            held_by_another_message(
8470                                node,
8471                                assignee,
8472                                &h,
8473                                if hold_alive(&h) {
8474                                    "still running"
8475                                } else {
8476                                    "its runner is gone"
8477                                }
8478                            )
8479                        ),
8480                        None => bail!(
8481                            "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"
8482                        ),
8483                    },
8484                    None => Err(e),
8485                };
8486            }
8487            if !text.contains("assignee busy") {
8488                return Err(e);
8489            }
8490            let held: Vec<String> = text
8491                .split_whitespace()
8492                .filter(|w| w.len() == 32 && w.chars().all(|c| c.is_ascii_hexdigit()))
8493                .map(str::to_string)
8494                .collect();
8495            let mut lines = vec![format!(
8496                "claim: {assignee} already holds a live node; one live claim per assignee."
8497            )];
8498            for hex in &held {
8499                let name = run_captured("claimdag", &["get", hex])
8500                    .ok()
8501                    .and_then(|s| {
8502                        s.stdout
8503                            .lines()
8504                            .next()
8505                            .and_then(|l| l.split_whitespace().last())
8506                            .map(str::to_string)
8507                    })
8508                    .unwrap_or_else(|| hex.clone());
8509                lines.push(format!(
8510                    "  holds {name}: `ljos complete {name} --status done` finishes it, \
8511                     `ljos release {name} --assignee {assignee}` hands it back"
8512                ));
8513            }
8514            bail!("{}", lines.join("\n"))
8515        }
8516    }
8517}
8518
8519/// Hand a session node back before it is terminal: ready again, assignee
8520/// cleared, generation moved.
8521///
8522/// # Errors
8523///
8524/// The claim graph's refusal: not held, or held by somebody else.
8525pub fn release(node: &str, assignee: &str) -> Result<String> {
8526    let id = node_for(node)?;
8527    let actor = work_id(&occupancy_scope(assignee, node));
8528    let said = run_captured("claimdag", &["release", &id, "--actor", &actor])?;
8529    drop_hold(&actor);
8530    drop_playbook(node);
8531    Ok(said.stdout)
8532}
8533
8534/// What a conversation left beside the claim graph when it took a node:
8535/// the name it held under, its seat, the runner process, and when. The
8536/// claim graph keeps only the hashed actor; this is how a later
8537/// conversation that finds the node held learns who holds it, and whether
8538/// that conversation is still running.
8539#[derive(Debug, Clone, PartialEq, Eq)]
8540pub struct Hold {
8541    pub assignee: String,
8542    pub seat: String,
8543    pub pid: u32,
8544    pub comm: String,
8545    pub since: String,
8546}
8547
8548fn hold_record_path(actor: &str) -> PathBuf {
8549    runtime_dir().join(format!("hold-{actor}"))
8550}
8551
8552/// The process that owns this conversation: the first ancestor that is
8553/// not a shell or a wrapper. For the MCP server that is the runner; for
8554/// the command line it is the runner above the shell, else the shell the
8555/// person types into.
8556fn conversation_process() -> (u32, String) {
8557    let chain = ancestry();
8558    // A command whose runner the tree lost (a detached pty, a reparented
8559    // shell) reaches the multiplexer first; the pane's own shell below it is
8560    // the conversation, since the multiplexer is every pane's parent.
8561    let mut below = chain.get(1);
8562    for entry in chain.iter().skip(1) {
8563        if is_session(&entry.1) {
8564            break;
8565        }
8566        if !WRAPPERS.contains(&entry.1.as_str()) {
8567            return entry.clone();
8568        }
8569        below = Some(entry);
8570    }
8571    below
8572        .cloned()
8573        .unwrap_or((std::process::id(), String::new()))
8574}
8575
8576fn write_hold(actor: &str, assignee: &str, node: &str) {
8577    let (pid, comm) = conversation_process();
8578    let path = hold_record_path(actor);
8579    if let Some(dir) = path.parent() {
8580        let _ = std::fs::create_dir_all(dir);
8581    }
8582    // The issue is the sixth line: a subagent reads what its parent holds
8583    // from here, since asking the tracker takes longer than a hook may run.
8584    let _ = std::fs::write(
8585        path,
8586        format!(
8587            "{assignee}\n{}\n{pid}\n{comm}\n{}\n{node}\n",
8588            seat_name(),
8589            now_utc()
8590        ),
8591    );
8592}
8593
8594/// The issue the newest hold record of this conversation names: a record
8595/// whose holder is one of `holders`, or whose conversation process is an
8596/// ancestor of this one. File reads only, so a hook can afford it.
8597fn held_from_records(holders: &[String]) -> Option<String> {
8598    held_from_records_in(holders, &runtime_dir(), &own_ancestry())
8599}
8600
8601/// [`held_from_records`] over one directory and one chain of ancestors. A
8602/// record whose process is a session process names every conversation
8603/// under that multiplexer, so it names none of them.
8604fn held_from_records_in(
8605    holders: &[String],
8606    dir: &std::path::Path,
8607    chain: &[(u32, String)],
8608) -> Option<String> {
8609    let pids: Vec<String> = chain.iter().map(|(p, _)| p.to_string()).collect();
8610    let mut best: Option<(String, String)> = None;
8611    for entry in std::fs::read_dir(dir).ok()?.flatten() {
8612        if !entry.file_name().to_string_lossy().starts_with("hold-") {
8613            continue;
8614        }
8615        let Ok(text) = std::fs::read_to_string(entry.path()) else {
8616            continue;
8617        };
8618        let lines: Vec<&str> = text.lines().map(str::trim).collect();
8619        let (Some(holder), Some(pid), Some(comm), Some(at), Some(node)) = (
8620            lines.first(),
8621            lines.get(2),
8622            lines.get(3),
8623            lines.get(4),
8624            lines.get(5),
8625        ) else {
8626            continue;
8627        };
8628        let by_process = !is_session(comm) && pids.iter().any(|p| p == pid);
8629        let ours = holders.iter().any(|h| h == holder) || by_process;
8630        if ours && !node.is_empty() && best.as_ref().is_none_or(|(t, _)| *at > t.as_str()) {
8631            best = Some(((*at).to_string(), (*node).to_string()));
8632        }
8633    }
8634    best.map(|(_, node)| node)
8635}
8636
8637fn drop_hold(actor: &str) {
8638    let _ = std::fs::remove_file(hold_record_path(actor));
8639}
8640
8641fn read_hold(actor: &str) -> Option<Hold> {
8642    let text = std::fs::read_to_string(hold_record_path(actor)).ok()?;
8643    let mut lines = text.lines();
8644    Some(Hold {
8645        assignee: lines.next()?.to_string(),
8646        seat: lines.next()?.to_string(),
8647        pid: lines.next()?.trim().parse().ok()?,
8648        comm: lines.next()?.to_string(),
8649        since: lines.next()?.to_string(),
8650    })
8651}
8652
8653/// Whether the conversation that wrote a hold is still running: its
8654/// process exists and is still the program it was. Off Linux nothing can
8655/// be read, and an unknown conversation is taken as running.
8656fn hold_alive(hold: &Hold) -> bool {
8657    match parent_and_comm(hold.pid) {
8658        Some((_, comm)) => comm == hold.comm,
8659        None => !cfg!(target_os = "linux"),
8660    }
8661}
8662
8663/// `; revises N earlier` when the pack closed earlier memories' windows
8664/// for this one (same kind, a rewrite of the same claim or an explicit
8665/// `supersedes`), else empty. The revision is the pack's; this names it.
8666fn revision_note(body: &Value) -> String {
8667    match body["supersedes"].as_array().map(Vec::len).unwrap_or(0) {
8668        0 => String::new(),
8669        1 => "; revises 1 earlier memory, now closed".to_string(),
8670        n => format!("; revises {n} earlier memories, now closed"),
8671    }
8672}
8673
8674/// One issue as JSON from the tracker library. Same card as `vissue show --json`.
8675///
8676/// # Errors
8677///
8678/// The tracker root cannot be resolved, or `id` is not in it.
8679pub fn tracker_show_json(id: &str) -> Result<Value> {
8680    let layout = vissue_core::Layout::resolve(None, None).map_err(anyhow::Error::from)?;
8681    let found = vissue_core::Router::load(layout)
8682        .map_err(anyhow::Error::from)?
8683        .find_by_id(id)
8684        .map_err(anyhow::Error::from)?;
8685    vissue_core::agent::show_json(&found.layout, id).map_err(anyhow::Error::from)
8686}
8687
8688/// Whether an issue asks for a decision: a `decision` tag, a `decision`
8689/// type, or a body line opening `Options:`.
8690#[must_use]
8691pub fn is_decision(v: &Value) -> bool {
8692    let tagged = v["tags"]
8693        .as_array()
8694        .is_some_and(|t| t.iter().any(|x| x.as_str() == Some("decision")));
8695    let typed = v["properties"]["TYPE"].as_str() == Some("decision");
8696    let listed = v["body"]
8697        .as_str()
8698        .is_some_and(|b| b.lines().any(|l| l.trim_start().starts_with("Options:")));
8699    tagged || typed || listed
8700}
8701
8702/// The issue's title, for a cue, from the tracker.
8703fn issue_title(issue: &str) -> Result<String> {
8704    let v = tracker_show_json(issue)?;
8705    Ok(v.get("title")
8706        .and_then(Value::as_str)
8707        .unwrap_or(issue)
8708        .to_string())
8709}
8710
8711/// One dated event on an issue's timeline, from whichever store holds it.
8712#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
8713pub struct Event {
8714    /// Days since the epoch of the event's date.
8715    pub days: i64,
8716    /// `HH:MM` when the stamp carries a time, else empty; sorts after the
8717    /// day.
8718    pub clock: String,
8719    /// `tracker`, `deed` or `memory`: the store the event came from.
8720    pub source: &'static str,
8721    /// The event in one line.
8722    pub text: String,
8723}
8724
8725/// The issue's timeline as dated rows. The HUD paints this; it does not
8726/// parse `ljos timeline` stdout. Tracker rows come from
8727/// [`vissue_core::agent::show_json`]. Deed rows still shell `deedar evidence`,
8728/// a named gap (`deedar::Store::evidence`).
8729///
8730/// # Errors
8731///
8732/// The tracker not answering. A deed store or pack that does not answer
8733/// leaves its rows out; the tracker's rows are the spine.
8734pub fn timeline_events(issue: &str, limit: usize) -> Result<Vec<Event>> {
8735    Ok(timeline_of(issue, limit)?.1)
8736}
8737
8738fn timeline_of(issue: &str, limit: usize) -> Result<(String, Vec<Event>)> {
8739    let v = tracker_show_json(issue)?;
8740    let title = v["title"].as_str().unwrap_or(issue).to_string();
8741    let mut events = tracker_events(&v);
8742    for accession in v["deeds"].as_array().into_iter().flatten() {
8743        let Some(accession) = accession.as_str() else {
8744            continue;
8745        };
8746        if let Ok(said) = run_captured("deedar", &["evidence", accession]) {
8747            if let Some(ev) = deed_event(accession, &said.stdout, local_offset) {
8748                events.push(ev);
8749            }
8750        }
8751    }
8752    if let Ok(island) = packset_island(&title, false) {
8753        for atom in island["island"]
8754            .as_array()
8755            .into_iter()
8756            .flatten()
8757            .filter(|a| reviewable(a))
8758            .take(8)
8759        {
8760            if let Some((days, clock)) = stamp_key(atom["ts"].as_str().map(local_stamp).as_deref())
8761            {
8762                events.push(Event {
8763                    days,
8764                    clock,
8765                    source: "memory",
8766                    text: format!(
8767                        "[{}] {}",
8768                        atom["kind"].as_str().unwrap_or("claim"),
8769                        atom["text"].as_str().unwrap_or("").trim()
8770                    ),
8771                });
8772            }
8773        }
8774    }
8775    events.sort_by(|a, b| (a.days, &a.clock).cmp(&(b.days, &b.clock)));
8776    let skip = events.len().saturating_sub(limit);
8777    Ok((title, events[skip..].to_vec()))
8778}
8779
8780/// The issue's timeline, the three stores read as one dated list, oldest
8781/// first: the tracker's logbook (creation, state changes, claims, notes),
8782/// the deeds the issue cites with the time each was produced, and the
8783/// memories the issue's title activates with the time each was written.
8784/// The reader gets time as data, not as stamps to do arithmetic on: each
8785/// line carries its age and the gap since the line before it, and a later
8786/// line supersedes an earlier one on the same matter.
8787///
8788/// # Errors
8789///
8790/// The tracker not answering. A deed store or pack that does not answer
8791/// leaves its rows out; the tracker's rows are the spine.
8792pub fn timeline(issue: &str, limit: usize) -> Result<String> {
8793    let (title, events) = timeline_of(issue, limit)?;
8794    Ok(format!(
8795        "timeline of {issue}: {title}
8796{}",
8797        format_events(&events, &now_local())
8798    ))
8799}
8800
8801/// The reader's seconds east of UTC at the instant `secs`. The tracker
8802/// writes org stamps in local wall time; a timeline reads every store in it.
8803fn local_offset(secs: i64) -> i64 {
8804    use chrono::{Local, Offset, TimeZone};
8805    Local
8806        .timestamp_opt(secs, 0)
8807        .single()
8808        .map_or(0, |t| i64::from(t.offset().fix().local_minus_utc()))
8809}
8810
8811/// Now in local wall time, `YYYY-MM-DDTHH:MM:SS`, the zone of the tracker's
8812/// org stamps.
8813fn now_local() -> String {
8814    chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string()
8815}
8816
8817/// An RFC 3339 stamp as local wall time, `YYYY-MM-DDTHH:MM`; any other shape
8818/// comes back unchanged.
8819fn local_stamp(ts: &str) -> String {
8820    chrono::DateTime::parse_from_rfc3339(ts.trim()).map_or_else(
8821        |_| ts.to_string(),
8822        |t| {
8823            t.with_timezone(&chrono::Local)
8824                .format("%Y-%m-%dT%H:%M")
8825                .to_string()
8826        },
8827    )
8828}
8829
8830/// The tracker's own events on an issue: created, each state change, the
8831/// claim, each note.
8832fn tracker_events(v: &Value) -> Vec<Event> {
8833    let mut events = Vec::new();
8834    let mut push = |stamp: Option<&str>, source: &'static str, text: String| {
8835        if let Some((days, clock)) = stamp_key(stamp) {
8836            events.push(Event {
8837                days,
8838                clock,
8839                source,
8840                text,
8841            });
8842        }
8843    };
8844    push(
8845        v["properties"]["CREATED"].as_str(),
8846        "tracker",
8847        "created".to_string(),
8848    );
8849    if let Some(by) = v["claimed_by"].as_str() {
8850        push(
8851            v["claimed_at"].as_str(),
8852            "tracker",
8853            format!("claimed by {by}"),
8854        );
8855    }
8856    if let Some(d) = v["properties"]["DEADLINE"].as_str() {
8857        push(
8858            v["properties"]["DEADLINE"].as_str(),
8859            "tracker",
8860            format!("DEADLINE {d}"),
8861        );
8862    }
8863    if let Some(s) = v["properties"]["SCHEDULED"].as_str() {
8864        push(
8865            v["properties"]["SCHEDULED"].as_str(),
8866            "tracker",
8867            format!("SCHEDULED {s}"),
8868        );
8869    }
8870    // The logbook is newest first; the timeline reads oldest first.
8871    for e in v["logbook"].as_array().into_iter().flatten().rev() {
8872        let stamp = e["timestamp"].as_str();
8873        if let Some(note) = e["note"].as_str() {
8874            push(stamp, "tracker", format!("note: {}", note.trim()));
8875        } else if let Some(to) = e["to_state"].as_str() {
8876            push(
8877                stamp,
8878                "tracker",
8879                format!("{} -> {to}", e["from_state"].as_str().unwrap_or("-")),
8880            );
8881        }
8882    }
8883    events
8884}
8885
8886/// A deed's event from `deedar evidence`: the time it was produced, by
8887/// whom.
8888/// `offset_of` gives the reader's seconds east of UTC at that instant, so
8889/// the deed lands on the same wall-clock day as the tracker's org stamps.
8890fn deed_event(accession: &str, evidence: &str, offset_of: fn(i64) -> i64) -> Option<Event> {
8891    let utc: i64 = evidence
8892        .lines()
8893        .find_map(|l| l.strip_prefix("time="))?
8894        .trim()
8895        .parse()
8896        .ok()?;
8897    let secs = utc + offset_of(utc);
8898    let by = evidence
8899        .lines()
8900        .find_map(|l| l.strip_prefix("producedBy="))
8901        .map(str::trim)
8902        .unwrap_or("-");
8903    Some(Event {
8904        days: secs.div_euclid(86_400),
8905        clock: format!(
8906            "{:02}:{:02}",
8907            secs.rem_euclid(86_400) / 3600,
8908            secs.rem_euclid(86_400) % 3600 / 60
8909        ),
8910        source: "deed",
8911        text: format!("{accession} produced by {by}"),
8912    })
8913}
8914
8915/// The sort key of a stamp in any of the three stores' shapes: RFC 3339
8916/// (`2026-09-12T21:54:00Z`), an org stamp (`[2026-09-12 Sat 21:54]`), or a
8917/// date alone. Day, then `HH:MM` when the stamp has one.
8918fn stamp_key(stamp: Option<&str>) -> Option<(i64, String)> {
8919    let s = stamp?
8920        .trim()
8921        .trim_start_matches(['[', '<'])
8922        .trim_end_matches([']', '>']);
8923    let days = days_of_stamp(Some(s))?;
8924    let rest = &s[10..];
8925    let clock = rest
8926        .split(['T', ' '])
8927        .find(|t| t.len() >= 5 && t.as_bytes()[2] == b':')
8928        .map(|t| t[..5].to_string())
8929        .unwrap_or_default();
8930    Some((days, clock))
8931}
8932
8933/// One line per event: date, age, gap since the line before, store, text.
8934fn format_events(events: &[Event], now: &str) -> String {
8935    let today = days_of_stamp(Some(now)).unwrap_or(0);
8936    let mut out = String::new();
8937    let mut last: Option<i64> = None;
8938    for e in events {
8939        let gap = match last {
8940            None => String::new(),
8941            Some(d) if e.days == d => "same day".to_string(),
8942            Some(d) => format!("+{} d", e.days - d),
8943        };
8944        last = Some(e.days);
8945        out.push_str(&format!(
8946            "{} {}	{}	{}	{}	{}
8947",
8948            civil_of_days(e.days),
8949            e.clock,
8950            age_of(Some(&civil_of_days(e.days)), &civil_of_days(today)),
8951            gap,
8952            e.source,
8953            e.text
8954        ));
8955    }
8956    out
8957}
8958
8959/// `YYYY-MM-DD` of a day count since the epoch.
8960fn civil_of_days(days: i64) -> String {
8961    let z = days + 719_468;
8962    let era = z.div_euclid(146_097);
8963    let doe = z.rem_euclid(146_097);
8964    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
8965    let y = yoe + era * 400;
8966    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
8967    let mp = (5 * doy + 2) / 153;
8968    let d = doy - (153 * mp + 2) / 5 + 1;
8969    let m = if mp < 10 { mp + 3 } else { mp - 9 };
8970    let y = if m <= 2 { y + 1 } else { y };
8971    format!("{y:04}-{m:02}-{d:02}")
8972}
8973
8974/// Open a sitting on an issue, in the protocol's order, and stop at the
8975/// first habitat that does not answer: doctor, cards, the review clock,
8976/// the island the issue's title activates, the working set, the timeline,
8977/// the claim.
8978/// One verb, so the loop that makes the seat a memory runs every time and
8979/// not only when somebody remembers to run it.
8980///
8981/// # Errors
8982///
8983/// A required habitat down, or the claim refused (the refusal names what
8984/// the assignee still holds).
8985pub fn sitting(issue: &str, assignee: &str, cards_dir: &Path) -> Result<String> {
8986    sitting_gated(issue, assignee, cards_dir, false, None)
8987}
8988
8989/// The blockers of an issue that are still open, as `id (STATE)`, read
8990/// from the tracker. Empty when the issue is workable, or when the tracker
8991/// does not answer (the sitting's doctor already said so).
8992pub fn open_blockers(issue: &str) -> Vec<String> {
8993    let Ok(shown) = tracker_show_json(issue) else {
8994        return Vec::new();
8995    };
8996    let mut out = Vec::new();
8997    for id in shown["blocked_by"]
8998        .as_array()
8999        .into_iter()
9000        .flatten()
9001        .filter_map(Value::as_str)
9002    {
9003        let state = tracker_show_json(id)
9004            .ok()
9005            .and_then(|v| v["state"].as_str().map(str::to_string))
9006            .unwrap_or_else(|| "?".to_string());
9007        if !matches!(state.as_str(), "DONE" | "CANCELLED") {
9008            out.push(format!("{id} ({state})"));
9009        }
9010    }
9011    out
9012}
9013
9014/// [`sitting`], and with `anyway` the claim goes through even when the
9015/// issue's blockers are open. Without it a blocked issue is refused before
9016/// anything is claimed: the tracker's graph says what is workable, and a
9017/// seat that sits on blocked work sits on nothing it can finish.
9018/// `playbook` names the recipe copied into `== playbook` before recall;
9019/// absent, a name already bound, else a closed-set token in the title,
9020/// else `sit`. Sitting always binds one of the five before claim. Finish
9021/// and release drop the sticky name.
9022pub fn sitting_gated(
9023    issue: &str,
9024    assignee: &str,
9025    cards_dir: &Path,
9026    anyway: bool,
9027    playbook: Option<&str>,
9028) -> Result<String> {
9029    let mut out = String::new();
9030    let rows = doctor_seat();
9031    out.push_str("== doctor\n");
9032    out.push_str(&format_doctor(&rows));
9033    if !healthy(&rows) {
9034        bail!("{out}sitting: a required habitat does not answer; nothing was claimed");
9035    }
9036    // Other machines' memories of this scope arrive before the island is
9037    // walked, or the sitting orients on half the seat.
9038    out.push_str("== sync\n");
9039    out.push_str(&sync::sync_repo(true, false).unwrap_or_else(|e| format!("sync: {e:#}\n")));
9040    out.push_str("== cards\n");
9041    out.push_str(&cards(cards_dir)?);
9042    let title = issue_title(issue)?;
9043    let island = packset_island(&title, false)?;
9044    out.push_str("== due\n");
9045    out.push_str(&sitting_due_report(&island)?);
9046    out.push_str(&format!("== island: {title}\n"));
9047    // The strongest eight: a sitting wants orientation, not the whole
9048    // cluster; `ljos island` prints it all.
9049    let mut top = island.clone();
9050    if let Some(rows) = top["island"].as_array_mut() {
9051        rows.truncate(8);
9052    }
9053    out.push_str(&format_island(&top));
9054    out.push_str("== blockers\n");
9055    let blockers = open_blockers(issue);
9056    if blockers.is_empty() {
9057        out.push_str("none open; the issue is workable\n");
9058    } else {
9059        out.push_str(&format!("open: {}\n", blockers.join(", ")));
9060        if !anyway {
9061            bail!(
9062                "{out}sitting: {issue} is blocked by {}; finish those first, or `ljos sitting {issue} --anyway` to sit on it regardless. Nothing was claimed",
9063                blockers.join(", ")
9064            );
9065        }
9066        out.push_str("sitting anyway, as asked\n");
9067    }
9068    // A decision is handed to the panel by the sitting itself: agents ran
9069    // only the verbs the loop put in front of them, never an optional
9070    // `ljos panel`, so the sitting binds the panel recipe and writes the
9071    // briefs.
9072    let decision = tracker_show_json(issue).is_ok_and(|v| is_decision(&v));
9073    let name = match (playbook, decision) {
9074        (None, true) if bound_playbook(issue).is_none() => "company-panel".to_string(),
9075        _ => resolve_sitting_playbook(issue, &title, playbook)?,
9076    };
9077    out.push_str("== playbook\n");
9078    out.push_str(&copy_playbook(issue, &name)?);
9079    if decision {
9080        out.push_str("== panel\n");
9081        let dir = runtime_dir().join(format!("panel-{issue}"));
9082        match panel(issue, &dir) {
9083            Ok(said) => out.push_str(&format!(
9084                "{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}"
9085            )),
9086            Err(e) => out.push_str(&format!("{issue} is a decision, and the panel could not be written: {e:#}\n")),
9087        }
9088    }
9089    out.push_str("== recall\n");
9090    out.push_str(&run_captured("vissue", &["recall", issue])?.stdout);
9091    // The last twelve dated events across the three stores; `ljos
9092    // timeline` prints them all.
9093    out.push_str("== timeline\n");
9094    out.push_str(&timeline(issue, SITTING_TIMELINE)?);
9095    out.push_str("== claim\n");
9096    out.push_str(&claim(issue, assignee)?);
9097    out.push_str(&persist_tracker(issue, "claimed"));
9098    Ok(out)
9099}
9100
9101/// Close a sitting: remember the lesson when there is one, fire the island
9102/// the issue's title activates, complete the session node, and learn from
9103/// the outcome when one is named. Without a lesson the report says so,
9104/// because a sitting that taught nothing worth two sentences is rare and
9105/// worth noticing.
9106///
9107/// # Errors
9108///
9109/// Any habitat refusing; the pack refuses a lesson longer than two
9110/// sentences, the claim graph a status that is not terminal.
9111/// Finish a session node only if `gen` is still the live lease.
9112///
9113/// # Errors
9114///
9115/// The claim graph refuses a stale generation, a missing actor, or a
9116/// status that is not terminal.
9117pub fn complete(
9118    node: &str,
9119    status: Option<&str>,
9120    assignee: &str,
9121    gen: Option<u64>,
9122) -> Result<String> {
9123    let id = node_for(node)?;
9124    let actor = work_id(&occupancy_scope(assignee, node));
9125    let gen_s = live_gen(&id, gen)?.to_string();
9126    let mut args = vec![
9127        "complete",
9128        id.as_str(),
9129        "--actor",
9130        actor.as_str(),
9131        "--gen",
9132        gen_s.as_str(),
9133    ];
9134    if let Some(s) = status {
9135        args.push("--status");
9136        args.push(s);
9137    }
9138    let said = run_captured("claimdag", &args)?;
9139    drop_hold(&actor);
9140    drop_playbook(node);
9141    Ok(said.stdout)
9142}
9143
9144#[expect(
9145    clippy::too_many_arguments,
9146    reason = "The public finish signature preserves its independent command options"
9147)]
9148pub fn finish(
9149    issue: &str,
9150    status: &str,
9151    lesson: Option<&str>,
9152    outcome: Option<&str>,
9153    beta: f64,
9154    assignee: &str,
9155    gen: Option<u64>,
9156    close: bool,
9157) -> Result<String> {
9158    // A decision closes on ballots, not on the say of the seat that sat on
9159    // it; refused before anything is written, so nothing half-happens.
9160    if close && tracker_show_json(issue).is_ok_and(|v| is_decision(&v)) {
9161        let said = run_captured("vissue", &["vote", issue, "--json"])?;
9162        let ballots = forecasts_from_json(&said.stdout)?.len();
9163        if ballots < 2 {
9164            bail!(
9165                "finish: {issue} is a decision and holds {ballots} ballot{}; run the panel \
9166                 (`ljos panel {issue}`), have each persona cast `ljos vote {issue} --for OPTION --expect OPTION --as NAME`, \
9167                 settle with `ljos consensus {issue}`, then --close. Nothing was written",
9168                if ballots == 1 { "" } else { "s" }
9169            );
9170        }
9171    }
9172    let mut out = String::new();
9173    match lesson.map(str::trim).filter(|l| !l.is_empty()) {
9174        Some(text) => {
9175            // A lesson learned on an issue belongs to the scope of the
9176            // repository that holds the issue, wherever it was written.
9177            let scope = sync::scope_for_issue(issue);
9178            let body = packset_write_scoped("Remember", text, issue, scope.as_deref())?;
9179            out.push_str(&format!(
9180                "remembered {}{}\n",
9181                body.get("id").and_then(Value::as_str).unwrap_or("-"),
9182                revision_note(&body)
9183            ));
9184        }
9185        None => out.push_str(
9186            "no lesson remembered this sitting; `ljos remember` takes one in two sentences\n",
9187        ),
9188    }
9189    let title = issue_title(issue)?;
9190    let island = packset_island(&title, true)?;
9191    if island["weak"].as_bool().unwrap_or(false) {
9192        out.push_str(&format!(
9193            "did not fire the island for {title:?}: its seeds are hits no two scorers agreed on{}; wiring them would tighten the wrong links\n",
9194            if island["dense"].as_bool().unwrap_or(true) { "" } else { " (the encoder is down, ranking is lexical only)" }
9195        ));
9196    } else if island["held"].as_bool().unwrap_or(false) {
9197        // Another sitting on this issue, or another persona's, fired the
9198        // same claims within the hour; the pack tightened them once.
9199        out.push_str(&format!(
9200            "the island for {title:?} fired within the hour; not fired again\n"
9201        ));
9202    } else {
9203        let fired = island["island"].as_array().map_or(0, Vec::len);
9204        out.push_str(&format!(
9205            "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"
9206        ));
9207    }
9208    let terminal = ["done", "failed", "cancelled"];
9209    if !terminal.contains(&status) {
9210        bail!("finish: status {status:?} is not one of done, failed, cancelled");
9211    }
9212    complete(issue, Some(status), assignee, gen)?;
9213    out.push_str(&format!(
9214        "completed the session node for {issue} as {status}\n"
9215    ));
9216    if let Some(option) = outcome.map(str::trim).filter(|o| !o.is_empty()) {
9217        let said = run_captured("vissue", &["vote", issue, "--json"])?;
9218        let forecasts = forecasts_from_json(&said.stdout)?;
9219        if forecasts.len() < 2 {
9220            out.push_str("outcome named but fewer than two ballots; nothing to learn from\n");
9221        } else {
9222            let ballots: Vec<(String, String)> = forecasts
9223                .iter()
9224                .map(|f| (f.agent.clone(), f.choice.clone()))
9225                .collect();
9226            let about = island_entities(issue).unwrap_or_default();
9227            let (rows, moved, calibration) =
9228                learn_and_write(&ballots, option, beta, &about, &forecasts)?;
9229            out.push_str(&learn_reading(
9230                rows.len(),
9231                moved.len(),
9232                &forecasts,
9233                option,
9234                &calibration,
9235            ));
9236            out.push('\n');
9237        }
9238    }
9239    // A sitting ending is not the work being accepted: a review can be
9240    // posted and still be open, a build can be green and still unmerged.
9241    // The ticket closes only when asked, so a blocker on it stays a blocker.
9242    if close && status.eq_ignore_ascii_case("done") {
9243        run_as("vissue", &["update", issue, "-s", "DONE"], None)
9244            .with_context(|| format!("finish: could not close the ticket {issue}"))?;
9245        out.push_str(&format!("closed the ticket {issue}\n"));
9246    } else {
9247        out.push_str(&format!(
9248            "the ticket {issue} keeps its state; `ljos finish {issue} --close` or `vissue update {issue} -s DONE` closes it when the work is accepted\n"
9249        ));
9250    }
9251    out.push_str(&persist_tracker(issue, "finished"));
9252    // What this sitting taught leaves the machine with the tracker.
9253    out.push_str(&sync::sync_repo(false, true).unwrap_or_else(|e| format!("sync: {e:#}\n")));
9254    Ok(out)
9255}
9256
9257/// An exclusive advisory lock on a file, held until dropped. Taking it
9258/// blocks; a lock that cannot be opened is no lock, and the commit goes on
9259/// as it would have without one.
9260pub struct CommitLock(Option<std::fs::File>);
9261
9262impl CommitLock {
9263    #[must_use]
9264    pub fn acquire(path: &std::path::Path) -> Self {
9265        use std::os::unix::io::AsRawFd;
9266        let Ok(file) = std::fs::OpenOptions::new()
9267            .create(true)
9268            .append(true)
9269            .open(path)
9270        else {
9271            return Self(None);
9272        };
9273        // SAFETY: flock on a descriptor this struct owns until drop.
9274        let ok = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } == 0;
9275        Self(ok.then_some(file))
9276    }
9277}
9278
9279impl Drop for CommitLock {
9280    fn drop(&mut self) {
9281        use std::os::unix::io::AsRawFd;
9282        if let Some(file) = &self.0 {
9283            // SAFETY: the descriptor is still open; unlocking it cannot fail
9284            // in a way that matters, since close releases it too.
9285            unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
9286        }
9287    }
9288}
9289
9290/// Commit the tracker file that holds `issue` and push it, when the tracker
9291/// is a git checkout. A write that stays in one working tree is lost to
9292/// every other host and to a rebuilt one; closures made on one laptop and
9293/// never committed were how tickets came back open. Only that file is
9294/// committed (`--only`), so another seat's staged work is left alone. Never
9295/// an error: the verb already happened, and the line says what did not.
9296/// `LJOS_TRACKER_GIT=off` skips it; `=commit` commits without pushing.
9297pub fn persist_tracker(issue: &str, verb: &str) -> String {
9298    let mode = std::env::var("LJOS_TRACKER_GIT").unwrap_or_default();
9299    if matches!(mode.as_str(), "off" | "0" | "false") {
9300        return "tracker git: off (LJOS_TRACKER_GIT)\n".into();
9301    }
9302    let path = match vissue_core::Layout::resolve(None, None)
9303        .and_then(vissue_core::Router::load)
9304        .and_then(|router| router.find_by_id(issue))
9305    {
9306        Ok(hit) => hit.path,
9307        Err(e) => return format!("tracker git: could not find {issue}: {e}\n"),
9308    };
9309    let Some(dir) = path.parent() else {
9310        return format!("tracker git: {} has no directory\n", path.display());
9311    };
9312    let git = |args: &[&str]| {
9313        std::process::Command::new("git")
9314            .arg("-C")
9315            .arg(dir)
9316            .args(args)
9317            .stdin(std::process::Stdio::null())
9318            .output()
9319    };
9320    let file = path.to_string_lossy().to_string();
9321    match git(&["rev-parse", "--is-inside-work-tree"]) {
9322        Ok(o) if o.status.success() => {}
9323        _ => return "tracker git: the tracker is not a git checkout\n".into(),
9324    }
9325    match git(&["status", "--porcelain", "--", &file]) {
9326        Ok(o) if o.status.success() && o.stdout.is_empty() => {
9327            return "tracker git: nothing to commit\n".into();
9328        }
9329        Ok(o) if o.status.success() => {}
9330        Ok(o) => return format!("tracker git: {}\n", first_line(&o.stderr)),
9331        Err(e) => return format!("tracker git: {e}\n"),
9332    }
9333    let message = format!("chore(issues): {issue} {verb}");
9334    // Every seat on the host commits this one checkout. The add and the
9335    // commit run under one lock in the git directory, so ljos writers queue
9336    // instead of meeting on index.lock; a git process outside ljos that
9337    // holds the index is waited out a few times before the line says so.
9338    let common = git(&["rev-parse", "--git-common-dir"])
9339        .ok()
9340        .filter(|o| o.status.success())
9341        .map(|o| dir.join(String::from_utf8_lossy(&o.stdout).trim()))
9342        .unwrap_or_else(|| dir.join(".git"));
9343    let _held = CommitLock::acquire(&common.join("ljos-commit.lock"));
9344    let mut committed = git(&["add", "--", &file])
9345        .and_then(|_| git(&["commit", "-q", "--only", "-m", &message, "--", &file]));
9346    for wait_ms in [200_u64, 400, 800, 1600, 3200] {
9347        let busy = matches!(&committed, Ok(o) if !o.status.success()
9348            && String::from_utf8_lossy(&o.stderr).contains("index.lock"));
9349        if !busy {
9350            break;
9351        }
9352        std::thread::sleep(std::time::Duration::from_millis(wait_ms));
9353        committed = git(&["add", "--", &file])
9354            .and_then(|_| git(&["commit", "-q", "--only", "-m", &message, "--", &file]));
9355    }
9356    drop(_held);
9357    match committed {
9358        Ok(o) if o.status.success() => {}
9359        Ok(o) => {
9360            return format!(
9361                "tracker git: commit refused: {}\n",
9362                first_line(if o.stderr.is_empty() {
9363                    &o.stdout
9364                } else {
9365                    &o.stderr
9366                })
9367            );
9368        }
9369        Err(e) => return format!("tracker git: {e}\n"),
9370    }
9371    if mode == "commit" {
9372        return format!("tracker git: committed {message}; not pushed (LJOS_TRACKER_GIT=commit)\n");
9373    }
9374    // A push can run a repository's pre-push hook that publishes data first
9375    // and takes minutes. The sitting waits a bounded time; a push still going
9376    // after that finishes on its own and writes its log where the line says.
9377    let log = runtime_dir().join(format!("tracker-push-{}.log", std::process::id()));
9378    let _ = std::fs::create_dir_all(runtime_dir());
9379    let Ok(out) = std::fs::File::create(&log) else {
9380        return format!("tracker git: committed {message}; push not started: no log file\n");
9381    };
9382    let err = out.try_clone();
9383    // Every other remote that carries the branch gets it too: seats that
9384    // read a tracker through different remotes see each other's claims
9385    // only when every push reaches all of them.
9386    let mirrors = tracker_upstream(dir)
9387        .and_then(|up| tracker_mirrors(dir, &up))
9388        .unwrap_or_default();
9389    // A push another host beat is merged, not left ahead: the next catch-up
9390    // only fast-forwards, so a clone left diverged never recovered. A merge
9391    // rather than a rebase, because other seats keep uncommitted edits in
9392    // the same worktree; issues.org merges by heading through vissue.
9393    let mut script =
9394        String::from("git push -q || { git pull -q --no-rebase --no-edit && git push -q; }; rc=$?");
9395    for (remote, branch) in &mirrors {
9396        script.push_str(&format!(
9397            "; git push -q '{remote}' 'HEAD:refs/heads/{branch}' || rc=1"
9398        ));
9399    }
9400    script.push_str("; exit $rc");
9401    let mut push = std::process::Command::new("sh");
9402    push.current_dir(dir)
9403        .args(["-c", &script])
9404        .stdin(std::process::Stdio::null())
9405        .stdout(out);
9406    if let Ok(err) = err {
9407        push.stderr(err);
9408    }
9409    let mut child = match push.spawn() {
9410        Ok(c) => c,
9411        Err(e) => return format!("tracker git: committed {message}; push failed: {e}\n"),
9412    };
9413    let wait = push_wait();
9414    let started = std::time::Instant::now();
9415    loop {
9416        match child.try_wait() {
9417            Ok(Some(status)) if status.success() => {
9418                let _ = std::fs::remove_file(&log);
9419                return format!("tracker git: committed and pushed {message}\n");
9420            }
9421            Ok(Some(_)) => {
9422                let said = std::fs::read(&log).unwrap_or_default();
9423                return format!(
9424                    "tracker git: committed {message}; push refused: {}\n",
9425                    first_line(&said)
9426                );
9427            }
9428            Ok(None) if started.elapsed() < wait => {
9429                std::thread::sleep(std::time::Duration::from_millis(200));
9430            }
9431            Ok(None) => {
9432                return format!(
9433                    "tracker git: committed {message}; push still running after {}s, finishing in the background (log {})\n",
9434                    wait.as_secs(),
9435                    log.display()
9436                );
9437            }
9438            Err(e) => return format!("tracker git: committed {message}; push failed: {e}\n"),
9439        }
9440    }
9441}
9442
9443/// How long a sitting waits for the tracker push: `LJOS_TRACKER_PUSH_WAIT`
9444/// seconds, else 5: agents wrap a finish in a timeout of about ten seconds.
9445fn push_wait() -> std::time::Duration {
9446    let secs = std::env::var("LJOS_TRACKER_PUSH_WAIT")
9447        .ok()
9448        .and_then(|v| v.trim().parse::<u64>().ok())
9449        .unwrap_or(5);
9450    std::time::Duration::from_secs(secs)
9451}
9452
9453fn first_line(bytes: &[u8]) -> String {
9454    String::from_utf8_lossy(bytes)
9455        .lines()
9456        .find(|l| !l.trim().is_empty())
9457        .unwrap_or("")
9458        .trim()
9459        .to_string()
9460}
9461
9462/// The weight a voter of estimated accuracy `p` earns: the log odds
9463/// `ln(p / (1 - p))`, the optimal weight for independent voters on a
9464/// two-way choice (Nitzan and Paroush, doi:10.2307/2526438; a weighted
9465/// majority under these weights is the maximum-likelihood decision), with
9466/// `p` held inside `[0.01, 0.99]` so a perfect record does not become an
9467/// infinite vote, and a voter at or under chance at [`TRUST_FLOOR`]. The
9468/// weights are scaled so the most reliable voter stands at one, which is
9469/// the scale the trust rows live on; the ratios between voters are the
9470/// rule's.
9471#[must_use]
9472pub fn calibration_weights(accuracy: &[(String, f64)]) -> Vec<(String, f64)> {
9473    let logit = |p: f64| {
9474        let p = p.clamp(0.01, 0.99);
9475        (p / (1.0 - p)).ln()
9476    };
9477    let raw: Vec<(String, f64)> = accuracy
9478        .iter()
9479        .map(|(who, p)| (who.clone(), logit(*p).max(0.0)))
9480        .collect();
9481    let top = raw.iter().map(|(_, w)| *w).fold(0.0_f64, f64::max);
9482    raw.into_iter()
9483        .map(|(who, w)| {
9484            let scaled = if top > 0.0 { w / top } else { 0.0 };
9485            (who, scaled.clamp(TRUST_FLOOR, 1.0))
9486        })
9487        .collect()
9488}
9489
9490/// Turn a project's voting history into trust rows without anyone naming
9491/// an outcome: Dawid and Skene's accuracy per voter
9492/// (doi:10.2307/2346806), from `ljos-consensus reliability`, turned into
9493/// the weight every other voter gives that voter by
9494/// [`calibration_weights`]: log odds, so a voter right nine times in ten
9495/// outweighs one right six times in ten by five to one, not three to two.
9496/// Rows are complete and floored at [`TRUST_FLOOR`], so the settle sees
9497/// the whole graph.
9498///
9499/// # Errors
9500///
9501/// No issue with two or more ballots, the consensus binary absent, or the
9502/// pack refusing a row.
9503pub fn calibrate(project: &str, rounds: usize) -> Result<Vec<Trust>> {
9504    let said = run_captured(
9505        "ljos-consensus",
9506        &[
9507            "reliability",
9508            "--project",
9509            project,
9510            "--rounds",
9511            &rounds.to_string(),
9512        ],
9513    )?;
9514    let v: Value = serde_json::from_str(&said.stdout).context("reliability: not JSON")?;
9515    let accuracy = v
9516        .get("accuracy")
9517        .and_then(Value::as_object)
9518        .context("reliability: no accuracy object")?;
9519    let mut voters: Vec<(String, f64)> = accuracy
9520        .iter()
9521        .filter_map(|(k, val)| val.as_f64().map(|a| (k.clone(), a)))
9522        .collect();
9523    voters.sort_by(|a, b| a.0.cmp(&b.0));
9524    if voters.len() < 2 {
9525        bail!("calibrate: fewer than two voters in {project}");
9526    }
9527    let weights = calibration_weights(&voters);
9528    let mut rows = Vec::new();
9529    for (from, _) in &voters {
9530        for (to, weight) in &weights {
9531            if from == to {
9532                continue;
9533            }
9534            rows.push(Trust {
9535                from: from.clone(),
9536                to: to.clone(),
9537                weight: *weight,
9538                about: Vec::new(),
9539            });
9540        }
9541    }
9542    for row in &rows {
9543        write_trust(row, &[])?;
9544    }
9545    Ok(rows)
9546}
9547
9548/// What a search score is. Empty and nonempty are different facts from a
9549/// writer that did not answer.
9550#[must_use]
9551pub fn search_reading(n: usize) -> &'static str {
9552    if n == 0 {
9553        "No hits. The pack holds nothing on this query. A failure would say the writer did not answer."
9554    } else {
9555        "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."
9556    }
9557}
9558
9559/// One line per hit: score, how many scorers named it out of how many
9560/// ran, kind, id, age, text. The age is the one column a reader needs to
9561/// lay the hits on a timeline; the count is what the hook keys on.
9562pub fn format_hits(hits: &[Hit]) -> String {
9563    let now = now_utc();
9564    let mine = seat_name();
9565    let mut out = format!("{}\n", search_reading(hits.len()));
9566    for h in hits {
9567        let id = h.id.as_deref().unwrap_or("-");
9568        let named = match (h.ballots, h.of) {
9569            (Some(b), Some(of)) => format!("{b}/{of}"),
9570            _ => "-".to_string(),
9571        };
9572        let from = other_seat(&h.entities, &mine)
9573            .map(|s| format!(" (from {s})"))
9574            .unwrap_or_default();
9575        out.push_str(&format!(
9576            "{:.4}\t{}\t{}\t{}\t{}{}\t{}\n",
9577            h.score,
9578            named,
9579            h.kind,
9580            id,
9581            age_of(h.ts.as_deref(), &now),
9582            from,
9583            h.text
9584        ));
9585    }
9586    out
9587}
9588
9589/// The seat that wrote a hit, when it was another than this one. Many
9590/// seats share a pack; a reader is told whose lesson it is reading only
9591/// when that is news.
9592#[must_use]
9593pub fn other_seat(entities: &[String], mine: &str) -> Option<String> {
9594    entities
9595        .iter()
9596        .filter_map(|e| e.strip_prefix(SEAT_ENTITY))
9597        .find(|s| !s.is_empty() && *s != mine)
9598        .map(str::to_string)
9599}
9600
9601/// The line a hit takes in injected context and in a brief: kind, age and,
9602/// when another seat wrote it, that seat in the bracket, then the text.
9603fn hit_line(h: &Hit, now: &str) -> String {
9604    let from = other_seat(&h.entities, &seat_name())
9605        .map(|s| format!(", from {s}"))
9606        .unwrap_or_default();
9607    format!(
9608        "- [{}{}{}] {}",
9609        if h.kind.is_empty() { "claim" } else { &h.kind },
9610        age_tag(h.ts.as_deref(), now),
9611        from,
9612        h.text.trim()
9613    )
9614}
9615
9616/// `, N days ago` for a bracket, empty when the stamp is missing.
9617fn age_tag(ts: Option<&str>, now: &str) -> String {
9618    let age = age_of(ts, now);
9619    if age.is_empty() {
9620        age
9621    } else {
9622        format!(", {age}")
9623    }
9624}
9625
9626/// How long ago a stamp was, in words a reader can place: `today`,
9627/// `yesterday`, `N days ago`, then weeks, months and years once the count
9628/// stops fitting the smaller unit. Empty when the stamp is missing or
9629/// unreadable, `in N days` for a stamp ahead of `now`.
9630#[must_use]
9631pub fn age_of(ts: Option<&str>, now: &str) -> String {
9632    let (Some(then), Some(today)) = (days_of_stamp(ts), days_of_stamp(Some(now))) else {
9633        return String::new();
9634    };
9635    let days = today - then;
9636    match days {
9637        d if d < 0 => format!("in {} day{}", -d, if d == -1 { "" } else { "s" }),
9638        0 => "today".into(),
9639        1 => "yesterday".into(),
9640        d if d < 14 => format!("{d} days ago"),
9641        d if d < 61 => format!("{} weeks ago", d / 7),
9642        d if d < 730 => format!("{} months ago", d / 30),
9643        d => format!("{} years ago", d / 365),
9644    }
9645}
9646
9647/// Days since the epoch of an RFC 3339 stamp's date, or none when the
9648/// first ten characters do not read as `YYYY-MM-DD`.
9649fn days_of_stamp(ts: Option<&str>) -> Option<i64> {
9650    let ts = ts?;
9651    let date = ts.get(..10)?;
9652    let mut it = date.split('-');
9653    let y: i64 = it.next()?.parse().ok()?;
9654    let m: i64 = it.next()?.parse().ok()?;
9655    let d: i64 = it.next()?.parse().ok()?;
9656    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
9657        return None;
9658    }
9659    // Civil date to days since the epoch (Howard Hinnant's algorithm).
9660    let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
9661    let era = y.div_euclid(400);
9662    let yoe = y - era * 400;
9663    let doy = (153 * m + 2) / 5 + d - 1;
9664    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
9665    Some(era * 146_097 + doe - 719_468)
9666}
9667
9668/// Read-only cards. Only [`CARD_NAMES`], never created, never written.
9669pub fn cards(dir: &Path) -> Result<String> {
9670    let mut out = String::new();
9671    for name in CARD_NAMES {
9672        let p = dir.join(name);
9673        if p.is_file() {
9674            out.push_str(&format!("--- {} ---\n", p.display()));
9675            out.push_str(&std::fs::read_to_string(&p)?);
9676        }
9677    }
9678    Ok(out)
9679}
9680
9681pub fn policy_line(argv: &[String]) -> Result<String> {
9682    if argv.is_empty() {
9683        bail!("policy: pass the argv to check");
9684    }
9685    Ok(argv.join(" "))
9686}
9687
9688/// The argv line, then what the pack knows that bears on it: the memory a
9689/// policy layer injects beside its verdict. The line prints even when the
9690/// pack is down; the memory is the part that may be empty.
9691pub fn policy_with_memory(argv: &[String]) -> Result<String> {
9692    let line = policy_line(argv)?;
9693    let call = HookCall {
9694        event: "argv".into(),
9695        cue: line.clone(),
9696        session: None,
9697        shape: HookShape::Asks,
9698    };
9699    let context = hook_context(&call, 5);
9700    // The rules are the law's memory: a deny or an ask fires before the
9701    // context, so a reader sees the verdict first.
9702    let rules = rules_from_pack().unwrap_or_default();
9703    let ruled = hook_output_ruled(&call, &context, verdict_for(&rules, &line));
9704    match tcb_check(argv) {
9705        Some(tcb) if !tcb.is_empty() => Ok(format!("{line}\n{tcb}\n{ruled}")),
9706        None if policyd_required() => Ok(format!("{line}\ndeny\tTCB required\n{ruled}")),
9707        _ => Ok(format!("{line}\n{ruled}")),
9708    }
9709}
9710
9711/// Operator switch: missing TCB is a deny. Unset, absence stays open.
9712pub fn policyd_required() -> bool {
9713    matches!(
9714        std::env::var("POLICYD_REQUIRED").as_deref(),
9715        Ok("1") | Ok("true") | Ok("TRUE")
9716    )
9717}
9718
9719/// `POLICYD_BIN`, else `ljos-policyd` on PATH.
9720pub fn policyd_bin() -> Option<std::path::PathBuf> {
9721    std::env::var_os("POLICYD_BIN")
9722        .filter(|s| !s.is_empty())
9723        .map(std::path::PathBuf::from)
9724        .or_else(|| which::which("ljos-policyd").ok())
9725}
9726
9727/// One line from `ljos-policyd check -- argv`. None if the binary is absent
9728/// or failed to start. Absence is not a deny.
9729pub fn tcb_check(argv: &[String]) -> Option<String> {
9730    let bin = policyd_bin()?;
9731    let out = std::process::Command::new(bin)
9732        .arg("check")
9733        .arg("--")
9734        .args(argv)
9735        .output()
9736        .ok()?;
9737    let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
9738    (!text.is_empty()).then_some(text)
9739}
9740
9741#[derive(Debug, Clone, PartialEq, Eq)]
9742pub struct ConsensusStep {
9743    pub bin: &'static str,
9744    pub args: Vec<String>,
9745}
9746
9747/// `ljos-consensus` first, then `vissue consensus`, both under the pack's
9748/// trust rows when there are any. Missing bins are skipped.
9749pub fn consensus_steps(
9750    id: &str,
9751    have_ljos: bool,
9752    have_vissue: bool,
9753    trust: &[Trust],
9754) -> Result<Vec<ConsensusStep>> {
9755    consensus_steps_anchored(id, have_ljos, have_vissue, trust, &[])
9756}
9757
9758/// The tag on an issue that asks for bounded confidence: a panel for a
9759/// broad audience is allowed to settle into clusters, and the settle says
9760/// how far apart they are, where a single-position model would average
9761/// them away. Without it the anchored model runs.
9762pub const BROAD_TAG: &str = "broad";
9763
9764/// The confidence bound a `broad` issue settles under: voters within this
9765/// L1 distance of each other's opinion listen to each other.
9766pub const BROAD_EPSILON: f64 = 1.0;
9767
9768/// The model flags an issue's tags ask for, beside the rows and anchors.
9769/// The kind of work sets the dynamics: `broad` runs bounded confidence.
9770#[must_use]
9771pub fn settle_flags_for(tags: &[String]) -> Vec<String> {
9772    if tags.iter().any(|t| t == BROAD_TAG) {
9773        vec!["--epsilon".into(), BROAD_EPSILON.to_string()]
9774    } else {
9775        Vec::new()
9776    }
9777}
9778
9779/// [`consensus_steps_anchored`] with the model flags the issue's tags ask
9780/// for on the model crate's settle.
9781pub fn consensus_steps_for(
9782    id: &str,
9783    have_ljos: bool,
9784    have_vissue: bool,
9785    trust: &[Trust],
9786    personas: &[Persona],
9787    tags: &[String],
9788) -> Result<Vec<ConsensusStep>> {
9789    let mut steps = consensus_steps_anchored(id, have_ljos, have_vissue, trust, personas)?;
9790    let flags = settle_flags_for(tags);
9791    if !flags.is_empty() {
9792        for step in steps.iter_mut().filter(|s| s.bin == "ljos-consensus") {
9793            step.args.extend(flags.iter().cloned());
9794        }
9795    }
9796    Ok(steps)
9797}
9798
9799/// The two readings beside a settle, when the pack holds what they need:
9800/// the surprisingly popular answer when two or more voters forecast the
9801/// others (`predict`), and the EigenTrust standing of the voters when
9802/// trust rows exist. Both are the model crate's verbs.
9803pub fn panel_steps(
9804    id: &str,
9805    have_ljos: bool,
9806    trust: &[Trust],
9807    predictions: &[Prediction],
9808) -> Vec<ConsensusStep> {
9809    let mut steps = Vec::new();
9810    if !have_ljos {
9811        return steps;
9812    }
9813    if predictions.len() >= 2 {
9814        steps.push(ConsensusStep {
9815            bin: "ljos-consensus",
9816            args: vec![
9817                "surprising".into(),
9818                "--issue".into(),
9819                id.into(),
9820                "--predictions".into(),
9821                predictions_json(predictions),
9822            ],
9823        });
9824    }
9825    if !trust.is_empty() {
9826        steps.push(ConsensusStep {
9827            bin: "ljos-consensus",
9828            args: vec!["reputation".into(), "--trust".into(), trust_json(trust)],
9829        });
9830    }
9831    steps
9832}
9833
9834/// [`consensus_steps`] passing the personas' anchors to both settles as
9835/// `--susceptibility-of`, so a persona holds its ballot as much as it says.
9836pub fn consensus_steps_anchored(
9837    id: &str,
9838    have_ljos: bool,
9839    have_vissue: bool,
9840    trust: &[Trust],
9841    personas: &[Persona],
9842) -> Result<Vec<ConsensusStep>> {
9843    if !have_ljos && !have_vissue {
9844        bail!("neither ljos-consensus nor vissue is on PATH");
9845    }
9846    let mut steps = Vec::new();
9847    if have_ljos {
9848        let mut args = vec!["settle".to_string(), "--issue".into(), id.into()];
9849        if !trust.is_empty() {
9850            args.push("--trust".into());
9851            args.push(trust_json(trust));
9852        }
9853        if !personas.is_empty() {
9854            args.push("--susceptibility-of".into());
9855            args.push(anchors_json(personas));
9856        }
9857        steps.push(ConsensusStep {
9858            bin: "ljos-consensus",
9859            args,
9860        });
9861    }
9862    if have_vissue {
9863        let mut args = vec!["consensus".to_string(), id.into()];
9864        if !trust.is_empty() {
9865            args.push("--trust".into());
9866            args.push(trust_json(trust));
9867        }
9868        if !personas.is_empty() {
9869            args.push("--susceptibility-of".into());
9870            args.push(anchors_json(personas));
9871        }
9872        steps.push(ConsensusStep {
9873            bin: "vissue",
9874            args,
9875        });
9876    }
9877    Ok(steps)
9878}
9879
9880pub fn on_path(bin: &str) -> bool {
9881    which::which(bin).is_ok()
9882}
9883
9884pub fn run(bin: &str, args: &[impl AsRef<str>]) -> Result<()> {
9885    run_as(bin, args, None)
9886}
9887
9888/// The identity a ballot is cast under: the persona named, else the seat
9889/// ([`whoami`]), the same name across a runner's conversations so its
9890/// record accrues to one voter.
9891#[must_use]
9892pub fn identity_or_seat(identity: Option<&str>) -> Option<String> {
9893    identity
9894        .map(str::trim)
9895        .filter(|w| !w.is_empty())
9896        .map(str::to_string)
9897        .or_else(|| Some(seat_name()))
9898}
9899
9900/// [`run`] with `VISSUE_AGENT` set to `identity`, so a ballot or a claim is
9901/// recorded under a persona's name rather than the seat's.
9902pub fn run_as(bin: &str, args: &[impl AsRef<str>], identity: Option<&str>) -> Result<()> {
9903    use std::process::{Command, Stdio};
9904    let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
9905    let mut cmd = Command::new(path);
9906    if let Some(who) = identity_or_seat(identity) {
9907        cmd.env("VISSUE_AGENT", who);
9908    }
9909    for a in args {
9910        cmd.arg(a.as_ref());
9911    }
9912    let st = cmd
9913        .stdin(Stdio::inherit())
9914        .stdout(Stdio::inherit())
9915        .stderr(Stdio::inherit())
9916        .status()?;
9917    // A child that died of a closed pipe was cut off by our own reader
9918    // going away (`ljos consensus ID | head`); that is not the habitat
9919    // refusing.
9920    #[cfg(unix)]
9921    {
9922        use std::os::unix::process::ExitStatusExt;
9923        if st.signal() == Some(libc::SIGPIPE) {
9924            return Ok(());
9925        }
9926    }
9927    if !st.success() {
9928        bail!("{bin} exited {st}");
9929    }
9930    Ok(())
9931}
9932
9933/// What a habitat printed, kept for a caller that has to hand it on. A
9934/// non-zero exit is an error carrying stderr.
9935#[derive(Debug, Clone, PartialEq, Eq)]
9936pub struct Said {
9937    pub stdout: String,
9938    pub stderr: String,
9939}
9940
9941pub fn run_captured(bin: &str, args: &[impl AsRef<str>]) -> Result<Said> {
9942    run_captured_as(bin, args, None)
9943}
9944
9945/// [`run_captured`] with `VISSUE_AGENT` set to `identity`, for a tracker
9946/// write whose output the caller has to hand on. `None` leaves the
9947/// environment as it is.
9948pub fn run_captured_as(
9949    bin: &str,
9950    args: &[impl AsRef<str>],
9951    identity: Option<&str>,
9952) -> Result<Said> {
9953    use std::process::{Command, Stdio};
9954    let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
9955    let mut cmd = Command::new(path);
9956    if let Some(who) = identity {
9957        cmd.env("VISSUE_AGENT", who);
9958    }
9959    for a in args {
9960        cmd.arg(a.as_ref());
9961    }
9962    let out = cmd
9963        .stdin(Stdio::null())
9964        .stdout(Stdio::piped())
9965        .stderr(Stdio::piped())
9966        .output()
9967        .with_context(|| format!("{bin}: could not start"))?;
9968    let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
9969    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
9970    if !out.status.success() {
9971        let why = if stderr.trim().is_empty() {
9972            stdout.trim().to_string()
9973        } else {
9974            stderr.trim().to_string()
9975        };
9976        bail!("{bin} exited {}: {why}", out.status);
9977    }
9978    Ok(Said { stdout, stderr })
9979}
9980
9981pub fn card_paths(dir: &Path) -> Vec<PathBuf> {
9982    CARD_NAMES.iter().map(|n| dir.join(n)).collect()
9983}
9984
9985/// One typed finding from an eb-stack campaign state file, flattened to
9986/// what a seat reads and remembers.
9987#[derive(Debug, Clone, PartialEq, Eq)]
9988pub struct Finding {
9989    pub id: String,
9990    pub status: String,
9991    pub class: String,
9992    pub disposition: String,
9993    pub stage: String,
9994    /// The recipe the campaign drives, as its file stem:
9995    /// `eOn-2.17.10-foss-2026.1`.
9996    pub recipe: String,
9997    /// The module whose build failed, when the evidence names one:
9998    /// `GCCcore-15.2.0`, `gettext-0.26-GCCcore-15.2.0`. A campaign fails in
9999    /// its dependencies far more often than in the recipe it drives.
10000    pub module: String,
10001    pub summary: String,
10002    /// The last error line the evidence carries, else the summary.
10003    pub error: String,
10004    /// The resolution's action, when it is resolved.
10005    pub action: String,
10006    pub changes: Vec<String>,
10007}
10008
10009/// A campaign state file: the package it builds, the target, its findings.
10010#[derive(Debug, Clone, PartialEq, Eq)]
10011pub struct Campaign {
10012    pub package: String,
10013    pub version: String,
10014    pub target: String,
10015    pub status: String,
10016    pub attempts: u64,
10017    pub findings: Vec<Finding>,
10018}
10019
10020fn recipe_stem(path: &str) -> String {
10021    Path::new(path)
10022        .file_stem()
10023        .map(|s| s.to_string_lossy().into_owned())
10024        .unwrap_or_else(|| path.to_string())
10025}
10026
10027/// The line a reader recognises the failure by: the last line of the
10028/// evidence that names an error, else the summary.
10029fn error_line(evidence: &str, summary: &str) -> String {
10030    let lower = |l: &str| l.to_ascii_lowercase();
10031    evidence
10032        .lines()
10033        .map(str::trim)
10034        .filter(|l| !l.is_empty())
10035        .filter(|l| {
10036            let l = lower(l);
10037            l.contains("error") || l.contains("fatal") || l.contains("failed")
10038        })
10039        .rfind(|l| !l.starts_with("srun:"))
10040        .map(str::to_string)
10041        .unwrap_or_else(|| summary.to_string())
10042}
10043
10044/// The module EasyBuild was installing when it stopped: `ERROR:
10045/// Installation of X.eb failed` names it; else the last `== building and
10046/// installing NAME/VERSION...` line does.
10047fn failed_module(evidence: &str) -> Option<String> {
10048    let installation = evidence.lines().rev().find_map(|l| {
10049        let rest = l.split("Installation of ").nth(1)?;
10050        let eb = rest.split(".eb failed").next()?;
10051        // `.eb` is already off; a stem call here would take a version's
10052        // last component for an extension.
10053        let name = eb.rsplit('/').next()?;
10054        (!name.is_empty() && !name.contains(' ')).then(|| name.to_string())
10055    });
10056    installation.or_else(|| {
10057        evidence.lines().rev().find_map(|l| {
10058            let rest = l.trim().strip_prefix("== building and installing ")?;
10059            let name = rest.trim_end_matches('.').trim();
10060            (!name.is_empty()).then(|| name.replacen('/', "-", 1))
10061        })
10062    })
10063}
10064
10065/// What EasyBuild said after naming the module, else the whole line.
10066fn error_reason(error: &str) -> &str {
10067    error
10068        .split(".eb failed: ")
10069        .nth(1)
10070        .unwrap_or(error)
10071        .trim_start_matches("ERROR: ")
10072}
10073
10074fn text_of(v: &Value, key: &str) -> String {
10075    v.get(key)
10076        .and_then(Value::as_str)
10077        .unwrap_or_default()
10078        .to_string()
10079}
10080
10081/// Read an eb-stack campaign state (`campaign.json`).
10082///
10083/// # Errors
10084///
10085/// The file is missing, not JSON, or not a campaign state.
10086pub fn read_campaign(state: &Path) -> Result<Campaign> {
10087    let text = std::fs::read_to_string(state)
10088        .with_context(|| format!("findings: cannot read {}", state.display()))?;
10089    let doc: Value = serde_json::from_str(&text)
10090        .with_context(|| format!("findings: {} is not JSON", state.display()))?;
10091    let rows = doc
10092        .get("findings")
10093        .and_then(Value::as_array)
10094        .with_context(|| format!("findings: {} has no findings list", state.display()))?;
10095    let findings = rows
10096        .iter()
10097        .map(|f| {
10098            let summary = text_of(f, "summary");
10099            let resolution = f.get("resolution");
10100            let evidence = text_of(f, "evidence");
10101            Finding {
10102                id: text_of(f, "id"),
10103                status: text_of(f, "status"),
10104                class: text_of(f, "class"),
10105                disposition: text_of(f, "disposition"),
10106                stage: text_of(f, "stage"),
10107                recipe: recipe_stem(&text_of(f, "recipe")),
10108                module: failed_module(&evidence).unwrap_or_default(),
10109                error: error_line(&evidence, &summary),
10110                summary,
10111                action: resolution.map(|r| text_of(r, "action")).unwrap_or_default(),
10112                changes: resolution
10113                    .and_then(|r| r.get("changes"))
10114                    .and_then(Value::as_array)
10115                    .map(|c| {
10116                        c.iter()
10117                            .filter_map(Value::as_str)
10118                            .map(str::to_string)
10119                            .collect()
10120                    })
10121                    .unwrap_or_default(),
10122            }
10123        })
10124        .collect();
10125    Ok(Campaign {
10126        package: text_of(&doc, "package"),
10127        version: text_of(&doc, "version"),
10128        target: text_of(&doc, "target"),
10129        status: text_of(&doc, "status"),
10130        attempts: doc.get("attempts").and_then(Value::as_u64).unwrap_or(0),
10131        findings,
10132    })
10133}
10134
10135/// The automatic resolution a campaign writes when a later attempt got
10136/// past the stage: not a lesson, nothing was learned about the recipe.
10137fn superseded_by_retry(f: &Finding) -> bool {
10138    f.status == "superseded" || f.action.contains("superseded this finding")
10139}
10140
10141/// At most `n` words, with the pack's sentence marks taken out so the
10142/// lesson stays two sentences.
10143fn clip_words(text: &str, n: usize) -> String {
10144    // A stop inside a word (`scc.h`, `2.17.10`) is not a sentence mark; an
10145    // ellipsis (`'make ...'`) is EasyBuild eliding a command and goes.
10146    let text = text.replace(" ...", "").replace("...", "");
10147    let chars: Vec<char> = text.chars().collect();
10148    let mut flat = String::with_capacity(text.len());
10149    for (i, &c) in chars.iter().enumerate() {
10150        let ends_word = chars.get(i + 1).is_none_or(|n| n.is_whitespace());
10151        flat.push(match c {
10152            '.' | '!' | '?' | ';' if ends_word => ',',
10153            '\n' | '\t' => ' ',
10154            c => c,
10155        });
10156    }
10157    let words: Vec<&str> = flat.split_whitespace().collect();
10158    let mut out = words[..words.len().min(n)].join(" ");
10159    while out.ends_with([',', ':', ' ']) {
10160        out.pop();
10161    }
10162    out
10163}
10164
10165/// The lesson a finding leaves: what failed where, then the fix, or that a
10166/// later attempt got past it. Two short sentences; the pack refuses more,
10167/// and refuses hard prose.
10168#[must_use]
10169pub fn finding_lesson(campaign: &Campaign, f: &Finding) -> String {
10170    let what = clip_words(error_reason(&f.error), 10);
10171    let subject = if f.module.is_empty() {
10172        f.recipe.clone()
10173    } else if f.module == f.recipe {
10174        f.module.clone()
10175    } else {
10176        format!("{} for {}", f.module, f.recipe)
10177    };
10178    let mut first = format!(
10179        "{subject} on {}: {} failed in the {} step",
10180        campaign.target, f.class, f.stage
10181    );
10182    if !what.is_empty() && what != f.summary {
10183        first.push_str(&format!(" with {what}"));
10184    }
10185    first.push('.');
10186    if superseded_by_retry(f) {
10187        return format!("{first} A later attempt got past it.");
10188    }
10189    let mut fix = clip_words(&f.action, 14);
10190    if !f.changes.is_empty() {
10191        let files: Vec<String> = f
10192            .changes
10193            .iter()
10194            .map(String::as_str)
10195            .map(recipe_stem)
10196            .collect();
10197        fix.push_str(&format!(" in {}", files.join(", ")));
10198    }
10199    if fix.is_empty() {
10200        first
10201    } else {
10202        format!("{first} Fix: {fix}.")
10203    }
10204}
10205
10206/// The entities a finding's lesson is about, so a later cue on the
10207/// recipe, the package or the failure class activates it.
10208fn finding_entities(campaign: &Campaign, f: &Finding) -> Vec<String> {
10209    let mut out: Vec<String> = Vec::new();
10210    for stem in [&f.module, &f.recipe] {
10211        if stem.is_empty() || out.contains(stem) {
10212            continue;
10213        }
10214        out.push(stem.clone());
10215        if let Some(name) = stem.split('-').next() {
10216            if !name.is_empty() && name != stem && !out.iter().any(|e| e == name) {
10217                out.push(name.to_string());
10218            }
10219        }
10220    }
10221    if !campaign.package.is_empty() {
10222        out.push(campaign.package.clone());
10223    }
10224    out.push(f.class.clone());
10225    out.dedup();
10226    out
10227}
10228
10229/// One line per finding: id, status, class, stage, recipe, then the fix
10230/// or the summary.
10231#[must_use]
10232pub fn format_findings(campaign: &Campaign) -> String {
10233    let mut out = format!(
10234        "{} {} on {}: {} after {} attempt{}, {} finding{}\n",
10235        campaign.package,
10236        campaign.version,
10237        campaign.target,
10238        campaign.status,
10239        campaign.attempts,
10240        if campaign.attempts == 1 { "" } else { "s" },
10241        campaign.findings.len(),
10242        if campaign.findings.len() == 1 {
10243            ""
10244        } else {
10245            "s"
10246        },
10247    );
10248    for f in &campaign.findings {
10249        let tail = if f.action.is_empty() {
10250            f.summary.clone()
10251        } else {
10252            format!("fix: {}", f.action)
10253        };
10254        out.push_str(&format!(
10255            "{}\t{}\t{}/{}\t{}\t{}\t{}\n",
10256            f.id,
10257            f.status,
10258            f.class,
10259            f.disposition,
10260            f.stage,
10261            if f.module.is_empty() {
10262                &f.recipe
10263            } else {
10264                &f.module
10265            },
10266            tail
10267        ));
10268    }
10269    out
10270}
10271
10272/// What `remember_findings` did with one finding.
10273#[derive(Debug, Clone, PartialEq, Eq)]
10274pub struct Remembered {
10275    pub id: String,
10276    pub lesson: String,
10277    /// The pack's answer: the atom id, `held` when the pack already had
10278    /// it, `skipped` for a retry supersession, else the refusal.
10279    pub result: String,
10280}
10281
10282/// Write one lesson per finding a person or a seat resolved (every
10283/// finding with `all`), cite the state file on the issue when one is
10284/// named, and say what happened to each.
10285///
10286/// # Errors
10287///
10288/// The state cannot be read, or the pack is down. A refusal of one lesson
10289/// is reported in its row, not returned.
10290pub fn remember_findings(state: &Path, issue: Option<&str>, all: bool) -> Result<Vec<Remembered>> {
10291    let campaign = read_campaign(state)?;
10292    let client = pack()?;
10293    let workspace = client.workspace();
10294    let mut out = Vec::new();
10295    for f in &campaign.findings {
10296        if !all && superseded_by_retry(f) {
10297            out.push(Remembered {
10298                id: f.id.clone(),
10299                lesson: String::new(),
10300                result: "skipped: a later attempt got past it, nothing was learned".into(),
10301            });
10302            continue;
10303        }
10304        if !all && f.status != "resolved" {
10305            out.push(Remembered {
10306                id: f.id.clone(),
10307                lesson: String::new(),
10308                result: format!("skipped: {}", f.status),
10309            });
10310            continue;
10311        }
10312        let lesson = finding_lesson(&campaign, f);
10313        let mut atom = atom_body("lesson", &lesson, &workspace);
10314        add_entities(&mut atom, finding_entities(&campaign, f));
10315        let result = match client.post_atom(&atom) {
10316            Ok(body) => format!(
10317                "{}{}",
10318                body["id"].as_str().unwrap_or("written"),
10319                revision_note(&body)
10320            ),
10321            Err(e) => format!("refused: {e}"),
10322        };
10323        out.push(Remembered {
10324            id: f.id.clone(),
10325            lesson,
10326            result,
10327        });
10328    }
10329    if let Some(issue) = issue.map(str::trim).filter(|i| !i.is_empty()) {
10330        let name = format!(
10331            "{} {} campaign state on {}, {} after {} attempts",
10332            campaign.package, campaign.version, campaign.target, campaign.status, campaign.attempts
10333        );
10334        let seat = seat_name();
10335        // The same state file under the same name is the same deed: a
10336        // second run finds it frozen, and the refusal names the accession.
10337        let said = match run_captured(
10338            "deedar",
10339            &[
10340                "create",
10341                "file",
10342                "--name",
10343                &name,
10344                "--path",
10345                &state.display().to_string(),
10346                "--agent",
10347                &seat,
10348            ],
10349        ) {
10350            Ok(said) => said.stdout,
10351            Err(e) if e.to_string().contains("deed frozen") => e.to_string(),
10352            Err(e) => return Err(e),
10353        };
10354        // `deedar create` prints `id=deed-...` on its first line; an older
10355        // build printed the accession bare.
10356        let accession = said
10357            .split_whitespace()
10358            .find_map(|w| {
10359                let at = w.find("deed-")?;
10360                let tail = &w[at..];
10361                let end = tail
10362                    .find(|c: char| !c.is_ascii_alphanumeric() && c != '-')
10363                    .unwrap_or(tail.len());
10364                Some(tail[..end].to_string())
10365            })
10366            .filter(|a| a.len() > "deed-".len())
10367            .context("findings: deedar create printed no accession")?;
10368        run_captured("vissue", &["deed", issue, "--add", &accession])?;
10369        let _ = persist_tracker(issue, "cited the campaign state");
10370        out.push(Remembered {
10371            id: "state".into(),
10372            lesson: name,
10373            result: format!("cited on {issue} as {accession}"),
10374        });
10375    }
10376    Ok(out)
10377}
10378
10379#[must_use]
10380pub fn format_remembered(rows: &[Remembered]) -> String {
10381    rows.iter()
10382        .map(|r| {
10383            if r.lesson.is_empty() {
10384                format!("{}\t{}\n", r.id, r.result)
10385            } else {
10386                format!("{}\t{}\n\t{}\n", r.id, r.result, r.lesson)
10387            }
10388        })
10389        .collect()
10390}
10391
10392/// One module of a bump bundle as the tracker will hold it.
10393#[derive(Debug, Clone, PartialEq, Eq)]
10394pub struct BumpRow {
10395    /// The issue id, the same on every run: a hash of the module and the
10396    /// generation under the project.
10397    pub id: String,
10398    /// The module as EasyBuild names it: `CMake-4.2.1-GCCcore-15.2.0`.
10399    pub module: String,
10400    /// The recipe path the lock names, when it does.
10401    pub recipe: String,
10402    /// The modules this one is built after, by issue id.
10403    pub blockers: Vec<String>,
10404    /// What this run did: `made`, `held` (it existed), or `would make`.
10405    pub result: String,
10406}
10407
10408/// The stem of an EasyBuild module: `name-version[-toolchain-version]`.
10409fn module_stem(name: &str, version: &str, toolchain: Option<(&str, &str)>) -> String {
10410    match toolchain {
10411        Some((tn, tv)) if !tn.is_empty() && tn != "system" => {
10412            format!("{name}-{version}-{tn}-{tv}")
10413        }
10414        _ => format!("{name}-{version}"),
10415    }
10416}
10417
10418/// A deterministic issue id for a module of a generation: the project,
10419/// then eight base-36 digits of the module and generation hashed.
10420#[must_use]
10421pub fn bump_issue_id(project: &str, module: &str, generation: &str) -> String {
10422    let hex = work_id(&format!("bump:{module}:{generation}"));
10423    let mut n = u128::from_str_radix(&hex[..24], 16).unwrap_or(0);
10424    const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
10425    let mut out = Vec::new();
10426    for _ in 0..8 {
10427        out.push(DIGITS[(n % 36) as usize]);
10428        n /= 36;
10429    }
10430    format!("{project}-{}", String::from_utf8(out).unwrap_or_default())
10431}
10432
10433/// The name behind a CycloneDX purl `pkg:generic/NAME@==VERSION`.
10434fn purl_name(purl: &str) -> String {
10435    purl.rsplit('/')
10436        .next()
10437        .unwrap_or(purl)
10438        .split('@')
10439        .next()
10440        .unwrap_or(purl)
10441        .to_string()
10442}
10443
10444/// The plan a bundle implies for the tracker: one row per module the lock
10445/// builds, blockers along the SBOM's dependency edges. Nothing is written.
10446///
10447/// # Errors
10448///
10449/// The bundle lacks `locks/default.lock.json` or `package.sbom.cdx.json`,
10450/// or either is not what eb-stack writes.
10451pub fn bump_rows(
10452    bundle: &Path,
10453    project: &str,
10454    generation: Option<&str>,
10455) -> Result<(String, Vec<BumpRow>)> {
10456    let lock_path = bundle.join("locks").join("default.lock.json");
10457    let sbom_path = bundle.join("package.sbom.cdx.json");
10458    let lock: Value = serde_json::from_str(
10459        &std::fs::read_to_string(&lock_path)
10460            .with_context(|| format!("bump-plan: cannot read {}", lock_path.display()))?,
10461    )
10462    .with_context(|| format!("bump-plan: {} is not JSON", lock_path.display()))?;
10463    let sbom: Value = serde_json::from_str(
10464        &std::fs::read_to_string(&sbom_path)
10465            .with_context(|| format!("bump-plan: cannot read {}", sbom_path.display()))?,
10466    )
10467    .with_context(|| format!("bump-plan: {} is not JSON", sbom_path.display()))?;
10468    let tc = &lock["toolchain"];
10469    let generation = generation.map(str::to_string).unwrap_or_else(|| {
10470        format!(
10471            "{}/{}",
10472            tc["name"].as_str().unwrap_or("system"),
10473            tc["version"].as_str().unwrap_or("")
10474        )
10475        .trim_end_matches('/')
10476        .to_string()
10477    });
10478    // Every module the lock names, the root package first.
10479    let mut modules: Vec<(String, String, String)> = Vec::new(); // name, stem, recipe
10480    let root_name = lock["package"].as_str().unwrap_or("").to_string();
10481    let root_stem = module_stem(
10482        &root_name,
10483        lock["version"].as_str().unwrap_or(""),
10484        Some((
10485            tc["name"].as_str().unwrap_or(""),
10486            tc["version"].as_str().unwrap_or(""),
10487        )),
10488    ) + lock["versionsuffix"].as_str().unwrap_or("");
10489    modules.push((root_name.clone(), root_stem, String::new()));
10490    // `build` on a lock entry says whether it is a build dependency, not
10491    // whether it is built: every entry is a module the generation needs.
10492    for dep in lock["dependencies"].as_array().into_iter().flatten() {
10493        let name = dep["name"].as_str().unwrap_or("").to_string();
10494        let dtc = &dep["toolchain"];
10495        let stem = module_stem(
10496            &name,
10497            dep["version"].as_str().unwrap_or(""),
10498            Some((
10499                dtc["name"].as_str().unwrap_or(""),
10500                dtc["version"].as_str().unwrap_or(""),
10501            )),
10502        );
10503        let recipe = dep["easyconfig_path"].as_str().unwrap_or("").to_string();
10504        if !name.is_empty() && !modules.iter().any(|(n, _, _)| *n == name) {
10505            modules.push((name, stem, recipe));
10506        }
10507    }
10508    let id_of = |name: &str| -> Option<String> {
10509        modules
10510            .iter()
10511            .find(|(n, _, _)| n == name)
10512            .map(|(_, stem, _)| bump_issue_id(project, stem, &generation))
10513    };
10514    // Edges from the SBOM, by name; only edges between modules the lock builds.
10515    let mut edges: std::collections::BTreeMap<String, Vec<String>> = Default::default();
10516    for d in sbom["dependencies"].as_array().into_iter().flatten() {
10517        let from = purl_name(d["ref"].as_str().unwrap_or(""));
10518        for on in d["dependsOn"].as_array().into_iter().flatten() {
10519            let to = purl_name(on.as_str().unwrap_or(""));
10520            if let Some(id) = id_of(&to) {
10521                edges.entry(from.clone()).or_default().push(id);
10522            }
10523        }
10524    }
10525    let rows = modules
10526        .iter()
10527        .map(|(name, stem, recipe)| BumpRow {
10528            id: bump_issue_id(project, stem, &generation),
10529            module: stem.clone(),
10530            recipe: recipe.clone(),
10531            blockers: edges.get(name).cloned().unwrap_or_default(),
10532            result: "would make".into(),
10533        })
10534        .collect();
10535    Ok((generation, rows))
10536}
10537
10538/// Put a bundle's modules on the tracker: one child issue per module under
10539/// `parent`, blockers along the dependency edges, ids the same on every run
10540/// so a rerun holds what exists and adds what is missing. `vissue ready`
10541/// then lists the modules a seat can build now, and a sitting refuses the
10542/// rest until their blockers close.
10543///
10544/// # Errors
10545///
10546/// The bundle is not readable, or the tracker refuses a create or an edge.
10547pub fn bump_plan(
10548    bundle: &Path,
10549    project: &str,
10550    parent: &str,
10551    generation: Option<&str>,
10552    dry: bool,
10553) -> Result<(String, Vec<BumpRow>)> {
10554    let (generation, mut rows) = bump_rows(bundle, project, generation)?;
10555    if dry {
10556        return Ok((generation, rows));
10557    }
10558    for row in &mut rows {
10559        let exists = tracker_show_json(&row.id).is_ok();
10560        if exists {
10561            row.result = "held".into();
10562        } else {
10563            let title = format!("Bump {} onto {generation}", row.module);
10564            let body = if row.recipe.is_empty() {
10565                format!("The bundle at {} names this module. Ladder: recipe check, package bump, lint, then the campaign.", bundle.display())
10566            } else {
10567                format!("Recipe {} in the bundle at {}. Ladder: recipe check, package bump, lint, then the campaign.", row.recipe, bundle.display())
10568            };
10569            run_captured(
10570                "vissue",
10571                &[
10572                    "create", "-p", project, "--id", &row.id, "--parent", parent, "-t", "task",
10573                    "--quiet", "--body", &body, &title,
10574                ],
10575            )
10576            .with_context(|| format!("bump-plan: create {} ({})", row.id, row.module))?;
10577            row.result = "made".into();
10578        }
10579    }
10580    // Edges after every node exists; an edge already held is not an error.
10581    for row in &rows {
10582        let held: Vec<String> = tracker_show_json(&row.id)
10583            .ok()
10584            .and_then(|v| v["blocked_by"].as_array().cloned())
10585            .into_iter()
10586            .flatten()
10587            .filter_map(|v| v.as_str().map(str::to_string))
10588            .collect();
10589        for dep in &row.blockers {
10590            if held.iter().any(|h| h == dep) {
10591                continue;
10592            }
10593            run_captured("vissue", &["update", &row.id, "--block", dep])
10594                .with_context(|| format!("bump-plan: {} --block {dep}", row.id))?;
10595        }
10596    }
10597    // Every module lands in one project file; one persist carries them all.
10598    if let Some(first) = rows.first() {
10599        let _ = persist_tracker(&first.id, "planned the bump");
10600    }
10601    Ok((generation, rows))
10602}
10603
10604#[must_use]
10605pub fn format_bump_rows(generation: &str, rows: &[BumpRow]) -> String {
10606    let mut out = format!(
10607        "{} module{} onto {generation}\n",
10608        rows.len(),
10609        if rows.len() == 1 { "" } else { "s" }
10610    );
10611    for r in rows {
10612        out.push_str(&format!(
10613            "{}\t{}\t{}\tafter {}\n",
10614            r.id,
10615            r.result,
10616            r.module,
10617            if r.blockers.is_empty() {
10618                "nothing".to_string()
10619            } else {
10620                r.blockers.join(" ")
10621            }
10622        ));
10623    }
10624    out
10625}
10626
10627#[cfg(test)]
10628mod tests {
10629    /// The tests that set or read the process environment take this lock:
10630    /// cargo runs tests on threads, and one process has one environment.
10631    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
10632        static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
10633        ENV.lock().unwrap_or_else(|e| e.into_inner())
10634    }
10635
10636    /// A root that kept its tilde is the home one.
10637    #[test]
10638    fn a_tilde_tracker_root_expands_against_home() {
10639        use super::expand_leading_tilde as x;
10640        assert_eq!(x("~/vault", "/home/s"), Some("/home/s/vault".into()));
10641        assert_eq!(x("~", "/home/s/"), Some("/home/s".into()));
10642        assert_eq!(x("/abs/vault", "/home/s"), None);
10643        assert_eq!(x("~other/vault", "/home/s"), None);
10644    }
10645
10646    /// A slow pre-push hook does not hold the sitting: the push outlives the
10647    /// wait and the line says so; a quick one reports the push.
10648    #[test]
10649    fn a_slow_tracker_push_finishes_in_the_background() {
10650        let _env = env_guard();
10651        let dir = tempfile::tempdir().unwrap();
10652        let (root, remote, hooks) = (
10653            dir.path().join("work"),
10654            dir.path().join("remote.git"),
10655            dir.path().join("hooks"),
10656        );
10657        let git = |cwd: &std::path::Path, args: &[&str]| {
10658            let o = std::process::Command::new("git")
10659                .arg("-C")
10660                .arg(cwd)
10661                .args(args)
10662                .output()
10663                .unwrap();
10664            assert!(
10665                o.status.success(),
10666                "git {args:?}: {}",
10667                String::from_utf8_lossy(&o.stderr)
10668            );
10669        };
10670        std::fs::create_dir_all(root.join("Software/probe")).unwrap();
10671        std::fs::create_dir_all(&hooks).unwrap();
10672        git(
10673            dir.path(),
10674            &["init", "-q", "--bare", remote.to_str().unwrap()],
10675        );
10676        git(&root, &["init", "-q"]);
10677        for (k, v) in [
10678            ("user.email", "seat@example.invalid"),
10679            ("user.name", "seat"),
10680            ("core.hooksPath", hooks.to_str().unwrap()),
10681        ] {
10682            git(&root, &["config", k, v]);
10683        }
10684        let hook = hooks.join("pre-push");
10685        std::fs::write(&hook, "#!/bin/sh\nsleep 4\n").unwrap();
10686        use std::os::unix::fs::PermissionsExt;
10687        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
10688        let issues = root.join("Software/probe/issues.org");
10689        let heading = "* TODO [#C] Probe\n:PROPERTIES:\n:ID:         probe-c3d4\n:END:\n";
10690        std::fs::write(&issues, heading).unwrap();
10691        git(&root, &["add", "."]);
10692        git(&root, &["commit", "-q", "-m", "seed"]);
10693        git(
10694            &root,
10695            &["remote", "add", "origin", remote.to_str().unwrap()],
10696        );
10697        std::fs::write(&hook, "#!/bin/sh\nexit 0\n").unwrap();
10698        git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
10699        std::fs::write(&hook, "#!/bin/sh\nsleep 4\n").unwrap();
10700        std::env::set_var("VISSUE_ROOT", &root);
10701        std::env::set_var("VISSUE_NO_ROUTE", "1");
10702        std::env::remove_var("ISSUE_ROOT");
10703        std::env::remove_var("LJOS_TRACKER_GIT");
10704        std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "1");
10705        std::env::set_var("XDG_RUNTIME_DIR", dir.path());
10706
10707        std::fs::write(&issues, heading.replace("TODO", "STARTED")).unwrap();
10708        let started = std::time::Instant::now();
10709        let said = super::persist_tracker("probe-c3d4", "claimed");
10710        assert!(
10711            started.elapsed() < std::time::Duration::from_secs(3),
10712            "{said}"
10713        );
10714        assert!(said.contains("still running after 1s"), "{said}");
10715
10716        std::thread::sleep(std::time::Duration::from_secs(5));
10717        std::fs::write(&hook, "#!/bin/sh\nexit 0\n").unwrap();
10718        std::fs::write(&issues, heading.replace("TODO", "DONE")).unwrap();
10719        std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "10");
10720        let said = super::persist_tracker("probe-c3d4", "finished");
10721        assert!(said.contains("committed and pushed"), "{said}");
10722        for var in [
10723            "VISSUE_ROOT",
10724            "VISSUE_NO_ROUTE",
10725            "LJOS_TRACKER_PUSH_WAIT",
10726            "XDG_RUNTIME_DIR",
10727        ] {
10728            std::env::remove_var(var);
10729        }
10730    }
10731
10732    /// A tracker write reaches git: the ticket's file alone is committed, a
10733    /// clean file is left alone, and the switch turns it off.
10734    #[test]
10735    fn a_tracker_write_is_committed_alone() {
10736        let _env = env_guard();
10737        let dir = tempfile::tempdir().unwrap();
10738        let root = dir.path();
10739        let run = |args: &[&str]| {
10740            let o = std::process::Command::new("git")
10741                .arg("-C")
10742                .arg(root)
10743                .args(args)
10744                .output()
10745                .unwrap();
10746            assert!(
10747                o.status.success(),
10748                "git {args:?}: {}",
10749                String::from_utf8_lossy(&o.stderr)
10750            );
10751            String::from_utf8_lossy(&o.stdout).to_string()
10752        };
10753        run(&["init", "-q"]);
10754        run(&["config", "user.email", "seat@example.invalid"]);
10755        run(&["config", "user.name", "seat"]);
10756        run(&["config", "core.hooksPath", "/dev/null"]);
10757        std::fs::create_dir_all(root.join("Software/probe")).unwrap();
10758        let issues = root.join("Software/probe/issues.org");
10759        let heading = "* TODO [#C] Probe\n:PROPERTIES:\n:ID:         probe-a1b2\n:END:\n";
10760        std::fs::write(&issues, heading).unwrap();
10761        std::fs::write(root.join("other.org"), "one\n").unwrap();
10762        run(&["add", "."]);
10763        run(&["commit", "-q", "-m", "seed"]);
10764        std::env::set_var("VISSUE_ROOT", root);
10765        std::env::set_var("VISSUE_NO_ROUTE", "1");
10766        std::env::remove_var("ISSUE_ROOT");
10767        std::env::set_var("LJOS_TRACKER_GIT", "commit");
10768        assert!(super::persist_tracker("probe-a1b2", "claimed").contains("nothing to commit"));
10769
10770        std::fs::write(&issues, heading.replace("TODO", "STARTED")).unwrap();
10771        std::fs::write(root.join("other.org"), "two\n").unwrap();
10772        run(&["add", "other.org"]);
10773        let said = super::persist_tracker("probe-a1b2", "claimed");
10774        assert!(
10775            said.contains("committed chore(issues): probe-a1b2 claimed"),
10776            "{said}"
10777        );
10778        assert_eq!(
10779            run(&["log", "-1", "--format=%s"]).trim(),
10780            "chore(issues): probe-a1b2 claimed"
10781        );
10782        // Another seat's staged file is not swept into the commit.
10783        assert_eq!(
10784            run(&["diff", "--cached", "--name-only"]).trim(),
10785            "other.org"
10786        );
10787
10788        std::fs::write(&issues, heading.replace("TODO", "DONE")).unwrap();
10789        std::env::set_var("LJOS_TRACKER_GIT", "off");
10790        assert!(super::persist_tracker("probe-a1b2", "finished").contains("off"));
10791        for var in ["VISSUE_ROOT", "VISSUE_NO_ROUTE", "LJOS_TRACKER_GIT"] {
10792            std::env::remove_var(var);
10793        }
10794    }
10795
10796    /// A scratch tracker with no remote still reports the commit: the
10797    /// default path pushes, and a refused push is a suffix, not silence.
10798    #[test]
10799    fn a_tracker_commit_with_no_remote_still_reports_the_commit() {
10800        let _env = env_guard();
10801        let dir = tempfile::tempdir().unwrap();
10802        let root = dir.path();
10803        let run = |args: &[&str]| {
10804            let o = std::process::Command::new("git")
10805                .arg("-C")
10806                .arg(root)
10807                .args(args)
10808                .output()
10809                .unwrap();
10810            assert!(
10811                o.status.success(),
10812                "git {args:?}: {}",
10813                String::from_utf8_lossy(&o.stderr)
10814            );
10815            String::from_utf8_lossy(&o.stdout).to_string()
10816        };
10817        run(&["init", "-q"]);
10818        run(&["config", "user.email", "seat@example.invalid"]);
10819        run(&["config", "user.name", "seat"]);
10820        run(&["config", "core.hooksPath", "/dev/null"]);
10821        std::fs::create_dir_all(root.join("Software/probe")).unwrap();
10822        let issues = root.join("Software/probe/issues.org");
10823        let heading = "* TODO [#C] Probe\n:PROPERTIES:\n:ID:         probe-a1b2\n:END:\n";
10824        std::fs::write(&issues, heading).unwrap();
10825        run(&["add", "."]);
10826        run(&["commit", "-q", "-m", "seed"]);
10827        std::fs::write(&issues, heading.replace("TODO", "STARTED")).unwrap();
10828        std::env::set_var("VISSUE_ROOT", root);
10829        std::env::set_var("VISSUE_NO_ROUTE", "1");
10830        std::env::remove_var("ISSUE_ROOT");
10831        std::env::remove_var("LJOS_TRACKER_GIT");
10832        let said = super::persist_tracker("probe-a1b2", "claimed");
10833        assert!(
10834            said.contains("tracker git: committed chore(issues): probe-a1b2 claimed"),
10835            "{said}"
10836        );
10837        assert!(
10838            said.contains("push refused") || said.contains("not pushed"),
10839            "a missing remote must still name the commit: {said}"
10840        );
10841        assert_eq!(
10842            run(&["log", "-1", "--format=%s"]).trim(),
10843            "chore(issues): probe-a1b2 claimed"
10844        );
10845        for var in ["VISSUE_ROOT", "VISSUE_NO_ROUTE", "LJOS_TRACKER_GIT"] {
10846            std::env::remove_var(var);
10847        }
10848    }
10849
10850    /// A fresh host's missing claim graph is a first sitting, not a fault;
10851    /// any other claimdag refusal still is.
10852    #[test]
10853    fn a_claim_graph_nobody_made_yet_is_not_a_fault() {
10854        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";
10855        assert_eq!(
10856            super::claim_graph_absent(fresh),
10857            Some("/h/claims".to_string())
10858        );
10859        assert_eq!(
10860            super::claim_graph_absent("claimdag exited exit status: 1: work.bin is corrupt"),
10861            None
10862        );
10863        assert_eq!(
10864            super::claim_graph_absent("no work graph at /h/claims: permission denied"),
10865            None
10866        );
10867    }
10868
10869    /// The tracker row names the root and fails one other seats cannot see.
10870    #[test]
10871    fn tracker_row_names_the_root_and_refuses_a_private_one() {
10872        let dir = tempfile::tempdir().unwrap();
10873        std::fs::create_dir(dir.path().join("Software")).unwrap();
10874        let id = |root: &str| format!("vissue 0.16.2\nprotocol: 1\nroot={root}\nprefix=Software\n");
10875        let root = dir.path().display().to_string();
10876
10877        let (state, ok) = super::tracker_state(&id(&root), "VISSUE_ROOT=x");
10878        assert!(ok, "{state}");
10879        assert!(state.contains(&format!("root={root}")), "{state}");
10880        assert!(state.contains("from VISSUE_ROOT=x"), "{state}");
10881
10882        let (state, ok) = super::tracker_state(&id("~/Git/vault"), "VISSUE_ROOT=~/Git/vault");
10883        assert!(!ok);
10884        assert!(state.contains("relative root"), "{state}");
10885
10886        let missing = dir.path().join("gone").display().to_string();
10887        assert!(!super::tracker_state(&id(&missing), "cwd").1);
10888
10889        std::fs::remove_dir(dir.path().join("Software")).unwrap();
10890        let (state, ok) = super::tracker_state(&id(&root), "cwd");
10891        assert!(!ok);
10892        assert!(state.contains("no prefix directory"), "{state}");
10893
10894        assert!(!super::tracker_state("vissue 0.16.1\n", "cwd").1);
10895    }
10896
10897    fn git_scratch(root: &std::path::Path) {
10898        let run = |args: &[&str]| {
10899            let o = std::process::Command::new("git")
10900                .arg("-C")
10901                .arg(root)
10902                .args(args)
10903                .output()
10904                .unwrap();
10905            assert!(
10906                o.status.success(),
10907                "git {args:?}: {}",
10908                String::from_utf8_lossy(&o.stderr)
10909            );
10910        };
10911        run(&["init", "-q"]);
10912        run(&["config", "user.email", "seat@example.invalid"]);
10913        run(&["config", "user.name", "seat"]);
10914        run(&["config", "core.hooksPath", "/dev/null"]);
10915    }
10916
10917    /// Two remotes of one tracker with different heads fail the row, and
10918    /// agreeing again clears it.
10919    #[test]
10920    fn tracker_row_fails_when_two_remotes_disagree() {
10921        let _env = env_guard();
10922        let dir = tempfile::tempdir().unwrap();
10923        let root = dir.path().join("work");
10924        std::fs::create_dir_all(root.join("Software")).unwrap();
10925        let git = |cwd: &std::path::Path, args: &[&str]| {
10926            let o = std::process::Command::new("git")
10927                .arg("-C")
10928                .arg(cwd)
10929                .args(args)
10930                .output()
10931                .unwrap();
10932            assert!(
10933                o.status.success(),
10934                "git {args:?}: {}",
10935                String::from_utf8_lossy(&o.stderr)
10936            );
10937        };
10938        for bare in ["origin.git", "mirror.git"] {
10939            git(dir.path(), &["init", "-q", "--bare", bare]);
10940        }
10941        git_scratch(&root);
10942        std::fs::write(root.join("Software/.keep"), "").unwrap();
10943        git(&root, &["add", "."]);
10944        git(&root, &["commit", "-q", "-m", "seed"]);
10945        for name in ["origin", "mirror"] {
10946            let url = dir.path().join(format!("{name}.git"));
10947            git(&root, &["remote", "add", name, url.to_str().unwrap()]);
10948            git(&root, &["push", "-q", name, "HEAD:refs/heads/main"]);
10949        }
10950        git(&root, &["branch", "-q", "-M", "main"]);
10951        git(&root, &["fetch", "-q", "--all"]);
10952        git(&root, &["branch", "-q", "-u", "origin/main"]);
10953        let (state, ok) = super::tracker_git_drift(&root).unwrap();
10954        assert!(ok, "{state}");
10955        assert_eq!(
10956            super::tracker_mirrors(&root, "origin/main").unwrap(),
10957            vec![("mirror".to_string(), "main".to_string())],
10958            "a tracker push reaches the mirror too"
10959        );
10960
10961        std::fs::write(root.join("Software/.keep"), "one side\n").unwrap();
10962        git(&root, &["commit", "-qam", "only origin"]);
10963        git(&root, &["push", "-q", "origin", "main"]);
10964        git(&root, &["fetch", "-q", "--all"]);
10965        let (state, ok) = super::tracker_git_drift(&root).unwrap();
10966        assert!(!ok, "{state}");
10967        assert!(
10968            state.contains("mirror/main differs from origin/main"),
10969            "{state}"
10970        );
10971
10972        git(&root, &["push", "-q", "mirror", "main"]);
10973        git(&root, &["fetch", "-q", "--all"]);
10974        let (state, ok) = super::tracker_git_drift(&root).unwrap();
10975        assert!(ok, "{state}");
10976    }
10977
10978    /// The tracker row names how many commits origin lacks, and fails when
10979    /// they have sat through the push wait or the last push was refused.
10980    #[test]
10981    fn tracker_row_fails_when_origin_never_got_the_commits() {
10982        let _env = env_guard();
10983        let dir = tempfile::tempdir().unwrap();
10984        let (root, remote) = (dir.path().join("work"), dir.path().join("remote.git"));
10985        std::fs::create_dir_all(root.join("Software")).unwrap();
10986        let git = |cwd: &std::path::Path, args: &[&str]| {
10987            let o = std::process::Command::new("git")
10988                .arg("-C")
10989                .arg(cwd)
10990                .args(args)
10991                .output()
10992                .unwrap();
10993            assert!(
10994                o.status.success(),
10995                "git {args:?}: {}",
10996                String::from_utf8_lossy(&o.stderr)
10997            );
10998        };
10999        git(
11000            dir.path(),
11001            &["init", "-q", "--bare", remote.to_str().unwrap()],
11002        );
11003        git_scratch(&root);
11004        std::fs::write(root.join("Software/.keep"), "").unwrap();
11005        git(&root, &["add", "."]);
11006        git(&root, &["commit", "-q", "-m", "seed"]);
11007        git(
11008            &root,
11009            &["remote", "add", "origin", remote.to_str().unwrap()],
11010        );
11011        git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
11012
11013        let id = |r: &str| format!("vissue 0.16.2\nprotocol: 1\nroot={r}\nprefix=Software\n");
11014        let root_s = root.display().to_string();
11015        std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "5");
11016        std::env::set_var("XDG_RUNTIME_DIR", dir.path());
11017
11018        let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
11019        assert!(ok, "{state}");
11020        assert!(state.contains("0 unpushed"), "{state}");
11021
11022        std::fs::write(root.join("Software/.keep"), "local\n").unwrap();
11023        git(&root, &["add", "."]);
11024        git(&root, &["commit", "-q", "-m", "ahead"]);
11025        let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
11026        assert!(ok, "a commit younger than the wait stays healthy: {state}");
11027        assert!(state.contains("1 unpushed"), "{state}");
11028
11029        std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "0");
11030        let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
11031        assert!(!ok, "{state}");
11032        assert!(state.contains("1 unpushed"), "{state}");
11033
11034        let mut dead = std::process::Command::new("true").spawn().unwrap();
11035        let dead_pid = dead.id();
11036        let _ = dead.wait();
11037        let logs = dir.path().join("ljos");
11038        std::fs::create_dir_all(&logs).unwrap();
11039        std::fs::write(
11040            logs.join(format!("tracker-push-{dead_pid}.log")),
11041            "remote: pre-push hook declined\nerror: failed to push some refs\n",
11042        )
11043        .unwrap();
11044        let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
11045        assert!(!ok, "{state}");
11046        assert!(state.contains("1 unpushed"), "{state}");
11047        assert!(
11048            state.contains("last push refused: remote: pre-push hook declined"),
11049            "{state}"
11050        );
11051
11052        for var in ["LJOS_TRACKER_PUSH_WAIT", "XDG_RUNTIME_DIR"] {
11053            std::env::remove_var(var);
11054        }
11055    }
11056
11057    #[test]
11058    fn tracker_row_stays_healthy_while_a_background_push_runs() {
11059        let _env = env_guard();
11060        let dir = tempfile::tempdir().unwrap();
11061        let (root, remote) = (dir.path().join("work"), dir.path().join("remote.git"));
11062        std::fs::create_dir_all(root.join("Software")).unwrap();
11063        let git = |cwd: &std::path::Path, args: &[&str]| {
11064            let o = std::process::Command::new("git")
11065                .arg("-C")
11066                .arg(cwd)
11067                .args(args)
11068                .output()
11069                .unwrap();
11070            assert!(
11071                o.status.success(),
11072                "git {args:?}: {}",
11073                String::from_utf8_lossy(&o.stderr)
11074            );
11075        };
11076        git(
11077            dir.path(),
11078            &["init", "-q", "--bare", remote.to_str().unwrap()],
11079        );
11080        git_scratch(&root);
11081        std::fs::write(root.join("Software/.keep"), "").unwrap();
11082        git(&root, &["add", "."]);
11083        git(&root, &["commit", "-q", "-m", "seed"]);
11084        git(
11085            &root,
11086            &["remote", "add", "origin", remote.to_str().unwrap()],
11087        );
11088        git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
11089        std::fs::write(root.join("Software/.keep"), "local\n").unwrap();
11090        git(&root, &["add", "."]);
11091        git(&root, &["commit", "-q", "-m", "ahead"]);
11092
11093        let mut sleeper = std::process::Command::new("sleep")
11094            .arg("8")
11095            .spawn()
11096            .unwrap();
11097        let pid = sleeper.id();
11098        let logs = dir.path().join("ljos");
11099        std::fs::create_dir_all(&logs).unwrap();
11100        std::fs::write(logs.join(format!("tracker-push-{pid}.log")), "").unwrap();
11101        std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "0");
11102        std::env::set_var("XDG_RUNTIME_DIR", dir.path());
11103        let id = format!(
11104            "vissue 0.16.2\nprotocol: 1\nroot={}\nprefix=Software\n",
11105            root.display()
11106        );
11107        let (state, ok) = super::tracker_state(&id, "VISSUE_ROOT=x");
11108        let _ = sleeper.kill();
11109        let _ = sleeper.wait();
11110        assert!(ok, "{state}");
11111        assert!(state.contains("1 unpushed; push still running"), "{state}");
11112        for var in ["LJOS_TRACKER_PUSH_WAIT", "XDG_RUNTIME_DIR"] {
11113            std::env::remove_var(var);
11114        }
11115    }
11116
11117    #[test]
11118    fn a_session_id_occupies_not_the_product_name_on_the_box() {
11119        let _g = env_guard();
11120        unsafe {
11121            std::env::remove_var("VISSUE_AGENT");
11122            std::env::set_var("LJOS_SEAT", "runner-x");
11123            std::env::set_var("GROK_SESSION_ID", "01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11124        }
11125        let holder = resolve_assignee(None);
11126        assert_eq!(
11127            holder, "01a09b25-ffe9-7972-881a-3cee2ea6efd6",
11128            "the session is the occupancy, not a prefix and not the seat"
11129        );
11130        assert_eq!(resolve_assignee(Some("seat")), holder);
11131        assert_eq!(
11132            resolve_assignee(Some("runner-x")),
11133            holder,
11134            "the process naming itself is omitted"
11135        );
11136        assert_eq!(resolve_assignee(Some("alice")), "alice");
11137        assert_eq!(seat_name(), "runner-x");
11138        unsafe {
11139            std::env::remove_var("GROK_SESSION_ID");
11140            std::env::remove_var("LJOS_SEAT");
11141        }
11142    }
11143
11144    #[test]
11145    fn two_session_ids_that_share_a_prefix_occupy_different_slots() {
11146        let _g = env_guard();
11147        unsafe {
11148            std::env::remove_var("LJOS_SEAT");
11149            std::env::remove_var("VISSUE_AGENT");
11150            std::env::set_var("GROK_SESSION_ID", "01a09b25-aaaa-7972-881a-3cee2ea6efd6");
11151        }
11152        let a = resolve_assignee(None);
11153        unsafe {
11154            std::env::set_var("GROK_SESSION_ID", "01a09b25-bbbb-7972-881a-3cee2ea6efd6");
11155        }
11156        let b = resolve_assignee(None);
11157        assert_ne!(
11158            a, b,
11159            "a shared eight-character prefix is not one conversation"
11160        );
11161        assert_eq!(a, "01a09b25-aaaa-7972-881a-3cee2ea6efd6");
11162        assert_eq!(b, "01a09b25-bbbb-7972-881a-3cee2ea6efd6");
11163        unsafe {
11164            std::env::remove_var("GROK_SESSION_ID");
11165        }
11166    }
11167
11168    #[test]
11169    fn a_named_holder_refusal_still_says_held_by_another() {
11170        let hold = Hold {
11171            assignee: "acme".into(),
11172            seat: "acme".into(),
11173            pid: 1,
11174            comm: "ljos".into(),
11175            since: "2026-01-01T00:00:00.000Z".into(),
11176        };
11177        let said = super::held_by_another_message("demo-aaaa", "brio", &hold, "still running");
11178        assert!(said.contains("held by another"), "{said}");
11179        assert!(said.contains("acme"), "{said}");
11180        assert!(said.contains("not by brio"), "{said}");
11181    }
11182
11183    /// Two seats on one ticket: LJOS_SEAT plus a distinct session id each.
11184    #[test]
11185    fn two_seats_with_distinct_session_ids_are_distinct_holders() {
11186        let _g = env_guard();
11187        let dir = std::env::temp_dir().join(format!("ljos-rt-two-seat-{}", std::process::id()));
11188        std::fs::create_dir_all(&dir).unwrap();
11189        let session_keys: Vec<String> = std::env::vars()
11190            .map(|(k, _)| k)
11191            .filter(|k| k.ends_with("_SESSION_ID"))
11192            .collect();
11193        unsafe {
11194            std::env::set_var("XDG_RUNTIME_DIR", &dir);
11195            std::env::remove_var("VISSUE_AGENT");
11196            for k in &session_keys {
11197                std::env::remove_var(k);
11198            }
11199            std::env::set_var("LJOS_SEAT", "acme");
11200            std::env::set_var("ACME_SESSION_ID", "acme-sess-aaaaaa");
11201        }
11202        let a_seat = seat_name();
11203        let a_holder = resolve_assignee(None);
11204        unsafe {
11205            std::env::remove_var("ACME_SESSION_ID");
11206            std::env::set_var("LJOS_SEAT", "brio");
11207            std::env::set_var("BRIO_SESSION_ID", "brio-sess-bbbbbb");
11208        }
11209        let b_seat = seat_name();
11210        let b_holder = resolve_assignee(None);
11211        assert_eq!(a_seat, "acme");
11212        assert_eq!(b_seat, "brio");
11213        assert_eq!(a_holder, "acme-sess-aaaaaa");
11214        assert_eq!(b_holder, "brio-sess-bbbbbb");
11215        assert_ne!(a_holder, b_holder);
11216        unsafe {
11217            std::env::remove_var("LJOS_SEAT");
11218            std::env::remove_var("BRIO_SESSION_ID");
11219            std::env::remove_var("ACME_SESSION_ID");
11220            std::env::remove_var("XDG_RUNTIME_DIR");
11221        }
11222    }
11223
11224    #[test]
11225    fn occupancy_is_per_issue_so_two_sittings_do_not_unseat() {
11226        let _g = env_guard();
11227        unsafe {
11228            std::env::remove_var("LJOS_SEAT");
11229            std::env::remove_var("VISSUE_AGENT");
11230        }
11231        let holder = resolve_assignee(None);
11232        let a = occupancy_assignee(None, "ljos-aaaa");
11233        let b = occupancy_assignee(None, "ljos-bbbb");
11234        assert_ne!(
11235            a, b,
11236            "two issues under one conversation must not share a slot"
11237        );
11238        assert_eq!(a, format!("{holder}:ljos-aaaa"), "{a}");
11239        assert_eq!(b, format!("{holder}:ljos-bbbb"), "{b}");
11240        assert_eq!(
11241            occupancy_assignee(Some("alice"), "ljos-aaaa"),
11242            "alice:ljos-aaaa"
11243        );
11244        assert_eq!(
11245            occupancy_assignee(Some("alice"), "ljos-bbbb"),
11246            "alice:ljos-bbbb"
11247        );
11248    }
11249
11250    #[test]
11251    fn doctor_lists_ljos_hud_but_does_not_require_it() {
11252        assert!(SEAT_BINS
11253            .iter()
11254            .any(|(n, c)| *n == "ljos-hud" && *c == "ljos-hud"));
11255        assert!(!REQUIRED.contains(&"ljos-hud"));
11256    }
11257
11258    #[test]
11259    fn doctor_names_the_session_not_the_default_seat() {
11260        let _g = env_guard();
11261        // A runtime directory of its own: a record another process left for
11262        // this id would name its holder instead.
11263        let dir = std::env::temp_dir().join(format!("ljos-rt-doctor-{}", std::process::id()));
11264        std::fs::create_dir_all(&dir).unwrap();
11265        unsafe {
11266            std::env::set_var("XDG_RUNTIME_DIR", &dir);
11267            std::env::remove_var("LJOS_SEAT");
11268            std::env::remove_var("VISSUE_AGENT");
11269            std::env::set_var("GROK_SESSION_ID", "01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11270        }
11271        let row = format_seat_row();
11272        assert!(
11273            row.contains("01a09b25-ffe9-7972-881a-3cee2ea6efd6"),
11274            "doctor names the whole session: {row}"
11275        );
11276        assert!(
11277            row.contains("GROK_SESSION_ID"),
11278            "doctor names where the session came from: {row}"
11279        );
11280        assert!(!row.contains("the default"), "{row}");
11281        unsafe {
11282            std::env::remove_var("GROK_SESSION_ID");
11283            std::env::remove_var("XDG_RUNTIME_DIR");
11284        }
11285        let _ = std::fs::remove_dir_all(&dir);
11286    }
11287
11288    #[test]
11289    fn a_shared_name_does_not_occupy_the_whole_host() {
11290        let _g = env_guard();
11291        // A pronoun is treated as omitted: the holder is this conversation's,
11292        // whatever the tree above the test says the seat is. A name that is
11293        // not a pronoun is a named worker and stands as given.
11294        let holder = resolve_assignee(None);
11295        assert_eq!(resolve_assignee(Some("you")), holder);
11296        assert_eq!(resolve_assignee(Some("seat")), holder);
11297        assert_eq!(resolve_assignee(Some("agent")), holder);
11298        assert_ne!(holder, "seat");
11299        assert_eq!(resolve_assignee(Some("alice")), "alice");
11300    }
11301
11302    #[test]
11303    fn a_reading_supersedes_the_one_before_and_keeps_it_as_was() {
11304        assert_eq!(parse_every("7d").unwrap(), 7 * 86_400);
11305        assert_eq!(parse_every("24h").unwrap(), 86_400);
11306        assert_eq!(parse_every("2w").unwrap(), 14 * 86_400);
11307        assert_eq!(parse_every("90").unwrap(), 90);
11308        assert!(parse_every("soon").is_err());
11309        assert!(parse_every("0d").is_err());
11310        assert_eq!(
11311            stamp_after("2026-09-19T23:30:00.000Z", 3_600).as_deref(),
11312            Some("2026-09-20T00:30:00.000Z")
11313        );
11314        assert_eq!(trim_num(0.5790), "0.579");
11315        assert_eq!(trim_num(12.0), "12");
11316        assert_eq!(
11317            habit_text("mab cr all", 0.579, "acc", "job 11793"),
11318            "habit mab cr all stands at 0.579 acc (job 11793)."
11319        );
11320        let first = serde_json::json!({
11321            "id": "a1", "kind": "habit", "ts": "2026-09-12T10:00:00.000Z",
11322            "due_at": "2026-09-19T10:00:00.000Z",
11323            "habit": {"name": "mab cr all", "value": 0.535, "unit": "acc", "source": "11750", "every_s": 604800}
11324        });
11325        let second = serde_json::json!({
11326            "id": "a2", "kind": "habit", "ts": "2026-09-19T10:00:00.000Z",
11327            "due_at": "2026-09-26T10:00:00.000Z",
11328            "habit": {"name": "mab cr all", "value": 0.579, "unit": "acc", "source": "11793", "every_s": 604800,
11329                       "was": 0.535, "was_ts": "2026-09-12T10:00:00.000Z"}
11330        });
11331        let other = serde_json::json!({
11332            "id": "l1", "kind": "lesson", "text": "not a habit", "ts": "2026-09-19T10:00:00.000Z"
11333        });
11334        // The pack hands back one live reading a habit; a stale copy sorts out.
11335        let rows = readings_of(&[first.clone(), other, second]);
11336        assert_eq!(rows.len(), 1);
11337        assert_eq!(rows[0].id.as_deref(), Some("a2"));
11338        assert_eq!(rows[0].was, Some(0.535));
11339        let now = "2026-09-20T09:00:00.000Z";
11340        let line = format_readings(&rows, now);
11341        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}");
11342        let late = readings_of(&[first]);
11343        assert!(format_readings(&late, now).contains("next reading late (yesterday)"));
11344        assert_eq!(format_change(&late[0], now), "first reading");
11345    }
11346
11347    #[test]
11348    fn a_program_is_named_by_its_path_not_its_version() {
11349        assert!(version_like("2.1.266"));
11350        assert!(version_like("v18.2.0"));
11351        assert!(!version_like("acme"));
11352        // The kernel's short name of a binary installed under a versions
11353        // directory is the version; the program is the directory above.
11354        let me = program_name(std::process::id(), "comm");
11355        assert!(!me.is_empty() && !version_like(&me), "{me}");
11356    }
11357
11358    #[test]
11359    fn a_hit_names_the_seat_that_wrote_it_only_when_that_is_another() {
11360        let ents = vec!["seat:brio".to_string(), "habit:x".to_string()];
11361        assert_eq!(other_seat(&ents, "acme-cli").as_deref(), Some("brio"));
11362        assert_eq!(other_seat(&ents, "brio"), None);
11363        assert_eq!(other_seat(&["habit:x".to_string()], "brio"), None);
11364    }
11365
11366    #[test]
11367    fn two_session_ids_that_share_a_prefix_take_two_slots() {
11368        let a = session_tag("01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11369        let b = session_tag("01a09b25-ffe9-7972-881a-3cee2ea6efd7");
11370        assert_ne!(a, b);
11371        assert_eq!(a.len(), 10);
11372        assert_eq!(a, session_tag(" 01a09b25-ffe9-7972-881a-3cee2ea6efd6 "));
11373    }
11374
11375    /// Two conversations started from one terminal share the line editor's
11376    /// id; each finds its own server's record, never the other's.
11377    #[test]
11378    fn a_record_from_another_conversation_is_not_this_ones() {
11379        let ble = "1000000000.000001/4242".to_string();
11380        let me = "01a09b25-ffe9-7972-881a-000000000001".to_string();
11381        let other = "01a09b25-ffe9-7972-881a-000000000002".to_string();
11382        let mine = vec![ble.clone(), me.clone()];
11383        let theirs = format!("acme-cli\nsess-other\nids\t{ble}\t{other}\n");
11384        assert!(super::record_for(&theirs, &mine, "t".into()).is_none());
11385        let ours = format!("acme-cli\nsess-mine\nids\t{ble}\t{me}\n");
11386        assert_eq!(
11387            super::record_for(&ours, &mine, "t".into()).unwrap().holder,
11388            "sess-mine"
11389        );
11390        // A shell that adds an id of its own still finds its server's record.
11391        let shell = vec![ble.clone(), me.clone(), "9f9f9f9f-extra".into()];
11392        assert!(super::record_for(&ours, &shell, "t".into()).is_some());
11393        // A record from before the ids line is taken as it stands.
11394        assert!(super::record_for("acme-cli\nsess-old\n", &mine, "t".into()).is_some());
11395    }
11396
11397    #[test]
11398    fn the_host_row_reads_oom_kills_and_this_logins_servers() {
11399        assert_eq!(
11400            parse_oom_kills("pgfault 12\noom_kill 43\nnr_free_pages 1\n"),
11401            Some(43)
11402        );
11403        assert_eq!(parse_oom_kills("pgfault 12\n"), None);
11404        assert_eq!(
11405            status_field("Name:\tx\nVmRSS:\t  2692 kB\n", "VmRSS:").as_deref(),
11406            Some("2692")
11407        );
11408        let row = host_row();
11409        assert_eq!(row.name, "host");
11410        assert!(row.state.contains("ljos-mcp"), "{}", row.state);
11411    }
11412
11413    #[test]
11414    fn a_library_default_client_name_is_not_a_seat() {
11415        assert_eq!(seat_for_client("Acme CLI"), "acme-cli");
11416        for library in ["mcp", "MCP", "mcp-client"] {
11417            let seat = seat_for_client(library);
11418            assert!(
11419                !LIBRARY_CLIENT_NAMES.contains(&seat.as_str()) || ancestry().is_empty(),
11420                "{library} named the seat {seat}"
11421            );
11422        }
11423    }
11424
11425    #[test]
11426    fn a_runner_started_inside_another_keeps_its_own_holder() {
11427        let _g = env_guard();
11428        let dir = std::env::temp_dir().join(format!("ljos-nest-{}", std::process::id()));
11429        std::fs::create_dir_all(&dir).unwrap();
11430        unsafe {
11431            std::env::set_var("XDG_RUNTIME_DIR", &dir);
11432            std::env::set_var("ACME_SESSION_ID", "01a09b25-1111-7972-881a-3cee2ea6efd6");
11433        }
11434        let parent = announce_seat("Acme CLI", 5151);
11435        // The child inherits the parent's id and connects under its own name.
11436        let child = announce_seat("Brio Agent", 5252);
11437        assert_eq!(child.seat, "brio-agent");
11438        assert_ne!(child.holder, parent.holder);
11439        assert_eq!(
11440            seat_from_session_records()
11441                .expect("the parent's record")
11442                .holder,
11443            parent.holder,
11444            "the child leaves the parent's record alone"
11445        );
11446        retire_seat(5252);
11447        assert_eq!(
11448            seat_from_session_records()
11449                .expect("still the parent's")
11450                .holder,
11451            parent.holder,
11452            "the child's exit does not take the parent's record"
11453        );
11454        retire_seat(5151);
11455        assert!(seat_from_session_records().is_none());
11456        unsafe {
11457            std::env::remove_var("ACME_SESSION_ID");
11458            std::env::remove_var("XDG_RUNTIME_DIR");
11459        }
11460        let _ = std::fs::remove_dir_all(&dir);
11461    }
11462
11463    #[test]
11464    fn a_thread_named_on_a_call_holds_as_its_shells_do() {
11465        let _g = env_guard();
11466        let dir = std::env::temp_dir().join(format!("ljos-thread-{}", std::process::id()));
11467        std::fs::create_dir_all(&dir).unwrap();
11468        unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
11469        assert!(runner_session_var("ACME_THREAD_ID", "0199a1b2-c3d4"));
11470        assert!(!runner_session_var("ACME_THREAD_ID", "short"));
11471        // No shell has sat yet: the thread id is the holder, and recorded.
11472        let first = seat_for_thread("0199a1b2-aaaa-thread");
11473        assert_eq!(first.holder, "0199a1b2-aaaa-thread");
11474        let text = std::fs::read_to_string(session_record_path("0199a1b2-aaaa-thread")).unwrap();
11475        assert_eq!(
11476            holder_naming(&text, "0199a1b2-aaaa-thread").as_deref(),
11477            Some("0199a1b2-aaaa-thread")
11478        );
11479        // A shell of the thread sat first: the call takes the shell's holder.
11480        let shell = Seat {
11481            seat: "acme".into(),
11482            holder: "sess-shellfirst".into(),
11483            source: String::new(),
11484        };
11485        write_record_ids(
11486            &session_record_path("0199a1b2-bbbb-thread"),
11487            &shell,
11488            &["line-editor-id".into(), "0199a1b2-bbbb-thread".into()],
11489        );
11490        assert_eq!(
11491            seat_for_thread("0199a1b2-bbbb-thread").holder,
11492            "sess-shellfirst"
11493        );
11494        unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11495        let _ = std::fs::remove_dir_all(&dir);
11496    }
11497
11498    #[test]
11499    fn a_shell_with_one_more_session_variable_finds_the_servers_record() {
11500        let _g = env_guard();
11501        let dir = std::env::temp_dir().join(format!("ljos-rt-{}", std::process::id()));
11502        std::fs::create_dir_all(&dir).unwrap();
11503        unsafe {
11504            std::env::set_var("XDG_RUNTIME_DIR", &dir);
11505            std::env::set_var("ACME_SESSION_ID", "01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11506        }
11507        let server = announce_seat("Acme CLI", 4242);
11508        assert_eq!(server.seat, "acme-cli");
11509        // The shell's line editor stamps its own id; the shared one still
11510        // finds the record, and the holder is the server's.
11511        unsafe {
11512            std::env::set_var(
11513                "AAA_LINE_EDITOR_SESSION_ID",
11514                "9f9f9f9f-0000-0000-0000-000000000000",
11515            );
11516        }
11517        let shell = seat_from_session_records().expect("the shared id finds the record");
11518        assert_eq!(shell.holder, server.holder);
11519        assert_eq!(shell.seat, server.seat);
11520        retire_seat(4242);
11521        assert!(seat_from_session_records().is_none());
11522        unsafe {
11523            std::env::remove_var("ACME_SESSION_ID");
11524            std::env::remove_var("AAA_LINE_EDITOR_SESSION_ID");
11525            std::env::remove_var("XDG_RUNTIME_DIR");
11526        }
11527        let _ = std::fs::remove_dir_all(&dir);
11528        assert_ne!(session_tag("01a09b25-aaaa"), session_tag("01a09b25-bbbb"));
11529    }
11530
11531    #[test]
11532    fn a_panel_seats_the_personas_that_speak_to_the_issue() {
11533        let mk = |name: &str, about: &[&str]| Persona {
11534            name: name.into(),
11535            anchor: 0.5,
11536            view: String::new(),
11537            entities: about.iter().map(|s| (*s).to_string()).collect(),
11538        };
11539        let all = vec![
11540            mk("reviewer", &["docs"]),
11541            mk("cuda", &["gpu", "kernels"]),
11542            mk("reader", &[]),
11543        ];
11544        let docs = personas_speaking_to(&all, &["Docs".to_string(), "site".to_string()]);
11545        assert_eq!(
11546            docs.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11547            ["reviewer"]
11548        );
11549        let nobody = personas_speaking_to(&all, &["fortran".to_string()]);
11550        assert_eq!(
11551            nobody.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11552            ["reader"],
11553            "no domain match seats only personas with no domains"
11554        );
11555        let specialists = vec![mk("reviewer", &["docs"]), mk("cuda", &["gpu"])];
11556        assert!(personas_speaking_to(&specialists, &["fortran".to_string()]).is_empty());
11557        let scoped = vec![
11558            mk("seatkeeper", &["seat", "ballot", "sync:rgsurflat"]),
11559            mk("cuda", &["gpu", "sync:rgsurflat"]),
11560        ];
11561        let seated = personas_speaking_to(
11562            &scoped,
11563            &["ballot".to_string(), "sync:rgsurflat".to_string()],
11564        );
11565        assert_eq!(
11566            seated.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11567            ["seatkeeper"],
11568            "a shared sync scope does not seat the roster"
11569        );
11570        let mut merger = mk("merger", &["git"]);
11571        merger.view = "Reads a merge for the writer it silently drops.".into();
11572        let mut other = mk("other", &["gpu"]);
11573        other.view = "Wants the kernel to be fast.".into();
11574        let by_view = personas_speaking_to(
11575            &[merger, other],
11576            &["merge".to_string(), "writers".to_string()],
11577        );
11578        assert_eq!(
11579            by_view.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11580            ["merger"],
11581            "a specialist whose view uses the issue's words is seated"
11582        );
11583    }
11584
11585    #[test]
11586    fn a_client_name_is_one_seat_however_it_is_spelt() {
11587        assert_eq!(seat_slug("Acme CLI"), "acme-cli");
11588        assert_eq!(seat_slug("acme_cli/1.2"), "acme-cli-1-2");
11589        assert_eq!(seat_slug("  --  "), "runner");
11590        assert_eq!(conversation_tag(4242), "39u");
11591        assert_eq!(conversation_tag(0), "0");
11592    }
11593
11594    #[test]
11595    fn the_server_leaves_a_record_a_shell_below_the_runner_reads() {
11596        let dir = std::env::temp_dir().join(format!("ljos-seat-{}", std::process::id()));
11597        std::fs::create_dir_all(&dir).unwrap();
11598        // The record path is pure in the directory, so build it the way the
11599        // server does and read it back the way a shell does.
11600        let path = dir.join("ljos").join("seat-4242");
11601        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
11602        let seat = Seat::tagged(
11603            seat_slug("Acme CLI"),
11604            &conversation_tag(4242),
11605            "test".to_string(),
11606        );
11607        std::fs::write(&path, format!("{}\n{}\n", seat.seat, seat.holder)).unwrap();
11608        let text = std::fs::read_to_string(&path).unwrap();
11609        let mut lines = text.lines();
11610        assert_eq!(lines.next(), Some("acme-cli"));
11611        assert_eq!(lines.next(), Some("acme-cli-39u"));
11612        assert_eq!(
11613            format_seat(&seat),
11614            "seat\tacme-cli\nholder\tacme-cli-39u\nsource\ttest\n"
11615        );
11616        let _ = std::fs::remove_dir_all(&dir);
11617    }
11618
11619    #[test]
11620    fn the_record_weighs_a_voter_by_what_it_got_right() {
11621        let ballots = vec![
11622            ("a".to_string(), "ship".to_string()),
11623            ("b".to_string(), "ship".to_string()),
11624            ("c".to_string(), "hold".to_string()),
11625        ];
11626        let (rows, records) =
11627            learn_record(&ballots, "ship", &std::collections::BTreeMap::new(), &[]).unwrap();
11628        assert_eq!(records["a"], (1.0, 0.0));
11629        assert_eq!(records["c"], (0.0, 1.0));
11630        let w = |to: &str| rows.iter().find(|r| r.to == to).unwrap().weight;
11631        assert_eq!(w("a"), 1.0, "a right voter stands at one");
11632        assert!(w("c") < w("a"), "a wrong voter stands lower");
11633        assert_eq!(rows.len(), 6, "complete over the voters");
11634        // The record accumulates: a second outcome against c lowers it further.
11635        let (rows2, records2) = learn_record(&ballots, "ship", &records, &[]).unwrap();
11636        assert_eq!(records2["c"], (0.0, 2.0));
11637        let w2 = |to: &str| rows2.iter().find(|r| r.to == to).unwrap().weight;
11638        assert!(w2("c") <= w("c"));
11639        assert!(learn_record(&ballots, "  ", &records, &[]).is_err());
11640        // Records are read back off trust atoms, latest first.
11641        let atoms = vec![
11642            serde_json::json!({"kind": "trust", "from": "a", "to": "c", "weight": 0.2, "hits": 1.0, "misses": 3.0, "ts": "2026-09-13T01:00:00Z"}),
11643            serde_json::json!({"kind": "trust", "from": "b", "to": "c", "weight": 0.5, "hits": 1.0, "misses": 1.0, "ts": "2026-09-12T01:00:00Z"}),
11644        ];
11645        assert_eq!(records_from_atoms(&atoms)["c"], (1.0, 3.0));
11646    }
11647
11648    #[test]
11649    fn a_correction_is_nudged_once_a_session_and_only_on_a_prompt() {
11650        let _g = env_guard();
11651        // The seen file lives under the runtime directory.
11652        let dir = std::env::temp_dir().join(format!("ljos-corr-{}", std::process::id()));
11653        std::fs::create_dir_all(&dir).unwrap();
11654        unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
11655        let prompt = HookCall {
11656            event: "UserPromptSubmit".into(),
11657            cue: "Do you not remember to use uv for scripts?".into(),
11658            session: Some("corr-test".into()),
11659            shape: HookShape::Asks,
11660        };
11661        let (key, first) = correction_nudge(&prompt).expect("a correction is nudged");
11662        assert!(first.contains("ljos prefer"), "{first}");
11663        assert!(
11664            correction_nudge(&prompt).is_some(),
11665            "unmarked until delivered"
11666        );
11667        mark_seen(Some("corr-test"), &[key]);
11668        assert!(correction_nudge(&prompt).is_none(), "once delivered");
11669        let tool = HookCall {
11670            event: "PreToolUse".into(),
11671            cue: "you should have used uv".into(),
11672            session: Some("corr-test".into()),
11673            shape: HookShape::Asks,
11674        };
11675        assert!(
11676            correction_nudge(&tool).is_none(),
11677            "tool calls are not prompts"
11678        );
11679        let plain = HookCall {
11680            event: "UserPromptSubmit".into(),
11681            cue: "add the timeline verb".into(),
11682            session: Some("corr-test-2".into()),
11683            shape: HookShape::Asks,
11684        };
11685        assert!(correction_nudge(&plain).is_none());
11686    }
11687
11688    #[test]
11689    fn a_subagent_is_told_its_parents_issue_and_held_once_at_stop() {
11690        let grok = r#"{"hookEventName":"subagent_stop","sessionId":"child","subagentType":"explore","stopHookActive":false}"#;
11691        assert_eq!(
11692            hook_subagent(grok),
11693            (Some("explore".into()), false, String::new())
11694        );
11695        let shared = r#"{"hook_event_name":"SubagentStop","session_id":"p","agent_id":"a1","agent_type":"review","stop_hook_active":true}"#;
11696        assert_eq!(
11697            hook_subagent(shared),
11698            (Some("review".into()), true, "a1".into())
11699        );
11700        assert_eq!(hook_subagent(r#"{"hook_event_name":"Stop"}"#).0, None);
11701        let brief = subagent_brief("explore", "acme-12ab", true);
11702        assert!(
11703            brief.contains("Do not open a sitting")
11704                && brief.contains("ljos vote acme-12ab")
11705                && brief.contains("--expect"),
11706            "{brief}"
11707        );
11708        let decide = subagent_stop_reason("explore", Some("acme-12ab"), true, false).unwrap();
11709        assert!(
11710            decide.contains("decision")
11711                && decide.contains("--expect")
11712                && decide.contains("--as ROLE"),
11713            "{decide}"
11714        );
11715        let plain = subagent_stop_reason("explore", Some("acme-12ab"), false, false).unwrap();
11716        assert!(plain.contains("Otherwise stop"), "{plain}");
11717        assert!(
11718            subagent_stop_reason("explore", Some("acme-12ab"), true, true).is_none(),
11719            "held once"
11720        );
11721        assert!(
11722            subagent_stop_reason("explore", None, true, false).is_none(),
11723            "no issue, no gate"
11724        );
11725    }
11726
11727    #[test]
11728    fn a_clone_without_the_named_merge_driver_is_reported() {
11729        let dir = tempfile::tempdir().unwrap();
11730        let git = |args: &[&str]| {
11731            std::process::Command::new("git")
11732                .arg("-C")
11733                .arg(dir.path())
11734                .args(args)
11735                .output()
11736                .unwrap()
11737        };
11738        git(&["init", "-q"]);
11739        assert!(
11740            tracker_merge_driver_missing(dir.path()).is_none(),
11741            "no attribute, no row"
11742        );
11743        std::fs::write(
11744            dir.path().join(".gitattributes"),
11745            "issues.org merge=vissue\n",
11746        )
11747        .unwrap();
11748        let said = tracker_merge_driver_missing(dir.path()).expect("named and missing");
11749        assert!(said.contains("vissue merge-driver --install"), "{said}");
11750        git(&[
11751            "config",
11752            "merge.vissue.driver",
11753            "vissue merge-driver %O %A %B %P",
11754        ]);
11755        assert!(tracker_merge_driver_missing(dir.path()).is_none());
11756    }
11757
11758    #[test]
11759    fn a_subagent_reads_its_parents_issue_from_the_hold_records() {
11760        let _g = env_guard();
11761        let dir = tempfile::tempdir().unwrap();
11762        unsafe { std::env::set_var("XDG_RUNTIME_DIR", dir.path()) };
11763        let ljos = dir.path().join("ljos");
11764        std::fs::create_dir_all(&ljos).unwrap();
11765        let rec = |name: &str, holder: &str, at: &str, node: &str| {
11766            std::fs::write(
11767                ljos.join(format!("hold-{name}")),
11768                format!("{holder}\nacme\n1\nacme\n{at}\n{node}\n"),
11769            )
11770            .unwrap();
11771        };
11772        rec("a", "sess-parent", "2026-09-27T10:00:00Z", "acme-old1");
11773        rec("b", "sess-parent", "2026-09-27T12:00:00Z", "acme-new2");
11774        rec("c", "sess-other", "2026-09-27T13:00:00Z", "brio-3c4d");
11775        std::fs::write(
11776            ljos.join("hold-d"),
11777            "sess-parent\nacme\n1\nacme\n2026-09-27T14:00:00Z\n",
11778        )
11779        .unwrap();
11780        assert_eq!(
11781            held_from_records(&["sess-parent".to_string()]).as_deref(),
11782            Some("acme-new2")
11783        );
11784        assert_eq!(held_from_records(&["sess-nobody".to_string()]), None);
11785        unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11786    }
11787
11788    #[test]
11789    fn a_long_run_without_the_seat_is_reminded_once_per_stretch() {
11790        let _g = env_guard();
11791        let dir = tempfile::tempdir().unwrap();
11792        unsafe { std::env::set_var("XDG_RUNTIME_DIR", dir.path()) };
11793        let call = |cue: &str, event: &str| HookCall {
11794            event: event.into(),
11795            cue: cue.into(),
11796            session: Some("work-test".into()),
11797            shape: HookShape::Asks,
11798        };
11799        for _ in 1..WORK_NUDGE_EVERY {
11800            assert!(work_nudge(&call("cargo test", "PostToolUse"), false).is_none());
11801        }
11802        let said =
11803            work_nudge(&call("cargo test", "PostToolUse"), false).expect("nudged at the count");
11804        assert!(
11805            said.contains("no issue held") || said.contains("vissue note"),
11806            "{said}"
11807        );
11808        assert!(
11809            work_nudge(&call("cargo test", "PostToolUse"), false).is_none(),
11810            "count starts over"
11811        );
11812        assert!(work_nudge(&call("ljos remember x", "PreToolUse"), false).is_none());
11813        assert!(
11814            work_nudge(&call("rg foo", "PostToolUse"), true).is_none(),
11815            "a subagent has its brief"
11816        );
11817        assert!(touches_seat("use_tool ljos__ljos_sitting"));
11818        assert!(!touches_seat("cargo build --release"));
11819        unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11820    }
11821
11822    #[test]
11823    fn a_twin_hook_call_is_answered_once() {
11824        let _g = env_guard();
11825        let dir = tempfile::tempdir().unwrap();
11826        unsafe { std::env::set_var("XDG_RUNTIME_DIR", dir.path()) };
11827        let call = |cue: &str| HookCall {
11828            event: "UserPromptSubmit".into(),
11829            cue: cue.into(),
11830            session: Some("twin".into()),
11831            shape: HookShape::CamelCase,
11832        };
11833        assert!(
11834            !hook_already_running(&call("fix the ci")),
11835            "the first answers"
11836        );
11837        assert!(
11838            hook_already_running(&call("fix the ci")),
11839            "its twin returns"
11840        );
11841        assert!(
11842            !hook_already_running(&call("another prompt")),
11843            "another prompt answers"
11844        );
11845        unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11846    }
11847
11848    #[test]
11849    fn a_second_commit_lock_waits_for_the_first() {
11850        let dir = tempfile::tempdir().unwrap();
11851        let path = dir.path().join("ljos-commit.lock");
11852        let first = CommitLock::acquire(&path);
11853        assert!(first.0.is_some(), "the lock opens");
11854        let other = path.clone();
11855        let started = std::time::Instant::now();
11856        let waiter = std::thread::spawn(move || {
11857            let _second = CommitLock::acquire(&other);
11858            started.elapsed()
11859        });
11860        std::thread::sleep(std::time::Duration::from_millis(300));
11861        drop(first);
11862        let waited = waiter.join().unwrap();
11863        assert!(
11864            waited >= std::time::Duration::from_millis(250),
11865            "{waited:?}"
11866        );
11867    }
11868
11869    #[test]
11870    fn a_verdict_from_jev_replaces_the_phrase_lists() {
11871        let call = |cue: &str, session: &str| HookCall {
11872            event: "UserPromptSubmit".into(),
11873            cue: cue.into(),
11874            session: Some(session.into()),
11875            shape: HookShape::Asks,
11876        };
11877        let plain = call("add the timeline verb", "verdict-1");
11878        assert!(decision_nudge_as(&plain, None).is_none(), "no cue word");
11879        assert!(
11880            decision_nudge_as(&plain, Some(true)).is_some(),
11881            "judged a choice"
11882        );
11883        let asked = call("should we seal with age or gpg?", "verdict-2");
11884        assert!(
11885            decision_nudge_as(&asked, Some(false)).is_none(),
11886            "judged not a choice"
11887        );
11888        let (key, _) = correction_nudge_as(&plain, Some(true)).expect("judged a correction");
11889        assert_eq!(key, "correction:judged");
11890        assert!(correction_nudge_as(&plain, Some(false)).is_none());
11891    }
11892
11893    #[test]
11894    fn a_choice_is_sent_to_a_panel_once_a_session() {
11895        let _g = env_guard();
11896        let dir = std::env::temp_dir().join(format!("ljos-dec-{}", std::process::id()));
11897        std::fs::create_dir_all(&dir).unwrap();
11898        unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
11899        let call = |cue: &str, session: &str, event: &str| HookCall {
11900            event: event.into(),
11901            cue: cue.into(),
11902            session: Some(session.into()),
11903            shape: HookShape::Asks,
11904        };
11905        let prompt = call(
11906            "should we seal with age or gpg?",
11907            "dec-test",
11908            "UserPromptSubmit",
11909        );
11910        let (key, first) = decision_nudge(&prompt).expect("a choice is nudged");
11911        assert!(
11912            first.contains("Options:") && first.contains("--as NAME"),
11913            "{first}"
11914        );
11915        assert!(
11916            decision_nudge(&prompt).is_some(),
11917            "unmarked until delivered"
11918        );
11919        mark_seen(Some("dec-test"), &[key]);
11920        assert!(decision_nudge(&prompt).is_none(), "once delivered");
11921        assert!(decision_nudge(&call("age vs gpg", "dec-test-2", "PreToolUse")).is_none());
11922        assert!(decision_nudge(&call(
11923            "add the timeline verb",
11924            "dec-test-3",
11925            "UserPromptSubmit"
11926        ))
11927        .is_none());
11928        assert!(
11929            decision_nudge(&call("go with option 2", "dec-test-4", "UserPromptSubmit")).is_some()
11930        );
11931        assert!(
11932            decision_nudge(&call(
11933                "tell me the option about caching",
11934                "dec-test-5",
11935                "UserPromptSubmit"
11936            ))
11937            .is_none(),
11938            "a cue ends at a word boundary"
11939        );
11940        let report = format!(
11941            "{} should we keep it?",
11942            "a long pasted report line. ".repeat(40)
11943        );
11944        assert!(
11945            decision_nudge(&call(&report, "dec-test-6", "UserPromptSubmit")).is_none(),
11946            "a cue past the opening is not a choice put to the agent"
11947        );
11948    }
11949
11950    #[test]
11951    fn calibration_weights_are_log_odds_with_the_best_at_one() {
11952        let w = calibration_weights(&[
11953            ("a".to_string(), 0.9),
11954            ("b".to_string(), 0.6),
11955            ("c".to_string(), 0.5),
11956            ("d".to_string(), 1.0),
11957        ]);
11958        let of = |who: &str| w.iter().find(|(n, _)| n == who).unwrap().1;
11959        assert_eq!(of("d"), 1.0, "a perfect record is the top of the scale");
11960        // ln(9) / ln(99) = 0.478; ln(1.5) / ln(99) = 0.088
11961        assert!((of("a") - 0.478).abs() < 0.01, "{}", of("a"));
11962        assert!((of("b") - 0.088).abs() < 0.01, "{}", of("b"));
11963        assert!(
11964            of("a") / of("b") > 5.0,
11965            "nine in ten outweighs six in ten by more than five"
11966        );
11967        assert_eq!(of("c"), TRUST_FLOOR, "chance earns the floor");
11968    }
11969
11970    #[test]
11971    fn a_consolidation_report_names_the_pairs() {
11972        let body = serde_json::json!({"live": 5, "closed": 1, "applied": false, "pairs": [
11973            {"old": "a", "old_text": "The default fuse is Borda.", "new": "b", "new_text": "The default fuse is CombMNZ."}
11974        ]});
11975        let text = format_consolidation(&body);
11976        assert!(
11977            text.starts_with(
11978                "closes a  The default fuse is Borda.\n    for b  The default fuse is CombMNZ.\n"
11979            ),
11980            "{text}"
11981        );
11982        assert!(
11983            text.ends_with(
11984                "1 of 5 live memories would close; `ljos consolidate --apply` closes them\n"
11985            ),
11986            "{text}"
11987        );
11988        let applied = format_consolidation(
11989            &serde_json::json!({"live": 5, "closed": 0, "applied": true, "pairs": []}),
11990        );
11991        assert_eq!(applied, "0 of 5 live memories closed\n");
11992    }
11993
11994    #[test]
11995    fn the_hook_keeps_what_two_scorers_agreed_on() {
11996        let hit = |ballots, of| Hit {
11997            id: None,
11998            text: "x".into(),
11999            score: 1.0,
12000            kind: "lesson".into(),
12001            ts: None,
12002            entities: vec![],
12003            ballots,
12004            of,
12005        };
12006        assert!(agreed(&hit(Some(2), Some(3))));
12007        assert!(!agreed(&hit(Some(1), Some(3))));
12008        assert!(agreed(&hit(Some(1), Some(1))));
12009        assert!(agreed(&hit(None, None)));
12010        assert!(names_the_cue(
12011            "OpenCPMD Fortran calls the rgsaddle band API.",
12012            "plot the eon outputs with opencpmd and chemparseplot"
12013        ));
12014        assert!(!names_the_cue(
12015            "A submitted CQA packet uses the reviewer-edited Org quotes.",
12016            "plot the eon outputs with chemparseplot"
12017        ));
12018        assert!(!names_the_cue(
12019            "A doc comment states what an item does and one why.",
12020            "why are you not making real images"
12021        ));
12022        assert!(!names_the_cue("The fuse default is CombMNZ.", "why"));
12023        assert!(!names_a_numbered_pr(
12024            "A PR branch has to contain main before it merges."
12025        ));
12026        assert!(names_a_numbered_pr(
12027            "Pull requests 32 and 36 share one tree, and PR 32 replays PR 36."
12028        ));
12029        assert!(names_a_numbered_pr("rgpot #80 left a sibling behind main."));
12030        assert!(!names_a_numbered_pr(
12031            "The prompt hook holds the pack note until the first tool result."
12032        ));
12033        assert!(is_transient(
12034            "Pull requests 32 and 36 share one tree, and PR 32 replays PR 36."
12035        ));
12036        assert!(is_transient("The closure is on ljos-wgo8."));
12037        assert!(is_transient("The sweep was commit 80c73416c."));
12038        assert!(!is_transient(
12039            "A PR branch has to contain main before it merges."
12040        ));
12041        assert!(!is_transient("The prompt hook holds the pack note."));
12042        let standing = Hit {
12043            id: None,
12044            text: "Pull requests 32 and 36 share one tree.".into(),
12045            score: 1.0,
12046            kind: "lesson".into(),
12047            ts: None,
12048            entities: vec!["horizon:standing".into()],
12049            ballots: None,
12050            of: None,
12051        };
12052        assert!(is_refresher(&standing));
12053        let tagged = Hit {
12054            id: None,
12055            text: "A PR branch has to contain main.".into(),
12056            score: 1.0,
12057            kind: "lesson".into(),
12058            ts: None,
12059            entities: vec!["horizon:transient".into()],
12060            ballots: None,
12061            of: None,
12062        };
12063        assert!(!is_refresher(&tagged));
12064        let untagged = Hit {
12065            id: None,
12066            text: "A PR branch has to contain main.".into(),
12067            score: 1.0,
12068            kind: "lesson".into(),
12069            ts: None,
12070            entities: vec![],
12071            ballots: None,
12072            of: None,
12073        };
12074        assert!(!is_refresher(&untagged));
12075    }
12076
12077    #[test]
12078    fn the_generation_is_read_off_a_get_line() {
12079        let line = "a25a…  claimed  task  unset  gen=2  assignee=69f917124f757277b806e9a0f48c0318  parent=0  x-1";
12080        assert_eq!(gen_of(line), Some(2));
12081        assert_eq!(gen_of("deps  -"), None);
12082        assert_eq!(gen_of("a  ready  task  unset  gen=x"), None);
12083    }
12084
12085    #[test]
12086    fn the_holder_is_read_off_a_get_line() {
12087        let line = "a25a…  claimed  task  unset  gen=2  assignee=69f917124f757277b806e9a0f48c0318  parent=0  x-1";
12088        assert_eq!(
12089            holder_of(line).as_deref(),
12090            Some("69f917124f757277b806e9a0f48c0318")
12091        );
12092        assert_eq!(
12093            holder_of("a  ready  task  unset  gen=1  assignee=00000000000000000000000000000000"),
12094            None
12095        );
12096        assert_eq!(holder_of("deps  -"), None);
12097    }
12098
12099    #[test]
12100    fn a_registration_carries_the_runners_name() {
12101        let argv: Vec<String> = ["run", "-e", "LJOS_SEAT={name}", "{server}"]
12102            .iter()
12103            .map(|s| (*s).to_string())
12104            .collect();
12105        let filled = filled(&argv, Path::new("/x/ljos-mcp"), "runner-a");
12106        assert_eq!(filled, ["run", "-e", "LJOS_SEAT=runner-a", "/x/ljos-mcp"]);
12107        assert_eq!(
12108            identity_or_seat(Some(" reviewer ")).as_deref(),
12109            Some("reviewer")
12110        );
12111    }
12112
12113    #[test]
12114    fn a_timeline_reads_every_store_on_the_local_day() {
12115        let _g = env_guard();
12116        let before = std::env::var("TZ").ok();
12117        unsafe { std::env::set_var("TZ", "CET-1CEST,M3.5.0,M10.5.0/3") };
12118        // 22:28 UTC on the 26th is 00:28 on the 27th in Amsterdam, the day
12119        // the tracker stamps an issue created then.
12120        assert_eq!(local_stamp("2026-09-26T22:28:12.170Z"), "2026-09-27T00:28");
12121        assert_eq!(local_stamp("[2026-09-27 Sun]"), "[2026-09-27 Sun]");
12122        assert_eq!(local_offset(1_788_566_400), 7200);
12123        let deed = deed_event("deed-x", "time=1790461680\n", local_offset).unwrap();
12124        let v = serde_json::json!({"properties": {"CREATED": "[2026-09-27 Sun]"}});
12125        let mut events = tracker_events(&v);
12126        events.push(deed);
12127        let text = format_events(&events, "2026-09-27T00:30:00");
12128        assert!(text.lines().all(|l| l.contains("\ttoday\t")), "{text}");
12129        unsafe {
12130            match before {
12131                Some(tz) => std::env::set_var("TZ", tz),
12132                None => std::env::remove_var("TZ"),
12133            }
12134        }
12135    }
12136
12137    #[test]
12138    fn a_timeline_merges_the_three_stores_oldest_first() {
12139        let v = serde_json::json!({
12140            "properties": {
12141                "CREATED": "[2026-09-01 Tue]",
12142                "SCHEDULED": "<2026-02-10 Tue>"
12143            },
12144            "claimed_by": "seat",
12145            "claimed_at": "[2026-09-03 Thu 11:48]",
12146            "logbook": [
12147                {"note": "second", "timestamp": "[2026-09-10 Thu 09:00]"},
12148                {"from_state": "TODO", "to_state": "STARTED", "timestamp": "[2026-09-03 Thu 11:48]"}
12149            ]
12150        });
12151        let mut events = tracker_events(&v);
12152        events.push(
12153            deed_event(
12154                "deed-x",
12155                "id=deed-x ok\nproducedBy=seat -\ntime=1788566400\n",
12156                |_| 0,
12157            )
12158            .unwrap(),
12159        );
12160        events.sort_by(|a, b| (a.days, &a.clock).cmp(&(b.days, &b.clock)));
12161        let text = format_events(&events, "2026-09-12T00:00:00Z");
12162        let lines: Vec<&str> = text.lines().collect();
12163        assert_eq!(lines.len(), 6, "{text}");
12164        assert!(
12165            lines[0].contains("tracker\tSCHEDULED <2026-02-10 Tue>"),
12166            "{}",
12167            lines[0]
12168        );
12169        assert!(
12170            lines[1].starts_with("2026-09-01 \t11 days ago"),
12171            "{}",
12172            lines[1]
12173        );
12174        assert!(lines[1].contains("tracker\tcreated"), "{}", lines[1]);
12175        assert!(
12176            lines[2].contains("+2 d\ttracker\tclaimed by seat"),
12177            "{}",
12178            lines[2]
12179        );
12180        assert!(
12181            lines[3].contains("same day\ttracker\tTODO -> STARTED"),
12182            "{}",
12183            lines[3]
12184        );
12185        assert!(
12186            lines[4]
12187                .starts_with("2026-09-05 00:00\t7 days ago\t+2 d\tdeed\tdeed-x produced by seat -"),
12188            "{}",
12189            lines[4]
12190        );
12191        assert!(
12192            lines[5].contains("2 days ago\t+5 d\ttracker\tnote: second"),
12193            "{}",
12194            lines[5]
12195        );
12196    }
12197
12198    #[test]
12199    fn sitting_caps_are_the_protocol_numbers() {
12200        assert_eq!(SITTING_DUE, 8);
12201        assert_eq!(SITTING_TIMELINE, 12);
12202    }
12203
12204    #[test]
12205    fn policyd_required_is_the_operator_switch() {
12206        let _g = env_guard();
12207        let before = std::env::var_os("POLICYD_REQUIRED");
12208        std::env::remove_var("POLICYD_REQUIRED");
12209        assert!(!policyd_required());
12210        std::env::set_var("POLICYD_REQUIRED", "1");
12211        assert!(policyd_required());
12212        std::env::set_var("POLICYD_REQUIRED", "0");
12213        assert!(!policyd_required());
12214        match before {
12215            Some(v) => std::env::set_var("POLICYD_REQUIRED", v),
12216            None => std::env::remove_var("POLICYD_REQUIRED"),
12217        }
12218    }
12219
12220    #[test]
12221    fn stamps_of_every_shape_key_the_same() {
12222        assert_eq!(
12223            stamp_key(Some("[2026-09-12 Sat 21:54]")),
12224            stamp_key(Some("2026-09-12T21:54:00.000Z"))
12225        );
12226        assert_eq!(stamp_key(Some("[2026-09-12 Sat]")).unwrap().1, "");
12227        assert_eq!(
12228            stamp_key(Some("<2026-02-10 Tue>")).map(|k| k.0),
12229            stamp_key(Some("2026-02-10")).map(|k| k.0)
12230        );
12231        assert_eq!(stamp_key(Some("soon")), None);
12232        assert_eq!(
12233            civil_of_days(days_of_stamp(Some("2026-09-12")).unwrap()),
12234            "2026-09-12"
12235        );
12236    }
12237
12238    #[test]
12239    fn ages_read_as_a_timeline() {
12240        let now = "2026-09-12T14:00:00.000Z";
12241        assert_eq!(age_of(Some("2026-09-12T01:00:00.000Z"), now), "today");
12242        assert_eq!(age_of(Some("2026-09-11T23:59:00.000Z"), now), "yesterday");
12243        assert_eq!(age_of(Some("2026-09-01T00:00:00.000Z"), now), "11 days ago");
12244        assert_eq!(age_of(Some("2026-08-01T00:00:00.000Z"), now), "6 weeks ago");
12245        assert_eq!(
12246            age_of(Some("2026-03-01T00:00:00.000Z"), now),
12247            "6 months ago"
12248        );
12249        assert_eq!(age_of(Some("2023-09-12T00:00:00.000Z"), now), "3 years ago");
12250        assert_eq!(age_of(Some("2026-09-13T00:00:00.000Z"), now), "in 1 day");
12251        assert_eq!(age_of(None, now), "");
12252        assert_eq!(age_of(Some("card"), now), "");
12253    }
12254
12255    #[test]
12256    fn a_hit_line_carries_kind_and_age() {
12257        let h = Hit {
12258            id: Some("a".into()),
12259            text: " keep the smoke green ".into(),
12260            score: 1.0,
12261            kind: "lesson".into(),
12262            ts: Some("2026-09-10T00:00:00.000Z".into()),
12263            entities: vec![],
12264            ballots: None,
12265            of: None,
12266        };
12267        assert_eq!(
12268            hit_line(&h, "2026-09-12T00:00:00.000Z"),
12269            "- [lesson, 2 days ago] keep the smoke green"
12270        );
12271        let bare = Hit {
12272            id: None,
12273            text: "x".into(),
12274            score: 1.0,
12275            kind: String::new(),
12276            ts: None,
12277            entities: vec![],
12278            ballots: None,
12279            of: None,
12280        };
12281        assert_eq!(hit_line(&bare, "2026-09-12T00:00:00.000Z"), "- [claim] x");
12282    }
12283
12284    /// A hook call is read from the runner's JSON or from plain text, and
12285    /// the answer is the runner's shape only when there is something to say.
12286    #[test]
12287    fn hook_calls_are_read_and_answered_in_the_runners_shape() {
12288        let tool = hook_call(
12289            r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cargo test","description":"run"}}"#,
12290        );
12291        assert_eq!(tool.event, "PreToolUse");
12292        assert_eq!(tool.cue, "cargo test");
12293        let prompt = hook_call(r#"{"hook_event_name":"UserPromptSubmit","prompt":"fix the fuse"}"#);
12294        assert_eq!(prompt.cue, "fix the fuse");
12295        let grok = hook_call(r#"{"hookEventName":"post_tool_use","sessionId":"s1"}"#);
12296        assert_eq!(grok.event, "PostToolUse");
12297        assert_eq!(grok.session.as_deref(), Some("s1"));
12298        hold_hook_context(Some("s1"), "held pack");
12299        assert_eq!(take_hook_context(Some("s1")), "held pack");
12300        assert!(take_hook_context(Some("s1")).is_empty());
12301        let session = format!("hold-{}", std::process::id());
12302        hold_hook_note(Some(&session), "pack line", &["m1".to_string()]);
12303        hold_hook_context(Some(&session), "");
12304        assert_eq!(peek_hook_context(Some(&session)), "pack line");
12305        assert_eq!(
12306            prompt_hook_stdout(
12307                HookShape::CamelCase,
12308                Some(&session),
12309                "pack line",
12310                &["m1".to_string()]
12311            ),
12312            ""
12313        );
12314        let (echoed, echo_ids) = post_hook_stdout(HookShape::CamelCase, Some(&session));
12315        assert_eq!(echoed, "pack line");
12316        assert_eq!(echo_ids, ["m1"]);
12317        assert!(post_hook_stdout(HookShape::CamelCase, Some(&session))
12318            .0
12319            .is_empty());
12320        assert!(
12321            stop_hook_stdout(Some(&session), false).0.is_empty(),
12322            "a delivered tool result leaves Stop nothing to say"
12323        );
12324        let quiet = format!("quiet-{}", std::process::id());
12325        hold_hook_note(Some(&quiet), "no tool", &["m2".to_string()]);
12326        let (delivered, ids) = stop_hook_stdout(Some(&quiet), false);
12327        assert_eq!(delivered, "no tool");
12328        assert_eq!(ids, ["m2"]);
12329        assert!(stop_hook_stdout(Some(&quiet), true).0.is_empty());
12330        let argv = hook_call("rm -rf build");
12331        assert_eq!(argv.event, "argv");
12332        assert_eq!(argv.session, None);
12333        let with_session = hook_call(
12334            r#"{"session_id":"abc/../x 1","hook_event_name":"PreToolUse","tool_input":{"command":"ls"}}"#,
12335        );
12336        assert_eq!(with_session.session.as_deref(), Some("abc/../x 1"));
12337        assert!(seen_path("abc/../x 1")
12338            .unwrap()
12339            .file_name()
12340            .unwrap()
12341            .to_string_lossy()
12342            .ends_with("hook-seen-abcx1"));
12343        assert_eq!(seen_path("/../"), None);
12344        assert_eq!(hook_output(&argv, ""), "");
12345        assert_eq!(hook_output(&argv, "- [lesson] x"), "- [lesson] x\n");
12346        let out = hook_output(&tool, "- [preference] y");
12347        let v: Value = serde_json::from_str(out.trim()).unwrap();
12348        assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
12349        assert_eq!(
12350            v["hookSpecificOutput"]["additionalContext"],
12351            "- [preference] y"
12352        );
12353        assert!(
12354            hook_context(
12355                &HookCall {
12356                    event: "argv".into(),
12357                    cue: "ab".into(),
12358                    session: None,
12359                    shape: HookShape::Asks,
12360                },
12361                8
12362            )
12363            .is_empty(),
12364            "a cue too short asks nothing"
12365        );
12366    }
12367
12368    /// The injected ids of a session are read back without the nudge marker,
12369    /// and the seen file goes with the session.
12370    #[test]
12371    fn a_sessions_injected_memories_are_read_back_and_cleared() {
12372        // The seen file lives under XDG_RUNTIME_DIR, which other tests move.
12373        let _g = env_guard();
12374        let session = format!("end-test-{}", std::process::id());
12375        mark_seen(
12376            Some(&session),
12377            &["a".to_string(), "due-nudge".to_string(), "b".to_string()],
12378        );
12379        let (ids, path) = injected_ids(&session);
12380        assert_eq!(ids, ["a", "b"]);
12381        assert!(path.as_ref().is_some_and(|p| p.is_file()));
12382        // No pack in a unit test: nothing fires, the file still goes.
12383        let _ = session_end(Some(&session));
12384        assert!(!path.unwrap().is_file());
12385        assert_eq!(session_end(None), 0);
12386    }
12387
12388    /// The memory hook merges into a runner's hooks file once per event and
12389    /// is not added twice.
12390    #[test]
12391    fn the_memory_hook_is_merged_once() {
12392        let dir = std::env::temp_dir().join(format!("ljos-hook-{}", std::process::id()));
12393        let _ = std::fs::remove_dir_all(&dir);
12394        std::fs::create_dir_all(&dir).unwrap();
12395        let file = dir.join("settings.json");
12396        std::fs::write(
12397            &file,
12398            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"other"}]}]},"theme":"dark"}"#,
12399        )
12400        .unwrap();
12401        let both: Vec<String> = vec!["UserPromptSubmit".into(), "PreToolUse".into()];
12402        let prompts: Vec<String> = HOOK_EVENTS.iter().map(|e| (*e).to_string()).collect();
12403        assert_eq!(
12404            prompts,
12405            ["UserPromptSubmit", "SessionEnd"],
12406            "the panel's default, and the session end that wires what it used"
12407        );
12408        assert!(!hook_installed(&file, &both));
12409        let dry = hook_step(&file, &both, true);
12410        assert!(
12411            dry.ok && dry.detail.starts_with("would add it on"),
12412            "{dry:?}"
12413        );
12414        let step = hook_step(&file, &both, false);
12415        assert!(step.ok, "{step:?}");
12416        assert!(hook_installed(&file, &both));
12417        let again = hook_step(&file, &both, false);
12418        assert!(
12419            again.detail.contains("carries the memory hook on"),
12420            "{again:?}"
12421        );
12422        let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
12423        assert_eq!(v["theme"], "dark", "the rest of the file is kept");
12424        assert_eq!(
12425            v["hooks"]["PreToolUse"].as_array().unwrap().len(),
12426            2,
12427            "the other hook stays"
12428        );
12429        assert_eq!(v["hooks"]["UserPromptSubmit"].as_array().unwrap().len(), 1);
12430        // Narrowing to the default drops the seat's tool-call group and
12431        // leaves the other tool's group alone.
12432        let narrowed = hook_step(&file, &prompts, false);
12433        assert!(
12434            narrowed.detail.contains("drop it from PreToolUse"),
12435            "{narrowed:?}"
12436        );
12437        let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
12438        assert_eq!(v["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
12439        assert_eq!(v["hooks"]["PreToolUse"][0]["hooks"][0]["command"], "other");
12440        assert!(hook_installed(&file, &prompts));
12441        assert!(!hook_installed(&file, &both));
12442        let _ = std::fs::remove_dir_all(&dir);
12443    }
12444
12445    /// Rules are globs over the whole line; deny wins over ask; the hook
12446    /// carries the verdict as the runner's permission decision.
12447    #[test]
12448    fn rules_match_the_line_and_the_hook_carries_the_verdict() {
12449        assert!(glob_matches("rm -rf *", "rm -rf /tmp/x"));
12450        assert!(!glob_matches("rm -rf *", "ls -la"));
12451        assert!(glob_matches("*sudo*", "echo hi && sudo reboot"));
12452        assert!(glob_matches("git push*", "git push origin main"));
12453        assert!(!glob_matches("git push*", "git pull"));
12454        let rules = vec![
12455            Rule {
12456                pattern: "git push*".into(),
12457                verdict: "ask".into(),
12458                reason: "A push is the trust gate.".into(),
12459            },
12460            Rule {
12461                pattern: "*--force*".into(),
12462                verdict: "deny".into(),
12463                reason: "Never force push.".into(),
12464            },
12465        ];
12466        assert_eq!(
12467            verdict_for(&rules, "git push --force").unwrap().verdict,
12468            "deny"
12469        );
12470        assert_eq!(
12471            verdict_for(&rules, "git push origin x").unwrap().verdict,
12472            "ask"
12473        );
12474        assert!(verdict_for(&rules, "cargo test").is_none());
12475        let call = hook_call(
12476            r#"{"hook_event_name":"PreToolUse","tool_input":{"command":"git push --force"}}"#,
12477        );
12478        let out = hook_output_ruled(&call, "", verdict_for(&rules, &call.cue));
12479        let v: Value = serde_json::from_str(out.trim()).unwrap();
12480        assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
12481        assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
12482            .as_str()
12483            .unwrap()
12484            .contains("Never force push"));
12485        assert!(v["hookSpecificOutput"].get("additionalContext").is_none());
12486        let argv = HookCall {
12487            event: "argv".into(),
12488            cue: "git push origin x".into(),
12489            session: None,
12490            shape: HookShape::Asks,
12491        };
12492        assert!(
12493            hook_output_ruled(&argv, "", verdict_for(&rules, &argv.cue)).starts_with("ask: A push")
12494        );
12495        // grok: camelCase in, a top-level decision out.
12496        let grok = hook_call(
12497            r#"{"hookEventName":"pre_tool_use","sessionId":"g-1","toolName":"run_terminal_command","toolInput":{"command":"git push --force"}}"#,
12498        );
12499        assert_eq!(grok.shape, HookShape::CamelCase);
12500        assert_eq!(grok.event, "PreToolUse");
12501        assert_eq!(grok.cue, "git push --force");
12502        let v: Value = serde_json::from_str(
12503            hook_output_ruled(&grok, "", verdict_for(&rules, &grok.cue)).trim(),
12504        )
12505        .unwrap();
12506        assert_eq!(v["decision"], "deny");
12507        assert!(v["reason"].as_str().unwrap().contains("Never force push"));
12508        // Lower-case events: the prompt under extra, answers at the top.
12509        let turn = hook_call(
12510            r#"{"hook_event_name":"pre_llm_call","tool_name":null,"tool_input":null,"session_id":"h-1","extra":{"user_message":"fix the fuse"}}"#,
12511        );
12512        assert_eq!(turn.shape, HookShape::Context);
12513        assert_eq!(turn.event, "UserPromptSubmit");
12514        assert_eq!(turn.cue, "fix the fuse");
12515        let v: Value =
12516            serde_json::from_str(hook_output_ruled(&turn, "- [lesson] x", None).trim()).unwrap();
12517        assert_eq!(v["context"], "- [lesson] x");
12518        assert!(v.get("hookSpecificOutput").is_none());
12519        let tool = hook_call(
12520            r#"{"hook_event_name":"pre_tool_call","tool_name":"terminal","tool_input":{"command":"git push origin x"},"session_id":"h-1","extra":{}}"#,
12521        );
12522        assert_eq!(tool.event, "PreToolUse");
12523        let v: Value = serde_json::from_str(
12524            hook_output_ruled(&tool, "", verdict_for(&rules, &tool.cue)).trim(),
12525        )
12526        .unwrap();
12527        assert_eq!(v["decision"], "block");
12528        assert!(v["reason"]
12529            .as_str()
12530            .unwrap()
12531            .starts_with("ask the person before running this"));
12532        assert_eq!(
12533            hook_call(r#"{"hook_event_name":"on_session_end","session_id":"h-1","extra":{}}"#)
12534                .event,
12535            "TurnEnd"
12536        );
12537        assert_eq!(
12538            hook_call(r#"{"hook_event_name":"on_session_finalize","session_id":"h-1","extra":{}}"#)
12539                .event,
12540            "SessionEnd"
12541        );
12542        // An ask on a runner that cannot ask stops the tool.
12543        let deny_only = hook_call(
12544            r#"{"hook_event_name":"PreToolUse","session_id":"c-1","turn_id":"t-1","tool_name":"Bash","tool_input":{"command":"git push origin x"}}"#,
12545        );
12546        assert_eq!(deny_only.shape, HookShape::DenyOnly);
12547        let v: Value = serde_json::from_str(
12548            hook_output_ruled(&deny_only, "", verdict_for(&rules, &deny_only.cue)).trim(),
12549        )
12550        .unwrap();
12551        assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
12552        assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
12553            .as_str()
12554            .unwrap()
12555            .starts_with("ask the person before running this: A push"));
12556        assert!(v.get("decision").is_none());
12557        let asks = hook_call(
12558            r#"{"hook_event_name":"PreToolUse","session_id":"k-1","tool_name":"Bash","tool_input":{"command":"git push origin x"}}"#,
12559        );
12560        let v: Value = serde_json::from_str(
12561            hook_output_ruled(&asks, "", verdict_for(&rules, &asks.cue)).trim(),
12562        )
12563        .unwrap();
12564        assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "ask");
12565        let steps = panel_steps("x-1", true, &[], &[]);
12566        assert!(steps.is_empty());
12567        let preds = vec![
12568            Prediction {
12569                issue: "x-1".into(),
12570                agent: "a".into(),
12571                expect: Value::String("ship".into()),
12572            },
12573            Prediction {
12574                issue: "x-1".into(),
12575                agent: "b".into(),
12576                expect: serde_json::json!({"ship": 0.6, "hold": 0.4}),
12577            },
12578        ];
12579        let steps = panel_steps("x-1", true, &[row("a", "b", 0.5)], &preds);
12580        assert_eq!(steps.len(), 2);
12581        assert_eq!(steps[0].args[0], "surprising");
12582        assert_eq!(steps[1].args[0], "reputation");
12583    }
12584
12585    /// A scoped row applies when the issue is about one of its domains; an
12586    /// unscoped row applies everywhere; a scoped learn starts from the
12587    /// unscoped row and leaves it standing.
12588    #[test]
12589    fn scoped_rows_apply_to_their_topic_and_learn_writes_in_scope() {
12590        let everywhere = row("a", "b", 0.9);
12591        let mut on_docs = row("a", "b", 0.2);
12592        on_docs.about = vec!["docs".into()];
12593        let rows = vec![everywhere.clone(), on_docs.clone()];
12594        let topic = topic_words("Rewrite the docs site");
12595        assert_eq!(topic, ["docs", "rewrite", "site", "the"]);
12596        // On the docs topic the scoped row stands in for the unscoped one;
12597        // elsewhere the unscoped row is the one that applies.
12598        assert_eq!(rows_about(&rows, &topic), vec![on_docs.clone()]);
12599        assert_eq!(
12600            rows_about(&rows, &topic_words("Fix the fuse")),
12601            vec![everywhere.clone()]
12602        );
12603
12604        let ballots = vec![
12605            ("a".to_string(), "ship".to_string()),
12606            ("b".to_string(), "hold".to_string()),
12607        ];
12608        let learned = learn_about(&ballots, "ship", &rows, 0.5, &["fuse".to_string()]).unwrap();
12609        let ab = learned
12610            .iter()
12611            .find(|r| r.from == "a" && r.to == "b")
12612            .unwrap();
12613        assert_eq!(ab.about, ["fuse"]);
12614        assert!(
12615            (ab.weight - 0.45).abs() < 1e-9,
12616            "starts from the unscoped 0.9: {ab:?}"
12617        );
12618        let ba = learned
12619            .iter()
12620            .find(|r| r.from == "b" && r.to == "a")
12621            .unwrap();
12622        assert!((ba.weight - 1.0).abs() < 1e-9, "a was right: {ba:?}");
12623
12624        // Rows read back keep scoped and unscoped apart, latest per scope.
12625        let atoms = vec![
12626            trust_atom(&everywhere, &[], "ws").unwrap(),
12627            trust_atom(&on_docs, &[], "ws").unwrap(),
12628        ];
12629        let mut back = trust_rows(&atoms);
12630        back.sort_by(|x, y| x.about.cmp(&y.about));
12631        assert_eq!(back, vec![everywhere, on_docs]);
12632    }
12633
12634    /// A persona is a voter with an anchor; the latest atom per name wins and
12635    /// the anchors go to the settle as one object.
12636    #[test]
12637    fn personas_are_latest_per_name_and_anchor_the_settle() {
12638        let p = Persona {
12639            name: "reviewer".into(),
12640            anchor: 0.2,
12641            view: "Reads for what could break in production.".into(),
12642            entities: vec!["Release".into()],
12643        };
12644        let mut a = persona_atom(&p, "ws").unwrap();
12645        a["ts"] = Value::String("2026-01-01T00:00:00Z".into());
12646        let mut later = a.clone();
12647        later["anchor"] = serde_json::json!(0.4);
12648        later["ts"] = Value::String("2026-02-01T00:00:00Z".into());
12649        let got = personas_of(&[a, later]);
12650        assert_eq!(got.len(), 1);
12651        assert_eq!(got[0].anchor, 0.4);
12652        assert_eq!(got[0].entities, ["release"]);
12653        assert_eq!(anchors_json(&got), r#"{"reviewer":0.4}"#);
12654        // A refuted persona listens more next time; a vindicated one does
12655        // not move; one that did not vote is untouched.
12656        let ballots = vec![
12657            ("reviewer".to_string(), "hold".to_string()),
12658            ("reader".to_string(), "ship".to_string()),
12659        ];
12660        let moved = learn_anchors(&got, &ballots, "ship", 0.5);
12661        assert_eq!(moved.len(), 1);
12662        assert!(
12663            (moved[0].anchor - 0.7).abs() < 1e-9,
12664            "0.4 + 0.6 * 0.5: {moved:?}"
12665        );
12666        assert!(learn_anchors(&got, &ballots, "hold", 0.5).is_empty());
12667        assert!(persona_atom(
12668            &Persona {
12669                anchor: 1.5,
12670                ..p.clone()
12671            },
12672            "ws"
12673        )
12674        .is_err());
12675        let steps = consensus_steps_anchored("x-1", true, true, &[], &got).unwrap();
12676        for step in &steps {
12677            assert!(
12678                step.args.contains(&"--susceptibility-of".to_string()),
12679                "{step:?}"
12680            );
12681        }
12682        // The kind of work sets the dynamics: a broad-audience issue runs
12683        // bounded confidence on the model crate, and the tracker verb, which
12684        // has no such model, is left as it was.
12685        let broad =
12686            consensus_steps_for("x-1", true, true, &[], &got, &["broad".to_string()]).unwrap();
12687        assert!(
12688            broad[0].args.contains(&"--epsilon".to_string()),
12689            "{:?}",
12690            broad[0]
12691        );
12692        assert!(
12693            !broad[1].args.contains(&"--epsilon".to_string()),
12694            "{:?}",
12695            broad[1]
12696        );
12697        assert!(settle_flags_for(&["feature".to_string()]).is_empty());
12698    }
12699
12700    /// Playbooks are kind playbook, latest per name, unreviewed; sitting
12701    /// copies the full body; a second name on a live sitting is refused;
12702    /// the inbound floor is unscoped.
12703    #[test]
12704    fn playbooks_are_latest_per_name_and_stick_until_finish() {
12705        let _g = env_guard();
12706        let dir = std::env::temp_dir().join(format!("ljos-playbook-{}", std::process::id()));
12707        let _ = std::fs::remove_dir_all(&dir);
12708        std::fs::create_dir_all(&dir).unwrap();
12709        let before = std::env::var_os("XDG_RUNTIME_DIR");
12710        unsafe {
12711            std::env::set_var("XDG_RUNTIME_DIR", &dir);
12712        }
12713        let shipped = shipped_playbooks();
12714        let names: Vec<&str> = shipped.iter().map(|p| p.name.as_str()).collect();
12715        assert_eq!(names, SHIPPED_PLAYBOOK_NAMES);
12716        for p in shipped_playbooks() {
12717            assert!(!p.body.is_empty(), "{}", p.name);
12718            assert!(
12719                !p.body.contains("/poteto-mode") && !p.body.contains("poteto-agent"),
12720                "{}",
12721                p.name
12722            );
12723            let atom = playbook_atom(&p, "ws").unwrap();
12724            assert_eq!(atom["kind"], "playbook");
12725            assert_eq!(atom["name"], p.name);
12726            assert_eq!(atom["text"], p.body);
12727            assert!(!super::reviewable(&atom), "{}", p.name);
12728        }
12729        assert!(playbook_atom(
12730            &Playbook {
12731                name: "sit".into(),
12732                body: "  ".into(),
12733                models: vec![],
12734            },
12735            "ws"
12736        )
12737        .is_err());
12738        let mut a = playbook_atom(
12739            &Playbook {
12740                name: "sit".into(),
12741                body: "first body".into(),
12742                models: vec![],
12743            },
12744            "ws",
12745        )
12746        .unwrap();
12747        a["ts"] = Value::String("2026-01-01T00:00:00Z".into());
12748        let mut later = a.clone();
12749        later["text"] = Value::String("second body".into());
12750        later["ts"] = Value::String("2026-02-01T00:00:00Z".into());
12751        let got = playbooks_of(&[a, later]);
12752        assert_eq!(got.len(), 1);
12753        assert_eq!(got[0].body, "second body");
12754        let copy = copy_playbook("proj-1a2b", "sit").unwrap();
12755        assert!(copy.starts_with("sit\n"), "{copy}");
12756        assert!(copy.contains("Grade due claims"), "{copy}");
12757        assert_eq!(bound_playbook("proj-1a2b").as_deref(), Some("sit"));
12758        let err = bind_playbook("proj-1a2b", "arena").unwrap_err().to_string();
12759        assert!(err.contains("bound to sit"), "{err}");
12760        assert!(err.contains("new sitting"), "{err}");
12761        let again = playbook_opening("proj-1a2b", None).unwrap();
12762        assert!(again.contains("Grade due claims"), "{again}");
12763        let blocks = brief_playbook_blocks("proj-1a2b");
12764        assert!(blocks.contains("== playbook"), "{blocks}");
12765        assert!(blocks.contains("Grade due claims"), "{blocks}");
12766        assert!(blocks.contains("== principles"), "{blocks}");
12767        assert!(blocks.contains("split-fence"), "{blocks}");
12768        assert!(blocks.contains("== rubric"), "{blocks}");
12769        assert!(blocks.contains("Ledger intact"), "{blocks}");
12770        drop_playbook("proj-1a2b");
12771        assert_eq!(bound_playbook("proj-1a2b"), None);
12772        let none = playbook_opening("proj-1a2b", None).unwrap();
12773        assert!(none.contains("none bound"), "{none}");
12774        assert!(none.contains("panel is refused"), "{none}");
12775        let err = panel("proj-1a2b", &dir.join("panel"))
12776            .unwrap_err()
12777            .to_string();
12778        assert!(err.contains("no playbook bound"), "{err}");
12779        let p = Persona {
12780            name: "reviewer".into(),
12781            anchor: 0.2,
12782            view: "Reads for what could break.".into(),
12783            entities: vec!["docs".into()],
12784        };
12785        let floor = inbound_floor(&p, "seat").unwrap();
12786        assert_eq!(floor.from, "seat");
12787        assert_eq!(floor.to, "reviewer");
12788        assert!((floor.weight - 1.0).abs() < 1e-9);
12789        assert!(floor.about.is_empty());
12790        assert!(inbound_floor(&p, "reviewer").is_none());
12791        assert!(has_unscoped_inbound(
12792            std::slice::from_ref(&floor),
12793            "reviewer",
12794            "seat"
12795        ));
12796        let scoped = Trust {
12797            about: vec!["docs".into()],
12798            ..floor
12799        };
12800        assert!(!has_unscoped_inbound(
12801            std::slice::from_ref(&scoped),
12802            "reviewer",
12803            "seat"
12804        ));
12805        let other = Trust {
12806            from: "other".into(),
12807            to: "reviewer".into(),
12808            weight: 1.0,
12809            about: Vec::new(),
12810        };
12811        assert!(
12812            !has_unscoped_inbound(std::slice::from_ref(&other), "reviewer", "seat"),
12813            "a third-party unscoped row is not the seat floor"
12814        );
12815        let arena_pb = shipped_playbooks()
12816            .into_iter()
12817            .find(|p| p.name == "arena")
12818            .unwrap();
12819        let arena = format_playbook_copy(&arena_pb);
12820        assert!(
12821            arena.contains("spawn hints (optional): judgment, instruction, fast"),
12822            "{arena}"
12823        );
12824        assert!(arena.contains("ljos vote --as"), "{arena}");
12825        assert!(
12826            COMPANY_PANEL_BODY.contains("--expect"),
12827            "a panel ballot carries the private forecast: {COMPANY_PANEL_BODY}"
12828        );
12829        match before {
12830            Some(v) => unsafe { std::env::set_var("XDG_RUNTIME_DIR", v) },
12831            None => unsafe { std::env::remove_var("XDG_RUNTIME_DIR") },
12832        }
12833        let _ = std::fs::remove_dir_all(&dir);
12834    }
12835
12836    #[test]
12837    fn playbook_note_latest_wins_and_empty_rest_drops() {
12838        let v = serde_json::json!({
12839            "logbook": [
12840                {"note": "playbook: land", "timestamp": "2026-09-21"},
12841                {"note": "playbook: sit", "timestamp": "2026-09-20"},
12842                {"note": "progress", "timestamp": "2026-09-19"}
12843            ]
12844        });
12845        assert_eq!(playbook_name_from_issue(&v).as_deref(), Some("land"));
12846        let empty = serde_json::json!({"logbook": []});
12847        assert_eq!(playbook_name_from_issue(&empty), None);
12848        let dropped = serde_json::json!({
12849            "logbook": [
12850                {"note": "playbook:", "timestamp": "2026-09-22T00:00:00Z"},
12851                {"note": "playbook: sit", "timestamp": "2026-09-21T00:00:00Z"}
12852            ]
12853        });
12854        assert_eq!(playbook_name_from_issue(&dropped), None);
12855        let undated = serde_json::json!({
12856            "logbook": [
12857                {"note": "playbook:"},
12858                {"note": "playbook: sit"}
12859            ]
12860        });
12861        assert_eq!(
12862            playbook_name_from_issue(&undated),
12863            None,
12864            "newest-first empty rest drops without walking back"
12865        );
12866    }
12867
12868    #[test]
12869    fn playbook_from_title_matches_a_closed_name_else_sit() {
12870        assert_eq!(playbook_from_title("Seat playbooks: routing"), "sit");
12871        assert_eq!(playbook_from_title("x5jz compose: land B"), "land");
12872        assert_eq!(
12873            playbook_from_title("Run the company-panel overnight"),
12874            "company-panel"
12875        );
12876        assert_eq!(playbook_from_title("sitting on a ticket"), "sit");
12877        assert_eq!(playbook_from_title("arena then compose"), "arena");
12878        assert_eq!(
12879            playbook_from_title("Benny and poteto-mode"),
12880            "sit",
12881            "title-match binds only closed-set tokens"
12882        );
12883    }
12884
12885    #[test]
12886    fn playbook_among_pack_latest_wins_and_unknown_names_are_refused() {
12887        let rewritten = Playbook {
12888            name: "sit".into(),
12889            body: "rewritten sit body".into(),
12890            models: vec![],
12891        };
12892        let got = playbook_among("sit", std::slice::from_ref(&rewritten)).unwrap();
12893        assert_eq!(got.body, "rewritten sit body");
12894        let seed = playbook_among("sit", &[]).unwrap();
12895        assert!(
12896            seed.body.contains("Grade due claims"),
12897            "shipped seed when the pack has no live atom: {}",
12898            seed.body
12899        );
12900        let err = playbook_among("Benny", &[]).unwrap_err().to_string();
12901        assert!(err.contains("unknown"), "{err}");
12902        let sneaky = Playbook {
12903            name: "poteto-mode".into(),
12904            body: "second roster".into(),
12905            models: vec![],
12906        };
12907        let err = playbook_among("poteto-mode", std::slice::from_ref(&sneaky))
12908            .unwrap_err()
12909            .to_string();
12910        assert!(err.contains("unknown"), "{err}");
12911        assert!(playbook_atom(&sneaky, "ws").is_err());
12912        assert!(parse_playbook_name("overnight").is_ok());
12913        assert!(parse_playbook_name("company-panel").is_ok());
12914        let listed = playbooks_of(&[serde_json::json!({
12915            "kind": "playbook",
12916            "name": "Benny",
12917            "text": "no",
12918            "ts": "2026-01-01T00:00:00Z"
12919        })]);
12920        assert!(listed.is_empty(), "{listed:?}");
12921        let err = bind_playbook("proj-1a2b", "Benny").unwrap_err().to_string();
12922        assert!(err.contains("unknown"), "{err}");
12923    }
12924
12925    #[test]
12926    fn sitting_resolves_asked_else_bound_else_title_else_sit() {
12927        let _g = env_guard();
12928        let dir =
12929            std::env::temp_dir().join(format!("ljos-playbook-resolve-{}", std::process::id()));
12930        let _ = std::fs::remove_dir_all(&dir);
12931        std::fs::create_dir_all(&dir).unwrap();
12932        let before = std::env::var_os("XDG_RUNTIME_DIR");
12933        unsafe {
12934            std::env::set_var("XDG_RUNTIME_DIR", &dir);
12935        }
12936        assert_eq!(
12937            resolve_sitting_playbook("proj-1a2b", "Seat playbooks", Some("arena")).unwrap(),
12938            "arena"
12939        );
12940        assert_eq!(
12941            resolve_sitting_playbook("proj-1a2b", "x5jz compose: land B", None).unwrap(),
12942            "land"
12943        );
12944        assert_eq!(
12945            resolve_sitting_playbook("proj-1a2b", "Ship the fuse change?", None).unwrap(),
12946            "sit"
12947        );
12948        bind_playbook("proj-1a2b", "sit").unwrap();
12949        assert_eq!(
12950            resolve_sitting_playbook("proj-1a2b", "x5jz compose: land B", None).unwrap(),
12951            "sit",
12952            "sticky wins over title"
12953        );
12954        drop_playbook("proj-1a2b");
12955        assert_eq!(bound_playbook("proj-1a2b"), None);
12956        match before {
12957            Some(v) => unsafe { std::env::set_var("XDG_RUNTIME_DIR", v) },
12958            None => unsafe { std::env::remove_var("XDG_RUNTIME_DIR") },
12959        }
12960        let _ = std::fs::remove_dir_all(&dir);
12961    }
12962
12963    /// A forecast is weighed on its ballot and never comes up for review.
12964    #[test]
12965    fn a_prediction_is_never_due() {
12966        let atoms = vec![
12967            serde_json::json!({"id": "f", "kind": "prediction", "text": "brio expects ship on acme-1."}),
12968            serde_json::json!({"id": "l", "kind": "lesson", "text": "a lesson"}),
12969        ];
12970        let due: Vec<String> = super::due_of(&atoms, "2026-01-01T00:00:00Z")
12971            .iter()
12972            .map(|a| a["id"].as_str().unwrap().to_string())
12973            .collect();
12974        assert_eq!(due, vec!["l"]);
12975    }
12976
12977    /// A claim that never entered the clock is due now; a scheduled one is
12978    /// not; trust rows never are; and the summary says whether the clock runs.
12979    #[test]
12980    fn unreviewed_claims_are_due_and_the_summary_says_if_the_clock_runs() {
12981        let atoms = vec![
12982            serde_json::json!({"id": "a", "kind": "conclusion", "text": "old", "due_at": ""}),
12983            serde_json::json!({"id": "b", "kind": "conclusion", "text": "older"}),
12984            serde_json::json!({"id": "c", "kind": "conclusion", "text": "later",
12985                "due_at": "2030-01-01T00:00:00Z"}),
12986            serde_json::json!({"id": "d", "kind": "conclusion", "text": "past",
12987                "due_at": "2020-01-01T00:00:00Z"}),
12988            serde_json::json!({"id": "t", "kind": "trust", "text": "x weighs y"}),
12989            serde_json::json!({"id": "p", "kind": "playbook", "text": "sit recipe", "name": "sit"}),
12990        ];
12991        let now = "2026-01-01T00:00:00Z";
12992        let due: Vec<String> = super::due_of(&atoms, now)
12993            .iter()
12994            .map(|a| a["id"].as_str().unwrap().to_string())
12995            .collect();
12996        assert_eq!(
12997            due,
12998            ["a", "b", "d"],
12999            "unreviewed first, then the past-due one"
13000        );
13001        assert_eq!(
13002            super::review_summary(&atoms, now),
13003            "3 due; 1 scheduled, next at 2030-01-01T00:00:00Z"
13004        );
13005        assert_eq!(
13006            super::review_summary(&[atoms[4].clone()], now),
13007            "0 due; nothing scheduled: this seat has remembered nothing yet"
13008        );
13009        assert!(super::format_due(&super::due_of(&atoms, now)).starts_with("unreviewed\t"));
13010    }
13011
13012    #[test]
13013    fn bumping_mcp_generation_respawns_without_rewriting_the_entry() {
13014        let dir = std::env::temp_dir().join(format!("ljos-gen-{}", std::process::id()));
13015        let _ = std::fs::remove_dir_all(&dir);
13016        std::fs::create_dir_all(&dir).expect("tempdir");
13017        let config = dir.join("config.toml");
13018        std::fs::write(
13019            &config,
13020            "[mcp_servers.ljos.env]\nLJOS_MCP_GENERATION = \"0.12.8\"\n",
13021        )
13022        .expect("write");
13023        let bumped = super::bump_ljos_mcp_generation(&config, "0.13.1", false)
13024            .expect("bumps")
13025            .expect("changed");
13026        assert_eq!(bumped, "0.13.1");
13027        let text = std::fs::read_to_string(&config).expect("read");
13028        assert!(text.contains("LJOS_MCP_GENERATION = \"0.13.1\""), "{text}");
13029        assert!(!text.contains("0.12.8"), "{text}");
13030        assert!(
13031            super::bump_ljos_mcp_generation(&config, "0.13.1", false)
13032                .expect("second")
13033                .is_none(),
13034            "a matching generation is left alone"
13035        );
13036        let _ = std::fs::remove_dir_all(&dir);
13037    }
13038
13039    #[test]
13040    fn a_client_name_listed_on_a_harness_is_that_runners_seat() {
13041        let dir = std::env::temp_dir().join(format!("ljos-clients-{}", std::process::id()));
13042        std::fs::create_dir_all(&dir).unwrap();
13043        let file = dir.join("harnesses.toml");
13044        std::fs::write(
13045            &file,
13046            "[[harness]]\nname = \"acme\"\nclients = [\"acme-mcp-client\"]\n\n[[harness]]\nname = \"brio\"\nclients = [\"brio-coding-agent\"]\n",
13047        )
13048        .unwrap();
13049        assert_eq!(
13050            runner_for_client(&file, "acme-mcp-client").as_deref(),
13051            Some("acme")
13052        );
13053        assert_eq!(
13054            runner_for_client(&file, &seat_slug("brio-coding-agent")).as_deref(),
13055            Some("brio")
13056        );
13057        assert!(runner_for_client(&file, "acme-cli").is_none());
13058        assert!(runner_for_client(&dir.join("absent.toml"), "acme-mcp-client").is_none());
13059        let _ = std::fs::remove_dir_all(&dir);
13060    }
13061
13062    #[test]
13063    fn an_issues_tags_are_words_it_speaks_in() {
13064        let v: Value = serde_json::from_str(r#"{"tags":["Decision","sharing","memory"]}"#).unwrap();
13065        assert_eq!(tags_of(&v), vec!["decision", "sharing", "memory"]);
13066        assert!(tags_of(&serde_json::json!({})).is_empty());
13067    }
13068
13069    #[test]
13070    fn a_jev_panel_stands_only_when_every_seat_is_sure_and_agrees() {
13071        let b = |choice: &str, confidence: f64| jev::Ballot {
13072            choice: choice.into(),
13073            confidence,
13074            probabilities: Default::default(),
13075            forecast: Default::default(),
13076            escalate_below: 0.8,
13077        };
13078        assert!(jev_panel_stands(&[b("age", 0.95), b("age", 0.9)]));
13079        assert!(!jev_panel_stands(&[b("age", 0.95), b("gpg", 0.9)]), "split");
13080        assert!(
13081            !jev_panel_stands(&[b("age", 0.95), b("age", 0.6)]),
13082            "one unsure"
13083        );
13084        assert!(!jev_panel_stands(&[]));
13085    }
13086
13087    #[test]
13088    fn a_turn_is_read_from_the_last_request_to_the_final_message() {
13089        let lines = [
13090            r#"{"type":"user","message":{"content":"old request"}}"#,
13091            r#"{"type":"user","message":{"content":"fix the parser and test it"}}"#,
13092            r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"cargo test -p brio"}}]}}"#,
13093            r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"test result: FAILED. 3 passed; 1 failed"}]}}"#,
13094            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"All done, the parser works."}]}}"#,
13095        ]
13096        .join("\n");
13097        let t = stop_turn_from_transcript(&lines);
13098        assert_eq!(t.request, "fix the parser and test it");
13099        assert!(t.test_ran);
13100        assert_eq!(t.commands, vec!["cargo test -p brio"]);
13101        assert!(t.outputs[0].contains("1 failed"));
13102        assert_eq!(t.final_message, "All done, the parser works.");
13103        assert!(t.state().contains("The agent's final message:\nAll done"));
13104        assert!(!runs_tests("git status"));
13105    }
13106
13107    #[test]
13108    fn a_hold_the_multiplexer_owns_names_no_conversation_under_it() {
13109        let dir = tempfile::tempdir().unwrap();
13110        let hold = |name: &str, holder: &str, pid: u32, comm: &str, at: &str, node: &str| {
13111            std::fs::write(
13112                dir.path().join(format!("hold-{name}")),
13113                format!("{holder}\nseat\n{pid}\n{comm}\n{at}\n{node}\n"),
13114            )
13115            .unwrap();
13116        };
13117        // Another session's command lost its runner and recorded the
13118        // multiplexer, newest of all.
13119        hold(
13120            "other",
13121            "sess-other",
13122            3142,
13123            "herdr",
13124            "2026-09-29T09:16:06Z",
13125            "acme-5i5r",
13126        );
13127        // This conversation's runner holds its own issue.
13128        hold(
13129            "mine",
13130            "sess-mine",
13131            4901,
13132            "acme",
13133            "2026-09-29T08:00:00Z",
13134            "brio-k6yq",
13135        );
13136        let chain = [
13137            (9001, "ljos".to_string()),
13138            (9000, "sh".to_string()),
13139            (4901, "acme".to_string()),
13140        ];
13141        assert_eq!(
13142            held_from_records_in(&[], dir.path(), &chain).as_deref(),
13143            Some("brio-k6yq"),
13144            "the runner's own record, not the multiplexer's"
13145        );
13146        let under_herdr = [(9001, "ljos".to_string()), (3142, "herdr".to_string())];
13147        assert_eq!(held_from_records_in(&[], dir.path(), &under_herdr), None);
13148        assert_eq!(
13149            held_from_records_in(&["sess-other".to_string()], dir.path(), &under_herdr).as_deref(),
13150            Some("acme-5i5r"),
13151            "a holder named outright still matches"
13152        );
13153        assert!(is_session("herdr") && is_session("tmux: server") && !is_session("acme"));
13154    }
13155
13156    #[test]
13157    fn a_generic_domain_gives_way_to_a_specific_one() {
13158        let persona = |name: &str, about: &[&str]| Persona {
13159            name: name.into(),
13160            anchor: 0.5,
13161            view: String::new(),
13162            entities: about.iter().map(|s| (*s).to_string()).collect(),
13163        };
13164        let pack = vec![
13165            persona("agentuser", &["seat", "hook"]),
13166            persona("build-meson", &["eon", "build"]),
13167        ];
13168        let words = |t: &str| topic_words(t);
13169        let seated = |t: &str| -> Vec<String> {
13170            personas_speaking_to(&pack, &words(t))
13171                .into_iter()
13172                .map(|p| p.name)
13173                .collect()
13174        };
13175        assert_eq!(
13176            seated("Which Jev hook integration to build next"),
13177            vec!["agentuser"]
13178        );
13179        assert_eq!(seated("Meson build breaks on Windows"), vec!["build-meson"]);
13180        assert_eq!(
13181            seated("eOn build flags"),
13182            vec!["build-meson"],
13183            "eon is specific"
13184        );
13185    }
13186
13187    #[test]
13188    fn options_come_from_a_line_or_its_bullets() {
13189        assert_eq!(
13190            issue_options("Why.\nOptions: age, gpg\n"),
13191            vec!["age", "gpg"]
13192        );
13193        assert_eq!(issue_options("Options:\n- a\n- b\n\nmore"), vec!["a", "b"]);
13194        assert!(
13195            issue_options("Options: only").is_empty(),
13196            "one option is no vote"
13197        );
13198        assert!(issue_options("no options").is_empty());
13199    }
13200
13201    #[test]
13202    fn a_decision_is_a_tag_a_type_or_an_options_line() {
13203        let v = |j: &str| -> Value { serde_json::from_str(j).unwrap() };
13204        assert!(is_decision(&v(r#"{"tags":["seat","decision"]}"#)));
13205        assert!(is_decision(&v(r#"{"properties":{"TYPE":"decision"}}"#)));
13206        assert!(is_decision(&v(
13207            r#"{"body":"Evidence.\n\nOptions:\n- a\n- b"}"#
13208        )));
13209        assert!(!is_decision(&v(
13210            r#"{"tags":["bug"],"properties":{"TYPE":"task"},"body":"no options here"}"#
13211        )));
13212        assert!(!is_decision(&v(
13213            r#"{"body":"We weighed the Options: none"}"#
13214        )));
13215    }
13216
13217    #[test]
13218    fn a_probe_passes_only_when_the_runner_lists_ljos() {
13219        let s = |v: &[&str]| v.iter().map(|x| (*x).to_string()).collect::<Vec<_>>();
13220        assert!(probe_lists_ljos(&s(&["sh", "-c", "echo '  ljos_sitting   Call this'"])).is_ok());
13221        assert!(probe_lists_ljos(&s(&["sh", "-c", "echo 'MCP SDK not installed'"])).is_err());
13222        assert!(probe_lists_ljos(&s(&["sh", "-c", "echo ljos_sitting; exit 3"])).is_err());
13223        assert!(probe_lists_ljos(&s(&["/nonexistent/runner"])).is_err());
13224        let all: super::Harnesses = toml::from_str(super::HARNESSES_EXAMPLE).expect("parses");
13225        let hermes = all.harness.iter().find(|h| h.name == "hermes").unwrap();
13226        assert_eq!(hermes.probe, s(&["hermes", "mcp", "test", "ljos"]));
13227    }
13228
13229    #[test]
13230    fn a_plugin_runner_gets_its_bundled_plugin_with_ljos_filled() {
13231        let all: super::Harnesses = toml::from_str(super::HARNESSES_EXAMPLE).expect("parses");
13232        for name in ["opencode", "omp"] {
13233            let h = all.harness.iter().find(|h| h.name == name).expect(name);
13234            assert!(h.plugin.is_some(), "{name} names a plugin path");
13235            let text = super::plugin_text(h, Path::new("/opt/seat/bin/ljos")).expect(name);
13236            assert!(text.contains("\"/opt/seat/bin/ljos\""), "{name}");
13237            assert!(!text.contains("{ljos}"), "{name}");
13238            assert!(
13239                text.contains("PreToolUse") && text.contains("UserPromptSubmit"),
13240                "{name}"
13241            );
13242        }
13243        let unknown = super::Harness {
13244            name: "x".into(),
13245            plugin: Some("/tmp/x.ts".into()),
13246            plugin_template: Some("nobody".into()),
13247            ..Default::default()
13248        };
13249        assert!(super::plugin_text(&unknown, Path::new("/l")).is_none());
13250        let step = super::plugin_step(&unknown, Path::new("/tmp/x.ts"), true);
13251        assert!(!step.ok, "an unknown template writes nothing: {step:?}");
13252    }
13253
13254    /// The example file parses, and onboarding a config-file runner from it
13255    /// appends the entry once and writes the skill once; a dry run writes
13256    /// nothing; an unnamed runner is refused with the names the file holds.
13257    #[test]
13258    fn onboarding_a_config_file_runner_writes_once() {
13259        let all: super::Harnesses = toml::from_str(super::HARNESSES_EXAMPLE).expect("parses");
13260        // Three shapes, then the four runners this seat has carried.
13261        assert_eq!(all.harness.len(), 7);
13262        assert!(all.harness[3..].iter().all(|h| h.register.len()
13263            + usize::from(h.config.is_some())
13264            + usize::from(h.config_json.is_some())
13265            > 0));
13266        assert_eq!(all.harness[1].marker.as_deref(), Some("[mcp_servers.ljos]"));
13267        assert_eq!(all.harness[2].json_pointer.as_deref(), Some("/mcp/ljos"));
13268
13269        let dir = std::env::temp_dir().join(format!("ljos-onboard-{}", std::process::id()));
13270        let _ = std::fs::remove_dir_all(&dir);
13271        std::fs::create_dir_all(&dir).expect("tempdir");
13272        let config = dir.join("config.toml");
13273        let skills = dir.join("skills");
13274        let file = dir.join("harnesses.toml");
13275        std::fs::write(
13276            &file,
13277            format!(
13278                "[[harness]]\nname = \"r\"\nconfig = {config:?}\nmarker = \"[mcp_servers.ljos]\"\n\
13279                 snippet = \"\\n[mcp_servers.ljos]\\ncommand = \\\"{{server}}\\\"\\n\"\nskills = {skills:?}\n",
13280                config = config.display().to_string(),
13281                skills = skills.display().to_string(),
13282            ),
13283        )
13284        .expect("write");
13285
13286        let refused = super::onboard_from(&file, "nobody", true)
13287            .unwrap_err()
13288            .to_string();
13289        assert!(
13290            refused.contains("no runner \"nobody\"") && refused.contains("names r"),
13291            "{refused}"
13292        );
13293
13294        let steps = match super::onboard_from(&file, "r", true) {
13295            Ok(steps) => steps,
13296            // Without ljos-mcp on PATH there is nothing to register; the
13297            // refusal says so and the rest of the check needs the binary.
13298            Err(e) => {
13299                assert!(e.to_string().contains("ljos-mcp not on PATH"), "{e}");
13300                return;
13301            }
13302        };
13303        assert!(steps.iter().all(|s| s.ok), "{steps:?}");
13304        assert!(
13305            steps[0].detail.starts_with("would append"),
13306            "{}",
13307            steps[0].detail
13308        );
13309        assert!(!config.exists() && !skills.exists(), "a dry run wrote");
13310
13311        let steps = super::onboard_from(&file, "r", false).expect("onboards");
13312        assert!(steps.iter().all(|s| s.ok), "{steps:?}");
13313        let written = std::fs::read_to_string(&config).expect("config written");
13314        assert_eq!(written.matches("[mcp_servers.ljos]").count(), 1);
13315        assert!(written.contains("ljos-mcp"), "{written}");
13316        let skill = std::fs::read_to_string(skills.join("ljos/SKILL.md")).expect("skill written");
13317        assert!(skill.starts_with("---\nname: ljos\n"));
13318        assert!(skill.contains("## Before the work"));
13319
13320        let again = super::onboard_from(&file, "r", false).expect("onboards again");
13321        assert_eq!(again[0].detail, "ljos registered");
13322        assert!(
13323            again[1].detail.ends_with("is current"),
13324            "{}",
13325            again[1].detail
13326        );
13327        assert_eq!(
13328            std::fs::read_to_string(&config)
13329                .expect("config")
13330                .matches("[mcp_servers.ljos]")
13331                .count(),
13332            1,
13333            "the entry was appended twice"
13334        );
13335        let _ = std::fs::remove_dir_all(&dir);
13336    }
13337
13338    #[test]
13339    fn grok_onboard_names_the_frozen_hook_file() {
13340        let file = std::env::temp_dir().join("ljos-missing-harnesses.toml");
13341        let steps = super::onboard_from(&file, "grok", true).expect("grok dry");
13342        assert!(steps[0].ok, "{steps:?}");
13343        assert!(
13344            steps[0].detail.contains(".grok/hooks/ljos.json"),
13345            "{}",
13346            steps[0].detail
13347        );
13348    }
13349
13350    #[test]
13351    fn the_grok_hook_file_runs_ljos_by_absolute_path() {
13352        let text = super::grok_hooks_json(Path::new("/opt/seat/bin/ljos"));
13353        let v: Value = serde_json::from_str(&text).expect("the hook file is JSON");
13354        let pre = &v["hooks"]["PreToolUse"][0]["hooks"][0];
13355        assert_eq!(pre["command"], "/opt/seat/bin/ljos hook");
13356        assert_eq!(pre["timeout"], 10);
13357        let stop = &v["hooks"]["Stop"][0]["hooks"][0];
13358        assert_eq!(stop["command"], "/opt/seat/bin/ljos hook");
13359        assert!(!text.contains("{ljos}"), "{text}");
13360        assert!(!text.contains("\"ljos hook\""), "{text}");
13361    }
13362
13363    use super::*;
13364    use std::io::{Read, Write};
13365    use std::net::TcpListener;
13366    use std::sync::{Arc, Mutex};
13367
13368    /// A non-zero exit is an error carrying what was said on stderr.
13369    #[test]
13370    fn a_refusal_is_an_error_not_an_answer() {
13371        let err = run_captured("false", &[] as &[&str]).unwrap_err();
13372        assert!(err.to_string().contains("false exited"), "{err}");
13373        let said = run_captured("sh", &["-c", "echo answered; echo aside >&2"]).unwrap();
13374        assert_eq!(said.stdout.trim(), "answered");
13375        assert_eq!(said.stderr.trim(), "aside");
13376        let said = run_captured("sh", &["-c", "echo reason >&2; exit 3"]).unwrap_err();
13377        assert!(said.to_string().contains("reason"), "{said}");
13378    }
13379
13380    #[test]
13381    fn join_keeps_spaces() {
13382        assert_eq!(
13383            join(&["the default fuse".into(), "is CombMNZ".into()]),
13384            "the default fuse is CombMNZ"
13385        );
13386    }
13387
13388    #[test]
13389    fn remember_is_lesson_prefer_is_preference() {
13390        assert_eq!(atom_kind("Remember").unwrap(), "lesson");
13391        assert_eq!(atom_kind("Prefer").unwrap(), "preference");
13392        assert!(atom_kind("extract").is_err());
13393    }
13394
13395    #[test]
13396    fn a_sitting_lists_the_due_claims_its_island_holds_first() {
13397        let due = vec![
13398            serde_json::json!({"id": "old", "due_at": "2026-09-01"}),
13399            serde_json::json!({"id": "here", "due_at": "2026-09-05"}),
13400            serde_json::json!({"id": "older", "due_at": "2026-08-01"}),
13401        ];
13402        let island = serde_json::json!({"island": [{"id": "here"}, {"id": "absent"}]});
13403        let ids: Vec<String> = due_on_island_first(due, &island)
13404            .iter()
13405            .map(|a| a["id"].as_str().unwrap().to_string())
13406            .collect();
13407        assert_eq!(ids, ["here", "old", "older"]);
13408        let weak = serde_json::json!({"weak": true, "island": [{"id": "older"}]});
13409        let kept = due_on_island_first(
13410            vec![
13411                serde_json::json!({"id": "a"}),
13412                serde_json::json!({"id": "older"}),
13413            ],
13414            &weak,
13415        );
13416        assert_eq!(kept[0]["id"], "a", "a weak island does not reorder");
13417    }
13418
13419    #[test]
13420    fn atom_body_is_explicit_and_unextracted() {
13421        let v = atom_body("lesson", "the default fuse is CombMNZ", "ws");
13422        assert_eq!(v["schema"], "inside.atom/v1");
13423        assert_eq!(v["kind"], "lesson");
13424        assert_eq!(v["level"], "explicit");
13425        assert_eq!(v["text"], "the default fuse is CombMNZ");
13426        assert_eq!(v["workspace"], "ws");
13427        // Every write says where it came from.
13428        assert_eq!(v["source"]["via"], "ljos");
13429        assert!(!v["source"]["host"].as_str().unwrap_or("").is_empty());
13430        assert!(!v["source"]["session"].as_str().unwrap_or("").is_empty());
13431        // Every write names the seat that wrote it, and other entities join it.
13432        let seat = v["entities"][0].as_str().unwrap();
13433        assert!(seat.starts_with(SEAT_ENTITY), "{seat}");
13434        let mut more = v.clone();
13435        add_entities(
13436            &mut more,
13437            ["persona:reviewer".to_string(), seat.to_string()],
13438        );
13439        assert_eq!(more["entities"].as_array().unwrap().len(), 2, "{more}");
13440        // Never harvest a transcript: the text is the claim, not a prefix parse.
13441        let raw = atom_body("lesson", "Remember: pin the review set", "ws");
13442        assert_eq!(raw["text"], "Remember: pin the review set");
13443    }
13444
13445    #[test]
13446    fn empty_claim_is_refused() {
13447        let client = PacksetClient::new("http://127.0.0.1:1");
13448        let err = post_claim(&client, "Remember", "   ", "ws").unwrap_err();
13449        assert!(err.to_string().contains("empty text"));
13450    }
13451
13452    #[test]
13453    fn cards_are_the_two_named_files_only() {
13454        assert_eq!(CARD_NAMES, &["USER.md", "MEMORY.md"]);
13455        let dir = std::env::temp_dir().join(format!("ljos-cards-{}", std::process::id()));
13456        let _ = std::fs::remove_dir_all(&dir);
13457        std::fs::create_dir_all(&dir).unwrap();
13458        std::fs::write(dir.join("USER.md"), "user card\n").unwrap();
13459        std::fs::write(dir.join("MEMORY.md"), "memory card\n").unwrap();
13460        std::fs::write(dir.join("NOTES.md"), "must not appear\n").unwrap();
13461        let out = cards(&dir).unwrap();
13462        assert!(out.contains("user card"));
13463        assert!(out.contains("memory card"));
13464        assert!(!out.contains("must not appear"));
13465        assert!(!out.contains("NOTES.md"));
13466        let _ = std::fs::remove_dir_all(&dir);
13467    }
13468
13469    #[test]
13470    fn policy_prints_argv_and_does_not_reload() {
13471        assert!(policy_line(&[]).is_err());
13472        assert_eq!(policy_line(&["ls".into(), "-la".into()]).unwrap(), "ls -la");
13473        let note = POLICY_TCB.to_ascii_lowercase();
13474        assert!(note.contains("ljos-policyd"));
13475        assert!(note.contains("not a check"));
13476        assert!(!note.contains("grokos policy reload"));
13477        assert!(!note.contains("policy reload"));
13478    }
13479
13480    #[test]
13481    fn consensus_is_ljos_then_vissue() {
13482        let steps = consensus_steps("vissue-1a5a", true, true, &[]).unwrap();
13483        assert_eq!(steps.len(), 2);
13484        assert_eq!(steps[0].bin, "ljos-consensus");
13485        assert_eq!(steps[0].args, vec!["settle", "--issue", "vissue-1a5a"]);
13486        assert_eq!(steps[1].bin, "vissue");
13487        assert_eq!(steps[1].args, vec!["consensus", "vissue-1a5a"]);
13488    }
13489
13490    #[test]
13491    fn consensus_carries_the_packs_trust() {
13492        let rows = vec![row("a", "b", 0.5)];
13493        let steps = consensus_steps("id", true, true, &rows).unwrap();
13494        assert_eq!(steps[0].args[3], "--trust");
13495        assert_eq!(steps[0].args[4], r#"[["a","b",0.5]]"#);
13496        assert_eq!(
13497            steps[1].args,
13498            vec!["consensus", "id", "--trust", r#"[["a","b",0.5]]"#]
13499        );
13500    }
13501
13502    #[test]
13503    fn consensus_skips_a_missing_bin() {
13504        let only_v = consensus_steps("id", false, true, &[]).unwrap();
13505        assert_eq!(only_v.len(), 1);
13506        assert_eq!(only_v[0].bin, "vissue");
13507        let only_l = consensus_steps("id", true, false, &[]).unwrap();
13508        assert_eq!(only_l[0].bin, "ljos-consensus");
13509        assert!(consensus_steps("id", false, false, &[]).is_err());
13510    }
13511
13512    fn row(from: &str, to: &str, weight: f64) -> Trust {
13513        Trust {
13514            about: Vec::new(),
13515            from: from.into(),
13516            to: to.into(),
13517            weight,
13518        }
13519    }
13520
13521    #[test]
13522    fn a_trust_atom_is_one_edge_with_its_evidence() {
13523        let atom = trust_atom(&row("a", "b", 0.25), &["deed-x-y".into()], "ws").unwrap();
13524        assert_eq!(atom["kind"], "trust");
13525        assert_eq!(atom["from"], "a");
13526        assert_eq!(atom["to"], "b");
13527        assert_eq!(atom["weight"], 0.25);
13528        assert_eq!(atom["entities"], serde_json::json!(["deed-x-y"]));
13529        assert_eq!(atom["text"], "a weighs b at 0.250.");
13530        assert!(trust_atom(&row("a", "a", 0.5), &[], "ws").is_err());
13531        assert!(trust_atom(&row("a", "b", 0.0), &[], "ws").is_err());
13532        assert!(trust_atom(&row("a", "b", 1.5), &[], "ws").is_err());
13533        assert!(trust_atom(&row("", "b", 0.5), &[], "ws").is_err());
13534    }
13535
13536    #[test]
13537    fn the_latest_row_per_pair_wins() {
13538        let atoms = vec![
13539            serde_json::json!({"kind": "trust", "from": "a", "to": "b", "weight": 0.9, "ts": "2026-01-01T00:00:00Z"}),
13540            serde_json::json!({"kind": "trust", "from": "a", "to": "b", "weight": 0.3, "ts": "2026-02-01T00:00:00Z"}),
13541            serde_json::json!({"kind": "trust", "from": "b", "to": "a", "weight": 0.7}),
13542            serde_json::json!({"kind": "lesson", "text": "not a row"}),
13543            serde_json::json!({"kind": "trust", "from": "b", "weight": 0.7}),
13544        ];
13545        let rows = trust_rows(&atoms);
13546        assert_eq!(rows, vec![row("a", "b", 0.3), row("b", "a", 0.7)]);
13547        assert_eq!(trust_json(&rows), r#"[["a","b",0.3],["b","a",0.7]]"#);
13548    }
13549
13550    #[test]
13551    fn ballots_are_agent_and_choice() {
13552        let rows =
13553            ballots_from_json(r#"[{"agent":"a","choice":"ship","stamp":"[2026-01-01]"}]"#).unwrap();
13554        assert_eq!(rows, vec![("a".to_string(), "ship".to_string())]);
13555        assert!(ballots_from_json(r#"[{"agent":"a"}]"#).is_err());
13556        assert!(ballots_from_json("{}").is_err());
13557    }
13558
13559    /// A refuted voter loses weight in every other voter's row; a vindicated
13560    /// one keeps it; the rows come back complete.
13561    #[test]
13562    fn learning_downweights_the_refuted_voter() {
13563        let ballots = vec![
13564            ("a".to_string(), "ship".to_string()),
13565            ("b".to_string(), "ship".to_string()),
13566            ("c".to_string(), "hold".to_string()),
13567        ];
13568        let rows = learn(&ballots, "ship", &[], 0.5).unwrap();
13569        assert_eq!(rows.len(), 6);
13570        let w = |from: &str, to: &str| {
13571            rows.iter()
13572                .find(|r| r.from == from && r.to == to)
13573                .unwrap()
13574                .weight
13575        };
13576        assert_eq!(w("a", "b"), 1.0);
13577        assert_eq!(w("a", "c"), 0.5);
13578        assert_eq!(w("b", "c"), 0.5);
13579        assert_eq!(w("c", "a"), 1.0);
13580
13581        let again = learn(&ballots, "ship", &rows, 0.5).unwrap();
13582        let w2 = |from: &str, to: &str| {
13583            again
13584                .iter()
13585                .find(|r| r.from == from && r.to == to)
13586                .unwrap()
13587                .weight
13588        };
13589        assert_eq!(w2("a", "c"), 0.25);
13590        assert_eq!(w2("a", "b"), 1.0);
13591
13592        let floored = learn(&ballots, "ship", &[row("a", "c", 0.015)], 0.5).unwrap();
13593        let low = floored
13594            .iter()
13595            .find(|r| r.from == "a" && r.to == "c")
13596            .unwrap();
13597        assert_eq!(low.weight, TRUST_FLOOR);
13598
13599        assert!(learn(&ballots, "ship", &[], 1.0).is_err());
13600        assert!(learn(&ballots, "  ", &[], 0.5).is_err());
13601        assert!(learn(&ballots[..1], "ship", &[], 0.5).is_err());
13602
13603        // A fixed share of recovery: the refuted row moves back toward one
13604        // by the share of the gap, the vindicated row stays at one.
13605        let shared = learn_shared(&ballots, "ship", &rows, 0.5, &[], 0.1).unwrap();
13606        let w3 = |from: &str, to: &str| {
13607            shared
13608                .iter()
13609                .find(|r| r.from == from && r.to == to)
13610                .unwrap()
13611                .weight
13612        };
13613        assert!((w3("a", "c") - (0.25 + 0.75 * 0.1)).abs() < 1e-12);
13614        assert_eq!(w3("a", "b"), 1.0);
13615        assert!(learn_shared(&ballots, "ship", &[], 0.5, &[], 1.0).is_err());
13616    }
13617
13618    #[test]
13619    fn a_name_is_one_work_id_and_hex_passes_through() {
13620        let a = work_id("demo-riml");
13621        assert_eq!(a.len(), 32);
13622        assert!(a.bytes().all(|b| b.is_ascii_hexdigit()));
13623        assert_eq!(a, work_id(" demo-riml "));
13624        assert_ne!(a, work_id("demo-rimm"));
13625        assert_eq!(work_id(&a.to_ascii_uppercase()), a);
13626        assert_ne!(work_id("seat"), work_id("reader"));
13627    }
13628
13629    #[test]
13630    fn a_refusal_is_not_a_writer_that_is_down() {
13631        let refused = anyhow::Error::from(packset_client::Error::Bad("no".into()));
13632        assert!(!writer_unreachable(&refused));
13633    }
13634
13635    #[test]
13636    fn a_stated_probability_has_a_brier_score_and_a_hard_vote_does_not() {
13637        let rows = vec![
13638            Forecast {
13639                agent: "a".into(),
13640                choice: "ship".into(),
13641                confidence: Some(0.8),
13642            },
13643            Forecast {
13644                agent: "b".into(),
13645                choice: "hold".into(),
13646                confidence: None,
13647            },
13648        ];
13649        assert!((brier("ship", "ship", 0.8) - 0.04).abs() < 1e-12);
13650        assert!((brier("hold", "ship", 0.8) - 0.64).abs() < 1e-12);
13651        let (mean, n) = mean_brier(&rows, "ship").unwrap();
13652        assert_eq!(n, 1);
13653        assert!((mean - 0.04).abs() < 1e-12);
13654        let said = learn_reading(2, 0, &rows, "ship", &std::collections::BTreeMap::new());
13655        assert!(said.contains("Brier 0.040"), "{said}");
13656        assert!(said.contains("not a trust weight"), "{said}");
13657        let silent = learn_reading(2, 0, &rows[1..], "ship", &std::collections::BTreeMap::new());
13658        assert!(silent.contains("No stated probability"), "{silent}");
13659        assert!(log_score("ship", "ship", 0.8).unwrap() > 0.0);
13660        assert!(log_score("hold", "ship", 1.0).is_none());
13661        let mut cal = Calibration::default();
13662        cal = observe(&cal, "ship", "ship", 0.8);
13663        cal = observe(&cal, "ship", "hold", 0.8);
13664        let part = murphy(&cal).unwrap();
13665        let mean_b = cal.sum_brier / f64::from(cal.n);
13666        assert!((part.reliability - part.resolution + part.uncertainty - mean_b).abs() < 1e-9);
13667        assert!((cal.sum_p / f64::from(cal.n) - 0.8).abs() < 1e-12);
13668        assert!((cal.sum_o / f64::from(cal.n) - 0.5).abs() < 1e-12);
13669    }
13670
13671    #[test]
13672    fn an_island_prints_one_memory_a_line() {
13673        let body = serde_json::json!({"island": [
13674            {"id": "a", "text": "one", "activation": 1.0, "seed": true, "ts": now_utc()},
13675            {"id": "b", "text": "two", "activation": 0.25, "seed": false}
13676        ]});
13677        let printed = format_island(&body);
13678        assert!(
13679            printed.contains("Seat island") && printed.contains("Not fired"),
13680            "{printed}"
13681        );
13682        assert!(
13683            printed.contains("1.000\tseed\ta\ttoday\tone\n"),
13684            "{printed}"
13685        );
13686        assert!(printed.contains("0.250\t    \tb\t\ttwo\n"), "{printed}");
13687        assert!(format_island(&serde_json::json!({})).is_empty());
13688        let persona = serde_json::json!({
13689            "as": "reviewer",
13690            "fired": 3,
13691            "island": [{"id": "a", "text": "one", "activation": 1.0, "seed": true, "ts": now_utc()}]
13692        });
13693        let walked = format_island(&persona);
13694        assert!(walked.contains("Persona reviewer"), "{walked}");
13695        assert!(walked.contains("Fired: 3"), "{walked}");
13696        assert!(!walked.contains("Seat island"), "{walked}");
13697    }
13698
13699    #[test]
13700    fn a_fed_verb_reads_its_stdin() {
13701        let said = run_fed("cat", &[] as &[&str], "one\ntwo\n").unwrap();
13702        assert_eq!(said.stdout, "one\ntwo\n");
13703        assert!(run_fed("sh", &["-c", "exit 2"], "").is_err());
13704    }
13705
13706    #[test]
13707    fn needs_and_cited_are_enclosed_once_each() {
13708        let needs = needs_of(r#"{"needs":["deed-b-2","deed-a-1"],"other":1}"#).unwrap();
13709        assert_eq!(needs, vec!["deed-b-2", "deed-a-1"]);
13710        assert_eq!(
13711            enclose(needs, "deed-a-1\n\ndeed-c-3\n"),
13712            vec!["deed-a-1", "deed-b-2", "deed-c-3"]
13713        );
13714        assert!(needs_of("{}").unwrap().is_empty());
13715        assert!(needs_of("not json").is_err());
13716    }
13717
13718    #[test]
13719    fn a_json_config_takes_the_entry_by_pointer() {
13720        let dir = std::env::temp_dir().join(format!("ljos-onboard-json-{}", std::process::id()));
13721        std::fs::create_dir_all(&dir).unwrap();
13722        let config = dir.join("runner.json");
13723        std::fs::write(&config, "{\"model\": \"x\"}\n").unwrap();
13724        let entry = serde_json::json!({"type": "local", "command": ["/bin/ljos-mcp"]});
13725        set_json_entry(&config, "/mcp/ljos", &entry).unwrap();
13726        let doc: Value = serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
13727        assert_eq!(doc["model"], "x", "the rest of the file stands");
13728        assert_eq!(doc["mcp"]["ljos"]["command"][0], "/bin/ljos-mcp");
13729        let h = Harness {
13730            name: "runner".into(),
13731            register: Vec::new(),
13732            registered: Vec::new(),
13733            config: None,
13734            marker: None,
13735            snippet: None,
13736            config_json: Some(config.display().to_string()),
13737            json_pointer: Some("/mcp/ljos".into()),
13738            json_entry: None,
13739            skills: None,
13740            hooks: None,
13741            hook_events: Vec::new(),
13742            plugin: None,
13743            plugin_template: None,
13744            probe: Vec::new(),
13745            clients: Vec::new(),
13746        };
13747        assert_eq!(is_registered(&h, Path::new("/bin/ljos-mcp")), Some(true));
13748        let _ = std::fs::remove_dir_all(&dir);
13749    }
13750
13751    #[test]
13752    fn a_persona_set_is_in_the_pack_alphabet() {
13753        assert_eq!(persona_set("Reviewer"), "persona-reviewer");
13754        assert_eq!(persona_set("first gpu:user"), "persona-first-gpu-user");
13755        assert!(persona_set("x".repeat(60).as_str()).len() <= 32);
13756    }
13757
13758    #[test]
13759    fn the_roster_lists_each_persona_on_one_line() {
13760        assert!(format_personas(&[]).starts_with("no personas;"));
13761        let roster = format_personas(&[
13762            Persona {
13763                name: "reviewer".into(),
13764                anchor: 0.2,
13765                view: "Reads for what breaks.".into(),
13766                entities: vec!["docs".into(), "release".into()],
13767            },
13768            Persona {
13769                name: "reader".into(),
13770                anchor: 0.8,
13771                view: "Reads as a first-time user.".into(),
13772                entities: Vec::new(),
13773            },
13774        ]);
13775        let lines: Vec<&str> = roster.lines().collect();
13776        assert_eq!(lines.len(), 2);
13777        assert!(
13778            lines[0].starts_with("reviewer  anchor 0.20  about docs, release  Reads"),
13779            "{}",
13780            lines[0]
13781        );
13782        assert!(lines[1].contains("about anything"), "{}", lines[1]);
13783    }
13784
13785    #[test]
13786    fn the_sweep_line_counts_what_moved_and_is_silent_otherwise() {
13787        assert_eq!(format_sweep(None), "");
13788        assert_eq!(
13789            format_sweep(Some(&serde_json::json!({"lapsed": 0, "forgotten": 0}))),
13790            ""
13791        );
13792        let line = format_sweep(Some(&serde_json::json!({"lapsed": 2, "forgotten": 1})));
13793        assert!(line.contains("2 reviews lapsed"), "{line}");
13794        assert!(line.contains("1 never-recalled claim forgotten"), "{line}");
13795        let one = format_sweep(Some(&serde_json::json!({"lapsed": 1, "forgotten": 0})));
13796        assert!(
13797            one.contains("1 review lapsed past twice its interval"),
13798            "{one}"
13799        );
13800    }
13801
13802    #[test]
13803    fn due_is_the_past_soonest_first() {
13804        let atoms = vec![
13805            serde_json::json!({"id": "late", "due_at": "2026-02-01T00:00:00.000Z"}),
13806            serde_json::json!({"id": "later", "due_at": "2026-03-01T00:00:00.000Z"}),
13807            serde_json::json!({"id": "future", "due_at": "2099-01-01T00:00:00.000Z"}),
13808            serde_json::json!({"id": "never"}),
13809            serde_json::json!({"id": "blank", "due_at": ""}),
13810        ];
13811        let due = due_of(&atoms, "2026-06-01T00:00:00.000Z");
13812        let ids: Vec<&str> = due.iter().map(|a| a["id"].as_str().unwrap()).collect();
13813        // A claim that never entered the clock is due now, ahead of the
13814        // past-due ones; the future one waits.
13815        assert_eq!(ids, ["never", "blank", "late", "later"]);
13816        assert!(now_utc().ends_with(".000Z"));
13817        assert!(now_utc().as_str() > "2026-01-01T00:00:00.000Z");
13818    }
13819
13820    #[test]
13821    fn timeline_exposes_event_rows() {
13822        let src = include_str!("lib.rs");
13823        assert!(src.contains("pub fn timeline_events"));
13824        assert!(src.contains("Result<Vec<Event>>"));
13825        assert!(src.contains("pub fn pack_last_write_ts"));
13826        assert!(src.contains("GET /v1/status"));
13827        assert!(src.contains("vissue_core::agent::show_json"));
13828    }
13829
13830    #[test]
13831    fn timeline_of_does_not_shell_vissue() {
13832        let src = include_str!("lib.rs");
13833        let start = src.find("fn timeline_of").expect("timeline_of");
13834        let end = src[start..]
13835            .find("\npub fn timeline(")
13836            .map(|i| start + i)
13837            .expect("timeline after timeline_of");
13838        let body = &src[start..end];
13839        assert!(
13840            !body.contains("run_captured(\"vissue\""),
13841            "timeline_of must not shell vissue"
13842        );
13843        assert!(
13844            !body.contains("Command::new(\"vissue\")"),
13845            "timeline_of must not Command::new vissue"
13846        );
13847        assert!(
13848            body.contains("tracker_show_json"),
13849            "timeline_of should call the tracker library"
13850        );
13851    }
13852
13853    #[test]
13854    fn timeline_events_reads_the_tracker_without_shelling_vissue() {
13855        let _g = env_guard();
13856        let dir = tempfile::tempdir().unwrap();
13857        let project = dir.path().join("Software/sample");
13858        std::fs::create_dir_all(&project).unwrap();
13859        std::fs::write(
13860            project.join("issues.org"),
13861            "#+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",
13862        )
13863        .unwrap();
13864        let old_issue_root = std::env::var_os("ISSUE_ROOT");
13865        let old_vissue_root = std::env::var_os("VISSUE_ROOT");
13866        let old_no_route = std::env::var_os("VISSUE_NO_ROUTE");
13867        let old_path = std::env::var_os("PATH");
13868        unsafe {
13869            std::env::set_var("ISSUE_ROOT", dir.path());
13870            std::env::set_var("VISSUE_ROOT", dir.path());
13871            std::env::set_var("VISSUE_NO_ROUTE", "1");
13872            std::env::set_var("PATH", "/usr/bin");
13873        }
13874        let events = timeline_events("sample-k2p2", 12);
13875        unsafe {
13876            match old_issue_root {
13877                Some(v) => std::env::set_var("ISSUE_ROOT", v),
13878                None => std::env::remove_var("ISSUE_ROOT"),
13879            }
13880            match old_vissue_root {
13881                Some(v) => std::env::set_var("VISSUE_ROOT", v),
13882                None => std::env::remove_var("VISSUE_ROOT"),
13883            }
13884            match old_no_route {
13885                Some(v) => std::env::set_var("VISSUE_NO_ROUTE", v),
13886                None => std::env::remove_var("VISSUE_NO_ROUTE"),
13887            }
13888            match old_path {
13889                Some(v) => std::env::set_var("PATH", v),
13890                None => std::env::remove_var("PATH"),
13891            }
13892        }
13893        let events = events.expect("timeline_events should read the tracker library");
13894        assert!(
13895            events
13896                .iter()
13897                .any(|e| e.source == "tracker" && e.text == "created"),
13898            "{events:?}"
13899        );
13900    }
13901
13902    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";
13903
13904    #[test]
13905    fn a_bundle_becomes_rows_with_edges_and_steady_ids() {
13906        let dir = std::env::temp_dir().join(format!("ljos-bump-{}", std::process::id()));
13907        let _ = std::fs::remove_dir_all(&dir);
13908        std::fs::create_dir_all(dir.join("locks")).unwrap();
13909        std::fs::write(
13910            dir.join("locks/default.lock.json"),
13911            r#"{"package":"eOn","version":"2.17.10","toolchain":{"name":"foss","version":"2026.1"},"versionsuffix":"",
13912                "dependencies":[
13913                 {"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},
13914                 {"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},
13915                 {"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}]}"#,
13916        )
13917        .unwrap();
13918        std::fs::write(
13919            dir.join("package.sbom.cdx.json"),
13920            r#"{"components":[],"dependencies":[
13921                {"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"]},
13922                {"ref":"pkg:generic/Eigen@==5.0.0","dependsOn":["pkg:generic/CMake@==4.2.1"]},
13923                {"ref":"pkg:generic/CMake@==4.2.1"}]}"#,
13924        )
13925        .unwrap();
13926        let (generation, rows) = bump_rows(&dir, "ebstack", None).unwrap();
13927        assert_eq!(generation, "foss/2026.1");
13928        let modules: Vec<&str> = rows.iter().map(|r| r.module.as_str()).collect();
13929        assert_eq!(
13930            modules,
13931            [
13932                "eOn-2.17.10-foss-2026.1",
13933                "CMake-4.2.1-GCCcore-15.2.0",
13934                "Eigen-5.0.0-GCCcore-15.2.0",
13935                "Python-3.14.2-GCCcore-15.2.0"
13936            ],
13937            "the root first, then every module the lock names, build dependencies included"
13938        );
13939        let cmake = &rows[1];
13940        let eigen = &rows[2];
13941        let python = &rows[3];
13942        assert!(cmake.blockers.is_empty());
13943        assert_eq!(eigen.blockers, std::slice::from_ref(&cmake.id));
13944        assert_eq!(
13945            rows[0].blockers,
13946            [cmake.id.clone(), eigen.id.clone(), python.id.clone()],
13947            "the root is blocked by every module it depends on"
13948        );
13949        assert_eq!(
13950            rows[0].id,
13951            bump_issue_id("ebstack", "eOn-2.17.10-foss-2026.1", "foss/2026.1")
13952        );
13953        assert!(rows[0].id.starts_with("ebstack-") && rows[0].id.len() == "ebstack-".len() + 8);
13954        assert_ne!(
13955            rows[0].id,
13956            bump_issue_id("ebstack", "eOn-2.17.10-foss-2026.1", "foss/2027a")
13957        );
13958        assert!(rows.iter().all(|r| r.result == "would make"));
13959        let _ = std::fs::remove_dir_all(&dir);
13960    }
13961
13962    #[test]
13963    fn a_finding_lesson_is_two_short_sentences_about_the_recipe() {
13964        let campaign = Campaign {
13965            package: "eOn".into(),
13966            version: "2.17.10".into(),
13967            target: "terra".into(),
13968            status: "completed".into(),
13969            attempts: 29,
13970            findings: Vec::new(),
13971        };
13972        let f = Finding {
13973            id: "attempt:6:finding:6".into(),
13974            status: "resolved".into(),
13975            class: "compile".into(),
13976            disposition: "requires-judgment".into(),
13977            stage: "build".into(),
13978            recipe: recipe_stem("easyconfigs/e/eOn/eOn-2.17.10-foss-2026.1.eb"),
13979            module: failed_module(EVIDENCE).unwrap_or_default(),
13980            summary: "Compile failure from EasyBuild command (exit Some(1))".into(),
13981            error: error_line(EVIDENCE, "Compile failure"),
13982            action: "applied the GCC 14 libsanitizer kernel headers patch. Kept in the overlay"
13983                .into(),
13984            changes: vec!["overlay/g/GCCcore/GCCcore-15.2.0.eb".into()],
13985        };
13986        assert_eq!(f.module, "GCCcore-15.2.0");
13987        let lesson = finding_lesson(&campaign, &f);
13988        assert_eq!(
13989            lesson,
13990            "GCCcore-15.2.0 for eOn-2.17.10-foss-2026.1 on terra: compile failed in the build step \
13991             with shell command 'make' failed with exit code 2 in build. \
13992             Fix: applied the GCC 14 libsanitizer kernel headers patch, Kept in the overlay in GCCcore-15.2.0."
13993        );
13994        assert!(!lesson.contains("srun"));
13995        assert_eq!(
13996            finding_entities(&campaign, &f),
13997            [
13998                "GCCcore-15.2.0",
13999                "GCCcore",
14000                "eOn-2.17.10-foss-2026.1",
14001                "eOn",
14002                "compile"
14003            ]
14004        );
14005        let retry = Finding {
14006            action: "successful campaign retry superseded this finding".into(),
14007            ..f.clone()
14008        };
14009        assert!(superseded_by_retry(&retry));
14010        assert!(!superseded_by_retry(&f));
14011        assert!(finding_lesson(&campaign, &retry).ends_with("A later attempt got past it."));
14012        assert_eq!(
14013            failed_module("== building and installing gettext/0.26...\n== FAILED"),
14014            Some("gettext-0.26".into())
14015        );
14016    }
14017
14018    #[test]
14019    fn tracker_decimal_confidence_remains_a_scored_forecast() {
14020        let forecasts = super::forecasts_from_json(
14021            r#"[{"agent":"alice","choice":"accept","confidence":"0.8"},
14022                {"agent":"bob","choice":"reject","confidence":0.6},
14023                {"agent":"carol","choice":"accept","confidence":null},
14024                {"agent":"dana","choice":"accept"}]"#,
14025        )
14026        .unwrap();
14027        assert_eq!(forecasts[0].confidence, Some(0.8));
14028        assert_eq!(forecasts[1].confidence, Some(0.6));
14029        assert_eq!(forecasts[2].confidence, None);
14030        assert_eq!(forecasts[3].confidence, None);
14031        let (score, count) = super::mean_brier(&forecasts, "accept").unwrap();
14032        assert_eq!(count, 2);
14033        assert!((score - 0.2).abs() < 1e-14);
14034    }
14035
14036    #[test]
14037    fn invalid_tracker_confidence_is_not_silently_unscored() {
14038        for confidence in ["0", "-0.1", "1.1", "\"NaN\"", "\"oops\"", "true", "[]"] {
14039            let raw =
14040                format!(r#"[{{"agent":"alice","choice":"accept","confidence":{confidence}}}]"#);
14041            let error = super::forecasts_from_json(&raw).unwrap_err().to_string();
14042            assert!(error.contains("probability in (0, 1]"), "{error}");
14043        }
14044    }
14045
14046    #[test]
14047    fn ahead_of_a_cached_registry_answer_is_said() {
14048        let cached = super::CrateVersion {
14049            version: "0.12.16".into(),
14050            cached: true,
14051        };
14052        let (state, ok) = super::bin_health("/bin/ljos", Some("0.13.5"), Some(&cached));
14053        assert!(ok, "{state}");
14054        assert!(
14055            state.contains("ahead of crates.io (cached) 0.12.16"),
14056            "{state}"
14057        );
14058        let (same, _) = super::bin_health("/bin/ljos", Some("0.12.16"), Some(&cached));
14059        assert!(same.ends_with("crates.io (cached) 0.12.16"), "{same}");
14060    }
14061
14062    #[test]
14063    fn the_mcp_binary_tracks_the_ljos_crate() {
14064        let crate_name = super::SEAT_BINS
14065            .iter()
14066            .find(|(bin, _)| *bin == "ljos-mcp")
14067            .map(|(_, name)| *name);
14068        assert_eq!(crate_name, Some("ljos"));
14069    }
14070
14071    #[test]
14072    fn a_behind_required_bin_still_answers() {
14073        let latest = super::CrateVersion {
14074            version: "0.9.5".into(),
14075            cached: false,
14076        };
14077        let (state, ok) = super::bin_health("/bin/packsetd", Some("0.9.2"), Some(&latest));
14078        assert!(ok, "{state}");
14079        assert!(state.contains("behind crates.io 0.9.5"), "{state}");
14080        let rows = vec![Habitat {
14081            name: "packsetd",
14082            state,
14083            ok,
14084        }];
14085        assert!(
14086            healthy(&rows),
14087            "sitting must not refuse a stale but answering bin"
14088        );
14089    }
14090
14091    #[test]
14092    fn ballot_health_requires_both_evidence_and_confidence_arguments() {
14093        use std::os::unix::fs::PermissionsExt;
14094        let dir = tempfile::tempdir().unwrap();
14095        let path = dir.path().join("vissue");
14096        for (help, missing) in [
14097            ("--for OPTION --json", Some("--used, --confidence")),
14098            ("--for OPTION --used DEEDS", Some("--confidence")),
14099            ("--for OPTION --confidence P", Some("--used")),
14100            ("--for OPTION --used DEEDS --confidence P", None),
14101        ] {
14102            std::fs::write(
14103                &path,
14104                format!(
14105                    "#!/bin/sh\n[ \"$*\" = 'vote --help' ] || exit 3\nprintf '%s\\n' '{help}'\n"
14106                ),
14107            )
14108            .unwrap();
14109            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
14110            let result = super::check_vissue_ballot_protocol(&path);
14111            if let Some(missing) = missing {
14112                let error = result.unwrap_err().to_string();
14113                assert!(error.contains(&format!("missing {missing};")), "{error}");
14114                let rows = vec![Habitat {
14115                    name: "vissue",
14116                    state: error,
14117                    ok: false,
14118                }];
14119                assert!(!healthy(&rows));
14120            } else {
14121                result.unwrap();
14122            }
14123        }
14124    }
14125
14126    #[test]
14127    fn ballot_health_refuses_a_failed_help_command() {
14128        use std::os::unix::fs::PermissionsExt;
14129        let dir = tempfile::tempdir().unwrap();
14130        let path = dir.path().join("vissue");
14131        std::fs::write(
14132            &path,
14133            "#!/bin/sh\necho '--used DEEDS --confidence P'\nexit 2\n",
14134        )
14135        .unwrap();
14136        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
14137        let error = super::check_vissue_ballot_protocol(&path)
14138            .unwrap_err()
14139            .to_string();
14140        assert!(error.contains("vote --help failed"), "{error}");
14141    }
14142
14143    #[test]
14144    fn the_doctor_names_every_habitat_and_the_pack_gates_health() {
14145        let rows = doctor();
14146        let names: Vec<&str> = rows.iter().map(|h| h.name).collect();
14147        for want in [
14148            "ljos",
14149            "packset-embed",
14150            "vissue",
14151            "deedar",
14152            "packset",
14153            "pack",
14154            "encoder",
14155            "host key",
14156            "deed store",
14157            "tracker",
14158        ] {
14159            assert!(names.contains(&want), "{names:?}");
14160        }
14161        let table = format_doctor(&rows);
14162        assert_eq!(table.lines().count(), rows.len());
14163        let sick = vec![Habitat {
14164            name: "pack",
14165            state: "PACKSET_URL unset".into(),
14166            ok: false,
14167        }];
14168        assert!(!healthy(&sick));
14169        let fine = vec![Habitat {
14170            name: "landfold",
14171            state: "not on PATH".into(),
14172            ok: false,
14173        }];
14174        assert!(healthy(&fine));
14175        assert_eq!(
14176            super::format_write_ack(&serde_json::json!({
14177                "id": "ab",
14178                "kind": "lesson",
14179                "due_at": "2026-09-15T00:00:00Z",
14180                "text": "The encoder sits beside packsetd."
14181            })),
14182            "ab\tlesson\tdue 2026-09-15T00:00:00Z\tThe encoder sits beside packsetd."
14183        );
14184        assert_eq!(super::parse_semver("ljos 0.12.8"), Some("0.12.8"));
14185        assert_eq!(
14186            super::cmp_semver("0.4.1", "0.5.3"),
14187            Some(std::cmp::Ordering::Less)
14188        );
14189    }
14190
14191    #[test]
14192    fn enclosed_atoms_are_read_from_every_jsonl_in_the_bag() {
14193        let dir = std::env::temp_dir().join(format!("ljos-bag-{}", std::process::id()));
14194        let _ = std::fs::remove_dir_all(&dir);
14195        let atoms = dir.join("data").join("atoms");
14196        std::fs::create_dir_all(&atoms).unwrap();
14197        std::fs::write(
14198            atoms.join("a.jsonl"),
14199            "{\"kind\":\"lesson\",\"text\":\"one\"}\n\n{\"kind\":\"trust\",\"from\":\"a\",\"to\":\"b\",\"weight\":0.5}\n",
14200        )
14201        .unwrap();
14202        std::fs::write(
14203            atoms.join("b.jsonl"),
14204            "{\"kind\":\"preference\",\"text\":\"two\"}\n",
14205        )
14206        .unwrap();
14207        let read = enclosed_atoms(&dir).unwrap();
14208        assert_eq!(read.len(), 3);
14209        assert_eq!(trust_rows(&read).len(), 1);
14210        assert!(enclosed_atoms(&dir.join("nowhere")).unwrap().is_empty());
14211        std::fs::write(atoms.join("c.jsonl"), "not json\n").unwrap();
14212        assert!(enclosed_atoms(&dir).is_err());
14213        let _ = std::fs::remove_dir_all(&dir);
14214
14215        let table = format_due(&[serde_json::json!({
14216            "id": "x", "kind": "lesson", "text": "t", "due_at": "2026-01-01T00:00:00.000Z"
14217        })]);
14218        assert_eq!(table, "2026-01-01T00:00:00.000Z\tlesson\tx\tt\n");
14219    }
14220
14221    fn read_http(s: &mut impl Read) -> String {
14222        let mut buf = Vec::new();
14223        let mut tmp = [0u8; 1024];
14224        loop {
14225            let n = s.read(&mut tmp).unwrap_or(0);
14226            if n == 0 {
14227                break;
14228            }
14229            buf.extend_from_slice(&tmp[..n]);
14230            if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
14231                let headers = &buf[..at];
14232                let mut need = 0usize;
14233                for line in headers.split(|b| *b == b'\n') {
14234                    let line = std::str::from_utf8(line).unwrap_or("").trim();
14235                    if let Some(v) = line
14236                        .split_once(':')
14237                        .filter(|(k, _)| k.eq_ignore_ascii_case("content-length"))
14238                        .map(|(_, v)| v.trim())
14239                    {
14240                        need = v.parse().unwrap_or(0);
14241                    }
14242                }
14243                let have = buf.len().saturating_sub(at + 4);
14244                if have >= need {
14245                    break;
14246                }
14247            }
14248        }
14249        String::from_utf8_lossy(&buf).into_owned()
14250    }
14251
14252    fn serve_capture() -> (String, Arc<Mutex<String>>) {
14253        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14254        let addr = listener.local_addr().unwrap();
14255        let captured = Arc::new(Mutex::new(String::new()));
14256        let slot = captured.clone();
14257        std::thread::spawn(move || {
14258            if let Ok((mut s, _)) = listener.accept() {
14259                *slot.lock().unwrap() = read_http(&mut s);
14260                let body =
14261                    r#"{"id":"atom-1","kind":"lesson","text":"the default fuse is CombMNZ"}"#;
14262                let resp = format!(
14263                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
14264                    body.len()
14265                );
14266                let _ = s.write_all(resp.as_bytes());
14267            }
14268        });
14269        (format!("http://{addr}"), captured)
14270    }
14271
14272    #[test]
14273    fn remember_posts_v1_atoms() {
14274        let (url, captured) = serve_capture();
14275        let client = PacksetClient::new(&url);
14276        let body = post_claim(&client, "Remember", "the default fuse is CombMNZ", "ws").unwrap();
14277        assert_eq!(body["id"], "atom-1");
14278        let req = captured.lock().unwrap().clone();
14279        assert!(req.contains("POST"), "{req}");
14280        assert!(req.contains("/v1/atoms"), "{req}");
14281        assert!(req.contains("\"kind\":\"lesson\""), "{req}");
14282        assert!(req.contains("the default fuse is CombMNZ"), "{req}");
14283        assert!(req.contains("\"level\":\"explicit\""), "{req}");
14284        assert!(req.contains("horizon:transient"), "{req}");
14285        assert!(!req.contains("extract"), "{req}");
14286    }
14287
14288    #[test]
14289    fn forget_posts_the_id_and_workspace() {
14290        let (url, captured) = serve_capture();
14291        let client = PacksetClient::new(&url);
14292        let body = client.delete_atom("ws", "atom-1", None).unwrap();
14293        assert_eq!(body["id"], "atom-1");
14294        let req = captured.lock().unwrap().clone();
14295        assert!(req.contains("POST"), "{req}");
14296        assert!(req.contains("/v1/atoms/delete"), "{req}");
14297        assert!(req.contains("\"id\":\"atom-1\""), "{req}");
14298        assert!(req.contains("\"workspace\":\"ws\""), "{req}");
14299        // No deed named, no field: the pack should not have to tell an absent
14300        // citation from an empty one.
14301        assert!(!req.contains("\"why\""), "{req}");
14302    }
14303
14304    /// The deed rides with the retraction, so the pack can write it onto the
14305    /// tombstone in the same step the atom leaves the live set.
14306    #[test]
14307    fn forget_carries_the_deed_that_withdrew_the_claim() {
14308        let (url, captured) = serve_capture();
14309        let client = PacksetClient::new(&url);
14310        client
14311            .delete_atom("ws", "atom-1", Some("deed-patch-overlay"))
14312            .unwrap();
14313        let req = captured.lock().unwrap().clone();
14314        assert!(req.contains("\"why\":\"deed-patch-overlay\""), "{req}");
14315    }
14316
14317    /// An id is the whole of the request, so an empty one is a mistake worth
14318    /// naming rather than a delete of whatever the server decides that means.
14319    #[test]
14320    fn forget_refuses_an_empty_id() {
14321        let err = packset_forget("   ", None).unwrap_err();
14322        assert!(err.to_string().contains("atom id is required"), "{err}");
14323    }
14324
14325    /// A fake tracker on PATH: `show` answers as told, `claim` logs its
14326    /// argv and the identity it was given.
14327    fn fake_vissue(dir: &std::path::Path, show_ok: bool, claim_ok: bool) -> std::path::PathBuf {
14328        let log = dir.join("calls.log");
14329        let script = format!(
14330            "#!/bin/sh\necho \"$* VISSUE_AGENT=${{VISSUE_AGENT:-}}\" >> '{}'\ncase \"$1\" in\n  show) {} ;;\n  claim) {} ;;\nesac\nexit 0\n",
14331            log.display(),
14332            if show_ok { "echo '{}'" } else { "exit 1" },
14333            if claim_ok { "echo claimed" } else { "echo refused >&2; exit 1" },
14334        );
14335        let path = dir.join("vissue");
14336        std::fs::write(&path, script).unwrap();
14337        #[cfg(unix)]
14338        {
14339            use std::os::unix::fs::PermissionsExt;
14340            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
14341        }
14342        log
14343    }
14344
14345    /// Run `f` with `dir` first on PATH, then put PATH back.
14346    fn with_fake_on_path<T>(dir: &std::path::Path, f: impl FnOnce() -> T) -> T {
14347        let old = std::env::var_os("PATH").unwrap_or_default();
14348        let mut new = std::ffi::OsString::from(dir.as_os_str());
14349        new.push(":");
14350        new.push(&old);
14351        unsafe {
14352            std::env::set_var("PATH", &new);
14353        }
14354        let out = f();
14355        unsafe {
14356            std::env::set_var("PATH", old);
14357        }
14358        out
14359    }
14360
14361    #[test]
14362    fn a_claim_stamps_the_tracker_under_the_assignee() {
14363        let _g = env_guard();
14364        let dir = tempfile::tempdir().unwrap();
14365        let log = fake_vissue(dir.path(), true, true);
14366        let said = with_fake_on_path(dir.path(), || stamp_tracker("proj-1a2b", "alice")).unwrap();
14367        assert_eq!(
14368            said.as_deref(),
14369            Some("tracker: proj-1a2b STARTED under alice")
14370        );
14371        let calls = std::fs::read_to_string(log).unwrap();
14372        assert!(
14373            calls.contains("claim proj-1a2b VISSUE_AGENT=alice"),
14374            "{calls}"
14375        );
14376    }
14377
14378    #[test]
14379    fn a_node_the_tracker_does_not_know_stamps_nothing() {
14380        let _g = env_guard();
14381        let dir = tempfile::tempdir().unwrap();
14382        let log = fake_vissue(dir.path(), false, true);
14383        let said = with_fake_on_path(dir.path(), || stamp_tracker("deadbeef", "alice")).unwrap();
14384        assert_eq!(said, None);
14385        let calls = std::fs::read_to_string(log).unwrap();
14386        assert!(
14387            !calls.contains("claim"),
14388            "asked to claim a non-issue: {calls}"
14389        );
14390    }
14391
14392    #[test]
14393    fn a_closed_tracker_heading_is_reopened_when_the_graph_takes_it() {
14394        let _g = env_guard();
14395        let dir = tempfile::tempdir().unwrap();
14396        let log = dir.path().join("calls.log");
14397        let script = format!(
14398            "#!/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",
14399            log = log.display()
14400        );
14401        let path = dir.path().join("vissue");
14402        std::fs::write(&path, script).unwrap();
14403        #[cfg(unix)]
14404        {
14405            use std::os::unix::fs::PermissionsExt;
14406            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
14407        }
14408        let said = with_fake_on_path(dir.path(), || stamp_tracker("proj-1a2b", "alice")).unwrap();
14409        assert_eq!(
14410            said.as_deref(),
14411            Some("tracker: proj-1a2b STARTED under alice")
14412        );
14413        let calls = std::fs::read_to_string(&log).unwrap();
14414        assert!(
14415            calls.contains("update proj-1a2b -s STARTED"),
14416            "reopen the heading: {calls}"
14417        );
14418        assert!(
14419            calls.contains("claim proj-1a2b --force VISSUE_AGENT=alice"),
14420            "{calls}"
14421        );
14422    }
14423
14424    #[test]
14425    fn a_tracker_refusal_names_the_way_out() {
14426        let _g = env_guard();
14427        let dir = tempfile::tempdir().unwrap();
14428        let _log = fake_vissue(dir.path(), true, false);
14429        let err =
14430            with_fake_on_path(dir.path(), || stamp_tracker("proj-1a2b", "alice")).unwrap_err();
14431        let text = format!("{err:#}");
14432        assert!(text.contains("ljos release proj-1a2b"), "{text}");
14433        assert!(text.contains("refused"), "{text}");
14434    }
14435}