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