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