use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use packset_client::{Hit, PacksetClient};
use serde_json::Value;
pub const CARD_NAMES: &[&str] = &["USER.md", "MEMORY.md"];
pub const PROTOCOL: &str = include_str!("../doc/protocol.md");
#[must_use]
pub fn skill_text() -> String {
format!(
"---\nname: ljos\ndescription: >\n The seat protocol for vissue, packset, deedar, claimdag and \
consensus through ljos: which store answers which question, the order of verbs in a \
sitting, and the refusals worth knowing. Load before any work that touches an issue, \
a memory, a deed, a claim or a vote.\n---\n\n{PROTOCOL}"
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Step {
pub what: String,
pub detail: String,
pub ok: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct Harness {
pub name: String,
#[serde(default)]
pub register: Vec<String>,
#[serde(default)]
pub registered: Vec<String>,
#[serde(default)]
pub config: Option<String>,
#[serde(default)]
pub marker: Option<String>,
#[serde(default)]
pub snippet: Option<String>,
#[serde(default)]
pub skills: Option<String>,
#[serde(default)]
pub hooks: Option<String>,
#[serde(default)]
pub hook_events: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct Harnesses {
#[serde(default)]
pub harness: Vec<Harness>,
}
pub const HARNESSES_EXAMPLE: &str = r#"# ~/.config/ljos/harnesses.toml: the agent runners on this machine.
# {server} is replaced by the path to ljos-mcp, {name} by the runner's name.
# Paths may start with ~. Passing LJOS_SEAT={name} to the server makes each
# runner claim and vote as itself; they share the one pack and tracker.
[[harness]]
name = "runner-with-a-command"
register = ["runner", "mcp", "add", "-s", "user", "-e", "LJOS_SEAT={name}", "ljos", "--", "{server}"]
registered = ["runner", "mcp", "get", "ljos"]
skills = "~/.runner/skills"
hooks = "~/.runner/settings.json"
# hook_events = ["UserPromptSubmit", "PreToolUse"] # the default is the prompt alone
[[harness]]
name = "runner-with-a-config-file"
config = "~/.other/config.toml"
marker = "[mcp_servers.ljos]"
snippet = "\n[mcp_servers.ljos]\ncommand = \"{server}\"\nargs = []\nenv = { LJOS_SEAT = \"{name}\", GROK_SESSION_ID = \"${GROK_SESSION_ID}\" }\n"
skills = "~/.other/skills"
"#;
fn home() -> Result<PathBuf> {
std::env::var_os("HOME")
.map(PathBuf::from)
.context("HOME unset; onboard needs a home directory")
}
fn expand(path: &str) -> PathBuf {
match path.strip_prefix("~/") {
Some(rest) => home().map_or_else(|_| PathBuf::from(path), |h| h.join(rest)),
None => PathBuf::from(path),
}
}
#[must_use]
pub fn harnesses_path() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.filter(|r| !r.is_empty())
.map(PathBuf::from)
.or_else(|| home().ok().map(|h| h.join(".config")))
.unwrap_or_else(|| PathBuf::from(".config"))
.join("ljos")
.join("harnesses.toml")
}
pub fn harnesses_from(path: &Path) -> Result<Harnesses> {
match std::fs::read_to_string(path) {
Ok(text) => toml::from_str(&text).with_context(|| format!("{}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Harnesses::default()),
Err(e) => Err(e).with_context(|| format!("{}", path.display())),
}
}
fn server_path() -> Result<PathBuf> {
which::which("ljos-mcp").context("ljos-mcp not on PATH; install it beside ljos")
}
pub fn server_entry() -> Result<Value> {
Ok(serde_json::json!({
"mcpServers": {
"ljos": {
"type": "stdio",
"command": server_path()?.display().to_string(),
"args": [],
"env": {}
}
}
}))
}
fn write_skill(dir: &Path, dry: bool) -> Step {
let path = dir.join("ljos").join("SKILL.md");
let text = skill_text();
if std::fs::read_to_string(&path).is_ok_and(|have| have == text) {
return Step {
what: "skill".into(),
detail: format!("{} is current", path.display()),
ok: true,
};
}
if dry {
return Step {
what: "skill".into(),
detail: format!("would write {}", path.display()),
ok: true,
};
}
let written = std::fs::create_dir_all(path.parent().unwrap_or(dir))
.and_then(|()| std::fs::write(&path, text));
match written {
Ok(()) => Step {
what: "skill".into(),
detail: format!("wrote {}", path.display()),
ok: true,
},
Err(e) => Step {
what: "skill".into(),
detail: format!("{}: {e}", path.display()),
ok: false,
},
}
}
fn filled(argv: &[String], server: &Path, name: &str) -> Vec<String> {
argv.iter()
.map(|a| a.replace("{server}", &server.display().to_string()))
.map(|a| a.replace("{name}", name))
.collect()
}
fn shared_actor_name(name: &str) -> bool {
matches!(
name.trim().to_ascii_lowercase().as_str(),
"grok" | "seat" | "you" | "agent" | "grok-build"
)
}
fn session_actor() -> Option<String> {
for key in ["GROK_SESSION_ID", "HARNESS_SESSION_ID", "TERM_SESSION_ID"] {
if let Ok(raw) = std::env::var(key) {
let t = raw.trim();
if t.is_empty() {
continue;
}
let prefix: String = t.chars().take(8).collect();
return Some(format!("sess-{prefix}"));
}
}
None
}
#[must_use]
pub fn seat_name() -> String {
if let Ok(v) = std::env::var("LJOS_SEAT") {
let t = v.trim();
if !t.is_empty() && !shared_actor_name(t) {
return t.to_string();
}
}
if let Some(s) = session_actor() {
return s;
}
if let Ok(v) = std::env::var("VISSUE_AGENT") {
let t = v.trim();
if !t.is_empty() && !shared_actor_name(t) {
return t.to_string();
}
}
"seat".to_string()
}
#[must_use]
pub fn resolve_assignee(passed: Option<&str>) -> String {
match passed.map(str::trim).filter(|s| !s.is_empty()) {
Some(n) if !shared_actor_name(n) => n.to_string(),
_ => seat_name(),
}
}
fn is_registered(h: &Harness, server: &Path) -> Option<bool> {
if !h.registered.is_empty() {
let argv = filled(&h.registered, server, &h.name);
return Some(
argv.first().is_some_and(|bin| on_path(bin)) && {
let (bin, rest) = (&argv[0], &argv[1..]);
run_captured(bin, rest).is_ok()
},
);
}
if let (Some(config), Some(marker)) = (&h.config, &h.marker) {
return Some(std::fs::read_to_string(expand(config)).is_ok_and(|t| t.contains(marker)));
}
None
}
fn register_step(h: &Harness, server: &Path, dry: bool) -> Step {
let what = format!("{} mcp", h.name);
match is_registered(h, server) {
Some(true) => Step {
what,
detail: "ljos registered".into(),
ok: true,
},
None => Step {
what,
detail: "no register or config in harnesses.toml; paste `ljos onboard --harness json`"
.into(),
ok: false,
},
Some(false) if !h.register.is_empty() => {
let argv = filled(&h.register, server, &h.name);
if !on_path(&argv[0]) {
return Step {
what,
detail: format!("{} not on PATH", argv[0]),
ok: false,
};
}
if dry {
return Step {
what,
detail: format!("would run {}", argv.join(" ")),
ok: true,
};
}
match run_captured(&argv[0], &argv[1..]) {
Ok(_) => Step {
what,
detail: format!("ran {}", argv.join(" ")),
ok: true,
},
Err(e) => Step {
what,
detail: e.to_string().lines().next().unwrap_or("").to_string(),
ok: false,
},
}
}
Some(false) => {
let config = expand(h.config.as_deref().unwrap_or_default());
let snippet = h
.snippet
.as_deref()
.unwrap_or_default()
.replace("{server}", &server.display().to_string())
.replace("{name}", &h.name);
if snippet.is_empty() {
return Step {
what,
detail: format!("no snippet to append to {}", config.display()),
ok: false,
};
}
if dry {
return Step {
what,
detail: format!("would append the entry to {}", config.display()),
ok: true,
};
}
let mut text = std::fs::read_to_string(&config).unwrap_or_default();
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&snippet);
let written = config
.parent()
.map_or(Ok(()), std::fs::create_dir_all)
.and_then(|()| std::fs::write(&config, text));
match written {
Ok(()) => Step {
what,
detail: format!("appended the entry to {}", config.display()),
ok: true,
},
Err(e) => Step {
what,
detail: format!("{}: {e}", config.display()),
ok: false,
},
}
}
}
}
pub fn onboard(harness: &str, dry: bool) -> Result<Vec<Step>> {
onboard_from(&harnesses_path(), harness, dry)
}
const GROK_HOOKS_JSON: &str = include_str!("../../../scripts/grok/ljos.json");
fn write_grok_hooks(dry: bool) -> Result<Step> {
let dest = home()?.join(".grok/hooks/ljos.json");
if dry {
return Ok(Step {
what: "hook".into(),
detail: format!("would write {}", dest.display()),
ok: true,
});
}
if let Some(dir) = dest.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&dest, GROK_HOOKS_JSON)?;
Ok(Step {
what: "hook".into(),
detail: format!("wrote {}", dest.display()),
ok: true,
})
}
pub fn onboard_from(file: &Path, harness: &str, dry: bool) -> Result<Vec<Step>> {
if harness == "json" {
return Ok(vec![Step {
what: "json".into(),
detail: serde_json::to_string_pretty(&server_entry()?)?,
ok: true,
}]);
}
if harness == "grok" {
let mut steps = vec![write_grok_hooks(dry)?];
if let Ok(all) = harnesses_from(file) {
if let Some(h) = all.harness.iter().find(|h| h.name == "grok") {
let server = server_path()?;
steps.push(register_step(h, &server, dry));
if let Some(dir) = &h.skills {
steps.push(write_skill(&expand(dir), dry));
}
}
}
return Ok(steps);
}
let all = harnesses_from(file)?;
let Some(h) = all.harness.iter().find(|h| h.name == harness) else {
let names: Vec<&str> = all.harness.iter().map(|h| h.name.as_str()).collect();
bail!(
"onboard: no runner {harness:?} in {}; it names {}. `ljos onboard --example` \
prints the file's shape, and `--harness json` prints the entry to paste anywhere.",
file.display(),
if names.is_empty() {
"none".to_string()
} else {
names.join(", ")
}
);
};
let server = server_path()?;
let mut steps = vec![
pack_step(dry),
host_key_step(dry),
register_step(h, &server, dry),
];
if let Some(file) = &h.hooks {
steps.push(hook_step(&expand(file), &hook_events_of(h), dry));
}
match &h.skills {
Some(dir) => steps.push(write_skill(&expand(dir), dry)),
None => steps.push(Step {
what: "skill".into(),
detail: "no skills directory in harnesses.toml; `ljos protocol` prints the text".into(),
ok: false,
}),
}
Ok(steps)
}
pub const HOOK_EVENTS: &[&str] = &["UserPromptSubmit", "SessionEnd"];
pub const HOOK_MATCHERS: &[(&str, &str)] = &[
("PreToolUse", "Bash"),
("PostToolUse", "*"),
("UserPromptSubmit", "*"),
("SessionEnd", "*"),
];
fn normalize_hook_event(raw: &str) -> &str {
match raw {
"pre_tool_use" | "PreToolUse" => "PreToolUse",
"post_tool_use" | "PostToolUse" => "PostToolUse",
"user_prompt_submit" | "UserPromptSubmit" => "UserPromptSubmit",
"session_end" | "SessionEnd" => "SessionEnd",
"session_start" | "SessionStart" => "SessionStart",
other => other,
}
}
fn hook_matcher(event: &str) -> &'static str {
HOOK_MATCHERS
.iter()
.find(|(e, _)| *e == event)
.map_or("*", |(_, m)| m)
}
fn hook_events_of(h: &Harness) -> Vec<String> {
if h.name == "grok" {
return ["UserPromptSubmit", "PostToolUse", "PreToolUse", "SessionEnd"]
.into_iter()
.map(str::to_string)
.collect();
}
if h.hook_events.is_empty() {
HOOK_EVENTS.iter().map(|e| (*e).to_string()).collect()
} else {
h.hook_events.clone()
}
}
fn is_seat_hook(h: &Value) -> bool {
h["command"]
.as_str()
.is_some_and(|c| c.contains("ljos") && c.ends_with(" hook"))
}
fn hook_command() -> String {
which::which("ljos").map_or_else(
|_| "ljos hook".to_string(),
|p| format!("{} hook", p.display()),
)
}
fn hook_step(file: &Path, events: &[String], dry: bool) -> Step {
let what = "hook".to_string();
let mut root: Value = match std::fs::read_to_string(file) {
Ok(text) if !text.trim().is_empty() => match serde_json::from_str(&text) {
Ok(v) => v,
Err(e) => {
return Step {
what,
detail: format!("{}: not JSON: {e}", file.display()),
ok: false,
}
}
},
_ => serde_json::json!({}),
};
let command = hook_command();
let Some(obj) = root.as_object_mut() else {
return Step {
what,
detail: format!("{}: not a JSON object", file.display()),
ok: false,
};
};
let hooks = obj.entry("hooks").or_insert_with(|| serde_json::json!({}));
let Some(hooks) = hooks.as_object_mut() else {
return Step {
what,
detail: format!("{}: hooks is not an object", file.display()),
ok: false,
};
};
let mut added = Vec::new();
let mut removed = Vec::new();
for event in events {
let groups = hooks
.entry(event.clone())
.or_insert_with(|| serde_json::json!([]));
let Some(groups) = groups.as_array_mut() else {
continue;
};
let present = groups.iter().any(|g| {
g["hooks"]
.as_array()
.into_iter()
.flatten()
.any(is_seat_hook)
});
if present {
continue;
}
groups.push(serde_json::json!({
"matcher": hook_matcher(event),
"hooks": [{"type": "command", "command": command, "timeout": 20}]
}));
added.push(event.clone());
}
for (event, groups) in hooks.iter_mut() {
if events.contains(event) {
continue;
}
let Some(groups) = groups.as_array_mut() else {
continue;
};
let before = groups.len();
groups.retain(|g| {
!g["hooks"]
.as_array()
.into_iter()
.flatten()
.any(is_seat_hook)
});
if groups.len() != before {
removed.push(event.clone());
}
}
if added.is_empty() && removed.is_empty() {
return Step {
what,
detail: format!(
"{} carries the memory hook on {}",
file.display(),
events.join(", ")
),
ok: true,
};
}
let mut change = Vec::new();
if !added.is_empty() {
change.push(format!("add it on {}", added.join(", ")));
}
if !removed.is_empty() {
change.push(format!("drop it from {}", removed.join(", ")));
}
let change = change.join(" and ");
if dry {
return Step {
what,
detail: format!("would {change} in {}", file.display()),
ok: true,
};
}
let written = file
.parent()
.map_or(Ok(()), std::fs::create_dir_all)
.and_then(|()| serde_json::to_string_pretty(&root).map_err(std::io::Error::other))
.and_then(|text| std::fs::write(file, text + "\n"));
match written {
Ok(()) => Step {
what,
detail: format!("memory hook: {change} in {}", file.display()),
ok: true,
},
Err(e) => Step {
what,
detail: format!("{}: {e}", file.display()),
ok: false,
},
}
}
fn hook_installed(file: &Path, events: &[String]) -> bool {
let Ok(text) = std::fs::read_to_string(file) else {
return false;
};
let Ok(root) = serde_json::from_str::<Value>(&text) else {
return false;
};
events.iter().all(|event| {
root["hooks"][event.as_str()]
.as_array()
.into_iter()
.flatten()
.any(|g| {
g["hooks"]
.as_array()
.into_iter()
.flatten()
.any(is_seat_hook)
})
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookCall {
pub event: String,
pub cue: String,
pub session: Option<String>,
}
#[must_use]
pub fn hook_call(input: &str) -> HookCall {
let trimmed = input.trim();
let Ok(v) = serde_json::from_str::<Value>(trimmed) else {
return HookCall {
event: "argv".into(),
cue: trimmed.to_string(),
session: None,
};
};
let session = v["session_id"]
.as_str()
.or_else(|| v["sessionId"].as_str())
.filter(|s| !s.is_empty())
.map(str::to_string);
let raw = v["hook_event_name"]
.as_str()
.or_else(|| v["hookEventName"].as_str())
.unwrap_or("PreToolUse");
let event = normalize_hook_event(raw).to_string();
let cue = if let Some(p) = v["prompt"].as_str() {
p.to_string()
} else if let Some(c) = v["tool_input"]["command"].as_str() {
c.to_string()
} else if let Some(map) = v["tool_input"].as_object() {
map.values()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(" ")
} else {
String::new()
};
HookCall {
event,
cue,
session,
}
}
fn seen_path(session: &str) -> Option<PathBuf> {
let safe: String = session
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.collect();
if safe.is_empty() {
return None;
}
let dir = std::env::var_os("XDG_RUNTIME_DIR")
.filter(|r| !r.is_empty())
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join("ljos");
Some(dir.join(format!("hook-seen-{safe}")))
}
fn seen_ids(session: Option<&str>) -> std::collections::BTreeSet<String> {
session
.and_then(seen_path)
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|t| t.lines().map(str::to_string).collect())
.unwrap_or_default()
}
fn injected_ids(session: &str) -> (Vec<String>, Option<PathBuf>) {
let path = seen_path(session);
let ids: Vec<String> = path
.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|t| {
t.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && *l != "due-nudge")
.map(str::to_string)
.collect()
})
.unwrap_or_default();
(ids, path)
}
pub fn session_end(session: Option<&str>) -> usize {
let Some(session) = session else {
return 0;
};
let (ids, path) = injected_ids(session);
let fired = if ids.len() >= 2 {
let top: Vec<String> = ids.into_iter().take(8).collect();
pack()
.ok()
.and_then(|c| c.fire(&c.workspace(), &top).ok())
.map_or(0, |_| top.len())
} else {
0
};
if let Some(p) = path {
let _ = std::fs::remove_file(p);
}
fired
}
fn hook_hold_path(session: Option<&str>) -> Option<PathBuf> {
let dir = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.or_else(|| std::env::var_os("TMPDIR").map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("/tmp"));
let name = session
.filter(|s| !s.is_empty())
.map(|s| {
s.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.take(32)
.collect::<String>()
})
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "default".into());
Some(dir.join(format!("ljos-hook-hold-{name}")))
}
pub fn hold_hook_context(session: Option<&str>, context: &str) {
let Some(path) = hook_hold_path(session) else {
return;
};
if context.is_empty() {
let _ = std::fs::remove_file(&path);
return;
}
let _ = std::fs::write(path, context);
}
#[must_use]
pub fn take_hook_context(session: Option<&str>) -> String {
let Some(path) = hook_hold_path(session) else {
return String::new();
};
let text = std::fs::read_to_string(&path).unwrap_or_default();
let _ = std::fs::remove_file(&path);
text
}
fn mark_seen(session: Option<&str>, ids: &[String]) {
let Some(path) = session.and_then(seen_path) else {
return;
};
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let mut text = std::fs::read_to_string(&path).unwrap_or_default();
for id in ids {
text.push_str(id);
text.push('\n');
}
let _ = std::fs::write(path, text);
}
pub const HOOK_SCORE_FLOOR: f64 = 0.6;
#[must_use]
pub fn hook_context(call: &HookCall, limit: usize) -> String {
let cue = call.cue.trim();
if cue.len() < 3 {
return String::new();
}
let Ok(hits) = packset_search(cue) else {
return String::new();
};
let top = hits.iter().map(|h| h.score).fold(0.0_f64, f64::max);
if top <= 0.0 {
return String::new();
}
let seen = seen_ids(call.session.as_deref());
let mut rows: Vec<&Hit> = hits
.iter()
.filter(|h| !UNREVIEWED_KINDS.contains(&h.kind.as_str()))
.filter(|h| h.score >= top * HOOK_SCORE_FLOOR)
.filter(|h| agreed(h))
.filter(|h| h.id.as_ref().is_none_or(|id| !seen.contains(id)))
.collect();
rows.sort_by(|a, b| {
let pa = a.kind == "preference";
let pb = b.kind == "preference";
pb.cmp(&pa).then(
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal),
)
});
let mut rows: Vec<&Hit> = rows.into_iter().take(limit).collect();
let now = now_utc();
let split = rows.iter().filter(|h| h.kind == "preference").count();
rows[split..].sort_by_key(|h| days_of_stamp(h.ts.as_deref()).unwrap_or(i64::MAX));
let lines: Vec<String> = rows.iter().map(|h| hit_line(h, &now)).collect();
let mut nudge = due_nudge(call);
if let Some(c) = correction_nudge(call) {
if !nudge.is_empty() {
nudge.push('\n');
}
nudge.push_str(&c);
}
if lines.is_empty() {
return nudge;
}
mark_seen(
call.session.as_deref(),
&rows.iter().filter_map(|h| h.id.clone()).collect::<Vec<_>>(),
);
let mut out = format!(
"What this seat already knows that bears on this (from the pack, each with its age, lessons oldest first; `ljos search` for more):\n{}",
lines.join("\n")
);
if !nudge.is_empty() {
out.push('\n');
out.push_str(&nudge);
}
out
}
fn agreed(h: &Hit) -> bool {
match (h.ballots, h.of) {
(Some(named), Some(of)) if of >= 2 => named >= 2,
_ => true,
}
}
pub const CORRECTION_CUES: &[&str] = &[
"do you not remember",
"don't you remember",
"dont you remember",
"you should have",
"why did you not",
"why didn't you",
"why havent you",
"why haven't you",
"you forgot",
"i told you",
"i've told you",
"as i said",
"again you",
"still not",
"not even able",
"you never",
"you keep",
];
fn correction_nudge(call: &HookCall) -> Option<String> {
if call.event != "UserPromptSubmit" {
return None;
}
let lower = call.cue.to_lowercase();
let hit = CORRECTION_CUES.iter().find(|c| lower.contains(*c))?;
let key = format!("correction:{hit}");
if seen_ids(call.session.as_deref()).contains(&key) {
return None;
}
mark_seen(call.session.as_deref(), &[key]);
Some(
"This prompt reads as a correction. Before the work: write what it corrects as one \
`ljos prefer \"...\"` (a standing choice) or `ljos remember \"...\"` (a lesson), \
so the pack holds it and the hook can raise it next time."
.to_string(),
)
}
fn due_nudge(call: &HookCall) -> String {
if call.event != "UserPromptSubmit" {
return String::new();
}
let key = "due-nudge".to_string();
if seen_ids(call.session.as_deref()).contains(&key) {
return String::new();
}
let Ok(client) = pack() else {
return String::new();
};
let Ok(atoms) = client.atoms_as_of(&client.workspace(), None) else {
return String::new();
};
let due = due_of(&atoms, &now_utc()).len();
mark_seen(call.session.as_deref(), &[key]);
if due == 0 {
return String::new();
}
format!(
"{due} claim{} due for review in this seat: `ljos due`, read each, then `ljos graded ID` (or `--lapsed`).",
if due == 1 { " is" } else { "s are" }
)
}
#[must_use]
pub fn hook_output(call: &HookCall, context: &str) -> String {
hook_output_ruled(call, context, None)
}
#[must_use]
pub fn hook_output_ruled(call: &HookCall, context: &str, verdict: Option<&Rule>) -> String {
if context.is_empty() && verdict.is_none() {
return String::new();
}
if call.event == "argv" {
let mut out = String::new();
if let Some(r) = verdict {
out.push_str(&format!(
"{}: {} (rule `{}`)\n",
r.verdict, r.reason, r.pattern
));
}
if !context.is_empty() {
out.push_str(context);
out.push('\n');
}
return out;
}
let mut specific = serde_json::json!({ "hookEventName": call.event });
if !context.is_empty() {
specific["additionalContext"] = Value::String(context.to_string());
}
if let Some(r) = verdict {
if call.event == "PreToolUse" {
specific["permissionDecision"] = Value::String(r.verdict.clone());
specific["permissionDecisionReason"] =
Value::String(format!("{} (seat rule `{}`)", r.reason, r.pattern));
}
}
serde_json::json!({ "hookSpecificOutput": specific }).to_string() + "\n"
}
pub fn format_steps(steps: &[Step]) -> String {
steps
.iter()
.map(|s| {
format!(
"{}\t{}\t{}\n",
if s.ok { "ok" } else { "no" },
s.what,
s.detail
)
})
.collect()
}
fn harness_rows() -> Vec<Habitat> {
let path = harnesses_path();
let all = match harnesses_from(&path) {
Ok(all) => all,
Err(e) => {
return vec![Habitat {
name: "runners",
state: format!("{e:#}"),
ok: false,
}]
}
};
if all.harness.is_empty() {
return vec![Habitat {
name: "runners",
state: format!(
"none named in {}; `ljos onboard --example` prints the shape",
path.display()
),
ok: false,
}];
}
let server = server_path().unwrap_or_else(|_| PathBuf::from("ljos-mcp"));
let mut rows = Vec::new();
for h in &all.harness {
let registered = is_registered(h, &server) == Some(true);
rows.push(Habitat {
name: "runner mcp",
state: if registered {
format!("{}: ljos registered", h.name)
} else {
format!(
"{}: not registered; ljos onboard --harness {}",
h.name, h.name
)
},
ok: registered,
});
let skill = h
.skills
.as_deref()
.map(|d| expand(d).join("ljos").join("SKILL.md"));
let current = skill
.as_ref()
.is_some_and(|p| std::fs::read_to_string(p).is_ok_and(|t| t == skill_text()));
if let Some(file) = &h.hooks {
let path = expand(file);
let installed = hook_installed(&path, &hook_events_of(h));
rows.push(Habitat {
name: "runner hook",
state: if installed {
format!("{}: memory hook on {}", h.name, path.display())
} else {
format!(
"{}: no memory hook; ljos onboard --harness {}",
h.name, h.name
)
},
ok: installed,
});
}
rows.push(Habitat {
name: "runner skill",
state: match (&skill, current) {
(Some(p), true) => format!("{}: {}", h.name, p.display()),
(Some(p), false) if p.is_file() => {
format!(
"{}: {} is stale; ljos onboard --harness {}",
h.name,
p.display(),
h.name
)
}
(Some(_), false) => {
format!("{}: absent; ljos onboard --harness {}", h.name, h.name)
}
(None, _) => format!("{}: no skills directory named", h.name),
},
ok: current,
});
}
rows
}
fn pack_step(dry: bool) -> Step {
let what = "pack".to_string();
if let Ok(client) = pack() {
if client.health().is_ok() {
return Step {
what,
detail: format!("writer up at {}", client.base()),
ok: true,
};
}
} else {
return Step {
what,
detail: "PACKSET_URL=off; no pack on purpose".into(),
ok: true,
};
}
if !on_path("packset") {
return Step {
what,
detail: "no writer answers and packset is not on PATH".into(),
ok: false,
};
}
if dry {
return Step {
what,
detail: "would run packset ensure".into(),
ok: true,
};
}
match run_captured("packset", &["ensure"]) {
Ok(said) => Step {
what,
detail: format!(
"started a writer: {}",
said.stdout.lines().next().unwrap_or("").trim()
),
ok: true,
},
Err(e) => Step {
what,
detail: e.to_string().lines().next().unwrap_or("").to_string(),
ok: false,
},
}
}
fn host_key_step(dry: bool) -> Step {
if let Some(path) = host_key_path() {
return Step {
what: "host key".into(),
detail: format!("{} exists", path.display()),
ok: true,
};
}
if std::env::var_os("DEEDAR_HOST_SIGNING_KEY").is_some_and(|r| r == "off") {
return Step {
what: "host key".into(),
detail: "DEEDAR_HOST_SIGNING_KEY=off; handovers go out unsigned on purpose".into(),
ok: true,
};
}
let Some(path) = default_host_key_path() else {
return Step {
what: "host key".into(),
detail: "no home directory to keep a key in".into(),
ok: false,
};
};
if dry {
return Step {
what: "host key".into(),
detail: format!("would write a 32-byte seed to {}", path.display()),
ok: true,
};
}
let made = (|| -> std::io::Result<()> {
use std::io::Read;
let mut seed = [0u8; 32];
std::fs::File::open("/dev/urandom")?.read_exact(&mut seed)?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, seed)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
})();
match made {
Ok(()) => Step {
what: "host key".into(),
detail: format!("wrote a 32-byte seed to {}", path.display()),
ok: true,
},
Err(e) => Step {
what: "host key".into(),
detail: format!("{}: {e}", path.display()),
ok: false,
},
}
}
fn default_host_key_path() -> Option<PathBuf> {
let config = std::env::var_os("XDG_CONFIG_HOME")
.filter(|r| !r.is_empty())
.map(PathBuf::from)
.or_else(|| home().ok().map(|h| h.join(".config")))?;
Some(config.join("deedar").join("host.key"))
}
fn host_key_path() -> Option<PathBuf> {
if let Some(raw) = std::env::var_os("DEEDAR_HOST_SIGNING_KEY").filter(|r| !r.is_empty()) {
return (raw != "off").then(|| PathBuf::from(raw));
}
let path = default_host_key_path()?;
path.is_file().then_some(path)
}
pub const POLICY_TCB: &str =
"argv law. ljos-policyd is the TCB when present. Reloading a pack is not a check.";
pub const SEAT_WORKSPACE: &str = "seat";
fn load_seat_env() {
let Ok(home) = home() else {
return;
};
let path = home.join(".config/ljos/env");
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
let k = k.trim();
if k.is_empty() || std::env::var_os(k).is_some() {
continue;
}
std::env::set_var(k, v.trim());
}
}
pub fn pack() -> Result<PacksetClient> {
load_seat_env();
let workspace = std::env::var("PACKSET_WORKSPACE")
.ok()
.filter(|w| !w.is_empty())
.unwrap_or_else(|| SEAT_WORKSPACE.to_string());
Ok(PacksetClient::from_env()
.context("PACKSET_URL=off: this seat has no pack on purpose")?
.with_workspace(workspace))
}
pub fn join(parts: &[String]) -> String {
parts.join(" ")
}
pub fn atom_kind(label: &str) -> Result<&'static str> {
match label {
"Remember" => Ok("lesson"),
"Prefer" => Ok("preference"),
other => bail!("unknown write kind {other}"),
}
}
pub fn atom_body(kind: &str, text: &str, workspace: &str) -> Value {
serde_json::json!({
"schema": "inside.atom/v1",
"kind": kind,
"level": "explicit",
"text": text,
"workspace": workspace,
})
}
pub fn post_claim(
client: &PacksetClient,
label: &str,
text: &str,
workspace: &str,
) -> Result<Value> {
let trimmed = text.trim();
if trimmed.is_empty() {
bail!("{label}: empty text is not a claim");
}
let kind = atom_kind(label)?;
let atom = atom_body(kind, trimmed, workspace);
client
.post_atom(&atom)
.with_context(|| format!("{label}: POST /v1/atoms failed"))
}
pub fn packset_write(label: &str, text: &str) -> Result<Value> {
packset_write_as(label, text, None)
}
#[must_use]
pub fn persona_entity(name: &str) -> String {
format!("persona:{}", name.trim().to_lowercase())
}
pub fn packset_write_as(label: &str, text: &str, persona: Option<&str>) -> Result<Value> {
let client = pack()?;
let workspace = client.workspace();
let Some(name) = persona.map(str::trim).filter(|n| !n.is_empty()) else {
return post_claim(&client, label, text, &workspace);
};
let trimmed = text.trim();
if trimmed.is_empty() {
bail!("{label}: empty text is not a claim");
}
let kind = atom_kind(label)?;
let mut atom = atom_body(kind, trimmed, &workspace);
atom["entities"] = Value::Array(vec![Value::String(persona_entity(name))]);
client
.post_atom(&atom)
.with_context(|| format!("{label}: POST /v1/atoms failed"))
}
pub fn packset_forget(id: &str, why: Option<&str>) -> Result<Value> {
let trimmed = id.trim();
if trimmed.is_empty() {
bail!("forget: an atom id is required");
}
let why = why.map(str::trim).filter(|w| !w.is_empty());
let client = pack()?;
let workspace = client.workspace();
client
.delete_atom(&workspace, trimmed, why)
.with_context(|| format!("forget: POST /v1/atoms/delete failed for {trimmed}"))
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Trust {
pub from: String,
pub to: String,
pub weight: f64,
pub about: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Persona {
pub name: String,
pub anchor: f64,
pub view: String,
pub entities: Vec<String>,
}
pub fn persona_atom(p: &Persona, workspace: &str) -> Result<Value> {
let name = p.name.trim();
if name.is_empty() {
bail!("persona: a name is required");
}
if !(0.0..=1.0).contains(&p.anchor) {
bail!("persona: anchor {} is not in [0, 1]", p.anchor);
}
let view = p.view.trim();
if view.is_empty() {
bail!("persona: say in a sentence or two how {name} reads the work");
}
let mut atom = atom_body("persona", view, workspace);
atom["name"] = Value::String(name.into());
atom["anchor"] = serde_json::json!(p.anchor);
if !p.entities.is_empty() {
atom["entities"] = Value::Array(
p.entities
.iter()
.map(|e| Value::String(e.to_lowercase()))
.collect(),
);
}
Ok(atom)
}
pub fn write_persona(p: &Persona) -> Result<Value> {
let client = pack()?;
let workspace = client.workspace();
client
.post_atom(&persona_atom(p, &workspace)?)
.context("persona: POST /v1/atoms failed")
}
pub fn personas_of(atoms: &[Value]) -> Vec<Persona> {
let mut latest: std::collections::BTreeMap<String, (String, Persona)> =
std::collections::BTreeMap::new();
for atom in atoms {
if atom.get("kind").and_then(Value::as_str) != Some("persona") {
continue;
}
let (Some(name), Some(anchor)) = (
atom.get("name").and_then(Value::as_str),
atom.get("anchor").and_then(Value::as_f64),
) else {
continue;
};
let ts = atom
.get("ts")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let p = Persona {
name: name.to_string(),
anchor,
view: atom
.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
entities: words_of(atom.get("entities")),
};
match latest.get(name) {
Some((seen, _)) if *seen > ts => {}
_ => {
latest.insert(name.to_string(), (ts, p));
}
}
}
latest.into_values().map(|(_, p)| p).collect()
}
pub fn personas_from_pack() -> Result<Vec<Persona>> {
let client = pack()?;
let atoms = client
.atoms_as_of(&client.workspace(), None)
.context("persona: GET /v1/atoms failed")?;
Ok(personas_of(&atoms))
}
pub fn brief(name: &str, issue: &str) -> Result<String> {
let personas = personas_from_pack()?;
let Some(p) = personas.iter().find(|p| p.name == name) else {
let names: Vec<&str> = personas.iter().map(|p| p.name.as_str()).collect();
bail!(
"brief: no persona {name:?} in the pack; the pack holds {}",
if names.is_empty() {
"none".to_string()
} else {
names.join(", ")
}
);
};
let mut out = format!(
"You are {}. {}\nYou hold your ballot at anchor {:.2}{}.\n",
p.name,
p.view,
p.anchor,
if p.entities.is_empty() {
String::new()
} else {
format!("; you speak to {}", p.entities.join(", "))
}
);
let mut seen = std::collections::BTreeSet::new();
let mut lines = Vec::new();
let now = now_utc();
let client = pack()?;
let own_tag = persona_entity(&p.name);
if let Ok(atoms) = client.atoms_as_of(&client.workspace(), None) {
let mut own: Vec<&Value> = atoms
.iter()
.filter(|a| reviewable(a))
.filter(|a| words_of(a.get("entities")).contains(&own_tag))
.collect();
own.sort_by(|a, b| b["ts"].as_str().cmp(&a["ts"].as_str()));
if !own.is_empty() {
out.push_str("\nWhat you remembered yourself:\n");
for a in own.iter().take(8) {
if let Some(id) = a["id"].as_str() {
seen.insert(id.to_string());
}
out.push_str(&format!(
"- [{}{}] {}\n",
a["kind"].as_str().unwrap_or("claim"),
age_tag(a["ts"].as_str(), &now),
a["text"].as_str().unwrap_or("").trim()
));
}
}
}
let cues: Vec<String> = if p.entities.is_empty() {
vec![issue_title(issue)?]
} else {
p.entities.clone()
};
for cue in &cues {
let Ok(hits) = packset_search(cue) else {
continue;
};
for h in hits.into_iter().take(5) {
if UNREVIEWED_KINDS.contains(&h.kind.as_str()) {
continue;
}
if let Some(id) = &h.id {
if !seen.insert(id.clone()) {
continue;
}
}
lines.push((h.kind == "preference", hit_line(&h, &now)));
}
}
lines.sort_by(|a, b| b.0.cmp(&a.0));
if !lines.is_empty() {
out.push_str("\nWhat this seat knows on your domains:\n");
for (_, l) in lines.iter().take(8) {
out.push_str(l);
out.push('\n');
}
}
out.push_str("\nThe work:\n");
out.push_str(&run_captured("vissue", &["recall", issue])?.stdout);
out.push_str(&format!(
"\nRead it your way and end with one ballot: `ljos vote {issue} --for OPTION --as {}`. \
A lesson of your own goes in with `ljos remember --as {} \"...\"`.\n",
p.name, p.name
));
Ok(out)
}
pub fn panel(issue: &str, out: &Path) -> Result<String> {
let personas = personas_from_pack()?;
if personas.is_empty() {
bail!("panel: the pack holds no personas; `ljos persona NAME --anchor A --view ...` writes one");
}
std::fs::create_dir_all(out)?;
let mut lines = vec![format!(
"{} briefs in {}; start one subagent per file, each ends with its ballot, then:",
personas.len(),
out.display()
)];
for p in &personas {
let path = out.join(format!("{}.md", p.name));
std::fs::write(&path, brief(&p.name, issue)?)?;
lines.push(format!(" {}", path.display()));
}
lines.push(format!("ljos consensus {issue}"));
Ok(lines.join("\n") + "\n")
}
#[derive(Debug, Clone, PartialEq)]
pub struct Prediction {
pub issue: String,
pub agent: String,
pub expect: Value,
}
pub fn write_prediction(issue: &str, agent: &str, expect: &str) -> Result<Value> {
let (issue, agent, expect) = (issue.trim(), agent.trim(), expect.trim());
if issue.is_empty() || agent.is_empty() || expect.is_empty() {
bail!("predict: an issue, an identity and an expectation are required");
}
let expect_value: Value = match serde_json::from_str::<Value>(expect) {
Ok(v @ Value::Object(_)) => v,
_ => Value::String(expect.to_string()),
};
let client = pack()?;
let workspace = client.workspace();
let mut atom = atom_body(
"prediction",
&format!("{agent} expects {expect} on {issue}."),
&workspace,
);
atom["issue"] = Value::String(issue.into());
atom["agent"] = Value::String(agent.into());
atom["expect"] = expect_value;
client
.post_atom(&atom)
.context("predict: POST /v1/atoms failed")
}
pub fn predictions_of(atoms: &[Value], issue: &str) -> Vec<Prediction> {
let mut latest: std::collections::BTreeMap<String, (String, Prediction)> =
std::collections::BTreeMap::new();
for atom in atoms {
if atom.get("kind").and_then(Value::as_str) != Some("prediction")
|| atom.get("issue").and_then(Value::as_str) != Some(issue)
{
continue;
}
let (Some(agent), Some(expect)) = (
atom.get("agent").and_then(Value::as_str),
atom.get("expect"),
) else {
continue;
};
let ts = atom
.get("ts")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let p = Prediction {
issue: issue.to_string(),
agent: agent.to_string(),
expect: expect.clone(),
};
match latest.get(agent) {
Some((seen, _)) if *seen > ts => {}
_ => {
latest.insert(agent.to_string(), (ts, p));
}
}
}
latest.into_values().map(|(_, p)| p).collect()
}
pub fn predictions_json(predictions: &[Prediction]) -> String {
Value::Array(
predictions
.iter()
.map(|p| serde_json::json!({"agent": p.agent, "expect": p.expect}))
.collect(),
)
.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rule {
pub pattern: String,
pub verdict: String,
pub reason: String,
}
pub fn write_rule(rule: &Rule) -> Result<Value> {
let pattern = rule.pattern.trim();
if pattern.is_empty() {
bail!("rule: a pattern over the command line is required");
}
if !matches!(rule.verdict.as_str(), "deny" | "ask") {
bail!("rule: the verdict is deny or ask, not {:?}", rule.verdict);
}
let reason = rule.reason.trim();
if reason.is_empty() {
bail!("rule: say in a sentence why, so the reader who is stopped knows");
}
let client = pack()?;
let workspace = client.workspace();
let mut atom = atom_body("rule", reason, &workspace);
atom["pattern"] = Value::String(pattern.into());
atom["verdict"] = Value::String(rule.verdict.clone());
client
.post_atom(&atom)
.context("rule: POST /v1/atoms failed")
}
pub fn rules_of(atoms: &[Value]) -> Vec<Rule> {
atoms
.iter()
.filter(|a| a.get("kind").and_then(Value::as_str) == Some("rule"))
.filter_map(|a| {
Some(Rule {
pattern: a.get("pattern")?.as_str()?.to_string(),
verdict: a.get("verdict")?.as_str()?.to_string(),
reason: a
.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
})
})
.collect()
}
pub fn rules_from_pack() -> Result<Vec<Rule>> {
let client = pack()?;
let atoms = client
.atoms_as_of(&client.workspace(), None)
.context("rules: GET /v1/atoms failed")?;
Ok(rules_of(&atoms))
}
#[must_use]
pub fn glob_matches(pattern: &str, line: &str) -> bool {
fn go(p: &[char], l: &[char]) -> bool {
match (p.first(), l.first()) {
(None, None) => true,
(Some('*'), _) => go(&p[1..], l) || (!l.is_empty() && go(p, &l[1..])),
(Some('?'), Some(_)) => go(&p[1..], &l[1..]),
(Some(a), Some(b)) if a == b => go(&p[1..], &l[1..]),
_ => false,
}
}
let p: Vec<char> = pattern.chars().collect();
let l: Vec<char> = line.trim().chars().collect();
go(&p, &l)
}
#[must_use]
pub fn verdict_for<'a>(rules: &'a [Rule], line: &str) -> Option<&'a Rule> {
rules
.iter()
.find(|r| r.verdict == "deny" && glob_matches(&r.pattern, line))
.or_else(|| {
rules
.iter()
.find(|r| r.verdict == "ask" && glob_matches(&r.pattern, line))
})
}
pub fn anchors_json(personas: &[Persona]) -> String {
let map: serde_json::Map<String, Value> = personas
.iter()
.map(|p| (p.name.clone(), serde_json::json!(p.anchor)))
.collect();
Value::Object(map).to_string()
}
fn words_of(v: Option<&Value>) -> Vec<String> {
v.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_lowercase)
.collect()
}
pub fn island_entities(issue: &str) -> Result<Vec<String>> {
let title = issue_title(issue)?;
let island = packset_island(&title, false)?;
let ids: Vec<&str> = island["island"]
.as_array()
.into_iter()
.flatten()
.filter_map(|a| a["id"].as_str())
.collect();
if ids.is_empty() {
return Ok(Vec::new());
}
let client = pack()?;
let atoms = client
.atoms_as_of(&client.workspace(), None)
.context("island: GET /v1/atoms failed")?;
let mut count: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for atom in &atoms {
if atom
.get("id")
.and_then(Value::as_str)
.is_some_and(|id| ids.contains(&id))
{
for e in words_of(atom.get("entities")) {
*count.entry(e).or_insert(0) += 1;
}
}
}
let mut ranked: Vec<(String, usize)> = count.into_iter().collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
Ok(ranked.into_iter().take(8).map(|(e, _)| e).collect())
}
pub fn topic_words(title: &str) -> Vec<String> {
let mut words: Vec<String> = title
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() >= 3)
.map(str::to_lowercase)
.collect();
words.sort_unstable();
words.dedup();
words
}
pub fn rows_about(rows: &[Trust], topic: &[String]) -> Vec<Trust> {
let mut chosen: std::collections::BTreeMap<(String, String), Trust> =
std::collections::BTreeMap::new();
for r in rows {
let applies = r.about.is_empty() || r.about.iter().any(|a| topic.contains(a));
if !applies {
continue;
}
let key = (r.from.clone(), r.to.clone());
match chosen.get(&key) {
Some(have) if !have.about.is_empty() && r.about.is_empty() => {}
_ => {
chosen.insert(key, r.clone());
}
}
}
chosen.into_values().collect()
}
#[must_use]
pub fn learn_anchors(
personas: &[Persona],
ballots: &[(String, String)],
outcome: &str,
beta: f64,
) -> Vec<Persona> {
let outcome = outcome.trim();
personas
.iter()
.filter(|p| {
ballots
.iter()
.any(|(agent, choice)| *agent == p.name && choice != outcome)
})
.map(|p| Persona {
anchor: (p.anchor + (1.0 - p.anchor) * (1.0 - beta)).min(1.0),
..p.clone()
})
.collect()
}
pub fn learn_and_write(
ballots: &[(String, String)],
outcome: &str,
beta: f64,
about: &[String],
) -> Result<(Vec<Trust>, Vec<Persona>)> {
let client = pack()?;
let atoms = client
.atoms_as_of(&client.workspace(), None)
.context("learn: GET /v1/atoms failed")?;
let (rows, records) = learn_record(ballots, outcome, &records_from_atoms(&atoms), about)?;
let moved = learn_anchors(&personas_from_pack()?, ballots, outcome, beta);
for row in &rows {
write_trust_record(row, &[], records.get(&row.to).copied())?;
}
for p in &moved {
write_persona(p)?;
}
Ok((rows, moved))
}
pub type Standing = (f64, f64);
#[must_use]
pub fn records_from_atoms(atoms: &[Value]) -> std::collections::BTreeMap<String, Standing> {
let mut latest: std::collections::BTreeMap<String, (String, Standing)> =
std::collections::BTreeMap::new();
for atom in atoms {
if atom.get("kind").and_then(Value::as_str) != Some("trust") {
continue;
}
let (Some(to), Some(hits), Some(misses)) = (
atom.get("to").and_then(Value::as_str),
atom.get("hits").and_then(Value::as_f64),
atom.get("misses").and_then(Value::as_f64),
) else {
continue;
};
let ts = atom
.get("ts")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
match latest.get(to) {
Some((seen, _)) if *seen > ts => {}
_ => {
latest.insert(to.to_string(), (ts, (hits, misses)));
}
}
}
latest.into_iter().map(|(k, (_, r))| (k, r)).collect()
}
pub fn learn_record(
ballots: &[(String, String)],
outcome: &str,
records: &std::collections::BTreeMap<String, Standing>,
about: &[String],
) -> Result<(Vec<Trust>, std::collections::BTreeMap<String, Standing>)> {
let outcome = outcome.trim();
if outcome.is_empty() {
bail!("learn: an outcome is required");
}
let mut agents: Vec<&str> = ballots.iter().map(|(a, _)| a.as_str()).collect();
agents.sort_unstable();
agents.dedup();
if agents.len() < 2 {
bail!("learn: fewer than two voters, nothing to weigh");
}
let mut next = records.clone();
for (agent, choice) in ballots {
let r = next.entry(agent.clone()).or_insert((0.0, 0.0));
if choice == outcome {
r.0 += 1.0;
} else {
r.1 += 1.0;
}
}
let accuracy: Vec<(String, f64)> = agents
.iter()
.map(|a| {
let (h, m) = next.get(*a).copied().unwrap_or((0.0, 0.0));
((*a).to_string(), (h + 1.0) / (h + m + 2.0))
})
.collect();
let weights = calibration_weights(&accuracy);
let mut out = Vec::new();
for from in &agents {
for (to, weight) in &weights {
if *from == to {
continue;
}
out.push(Trust {
from: (*from).to_string(),
to: to.clone(),
weight: *weight,
about: about.to_vec(),
});
}
}
Ok((out, next))
}
pub fn write_trust_record(row: &Trust, why: &[String], record: Option<Standing>) -> Result<Value> {
let client = pack()?;
let workspace = client.workspace();
let mut atom = trust_atom(row, why, &workspace)?;
if let Some((hits, misses)) = record {
atom["hits"] = serde_json::json!(hits);
atom["misses"] = serde_json::json!(misses);
}
client
.post_atom(&atom)
.context("trust: POST /v1/atoms failed")
}
pub const LEARN_BETA: f64 = 0.5;
pub const TRUST_FLOOR: f64 = 0.01;
pub fn trust_atom(row: &Trust, why: &[String], workspace: &str) -> Result<Value> {
let (from, to) = (row.from.trim(), row.to.trim());
if from.is_empty() || to.is_empty() {
bail!("trust: from and to are required");
}
if from == to {
bail!("trust: {from} cannot weigh itself; self weight is the settle's");
}
if !(row.weight > 0.0 && row.weight <= 1.0) {
bail!("trust: weight {} is not in (0, 1]", row.weight);
}
let mut atom = atom_body(
"trust",
&format!("{from} weighs {to} at {:.3}.", row.weight),
workspace,
);
atom["from"] = Value::String(from.into());
atom["to"] = Value::String(to.into());
atom["weight"] = serde_json::json!(row.weight);
if !why.is_empty() {
atom["entities"] = Value::Array(why.iter().map(|w| Value::String(w.clone())).collect());
}
if !row.about.is_empty() {
atom["about"] = Value::Array(
row.about
.iter()
.map(|w| Value::String(w.to_lowercase()))
.collect(),
);
}
Ok(atom)
}
pub fn trust_rows(atoms: &[Value]) -> Vec<Trust> {
let mut latest: std::collections::BTreeMap<(String, String, Vec<String>), (String, f64)> =
std::collections::BTreeMap::new();
for atom in atoms {
if atom.get("kind").and_then(Value::as_str) != Some("trust") {
continue;
}
let (Some(from), Some(to), Some(weight)) = (
atom.get("from").and_then(Value::as_str),
atom.get("to").and_then(Value::as_str),
atom.get("weight").and_then(Value::as_f64),
) else {
continue;
};
let ts = atom
.get("ts")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let mut about = words_of(atom.get("about"));
about.sort_unstable();
let key = (from.to_string(), to.to_string(), about);
match latest.get(&key) {
Some((seen, _)) if *seen > ts => {}
_ => {
latest.insert(key, (ts, weight));
}
}
}
latest
.into_iter()
.map(|((from, to, about), (_, weight))| Trust {
from,
to,
weight,
about,
})
.collect()
}
pub fn trust_json(rows: &[Trust]) -> String {
let tuples: Vec<Value> = rows
.iter()
.map(|r| serde_json::json!([r.from, r.to, r.weight]))
.collect();
Value::Array(tuples).to_string()
}
pub fn ballots_from_json(raw: &str) -> Result<Vec<(String, String)>> {
let rows: Vec<Value> = serde_json::from_str(raw).context("ballots: not a JSON array")?;
rows.iter()
.map(|row| {
let agent = row.get("agent").and_then(Value::as_str);
let choice = row.get("choice").and_then(Value::as_str);
match (agent, choice) {
(Some(a), Some(c)) => Ok((a.to_string(), c.to_string())),
_ => bail!("ballots: a row without agent and choice"),
}
})
.collect()
}
pub fn learn(
ballots: &[(String, String)],
outcome: &str,
rows: &[Trust],
beta: f64,
) -> Result<Vec<Trust>> {
learn_about(ballots, outcome, rows, beta, &[])
}
pub fn learn_about(
ballots: &[(String, String)],
outcome: &str,
rows: &[Trust],
beta: f64,
about: &[String],
) -> Result<Vec<Trust>> {
learn_shared(ballots, outcome, rows, beta, about, 0.0)
}
pub fn learn_shared(
ballots: &[(String, String)],
outcome: &str,
rows: &[Trust],
beta: f64,
about: &[String],
share: f64,
) -> Result<Vec<Trust>> {
if !(beta > 0.0 && beta < 1.0) {
bail!("learn: beta {beta} is not in (0, 1)");
}
if !(0.0..1.0).contains(&share) {
bail!("learn: share {share} is not in [0, 1)");
}
let outcome = outcome.trim();
if outcome.is_empty() {
bail!("learn: an outcome is required");
}
let mut agents: Vec<&str> = ballots.iter().map(|(a, _)| a.as_str()).collect();
agents.sort_unstable();
agents.dedup();
if agents.len() < 2 {
bail!("learn: fewer than two voters, nothing to weigh");
}
let refuted = |agent: &str| {
ballots
.iter()
.any(|(a, choice)| a == agent && choice != outcome)
};
let mut out = Vec::new();
for from in &agents {
for to in &agents {
if from == to {
continue;
}
let current = rows
.iter()
.find(|r| r.from == *from && r.to == *to && r.about == about)
.or_else(|| {
rows.iter()
.find(|r| r.from == *from && r.to == *to && r.about.is_empty())
})
.map_or(1.0, |r| r.weight);
let stepped = if refuted(to) {
(current * beta).max(TRUST_FLOOR)
} else {
current
};
let next = stepped + (1.0 - stepped) * share;
out.push(Trust {
from: (*from).to_string(),
to: (*to).to_string(),
weight: next,
about: about.to_vec(),
});
}
}
Ok(out)
}
pub fn trust_from_pack() -> Result<Vec<Trust>> {
let client = pack()?;
let workspace = client.workspace();
let atoms = client
.atoms_as_of(&workspace, None)
.context("trust: GET /v1/atoms failed")?;
Ok(trust_rows(&atoms))
}
pub fn write_trust(row: &Trust, why: &[String]) -> Result<Value> {
let client = pack()?;
let workspace = client.workspace();
client
.post_atom(&trust_atom(row, why, &workspace)?)
.context("trust: POST /v1/atoms failed")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Habitat {
pub name: &'static str,
pub state: String,
pub ok: bool,
}
pub const REQUIRED: &[&str] = &[
"ljos",
"ljos-mcp",
"ljos-policyd",
"vissue",
"deedar",
"claimdag",
"packset",
"packsetd",
"packset-embed",
"pack",
"encoder",
];
const SEAT_BINS: &[(&str, &str)] = &[
("ljos", "ljos"),
("ljos-mcp", "ljos-mcp"),
("ljos-policyd", "ljos-policyd"),
("ljos-consensus", "ljos-consensus"),
("vissue", "vissue-cli"),
("deedar", "deedar-cli"),
("claimdag", "claimdag-cli"),
("packset", "packset"),
("packsetd", "packset-daemon"),
("packset-embed", "packset-embed"),
("packset-mcp", "packset-mcp"),
];
#[must_use]
pub fn parse_semver(text: &str) -> Option<&str> {
let bytes = text.as_bytes();
let mut i = 0;
while i + 4 < bytes.len() {
if bytes[i].is_ascii_digit() {
let start = i;
let mut dots = 0;
while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
if bytes[i] == b'.' {
dots += 1;
}
i += 1;
}
if dots >= 2 {
return Some(&text[start..i]);
}
}
i += 1;
}
None
}
fn bin_version(bin: &str) -> Option<String> {
if bin == "packset-mcp" {
return None;
}
let said = run_captured(bin, &["--version"]).ok()?;
parse_semver(&said.stdout).or_else(|| parse_semver(&said.stderr))
.map(str::to_string)
}
fn crate_max_version(name: &str) -> Option<String> {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Ok(guard) = cache.lock() {
if let Some(hit) = guard.get(name) {
return hit.clone();
}
}
let url = format!("https://crates.io/api/v1/crates/{name}");
let said = std::process::Command::new("curl")
.args(["-sS", "-A", "ljos-doctor", "--max-time", "3", &url])
.output()
.ok();
let got = said.and_then(|said| {
if !said.status.success() {
return None;
}
let v: serde_json::Value = serde_json::from_slice(&said.stdout).ok()?;
v["crate"]["max_version"].as_str().map(str::to_string)
});
if let Ok(mut guard) = cache.lock() {
guard.insert(name.to_string(), got.clone());
}
got
}
fn cmp_semver(a: &str, b: &str) -> Option<std::cmp::Ordering> {
let parse = |s: &str| -> Option<[u64; 3]> {
let mut it = s.split('.');
Some([
it.next()?.parse().ok()?,
it.next()?.parse().ok()?,
it.next()?.parse().ok()?,
])
};
Some(parse(a)?.cmp(&parse(b)?))
}
pub fn doctor() -> Vec<Habitat> {
let (mut out, runners) = std::thread::scope(|s| {
let runners = s.spawn(harness_rows);
let seat = doctor_seat();
(seat, runners.join().unwrap_or_default())
});
out.extend(runners);
out
}
pub fn doctor_seat() -> Vec<Habitat> {
let mut out = Vec::new();
for (bin, crate_name) in SEAT_BINS {
let found = which::which(bin).ok();
let latest = crate_max_version(crate_name);
let have = found.as_ref().and_then(|_| bin_version(bin));
let (state, ok) = match (found, have.as_deref(), latest.as_deref()) {
(None, _, Some(cr)) => (
format!("not on PATH; cargo binstall {crate_name} (crates.io {cr})"),
false,
),
(None, _, None) => ("not on PATH".into(), false),
(Some(path), have, Some(cr)) => {
let behind = have.is_some_and(|v| cmp_semver(v, cr) == Some(std::cmp::Ordering::Less));
let ver = have.unwrap_or("?");
if behind {
(
format!("{} {ver} behind crates.io {cr}", path.display()),
false,
)
} else {
(format!("{} {ver} crates.io {cr}", path.display()), true)
}
}
(Some(path), have, None) => {
let ver = have.unwrap_or("?");
(format!("{} {ver}", path.display()), true)
}
};
out.push(Habitat {
name: bin,
state,
ok,
});
}
let (seat, source) = ["LJOS_SEAT", "VISSUE_AGENT"]
.iter()
.find_map(|k| {
std::env::var(k)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.map(|v| (v, *k))
})
.unwrap_or_else(|| ("seat".to_string(), "the default"));
out.push(Habitat {
name: "seat",
state: format!("{seat} (from {source})"),
ok: true,
});
load_seat_env();
out.push(match PacksetClient::from_env().and_then(|c| c.status(None)) {
Ok(status) => {
let available = status["embedder"]["available"].as_bool().unwrap_or(false);
Habitat {
name: "encoder",
state: if available {
"dense ballot on".to_string()
} else {
"down; cargo binstall packset-embed and put it beside packsetd".to_string()
},
ok: available,
}
}
Err(e) => Habitat {
name: "encoder",
state: format!("pack does not answer: {e}"),
ok: false,
},
});
out.push(match pack() {
Ok(client) => match client.health() {
Ok(_) => Habitat {
name: "pack",
state: format!("{} workspace {}", client.base(), client.workspace()),
ok: true,
},
Err(e) => Habitat {
name: "pack",
state: format!("{} does not answer: {e}", client.base()),
ok: false,
},
},
Err(_) => Habitat {
name: "pack",
state: "PACKSET_URL=off: no pack on purpose".into(),
ok: false,
},
});
out.push(match host_key_path() {
Some(path) => {
let seed = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0) == 32;
Habitat {
name: "host key",
state: if seed {
format!("{} (32-byte seed)", path.display())
} else {
format!("{} is not a 32-byte seed", path.display())
},
ok: seed,
}
}
None => Habitat {
name: "host key",
state: "none at ~/.config/deedar/host.key and DEEDAR_HOST_SIGNING_KEY unset; \
handovers go out unsigned"
.into(),
ok: false,
},
});
for (name, bin, args) in [
("deed store", "deedar", &["log", "head"][..]),
("tracker", "vissue", &["identity"][..]),
("claim graph", "claimdag", &["list"][..]),
] {
out.push(match run_captured(bin, args) {
Ok(said) => Habitat {
name,
state: said.stdout.lines().next().unwrap_or("").to_string(),
ok: true,
},
Err(e) => Habitat {
name,
state: e.to_string().lines().next().unwrap_or("").to_string(),
ok: false,
},
});
}
out
}
pub fn healthy(rows: &[Habitat]) -> bool {
rows.iter()
.all(|h| h.ok || !REQUIRED.contains(&h.name) && h.name != "pack")
}
pub fn format_doctor(rows: &[Habitat]) -> String {
rows.iter()
.map(|h| {
format!(
"{} {} {}
",
if h.ok { "ok" } else { "no" },
h.name,
h.state
)
})
.collect()
}
pub fn needs_of(satchel_json: &str) -> Result<Vec<String>> {
let v: Value = serde_json::from_str(satchel_json).context("satchel.json")?;
Ok(v.get("needs")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default())
}
pub fn enclose(needs: Vec<String>, cited: &str) -> Vec<String> {
let mut all: Vec<String> = needs
.into_iter()
.chain(cited.lines().map(str::trim).map(str::to_string))
.filter(|s| !s.is_empty())
.collect();
all.sort();
all.dedup();
all
}
pub fn handover(out: &Path, projects: &[String], issues: &[String]) -> Result<Vec<String>> {
if projects.is_empty() && issues.is_empty() {
bail!("handover: name a project or an issue");
}
let mut lines = Vec::new();
let mut args = vec![
"satchel".to_string(),
"--out".into(),
out.display().to_string(),
];
for p in projects {
args.push("--project".into());
args.push(p.clone());
}
for i in issues {
args.push("--issue".into());
args.push(i.clone());
}
lines.push(run_captured("vissue", &args)?.stdout.trim_end().to_string());
let mut cited = String::new();
match PacksetClient::from_env() {
Ok(client) => {
let atoms_dir = out.join("data").join("atoms");
match run_captured(
"packset",
&[
"export",
"--into",
&atoms_dir.display().to_string(),
&client.workspace(),
],
) {
Ok(said) => {
cited = said.stdout;
lines.push(said.stderr.trim_end().to_string());
}
Err(e) => lines.push(format!("atoms not enclosed: {e}")),
}
}
Err(_) => lines.push("no pack: PACKSET_URL=off, atoms not enclosed".into()),
}
let description = std::fs::read_to_string(out.join("data").join("satchel.json"))
.context("handover: the satchel has no description")?;
let deeds = enclose(needs_of(&description)?, &cited);
if deeds.is_empty() {
lines.push("no deeds cited".into());
} else {
let deeds_dir = out.join("data").join("deeds");
let said = run_fed(
"deedar",
&["export", "--into", &deeds_dir.display().to_string(), "-"],
&format!(
"{}
",
deeds.join(
"
"
)
),
)?;
lines.push(said.stdout.trim_end().to_string());
}
lines.push(
run_captured("vissue", &["satchel", "--seal", &out.display().to_string()])?
.stdout
.trim_end()
.to_string(),
);
if host_key_path().is_some() {
let manifest = out.join("manifest-sha256.txt");
let said = run_captured(
"deedar",
&["vouch", "sign", &manifest.display().to_string()],
)?;
lines.push(said.stdout.trim_end().to_string());
} else {
lines.push(
"unsigned: no host key at ~/.config/deedar/host.key and DEEDAR_HOST_SIGNING_KEY unset; \
`ljos onboard` writes one"
.into(),
);
}
Ok(lines)
}
pub fn receive(dir: &Path, since: Option<&Path>, import: bool) -> Result<Vec<String>> {
let mut lines = Vec::new();
lines.push(
run_captured(
"vissue",
&["satchel", "--verify", &dir.display().to_string()],
)?
.stdout
.trim_end()
.to_string(),
);
if dir.join("data").join("deeds").is_dir() {
let mut args = vec!["check".to_string(), dir.display().to_string()];
if let Some(bridge) = since {
args.push("--since".into());
args.push(bridge.display().to_string());
}
lines.push(run_captured("deedar", &args)?.stdout.trim_end().to_string());
} else {
lines.push("no deeds enclosed".into());
}
let manifest = dir.join("manifest-sha256.txt");
let mut sender = "from:handover".to_string();
if manifest.with_extension("txt.sig").is_file() {
let said = run_captured(
"deedar",
&["vouch", "check", &manifest.display().to_string()],
)?
.stdout
.trim_end()
.to_string();
if let Some(hex) = said
.strip_prefix("signed by ")
.and_then(|rest| rest.split(|c: char| !c.is_ascii_hexdigit()).next())
.filter(|h| h.len() >= 12)
{
sender = format!("from:{}", &hex[..12]);
}
lines.push(said);
} else {
lines.push("unsigned".into());
}
let atoms = enclosed_atoms(dir)?;
let rows = trust_rows(&atoms);
lines.push(format!(
"{} atoms enclosed, {} trust rows",
atoms.len(),
rows.len()
));
if import {
let client = pack()?;
let workspace = client.workspace();
let (mut kept, mut refused) = (0usize, Vec::new());
for atom in &atoms {
let mut atom = atom.clone();
if let Some(map) = atom.as_object_mut() {
map.insert("workspace".into(), Value::String(workspace.clone()));
let mut entities: Vec<Value> = map
.get("entities")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if !entities.iter().any(|e| e.as_str() == Some(sender.as_str())) {
entities.push(Value::String(sender.clone()));
}
map.insert("entities".into(), Value::Array(entities));
}
match client.post_atom(&atom) {
Ok(_) => kept += 1,
Err(e) => refused.push(e.to_string()),
}
}
lines.push(format!("{kept} atoms imported, {} refused", refused.len()));
lines.extend(refused.into_iter().take(5));
if kept > 0 {
lines.push(
"imported claims may rewrite held ones; `ljos consolidate` reports the pairs, `--apply` closes them"
.to_string(),
);
}
}
Ok(lines)
}
pub fn enclosed_atoms(dir: &Path) -> Result<Vec<Value>> {
let atoms_dir = dir.join("data").join("atoms");
let Ok(entries) = std::fs::read_dir(&atoms_dir) else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for entry in entries.flatten() {
let text = std::fs::read_to_string(entry.path())?;
for line in text.lines().filter(|l| !l.trim().is_empty()) {
out.push(
serde_json::from_str(line).with_context(|| entry.path().display().to_string())?,
);
}
}
Ok(out)
}
const UNREVIEWED_KINDS: &[&str] = &["trust", "persona"];
fn reviewable(a: &Value) -> bool {
!UNREVIEWED_KINDS.contains(&a.get("kind").and_then(Value::as_str).unwrap_or(""))
}
pub fn due_of(atoms: &[Value], now: &str) -> Vec<Value> {
let mut due: Vec<Value> = atoms
.iter()
.filter(|a| reviewable(a))
.filter(|a| {
a.get("due_at")
.and_then(Value::as_str)
.is_none_or(|d| d.is_empty() || d <= now)
})
.cloned()
.collect();
due.sort_by(|a, b| {
a["due_at"]
.as_str()
.unwrap_or("")
.cmp(b["due_at"].as_str().unwrap_or(""))
});
due
}
pub fn review_summary(atoms: &[Value], now: &str) -> String {
let due = due_of(atoms, now).len();
let mut later: Vec<&str> = atoms
.iter()
.filter(|a| reviewable(a))
.filter_map(|a| a.get("due_at").and_then(Value::as_str))
.filter(|d| !d.is_empty() && *d > now)
.collect();
later.sort_unstable();
match later.first() {
Some(next) => format!("{due} due; {} scheduled, next at {next}", later.len()),
None if due == 0 => "0 due; nothing scheduled: this seat has remembered nothing yet".into(),
None => format!("{due} due; nothing else scheduled"),
}
}
pub fn due_report() -> Result<String> {
let client = pack()?;
let atoms = client
.atoms_as_of(&client.workspace(), None)
.context("due: GET /v1/atoms failed")?;
let now = now_utc();
Ok(format!(
"{}{}\n",
format_due(&due_of(&atoms, &now)),
review_summary(&atoms, &now)
))
}
pub fn due() -> Result<Vec<Value>> {
let client = pack()?;
let atoms = client
.atoms_as_of(&client.workspace(), None)
.context("due: GET /v1/atoms failed")?;
Ok(due_of(&atoms, &now_utc()))
}
pub fn format_due(atoms: &[Value]) -> String {
atoms
.iter()
.map(|a| {
format!(
"{} {} {} {}
",
a["due_at"]
.as_str()
.filter(|d| !d.is_empty())
.unwrap_or("unreviewed"),
a["kind"].as_str().unwrap_or(""),
a["id"].as_str().unwrap_or("-"),
a["text"].as_str().unwrap_or("")
)
})
.collect()
}
pub fn graded(id: &str, recalled: bool) -> Result<Value> {
let id = id.trim();
if id.is_empty() {
bail!("graded: an atom id is required");
}
let client = pack()?;
client
.grade(&client.workspace(), id, recalled)
.with_context(|| format!("graded: POST /v1/grade failed for {id}"))
}
#[must_use]
pub fn now_utc() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = secs / 86_400;
let rem = secs % 86_400;
let z = days as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
rem / 3600,
rem % 3600 / 60,
rem % 60
)
}
pub fn run_fed(bin: &str, args: &[impl AsRef<str>], input: &str) -> Result<Said> {
use std::io::Write;
use std::process::{Command, Stdio};
let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
let mut cmd = Command::new(path);
for a in args {
cmd.arg(a.as_ref());
}
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("{bin}: could not start"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(input.as_bytes())?;
}
let out = child.wait_with_output()?;
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
if !out.status.success() {
let why = if stderr.trim().is_empty() {
stdout.trim().to_string()
} else {
stderr.trim().to_string()
};
bail!("{bin} exited {}: {why}", out.status);
}
Ok(Said { stdout, stderr })
}
pub fn work_id(name: &str) -> String {
let name = name.trim();
if name.len() == 32 && name.bytes().all(|b| b.is_ascii_hexdigit()) {
return name.to_ascii_lowercase();
}
const OFFSET: u128 = 0x6c62_272e_07bb_0142_62b8_2175_6295_c58d;
const PRIME: u128 = 0x0000_0000_0100_0000_0000_0000_0000_013b;
let mut h = OFFSET;
for b in name.bytes() {
h ^= u128::from(b);
h = h.wrapping_mul(PRIME);
}
format!("{h:032x}")
}
pub fn node_for(issue: &str) -> Result<String> {
let id = work_id(issue);
if id != issue.trim() && run_captured("claimdag", &["get", &id]).is_err() {
run_captured(
"claimdag",
&["upsert", "--id", &id, "--summary", issue.trim()],
)
.with_context(|| format!("claim: could not mint a node for {issue}"))?;
}
Ok(id)
}
pub fn packset_island(cue: &str, fire: bool) -> Result<Value> {
let cue = cue.trim();
if cue.is_empty() {
bail!("island: pass the task or question at hand");
}
let client = pack()?;
let workspace = client.workspace();
client
.activate(&workspace, cue, 24, fire)
.context("island: GET /v1/activate failed")
}
pub fn packset_hubs(limit: usize) -> Result<Value> {
let client = pack()?;
let workspace = client.workspace();
client
.hubs(&workspace, limit)
.context("hubs: GET /v1/hubs failed")
}
pub fn conflicts(limit: usize) -> Result<String> {
if which::which("landscape").is_err() {
bail!(
"conflicts: `landscape` is not on PATH; it is the optional habitat that reads the pack's geometry (leidarljos/landscape)"
);
}
let client = pack()?;
let said = match run_captured(
"landscape",
&[
"--atoms",
client.base(),
"--workspace",
&client.workspace(),
"--conflicts",
],
) {
Ok(said) => said,
Err(e) if e.to_string().contains("at least two") => {
return Ok(
"fewer than two memories with embeddings in the pack; conflicts by geometry need the encoder (`packset doctor` shows it)\n"
.to_string(),
);
}
Err(e) => return Err(e),
};
let v: Value =
serde_json::from_str(&said.stdout).context("conflicts: landscape printed no JSON")?;
let now = now_utc();
let atoms = client
.atoms_as_of(&client.workspace(), None)
.unwrap_or_default();
let stamp_of = |id: &str| -> Option<String> {
atoms
.iter()
.find(|a| a["id"].as_str() == Some(id))
.and_then(|a| a["ts"].as_str().map(str::to_string))
};
let recalled = |id: &str| -> bool {
atoms
.iter()
.find(|a| a["id"].as_str() == Some(id))
.is_none_or(reviewable)
};
let mut out = String::new();
for pair in v["pairs"]
.as_array()
.into_iter()
.flatten()
.filter(|p| {
recalled(p["a"].as_str().unwrap_or("")) && recalled(p["b"].as_str().unwrap_or(""))
})
.take(limit)
{
let a = pair["a"].as_str().unwrap_or("-");
let b = pair["b"].as_str().unwrap_or("-");
out.push_str(&format!(
"pass {:.3}\n {a} {} {}\n {b} {} {}\n",
pair["barrier"].as_f64().unwrap_or(0.0),
age_of(stamp_of(a).as_deref(), &now),
pair["a_text"].as_str().unwrap_or("").trim(),
age_of(stamp_of(b).as_deref(), &now),
pair["b_text"].as_str().unwrap_or("").trim()
));
}
let n = v["pairs"].as_array().map_or(0, Vec::len);
out.push_str(&format!(
"{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",
v["sigma"].as_f64().unwrap_or(0.0)
));
Ok(out)
}
pub fn packset_consolidate(apply: bool) -> Result<Value> {
let client = pack()?;
let workspace = client.workspace();
client
.consolidate(&workspace, apply)
.context("consolidate: POST /v1/consolidate failed")
}
pub fn format_consolidation(body: &Value) -> String {
let mut out = String::new();
for pair in body["pairs"].as_array().into_iter().flatten() {
out.push_str(&format!(
"closes {} {}\n for {} {}\n",
pair["old"].as_str().unwrap_or("-"),
pair["old_text"].as_str().unwrap_or("").trim(),
pair["new"].as_str().unwrap_or("-"),
pair["new_text"].as_str().unwrap_or("").trim()
));
}
let closed = body["closed"].as_u64().unwrap_or(0);
let live = body["live"].as_u64().unwrap_or(0);
if body["applied"].as_bool().unwrap_or(false) {
out.push_str(&format!("{closed} of {live} live memories closed\n"));
} else {
out.push_str(&format!(
"{closed} of {live} live memories would close; `ljos consolidate --apply` closes them\n"
));
}
out
}
pub fn format_hubs(body: &Value) -> String {
let mut out = String::new();
for hub in body["hubs"]
.as_array()
.into_iter()
.flatten()
.filter(|a| reviewable(a))
{
out.push_str(&format!(
"{:.4}\t{}\t{}\t{}\n",
hub["score"].as_f64().unwrap_or(0.0),
hub["links"].as_u64().unwrap_or(0),
hub["id"].as_str().unwrap_or("-"),
hub["text"].as_str().unwrap_or("")
));
}
out
}
pub fn format_island(body: &Value) -> String {
let mut out = String::new();
let now = now_utc();
if body["weak"].as_bool().unwrap_or(false) {
out.push_str(&format!(
"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",
body["agreed_seeds"].as_u64().unwrap_or(0),
if body["agreed_seeds"].as_u64().unwrap_or(0) == 1 { "" } else { "s" },
if body["dense"].as_bool().unwrap_or(true) { "" } else { "; the encoder is down, ranking is lexical only" }
));
}
for atom in body["island"]
.as_array()
.into_iter()
.flatten()
.filter(|a| reviewable(a))
{
out.push_str(&format!(
"{:.3}\t{}\t{}\t{}\t{}\n",
atom["activation"].as_f64().unwrap_or(0.0),
if atom["seed"].as_bool().unwrap_or(false) {
"seed"
} else {
" "
},
atom["id"].as_str().unwrap_or("-"),
age_of(atom["ts"].as_str(), &now),
atom["text"].as_str().unwrap_or("")
));
}
out
}
pub fn packset_search(query: &str) -> Result<Vec<Hit>> {
packset_search_opts(query, 10, false)
}
pub fn packset_search_opts(query: &str, limit: u32, rerank: bool) -> Result<Vec<Hit>> {
packset_search_as_of(query, limit, None, rerank)
}
pub fn packset_search_as_of(
query: &str,
limit: u32,
as_of: Option<&str>,
rerank: bool,
) -> Result<Vec<Hit>> {
let q = query.trim();
if q.is_empty() {
bail!("search: empty query");
}
let as_of = as_of.map(str::trim).filter(|s| !s.is_empty());
let stamp = match as_of {
Some(at) if days_of_stamp(Some(at)).is_none() => {
bail!("search: --as-of {at:?} is not a date; write YYYY-MM-DD or RFC 3339")
}
Some(at) if at.len() == 10 => Some(format!("{at}T00:00:00.000Z")),
Some(at) => Some(at.to_string()),
None => None,
};
let client = pack()?;
let workspace = client.workspace();
client
.search_opts(&workspace, q, limit, stamp.as_deref(), rerank)
.context("search: GET /v1/search failed")
}
fn holder_of(get_output: &str) -> Option<String> {
get_output
.split_whitespace()
.find_map(|w| w.strip_prefix("assignee="))
.filter(|h| h.len() == 32 && *h != "00000000000000000000000000000000")
.map(str::to_string)
}
pub fn claim(node: &str, assignee: &str) -> Result<String> {
let id = node_for(node)?;
let actor = work_id(assignee);
match run_captured("claimdag", &["claim", &id, "--assignee", &actor]) {
Ok(said) => Ok(said.stdout),
Err(e) => {
let text = e.to_string();
if ["status done", "status failed", "status cancelled"]
.iter()
.any(|s| text.contains(s))
{
run_captured("claimdag", &["reopen", &id, "--actor", &actor])?;
let said = run_captured("claimdag", &["claim", &id, "--assignee", &actor])?;
return Ok(format!("reopened a finished session node\n{}", said.stdout));
}
if text.contains("status claimed") {
let got = run_captured("claimdag", &["get", &id])?.stdout;
return match holder_of(&got) {
Some(holder) if holder == actor => {
let renewed = run_captured("claimdag", &["renew", &id, "--actor", &actor])
.map(|s| s.stdout)
.unwrap_or_default();
Ok(format!(
"already held by {assignee}; the sitting resumes\n{renewed}"
))
}
Some(holder) => bail!(
"claim: {node} is held by another seat (actor {holder}); that seat frees it with `ljos release {node}` or `ljos complete {node}`"
),
None => Err(e),
};
}
if !text.contains("assignee busy") {
return Err(e);
}
let held: Vec<String> = text
.split_whitespace()
.filter(|w| w.len() == 32 && w.chars().all(|c| c.is_ascii_hexdigit()))
.map(str::to_string)
.collect();
let mut lines = vec![format!(
"claim: {assignee} already holds a live node; one live claim per assignee."
)];
for hex in &held {
let name = run_captured("claimdag", &["get", hex])
.ok()
.and_then(|s| {
s.stdout
.lines()
.next()
.and_then(|l| l.split_whitespace().last())
.map(str::to_string)
})
.unwrap_or_else(|| hex.clone());
lines.push(format!(
" holds {name}: `ljos complete {name} --status done` finishes it, \
`ljos release {name} --assignee {assignee}` hands it back"
));
}
bail!("{}", lines.join("\n"))
}
}
}
pub fn release(node: &str, assignee: &str) -> Result<String> {
let id = node_for(node)?;
Ok(run_captured("claimdag", &["release", &id, "--actor", &work_id(assignee)])?.stdout)
}
fn revision_note(body: &Value) -> String {
match body["supersedes"].as_array().map(Vec::len).unwrap_or(0) {
0 => String::new(),
1 => "; revises 1 earlier memory, now closed".to_string(),
n => format!("; revises {n} earlier memories, now closed"),
}
}
fn issue_title(issue: &str) -> Result<String> {
let said = run_captured("vissue", &["show", issue, "--json"])?;
let v: Value = serde_json::from_str(&said.stdout).context("vissue show --json")?;
Ok(v.get("title")
.and_then(Value::as_str)
.unwrap_or(issue)
.to_string())
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Event {
pub days: i64,
pub clock: String,
pub source: &'static str,
pub text: String,
}
pub fn timeline(issue: &str, limit: usize) -> Result<String> {
let said = run_captured("vissue", &["show", issue, "--json"])?;
let v: Value = serde_json::from_str(&said.stdout).context("vissue show --json")?;
let title = v["title"].as_str().unwrap_or(issue).to_string();
let mut events = tracker_events(&v);
for accession in v["deeds"].as_array().into_iter().flatten() {
let Some(accession) = accession.as_str() else {
continue;
};
if let Ok(said) = run_captured("deedar", &["evidence", accession]) {
if let Some(ev) = deed_event(accession, &said.stdout) {
events.push(ev);
}
}
}
if let Ok(island) = packset_island(&title, false) {
for atom in island["island"]
.as_array()
.into_iter()
.flatten()
.filter(|a| reviewable(a))
.take(8)
{
if let Some((days, clock)) = stamp_key(atom["ts"].as_str()) {
events.push(Event {
days,
clock,
source: "memory",
text: format!(
"[{}] {}",
atom["kind"].as_str().unwrap_or("claim"),
atom["text"].as_str().unwrap_or("").trim()
),
});
}
}
}
events.sort_by(|a, b| (a.days, &a.clock).cmp(&(b.days, &b.clock)));
let skip = events.len().saturating_sub(limit);
Ok(format!(
"timeline of {issue}: {title}
{}",
format_events(&events[skip..], &now_utc())
))
}
fn tracker_events(v: &Value) -> Vec<Event> {
let mut events = Vec::new();
let mut push = |stamp: Option<&str>, source: &'static str, text: String| {
if let Some((days, clock)) = stamp_key(stamp) {
events.push(Event {
days,
clock,
source,
text,
});
}
};
push(
v["properties"]["CREATED"].as_str(),
"tracker",
"created".to_string(),
);
if let Some(by) = v["claimed_by"].as_str() {
push(
v["claimed_at"].as_str(),
"tracker",
format!("claimed by {by}"),
);
}
for e in v["logbook"].as_array().into_iter().flatten().rev() {
let stamp = e["timestamp"].as_str();
if let Some(note) = e["note"].as_str() {
push(stamp, "tracker", format!("note: {}", note.trim()));
} else if let Some(to) = e["to_state"].as_str() {
push(
stamp,
"tracker",
format!("{} -> {to}", e["from_state"].as_str().unwrap_or("-")),
);
}
}
events
}
fn deed_event(accession: &str, evidence: &str) -> Option<Event> {
let secs: i64 = evidence
.lines()
.find_map(|l| l.strip_prefix("time="))?
.trim()
.parse()
.ok()?;
let by = evidence
.lines()
.find_map(|l| l.strip_prefix("producedBy="))
.map(str::trim)
.unwrap_or("-");
Some(Event {
days: secs.div_euclid(86_400),
clock: format!(
"{:02}:{:02}",
secs.rem_euclid(86_400) / 3600,
secs.rem_euclid(86_400) % 3600 / 60
),
source: "deed",
text: format!("{accession} produced by {by}"),
})
}
fn stamp_key(stamp: Option<&str>) -> Option<(i64, String)> {
let s = stamp?.trim().trim_start_matches('[').trim_end_matches(']');
let days = days_of_stamp(Some(s))?;
let rest = &s[10..];
let clock = rest
.split(['T', ' '])
.find(|t| t.len() >= 5 && t.as_bytes()[2] == b':')
.map(|t| t[..5].to_string())
.unwrap_or_default();
Some((days, clock))
}
fn format_events(events: &[Event], now: &str) -> String {
let today = days_of_stamp(Some(now)).unwrap_or(0);
let mut out = String::new();
let mut last: Option<i64> = None;
for e in events {
let gap = match last {
None => String::new(),
Some(d) if e.days == d => "same day".to_string(),
Some(d) => format!("+{} d", e.days - d),
};
last = Some(e.days);
out.push_str(&format!(
"{} {} {} {} {} {}
",
civil_of_days(e.days),
e.clock,
age_of(Some(&civil_of_days(e.days)), &civil_of_days(today)),
gap,
e.source,
e.text
));
}
out
}
fn civil_of_days(days: i64) -> String {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}")
}
pub fn sitting(issue: &str, assignee: &str, cards_dir: &Path) -> Result<String> {
let mut out = String::new();
let rows = doctor_seat();
out.push_str("== doctor\n");
out.push_str(&format_doctor(&rows));
if !healthy(&rows) {
bail!("{out}sitting: a required habitat does not answer; nothing was claimed");
}
out.push_str("== cards\n");
out.push_str(&cards(cards_dir)?);
out.push_str("== due\n");
out.push_str(&due_report()?);
let title = issue_title(issue)?;
out.push_str(&format!("== island: {title}\n"));
let island = packset_island(&title, false)?;
let mut top = island.clone();
if let Some(rows) = top["island"].as_array_mut() {
rows.truncate(8);
}
out.push_str(&format_island(&top));
out.push_str("== recall\n");
out.push_str(&run_captured("vissue", &["recall", issue])?.stdout);
out.push_str("== timeline\n");
out.push_str(&timeline(issue, 12)?);
out.push_str("== claim\n");
out.push_str(&claim(issue, assignee)?);
Ok(out)
}
pub fn finish(
issue: &str,
status: &str,
lesson: Option<&str>,
outcome: Option<&str>,
beta: f64,
) -> Result<String> {
let mut out = String::new();
match lesson.map(str::trim).filter(|l| !l.is_empty()) {
Some(text) => {
let body = packset_write("Remember", text)?;
out.push_str(&format!(
"remembered {}{}\n",
body.get("id").and_then(Value::as_str).unwrap_or("-"),
revision_note(&body)
));
}
None => out.push_str(
"no lesson remembered this sitting; `ljos remember` takes one in two sentences\n",
),
}
let title = issue_title(issue)?;
let island = packset_island(&title, true)?;
if island["weak"].as_bool().unwrap_or(false) {
out.push_str(&format!(
"did not fire the island for {title:?}: its seeds are hits no two scorers agreed on{}; wiring them would tighten the wrong links\n",
if island["dense"].as_bool().unwrap_or(true) { "" } else { " (the encoder is down, ranking is lexical only)" }
));
} else {
let fired = island["island"].as_array().map_or(0, Vec::len);
out.push_str(&format!(
"fired the island for {title:?}: {fired} memories\n"
));
}
let terminal = ["done", "failed", "cancelled"];
if !terminal.contains(&status) {
bail!("finish: status {status:?} is not one of done, failed, cancelled");
}
run_captured(
"claimdag",
&["complete", &node_for(issue)?, "--status", status],
)?;
out.push_str(&format!(
"completed the session node for {issue} as {status}\n"
));
if let Some(option) = outcome.map(str::trim).filter(|o| !o.is_empty()) {
let said = run_captured("vissue", &["vote", issue, "--json"])?;
let ballots = ballots_from_json(&said.stdout)?;
if ballots.len() < 2 {
out.push_str("outcome named but fewer than two ballots; nothing to learn from\n");
} else {
let about = island_entities(issue).unwrap_or_default();
let (rows, moved) = learn_and_write(&ballots, option, beta, &about)?;
out.push_str(&format!(
"learned from outcome {option:?}: {} trust rows rewritten, {} persona anchors moved\n",
rows.len(),
moved.len()
));
}
}
out.push_str(&format!(
"the ticket stays {issue}'s state; `vissue update {issue} -s DONE` closes it\n"
));
Ok(out)
}
#[must_use]
pub fn calibration_weights(accuracy: &[(String, f64)]) -> Vec<(String, f64)> {
let logit = |p: f64| {
let p = p.clamp(0.01, 0.99);
(p / (1.0 - p)).ln()
};
let raw: Vec<(String, f64)> = accuracy
.iter()
.map(|(who, p)| (who.clone(), logit(*p).max(0.0)))
.collect();
let top = raw.iter().map(|(_, w)| *w).fold(0.0_f64, f64::max);
raw.into_iter()
.map(|(who, w)| {
let scaled = if top > 0.0 { w / top } else { 0.0 };
(who, scaled.clamp(TRUST_FLOOR, 1.0))
})
.collect()
}
pub fn calibrate(project: &str, rounds: usize) -> Result<Vec<Trust>> {
let said = run_captured(
"ljos-consensus",
&[
"reliability",
"--project",
project,
"--rounds",
&rounds.to_string(),
],
)?;
let v: Value = serde_json::from_str(&said.stdout).context("reliability: not JSON")?;
let accuracy = v
.get("accuracy")
.and_then(Value::as_object)
.context("reliability: no accuracy object")?;
let mut voters: Vec<(String, f64)> = accuracy
.iter()
.filter_map(|(k, val)| val.as_f64().map(|a| (k.clone(), a)))
.collect();
voters.sort_by(|a, b| a.0.cmp(&b.0));
if voters.len() < 2 {
bail!("calibrate: fewer than two voters in {project}");
}
let weights = calibration_weights(&voters);
let mut rows = Vec::new();
for (from, _) in &voters {
for (to, weight) in &weights {
if from == to {
continue;
}
rows.push(Trust {
from: from.clone(),
to: to.clone(),
weight: *weight,
about: Vec::new(),
});
}
}
for row in &rows {
write_trust(row, &[])?;
}
Ok(rows)
}
pub fn format_hits(hits: &[Hit]) -> String {
let now = now_utc();
let mut out = String::new();
for h in hits {
let id = h.id.as_deref().unwrap_or("-");
let named = match (h.ballots, h.of) {
(Some(b), Some(of)) => format!("{b}/{of}"),
_ => "-".to_string(),
};
out.push_str(&format!(
"{:.4}\t{}\t{}\t{}\t{}\t{}\n",
h.score,
named,
h.kind,
id,
age_of(h.ts.as_deref(), &now),
h.text
));
}
out
}
fn hit_line(h: &Hit, now: &str) -> String {
format!(
"- [{}{}] {}",
if h.kind.is_empty() { "claim" } else { &h.kind },
age_tag(h.ts.as_deref(), now),
h.text.trim()
)
}
fn age_tag(ts: Option<&str>, now: &str) -> String {
let age = age_of(ts, now);
if age.is_empty() {
age
} else {
format!(", {age}")
}
}
#[must_use]
pub fn age_of(ts: Option<&str>, now: &str) -> String {
let (Some(then), Some(today)) = (days_of_stamp(ts), days_of_stamp(Some(now))) else {
return String::new();
};
let days = today - then;
match days {
d if d < 0 => format!("in {} day{}", -d, if d == -1 { "" } else { "s" }),
0 => "today".into(),
1 => "yesterday".into(),
d if d < 14 => format!("{d} days ago"),
d if d < 61 => format!("{} weeks ago", d / 7),
d if d < 730 => format!("{} months ago", d / 30),
d => format!("{} years ago", d / 365),
}
}
fn days_of_stamp(ts: Option<&str>) -> Option<i64> {
let ts = ts?;
let date = ts.get(..10)?;
let mut it = date.split('-');
let y: i64 = it.next()?.parse().ok()?;
let m: i64 = it.next()?.parse().ok()?;
let d: i64 = it.next()?.parse().ok()?;
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * m + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
Some(era * 146_097 + doe - 719_468)
}
pub fn cards(dir: &Path) -> Result<String> {
let mut out = String::new();
for name in CARD_NAMES {
let p = dir.join(name);
if p.is_file() {
out.push_str(&format!("--- {} ---\n", p.display()));
out.push_str(&std::fs::read_to_string(&p)?);
}
}
Ok(out)
}
pub fn policy_line(argv: &[String]) -> Result<String> {
if argv.is_empty() {
bail!("policy: pass the argv to check");
}
Ok(argv.join(" "))
}
pub fn policy_with_memory(argv: &[String]) -> Result<String> {
let line = policy_line(argv)?;
let call = HookCall {
event: "argv".into(),
cue: line.clone(),
session: None,
};
let context = hook_context(&call, 5);
let rules = rules_from_pack().unwrap_or_default();
let ruled = hook_output_ruled(&call, &context, verdict_for(&rules, &line));
match tcb_check(argv) {
Some(tcb) if !tcb.is_empty() => Ok(format!("{line}\n{tcb}\n{ruled}")),
_ => Ok(format!("{line}\n{ruled}")),
}
}
pub fn policyd_bin() -> Option<std::path::PathBuf> {
std::env::var_os("POLICYD_BIN")
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from)
.or_else(|| which::which("ljos-policyd").ok())
}
pub fn tcb_check(argv: &[String]) -> Option<String> {
let bin = policyd_bin()?;
let out = std::process::Command::new(bin)
.arg("check")
.arg("--")
.args(argv)
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!text.is_empty()).then_some(text)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsensusStep {
pub bin: &'static str,
pub args: Vec<String>,
}
pub fn consensus_steps(
id: &str,
have_ljos: bool,
have_vissue: bool,
trust: &[Trust],
) -> Result<Vec<ConsensusStep>> {
consensus_steps_anchored(id, have_ljos, have_vissue, trust, &[])
}
pub const BROAD_TAG: &str = "broad";
pub const BROAD_EPSILON: f64 = 1.0;
#[must_use]
pub fn settle_flags_for(tags: &[String]) -> Vec<String> {
if tags.iter().any(|t| t == BROAD_TAG) {
vec!["--epsilon".into(), BROAD_EPSILON.to_string()]
} else {
Vec::new()
}
}
pub fn consensus_steps_for(
id: &str,
have_ljos: bool,
have_vissue: bool,
trust: &[Trust],
personas: &[Persona],
tags: &[String],
) -> Result<Vec<ConsensusStep>> {
let mut steps = consensus_steps_anchored(id, have_ljos, have_vissue, trust, personas)?;
let flags = settle_flags_for(tags);
if !flags.is_empty() {
for step in steps.iter_mut().filter(|s| s.bin == "ljos-consensus") {
step.args.extend(flags.iter().cloned());
}
}
Ok(steps)
}
pub fn panel_steps(
id: &str,
have_ljos: bool,
trust: &[Trust],
predictions: &[Prediction],
) -> Vec<ConsensusStep> {
let mut steps = Vec::new();
if !have_ljos {
return steps;
}
if predictions.len() >= 2 {
steps.push(ConsensusStep {
bin: "ljos-consensus",
args: vec![
"surprising".into(),
"--issue".into(),
id.into(),
"--predictions".into(),
predictions_json(predictions),
],
});
}
if !trust.is_empty() {
steps.push(ConsensusStep {
bin: "ljos-consensus",
args: vec!["reputation".into(), "--trust".into(), trust_json(trust)],
});
}
steps
}
pub fn consensus_steps_anchored(
id: &str,
have_ljos: bool,
have_vissue: bool,
trust: &[Trust],
personas: &[Persona],
) -> Result<Vec<ConsensusStep>> {
if !have_ljos && !have_vissue {
bail!("neither ljos-consensus nor vissue is on PATH");
}
let mut steps = Vec::new();
if have_ljos {
let mut args = vec!["settle".to_string(), "--issue".into(), id.into()];
if !trust.is_empty() {
args.push("--trust".into());
args.push(trust_json(trust));
}
if !personas.is_empty() {
args.push("--susceptibility-of".into());
args.push(anchors_json(personas));
}
steps.push(ConsensusStep {
bin: "ljos-consensus",
args,
});
}
if have_vissue {
let mut args = vec!["consensus".to_string(), id.into()];
if !trust.is_empty() {
args.push("--trust".into());
args.push(trust_json(trust));
}
if !personas.is_empty() {
args.push("--susceptibility-of".into());
args.push(anchors_json(personas));
}
steps.push(ConsensusStep {
bin: "vissue",
args,
});
}
Ok(steps)
}
pub fn on_path(bin: &str) -> bool {
which::which(bin).is_ok()
}
pub fn run(bin: &str, args: &[impl AsRef<str>]) -> Result<()> {
run_as(bin, args, None)
}
#[must_use]
pub fn identity_or_seat(identity: Option<&str>) -> Option<String> {
identity
.map(str::trim)
.filter(|w| !w.is_empty())
.map(str::to_string)
.or_else(|| {
std::env::var("LJOS_SEAT")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
})
}
pub fn run_as(bin: &str, args: &[impl AsRef<str>], identity: Option<&str>) -> Result<()> {
use std::process::{Command, Stdio};
let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
let mut cmd = Command::new(path);
if let Some(who) = identity_or_seat(identity) {
cmd.env("VISSUE_AGENT", who);
}
for a in args {
cmd.arg(a.as_ref());
}
let st = cmd
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()?;
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if st.signal() == Some(libc::SIGPIPE) {
return Ok(());
}
}
if !st.success() {
bail!("{bin} exited {st}");
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Said {
pub stdout: String,
pub stderr: String,
}
pub fn run_captured(bin: &str, args: &[impl AsRef<str>]) -> Result<Said> {
use std::process::{Command, Stdio};
let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
let mut cmd = Command::new(path);
for a in args {
cmd.arg(a.as_ref());
}
let out = cmd
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.with_context(|| format!("{bin}: could not start"))?;
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
if !out.status.success() {
let why = if stderr.trim().is_empty() {
stdout.trim().to_string()
} else {
stderr.trim().to_string()
};
bail!("{bin} exited {}: {why}", out.status);
}
Ok(Said { stdout, stderr })
}
pub fn card_paths(dir: &Path) -> Vec<PathBuf> {
CARD_NAMES.iter().map(|n| dir.join(n)).collect()
}
#[cfg(test)]
mod tests {
#[test]
fn a_shared_name_does_not_occupy_the_whole_host() {
unsafe {
std::env::remove_var("LJOS_SEAT");
std::env::remove_var("VISSUE_AGENT");
std::env::set_var("GROK_SESSION_ID", "01a09b25-ffe9-7972-881a-3cee2ea6efd6");
}
assert_eq!(resolve_assignee(Some("grok")), "sess-01a09b25");
assert_eq!(resolve_assignee(Some("seat")), "sess-01a09b25");
assert_eq!(resolve_assignee(None), "sess-01a09b25");
assert_eq!(resolve_assignee(Some("alice")), "alice");
unsafe {
std::env::remove_var("GROK_SESSION_ID");
}
}
#[test]
fn the_record_weighs_a_voter_by_what_it_got_right() {
let ballots = vec![
("a".to_string(), "ship".to_string()),
("b".to_string(), "ship".to_string()),
("c".to_string(), "hold".to_string()),
];
let (rows, records) =
learn_record(&ballots, "ship", &std::collections::BTreeMap::new(), &[]).unwrap();
assert_eq!(records["a"], (1.0, 0.0));
assert_eq!(records["c"], (0.0, 1.0));
let w = |to: &str| rows.iter().find(|r| r.to == to).unwrap().weight;
assert_eq!(w("a"), 1.0, "a right voter stands at one");
assert!(w("c") < w("a"), "a wrong voter stands lower");
assert_eq!(rows.len(), 6, "complete over the voters");
let (rows2, records2) = learn_record(&ballots, "ship", &records, &[]).unwrap();
assert_eq!(records2["c"], (0.0, 2.0));
let w2 = |to: &str| rows2.iter().find(|r| r.to == to).unwrap().weight;
assert!(w2("c") <= w("c"));
assert!(learn_record(&ballots, " ", &records, &[]).is_err());
let atoms = vec![
serde_json::json!({"kind": "trust", "from": "a", "to": "c", "weight": 0.2, "hits": 1.0, "misses": 3.0, "ts": "2026-09-13T01:00:00Z"}),
serde_json::json!({"kind": "trust", "from": "b", "to": "c", "weight": 0.5, "hits": 1.0, "misses": 1.0, "ts": "2026-09-12T01:00:00Z"}),
];
assert_eq!(records_from_atoms(&atoms)["c"], (1.0, 3.0));
}
#[test]
fn a_correction_is_nudged_once_a_session_and_only_on_a_prompt() {
let dir = std::env::temp_dir().join(format!("ljos-corr-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
let prompt = HookCall {
event: "UserPromptSubmit".into(),
cue: "Do you not remember to use uv for scripts?".into(),
session: Some("corr-test".into()),
};
let first = correction_nudge(&prompt).expect("a correction is nudged");
assert!(first.contains("ljos prefer"), "{first}");
assert!(correction_nudge(&prompt).is_none(), "once a session");
let tool = HookCall {
event: "PreToolUse".into(),
cue: "you should have used uv".into(),
session: Some("corr-test".into()),
};
assert!(
correction_nudge(&tool).is_none(),
"tool calls are not prompts"
);
let plain = HookCall {
event: "UserPromptSubmit".into(),
cue: "add the timeline verb".into(),
session: Some("corr-test-2".into()),
};
assert!(correction_nudge(&plain).is_none());
}
#[test]
fn calibration_weights_are_log_odds_with_the_best_at_one() {
let w = calibration_weights(&[
("a".to_string(), 0.9),
("b".to_string(), 0.6),
("c".to_string(), 0.5),
("d".to_string(), 1.0),
]);
let of = |who: &str| w.iter().find(|(n, _)| n == who).unwrap().1;
assert_eq!(of("d"), 1.0, "a perfect record is the top of the scale");
assert!((of("a") - 0.478).abs() < 0.01, "{}", of("a"));
assert!((of("b") - 0.088).abs() < 0.01, "{}", of("b"));
assert!(
of("a") / of("b") > 5.0,
"nine in ten outweighs six in ten by more than five"
);
assert_eq!(of("c"), TRUST_FLOOR, "chance earns the floor");
}
#[test]
fn a_consolidation_report_names_the_pairs() {
let body = serde_json::json!({"live": 5, "closed": 1, "applied": false, "pairs": [
{"old": "a", "old_text": "The default fuse is Borda.", "new": "b", "new_text": "The default fuse is CombMNZ."}
]});
let text = format_consolidation(&body);
assert!(
text.starts_with(
"closes a The default fuse is Borda.\n for b The default fuse is CombMNZ.\n"
),
"{text}"
);
assert!(
text.ends_with(
"1 of 5 live memories would close; `ljos consolidate --apply` closes them\n"
),
"{text}"
);
let applied = format_consolidation(
&serde_json::json!({"live": 5, "closed": 0, "applied": true, "pairs": []}),
);
assert_eq!(applied, "0 of 5 live memories closed\n");
}
#[test]
fn the_hook_keeps_what_two_scorers_agreed_on() {
let hit = |ballots, of| Hit {
id: None,
text: "x".into(),
score: 1.0,
kind: "lesson".into(),
ts: None,
ballots,
of,
};
assert!(agreed(&hit(Some(2), Some(3))));
assert!(!agreed(&hit(Some(1), Some(3))));
assert!(agreed(&hit(Some(1), Some(1))));
assert!(agreed(&hit(None, None)));
}
#[test]
fn the_holder_is_read_off_a_get_line() {
let line = "a25a… claimed task unset gen=2 assignee=69f917124f757277b806e9a0f48c0318 parent=0 x-1";
assert_eq!(
holder_of(line).as_deref(),
Some("69f917124f757277b806e9a0f48c0318")
);
assert_eq!(
holder_of("a ready task unset gen=1 assignee=00000000000000000000000000000000"),
None
);
assert_eq!(holder_of("deps -"), None);
}
#[test]
fn a_registration_carries_the_runners_name() {
let argv: Vec<String> = ["run", "-e", "LJOS_SEAT={name}", "{server}"]
.iter()
.map(|s| (*s).to_string())
.collect();
let filled = filled(&argv, Path::new("/x/ljos-mcp"), "runner-a");
assert_eq!(filled, ["run", "-e", "LJOS_SEAT=runner-a", "/x/ljos-mcp"]);
assert_eq!(
identity_or_seat(Some(" reviewer ")).as_deref(),
Some("reviewer")
);
}
#[test]
fn a_timeline_merges_the_three_stores_oldest_first() {
let v = serde_json::json!({
"properties": {"CREATED": "[2026-09-01 Tue]"},
"claimed_by": "seat",
"claimed_at": "[2026-09-03 Thu 11:48]",
"logbook": [
{"note": "second", "timestamp": "[2026-09-10 Thu 09:00]"},
{"from_state": "TODO", "to_state": "STARTED", "timestamp": "[2026-09-03 Thu 11:48]"}
]
});
let mut events = tracker_events(&v);
events.push(
deed_event(
"deed-x",
"id=deed-x ok\nproducedBy=seat -\ntime=1788566400\n",
)
.unwrap(),
);
events.sort_by(|a, b| (a.days, &a.clock).cmp(&(b.days, &b.clock)));
let text = format_events(&events, "2026-09-12T00:00:00Z");
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 5, "{text}");
assert!(
lines[0].starts_with("2026-09-01 \t11 days ago\t\ttracker\tcreated"),
"{}",
lines[0]
);
assert!(
lines[1].contains("+2 d\ttracker\tclaimed by seat"),
"{}",
lines[1]
);
assert!(
lines[2].contains("same day\ttracker\tTODO -> STARTED"),
"{}",
lines[2]
);
assert!(
lines[3]
.starts_with("2026-09-05 00:00\t7 days ago\t+2 d\tdeed\tdeed-x produced by seat -"),
"{}",
lines[3]
);
assert!(
lines[4].contains("2 days ago\t+5 d\ttracker\tnote: second"),
"{}",
lines[4]
);
}
#[test]
fn stamps_of_every_shape_key_the_same() {
assert_eq!(
stamp_key(Some("[2026-09-12 Sat 21:54]")),
stamp_key(Some("2026-09-12T21:54:00.000Z"))
);
assert_eq!(stamp_key(Some("[2026-09-12 Sat]")).unwrap().1, "");
assert_eq!(stamp_key(Some("soon")), None);
assert_eq!(
civil_of_days(days_of_stamp(Some("2026-09-12")).unwrap()),
"2026-09-12"
);
}
#[test]
fn ages_read_as_a_timeline() {
let now = "2026-09-12T14:00:00.000Z";
assert_eq!(age_of(Some("2026-09-12T01:00:00.000Z"), now), "today");
assert_eq!(age_of(Some("2026-09-11T23:59:00.000Z"), now), "yesterday");
assert_eq!(age_of(Some("2026-09-01T00:00:00.000Z"), now), "11 days ago");
assert_eq!(age_of(Some("2026-08-01T00:00:00.000Z"), now), "6 weeks ago");
assert_eq!(
age_of(Some("2026-03-01T00:00:00.000Z"), now),
"6 months ago"
);
assert_eq!(age_of(Some("2023-09-12T00:00:00.000Z"), now), "3 years ago");
assert_eq!(age_of(Some("2026-09-13T00:00:00.000Z"), now), "in 1 day");
assert_eq!(age_of(None, now), "");
assert_eq!(age_of(Some("card"), now), "");
}
#[test]
fn a_hit_line_carries_kind_and_age() {
let h = Hit {
id: Some("a".into()),
text: " keep the smoke green ".into(),
score: 1.0,
kind: "lesson".into(),
ts: Some("2026-09-10T00:00:00.000Z".into()),
ballots: None,
of: None,
};
assert_eq!(
hit_line(&h, "2026-09-12T00:00:00.000Z"),
"- [lesson, 2 days ago] keep the smoke green"
);
let bare = Hit {
id: None,
text: "x".into(),
score: 1.0,
kind: String::new(),
ts: None,
ballots: None,
of: None,
};
assert_eq!(hit_line(&bare, "2026-09-12T00:00:00.000Z"), "- [claim] x");
}
#[test]
fn hook_calls_are_read_and_answered_in_the_runners_shape() {
let tool = hook_call(
r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cargo test","description":"run"}}"#,
);
assert_eq!(tool.event, "PreToolUse");
assert_eq!(tool.cue, "cargo test");
let prompt = hook_call(r#"{"hook_event_name":"UserPromptSubmit","prompt":"fix the fuse"}"#);
assert_eq!(prompt.cue, "fix the fuse");
let grok = hook_call(r#"{"hookEventName":"post_tool_use","sessionId":"s1"}"#);
assert_eq!(grok.event, "PostToolUse");
assert_eq!(grok.session.as_deref(), Some("s1"));
hold_hook_context(Some("s1"), "held pack");
assert_eq!(take_hook_context(Some("s1")), "held pack");
assert!(take_hook_context(Some("s1")).is_empty());
let argv = hook_call("rm -rf build");
assert_eq!(argv.event, "argv");
assert_eq!(argv.session, None);
let with_session = hook_call(
r#"{"session_id":"abc/../x 1","hook_event_name":"PreToolUse","tool_input":{"command":"ls"}}"#,
);
assert_eq!(with_session.session.as_deref(), Some("abc/../x 1"));
assert!(seen_path("abc/../x 1")
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
.ends_with("hook-seen-abcx1"));
assert_eq!(seen_path("/../"), None);
assert_eq!(hook_output(&argv, ""), "");
assert_eq!(hook_output(&argv, "- [lesson] x"), "- [lesson] x\n");
let out = hook_output(&tool, "- [preference] y");
let v: Value = serde_json::from_str(out.trim()).unwrap();
assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
assert_eq!(
v["hookSpecificOutput"]["additionalContext"],
"- [preference] y"
);
assert!(
hook_context(
&HookCall {
event: "argv".into(),
cue: "ab".into(),
session: None
},
8
)
.is_empty(),
"a cue too short asks nothing"
);
}
#[test]
fn a_sessions_injected_memories_are_read_back_and_cleared() {
let session = format!("end-test-{}", std::process::id());
mark_seen(
Some(&session),
&["a".to_string(), "due-nudge".to_string(), "b".to_string()],
);
let (ids, path) = injected_ids(&session);
assert_eq!(ids, ["a", "b"]);
assert!(path.as_ref().is_some_and(|p| p.is_file()));
let _ = session_end(Some(&session));
assert!(!path.unwrap().is_file());
assert_eq!(session_end(None), 0);
}
#[test]
fn the_memory_hook_is_merged_once() {
let dir = std::env::temp_dir().join(format!("ljos-hook-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("settings.json");
std::fs::write(
&file,
r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"other"}]}]},"theme":"dark"}"#,
)
.unwrap();
let both: Vec<String> = vec!["UserPromptSubmit".into(), "PreToolUse".into()];
let prompts: Vec<String> = HOOK_EVENTS.iter().map(|e| (*e).to_string()).collect();
assert_eq!(
prompts,
["UserPromptSubmit", "SessionEnd"],
"the panel's default, and the session end that wires what it used"
);
assert!(!hook_installed(&file, &both));
let dry = hook_step(&file, &both, true);
assert!(
dry.ok && dry.detail.starts_with("would add it on"),
"{dry:?}"
);
let step = hook_step(&file, &both, false);
assert!(step.ok, "{step:?}");
assert!(hook_installed(&file, &both));
let again = hook_step(&file, &both, false);
assert!(
again.detail.contains("carries the memory hook on"),
"{again:?}"
);
let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
assert_eq!(v["theme"], "dark", "the rest of the file is kept");
assert_eq!(
v["hooks"]["PreToolUse"].as_array().unwrap().len(),
2,
"the other hook stays"
);
assert_eq!(v["hooks"]["UserPromptSubmit"].as_array().unwrap().len(), 1);
let narrowed = hook_step(&file, &prompts, false);
assert!(
narrowed.detail.contains("drop it from PreToolUse"),
"{narrowed:?}"
);
let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
assert_eq!(v["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
assert_eq!(v["hooks"]["PreToolUse"][0]["hooks"][0]["command"], "other");
assert!(hook_installed(&file, &prompts));
assert!(!hook_installed(&file, &both));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rules_match_the_line_and_the_hook_carries_the_verdict() {
assert!(glob_matches("rm -rf *", "rm -rf /tmp/x"));
assert!(!glob_matches("rm -rf *", "ls -la"));
assert!(glob_matches("*sudo*", "echo hi && sudo reboot"));
assert!(glob_matches("git push*", "git push origin main"));
assert!(!glob_matches("git push*", "git pull"));
let rules = vec![
Rule {
pattern: "git push*".into(),
verdict: "ask".into(),
reason: "A push is the trust gate.".into(),
},
Rule {
pattern: "*--force*".into(),
verdict: "deny".into(),
reason: "Never force push.".into(),
},
];
assert_eq!(
verdict_for(&rules, "git push --force").unwrap().verdict,
"deny"
);
assert_eq!(
verdict_for(&rules, "git push origin x").unwrap().verdict,
"ask"
);
assert!(verdict_for(&rules, "cargo test").is_none());
let call = hook_call(
r#"{"hook_event_name":"PreToolUse","tool_input":{"command":"git push --force"}}"#,
);
let out = hook_output_ruled(&call, "", verdict_for(&rules, &call.cue));
let v: Value = serde_json::from_str(out.trim()).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap()
.contains("Never force push"));
assert!(v["hookSpecificOutput"].get("additionalContext").is_none());
let argv = HookCall {
event: "argv".into(),
cue: "git push origin x".into(),
session: None,
};
assert!(
hook_output_ruled(&argv, "", verdict_for(&rules, &argv.cue)).starts_with("ask: A push")
);
let steps = panel_steps("x-1", true, &[], &[]);
assert!(steps.is_empty());
let preds = vec![
Prediction {
issue: "x-1".into(),
agent: "a".into(),
expect: Value::String("ship".into()),
},
Prediction {
issue: "x-1".into(),
agent: "b".into(),
expect: serde_json::json!({"ship": 0.6, "hold": 0.4}),
},
];
let steps = panel_steps("x-1", true, &[row("a", "b", 0.5)], &preds);
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].args[0], "surprising");
assert_eq!(steps[1].args[0], "reputation");
}
#[test]
fn scoped_rows_apply_to_their_topic_and_learn_writes_in_scope() {
let everywhere = row("a", "b", 0.9);
let mut on_docs = row("a", "b", 0.2);
on_docs.about = vec!["docs".into()];
let rows = vec![everywhere.clone(), on_docs.clone()];
let topic = topic_words("Rewrite the docs site");
assert_eq!(topic, ["docs", "rewrite", "site", "the"]);
assert_eq!(rows_about(&rows, &topic), vec![on_docs.clone()]);
assert_eq!(
rows_about(&rows, &topic_words("Fix the fuse")),
vec![everywhere.clone()]
);
let ballots = vec![
("a".to_string(), "ship".to_string()),
("b".to_string(), "hold".to_string()),
];
let learned = learn_about(&ballots, "ship", &rows, 0.5, &["fuse".to_string()]).unwrap();
let ab = learned
.iter()
.find(|r| r.from == "a" && r.to == "b")
.unwrap();
assert_eq!(ab.about, ["fuse"]);
assert!(
(ab.weight - 0.45).abs() < 1e-9,
"starts from the unscoped 0.9: {ab:?}"
);
let ba = learned
.iter()
.find(|r| r.from == "b" && r.to == "a")
.unwrap();
assert!((ba.weight - 1.0).abs() < 1e-9, "a was right: {ba:?}");
let atoms = vec![
trust_atom(&everywhere, &[], "ws").unwrap(),
trust_atom(&on_docs, &[], "ws").unwrap(),
];
let mut back = trust_rows(&atoms);
back.sort_by(|x, y| x.about.cmp(&y.about));
assert_eq!(back, vec![everywhere, on_docs]);
}
#[test]
fn personas_are_latest_per_name_and_anchor_the_settle() {
let p = Persona {
name: "reviewer".into(),
anchor: 0.2,
view: "Reads for what could break in production.".into(),
entities: vec!["Release".into()],
};
let mut a = persona_atom(&p, "ws").unwrap();
a["ts"] = Value::String("2026-01-01T00:00:00Z".into());
let mut later = a.clone();
later["anchor"] = serde_json::json!(0.4);
later["ts"] = Value::String("2026-02-01T00:00:00Z".into());
let got = personas_of(&[a, later]);
assert_eq!(got.len(), 1);
assert_eq!(got[0].anchor, 0.4);
assert_eq!(got[0].entities, ["release"]);
assert_eq!(anchors_json(&got), r#"{"reviewer":0.4}"#);
let ballots = vec![
("reviewer".to_string(), "hold".to_string()),
("reader".to_string(), "ship".to_string()),
];
let moved = learn_anchors(&got, &ballots, "ship", 0.5);
assert_eq!(moved.len(), 1);
assert!(
(moved[0].anchor - 0.7).abs() < 1e-9,
"0.4 + 0.6 * 0.5: {moved:?}"
);
assert!(learn_anchors(&got, &ballots, "hold", 0.5).is_empty());
assert!(persona_atom(
&Persona {
anchor: 1.5,
..p.clone()
},
"ws"
)
.is_err());
let steps = consensus_steps_anchored("x-1", true, true, &[], &got).unwrap();
for step in &steps {
assert!(
step.args.contains(&"--susceptibility-of".to_string()),
"{step:?}"
);
}
let broad =
consensus_steps_for("x-1", true, true, &[], &got, &["broad".to_string()]).unwrap();
assert!(
broad[0].args.contains(&"--epsilon".to_string()),
"{:?}",
broad[0]
);
assert!(
!broad[1].args.contains(&"--epsilon".to_string()),
"{:?}",
broad[1]
);
assert!(settle_flags_for(&["feature".to_string()]).is_empty());
}
#[test]
fn unreviewed_claims_are_due_and_the_summary_says_if_the_clock_runs() {
let atoms = vec![
serde_json::json!({"id": "a", "kind": "conclusion", "text": "old", "due_at": ""}),
serde_json::json!({"id": "b", "kind": "conclusion", "text": "older"}),
serde_json::json!({"id": "c", "kind": "conclusion", "text": "later",
"due_at": "2030-01-01T00:00:00Z"}),
serde_json::json!({"id": "d", "kind": "conclusion", "text": "past",
"due_at": "2020-01-01T00:00:00Z"}),
serde_json::json!({"id": "t", "kind": "trust", "text": "x weighs y"}),
];
let now = "2026-01-01T00:00:00Z";
let due: Vec<String> = super::due_of(&atoms, now)
.iter()
.map(|a| a["id"].as_str().unwrap().to_string())
.collect();
assert_eq!(
due,
["a", "b", "d"],
"unreviewed first, then the past-due one"
);
assert_eq!(
super::review_summary(&atoms, now),
"3 due; 1 scheduled, next at 2030-01-01T00:00:00Z"
);
assert_eq!(
super::review_summary(&[atoms[4].clone()], now),
"0 due; nothing scheduled: this seat has remembered nothing yet"
);
assert!(super::format_due(&super::due_of(&atoms, now)).starts_with("unreviewed\t"));
}
#[test]
fn onboarding_a_config_file_runner_writes_once() {
let all: super::Harnesses = toml::from_str(super::HARNESSES_EXAMPLE).expect("parses");
assert_eq!(all.harness.len(), 2);
assert_eq!(all.harness[1].marker.as_deref(), Some("[mcp_servers.ljos]"));
let dir = std::env::temp_dir().join(format!("ljos-onboard-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("tempdir");
let config = dir.join("config.toml");
let skills = dir.join("skills");
let file = dir.join("harnesses.toml");
std::fs::write(
&file,
format!(
"[[harness]]\nname = \"r\"\nconfig = {config:?}\nmarker = \"[mcp_servers.ljos]\"\n\
snippet = \"\\n[mcp_servers.ljos]\\ncommand = \\\"{{server}}\\\"\\n\"\nskills = {skills:?}\n",
config = config.display().to_string(),
skills = skills.display().to_string(),
),
)
.expect("write");
let refused = super::onboard_from(&file, "nobody", true)
.unwrap_err()
.to_string();
assert!(
refused.contains("no runner \"nobody\"") && refused.contains("names r"),
"{refused}"
);
let steps = match super::onboard_from(&file, "r", true) {
Ok(steps) => steps,
Err(e) => {
assert!(e.to_string().contains("ljos-mcp not on PATH"), "{e}");
return;
}
};
assert!(steps.iter().all(|s| s.ok), "{steps:?}");
assert!(
steps[0].detail.starts_with("would append"),
"{}",
steps[0].detail
);
assert!(!config.exists() && !skills.exists(), "a dry run wrote");
let steps = super::onboard_from(&file, "r", false).expect("onboards");
assert!(steps.iter().all(|s| s.ok), "{steps:?}");
let written = std::fs::read_to_string(&config).expect("config written");
assert_eq!(written.matches("[mcp_servers.ljos]").count(), 1);
assert!(written.contains("ljos-mcp"), "{written}");
let skill = std::fs::read_to_string(skills.join("ljos/SKILL.md")).expect("skill written");
assert!(skill.starts_with("---\nname: ljos\n"));
assert!(skill.contains("## Before the work"));
let again = super::onboard_from(&file, "r", false).expect("onboards again");
assert_eq!(again[0].detail, "ljos registered");
assert!(
again[1].detail.ends_with("is current"),
"{}",
again[1].detail
);
assert_eq!(
std::fs::read_to_string(&config)
.expect("config")
.matches("[mcp_servers.ljos]")
.count(),
1,
"the entry was appended twice"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn grok_onboard_names_the_frozen_hook_file() {
let file = std::env::temp_dir().join("ljos-missing-harnesses.toml");
let steps = super::onboard_from(&file, "grok", true).expect("grok dry");
assert!(steps[0].ok, "{steps:?}");
assert!(
steps[0].detail.contains(".grok/hooks/ljos.json"),
"{}",
steps[0].detail
);
}
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
#[test]
fn a_refusal_is_an_error_not_an_answer() {
let err = run_captured("false", &[] as &[&str]).unwrap_err();
assert!(err.to_string().contains("false exited"), "{err}");
let said = run_captured("sh", &["-c", "echo answered; echo aside >&2"]).unwrap();
assert_eq!(said.stdout.trim(), "answered");
assert_eq!(said.stderr.trim(), "aside");
let said = run_captured("sh", &["-c", "echo reason >&2; exit 3"]).unwrap_err();
assert!(said.to_string().contains("reason"), "{said}");
}
#[test]
fn join_keeps_spaces() {
assert_eq!(
join(&["the default fuse".into(), "is CombMNZ".into()]),
"the default fuse is CombMNZ"
);
}
#[test]
fn remember_is_lesson_prefer_is_preference() {
assert_eq!(atom_kind("Remember").unwrap(), "lesson");
assert_eq!(atom_kind("Prefer").unwrap(), "preference");
assert!(atom_kind("extract").is_err());
}
#[test]
fn atom_body_is_explicit_and_unextracted() {
let v = atom_body("lesson", "the default fuse is CombMNZ", "ws");
assert_eq!(v["schema"], "inside.atom/v1");
assert_eq!(v["kind"], "lesson");
assert_eq!(v["level"], "explicit");
assert_eq!(v["text"], "the default fuse is CombMNZ");
assert_eq!(v["workspace"], "ws");
let raw = atom_body("lesson", "Remember: pin the review set", "ws");
assert_eq!(raw["text"], "Remember: pin the review set");
}
#[test]
fn empty_claim_is_refused() {
let client = PacksetClient::new("http://127.0.0.1:1");
let err = post_claim(&client, "Remember", " ", "ws").unwrap_err();
assert!(err.to_string().contains("empty text"));
}
#[test]
fn cards_are_the_two_named_files_only() {
assert_eq!(CARD_NAMES, &["USER.md", "MEMORY.md"]);
let dir = std::env::temp_dir().join(format!("ljos-cards-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("USER.md"), "user card\n").unwrap();
std::fs::write(dir.join("MEMORY.md"), "memory card\n").unwrap();
std::fs::write(dir.join("NOTES.md"), "must not appear\n").unwrap();
let out = cards(&dir).unwrap();
assert!(out.contains("user card"));
assert!(out.contains("memory card"));
assert!(!out.contains("must not appear"));
assert!(!out.contains("NOTES.md"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn policy_prints_argv_and_does_not_reload() {
assert!(policy_line(&[]).is_err());
assert_eq!(policy_line(&["ls".into(), "-la".into()]).unwrap(), "ls -la");
let note = POLICY_TCB.to_ascii_lowercase();
assert!(note.contains("ljos-policyd"));
assert!(note.contains("not a check"));
assert!(!note.contains("grokos policy reload"));
assert!(!note.contains("policy reload"));
}
#[test]
fn consensus_is_ljos_then_vissue() {
let steps = consensus_steps("vissue-1a5a", true, true, &[]).unwrap();
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].bin, "ljos-consensus");
assert_eq!(steps[0].args, vec!["settle", "--issue", "vissue-1a5a"]);
assert_eq!(steps[1].bin, "vissue");
assert_eq!(steps[1].args, vec!["consensus", "vissue-1a5a"]);
}
#[test]
fn consensus_carries_the_packs_trust() {
let rows = vec![row("a", "b", 0.5)];
let steps = consensus_steps("id", true, true, &rows).unwrap();
assert_eq!(steps[0].args[3], "--trust");
assert_eq!(steps[0].args[4], r#"[["a","b",0.5]]"#);
assert_eq!(
steps[1].args,
vec!["consensus", "id", "--trust", r#"[["a","b",0.5]]"#]
);
}
#[test]
fn consensus_skips_a_missing_bin() {
let only_v = consensus_steps("id", false, true, &[]).unwrap();
assert_eq!(only_v.len(), 1);
assert_eq!(only_v[0].bin, "vissue");
let only_l = consensus_steps("id", true, false, &[]).unwrap();
assert_eq!(only_l[0].bin, "ljos-consensus");
assert!(consensus_steps("id", false, false, &[]).is_err());
}
fn row(from: &str, to: &str, weight: f64) -> Trust {
Trust {
about: Vec::new(),
from: from.into(),
to: to.into(),
weight,
}
}
#[test]
fn a_trust_atom_is_one_edge_with_its_evidence() {
let atom = trust_atom(&row("a", "b", 0.25), &["deed-x-y".into()], "ws").unwrap();
assert_eq!(atom["kind"], "trust");
assert_eq!(atom["from"], "a");
assert_eq!(atom["to"], "b");
assert_eq!(atom["weight"], 0.25);
assert_eq!(atom["entities"], serde_json::json!(["deed-x-y"]));
assert_eq!(atom["text"], "a weighs b at 0.250.");
assert!(trust_atom(&row("a", "a", 0.5), &[], "ws").is_err());
assert!(trust_atom(&row("a", "b", 0.0), &[], "ws").is_err());
assert!(trust_atom(&row("a", "b", 1.5), &[], "ws").is_err());
assert!(trust_atom(&row("", "b", 0.5), &[], "ws").is_err());
}
#[test]
fn the_latest_row_per_pair_wins() {
let atoms = vec![
serde_json::json!({"kind": "trust", "from": "a", "to": "b", "weight": 0.9, "ts": "2026-01-01T00:00:00Z"}),
serde_json::json!({"kind": "trust", "from": "a", "to": "b", "weight": 0.3, "ts": "2026-02-01T00:00:00Z"}),
serde_json::json!({"kind": "trust", "from": "b", "to": "a", "weight": 0.7}),
serde_json::json!({"kind": "lesson", "text": "not a row"}),
serde_json::json!({"kind": "trust", "from": "b", "weight": 0.7}),
];
let rows = trust_rows(&atoms);
assert_eq!(rows, vec![row("a", "b", 0.3), row("b", "a", 0.7)]);
assert_eq!(trust_json(&rows), r#"[["a","b",0.3],["b","a",0.7]]"#);
}
#[test]
fn ballots_are_agent_and_choice() {
let rows =
ballots_from_json(r#"[{"agent":"a","choice":"ship","stamp":"[2026-01-01]"}]"#).unwrap();
assert_eq!(rows, vec![("a".to_string(), "ship".to_string())]);
assert!(ballots_from_json(r#"[{"agent":"a"}]"#).is_err());
assert!(ballots_from_json("{}").is_err());
}
#[test]
fn learning_downweights_the_refuted_voter() {
let ballots = vec![
("a".to_string(), "ship".to_string()),
("b".to_string(), "ship".to_string()),
("c".to_string(), "hold".to_string()),
];
let rows = learn(&ballots, "ship", &[], 0.5).unwrap();
assert_eq!(rows.len(), 6);
let w = |from: &str, to: &str| {
rows.iter()
.find(|r| r.from == from && r.to == to)
.unwrap()
.weight
};
assert_eq!(w("a", "b"), 1.0);
assert_eq!(w("a", "c"), 0.5);
assert_eq!(w("b", "c"), 0.5);
assert_eq!(w("c", "a"), 1.0);
let again = learn(&ballots, "ship", &rows, 0.5).unwrap();
let w2 = |from: &str, to: &str| {
again
.iter()
.find(|r| r.from == from && r.to == to)
.unwrap()
.weight
};
assert_eq!(w2("a", "c"), 0.25);
assert_eq!(w2("a", "b"), 1.0);
let floored = learn(&ballots, "ship", &[row("a", "c", 0.015)], 0.5).unwrap();
let low = floored
.iter()
.find(|r| r.from == "a" && r.to == "c")
.unwrap();
assert_eq!(low.weight, TRUST_FLOOR);
assert!(learn(&ballots, "ship", &[], 1.0).is_err());
assert!(learn(&ballots, " ", &[], 0.5).is_err());
assert!(learn(&ballots[..1], "ship", &[], 0.5).is_err());
let shared = learn_shared(&ballots, "ship", &rows, 0.5, &[], 0.1).unwrap();
let w3 = |from: &str, to: &str| {
shared
.iter()
.find(|r| r.from == from && r.to == to)
.unwrap()
.weight
};
assert!((w3("a", "c") - (0.25 + 0.75 * 0.1)).abs() < 1e-12);
assert_eq!(w3("a", "b"), 1.0);
assert!(learn_shared(&ballots, "ship", &[], 0.5, &[], 1.0).is_err());
}
#[test]
fn a_name_is_one_work_id_and_hex_passes_through() {
let a = work_id("demo-riml");
assert_eq!(a.len(), 32);
assert!(a.bytes().all(|b| b.is_ascii_hexdigit()));
assert_eq!(a, work_id(" demo-riml "));
assert_ne!(a, work_id("demo-rimm"));
assert_eq!(work_id(&a.to_ascii_uppercase()), a);
assert_ne!(work_id("seat"), work_id("reader"));
}
#[test]
fn an_island_prints_one_memory_a_line() {
let body = serde_json::json!({"island": [
{"id": "a", "text": "one", "activation": 1.0, "seed": true, "ts": now_utc()},
{"id": "b", "text": "two", "activation": 0.25, "seed": false}
]});
assert_eq!(
format_island(&body),
"1.000\tseed\ta\ttoday\tone\n0.250\t \tb\t\ttwo\n"
);
assert!(format_island(&serde_json::json!({})).is_empty());
}
#[test]
fn a_fed_verb_reads_its_stdin() {
let said = run_fed("cat", &[] as &[&str], "one\ntwo\n").unwrap();
assert_eq!(said.stdout, "one\ntwo\n");
assert!(run_fed("sh", &["-c", "exit 2"], "").is_err());
}
#[test]
fn needs_and_cited_are_enclosed_once_each() {
let needs = needs_of(r#"{"needs":["deed-b-2","deed-a-1"],"other":1}"#).unwrap();
assert_eq!(needs, vec!["deed-b-2", "deed-a-1"]);
assert_eq!(
enclose(needs, "deed-a-1\n\ndeed-c-3\n"),
vec!["deed-a-1", "deed-b-2", "deed-c-3"]
);
assert!(needs_of("{}").unwrap().is_empty());
assert!(needs_of("not json").is_err());
}
#[test]
fn due_is_the_past_soonest_first() {
let atoms = vec![
serde_json::json!({"id": "late", "due_at": "2026-02-01T00:00:00.000Z"}),
serde_json::json!({"id": "later", "due_at": "2026-03-01T00:00:00.000Z"}),
serde_json::json!({"id": "future", "due_at": "2099-01-01T00:00:00.000Z"}),
serde_json::json!({"id": "never"}),
serde_json::json!({"id": "blank", "due_at": ""}),
];
let due = due_of(&atoms, "2026-06-01T00:00:00.000Z");
let ids: Vec<&str> = due.iter().map(|a| a["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["never", "blank", "late", "later"]);
assert!(now_utc().ends_with(".000Z"));
assert!(now_utc().as_str() > "2026-01-01T00:00:00.000Z");
}
#[test]
fn the_doctor_names_every_habitat_and_the_pack_gates_health() {
let rows = doctor();
let names: Vec<&str> = rows.iter().map(|h| h.name).collect();
for want in [
"ljos",
"packset-embed",
"vissue",
"deedar",
"packset",
"pack",
"encoder",
"host key",
"deed store",
"tracker",
] {
assert!(names.contains(&want), "{names:?}");
}
let table = format_doctor(&rows);
assert_eq!(table.lines().count(), rows.len());
let sick = vec![Habitat {
name: "pack",
state: "PACKSET_URL unset".into(),
ok: false,
}];
assert!(!healthy(&sick));
let fine = vec![Habitat {
name: "landfold",
state: "not on PATH".into(),
ok: false,
}];
assert!(healthy(&fine));
assert_eq!(super::parse_semver("ljos 0.12.8"), Some("0.12.8"));
assert_eq!(
super::cmp_semver("0.4.1", "0.5.3"),
Some(std::cmp::Ordering::Less)
);
}
#[test]
fn enclosed_atoms_are_read_from_every_jsonl_in_the_bag() {
let dir = std::env::temp_dir().join(format!("ljos-bag-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let atoms = dir.join("data").join("atoms");
std::fs::create_dir_all(&atoms).unwrap();
std::fs::write(
atoms.join("a.jsonl"),
"{\"kind\":\"lesson\",\"text\":\"one\"}\n\n{\"kind\":\"trust\",\"from\":\"a\",\"to\":\"b\",\"weight\":0.5}\n",
)
.unwrap();
std::fs::write(
atoms.join("b.jsonl"),
"{\"kind\":\"preference\",\"text\":\"two\"}\n",
)
.unwrap();
let read = enclosed_atoms(&dir).unwrap();
assert_eq!(read.len(), 3);
assert_eq!(trust_rows(&read).len(), 1);
assert!(enclosed_atoms(&dir.join("nowhere")).unwrap().is_empty());
std::fs::write(atoms.join("c.jsonl"), "not json\n").unwrap();
assert!(enclosed_atoms(&dir).is_err());
let _ = std::fs::remove_dir_all(&dir);
let table = format_due(&[serde_json::json!({
"id": "x", "kind": "lesson", "text": "t", "due_at": "2026-01-01T00:00:00.000Z"
})]);
assert_eq!(table, "2026-01-01T00:00:00.000Z\tlesson\tx\tt\n");
}
fn read_http(s: &mut impl Read) -> String {
let mut buf = Vec::new();
let mut tmp = [0u8; 1024];
loop {
let n = s.read(&mut tmp).unwrap_or(0);
if n == 0 {
break;
}
buf.extend_from_slice(&tmp[..n]);
if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
let headers = &buf[..at];
let mut need = 0usize;
for line in headers.split(|b| *b == b'\n') {
let line = std::str::from_utf8(line).unwrap_or("").trim();
if let Some(v) = line
.split_once(':')
.filter(|(k, _)| k.eq_ignore_ascii_case("content-length"))
.map(|(_, v)| v.trim())
{
need = v.parse().unwrap_or(0);
}
}
let have = buf.len().saturating_sub(at + 4);
if have >= need {
break;
}
}
}
String::from_utf8_lossy(&buf).into_owned()
}
fn serve_capture() -> (String, Arc<Mutex<String>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let captured = Arc::new(Mutex::new(String::new()));
let slot = captured.clone();
std::thread::spawn(move || {
if let Ok((mut s, _)) = listener.accept() {
*slot.lock().unwrap() = read_http(&mut s);
let body =
r#"{"id":"atom-1","kind":"lesson","text":"the default fuse is CombMNZ"}"#;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = s.write_all(resp.as_bytes());
}
});
(format!("http://{addr}"), captured)
}
#[test]
fn remember_posts_v1_atoms() {
let (url, captured) = serve_capture();
let client = PacksetClient::new(&url);
let body = post_claim(&client, "Remember", "the default fuse is CombMNZ", "ws").unwrap();
assert_eq!(body["id"], "atom-1");
let req = captured.lock().unwrap().clone();
assert!(req.contains("POST"), "{req}");
assert!(req.contains("/v1/atoms"), "{req}");
assert!(req.contains("\"kind\":\"lesson\""), "{req}");
assert!(req.contains("the default fuse is CombMNZ"), "{req}");
assert!(req.contains("\"level\":\"explicit\""), "{req}");
assert!(!req.contains("extract"), "{req}");
}
#[test]
fn forget_posts_the_id_and_workspace() {
let (url, captured) = serve_capture();
let client = PacksetClient::new(&url);
let body = client.delete_atom("ws", "atom-1", None).unwrap();
assert_eq!(body["id"], "atom-1");
let req = captured.lock().unwrap().clone();
assert!(req.contains("POST"), "{req}");
assert!(req.contains("/v1/atoms/delete"), "{req}");
assert!(req.contains("\"id\":\"atom-1\""), "{req}");
assert!(req.contains("\"workspace\":\"ws\""), "{req}");
assert!(!req.contains("\"why\""), "{req}");
}
#[test]
fn forget_carries_the_deed_that_withdrew_the_claim() {
let (url, captured) = serve_capture();
let client = PacksetClient::new(&url);
client
.delete_atom("ws", "atom-1", Some("deed-patch-overlay"))
.unwrap();
let req = captured.lock().unwrap().clone();
assert!(req.contains("\"why\":\"deed-patch-overlay\""), "{req}");
}
#[test]
fn forget_refuses_an_empty_id() {
let err = packset_forget(" ", None).unwrap_err();
assert!(err.to_string().contains("atom id is required"), "{err}");
}
}