Skip to main content

ljos_cli/
lib.rs

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