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