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