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