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