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 claim.
3345/// One verb, so the loop that makes the seat a memory runs every time and
3346/// not only when somebody remembers to run it.
3347///
3348/// # Errors
3349///
3350/// A required habitat down, or the claim refused (the refusal names what
3351/// the assignee still holds).
3352pub fn sitting(issue: &str, assignee: &str, cards_dir: &Path) -> Result<String> {
3353    let mut out = String::new();
3354    let rows = doctor_seat();
3355    out.push_str("== doctor\n");
3356    out.push_str(&format_doctor(&rows));
3357    if !healthy(&rows) {
3358        bail!("{out}sitting: a required habitat does not answer; nothing was claimed");
3359    }
3360    out.push_str("== cards\n");
3361    out.push_str(&cards(cards_dir)?);
3362    out.push_str("== due\n");
3363    out.push_str(&due_report()?);
3364    let title = issue_title(issue)?;
3365    out.push_str(&format!("== island: {title}\n"));
3366    // The strongest eight: a sitting wants orientation, not the whole
3367    // cluster; `ljos island` prints it all.
3368    let island = packset_island(&title, false)?;
3369    let mut top = island.clone();
3370    if let Some(rows) = top["island"].as_array_mut() {
3371        rows.truncate(8);
3372    }
3373    out.push_str(&format_island(&top));
3374    out.push_str("== recall\n");
3375    out.push_str(&run_captured("vissue", &["recall", issue])?.stdout);
3376    // The last twelve dated events across the three stores; `ljos
3377    // timeline` prints them all.
3378    out.push_str("== timeline\n");
3379    out.push_str(&timeline(issue, 12)?);
3380    out.push_str("== claim\n");
3381    out.push_str(&claim(issue, assignee)?);
3382    Ok(out)
3383}
3384
3385/// Close a sitting: remember the lesson when there is one, fire the island
3386/// the issue's title activates, complete the session node, and learn from
3387/// the outcome when one is named. Without a lesson the report says so,
3388/// because a sitting that taught nothing worth two sentences is rare and
3389/// worth noticing.
3390///
3391/// # Errors
3392///
3393/// Any habitat refusing; the pack refuses a lesson longer than two
3394/// sentences, the claim graph a status that is not terminal.
3395pub fn finish(
3396    issue: &str,
3397    status: &str,
3398    lesson: Option<&str>,
3399    outcome: Option<&str>,
3400    beta: f64,
3401) -> Result<String> {
3402    let mut out = String::new();
3403    match lesson.map(str::trim).filter(|l| !l.is_empty()) {
3404        Some(text) => {
3405            let body = packset_write("Remember", text)?;
3406            out.push_str(&format!(
3407                "remembered {}{}\n",
3408                body.get("id").and_then(Value::as_str).unwrap_or("-"),
3409                revision_note(&body)
3410            ));
3411        }
3412        None => out.push_str(
3413            "no lesson remembered this sitting; `ljos remember` takes one in two sentences\n",
3414        ),
3415    }
3416    let title = issue_title(issue)?;
3417    let island = packset_island(&title, true)?;
3418    if island["weak"].as_bool().unwrap_or(false) {
3419        out.push_str(&format!(
3420            "did not fire the island for {title:?}: its seeds are hits no two scorers agreed on{}; wiring them would tighten the wrong links\n",
3421            if island["dense"].as_bool().unwrap_or(true) { "" } else { " (the encoder is down, ranking is lexical only)" }
3422        ));
3423    } else {
3424        let fired = island["island"].as_array().map_or(0, Vec::len);
3425        out.push_str(&format!(
3426            "fired the island for {title:?}: {fired} memories\n"
3427        ));
3428    }
3429    let terminal = ["done", "failed", "cancelled"];
3430    if !terminal.contains(&status) {
3431        bail!("finish: status {status:?} is not one of done, failed, cancelled");
3432    }
3433    run_captured(
3434        "claimdag",
3435        &["complete", &node_for(issue)?, "--status", status],
3436    )?;
3437    out.push_str(&format!(
3438        "completed the session node for {issue} as {status}\n"
3439    ));
3440    if let Some(option) = outcome.map(str::trim).filter(|o| !o.is_empty()) {
3441        let said = run_captured("vissue", &["vote", issue, "--json"])?;
3442        let ballots = ballots_from_json(&said.stdout)?;
3443        if ballots.len() < 2 {
3444            out.push_str("outcome named but fewer than two ballots; nothing to learn from\n");
3445        } else {
3446            let about = island_entities(issue).unwrap_or_default();
3447            let (rows, moved) = learn_and_write(&ballots, option, beta, &about)?;
3448            out.push_str(&format!(
3449                "learned from outcome {option:?}: {} trust rows rewritten, {} persona anchors moved\n",
3450                rows.len(),
3451                moved.len()
3452            ));
3453        }
3454    }
3455    out.push_str(&format!(
3456        "the ticket stays {issue}'s state; `vissue update {issue} -s DONE` closes it\n"
3457    ));
3458    Ok(out)
3459}
3460
3461/// The weight a voter of estimated accuracy `p` earns: the log odds
3462/// `ln(p / (1 - p))`, the optimal weight for independent voters on a
3463/// two-way choice (Nitzan and Paroush, doi:10.2307/2526438; a weighted
3464/// majority under these weights is the maximum-likelihood decision), with
3465/// `p` held inside `[0.01, 0.99]` so a perfect record does not become an
3466/// infinite vote, and a voter at or under chance at [`TRUST_FLOOR`]. The
3467/// weights are scaled so the most reliable voter stands at one, which is
3468/// the scale the trust rows live on; the ratios between voters are the
3469/// rule's.
3470#[must_use]
3471pub fn calibration_weights(accuracy: &[(String, f64)]) -> Vec<(String, f64)> {
3472    let logit = |p: f64| {
3473        let p = p.clamp(0.01, 0.99);
3474        (p / (1.0 - p)).ln()
3475    };
3476    let raw: Vec<(String, f64)> = accuracy
3477        .iter()
3478        .map(|(who, p)| (who.clone(), logit(*p).max(0.0)))
3479        .collect();
3480    let top = raw.iter().map(|(_, w)| *w).fold(0.0_f64, f64::max);
3481    raw.into_iter()
3482        .map(|(who, w)| {
3483            let scaled = if top > 0.0 { w / top } else { 0.0 };
3484            (who, scaled.clamp(TRUST_FLOOR, 1.0))
3485        })
3486        .collect()
3487}
3488
3489/// Turn a project's voting history into trust rows without anyone naming
3490/// an outcome: Dawid and Skene's accuracy per voter
3491/// (doi:10.2307/2346806), from `ljos-consensus reliability`, turned into
3492/// the weight every other voter gives that voter by
3493/// [`calibration_weights`]: log odds, so a voter right nine times in ten
3494/// outweighs one right six times in ten by five to one, not three to two.
3495/// Rows are complete and floored at [`TRUST_FLOOR`], so the settle sees
3496/// the whole graph.
3497///
3498/// # Errors
3499///
3500/// No issue with two or more ballots, the consensus binary absent, or the
3501/// pack refusing a row.
3502pub fn calibrate(project: &str, rounds: usize) -> Result<Vec<Trust>> {
3503    let said = run_captured(
3504        "ljos-consensus",
3505        &[
3506            "reliability",
3507            "--project",
3508            project,
3509            "--rounds",
3510            &rounds.to_string(),
3511        ],
3512    )?;
3513    let v: Value = serde_json::from_str(&said.stdout).context("reliability: not JSON")?;
3514    let accuracy = v
3515        .get("accuracy")
3516        .and_then(Value::as_object)
3517        .context("reliability: no accuracy object")?;
3518    let mut voters: Vec<(String, f64)> = accuracy
3519        .iter()
3520        .filter_map(|(k, val)| val.as_f64().map(|a| (k.clone(), a)))
3521        .collect();
3522    voters.sort_by(|a, b| a.0.cmp(&b.0));
3523    if voters.len() < 2 {
3524        bail!("calibrate: fewer than two voters in {project}");
3525    }
3526    let weights = calibration_weights(&voters);
3527    let mut rows = Vec::new();
3528    for (from, _) in &voters {
3529        for (to, weight) in &weights {
3530            if from == to {
3531                continue;
3532            }
3533            rows.push(Trust {
3534                from: from.clone(),
3535                to: to.clone(),
3536                weight: *weight,
3537                about: Vec::new(),
3538            });
3539        }
3540    }
3541    for row in &rows {
3542        write_trust(row, &[])?;
3543    }
3544    Ok(rows)
3545}
3546
3547/// One line per hit: score, how many scorers named it out of how many
3548/// ran, kind, id, age, text. The age is the one column a reader needs to
3549/// lay the hits on a timeline; the count is what the hook keys on.
3550pub fn format_hits(hits: &[Hit]) -> String {
3551    let now = now_utc();
3552    let mut out = String::new();
3553    for h in hits {
3554        let id = h.id.as_deref().unwrap_or("-");
3555        let named = match (h.ballots, h.of) {
3556            (Some(b), Some(of)) => format!("{b}/{of}"),
3557            _ => "-".to_string(),
3558        };
3559        out.push_str(&format!(
3560            "{:.4}\t{}\t{}\t{}\t{}\t{}\n",
3561            h.score,
3562            named,
3563            h.kind,
3564            id,
3565            age_of(h.ts.as_deref(), &now),
3566            h.text
3567        ));
3568    }
3569    out
3570}
3571
3572/// The line a hit takes in injected context and in a brief: kind and age
3573/// in the bracket, then the text.
3574fn hit_line(h: &Hit, now: &str) -> String {
3575    format!(
3576        "- [{}{}] {}",
3577        if h.kind.is_empty() { "claim" } else { &h.kind },
3578        age_tag(h.ts.as_deref(), now),
3579        h.text.trim()
3580    )
3581}
3582
3583/// `, N days ago` for a bracket, empty when the stamp is missing.
3584fn age_tag(ts: Option<&str>, now: &str) -> String {
3585    let age = age_of(ts, now);
3586    if age.is_empty() {
3587        age
3588    } else {
3589        format!(", {age}")
3590    }
3591}
3592
3593/// How long ago a stamp was, in words a reader can place: `today`,
3594/// `yesterday`, `N days ago`, then weeks, months and years once the count
3595/// stops fitting the smaller unit. Empty when the stamp is missing or
3596/// unreadable, `in N days` for a stamp ahead of `now`.
3597#[must_use]
3598pub fn age_of(ts: Option<&str>, now: &str) -> String {
3599    let (Some(then), Some(today)) = (days_of_stamp(ts), days_of_stamp(Some(now))) else {
3600        return String::new();
3601    };
3602    let days = today - then;
3603    match days {
3604        d if d < 0 => format!("in {} day{}", -d, if d == -1 { "" } else { "s" }),
3605        0 => "today".into(),
3606        1 => "yesterday".into(),
3607        d if d < 14 => format!("{d} days ago"),
3608        d if d < 61 => format!("{} weeks ago", d / 7),
3609        d if d < 730 => format!("{} months ago", d / 30),
3610        d => format!("{} years ago", d / 365),
3611    }
3612}
3613
3614/// Days since the epoch of an RFC 3339 stamp's date, or none when the
3615/// first ten characters do not read as `YYYY-MM-DD`.
3616fn days_of_stamp(ts: Option<&str>) -> Option<i64> {
3617    let ts = ts?;
3618    let date = ts.get(..10)?;
3619    let mut it = date.split('-');
3620    let y: i64 = it.next()?.parse().ok()?;
3621    let m: i64 = it.next()?.parse().ok()?;
3622    let d: i64 = it.next()?.parse().ok()?;
3623    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
3624        return None;
3625    }
3626    // Civil date to days since the epoch (Howard Hinnant's algorithm).
3627    let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
3628    let era = y.div_euclid(400);
3629    let yoe = y - era * 400;
3630    let doy = (153 * m + 2) / 5 + d - 1;
3631    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
3632    Some(era * 146_097 + doe - 719_468)
3633}
3634
3635/// Read-only cards. Only [`CARD_NAMES`], never created, never written.
3636pub fn cards(dir: &Path) -> Result<String> {
3637    let mut out = String::new();
3638    for name in CARD_NAMES {
3639        let p = dir.join(name);
3640        if p.is_file() {
3641            out.push_str(&format!("--- {} ---\n", p.display()));
3642            out.push_str(&std::fs::read_to_string(&p)?);
3643        }
3644    }
3645    Ok(out)
3646}
3647
3648pub fn policy_line(argv: &[String]) -> Result<String> {
3649    if argv.is_empty() {
3650        bail!("policy: pass the argv to check");
3651    }
3652    Ok(argv.join(" "))
3653}
3654
3655/// The argv line, then what the pack knows that bears on it: the memory a
3656/// policy layer injects beside its verdict. The line prints even when the
3657/// pack is down; the memory is the part that may be empty.
3658pub fn policy_with_memory(argv: &[String]) -> Result<String> {
3659    let line = policy_line(argv)?;
3660    let call = HookCall {
3661        event: "argv".into(),
3662        cue: line.clone(),
3663        session: None,
3664    };
3665    let context = hook_context(&call, 5);
3666    // The rules are the law's memory: a deny or an ask fires before the
3667    // context, so a reader sees the verdict first.
3668    let rules = rules_from_pack().unwrap_or_default();
3669    let ruled = hook_output_ruled(&call, &context, verdict_for(&rules, &line));
3670    match tcb_check(argv) {
3671        Some(tcb) if !tcb.is_empty() => Ok(format!("{line}\n{tcb}\n{ruled}")),
3672        _ => Ok(format!("{line}\n{ruled}")),
3673    }
3674}
3675
3676/// `POLICYD_BIN`, else `ljos-policyd` on PATH.
3677pub fn policyd_bin() -> Option<std::path::PathBuf> {
3678    std::env::var_os("POLICYD_BIN")
3679        .filter(|s| !s.is_empty())
3680        .map(std::path::PathBuf::from)
3681        .or_else(|| which::which("ljos-policyd").ok())
3682}
3683
3684/// One line from `ljos-policyd check -- argv`. None if the binary is absent
3685/// or failed to start. Absence is not a deny.
3686pub fn tcb_check(argv: &[String]) -> Option<String> {
3687    let bin = policyd_bin()?;
3688    let out = std::process::Command::new(bin)
3689        .arg("check")
3690        .arg("--")
3691        .args(argv)
3692        .output()
3693        .ok()?;
3694    let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
3695    (!text.is_empty()).then_some(text)
3696}
3697
3698#[derive(Debug, Clone, PartialEq, Eq)]
3699pub struct ConsensusStep {
3700    pub bin: &'static str,
3701    pub args: Vec<String>,
3702}
3703
3704/// `ljos-consensus` first, then `vissue consensus`, both under the pack's
3705/// trust rows when there are any. Missing bins are skipped.
3706pub fn consensus_steps(
3707    id: &str,
3708    have_ljos: bool,
3709    have_vissue: bool,
3710    trust: &[Trust],
3711) -> Result<Vec<ConsensusStep>> {
3712    consensus_steps_anchored(id, have_ljos, have_vissue, trust, &[])
3713}
3714
3715/// The tag on an issue that asks for bounded confidence: a panel for a
3716/// broad audience is allowed to settle into clusters, and the settle says
3717/// how far apart they are, where a single-position model would average
3718/// them away. Without it the anchored model runs.
3719pub const BROAD_TAG: &str = "broad";
3720
3721/// The confidence bound a `broad` issue settles under: voters within this
3722/// L1 distance of each other's opinion listen to each other.
3723pub const BROAD_EPSILON: f64 = 1.0;
3724
3725/// The model flags an issue's tags ask for, beside the rows and anchors.
3726/// The kind of work sets the dynamics: `broad` runs bounded confidence.
3727#[must_use]
3728pub fn settle_flags_for(tags: &[String]) -> Vec<String> {
3729    if tags.iter().any(|t| t == BROAD_TAG) {
3730        vec!["--epsilon".into(), BROAD_EPSILON.to_string()]
3731    } else {
3732        Vec::new()
3733    }
3734}
3735
3736/// [`consensus_steps_anchored`] with the model flags the issue's tags ask
3737/// for on the model crate's settle.
3738pub fn consensus_steps_for(
3739    id: &str,
3740    have_ljos: bool,
3741    have_vissue: bool,
3742    trust: &[Trust],
3743    personas: &[Persona],
3744    tags: &[String],
3745) -> Result<Vec<ConsensusStep>> {
3746    let mut steps = consensus_steps_anchored(id, have_ljos, have_vissue, trust, personas)?;
3747    let flags = settle_flags_for(tags);
3748    if !flags.is_empty() {
3749        for step in steps.iter_mut().filter(|s| s.bin == "ljos-consensus") {
3750            step.args.extend(flags.iter().cloned());
3751        }
3752    }
3753    Ok(steps)
3754}
3755
3756/// The two readings beside a settle, when the pack holds what they need:
3757/// the surprisingly popular answer when two or more voters forecast the
3758/// others (`predict`), and the EigenTrust standing of the voters when
3759/// trust rows exist. Both are the model crate's verbs.
3760pub fn panel_steps(
3761    id: &str,
3762    have_ljos: bool,
3763    trust: &[Trust],
3764    predictions: &[Prediction],
3765) -> Vec<ConsensusStep> {
3766    let mut steps = Vec::new();
3767    if !have_ljos {
3768        return steps;
3769    }
3770    if predictions.len() >= 2 {
3771        steps.push(ConsensusStep {
3772            bin: "ljos-consensus",
3773            args: vec![
3774                "surprising".into(),
3775                "--issue".into(),
3776                id.into(),
3777                "--predictions".into(),
3778                predictions_json(predictions),
3779            ],
3780        });
3781    }
3782    if !trust.is_empty() {
3783        steps.push(ConsensusStep {
3784            bin: "ljos-consensus",
3785            args: vec!["reputation".into(), "--trust".into(), trust_json(trust)],
3786        });
3787    }
3788    steps
3789}
3790
3791/// [`consensus_steps`] passing the personas' anchors to both settles as
3792/// `--susceptibility-of`, so a persona holds its ballot as much as it says.
3793pub fn consensus_steps_anchored(
3794    id: &str,
3795    have_ljos: bool,
3796    have_vissue: bool,
3797    trust: &[Trust],
3798    personas: &[Persona],
3799) -> Result<Vec<ConsensusStep>> {
3800    if !have_ljos && !have_vissue {
3801        bail!("neither ljos-consensus nor vissue is on PATH");
3802    }
3803    let mut steps = Vec::new();
3804    if have_ljos {
3805        let mut args = vec!["settle".to_string(), "--issue".into(), id.into()];
3806        if !trust.is_empty() {
3807            args.push("--trust".into());
3808            args.push(trust_json(trust));
3809        }
3810        if !personas.is_empty() {
3811            args.push("--susceptibility-of".into());
3812            args.push(anchors_json(personas));
3813        }
3814        steps.push(ConsensusStep {
3815            bin: "ljos-consensus",
3816            args,
3817        });
3818    }
3819    if have_vissue {
3820        let mut args = vec!["consensus".to_string(), id.into()];
3821        if !trust.is_empty() {
3822            args.push("--trust".into());
3823            args.push(trust_json(trust));
3824        }
3825        if !personas.is_empty() {
3826            args.push("--susceptibility-of".into());
3827            args.push(anchors_json(personas));
3828        }
3829        steps.push(ConsensusStep {
3830            bin: "vissue",
3831            args,
3832        });
3833    }
3834    Ok(steps)
3835}
3836
3837pub fn on_path(bin: &str) -> bool {
3838    which::which(bin).is_ok()
3839}
3840
3841pub fn run(bin: &str, args: &[impl AsRef<str>]) -> Result<()> {
3842    run_as(bin, args, None)
3843}
3844
3845/// The identity a ballot is cast under: the persona named, else the
3846/// runner's seat name when `LJOS_SEAT` is set, else none (the tracker's
3847/// own default, `VISSUE_AGENT` or `user@host`).
3848#[must_use]
3849pub fn identity_or_seat(identity: Option<&str>) -> Option<String> {
3850    identity
3851        .map(str::trim)
3852        .filter(|w| !w.is_empty())
3853        .map(str::to_string)
3854        .or_else(|| {
3855            std::env::var("LJOS_SEAT")
3856                .ok()
3857                .map(|v| v.trim().to_string())
3858                .filter(|v| !v.is_empty())
3859        })
3860}
3861
3862/// [`run`] with `VISSUE_AGENT` set to `identity`, so a ballot or a claim is
3863/// recorded under a persona's name rather than the seat's.
3864pub fn run_as(bin: &str, args: &[impl AsRef<str>], identity: Option<&str>) -> Result<()> {
3865    use std::process::{Command, Stdio};
3866    let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
3867    let mut cmd = Command::new(path);
3868    if let Some(who) = identity_or_seat(identity) {
3869        cmd.env("VISSUE_AGENT", who);
3870    }
3871    for a in args {
3872        cmd.arg(a.as_ref());
3873    }
3874    let st = cmd
3875        .stdin(Stdio::inherit())
3876        .stdout(Stdio::inherit())
3877        .stderr(Stdio::inherit())
3878        .status()?;
3879    // A child that died of a closed pipe was cut off by our own reader
3880    // going away (`ljos consensus ID | head`); that is not the habitat
3881    // refusing.
3882    #[cfg(unix)]
3883    {
3884        use std::os::unix::process::ExitStatusExt;
3885        if st.signal() == Some(libc::SIGPIPE) {
3886            return Ok(());
3887        }
3888    }
3889    if !st.success() {
3890        bail!("{bin} exited {st}");
3891    }
3892    Ok(())
3893}
3894
3895/// What a habitat printed, kept for a caller that has to hand it on. A
3896/// non-zero exit is an error carrying stderr.
3897#[derive(Debug, Clone, PartialEq, Eq)]
3898pub struct Said {
3899    pub stdout: String,
3900    pub stderr: String,
3901}
3902
3903pub fn run_captured(bin: &str, args: &[impl AsRef<str>]) -> Result<Said> {
3904    use std::process::{Command, Stdio};
3905    let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
3906    let mut cmd = Command::new(path);
3907    for a in args {
3908        cmd.arg(a.as_ref());
3909    }
3910    let out = cmd
3911        .stdin(Stdio::null())
3912        .stdout(Stdio::piped())
3913        .stderr(Stdio::piped())
3914        .output()
3915        .with_context(|| format!("{bin}: could not start"))?;
3916    let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
3917    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
3918    if !out.status.success() {
3919        let why = if stderr.trim().is_empty() {
3920            stdout.trim().to_string()
3921        } else {
3922            stderr.trim().to_string()
3923        };
3924        bail!("{bin} exited {}: {why}", out.status);
3925    }
3926    Ok(Said { stdout, stderr })
3927}
3928
3929pub fn card_paths(dir: &Path) -> Vec<PathBuf> {
3930    CARD_NAMES.iter().map(|n| dir.join(n)).collect()
3931}
3932
3933#[cfg(test)]
3934mod tests {
3935    #[test]
3936    fn the_record_weighs_a_voter_by_what_it_got_right() {
3937        let ballots = vec![
3938            ("a".to_string(), "ship".to_string()),
3939            ("b".to_string(), "ship".to_string()),
3940            ("c".to_string(), "hold".to_string()),
3941        ];
3942        let (rows, records) =
3943            learn_record(&ballots, "ship", &std::collections::BTreeMap::new(), &[]).unwrap();
3944        assert_eq!(records["a"], (1.0, 0.0));
3945        assert_eq!(records["c"], (0.0, 1.0));
3946        let w = |to: &str| rows.iter().find(|r| r.to == to).unwrap().weight;
3947        assert_eq!(w("a"), 1.0, "a right voter stands at one");
3948        assert!(w("c") < w("a"), "a wrong voter stands lower");
3949        assert_eq!(rows.len(), 6, "complete over the voters");
3950        // The record accumulates: a second outcome against c lowers it further.
3951        let (rows2, records2) = learn_record(&ballots, "ship", &records, &[]).unwrap();
3952        assert_eq!(records2["c"], (0.0, 2.0));
3953        let w2 = |to: &str| rows2.iter().find(|r| r.to == to).unwrap().weight;
3954        assert!(w2("c") <= w("c"));
3955        assert!(learn_record(&ballots, "  ", &records, &[]).is_err());
3956        // Records are read back off trust atoms, latest first.
3957        let atoms = vec![
3958            serde_json::json!({"kind": "trust", "from": "a", "to": "c", "weight": 0.2, "hits": 1.0, "misses": 3.0, "ts": "2026-09-13T01:00:00Z"}),
3959            serde_json::json!({"kind": "trust", "from": "b", "to": "c", "weight": 0.5, "hits": 1.0, "misses": 1.0, "ts": "2026-09-12T01:00:00Z"}),
3960        ];
3961        assert_eq!(records_from_atoms(&atoms)["c"], (1.0, 3.0));
3962    }
3963
3964    #[test]
3965    fn a_correction_is_nudged_once_a_session_and_only_on_a_prompt() {
3966        // The seen file lives under the runtime directory.
3967        let dir = std::env::temp_dir().join(format!("ljos-corr-{}", std::process::id()));
3968        std::fs::create_dir_all(&dir).unwrap();
3969        unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
3970        let prompt = HookCall {
3971            event: "UserPromptSubmit".into(),
3972            cue: "Do you not remember to use uv for scripts?".into(),
3973            session: Some("corr-test".into()),
3974        };
3975        let first = correction_nudge(&prompt).expect("a correction is nudged");
3976        assert!(first.contains("ljos prefer"), "{first}");
3977        assert!(correction_nudge(&prompt).is_none(), "once a session");
3978        let tool = HookCall {
3979            event: "PreToolUse".into(),
3980            cue: "you should have used uv".into(),
3981            session: Some("corr-test".into()),
3982        };
3983        assert!(
3984            correction_nudge(&tool).is_none(),
3985            "tool calls are not prompts"
3986        );
3987        let plain = HookCall {
3988            event: "UserPromptSubmit".into(),
3989            cue: "add the timeline verb".into(),
3990            session: Some("corr-test-2".into()),
3991        };
3992        assert!(correction_nudge(&plain).is_none());
3993    }
3994
3995    #[test]
3996    fn calibration_weights_are_log_odds_with_the_best_at_one() {
3997        let w = calibration_weights(&[
3998            ("a".to_string(), 0.9),
3999            ("b".to_string(), 0.6),
4000            ("c".to_string(), 0.5),
4001            ("d".to_string(), 1.0),
4002        ]);
4003        let of = |who: &str| w.iter().find(|(n, _)| n == who).unwrap().1;
4004        assert_eq!(of("d"), 1.0, "a perfect record is the top of the scale");
4005        // ln(9) / ln(99) = 0.478; ln(1.5) / ln(99) = 0.088
4006        assert!((of("a") - 0.478).abs() < 0.01, "{}", of("a"));
4007        assert!((of("b") - 0.088).abs() < 0.01, "{}", of("b"));
4008        assert!(
4009            of("a") / of("b") > 5.0,
4010            "nine in ten outweighs six in ten by more than five"
4011        );
4012        assert_eq!(of("c"), TRUST_FLOOR, "chance earns the floor");
4013    }
4014
4015    #[test]
4016    fn a_consolidation_report_names_the_pairs() {
4017        let body = serde_json::json!({"live": 5, "closed": 1, "applied": false, "pairs": [
4018            {"old": "a", "old_text": "The default fuse is Borda.", "new": "b", "new_text": "The default fuse is CombMNZ."}
4019        ]});
4020        let text = format_consolidation(&body);
4021        assert!(
4022            text.starts_with(
4023                "closes a  The default fuse is Borda.\n    for b  The default fuse is CombMNZ.\n"
4024            ),
4025            "{text}"
4026        );
4027        assert!(
4028            text.ends_with(
4029                "1 of 5 live memories would close; `ljos consolidate --apply` closes them\n"
4030            ),
4031            "{text}"
4032        );
4033        let applied = format_consolidation(
4034            &serde_json::json!({"live": 5, "closed": 0, "applied": true, "pairs": []}),
4035        );
4036        assert_eq!(applied, "0 of 5 live memories closed\n");
4037    }
4038
4039    #[test]
4040    fn the_hook_keeps_what_two_scorers_agreed_on() {
4041        let hit = |ballots, of| Hit {
4042            id: None,
4043            text: "x".into(),
4044            score: 1.0,
4045            kind: "lesson".into(),
4046            ts: None,
4047            ballots,
4048            of,
4049        };
4050        assert!(agreed(&hit(Some(2), Some(3))));
4051        assert!(!agreed(&hit(Some(1), Some(3))));
4052        assert!(agreed(&hit(Some(1), Some(1))));
4053        assert!(agreed(&hit(None, None)));
4054    }
4055
4056    #[test]
4057    fn the_holder_is_read_off_a_get_line() {
4058        let line = "a25a…  claimed  task  unset  gen=2  assignee=69f917124f757277b806e9a0f48c0318  parent=0  x-1";
4059        assert_eq!(
4060            holder_of(line).as_deref(),
4061            Some("69f917124f757277b806e9a0f48c0318")
4062        );
4063        assert_eq!(
4064            holder_of("a  ready  task  unset  gen=1  assignee=00000000000000000000000000000000"),
4065            None
4066        );
4067        assert_eq!(holder_of("deps  -"), None);
4068    }
4069
4070    #[test]
4071    fn a_registration_carries_the_runners_name() {
4072        let argv: Vec<String> = ["run", "-e", "LJOS_SEAT={name}", "{server}"]
4073            .iter()
4074            .map(|s| (*s).to_string())
4075            .collect();
4076        let filled = filled(&argv, Path::new("/x/ljos-mcp"), "runner-a");
4077        assert_eq!(filled, ["run", "-e", "LJOS_SEAT=runner-a", "/x/ljos-mcp"]);
4078        assert_eq!(
4079            identity_or_seat(Some(" reviewer ")).as_deref(),
4080            Some("reviewer")
4081        );
4082    }
4083
4084    #[test]
4085    fn a_timeline_merges_the_three_stores_oldest_first() {
4086        let v = serde_json::json!({
4087            "properties": {"CREATED": "[2026-09-01 Tue]"},
4088            "claimed_by": "seat",
4089            "claimed_at": "[2026-09-03 Thu 11:48]",
4090            "logbook": [
4091                {"note": "second", "timestamp": "[2026-09-10 Thu 09:00]"},
4092                {"from_state": "TODO", "to_state": "STARTED", "timestamp": "[2026-09-03 Thu 11:48]"}
4093            ]
4094        });
4095        let mut events = tracker_events(&v);
4096        events.push(
4097            deed_event(
4098                "deed-x",
4099                "id=deed-x ok\nproducedBy=seat -\ntime=1788566400\n",
4100            )
4101            .unwrap(),
4102        );
4103        events.sort_by(|a, b| (a.days, &a.clock).cmp(&(b.days, &b.clock)));
4104        let text = format_events(&events, "2026-09-12T00:00:00Z");
4105        let lines: Vec<&str> = text.lines().collect();
4106        assert_eq!(lines.len(), 5, "{text}");
4107        assert!(
4108            lines[0].starts_with("2026-09-01 \t11 days ago\t\ttracker\tcreated"),
4109            "{}",
4110            lines[0]
4111        );
4112        assert!(
4113            lines[1].contains("+2 d\ttracker\tclaimed by seat"),
4114            "{}",
4115            lines[1]
4116        );
4117        assert!(
4118            lines[2].contains("same day\ttracker\tTODO -> STARTED"),
4119            "{}",
4120            lines[2]
4121        );
4122        assert!(
4123            lines[3]
4124                .starts_with("2026-09-05 00:00\t7 days ago\t+2 d\tdeed\tdeed-x produced by seat -"),
4125            "{}",
4126            lines[3]
4127        );
4128        assert!(
4129            lines[4].contains("2 days ago\t+5 d\ttracker\tnote: second"),
4130            "{}",
4131            lines[4]
4132        );
4133    }
4134
4135    #[test]
4136    fn stamps_of_every_shape_key_the_same() {
4137        assert_eq!(
4138            stamp_key(Some("[2026-09-12 Sat 21:54]")),
4139            stamp_key(Some("2026-09-12T21:54:00.000Z"))
4140        );
4141        assert_eq!(stamp_key(Some("[2026-09-12 Sat]")).unwrap().1, "");
4142        assert_eq!(stamp_key(Some("soon")), None);
4143        assert_eq!(
4144            civil_of_days(days_of_stamp(Some("2026-09-12")).unwrap()),
4145            "2026-09-12"
4146        );
4147    }
4148
4149    #[test]
4150    fn ages_read_as_a_timeline() {
4151        let now = "2026-09-12T14:00:00.000Z";
4152        assert_eq!(age_of(Some("2026-09-12T01:00:00.000Z"), now), "today");
4153        assert_eq!(age_of(Some("2026-09-11T23:59:00.000Z"), now), "yesterday");
4154        assert_eq!(age_of(Some("2026-09-01T00:00:00.000Z"), now), "11 days ago");
4155        assert_eq!(age_of(Some("2026-08-01T00:00:00.000Z"), now), "6 weeks ago");
4156        assert_eq!(
4157            age_of(Some("2026-03-01T00:00:00.000Z"), now),
4158            "6 months ago"
4159        );
4160        assert_eq!(age_of(Some("2023-09-12T00:00:00.000Z"), now), "3 years ago");
4161        assert_eq!(age_of(Some("2026-09-13T00:00:00.000Z"), now), "in 1 day");
4162        assert_eq!(age_of(None, now), "");
4163        assert_eq!(age_of(Some("card"), now), "");
4164    }
4165
4166    #[test]
4167    fn a_hit_line_carries_kind_and_age() {
4168        let h = Hit {
4169            id: Some("a".into()),
4170            text: " keep the smoke green ".into(),
4171            score: 1.0,
4172            kind: "lesson".into(),
4173            ts: Some("2026-09-10T00:00:00.000Z".into()),
4174            ballots: None,
4175            of: None,
4176        };
4177        assert_eq!(
4178            hit_line(&h, "2026-09-12T00:00:00.000Z"),
4179            "- [lesson, 2 days ago] keep the smoke green"
4180        );
4181        let bare = Hit {
4182            id: None,
4183            text: "x".into(),
4184            score: 1.0,
4185            kind: String::new(),
4186            ts: None,
4187            ballots: None,
4188            of: None,
4189        };
4190        assert_eq!(hit_line(&bare, "2026-09-12T00:00:00.000Z"), "- [claim] x");
4191    }
4192
4193    /// A hook call is read from the runner's JSON or from plain text, and
4194    /// the answer is the runner's shape only when there is something to say.
4195    #[test]
4196    fn hook_calls_are_read_and_answered_in_the_runners_shape() {
4197        let tool = hook_call(
4198            r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cargo test","description":"run"}}"#,
4199        );
4200        assert_eq!(tool.event, "PreToolUse");
4201        assert_eq!(tool.cue, "cargo test");
4202        let prompt = hook_call(r#"{"hook_event_name":"UserPromptSubmit","prompt":"fix the fuse"}"#);
4203        assert_eq!(prompt.cue, "fix the fuse");
4204        let argv = hook_call("rm -rf build");
4205        assert_eq!(argv.event, "argv");
4206        assert_eq!(argv.session, None);
4207        let with_session = hook_call(
4208            r#"{"session_id":"abc/../x 1","hook_event_name":"PreToolUse","tool_input":{"command":"ls"}}"#,
4209        );
4210        assert_eq!(with_session.session.as_deref(), Some("abc/../x 1"));
4211        assert!(seen_path("abc/../x 1")
4212            .unwrap()
4213            .file_name()
4214            .unwrap()
4215            .to_string_lossy()
4216            .ends_with("hook-seen-abcx1"));
4217        assert_eq!(seen_path("/../"), None);
4218        assert_eq!(hook_output(&argv, ""), "");
4219        assert_eq!(hook_output(&argv, "- [lesson] x"), "- [lesson] x\n");
4220        let out = hook_output(&tool, "- [preference] y");
4221        let v: Value = serde_json::from_str(out.trim()).unwrap();
4222        assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
4223        assert_eq!(
4224            v["hookSpecificOutput"]["additionalContext"],
4225            "- [preference] y"
4226        );
4227        assert!(
4228            hook_context(
4229                &HookCall {
4230                    event: "argv".into(),
4231                    cue: "ab".into(),
4232                    session: None
4233                },
4234                8
4235            )
4236            .is_empty(),
4237            "a cue too short asks nothing"
4238        );
4239    }
4240
4241    /// The injected ids of a session are read back without the nudge marker,
4242    /// and the seen file goes with the session.
4243    #[test]
4244    fn a_sessions_injected_memories_are_read_back_and_cleared() {
4245        let session = format!("end-test-{}", std::process::id());
4246        mark_seen(
4247            Some(&session),
4248            &["a".to_string(), "due-nudge".to_string(), "b".to_string()],
4249        );
4250        let (ids, path) = injected_ids(&session);
4251        assert_eq!(ids, ["a", "b"]);
4252        assert!(path.as_ref().is_some_and(|p| p.is_file()));
4253        // No pack in a unit test: nothing fires, the file still goes.
4254        let _ = session_end(Some(&session));
4255        assert!(!path.unwrap().is_file());
4256        assert_eq!(session_end(None), 0);
4257    }
4258
4259    /// The memory hook merges into a runner's hooks file once per event and
4260    /// is not added twice.
4261    #[test]
4262    fn the_memory_hook_is_merged_once() {
4263        let dir = std::env::temp_dir().join(format!("ljos-hook-{}", std::process::id()));
4264        let _ = std::fs::remove_dir_all(&dir);
4265        std::fs::create_dir_all(&dir).unwrap();
4266        let file = dir.join("settings.json");
4267        std::fs::write(
4268            &file,
4269            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"other"}]}]},"theme":"dark"}"#,
4270        )
4271        .unwrap();
4272        let both: Vec<String> = vec!["UserPromptSubmit".into(), "PreToolUse".into()];
4273        let prompts: Vec<String> = HOOK_EVENTS.iter().map(|e| (*e).to_string()).collect();
4274        assert_eq!(
4275            prompts,
4276            ["UserPromptSubmit", "SessionEnd"],
4277            "the panel's default, and the session end that wires what it used"
4278        );
4279        assert!(!hook_installed(&file, &both));
4280        let dry = hook_step(&file, &both, true);
4281        assert!(
4282            dry.ok && dry.detail.starts_with("would add it on"),
4283            "{dry:?}"
4284        );
4285        let step = hook_step(&file, &both, false);
4286        assert!(step.ok, "{step:?}");
4287        assert!(hook_installed(&file, &both));
4288        let again = hook_step(&file, &both, false);
4289        assert!(
4290            again.detail.contains("carries the memory hook on"),
4291            "{again:?}"
4292        );
4293        let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
4294        assert_eq!(v["theme"], "dark", "the rest of the file is kept");
4295        assert_eq!(
4296            v["hooks"]["PreToolUse"].as_array().unwrap().len(),
4297            2,
4298            "the other hook stays"
4299        );
4300        assert_eq!(v["hooks"]["UserPromptSubmit"].as_array().unwrap().len(), 1);
4301        // Narrowing to the default drops the seat's tool-call group and
4302        // leaves the other tool's group alone.
4303        let narrowed = hook_step(&file, &prompts, false);
4304        assert!(
4305            narrowed.detail.contains("drop it from PreToolUse"),
4306            "{narrowed:?}"
4307        );
4308        let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
4309        assert_eq!(v["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
4310        assert_eq!(v["hooks"]["PreToolUse"][0]["hooks"][0]["command"], "other");
4311        assert!(hook_installed(&file, &prompts));
4312        assert!(!hook_installed(&file, &both));
4313        let _ = std::fs::remove_dir_all(&dir);
4314    }
4315
4316    /// Rules are globs over the whole line; deny wins over ask; the hook
4317    /// carries the verdict as the runner's permission decision.
4318    #[test]
4319    fn rules_match_the_line_and_the_hook_carries_the_verdict() {
4320        assert!(glob_matches("rm -rf *", "rm -rf /tmp/x"));
4321        assert!(!glob_matches("rm -rf *", "ls -la"));
4322        assert!(glob_matches("*sudo*", "echo hi && sudo reboot"));
4323        assert!(glob_matches("git push*", "git push origin main"));
4324        assert!(!glob_matches("git push*", "git pull"));
4325        let rules = vec![
4326            Rule {
4327                pattern: "git push*".into(),
4328                verdict: "ask".into(),
4329                reason: "A push is the trust gate.".into(),
4330            },
4331            Rule {
4332                pattern: "*--force*".into(),
4333                verdict: "deny".into(),
4334                reason: "Never force push.".into(),
4335            },
4336        ];
4337        assert_eq!(
4338            verdict_for(&rules, "git push --force").unwrap().verdict,
4339            "deny"
4340        );
4341        assert_eq!(
4342            verdict_for(&rules, "git push origin x").unwrap().verdict,
4343            "ask"
4344        );
4345        assert!(verdict_for(&rules, "cargo test").is_none());
4346        let call = hook_call(
4347            r#"{"hook_event_name":"PreToolUse","tool_input":{"command":"git push --force"}}"#,
4348        );
4349        let out = hook_output_ruled(&call, "", verdict_for(&rules, &call.cue));
4350        let v: Value = serde_json::from_str(out.trim()).unwrap();
4351        assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
4352        assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
4353            .as_str()
4354            .unwrap()
4355            .contains("Never force push"));
4356        assert!(v["hookSpecificOutput"].get("additionalContext").is_none());
4357        let argv = HookCall {
4358            event: "argv".into(),
4359            cue: "git push origin x".into(),
4360            session: None,
4361        };
4362        assert!(
4363            hook_output_ruled(&argv, "", verdict_for(&rules, &argv.cue)).starts_with("ask: A push")
4364        );
4365        let steps = panel_steps("x-1", true, &[], &[]);
4366        assert!(steps.is_empty());
4367        let preds = vec![
4368            Prediction {
4369                issue: "x-1".into(),
4370                agent: "a".into(),
4371                expect: Value::String("ship".into()),
4372            },
4373            Prediction {
4374                issue: "x-1".into(),
4375                agent: "b".into(),
4376                expect: serde_json::json!({"ship": 0.6, "hold": 0.4}),
4377            },
4378        ];
4379        let steps = panel_steps("x-1", true, &[row("a", "b", 0.5)], &preds);
4380        assert_eq!(steps.len(), 2);
4381        assert_eq!(steps[0].args[0], "surprising");
4382        assert_eq!(steps[1].args[0], "reputation");
4383    }
4384
4385    /// A scoped row applies when the issue is about one of its domains; an
4386    /// unscoped row applies everywhere; a scoped learn starts from the
4387    /// unscoped row and leaves it standing.
4388    #[test]
4389    fn scoped_rows_apply_to_their_topic_and_learn_writes_in_scope() {
4390        let everywhere = row("a", "b", 0.9);
4391        let mut on_docs = row("a", "b", 0.2);
4392        on_docs.about = vec!["docs".into()];
4393        let rows = vec![everywhere.clone(), on_docs.clone()];
4394        let topic = topic_words("Rewrite the docs site");
4395        assert_eq!(topic, ["docs", "rewrite", "site", "the"]);
4396        // On the docs topic the scoped row stands in for the unscoped one;
4397        // elsewhere the unscoped row is the one that applies.
4398        assert_eq!(rows_about(&rows, &topic), vec![on_docs.clone()]);
4399        assert_eq!(
4400            rows_about(&rows, &topic_words("Fix the fuse")),
4401            vec![everywhere.clone()]
4402        );
4403
4404        let ballots = vec![
4405            ("a".to_string(), "ship".to_string()),
4406            ("b".to_string(), "hold".to_string()),
4407        ];
4408        let learned = learn_about(&ballots, "ship", &rows, 0.5, &["fuse".to_string()]).unwrap();
4409        let ab = learned
4410            .iter()
4411            .find(|r| r.from == "a" && r.to == "b")
4412            .unwrap();
4413        assert_eq!(ab.about, ["fuse"]);
4414        assert!(
4415            (ab.weight - 0.45).abs() < 1e-9,
4416            "starts from the unscoped 0.9: {ab:?}"
4417        );
4418        let ba = learned
4419            .iter()
4420            .find(|r| r.from == "b" && r.to == "a")
4421            .unwrap();
4422        assert!((ba.weight - 1.0).abs() < 1e-9, "a was right: {ba:?}");
4423
4424        // Rows read back keep scoped and unscoped apart, latest per scope.
4425        let atoms = vec![
4426            trust_atom(&everywhere, &[], "ws").unwrap(),
4427            trust_atom(&on_docs, &[], "ws").unwrap(),
4428        ];
4429        let mut back = trust_rows(&atoms);
4430        back.sort_by(|x, y| x.about.cmp(&y.about));
4431        assert_eq!(back, vec![everywhere, on_docs]);
4432    }
4433
4434    /// A persona is a voter with an anchor; the latest atom per name wins and
4435    /// the anchors go to the settle as one object.
4436    #[test]
4437    fn personas_are_latest_per_name_and_anchor_the_settle() {
4438        let p = Persona {
4439            name: "reviewer".into(),
4440            anchor: 0.2,
4441            view: "Reads for what could break in production.".into(),
4442            entities: vec!["Release".into()],
4443        };
4444        let mut a = persona_atom(&p, "ws").unwrap();
4445        a["ts"] = Value::String("2026-01-01T00:00:00Z".into());
4446        let mut later = a.clone();
4447        later["anchor"] = serde_json::json!(0.4);
4448        later["ts"] = Value::String("2026-02-01T00:00:00Z".into());
4449        let got = personas_of(&[a, later]);
4450        assert_eq!(got.len(), 1);
4451        assert_eq!(got[0].anchor, 0.4);
4452        assert_eq!(got[0].entities, ["release"]);
4453        assert_eq!(anchors_json(&got), r#"{"reviewer":0.4}"#);
4454        // A refuted persona listens more next time; a vindicated one does
4455        // not move; one that did not vote is untouched.
4456        let ballots = vec![
4457            ("reviewer".to_string(), "hold".to_string()),
4458            ("reader".to_string(), "ship".to_string()),
4459        ];
4460        let moved = learn_anchors(&got, &ballots, "ship", 0.5);
4461        assert_eq!(moved.len(), 1);
4462        assert!(
4463            (moved[0].anchor - 0.7).abs() < 1e-9,
4464            "0.4 + 0.6 * 0.5: {moved:?}"
4465        );
4466        assert!(learn_anchors(&got, &ballots, "hold", 0.5).is_empty());
4467        assert!(persona_atom(
4468            &Persona {
4469                anchor: 1.5,
4470                ..p.clone()
4471            },
4472            "ws"
4473        )
4474        .is_err());
4475        let steps = consensus_steps_anchored("x-1", true, true, &[], &got).unwrap();
4476        for step in &steps {
4477            assert!(
4478                step.args.contains(&"--susceptibility-of".to_string()),
4479                "{step:?}"
4480            );
4481        }
4482        // The kind of work sets the dynamics: a broad-audience issue runs
4483        // bounded confidence on the model crate, and the tracker verb, which
4484        // has no such model, is left as it was.
4485        let broad =
4486            consensus_steps_for("x-1", true, true, &[], &got, &["broad".to_string()]).unwrap();
4487        assert!(
4488            broad[0].args.contains(&"--epsilon".to_string()),
4489            "{:?}",
4490            broad[0]
4491        );
4492        assert!(
4493            !broad[1].args.contains(&"--epsilon".to_string()),
4494            "{:?}",
4495            broad[1]
4496        );
4497        assert!(settle_flags_for(&["feature".to_string()]).is_empty());
4498    }
4499
4500    /// A claim that never entered the clock is due now; a scheduled one is
4501    /// not; trust rows never are; and the summary says whether the clock runs.
4502    #[test]
4503    fn unreviewed_claims_are_due_and_the_summary_says_if_the_clock_runs() {
4504        let atoms = vec![
4505            serde_json::json!({"id": "a", "kind": "conclusion", "text": "old", "due_at": ""}),
4506            serde_json::json!({"id": "b", "kind": "conclusion", "text": "older"}),
4507            serde_json::json!({"id": "c", "kind": "conclusion", "text": "later",
4508                "due_at": "2030-01-01T00:00:00Z"}),
4509            serde_json::json!({"id": "d", "kind": "conclusion", "text": "past",
4510                "due_at": "2020-01-01T00:00:00Z"}),
4511            serde_json::json!({"id": "t", "kind": "trust", "text": "x weighs y"}),
4512        ];
4513        let now = "2026-01-01T00:00:00Z";
4514        let due: Vec<String> = super::due_of(&atoms, now)
4515            .iter()
4516            .map(|a| a["id"].as_str().unwrap().to_string())
4517            .collect();
4518        assert_eq!(
4519            due,
4520            ["a", "b", "d"],
4521            "unreviewed first, then the past-due one"
4522        );
4523        assert_eq!(
4524            super::review_summary(&atoms, now),
4525            "3 due; 1 scheduled, next at 2030-01-01T00:00:00Z"
4526        );
4527        assert_eq!(
4528            super::review_summary(&[atoms[4].clone()], now),
4529            "0 due; nothing scheduled: this seat has remembered nothing yet"
4530        );
4531        assert!(super::format_due(&super::due_of(&atoms, now)).starts_with("unreviewed\t"));
4532    }
4533
4534    /// The example file parses, and onboarding a config-file runner from it
4535    /// appends the entry once and writes the skill once; a dry run writes
4536    /// nothing; an unnamed runner is refused with the names the file holds.
4537    #[test]
4538    fn onboarding_a_config_file_runner_writes_once() {
4539        let all: super::Harnesses = toml::from_str(super::HARNESSES_EXAMPLE).expect("parses");
4540        assert_eq!(all.harness.len(), 2);
4541        assert_eq!(all.harness[1].marker.as_deref(), Some("[mcp_servers.ljos]"));
4542
4543        let dir = std::env::temp_dir().join(format!("ljos-onboard-{}", std::process::id()));
4544        let _ = std::fs::remove_dir_all(&dir);
4545        std::fs::create_dir_all(&dir).expect("tempdir");
4546        let config = dir.join("config.toml");
4547        let skills = dir.join("skills");
4548        let file = dir.join("harnesses.toml");
4549        std::fs::write(
4550            &file,
4551            format!(
4552                "[[harness]]\nname = \"r\"\nconfig = {config:?}\nmarker = \"[mcp_servers.ljos]\"\n\
4553                 snippet = \"\\n[mcp_servers.ljos]\\ncommand = \\\"{{server}}\\\"\\n\"\nskills = {skills:?}\n",
4554                config = config.display().to_string(),
4555                skills = skills.display().to_string(),
4556            ),
4557        )
4558        .expect("write");
4559
4560        let refused = super::onboard_from(&file, "nobody", true)
4561            .unwrap_err()
4562            .to_string();
4563        assert!(
4564            refused.contains("no runner \"nobody\"") && refused.contains("names r"),
4565            "{refused}"
4566        );
4567
4568        let steps = match super::onboard_from(&file, "r", true) {
4569            Ok(steps) => steps,
4570            // Without ljos-mcp on PATH there is nothing to register; the
4571            // refusal says so and the rest of the check needs the binary.
4572            Err(e) => {
4573                assert!(e.to_string().contains("ljos-mcp not on PATH"), "{e}");
4574                return;
4575            }
4576        };
4577        assert!(steps.iter().all(|s| s.ok), "{steps:?}");
4578        assert!(
4579            steps[0].detail.starts_with("would append"),
4580            "{}",
4581            steps[0].detail
4582        );
4583        assert!(!config.exists() && !skills.exists(), "a dry run wrote");
4584
4585        let steps = super::onboard_from(&file, "r", false).expect("onboards");
4586        assert!(steps.iter().all(|s| s.ok), "{steps:?}");
4587        let written = std::fs::read_to_string(&config).expect("config written");
4588        assert_eq!(written.matches("[mcp_servers.ljos]").count(), 1);
4589        assert!(written.contains("ljos-mcp"), "{written}");
4590        let skill = std::fs::read_to_string(skills.join("ljos/SKILL.md")).expect("skill written");
4591        assert!(skill.starts_with("---\nname: ljos\n"));
4592        assert!(skill.contains("## Before the work"));
4593
4594        let again = super::onboard_from(&file, "r", false).expect("onboards again");
4595        assert_eq!(again[0].detail, "ljos registered");
4596        assert!(
4597            again[1].detail.ends_with("is current"),
4598            "{}",
4599            again[1].detail
4600        );
4601        assert_eq!(
4602            std::fs::read_to_string(&config)
4603                .expect("config")
4604                .matches("[mcp_servers.ljos]")
4605                .count(),
4606            1,
4607            "the entry was appended twice"
4608        );
4609        let _ = std::fs::remove_dir_all(&dir);
4610    }
4611
4612    use super::*;
4613    use std::io::{Read, Write};
4614    use std::net::TcpListener;
4615    use std::sync::{Arc, Mutex};
4616
4617    /// A non-zero exit is an error carrying what was said on stderr.
4618    #[test]
4619    fn a_refusal_is_an_error_not_an_answer() {
4620        let err = run_captured("false", &[] as &[&str]).unwrap_err();
4621        assert!(err.to_string().contains("false exited"), "{err}");
4622        let said = run_captured("sh", &["-c", "echo answered; echo aside >&2"]).unwrap();
4623        assert_eq!(said.stdout.trim(), "answered");
4624        assert_eq!(said.stderr.trim(), "aside");
4625        let said = run_captured("sh", &["-c", "echo reason >&2; exit 3"]).unwrap_err();
4626        assert!(said.to_string().contains("reason"), "{said}");
4627    }
4628
4629    #[test]
4630    fn join_keeps_spaces() {
4631        assert_eq!(
4632            join(&["the default fuse".into(), "is CombMNZ".into()]),
4633            "the default fuse is CombMNZ"
4634        );
4635    }
4636
4637    #[test]
4638    fn remember_is_lesson_prefer_is_preference() {
4639        assert_eq!(atom_kind("Remember").unwrap(), "lesson");
4640        assert_eq!(atom_kind("Prefer").unwrap(), "preference");
4641        assert!(atom_kind("extract").is_err());
4642    }
4643
4644    #[test]
4645    fn atom_body_is_explicit_and_unextracted() {
4646        let v = atom_body("lesson", "the default fuse is CombMNZ", "ws");
4647        assert_eq!(v["schema"], "inside.atom/v1");
4648        assert_eq!(v["kind"], "lesson");
4649        assert_eq!(v["level"], "explicit");
4650        assert_eq!(v["text"], "the default fuse is CombMNZ");
4651        assert_eq!(v["workspace"], "ws");
4652        // Never harvest a transcript: the text is the claim, not a prefix parse.
4653        let raw = atom_body("lesson", "Remember: pin the review set", "ws");
4654        assert_eq!(raw["text"], "Remember: pin the review set");
4655    }
4656
4657    #[test]
4658    fn empty_claim_is_refused() {
4659        let client = PacksetClient::new("http://127.0.0.1:1");
4660        let err = post_claim(&client, "Remember", "   ", "ws").unwrap_err();
4661        assert!(err.to_string().contains("empty text"));
4662    }
4663
4664    #[test]
4665    fn cards_are_the_two_named_files_only() {
4666        assert_eq!(CARD_NAMES, &["USER.md", "MEMORY.md"]);
4667        let dir = std::env::temp_dir().join(format!("ljos-cards-{}", std::process::id()));
4668        let _ = std::fs::remove_dir_all(&dir);
4669        std::fs::create_dir_all(&dir).unwrap();
4670        std::fs::write(dir.join("USER.md"), "user card\n").unwrap();
4671        std::fs::write(dir.join("MEMORY.md"), "memory card\n").unwrap();
4672        std::fs::write(dir.join("NOTES.md"), "must not appear\n").unwrap();
4673        let out = cards(&dir).unwrap();
4674        assert!(out.contains("user card"));
4675        assert!(out.contains("memory card"));
4676        assert!(!out.contains("must not appear"));
4677        assert!(!out.contains("NOTES.md"));
4678        let _ = std::fs::remove_dir_all(&dir);
4679    }
4680
4681    #[test]
4682    fn policy_prints_argv_and_does_not_reload() {
4683        assert!(policy_line(&[]).is_err());
4684        assert_eq!(policy_line(&["ls".into(), "-la".into()]).unwrap(), "ls -la");
4685        let note = POLICY_TCB.to_ascii_lowercase();
4686        assert!(note.contains("ljos-policyd"));
4687        assert!(note.contains("not a check"));
4688        assert!(!note.contains("grokos policy reload"));
4689        assert!(!note.contains("policy reload"));
4690    }
4691
4692    #[test]
4693    fn consensus_is_ljos_then_vissue() {
4694        let steps = consensus_steps("vissue-1a5a", true, true, &[]).unwrap();
4695        assert_eq!(steps.len(), 2);
4696        assert_eq!(steps[0].bin, "ljos-consensus");
4697        assert_eq!(steps[0].args, vec!["settle", "--issue", "vissue-1a5a"]);
4698        assert_eq!(steps[1].bin, "vissue");
4699        assert_eq!(steps[1].args, vec!["consensus", "vissue-1a5a"]);
4700    }
4701
4702    #[test]
4703    fn consensus_carries_the_packs_trust() {
4704        let rows = vec![row("a", "b", 0.5)];
4705        let steps = consensus_steps("id", true, true, &rows).unwrap();
4706        assert_eq!(steps[0].args[3], "--trust");
4707        assert_eq!(steps[0].args[4], r#"[["a","b",0.5]]"#);
4708        assert_eq!(
4709            steps[1].args,
4710            vec!["consensus", "id", "--trust", r#"[["a","b",0.5]]"#]
4711        );
4712    }
4713
4714    #[test]
4715    fn consensus_skips_a_missing_bin() {
4716        let only_v = consensus_steps("id", false, true, &[]).unwrap();
4717        assert_eq!(only_v.len(), 1);
4718        assert_eq!(only_v[0].bin, "vissue");
4719        let only_l = consensus_steps("id", true, false, &[]).unwrap();
4720        assert_eq!(only_l[0].bin, "ljos-consensus");
4721        assert!(consensus_steps("id", false, false, &[]).is_err());
4722    }
4723
4724    fn row(from: &str, to: &str, weight: f64) -> Trust {
4725        Trust {
4726            about: Vec::new(),
4727            from: from.into(),
4728            to: to.into(),
4729            weight,
4730        }
4731    }
4732
4733    #[test]
4734    fn a_trust_atom_is_one_edge_with_its_evidence() {
4735        let atom = trust_atom(&row("a", "b", 0.25), &["deed-x-y".into()], "ws").unwrap();
4736        assert_eq!(atom["kind"], "trust");
4737        assert_eq!(atom["from"], "a");
4738        assert_eq!(atom["to"], "b");
4739        assert_eq!(atom["weight"], 0.25);
4740        assert_eq!(atom["entities"], serde_json::json!(["deed-x-y"]));
4741        assert_eq!(atom["text"], "a weighs b at 0.250.");
4742        assert!(trust_atom(&row("a", "a", 0.5), &[], "ws").is_err());
4743        assert!(trust_atom(&row("a", "b", 0.0), &[], "ws").is_err());
4744        assert!(trust_atom(&row("a", "b", 1.5), &[], "ws").is_err());
4745        assert!(trust_atom(&row("", "b", 0.5), &[], "ws").is_err());
4746    }
4747
4748    #[test]
4749    fn the_latest_row_per_pair_wins() {
4750        let atoms = vec![
4751            serde_json::json!({"kind": "trust", "from": "a", "to": "b", "weight": 0.9, "ts": "2026-01-01T00:00:00Z"}),
4752            serde_json::json!({"kind": "trust", "from": "a", "to": "b", "weight": 0.3, "ts": "2026-02-01T00:00:00Z"}),
4753            serde_json::json!({"kind": "trust", "from": "b", "to": "a", "weight": 0.7}),
4754            serde_json::json!({"kind": "lesson", "text": "not a row"}),
4755            serde_json::json!({"kind": "trust", "from": "b", "weight": 0.7}),
4756        ];
4757        let rows = trust_rows(&atoms);
4758        assert_eq!(rows, vec![row("a", "b", 0.3), row("b", "a", 0.7)]);
4759        assert_eq!(trust_json(&rows), r#"[["a","b",0.3],["b","a",0.7]]"#);
4760    }
4761
4762    #[test]
4763    fn ballots_are_agent_and_choice() {
4764        let rows =
4765            ballots_from_json(r#"[{"agent":"a","choice":"ship","stamp":"[2026-01-01]"}]"#).unwrap();
4766        assert_eq!(rows, vec![("a".to_string(), "ship".to_string())]);
4767        assert!(ballots_from_json(r#"[{"agent":"a"}]"#).is_err());
4768        assert!(ballots_from_json("{}").is_err());
4769    }
4770
4771    /// A refuted voter loses weight in every other voter's row; a vindicated
4772    /// one keeps it; the rows come back complete.
4773    #[test]
4774    fn learning_downweights_the_refuted_voter() {
4775        let ballots = vec![
4776            ("a".to_string(), "ship".to_string()),
4777            ("b".to_string(), "ship".to_string()),
4778            ("c".to_string(), "hold".to_string()),
4779        ];
4780        let rows = learn(&ballots, "ship", &[], 0.5).unwrap();
4781        assert_eq!(rows.len(), 6);
4782        let w = |from: &str, to: &str| {
4783            rows.iter()
4784                .find(|r| r.from == from && r.to == to)
4785                .unwrap()
4786                .weight
4787        };
4788        assert_eq!(w("a", "b"), 1.0);
4789        assert_eq!(w("a", "c"), 0.5);
4790        assert_eq!(w("b", "c"), 0.5);
4791        assert_eq!(w("c", "a"), 1.0);
4792
4793        let again = learn(&ballots, "ship", &rows, 0.5).unwrap();
4794        let w2 = |from: &str, to: &str| {
4795            again
4796                .iter()
4797                .find(|r| r.from == from && r.to == to)
4798                .unwrap()
4799                .weight
4800        };
4801        assert_eq!(w2("a", "c"), 0.25);
4802        assert_eq!(w2("a", "b"), 1.0);
4803
4804        let floored = learn(&ballots, "ship", &[row("a", "c", 0.015)], 0.5).unwrap();
4805        let low = floored
4806            .iter()
4807            .find(|r| r.from == "a" && r.to == "c")
4808            .unwrap();
4809        assert_eq!(low.weight, TRUST_FLOOR);
4810
4811        assert!(learn(&ballots, "ship", &[], 1.0).is_err());
4812        assert!(learn(&ballots, "  ", &[], 0.5).is_err());
4813        assert!(learn(&ballots[..1], "ship", &[], 0.5).is_err());
4814
4815        // A fixed share of recovery: the refuted row moves back toward one
4816        // by the share of the gap, the vindicated row stays at one.
4817        let shared = learn_shared(&ballots, "ship", &rows, 0.5, &[], 0.1).unwrap();
4818        let w3 = |from: &str, to: &str| {
4819            shared
4820                .iter()
4821                .find(|r| r.from == from && r.to == to)
4822                .unwrap()
4823                .weight
4824        };
4825        assert!((w3("a", "c") - (0.25 + 0.75 * 0.1)).abs() < 1e-12);
4826        assert_eq!(w3("a", "b"), 1.0);
4827        assert!(learn_shared(&ballots, "ship", &[], 0.5, &[], 1.0).is_err());
4828    }
4829
4830    #[test]
4831    fn a_name_is_one_work_id_and_hex_passes_through() {
4832        let a = work_id("demo-riml");
4833        assert_eq!(a.len(), 32);
4834        assert!(a.bytes().all(|b| b.is_ascii_hexdigit()));
4835        assert_eq!(a, work_id(" demo-riml "));
4836        assert_ne!(a, work_id("demo-rimm"));
4837        assert_eq!(work_id(&a.to_ascii_uppercase()), a);
4838        assert_ne!(work_id("seat"), work_id("reader"));
4839    }
4840
4841    #[test]
4842    fn an_island_prints_one_memory_a_line() {
4843        let body = serde_json::json!({"island": [
4844            {"id": "a", "text": "one", "activation": 1.0, "seed": true, "ts": now_utc()},
4845            {"id": "b", "text": "two", "activation": 0.25, "seed": false}
4846        ]});
4847        assert_eq!(
4848            format_island(&body),
4849            "1.000\tseed\ta\ttoday\tone\n0.250\t    \tb\t\ttwo\n"
4850        );
4851        assert!(format_island(&serde_json::json!({})).is_empty());
4852    }
4853
4854    #[test]
4855    fn a_fed_verb_reads_its_stdin() {
4856        let said = run_fed("cat", &[] as &[&str], "one\ntwo\n").unwrap();
4857        assert_eq!(said.stdout, "one\ntwo\n");
4858        assert!(run_fed("sh", &["-c", "exit 2"], "").is_err());
4859    }
4860
4861    #[test]
4862    fn needs_and_cited_are_enclosed_once_each() {
4863        let needs = needs_of(r#"{"needs":["deed-b-2","deed-a-1"],"other":1}"#).unwrap();
4864        assert_eq!(needs, vec!["deed-b-2", "deed-a-1"]);
4865        assert_eq!(
4866            enclose(needs, "deed-a-1\n\ndeed-c-3\n"),
4867            vec!["deed-a-1", "deed-b-2", "deed-c-3"]
4868        );
4869        assert!(needs_of("{}").unwrap().is_empty());
4870        assert!(needs_of("not json").is_err());
4871    }
4872
4873    #[test]
4874    fn due_is_the_past_soonest_first() {
4875        let atoms = vec![
4876            serde_json::json!({"id": "late", "due_at": "2026-02-01T00:00:00.000Z"}),
4877            serde_json::json!({"id": "later", "due_at": "2026-03-01T00:00:00.000Z"}),
4878            serde_json::json!({"id": "future", "due_at": "2099-01-01T00:00:00.000Z"}),
4879            serde_json::json!({"id": "never"}),
4880            serde_json::json!({"id": "blank", "due_at": ""}),
4881        ];
4882        let due = due_of(&atoms, "2026-06-01T00:00:00.000Z");
4883        let ids: Vec<&str> = due.iter().map(|a| a["id"].as_str().unwrap()).collect();
4884        // A claim that never entered the clock is due now, ahead of the
4885        // past-due ones; the future one waits.
4886        assert_eq!(ids, ["never", "blank", "late", "later"]);
4887        assert!(now_utc().ends_with(".000Z"));
4888        assert!(now_utc().as_str() > "2026-01-01T00:00:00.000Z");
4889    }
4890
4891    #[test]
4892    fn the_doctor_names_every_habitat_and_the_pack_gates_health() {
4893        let rows = doctor();
4894        let names: Vec<&str> = rows.iter().map(|h| h.name).collect();
4895        for want in [
4896            "vissue",
4897            "deedar",
4898            "packset",
4899            "pack",
4900            "host key",
4901            "deed store",
4902            "tracker",
4903        ] {
4904            assert!(names.contains(&want), "{names:?}");
4905        }
4906        let table = format_doctor(&rows);
4907        assert_eq!(table.lines().count(), rows.len());
4908        let sick = vec![Habitat {
4909            name: "pack",
4910            state: "PACKSET_URL unset".into(),
4911            ok: false,
4912        }];
4913        assert!(!healthy(&sick));
4914        let fine = vec![Habitat {
4915            name: "claimdag",
4916            state: "not on PATH".into(),
4917            ok: false,
4918        }];
4919        assert!(healthy(&fine));
4920    }
4921
4922    #[test]
4923    fn enclosed_atoms_are_read_from_every_jsonl_in_the_bag() {
4924        let dir = std::env::temp_dir().join(format!("ljos-bag-{}", std::process::id()));
4925        let _ = std::fs::remove_dir_all(&dir);
4926        let atoms = dir.join("data").join("atoms");
4927        std::fs::create_dir_all(&atoms).unwrap();
4928        std::fs::write(
4929            atoms.join("a.jsonl"),
4930            "{\"kind\":\"lesson\",\"text\":\"one\"}\n\n{\"kind\":\"trust\",\"from\":\"a\",\"to\":\"b\",\"weight\":0.5}\n",
4931        )
4932        .unwrap();
4933        std::fs::write(
4934            atoms.join("b.jsonl"),
4935            "{\"kind\":\"preference\",\"text\":\"two\"}\n",
4936        )
4937        .unwrap();
4938        let read = enclosed_atoms(&dir).unwrap();
4939        assert_eq!(read.len(), 3);
4940        assert_eq!(trust_rows(&read).len(), 1);
4941        assert!(enclosed_atoms(&dir.join("nowhere")).unwrap().is_empty());
4942        std::fs::write(atoms.join("c.jsonl"), "not json\n").unwrap();
4943        assert!(enclosed_atoms(&dir).is_err());
4944        let _ = std::fs::remove_dir_all(&dir);
4945
4946        let table = format_due(&[serde_json::json!({
4947            "id": "x", "kind": "lesson", "text": "t", "due_at": "2026-01-01T00:00:00.000Z"
4948        })]);
4949        assert_eq!(table, "2026-01-01T00:00:00.000Z\tlesson\tx\tt\n");
4950    }
4951
4952    fn read_http(s: &mut impl Read) -> String {
4953        let mut buf = Vec::new();
4954        let mut tmp = [0u8; 1024];
4955        loop {
4956            let n = s.read(&mut tmp).unwrap_or(0);
4957            if n == 0 {
4958                break;
4959            }
4960            buf.extend_from_slice(&tmp[..n]);
4961            if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
4962                let headers = &buf[..at];
4963                let mut need = 0usize;
4964                for line in headers.split(|b| *b == b'\n') {
4965                    let line = std::str::from_utf8(line).unwrap_or("").trim();
4966                    if let Some(v) = line
4967                        .split_once(':')
4968                        .filter(|(k, _)| k.eq_ignore_ascii_case("content-length"))
4969                        .map(|(_, v)| v.trim())
4970                    {
4971                        need = v.parse().unwrap_or(0);
4972                    }
4973                }
4974                let have = buf.len().saturating_sub(at + 4);
4975                if have >= need {
4976                    break;
4977                }
4978            }
4979        }
4980        String::from_utf8_lossy(&buf).into_owned()
4981    }
4982
4983    fn serve_capture() -> (String, Arc<Mutex<String>>) {
4984        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
4985        let addr = listener.local_addr().unwrap();
4986        let captured = Arc::new(Mutex::new(String::new()));
4987        let slot = captured.clone();
4988        std::thread::spawn(move || {
4989            if let Ok((mut s, _)) = listener.accept() {
4990                *slot.lock().unwrap() = read_http(&mut s);
4991                let body =
4992                    r#"{"id":"atom-1","kind":"lesson","text":"the default fuse is CombMNZ"}"#;
4993                let resp = format!(
4994                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
4995                    body.len()
4996                );
4997                let _ = s.write_all(resp.as_bytes());
4998            }
4999        });
5000        (format!("http://{addr}"), captured)
5001    }
5002
5003    #[test]
5004    fn remember_posts_v1_atoms() {
5005        let (url, captured) = serve_capture();
5006        let client = PacksetClient::new(&url);
5007        let body = post_claim(&client, "Remember", "the default fuse is CombMNZ", "ws").unwrap();
5008        assert_eq!(body["id"], "atom-1");
5009        let req = captured.lock().unwrap().clone();
5010        assert!(req.contains("POST"), "{req}");
5011        assert!(req.contains("/v1/atoms"), "{req}");
5012        assert!(req.contains("\"kind\":\"lesson\""), "{req}");
5013        assert!(req.contains("the default fuse is CombMNZ"), "{req}");
5014        assert!(req.contains("\"level\":\"explicit\""), "{req}");
5015        assert!(!req.contains("extract"), "{req}");
5016    }
5017
5018    #[test]
5019    fn forget_posts_the_id_and_workspace() {
5020        let (url, captured) = serve_capture();
5021        let client = PacksetClient::new(&url);
5022        let body = client.delete_atom("ws", "atom-1", None).unwrap();
5023        assert_eq!(body["id"], "atom-1");
5024        let req = captured.lock().unwrap().clone();
5025        assert!(req.contains("POST"), "{req}");
5026        assert!(req.contains("/v1/atoms/delete"), "{req}");
5027        assert!(req.contains("\"id\":\"atom-1\""), "{req}");
5028        assert!(req.contains("\"workspace\":\"ws\""), "{req}");
5029        // No deed named, no field: the pack should not have to tell an absent
5030        // citation from an empty one.
5031        assert!(!req.contains("\"why\""), "{req}");
5032    }
5033
5034    /// The deed rides with the retraction, so the pack can write it onto the
5035    /// tombstone in the same step the atom leaves the live set.
5036    #[test]
5037    fn forget_carries_the_deed_that_withdrew_the_claim() {
5038        let (url, captured) = serve_capture();
5039        let client = PacksetClient::new(&url);
5040        client
5041            .delete_atom("ws", "atom-1", Some("deed-patch-overlay"))
5042            .unwrap();
5043        let req = captured.lock().unwrap().clone();
5044        assert!(req.contains("\"why\":\"deed-patch-overlay\""), "{req}");
5045    }
5046
5047    /// An id is the whole of the request, so an empty one is a mistake worth
5048    /// naming rather than a delete of whatever the server decides that means.
5049    #[test]
5050    fn forget_refuses_an_empty_id() {
5051        let err = packset_forget("   ", None).unwrap_err();
5052        assert!(err.to_string().contains("atom id is required"), "{err}");
5053    }
5054}