Skip to main content

ljos_cli/
lib.rs

1//! One seat over the habitats. It does not own them.
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
13/// Working-core files this seat will print. Nothing else, and never write.
14pub const CARD_NAMES: &[&str] = &["USER.md", "MEMORY.md"];
15
16/// The sitting protocol: which store answers which question, the order of
17/// verbs before, during and after the work, and the refusals worth knowing.
18/// `ljos protocol` prints it, `ljos onboard` installs it as a skill, and the
19/// server serves it at `ljos://protocol`. Harness agnostic on purpose.
20pub const PROTOCOL: &str = include_str!("../doc/protocol.md");
21
22/// The skill file a harness loads: front matter, then the protocol.
23#[must_use]
24pub fn skill_text() -> String {
25    format!(
26        "---\nname: ljos\ndescription: >\n  The seat protocol for vissue, packset, deedar, claimdag and \
27consensus through ljos: which store answers which question, the order of verbs in a \
28sitting, and the refusals worth knowing. Load before any work that touches an issue, \
29a memory, a deed, a claim or a vote.\n---\n\n{PROTOCOL}"
30    )
31}
32
33/// One step an onboarding took, or would take.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Step {
36    pub what: String,
37    pub detail: String,
38    pub ok: bool,
39}
40
41/// One agent runner, as the seat's own configuration describes it. The seat
42/// ships no runner's name: the file at [`harnesses_path`] names them, one
43/// table each, and `onboard` and `doctor` read it.
44///
45/// A runner registers MCP servers one of two ways. `register` is a command
46/// that does it (`{server}` is replaced by the path to `ljos-mcp`) and
47/// `registered` a command that exits 0 once it is done. Or `config` is a
48/// file the runner reads, `marker` a line that means the entry is present,
49/// and `snippet` what to append when it is not. `skills` is the directory
50/// the runner loads skills from; the protocol goes to `<skills>/ljos/SKILL.md`.
51#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
52pub struct Harness {
53    pub name: String,
54    #[serde(default)]
55    pub register: Vec<String>,
56    #[serde(default)]
57    pub registered: Vec<String>,
58    #[serde(default)]
59    pub config: Option<String>,
60    #[serde(default)]
61    pub marker: Option<String>,
62    #[serde(default)]
63    pub snippet: Option<String>,
64    #[serde(default)]
65    pub skills: Option<String>,
66    /// A JSON settings file the runner reads hooks from, in the shape
67    /// `{"hooks": {"<Event>": [{"matcher": "...", "hooks": [{"type":
68    /// "command", "command": "..."}]}]}}`. `onboard` merges the seat's
69    /// memory hook into it, so what the seat knows about a command or a
70    /// prompt reaches the agent at the point of action.
71    #[serde(default)]
72    pub hooks: Option<String>,
73    /// The events the memory hook fires on. Empty means [`HOOK_EVENTS`],
74    /// the prompt event alone: a panel of this seat's personas settled on
75    /// prompts over tool calls, because a turn issues many shell commands
76    /// and one prompt. `["UserPromptSubmit", "PreToolUse"]` injects on both.
77    #[serde(default)]
78    pub hook_events: Vec<String>,
79}
80
81/// The whole file: `[[harness]]` tables.
82#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
83pub struct Harnesses {
84    #[serde(default)]
85    pub harness: Vec<Harness>,
86}
87
88/// An example of the file, with placeholder names. `ljos onboard --example`
89/// prints it; the two shapes are a registering command and a config file.
90pub const HARNESSES_EXAMPLE: &str = r#"# ~/.config/ljos/harnesses.toml: the agent runners on this machine.
91# {server} is replaced by the path to ljos-mcp, {name} by the runner's name.
92# Paths may start with ~. Passing LJOS_SEAT={name} to the server makes each
93# runner claim and vote as itself; they share the one pack and tracker.
94
95[[harness]]
96name = "runner-with-a-command"
97register = ["runner", "mcp", "add", "-s", "user", "-e", "LJOS_SEAT={name}", "ljos", "--", "{server}"]
98registered = ["runner", "mcp", "get", "ljos"]
99skills = "~/.runner/skills"
100hooks = "~/.runner/settings.json"
101# hook_events = ["UserPromptSubmit", "PreToolUse"]   # the default is the prompt alone
102
103[[harness]]
104name = "runner-with-a-config-file"
105config = "~/.other/config.toml"
106marker = "[mcp_servers.ljos]"
107snippet = "\n[mcp_servers.ljos]\ncommand = \"{server}\"\nargs = []\nenv = { LJOS_SEAT = \"{name}\", GROK_SESSION_ID = \"${GROK_SESSION_ID}\" }\n"
108skills = "~/.other/skills"
109"#;
110
111fn home() -> Result<PathBuf> {
112    std::env::var_os("HOME")
113        .map(PathBuf::from)
114        .context("HOME unset; onboard needs a home directory")
115}
116
117/// `~` at the start of a configured path is the home directory.
118fn expand(path: &str) -> PathBuf {
119    match path.strip_prefix("~/") {
120        Some(rest) => home().map_or_else(|_| PathBuf::from(path), |h| h.join(rest)),
121        None => PathBuf::from(path),
122    }
123}
124
125/// Where the runners are described: `$XDG_CONFIG_HOME/ljos/harnesses.toml`.
126#[must_use]
127pub fn harnesses_path() -> PathBuf {
128    std::env::var_os("XDG_CONFIG_HOME")
129        .filter(|r| !r.is_empty())
130        .map(PathBuf::from)
131        .or_else(|| home().ok().map(|h| h.join(".config")))
132        .unwrap_or_else(|| PathBuf::from(".config"))
133        .join("ljos")
134        .join("harnesses.toml")
135}
136
137/// Parse the runners file. An absent file is no runners, not an error.
138///
139/// # Errors
140///
141/// A file that is present and not this shape.
142pub fn harnesses_from(path: &Path) -> Result<Harnesses> {
143    match std::fs::read_to_string(path) {
144        Ok(text) => toml::from_str(&text).with_context(|| format!("{}", path.display())),
145        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Harnesses::default()),
146        Err(e) => Err(e).with_context(|| format!("{}", path.display())),
147    }
148}
149
150/// Where `ljos-mcp` is, as the runner will start it.
151fn server_path() -> Result<PathBuf> {
152    which::which("ljos-mcp").context("ljos-mcp not on PATH; install it beside ljos")
153}
154
155/// The MCP server entry any runner that reads JSON accepts.
156pub fn server_entry() -> Result<Value> {
157    Ok(serde_json::json!({
158        "mcpServers": {
159            "ljos": {
160                "type": "stdio",
161                "command": server_path()?.display().to_string(),
162                "args": [],
163                "env": {}
164            }
165        }
166    }))
167}
168
169fn write_skill(dir: &Path, dry: bool) -> Step {
170    let path = dir.join("ljos").join("SKILL.md");
171    let text = skill_text();
172    if std::fs::read_to_string(&path).is_ok_and(|have| have == text) {
173        return Step {
174            what: "skill".into(),
175            detail: format!("{} is current", path.display()),
176            ok: true,
177        };
178    }
179    if dry {
180        return Step {
181            what: "skill".into(),
182            detail: format!("would write {}", path.display()),
183            ok: true,
184        };
185    }
186    let written = std::fs::create_dir_all(path.parent().unwrap_or(dir))
187        .and_then(|()| std::fs::write(&path, text));
188    match written {
189        Ok(()) => Step {
190            what: "skill".into(),
191            detail: format!("wrote {}", path.display()),
192            ok: true,
193        },
194        Err(e) => Step {
195            what: "skill".into(),
196            detail: format!("{}: {e}", path.display()),
197            ok: false,
198        },
199    }
200}
201
202/// `{server}` is the path to `ljos-mcp`, `{name}` the runner's name from
203/// the runners file, so a registration can pass `LJOS_SEAT={name}` and
204/// each runner claims and votes as itself.
205fn filled(argv: &[String], server: &Path, name: &str) -> Vec<String> {
206    argv.iter()
207        .map(|a| a.replace("{server}", &server.display().to_string()))
208        .map(|a| a.replace("{name}", name))
209        .collect()
210}
211
212/// Names that many harnesses pass for every conversation on a host.
213/// Occupancy is one live claim per assignee, so these would make two
214/// grok sessions unable to hold two tickets.
215fn shared_actor_name(name: &str) -> bool {
216    matches!(
217        name.trim().to_ascii_lowercase().as_str(),
218        "grok" | "seat" | "you" | "agent" | "grok-build"
219    )
220}
221
222/// The conversation this process belongs to, when the runner stamped one.
223fn session_actor() -> Option<String> {
224    for key in ["GROK_SESSION_ID", "HARNESS_SESSION_ID", "TERM_SESSION_ID"] {
225        if let Ok(raw) = std::env::var(key) {
226            let t = raw.trim();
227            if t.is_empty() {
228                continue;
229            }
230            let prefix: String = t.chars().take(8).collect();
231            return Some(format!("sess-{prefix}"));
232        }
233    }
234    None
235}
236
237/// The name this seat claims and votes under when none is given:
238/// `LJOS_SEAT` (a runner's registration sets it to the runner's name, so
239/// two runners on one host hold separate claims), else the session id
240/// the runner stamped, else `VISSUE_AGENT`, else `seat`.
241#[must_use]
242pub fn seat_name() -> String {
243    if let Ok(v) = std::env::var("LJOS_SEAT") {
244        let t = v.trim();
245        if !t.is_empty() && !shared_actor_name(t) {
246            return t.to_string();
247        }
248    }
249    if let Some(s) = session_actor() {
250        return s;
251    }
252    if let Ok(v) = std::env::var("VISSUE_AGENT") {
253        let t = v.trim();
254        if !t.is_empty() && !shared_actor_name(t) {
255            return t.to_string();
256        }
257    }
258    "seat".to_string()
259}
260
261/// Resolve an `--assignee` / MCP field. A shared name (`grok`, `seat`,
262/// `you`) is treated as omitted so two conversations do not share one
263/// occupancy slot.
264#[must_use]
265pub fn resolve_assignee(passed: Option<&str>) -> String {
266    match passed.map(str::trim).filter(|s| !s.is_empty()) {
267        Some(n) if !shared_actor_name(n) => n.to_string(),
268        _ => seat_name(),
269    }
270}
271
272/// Whether a runner with a `registered` command already has the server.
273fn is_registered(h: &Harness, server: &Path) -> Option<bool> {
274    if !h.registered.is_empty() {
275        let argv = filled(&h.registered, server, &h.name);
276        return Some(
277            argv.first().is_some_and(|bin| on_path(bin)) && {
278                let (bin, rest) = (&argv[0], &argv[1..]);
279                run_captured(bin, rest).is_ok()
280            },
281        );
282    }
283    if let (Some(config), Some(marker)) = (&h.config, &h.marker) {
284        return Some(std::fs::read_to_string(expand(config)).is_ok_and(|t| t.contains(marker)));
285    }
286    None
287}
288
289fn register_step(h: &Harness, server: &Path, dry: bool) -> Step {
290    let what = format!("{} mcp", h.name);
291    match is_registered(h, server) {
292        Some(true) => Step {
293            what,
294            detail: "ljos registered".into(),
295            ok: true,
296        },
297        None => Step {
298            what,
299            detail: "no register or config in harnesses.toml; paste `ljos onboard --harness json`"
300                .into(),
301            ok: false,
302        },
303        Some(false) if !h.register.is_empty() => {
304            let argv = filled(&h.register, server, &h.name);
305            if !on_path(&argv[0]) {
306                return Step {
307                    what,
308                    detail: format!("{} not on PATH", argv[0]),
309                    ok: false,
310                };
311            }
312            if dry {
313                return Step {
314                    what,
315                    detail: format!("would run {}", argv.join(" ")),
316                    ok: true,
317                };
318            }
319            match run_captured(&argv[0], &argv[1..]) {
320                Ok(_) => Step {
321                    what,
322                    detail: format!("ran {}", argv.join(" ")),
323                    ok: true,
324                },
325                Err(e) => Step {
326                    what,
327                    detail: e.to_string().lines().next().unwrap_or("").to_string(),
328                    ok: false,
329                },
330            }
331        }
332        Some(false) => {
333            let config = expand(h.config.as_deref().unwrap_or_default());
334            let snippet = h
335                .snippet
336                .as_deref()
337                .unwrap_or_default()
338                .replace("{server}", &server.display().to_string())
339                .replace("{name}", &h.name);
340            if snippet.is_empty() {
341                return Step {
342                    what,
343                    detail: format!("no snippet to append to {}", config.display()),
344                    ok: false,
345                };
346            }
347            if dry {
348                return Step {
349                    what,
350                    detail: format!("would append the entry to {}", config.display()),
351                    ok: true,
352                };
353            }
354            let mut text = std::fs::read_to_string(&config).unwrap_or_default();
355            if !text.is_empty() && !text.ends_with('\n') {
356                text.push('\n');
357            }
358            text.push_str(&snippet);
359            let written = config
360                .parent()
361                .map_or(Ok(()), std::fs::create_dir_all)
362                .and_then(|()| std::fs::write(&config, text));
363            match written {
364                Ok(()) => Step {
365                    what,
366                    detail: format!("appended the entry to {}", config.display()),
367                    ok: true,
368                },
369                Err(e) => Step {
370                    what,
371                    detail: format!("{}: {e}", config.display()),
372                    ok: false,
373                },
374            }
375        }
376    }
377}
378
379/// Register the server and install the skill for one runner named in the
380/// runners file. `json` registers nothing and returns the entry to paste.
381/// `dry` reports without writing.
382///
383/// # Errors
384///
385/// No such runner in the file, no home directory, or `ljos-mcp` not on `PATH`.
386pub fn onboard(harness: &str, dry: bool) -> Result<Vec<Step>> {
387    onboard_from(&harnesses_path(), harness, dry)
388}
389
390pub fn onboard_from(file: &Path, harness: &str, dry: bool) -> Result<Vec<Step>> {
391    if harness == "json" {
392        return Ok(vec![Step {
393            what: "json".into(),
394            detail: serde_json::to_string_pretty(&server_entry()?)?,
395            ok: true,
396        }]);
397    }
398    let all = harnesses_from(file)?;
399    let Some(h) = all.harness.iter().find(|h| h.name == harness) else {
400        let names: Vec<&str> = all.harness.iter().map(|h| h.name.as_str()).collect();
401        bail!(
402            "onboard: no runner {harness:?} in {}; it names {}. `ljos onboard --example` \
403             prints the file's shape, and `--harness json` prints the entry to paste anywhere.",
404            file.display(),
405            if names.is_empty() {
406                "none".to_string()
407            } else {
408                names.join(", ")
409            }
410        );
411    };
412    let server = server_path()?;
413    let mut steps = vec![
414        pack_step(dry),
415        host_key_step(dry),
416        register_step(h, &server, dry),
417    ];
418    if let Some(file) = &h.hooks {
419        steps.push(hook_step(&expand(file), &hook_events_of(h), dry));
420    }
421    match &h.skills {
422        Some(dir) => steps.push(write_skill(&expand(dir), dry)),
423        None => steps.push(Step {
424            what: "skill".into(),
425            detail: "no skills directory in harnesses.toml; `ljos protocol` prints the text".into(),
426            ok: false,
427        }),
428    }
429    Ok(steps)
430}
431
432/// The events the memory hook fires on when a runner's table names none:
433/// the prompt, which carries the task in the person's words. A tool call
434/// carries the command about to run and is a cue too; a runner asks for it
435/// with `hook_events`. The default came out of a panel of this seat's
436/// personas: a turn issues many shell commands and one prompt.
437pub const HOOK_EVENTS: &[&str] = &["UserPromptSubmit", "SessionEnd"];
438
439/// The events the hook knows a matcher for; any other event takes `*`.
440pub const HOOK_MATCHERS: &[(&str, &str)] = &[
441    ("PreToolUse", "Bash"),
442    ("PostToolUse", "*"),
443    ("UserPromptSubmit", "*"),
444    ("SessionEnd", "*"),
445];
446
447/// One runner sends snake_case `hookEventName`; another sends
448/// PascalCase `hook_event_name`. One name in the seat.
449fn normalize_hook_event(raw: &str) -> &str {
450    match raw {
451        "pre_tool_use" | "PreToolUse" => "PreToolUse",
452        "post_tool_use" | "PostToolUse" => "PostToolUse",
453        "user_prompt_submit" | "UserPromptSubmit" => "UserPromptSubmit",
454        "session_end" | "SessionEnd" => "SessionEnd",
455        "session_start" | "SessionStart" => "SessionStart",
456        other => other,
457    }
458}
459
460fn hook_matcher(event: &str) -> &'static str {
461    HOOK_MATCHERS
462        .iter()
463        .find(|(e, _)| *e == event)
464        .map_or("*", |(_, m)| m)
465}
466
467/// The events a runner's table asks for, or the default.
468fn hook_events_of(h: &Harness) -> Vec<String> {
469    if h.hook_events.is_empty() {
470        HOOK_EVENTS.iter().map(|e| (*e).to_string()).collect()
471    } else {
472        h.hook_events.clone()
473    }
474}
475
476fn is_seat_hook(h: &Value) -> bool {
477    h["command"]
478        .as_str()
479        .is_some_and(|c| c.contains("ljos") && c.ends_with(" hook"))
480}
481
482/// The command the runner's hook runs.
483fn hook_command() -> String {
484    which::which("ljos").map_or_else(
485        |_| "ljos hook".to_string(),
486        |p| format!("{} hook", p.display()),
487    )
488}
489
490/// Merge the seat's memory hook into a runner's hooks file, once per event.
491/// The file is JSON with a `hooks` object of event name to matcher groups;
492/// a group whose command is the seat's is left alone, so the step is
493/// idempotent.
494fn hook_step(file: &Path, events: &[String], dry: bool) -> Step {
495    let what = "hook".to_string();
496    let mut root: Value = match std::fs::read_to_string(file) {
497        Ok(text) if !text.trim().is_empty() => match serde_json::from_str(&text) {
498            Ok(v) => v,
499            Err(e) => {
500                return Step {
501                    what,
502                    detail: format!("{}: not JSON: {e}", file.display()),
503                    ok: false,
504                }
505            }
506        },
507        _ => serde_json::json!({}),
508    };
509    let command = hook_command();
510    let Some(obj) = root.as_object_mut() else {
511        return Step {
512            what,
513            detail: format!("{}: not a JSON object", file.display()),
514            ok: false,
515        };
516    };
517    let hooks = obj.entry("hooks").or_insert_with(|| serde_json::json!({}));
518    let Some(hooks) = hooks.as_object_mut() else {
519        return Step {
520            what,
521            detail: format!("{}: hooks is not an object", file.display()),
522            ok: false,
523        };
524    };
525    // Reconcile: the seat's hook is on the events asked for and on no
526    // other, and every group that is not the seat's is left alone.
527    let mut added = Vec::new();
528    let mut removed = Vec::new();
529    for event in events {
530        let groups = hooks
531            .entry(event.clone())
532            .or_insert_with(|| serde_json::json!([]));
533        let Some(groups) = groups.as_array_mut() else {
534            continue;
535        };
536        let present = groups.iter().any(|g| {
537            g["hooks"]
538                .as_array()
539                .into_iter()
540                .flatten()
541                .any(is_seat_hook)
542        });
543        if present {
544            continue;
545        }
546        groups.push(serde_json::json!({
547            "matcher": hook_matcher(event),
548            "hooks": [{"type": "command", "command": command, "timeout": 20}]
549        }));
550        added.push(event.clone());
551    }
552    for (event, groups) in hooks.iter_mut() {
553        if events.contains(event) {
554            continue;
555        }
556        let Some(groups) = groups.as_array_mut() else {
557            continue;
558        };
559        let before = groups.len();
560        groups.retain(|g| {
561            !g["hooks"]
562                .as_array()
563                .into_iter()
564                .flatten()
565                .any(is_seat_hook)
566        });
567        if groups.len() != before {
568            removed.push(event.clone());
569        }
570    }
571    if added.is_empty() && removed.is_empty() {
572        return Step {
573            what,
574            detail: format!(
575                "{} carries the memory hook on {}",
576                file.display(),
577                events.join(", ")
578            ),
579            ok: true,
580        };
581    }
582    let mut change = Vec::new();
583    if !added.is_empty() {
584        change.push(format!("add it on {}", added.join(", ")));
585    }
586    if !removed.is_empty() {
587        change.push(format!("drop it from {}", removed.join(", ")));
588    }
589    let change = change.join(" and ");
590    if dry {
591        return Step {
592            what,
593            detail: format!("would {change} in {}", file.display()),
594            ok: true,
595        };
596    }
597    let written = file
598        .parent()
599        .map_or(Ok(()), std::fs::create_dir_all)
600        .and_then(|()| serde_json::to_string_pretty(&root).map_err(std::io::Error::other))
601        .and_then(|text| std::fs::write(file, text + "\n"));
602    match written {
603        Ok(()) => Step {
604            what,
605            detail: format!("memory hook: {change} in {}", file.display()),
606            ok: true,
607        },
608        Err(e) => Step {
609            what,
610            detail: format!("{}: {e}", file.display()),
611            ok: false,
612        },
613    }
614}
615
616/// Whether a runner's hooks file carries the memory hook on every event.
617fn hook_installed(file: &Path, events: &[String]) -> bool {
618    let Ok(text) = std::fs::read_to_string(file) else {
619        return false;
620    };
621    let Ok(root) = serde_json::from_str::<Value>(&text) else {
622        return false;
623    };
624    events.iter().all(|event| {
625        root["hooks"][event.as_str()]
626            .as_array()
627            .into_iter()
628            .flatten()
629            .any(|g| {
630                g["hooks"]
631                    .as_array()
632                    .into_iter()
633                    .flatten()
634                    .any(is_seat_hook)
635            })
636    })
637}
638
639/// What the runner's hook hands the seat: the event, and the text worth
640/// asking the pack about. From a tool call, the command about to run; from
641/// a prompt, the prompt.
642#[derive(Debug, Clone, PartialEq, Eq)]
643pub struct HookCall {
644    pub event: String,
645    pub cue: String,
646    /// The runner's session, when it says: each memory is injected once
647    /// per session, so the same lesson does not arrive on every command.
648    pub session: Option<String>,
649}
650
651/// Read a hook call from the runner's JSON, or from plain text (an argv
652/// under argv law). Fields: `hook_event_name`, `tool_name`, `tool_input`
653/// (its `command`, else every string value joined), `prompt`.
654#[must_use]
655pub fn hook_call(input: &str) -> HookCall {
656    let trimmed = input.trim();
657    let Ok(v) = serde_json::from_str::<Value>(trimmed) else {
658        return HookCall {
659            event: "argv".into(),
660            cue: trimmed.to_string(),
661            session: None,
662        };
663    };
664    let session = v["session_id"]
665        .as_str()
666        .or_else(|| v["sessionId"].as_str())
667        .filter(|s| !s.is_empty())
668        .map(str::to_string);
669    let raw = v["hook_event_name"]
670        .as_str()
671        .or_else(|| v["hookEventName"].as_str())
672        .unwrap_or("PreToolUse");
673    let event = normalize_hook_event(raw).to_string();
674    let cue = if let Some(p) = v["prompt"].as_str() {
675        p.to_string()
676    } else if let Some(c) = v["tool_input"]["command"].as_str() {
677        c.to_string()
678    } else if let Some(map) = v["tool_input"].as_object() {
679        map.values()
680            .filter_map(Value::as_str)
681            .collect::<Vec<_>>()
682            .join(" ")
683    } else {
684        String::new()
685    };
686    HookCall {
687        event,
688        cue,
689        session,
690    }
691}
692
693/// Where the ids already injected in a session are kept: the runtime
694/// directory, so they go with the login and never into the pack.
695fn seen_path(session: &str) -> Option<PathBuf> {
696    let safe: String = session
697        .chars()
698        .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
699        .collect();
700    if safe.is_empty() {
701        return None;
702    }
703    let dir = std::env::var_os("XDG_RUNTIME_DIR")
704        .filter(|r| !r.is_empty())
705        .map(PathBuf::from)
706        .unwrap_or_else(std::env::temp_dir)
707        .join("ljos");
708    Some(dir.join(format!("hook-seen-{safe}")))
709}
710
711fn seen_ids(session: Option<&str>) -> std::collections::BTreeSet<String> {
712    session
713        .and_then(seen_path)
714        .and_then(|p| std::fs::read_to_string(p).ok())
715        .map(|t| t.lines().map(str::to_string).collect())
716        .unwrap_or_default()
717}
718
719/// The memories injected during a session, in the order they arrived, and
720/// the file they were kept in. The nudge marker is not a memory.
721fn injected_ids(session: &str) -> (Vec<String>, Option<PathBuf>) {
722    let path = seen_path(session);
723    let ids: Vec<String> = path
724        .as_ref()
725        .and_then(|p| std::fs::read_to_string(p).ok())
726        .map(|t| {
727            t.lines()
728                .map(str::trim)
729                .filter(|l| !l.is_empty() && *l != "due-nudge")
730                .map(str::to_string)
731                .collect()
732        })
733        .unwrap_or_default();
734    (ids, path)
735}
736
737/// When a session ends, the memories injected during it fire together:
738/// they served one sitting, so their links gain weight and the next
739/// sitting like it walks a heavier path (Hebb, through the pack's `fire`).
740/// The seen file goes with the session. Returns how many fired; nothing to
741/// fire, or no pack, is zero and not an error, since a hook must not stop
742/// a runner from ending.
743pub fn session_end(session: Option<&str>) -> usize {
744    let Some(session) = session else {
745        return 0;
746    };
747    let (ids, path) = injected_ids(session);
748    let fired = if ids.len() >= 2 {
749        let top: Vec<String> = ids.into_iter().take(8).collect();
750        pack()
751            .ok()
752            .and_then(|c| c.fire(&c.workspace(), &top).ok())
753            .map_or(0, |_| top.len())
754    } else {
755        0
756    };
757    if let Some(p) = path {
758        let _ = std::fs::remove_file(p);
759    }
760    fired
761}
762
763/// Where a Grok prompt's pack context waits for `PostToolUse`.
764/// Grok discards `UserPromptSubmit` stdout; it delivers
765/// `PostToolUse` `additionalContext` after the first tool.
766fn hook_hold_path(session: Option<&str>) -> Option<PathBuf> {
767    let dir = std::env::var_os("XDG_RUNTIME_DIR")
768        .map(PathBuf::from)
769        .or_else(|| std::env::var_os("TMPDIR").map(PathBuf::from))
770        .unwrap_or_else(|| PathBuf::from("/tmp"));
771    let name = session
772        .filter(|s| !s.is_empty())
773        .map(|s| {
774            s.chars()
775                .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
776                .take(32)
777                .collect::<String>()
778        })
779        .filter(|s| !s.is_empty())
780        .unwrap_or_else(|| "default".into());
781    Some(dir.join(format!("ljos-hook-hold-{name}")))
782}
783
784/// Remember the prompt's pack text so the next `PostToolUse` can emit it.
785pub fn hold_hook_context(session: Option<&str>, context: &str) {
786    let Some(path) = hook_hold_path(session) else {
787        return;
788    };
789    if context.is_empty() {
790        let _ = std::fs::remove_file(&path);
791        return;
792    }
793    let _ = std::fs::write(path, context);
794}
795
796/// Take the held pack text once. Empty if nothing was held.
797#[must_use]
798pub fn take_hook_context(session: Option<&str>) -> String {
799    let Some(path) = hook_hold_path(session) else {
800        return String::new();
801    };
802    let text = std::fs::read_to_string(&path).unwrap_or_default();
803    let _ = std::fs::remove_file(&path);
804    text
805}
806
807fn mark_seen(session: Option<&str>, ids: &[String]) {
808    let Some(path) = session.and_then(seen_path) else {
809        return;
810    };
811    if let Some(dir) = path.parent() {
812        let _ = std::fs::create_dir_all(dir);
813    }
814    let mut text = std::fs::read_to_string(&path).unwrap_or_default();
815    for id in ids {
816        text.push_str(id);
817        text.push('\n');
818    }
819    let _ = std::fs::write(path, text);
820}
821
822/// The floor a hit must reach, as a share of the strongest hit's score, to
823/// be injected. A command line matches many claims weakly; only the ones
824/// that match it as well as the best does are worth the agent's context.
825pub const HOOK_SCORE_FLOOR: f64 = 0.6;
826
827/// The context the hook injects: the island the cue activates, standing
828/// preferences first because they bear on what to do, then lessons. Empty
829/// when the pack holds nothing on it or does not answer; a hook that fails
830/// must not stop the runner, so this never errors.
831#[must_use]
832pub fn hook_context(call: &HookCall, limit: usize) -> String {
833    let cue = call.cue.trim();
834    if cue.len() < 3 {
835        return String::new();
836    }
837    let Ok(hits) = packset_search(cue) else {
838        return String::new();
839    };
840    let top = hits.iter().map(|h| h.score).fold(0.0_f64, f64::max);
841    if top <= 0.0 {
842        return String::new();
843    }
844    let seen = seen_ids(call.session.as_deref());
845    let mut rows: Vec<&Hit> = hits
846        .iter()
847        .filter(|h| !UNREVIEWED_KINDS.contains(&h.kind.as_str()))
848        .filter(|h| h.score >= top * HOOK_SCORE_FLOOR)
849        .filter(|h| agreed(h))
850        .filter(|h| h.id.as_ref().is_none_or(|id| !seen.contains(id)))
851        .collect();
852    rows.sort_by(|a, b| {
853        let pa = a.kind == "preference";
854        let pb = b.kind == "preference";
855        pb.cmp(&pa).then(
856            b.score
857                .partial_cmp(&a.score)
858                .unwrap_or(std::cmp::Ordering::Equal),
859        )
860    });
861    let mut rows: Vec<&Hit> = rows.into_iter().take(limit).collect();
862    // Preferences stay in front by score; the lessons behind them run
863    // oldest to newest, so what was learnt last is read last and nearest
864    // the action, and a later lesson that revises an earlier one reads as
865    // a revision.
866    let now = now_utc();
867    let split = rows.iter().filter(|h| h.kind == "preference").count();
868    rows[split..].sort_by_key(|h| days_of_stamp(h.ts.as_deref()).unwrap_or(i64::MAX));
869    let lines: Vec<String> = rows.iter().map(|h| hit_line(h, &now)).collect();
870    let mut nudge = due_nudge(call);
871    if let Some(c) = correction_nudge(call) {
872        if !nudge.is_empty() {
873            nudge.push('\n');
874        }
875        nudge.push_str(&c);
876    }
877    if lines.is_empty() {
878        return nudge;
879    }
880    mark_seen(
881        call.session.as_deref(),
882        &rows.iter().filter_map(|h| h.id.clone()).collect::<Vec<_>>(),
883    );
884    let mut out = format!(
885        "What this seat already knows that bears on this (from the pack, each with its age, lessons oldest first; `ljos search` for more):\n{}",
886        lines.join("\n")
887    );
888    if !nudge.is_empty() {
889        out.push('\n');
890        out.push_str(&nudge);
891    }
892    out
893}
894
895/// Whether the pack's scorers agreed on a hit: named by at least two of
896/// the ballots that ran. When one ballot ran, or the hit carries no
897/// count, it stands. A command line matches many claims weakly on one
898/// scorer; what reaches the agent unasked should be what two scorers
899/// found.
900fn agreed(h: &Hit) -> bool {
901    match (h.ballots, h.of) {
902        (Some(named), Some(of)) if of >= 2 => named >= 2,
903        _ => true,
904    }
905}
906
907/// Phrases a person uses when the agent has forgotten something it was
908/// told. A prompt that opens this way is a preference or a lesson the
909/// pack does not hold yet, and the moment to write it is now, before the
910/// work that follows.
911pub const CORRECTION_CUES: &[&str] = &[
912    "do you not remember",
913    "don't you remember",
914    "dont you remember",
915    "you should have",
916    "why did you not",
917    "why didn't you",
918    "why havent you",
919    "why haven't you",
920    "you forgot",
921    "i told you",
922    "i've told you",
923    "as i said",
924    "again you",
925    "still not",
926    "not even able",
927    "you never",
928    "you keep",
929];
930
931/// On a prompt that reads as a correction, the one line that turns it
932/// into memory: the agent writes the preference or lesson with `ljos
933/// prefer` or `ljos remember` before it goes on. Once a session for the
934/// same cue, so a run of corrections does not repeat it.
935fn correction_nudge(call: &HookCall) -> Option<String> {
936    if call.event != "UserPromptSubmit" {
937        return None;
938    }
939    let lower = call.cue.to_lowercase();
940    let hit = CORRECTION_CUES.iter().find(|c| lower.contains(*c))?;
941    let key = format!("correction:{hit}");
942    if seen_ids(call.session.as_deref()).contains(&key) {
943        return None;
944    }
945    mark_seen(call.session.as_deref(), &[key]);
946    Some(
947        "This prompt reads as a correction. Before the work: write what it corrects as one \
948         `ljos prefer \"...\"` (a standing choice) or `ljos remember \"...\"` (a lesson), \
949         so the pack holds it and the hook can raise it next time."
950            .to_string(),
951    )
952}
953
954/// On a prompt, once per session: how many claims are due for review. The
955/// review loop runs only when somebody grades, and nobody grades what they
956/// were not told about.
957fn due_nudge(call: &HookCall) -> String {
958    if call.event != "UserPromptSubmit" {
959        return String::new();
960    }
961    let key = "due-nudge".to_string();
962    if seen_ids(call.session.as_deref()).contains(&key) {
963        return String::new();
964    }
965    let Ok(client) = pack() else {
966        return String::new();
967    };
968    let Ok(atoms) = client.atoms_as_of(&client.workspace(), None) else {
969        return String::new();
970    };
971    let due = due_of(&atoms, &now_utc()).len();
972    // Counted once a session either way; a quiet seat is not re-counted on
973    // every prompt. Do not call consolidate here: that walk is a sitting,
974    // not a hook, and it is what made PreToolUse time out at 20s.
975    mark_seen(call.session.as_deref(), &[key]);
976    if due == 0 {
977        return String::new();
978    }
979    format!(
980        "{due} claim{} due for review in this seat: `ljos due`, read each, then `ljos graded ID` (or `--lapsed`).",
981        if due == 1 { " is" } else { "s are" }
982    )
983}
984
985/// The hook's answer in the runner's JSON: `additionalContext` under the
986/// event that fired. Empty context is no output, which the runner reads as
987/// no opinion.
988#[must_use]
989pub fn hook_output(call: &HookCall, context: &str) -> String {
990    hook_output_ruled(call, context, None)
991}
992
993/// [`hook_output`] carrying a rule's verdict on a tool call: `deny` or
994/// `ask` as the runner's permission decision, with the rule's reason. On a
995/// prompt or an argv line the verdict is a line of text.
996#[must_use]
997pub fn hook_output_ruled(call: &HookCall, context: &str, verdict: Option<&Rule>) -> String {
998    if context.is_empty() && verdict.is_none() {
999        return String::new();
1000    }
1001    if call.event == "argv" {
1002        let mut out = String::new();
1003        if let Some(r) = verdict {
1004            out.push_str(&format!(
1005                "{}: {} (rule `{}`)\n",
1006                r.verdict, r.reason, r.pattern
1007            ));
1008        }
1009        if !context.is_empty() {
1010            out.push_str(context);
1011            out.push('\n');
1012        }
1013        return out;
1014    }
1015    let mut specific = serde_json::json!({ "hookEventName": call.event });
1016    if !context.is_empty() {
1017        specific["additionalContext"] = Value::String(context.to_string());
1018    }
1019    if let Some(r) = verdict {
1020        if call.event == "PreToolUse" {
1021            specific["permissionDecision"] = Value::String(r.verdict.clone());
1022            specific["permissionDecisionReason"] =
1023                Value::String(format!("{} (seat rule `{}`)", r.reason, r.pattern));
1024        }
1025    }
1026    serde_json::json!({ "hookSpecificOutput": specific }).to_string() + "\n"
1027}
1028
1029pub fn format_steps(steps: &[Step]) -> String {
1030    steps
1031        .iter()
1032        .map(|s| {
1033            format!(
1034                "{}\t{}\t{}\n",
1035                if s.ok { "ok" } else { "no" },
1036                s.what,
1037                s.detail
1038            )
1039        })
1040        .collect()
1041}
1042
1043/// The runner rows for `doctor`, one pair per runner the file names.
1044fn harness_rows() -> Vec<Habitat> {
1045    let path = harnesses_path();
1046    let all = match harnesses_from(&path) {
1047        Ok(all) => all,
1048        Err(e) => {
1049            return vec![Habitat {
1050                name: "runners",
1051                state: format!("{e:#}"),
1052                ok: false,
1053            }]
1054        }
1055    };
1056    if all.harness.is_empty() {
1057        return vec![Habitat {
1058            name: "runners",
1059            state: format!(
1060                "none named in {}; `ljos onboard --example` prints the shape",
1061                path.display()
1062            ),
1063            ok: false,
1064        }];
1065    }
1066    let server = server_path().unwrap_or_else(|_| PathBuf::from("ljos-mcp"));
1067    let mut rows = Vec::new();
1068    for h in &all.harness {
1069        let registered = is_registered(h, &server) == Some(true);
1070        rows.push(Habitat {
1071            name: "runner mcp",
1072            state: if registered {
1073                format!("{}: ljos registered", h.name)
1074            } else {
1075                format!(
1076                    "{}: not registered; ljos onboard --harness {}",
1077                    h.name, h.name
1078                )
1079            },
1080            ok: registered,
1081        });
1082        let skill = h
1083            .skills
1084            .as_deref()
1085            .map(|d| expand(d).join("ljos").join("SKILL.md"));
1086        let current = skill
1087            .as_ref()
1088            .is_some_and(|p| std::fs::read_to_string(p).is_ok_and(|t| t == skill_text()));
1089        if let Some(file) = &h.hooks {
1090            let path = expand(file);
1091            let installed = hook_installed(&path, &hook_events_of(h));
1092            rows.push(Habitat {
1093                name: "runner hook",
1094                state: if installed {
1095                    format!("{}: memory hook on {}", h.name, path.display())
1096                } else {
1097                    format!(
1098                        "{}: no memory hook; ljos onboard --harness {}",
1099                        h.name, h.name
1100                    )
1101                },
1102                ok: installed,
1103            });
1104        }
1105        rows.push(Habitat {
1106            name: "runner skill",
1107            state: match (&skill, current) {
1108                (Some(p), true) => format!("{}: {}", h.name, p.display()),
1109                (Some(p), false) if p.is_file() => {
1110                    format!(
1111                        "{}: {} is stale; ljos onboard --harness {}",
1112                        h.name,
1113                        p.display(),
1114                        h.name
1115                    )
1116                }
1117                (Some(_), false) => {
1118                    format!("{}: absent; ljos onboard --harness {}", h.name, h.name)
1119                }
1120                (None, _) => format!("{}: no skills directory named", h.name),
1121            },
1122            ok: current,
1123        });
1124    }
1125    rows
1126}
1127
1128/// Have a pack writer up before anything else is wired: a runner onboarded
1129/// to a seat with no writer would meet every memory verb failing. `packset
1130/// ensure` starts one when none answers and is idempotent when one does.
1131fn pack_step(dry: bool) -> Step {
1132    let what = "pack".to_string();
1133    if let Ok(client) = pack() {
1134        if client.health().is_ok() {
1135            return Step {
1136                what,
1137                detail: format!("writer up at {}", client.base()),
1138                ok: true,
1139            };
1140        }
1141    } else {
1142        return Step {
1143            what,
1144            detail: "PACKSET_URL=off; no pack on purpose".into(),
1145            ok: true,
1146        };
1147    }
1148    if !on_path("packset") {
1149        return Step {
1150            what,
1151            detail: "no writer answers and packset is not on PATH".into(),
1152            ok: false,
1153        };
1154    }
1155    if dry {
1156        return Step {
1157            what,
1158            detail: "would run packset ensure".into(),
1159            ok: true,
1160        };
1161    }
1162    match run_captured("packset", &["ensure"]) {
1163        Ok(said) => Step {
1164            what,
1165            detail: format!(
1166                "started a writer: {}",
1167                said.stdout.lines().next().unwrap_or("").trim()
1168            ),
1169            ok: true,
1170        },
1171        Err(e) => Step {
1172            what,
1173            detail: e.to_string().lines().next().unwrap_or("").to_string(),
1174            ok: false,
1175        },
1176    }
1177}
1178
1179/// Make the seat's host key at `~/.config/deedar/host.key` when there is
1180/// none, so handovers go out signed from the first one. An existing key, or
1181/// one named by `DEEDAR_HOST_SIGNING_KEY`, is left alone.
1182fn host_key_step(dry: bool) -> Step {
1183    if let Some(path) = host_key_path() {
1184        return Step {
1185            what: "host key".into(),
1186            detail: format!("{} exists", path.display()),
1187            ok: true,
1188        };
1189    }
1190    if std::env::var_os("DEEDAR_HOST_SIGNING_KEY").is_some_and(|r| r == "off") {
1191        return Step {
1192            what: "host key".into(),
1193            detail: "DEEDAR_HOST_SIGNING_KEY=off; handovers go out unsigned on purpose".into(),
1194            ok: true,
1195        };
1196    }
1197    let Some(path) = default_host_key_path() else {
1198        return Step {
1199            what: "host key".into(),
1200            detail: "no home directory to keep a key in".into(),
1201            ok: false,
1202        };
1203    };
1204    if dry {
1205        return Step {
1206            what: "host key".into(),
1207            detail: format!("would write a 32-byte seed to {}", path.display()),
1208            ok: true,
1209        };
1210    }
1211    let made = (|| -> std::io::Result<()> {
1212        use std::io::Read;
1213        let mut seed = [0u8; 32];
1214        std::fs::File::open("/dev/urandom")?.read_exact(&mut seed)?;
1215        if let Some(dir) = path.parent() {
1216            std::fs::create_dir_all(dir)?;
1217        }
1218        std::fs::write(&path, seed)?;
1219        #[cfg(unix)]
1220        {
1221            use std::os::unix::fs::PermissionsExt;
1222            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
1223        }
1224        Ok(())
1225    })();
1226    match made {
1227        Ok(()) => Step {
1228            what: "host key".into(),
1229            detail: format!("wrote a 32-byte seed to {}", path.display()),
1230            ok: true,
1231        },
1232        Err(e) => Step {
1233            what: "host key".into(),
1234            detail: format!("{}: {e}", path.display()),
1235            ok: false,
1236        },
1237    }
1238}
1239
1240/// `$XDG_CONFIG_HOME/deedar/host.key`, whether or not it exists.
1241fn default_host_key_path() -> Option<PathBuf> {
1242    let config = std::env::var_os("XDG_CONFIG_HOME")
1243        .filter(|r| !r.is_empty())
1244        .map(PathBuf::from)
1245        .or_else(|| home().ok().map(|h| h.join(".config")))?;
1246    Some(config.join("deedar").join("host.key"))
1247}
1248
1249/// The host key `deedar` will sign with: `DEEDAR_HOST_SIGNING_KEY`, else
1250/// `~/.config/deedar/host.key` when it exists. `off` is no key on purpose.
1251fn host_key_path() -> Option<PathBuf> {
1252    if let Some(raw) = std::env::var_os("DEEDAR_HOST_SIGNING_KEY").filter(|r| !r.is_empty()) {
1253        return (raw != "off").then(|| PathBuf::from(raw));
1254    }
1255    let path = default_host_key_path()?;
1256    path.is_file().then_some(path)
1257}
1258
1259/// Printed on stderr. `ljos-policyd` is the TCB when it exists.
1260pub const POLICY_TCB: &str =
1261    "argv law. ljos-policyd is the TCB when present. Reloading a pack is not a check.";
1262
1263/// The workspace the seat's memory lives in when nothing names one. The
1264/// pack's command line keys a workspace to the repository it stands in;
1265/// a seat is one memory across every repository it works in, so the seat
1266/// pins one. `PACKSET_WORKSPACE` overrides it.
1267pub const SEAT_WORKSPACE: &str = "seat";
1268
1269/// The pack client. With nothing set it speaks to `127.0.0.1:8761` about
1270/// the `seat` workspace; `PACKSET_URL` points elsewhere, `PACKSET_WORKSPACE`
1271/// names another workspace, and `PACKSET_URL=off` is the one way to have no
1272/// pack.
1273pub fn pack() -> Result<PacksetClient> {
1274    let workspace = std::env::var("PACKSET_WORKSPACE")
1275        .ok()
1276        .filter(|w| !w.is_empty())
1277        .unwrap_or_else(|| SEAT_WORKSPACE.to_string());
1278    Ok(PacksetClient::from_env()
1279        .context("PACKSET_URL=off: this seat has no pack on purpose")?
1280        .with_workspace(workspace))
1281}
1282
1283pub fn join(parts: &[String]) -> String {
1284    parts.join(" ")
1285}
1286
1287/// Remember → lesson, Prefer → preference. Trust rows go through [`trust_atom`].
1288pub fn atom_kind(label: &str) -> Result<&'static str> {
1289    match label {
1290        "Remember" => Ok("lesson"),
1291        "Prefer" => Ok("preference"),
1292        other => bail!("unknown write kind {other}"),
1293    }
1294}
1295
1296/// Explicit claim body. The text is stored as given; never harvested.
1297pub fn atom_body(kind: &str, text: &str, workspace: &str) -> Value {
1298    serde_json::json!({
1299        "schema": "inside.atom/v1",
1300        "kind": kind,
1301        "level": "explicit",
1302        "text": text,
1303        "workspace": workspace,
1304    })
1305}
1306
1307/// POST one explicit claim. Callers pass Remember/Prefer only.
1308pub fn post_claim(
1309    client: &PacksetClient,
1310    label: &str,
1311    text: &str,
1312    workspace: &str,
1313) -> Result<Value> {
1314    let trimmed = text.trim();
1315    if trimmed.is_empty() {
1316        bail!("{label}: empty text is not a claim");
1317    }
1318    let kind = atom_kind(label)?;
1319    let atom = atom_body(kind, trimmed, workspace);
1320    client
1321        .post_atom(&atom)
1322        .with_context(|| format!("{label}: POST /v1/atoms failed"))
1323}
1324
1325pub fn packset_write(label: &str, text: &str) -> Result<Value> {
1326    packset_write_as(label, text, None)
1327}
1328
1329/// The entity a persona's own claims carry, so a brief can find them.
1330#[must_use]
1331pub fn persona_entity(name: &str) -> String {
1332    format!("persona:{}", name.trim().to_lowercase())
1333}
1334
1335/// [`packset_write`] as a persona: the claim carries the persona's entity,
1336/// so what a persona learned comes back to it first in its next brief and
1337/// stays in the seat's one pack. A persona accumulates its own lessons the
1338/// way a reviewer does; the seat still reads them all.
1339pub fn packset_write_as(label: &str, text: &str, persona: Option<&str>) -> Result<Value> {
1340    let client = pack()?;
1341    let workspace = client.workspace();
1342    let Some(name) = persona.map(str::trim).filter(|n| !n.is_empty()) else {
1343        return post_claim(&client, label, text, &workspace);
1344    };
1345    let trimmed = text.trim();
1346    if trimmed.is_empty() {
1347        bail!("{label}: empty text is not a claim");
1348    }
1349    let kind = atom_kind(label)?;
1350    let mut atom = atom_body(kind, trimmed, &workspace);
1351    atom["entities"] = Value::Array(vec![Value::String(persona_entity(name))]);
1352    client
1353        .post_atom(&atom)
1354        .with_context(|| format!("{label}: POST /v1/atoms failed"))
1355}
1356
1357/// Retire one atom from the workspace the cwd resolves to, optionally naming
1358/// the deed that withdrew it.
1359///
1360/// The daemon tombstones rather than erases: the atom stops being recalled and
1361/// the pack still records that it was held and withdrawn. That is the right
1362/// shape for standing knowledge, where "we no longer believe this" is itself
1363/// worth keeping.
1364///
1365/// `why` is a deed accession and the pack refuses free text in its place. It
1366/// runs the same join as a remembered claim's `entities`, in the same
1367/// direction: the pack cites the deed store, never the other way round. A
1368/// retraction the work justified is therefore checkable with `deedar evidence`
1369/// like any other citation, and one nothing justified simply carries no `why`.
1370///
1371/// # Errors
1372///
1373/// An unset `PACKSET_URL`, an id the workspace does not hold, a `why` that is
1374/// not an accession, or the request's.
1375pub fn packset_forget(id: &str, why: Option<&str>) -> Result<Value> {
1376    let trimmed = id.trim();
1377    if trimmed.is_empty() {
1378        bail!("forget: an atom id is required");
1379    }
1380    let why = why.map(str::trim).filter(|w| !w.is_empty());
1381    let client = pack()?;
1382    let workspace = client.workspace();
1383    client
1384        .delete_atom(&workspace, trimmed, why)
1385        .with_context(|| format!("forget: POST /v1/atoms/delete failed for {trimmed}"))
1386}
1387
1388/// One row of the influence graph: `from` listens to `to` with `weight`.
1389/// `about` scopes the row to the domains it speaks to: a row with none
1390/// applies everywhere, a row with some applies when one of them meets the
1391/// issue at hand (its title, or the entities of the island it activates).
1392#[derive(Debug, Clone, PartialEq, Default)]
1393pub struct Trust {
1394    pub from: String,
1395    pub to: String,
1396    pub weight: f64,
1397    pub about: Vec<String>,
1398}
1399
1400/// A voter with a view of its own: a persona. `anchor` in `[0, 1]` is how
1401/// far it moves off its ballot in a settle; 0 never moves, 1 is a plain
1402/// DeGroot voter. `entities` are the domains it speaks to.
1403#[derive(Debug, Clone, PartialEq)]
1404pub struct Persona {
1405    pub name: String,
1406    pub anchor: f64,
1407    pub view: String,
1408    pub entities: Vec<String>,
1409}
1410
1411/// The `persona` atom for the pack: kind `persona`, the view as text.
1412///
1413/// # Errors
1414///
1415/// An empty name, an anchor outside `[0, 1]`, or an empty view.
1416pub fn persona_atom(p: &Persona, workspace: &str) -> Result<Value> {
1417    let name = p.name.trim();
1418    if name.is_empty() {
1419        bail!("persona: a name is required");
1420    }
1421    if !(0.0..=1.0).contains(&p.anchor) {
1422        bail!("persona: anchor {} is not in [0, 1]", p.anchor);
1423    }
1424    let view = p.view.trim();
1425    if view.is_empty() {
1426        bail!("persona: say in a sentence or two how {name} reads the work");
1427    }
1428    let mut atom = atom_body("persona", view, workspace);
1429    atom["name"] = Value::String(name.into());
1430    atom["anchor"] = serde_json::json!(p.anchor);
1431    if !p.entities.is_empty() {
1432        atom["entities"] = Value::Array(
1433            p.entities
1434                .iter()
1435                .map(|e| Value::String(e.to_lowercase()))
1436                .collect(),
1437        );
1438    }
1439    Ok(atom)
1440}
1441
1442/// POST one persona.
1443pub fn write_persona(p: &Persona) -> Result<Value> {
1444    let client = pack()?;
1445    let workspace = client.workspace();
1446    client
1447        .post_atom(&persona_atom(p, &workspace)?)
1448        .context("persona: POST /v1/atoms failed")
1449}
1450
1451/// The live personas: the latest `persona` atom per name.
1452pub fn personas_of(atoms: &[Value]) -> Vec<Persona> {
1453    let mut latest: std::collections::BTreeMap<String, (String, Persona)> =
1454        std::collections::BTreeMap::new();
1455    for atom in atoms {
1456        if atom.get("kind").and_then(Value::as_str) != Some("persona") {
1457            continue;
1458        }
1459        let (Some(name), Some(anchor)) = (
1460            atom.get("name").and_then(Value::as_str),
1461            atom.get("anchor").and_then(Value::as_f64),
1462        ) else {
1463            continue;
1464        };
1465        let ts = atom
1466            .get("ts")
1467            .and_then(Value::as_str)
1468            .unwrap_or("")
1469            .to_string();
1470        let p = Persona {
1471            name: name.to_string(),
1472            anchor,
1473            view: atom
1474                .get("text")
1475                .and_then(Value::as_str)
1476                .unwrap_or("")
1477                .to_string(),
1478            entities: words_of(atom.get("entities")),
1479        };
1480        match latest.get(name) {
1481            Some((seen, _)) if *seen > ts => {}
1482            _ => {
1483                latest.insert(name.to_string(), (ts, p));
1484            }
1485        }
1486    }
1487    latest.into_values().map(|(_, p)| p).collect()
1488}
1489
1490/// The personas in the seat's pack.
1491pub fn personas_from_pack() -> Result<Vec<Persona>> {
1492    let client = pack()?;
1493    let atoms = client
1494        .atoms_as_of(&client.workspace(), None)
1495        .context("persona: GET /v1/atoms failed")?;
1496    Ok(personas_of(&atoms))
1497}
1498
1499/// The brief a subagent playing a persona starts from: the persona's view
1500/// and domains, what the seat knows on those domains (preferences first),
1501/// and the issue's working set. One text, so a panel member reads the
1502/// same seat the rest do and still reads it its own way.
1503///
1504/// # Errors
1505///
1506/// No such persona in the pack, or the tracker or pack not answering.
1507pub fn brief(name: &str, issue: &str) -> Result<String> {
1508    let personas = personas_from_pack()?;
1509    let Some(p) = personas.iter().find(|p| p.name == name) else {
1510        let names: Vec<&str> = personas.iter().map(|p| p.name.as_str()).collect();
1511        bail!(
1512            "brief: no persona {name:?} in the pack; the pack holds {}",
1513            if names.is_empty() {
1514                "none".to_string()
1515            } else {
1516                names.join(", ")
1517            }
1518        );
1519    };
1520    let mut out = format!(
1521        "You are {}. {}\nYou hold your ballot at anchor {:.2}{}.\n",
1522        p.name,
1523        p.view,
1524        p.anchor,
1525        if p.entities.is_empty() {
1526            String::new()
1527        } else {
1528            format!("; you speak to {}", p.entities.join(", "))
1529        }
1530    );
1531    let mut seen = std::collections::BTreeSet::new();
1532    let mut lines = Vec::new();
1533    let now = now_utc();
1534    // What this persona remembered itself comes first: its own lessons,
1535    // written with `remember --as`, carry its entity.
1536    let client = pack()?;
1537    let own_tag = persona_entity(&p.name);
1538    if let Ok(atoms) = client.atoms_as_of(&client.workspace(), None) {
1539        let mut own: Vec<&Value> = atoms
1540            .iter()
1541            .filter(|a| reviewable(a))
1542            .filter(|a| words_of(a.get("entities")).contains(&own_tag))
1543            .collect();
1544        own.sort_by(|a, b| b["ts"].as_str().cmp(&a["ts"].as_str()));
1545        if !own.is_empty() {
1546            out.push_str("\nWhat you remembered yourself:\n");
1547            for a in own.iter().take(8) {
1548                if let Some(id) = a["id"].as_str() {
1549                    seen.insert(id.to_string());
1550                }
1551                out.push_str(&format!(
1552                    "- [{}{}] {}\n",
1553                    a["kind"].as_str().unwrap_or("claim"),
1554                    age_tag(a["ts"].as_str(), &now),
1555                    a["text"].as_str().unwrap_or("").trim()
1556                ));
1557            }
1558        }
1559    }
1560    let cues: Vec<String> = if p.entities.is_empty() {
1561        vec![issue_title(issue)?]
1562    } else {
1563        p.entities.clone()
1564    };
1565    for cue in &cues {
1566        let Ok(hits) = packset_search(cue) else {
1567            continue;
1568        };
1569        for h in hits.into_iter().take(5) {
1570            if UNREVIEWED_KINDS.contains(&h.kind.as_str()) {
1571                continue;
1572            }
1573            if let Some(id) = &h.id {
1574                if !seen.insert(id.clone()) {
1575                    continue;
1576                }
1577            }
1578            lines.push((h.kind == "preference", hit_line(&h, &now)));
1579        }
1580    }
1581    lines.sort_by(|a, b| b.0.cmp(&a.0));
1582    if !lines.is_empty() {
1583        out.push_str("\nWhat this seat knows on your domains:\n");
1584        for (_, l) in lines.iter().take(8) {
1585            out.push_str(l);
1586            out.push('\n');
1587        }
1588    }
1589    out.push_str("\nThe work:\n");
1590    out.push_str(&run_captured("vissue", &["recall", issue])?.stdout);
1591    out.push_str(&format!(
1592        "\nRead it your way and end with one ballot: `ljos vote {issue} --for OPTION --as {}`. \
1593         A lesson of your own goes in with `ljos remember --as {} \"...\"`.\n",
1594        p.name, p.name
1595    ));
1596    Ok(out)
1597}
1598
1599/// A panel for a runner with no MCP: one brief per persona written to
1600/// `out`, named `<persona>.md`, and the lines that run it. A runner starts
1601/// one subagent per file, each ends with the ballot its brief names, and
1602/// `ljos consensus ISSUE` settles.
1603///
1604/// # Errors
1605///
1606/// No personas in the pack, or a brief that cannot be written.
1607pub fn panel(issue: &str, out: &Path) -> Result<String> {
1608    let personas = personas_from_pack()?;
1609    if personas.is_empty() {
1610        bail!("panel: the pack holds no personas; `ljos persona NAME --anchor A --view ...` writes one");
1611    }
1612    std::fs::create_dir_all(out)?;
1613    let mut lines = vec![format!(
1614        "{} briefs in {}; start one subagent per file, each ends with its ballot, then:",
1615        personas.len(),
1616        out.display()
1617    )];
1618    for p in &personas {
1619        let path = out.join(format!("{}.md", p.name));
1620        std::fs::write(&path, brief(&p.name, issue)?)?;
1621        lines.push(format!("  {}", path.display()));
1622    }
1623    lines.push(format!("ljos consensus {issue}"));
1624    Ok(lines.join("\n") + "\n")
1625}
1626
1627/// One voter's forecast on one issue: what share the others give each
1628/// option, or the option it expects to win.
1629#[derive(Debug, Clone, PartialEq)]
1630pub struct Prediction {
1631    pub issue: String,
1632    pub agent: String,
1633    pub expect: Value,
1634}
1635
1636/// POST one forecast. `expect` is an option name or `{option: share}`.
1637pub fn write_prediction(issue: &str, agent: &str, expect: &str) -> Result<Value> {
1638    let (issue, agent, expect) = (issue.trim(), agent.trim(), expect.trim());
1639    if issue.is_empty() || agent.is_empty() || expect.is_empty() {
1640        bail!("predict: an issue, an identity and an expectation are required");
1641    }
1642    let expect_value: Value = match serde_json::from_str::<Value>(expect) {
1643        Ok(v @ Value::Object(_)) => v,
1644        _ => Value::String(expect.to_string()),
1645    };
1646    let client = pack()?;
1647    let workspace = client.workspace();
1648    let mut atom = atom_body(
1649        "prediction",
1650        &format!("{agent} expects {expect} on {issue}."),
1651        &workspace,
1652    );
1653    atom["issue"] = Value::String(issue.into());
1654    atom["agent"] = Value::String(agent.into());
1655    atom["expect"] = expect_value;
1656    client
1657        .post_atom(&atom)
1658        .context("predict: POST /v1/atoms failed")
1659}
1660
1661/// The latest forecast per agent on an issue.
1662pub fn predictions_of(atoms: &[Value], issue: &str) -> Vec<Prediction> {
1663    let mut latest: std::collections::BTreeMap<String, (String, Prediction)> =
1664        std::collections::BTreeMap::new();
1665    for atom in atoms {
1666        if atom.get("kind").and_then(Value::as_str) != Some("prediction")
1667            || atom.get("issue").and_then(Value::as_str) != Some(issue)
1668        {
1669            continue;
1670        }
1671        let (Some(agent), Some(expect)) = (
1672            atom.get("agent").and_then(Value::as_str),
1673            atom.get("expect"),
1674        ) else {
1675            continue;
1676        };
1677        let ts = atom
1678            .get("ts")
1679            .and_then(Value::as_str)
1680            .unwrap_or("")
1681            .to_string();
1682        let p = Prediction {
1683            issue: issue.to_string(),
1684            agent: agent.to_string(),
1685            expect: expect.clone(),
1686        };
1687        match latest.get(agent) {
1688            Some((seen, _)) if *seen > ts => {}
1689            _ => {
1690                latest.insert(agent.to_string(), (ts, p));
1691            }
1692        }
1693    }
1694    latest.into_values().map(|(_, p)| p).collect()
1695}
1696
1697/// Forecasts as `ljos-consensus surprising --predictions` takes them.
1698pub fn predictions_json(predictions: &[Prediction]) -> String {
1699    Value::Array(
1700        predictions
1701            .iter()
1702            .map(|p| serde_json::json!({"agent": p.agent, "expect": p.expect}))
1703            .collect(),
1704    )
1705    .to_string()
1706}
1707
1708/// Argv law kept in the pack: a glob over the command line, a verdict, and
1709/// the reason a reader sees when it fires. `deny` stops the action at the
1710/// runner and under `ljos policy`; `ask` hands it to the person.
1711#[derive(Debug, Clone, PartialEq, Eq)]
1712pub struct Rule {
1713    pub pattern: String,
1714    pub verdict: String,
1715    pub reason: String,
1716}
1717
1718/// POST one rule.
1719pub fn write_rule(rule: &Rule) -> Result<Value> {
1720    let pattern = rule.pattern.trim();
1721    if pattern.is_empty() {
1722        bail!("rule: a pattern over the command line is required");
1723    }
1724    if !matches!(rule.verdict.as_str(), "deny" | "ask") {
1725        bail!("rule: the verdict is deny or ask, not {:?}", rule.verdict);
1726    }
1727    let reason = rule.reason.trim();
1728    if reason.is_empty() {
1729        bail!("rule: say in a sentence why, so the reader who is stopped knows");
1730    }
1731    let client = pack()?;
1732    let workspace = client.workspace();
1733    let mut atom = atom_body("rule", reason, &workspace);
1734    atom["pattern"] = Value::String(pattern.into());
1735    atom["verdict"] = Value::String(rule.verdict.clone());
1736    client
1737        .post_atom(&atom)
1738        .context("rule: POST /v1/atoms failed")
1739}
1740
1741/// The live rules in a set of atoms.
1742pub fn rules_of(atoms: &[Value]) -> Vec<Rule> {
1743    atoms
1744        .iter()
1745        .filter(|a| a.get("kind").and_then(Value::as_str) == Some("rule"))
1746        .filter_map(|a| {
1747            Some(Rule {
1748                pattern: a.get("pattern")?.as_str()?.to_string(),
1749                verdict: a.get("verdict")?.as_str()?.to_string(),
1750                reason: a
1751                    .get("text")
1752                    .and_then(Value::as_str)
1753                    .unwrap_or("")
1754                    .to_string(),
1755            })
1756        })
1757        .collect()
1758}
1759
1760/// The rules in the seat's pack.
1761pub fn rules_from_pack() -> Result<Vec<Rule>> {
1762    let client = pack()?;
1763    let atoms = client
1764        .atoms_as_of(&client.workspace(), None)
1765        .context("rules: GET /v1/atoms failed")?;
1766    Ok(rules_of(&atoms))
1767}
1768
1769/// A glob over a command line: `*` matches any run of characters, `?` one.
1770/// The match is on the whole line, so `rm -rf *` is `rm -rf ` and anything
1771/// after, and `*sudo*` is sudo anywhere.
1772#[must_use]
1773pub fn glob_matches(pattern: &str, line: &str) -> bool {
1774    fn go(p: &[char], l: &[char]) -> bool {
1775        match (p.first(), l.first()) {
1776            (None, None) => true,
1777            (Some('*'), _) => go(&p[1..], l) || (!l.is_empty() && go(p, &l[1..])),
1778            (Some('?'), Some(_)) => go(&p[1..], &l[1..]),
1779            (Some(a), Some(b)) if a == b => go(&p[1..], &l[1..]),
1780            _ => false,
1781        }
1782    }
1783    let p: Vec<char> = pattern.chars().collect();
1784    let l: Vec<char> = line.trim().chars().collect();
1785    go(&p, &l)
1786}
1787
1788/// The verdict the rules give a command line: the first `deny` wins, then
1789/// the first `ask`, else none. Returns the rule that fired.
1790#[must_use]
1791pub fn verdict_for<'a>(rules: &'a [Rule], line: &str) -> Option<&'a Rule> {
1792    rules
1793        .iter()
1794        .find(|r| r.verdict == "deny" && glob_matches(&r.pattern, line))
1795        .or_else(|| {
1796            rules
1797                .iter()
1798                .find(|r| r.verdict == "ask" && glob_matches(&r.pattern, line))
1799        })
1800}
1801
1802/// Anchors as the settles take them: `{"name": anchor, ...}`.
1803pub fn anchors_json(personas: &[Persona]) -> String {
1804    let map: serde_json::Map<String, Value> = personas
1805        .iter()
1806        .map(|p| (p.name.clone(), serde_json::json!(p.anchor)))
1807        .collect();
1808    Value::Object(map).to_string()
1809}
1810
1811fn words_of(v: Option<&Value>) -> Vec<String> {
1812    v.and_then(Value::as_array)
1813        .into_iter()
1814        .flatten()
1815        .filter_map(Value::as_str)
1816        .map(str::to_lowercase)
1817        .collect()
1818}
1819
1820/// The domains an issue's island speaks to: the entities of the memories
1821/// its title activates, most frequent first, eight at most. What `learn`
1822/// scopes its rows to.
1823///
1824/// # Errors
1825///
1826/// The tracker or the pack not answering.
1827pub fn island_entities(issue: &str) -> Result<Vec<String>> {
1828    let title = issue_title(issue)?;
1829    let island = packset_island(&title, false)?;
1830    let ids: Vec<&str> = island["island"]
1831        .as_array()
1832        .into_iter()
1833        .flatten()
1834        .filter_map(|a| a["id"].as_str())
1835        .collect();
1836    if ids.is_empty() {
1837        return Ok(Vec::new());
1838    }
1839    let client = pack()?;
1840    let atoms = client
1841        .atoms_as_of(&client.workspace(), None)
1842        .context("island: GET /v1/atoms failed")?;
1843    let mut count: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
1844    for atom in &atoms {
1845        if atom
1846            .get("id")
1847            .and_then(Value::as_str)
1848            .is_some_and(|id| ids.contains(&id))
1849        {
1850            for e in words_of(atom.get("entities")) {
1851                *count.entry(e).or_insert(0) += 1;
1852            }
1853        }
1854    }
1855    let mut ranked: Vec<(String, usize)> = count.into_iter().collect();
1856    ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
1857    Ok(ranked.into_iter().take(8).map(|(e, _)| e).collect())
1858}
1859
1860/// The words an issue is about, for scoping trust rows: its title, lower
1861/// case, three letters or longer.
1862pub fn topic_words(title: &str) -> Vec<String> {
1863    let mut words: Vec<String> = title
1864        .split(|c: char| !c.is_alphanumeric())
1865        .filter(|w| w.len() >= 3)
1866        .map(str::to_lowercase)
1867        .collect();
1868    words.sort_unstable();
1869    words.dedup();
1870    words
1871}
1872
1873/// The rows that apply to an issue about `topic`: every unscoped row, and
1874/// every scoped row one of whose domains is among the topic's words.
1875pub fn rows_about(rows: &[Trust], topic: &[String]) -> Vec<Trust> {
1876    // A scoped row that applies stands in for the unscoped row of the same
1877    // pair, so the settle sees one weight per pair and never a sum of two.
1878    let mut chosen: std::collections::BTreeMap<(String, String), Trust> =
1879        std::collections::BTreeMap::new();
1880    for r in rows {
1881        let applies = r.about.is_empty() || r.about.iter().any(|a| topic.contains(a));
1882        if !applies {
1883            continue;
1884        }
1885        let key = (r.from.clone(), r.to.clone());
1886        match chosen.get(&key) {
1887            Some(have) if !have.about.is_empty() && r.about.is_empty() => {}
1888            _ => {
1889                chosen.insert(key, r.clone());
1890            }
1891        }
1892    }
1893    chosen.into_values().collect()
1894}
1895
1896/// The personas after an outcome: one whose ballot the outcome refuted
1897/// moves its anchor toward one by `1 - beta` of the gap, so a persona that
1898/// keeps being wrong listens more; a vindicated one keeps its anchor. The
1899/// personas that voted are the only ones touched. Acemoglu, Como, Fagnani
1900/// and Ozdaglar (doi:10.1287/moor.1120.0570) show what a stubborn wrong
1901/// voter does to a pool; this is the seat's remedy.
1902#[must_use]
1903pub fn learn_anchors(
1904    personas: &[Persona],
1905    ballots: &[(String, String)],
1906    outcome: &str,
1907    beta: f64,
1908) -> Vec<Persona> {
1909    let outcome = outcome.trim();
1910    personas
1911        .iter()
1912        .filter(|p| {
1913            ballots
1914                .iter()
1915                .any(|(agent, choice)| *agent == p.name && choice != outcome)
1916        })
1917        .map(|p| Persona {
1918            anchor: (p.anchor + (1.0 - p.anchor) * (1.0 - beta)).min(1.0),
1919            ..p.clone()
1920        })
1921        .collect()
1922}
1923
1924/// [`learn_about`] and [`learn_anchors`] together, written to the pack:
1925/// the rows, then the personas the outcome moved. Returns what was written.
1926///
1927/// # Errors
1928///
1929/// The pack refusing a row or a persona.
1930pub fn learn_and_write(
1931    ballots: &[(String, String)],
1932    outcome: &str,
1933    beta: f64,
1934    about: &[String],
1935) -> Result<(Vec<Trust>, Vec<Persona>)> {
1936    let client = pack()?;
1937    let atoms = client
1938        .atoms_as_of(&client.workspace(), None)
1939        .context("learn: GET /v1/atoms failed")?;
1940    let (rows, records) = learn_record(ballots, outcome, &records_from_atoms(&atoms), about)?;
1941    let moved = learn_anchors(&personas_from_pack()?, ballots, outcome, beta);
1942    // Every row lands before anything is printed, so a closed pipe cannot
1943    // leave the graph half written.
1944    for row in &rows {
1945        write_trust_record(row, &[], records.get(&row.to).copied())?;
1946    }
1947    for p in &moved {
1948        write_persona(p)?;
1949    }
1950    Ok((rows, moved))
1951}
1952
1953/// A voter's record: how often the outcome agreed with its ballot, and
1954/// how often not, carried on every trust row into that voter.
1955pub type Standing = (f64, f64);
1956
1957/// The latest record per voter among the trust atoms that carry one.
1958#[must_use]
1959pub fn records_from_atoms(atoms: &[Value]) -> std::collections::BTreeMap<String, Standing> {
1960    let mut latest: std::collections::BTreeMap<String, (String, Standing)> =
1961        std::collections::BTreeMap::new();
1962    for atom in atoms {
1963        if atom.get("kind").and_then(Value::as_str) != Some("trust") {
1964            continue;
1965        }
1966        let (Some(to), Some(hits), Some(misses)) = (
1967            atom.get("to").and_then(Value::as_str),
1968            atom.get("hits").and_then(Value::as_f64),
1969            atom.get("misses").and_then(Value::as_f64),
1970        ) else {
1971            continue;
1972        };
1973        let ts = atom
1974            .get("ts")
1975            .and_then(Value::as_str)
1976            .unwrap_or("")
1977            .to_string();
1978        match latest.get(to) {
1979            Some((seen, _)) if *seen > ts => {}
1980            _ => {
1981                latest.insert(to.to_string(), (ts, (hits, misses)));
1982            }
1983        }
1984    }
1985    latest.into_iter().map(|(k, (_, r))| (k, r)).collect()
1986}
1987
1988/// Learn from an outcome by the record: each voter's hits and misses so
1989/// far, this outcome added, give its accuracy with one of each smoothed
1990/// in, and the rows are the log odds of that scaled to the best voter at
1991/// one ([`calibration_weights`]). Measured against multiplicative
1992/// shrinking (Hedge) on voters of known accuracy, the record reaches the
1993/// batch calibration and the shrink does not: a voter is weighed by what
1994/// it got right, not by how many times it has been punished. Rows are
1995/// complete over the voters and scoped to `about`.
1996///
1997/// # Errors
1998///
1999/// No outcome, or fewer than two voters.
2000pub fn learn_record(
2001    ballots: &[(String, String)],
2002    outcome: &str,
2003    records: &std::collections::BTreeMap<String, Standing>,
2004    about: &[String],
2005) -> Result<(Vec<Trust>, std::collections::BTreeMap<String, Standing>)> {
2006    let outcome = outcome.trim();
2007    if outcome.is_empty() {
2008        bail!("learn: an outcome is required");
2009    }
2010    let mut agents: Vec<&str> = ballots.iter().map(|(a, _)| a.as_str()).collect();
2011    agents.sort_unstable();
2012    agents.dedup();
2013    if agents.len() < 2 {
2014        bail!("learn: fewer than two voters, nothing to weigh");
2015    }
2016    let mut next = records.clone();
2017    for (agent, choice) in ballots {
2018        let r = next.entry(agent.clone()).or_insert((0.0, 0.0));
2019        if choice == outcome {
2020            r.0 += 1.0;
2021        } else {
2022            r.1 += 1.0;
2023        }
2024    }
2025    let accuracy: Vec<(String, f64)> = agents
2026        .iter()
2027        .map(|a| {
2028            let (h, m) = next.get(*a).copied().unwrap_or((0.0, 0.0));
2029            ((*a).to_string(), (h + 1.0) / (h + m + 2.0))
2030        })
2031        .collect();
2032    let weights = calibration_weights(&accuracy);
2033    let mut out = Vec::new();
2034    for from in &agents {
2035        for (to, weight) in &weights {
2036            if *from == to {
2037                continue;
2038            }
2039            out.push(Trust {
2040                from: (*from).to_string(),
2041                to: to.clone(),
2042                weight: *weight,
2043                about: about.to_vec(),
2044            });
2045        }
2046    }
2047    Ok((out, next))
2048}
2049
2050/// [`write_trust`] carrying the voter's record on the row.
2051pub fn write_trust_record(row: &Trust, why: &[String], record: Option<Standing>) -> Result<Value> {
2052    let client = pack()?;
2053    let workspace = client.workspace();
2054    let mut atom = trust_atom(row, why, &workspace)?;
2055    if let Some((hits, misses)) = record {
2056        atom["hits"] = serde_json::json!(hits);
2057        atom["misses"] = serde_json::json!(misses);
2058    }
2059    client
2060        .post_atom(&atom)
2061        .context("trust: POST /v1/atoms failed")
2062}
2063
2064/// The factor a refuted voter's rows shrink by (Hedge, doi:10.1006/jcss.1997.1504).
2065pub const LEARN_BETA: f64 = 0.5;
2066
2067/// The least a row can fall to, so a voter who is right again is heard again.
2068pub const TRUST_FLOOR: f64 = 0.01;
2069
2070/// A `trust` atom for one row. `why` are deed accessions it cites.
2071pub fn trust_atom(row: &Trust, why: &[String], workspace: &str) -> Result<Value> {
2072    let (from, to) = (row.from.trim(), row.to.trim());
2073    if from.is_empty() || to.is_empty() {
2074        bail!("trust: from and to are required");
2075    }
2076    if from == to {
2077        bail!("trust: {from} cannot weigh itself; self weight is the settle's");
2078    }
2079    if !(row.weight > 0.0 && row.weight <= 1.0) {
2080        bail!("trust: weight {} is not in (0, 1]", row.weight);
2081    }
2082    let mut atom = atom_body(
2083        "trust",
2084        &format!("{from} weighs {to} at {:.3}.", row.weight),
2085        workspace,
2086    );
2087    atom["from"] = Value::String(from.into());
2088    atom["to"] = Value::String(to.into());
2089    atom["weight"] = serde_json::json!(row.weight);
2090    if !why.is_empty() {
2091        atom["entities"] = Value::Array(why.iter().map(|w| Value::String(w.clone())).collect());
2092    }
2093    if !row.about.is_empty() {
2094        atom["about"] = Value::Array(
2095            row.about
2096                .iter()
2097                .map(|w| Value::String(w.to_lowercase()))
2098                .collect(),
2099        );
2100    }
2101    Ok(atom)
2102}
2103
2104/// The live rows in a set of atoms: the latest `trust` atom per `(from, to)`.
2105pub fn trust_rows(atoms: &[Value]) -> Vec<Trust> {
2106    // The latest row per (from, to, scope): an unscoped row and a scoped one
2107    // for the same pair are different rows, and a later row of the same
2108    // scope supersedes.
2109    let mut latest: std::collections::BTreeMap<(String, String, Vec<String>), (String, f64)> =
2110        std::collections::BTreeMap::new();
2111    for atom in atoms {
2112        if atom.get("kind").and_then(Value::as_str) != Some("trust") {
2113            continue;
2114        }
2115        let (Some(from), Some(to), Some(weight)) = (
2116            atom.get("from").and_then(Value::as_str),
2117            atom.get("to").and_then(Value::as_str),
2118            atom.get("weight").and_then(Value::as_f64),
2119        ) else {
2120            continue;
2121        };
2122        let ts = atom
2123            .get("ts")
2124            .and_then(Value::as_str)
2125            .unwrap_or("")
2126            .to_string();
2127        let mut about = words_of(atom.get("about"));
2128        about.sort_unstable();
2129        let key = (from.to_string(), to.to_string(), about);
2130        match latest.get(&key) {
2131            Some((seen, _)) if *seen > ts => {}
2132            _ => {
2133                latest.insert(key, (ts, weight));
2134            }
2135        }
2136    }
2137    latest
2138        .into_iter()
2139        .map(|((from, to, about), (_, weight))| Trust {
2140            from,
2141            to,
2142            weight,
2143            about,
2144        })
2145        .collect()
2146}
2147
2148/// Rows as the consensus takes them: `[[from, to, weight], ...]`.
2149pub fn trust_json(rows: &[Trust]) -> String {
2150    let tuples: Vec<Value> = rows
2151        .iter()
2152        .map(|r| serde_json::json!([r.from, r.to, r.weight]))
2153        .collect();
2154    Value::Array(tuples).to_string()
2155}
2156
2157/// `(agent, choice)` pairs from a tracker's `vote --json`.
2158pub fn ballots_from_json(raw: &str) -> Result<Vec<(String, String)>> {
2159    let rows: Vec<Value> = serde_json::from_str(raw).context("ballots: not a JSON array")?;
2160    rows.iter()
2161        .map(|row| {
2162            let agent = row.get("agent").and_then(Value::as_str);
2163            let choice = row.get("choice").and_then(Value::as_str);
2164            match (agent, choice) {
2165                (Some(a), Some(c)) => Ok((a.to_string(), c.to_string())),
2166                _ => bail!("ballots: a row without agent and choice"),
2167            }
2168        })
2169        .collect()
2170}
2171
2172/// The rows every voter holds on every other after `outcome` is known: a
2173/// voter whose ballot was refuted shrinks by `beta`, floored at
2174/// [`TRUST_FLOOR`]; a missing row starts at one. Complete, so the settle
2175/// sees the whole graph.
2176pub fn learn(
2177    ballots: &[(String, String)],
2178    outcome: &str,
2179    rows: &[Trust],
2180    beta: f64,
2181) -> Result<Vec<Trust>> {
2182    learn_about(ballots, outcome, rows, beta, &[])
2183}
2184
2185/// [`learn`] writing rows scoped to `about`: the domains the issue's island
2186/// speaks to, so that being wrong about one topic does not cost a voter its
2187/// standing on every other. An empty `about` is the unscoped rule.
2188pub fn learn_about(
2189    ballots: &[(String, String)],
2190    outcome: &str,
2191    rows: &[Trust],
2192    beta: f64,
2193    about: &[String],
2194) -> Result<Vec<Trust>> {
2195    learn_shared(ballots, outcome, rows, beta, about, 0.0)
2196}
2197
2198/// [`learn_about`] with a fixed share of recovery: after the Hedge step
2199/// every row moves toward one by `share` of the gap, so a voter refuted
2200/// long ago is not held down forever and the best voter can change
2201/// (Herbster and Warmuth, doi:10.1023/A:1007424614876). Zero is plain
2202/// Hedge; the seat's default.
2203pub fn learn_shared(
2204    ballots: &[(String, String)],
2205    outcome: &str,
2206    rows: &[Trust],
2207    beta: f64,
2208    about: &[String],
2209    share: f64,
2210) -> Result<Vec<Trust>> {
2211    if !(beta > 0.0 && beta < 1.0) {
2212        bail!("learn: beta {beta} is not in (0, 1)");
2213    }
2214    if !(0.0..1.0).contains(&share) {
2215        bail!("learn: share {share} is not in [0, 1)");
2216    }
2217    let outcome = outcome.trim();
2218    if outcome.is_empty() {
2219        bail!("learn: an outcome is required");
2220    }
2221    let mut agents: Vec<&str> = ballots.iter().map(|(a, _)| a.as_str()).collect();
2222    agents.sort_unstable();
2223    agents.dedup();
2224    if agents.len() < 2 {
2225        bail!("learn: fewer than two voters, nothing to weigh");
2226    }
2227    let refuted = |agent: &str| {
2228        ballots
2229            .iter()
2230            .any(|(a, choice)| a == agent && choice != outcome)
2231    };
2232    let mut out = Vec::new();
2233    for from in &agents {
2234        for to in &agents {
2235            if from == to {
2236                continue;
2237            }
2238            // The row being moved is the one of this scope; a scoped learn
2239            // starts from the unscoped row when it has none of its own.
2240            let current = rows
2241                .iter()
2242                .find(|r| r.from == *from && r.to == *to && r.about == about)
2243                .or_else(|| {
2244                    rows.iter()
2245                        .find(|r| r.from == *from && r.to == *to && r.about.is_empty())
2246                })
2247                .map_or(1.0, |r| r.weight);
2248            let stepped = if refuted(to) {
2249                (current * beta).max(TRUST_FLOOR)
2250            } else {
2251                current
2252            };
2253            let next = stepped + (1.0 - stepped) * share;
2254            out.push(Trust {
2255                from: (*from).to_string(),
2256                to: (*to).to_string(),
2257                weight: next,
2258                about: about.to_vec(),
2259            });
2260        }
2261    }
2262    Ok(out)
2263}
2264
2265/// The live trust rows in the seat's pack.
2266pub fn trust_from_pack() -> Result<Vec<Trust>> {
2267    let client = pack()?;
2268    let workspace = client.workspace();
2269    let atoms = client
2270        .atoms_as_of(&workspace, None)
2271        .context("trust: GET /v1/atoms failed")?;
2272    Ok(trust_rows(&atoms))
2273}
2274
2275/// POST one trust row.
2276pub fn write_trust(row: &Trust, why: &[String]) -> Result<Value> {
2277    let client = pack()?;
2278    let workspace = client.workspace();
2279    client
2280        .post_atom(&trust_atom(row, why, &workspace)?)
2281        .context("trust: POST /v1/atoms failed")
2282}
2283
2284/// One habitat and whether it answers.
2285#[derive(Debug, Clone, PartialEq, Eq)]
2286pub struct Habitat {
2287    pub name: &'static str,
2288    pub state: String,
2289    pub ok: bool,
2290}
2291
2292/// The habitats the seat needs.
2293pub const REQUIRED: &[&str] = &["vissue", "deedar", "packset"];
2294
2295/// Which habitats answer: binaries on `PATH`, the pack over `PACKSET_URL`, the
2296/// deed store, the tracker, the claim graph.
2297pub fn doctor() -> Vec<Habitat> {
2298    // The runner rows ask the runners' own command lines, which start slowly;
2299    // they run beside the seat's rows rather than after them.
2300    let (mut out, runners) = std::thread::scope(|s| {
2301        let runners = s.spawn(harness_rows);
2302        let seat = doctor_seat();
2303        (seat, runners.join().unwrap_or_default())
2304    });
2305    out.extend(runners);
2306    out
2307}
2308
2309/// The seat's own rows: binaries, pack, host key, deed store, tracker,
2310/// claim graph. What a sitting checks; the runner rows are onboarding.
2311pub fn doctor_seat() -> Vec<Habitat> {
2312    let mut out = Vec::new();
2313    for bin in [
2314        "vissue",
2315        "deedar",
2316        "claimdag",
2317        "packset",
2318        "packsetd",
2319        "ljos-consensus",
2320        "ljos-mcp",
2321        "ljos-policyd",
2322    ] {
2323        let found = which::which(bin).ok();
2324        out.push(Habitat {
2325            name: bin,
2326            state: found
2327                .as_ref()
2328                .map_or_else(|| "not on PATH".to_string(), |p| p.display().to_string()),
2329            ok: found.is_some(),
2330        });
2331    }
2332    // The name this runner claims and votes under, and where it came from.
2333    let (seat, source) = ["LJOS_SEAT", "VISSUE_AGENT"]
2334        .iter()
2335        .find_map(|k| {
2336            std::env::var(k)
2337                .ok()
2338                .map(|v| v.trim().to_string())
2339                .filter(|v| !v.is_empty())
2340                .map(|v| (v, *k))
2341        })
2342        .unwrap_or_else(|| ("seat".to_string(), "the default"));
2343    out.push(Habitat {
2344        name: "seat",
2345        state: format!("{seat} (from {source})"),
2346        ok: true,
2347    });
2348    // The dense ballot: without it the pack ranks by words alone, and an
2349    // island's seeds are weaker than the agent may assume.
2350    if let Ok(client) = PacksetClient::from_env() {
2351        if let Ok(status) = client.status(None) {
2352            let available = status["embedder"]["available"].as_bool().unwrap_or(false);
2353            out.push(Habitat {
2354                name: "encoder",
2355                state: if available {
2356                    "dense ballot on".to_string()
2357                } else {
2358                    "down; search is lexical only, islands seed weakly".to_string()
2359                },
2360                ok: available,
2361            });
2362        }
2363    }
2364    out.push(match PacksetClient::from_env() {
2365        Ok(client) => match client.health() {
2366            Ok(_) => Habitat {
2367                name: "pack",
2368                state: format!("{} workspace {}", client.base(), client.workspace()),
2369                ok: true,
2370            },
2371            Err(e) => Habitat {
2372                name: "pack",
2373                state: format!("{} does not answer: {e}", client.base()),
2374                ok: false,
2375            },
2376        },
2377        Err(_) => Habitat {
2378            name: "pack",
2379            state: "PACKSET_URL=off: no pack on purpose".into(),
2380            ok: false,
2381        },
2382    });
2383    out.push(match host_key_path() {
2384        Some(path) => {
2385            let seed = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0) == 32;
2386            Habitat {
2387                name: "host key",
2388                state: if seed {
2389                    format!("{} (32-byte seed)", path.display())
2390                } else {
2391                    format!("{} is not a 32-byte seed", path.display())
2392                },
2393                ok: seed,
2394            }
2395        }
2396        None => Habitat {
2397            name: "host key",
2398            state: "none at ~/.config/deedar/host.key and DEEDAR_HOST_SIGNING_KEY unset; \
2399                    handovers go out unsigned"
2400                .into(),
2401            ok: false,
2402        },
2403    });
2404    for (name, bin, args) in [
2405        ("deed store", "deedar", &["log", "head"][..]),
2406        ("tracker", "vissue", &["identity"][..]),
2407        ("claim graph", "claimdag", &["list"][..]),
2408    ] {
2409        out.push(match run_captured(bin, args) {
2410            Ok(said) => Habitat {
2411                name,
2412                state: said.stdout.lines().next().unwrap_or("").to_string(),
2413                ok: true,
2414            },
2415            Err(e) => Habitat {
2416                name,
2417                state: e.to_string().lines().next().unwrap_or("").to_string(),
2418                ok: false,
2419            },
2420        });
2421    }
2422    out
2423}
2424
2425/// Whether every required habitat answers.
2426pub fn healthy(rows: &[Habitat]) -> bool {
2427    rows.iter()
2428        .all(|h| h.ok || !REQUIRED.contains(&h.name) && h.name != "pack")
2429}
2430
2431pub fn format_doctor(rows: &[Habitat]) -> String {
2432    rows.iter()
2433        .map(|h| {
2434            format!(
2435                "{}	{}	{}
2436",
2437                if h.ok { "ok" } else { "no" },
2438                h.name,
2439                h.state
2440            )
2441        })
2442        .collect()
2443}
2444
2445/// The accessions a satchel's description says it needs.
2446pub fn needs_of(satchel_json: &str) -> Result<Vec<String>> {
2447    let v: Value = serde_json::from_str(satchel_json).context("satchel.json")?;
2448    Ok(v.get("needs")
2449        .and_then(Value::as_array)
2450        .map(|a| {
2451            a.iter()
2452                .filter_map(Value::as_str)
2453                .map(str::to_string)
2454                .collect()
2455        })
2456        .unwrap_or_default())
2457}
2458
2459/// Deeds to enclose: the satchel's `needs` plus what the pack cites, once each.
2460pub fn enclose(needs: Vec<String>, cited: &str) -> Vec<String> {
2461    let mut all: Vec<String> = needs
2462        .into_iter()
2463        .chain(cited.lines().map(str::trim).map(str::to_string))
2464        .filter(|s| !s.is_empty())
2465        .collect();
2466    all.sort();
2467    all.dedup();
2468    all
2469}
2470
2471/// Pack a slice of the seat into `out`: the tracker's satchel, the pack's
2472/// atoms, the deeds both cite, sealed, and signed when a host key is set.
2473pub fn handover(out: &Path, projects: &[String], issues: &[String]) -> Result<Vec<String>> {
2474    if projects.is_empty() && issues.is_empty() {
2475        bail!("handover: name a project or an issue");
2476    }
2477    let mut lines = Vec::new();
2478    let mut args = vec![
2479        "satchel".to_string(),
2480        "--out".into(),
2481        out.display().to_string(),
2482    ];
2483    for p in projects {
2484        args.push("--project".into());
2485        args.push(p.clone());
2486    }
2487    for i in issues {
2488        args.push("--issue".into());
2489        args.push(i.clone());
2490    }
2491    lines.push(run_captured("vissue", &args)?.stdout.trim_end().to_string());
2492
2493    let mut cited = String::new();
2494    match PacksetClient::from_env() {
2495        Ok(client) => {
2496            let atoms_dir = out.join("data").join("atoms");
2497            match run_captured(
2498                "packset",
2499                &[
2500                    "export",
2501                    "--into",
2502                    &atoms_dir.display().to_string(),
2503                    &client.workspace(),
2504                ],
2505            ) {
2506                Ok(said) => {
2507                    cited = said.stdout;
2508                    lines.push(said.stderr.trim_end().to_string());
2509                }
2510                Err(e) => lines.push(format!("atoms not enclosed: {e}")),
2511            }
2512        }
2513        Err(_) => lines.push("no pack: PACKSET_URL=off, atoms not enclosed".into()),
2514    }
2515
2516    let description = std::fs::read_to_string(out.join("data").join("satchel.json"))
2517        .context("handover: the satchel has no description")?;
2518    let deeds = enclose(needs_of(&description)?, &cited);
2519    if deeds.is_empty() {
2520        lines.push("no deeds cited".into());
2521    } else {
2522        let deeds_dir = out.join("data").join("deeds");
2523        let said = run_fed(
2524            "deedar",
2525            &["export", "--into", &deeds_dir.display().to_string(), "-"],
2526            &format!(
2527                "{}
2528",
2529                deeds.join(
2530                    "
2531"
2532                )
2533            ),
2534        )?;
2535        lines.push(said.stdout.trim_end().to_string());
2536    }
2537
2538    lines.push(
2539        run_captured("vissue", &["satchel", "--seal", &out.display().to_string()])?
2540            .stdout
2541            .trim_end()
2542            .to_string(),
2543    );
2544    // The key deedar signs with is the one doctor reports: the variable, or
2545    // the seat's own at ~/.config/deedar/host.key. `off` signs nothing.
2546    if host_key_path().is_some() {
2547        let manifest = out.join("manifest-sha256.txt");
2548        let said = run_captured(
2549            "deedar",
2550            &["vouch", "sign", &manifest.display().to_string()],
2551        )?;
2552        lines.push(said.stdout.trim_end().to_string());
2553    } else {
2554        lines.push(
2555            "unsigned: no host key at ~/.config/deedar/host.key and DEEDAR_HOST_SIGNING_KEY unset; \
2556             `ljos onboard` writes one"
2557                .into(),
2558        );
2559    }
2560    Ok(lines)
2561}
2562
2563/// Check a satchel that arrived: manifest, deed receipts, signature, and what
2564/// the atoms hold; with `import`, POST the atoms into this seat's pack.
2565pub fn receive(dir: &Path, since: Option<&Path>, import: bool) -> Result<Vec<String>> {
2566    let mut lines = Vec::new();
2567    lines.push(
2568        run_captured(
2569            "vissue",
2570            &["satchel", "--verify", &dir.display().to_string()],
2571        )?
2572        .stdout
2573        .trim_end()
2574        .to_string(),
2575    );
2576    if dir.join("data").join("deeds").is_dir() {
2577        let mut args = vec!["check".to_string(), dir.display().to_string()];
2578        if let Some(bridge) = since {
2579            args.push("--since".into());
2580            args.push(bridge.display().to_string());
2581        }
2582        lines.push(run_captured("deedar", &args)?.stdout.trim_end().to_string());
2583    } else {
2584        lines.push("no deeds enclosed".into());
2585    }
2586    let manifest = dir.join("manifest-sha256.txt");
2587    // Who sent it, for the atoms' provenance: the signing key when the bag
2588    // is signed, else the fact of a handover. An imported claim then says
2589    // where it came from, and a search can ask for what one seat taught.
2590    let mut sender = "from:handover".to_string();
2591    if manifest.with_extension("txt.sig").is_file() {
2592        let said = run_captured(
2593            "deedar",
2594            &["vouch", "check", &manifest.display().to_string()],
2595        )?
2596        .stdout
2597        .trim_end()
2598        .to_string();
2599        if let Some(hex) = said
2600            .strip_prefix("signed by ")
2601            .and_then(|rest| rest.split(|c: char| !c.is_ascii_hexdigit()).next())
2602            .filter(|h| h.len() >= 12)
2603        {
2604            sender = format!("from:{}", &hex[..12]);
2605        }
2606        lines.push(said);
2607    } else {
2608        lines.push("unsigned".into());
2609    }
2610
2611    let atoms = enclosed_atoms(dir)?;
2612    let rows = trust_rows(&atoms);
2613    lines.push(format!(
2614        "{} atoms enclosed, {} trust rows",
2615        atoms.len(),
2616        rows.len()
2617    ));
2618    if import {
2619        let client = pack()?;
2620        let workspace = client.workspace();
2621        let (mut kept, mut refused) = (0usize, Vec::new());
2622        for atom in &atoms {
2623            // The atoms arrive stamped with the sender's workspace; they join
2624            // this seat's, or the import lands in a workspace nobody reads.
2625            let mut atom = atom.clone();
2626            if let Some(map) = atom.as_object_mut() {
2627                map.insert("workspace".into(), Value::String(workspace.clone()));
2628                let mut entities: Vec<Value> = map
2629                    .get("entities")
2630                    .and_then(Value::as_array)
2631                    .cloned()
2632                    .unwrap_or_default();
2633                if !entities.iter().any(|e| e.as_str() == Some(sender.as_str())) {
2634                    entities.push(Value::String(sender.clone()));
2635                }
2636                map.insert("entities".into(), Value::Array(entities));
2637            }
2638            match client.post_atom(&atom) {
2639                Ok(_) => kept += 1,
2640                Err(e) => refused.push(e.to_string()),
2641            }
2642        }
2643        lines.push(format!("{kept} atoms imported, {} refused", refused.len()));
2644        lines.extend(refused.into_iter().take(5));
2645        if kept > 0 {
2646            lines.push(
2647                "imported claims may rewrite held ones; `ljos consolidate` reports the pairs, `--apply` closes them"
2648                    .to_string(),
2649            );
2650        }
2651    }
2652    Ok(lines)
2653}
2654
2655/// Every atom in a satchel's `data/atoms/*.jsonl`.
2656pub fn enclosed_atoms(dir: &Path) -> Result<Vec<Value>> {
2657    let atoms_dir = dir.join("data").join("atoms");
2658    let Ok(entries) = std::fs::read_dir(&atoms_dir) else {
2659        return Ok(Vec::new());
2660    };
2661    let mut out = Vec::new();
2662    for entry in entries.flatten() {
2663        let text = std::fs::read_to_string(entry.path())?;
2664        for line in text.lines().filter(|l| !l.trim().is_empty()) {
2665            out.push(
2666                serde_json::from_str(line).with_context(|| entry.path().display().to_string())?,
2667            );
2668        }
2669    }
2670    Ok(out)
2671}
2672
2673/// Kinds that are weighed, not recalled, and so never come up for review.
2674const UNREVIEWED_KINDS: &[&str] = &["trust", "persona"];
2675
2676/// Whether an atom is a claim the review clock should hold at all.
2677fn reviewable(a: &Value) -> bool {
2678    !UNREVIEWED_KINDS.contains(&a.get("kind").and_then(Value::as_str).unwrap_or(""))
2679}
2680
2681/// The live atoms whose review is due at `now` (RFC 3339 UTC), soonest first.
2682/// A claim that has never entered the review clock has no `due_at`; it is
2683/// due now, and grading it puts it on the clock. Trust and persona rows are
2684/// weighed, not recalled, and never come up.
2685pub fn due_of(atoms: &[Value], now: &str) -> Vec<Value> {
2686    let mut due: Vec<Value> = atoms
2687        .iter()
2688        .filter(|a| reviewable(a))
2689        .filter(|a| {
2690            a.get("due_at")
2691                .and_then(Value::as_str)
2692                .is_none_or(|d| d.is_empty() || d <= now)
2693        })
2694        .cloned()
2695        .collect();
2696    due.sort_by(|a, b| {
2697        a["due_at"]
2698            .as_str()
2699            .unwrap_or("")
2700            .cmp(b["due_at"].as_str().unwrap_or(""))
2701    });
2702    due
2703}
2704
2705/// One line on the state of the review clock: how many are due, how many
2706/// are scheduled, and when the next one comes up. An empty `due` with a
2707/// next date is a clock that is running; an empty `due` with nothing
2708/// scheduled is a seat that has remembered nothing.
2709pub fn review_summary(atoms: &[Value], now: &str) -> String {
2710    let due = due_of(atoms, now).len();
2711    let mut later: Vec<&str> = atoms
2712        .iter()
2713        .filter(|a| reviewable(a))
2714        .filter_map(|a| a.get("due_at").and_then(Value::as_str))
2715        .filter(|d| !d.is_empty() && *d > now)
2716        .collect();
2717    later.sort_unstable();
2718    match later.first() {
2719        Some(next) => format!("{due} due; {} scheduled, next at {next}", later.len()),
2720        None if due == 0 => "0 due; nothing scheduled: this seat has remembered nothing yet".into(),
2721        None => format!("{due} due; nothing else scheduled"),
2722    }
2723}
2724
2725/// The review clock as `ljos due` prints it: the due atoms, then the summary.
2726pub fn due_report() -> Result<String> {
2727    let client = pack()?;
2728    let atoms = client
2729        .atoms_as_of(&client.workspace(), None)
2730        .context("due: GET /v1/atoms failed")?;
2731    let now = now_utc();
2732    Ok(format!(
2733        "{}{}\n",
2734        format_due(&due_of(&atoms, &now)),
2735        review_summary(&atoms, &now)
2736    ))
2737}
2738
2739/// What the pack holds for review now.
2740pub fn due() -> Result<Vec<Value>> {
2741    let client = pack()?;
2742    let atoms = client
2743        .atoms_as_of(&client.workspace(), None)
2744        .context("due: GET /v1/atoms failed")?;
2745    Ok(due_of(&atoms, &now_utc()))
2746}
2747
2748pub fn format_due(atoms: &[Value]) -> String {
2749    atoms
2750        .iter()
2751        .map(|a| {
2752            format!(
2753                "{}	{}	{}	{}
2754",
2755                a["due_at"]
2756                    .as_str()
2757                    .filter(|d| !d.is_empty())
2758                    .unwrap_or("unreviewed"),
2759                a["kind"].as_str().unwrap_or(""),
2760                a["id"].as_str().unwrap_or("-"),
2761                a["text"].as_str().unwrap_or("")
2762            )
2763        })
2764        .collect()
2765}
2766
2767/// Grade one review: recalled moves the atom out, lapsed brings it back sooner.
2768pub fn graded(id: &str, recalled: bool) -> Result<Value> {
2769    let id = id.trim();
2770    if id.is_empty() {
2771        bail!("graded: an atom id is required");
2772    }
2773    let client = pack()?;
2774    client
2775        .grade(&client.workspace(), id, recalled)
2776        .with_context(|| format!("graded: POST /v1/grade failed for {id}"))
2777}
2778
2779/// Now, RFC 3339 UTC to the second, the stamp the pack writes.
2780#[must_use]
2781pub fn now_utc() -> String {
2782    let secs = std::time::SystemTime::now()
2783        .duration_since(std::time::UNIX_EPOCH)
2784        .map(|d| d.as_secs())
2785        .unwrap_or(0);
2786    let days = secs / 86_400;
2787    let rem = secs % 86_400;
2788    // Civil date from days since the epoch (Howard Hinnant's algorithm).
2789    let z = days as i64 + 719_468;
2790    let era = z.div_euclid(146_097);
2791    let doe = z.rem_euclid(146_097);
2792    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
2793    let y = yoe + era * 400;
2794    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2795    let mp = (5 * doy + 2) / 153;
2796    let d = doy - (153 * mp + 2) / 5 + 1;
2797    let m = if mp < 10 { mp + 3 } else { mp - 9 };
2798    let y = if m <= 2 { y + 1 } else { y };
2799    format!(
2800        "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
2801        rem / 3600,
2802        rem % 3600 / 60,
2803        rem % 60
2804    )
2805}
2806
2807/// Run a habitat's verb with `input` on stdin.
2808pub fn run_fed(bin: &str, args: &[impl AsRef<str>], input: &str) -> Result<Said> {
2809    use std::io::Write;
2810    use std::process::{Command, Stdio};
2811    let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
2812    let mut cmd = Command::new(path);
2813    for a in args {
2814        cmd.arg(a.as_ref());
2815    }
2816    let mut child = cmd
2817        .stdin(Stdio::piped())
2818        .stdout(Stdio::piped())
2819        .stderr(Stdio::piped())
2820        .spawn()
2821        .with_context(|| format!("{bin}: could not start"))?;
2822    if let Some(mut stdin) = child.stdin.take() {
2823        stdin.write_all(input.as_bytes())?;
2824    }
2825    let out = child.wait_with_output()?;
2826    let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
2827    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
2828    if !out.status.success() {
2829        let why = if stderr.trim().is_empty() {
2830            stdout.trim().to_string()
2831        } else {
2832            stderr.trim().to_string()
2833        };
2834        bail!("{bin} exited {}: {why}", out.status);
2835    }
2836    Ok(Said { stdout, stderr })
2837}
2838
2839/// A claimdag id for a name: the name itself when it is already 32 hex, else
2840/// FNV-1a 128 of it. One tracker id maps to one node; one assignee to one actor.
2841pub fn work_id(name: &str) -> String {
2842    let name = name.trim();
2843    if name.len() == 32 && name.bytes().all(|b| b.is_ascii_hexdigit()) {
2844        return name.to_ascii_lowercase();
2845    }
2846    const OFFSET: u128 = 0x6c62_272e_07bb_0142_62b8_2175_6295_c58d;
2847    const PRIME: u128 = 0x0000_0000_0100_0000_0000_0000_0000_013b;
2848    let mut h = OFFSET;
2849    for b in name.bytes() {
2850        h ^= u128::from(b);
2851        h = h.wrapping_mul(PRIME);
2852    }
2853    format!("{h:032x}")
2854}
2855
2856/// The claimdag node standing for `issue`, minted with the tracker id as its
2857/// summary when the graph does not hold it yet.
2858pub fn node_for(issue: &str) -> Result<String> {
2859    let id = work_id(issue);
2860    if id != issue.trim() && run_captured("claimdag", &["get", &id]).is_err() {
2861        run_captured(
2862            "claimdag",
2863            &["upsert", "--id", &id, "--summary", issue.trim()],
2864        )
2865        .with_context(|| format!("claim: could not mint a node for {issue}"))?;
2866    }
2867    Ok(id)
2868}
2869
2870/// The memories a task activates: the pack's island around the cue. With
2871/// `fire`, the strongest of them fire together and their links gain weight.
2872pub fn packset_island(cue: &str, fire: bool) -> Result<Value> {
2873    let cue = cue.trim();
2874    if cue.is_empty() {
2875        bail!("island: pass the task or question at hand");
2876    }
2877    let client = pack()?;
2878    let workspace = client.workspace();
2879    client
2880        .activate(&workspace, cue, 24, fire)
2881        .context("island: GET /v1/activate failed")
2882}
2883
2884/// The claims the pack's link graph turns on, highest first: what matters
2885/// in this seat's memory by its own connections, before any query.
2886pub fn packset_hubs(limit: usize) -> Result<Value> {
2887    let client = pack()?;
2888    let workspace = client.workspace();
2889    client
2890        .hubs(&workspace, limit)
2891        .context("hubs: GET /v1/hubs failed")
2892}
2893
2894/// Consolidate the seat's memory: every claim that replaces an earlier
2895/// one (a rewrite, a new object under the same head, a correction, an
2896/// explicit supersedes) closes the earlier one's window and names it.
2897/// Candidate contradictions from the geometry of the seat's memory: the
2898/// `landscape` binary reads the pack's embeddings at the point scale and
2899/// prints the lowest passes between single memories, which on a record of
2900/// planted contradictions were the contradictions nine times in ten. The
2901/// replacement rule reads words; this reads distance, in any language.
2902/// A candidate is for a person or `consolidate` to judge; nothing is
2903/// written here. `landscape` is an optional habitat: absent, this says so.
2904///
2905/// # Errors
2906///
2907/// The binary absent or refusing, or the pack not answering.
2908pub fn conflicts(limit: usize) -> Result<String> {
2909    if which::which("landscape").is_err() {
2910        bail!(
2911            "conflicts: `landscape` is not on PATH; it is the optional habitat that reads the pack's geometry (leidarljos/landscape)"
2912        );
2913    }
2914    let client = pack()?;
2915    let said = match run_captured(
2916        "landscape",
2917        &[
2918            "--atoms",
2919            client.base(),
2920            "--workspace",
2921            &client.workspace(),
2922            "--conflicts",
2923        ],
2924    ) {
2925        Ok(said) => said,
2926        // A pack whose memories carry no embeddings has no landscape to
2927        // read; that is a fact about the pack, not a refusal.
2928        Err(e) if e.to_string().contains("at least two") => {
2929            return Ok(
2930                "fewer than two memories with embeddings in the pack; conflicts by geometry need the encoder (`packset doctor` shows it)\n"
2931                    .to_string(),
2932            );
2933        }
2934        Err(e) => return Err(e),
2935    };
2936    let v: Value =
2937        serde_json::from_str(&said.stdout).context("conflicts: landscape printed no JSON")?;
2938    let now = now_utc();
2939    let atoms = client
2940        .atoms_as_of(&client.workspace(), None)
2941        .unwrap_or_default();
2942    let stamp_of = |id: &str| -> Option<String> {
2943        atoms
2944            .iter()
2945            .find(|a| a["id"].as_str() == Some(id))
2946            .and_then(|a| a["ts"].as_str().map(str::to_string))
2947    };
2948    // Trust rows, personas, forecasts and rules are weighed, not recalled;
2949    // a pass between two of them is not a contradiction to judge.
2950    let recalled = |id: &str| -> bool {
2951        atoms
2952            .iter()
2953            .find(|a| a["id"].as_str() == Some(id))
2954            .is_none_or(reviewable)
2955    };
2956    let mut out = String::new();
2957    for pair in v["pairs"]
2958        .as_array()
2959        .into_iter()
2960        .flatten()
2961        .filter(|p| {
2962            recalled(p["a"].as_str().unwrap_or("")) && recalled(p["b"].as_str().unwrap_or(""))
2963        })
2964        .take(limit)
2965    {
2966        let a = pair["a"].as_str().unwrap_or("-");
2967        let b = pair["b"].as_str().unwrap_or("-");
2968        out.push_str(&format!(
2969            "pass {:.3}\n  {a} {}  {}\n  {b} {}  {}\n",
2970            pair["barrier"].as_f64().unwrap_or(0.0),
2971            age_of(stamp_of(a).as_deref(), &now),
2972            pair["a_text"].as_str().unwrap_or("").trim(),
2973            age_of(stamp_of(b).as_deref(), &now),
2974            pair["b_text"].as_str().unwrap_or("").trim()
2975        ));
2976    }
2977    let n = v["pairs"].as_array().map_or(0, Vec::len);
2978    out.push_str(&format!(
2979        "{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",
2980        v["sigma"].as_f64().unwrap_or(0.0)
2981    ));
2982    Ok(out)
2983}
2984
2985/// The rule a write applies on arrival, run over what the pack already
2986/// holds. Without `apply` nothing is written; the pairs are reported.
2987pub fn packset_consolidate(apply: bool) -> Result<Value> {
2988    let client = pack()?;
2989    let workspace = client.workspace();
2990    client
2991        .consolidate(&workspace, apply)
2992        .context("consolidate: POST /v1/consolidate failed")
2993}
2994
2995/// The pairs a consolidation closed or would close, one a line, then the
2996/// count and whether it was applied.
2997pub fn format_consolidation(body: &Value) -> String {
2998    let mut out = String::new();
2999    for pair in body["pairs"].as_array().into_iter().flatten() {
3000        out.push_str(&format!(
3001            "closes {}  {}\n    for {}  {}\n",
3002            pair["old"].as_str().unwrap_or("-"),
3003            pair["old_text"].as_str().unwrap_or("").trim(),
3004            pair["new"].as_str().unwrap_or("-"),
3005            pair["new_text"].as_str().unwrap_or("").trim()
3006        ));
3007    }
3008    let closed = body["closed"].as_u64().unwrap_or(0);
3009    let live = body["live"].as_u64().unwrap_or(0);
3010    if body["applied"].as_bool().unwrap_or(false) {
3011        out.push_str(&format!("{closed} of {live} live memories closed\n"));
3012    } else {
3013        out.push_str(&format!(
3014            "{closed} of {live} live memories would close; `ljos consolidate --apply` closes them\n"
3015        ));
3016    }
3017    out
3018}
3019
3020/// One line per hub: score, links, id, text.
3021pub fn format_hubs(body: &Value) -> String {
3022    let mut out = String::new();
3023    for hub in body["hubs"]
3024        .as_array()
3025        .into_iter()
3026        .flatten()
3027        .filter(|a| reviewable(a))
3028    {
3029        out.push_str(&format!(
3030            "{:.4}\t{}\t{}\t{}\n",
3031            hub["score"].as_f64().unwrap_or(0.0),
3032            hub["links"].as_u64().unwrap_or(0),
3033            hub["id"].as_str().unwrap_or("-"),
3034            hub["text"].as_str().unwrap_or("")
3035        ));
3036    }
3037    out
3038}
3039
3040/// One line per activated memory: activation, seed mark, id, text.
3041pub fn format_island(body: &Value) -> String {
3042    let mut out = String::new();
3043    let now = now_utc();
3044    if body["weak"].as_bool().unwrap_or(false) {
3045        out.push_str(&format!(
3046            "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",
3047            body["agreed_seeds"].as_u64().unwrap_or(0),
3048            if body["agreed_seeds"].as_u64().unwrap_or(0) == 1 { "" } else { "s" },
3049            if body["dense"].as_bool().unwrap_or(true) { "" } else { "; the encoder is down, ranking is lexical only" }
3050        ));
3051    }
3052    for atom in body["island"]
3053        .as_array()
3054        .into_iter()
3055        .flatten()
3056        .filter(|a| reviewable(a))
3057    {
3058        out.push_str(&format!(
3059            "{:.3}\t{}\t{}\t{}\t{}\n",
3060            atom["activation"].as_f64().unwrap_or(0.0),
3061            if atom["seed"].as_bool().unwrap_or(false) {
3062                "seed"
3063            } else {
3064                "    "
3065            },
3066            atom["id"].as_str().unwrap_or("-"),
3067            age_of(atom["ts"].as_str(), &now),
3068            atom["text"].as_str().unwrap_or("")
3069        ));
3070    }
3071    out
3072}
3073
3074pub fn packset_search(query: &str) -> Result<Vec<Hit>> {
3075    packset_search_opts(query, 10, false)
3076}
3077
3078/// [`packset_search`] with a limit and the cross-encoder rerank: the
3079/// writer scores the top hits against the query with its reranker, which
3080/// costs a model call and buys precision. For a brief or a person reading,
3081/// not for the hook.
3082pub fn packset_search_opts(query: &str, limit: u32, rerank: bool) -> Result<Vec<Hit>> {
3083    packset_search_as_of(query, limit, None, rerank)
3084}
3085
3086/// [`packset_search_opts`] asked of the pack as it stood at `as_of` (RFC
3087/// 3339; a date alone reads as its start): only memories live then answer,
3088/// what was withdrawn since included and what was learnt since left out.
3089/// `None` is now. This is the question "what did the seat know when it
3090/// decided that", and the pack keeps every record so it can be asked.
3091pub fn packset_search_as_of(
3092    query: &str,
3093    limit: u32,
3094    as_of: Option<&str>,
3095    rerank: bool,
3096) -> Result<Vec<Hit>> {
3097    let q = query.trim();
3098    if q.is_empty() {
3099        bail!("search: empty query");
3100    }
3101    let as_of = as_of.map(str::trim).filter(|s| !s.is_empty());
3102    let stamp = match as_of {
3103        Some(at) if days_of_stamp(Some(at)).is_none() => {
3104            bail!("search: --as-of {at:?} is not a date; write YYYY-MM-DD or RFC 3339")
3105        }
3106        // A date alone is its start; the pack wants the instant spelt out.
3107        Some(at) if at.len() == 10 => Some(format!("{at}T00:00:00.000Z")),
3108        Some(at) => Some(at.to_string()),
3109        None => None,
3110    };
3111    let client = pack()?;
3112    let workspace = client.workspace();
3113    client
3114        .search_opts(&workspace, q, limit, stamp.as_deref(), rerank)
3115        .context("search: GET /v1/search failed")
3116}
3117
3118/// The actor id in a `claimdag get` line (`assignee=HEX`), if any.
3119fn holder_of(get_output: &str) -> Option<String> {
3120    get_output
3121        .split_whitespace()
3122        .find_map(|w| w.strip_prefix("assignee="))
3123        .filter(|h| h.len() == 32 && *h != "00000000000000000000000000000000")
3124        .map(str::to_string)
3125}
3126
3127/// Take a session node, and when the claim graph refuses because the
3128/// assignee still holds another node, say which tracker id that is and the
3129/// two verbs that free it. The bare refusal names a 32-hex id nobody can
3130/// act on.
3131///
3132/// # Errors
3133///
3134/// The refusal, explained, or any other failure of the claim graph.
3135pub fn claim(node: &str, assignee: &str) -> Result<String> {
3136    let id = node_for(node)?;
3137    let actor = work_id(assignee);
3138    match run_captured("claimdag", &["claim", &id, "--assignee", &actor]) {
3139        Ok(said) => Ok(said.stdout),
3140        Err(e) => {
3141            let text = e.to_string();
3142            // A tracker id maps to one node. When an earlier sitting finished
3143            // it, this is a new sitting on the same work: reopen, then claim.
3144            if ["status done", "status failed", "status cancelled"]
3145                .iter()
3146                .any(|s| text.contains(s))
3147            {
3148                run_captured("claimdag", &["reopen", &id, "--actor", &actor])?;
3149                let said = run_captured("claimdag", &["claim", &id, "--assignee", &actor])?;
3150                return Ok(format!("reopened a finished session node\n{}", said.stdout));
3151            }
3152            // The node is already claimed. By this name it is a sitting
3153            // resumed: renew the lease and go on. By another it is theirs.
3154            if text.contains("status claimed") {
3155                let got = run_captured("claimdag", &["get", &id])?.stdout;
3156                return match holder_of(&got) {
3157                    Some(holder) if holder == actor => {
3158                        let renewed = run_captured("claimdag", &["renew", &id, "--actor", &actor])
3159                            .map(|s| s.stdout)
3160                            .unwrap_or_default();
3161                        Ok(format!(
3162                            "already held by {assignee}; the sitting resumes\n{renewed}"
3163                        ))
3164                    }
3165                    Some(holder) => bail!(
3166                        "claim: {node} is held by another seat (actor {holder}); that seat frees it with `ljos release {node}` or `ljos complete {node}`"
3167                    ),
3168                    None => Err(e),
3169                };
3170            }
3171            if !text.contains("assignee busy") {
3172                return Err(e);
3173            }
3174            let held: Vec<String> = text
3175                .split_whitespace()
3176                .filter(|w| w.len() == 32 && w.chars().all(|c| c.is_ascii_hexdigit()))
3177                .map(str::to_string)
3178                .collect();
3179            let mut lines = vec![format!(
3180                "claim: {assignee} already holds a live node; one live claim per assignee."
3181            )];
3182            for hex in &held {
3183                let name = run_captured("claimdag", &["get", hex])
3184                    .ok()
3185                    .and_then(|s| {
3186                        s.stdout
3187                            .lines()
3188                            .next()
3189                            .and_then(|l| l.split_whitespace().last())
3190                            .map(str::to_string)
3191                    })
3192                    .unwrap_or_else(|| hex.clone());
3193                lines.push(format!(
3194                    "  holds {name}: `ljos complete {name} --status done` finishes it, \
3195                     `ljos release {name} --assignee {assignee}` hands it back"
3196                ));
3197            }
3198            bail!("{}", lines.join("\n"))
3199        }
3200    }
3201}
3202
3203/// Hand a session node back before it is terminal: ready again, assignee
3204/// cleared, generation moved.
3205///
3206/// # Errors
3207///
3208/// The claim graph's refusal: not held, or held by somebody else.
3209pub fn release(node: &str, assignee: &str) -> Result<String> {
3210    let id = node_for(node)?;
3211    Ok(run_captured("claimdag", &["release", &id, "--actor", &work_id(assignee)])?.stdout)
3212}
3213
3214/// `; revises N earlier` when the pack closed earlier memories' windows
3215/// for this one (same kind, a rewrite of the same claim or an explicit
3216/// `supersedes`), else empty. The revision is the pack's; this names it.
3217fn revision_note(body: &Value) -> String {
3218    match body["supersedes"].as_array().map(Vec::len).unwrap_or(0) {
3219        0 => String::new(),
3220        1 => "; revises 1 earlier memory, now closed".to_string(),
3221        n => format!("; revises {n} earlier memories, now closed"),
3222    }
3223}
3224
3225/// The issue's title, for a cue, from the tracker.
3226fn issue_title(issue: &str) -> Result<String> {
3227    let said = run_captured("vissue", &["show", issue, "--json"])?;
3228    let v: Value = serde_json::from_str(&said.stdout).context("vissue show --json")?;
3229    Ok(v.get("title")
3230        .and_then(Value::as_str)
3231        .unwrap_or(issue)
3232        .to_string())
3233}
3234
3235/// One dated event on an issue's timeline, from whichever store holds it.
3236#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
3237pub struct Event {
3238    /// Days since the epoch of the event's date.
3239    pub days: i64,
3240    /// `HH:MM` when the stamp carries a time, else empty; sorts after the
3241    /// day.
3242    pub clock: String,
3243    /// `tracker`, `deed` or `memory`: the store the event came from.
3244    pub source: &'static str,
3245    /// The event in one line.
3246    pub text: String,
3247}
3248
3249/// The issue's timeline, the three stores read as one dated list, oldest
3250/// first: the tracker's logbook (creation, state changes, claims, notes),
3251/// the deeds the issue cites with the time each was produced, and the
3252/// memories the issue's title activates with the time each was written.
3253/// The reader gets time as data, not as stamps to do arithmetic on: each
3254/// line carries its age and the gap since the line before it, and a later
3255/// line supersedes an earlier one on the same matter.
3256///
3257/// # Errors
3258///
3259/// The tracker not answering. A deed store or pack that does not answer
3260/// leaves its rows out; the tracker's rows are the spine.
3261pub fn timeline(issue: &str, limit: usize) -> Result<String> {
3262    let said = run_captured("vissue", &["show", issue, "--json"])?;
3263    let v: Value = serde_json::from_str(&said.stdout).context("vissue show --json")?;
3264    let title = v["title"].as_str().unwrap_or(issue).to_string();
3265    let mut events = tracker_events(&v);
3266    for accession in v["deeds"].as_array().into_iter().flatten() {
3267        let Some(accession) = accession.as_str() else {
3268            continue;
3269        };
3270        if let Ok(said) = run_captured("deedar", &["evidence", accession]) {
3271            if let Some(ev) = deed_event(accession, &said.stdout) {
3272                events.push(ev);
3273            }
3274        }
3275    }
3276    if let Ok(island) = packset_island(&title, false) {
3277        for atom in island["island"]
3278            .as_array()
3279            .into_iter()
3280            .flatten()
3281            .filter(|a| reviewable(a))
3282            .take(8)
3283        {
3284            if let Some((days, clock)) = stamp_key(atom["ts"].as_str()) {
3285                events.push(Event {
3286                    days,
3287                    clock,
3288                    source: "memory",
3289                    text: format!(
3290                        "[{}] {}",
3291                        atom["kind"].as_str().unwrap_or("claim"),
3292                        atom["text"].as_str().unwrap_or("").trim()
3293                    ),
3294                });
3295            }
3296        }
3297    }
3298    // Stable, so events sharing a minute keep the order their store gave.
3299    events.sort_by(|a, b| (a.days, &a.clock).cmp(&(b.days, &b.clock)));
3300    let skip = events.len().saturating_sub(limit);
3301    Ok(format!(
3302        "timeline of {issue}: {title}
3303{}",
3304        format_events(&events[skip..], &now_utc())
3305    ))
3306}
3307
3308/// The tracker's own events on an issue: created, each state change, the
3309/// claim, each note.
3310fn tracker_events(v: &Value) -> Vec<Event> {
3311    let mut events = Vec::new();
3312    let mut push = |stamp: Option<&str>, source: &'static str, text: String| {
3313        if let Some((days, clock)) = stamp_key(stamp) {
3314            events.push(Event {
3315                days,
3316                clock,
3317                source,
3318                text,
3319            });
3320        }
3321    };
3322    push(
3323        v["properties"]["CREATED"].as_str(),
3324        "tracker",
3325        "created".to_string(),
3326    );
3327    if let Some(by) = v["claimed_by"].as_str() {
3328        push(
3329            v["claimed_at"].as_str(),
3330            "tracker",
3331            format!("claimed by {by}"),
3332        );
3333    }
3334    // The logbook is newest first; the timeline reads oldest first.
3335    for e in v["logbook"].as_array().into_iter().flatten().rev() {
3336        let stamp = e["timestamp"].as_str();
3337        if let Some(note) = e["note"].as_str() {
3338            push(stamp, "tracker", format!("note: {}", note.trim()));
3339        } else if let Some(to) = e["to_state"].as_str() {
3340            push(
3341                stamp,
3342                "tracker",
3343                format!("{} -> {to}", e["from_state"].as_str().unwrap_or("-")),
3344            );
3345        }
3346    }
3347    events
3348}
3349
3350/// A deed's event from `deedar evidence`: the time it was produced, by
3351/// whom.
3352fn deed_event(accession: &str, evidence: &str) -> Option<Event> {
3353    let secs: i64 = evidence
3354        .lines()
3355        .find_map(|l| l.strip_prefix("time="))?
3356        .trim()
3357        .parse()
3358        .ok()?;
3359    let by = evidence
3360        .lines()
3361        .find_map(|l| l.strip_prefix("producedBy="))
3362        .map(str::trim)
3363        .unwrap_or("-");
3364    Some(Event {
3365        days: secs.div_euclid(86_400),
3366        clock: format!(
3367            "{:02}:{:02}",
3368            secs.rem_euclid(86_400) / 3600,
3369            secs.rem_euclid(86_400) % 3600 / 60
3370        ),
3371        source: "deed",
3372        text: format!("{accession} produced by {by}"),
3373    })
3374}
3375
3376/// The sort key of a stamp in any of the three stores' shapes: RFC 3339
3377/// (`2026-09-12T21:54:00Z`), an org stamp (`[2026-09-12 Sat 21:54]`), or a
3378/// date alone. Day, then `HH:MM` when the stamp has one.
3379fn stamp_key(stamp: Option<&str>) -> Option<(i64, String)> {
3380    let s = stamp?.trim().trim_start_matches('[').trim_end_matches(']');
3381    let days = days_of_stamp(Some(s))?;
3382    let rest = &s[10..];
3383    let clock = rest
3384        .split(['T', ' '])
3385        .find(|t| t.len() >= 5 && t.as_bytes()[2] == b':')
3386        .map(|t| t[..5].to_string())
3387        .unwrap_or_default();
3388    Some((days, clock))
3389}
3390
3391/// One line per event: date, age, gap since the line before, store, text.
3392fn format_events(events: &[Event], now: &str) -> String {
3393    let today = days_of_stamp(Some(now)).unwrap_or(0);
3394    let mut out = String::new();
3395    let mut last: Option<i64> = None;
3396    for e in events {
3397        let gap = match last {
3398            None => String::new(),
3399            Some(d) if e.days == d => "same day".to_string(),
3400            Some(d) => format!("+{} d", e.days - d),
3401        };
3402        last = Some(e.days);
3403        out.push_str(&format!(
3404            "{} {}	{}	{}	{}	{}
3405",
3406            civil_of_days(e.days),
3407            e.clock,
3408            age_of(Some(&civil_of_days(e.days)), &civil_of_days(today)),
3409            gap,
3410            e.source,
3411            e.text
3412        ));
3413    }
3414    out
3415}
3416
3417/// `YYYY-MM-DD` of a day count since the epoch.
3418fn civil_of_days(days: i64) -> String {
3419    let z = days + 719_468;
3420    let era = z.div_euclid(146_097);
3421    let doe = z.rem_euclid(146_097);
3422    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
3423    let y = yoe + era * 400;
3424    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
3425    let mp = (5 * doy + 2) / 153;
3426    let d = doy - (153 * mp + 2) / 5 + 1;
3427    let m = if mp < 10 { mp + 3 } else { mp - 9 };
3428    let y = if m <= 2 { y + 1 } else { y };
3429    format!("{y:04}-{m:02}-{d:02}")
3430}
3431
3432/// Open a sitting on an issue, in the protocol's order, and stop at the
3433/// first habitat that does not answer: doctor, cards, the review clock,
3434/// the island the issue's title activates, the working set, the timeline,
3435/// the claim.
3436/// One verb, so the loop that makes the seat a memory runs every time and
3437/// not only when somebody remembers to run it.
3438///
3439/// # Errors
3440///
3441/// A required habitat down, or the claim refused (the refusal names what
3442/// the assignee still holds).
3443pub fn sitting(issue: &str, assignee: &str, cards_dir: &Path) -> Result<String> {
3444    let mut out = String::new();
3445    let rows = doctor_seat();
3446    out.push_str("== doctor\n");
3447    out.push_str(&format_doctor(&rows));
3448    if !healthy(&rows) {
3449        bail!("{out}sitting: a required habitat does not answer; nothing was claimed");
3450    }
3451    out.push_str("== cards\n");
3452    out.push_str(&cards(cards_dir)?);
3453    out.push_str("== due\n");
3454    out.push_str(&due_report()?);
3455    let title = issue_title(issue)?;
3456    out.push_str(&format!("== island: {title}\n"));
3457    // The strongest eight: a sitting wants orientation, not the whole
3458    // cluster; `ljos island` prints it all.
3459    let island = packset_island(&title, false)?;
3460    let mut top = island.clone();
3461    if let Some(rows) = top["island"].as_array_mut() {
3462        rows.truncate(8);
3463    }
3464    out.push_str(&format_island(&top));
3465    out.push_str("== recall\n");
3466    out.push_str(&run_captured("vissue", &["recall", issue])?.stdout);
3467    // The last twelve dated events across the three stores; `ljos
3468    // timeline` prints them all.
3469    out.push_str("== timeline\n");
3470    out.push_str(&timeline(issue, 12)?);
3471    out.push_str("== claim\n");
3472    out.push_str(&claim(issue, assignee)?);
3473    Ok(out)
3474}
3475
3476/// Close a sitting: remember the lesson when there is one, fire the island
3477/// the issue's title activates, complete the session node, and learn from
3478/// the outcome when one is named. Without a lesson the report says so,
3479/// because a sitting that taught nothing worth two sentences is rare and
3480/// worth noticing.
3481///
3482/// # Errors
3483///
3484/// Any habitat refusing; the pack refuses a lesson longer than two
3485/// sentences, the claim graph a status that is not terminal.
3486pub fn finish(
3487    issue: &str,
3488    status: &str,
3489    lesson: Option<&str>,
3490    outcome: Option<&str>,
3491    beta: f64,
3492) -> Result<String> {
3493    let mut out = String::new();
3494    match lesson.map(str::trim).filter(|l| !l.is_empty()) {
3495        Some(text) => {
3496            let body = packset_write("Remember", text)?;
3497            out.push_str(&format!(
3498                "remembered {}{}\n",
3499                body.get("id").and_then(Value::as_str).unwrap_or("-"),
3500                revision_note(&body)
3501            ));
3502        }
3503        None => out.push_str(
3504            "no lesson remembered this sitting; `ljos remember` takes one in two sentences\n",
3505        ),
3506    }
3507    let title = issue_title(issue)?;
3508    let island = packset_island(&title, true)?;
3509    if island["weak"].as_bool().unwrap_or(false) {
3510        out.push_str(&format!(
3511            "did not fire the island for {title:?}: its seeds are hits no two scorers agreed on{}; wiring them would tighten the wrong links\n",
3512            if island["dense"].as_bool().unwrap_or(true) { "" } else { " (the encoder is down, ranking is lexical only)" }
3513        ));
3514    } else {
3515        let fired = island["island"].as_array().map_or(0, Vec::len);
3516        out.push_str(&format!(
3517            "fired the island for {title:?}: {fired} memories\n"
3518        ));
3519    }
3520    let terminal = ["done", "failed", "cancelled"];
3521    if !terminal.contains(&status) {
3522        bail!("finish: status {status:?} is not one of done, failed, cancelled");
3523    }
3524    run_captured(
3525        "claimdag",
3526        &["complete", &node_for(issue)?, "--status", status],
3527    )?;
3528    out.push_str(&format!(
3529        "completed the session node for {issue} as {status}\n"
3530    ));
3531    if let Some(option) = outcome.map(str::trim).filter(|o| !o.is_empty()) {
3532        let said = run_captured("vissue", &["vote", issue, "--json"])?;
3533        let ballots = ballots_from_json(&said.stdout)?;
3534        if ballots.len() < 2 {
3535            out.push_str("outcome named but fewer than two ballots; nothing to learn from\n");
3536        } else {
3537            let about = island_entities(issue).unwrap_or_default();
3538            let (rows, moved) = learn_and_write(&ballots, option, beta, &about)?;
3539            out.push_str(&format!(
3540                "learned from outcome {option:?}: {} trust rows rewritten, {} persona anchors moved\n",
3541                rows.len(),
3542                moved.len()
3543            ));
3544        }
3545    }
3546    out.push_str(&format!(
3547        "the ticket stays {issue}'s state; `vissue update {issue} -s DONE` closes it\n"
3548    ));
3549    Ok(out)
3550}
3551
3552/// The weight a voter of estimated accuracy `p` earns: the log odds
3553/// `ln(p / (1 - p))`, the optimal weight for independent voters on a
3554/// two-way choice (Nitzan and Paroush, doi:10.2307/2526438; a weighted
3555/// majority under these weights is the maximum-likelihood decision), with
3556/// `p` held inside `[0.01, 0.99]` so a perfect record does not become an
3557/// infinite vote, and a voter at or under chance at [`TRUST_FLOOR`]. The
3558/// weights are scaled so the most reliable voter stands at one, which is
3559/// the scale the trust rows live on; the ratios between voters are the
3560/// rule's.
3561#[must_use]
3562pub fn calibration_weights(accuracy: &[(String, f64)]) -> Vec<(String, f64)> {
3563    let logit = |p: f64| {
3564        let p = p.clamp(0.01, 0.99);
3565        (p / (1.0 - p)).ln()
3566    };
3567    let raw: Vec<(String, f64)> = accuracy
3568        .iter()
3569        .map(|(who, p)| (who.clone(), logit(*p).max(0.0)))
3570        .collect();
3571    let top = raw.iter().map(|(_, w)| *w).fold(0.0_f64, f64::max);
3572    raw.into_iter()
3573        .map(|(who, w)| {
3574            let scaled = if top > 0.0 { w / top } else { 0.0 };
3575            (who, scaled.clamp(TRUST_FLOOR, 1.0))
3576        })
3577        .collect()
3578}
3579
3580/// Turn a project's voting history into trust rows without anyone naming
3581/// an outcome: Dawid and Skene's accuracy per voter
3582/// (doi:10.2307/2346806), from `ljos-consensus reliability`, turned into
3583/// the weight every other voter gives that voter by
3584/// [`calibration_weights`]: log odds, so a voter right nine times in ten
3585/// outweighs one right six times in ten by five to one, not three to two.
3586/// Rows are complete and floored at [`TRUST_FLOOR`], so the settle sees
3587/// the whole graph.
3588///
3589/// # Errors
3590///
3591/// No issue with two or more ballots, the consensus binary absent, or the
3592/// pack refusing a row.
3593pub fn calibrate(project: &str, rounds: usize) -> Result<Vec<Trust>> {
3594    let said = run_captured(
3595        "ljos-consensus",
3596        &[
3597            "reliability",
3598            "--project",
3599            project,
3600            "--rounds",
3601            &rounds.to_string(),
3602        ],
3603    )?;
3604    let v: Value = serde_json::from_str(&said.stdout).context("reliability: not JSON")?;
3605    let accuracy = v
3606        .get("accuracy")
3607        .and_then(Value::as_object)
3608        .context("reliability: no accuracy object")?;
3609    let mut voters: Vec<(String, f64)> = accuracy
3610        .iter()
3611        .filter_map(|(k, val)| val.as_f64().map(|a| (k.clone(), a)))
3612        .collect();
3613    voters.sort_by(|a, b| a.0.cmp(&b.0));
3614    if voters.len() < 2 {
3615        bail!("calibrate: fewer than two voters in {project}");
3616    }
3617    let weights = calibration_weights(&voters);
3618    let mut rows = Vec::new();
3619    for (from, _) in &voters {
3620        for (to, weight) in &weights {
3621            if from == to {
3622                continue;
3623            }
3624            rows.push(Trust {
3625                from: from.clone(),
3626                to: to.clone(),
3627                weight: *weight,
3628                about: Vec::new(),
3629            });
3630        }
3631    }
3632    for row in &rows {
3633        write_trust(row, &[])?;
3634    }
3635    Ok(rows)
3636}
3637
3638/// One line per hit: score, how many scorers named it out of how many
3639/// ran, kind, id, age, text. The age is the one column a reader needs to
3640/// lay the hits on a timeline; the count is what the hook keys on.
3641pub fn format_hits(hits: &[Hit]) -> String {
3642    let now = now_utc();
3643    let mut out = String::new();
3644    for h in hits {
3645        let id = h.id.as_deref().unwrap_or("-");
3646        let named = match (h.ballots, h.of) {
3647            (Some(b), Some(of)) => format!("{b}/{of}"),
3648            _ => "-".to_string(),
3649        };
3650        out.push_str(&format!(
3651            "{:.4}\t{}\t{}\t{}\t{}\t{}\n",
3652            h.score,
3653            named,
3654            h.kind,
3655            id,
3656            age_of(h.ts.as_deref(), &now),
3657            h.text
3658        ));
3659    }
3660    out
3661}
3662
3663/// The line a hit takes in injected context and in a brief: kind and age
3664/// in the bracket, then the text.
3665fn hit_line(h: &Hit, now: &str) -> String {
3666    format!(
3667        "- [{}{}] {}",
3668        if h.kind.is_empty() { "claim" } else { &h.kind },
3669        age_tag(h.ts.as_deref(), now),
3670        h.text.trim()
3671    )
3672}
3673
3674/// `, N days ago` for a bracket, empty when the stamp is missing.
3675fn age_tag(ts: Option<&str>, now: &str) -> String {
3676    let age = age_of(ts, now);
3677    if age.is_empty() {
3678        age
3679    } else {
3680        format!(", {age}")
3681    }
3682}
3683
3684/// How long ago a stamp was, in words a reader can place: `today`,
3685/// `yesterday`, `N days ago`, then weeks, months and years once the count
3686/// stops fitting the smaller unit. Empty when the stamp is missing or
3687/// unreadable, `in N days` for a stamp ahead of `now`.
3688#[must_use]
3689pub fn age_of(ts: Option<&str>, now: &str) -> String {
3690    let (Some(then), Some(today)) = (days_of_stamp(ts), days_of_stamp(Some(now))) else {
3691        return String::new();
3692    };
3693    let days = today - then;
3694    match days {
3695        d if d < 0 => format!("in {} day{}", -d, if d == -1 { "" } else { "s" }),
3696        0 => "today".into(),
3697        1 => "yesterday".into(),
3698        d if d < 14 => format!("{d} days ago"),
3699        d if d < 61 => format!("{} weeks ago", d / 7),
3700        d if d < 730 => format!("{} months ago", d / 30),
3701        d => format!("{} years ago", d / 365),
3702    }
3703}
3704
3705/// Days since the epoch of an RFC 3339 stamp's date, or none when the
3706/// first ten characters do not read as `YYYY-MM-DD`.
3707fn days_of_stamp(ts: Option<&str>) -> Option<i64> {
3708    let ts = ts?;
3709    let date = ts.get(..10)?;
3710    let mut it = date.split('-');
3711    let y: i64 = it.next()?.parse().ok()?;
3712    let m: i64 = it.next()?.parse().ok()?;
3713    let d: i64 = it.next()?.parse().ok()?;
3714    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
3715        return None;
3716    }
3717    // Civil date to days since the epoch (Howard Hinnant's algorithm).
3718    let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
3719    let era = y.div_euclid(400);
3720    let yoe = y - era * 400;
3721    let doy = (153 * m + 2) / 5 + d - 1;
3722    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
3723    Some(era * 146_097 + doe - 719_468)
3724}
3725
3726/// Read-only cards. Only [`CARD_NAMES`], never created, never written.
3727pub fn cards(dir: &Path) -> Result<String> {
3728    let mut out = String::new();
3729    for name in CARD_NAMES {
3730        let p = dir.join(name);
3731        if p.is_file() {
3732            out.push_str(&format!("--- {} ---\n", p.display()));
3733            out.push_str(&std::fs::read_to_string(&p)?);
3734        }
3735    }
3736    Ok(out)
3737}
3738
3739pub fn policy_line(argv: &[String]) -> Result<String> {
3740    if argv.is_empty() {
3741        bail!("policy: pass the argv to check");
3742    }
3743    Ok(argv.join(" "))
3744}
3745
3746/// The argv line, then what the pack knows that bears on it: the memory a
3747/// policy layer injects beside its verdict. The line prints even when the
3748/// pack is down; the memory is the part that may be empty.
3749pub fn policy_with_memory(argv: &[String]) -> Result<String> {
3750    let line = policy_line(argv)?;
3751    let call = HookCall {
3752        event: "argv".into(),
3753        cue: line.clone(),
3754        session: None,
3755    };
3756    let context = hook_context(&call, 5);
3757    // The rules are the law's memory: a deny or an ask fires before the
3758    // context, so a reader sees the verdict first.
3759    let rules = rules_from_pack().unwrap_or_default();
3760    let ruled = hook_output_ruled(&call, &context, verdict_for(&rules, &line));
3761    match tcb_check(argv) {
3762        Some(tcb) if !tcb.is_empty() => Ok(format!("{line}\n{tcb}\n{ruled}")),
3763        _ => Ok(format!("{line}\n{ruled}")),
3764    }
3765}
3766
3767/// `POLICYD_BIN`, else `ljos-policyd` on PATH.
3768pub fn policyd_bin() -> Option<std::path::PathBuf> {
3769    std::env::var_os("POLICYD_BIN")
3770        .filter(|s| !s.is_empty())
3771        .map(std::path::PathBuf::from)
3772        .or_else(|| which::which("ljos-policyd").ok())
3773}
3774
3775/// One line from `ljos-policyd check -- argv`. None if the binary is absent
3776/// or failed to start. Absence is not a deny.
3777pub fn tcb_check(argv: &[String]) -> Option<String> {
3778    let bin = policyd_bin()?;
3779    let out = std::process::Command::new(bin)
3780        .arg("check")
3781        .arg("--")
3782        .args(argv)
3783        .output()
3784        .ok()?;
3785    let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
3786    (!text.is_empty()).then_some(text)
3787}
3788
3789#[derive(Debug, Clone, PartialEq, Eq)]
3790pub struct ConsensusStep {
3791    pub bin: &'static str,
3792    pub args: Vec<String>,
3793}
3794
3795/// `ljos-consensus` first, then `vissue consensus`, both under the pack's
3796/// trust rows when there are any. Missing bins are skipped.
3797pub fn consensus_steps(
3798    id: &str,
3799    have_ljos: bool,
3800    have_vissue: bool,
3801    trust: &[Trust],
3802) -> Result<Vec<ConsensusStep>> {
3803    consensus_steps_anchored(id, have_ljos, have_vissue, trust, &[])
3804}
3805
3806/// The tag on an issue that asks for bounded confidence: a panel for a
3807/// broad audience is allowed to settle into clusters, and the settle says
3808/// how far apart they are, where a single-position model would average
3809/// them away. Without it the anchored model runs.
3810pub const BROAD_TAG: &str = "broad";
3811
3812/// The confidence bound a `broad` issue settles under: voters within this
3813/// L1 distance of each other's opinion listen to each other.
3814pub const BROAD_EPSILON: f64 = 1.0;
3815
3816/// The model flags an issue's tags ask for, beside the rows and anchors.
3817/// The kind of work sets the dynamics: `broad` runs bounded confidence.
3818#[must_use]
3819pub fn settle_flags_for(tags: &[String]) -> Vec<String> {
3820    if tags.iter().any(|t| t == BROAD_TAG) {
3821        vec!["--epsilon".into(), BROAD_EPSILON.to_string()]
3822    } else {
3823        Vec::new()
3824    }
3825}
3826
3827/// [`consensus_steps_anchored`] with the model flags the issue's tags ask
3828/// for on the model crate's settle.
3829pub fn consensus_steps_for(
3830    id: &str,
3831    have_ljos: bool,
3832    have_vissue: bool,
3833    trust: &[Trust],
3834    personas: &[Persona],
3835    tags: &[String],
3836) -> Result<Vec<ConsensusStep>> {
3837    let mut steps = consensus_steps_anchored(id, have_ljos, have_vissue, trust, personas)?;
3838    let flags = settle_flags_for(tags);
3839    if !flags.is_empty() {
3840        for step in steps.iter_mut().filter(|s| s.bin == "ljos-consensus") {
3841            step.args.extend(flags.iter().cloned());
3842        }
3843    }
3844    Ok(steps)
3845}
3846
3847/// The two readings beside a settle, when the pack holds what they need:
3848/// the surprisingly popular answer when two or more voters forecast the
3849/// others (`predict`), and the EigenTrust standing of the voters when
3850/// trust rows exist. Both are the model crate's verbs.
3851pub fn panel_steps(
3852    id: &str,
3853    have_ljos: bool,
3854    trust: &[Trust],
3855    predictions: &[Prediction],
3856) -> Vec<ConsensusStep> {
3857    let mut steps = Vec::new();
3858    if !have_ljos {
3859        return steps;
3860    }
3861    if predictions.len() >= 2 {
3862        steps.push(ConsensusStep {
3863            bin: "ljos-consensus",
3864            args: vec![
3865                "surprising".into(),
3866                "--issue".into(),
3867                id.into(),
3868                "--predictions".into(),
3869                predictions_json(predictions),
3870            ],
3871        });
3872    }
3873    if !trust.is_empty() {
3874        steps.push(ConsensusStep {
3875            bin: "ljos-consensus",
3876            args: vec!["reputation".into(), "--trust".into(), trust_json(trust)],
3877        });
3878    }
3879    steps
3880}
3881
3882/// [`consensus_steps`] passing the personas' anchors to both settles as
3883/// `--susceptibility-of`, so a persona holds its ballot as much as it says.
3884pub fn consensus_steps_anchored(
3885    id: &str,
3886    have_ljos: bool,
3887    have_vissue: bool,
3888    trust: &[Trust],
3889    personas: &[Persona],
3890) -> Result<Vec<ConsensusStep>> {
3891    if !have_ljos && !have_vissue {
3892        bail!("neither ljos-consensus nor vissue is on PATH");
3893    }
3894    let mut steps = Vec::new();
3895    if have_ljos {
3896        let mut args = vec!["settle".to_string(), "--issue".into(), id.into()];
3897        if !trust.is_empty() {
3898            args.push("--trust".into());
3899            args.push(trust_json(trust));
3900        }
3901        if !personas.is_empty() {
3902            args.push("--susceptibility-of".into());
3903            args.push(anchors_json(personas));
3904        }
3905        steps.push(ConsensusStep {
3906            bin: "ljos-consensus",
3907            args,
3908        });
3909    }
3910    if have_vissue {
3911        let mut args = vec!["consensus".to_string(), id.into()];
3912        if !trust.is_empty() {
3913            args.push("--trust".into());
3914            args.push(trust_json(trust));
3915        }
3916        if !personas.is_empty() {
3917            args.push("--susceptibility-of".into());
3918            args.push(anchors_json(personas));
3919        }
3920        steps.push(ConsensusStep {
3921            bin: "vissue",
3922            args,
3923        });
3924    }
3925    Ok(steps)
3926}
3927
3928pub fn on_path(bin: &str) -> bool {
3929    which::which(bin).is_ok()
3930}
3931
3932pub fn run(bin: &str, args: &[impl AsRef<str>]) -> Result<()> {
3933    run_as(bin, args, None)
3934}
3935
3936/// The identity a ballot is cast under: the persona named, else the
3937/// runner's seat name when `LJOS_SEAT` is set, else none (the tracker's
3938/// own default, `VISSUE_AGENT` or `user@host`).
3939#[must_use]
3940pub fn identity_or_seat(identity: Option<&str>) -> Option<String> {
3941    identity
3942        .map(str::trim)
3943        .filter(|w| !w.is_empty())
3944        .map(str::to_string)
3945        .or_else(|| {
3946            std::env::var("LJOS_SEAT")
3947                .ok()
3948                .map(|v| v.trim().to_string())
3949                .filter(|v| !v.is_empty())
3950        })
3951}
3952
3953/// [`run`] with `VISSUE_AGENT` set to `identity`, so a ballot or a claim is
3954/// recorded under a persona's name rather than the seat's.
3955pub fn run_as(bin: &str, args: &[impl AsRef<str>], identity: Option<&str>) -> Result<()> {
3956    use std::process::{Command, Stdio};
3957    let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
3958    let mut cmd = Command::new(path);
3959    if let Some(who) = identity_or_seat(identity) {
3960        cmd.env("VISSUE_AGENT", who);
3961    }
3962    for a in args {
3963        cmd.arg(a.as_ref());
3964    }
3965    let st = cmd
3966        .stdin(Stdio::inherit())
3967        .stdout(Stdio::inherit())
3968        .stderr(Stdio::inherit())
3969        .status()?;
3970    // A child that died of a closed pipe was cut off by our own reader
3971    // going away (`ljos consensus ID | head`); that is not the habitat
3972    // refusing.
3973    #[cfg(unix)]
3974    {
3975        use std::os::unix::process::ExitStatusExt;
3976        if st.signal() == Some(libc::SIGPIPE) {
3977            return Ok(());
3978        }
3979    }
3980    if !st.success() {
3981        bail!("{bin} exited {st}");
3982    }
3983    Ok(())
3984}
3985
3986/// What a habitat printed, kept for a caller that has to hand it on. A
3987/// non-zero exit is an error carrying stderr.
3988#[derive(Debug, Clone, PartialEq, Eq)]
3989pub struct Said {
3990    pub stdout: String,
3991    pub stderr: String,
3992}
3993
3994pub fn run_captured(bin: &str, args: &[impl AsRef<str>]) -> Result<Said> {
3995    use std::process::{Command, Stdio};
3996    let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
3997    let mut cmd = Command::new(path);
3998    for a in args {
3999        cmd.arg(a.as_ref());
4000    }
4001    let out = cmd
4002        .stdin(Stdio::null())
4003        .stdout(Stdio::piped())
4004        .stderr(Stdio::piped())
4005        .output()
4006        .with_context(|| format!("{bin}: could not start"))?;
4007    let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
4008    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
4009    if !out.status.success() {
4010        let why = if stderr.trim().is_empty() {
4011            stdout.trim().to_string()
4012        } else {
4013            stderr.trim().to_string()
4014        };
4015        bail!("{bin} exited {}: {why}", out.status);
4016    }
4017    Ok(Said { stdout, stderr })
4018}
4019
4020pub fn card_paths(dir: &Path) -> Vec<PathBuf> {
4021    CARD_NAMES.iter().map(|n| dir.join(n)).collect()
4022}
4023
4024#[cfg(test)]
4025mod tests {
4026    #[test]
4027    fn a_shared_name_does_not_occupy_the_whole_host() {
4028        unsafe {
4029            std::env::remove_var("LJOS_SEAT");
4030            std::env::remove_var("VISSUE_AGENT");
4031            std::env::set_var("GROK_SESSION_ID", "01a09b25-ffe9-7972-881a-3cee2ea6efd6");
4032        }
4033        assert_eq!(resolve_assignee(Some("grok")), "sess-01a09b25");
4034        assert_eq!(resolve_assignee(Some("seat")), "sess-01a09b25");
4035        assert_eq!(resolve_assignee(None), "sess-01a09b25");
4036        assert_eq!(resolve_assignee(Some("alice")), "alice");
4037        unsafe {
4038            std::env::remove_var("GROK_SESSION_ID");
4039        }
4040    }
4041
4042    #[test]
4043    fn the_record_weighs_a_voter_by_what_it_got_right() {
4044        let ballots = vec![
4045            ("a".to_string(), "ship".to_string()),
4046            ("b".to_string(), "ship".to_string()),
4047            ("c".to_string(), "hold".to_string()),
4048        ];
4049        let (rows, records) =
4050            learn_record(&ballots, "ship", &std::collections::BTreeMap::new(), &[]).unwrap();
4051        assert_eq!(records["a"], (1.0, 0.0));
4052        assert_eq!(records["c"], (0.0, 1.0));
4053        let w = |to: &str| rows.iter().find(|r| r.to == to).unwrap().weight;
4054        assert_eq!(w("a"), 1.0, "a right voter stands at one");
4055        assert!(w("c") < w("a"), "a wrong voter stands lower");
4056        assert_eq!(rows.len(), 6, "complete over the voters");
4057        // The record accumulates: a second outcome against c lowers it further.
4058        let (rows2, records2) = learn_record(&ballots, "ship", &records, &[]).unwrap();
4059        assert_eq!(records2["c"], (0.0, 2.0));
4060        let w2 = |to: &str| rows2.iter().find(|r| r.to == to).unwrap().weight;
4061        assert!(w2("c") <= w("c"));
4062        assert!(learn_record(&ballots, "  ", &records, &[]).is_err());
4063        // Records are read back off trust atoms, latest first.
4064        let atoms = vec![
4065            serde_json::json!({"kind": "trust", "from": "a", "to": "c", "weight": 0.2, "hits": 1.0, "misses": 3.0, "ts": "2026-09-13T01:00:00Z"}),
4066            serde_json::json!({"kind": "trust", "from": "b", "to": "c", "weight": 0.5, "hits": 1.0, "misses": 1.0, "ts": "2026-09-12T01:00:00Z"}),
4067        ];
4068        assert_eq!(records_from_atoms(&atoms)["c"], (1.0, 3.0));
4069    }
4070
4071    #[test]
4072    fn a_correction_is_nudged_once_a_session_and_only_on_a_prompt() {
4073        // The seen file lives under the runtime directory.
4074        let dir = std::env::temp_dir().join(format!("ljos-corr-{}", std::process::id()));
4075        std::fs::create_dir_all(&dir).unwrap();
4076        unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
4077        let prompt = HookCall {
4078            event: "UserPromptSubmit".into(),
4079            cue: "Do you not remember to use uv for scripts?".into(),
4080            session: Some("corr-test".into()),
4081        };
4082        let first = correction_nudge(&prompt).expect("a correction is nudged");
4083        assert!(first.contains("ljos prefer"), "{first}");
4084        assert!(correction_nudge(&prompt).is_none(), "once a session");
4085        let tool = HookCall {
4086            event: "PreToolUse".into(),
4087            cue: "you should have used uv".into(),
4088            session: Some("corr-test".into()),
4089        };
4090        assert!(
4091            correction_nudge(&tool).is_none(),
4092            "tool calls are not prompts"
4093        );
4094        let plain = HookCall {
4095            event: "UserPromptSubmit".into(),
4096            cue: "add the timeline verb".into(),
4097            session: Some("corr-test-2".into()),
4098        };
4099        assert!(correction_nudge(&plain).is_none());
4100    }
4101
4102    #[test]
4103    fn calibration_weights_are_log_odds_with_the_best_at_one() {
4104        let w = calibration_weights(&[
4105            ("a".to_string(), 0.9),
4106            ("b".to_string(), 0.6),
4107            ("c".to_string(), 0.5),
4108            ("d".to_string(), 1.0),
4109        ]);
4110        let of = |who: &str| w.iter().find(|(n, _)| n == who).unwrap().1;
4111        assert_eq!(of("d"), 1.0, "a perfect record is the top of the scale");
4112        // ln(9) / ln(99) = 0.478; ln(1.5) / ln(99) = 0.088
4113        assert!((of("a") - 0.478).abs() < 0.01, "{}", of("a"));
4114        assert!((of("b") - 0.088).abs() < 0.01, "{}", of("b"));
4115        assert!(
4116            of("a") / of("b") > 5.0,
4117            "nine in ten outweighs six in ten by more than five"
4118        );
4119        assert_eq!(of("c"), TRUST_FLOOR, "chance earns the floor");
4120    }
4121
4122    #[test]
4123    fn a_consolidation_report_names_the_pairs() {
4124        let body = serde_json::json!({"live": 5, "closed": 1, "applied": false, "pairs": [
4125            {"old": "a", "old_text": "The default fuse is Borda.", "new": "b", "new_text": "The default fuse is CombMNZ."}
4126        ]});
4127        let text = format_consolidation(&body);
4128        assert!(
4129            text.starts_with(
4130                "closes a  The default fuse is Borda.\n    for b  The default fuse is CombMNZ.\n"
4131            ),
4132            "{text}"
4133        );
4134        assert!(
4135            text.ends_with(
4136                "1 of 5 live memories would close; `ljos consolidate --apply` closes them\n"
4137            ),
4138            "{text}"
4139        );
4140        let applied = format_consolidation(
4141            &serde_json::json!({"live": 5, "closed": 0, "applied": true, "pairs": []}),
4142        );
4143        assert_eq!(applied, "0 of 5 live memories closed\n");
4144    }
4145
4146    #[test]
4147    fn the_hook_keeps_what_two_scorers_agreed_on() {
4148        let hit = |ballots, of| Hit {
4149            id: None,
4150            text: "x".into(),
4151            score: 1.0,
4152            kind: "lesson".into(),
4153            ts: None,
4154            ballots,
4155            of,
4156        };
4157        assert!(agreed(&hit(Some(2), Some(3))));
4158        assert!(!agreed(&hit(Some(1), Some(3))));
4159        assert!(agreed(&hit(Some(1), Some(1))));
4160        assert!(agreed(&hit(None, None)));
4161    }
4162
4163    #[test]
4164    fn the_holder_is_read_off_a_get_line() {
4165        let line = "a25a…  claimed  task  unset  gen=2  assignee=69f917124f757277b806e9a0f48c0318  parent=0  x-1";
4166        assert_eq!(
4167            holder_of(line).as_deref(),
4168            Some("69f917124f757277b806e9a0f48c0318")
4169        );
4170        assert_eq!(
4171            holder_of("a  ready  task  unset  gen=1  assignee=00000000000000000000000000000000"),
4172            None
4173        );
4174        assert_eq!(holder_of("deps  -"), None);
4175    }
4176
4177    #[test]
4178    fn a_registration_carries_the_runners_name() {
4179        let argv: Vec<String> = ["run", "-e", "LJOS_SEAT={name}", "{server}"]
4180            .iter()
4181            .map(|s| (*s).to_string())
4182            .collect();
4183        let filled = filled(&argv, Path::new("/x/ljos-mcp"), "runner-a");
4184        assert_eq!(filled, ["run", "-e", "LJOS_SEAT=runner-a", "/x/ljos-mcp"]);
4185        assert_eq!(
4186            identity_or_seat(Some(" reviewer ")).as_deref(),
4187            Some("reviewer")
4188        );
4189    }
4190
4191    #[test]
4192    fn a_timeline_merges_the_three_stores_oldest_first() {
4193        let v = serde_json::json!({
4194            "properties": {"CREATED": "[2026-09-01 Tue]"},
4195            "claimed_by": "seat",
4196            "claimed_at": "[2026-09-03 Thu 11:48]",
4197            "logbook": [
4198                {"note": "second", "timestamp": "[2026-09-10 Thu 09:00]"},
4199                {"from_state": "TODO", "to_state": "STARTED", "timestamp": "[2026-09-03 Thu 11:48]"}
4200            ]
4201        });
4202        let mut events = tracker_events(&v);
4203        events.push(
4204            deed_event(
4205                "deed-x",
4206                "id=deed-x ok\nproducedBy=seat -\ntime=1788566400\n",
4207            )
4208            .unwrap(),
4209        );
4210        events.sort_by(|a, b| (a.days, &a.clock).cmp(&(b.days, &b.clock)));
4211        let text = format_events(&events, "2026-09-12T00:00:00Z");
4212        let lines: Vec<&str> = text.lines().collect();
4213        assert_eq!(lines.len(), 5, "{text}");
4214        assert!(
4215            lines[0].starts_with("2026-09-01 \t11 days ago\t\ttracker\tcreated"),
4216            "{}",
4217            lines[0]
4218        );
4219        assert!(
4220            lines[1].contains("+2 d\ttracker\tclaimed by seat"),
4221            "{}",
4222            lines[1]
4223        );
4224        assert!(
4225            lines[2].contains("same day\ttracker\tTODO -> STARTED"),
4226            "{}",
4227            lines[2]
4228        );
4229        assert!(
4230            lines[3]
4231                .starts_with("2026-09-05 00:00\t7 days ago\t+2 d\tdeed\tdeed-x produced by seat -"),
4232            "{}",
4233            lines[3]
4234        );
4235        assert!(
4236            lines[4].contains("2 days ago\t+5 d\ttracker\tnote: second"),
4237            "{}",
4238            lines[4]
4239        );
4240    }
4241
4242    #[test]
4243    fn stamps_of_every_shape_key_the_same() {
4244        assert_eq!(
4245            stamp_key(Some("[2026-09-12 Sat 21:54]")),
4246            stamp_key(Some("2026-09-12T21:54:00.000Z"))
4247        );
4248        assert_eq!(stamp_key(Some("[2026-09-12 Sat]")).unwrap().1, "");
4249        assert_eq!(stamp_key(Some("soon")), None);
4250        assert_eq!(
4251            civil_of_days(days_of_stamp(Some("2026-09-12")).unwrap()),
4252            "2026-09-12"
4253        );
4254    }
4255
4256    #[test]
4257    fn ages_read_as_a_timeline() {
4258        let now = "2026-09-12T14:00:00.000Z";
4259        assert_eq!(age_of(Some("2026-09-12T01:00:00.000Z"), now), "today");
4260        assert_eq!(age_of(Some("2026-09-11T23:59:00.000Z"), now), "yesterday");
4261        assert_eq!(age_of(Some("2026-09-01T00:00:00.000Z"), now), "11 days ago");
4262        assert_eq!(age_of(Some("2026-08-01T00:00:00.000Z"), now), "6 weeks ago");
4263        assert_eq!(
4264            age_of(Some("2026-03-01T00:00:00.000Z"), now),
4265            "6 months ago"
4266        );
4267        assert_eq!(age_of(Some("2023-09-12T00:00:00.000Z"), now), "3 years ago");
4268        assert_eq!(age_of(Some("2026-09-13T00:00:00.000Z"), now), "in 1 day");
4269        assert_eq!(age_of(None, now), "");
4270        assert_eq!(age_of(Some("card"), now), "");
4271    }
4272
4273    #[test]
4274    fn a_hit_line_carries_kind_and_age() {
4275        let h = Hit {
4276            id: Some("a".into()),
4277            text: " keep the smoke green ".into(),
4278            score: 1.0,
4279            kind: "lesson".into(),
4280            ts: Some("2026-09-10T00:00:00.000Z".into()),
4281            ballots: None,
4282            of: None,
4283        };
4284        assert_eq!(
4285            hit_line(&h, "2026-09-12T00:00:00.000Z"),
4286            "- [lesson, 2 days ago] keep the smoke green"
4287        );
4288        let bare = Hit {
4289            id: None,
4290            text: "x".into(),
4291            score: 1.0,
4292            kind: String::new(),
4293            ts: None,
4294            ballots: None,
4295            of: None,
4296        };
4297        assert_eq!(hit_line(&bare, "2026-09-12T00:00:00.000Z"), "- [claim] x");
4298    }
4299
4300    /// A hook call is read from the runner's JSON or from plain text, and
4301    /// the answer is the runner's shape only when there is something to say.
4302    #[test]
4303    fn hook_calls_are_read_and_answered_in_the_runners_shape() {
4304        let tool = hook_call(
4305            r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cargo test","description":"run"}}"#,
4306        );
4307        assert_eq!(tool.event, "PreToolUse");
4308        assert_eq!(tool.cue, "cargo test");
4309        let prompt = hook_call(r#"{"hook_event_name":"UserPromptSubmit","prompt":"fix the fuse"}"#);
4310        assert_eq!(prompt.cue, "fix the fuse");
4311        let grok = hook_call(r#"{"hookEventName":"post_tool_use","sessionId":"s1"}"#);
4312        assert_eq!(grok.event, "PostToolUse");
4313        assert_eq!(grok.session.as_deref(), Some("s1"));
4314        hold_hook_context(Some("s1"), "held pack");
4315        assert_eq!(take_hook_context(Some("s1")), "held pack");
4316        assert!(take_hook_context(Some("s1")).is_empty());
4317        let argv = hook_call("rm -rf build");
4318        assert_eq!(argv.event, "argv");
4319        assert_eq!(argv.session, None);
4320        let with_session = hook_call(
4321            r#"{"session_id":"abc/../x 1","hook_event_name":"PreToolUse","tool_input":{"command":"ls"}}"#,
4322        );
4323        assert_eq!(with_session.session.as_deref(), Some("abc/../x 1"));
4324        assert!(seen_path("abc/../x 1")
4325            .unwrap()
4326            .file_name()
4327            .unwrap()
4328            .to_string_lossy()
4329            .ends_with("hook-seen-abcx1"));
4330        assert_eq!(seen_path("/../"), None);
4331        assert_eq!(hook_output(&argv, ""), "");
4332        assert_eq!(hook_output(&argv, "- [lesson] x"), "- [lesson] x\n");
4333        let out = hook_output(&tool, "- [preference] y");
4334        let v: Value = serde_json::from_str(out.trim()).unwrap();
4335        assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
4336        assert_eq!(
4337            v["hookSpecificOutput"]["additionalContext"],
4338            "- [preference] y"
4339        );
4340        assert!(
4341            hook_context(
4342                &HookCall {
4343                    event: "argv".into(),
4344                    cue: "ab".into(),
4345                    session: None
4346                },
4347                8
4348            )
4349            .is_empty(),
4350            "a cue too short asks nothing"
4351        );
4352    }
4353
4354    /// The injected ids of a session are read back without the nudge marker,
4355    /// and the seen file goes with the session.
4356    #[test]
4357    fn a_sessions_injected_memories_are_read_back_and_cleared() {
4358        let session = format!("end-test-{}", std::process::id());
4359        mark_seen(
4360            Some(&session),
4361            &["a".to_string(), "due-nudge".to_string(), "b".to_string()],
4362        );
4363        let (ids, path) = injected_ids(&session);
4364        assert_eq!(ids, ["a", "b"]);
4365        assert!(path.as_ref().is_some_and(|p| p.is_file()));
4366        // No pack in a unit test: nothing fires, the file still goes.
4367        let _ = session_end(Some(&session));
4368        assert!(!path.unwrap().is_file());
4369        assert_eq!(session_end(None), 0);
4370    }
4371
4372    /// The memory hook merges into a runner's hooks file once per event and
4373    /// is not added twice.
4374    #[test]
4375    fn the_memory_hook_is_merged_once() {
4376        let dir = std::env::temp_dir().join(format!("ljos-hook-{}", std::process::id()));
4377        let _ = std::fs::remove_dir_all(&dir);
4378        std::fs::create_dir_all(&dir).unwrap();
4379        let file = dir.join("settings.json");
4380        std::fs::write(
4381            &file,
4382            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"other"}]}]},"theme":"dark"}"#,
4383        )
4384        .unwrap();
4385        let both: Vec<String> = vec!["UserPromptSubmit".into(), "PreToolUse".into()];
4386        let prompts: Vec<String> = HOOK_EVENTS.iter().map(|e| (*e).to_string()).collect();
4387        assert_eq!(
4388            prompts,
4389            ["UserPromptSubmit", "SessionEnd"],
4390            "the panel's default, and the session end that wires what it used"
4391        );
4392        assert!(!hook_installed(&file, &both));
4393        let dry = hook_step(&file, &both, true);
4394        assert!(
4395            dry.ok && dry.detail.starts_with("would add it on"),
4396            "{dry:?}"
4397        );
4398        let step = hook_step(&file, &both, false);
4399        assert!(step.ok, "{step:?}");
4400        assert!(hook_installed(&file, &both));
4401        let again = hook_step(&file, &both, false);
4402        assert!(
4403            again.detail.contains("carries the memory hook on"),
4404            "{again:?}"
4405        );
4406        let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
4407        assert_eq!(v["theme"], "dark", "the rest of the file is kept");
4408        assert_eq!(
4409            v["hooks"]["PreToolUse"].as_array().unwrap().len(),
4410            2,
4411            "the other hook stays"
4412        );
4413        assert_eq!(v["hooks"]["UserPromptSubmit"].as_array().unwrap().len(), 1);
4414        // Narrowing to the default drops the seat's tool-call group and
4415        // leaves the other tool's group alone.
4416        let narrowed = hook_step(&file, &prompts, false);
4417        assert!(
4418            narrowed.detail.contains("drop it from PreToolUse"),
4419            "{narrowed:?}"
4420        );
4421        let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
4422        assert_eq!(v["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
4423        assert_eq!(v["hooks"]["PreToolUse"][0]["hooks"][0]["command"], "other");
4424        assert!(hook_installed(&file, &prompts));
4425        assert!(!hook_installed(&file, &both));
4426        let _ = std::fs::remove_dir_all(&dir);
4427    }
4428
4429    /// Rules are globs over the whole line; deny wins over ask; the hook
4430    /// carries the verdict as the runner's permission decision.
4431    #[test]
4432    fn rules_match_the_line_and_the_hook_carries_the_verdict() {
4433        assert!(glob_matches("rm -rf *", "rm -rf /tmp/x"));
4434        assert!(!glob_matches("rm -rf *", "ls -la"));
4435        assert!(glob_matches("*sudo*", "echo hi && sudo reboot"));
4436        assert!(glob_matches("git push*", "git push origin main"));
4437        assert!(!glob_matches("git push*", "git pull"));
4438        let rules = vec![
4439            Rule {
4440                pattern: "git push*".into(),
4441                verdict: "ask".into(),
4442                reason: "A push is the trust gate.".into(),
4443            },
4444            Rule {
4445                pattern: "*--force*".into(),
4446                verdict: "deny".into(),
4447                reason: "Never force push.".into(),
4448            },
4449        ];
4450        assert_eq!(
4451            verdict_for(&rules, "git push --force").unwrap().verdict,
4452            "deny"
4453        );
4454        assert_eq!(
4455            verdict_for(&rules, "git push origin x").unwrap().verdict,
4456            "ask"
4457        );
4458        assert!(verdict_for(&rules, "cargo test").is_none());
4459        let call = hook_call(
4460            r#"{"hook_event_name":"PreToolUse","tool_input":{"command":"git push --force"}}"#,
4461        );
4462        let out = hook_output_ruled(&call, "", verdict_for(&rules, &call.cue));
4463        let v: Value = serde_json::from_str(out.trim()).unwrap();
4464        assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
4465        assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
4466            .as_str()
4467            .unwrap()
4468            .contains("Never force push"));
4469        assert!(v["hookSpecificOutput"].get("additionalContext").is_none());
4470        let argv = HookCall {
4471            event: "argv".into(),
4472            cue: "git push origin x".into(),
4473            session: None,
4474        };
4475        assert!(
4476            hook_output_ruled(&argv, "", verdict_for(&rules, &argv.cue)).starts_with("ask: A push")
4477        );
4478        let steps = panel_steps("x-1", true, &[], &[]);
4479        assert!(steps.is_empty());
4480        let preds = vec![
4481            Prediction {
4482                issue: "x-1".into(),
4483                agent: "a".into(),
4484                expect: Value::String("ship".into()),
4485            },
4486            Prediction {
4487                issue: "x-1".into(),
4488                agent: "b".into(),
4489                expect: serde_json::json!({"ship": 0.6, "hold": 0.4}),
4490            },
4491        ];
4492        let steps = panel_steps("x-1", true, &[row("a", "b", 0.5)], &preds);
4493        assert_eq!(steps.len(), 2);
4494        assert_eq!(steps[0].args[0], "surprising");
4495        assert_eq!(steps[1].args[0], "reputation");
4496    }
4497
4498    /// A scoped row applies when the issue is about one of its domains; an
4499    /// unscoped row applies everywhere; a scoped learn starts from the
4500    /// unscoped row and leaves it standing.
4501    #[test]
4502    fn scoped_rows_apply_to_their_topic_and_learn_writes_in_scope() {
4503        let everywhere = row("a", "b", 0.9);
4504        let mut on_docs = row("a", "b", 0.2);
4505        on_docs.about = vec!["docs".into()];
4506        let rows = vec![everywhere.clone(), on_docs.clone()];
4507        let topic = topic_words("Rewrite the docs site");
4508        assert_eq!(topic, ["docs", "rewrite", "site", "the"]);
4509        // On the docs topic the scoped row stands in for the unscoped one;
4510        // elsewhere the unscoped row is the one that applies.
4511        assert_eq!(rows_about(&rows, &topic), vec![on_docs.clone()]);
4512        assert_eq!(
4513            rows_about(&rows, &topic_words("Fix the fuse")),
4514            vec![everywhere.clone()]
4515        );
4516
4517        let ballots = vec![
4518            ("a".to_string(), "ship".to_string()),
4519            ("b".to_string(), "hold".to_string()),
4520        ];
4521        let learned = learn_about(&ballots, "ship", &rows, 0.5, &["fuse".to_string()]).unwrap();
4522        let ab = learned
4523            .iter()
4524            .find(|r| r.from == "a" && r.to == "b")
4525            .unwrap();
4526        assert_eq!(ab.about, ["fuse"]);
4527        assert!(
4528            (ab.weight - 0.45).abs() < 1e-9,
4529            "starts from the unscoped 0.9: {ab:?}"
4530        );
4531        let ba = learned
4532            .iter()
4533            .find(|r| r.from == "b" && r.to == "a")
4534            .unwrap();
4535        assert!((ba.weight - 1.0).abs() < 1e-9, "a was right: {ba:?}");
4536
4537        // Rows read back keep scoped and unscoped apart, latest per scope.
4538        let atoms = vec![
4539            trust_atom(&everywhere, &[], "ws").unwrap(),
4540            trust_atom(&on_docs, &[], "ws").unwrap(),
4541        ];
4542        let mut back = trust_rows(&atoms);
4543        back.sort_by(|x, y| x.about.cmp(&y.about));
4544        assert_eq!(back, vec![everywhere, on_docs]);
4545    }
4546
4547    /// A persona is a voter with an anchor; the latest atom per name wins and
4548    /// the anchors go to the settle as one object.
4549    #[test]
4550    fn personas_are_latest_per_name_and_anchor_the_settle() {
4551        let p = Persona {
4552            name: "reviewer".into(),
4553            anchor: 0.2,
4554            view: "Reads for what could break in production.".into(),
4555            entities: vec!["Release".into()],
4556        };
4557        let mut a = persona_atom(&p, "ws").unwrap();
4558        a["ts"] = Value::String("2026-01-01T00:00:00Z".into());
4559        let mut later = a.clone();
4560        later["anchor"] = serde_json::json!(0.4);
4561        later["ts"] = Value::String("2026-02-01T00:00:00Z".into());
4562        let got = personas_of(&[a, later]);
4563        assert_eq!(got.len(), 1);
4564        assert_eq!(got[0].anchor, 0.4);
4565        assert_eq!(got[0].entities, ["release"]);
4566        assert_eq!(anchors_json(&got), r#"{"reviewer":0.4}"#);
4567        // A refuted persona listens more next time; a vindicated one does
4568        // not move; one that did not vote is untouched.
4569        let ballots = vec![
4570            ("reviewer".to_string(), "hold".to_string()),
4571            ("reader".to_string(), "ship".to_string()),
4572        ];
4573        let moved = learn_anchors(&got, &ballots, "ship", 0.5);
4574        assert_eq!(moved.len(), 1);
4575        assert!(
4576            (moved[0].anchor - 0.7).abs() < 1e-9,
4577            "0.4 + 0.6 * 0.5: {moved:?}"
4578        );
4579        assert!(learn_anchors(&got, &ballots, "hold", 0.5).is_empty());
4580        assert!(persona_atom(
4581            &Persona {
4582                anchor: 1.5,
4583                ..p.clone()
4584            },
4585            "ws"
4586        )
4587        .is_err());
4588        let steps = consensus_steps_anchored("x-1", true, true, &[], &got).unwrap();
4589        for step in &steps {
4590            assert!(
4591                step.args.contains(&"--susceptibility-of".to_string()),
4592                "{step:?}"
4593            );
4594        }
4595        // The kind of work sets the dynamics: a broad-audience issue runs
4596        // bounded confidence on the model crate, and the tracker verb, which
4597        // has no such model, is left as it was.
4598        let broad =
4599            consensus_steps_for("x-1", true, true, &[], &got, &["broad".to_string()]).unwrap();
4600        assert!(
4601            broad[0].args.contains(&"--epsilon".to_string()),
4602            "{:?}",
4603            broad[0]
4604        );
4605        assert!(
4606            !broad[1].args.contains(&"--epsilon".to_string()),
4607            "{:?}",
4608            broad[1]
4609        );
4610        assert!(settle_flags_for(&["feature".to_string()]).is_empty());
4611    }
4612
4613    /// A claim that never entered the clock is due now; a scheduled one is
4614    /// not; trust rows never are; and the summary says whether the clock runs.
4615    #[test]
4616    fn unreviewed_claims_are_due_and_the_summary_says_if_the_clock_runs() {
4617        let atoms = vec![
4618            serde_json::json!({"id": "a", "kind": "conclusion", "text": "old", "due_at": ""}),
4619            serde_json::json!({"id": "b", "kind": "conclusion", "text": "older"}),
4620            serde_json::json!({"id": "c", "kind": "conclusion", "text": "later",
4621                "due_at": "2030-01-01T00:00:00Z"}),
4622            serde_json::json!({"id": "d", "kind": "conclusion", "text": "past",
4623                "due_at": "2020-01-01T00:00:00Z"}),
4624            serde_json::json!({"id": "t", "kind": "trust", "text": "x weighs y"}),
4625        ];
4626        let now = "2026-01-01T00:00:00Z";
4627        let due: Vec<String> = super::due_of(&atoms, now)
4628            .iter()
4629            .map(|a| a["id"].as_str().unwrap().to_string())
4630            .collect();
4631        assert_eq!(
4632            due,
4633            ["a", "b", "d"],
4634            "unreviewed first, then the past-due one"
4635        );
4636        assert_eq!(
4637            super::review_summary(&atoms, now),
4638            "3 due; 1 scheduled, next at 2030-01-01T00:00:00Z"
4639        );
4640        assert_eq!(
4641            super::review_summary(&[atoms[4].clone()], now),
4642            "0 due; nothing scheduled: this seat has remembered nothing yet"
4643        );
4644        assert!(super::format_due(&super::due_of(&atoms, now)).starts_with("unreviewed\t"));
4645    }
4646
4647    /// The example file parses, and onboarding a config-file runner from it
4648    /// appends the entry once and writes the skill once; a dry run writes
4649    /// nothing; an unnamed runner is refused with the names the file holds.
4650    #[test]
4651    fn onboarding_a_config_file_runner_writes_once() {
4652        let all: super::Harnesses = toml::from_str(super::HARNESSES_EXAMPLE).expect("parses");
4653        assert_eq!(all.harness.len(), 2);
4654        assert_eq!(all.harness[1].marker.as_deref(), Some("[mcp_servers.ljos]"));
4655
4656        let dir = std::env::temp_dir().join(format!("ljos-onboard-{}", std::process::id()));
4657        let _ = std::fs::remove_dir_all(&dir);
4658        std::fs::create_dir_all(&dir).expect("tempdir");
4659        let config = dir.join("config.toml");
4660        let skills = dir.join("skills");
4661        let file = dir.join("harnesses.toml");
4662        std::fs::write(
4663            &file,
4664            format!(
4665                "[[harness]]\nname = \"r\"\nconfig = {config:?}\nmarker = \"[mcp_servers.ljos]\"\n\
4666                 snippet = \"\\n[mcp_servers.ljos]\\ncommand = \\\"{{server}}\\\"\\n\"\nskills = {skills:?}\n",
4667                config = config.display().to_string(),
4668                skills = skills.display().to_string(),
4669            ),
4670        )
4671        .expect("write");
4672
4673        let refused = super::onboard_from(&file, "nobody", true)
4674            .unwrap_err()
4675            .to_string();
4676        assert!(
4677            refused.contains("no runner \"nobody\"") && refused.contains("names r"),
4678            "{refused}"
4679        );
4680
4681        let steps = match super::onboard_from(&file, "r", true) {
4682            Ok(steps) => steps,
4683            // Without ljos-mcp on PATH there is nothing to register; the
4684            // refusal says so and the rest of the check needs the binary.
4685            Err(e) => {
4686                assert!(e.to_string().contains("ljos-mcp not on PATH"), "{e}");
4687                return;
4688            }
4689        };
4690        assert!(steps.iter().all(|s| s.ok), "{steps:?}");
4691        assert!(
4692            steps[0].detail.starts_with("would append"),
4693            "{}",
4694            steps[0].detail
4695        );
4696        assert!(!config.exists() && !skills.exists(), "a dry run wrote");
4697
4698        let steps = super::onboard_from(&file, "r", false).expect("onboards");
4699        assert!(steps.iter().all(|s| s.ok), "{steps:?}");
4700        let written = std::fs::read_to_string(&config).expect("config written");
4701        assert_eq!(written.matches("[mcp_servers.ljos]").count(), 1);
4702        assert!(written.contains("ljos-mcp"), "{written}");
4703        let skill = std::fs::read_to_string(skills.join("ljos/SKILL.md")).expect("skill written");
4704        assert!(skill.starts_with("---\nname: ljos\n"));
4705        assert!(skill.contains("## Before the work"));
4706
4707        let again = super::onboard_from(&file, "r", false).expect("onboards again");
4708        assert_eq!(again[0].detail, "ljos registered");
4709        assert!(
4710            again[1].detail.ends_with("is current"),
4711            "{}",
4712            again[1].detail
4713        );
4714        assert_eq!(
4715            std::fs::read_to_string(&config)
4716                .expect("config")
4717                .matches("[mcp_servers.ljos]")
4718                .count(),
4719            1,
4720            "the entry was appended twice"
4721        );
4722        let _ = std::fs::remove_dir_all(&dir);
4723    }
4724
4725    use super::*;
4726    use std::io::{Read, Write};
4727    use std::net::TcpListener;
4728    use std::sync::{Arc, Mutex};
4729
4730    /// A non-zero exit is an error carrying what was said on stderr.
4731    #[test]
4732    fn a_refusal_is_an_error_not_an_answer() {
4733        let err = run_captured("false", &[] as &[&str]).unwrap_err();
4734        assert!(err.to_string().contains("false exited"), "{err}");
4735        let said = run_captured("sh", &["-c", "echo answered; echo aside >&2"]).unwrap();
4736        assert_eq!(said.stdout.trim(), "answered");
4737        assert_eq!(said.stderr.trim(), "aside");
4738        let said = run_captured("sh", &["-c", "echo reason >&2; exit 3"]).unwrap_err();
4739        assert!(said.to_string().contains("reason"), "{said}");
4740    }
4741
4742    #[test]
4743    fn join_keeps_spaces() {
4744        assert_eq!(
4745            join(&["the default fuse".into(), "is CombMNZ".into()]),
4746            "the default fuse is CombMNZ"
4747        );
4748    }
4749
4750    #[test]
4751    fn remember_is_lesson_prefer_is_preference() {
4752        assert_eq!(atom_kind("Remember").unwrap(), "lesson");
4753        assert_eq!(atom_kind("Prefer").unwrap(), "preference");
4754        assert!(atom_kind("extract").is_err());
4755    }
4756
4757    #[test]
4758    fn atom_body_is_explicit_and_unextracted() {
4759        let v = atom_body("lesson", "the default fuse is CombMNZ", "ws");
4760        assert_eq!(v["schema"], "inside.atom/v1");
4761        assert_eq!(v["kind"], "lesson");
4762        assert_eq!(v["level"], "explicit");
4763        assert_eq!(v["text"], "the default fuse is CombMNZ");
4764        assert_eq!(v["workspace"], "ws");
4765        // Never harvest a transcript: the text is the claim, not a prefix parse.
4766        let raw = atom_body("lesson", "Remember: pin the review set", "ws");
4767        assert_eq!(raw["text"], "Remember: pin the review set");
4768    }
4769
4770    #[test]
4771    fn empty_claim_is_refused() {
4772        let client = PacksetClient::new("http://127.0.0.1:1");
4773        let err = post_claim(&client, "Remember", "   ", "ws").unwrap_err();
4774        assert!(err.to_string().contains("empty text"));
4775    }
4776
4777    #[test]
4778    fn cards_are_the_two_named_files_only() {
4779        assert_eq!(CARD_NAMES, &["USER.md", "MEMORY.md"]);
4780        let dir = std::env::temp_dir().join(format!("ljos-cards-{}", std::process::id()));
4781        let _ = std::fs::remove_dir_all(&dir);
4782        std::fs::create_dir_all(&dir).unwrap();
4783        std::fs::write(dir.join("USER.md"), "user card\n").unwrap();
4784        std::fs::write(dir.join("MEMORY.md"), "memory card\n").unwrap();
4785        std::fs::write(dir.join("NOTES.md"), "must not appear\n").unwrap();
4786        let out = cards(&dir).unwrap();
4787        assert!(out.contains("user card"));
4788        assert!(out.contains("memory card"));
4789        assert!(!out.contains("must not appear"));
4790        assert!(!out.contains("NOTES.md"));
4791        let _ = std::fs::remove_dir_all(&dir);
4792    }
4793
4794    #[test]
4795    fn policy_prints_argv_and_does_not_reload() {
4796        assert!(policy_line(&[]).is_err());
4797        assert_eq!(policy_line(&["ls".into(), "-la".into()]).unwrap(), "ls -la");
4798        let note = POLICY_TCB.to_ascii_lowercase();
4799        assert!(note.contains("ljos-policyd"));
4800        assert!(note.contains("not a check"));
4801        assert!(!note.contains("grokos policy reload"));
4802        assert!(!note.contains("policy reload"));
4803    }
4804
4805    #[test]
4806    fn consensus_is_ljos_then_vissue() {
4807        let steps = consensus_steps("vissue-1a5a", true, true, &[]).unwrap();
4808        assert_eq!(steps.len(), 2);
4809        assert_eq!(steps[0].bin, "ljos-consensus");
4810        assert_eq!(steps[0].args, vec!["settle", "--issue", "vissue-1a5a"]);
4811        assert_eq!(steps[1].bin, "vissue");
4812        assert_eq!(steps[1].args, vec!["consensus", "vissue-1a5a"]);
4813    }
4814
4815    #[test]
4816    fn consensus_carries_the_packs_trust() {
4817        let rows = vec![row("a", "b", 0.5)];
4818        let steps = consensus_steps("id", true, true, &rows).unwrap();
4819        assert_eq!(steps[0].args[3], "--trust");
4820        assert_eq!(steps[0].args[4], r#"[["a","b",0.5]]"#);
4821        assert_eq!(
4822            steps[1].args,
4823            vec!["consensus", "id", "--trust", r#"[["a","b",0.5]]"#]
4824        );
4825    }
4826
4827    #[test]
4828    fn consensus_skips_a_missing_bin() {
4829        let only_v = consensus_steps("id", false, true, &[]).unwrap();
4830        assert_eq!(only_v.len(), 1);
4831        assert_eq!(only_v[0].bin, "vissue");
4832        let only_l = consensus_steps("id", true, false, &[]).unwrap();
4833        assert_eq!(only_l[0].bin, "ljos-consensus");
4834        assert!(consensus_steps("id", false, false, &[]).is_err());
4835    }
4836
4837    fn row(from: &str, to: &str, weight: f64) -> Trust {
4838        Trust {
4839            about: Vec::new(),
4840            from: from.into(),
4841            to: to.into(),
4842            weight,
4843        }
4844    }
4845
4846    #[test]
4847    fn a_trust_atom_is_one_edge_with_its_evidence() {
4848        let atom = trust_atom(&row("a", "b", 0.25), &["deed-x-y".into()], "ws").unwrap();
4849        assert_eq!(atom["kind"], "trust");
4850        assert_eq!(atom["from"], "a");
4851        assert_eq!(atom["to"], "b");
4852        assert_eq!(atom["weight"], 0.25);
4853        assert_eq!(atom["entities"], serde_json::json!(["deed-x-y"]));
4854        assert_eq!(atom["text"], "a weighs b at 0.250.");
4855        assert!(trust_atom(&row("a", "a", 0.5), &[], "ws").is_err());
4856        assert!(trust_atom(&row("a", "b", 0.0), &[], "ws").is_err());
4857        assert!(trust_atom(&row("a", "b", 1.5), &[], "ws").is_err());
4858        assert!(trust_atom(&row("", "b", 0.5), &[], "ws").is_err());
4859    }
4860
4861    #[test]
4862    fn the_latest_row_per_pair_wins() {
4863        let atoms = vec![
4864            serde_json::json!({"kind": "trust", "from": "a", "to": "b", "weight": 0.9, "ts": "2026-01-01T00:00:00Z"}),
4865            serde_json::json!({"kind": "trust", "from": "a", "to": "b", "weight": 0.3, "ts": "2026-02-01T00:00:00Z"}),
4866            serde_json::json!({"kind": "trust", "from": "b", "to": "a", "weight": 0.7}),
4867            serde_json::json!({"kind": "lesson", "text": "not a row"}),
4868            serde_json::json!({"kind": "trust", "from": "b", "weight": 0.7}),
4869        ];
4870        let rows = trust_rows(&atoms);
4871        assert_eq!(rows, vec![row("a", "b", 0.3), row("b", "a", 0.7)]);
4872        assert_eq!(trust_json(&rows), r#"[["a","b",0.3],["b","a",0.7]]"#);
4873    }
4874
4875    #[test]
4876    fn ballots_are_agent_and_choice() {
4877        let rows =
4878            ballots_from_json(r#"[{"agent":"a","choice":"ship","stamp":"[2026-01-01]"}]"#).unwrap();
4879        assert_eq!(rows, vec![("a".to_string(), "ship".to_string())]);
4880        assert!(ballots_from_json(r#"[{"agent":"a"}]"#).is_err());
4881        assert!(ballots_from_json("{}").is_err());
4882    }
4883
4884    /// A refuted voter loses weight in every other voter's row; a vindicated
4885    /// one keeps it; the rows come back complete.
4886    #[test]
4887    fn learning_downweights_the_refuted_voter() {
4888        let ballots = vec![
4889            ("a".to_string(), "ship".to_string()),
4890            ("b".to_string(), "ship".to_string()),
4891            ("c".to_string(), "hold".to_string()),
4892        ];
4893        let rows = learn(&ballots, "ship", &[], 0.5).unwrap();
4894        assert_eq!(rows.len(), 6);
4895        let w = |from: &str, to: &str| {
4896            rows.iter()
4897                .find(|r| r.from == from && r.to == to)
4898                .unwrap()
4899                .weight
4900        };
4901        assert_eq!(w("a", "b"), 1.0);
4902        assert_eq!(w("a", "c"), 0.5);
4903        assert_eq!(w("b", "c"), 0.5);
4904        assert_eq!(w("c", "a"), 1.0);
4905
4906        let again = learn(&ballots, "ship", &rows, 0.5).unwrap();
4907        let w2 = |from: &str, to: &str| {
4908            again
4909                .iter()
4910                .find(|r| r.from == from && r.to == to)
4911                .unwrap()
4912                .weight
4913        };
4914        assert_eq!(w2("a", "c"), 0.25);
4915        assert_eq!(w2("a", "b"), 1.0);
4916
4917        let floored = learn(&ballots, "ship", &[row("a", "c", 0.015)], 0.5).unwrap();
4918        let low = floored
4919            .iter()
4920            .find(|r| r.from == "a" && r.to == "c")
4921            .unwrap();
4922        assert_eq!(low.weight, TRUST_FLOOR);
4923
4924        assert!(learn(&ballots, "ship", &[], 1.0).is_err());
4925        assert!(learn(&ballots, "  ", &[], 0.5).is_err());
4926        assert!(learn(&ballots[..1], "ship", &[], 0.5).is_err());
4927
4928        // A fixed share of recovery: the refuted row moves back toward one
4929        // by the share of the gap, the vindicated row stays at one.
4930        let shared = learn_shared(&ballots, "ship", &rows, 0.5, &[], 0.1).unwrap();
4931        let w3 = |from: &str, to: &str| {
4932            shared
4933                .iter()
4934                .find(|r| r.from == from && r.to == to)
4935                .unwrap()
4936                .weight
4937        };
4938        assert!((w3("a", "c") - (0.25 + 0.75 * 0.1)).abs() < 1e-12);
4939        assert_eq!(w3("a", "b"), 1.0);
4940        assert!(learn_shared(&ballots, "ship", &[], 0.5, &[], 1.0).is_err());
4941    }
4942
4943    #[test]
4944    fn a_name_is_one_work_id_and_hex_passes_through() {
4945        let a = work_id("demo-riml");
4946        assert_eq!(a.len(), 32);
4947        assert!(a.bytes().all(|b| b.is_ascii_hexdigit()));
4948        assert_eq!(a, work_id(" demo-riml "));
4949        assert_ne!(a, work_id("demo-rimm"));
4950        assert_eq!(work_id(&a.to_ascii_uppercase()), a);
4951        assert_ne!(work_id("seat"), work_id("reader"));
4952    }
4953
4954    #[test]
4955    fn an_island_prints_one_memory_a_line() {
4956        let body = serde_json::json!({"island": [
4957            {"id": "a", "text": "one", "activation": 1.0, "seed": true, "ts": now_utc()},
4958            {"id": "b", "text": "two", "activation": 0.25, "seed": false}
4959        ]});
4960        assert_eq!(
4961            format_island(&body),
4962            "1.000\tseed\ta\ttoday\tone\n0.250\t    \tb\t\ttwo\n"
4963        );
4964        assert!(format_island(&serde_json::json!({})).is_empty());
4965    }
4966
4967    #[test]
4968    fn a_fed_verb_reads_its_stdin() {
4969        let said = run_fed("cat", &[] as &[&str], "one\ntwo\n").unwrap();
4970        assert_eq!(said.stdout, "one\ntwo\n");
4971        assert!(run_fed("sh", &["-c", "exit 2"], "").is_err());
4972    }
4973
4974    #[test]
4975    fn needs_and_cited_are_enclosed_once_each() {
4976        let needs = needs_of(r#"{"needs":["deed-b-2","deed-a-1"],"other":1}"#).unwrap();
4977        assert_eq!(needs, vec!["deed-b-2", "deed-a-1"]);
4978        assert_eq!(
4979            enclose(needs, "deed-a-1\n\ndeed-c-3\n"),
4980            vec!["deed-a-1", "deed-b-2", "deed-c-3"]
4981        );
4982        assert!(needs_of("{}").unwrap().is_empty());
4983        assert!(needs_of("not json").is_err());
4984    }
4985
4986    #[test]
4987    fn due_is_the_past_soonest_first() {
4988        let atoms = vec![
4989            serde_json::json!({"id": "late", "due_at": "2026-02-01T00:00:00.000Z"}),
4990            serde_json::json!({"id": "later", "due_at": "2026-03-01T00:00:00.000Z"}),
4991            serde_json::json!({"id": "future", "due_at": "2099-01-01T00:00:00.000Z"}),
4992            serde_json::json!({"id": "never"}),
4993            serde_json::json!({"id": "blank", "due_at": ""}),
4994        ];
4995        let due = due_of(&atoms, "2026-06-01T00:00:00.000Z");
4996        let ids: Vec<&str> = due.iter().map(|a| a["id"].as_str().unwrap()).collect();
4997        // A claim that never entered the clock is due now, ahead of the
4998        // past-due ones; the future one waits.
4999        assert_eq!(ids, ["never", "blank", "late", "later"]);
5000        assert!(now_utc().ends_with(".000Z"));
5001        assert!(now_utc().as_str() > "2026-01-01T00:00:00.000Z");
5002    }
5003
5004    #[test]
5005    fn the_doctor_names_every_habitat_and_the_pack_gates_health() {
5006        let rows = doctor();
5007        let names: Vec<&str> = rows.iter().map(|h| h.name).collect();
5008        for want in [
5009            "vissue",
5010            "deedar",
5011            "packset",
5012            "pack",
5013            "host key",
5014            "deed store",
5015            "tracker",
5016        ] {
5017            assert!(names.contains(&want), "{names:?}");
5018        }
5019        let table = format_doctor(&rows);
5020        assert_eq!(table.lines().count(), rows.len());
5021        let sick = vec![Habitat {
5022            name: "pack",
5023            state: "PACKSET_URL unset".into(),
5024            ok: false,
5025        }];
5026        assert!(!healthy(&sick));
5027        let fine = vec![Habitat {
5028            name: "claimdag",
5029            state: "not on PATH".into(),
5030            ok: false,
5031        }];
5032        assert!(healthy(&fine));
5033    }
5034
5035    #[test]
5036    fn enclosed_atoms_are_read_from_every_jsonl_in_the_bag() {
5037        let dir = std::env::temp_dir().join(format!("ljos-bag-{}", std::process::id()));
5038        let _ = std::fs::remove_dir_all(&dir);
5039        let atoms = dir.join("data").join("atoms");
5040        std::fs::create_dir_all(&atoms).unwrap();
5041        std::fs::write(
5042            atoms.join("a.jsonl"),
5043            "{\"kind\":\"lesson\",\"text\":\"one\"}\n\n{\"kind\":\"trust\",\"from\":\"a\",\"to\":\"b\",\"weight\":0.5}\n",
5044        )
5045        .unwrap();
5046        std::fs::write(
5047            atoms.join("b.jsonl"),
5048            "{\"kind\":\"preference\",\"text\":\"two\"}\n",
5049        )
5050        .unwrap();
5051        let read = enclosed_atoms(&dir).unwrap();
5052        assert_eq!(read.len(), 3);
5053        assert_eq!(trust_rows(&read).len(), 1);
5054        assert!(enclosed_atoms(&dir.join("nowhere")).unwrap().is_empty());
5055        std::fs::write(atoms.join("c.jsonl"), "not json\n").unwrap();
5056        assert!(enclosed_atoms(&dir).is_err());
5057        let _ = std::fs::remove_dir_all(&dir);
5058
5059        let table = format_due(&[serde_json::json!({
5060            "id": "x", "kind": "lesson", "text": "t", "due_at": "2026-01-01T00:00:00.000Z"
5061        })]);
5062        assert_eq!(table, "2026-01-01T00:00:00.000Z\tlesson\tx\tt\n");
5063    }
5064
5065    fn read_http(s: &mut impl Read) -> String {
5066        let mut buf = Vec::new();
5067        let mut tmp = [0u8; 1024];
5068        loop {
5069            let n = s.read(&mut tmp).unwrap_or(0);
5070            if n == 0 {
5071                break;
5072            }
5073            buf.extend_from_slice(&tmp[..n]);
5074            if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
5075                let headers = &buf[..at];
5076                let mut need = 0usize;
5077                for line in headers.split(|b| *b == b'\n') {
5078                    let line = std::str::from_utf8(line).unwrap_or("").trim();
5079                    if let Some(v) = line
5080                        .split_once(':')
5081                        .filter(|(k, _)| k.eq_ignore_ascii_case("content-length"))
5082                        .map(|(_, v)| v.trim())
5083                    {
5084                        need = v.parse().unwrap_or(0);
5085                    }
5086                }
5087                let have = buf.len().saturating_sub(at + 4);
5088                if have >= need {
5089                    break;
5090                }
5091            }
5092        }
5093        String::from_utf8_lossy(&buf).into_owned()
5094    }
5095
5096    fn serve_capture() -> (String, Arc<Mutex<String>>) {
5097        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
5098        let addr = listener.local_addr().unwrap();
5099        let captured = Arc::new(Mutex::new(String::new()));
5100        let slot = captured.clone();
5101        std::thread::spawn(move || {
5102            if let Ok((mut s, _)) = listener.accept() {
5103                *slot.lock().unwrap() = read_http(&mut s);
5104                let body =
5105                    r#"{"id":"atom-1","kind":"lesson","text":"the default fuse is CombMNZ"}"#;
5106                let resp = format!(
5107                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
5108                    body.len()
5109                );
5110                let _ = s.write_all(resp.as_bytes());
5111            }
5112        });
5113        (format!("http://{addr}"), captured)
5114    }
5115
5116    #[test]
5117    fn remember_posts_v1_atoms() {
5118        let (url, captured) = serve_capture();
5119        let client = PacksetClient::new(&url);
5120        let body = post_claim(&client, "Remember", "the default fuse is CombMNZ", "ws").unwrap();
5121        assert_eq!(body["id"], "atom-1");
5122        let req = captured.lock().unwrap().clone();
5123        assert!(req.contains("POST"), "{req}");
5124        assert!(req.contains("/v1/atoms"), "{req}");
5125        assert!(req.contains("\"kind\":\"lesson\""), "{req}");
5126        assert!(req.contains("the default fuse is CombMNZ"), "{req}");
5127        assert!(req.contains("\"level\":\"explicit\""), "{req}");
5128        assert!(!req.contains("extract"), "{req}");
5129    }
5130
5131    #[test]
5132    fn forget_posts_the_id_and_workspace() {
5133        let (url, captured) = serve_capture();
5134        let client = PacksetClient::new(&url);
5135        let body = client.delete_atom("ws", "atom-1", None).unwrap();
5136        assert_eq!(body["id"], "atom-1");
5137        let req = captured.lock().unwrap().clone();
5138        assert!(req.contains("POST"), "{req}");
5139        assert!(req.contains("/v1/atoms/delete"), "{req}");
5140        assert!(req.contains("\"id\":\"atom-1\""), "{req}");
5141        assert!(req.contains("\"workspace\":\"ws\""), "{req}");
5142        // No deed named, no field: the pack should not have to tell an absent
5143        // citation from an empty one.
5144        assert!(!req.contains("\"why\""), "{req}");
5145    }
5146
5147    /// The deed rides with the retraction, so the pack can write it onto the
5148    /// tombstone in the same step the atom leaves the live set.
5149    #[test]
5150    fn forget_carries_the_deed_that_withdrew_the_claim() {
5151        let (url, captured) = serve_capture();
5152        let client = PacksetClient::new(&url);
5153        client
5154            .delete_atom("ws", "atom-1", Some("deed-patch-overlay"))
5155            .unwrap();
5156        let req = captured.lock().unwrap().clone();
5157        assert!(req.contains("\"why\":\"deed-patch-overlay\""), "{req}");
5158    }
5159
5160    /// An id is the whole of the request, so an empty one is a mistake worth
5161    /// naming rather than a delete of whatever the server decides that means.
5162    #[test]
5163    fn forget_refuses_an_empty_id() {
5164        let err = packset_forget("   ", None).unwrap_err();
5165        assert!(err.to_string().contains("atom id is required"), "{err}");
5166    }
5167}