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