1use std::path::{Path, PathBuf};
8
9use anyhow::{bail, Context, Result};
10use packset_client::{Hit, PacksetClient};
11use serde_json::Value;
12
13pub mod hud;
14pub mod jev;
15pub mod sync;
16
17pub const CARD_NAMES: &[&str] = &["USER.md", "MEMORY.md"];
19
20pub const PROTOCOL: &str = include_str!("../doc/protocol.md");
25
26#[must_use]
28pub fn skill_text() -> String {
29 format!(
30 "---\nname: ljos\ndescription: >\n The seat protocol for vissue, packset, deedar, claimdag and \
31consensus through ljos: which store answers which question, the order of verbs in a \
32sitting, and the refusals worth knowing. Load before any work that touches an issue, \
33a memory, a deed, a claim or a vote.\n---\n\n{PROTOCOL}"
34 )
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Step {
40 pub what: String,
41 pub detail: String,
42 pub ok: bool,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
56pub struct Harness {
57 pub name: String,
58 #[serde(default)]
59 pub register: Vec<String>,
60 #[serde(default)]
61 pub registered: Vec<String>,
62 #[serde(default)]
63 pub config: Option<String>,
64 #[serde(default)]
65 pub marker: Option<String>,
66 #[serde(default)]
67 pub snippet: Option<String>,
68 pub config_json: Option<String>,
71 pub json_pointer: Option<String>,
73 pub json_entry: Option<String>,
76 #[serde(default)]
77 pub skills: Option<String>,
78 #[serde(default)]
84 pub hooks: Option<String>,
85 #[serde(default)]
90 pub hook_events: Vec<String>,
91 #[serde(default)]
95 pub plugin: Option<String>,
96 #[serde(default)]
98 pub plugin_template: Option<String>,
99 #[serde(default)]
104 pub probe: Vec<String>,
105 #[serde(default)]
110 pub clients: Vec<String>,
111}
112
113pub const PLUGIN_TEMPLATES: &[(&str, &str)] = &[
116 ("opencode", include_str!("../assets/opencode/ljos.ts")),
117 ("omp", include_str!("../assets/omp/ljos.ts")),
118];
119
120fn plugin_text(h: &Harness, ljos: &Path) -> Option<String> {
122 let name = h.plugin_template.as_deref()?;
123 PLUGIN_TEMPLATES
124 .iter()
125 .find(|(n, _)| *n == name)
126 .map(|(_, t)| t.replace("{ljos}", &ljos.display().to_string()))
127}
128
129fn plugin_step(h: &Harness, dest: &Path, dry: bool) -> Step {
130 let what = "plugin".to_string();
131 let ljos = match ljos_path() {
132 Ok(l) => l,
133 Err(e) => {
134 return Step {
135 what,
136 detail: format!("{e:#}"),
137 ok: false,
138 };
139 }
140 };
141 let Some(text) = plugin_text(h, &ljos) else {
142 return Step {
143 what,
144 detail: format!(
145 "plugin_template {:?} is not one of {}",
146 h.plugin_template.as_deref().unwrap_or(""),
147 PLUGIN_TEMPLATES
148 .iter()
149 .map(|(n, _)| *n)
150 .collect::<Vec<_>>()
151 .join(", ")
152 ),
153 ok: false,
154 };
155 };
156 if std::fs::read_to_string(dest).is_ok_and(|have| have == text) {
157 return Step {
158 what,
159 detail: format!("{} is current", dest.display()),
160 ok: true,
161 };
162 }
163 if dry {
164 return Step {
165 what,
166 detail: format!("would write {}", dest.display()),
167 ok: true,
168 };
169 }
170 let written = dest
171 .parent()
172 .map_or(Ok(()), std::fs::create_dir_all)
173 .and_then(|()| std::fs::write(dest, text));
174 match written {
175 Ok(()) => Step {
176 what,
177 detail: format!("wrote {}", dest.display()),
178 ok: true,
179 },
180 Err(e) => Step {
181 what,
182 detail: format!("{}: {e}", dest.display()),
183 ok: false,
184 },
185 }
186}
187
188#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
190pub struct Harnesses {
191 #[serde(default)]
192 pub harness: Vec<Harness>,
193}
194
195pub const HARNESSES_EXAMPLE: &str = r#"# ~/.config/ljos/harnesses.toml: runners this machine registers by command.
198# Optional: `ljos onboard` alone prints the one entry any runner takes.
199# {server} is replaced by the path to ljos-mcp, {name} by the runner's name.
200# Paths may start with ~. The seat names itself after the client that
201# connects; nothing is passed in env.
202
203[[harness]]
204name = "runner-with-a-command"
205register = ["runner", "mcp", "add", "-s", "user", "ljos", "--", "{server}"]
206registered = ["runner", "mcp", "get", "ljos"]
207skills = "~/.runner/skills"
208hooks = "~/.runner/settings.json"
209# hook_events = ["UserPromptSubmit", "PreToolUse"] # the default is the prompt alone
210
211[[harness]]
212name = "runner-with-a-config-file"
213config = "~/.other/config.toml"
214marker = "[mcp_servers.ljos]"
215# A runner that rebuilds its servers' environment from a short list must be
216# told to pass XDG_RUNTIME_DIR, where the seat records live.
217snippet = "\n[mcp_servers.ljos]\ncommand = \"{server}\"\nargs = []\nenv_vars = [\"XDG_RUNTIME_DIR\"]\n"
218skills = "~/.other/skills"
219hooks = "~/.other/hooks.json"
220# A runner with no SessionEnd event takes the prompt and the tool call.
221hook_events = ["UserPromptSubmit", "PreToolUse"]
222
223[[harness]]
224name = "runner-with-a-json-config"
225config_json = "~/.config/runner/runner.json"
226json_pointer = "/mcp/ljos"
227json_entry = '{"type": "local", "command": ["{server}"], "enabled": true, "environment": {"LJOS_SEAT": "{name}"}}'
228skills = "~/.config/runner/skills"
229
230# Runners this seat has carried through the same work, as they take the
231# server on this machine: a runner with an `mcp add` of its own is the
232# first shape above, a runner with a TOML config the second. Copy the
233# ones you run.
234
235[[harness]]
236name = "opencode"
237config_json = "~/.config/opencode/opencode.json"
238json_pointer = "/mcp/ljos"
239json_entry = '{"type": "local", "command": ["{server}"], "enabled": true, "timeout": 30000}'
240skills = "~/.config/opencode/skills"
241# opencode's hooks are a plugin: the memory hook on each prompt, argv law
242# on each bash call, the session id in every shell it opens.
243plugin = "~/.config/opencode/plugins/ljos.ts"
244plugin_template = "opencode"
245
246[[harness]]
247name = "hermes"
248# `hermes mcp add` asks which tools to enable; the answer is all of them.
249register = ["sh", "-c", "printf 'Y\\n' | hermes mcp add ljos --command {server}"]
250config = "~/.hermes/config.yaml"
251marker = "\n ljos:\n command:"
252skills = "~/.hermes/skills"
253# A hermes installed without its MCP extra lists ljos and loads nothing.
254probe = ["hermes", "mcp", "test", "ljos"]
255
256[[harness]]
257name = "omp"
258config_json = "~/.omp/agent/mcp.json"
259json_pointer = "/mcpServers/ljos"
260json_entry = '{"type": "stdio", "command": "{server}", "args": []}'
261# A host whose omp config sets enablePiUser false reads skills from its
262# skills.customDirectories instead; name that directory here.
263skills = "~/.omp/agent/skills"
264plugin = "~/.omp/agent/extensions/ljos.ts"
265plugin_template = "omp"
266
267[[harness]]
268name = "grok"
269config = "~/.grok/config.toml"
270marker = "[mcp_servers.ljos]"
271snippet = "\n[mcp_servers.ljos]\ncommand = \"{server}\"\nargs = []\nenabled = true\n"
272skills = "~/.grok/skills"
273"#;
274
275fn home() -> Result<PathBuf> {
276 std::env::var_os("HOME")
277 .map(PathBuf::from)
278 .context("HOME unset; onboard needs a home directory")
279}
280
281fn expand(path: &str) -> PathBuf {
283 match path.strip_prefix("~/") {
284 Some(rest) => home().map_or_else(|_| PathBuf::from(path), |h| h.join(rest)),
285 None => PathBuf::from(path),
286 }
287}
288
289#[must_use]
291pub fn harnesses_path() -> PathBuf {
292 std::env::var_os("XDG_CONFIG_HOME")
293 .filter(|r| !r.is_empty())
294 .map(PathBuf::from)
295 .or_else(|| home().ok().map(|h| h.join(".config")))
296 .unwrap_or_else(|| PathBuf::from(".config"))
297 .join("ljos")
298 .join("harnesses.toml")
299}
300
301pub fn harnesses_from(path: &Path) -> Result<Harnesses> {
307 match std::fs::read_to_string(path) {
308 Ok(text) => toml::from_str(&text).with_context(|| format!("{}", path.display())),
309 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Harnesses::default()),
310 Err(e) => Err(e).with_context(|| format!("{}", path.display())),
311 }
312}
313
314fn server_path() -> Result<PathBuf> {
316 which::which("ljos-mcp").context("ljos-mcp not on PATH; install it beside ljos")
317}
318
319pub fn server_entry() -> Result<Value> {
321 Ok(serde_json::json!({
322 "mcpServers": {
323 "ljos": {
324 "type": "stdio",
325 "command": server_path()?.display().to_string(),
326 "args": [],
327 "env": {}
328 }
329 }
330 }))
331}
332
333fn write_skill(dir: &Path, dry: bool) -> Step {
334 let path = dir.join("ljos").join("SKILL.md");
335 let text = skill_text();
336 if std::fs::read_to_string(&path).is_ok_and(|have| have == text) {
337 return Step {
338 what: "skill".into(),
339 detail: format!("{} is current", path.display()),
340 ok: true,
341 };
342 }
343 if dry {
344 return Step {
345 what: "skill".into(),
346 detail: format!("would write {}", path.display()),
347 ok: true,
348 };
349 }
350 let written = std::fs::create_dir_all(path.parent().unwrap_or(dir))
351 .and_then(|()| std::fs::write(&path, text));
352 match written {
353 Ok(()) => Step {
354 what: "skill".into(),
355 detail: format!("wrote {}", path.display()),
356 ok: true,
357 },
358 Err(e) => Step {
359 what: "skill".into(),
360 detail: format!("{}: {e}", path.display()),
361 ok: false,
362 },
363 }
364}
365
366fn filled(argv: &[String], server: &Path, name: &str) -> Vec<String> {
369 argv.iter()
370 .map(|a| a.replace("{server}", &server.display().to_string()))
371 .map(|a| a.replace("{name}", name))
372 .collect()
373}
374
375fn omitted_actor_name(name: &str) -> bool {
379 matches!(
380 name.trim().to_ascii_lowercase().as_str(),
381 "seat" | "you" | "agent"
382 )
383}
384
385fn own_seat(name: &str) -> bool {
389 let n = name.trim();
390 std::env::var("LJOS_SEAT")
391 .ok()
392 .is_some_and(|s| s.trim() == n)
393 || whoami().seat == n
394}
395
396fn session_actor() -> Option<(String, String)> {
400 let mut parts: Vec<(String, String)> = std::env::vars()
401 .filter(|(k, v)| runner_session_var(k, v))
402 .collect();
403 if parts.is_empty() {
404 return None;
405 }
406 parts.sort_by(|a, b| a.0.cmp(&b.0));
407 if parts.len() == 1 {
408 return Some(session_from_value(&parts[0].0, &parts[0].1));
409 }
410 let joined = parts
411 .iter()
412 .map(|(k, v)| format!("{k}={}", v.trim()))
413 .collect::<Vec<_>>()
414 .join(";");
415 let id = work_id(&joined);
416 let keys = parts
417 .iter()
418 .map(|(k, _)| k.as_str())
419 .collect::<Vec<_>>()
420 .join("+");
421 Some((format!("sess-{id}"), keys))
422}
423
424fn runner_session_var(key: &str, val: &str) -> bool {
429 (key.ends_with("_SESSION_ID") || key.ends_with("_THREAD_ID"))
430 && key != "XDG_SESSION_ID"
431 && val.trim().len() >= 8
432}
433
434fn session_from_value(key: &str, raw: &str) -> (String, String) {
435 (raw.trim().to_string(), key.to_string())
436}
437
438#[derive(Debug, Clone, PartialEq, Eq)]
444pub struct Seat {
445 pub seat: String,
446 pub holder: String,
447 pub source: String,
449}
450
451impl Seat {
452 fn whole(name: &str, source: &str) -> Self {
453 Self {
454 seat: name.to_string(),
455 holder: name.to_string(),
456 source: source.to_string(),
457 }
458 }
459
460 fn tagged(seat: String, tag: &str, source: String) -> Self {
461 Self {
462 holder: format!("{seat}-{tag}"),
463 seat,
464 source,
465 }
466 }
467}
468
469static ANNOUNCED: std::sync::OnceLock<Seat> = std::sync::OnceLock::new();
471
472#[must_use]
475pub fn seat_slug(name: &str) -> String {
476 let mut out = String::new();
477 for c in name.trim().chars() {
478 if c.is_ascii_alphanumeric() {
479 out.push(c.to_ascii_lowercase());
480 } else if !out.is_empty() && !out.ends_with('-') {
481 out.push('-');
482 }
483 }
484 let out = out.trim_end_matches('-').to_string();
485 if out.is_empty() {
486 "runner".to_string()
487 } else {
488 out
489 }
490}
491
492#[must_use]
495pub fn conversation_tag(pid: u32) -> String {
496 const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
497 let mut n = u64::from(pid);
498 let mut out = Vec::new();
499 loop {
500 out.push(DIGITS[(n % 36) as usize]);
501 n /= 36;
502 if n == 0 {
503 break;
504 }
505 }
506 out.reverse();
507 String::from_utf8(out).unwrap_or_default()
508}
509
510fn runtime_dir() -> PathBuf {
513 std::env::var_os("XDG_RUNTIME_DIR")
514 .filter(|r| !r.is_empty())
515 .map(PathBuf::from)
516 .unwrap_or_else(std::env::temp_dir)
517 .join("ljos")
518}
519
520fn seat_record_path(runner_pid: u32) -> PathBuf {
522 runtime_dir().join(format!("seat-{runner_pid}"))
523}
524
525#[must_use]
528pub fn runner_pid() -> u32 {
529 let ppid = unsafe { libc::getppid() };
531 u32::try_from(ppid).unwrap_or(0)
532}
533
534pub fn mcp_forward(
543 program: &Path,
544 marker: &str,
545 init: Option<Value>,
546 params: Value,
547) -> Result<Value> {
548 use std::io::{BufRead, Write};
549 use std::process::{Command, Stdio};
550 let mut child = Command::new(program)
551 .env(marker, "1")
552 .stdin(Stdio::piped())
553 .stdout(Stdio::piped())
554 .stderr(Stdio::inherit())
555 .spawn()
556 .with_context(|| format!("{}: spawn", program.display()))?;
557 let init = init.unwrap_or_else(|| {
558 serde_json::json!({"protocolVersion": "2025-06-18", "capabilities": {},
559 "clientInfo": {"name": "runner", "version": "0"}})
560 });
561 let lines = [
562 serde_json::json!({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": init}),
563 serde_json::json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
564 serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params}),
565 ];
566 {
567 let stdin = child.stdin.as_mut().context("forward: stdin closed")?;
568 for line in &lines {
569 writeln!(stdin, "{line}")?;
570 }
571 }
572 let stdout = child.stdout.take().context("forward: stdout closed")?;
573 let mut answer = None;
574 for line in std::io::BufReader::new(stdout).lines() {
575 let Ok(v) = serde_json::from_str::<Value>(&line?) else {
576 continue;
577 };
578 if v["id"] == serde_json::json!(1) {
579 answer = Some(v);
580 break;
581 }
582 }
583 drop(child.stdin.take());
584 let _ = child.wait();
585 answer.with_context(|| format!("{}: closed without answering the call", program.display()))
586}
587
588fn stamped_sessions() -> Vec<(String, String)> {
592 let mut found: Vec<(String, String)> = std::env::vars()
593 .filter(|(k, v)| runner_session_var(k, v))
594 .map(|(k, v)| (k, v.trim().to_string()))
595 .collect();
596 found.sort();
597 found
598}
599
600#[must_use]
604pub fn session_tag(id: &str) -> String {
605 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
606 for b in id.trim().bytes() {
607 h ^= u64::from(b);
608 h = h.wrapping_mul(0x0100_0000_01b3);
609 }
610 const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
611 let mut out = Vec::new();
612 for _ in 0..10 {
613 out.push(DIGITS[(h % 36) as usize]);
614 h /= 36;
615 }
616 String::from_utf8(out).unwrap_or_default()
617}
618
619fn session_record_path(id: &str) -> PathBuf {
622 runtime_dir().join(format!("session-{}", session_tag(id)))
623}
624
625fn write_record(path: &Path, seat: &Seat) {
630 let ids: Vec<String> = stamped_sessions().into_iter().map(|(_, id)| id).collect();
631 write_record_ids(path, seat, &ids);
632}
633
634fn write_record_ids(path: &Path, seat: &Seat, ids: &[String]) {
635 if let Some(dir) = path.parent() {
636 let _ = std::fs::create_dir_all(dir);
637 }
638 let _ = std::fs::write(
639 path,
640 format!("{}\n{}\nids\t{}\n", seat.seat, seat.holder, ids.join("\t")),
641 );
642}
643
644fn read_record(path: &Path, source: String) -> Option<Seat> {
645 let text = std::fs::read_to_string(path).ok()?;
646 let mine: Vec<String> = stamped_sessions().into_iter().map(|(_, id)| id).collect();
647 record_for(&text, &mine, source)
648}
649
650fn record_for(text: &str, mine: &[String], source: String) -> Option<Seat> {
655 let mut lines = text.lines();
656 let (seat, holder) = (lines.next()?, lines.next()?);
657 if let Some(ids) = lines.next().and_then(|l| l.strip_prefix("ids")) {
658 let foreign = ids
659 .split('\t')
660 .map(str::trim)
661 .filter(|id| !id.is_empty())
662 .any(|id| !mine.iter().any(|m| m == id));
663 if foreign {
664 return None;
665 }
666 }
667 Some(Seat {
668 seat: seat.to_string(),
669 holder: holder.to_string(),
670 source,
671 })
672}
673
674const LIBRARY_CLIENT_NAMES: &[&str] = &["mcp", "mcp-client", "client", "runner"];
678
679fn seat_for_client(client: &str) -> String {
682 let name = seat_slug(client);
683 if let Some(runner) = runner_for_client(&harnesses_path(), &name) {
684 return runner;
685 }
686 if !LIBRARY_CLIENT_NAMES.contains(&name.as_str()) {
687 return name;
688 }
689 ancestry()
690 .into_iter()
691 .find(|(_, comm)| !WRAPPERS.contains(&comm.as_str()))
692 .map(|(pid, comm)| seat_slug(&program_name(pid, &comm)))
693 .unwrap_or(name)
694}
695
696fn runner_for_client(file: &Path, slug: &str) -> Option<String> {
699 harnesses_from(file)
700 .ok()?
701 .harness
702 .into_iter()
703 .find_map(|h| {
704 h.clients
705 .iter()
706 .any(|c| seat_slug(c) == slug)
707 .then(|| seat_slug(&h.name))
708 })
709}
710
711fn inherited_record(name: &str) -> Option<Seat> {
715 stamped_sessions().into_iter().find_map(|(_, id)| {
716 read_record(&session_record_path(&id), String::new()).filter(|s| s.seat != name)
717 })
718}
719
720tokio::task_local! {
721 static CALL_SEAT: Seat;
723}
724
725pub async fn as_thread<F: std::future::Future>(thread: Option<String>, f: F) -> F::Output {
729 match thread.filter(|t| t.trim().len() >= 8) {
730 Some(t) => CALL_SEAT.scope(seat_for_thread(&t), f).await,
731 None => f.await,
732 }
733}
734
735#[must_use]
739pub fn seat_for_thread(thread: &str) -> Seat {
740 let thread = thread.trim();
741 let seat = named_var("LJOS_SEAT")
742 .or_else(|| ANNOUNCED.get().map(|s| s.seat.clone()))
743 .unwrap_or_else(login_user);
744 let path = session_record_path(thread);
745 if let Some(holder) = std::fs::read_to_string(&path)
746 .ok()
747 .and_then(|t| holder_naming(&t, thread))
748 {
749 return Seat {
750 seat,
751 holder,
752 source: "the thread the runner named on this call, as its shells hold it".into(),
753 };
754 }
755 let found = Seat {
756 seat,
757 holder: thread.to_string(),
758 source: "the thread the runner named on this call".into(),
759 };
760 write_record_ids(&path, &found, &[thread.to_string()]);
761 found
762}
763
764fn holder_naming(text: &str, id: &str) -> Option<String> {
766 let mut lines = text.lines();
767 let (_, holder) = (lines.next()?, lines.next()?);
768 let ids = lines.next()?.strip_prefix("ids")?;
769 ids.split('\t')
770 .any(|i| i.trim() == id)
771 .then(|| holder.to_string())
772}
773
774pub fn announce_seat(client: &str, runner_pid: u32) -> Seat {
782 let name = seat_for_client(client);
783 if let Some(parent) = inherited_record(&name) {
784 let seat = Seat::tagged(
785 name,
786 &conversation_tag(runner_pid),
787 format!(
788 "the client that connected, process {runner_pid}, inside {}",
789 parent.seat
790 ),
791 );
792 write_record(&seat_record_path(runner_pid), &seat);
793 let _ = ANNOUNCED.set(seat.clone());
794 return seat;
795 }
796 let seat = if let Some((holder, keys)) = session_actor() {
797 Seat {
798 seat: name,
799 holder,
800 source: format!("the client that connected, process {runner_pid}; session {keys}"),
801 }
802 } else {
803 Seat::tagged(
804 name,
805 &conversation_tag(runner_pid),
806 format!("the client that connected, process {runner_pid}"),
807 )
808 };
809 write_record(&seat_record_path(runner_pid), &seat);
813 for (_, id) in stamped_sessions() {
814 write_record(&session_record_path(&id), &seat);
815 }
816 let _ = ANNOUNCED.set(seat.clone());
817 seat
818}
819
820pub fn retire_seat(runner_pid: u32) {
822 let mine = read_record(&seat_record_path(runner_pid), String::new());
823 let _ = std::fs::remove_file(seat_record_path(runner_pid));
824 for (_, id) in stamped_sessions() {
825 let path = session_record_path(&id);
826 let theirs = read_record(&path, String::new())
828 .is_some_and(|r| mine.as_ref().is_some_and(|m| m.holder != r.holder));
829 if !theirs {
830 let _ = std::fs::remove_file(path);
831 }
832 }
833}
834
835fn seat_from_session_records() -> Option<Seat> {
839 stamped_sessions().into_iter().find_map(|(key, id)| {
840 read_record(
841 &session_record_path(&id),
842 format!("this conversation's record, session {key}"),
843 )
844 })
845}
846
847#[cfg(target_os = "linux")]
849fn parent_and_comm(pid: u32) -> Option<(u32, String)> {
850 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
851 let open = stat.find('(')?;
852 let close = stat.rfind(')')?;
853 let comm = stat.get(open + 1..close)?.to_string();
854 let ppid = stat
855 .get(close + 2..)?
856 .split_whitespace()
857 .nth(1)?
858 .parse()
859 .ok()?;
860 Some((ppid, comm))
861}
862
863#[cfg(not(target_os = "linux"))]
864fn parent_and_comm(_pid: u32) -> Option<(u32, String)> {
865 None
866}
867
868fn ancestry() -> Vec<(u32, String)> {
871 let mut out = Vec::new();
872 let mut pid = std::process::id();
873 for _ in 0..32 {
874 let Some((ppid, _)) = parent_and_comm(pid) else {
875 break;
876 };
877 if ppid <= 1 {
878 break;
879 }
880 let Some((_, comm)) = parent_and_comm(ppid) else {
881 break;
882 };
883 out.push((ppid, comm));
884 pid = ppid;
885 }
886 out
887}
888
889const WRAPPERS: &[&str] = &[
891 "sh", "bash", "zsh", "fish", "dash", "ksh", "tcsh", "csh", "nu", "env", "sudo", "doas",
892 "timeout", "nohup", "xargs", "script", "uv", "direnv", "ljos", "ljos-mcp",
893];
894
895const SESSION: &[&str] = &[
898 "tmux", "screen", "zellij", "systemd", "init", "sshd", "login",
899];
900
901const PLACES: &[&str] = &[
903 "bin",
904 "sbin",
905 "versions",
906 "current",
907 "dist",
908 "build",
909 "target",
910 "release",
911 "debug",
912 "node_modules",
913 ".bin",
914 "lib",
915 "libexec",
916 "app",
917 "resources",
918];
919
920const INTERPRETERS: &[&str] = &[
922 "node", "nodejs", "bun", "deno", "python", "python3", "ruby", "perl", "java",
923];
924
925fn version_like(s: &str) -> bool {
926 let t = s.strip_prefix('v').unwrap_or(s);
927 t.chars().next().is_some_and(|c| c.is_ascii_digit())
928}
929
930#[cfg(target_os = "linux")]
935fn program_name(pid: u32, comm: &str) -> String {
936 let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default();
937 let args: Vec<String> = cmdline
938 .split(|b| *b == 0)
939 .filter(|a| !a.is_empty())
940 .map(|a| String::from_utf8_lossy(a).into_owned())
941 .collect();
942 let mut candidates: Vec<&str> = Vec::new();
943 if let Some(first) = args.first() {
944 let base = Path::new(first)
945 .file_name()
946 .and_then(|f| f.to_str())
947 .unwrap_or(first);
948 if INTERPRETERS.contains(&base) {
949 if let Some(script) = args.iter().skip(1).find(|a| !a.starts_with('-')) {
950 candidates.push(script);
951 }
952 }
953 candidates.push(first);
954 }
955 for path in candidates {
956 let mut parts: Vec<&str> = Path::new(path)
957 .components()
958 .filter_map(|c| c.as_os_str().to_str())
959 .collect();
960 while let Some(last) = parts.pop() {
961 let name = last.rsplit_once('.').map_or(last, |(stem, ext)| {
962 if ["js", "mjs", "cjs", "py", "rb", "pl", "jar", "exe"].contains(&ext) {
963 stem
964 } else {
965 last
966 }
967 });
968 if name.is_empty() || version_like(name) || PLACES.contains(&name) || name == "/" {
969 continue;
970 }
971 if name.starts_with('.') || name.contains(std::path::MAIN_SEPARATOR) {
972 continue;
973 }
974 return name.to_string();
975 }
976 }
977 comm.to_string()
978}
979
980#[cfg(not(target_os = "linux"))]
981fn program_name(_pid: u32, comm: &str) -> String {
982 comm.to_string()
983}
984
985fn seat_from_tree() -> Option<Seat> {
991 if let Some(seat) = seat_from_tree_records() {
992 return Some(seat);
993 }
994 let chain = ancestry();
995 for (pid, comm) in &chain {
996 let name = comm.as_str();
997 if WRAPPERS.contains(&name) {
998 continue;
999 }
1000 if SESSION.iter().any(|s| name.starts_with(s)) {
1001 return None;
1002 }
1003 let program = program_name(*pid, name);
1004 return Some(Seat::tagged(
1005 seat_slug(&program),
1006 &conversation_tag(*pid),
1007 format!("the process tree, {program} {pid}"),
1008 ));
1009 }
1010 None
1011}
1012
1013fn seat_from_tree_records() -> Option<Seat> {
1017 ancestry().into_iter().find_map(|(pid, _)| {
1018 read_record(
1019 &seat_record_path(pid),
1020 format!("the server the runner opened, process {pid}"),
1021 )
1022 })
1023}
1024
1025fn named_var(key: &str) -> Option<String> {
1026 std::env::var(key)
1027 .ok()
1028 .map(|v| v.trim().to_string())
1029 .filter(|v| !v.is_empty() && !omitted_actor_name(v))
1030}
1031
1032#[must_use]
1041pub fn whoami() -> Seat {
1042 if let Ok(seat) = CALL_SEAT.try_with(Clone::clone) {
1043 return seat;
1044 }
1045 let session = session_actor();
1046 let named = named_var("LJOS_SEAT")
1049 .map(|n| (n, "LJOS_SEAT"))
1050 .or_else(|| named_var("VISSUE_AGENT").map(|n| (n, "VISSUE_AGENT")));
1051 let record = seat_from_session_records().map(|by_id| {
1056 seat_from_tree_records()
1057 .filter(|above| above.seat != by_id.seat)
1058 .unwrap_or(by_id)
1059 });
1060 let program = ANNOUNCED
1061 .get()
1062 .cloned()
1063 .or_else(|| record.clone())
1064 .or_else(seat_from_tree);
1065 let agent = named_var("VISSUE_AGENT");
1066 let seat_name = named
1067 .as_ref()
1068 .map(|(n, _)| n.clone())
1069 .or_else(|| program.as_ref().map(|p| p.seat.clone()))
1070 .or_else(|| agent.clone())
1071 .unwrap_or_else(login_user);
1072 if let Some(record) = record {
1075 return Seat {
1076 seat: seat_name,
1077 holder: record.holder,
1078 source: record.source,
1079 };
1080 }
1081 if let Some((holder, keys)) = session {
1082 let seat = Seat {
1083 seat: seat_name,
1084 holder,
1085 source: keys,
1086 };
1087 #[cfg(not(test))]
1093 for (_, id) in stamped_sessions() {
1094 write_record(&session_record_path(&id), &seat);
1095 }
1096 return seat;
1097 }
1098 match (&named, &program) {
1099 (Some((name, key)), Some(p)) => Seat {
1100 seat: name.clone(),
1101 holder: p.holder.replacen(&p.seat, name, 1),
1102 source: format!("{key}, held by {}", p.source),
1103 },
1104 (Some((name, key)), None) => Seat::whole(name, key),
1105 (None, Some(p)) => p.clone(),
1106 (None, None) => {
1107 if let Some(name) = agent {
1108 Seat::whole(&name, "VISSUE_AGENT")
1109 } else {
1110 Seat::whole(&login_user(), "the login user")
1111 }
1112 }
1113 }
1114}
1115
1116fn login_user() -> String {
1118 std::env::var("USER")
1119 .ok()
1120 .map(|u| u.trim().to_string())
1121 .filter(|u| !u.is_empty())
1122 .unwrap_or_else(|| "seat".to_string())
1123}
1124
1125#[must_use]
1127pub fn seat_name() -> String {
1128 whoami().seat
1129}
1130
1131#[must_use]
1133pub fn holder_name() -> String {
1134 whoami().holder
1135}
1136
1137#[must_use]
1142pub fn resolve_assignee(passed: Option<&str>) -> String {
1143 match passed.map(str::trim).filter(|s| !s.is_empty()) {
1144 Some(n) if !omitted_actor_name(n) && !own_seat(n) => n.to_string(),
1145 _ => holder_name(),
1146 }
1147}
1148
1149#[must_use]
1153pub fn occupancy_assignee(passed: Option<&str>, issue: &str) -> String {
1154 occupancy_scope(&resolve_assignee(passed), issue)
1155}
1156
1157fn occupancy_scope(assignee: &str, issue: &str) -> String {
1158 let issue = issue.trim();
1159 if issue.is_empty() || assignee.contains(':') {
1160 assignee.to_string()
1161 } else {
1162 format!("{assignee}:{issue}")
1163 }
1164}
1165
1166#[must_use]
1169pub fn format_seat_row() -> String {
1170 let who = whoami();
1171 format!(
1172 "{}, holding as {} (from {})",
1173 who.seat, who.holder, who.source
1174 )
1175}
1176
1177#[must_use]
1179pub fn format_seat(seat: &Seat) -> String {
1180 format!(
1181 "seat\t{}\nholder\t{}\nsource\t{}\n",
1182 seat.seat, seat.holder, seat.source
1183 )
1184}
1185
1186fn is_registered(h: &Harness, server: &Path) -> Option<bool> {
1188 if !h.registered.is_empty() {
1189 let argv = filled(&h.registered, server, &h.name);
1190 return Some(
1191 argv.first().is_some_and(|bin| on_path(bin)) && {
1192 let (bin, rest) = (&argv[0], &argv[1..]);
1193 run_captured(bin, rest).is_ok()
1194 },
1195 );
1196 }
1197 if let (Some(config), Some(marker)) = (&h.config, &h.marker) {
1198 return Some(std::fs::read_to_string(expand(config)).is_ok_and(|t| t.contains(marker)));
1199 }
1200 if let (Some(config), Some(pointer)) = (&h.config_json, &h.json_pointer) {
1201 return Some(
1202 std::fs::read_to_string(expand(config))
1203 .ok()
1204 .and_then(|t| serde_json::from_str::<Value>(&t).ok())
1205 .is_some_and(|doc| doc.pointer(pointer).is_some()),
1206 );
1207 }
1208 None
1209}
1210
1211fn set_json_entry(config: &Path, pointer: &str, entry: &Value) -> Result<()> {
1214 let mut doc: Value = match std::fs::read_to_string(config) {
1215 Ok(t) if !t.trim().is_empty() => {
1216 serde_json::from_str(&t).with_context(|| format!("{}: not JSON", config.display()))?
1217 }
1218 _ => serde_json::json!({}),
1219 };
1220 let mut at = &mut doc;
1221 let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
1222 let (last, path) = parts
1223 .split_last()
1224 .context("onboard: an empty JSON pointer")?;
1225 for key in path {
1226 at = at
1227 .as_object_mut()
1228 .context("onboard: the pointer crosses a value that is not an object")?
1229 .entry((*key).to_string())
1230 .or_insert_with(|| serde_json::json!({}));
1231 }
1232 at.as_object_mut()
1233 .context("onboard: the pointer's parent is not an object")?
1234 .insert((*last).to_string(), entry.clone());
1235 if let Some(parent) = config.parent() {
1236 std::fs::create_dir_all(parent)?;
1237 }
1238 let mut text = serde_json::to_string_pretty(&doc)?;
1239 text.push('\n');
1240 std::fs::write(config, text)?;
1241 Ok(())
1242}
1243
1244fn bump_ljos_mcp_generation(config: &Path, version: &str, dry: bool) -> Result<Option<String>> {
1247 let text = match std::fs::read_to_string(config) {
1248 Ok(t) => t,
1249 Err(_) => return Ok(None),
1250 };
1251 let mut changed = false;
1252 let mut out = String::new();
1253 for line in text.lines() {
1254 let trimmed = line.trim_start();
1255 if let Some(rhs) = trimmed.strip_prefix("LJOS_MCP_GENERATION") {
1256 let rhs = rhs.trim_start().strip_prefix('=').unwrap_or("").trim();
1257 let val = rhs.trim_matches(|c| c == '"' || c == '\'');
1258 if val == version {
1259 out.push_str(line);
1260 } else {
1261 let indent_len = line.len() - trimmed.len();
1262 out.push_str(&line[..indent_len]);
1263 out.push_str("LJOS_MCP_GENERATION = \"");
1264 out.push_str(version);
1265 out.push('"');
1266 changed = true;
1267 }
1268 } else {
1269 out.push_str(line);
1270 }
1271 out.push('\n');
1272 }
1273 if !changed {
1274 return Ok(None);
1275 }
1276 if dry {
1277 return Ok(Some(version.to_string()));
1278 }
1279 std::fs::write(config, out).with_context(|| config.display().to_string())?;
1280 Ok(Some(version.to_string()))
1281}
1282
1283fn register_step(h: &Harness, server: &Path, dry: bool) -> Step {
1284 let what = format!("{} mcp", h.name);
1285 match is_registered(h, server) {
1286 Some(true) => {
1287 let config = expand(h.config.as_deref().unwrap_or_default());
1288 match bump_ljos_mcp_generation(&config, env!("CARGO_PKG_VERSION"), dry) {
1289 Ok(Some(v)) => Step {
1290 what,
1291 detail: format!("ljos registered; MCP generation {v}"),
1292 ok: true,
1293 },
1294 Ok(None) => Step {
1295 what,
1296 detail: "ljos registered".into(),
1297 ok: true,
1298 },
1299 Err(e) => Step {
1300 what,
1301 detail: format!("ljos registered; generation {e}"),
1302 ok: false,
1303 },
1304 }
1305 }
1306 None => Step {
1307 what,
1308 detail: "no register or config in harnesses.toml; paste `ljos onboard --harness json`"
1309 .into(),
1310 ok: false,
1311 },
1312 Some(false) if !h.register.is_empty() => {
1313 let argv = filled(&h.register, server, &h.name);
1314 if !on_path(&argv[0]) {
1315 return Step {
1316 what,
1317 detail: format!("{} not on PATH", argv[0]),
1318 ok: false,
1319 };
1320 }
1321 if dry {
1322 return Step {
1323 what,
1324 detail: format!("would run {}", argv.join(" ")),
1325 ok: true,
1326 };
1327 }
1328 match run_captured(&argv[0], &argv[1..]) {
1329 Ok(_) => Step {
1330 what,
1331 detail: format!("ran {}", argv.join(" ")),
1332 ok: true,
1333 },
1334 Err(e) => Step {
1335 what,
1336 detail: e.to_string().lines().next().unwrap_or("").to_string(),
1337 ok: false,
1338 },
1339 }
1340 }
1341 Some(false) if h.config_json.is_some() => {
1342 let config = expand(h.config_json.as_deref().unwrap_or_default());
1343 let pointer = h.json_pointer.clone().unwrap_or_default();
1344 let entry_text = h
1345 .json_entry
1346 .as_deref()
1347 .unwrap_or_default()
1348 .replace("{server}", &server.display().to_string())
1349 .replace("{name}", &h.name);
1350 let entry: Value = match serde_json::from_str(&entry_text) {
1351 Ok(v) => v,
1352 Err(e) => {
1353 return Step {
1354 what,
1355 detail: format!("json_entry is not JSON: {e}"),
1356 ok: false,
1357 }
1358 }
1359 };
1360 if dry {
1361 return Step {
1362 what,
1363 detail: format!("would set {pointer} in {}", config.display()),
1364 ok: true,
1365 };
1366 }
1367 match set_json_entry(&config, &pointer, &entry) {
1368 Ok(()) => Step {
1369 what,
1370 detail: format!("set {pointer} in {}", config.display()),
1371 ok: true,
1372 },
1373 Err(e) => Step {
1374 what,
1375 detail: format!("{}: {e}", config.display()),
1376 ok: false,
1377 },
1378 }
1379 }
1380 Some(false) => {
1381 let config = expand(h.config.as_deref().unwrap_or_default());
1382 let snippet = h
1383 .snippet
1384 .as_deref()
1385 .unwrap_or_default()
1386 .replace("{server}", &server.display().to_string())
1387 .replace("{name}", &h.name);
1388 if snippet.is_empty() {
1389 return Step {
1390 what,
1391 detail: format!("no snippet to append to {}", config.display()),
1392 ok: false,
1393 };
1394 }
1395 if dry {
1396 return Step {
1397 what,
1398 detail: format!("would append the entry to {}", config.display()),
1399 ok: true,
1400 };
1401 }
1402 let mut text = std::fs::read_to_string(&config).unwrap_or_default();
1403 if !text.is_empty() && !text.ends_with('\n') {
1404 text.push('\n');
1405 }
1406 text.push_str(&snippet);
1407 let written = config
1408 .parent()
1409 .map_or(Ok(()), std::fs::create_dir_all)
1410 .and_then(|()| std::fs::write(&config, text));
1411 match written {
1412 Ok(()) => Step {
1413 what,
1414 detail: format!("appended the entry to {}", config.display()),
1415 ok: true,
1416 },
1417 Err(e) => Step {
1418 what,
1419 detail: format!("{}: {e}", config.display()),
1420 ok: false,
1421 },
1422 }
1423 }
1424 }
1425}
1426
1427pub fn onboard(harness: &str, dry: bool) -> Result<Vec<Step>> {
1435 onboard_from(&harnesses_path(), harness, dry)
1436}
1437
1438const GROK_HOOKS_JSON: &str = include_str!("../assets/grok/ljos.json");
1440
1441fn ljos_path() -> Result<PathBuf> {
1445 let beside = server_path()?.with_file_name("ljos");
1446 if beside.is_file() {
1447 return Ok(beside);
1448 }
1449 which::which("ljos").context("ljos not on PATH")
1450}
1451
1452fn grok_hooks_json(ljos: &Path) -> String {
1454 GROK_HOOKS_JSON.replace("{ljos}", &ljos.display().to_string())
1455}
1456
1457fn write_grok_hooks(dry: bool) -> Result<Step> {
1458 let dest = home()?.join(".grok/hooks/ljos.json");
1459 if dry {
1460 return Ok(Step {
1461 what: "hook".into(),
1462 detail: format!("would write {}", dest.display()),
1463 ok: true,
1464 });
1465 }
1466 if let Some(dir) = dest.parent() {
1467 std::fs::create_dir_all(dir)?;
1468 }
1469 std::fs::write(&dest, grok_hooks_json(&ljos_path()?))?;
1470 Ok(Step {
1471 what: "hook".into(),
1472 detail: format!("wrote {}", dest.display()),
1473 ok: true,
1474 })
1475}
1476
1477pub fn onboard_from(file: &Path, harness: &str, dry: bool) -> Result<Vec<Step>> {
1478 if harness == "json" {
1479 return Ok(vec![Step {
1480 what: "json".into(),
1481 detail: serde_json::to_string_pretty(&server_entry()?)?,
1482 ok: true,
1483 }]);
1484 }
1485 if harness == "grok" {
1486 let mut steps = vec![write_grok_hooks(dry)?];
1487 if let Ok(all) = harnesses_from(file) {
1488 if let Some(h) = all.harness.iter().find(|h| h.name == "grok") {
1489 let server = server_path()?;
1490 steps.push(register_step(h, &server, dry));
1491 if let Some(dir) = &h.skills {
1492 steps.push(write_skill(&expand(dir), dry));
1493 }
1494 }
1495 }
1496 return Ok(steps);
1497 }
1498 let all = harnesses_from(file)?;
1499 let Some(h) = all.harness.iter().find(|h| h.name == harness) else {
1500 let names: Vec<&str> = all.harness.iter().map(|h| h.name.as_str()).collect();
1501 bail!(
1502 "onboard: no runner {harness:?} in {}; it names {}. `ljos onboard --example` \
1503 prints the file's shape, and `--harness json` prints the entry to paste anywhere.",
1504 file.display(),
1505 if names.is_empty() {
1506 "none".to_string()
1507 } else {
1508 names.join(", ")
1509 }
1510 );
1511 };
1512 let server = server_path()?;
1513 let dependencies = [pack_step(dry), host_key_step(dry)];
1514 let mut steps = vec![register_step(h, &server, dry)];
1515 if let Some(file) = &h.hooks {
1516 steps.push(hook_step(&expand(file), &hook_events_of(h), dry));
1517 }
1518 if let Some(dest) = &h.plugin {
1519 steps.push(plugin_step(h, &expand(dest), dry));
1520 }
1521 match &h.skills {
1522 Some(dir) => steps.push(write_skill(&expand(dir), dry)),
1523 None => steps.push(Step {
1524 what: "skill".into(),
1525 detail: "no skills directory in harnesses.toml; `ljos protocol` prints the text".into(),
1526 ok: false,
1527 }),
1528 }
1529 steps.extend(dependencies);
1530 Ok(steps)
1531}
1532
1533pub const HOOK_EVENTS: &[&str] = &["UserPromptSubmit", "SessionEnd"];
1539
1540pub const HOOK_MATCHERS: &[(&str, &str)] = &[
1542 ("PreToolUse", "Bash"),
1543 ("PostToolUse", "*"),
1544 ("UserPromptSubmit", "*"),
1545 ("Stop", "*"),
1546 ("SessionEnd", "*"),
1547 ("SubagentStop", "*"),
1548];
1549
1550fn normalize_hook_event(raw: &str) -> &str {
1553 match raw {
1554 "pre_llm_call" => "UserPromptSubmit",
1555 "pre_tool_call" => "PreToolUse",
1556 "post_tool_call" => "PostToolUse",
1557 "on_session_finalize" | "on_session_reset" => "SessionEnd",
1560 "on_session_end" => "TurnEnd",
1561 "pre_tool_use" | "PreToolUse" => "PreToolUse",
1562 "post_tool_use" | "PostToolUse" => "PostToolUse",
1563 "user_prompt_submit" | "UserPromptSubmit" => "UserPromptSubmit",
1564 "session_end" | "SessionEnd" => "SessionEnd",
1565 "session_start" | "SessionStart" => "SessionStart",
1566 "subagent_stop" | "SubagentStop" | "SubagentEnd" | "subagentStop" => "SubagentStop",
1567 "stop" | "Stop" => "Stop",
1568 other => other,
1569 }
1570}
1571
1572fn hook_matcher(event: &str) -> &'static str {
1573 HOOK_MATCHERS
1574 .iter()
1575 .find(|(e, _)| *e == event)
1576 .map_or("*", |(_, m)| m)
1577}
1578
1579fn hook_events_of(h: &Harness) -> Vec<String> {
1581 if h.name == "grok" {
1582 return [
1583 "UserPromptSubmit",
1584 "PostToolUse",
1585 "PreToolUse",
1586 "Stop",
1587 "SessionEnd",
1588 "SubagentStop",
1589 ]
1590 .into_iter()
1591 .map(str::to_string)
1592 .collect();
1593 }
1594 if h.hook_events.is_empty() {
1595 HOOK_EVENTS.iter().map(|e| (*e).to_string()).collect()
1596 } else {
1597 h.hook_events.clone()
1598 }
1599}
1600
1601fn is_seat_hook(h: &Value) -> bool {
1602 h["command"]
1603 .as_str()
1604 .is_some_and(|c| c.contains("ljos") && c.ends_with(" hook"))
1605}
1606
1607fn hook_command() -> String {
1609 which::which("ljos").map_or_else(
1610 |_| "ljos hook".to_string(),
1611 |p| format!("{} hook", p.display()),
1612 )
1613}
1614
1615fn hook_step(file: &Path, events: &[String], dry: bool) -> Step {
1620 let what = "hook".to_string();
1621 let mut root: Value = match std::fs::read_to_string(file) {
1622 Ok(text) if !text.trim().is_empty() => match serde_json::from_str(&text) {
1623 Ok(v) => v,
1624 Err(e) => {
1625 return Step {
1626 what,
1627 detail: format!("{}: not JSON: {e}", file.display()),
1628 ok: false,
1629 }
1630 }
1631 },
1632 _ => serde_json::json!({}),
1633 };
1634 let command = hook_command();
1635 let Some(obj) = root.as_object_mut() else {
1636 return Step {
1637 what,
1638 detail: format!("{}: not a JSON object", file.display()),
1639 ok: false,
1640 };
1641 };
1642 let hooks = obj.entry("hooks").or_insert_with(|| serde_json::json!({}));
1643 let Some(hooks) = hooks.as_object_mut() else {
1644 return Step {
1645 what,
1646 detail: format!("{}: hooks is not an object", file.display()),
1647 ok: false,
1648 };
1649 };
1650 let mut added = Vec::new();
1653 let mut removed = Vec::new();
1654 for event in events {
1655 let groups = hooks
1656 .entry(event.clone())
1657 .or_insert_with(|| serde_json::json!([]));
1658 let Some(groups) = groups.as_array_mut() else {
1659 continue;
1660 };
1661 let present = groups.iter().any(|g| {
1662 g["hooks"]
1663 .as_array()
1664 .into_iter()
1665 .flatten()
1666 .any(is_seat_hook)
1667 });
1668 if present {
1669 continue;
1670 }
1671 groups.push(serde_json::json!({
1672 "matcher": hook_matcher(event),
1673 "hooks": [{"type": "command", "command": command, "timeout": 20}]
1674 }));
1675 added.push(event.clone());
1676 }
1677 for (event, groups) in hooks.iter_mut() {
1678 if events.contains(event) {
1679 continue;
1680 }
1681 let Some(groups) = groups.as_array_mut() else {
1682 continue;
1683 };
1684 let before = groups.len();
1685 groups.retain(|g| {
1686 !g["hooks"]
1687 .as_array()
1688 .into_iter()
1689 .flatten()
1690 .any(is_seat_hook)
1691 });
1692 if groups.len() != before {
1693 removed.push(event.clone());
1694 }
1695 }
1696 if added.is_empty() && removed.is_empty() {
1697 return Step {
1698 what,
1699 detail: format!(
1700 "{} carries the memory hook on {}",
1701 file.display(),
1702 events.join(", ")
1703 ),
1704 ok: true,
1705 };
1706 }
1707 let mut change = Vec::new();
1708 if !added.is_empty() {
1709 change.push(format!("add it on {}", added.join(", ")));
1710 }
1711 if !removed.is_empty() {
1712 change.push(format!("drop it from {}", removed.join(", ")));
1713 }
1714 let change = change.join(" and ");
1715 if dry {
1716 return Step {
1717 what,
1718 detail: format!("would {change} in {}", file.display()),
1719 ok: true,
1720 };
1721 }
1722 let written = file
1723 .parent()
1724 .map_or(Ok(()), std::fs::create_dir_all)
1725 .and_then(|()| serde_json::to_string_pretty(&root).map_err(std::io::Error::other))
1726 .and_then(|text| std::fs::write(file, text + "\n"));
1727 match written {
1728 Ok(()) => Step {
1729 what,
1730 detail: format!("memory hook: {change} in {}", file.display()),
1731 ok: true,
1732 },
1733 Err(e) => Step {
1734 what,
1735 detail: format!("{}: {e}", file.display()),
1736 ok: false,
1737 },
1738 }
1739}
1740
1741fn hook_installed(file: &Path, events: &[String]) -> bool {
1743 let Ok(text) = std::fs::read_to_string(file) else {
1744 return false;
1745 };
1746 let Ok(root) = serde_json::from_str::<Value>(&text) else {
1747 return false;
1748 };
1749 events.iter().all(|event| {
1750 root["hooks"][event.as_str()]
1751 .as_array()
1752 .into_iter()
1753 .flatten()
1754 .any(|g| {
1755 g["hooks"]
1756 .as_array()
1757 .into_iter()
1758 .flatten()
1759 .any(is_seat_hook)
1760 })
1761 })
1762}
1763
1764#[derive(Debug, Clone, PartialEq, Eq)]
1768pub struct HookCall {
1769 pub event: String,
1770 pub cue: String,
1771 pub session: Option<String>,
1774 pub shape: HookShape,
1777}
1778
1779#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1783pub enum HookShape {
1784 #[default]
1786 Asks,
1787 DenyOnly,
1790 CamelCase,
1793 Context,
1797}
1798
1799impl HookShape {
1800 #[must_use]
1802 pub fn asks(self) -> bool {
1803 self == Self::Asks
1804 }
1805}
1806
1807#[must_use]
1812pub fn hook_call(input: &str) -> HookCall {
1813 let trimmed = input.trim();
1814 let Ok(v) = serde_json::from_str::<Value>(trimmed) else {
1815 return HookCall {
1816 event: "argv".into(),
1817 cue: trimmed.to_string(),
1818 session: None,
1819 shape: HookShape::Asks,
1820 };
1821 };
1822 let raw_event = v["hook_event_name"].as_str().unwrap_or("");
1823 let shape = if v.get("hookEventName").is_some() || v.get("toolInput").is_some() {
1824 HookShape::CamelCase
1825 } else if raw_event.starts_with("pre_")
1826 || raw_event.starts_with("post_")
1827 || raw_event.starts_with("on_")
1828 {
1829 HookShape::Context
1830 } else if v.get("turn_id").is_some() {
1831 HookShape::DenyOnly
1832 } else {
1833 HookShape::Asks
1834 };
1835 let input = if v["tool_input"].is_null() {
1836 &v["toolInput"]
1837 } else {
1838 &v["tool_input"]
1839 };
1840 let session = v["session_id"]
1841 .as_str()
1842 .or_else(|| v["sessionId"].as_str())
1843 .filter(|s| !s.is_empty())
1844 .map(str::to_string);
1845 let raw = v["hook_event_name"]
1846 .as_str()
1847 .or_else(|| v["hookEventName"].as_str())
1848 .unwrap_or("PreToolUse");
1849 let event = normalize_hook_event(raw).to_string();
1850 let cue = if let Some(p) = v["prompt"].as_str() {
1851 p.to_string()
1852 } else if let Some(p) = v["extra"]["user_message"].as_str() {
1853 p.to_string()
1854 } else if let Some(c) = input["command"].as_str() {
1855 c.to_string()
1856 } else if let Some(map) = input.as_object() {
1857 map.values()
1858 .filter_map(Value::as_str)
1859 .collect::<Vec<_>>()
1860 .join(" ")
1861 } else {
1862 String::new()
1863 };
1864 HookCall {
1865 event,
1866 cue,
1867 session,
1868 shape,
1869 }
1870}
1871
1872fn seen_path(session: &str) -> Option<PathBuf> {
1875 let safe: String = session
1876 .chars()
1877 .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
1878 .collect();
1879 if safe.is_empty() {
1880 return None;
1881 }
1882 let dir = std::env::var_os("XDG_RUNTIME_DIR")
1883 .filter(|r| !r.is_empty())
1884 .map(PathBuf::from)
1885 .unwrap_or_else(std::env::temp_dir)
1886 .join("ljos");
1887 Some(dir.join(format!("hook-seen-{safe}")))
1888}
1889
1890pub fn seen_ids(session: Option<&str>) -> std::collections::BTreeSet<String> {
1891 session
1892 .and_then(seen_path)
1893 .and_then(|p| std::fs::read_to_string(p).ok())
1894 .map(|t| t.lines().map(str::to_string).collect())
1895 .unwrap_or_default()
1896}
1897
1898fn injected_ids(session: &str) -> (Vec<String>, Option<PathBuf>) {
1901 let path = seen_path(session);
1902 let ids: Vec<String> = path
1903 .as_ref()
1904 .and_then(|p| std::fs::read_to_string(p).ok())
1905 .map(|t| {
1906 t.lines()
1907 .map(str::trim)
1908 .filter(|l| !l.is_empty() && *l != "due-nudge")
1909 .map(str::to_string)
1910 .collect()
1911 })
1912 .unwrap_or_default();
1913 (ids, path)
1914}
1915
1916pub fn session_end(session: Option<&str>) -> usize {
1923 let Some(session) = session else {
1924 return 0;
1925 };
1926 let (ids, path) = injected_ids(session);
1927 let fired = if ids.len() >= 2 {
1928 let top: Vec<String> = ids.into_iter().take(8).collect();
1929 pack()
1930 .ok()
1931 .and_then(|c| c.fire(&c.workspace(), &top).ok())
1932 .map_or(0, |_| top.len())
1933 } else {
1934 0
1935 };
1936 if let Some(p) = path {
1937 let _ = std::fs::remove_file(p);
1938 }
1939 fired
1940}
1941
1942fn hook_hold_path(session: Option<&str>) -> Option<PathBuf> {
1945 let dir = std::env::var_os("XDG_RUNTIME_DIR")
1946 .map(PathBuf::from)
1947 .or_else(|| std::env::var_os("TMPDIR").map(PathBuf::from))
1948 .unwrap_or_else(|| PathBuf::from("/tmp"));
1949 let name = session
1950 .filter(|s| !s.is_empty())
1951 .map(|s| {
1952 s.chars()
1953 .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
1954 .take(32)
1955 .collect::<String>()
1956 })
1957 .filter(|s| !s.is_empty())
1958 .unwrap_or_else(|| "default".into());
1959 Some(dir.join(format!("ljos-hook-hold-{name}")))
1960}
1961
1962fn hook_hold_ids_path(session: Option<&str>) -> Option<PathBuf> {
1963 hook_hold_path(session).map(|p| {
1964 let mut os = p.into_os_string();
1965 os.push(".ids");
1966 PathBuf::from(os)
1967 })
1968}
1969
1970pub fn hold_hook_context(session: Option<&str>, context: &str) {
1974 hold_hook_note(session, context, &[]);
1975}
1976
1977pub fn hold_hook_note(session: Option<&str>, context: &str, ids: &[String]) {
1979 let Some(path) = hook_hold_path(session) else {
1980 return;
1981 };
1982 if context.is_empty() {
1983 return;
1984 }
1985 let _ = std::fs::write(&path, context);
1986 if let Some(ids_path) = hook_hold_ids_path(session) {
1987 let _ = std::fs::write(ids_path, ids.join("\n"));
1988 }
1989}
1990
1991#[must_use]
1993pub fn peek_hook_context(session: Option<&str>) -> String {
1994 hook_hold_path(session)
1995 .and_then(|p| std::fs::read_to_string(p).ok())
1996 .unwrap_or_default()
1997}
1998
1999#[must_use]
2001pub fn take_hook_context(session: Option<&str>) -> String {
2002 take_hook_note(session).0
2003}
2004
2005#[must_use]
2007pub fn take_hook_note(session: Option<&str>) -> (String, Vec<String>) {
2008 let Some(path) = hook_hold_path(session) else {
2009 return (String::new(), Vec::new());
2010 };
2011 let text = std::fs::read_to_string(&path).unwrap_or_default();
2012 let _ = std::fs::remove_file(&path);
2013 let ids = hook_hold_ids_path(session)
2014 .and_then(|p| std::fs::read_to_string(p).ok())
2015 .map(|t| {
2016 let _ = hook_hold_ids_path(session).map(std::fs::remove_file);
2017 t.lines()
2018 .map(str::trim)
2019 .filter(|l| !l.is_empty())
2020 .map(str::to_string)
2021 .collect()
2022 })
2023 .unwrap_or_default();
2024 (text, ids)
2025}
2026
2027#[must_use]
2031pub fn prompt_hook_stdout(
2032 shape: HookShape,
2033 session: Option<&str>,
2034 text: &str,
2035 ids: &[String],
2036) -> String {
2037 if shape == HookShape::CamelCase {
2038 hold_hook_note(session, text, ids);
2039 String::new()
2040 } else {
2041 hold_hook_context(session, text);
2042 text.to_string()
2043 }
2044}
2045
2046#[must_use]
2052pub fn post_hook_stdout(shape: HookShape, session: Option<&str>) -> (String, Vec<String>) {
2053 if shape == HookShape::CamelCase {
2054 let key = "hold-echoed".to_string();
2055 if seen_ids(session).contains(&key) {
2056 return (String::new(), Vec::new());
2057 }
2058 let (text, ids) = take_hook_note(session);
2059 if !text.is_empty() {
2060 mark_seen(session, &[key]);
2061 }
2062 (text, ids)
2063 } else {
2064 (take_hook_context(session), Vec::new())
2065 }
2066}
2067
2068#[must_use]
2072pub fn stop_hook_stdout(session: Option<&str>, stop_active: bool) -> (String, Vec<String>) {
2073 if stop_active {
2074 return (String::new(), Vec::new());
2075 }
2076 take_hook_note(session)
2077}
2078
2079pub fn mark_seen(session: Option<&str>, ids: &[String]) {
2080 let Some(path) = session.and_then(seen_path) else {
2081 return;
2082 };
2083 if let Some(dir) = path.parent() {
2084 let _ = std::fs::create_dir_all(dir);
2085 }
2086 let mut text = std::fs::read_to_string(&path).unwrap_or_default();
2087 for id in ids {
2088 text.push_str(id);
2089 text.push('\n');
2090 }
2091 let _ = std::fs::write(path, text);
2092}
2093
2094pub const HOOK_SCORE_FLOOR: f64 = 0.6;
2100
2101const CUE_STOP: &[&str] = &[
2104 "about",
2105 "after",
2106 "also",
2107 "anything",
2108 "because",
2109 "been",
2110 "before",
2111 "being",
2112 "both",
2113 "could",
2114 "does",
2115 "doing",
2116 "each",
2117 "everything",
2118 "from",
2119 "have",
2120 "having",
2121 "into",
2122 "just",
2123 "like",
2124 "making",
2125 "more",
2126 "most",
2127 "need",
2128 "nothing",
2129 "only",
2130 "other",
2131 "over",
2132 "please",
2133 "really",
2134 "same",
2135 "should",
2136 "some",
2137 "something",
2138 "still",
2139 "such",
2140 "than",
2141 "that",
2142 "their",
2143 "them",
2144 "then",
2145 "there",
2146 "these",
2147 "they",
2148 "this",
2149 "those",
2150 "through",
2151 "using",
2152 "very",
2153 "want",
2154 "were",
2155 "what",
2156 "when",
2157 "where",
2158 "which",
2159 "while",
2160 "will",
2161 "with",
2162 "would",
2163 "your",
2164];
2165
2166fn cue_content_words(text: &str) -> Vec<String> {
2169 let mut words: Vec<String> = text
2170 .split(|c: char| !c.is_alphanumeric())
2171 .filter(|w| w.len() >= 4)
2172 .map(str::to_lowercase)
2173 .filter(|w| !CUE_STOP.contains(&w.as_str()))
2174 .collect();
2175 words.sort_unstable();
2176 words.dedup();
2177 words
2178}
2179
2180fn names_the_cue(text: &str, cue: &str) -> bool {
2183 let want = cue_content_words(cue);
2184 if want.is_empty() {
2185 return false;
2186 }
2187 let have = cue_content_words(text);
2188 want.iter().any(|w| have.binary_search(w).is_ok())
2189}
2190
2191#[cfg(test)]
2192fn names_a_numbered_pr(text: &str) -> bool {
2195 let t = text.to_lowercase();
2196 let b = t.as_bytes();
2197 let mut i = 0;
2198 while i < b.len() {
2199 if (i == 0 || !b[i - 1].is_ascii_alphanumeric())
2200 && (pr_number_at(&t[i..]) || hash_number_at(&t[i..]))
2201 {
2202 return true;
2203 }
2204 i += 1;
2205 }
2206 false
2207}
2208
2209#[cfg(test)]
2210fn pr_number_at(rest: &str) -> bool {
2212 let after = if let Some(s) = rest.strip_prefix("pull requests") {
2213 s
2214 } else if let Some(s) = rest.strip_prefix("pull request") {
2215 s
2216 } else if let Some(s) = rest.strip_prefix("prs") {
2217 if s.starts_with(|c: char| c.is_ascii_alphanumeric()) {
2218 return false;
2219 }
2220 s
2221 } else if let Some(s) = rest.strip_prefix("pr") {
2222 if s.starts_with(|c: char| c.is_ascii_alphabetic()) {
2223 return false;
2224 }
2225 s
2226 } else {
2227 return false;
2228 };
2229 let after = after.trim_start();
2230 let after = after.strip_prefix('#').unwrap_or(after).trim_start();
2231 after.starts_with(|c: char| c.is_ascii_digit())
2232}
2233
2234#[cfg(test)]
2235fn hash_number_at(rest: &str) -> bool {
2237 let Some(after) = rest.strip_prefix('#') else {
2238 return false;
2239 };
2240 after.starts_with(|c: char| c.is_ascii_digit())
2241}
2242
2243#[cfg(test)]
2244fn is_transient(text: &str) -> bool {
2247 names_a_numbered_pr(text) || names_a_ticket(text) || names_a_commit(text)
2248}
2249
2250#[cfg(test)]
2251fn names_a_ticket(text: &str) -> bool {
2253 text.split(|c: char| !c.is_ascii_alphanumeric() && c != '-')
2254 .any(|tok| {
2255 let Some((head, tail)) = tok.split_once('-') else {
2256 return false;
2257 };
2258 head.len() >= 2
2259 && head.chars().all(|c| c.is_ascii_alphabetic())
2260 && tail.len() == 4
2261 && tail.chars().all(|c| c.is_ascii_alphanumeric())
2262 && !tail.contains('-')
2263 })
2264}
2265
2266#[cfg(test)]
2267fn names_a_commit(text: &str) -> bool {
2269 text.split(|c: char| !c.is_ascii_alphanumeric()).any(|tok| {
2270 (7..=40).contains(&tok.len())
2271 && tok.chars().all(|c| c.is_ascii_hexdigit())
2272 && tok.chars().any(|c| c.is_ascii_digit())
2273 })
2274}
2275
2276fn is_refresher(hit: &Hit) -> bool {
2279 if hit.kind == "preference" {
2280 return true;
2281 }
2282 if hit.entities.iter().any(|e| e == "horizon:transient") {
2283 return false;
2284 }
2285 hit.entities.iter().any(|e| e == "horizon:standing")
2286}
2287
2288#[must_use]
2293pub fn hook_note(call: &HookCall, limit: usize) -> (String, Vec<String>) {
2294 let cue = call.cue.trim();
2295 if cue.len() < 3 {
2296 return (String::new(), Vec::new());
2297 }
2298 let (mut nudge, due_key) = due_nudge(call);
2302 let mut pending = Vec::new();
2303 if let Some(key) = due_key {
2304 pending.push(key);
2305 }
2306 let judged = judged_prompt(call, cue);
2310 let (correction, choice) = judged.as_ref().map_or((None, None), |(_, j)| {
2311 (Some(j.correction >= j.cue_at), Some(j.choice >= j.cue_at))
2312 });
2313 for (key, extra) in [
2314 correction_nudge_as(call, correction),
2315 decision_nudge_as(call, choice),
2316 ]
2317 .into_iter()
2318 .flatten()
2319 {
2320 pending.push(key);
2321 if !nudge.is_empty() {
2322 nudge.push('\n');
2323 }
2324 nudge.push_str(&extra);
2325 }
2326 let seen = seen_ids(call.session.as_deref());
2332 let hits: Vec<Hit>;
2333 let mut rows: Vec<&Hit> = if let Some((candidates, j)) = &judged {
2334 candidates
2337 .iter()
2338 .enumerate()
2339 .filter(|(i, _)| j.bears(*i))
2340 .map(|(_, h)| h)
2341 .filter(|h| h.id.as_ref().is_none_or(|id| !seen.contains(id)))
2342 .collect()
2343 } else {
2344 let rerank = !jev::enabled();
2347 let reranked = with_pack_timeout(HOOK_RERANK_BUDGET_MS, || {
2348 packset_search_opts(cue, 10, rerank)
2349 });
2350 let Ok(found) = reranked.or_else(|_| packset_search(cue)) else {
2351 return (nudge, pending);
2352 };
2353 hits = found;
2354 let top = hits.iter().map(|h| h.score).fold(0.0_f64, f64::max);
2355 if top <= 0.0 {
2356 return (nudge, pending);
2357 }
2358 hits.iter()
2359 .filter(|h| !UNREVIEWED_KINDS.contains(&h.kind.as_str()))
2360 .filter(|h| h.score >= top * HOOK_SCORE_FLOOR)
2361 .filter(|h| agreed(h))
2362 .filter(|h| names_the_cue(&h.text, cue))
2363 .filter(|h| is_refresher(h))
2364 .filter(|h| h.id.as_ref().is_none_or(|id| !seen.contains(id)))
2365 .collect()
2366 };
2367 let weight = |h: &Hit| -> f64 {
2369 judged
2370 .as_ref()
2371 .and_then(|(c, j)| {
2372 let i = c.iter().position(|x| x.id == h.id && x.text == h.text)?;
2373 j.bears.get(i).copied()
2374 })
2375 .unwrap_or(h.score)
2376 };
2377 rows.sort_by(|a, b| {
2378 let pa = a.kind == "preference";
2379 let pb = b.kind == "preference";
2380 pb.cmp(&pa).then(
2381 weight(b)
2382 .partial_cmp(&weight(a))
2383 .unwrap_or(std::cmp::Ordering::Equal),
2384 )
2385 });
2386 let mut rows: Vec<&Hit> = rows.into_iter().take(limit).collect();
2387 let now = now_utc();
2392 let split = rows.iter().filter(|h| h.kind == "preference").count();
2393 rows[split..].sort_by_key(|h| days_of_stamp(h.ts.as_deref()).unwrap_or(i64::MAX));
2394 let lines: Vec<String> = rows.iter().map(|h| hit_line(h, &now)).collect();
2395 let mut ids: Vec<String> = rows.iter().filter_map(|h| h.id.clone()).collect();
2396 ids.extend(pending);
2397 if lines.is_empty() {
2398 return (nudge, ids);
2399 }
2400 let mut out = format!(
2401 "What this seat already knows that bears on this (from the pack, each with its age, lessons oldest first; `ljos search` for more):\n{}",
2402 lines.join("\n")
2403 );
2404 if !nudge.is_empty() {
2405 out.push('\n');
2406 out.push_str(&nudge);
2407 }
2408 (out, ids)
2409}
2410
2411fn judged_prompt(call: &HookCall, cue: &str) -> Option<(Vec<Hit>, jev::Judgment)> {
2418 if call.event != "UserPromptSubmit" {
2419 return None;
2420 }
2421 let (cfg, _) = jev::config()?;
2422 if cue.split_whitespace().count() < cfg.min_words {
2423 return None;
2424 }
2425 let seen = seen_ids(call.session.as_deref());
2426 let hits = packset_search_opts(cue, 10, false).ok()?;
2427 let candidates: Vec<Hit> = hits
2428 .into_iter()
2429 .filter(|h| !UNREVIEWED_KINDS.contains(&h.kind.as_str()))
2430 .filter(is_refresher)
2431 .filter(|h| h.id.as_ref().is_none_or(|id| !seen.contains(id)))
2432 .take(10)
2433 .collect();
2434 if candidates.len() < cfg.min_candidates {
2435 return None;
2436 }
2437 let texts: Vec<&str> = candidates.iter().map(|h| h.text.as_str()).collect();
2438 let judged = jev::judge(cue, &texts)?;
2439 Some((candidates, judged))
2440}
2441
2442#[must_use]
2447pub fn hook_context(call: &HookCall, limit: usize) -> String {
2448 let (text, ids) = hook_note(call, limit);
2449 if call.shape != HookShape::CamelCase {
2450 mark_seen(call.session.as_deref(), &ids);
2451 }
2452 text
2453}
2454
2455fn agreed(h: &Hit) -> bool {
2461 match (h.ballots, h.of) {
2462 (Some(named), Some(of)) if of >= 2 => named >= 2,
2463 _ => true,
2464 }
2465}
2466
2467#[must_use]
2472pub fn hook_subagent(input: &str) -> (Option<String>, bool, String) {
2473 let Ok(v) = serde_json::from_str::<Value>(input.trim()) else {
2474 return (None, false, String::new());
2475 };
2476 let kind = v["subagentType"]
2477 .as_str()
2478 .or_else(|| v["subagent_type"].as_str())
2479 .or_else(|| v["agent_type"].as_str())
2480 .filter(|s| !s.is_empty())
2481 .map(str::to_string);
2482 let active = v["stopHookActive"]
2483 .as_bool()
2484 .or_else(|| v["stop_hook_active"].as_bool())
2485 .unwrap_or(false);
2486 let agent = v["agent_id"]
2487 .as_str()
2488 .or_else(|| v["agentId"].as_str())
2489 .unwrap_or("")
2490 .to_string();
2491 (kind, active, agent)
2492}
2493
2494#[must_use]
2497pub fn runs_tests(command: &str) -> bool {
2498 const RUNNERS: &[&str] = &[
2499 "cargo test",
2500 "cargo nextest",
2501 "pytest",
2502 "ctest",
2503 "meson test",
2504 "npm test",
2505 "npm run test",
2506 "pnpm test",
2507 "go test",
2508 "make check",
2509 "make test",
2510 "repo-test",
2511 "tox",
2512 "bats ",
2513 "prove ",
2514 "mix test",
2515 "gradle test",
2516 "mvn test",
2517 ];
2518 RUNNERS.iter().any(|r| command.contains(r))
2519}
2520
2521#[derive(Debug, Clone, Default, PartialEq)]
2526pub struct StopTurn {
2527 pub request: String,
2528 pub commands: Vec<String>,
2529 pub test_ran: bool,
2530 pub outputs: Vec<String>,
2531 pub final_message: String,
2532}
2533
2534fn tail_chars(s: &str, n: usize) -> String {
2535 let count = s.chars().count();
2536 s.chars().skip(count.saturating_sub(n)).collect()
2537}
2538
2539fn block_text(content: &Value) -> String {
2540 match content {
2541 Value::String(t) => t.clone(),
2542 Value::Array(parts) => parts
2543 .iter()
2544 .filter_map(|p| p["text"].as_str())
2545 .collect::<Vec<_>>()
2546 .join("\n"),
2547 _ => String::new(),
2548 }
2549}
2550
2551#[must_use]
2555pub fn stop_turn_from_transcript(text: &str) -> StopTurn {
2556 let entries: Vec<Value> = text
2557 .lines()
2558 .filter_map(|l| serde_json::from_str::<Value>(l).ok())
2559 .collect();
2560 let is_prompt = |e: &Value| {
2561 e["type"] == "user"
2562 && !e["isMeta"].as_bool().unwrap_or(false)
2563 && match &e["message"]["content"] {
2564 Value::String(t) => !t.trim_start().starts_with('<'),
2565 Value::Array(parts) => {
2566 parts.iter().any(|p| p["type"] == "text")
2567 && !parts.iter().any(|p| p["type"] == "tool_result")
2568 }
2569 _ => false,
2570 }
2571 };
2572 let start = entries.iter().rposition(is_prompt).unwrap_or(0);
2573 let mut turn = StopTurn {
2574 request: entries
2575 .get(start)
2576 .map(|e| block_text(&e["message"]["content"]))
2577 .unwrap_or_default(),
2578 ..StopTurn::default()
2579 };
2580 let mut pending: std::collections::BTreeMap<String, String> = Default::default();
2581 let mut outputs: Vec<(bool, String)> = Vec::new();
2582 for e in entries.iter().skip(start + 1) {
2583 let Value::Array(parts) = &e["message"]["content"] else {
2584 if e["type"] == "assistant" {
2585 turn.final_message = block_text(&e["message"]["content"]);
2586 }
2587 continue;
2588 };
2589 for part in parts {
2590 match part["type"].as_str() {
2591 Some("tool_use") => {
2592 if let Some(cmd) = part["input"]["command"].as_str() {
2593 let cmd: String = cmd.chars().take(200).collect();
2594 if let Some(id) = part["id"].as_str() {
2595 pending.insert(id.to_string(), cmd.clone());
2596 }
2597 turn.test_ran |= runs_tests(&cmd);
2598 turn.commands.push(cmd);
2599 }
2600 }
2601 Some("tool_result") => {
2602 let id = part["tool_use_id"].as_str().unwrap_or("");
2603 if let Some(cmd) = pending.remove(id) {
2604 let out = tail_chars(&block_text(&part["content"]), 1500);
2605 outputs.push((runs_tests(&cmd), format!("$ {cmd}\n{out}")));
2606 }
2607 }
2608 Some("text") if e["type"] == "assistant" => {
2609 turn.final_message = part["text"].as_str().unwrap_or("").to_string();
2610 }
2611 _ => {}
2612 }
2613 }
2614 }
2615 let tests: Vec<String> = outputs
2616 .iter()
2617 .filter(|o| o.0)
2618 .map(|o| o.1.clone())
2619 .collect();
2620 let chosen = if tests.is_empty() {
2621 outputs.into_iter().map(|o| o.1).collect::<Vec<_>>()
2622 } else {
2623 tests
2624 };
2625 turn.outputs = chosen.into_iter().rev().take(2).rev().collect();
2626 let n = turn.commands.len();
2627 turn.commands = turn.commands.split_off(n.saturating_sub(30));
2628 turn
2629}
2630
2631impl StopTurn {
2632 #[must_use]
2634 pub fn state(&self) -> String {
2635 format!(
2636 "The person asked:\n{}\n\nShell commands the agent ran since:\n{}\n\nLatest output:\n{}\n\nThe agent's final message:\n{}\n",
2637 tail_chars(&self.request, 1500),
2638 self.commands.join("\n"),
2639 self.outputs.join("\n---\n"),
2640 tail_chars(&self.final_message, 3000)
2641 )
2642 }
2643}
2644
2645#[must_use]
2650pub fn stop_audit(input: &str, stop_active: bool) -> Option<String> {
2651 if stop_active {
2652 return None;
2653 }
2654 jev::config()?;
2655 let v: Value = serde_json::from_str(input.trim()).ok()?;
2656 let path = v["transcript_path"]
2657 .as_str()
2658 .or_else(|| v["transcriptPath"].as_str());
2659 let mut turn = path
2660 .and_then(|p| std::fs::read_to_string(p).ok())
2661 .map(|t| stop_turn_from_transcript(&t))
2662 .unwrap_or_default();
2663 if let Some(last) = v["last_assistant_message"]
2664 .as_str()
2665 .or_else(|| v["lastAssistantMessage"].as_str())
2666 {
2667 turn.final_message = last.to_string();
2668 }
2669 if turn.final_message.chars().count() < 80 {
2670 return None;
2671 }
2672 let a = jev::audit(&turn.state())?;
2673 jev::audit_reason(&a, turn.test_ran)
2674}
2675
2676pub const WORK_NUDGE_EVERY: u64 = 40;
2680
2681#[must_use]
2683pub fn touches_seat(cue: &str) -> bool {
2684 cue.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2685 .any(|w| w == "ljos" || w == "vissue" || w.starts_with("ljos_") || w.starts_with("vissue_"))
2686}
2687
2688pub fn work_nudge(call: &HookCall, subagent: bool) -> Option<String> {
2693 let session = call.session.as_deref()?;
2694 let safe: String = session
2695 .chars()
2696 .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
2697 .collect();
2698 if safe.is_empty() || subagent {
2699 return None;
2700 }
2701 let path = runtime_dir().join(format!("work-{safe}"));
2702 if touches_seat(&call.cue) {
2703 let _ = std::fs::write(&path, "0");
2704 return None;
2705 }
2706 if call.event != "PostToolUse" {
2707 return None;
2708 }
2709 let count = std::fs::read_to_string(&path)
2710 .ok()
2711 .and_then(|t| t.trim().parse::<u64>().ok())
2712 .unwrap_or(0)
2713 + 1;
2714 if count < WORK_NUDGE_EVERY {
2715 let _ = std::fs::create_dir_all(runtime_dir());
2716 let _ = std::fs::write(&path, count.to_string());
2717 return None;
2718 }
2719 let _ = std::fs::write(&path, "0");
2720 Some(match held_issue() {
2721 Some(issue) => format!(
2722 "{count} tool calls on {issue} since the seat last heard from this conversation. \
2723 Record what the work has shown: progress is `vissue note {issue} \"...\"`, a lesson \
2724 that holds next time is `ljos remember \"...\"`, an artifact is `ljos deed {issue} \
2725 --add ACCESSION`; the work closes with `ljos finish {issue} --lesson \"...\"`."
2726 ),
2727 None => format!(
2728 "{count} tool calls in this conversation with no issue held. Work goes on an issue: \
2729 `vissue q -p PROJECT \"TITLE\"` prints an id, then `ljos sitting ID` opens it."
2730 ),
2731 })
2732}
2733
2734pub fn hook_trace(input: &str, call: &HookCall, subagent: Option<&str>) {
2740 let dir = runtime_dir();
2741 if !dir.join("hook-trace").exists() {
2742 return;
2743 }
2744 let v: Value = serde_json::from_str(input.trim()).unwrap_or(Value::Null);
2745 let keys: Vec<&str> = v
2746 .as_object()
2747 .map(|m| m.keys().map(String::as_str).collect())
2748 .unwrap_or_default();
2749 let raw = v["hook_event_name"]
2750 .as_str()
2751 .or_else(|| v["hookEventName"].as_str())
2752 .unwrap_or("");
2753 let line = serde_json::json!({
2754 "ts": now_utc(),
2755 "event": call.event,
2756 "raw": raw,
2757 "keys": keys,
2758 "session": call.session,
2759 "subagent": subagent,
2760 "holder": holder_name(),
2761 "tree_holder": runner_record_holders().first().cloned(),
2762 "held": subagent.and_then(|_| held_issue()),
2763 });
2764 use std::io::Write as _;
2765 if let Ok(mut f) = std::fs::OpenOptions::new()
2766 .create(true)
2767 .append(true)
2768 .open(dir.join("hook-trace.jsonl"))
2769 {
2770 let _ = writeln!(f, "{line}");
2771 }
2772}
2773
2774fn runner_record_holders() -> Vec<String> {
2780 let mut out = Vec::new();
2781 for (pid, _) in ancestry() {
2782 let Ok(text) = std::fs::read_to_string(seat_record_path(pid)) else {
2783 continue;
2784 };
2785 if let Some(holder) = text.lines().nth(1).map(str::trim).filter(|h| !h.is_empty()) {
2786 if !out.iter().any(|h| h == holder) {
2787 out.push(holder.to_string());
2788 }
2789 }
2790 }
2791 out
2792}
2793
2794#[must_use]
2798pub fn held_issue() -> Option<String> {
2799 let mut holders: Vec<String> = runner_record_holders();
2804 let own = holder_name();
2805 if !holders.contains(&own) {
2806 holders.push(own);
2807 }
2808 if let Some(node) = held_from_records(&holders) {
2811 return Some(node);
2812 }
2813 if std::env::var_os("LJOS_IN_HOOK").is_some() {
2814 return None;
2815 }
2816 holders.iter().find_map(|holder| {
2817 let out = run_captured("vissue", &["claims", "--by", holder, "--json"]).ok()?;
2818 let rows: Value = serde_json::from_str(&out.stdout).ok()?;
2819 rows.as_array()?
2820 .iter()
2821 .rfind(|c| c["state"].as_str() == Some("STARTED"))?["id"]
2822 .as_str()
2823 .map(str::to_string)
2824 })
2825}
2826
2827#[must_use]
2832pub fn subagent_brief(kind: &str, issue: &str, decision: bool) -> String {
2833 let judge = if decision {
2834 format!("{issue} is a decision: end with your ballot, `ljos vote {issue} --for OPTION --expect OPTION --as ROLE`.")
2835 } else {
2836 format!(
2837 "A judgement between options is a ballot: `ljos vote {issue} --for OPTION --expect OPTION --as ROLE`."
2838 )
2839 };
2840 format!(
2841 "You are a subagent ({kind}) working under {issue}, which your parent holds. Do not open a sitting \
2842 on it. {judge} A lesson that will hold next time is `ljos remember \"...\" --as ROLE`; a \
2843 finding is `vissue note {issue} \"...\"`. ROLE is a persona from `ljos personas` when one fits \
2844 your task, else `{kind}`."
2845 )
2846}
2847
2848#[must_use]
2852pub fn subagent_stop_reason(
2853 kind: &str,
2854 issue: Option<&str>,
2855 decision: bool,
2856 active: bool,
2857) -> Option<String> {
2858 if active {
2859 return None;
2860 }
2861 let issue = issue?;
2862 Some(if decision {
2863 format!(
2864 "{issue} is a decision your parent holds. Before you stop, cast your ballot: \
2865 `ljos vote {issue} --for OPTION --expect OPTION --as ROLE` (ROLE: your persona, else `{kind}`)."
2866 )
2867 } else {
2868 format!(
2869 "You worked under {issue}. Before you stop: if your result settles a choice, \
2870 `ljos vote {issue} --for OPTION --expect OPTION --as ROLE`; if it taught something that holds next time, \
2871 `ljos remember \"...\" --as ROLE`. Otherwise stop."
2872 )
2873 })
2874}
2875
2876pub const HOOK_DEADLINE_MS: u64 = 8000;
2880
2881pub fn hook_already_running(call: &HookCall) -> bool {
2886 let key = work_id(&format!(
2887 "{}|{}|{}",
2888 call.event,
2889 call.session.as_deref().unwrap_or(""),
2890 call.cue
2891 ));
2892 let dir = runtime_dir();
2893 let _ = std::fs::create_dir_all(&dir);
2894 if key.starts_with('0') {
2896 if let Ok(entries) = std::fs::read_dir(&dir) {
2897 for e in entries.flatten() {
2898 let old = e.file_name().to_string_lossy().starts_with("hook-once-")
2899 && e.metadata()
2900 .and_then(|m| m.modified())
2901 .ok()
2902 .and_then(|t| t.elapsed().ok())
2903 .is_some_and(|age| age > std::time::Duration::from_secs(60));
2904 if old {
2905 let _ = std::fs::remove_file(e.path());
2906 }
2907 }
2908 }
2909 }
2910 let path = dir.join(format!("hook-once-{key}"));
2911 match std::fs::OpenOptions::new()
2912 .write(true)
2913 .create_new(true)
2914 .open(&path)
2915 {
2916 Ok(_) => false,
2917 Err(_) => {
2918 let fresh = std::fs::metadata(&path)
2919 .and_then(|m| m.modified())
2920 .ok()
2921 .and_then(|t| t.elapsed().ok())
2922 .is_some_and(|age| age < std::time::Duration::from_secs(20));
2923 if !fresh {
2924 let _ = std::fs::write(&path, "");
2925 }
2926 fresh
2927 }
2928 }
2929}
2930
2931pub const HOOK_RERANK_BUDGET_MS: u64 = 2500;
2935
2936fn with_pack_timeout<R>(ms: u64, f: impl FnOnce() -> R) -> R {
2939 let before = std::env::var_os("PACKSET_TIMEOUT_MS");
2940 unsafe { std::env::set_var("PACKSET_TIMEOUT_MS", ms.to_string()) };
2943 let out = f();
2944 match before {
2945 Some(v) => unsafe { std::env::set_var("PACKSET_TIMEOUT_MS", v) },
2946 None => unsafe { std::env::remove_var("PACKSET_TIMEOUT_MS") },
2947 }
2948 out
2949}
2950
2951pub const CORRECTION_CUES: &[&str] = &[
2956 "do you not remember",
2957 "don't you remember",
2958 "dont you remember",
2959 "you should have",
2960 "why did you not",
2961 "why didn't you",
2962 "why havent you",
2963 "why haven't you",
2964 "you forgot",
2965 "i told you",
2966 "i've told you",
2967 "as i said",
2968 "again you",
2969 "still not",
2970 "not even able",
2971 "you never",
2972 "you keep",
2973];
2974
2975#[cfg(test)]
2976fn correction_nudge(call: &HookCall) -> Option<(String, String)> {
2981 correction_nudge_as(call, None)
2982}
2983
2984fn correction_nudge_as(call: &HookCall, verdict: Option<bool>) -> Option<(String, String)> {
2987 if call.event != "UserPromptSubmit" {
2988 return None;
2989 }
2990 let key = match verdict {
2991 Some(false) => return None,
2992 Some(true) => "correction:judged".to_string(),
2993 None => {
2994 let lower = call.cue.to_lowercase();
2995 let hit = CORRECTION_CUES.iter().find(|c| lower.contains(*c))?;
2996 format!("correction:{hit}")
2997 }
2998 };
2999 if seen_ids(call.session.as_deref()).contains(&key) {
3000 return None;
3001 }
3002 Some((
3003 key,
3004 "This prompt reads as a correction. Before the work: write what it corrects as one \
3005 `ljos prefer \"...\"` (a standing choice) or `ljos remember \"...\"` (a lesson), \
3006 so the pack holds it and the hook can raise it next time."
3007 .to_string(),
3008 ))
3009}
3010
3011pub const DECISION_CUES: &[&str] = &[
3014 "should we",
3015 "should i ",
3016 "or should",
3017 "which is better",
3018 "which one",
3019 "which approach",
3020 "which option",
3021 "pros and cons",
3022 "trade-off",
3023 "tradeoff",
3024 " versus ",
3025 " vs ",
3026 " vs. ",
3027 "what do you recommend",
3028 "do you think we",
3029 "option 1",
3030 "option 2",
3031 "option a",
3032 "option b",
3033];
3034
3035pub const DECISION_OPENING: usize = 400;
3037
3038fn cue_at_word_end(text: &str, cue: &str) -> bool {
3041 text.match_indices(cue).any(|(i, _)| {
3042 text[i + cue.len()..]
3043 .chars()
3044 .next()
3045 .is_none_or(|c| !c.is_alphanumeric())
3046 })
3047}
3048
3049#[cfg(test)]
3050fn decision_nudge(call: &HookCall) -> Option<(String, String)> {
3054 decision_nudge_as(call, None)
3055}
3056
3057fn decision_nudge_as(call: &HookCall, verdict: Option<bool>) -> Option<(String, String)> {
3059 if call.event != "UserPromptSubmit" {
3060 return None;
3061 }
3062 match verdict {
3063 Some(false) => return None,
3064 Some(true) => {}
3065 None => {
3066 let opening: String = call.cue.chars().take(DECISION_OPENING).collect();
3070 let lower = format!(" {} ", opening.to_lowercase());
3071 DECISION_CUES.iter().find(|c| cue_at_word_end(&lower, c))?;
3072 }
3073 }
3074 let key = "decision-nudge".to_string();
3075 if seen_ids(call.session.as_deref()).contains(&key) {
3076 return None;
3077 }
3078 Some((
3079 key,
3080 "This prompt puts a choice. Before choosing: put it on an issue whose body has an \
3081 `Options: A, B` line, then `ljos sitting ISSUE` writes one brief per persona the \
3082 title names; start one subagent per brief, each casting `ljos vote ISSUE --for \
3083 OPTION --expect OPTION --as NAME`, and settle with `ljos consensus ISSUE`."
3084 .to_string(),
3085 ))
3086}
3087
3088fn due_nudge(call: &HookCall) -> (String, Option<String>) {
3092 if call.event != "UserPromptSubmit" {
3093 return (String::new(), None);
3094 }
3095 let key = "due-nudge".to_string();
3096 if seen_ids(call.session.as_deref()).contains(&key) {
3097 return (String::new(), None);
3098 }
3099 let Ok(client) = pack() else {
3100 return (String::new(), None);
3101 };
3102 let Ok(atoms) = atoms_lean(&client, &client.workspace()) else {
3103 return (String::new(), None);
3104 };
3105 let due = due_of(&atoms, &now_utc()).len();
3106 if due == 0 {
3111 mark_seen(call.session.as_deref(), &[key]);
3112 return (String::new(), None);
3113 }
3114 (
3115 format!(
3116 "{due} claim{} due for review in this seat: `ljos due`, read each, then `ljos graded ID` (or `--lapsed`).",
3117 if due == 1 { " is" } else { "s are" }
3118 ),
3119 Some(key),
3120 )
3121}
3122
3123#[must_use]
3127pub fn hook_output(call: &HookCall, context: &str) -> String {
3128 hook_output_ruled(call, context, None)
3129}
3130
3131#[must_use]
3135pub fn hook_output_ruled(call: &HookCall, context: &str, verdict: Option<&Rule>) -> String {
3136 if context.is_empty() && verdict.is_none() {
3137 return String::new();
3138 }
3139 if call.event == "argv" {
3140 let mut out = String::new();
3141 if let Some(r) = verdict {
3142 out.push_str(&format!(
3143 "{}: {} (rule `{}`)\n",
3144 r.verdict, r.reason, r.pattern
3145 ));
3146 }
3147 if !context.is_empty() {
3148 out.push_str(context);
3149 out.push('\n');
3150 }
3151 return out;
3152 }
3153 if call.shape == HookShape::Context && verdict.is_none() {
3154 return if context.is_empty() {
3155 String::new()
3156 } else {
3157 serde_json::json!({ "context": context }).to_string() + "\n"
3158 };
3159 }
3160 let mut specific = serde_json::json!({ "hookEventName": call.event });
3161 if !context.is_empty() {
3162 specific["additionalContext"] = Value::String(context.to_string());
3163 }
3164 let mut top = serde_json::Map::new();
3165 if let Some(r) = verdict {
3166 if call.event == "PreToolUse" {
3167 let (decision, reason) = if r.verdict == "ask" && !call.shape.asks() {
3170 (
3171 "deny",
3172 format!(
3173 "ask the person before running this: {} (seat rule `{}`)",
3174 r.reason, r.pattern
3175 ),
3176 )
3177 } else {
3178 (
3179 r.verdict.as_str(),
3180 format!("{} (seat rule `{}`)", r.reason, r.pattern),
3181 )
3182 };
3183 if call.shape == HookShape::Context {
3184 let mut out = serde_json::json!({ "decision": "block", "reason": reason });
3186 if !context.is_empty() {
3187 out["context"] = Value::String(context.to_string());
3188 }
3189 return out.to_string() + "\n";
3190 }
3191 specific["permissionDecision"] = Value::String(decision.to_string());
3192 specific["permissionDecisionReason"] = Value::String(reason.clone());
3193 if call.shape == HookShape::CamelCase {
3194 top.insert("decision".into(), Value::String(decision.to_string()));
3195 top.insert("reason".into(), Value::String(reason));
3196 }
3197 }
3198 }
3199 top.insert("hookSpecificOutput".into(), specific);
3200 Value::Object(top).to_string() + "\n"
3201}
3202
3203pub fn format_steps(steps: &[Step]) -> String {
3204 steps
3205 .iter()
3206 .map(|s| {
3207 format!(
3208 "{}\t{}\t{}\n",
3209 if s.ok { "ok" } else { "no" },
3210 s.what,
3211 s.detail
3212 )
3213 })
3214 .collect()
3215}
3216
3217fn harness_rows() -> Vec<Habitat> {
3219 let path = harnesses_path();
3220 let all = match harnesses_from(&path) {
3221 Ok(all) => all,
3222 Err(e) => {
3223 return vec![Habitat {
3224 name: "runners",
3225 state: format!("{e:#}"),
3226 ok: false,
3227 }]
3228 }
3229 };
3230 if all.harness.is_empty() {
3231 return vec![Habitat {
3232 name: "runners",
3233 state: format!(
3234 "none named in {}; `ljos onboard --example` prints the shape",
3235 path.display()
3236 ),
3237 ok: false,
3238 }];
3239 }
3240 let server = server_path().unwrap_or_else(|_| PathBuf::from("ljos-mcp"));
3241 let mut rows = Vec::new();
3242 for h in &all.harness {
3243 let registered = is_registered(h, &server) == Some(true);
3244 let probed = (registered && !h.probe.is_empty()).then(|| probe_lists_ljos(&h.probe));
3245 rows.push(Habitat {
3246 name: "runner mcp",
3247 state: match (registered, &probed) {
3248 (false, _) => format!(
3249 "{}: not registered; ljos onboard --harness {}",
3250 h.name, h.name
3251 ),
3252 (true, Some(Err(why))) => format!(
3253 "{}: registered, but `{}` does not list ljos_sitting: {why}",
3254 h.name,
3255 h.probe.join(" ")
3256 ),
3257 (true, Some(Ok(()))) => format!("{}: ljos registered and loads", h.name),
3258 (true, None) => format!("{}: ljos registered", h.name),
3259 },
3260 ok: registered && !matches!(probed, Some(Err(_))),
3261 });
3262 let skill = h
3263 .skills
3264 .as_deref()
3265 .map(|d| expand(d).join("ljos").join("SKILL.md"));
3266 let current = skill
3267 .as_ref()
3268 .is_some_and(|p| std::fs::read_to_string(p).is_ok_and(|t| t == skill_text()));
3269 if let Some(file) = &h.hooks {
3270 let path = expand(file);
3271 let installed = hook_installed(&path, &hook_events_of(h));
3272 rows.push(Habitat {
3273 name: "runner hook",
3274 state: if installed {
3275 format!("{}: memory hook on {}", h.name, path.display())
3276 } else {
3277 format!(
3278 "{}: no memory hook; ljos onboard --harness {}",
3279 h.name, h.name
3280 )
3281 },
3282 ok: installed,
3283 });
3284 } else if h.plugin.is_none() {
3285 if let Some(cfg) = &h.config {
3286 let path = expand(cfg);
3287 let installed =
3288 std::fs::read_to_string(&path).is_ok_and(|t| t.contains("ljos hook"));
3289 rows.push(Habitat {
3290 name: "runner hook",
3291 state: if installed {
3292 format!("{}: memory hook in {}", h.name, path.display())
3293 } else {
3294 format!(
3295 "{}: no memory hook in {}; ljos onboard --harness {}",
3296 h.name,
3297 path.display(),
3298 h.name
3299 )
3300 },
3301 ok: installed,
3302 });
3303 }
3304 }
3305 if let Some(dest) = &h.plugin {
3306 let path = expand(dest);
3307 let want = ljos_path().ok().and_then(|l| plugin_text(h, &l));
3308 let current = want
3309 .as_ref()
3310 .is_some_and(|w| std::fs::read_to_string(&path).is_ok_and(|t| &t == w));
3311 rows.push(Habitat {
3312 name: "runner hook",
3313 state: if current {
3314 format!("{}: plugin {}", h.name, path.display())
3315 } else if path.is_file() {
3316 format!(
3317 "{}: plugin {} is stale; ljos onboard --harness {}",
3318 h.name,
3319 path.display(),
3320 h.name
3321 )
3322 } else {
3323 format!("{}: no plugin; ljos onboard --harness {}", h.name, h.name)
3324 },
3325 ok: current,
3326 });
3327 }
3328 rows.push(Habitat {
3329 name: "runner skill",
3330 state: match (&skill, current) {
3331 (Some(p), true) => format!("{}: {}", h.name, p.display()),
3332 (Some(p), false) if p.is_file() => {
3333 format!(
3334 "{}: {} is stale; ljos onboard --harness {}",
3335 h.name,
3336 p.display(),
3337 h.name
3338 )
3339 }
3340 (Some(_), false) => {
3341 format!("{}: absent; ljos onboard --harness {}", h.name, h.name)
3342 }
3343 (None, _) => format!("{}: no skills directory named", h.name),
3344 },
3345 ok: current,
3346 });
3347 }
3348 rows
3349}
3350
3351fn probe_lists_ljos(argv: &[String]) -> std::result::Result<(), String> {
3354 use std::io::Read;
3355 use std::process::{Command, Stdio};
3356 let (bin, args) = argv.split_first().ok_or("empty probe")?;
3357 let mut child = Command::new(expand(bin))
3358 .args(args)
3359 .stdin(Stdio::null())
3360 .stdout(Stdio::piped())
3361 .stderr(Stdio::piped())
3362 .spawn()
3363 .map_err(|e| format!("{bin}: {e}"))?;
3364 let started = std::time::Instant::now();
3365 let status = loop {
3366 match child.try_wait() {
3367 Ok(Some(status)) => break status,
3368 Ok(None) if started.elapsed() > std::time::Duration::from_secs(30) => {
3369 let _ = child.kill();
3370 let _ = child.wait();
3371 return Err("no answer in 30 s".into());
3372 }
3373 Ok(None) => std::thread::sleep(std::time::Duration::from_millis(100)),
3374 Err(e) => return Err(e.to_string()),
3375 }
3376 };
3377 let mut out = String::new();
3378 if let Some(mut o) = child.stdout.take() {
3379 let _ = o.read_to_string(&mut out);
3380 }
3381 if let Some(mut e) = child.stderr.take() {
3382 let _ = e.read_to_string(&mut out);
3383 }
3384 if !status.success() {
3385 return Err(format!("exit {}", status.code().unwrap_or(-1)));
3386 }
3387 if out.contains("ljos_sitting") {
3388 Ok(())
3389 } else {
3390 Err("its output names no ljos tool".into())
3391 }
3392}
3393
3394fn pack_step(dry: bool) -> Step {
3398 let what = "pack".to_string();
3399 if let Ok(client) = pack() {
3400 if client.health().is_ok() {
3401 return Step {
3402 what,
3403 detail: format!("writer up at {}", client.base()),
3404 ok: true,
3405 };
3406 }
3407 } else {
3408 return Step {
3409 what,
3410 detail: "PACKSET_URL=off; no pack on purpose".into(),
3411 ok: true,
3412 };
3413 }
3414 if !on_path("packset") {
3415 return Step {
3416 what,
3417 detail: "no writer answers and packset is not on PATH".into(),
3418 ok: false,
3419 };
3420 }
3421 if dry {
3422 return Step {
3423 what,
3424 detail: "would run packset ensure".into(),
3425 ok: true,
3426 };
3427 }
3428 match run_captured("packset", &["ensure"]) {
3429 Ok(said) => Step {
3430 what,
3431 detail: format!(
3432 "started a writer: {}",
3433 said.stdout.lines().next().unwrap_or("").trim()
3434 ),
3435 ok: true,
3436 },
3437 Err(e) => Step {
3438 what,
3439 detail: e.to_string().lines().next().unwrap_or("").to_string(),
3440 ok: false,
3441 },
3442 }
3443}
3444
3445fn host_key_step(dry: bool) -> Step {
3449 if let Some(path) = host_key_path() {
3450 return Step {
3451 what: "host key".into(),
3452 detail: format!("{} exists", path.display()),
3453 ok: true,
3454 };
3455 }
3456 if std::env::var_os("DEEDAR_HOST_SIGNING_KEY").is_some_and(|r| r == "off") {
3457 return Step {
3458 what: "host key".into(),
3459 detail: "DEEDAR_HOST_SIGNING_KEY=off; handovers go out unsigned on purpose".into(),
3460 ok: true,
3461 };
3462 }
3463 let Some(path) = default_host_key_path() else {
3464 return Step {
3465 what: "host key".into(),
3466 detail: "no home directory to keep a key in".into(),
3467 ok: false,
3468 };
3469 };
3470 if dry {
3471 return Step {
3472 what: "host key".into(),
3473 detail: format!("would write a 32-byte seed to {}", path.display()),
3474 ok: true,
3475 };
3476 }
3477 let made = (|| -> std::io::Result<()> {
3478 use std::io::Read;
3479 let mut seed = [0u8; 32];
3480 std::fs::File::open("/dev/urandom")?.read_exact(&mut seed)?;
3481 if let Some(dir) = path.parent() {
3482 std::fs::create_dir_all(dir)?;
3483 }
3484 std::fs::write(&path, seed)?;
3485 #[cfg(unix)]
3486 {
3487 use std::os::unix::fs::PermissionsExt;
3488 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
3489 }
3490 Ok(())
3491 })();
3492 match made {
3493 Ok(()) => Step {
3494 what: "host key".into(),
3495 detail: format!("wrote a 32-byte seed to {}", path.display()),
3496 ok: true,
3497 },
3498 Err(e) => Step {
3499 what: "host key".into(),
3500 detail: format!("{}: {e}", path.display()),
3501 ok: false,
3502 },
3503 }
3504}
3505
3506fn default_host_key_path() -> Option<PathBuf> {
3508 let config = std::env::var_os("XDG_CONFIG_HOME")
3509 .filter(|r| !r.is_empty())
3510 .map(PathBuf::from)
3511 .or_else(|| home().ok().map(|h| h.join(".config")))?;
3512 Some(config.join("deedar").join("host.key"))
3513}
3514
3515fn host_key_path() -> Option<PathBuf> {
3518 if let Some(raw) = std::env::var_os("DEEDAR_HOST_SIGNING_KEY").filter(|r| !r.is_empty()) {
3519 return (raw != "off").then(|| PathBuf::from(raw));
3520 }
3521 let path = default_host_key_path()?;
3522 path.is_file().then_some(path)
3523}
3524
3525pub fn expand_leading_tilde(raw: &str, home: &str) -> Option<String> {
3528 let home = home.trim_end_matches('/');
3529 if raw == "~" {
3530 return Some(home.to_string());
3531 }
3532 raw.strip_prefix("~/").map(|rest| format!("{home}/{rest}"))
3533}
3534
3535pub fn normalize_tracker_env() {
3540 let Some(home) = std::env::var_os("HOME").filter(|h| !h.is_empty()) else {
3541 return;
3542 };
3543 let home = home.to_string_lossy().to_string();
3544 for var in ["ISSUE_ROOT", "VISSUE_ROOT"] {
3545 if let Ok(raw) = std::env::var(var) {
3546 if let Some(expanded) = expand_leading_tilde(&raw, &home) {
3547 std::env::set_var(var, expanded);
3548 }
3549 }
3550 }
3551}
3552
3553pub const POLICY_TCB: &str =
3555 "argv law. ljos-policyd is the TCB when present. Reloading a pack is not a check.";
3556
3557pub const SEAT_WORKSPACE: &str = "seat";
3562
3563fn load_seat_env() {
3570 let Ok(home) = home() else {
3571 return;
3572 };
3573 let path = home.join(".config/ljos/env");
3574 let Ok(text) = std::fs::read_to_string(path) else {
3575 return;
3576 };
3577 for line in text.lines() {
3578 let line = line.trim();
3579 if line.is_empty() || line.starts_with('#') {
3580 continue;
3581 }
3582 let Some((k, v)) = line.split_once('=') else {
3583 continue;
3584 };
3585 let k = k.trim();
3586 if k.is_empty() || std::env::var_os(k).is_some() {
3587 continue;
3588 }
3589 std::env::set_var(k, v.trim());
3590 }
3591}
3592
3593fn writer_unreachable(err: &anyhow::Error) -> bool {
3595 err.chain().any(|cause| {
3596 cause
3597 .downcast_ref::<packset_client::Error>()
3598 .is_some_and(|inner| matches!(inner, packset_client::Error::Http(_)))
3599 })
3600}
3601
3602fn ensure_writer() -> Result<()> {
3606 if std::env::var("PACKSET_URL").ok().as_deref() == Some("off") {
3607 return Ok(());
3608 }
3609 if std::env::var("PACKSET_URL")
3610 .ok()
3611 .is_some_and(|url| !url.is_empty())
3612 {
3613 bail!(
3614 "the pack writer at PACKSET_URL is not answering. This seat is not pointed at the default writer, so it was not started"
3615 );
3616 }
3617 if !on_path("packset") {
3618 bail!("no pack writer is answering, and packset is not on PATH. cargo binstall packset");
3619 }
3620 run_captured("packset", &["ensure"]).context("packset ensure")?;
3621 Ok(())
3622}
3623
3624fn with_writer<T>(op: impl Fn() -> Result<T>) -> Result<T> {
3625 match op() {
3626 Ok(value) => Ok(value),
3627 Err(err) if writer_unreachable(&err) => {
3628 ensure_writer()?;
3629 op()
3630 }
3631 Err(err) => Err(err),
3632 }
3633}
3634
3635pub fn atoms_lean(client: &PacksetClient, workspace: &str) -> Result<Vec<Value>> {
3645 let url = format!("{}/v1/atoms", client.base());
3646 let mut body: Value = ureq::get(&url)
3647 .query("workspace", workspace)
3648 .query("embedding", "omit")
3649 .timeout(std::time::Duration::from_secs(30))
3650 .call()
3651 .map_err(|e| anyhow::anyhow!("{url}: {e}"))?
3652 .into_json()?;
3653 let atoms = body
3654 .get_mut("atoms")
3655 .map(Value::take)
3656 .unwrap_or(Value::Array(Vec::new()));
3657 Ok(serde_json::from_value(atoms)?)
3658}
3659
3660pub fn pack() -> Result<PacksetClient> {
3661 load_seat_env();
3662 let workspace = std::env::var("PACKSET_WORKSPACE")
3663 .ok()
3664 .filter(|w| !w.is_empty())
3665 .unwrap_or_else(|| SEAT_WORKSPACE.to_string());
3666 Ok(PacksetClient::from_env()
3667 .context("PACKSET_URL=off: this seat has no pack on purpose")?
3668 .with_workspace(workspace))
3669}
3670
3671pub fn pack_last_write_ts() -> Result<Option<String>> {
3678 let client = pack()?;
3679 let status = client
3680 .status(Some(&client.workspace()))
3681 .context("pack: GET /v1/status failed")?;
3682 Ok(status
3683 .get("last_write_ts")
3684 .and_then(Value::as_str)
3685 .filter(|s| !s.is_empty())
3686 .map(str::to_string))
3687}
3688
3689pub fn join(parts: &[String]) -> String {
3690 parts.join(" ")
3691}
3692
3693pub fn atom_kind(label: &str) -> Result<&'static str> {
3695 match label {
3696 "Remember" => Ok("lesson"),
3697 "Prefer" => Ok("preference"),
3698 other => bail!("unknown write kind {other}"),
3699 }
3700}
3701
3702pub const SEAT_ENTITY: &str = "seat:";
3705
3706pub fn atom_body(kind: &str, text: &str, workspace: &str) -> Value {
3709 serde_json::json!({
3710 "schema": "inside.atom/v1",
3711 "kind": kind,
3712 "level": "explicit",
3713 "text": text,
3714 "workspace": workspace,
3715 "entities": [format!("{SEAT_ENTITY}{}", seat_name())],
3716 "source": atom_source(),
3717 })
3718}
3719
3720#[must_use]
3724pub fn atom_source() -> Value {
3725 let seat = whoami();
3726 let mut source = serde_json::json!({
3727 "harness": seat.seat,
3728 "session": seat.holder,
3729 "host": sync::host(),
3730 "via": "ljos",
3731 });
3732 let turn = std::env::vars()
3733 .filter(|(k, v)| k.ends_with("_TURN_ID") && !v.trim().is_empty())
3734 .map(|(_, v)| v.trim().to_string())
3735 .next();
3736 if let Some(turn) = turn {
3737 source["turn"] = Value::String(turn);
3738 }
3739 source
3740}
3741
3742pub fn add_entities(atom: &mut Value, more: impl IntoIterator<Item = String>) {
3744 let list = atom["entities"]
3745 .as_array_mut()
3746 .map(std::mem::take)
3747 .unwrap_or_default();
3748 let mut list = list;
3749 for e in more {
3750 let v = Value::String(e);
3751 if !list.contains(&v) {
3752 list.push(v);
3753 }
3754 }
3755 atom["entities"] = Value::Array(list);
3756}
3757
3758pub fn post_claim(
3760 client: &PacksetClient,
3761 label: &str,
3762 text: &str,
3763 workspace: &str,
3764) -> Result<Value> {
3765 post_claim_horizon(client, label, text, workspace, None)
3766}
3767
3768fn post_claim_horizon(
3769 client: &PacksetClient,
3770 label: &str,
3771 text: &str,
3772 workspace: &str,
3773 transient: Option<bool>,
3774) -> Result<Value> {
3775 let trimmed = text.trim();
3776 if trimmed.is_empty() {
3777 bail!("{label}: empty text is not a claim");
3778 }
3779 let kind = atom_kind(label)?;
3780 let mut atom = atom_body(kind, trimmed, workspace);
3781 stamp_horizon(&mut atom, kind, trimmed, transient);
3782 with_writer(|| {
3783 client
3784 .post_atom(&atom)
3785 .with_context(|| format!("{label}: POST /v1/atoms failed"))
3786 })
3787}
3788
3789fn stamp_horizon(atom: &mut Value, kind: &str, _text: &str, force: Option<bool>) {
3793 let transient = match (kind, force) {
3794 ("preference", _) => false,
3795 (_, Some(flag)) => flag,
3796 _ => true,
3797 };
3798 let tag = if transient {
3799 "horizon:transient"
3800 } else {
3801 "horizon:standing"
3802 };
3803 add_entities(atom, [tag.to_string()]);
3804}
3805
3806pub fn packset_write(label: &str, text: &str) -> Result<Value> {
3807 packset_write_as(label, text, None, None)
3808}
3809
3810pub fn packset_write_scoped(
3819 label: &str,
3820 text: &str,
3821 issue: &str,
3822 scope: Option<&str>,
3823) -> Result<Value> {
3824 let client = pack()?;
3825 let workspace = client.workspace();
3826 let trimmed = text.trim();
3827 if trimmed.is_empty() {
3828 bail!("{label}: empty text is not a claim");
3829 }
3830 let kind = atom_kind(label)?;
3831 let mut atom = atom_body(kind, trimmed, &workspace);
3832 let mut tags = vec![format!("issue:{}", issue.trim())];
3833 if let Some(scope) = scope.map(str::trim).filter(|s| !s.is_empty()) {
3834 tags.push(format!("scope:{scope}"));
3835 }
3836 add_entities(&mut atom, tags);
3837 stamp_horizon(&mut atom, kind, trimmed, None);
3838 with_writer(|| {
3839 client
3840 .post_atom(&atom)
3841 .with_context(|| format!("{label}: POST /v1/atoms failed"))
3842 })
3843}
3844
3845#[must_use]
3847pub fn persona_entity(name: &str) -> String {
3848 format!("persona:{}", name.trim().to_lowercase())
3849}
3850
3851#[must_use]
3856pub fn persona_set(name: &str) -> String {
3857 let mut out = String::from("persona-");
3858 for c in name.trim().to_lowercase().chars() {
3859 if c.is_ascii_lowercase() || c.is_ascii_digit() {
3860 out.push(c);
3861 } else if !out.ends_with('-') {
3862 out.push('-');
3863 }
3864 }
3865 out.trim_end_matches('-').chars().take(32).collect()
3866}
3867
3868pub fn packset_write_as(
3873 label: &str,
3874 text: &str,
3875 persona: Option<&str>,
3876 transient: Option<bool>,
3877) -> Result<Value> {
3878 let client = pack()?;
3879 let workspace = client.workspace();
3880 let Some(name) = persona.map(str::trim).filter(|n| !n.is_empty()) else {
3881 return post_claim_horizon(&client, label, text, &workspace, transient);
3882 };
3883 let trimmed = text.trim();
3884 if trimmed.is_empty() {
3885 bail!("{label}: empty text is not a claim");
3886 }
3887 let kind = atom_kind(label)?;
3888 let mut atom = atom_body(kind, trimmed, &workspace);
3889 add_entities(&mut atom, [persona_entity(name)]);
3890 stamp_horizon(&mut atom, kind, trimmed, transient);
3891 atom["set"] = Value::String(persona_set(name));
3894 with_writer(|| {
3895 client
3896 .post_atom(&atom)
3897 .with_context(|| format!("{label}: POST /v1/atoms failed"))
3898 })
3899}
3900
3901pub fn packset_forget(id: &str, why: Option<&str>) -> Result<Value> {
3920 let trimmed = id.trim();
3921 if trimmed.is_empty() {
3922 bail!("forget: an atom id is required");
3923 }
3924 let why = why.map(str::trim).filter(|w| !w.is_empty());
3925 let client = pack()?;
3926 let workspace = client.workspace();
3927 client
3928 .delete_atom(&workspace, trimmed, why)
3929 .with_context(|| format!("forget: POST /v1/atoms/delete failed for {trimmed}"))
3930}
3931
3932#[derive(Debug, Clone, PartialEq, Default)]
3937pub struct Trust {
3938 pub from: String,
3939 pub to: String,
3940 pub weight: f64,
3941 pub about: Vec<String>,
3942}
3943
3944#[derive(Debug, Clone, PartialEq)]
3948pub struct Persona {
3949 pub name: String,
3950 pub anchor: f64,
3951 pub view: String,
3952 pub entities: Vec<String>,
3953}
3954
3955pub fn persona_atom(p: &Persona, workspace: &str) -> Result<Value> {
3961 let name = p.name.trim();
3962 if name.is_empty() {
3963 bail!("persona: a name is required");
3964 }
3965 if !(0.0..=1.0).contains(&p.anchor) {
3966 bail!("persona: anchor {} is not in [0, 1]", p.anchor);
3967 }
3968 let view = p.view.trim();
3969 if view.is_empty() {
3970 bail!("persona: say in a sentence or two how {name} reads the work");
3971 }
3972 let mut atom = atom_body("persona", view, workspace);
3973 atom["name"] = Value::String(name.into());
3974 atom["anchor"] = serde_json::json!(p.anchor);
3975 if !p.entities.is_empty() {
3976 add_entities(&mut atom, p.entities.iter().map(|e| e.to_lowercase()));
3977 }
3978 Ok(atom)
3979}
3980
3981pub fn write_persona(p: &Persona) -> Result<Value> {
3986 let client = pack()?;
3987 let workspace = client.workspace();
3988 let mut atom = persona_atom(p, &workspace)?;
3989 let previous: Vec<Value> = client
3990 .atoms_of_kind(&workspace, "persona")
3991 .unwrap_or_default()
3992 .into_iter()
3993 .filter(|a| a.get("name").and_then(Value::as_str) == Some(p.name.trim()))
3994 .filter_map(|a| {
3995 a.get("id")
3996 .and_then(Value::as_str)
3997 .map(|id| Value::String(id.to_string()))
3998 })
3999 .collect();
4000 if !previous.is_empty() {
4001 atom["supersedes"] = Value::Array(previous);
4002 }
4003 let posted = client
4004 .post_atom(&atom)
4005 .context("persona: POST /v1/atoms failed")?;
4006 ensure_unscoped_inbound(p)?;
4007 Ok(posted)
4008}
4009
4010#[must_use]
4014pub fn inbound_floor(p: &Persona, seat: &str) -> Option<Trust> {
4015 let to = p.name.trim();
4016 let from = seat.trim();
4017 if to.is_empty() || from.is_empty() || from == to {
4018 return None;
4019 }
4020 Some(Trust {
4021 from: from.to_string(),
4022 to: to.to_string(),
4023 weight: 1.0,
4024 about: Vec::new(),
4025 })
4026}
4027
4028#[must_use]
4031pub fn has_unscoped_inbound(rows: &[Trust], name: &str, seat: &str) -> bool {
4032 let name = name.trim();
4033 let seat = seat.trim();
4034 rows.iter()
4035 .any(|r| r.from == seat && r.to == name && r.about.is_empty() && r.weight > 0.0)
4036}
4037
4038fn ensure_unscoped_inbound(p: &Persona) -> Result<()> {
4039 let name = p.name.trim();
4040 let seat = seat_name();
4041 if has_unscoped_inbound(&trust_from_pack().unwrap_or_default(), name, &seat) {
4042 return Ok(());
4043 }
4044 let Some(row) = inbound_floor(p, &seat) else {
4045 return Ok(());
4046 };
4047 write_trust(&row, &[]).map(|_| ())
4048}
4049
4050pub fn personas_of(atoms: &[Value]) -> Vec<Persona> {
4052 let mut latest: std::collections::BTreeMap<String, (String, Persona)> =
4053 std::collections::BTreeMap::new();
4054 for atom in atoms {
4055 if atom.get("kind").and_then(Value::as_str) != Some("persona") {
4056 continue;
4057 }
4058 let (Some(name), Some(anchor)) = (
4059 atom.get("name").and_then(Value::as_str),
4060 atom.get("anchor").and_then(Value::as_f64),
4061 ) else {
4062 continue;
4063 };
4064 let ts = atom
4065 .get("ts")
4066 .and_then(Value::as_str)
4067 .unwrap_or("")
4068 .to_string();
4069 let p = Persona {
4070 name: name.to_string(),
4071 anchor,
4072 view: atom
4073 .get("text")
4074 .and_then(Value::as_str)
4075 .unwrap_or("")
4076 .to_string(),
4077 entities: domains_of(atom.get("entities")),
4078 };
4079 match latest.get(name) {
4080 Some((seen, _)) if *seen > ts => {}
4081 _ => {
4082 latest.insert(name.to_string(), (ts, p));
4083 }
4084 }
4085 }
4086 latest.into_values().map(|(_, p)| p).collect()
4087}
4088
4089pub fn personas_from_pack() -> Result<Vec<Persona>> {
4091 let client = pack()?;
4092 let atoms = client
4095 .atoms_of_kind(&client.workspace(), "persona")
4096 .context("persona: GET /v1/atoms?kind=persona failed")?;
4097 Ok(personas_of(&atoms))
4098}
4099
4100#[derive(Debug, Clone, PartialEq, Eq)]
4104pub struct Playbook {
4105 pub name: String,
4106 pub body: String,
4107 pub models: Vec<String>,
4108}
4109
4110pub const PLAYBOOK_NAMES: &[&str] = &["sit", "arena", "land", "company-panel", "overnight"];
4112
4113pub const SHIPPED_PLAYBOOK_NAMES: &[&str] = PLAYBOOK_NAMES;
4115
4116pub const PRINCIPLES: &str = "\
4118== principles
4119split-fence: independent implementers, independent trees. A's fence stays: no second plugin, no poteto-mode, no Benny, musl CLI iced-free, `ljos vote --as` and DeGroot stay.
4120prove-on-real-surface: measure on the host the users run. A cheaper substitute is not the result.
4121open-sibling-first: a second implementer opens a sibling leftover, not a rewrite of the first tree.
4122arena-then-compose: designs write scratch; the host writes a rubric on a compose child; personas vote the compose `--as`.
4123one-step-delegate: a subagent is one playbook step. No resume across phases. A new task is a new sitting.
4124";
4125
4126pub const RUBRIC: &str = "\
4129== rubric
41301. Ledger intact. `ljos vote --as` and DeGroot stay. No schema_yes, no BARMA, no host for-loop of accepts.
41312. Playbook before panel. Sitting names one recipe and copies it before personas enter.
41323. Rubric in brief. `ljos brief` carries the playbook step, these principles, and this sheet.
41334. One-step delegate. Subagent = one playbook step. No resume across phases.
41345. Unscoped inbound trust. Every panel persona has one unscoped inbound row; `--about` only adds weight.
41356. No second plugin. Do not copy 47 skills, poteto-mode, Benny, or Cursor model files.
41367. Small surface. Prefer pack atoms and brief fields over a new crate. Musl CLI stays iced-free.
41378. Named principles. Five families, invocable mid-sitting, mapped onto existing law (split-fence, prove-on-real-surface, open-sibling-first, arena-then-compose, one-step-delegate).
4138";
4139
4140const SIT_BODY: &str = "\
4141A sitting on one issue. Name this recipe at open (`ljos sitting ISSUE --playbook sit` or `ljos playbook ISSUE sit`). The sitting prints this body before recall and holds the name until finish or release.
4142
41431. Open with `ljos sitting ISSUE --playbook sit`. Read doctor, cards, due, island, this recipe, recall, timeline, claim.
41442. Grade due claims (`ljos graded ID`).
41453. Do the work on this claim only. Artefacts are deeds, then `ljos deed ISSUE --add ACCESSION`. Lessons are `ljos remember` in two sentences.
41464. One playbook step is the whole sitting. A subagent takes this recipe and this issue; it does not resume a later phase.
41475. Close with `ljos finish ISSUE --lesson \"...\"`. Completing the node does not close the ticket. `ljos finish ISSUE --close` does, when the work is accepted.
4148";
4149
4150const ARENA_BODY: &str = "\
4151Designs compete; the host writes a rubric; personas vote a compose, not the designs.
4152
41531. Bind this recipe: `ljos sitting ISSUE --playbook arena` or `ljos playbook ISSUE arena`.
41542. Each design writes scratch (summary and body). Do not vote the design children as accept-at-most-one.
41553. The host writes a compose child and a rubric with named axes. Personas vote the compose `--as`.
41564. Spawn hints are optional model-family names on this atom. Each subagent still ends with `ljos vote ISSUE --for accept|reject --as NAME`. No graft. PASS on an axis is not GREEN.
41575. `ljos consensus ISSUE` settles under trust rows and DeGroot. `ljos vote --as` stays.
4158";
4159
4160const LAND_BODY: &str = "\
4161Land a chosen design on the real surface.
4162
41631. Bind `land`. Sitting copies this body before recall.
41642. Prove on the real surface: the host the users run, the crate they install. A cheaper substitute is not the result.
41653. Keep A's fence: no 47 skills, no poteto-mode, no Benny, musl iced-free, `ljos vote --as` and DeGroot stay.
41664. One step per subagent. Open a sibling first when a second implementer is in flight.
41675. Close with finish. Do not ship a count as consensus.
4168";
4169
4170const COMPANY_PANEL_BODY: &str = "\
4171A panel of personas on one bound recipe.
4172
41731. Bind `company-panel` before any persona enters. `ljos panel` refuses if none is bound.
41742. Every persona has one unscoped inbound trust row; `--about` only adds weight.
41753. `ljos brief NAME ISSUE` reprints this recipe in full, the five named principles, and the arena rubric.
41764. One subagent per persona, optional model-family spawn hints. Each casts `ljos vote ISSUE --for OPTION --expect OPTION --as NAME`. `--expect` is the private forecast of the others, for the surprisingly popular reading. Then `ljos consensus ISSUE`.
41775. Do not resume across phases. A new task is a new sitting.
4178";
4179
4180const OVERNIGHT_BODY: &str = "\
4181Drive work while unattended, still one sitting.
4182
41831. Bind `overnight`. Name a checkable finish condition on the issue.
41842. One playbook step per subagent. No session-pickup, no resume across phases.
41853. Isolated worktree. Prove on the real surface before claiming done.
41864. Decision log is tracker notes and deeds, not a second ledger.
41875. `ljos finish` when the condition holds; otherwise `ljos release` and a new sitting.
4188";
4189
4190#[must_use]
4192pub fn shipped_playbooks() -> Vec<Playbook> {
4193 vec![
4194 Playbook {
4195 name: "sit".into(),
4196 body: SIT_BODY.trim().into(),
4197 models: Vec::new(),
4198 },
4199 Playbook {
4200 name: "arena".into(),
4201 body: ARENA_BODY.trim().into(),
4202 models: vec!["judgment".into(), "instruction".into(), "fast".into()],
4203 },
4204 Playbook {
4205 name: "land".into(),
4206 body: LAND_BODY.trim().into(),
4207 models: Vec::new(),
4208 },
4209 Playbook {
4210 name: "company-panel".into(),
4211 body: COMPANY_PANEL_BODY.trim().into(),
4212 models: vec!["judgment".into(), "instruction".into()],
4213 },
4214 Playbook {
4215 name: "overnight".into(),
4216 body: OVERNIGHT_BODY.trim().into(),
4217 models: Vec::new(),
4218 },
4219 ]
4220}
4221
4222pub fn parse_playbook_name(name: &str) -> Result<&'static str> {
4228 let n = name.trim();
4229 if n.is_empty() {
4230 bail!(
4231 "playbook: a name is required ({})",
4232 PLAYBOOK_NAMES.join(", ")
4233 );
4234 }
4235 PLAYBOOK_NAMES
4236 .iter()
4237 .copied()
4238 .find(|k| *k == n)
4239 .ok_or_else(|| {
4240 anyhow::anyhow!(
4241 "playbook: unknown name {n:?}; the closed set is {}",
4242 PLAYBOOK_NAMES.join(", ")
4243 )
4244 })
4245}
4246
4247pub fn playbook_atom(p: &Playbook, workspace: &str) -> Result<Value> {
4253 let name = parse_playbook_name(&p.name)?;
4254 let body = p.body.trim();
4255 if body.is_empty() {
4256 bail!("playbook: {name} needs a recipe body");
4257 }
4258 let mut atom = atom_body("playbook", body, workspace);
4259 atom["name"] = Value::String(name.into());
4260 if !p.models.is_empty() {
4261 atom["models"] = Value::Array(
4262 p.models
4263 .iter()
4264 .map(|m| m.trim())
4265 .filter(|m| !m.is_empty())
4266 .map(|m| Value::String(m.to_string()))
4267 .collect(),
4268 );
4269 }
4270 Ok(atom)
4271}
4272
4273pub fn write_playbook(p: &Playbook) -> Result<Value> {
4277 let client = pack()?;
4278 let workspace = client.workspace();
4279 let mut atom = playbook_atom(p, &workspace)?;
4280 let previous: Vec<Value> = client
4281 .atoms_of_kind(&workspace, "playbook")
4282 .unwrap_or_default()
4283 .into_iter()
4284 .filter(|a| a.get("name").and_then(Value::as_str) == Some(p.name.trim()))
4285 .filter_map(|a| {
4286 a.get("id")
4287 .and_then(Value::as_str)
4288 .map(|id| Value::String(id.to_string()))
4289 })
4290 .collect();
4291 if !previous.is_empty() {
4292 atom["supersedes"] = Value::Array(previous);
4293 }
4294 client
4295 .post_atom(&atom)
4296 .context("playbook: POST /v1/atoms failed")
4297}
4298
4299pub fn playbooks_of(atoms: &[Value]) -> Vec<Playbook> {
4301 let mut latest: std::collections::BTreeMap<String, (String, Playbook)> =
4302 std::collections::BTreeMap::new();
4303 for atom in atoms {
4304 if atom.get("kind").and_then(Value::as_str) != Some("playbook") {
4305 continue;
4306 }
4307 let Some(name) = atom.get("name").and_then(Value::as_str) else {
4308 continue;
4309 };
4310 if parse_playbook_name(name).is_err() {
4311 continue;
4312 }
4313 let ts = atom
4314 .get("ts")
4315 .and_then(Value::as_str)
4316 .unwrap_or("")
4317 .to_string();
4318 let p = Playbook {
4319 name: name.to_string(),
4320 body: atom
4321 .get("text")
4322 .and_then(Value::as_str)
4323 .unwrap_or("")
4324 .to_string(),
4325 models: atom
4326 .get("models")
4327 .and_then(Value::as_array)
4328 .into_iter()
4329 .flatten()
4330 .filter_map(Value::as_str)
4331 .map(str::to_string)
4332 .collect(),
4333 };
4334 match latest.get(name) {
4335 Some((seen, _)) if *seen > ts => {}
4336 _ => {
4337 latest.insert(name.to_string(), (ts, p));
4338 }
4339 }
4340 }
4341 latest.into_values().map(|(_, p)| p).collect()
4342}
4343
4344fn ensure_shipped_playbooks() {
4345 let have = pack()
4346 .ok()
4347 .and_then(|c| c.atoms_of_kind(&c.workspace(), "playbook").ok())
4348 .map(|atoms| playbooks_of(&atoms))
4349 .unwrap_or_default();
4350 for p in shipped_playbooks() {
4351 if have.iter().any(|h| h.name == p.name) {
4352 continue;
4353 }
4354 let _ = write_playbook(&p);
4355 }
4356}
4357
4358pub fn playbooks_from_pack() -> Result<Vec<Playbook>> {
4360 ensure_shipped_playbooks();
4361 let client = pack()?;
4362 let atoms = client
4363 .atoms_of_kind(&client.workspace(), "playbook")
4364 .context("playbook: GET /v1/atoms?kind=playbook failed")?;
4365 let mut got = playbooks_of(&atoms);
4366 for p in shipped_playbooks() {
4367 if !got.iter().any(|g| g.name == p.name) {
4368 got.push(p);
4369 }
4370 }
4371 got.sort_by(|a, b| a.name.cmp(&b.name));
4372 Ok(got)
4373}
4374
4375pub fn playbook_among(name: &str, pack: &[Playbook]) -> Result<Playbook> {
4382 let name = parse_playbook_name(name)?;
4383 if let Some(p) = pack.iter().find(|p| p.name == name) {
4384 return Ok(p.clone());
4385 }
4386 shipped_playbooks()
4387 .into_iter()
4388 .find(|p| p.name == name)
4389 .ok_or_else(|| {
4390 anyhow::anyhow!(
4391 "playbook: unknown name {name:?}; the closed set is {}",
4392 PLAYBOOK_NAMES.join(", ")
4393 )
4394 })
4395}
4396
4397pub fn playbook_named(name: &str) -> Result<Playbook> {
4404 let pack = playbooks_from_pack().unwrap_or_default();
4405 playbook_among(name, &pack)
4406}
4407
4408#[must_use]
4410pub fn format_playbook_copy(p: &Playbook) -> String {
4411 let mut out = format!("{}\n{}\n", p.name, p.body.trim());
4412 if !p.models.is_empty() {
4413 out.push_str("spawn hints (optional): ");
4414 out.push_str(&p.models.join(", "));
4415 out.push_str("; each subagent still ends with `ljos vote --as` then `ljos consensus`.\n");
4416 }
4417 out
4418}
4419
4420#[must_use]
4422pub fn format_playbooks(playbooks: &[Playbook]) -> String {
4423 if playbooks.is_empty() {
4424 return "no playbooks; the shipped recipes are sit, arena, land, company-panel, overnight\n"
4425 .to_string();
4426 }
4427 let width = playbooks.iter().map(|p| p.name.len()).max().unwrap_or(0);
4428 playbooks
4429 .iter()
4430 .map(|p| {
4431 let first = p
4432 .body
4433 .split_once('.')
4434 .map(|(s, _)| s.trim())
4435 .unwrap_or(p.body.trim());
4436 format!(
4437 "{:width$} {} {}\n",
4438 p.name,
4439 if p.models.is_empty() {
4440 "no spawn hints".to_string()
4441 } else {
4442 format!("hints {}", p.models.join(", "))
4443 },
4444 first
4445 )
4446 })
4447 .collect()
4448}
4449
4450pub const PLAYBOOK_NOTE_PREFIX: &str = "playbook:";
4453
4454fn playbook_key(issue: &str) -> String {
4455 issue
4456 .trim()
4457 .chars()
4458 .map(|c| {
4459 if c.is_ascii_alphanumeric() || c == '-' {
4460 c
4461 } else {
4462 '_'
4463 }
4464 })
4465 .collect()
4466}
4467
4468fn playbook_bind_path(issue: &str) -> PathBuf {
4469 runtime_dir().join(format!("playbook-{}", playbook_key(issue)))
4470}
4471
4472fn cached_playbook(issue: &str) -> Option<String> {
4473 let text = std::fs::read_to_string(playbook_bind_path(issue)).ok()?;
4474 let name = text.trim();
4475 if name.is_empty() {
4476 None
4477 } else {
4478 Some(name.to_string())
4479 }
4480}
4481
4482fn write_playbook_cache(issue: &str, name: &str) -> Result<()> {
4483 let path = playbook_bind_path(issue);
4484 if let Some(dir) = path.parent() {
4485 let _ = std::fs::create_dir_all(dir);
4486 }
4487 std::fs::write(&path, format!("{name}\n"))
4488 .with_context(|| format!("playbook: could not bind {name} on {issue}"))
4489}
4490
4491#[must_use]
4495pub fn playbook_name_from_issue(v: &Value) -> Option<String> {
4496 let mut dated: Vec<(String, Option<String>)> = Vec::new();
4497 for e in v["logbook"].as_array().into_iter().flatten() {
4498 let Some(note) = e["note"].as_str() else {
4499 continue;
4500 };
4501 let Some(rest) = note.trim().strip_prefix(PLAYBOOK_NOTE_PREFIX) else {
4502 continue;
4503 };
4504 let name = rest.trim();
4505 let live = if name.is_empty() {
4506 None
4507 } else {
4508 Some(name.to_string())
4509 };
4510 let ts = e["timestamp"].as_str().unwrap_or("").to_string();
4511 dated.push((ts, live));
4512 }
4513 if dated.iter().any(|(ts, _)| !ts.is_empty()) {
4514 dated
4515 .into_iter()
4516 .max_by_key(|(ts, _)| ts.clone())
4517 .and_then(|(_, n)| n)
4518 } else {
4519 dated.into_iter().next().and_then(|(_, n)| n)
4520 }
4521}
4522
4523pub fn playbook_named_on(issue: &str) -> Result<Option<String>> {
4529 let said = run_captured("vissue", &["show", issue, "--json"])?;
4530 let v: Value = serde_json::from_str(&said.stdout).context("vissue show --json")?;
4531 Ok(playbook_name_from_issue(&v))
4532}
4533
4534#[must_use]
4538pub fn bound_playbook(issue: &str) -> Option<String> {
4539 match playbook_named_on(issue) {
4540 Ok(name) => name,
4541 Err(_) => cached_playbook(issue),
4542 }
4543}
4544
4545pub fn drop_playbook(issue: &str) {
4549 if bound_playbook(issue).is_some() {
4550 let _ = run_captured("vissue", &["note", issue, PLAYBOOK_NOTE_PREFIX]);
4551 }
4552 let _ = std::fs::remove_file(playbook_bind_path(issue));
4553}
4554
4555pub fn bind_playbook(issue: &str, name: &str) -> Result<()> {
4562 let issue = issue.trim();
4563 let name = name.trim();
4564 if issue.is_empty() {
4565 bail!("playbook: an issue is required");
4566 }
4567 if name.is_empty() {
4568 bail!("playbook: a name is required");
4569 }
4570 let name = parse_playbook_name(name)?;
4571 if let Some(have) = bound_playbook(issue) {
4572 if have != name {
4573 bail!(
4574 "playbook: {issue} is bound to {have} until finish or release; \
4575 a new task is a new sitting"
4576 );
4577 }
4578 let _ = write_playbook_cache(issue, name);
4579 return Ok(());
4580 }
4581 let note = format!("{PLAYBOOK_NOTE_PREFIX} {name}");
4582 match run_captured("vissue", &["note", issue, ¬e]) {
4583 Ok(_) => {
4584 let _ = write_playbook_cache(issue, name);
4585 Ok(())
4586 }
4587 Err(_) => write_playbook_cache(issue, name),
4588 }
4589}
4590
4591pub fn copy_playbook(issue: &str, name: &str) -> Result<String> {
4594 let p = playbook_named(name)?;
4595 bind_playbook(issue, &p.name)?;
4596 Ok(format_playbook_copy(&p))
4597}
4598
4599#[must_use]
4602pub fn playbook_from_title(title: &str) -> &'static str {
4603 let tokens: Vec<String> = title
4604 .to_lowercase()
4605 .split(|c: char| !c.is_ascii_alphanumeric() && c != '-')
4606 .filter(|s| !s.is_empty())
4607 .map(str::to_string)
4608 .collect();
4609 let mut names: Vec<&'static str> = PLAYBOOK_NAMES.to_vec();
4610 names.sort_by_key(|n| std::cmp::Reverse(n.len()));
4611 for name in names {
4612 if tokens.iter().any(|t| t == name) {
4613 return name;
4614 }
4615 }
4616 "sit"
4617}
4618
4619pub fn resolve_sitting_playbook(issue: &str, title: &str, asked: Option<&str>) -> Result<String> {
4627 if let Some(name) = asked.map(str::trim).filter(|n| !n.is_empty()) {
4628 return Ok(playbook_named(name)?.name);
4629 }
4630 if let Some(name) = bound_playbook(issue) {
4631 return Ok(name);
4632 }
4633 Ok(playbook_from_title(title).to_string())
4634}
4635
4636pub fn playbook_opening(issue: &str, name: Option<&str>) -> Result<String> {
4639 match name.map(str::trim).filter(|n| !n.is_empty()) {
4640 Some(n) => copy_playbook(issue, n),
4641 None => match bound_playbook(issue) {
4642 Some(have) => {
4643 let p = playbook_named(&have)?;
4644 Ok(format_playbook_copy(&p))
4645 }
4646 None => Ok("none bound; `ljos sitting ISSUE --playbook NAME` or \
4647 `ljos playbook ISSUE NAME` names one. A panel is refused until then.\n"
4648 .to_string()),
4649 },
4650 }
4651}
4652
4653#[must_use]
4656pub fn brief_playbook_blocks(issue: &str) -> String {
4657 let copy = match bound_playbook(issue) {
4658 Some(name) => playbook_named(&name)
4659 .map(|p| format_playbook_copy(&p))
4660 .unwrap_or_else(|e| format!("{e}\n")),
4661 None => {
4662 "none bound; `ljos playbook ISSUE NAME` names one before personas enter.\n".to_string()
4663 }
4664 };
4665 format!("== playbook\n{copy}\n{PRINCIPLES}\n{RUBRIC}")
4666}
4667
4668pub fn brief(name: &str, issue: &str) -> Result<String> {
4677 let personas = personas_from_pack()?;
4678 let Some(p) = personas.iter().find(|p| p.name == name) else {
4679 let names: Vec<&str> = personas.iter().map(|p| p.name.as_str()).collect();
4680 bail!(
4681 "brief: no persona {name:?} in the pack; the pack holds {}",
4682 if names.is_empty() {
4683 "none".to_string()
4684 } else {
4685 names.join(", ")
4686 }
4687 );
4688 };
4689 let mut out = format!(
4690 "You are {}. {}\nYou hold your ballot at anchor {:.2}{}.\n\n{}",
4691 p.name,
4692 p.view,
4693 p.anchor,
4694 if p.entities.is_empty() {
4695 String::new()
4696 } else {
4697 format!("; you speak to {}", p.entities.join(", "))
4698 },
4699 brief_playbook_blocks(issue)
4700 );
4701 let mut seen = std::collections::BTreeSet::new();
4702 let mut lines = Vec::new();
4703 let now = now_utc();
4704 let client = pack()?;
4707 let own_tag = persona_entity(&p.name);
4708 let mut pool = client
4710 .atoms_in_set(&client.workspace(), &persona_set(&p.name))
4711 .unwrap_or_default();
4712 if let Ok(all) = client.atoms_of_kind(&client.workspace(), "lesson") {
4713 pool.extend(
4714 all.into_iter()
4715 .filter(|a| words_of(a.get("entities")).contains(&own_tag))
4716 .filter(|a| a.get("set").is_none()),
4717 );
4718 }
4719 {
4720 let atoms = pool;
4721 let mut own: Vec<&Value> = atoms.iter().filter(|a| reviewable(a)).collect();
4722 own.sort_by(|a, b| b["ts"].as_str().cmp(&a["ts"].as_str()));
4723 if !own.is_empty() {
4724 out.push_str("\nWhat you remembered yourself:\n");
4725 for a in own.iter().take(8) {
4726 if let Some(id) = a["id"].as_str() {
4727 seen.insert(id.to_string());
4728 }
4729 out.push_str(&format!(
4730 "- [{}{}] {}\n",
4731 a["kind"].as_str().unwrap_or("claim"),
4732 age_tag(a["ts"].as_str(), &now),
4733 a["text"].as_str().unwrap_or("").trim()
4734 ));
4735 }
4736 }
4737 }
4738 let cues: Vec<String> = if p.entities.is_empty() {
4739 vec![issue_title(issue)?]
4740 } else {
4741 p.entities.clone()
4742 };
4743 for cue in &cues {
4744 let Ok(hits) = packset_search(cue) else {
4745 continue;
4746 };
4747 for h in hits.into_iter().take(5) {
4748 if UNREVIEWED_KINDS.contains(&h.kind.as_str()) {
4749 continue;
4750 }
4751 if let Some(id) = &h.id {
4752 if !seen.insert(id.clone()) {
4753 continue;
4754 }
4755 }
4756 lines.push((h.kind == "preference", hit_line(&h, &now)));
4757 }
4758 }
4759 lines.sort_by_key(|row| std::cmp::Reverse(row.0));
4760 if !lines.is_empty() {
4761 out.push_str("\nWhat this seat knows on your domains:\n");
4762 for (_, l) in lines.iter().take(8) {
4763 out.push_str(l);
4764 out.push('\n');
4765 }
4766 }
4767 out.push_str("\nThe work:\n");
4768 out.push_str(&run_captured("vissue", &["recall", issue])?.stdout);
4769 out.push_str(&format!(
4770 "\nWalk the island as yourself before the ballot: `ljos island` on the work with `--as {}`. \
4771 The number on a row is spread along your links, not a rank of what is true. \
4772 Pass `--fire` only after you have used that island. Fire rewrites your weights, not the seat's, and the next walk of the same cue follows them. \
4773 End with one ballot: `ljos vote {{issue}} --for OPTION --expect OPTION --confidence P --used deed-... --as {}`. \
4774 --expect is what you think the others will pick, or a JSON object of option to share; the surprisingly popular reading needs that forecast on the same command. \
4775 P is the probability you give that your own choice is the outcome. \
4776 --used none records that the ballot drew on no deed. \
4777 The line it prints is a count. `ljos consensus {{issue}}` is the settle. \
4778 A lesson of your own goes in with `ljos remember --as {} \"...\"`.\n",
4779 p.name, p.name, p.name
4780 ));
4781 Ok(out)
4782}
4783
4784#[must_use]
4797pub fn format_personas(personas: &[Persona]) -> String {
4800 if personas.is_empty() {
4801 return "no personas; `ljos persona NAME --anchor A --view \"...\" --about DOMAIN` writes one\n"
4802 .to_string();
4803 }
4804 let width = personas.iter().map(|p| p.name.len()).max().unwrap_or(0);
4805 personas
4806 .iter()
4807 .map(|p| {
4808 format!(
4809 "{:width$} anchor {:.2} {} {}\n",
4810 p.name,
4811 p.anchor,
4812 if p.entities.is_empty() {
4813 "about anything".to_string()
4814 } else {
4815 format!("about {}", p.entities.join(", "))
4816 },
4817 p.view
4818 )
4819 })
4820 .collect()
4821}
4822
4823fn is_scope_marker(word: &str) -> bool {
4826 word.to_lowercase().starts_with("sync:")
4827}
4828
4829pub fn personas_speaking_to(personas: &[Persona], words: &[String]) -> Vec<Persona> {
4830 let words: Vec<String> = words
4831 .iter()
4832 .map(|w| w.to_lowercase())
4833 .filter(|w| !is_scope_marker(w))
4834 .collect();
4835 let speaking: Vec<Persona> = personas
4836 .iter()
4837 .filter(|p| {
4838 p.entities.iter().any(|d| {
4839 let d = d.to_lowercase();
4840 !is_scope_marker(&d) && words.iter().any(|w| w == &d)
4841 })
4842 })
4843 .cloned()
4844 .collect();
4845 if !speaking.is_empty() {
4846 return speaking;
4847 }
4848 let general: Vec<Persona> = personas
4851 .iter()
4852 .filter(|p| p.entities.is_empty())
4853 .cloned()
4854 .collect();
4855 if !general.is_empty() {
4856 return general;
4857 }
4858 let mut ranked: Vec<(usize, &Persona)> = personas
4861 .iter()
4862 .map(|p| {
4863 let view = p.view.to_lowercase();
4864 let hits = words
4865 .iter()
4866 .filter(|w| w.chars().count() > 3 && view.contains(w.as_str()))
4867 .count();
4868 (hits, p)
4869 })
4870 .filter(|(hits, _)| *hits > 0)
4871 .collect();
4872 ranked.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name)));
4873 ranked
4874 .into_iter()
4875 .take(PANEL_BY_VIEW)
4876 .map(|(_, p)| p.clone())
4877 .collect()
4878}
4879
4880pub const PANEL_BY_VIEW: usize = 5;
4883
4884pub fn issue_words(issue: &str) -> Vec<String> {
4888 let title = issue_title(issue).unwrap_or_default();
4889 let mut words = topic_words(&title);
4890 if let Ok(v) = tracker_show_json(issue) {
4892 words.extend(tags_of(&v));
4893 }
4894 if packset_island(&title, false).is_ok_and(|i| !i["weak"].as_bool().unwrap_or(false)) {
4898 words.extend(island_entities(issue).unwrap_or_default());
4899 }
4900 words
4901}
4902
4903fn tags_of(v: &Value) -> Vec<String> {
4905 v["tags"]
4906 .as_array()
4907 .into_iter()
4908 .flatten()
4909 .filter_map(Value::as_str)
4910 .map(str::to_lowercase)
4911 .collect()
4912}
4913
4914pub fn panel(issue: &str, out: &Path) -> Result<String> {
4915 if bound_playbook(issue).is_none() {
4916 bail!(
4917 "panel: no playbook bound on {issue}; `ljos playbook {issue} NAME` or \
4918 `ljos sitting {issue} --playbook NAME` names one before personas enter"
4919 );
4920 }
4921 let all = personas_from_pack()?;
4922 if all.is_empty() {
4923 bail!("panel: the pack holds no personas; `ljos persona NAME --anchor A --view ...` writes one");
4924 }
4925 let words = issue_words(issue);
4926 let personas = personas_speaking_to(&all, &words);
4927 if personas.is_empty() {
4928 bail!(
4929 "panel: none of the {} personas speaks to {issue}: none holds its words ({}) as a \
4930 domain or in its view. Tag the issue with a domain a persona holds, or write the \
4931 briefs by hand with `ljos brief NAME {issue}`",
4932 all.len(),
4933 words.join(", ")
4934 );
4935 }
4936 std::fs::create_dir_all(out)?;
4937 let mut lines = vec![format!(
4938 "{} of {} personas speak to {issue}; briefs in {}; start one subagent per file, each ends with its ballot, then:",
4939 personas.len(),
4940 all.len(),
4941 out.display()
4942 )];
4943 for p in &personas {
4944 let path = out.join(format!("{}.md", p.name));
4945 std::fs::write(&path, brief(&p.name, issue)?)?;
4946 lines.push(format!(" {}", path.display()));
4947 }
4948 lines.push(format!("ljos consensus {issue}"));
4949 Ok(lines.join("\n") + "\n")
4950}
4951
4952#[must_use]
4955pub fn issue_options(body: &str) -> Vec<String> {
4956 let mut lines = body.lines().map(str::trim);
4957 while let Some(line) = lines.next() {
4958 let Some(rest) = line.strip_prefix("Options:") else {
4959 continue;
4960 };
4961 let rest = rest.trim();
4962 let options: Vec<String> = if rest.is_empty() {
4963 lines
4964 .by_ref()
4965 .map_while(|l| l.strip_prefix("- ").or_else(|| l.strip_prefix("+ ")))
4966 .map(|o| o.trim().to_string())
4967 .collect()
4968 } else {
4969 rest.split(',').map(|o| o.trim().to_string()).collect()
4970 };
4971 let options: Vec<String> = options.into_iter().filter(|o| !o.is_empty()).collect();
4972 if options.len() >= 2 {
4973 return options;
4974 }
4975 }
4976 Vec::new()
4977}
4978
4979pub fn jev_ballot(name: &str, issue: &str) -> Result<jev::Ballot> {
4988 let v = tracker_show_json(issue)?;
4989 let options = issue_options(v["body"].as_str().unwrap_or(""));
4990 if options.len() < 2 {
4991 bail!("vote --jev: {issue} has no `Options: A, B` line with two options or more");
4992 }
4993 let full = brief(name, issue)?;
4994 let state = full
4995 .split("\nWalk the island as yourself")
4996 .next()
4997 .unwrap_or(&full);
4998 let state: String = state.chars().take(JEV_BRIEF_CHARS).collect();
4999 let state = format!("{state}\nOptions: {}\n", options.join(", "));
5000 jev::ballot(name, issue, &state, &options).with_context(|| {
5001 format!(
5002 "vote --jev: Jev did not answer (off, no key, over the month's cap, or past its budget); \
5003 `ljos brief {name} {issue}` starts a subagent instead"
5004 )
5005 })
5006}
5007
5008fn odds(m: &std::collections::BTreeMap<String, f64>) -> String {
5009 m.iter()
5010 .map(|(k, p)| format!("{k} {p:.2}"))
5011 .collect::<Vec<_>>()
5012 .join(", ")
5013}
5014
5015pub fn cast_jev(name: &str, issue: &str, b: &jev::Ballot) -> Result<()> {
5025 let p = b
5026 .probabilities
5027 .get(&b.choice)
5028 .copied()
5029 .unwrap_or(b.confidence);
5030 let p = format!("{:.3}", p.clamp(0.01, 1.0));
5031 run_captured_as(
5032 "vissue",
5033 &[
5034 "vote",
5035 issue,
5036 "--for",
5037 &b.choice,
5038 "--used",
5039 "none",
5040 "--confidence",
5041 &p,
5042 ],
5043 Some(name),
5044 )?;
5045 write_prediction(issue, name, &serde_json::to_string(&b.forecast)?)?;
5046 note_jev(
5047 issue,
5048 &format!(
5049 "{name}: ballot from Jev, {} ({}); forecast {}",
5050 b.choice,
5051 odds(&b.probabilities),
5052 odds(&b.forecast)
5053 ),
5054 );
5055 Ok(())
5056}
5057
5058fn note_jev(issue: &str, text: &str) {
5059 let _ = run_captured("vissue", &["note", issue, text]);
5060}
5061
5062#[derive(Debug, Clone, PartialEq)]
5065pub enum JevVote {
5066 Cast(jev::Ballot),
5067 Escalated(jev::Ballot),
5068}
5069
5070pub fn jev_vote(name: &str, issue: &str) -> Result<JevVote> {
5077 let b = jev_ballot(name, issue)?;
5078 if b.escalates() {
5079 note_jev(
5080 issue,
5081 &format!(
5082 "{name}: Jev leaned {} at confidence {:.2} ({}), under the {:.2} cut; the ballot goes to a subagent",
5083 b.choice,
5084 b.confidence,
5085 odds(&b.probabilities),
5086 b.escalate_below
5087 ),
5088 );
5089 return Ok(JevVote::Escalated(b));
5090 }
5091 cast_jev(name, issue, &b)?;
5092 Ok(JevVote::Cast(b))
5093}
5094
5095#[must_use]
5100pub fn jev_panel_stands(ballots: &[jev::Ballot]) -> bool {
5101 !ballots.is_empty()
5102 && ballots.iter().all(|b| !b.escalates())
5103 && ballots.iter().all(|b| b.choice == ballots[0].choice)
5104}
5105
5106const JEV_BRIEF_CHARS: usize = 8000;
5108
5109pub fn panel_jev(issue: &str, out: &Path) -> Result<String> {
5118 let all = personas_from_pack()?;
5119 let personas = personas_speaking_to(&all, &issue_words(issue));
5120 if personas.is_empty() {
5121 bail!("panel --jev: no persona speaks to {issue}");
5122 }
5123 let mut ballots = Vec::new();
5124 for p in &personas {
5125 ballots.push(jev_ballot(&p.name, issue)?);
5126 }
5127 let rows: Vec<String> = personas
5128 .iter()
5129 .zip(&ballots)
5130 .map(|(p, b)| {
5131 format!(
5132 " {} {} at confidence {:.2}",
5133 p.name, b.choice, b.confidence
5134 )
5135 })
5136 .collect();
5137 let mut lines = Vec::new();
5138 if jev_panel_stands(&ballots) {
5139 for (p, b) in personas.iter().zip(&ballots) {
5140 cast_jev(&p.name, issue, b)?;
5141 }
5142 lines.push(format!(
5143 "{} personas on {issue} through Jev: all sure, all {}; cast",
5144 personas.len(),
5145 ballots[0].choice
5146 ));
5147 lines.extend(rows);
5148 } else {
5149 std::fs::create_dir_all(out)?;
5150 lines.push(format!(
5151 "{} personas on {issue} through Jev: split or unsure, none cast; start one subagent per brief in {}",
5152 personas.len(),
5153 out.display()
5154 ));
5155 lines.extend(rows);
5156 for (p, b) in personas.iter().zip(&ballots) {
5157 let path = out.join(format!("{}.md", p.name));
5158 std::fs::write(&path, brief(&p.name, issue)?)?;
5159 lines.push(format!(" {}", path.display()));
5160 note_jev(
5161 issue,
5162 &format!(
5163 "{}: Jev leaned {} ({}); panel split or unsure, ballot goes to a subagent",
5164 p.name,
5165 b.choice,
5166 odds(&b.probabilities)
5167 ),
5168 );
5169 }
5170 }
5171 lines.push(format!("ljos consensus {issue}"));
5172 Ok(lines.join("\n") + "\n")
5173}
5174
5175#[derive(Debug, Clone, PartialEq)]
5178pub struct Prediction {
5179 pub issue: String,
5180 pub agent: String,
5181 pub expect: Value,
5182}
5183
5184pub fn write_prediction(issue: &str, agent: &str, expect: &str) -> Result<Value> {
5186 let (issue, agent, expect) = (issue.trim(), agent.trim(), expect.trim());
5187 if issue.is_empty() || agent.is_empty() || expect.is_empty() {
5188 bail!("predict: an issue, an identity and an expectation are required");
5189 }
5190 let expect_value: Value = match serde_json::from_str::<Value>(expect) {
5191 Ok(v @ Value::Object(_)) => v,
5192 _ => Value::String(expect.to_string()),
5193 };
5194 let client = pack()?;
5195 let workspace = client.workspace();
5196 let mut atom = atom_body(
5197 "prediction",
5198 &format!("{agent} expects {expect} on {issue}."),
5199 &workspace,
5200 );
5201 atom["issue"] = Value::String(issue.into());
5202 atom["agent"] = Value::String(agent.into());
5203 atom["expect"] = expect_value;
5204 client
5205 .post_atom(&atom)
5206 .context("predict: POST /v1/atoms failed")
5207}
5208
5209pub fn predictions_of(atoms: &[Value], issue: &str) -> Vec<Prediction> {
5211 let mut latest: std::collections::BTreeMap<String, (String, Prediction)> =
5212 std::collections::BTreeMap::new();
5213 for atom in atoms {
5214 if atom.get("kind").and_then(Value::as_str) != Some("prediction")
5215 || atom.get("issue").and_then(Value::as_str) != Some(issue)
5216 {
5217 continue;
5218 }
5219 let (Some(agent), Some(expect)) = (
5220 atom.get("agent").and_then(Value::as_str),
5221 atom.get("expect"),
5222 ) else {
5223 continue;
5224 };
5225 let ts = atom
5226 .get("ts")
5227 .and_then(Value::as_str)
5228 .unwrap_or("")
5229 .to_string();
5230 let p = Prediction {
5231 issue: issue.to_string(),
5232 agent: agent.to_string(),
5233 expect: expect.clone(),
5234 };
5235 match latest.get(agent) {
5236 Some((seen, _)) if *seen > ts => {}
5237 _ => {
5238 latest.insert(agent.to_string(), (ts, p));
5239 }
5240 }
5241 }
5242 latest.into_values().map(|(_, p)| p).collect()
5243}
5244
5245pub fn predictions_json(predictions: &[Prediction]) -> String {
5247 Value::Array(
5248 predictions
5249 .iter()
5250 .map(|p| serde_json::json!({"agent": p.agent, "expect": p.expect}))
5251 .collect(),
5252 )
5253 .to_string()
5254}
5255
5256#[derive(Debug, Clone, PartialEq, Eq)]
5260pub struct Rule {
5261 pub pattern: String,
5262 pub verdict: String,
5263 pub reason: String,
5264}
5265
5266pub fn write_rule(rule: &Rule) -> Result<Value> {
5268 let pattern = rule.pattern.trim();
5269 if pattern.is_empty() {
5270 bail!("rule: a pattern over the command line is required");
5271 }
5272 if !matches!(rule.verdict.as_str(), "deny" | "ask") {
5273 bail!("rule: the verdict is deny or ask, not {:?}", rule.verdict);
5274 }
5275 let reason = rule.reason.trim();
5276 if reason.is_empty() {
5277 bail!("rule: say in a sentence why, so the reader who is stopped knows");
5278 }
5279 let client = pack()?;
5280 let workspace = client.workspace();
5281 let mut atom = atom_body("rule", reason, &workspace);
5282 atom["pattern"] = Value::String(pattern.into());
5283 atom["verdict"] = Value::String(rule.verdict.clone());
5284 client
5285 .post_atom(&atom)
5286 .context("rule: POST /v1/atoms failed")
5287}
5288
5289pub fn rules_of(atoms: &[Value]) -> Vec<Rule> {
5291 atoms
5292 .iter()
5293 .filter(|a| a.get("kind").and_then(Value::as_str) == Some("rule"))
5294 .filter_map(|a| {
5295 Some(Rule {
5296 pattern: a.get("pattern")?.as_str()?.to_string(),
5297 verdict: a.get("verdict")?.as_str()?.to_string(),
5298 reason: a
5299 .get("text")
5300 .and_then(Value::as_str)
5301 .unwrap_or("")
5302 .to_string(),
5303 })
5304 })
5305 .collect()
5306}
5307
5308pub fn rules_from_pack() -> Result<Vec<Rule>> {
5310 let client = pack()?;
5311 let atoms = atoms_lean(&client, &client.workspace()).context("rules: GET /v1/atoms failed")?;
5312 Ok(rules_of(&atoms))
5313}
5314
5315#[must_use]
5319pub fn glob_matches(pattern: &str, line: &str) -> bool {
5320 fn go(p: &[char], l: &[char]) -> bool {
5321 match (p.first(), l.first()) {
5322 (None, None) => true,
5323 (Some('*'), _) => go(&p[1..], l) || (!l.is_empty() && go(p, &l[1..])),
5324 (Some('?'), Some(_)) => go(&p[1..], &l[1..]),
5325 (Some(a), Some(b)) if a == b => go(&p[1..], &l[1..]),
5326 _ => false,
5327 }
5328 }
5329 let p: Vec<char> = pattern.chars().collect();
5330 let l: Vec<char> = line.trim().chars().collect();
5331 go(&p, &l)
5332}
5333
5334#[must_use]
5337pub fn verdict_for<'a>(rules: &'a [Rule], line: &str) -> Option<&'a Rule> {
5338 rules
5339 .iter()
5340 .find(|r| r.verdict == "deny" && glob_matches(&r.pattern, line))
5341 .or_else(|| {
5342 rules
5343 .iter()
5344 .find(|r| r.verdict == "ask" && glob_matches(&r.pattern, line))
5345 })
5346}
5347
5348pub fn anchors_json(personas: &[Persona]) -> String {
5350 let map: serde_json::Map<String, Value> = personas
5351 .iter()
5352 .map(|p| (p.name.clone(), serde_json::json!(p.anchor)))
5353 .collect();
5354 Value::Object(map).to_string()
5355}
5356
5357fn domains_of(v: Option<&Value>) -> Vec<String> {
5360 words_of(v)
5361 .into_iter()
5362 .filter(|e| !e.starts_with(SEAT_ENTITY))
5363 .collect()
5364}
5365
5366fn words_of(v: Option<&Value>) -> Vec<String> {
5367 v.and_then(Value::as_array)
5368 .into_iter()
5369 .flatten()
5370 .filter_map(Value::as_str)
5371 .map(str::to_lowercase)
5372 .collect()
5373}
5374
5375pub fn island_entities(issue: &str) -> Result<Vec<String>> {
5383 let title = issue_title(issue)?;
5384 let island = packset_island(&title, false)?;
5385 let ids: Vec<&str> = island["island"]
5386 .as_array()
5387 .into_iter()
5388 .flatten()
5389 .filter_map(|a| a["id"].as_str())
5390 .collect();
5391 if ids.is_empty() {
5392 return Ok(Vec::new());
5393 }
5394 let client = pack()?;
5395 let atoms = atoms_lean(&client, &client.workspace()).context("island: GET /v1/atoms failed")?;
5396 let mut count: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
5397 for atom in &atoms {
5398 if atom
5399 .get("id")
5400 .and_then(Value::as_str)
5401 .is_some_and(|id| ids.contains(&id))
5402 {
5403 for e in words_of(atom.get("entities")) {
5404 *count.entry(e).or_insert(0) += 1;
5405 }
5406 }
5407 }
5408 let mut ranked: Vec<(String, usize)> = count.into_iter().collect();
5409 ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
5410 Ok(ranked.into_iter().take(8).map(|(e, _)| e).collect())
5411}
5412
5413pub fn topic_words(title: &str) -> Vec<String> {
5416 let mut words: Vec<String> = title
5417 .split(|c: char| !c.is_alphanumeric())
5418 .filter(|w| w.len() >= 3)
5419 .map(str::to_lowercase)
5420 .collect();
5421 words.sort_unstable();
5422 words.dedup();
5423 words
5424}
5425
5426pub fn rows_about(rows: &[Trust], topic: &[String]) -> Vec<Trust> {
5429 let mut chosen: std::collections::BTreeMap<(String, String), Trust> =
5432 std::collections::BTreeMap::new();
5433 for r in rows {
5434 let applies = r.about.is_empty() || r.about.iter().any(|a| topic.contains(a));
5435 if !applies {
5436 continue;
5437 }
5438 let key = (r.from.clone(), r.to.clone());
5439 match chosen.get(&key) {
5440 Some(have) if !have.about.is_empty() && r.about.is_empty() => {}
5441 _ => {
5442 chosen.insert(key, r.clone());
5443 }
5444 }
5445 }
5446 chosen.into_values().collect()
5447}
5448
5449#[must_use]
5456pub fn learn_anchors(
5457 personas: &[Persona],
5458 ballots: &[(String, String)],
5459 outcome: &str,
5460 beta: f64,
5461) -> Vec<Persona> {
5462 let outcome = outcome.trim();
5463 personas
5464 .iter()
5465 .filter(|p| {
5466 ballots
5467 .iter()
5468 .any(|(agent, choice)| *agent == p.name && choice != outcome)
5469 })
5470 .map(|p| Persona {
5471 anchor: (p.anchor + (1.0 - p.anchor) * (1.0 - beta)).min(1.0),
5472 ..p.clone()
5473 })
5474 .collect()
5475}
5476
5477#[derive(Debug, Clone, PartialEq)]
5486pub struct Forecast {
5487 pub agent: String,
5488 pub choice: String,
5489 pub confidence: Option<f64>,
5490}
5491
5492#[must_use]
5500pub fn brier(choice: &str, outcome: &str, p: f64) -> f64 {
5501 let o = if choice == outcome { 1.0 } else { 0.0 };
5502 let d = p - o;
5503 d * d
5504}
5505
5506#[must_use]
5513pub fn log_score(choice: &str, outcome: &str, p: f64) -> Option<f64> {
5514 let assigned = if choice == outcome { p } else { 1.0 - p };
5515 if assigned <= 0.0 {
5516 None
5517 } else {
5518 Some(-assigned.ln())
5519 }
5520}
5521
5522#[must_use]
5525pub fn mean_log(rows: &[Forecast], outcome: &str) -> (Option<f64>, usize, usize) {
5526 let mut sum = 0.0;
5527 let mut finite = 0usize;
5528 let mut unbounded = 0usize;
5529 for row in rows {
5530 let Some(p) = row.confidence else { continue };
5531 match log_score(&row.choice, outcome, p) {
5532 Some(score) => {
5533 sum += score;
5534 finite += 1;
5535 }
5536 None => unbounded += 1,
5537 }
5538 }
5539 let mean = (finite > 0).then_some(sum / finite as f64);
5540 (mean, finite, unbounded)
5541}
5542
5543#[derive(Debug, Clone, Default, PartialEq)]
5548pub struct Calibration {
5549 pub n: u32,
5550 pub sum_p: f64,
5551 pub sum_o: f64,
5552 pub sum_brier: f64,
5553 pub sum_log: f64,
5554 pub log_n: u32,
5555 pub bins: std::collections::BTreeMap<u16, (u32, u32)>,
5556}
5557
5558#[derive(Debug, Clone, Copy, PartialEq)]
5562pub struct Partition {
5563 pub reliability: f64,
5564 pub resolution: f64,
5565 pub uncertainty: f64,
5566}
5567
5568#[must_use]
5570pub fn observe(cal: &Calibration, choice: &str, outcome: &str, p: f64) -> Calibration {
5571 let mut next = cal.clone();
5572 let occurred = choice == outcome;
5573 let o = if occurred { 1.0 } else { 0.0 };
5574 next.n += 1;
5575 next.sum_p += p;
5576 next.sum_o += o;
5577 next.sum_brier += brier(choice, outcome, p);
5578 if let Some(score) = log_score(choice, outcome, p) {
5579 next.sum_log += score;
5580 next.log_n += 1;
5581 }
5582 let key = (p.clamp(0.0, 1.0) * 1000.0).round() as u16;
5583 let slot = next.bins.entry(key).or_insert((0, 0));
5584 slot.0 += 1;
5585 if occurred {
5586 slot.1 += 1;
5587 }
5588 next
5589}
5590
5591#[must_use]
5594pub fn murphy(cal: &Calibration) -> Option<Partition> {
5595 if cal.n < 2 || cal.bins.is_empty() {
5596 return None;
5597 }
5598 let n = f64::from(cal.n);
5599 let base = cal.sum_o / n;
5600 let mut reliability = 0.0;
5601 let mut resolution = 0.0;
5602 for (thou, (count, occurred)) in &cal.bins {
5603 let nk = f64::from(*count);
5604 if nk == 0.0 {
5605 continue;
5606 }
5607 let forecast = f64::from(*thou) / 1000.0;
5608 let rate = f64::from(*occurred) / nk;
5609 reliability += nk * (forecast - rate) * (forecast - rate);
5610 resolution += nk * (rate - base) * (rate - base);
5611 }
5612 Some(Partition {
5613 reliability: reliability / n,
5614 resolution: resolution / n,
5615 uncertainty: base * (1.0 - base),
5616 })
5617}
5618
5619#[must_use]
5622pub fn mean_brier(rows: &[Forecast], outcome: &str) -> Option<(f64, usize)> {
5623 let scores: Vec<f64> = rows
5624 .iter()
5625 .filter_map(|r| r.confidence.map(|p| brier(&r.choice, outcome, p)))
5626 .collect();
5627 if scores.is_empty() {
5628 None
5629 } else {
5630 Some((
5631 scores.iter().sum::<f64>() / scores.len() as f64,
5632 scores.len(),
5633 ))
5634 }
5635}
5636
5637pub fn forecasts_from_json(raw: &str) -> Result<Vec<Forecast>> {
5639 let rows: Vec<Value> = serde_json::from_str(raw).context("ballots: not a JSON array")?;
5640 rows.iter()
5641 .map(|row| {
5642 let agent = row.get("agent").and_then(Value::as_str);
5643 let choice = row.get("choice").and_then(Value::as_str);
5644 let confidence = match row.get("confidence") {
5645 None | Some(Value::Null) => None,
5646 Some(value) => {
5647 let probability = value
5648 .as_f64()
5649 .or_else(|| value.as_str()?.parse::<f64>().ok())
5650 .context("ballots: confidence must be a probability in (0, 1]")?;
5651 if !probability.is_finite() || probability <= 0.0 || probability > 1.0 {
5652 bail!("ballots: confidence must be a probability in (0, 1]");
5653 }
5654 Some(probability)
5655 }
5656 };
5657 match (agent, choice) {
5658 (Some(a), Some(c)) => Ok(Forecast {
5659 agent: a.to_string(),
5660 choice: c.to_string(),
5661 confidence,
5662 }),
5663 _ => bail!("ballots: a row without agent and choice"),
5664 }
5665 })
5666 .collect()
5667}
5668
5669#[must_use]
5673pub fn learn_reading(
5674 rows: usize,
5675 moved: usize,
5676 forecasts: &[Forecast],
5677 outcome: &str,
5678 calibration: &std::collections::BTreeMap<String, Calibration>,
5679) -> String {
5680 let mut out = format!(
5681 "Learned. {rows} trust rows rewritten. A voter the outcome refuted shrinks; a vindicated one keeps its weight. {moved} persona anchors moved. This is not a new settle; the next ljos consensus uses these rows."
5682 );
5683 match mean_brier(forecasts, outcome) {
5684 Some((mean, n)) => {
5685 let silent = forecasts.len().saturating_sub(n);
5686 out.push_str(&format!(
5687 " Brier {mean:.3} over {n} stated probabilities (doi:10.1198/016214506000001437). {silent} ballots stated none and were not scored. The score is not a trust weight."
5688 ));
5689 }
5690 None => out.push_str(
5691 " No stated probability, so there is no Brier score. A hard vote is not a claim of certainty.",
5692 ),
5693 }
5694 let (mean_log, finite, unbounded) = mean_log(forecasts, outcome);
5695 if let Some(mean) = mean_log {
5696 out.push_str(&format!(
5697 " Logarithmic score {mean:.3} over {finite} (doi:10.1111/j.2517-6161.1952.tb00104.x)."
5698 ));
5699 }
5700 if unbounded > 0 {
5701 out.push_str(&format!(
5702 " {unbounded} assigned probability 0 to the event that occurred, so those logarithmic scores are unbounded."
5703 ));
5704 }
5705 let mut named: Vec<(&str, &Calibration)> = forecasts
5706 .iter()
5707 .filter(|f| f.confidence.is_some())
5708 .filter_map(|f| calibration.get(&f.agent).map(|cal| (f.agent.as_str(), cal)))
5709 .collect();
5710 named.sort_by(|a, b| {
5711 let gap = |c: &Calibration| {
5712 if c.n == 0 {
5713 0.0
5714 } else {
5715 (c.sum_p / f64::from(c.n) - c.sum_o / f64::from(c.n)).abs()
5716 }
5717 };
5718 gap(b.1)
5719 .partial_cmp(&gap(a.1))
5720 .unwrap_or(std::cmp::Ordering::Equal)
5721 .then(a.0.cmp(b.0))
5722 });
5723 named.dedup_by_key(|row| row.0);
5724 for (name, cal) in named.into_iter().take(8) {
5725 if cal.n == 0 {
5726 continue;
5727 }
5728 let n = f64::from(cal.n);
5729 let mean_p = cal.sum_p / n;
5730 let rate = cal.sum_o / n;
5731 out.push_str(&format!(
5732 " {name}: {} forecasts, mean probability {mean_p:.3}, event rate {rate:.3} (doi:10.1080/01621459.1982.10477856)",
5733 cal.n
5734 ));
5735 if let Some(part) = murphy(cal) {
5736 out.push_str(&format!(
5737 "; reliability {:.3}, resolution {:.3}, uncertainty {:.3} (doi:10.1175/1520-0450(1973)012<0595:ANVPOT>2.0.CO;2)",
5738 part.reliability, part.resolution, part.uncertainty
5739 ));
5740 }
5741 out.push('.');
5742 }
5743 out
5744}
5745
5746pub type LearnedState = (
5748 Vec<Trust>,
5749 Vec<Persona>,
5750 std::collections::BTreeMap<String, Calibration>,
5751);
5752
5753pub fn learn_and_write(
5754 ballots: &[(String, String)],
5755 outcome: &str,
5756 beta: f64,
5757 about: &[String],
5758 forecasts: &[Forecast],
5759) -> Result<LearnedState> {
5760 let client = pack()?;
5761 let atoms = atoms_lean(&client, &client.workspace()).context("learn: GET /v1/atoms failed")?;
5762 let (rows, records) = learn_record(ballots, outcome, &records_from_atoms(&atoms), about)?;
5763 let mut calibration = calibration_from_atoms(&atoms);
5764 for forecast in forecasts {
5765 let Some(p) = forecast.confidence else {
5766 continue;
5767 };
5768 let slot = calibration.entry(forecast.agent.clone()).or_default();
5769 *slot = observe(slot, &forecast.choice, outcome, p);
5770 }
5771 let moved = learn_anchors(&personas_from_pack()?, ballots, outcome, beta);
5772 for row in &rows {
5775 write_trust_record(
5776 row,
5777 &[],
5778 records.get(&row.to).copied(),
5779 calibration.get(&row.to),
5780 )?;
5781 }
5782 for p in &moved {
5783 write_persona(p)?;
5784 }
5785 Ok((rows, moved, calibration))
5786}
5787
5788pub type Standing = (f64, f64);
5791
5792#[must_use]
5794pub fn records_from_atoms(atoms: &[Value]) -> std::collections::BTreeMap<String, Standing> {
5795 let mut latest: std::collections::BTreeMap<String, (String, Standing)> =
5796 std::collections::BTreeMap::new();
5797 for atom in atoms {
5798 if atom.get("kind").and_then(Value::as_str) != Some("trust") {
5799 continue;
5800 }
5801 let (Some(to), Some(hits), Some(misses)) = (
5802 atom.get("to").and_then(Value::as_str),
5803 atom.get("hits").and_then(Value::as_f64),
5804 atom.get("misses").and_then(Value::as_f64),
5805 ) else {
5806 continue;
5807 };
5808 let ts = atom
5809 .get("ts")
5810 .and_then(Value::as_str)
5811 .unwrap_or("")
5812 .to_string();
5813 match latest.get(to) {
5814 Some((seen, _)) if *seen > ts => {}
5815 _ => {
5816 latest.insert(to.to_string(), (ts, (hits, misses)));
5817 }
5818 }
5819 }
5820 latest.into_iter().map(|(k, (_, r))| (k, r)).collect()
5821}
5822
5823pub fn learn_record(
5836 ballots: &[(String, String)],
5837 outcome: &str,
5838 records: &std::collections::BTreeMap<String, Standing>,
5839 about: &[String],
5840) -> Result<(Vec<Trust>, std::collections::BTreeMap<String, Standing>)> {
5841 let outcome = outcome.trim();
5842 if outcome.is_empty() {
5843 bail!("learn: an outcome is required");
5844 }
5845 let mut agents: Vec<&str> = ballots.iter().map(|(a, _)| a.as_str()).collect();
5846 agents.sort_unstable();
5847 agents.dedup();
5848 if agents.len() < 2 {
5849 bail!("learn: fewer than two voters, nothing to weigh");
5850 }
5851 let mut next = records.clone();
5852 for (agent, choice) in ballots {
5853 let r = next.entry(agent.clone()).or_insert((0.0, 0.0));
5854 if choice == outcome {
5855 r.0 += 1.0;
5856 } else {
5857 r.1 += 1.0;
5858 }
5859 }
5860 let accuracy: Vec<(String, f64)> = agents
5861 .iter()
5862 .map(|a| {
5863 let (h, m) = next.get(*a).copied().unwrap_or((0.0, 0.0));
5864 ((*a).to_string(), (h + 1.0) / (h + m + 2.0))
5865 })
5866 .collect();
5867 let weights = calibration_weights(&accuracy);
5868 let mut out = Vec::new();
5869 for from in &agents {
5870 for (to, weight) in &weights {
5871 if *from == to {
5872 continue;
5873 }
5874 out.push(Trust {
5875 from: (*from).to_string(),
5876 to: to.clone(),
5877 weight: *weight,
5878 about: about.to_vec(),
5879 });
5880 }
5881 }
5882 Ok((out, next))
5883}
5884
5885pub fn write_trust_record(
5887 row: &Trust,
5888 why: &[String],
5889 record: Option<Standing>,
5890 calibration: Option<&Calibration>,
5891) -> Result<Value> {
5892 let client = pack()?;
5893 let workspace = client.workspace();
5894 let mut atom = trust_atom(row, why, &workspace)?;
5895 if let Some((hits, misses)) = record {
5896 atom["hits"] = serde_json::json!(hits);
5897 atom["misses"] = serde_json::json!(misses);
5898 }
5899 if let Some(cal) = calibration.filter(|c| c.n > 0) {
5900 atom["forecast_n"] = serde_json::json!(cal.n);
5901 atom["forecast_sum_p"] = serde_json::json!(cal.sum_p);
5902 atom["forecast_sum_o"] = serde_json::json!(cal.sum_o);
5903 atom["forecast_sum_brier"] = serde_json::json!(cal.sum_brier);
5904 atom["forecast_sum_log"] = serde_json::json!(cal.sum_log);
5905 atom["forecast_log_n"] = serde_json::json!(cal.log_n);
5906 let mut bins = serde_json::Map::new();
5907 for (key, (count, occurred)) in &cal.bins {
5908 bins.insert(key.to_string(), serde_json::json!([count, occurred]));
5909 }
5910 atom["forecast_bins"] = Value::Object(bins);
5911 }
5912 client
5913 .post_atom(&atom)
5914 .context("trust: POST /v1/atoms failed")
5915}
5916
5917#[must_use]
5919pub fn calibration_from_atoms(atoms: &[Value]) -> std::collections::BTreeMap<String, Calibration> {
5920 let mut latest: std::collections::BTreeMap<String, (String, Calibration)> =
5921 std::collections::BTreeMap::new();
5922 for atom in atoms {
5923 if atom.get("kind").and_then(Value::as_str) != Some("trust") {
5924 continue;
5925 }
5926 let Some(to) = atom.get("to").and_then(Value::as_str) else {
5927 continue;
5928 };
5929 let Some(n) = atom.get("forecast_n").and_then(Value::as_u64) else {
5930 continue;
5931 };
5932 let ts = atom
5933 .get("ts")
5934 .and_then(Value::as_str)
5935 .unwrap_or("")
5936 .to_string();
5937 let cal = Calibration {
5938 n: n as u32,
5939 sum_p: atom
5940 .get("forecast_sum_p")
5941 .and_then(Value::as_f64)
5942 .unwrap_or(0.0),
5943 sum_o: atom
5944 .get("forecast_sum_o")
5945 .and_then(Value::as_f64)
5946 .unwrap_or(0.0),
5947 sum_brier: atom
5948 .get("forecast_sum_brier")
5949 .and_then(Value::as_f64)
5950 .unwrap_or(0.0),
5951 sum_log: atom
5952 .get("forecast_sum_log")
5953 .and_then(Value::as_f64)
5954 .unwrap_or(0.0),
5955 log_n: atom
5956 .get("forecast_log_n")
5957 .and_then(Value::as_u64)
5958 .unwrap_or(0) as u32,
5959 bins: bins_of(atom.get("forecast_bins")),
5960 };
5961 match latest.get(to) {
5962 Some((seen, _)) if *seen > ts => {}
5963 _ => {
5964 latest.insert(to.to_string(), (ts, cal));
5965 }
5966 }
5967 }
5968 latest.into_iter().map(|(k, (_, cal))| (k, cal)).collect()
5969}
5970
5971fn bins_of(value: Option<&Value>) -> std::collections::BTreeMap<u16, (u32, u32)> {
5972 let mut out = std::collections::BTreeMap::new();
5973 let Some(obj) = value.and_then(Value::as_object) else {
5974 return out;
5975 };
5976 for (key, row) in obj {
5977 let Ok(thou) = key.parse::<u16>() else {
5978 continue;
5979 };
5980 let Some(pair) = row.as_array() else { continue };
5981 let count = pair.first().and_then(Value::as_u64).unwrap_or(0) as u32;
5982 let occurred = pair.get(1).and_then(Value::as_u64).unwrap_or(0) as u32;
5983 out.insert(thou, (count, occurred));
5984 }
5985 out
5986}
5987
5988pub const LEARN_BETA: f64 = 0.5;
5990
5991pub const TRUST_FLOOR: f64 = 0.01;
5993
5994pub fn trust_atom(row: &Trust, why: &[String], workspace: &str) -> Result<Value> {
5996 let (from, to) = (row.from.trim(), row.to.trim());
5997 if from.is_empty() || to.is_empty() {
5998 bail!("trust: from and to are required");
5999 }
6000 if from == to {
6001 bail!("trust: {from} cannot weigh itself; self weight is the settle's");
6002 }
6003 if !(row.weight > 0.0 && row.weight <= 1.0) {
6004 bail!("trust: weight {} is not in (0, 1]", row.weight);
6005 }
6006 let mut atom = atom_body(
6007 "trust",
6008 &format!("{from} weighs {to} at {:.3}.", row.weight),
6009 workspace,
6010 );
6011 atom["from"] = Value::String(from.into());
6012 atom["to"] = Value::String(to.into());
6013 atom["weight"] = serde_json::json!(row.weight);
6014 for w in why {
6017 if !w.starts_with("deed-") && !w.starts_with("sha256:") {
6018 bail!("trust: {w} is not a deed accession");
6019 }
6020 }
6021 atom["entities"] = Value::Array(why.iter().map(|w| Value::String(w.clone())).collect());
6022 if !row.about.is_empty() {
6023 atom["about"] = Value::Array(
6024 row.about
6025 .iter()
6026 .map(|w| Value::String(w.to_lowercase()))
6027 .collect(),
6028 );
6029 }
6030 Ok(atom)
6031}
6032
6033pub fn trust_rows(atoms: &[Value]) -> Vec<Trust> {
6035 let mut latest: std::collections::BTreeMap<(String, String, Vec<String>), (String, f64)> =
6039 std::collections::BTreeMap::new();
6040 for atom in atoms {
6041 if atom.get("kind").and_then(Value::as_str) != Some("trust") {
6042 continue;
6043 }
6044 let (Some(from), Some(to), Some(weight)) = (
6045 atom.get("from").and_then(Value::as_str),
6046 atom.get("to").and_then(Value::as_str),
6047 atom.get("weight").and_then(Value::as_f64),
6048 ) else {
6049 continue;
6050 };
6051 let ts = atom
6052 .get("ts")
6053 .and_then(Value::as_str)
6054 .unwrap_or("")
6055 .to_string();
6056 let mut about = words_of(atom.get("about"));
6057 about.sort_unstable();
6058 let key = (from.to_string(), to.to_string(), about);
6059 match latest.get(&key) {
6060 Some((seen, _)) if *seen > ts => {}
6061 _ => {
6062 latest.insert(key, (ts, weight));
6063 }
6064 }
6065 }
6066 latest
6067 .into_iter()
6068 .map(|((from, to, about), (_, weight))| Trust {
6069 from,
6070 to,
6071 weight,
6072 about,
6073 })
6074 .collect()
6075}
6076
6077pub fn trust_json(rows: &[Trust]) -> String {
6079 let tuples: Vec<Value> = rows
6080 .iter()
6081 .map(|r| serde_json::json!([r.from, r.to, r.weight]))
6082 .collect();
6083 Value::Array(tuples).to_string()
6084}
6085
6086pub fn ballots_from_json(raw: &str) -> Result<Vec<(String, String)>> {
6088 let rows: Vec<Value> = serde_json::from_str(raw).context("ballots: not a JSON array")?;
6089 rows.iter()
6090 .map(|row| {
6091 let agent = row.get("agent").and_then(Value::as_str);
6092 let choice = row.get("choice").and_then(Value::as_str);
6093 match (agent, choice) {
6094 (Some(a), Some(c)) => Ok((a.to_string(), c.to_string())),
6095 _ => bail!("ballots: a row without agent and choice"),
6096 }
6097 })
6098 .collect()
6099}
6100
6101pub fn learn(
6106 ballots: &[(String, String)],
6107 outcome: &str,
6108 rows: &[Trust],
6109 beta: f64,
6110) -> Result<Vec<Trust>> {
6111 learn_about(ballots, outcome, rows, beta, &[])
6112}
6113
6114pub fn learn_about(
6118 ballots: &[(String, String)],
6119 outcome: &str,
6120 rows: &[Trust],
6121 beta: f64,
6122 about: &[String],
6123) -> Result<Vec<Trust>> {
6124 learn_shared(ballots, outcome, rows, beta, about, 0.0)
6125}
6126
6127pub fn learn_shared(
6133 ballots: &[(String, String)],
6134 outcome: &str,
6135 rows: &[Trust],
6136 beta: f64,
6137 about: &[String],
6138 share: f64,
6139) -> Result<Vec<Trust>> {
6140 if !(beta > 0.0 && beta < 1.0) {
6141 bail!("learn: beta {beta} is not in (0, 1)");
6142 }
6143 if !(0.0..1.0).contains(&share) {
6144 bail!("learn: share {share} is not in [0, 1)");
6145 }
6146 let outcome = outcome.trim();
6147 if outcome.is_empty() {
6148 bail!("learn: an outcome is required");
6149 }
6150 let mut agents: Vec<&str> = ballots.iter().map(|(a, _)| a.as_str()).collect();
6151 agents.sort_unstable();
6152 agents.dedup();
6153 if agents.len() < 2 {
6154 bail!("learn: fewer than two voters, nothing to weigh");
6155 }
6156 let refuted = |agent: &str| {
6157 ballots
6158 .iter()
6159 .any(|(a, choice)| a == agent && choice != outcome)
6160 };
6161 let mut out = Vec::new();
6162 for from in &agents {
6163 for to in &agents {
6164 if from == to {
6165 continue;
6166 }
6167 let current = rows
6170 .iter()
6171 .find(|r| r.from == *from && r.to == *to && r.about == about)
6172 .or_else(|| {
6173 rows.iter()
6174 .find(|r| r.from == *from && r.to == *to && r.about.is_empty())
6175 })
6176 .map_or(1.0, |r| r.weight);
6177 let stepped = if refuted(to) {
6178 (current * beta).max(TRUST_FLOOR)
6179 } else {
6180 current
6181 };
6182 let next = stepped + (1.0 - stepped) * share;
6183 out.push(Trust {
6184 from: (*from).to_string(),
6185 to: (*to).to_string(),
6186 weight: next,
6187 about: about.to_vec(),
6188 });
6189 }
6190 }
6191 Ok(out)
6192}
6193
6194pub fn trust_from_pack() -> Result<Vec<Trust>> {
6196 let client = pack()?;
6197 let workspace = client.workspace();
6198 let atoms = atoms_lean(&client, &workspace).context("trust: GET /v1/atoms failed")?;
6199 Ok(trust_rows(&atoms))
6200}
6201
6202pub fn write_trust(row: &Trust, why: &[String]) -> Result<Value> {
6204 let client = pack()?;
6205 let workspace = client.workspace();
6206 client
6207 .post_atom(&trust_atom(row, why, &workspace)?)
6208 .context("trust: POST /v1/atoms failed")
6209}
6210
6211#[derive(Debug, Clone, PartialEq, Eq)]
6213pub struct Habitat {
6214 pub name: &'static str,
6215 pub state: String,
6216 pub ok: bool,
6217}
6218
6219#[must_use]
6221pub fn format_write_ack(body: &serde_json::Value) -> String {
6222 format!(
6223 "{}\t{}\tdue {}\t{}",
6224 body["id"].as_str().unwrap_or("?"),
6225 body["kind"].as_str().unwrap_or("?"),
6226 body["due_at"].as_str().unwrap_or("-"),
6227 body["text"].as_str().unwrap_or("").replace('\n', " "),
6228 )
6229}
6230
6231pub const REQUIRED: &[&str] = &[
6233 "ljos",
6234 "ljos-mcp",
6235 "ljos-policyd",
6236 "vissue",
6237 "deedar",
6238 "claimdag",
6239 "packset",
6240 "packsetd",
6241 "packset-embed",
6242 "pack",
6243 "encoder",
6244];
6245
6246const SEAT_BINS: &[(&str, &str)] = &[
6248 ("ljos", "ljos"),
6249 ("ljos-mcp", "ljos"),
6252 ("ljos-policyd", "ljos-policyd"),
6253 ("ljos-consensus", "ljos-consensus"),
6254 ("vissue", "vissue-cli"),
6255 ("deedar", "deedar-cli"),
6256 ("claimdag", "claimdag-cli"),
6257 ("packset", "packset"),
6258 ("packsetd", "packset"),
6259 ("packset-embed", "packset-embed"),
6260 ("packset-mcp", "packset"),
6261 ("ljos-hud", "ljos-hud"),
6262];
6263
6264#[must_use]
6266pub fn parse_semver(text: &str) -> Option<&str> {
6267 let bytes = text.as_bytes();
6268 let mut i = 0;
6269 while i + 4 < bytes.len() {
6270 if bytes[i].is_ascii_digit() {
6271 let start = i;
6272 let mut dots = 0;
6273 while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
6274 if bytes[i] == b'.' {
6275 dots += 1;
6276 }
6277 i += 1;
6278 }
6279 if dots >= 2 {
6280 return Some(&text[start..i]);
6281 }
6282 }
6283 i += 1;
6284 }
6285 None
6286}
6287
6288fn bin_version(bin: &str) -> Option<String> {
6289 use std::process::{Command, Stdio};
6290 let path = which::which(bin).ok()?;
6291 let mut cmd = if bin.ends_with("-mcp") {
6294 let mut c = Command::new("timeout");
6295 c.args(["0.4", path.to_str()?, "--version"]);
6296 c
6297 } else {
6298 let mut c = Command::new(&path);
6299 c.arg("--version");
6300 c
6301 };
6302 let said = cmd
6303 .stdin(Stdio::null())
6304 .stdout(Stdio::piped())
6305 .stderr(Stdio::piped())
6306 .output()
6307 .ok()?;
6308 let stdout = String::from_utf8_lossy(&said.stdout);
6309 let stderr = String::from_utf8_lossy(&said.stderr);
6310 parse_semver(&stdout)
6311 .or_else(|| parse_semver(&stderr))
6312 .map(str::to_string)
6313}
6314
6315const CRATE_VERSION_TTL_S: u64 = 86_400;
6317
6318fn crate_version_cache(name: &str) -> Option<PathBuf> {
6322 let dir = std::env::var_os("XDG_CACHE_HOME")
6323 .filter(|r| !r.is_empty())
6324 .map(PathBuf::from)
6325 .or_else(|| home().ok().map(|h| h.join(".cache")))?
6326 .join("ljos");
6327 Some(dir.join(format!("crate-{name}")))
6328}
6329
6330#[derive(Debug, Clone, PartialEq, Eq)]
6333pub struct CrateVersion {
6334 pub version: String,
6335 pub cached: bool,
6336}
6337
6338fn crate_max_version(name: &str, refresh: bool) -> Option<CrateVersion> {
6342 use std::collections::HashMap;
6343 use std::sync::{Mutex, OnceLock};
6344 static CACHE: OnceLock<Mutex<HashMap<String, Option<CrateVersion>>>> = OnceLock::new();
6345 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
6346 if !refresh {
6347 if let Ok(guard) = cache.lock() {
6348 if let Some(hit) = guard.get(name) {
6349 return hit.clone();
6350 }
6351 }
6352 }
6353 let on_disk = crate_version_cache(name);
6354 if let Some(path) = on_disk.as_ref().filter(|_| !refresh) {
6355 let fresh = std::fs::metadata(path)
6356 .and_then(|m| m.modified())
6357 .ok()
6358 .and_then(|t| t.elapsed().ok())
6359 .is_some_and(|age| age.as_secs() < CRATE_VERSION_TTL_S);
6360 if fresh {
6361 if let Ok(text) = std::fs::read_to_string(path) {
6362 let v = text.trim();
6363 let got = (!v.is_empty()).then(|| CrateVersion {
6364 version: v.to_string(),
6365 cached: true,
6366 });
6367 if let Ok(mut guard) = cache.lock() {
6368 guard.insert(name.to_string(), got.clone());
6369 }
6370 return got;
6371 }
6372 }
6373 }
6374 let url = format!("https://crates.io/api/v1/crates/{name}");
6375 let said = std::process::Command::new("curl")
6376 .args(["-sS", "-A", "ljos-doctor", "--max-time", "3", &url])
6377 .output()
6378 .ok();
6379 let got = said.and_then(|said| {
6380 if !said.status.success() {
6381 return None;
6382 }
6383 let v: serde_json::Value = serde_json::from_slice(&said.stdout).ok()?;
6384 v["crate"]["max_version"].as_str().map(|v| CrateVersion {
6385 version: v.to_string(),
6386 cached: false,
6387 })
6388 });
6389 if let (Some(path), Some(v)) = (&on_disk, &got) {
6390 if let Some(dir) = path.parent() {
6391 let _ = std::fs::create_dir_all(dir);
6392 }
6393 let _ = std::fs::write(path, format!("{}\n", v.version));
6394 }
6395 if let Ok(mut guard) = cache.lock() {
6396 guard.insert(name.to_string(), got.clone());
6397 }
6398 got
6399}
6400
6401fn cmp_semver(a: &str, b: &str) -> Option<std::cmp::Ordering> {
6402 let parse = |s: &str| -> Option<[u64; 3]> {
6403 let mut it = s.split('.');
6404 Some([
6405 it.next()?.parse().ok()?,
6406 it.next()?.parse().ok()?,
6407 it.next()?.parse().ok()?,
6408 ])
6409 };
6410 Some(parse(a)?.cmp(&parse(b)?))
6411}
6412
6413pub fn doctor() -> Vec<Habitat> {
6416 let (mut out, runners) = std::thread::scope(|s| {
6419 let runners = s.spawn(harness_rows);
6420 let seat = doctor_seat();
6421 (seat, runners.join().unwrap_or_default())
6422 });
6423 out.extend(runners);
6424 out.extend(jev::doctor_row());
6425 out
6426}
6427
6428fn bin_health(path: &str, have: Option<&str>, latest: Option<&CrateVersion>) -> (String, bool) {
6432 use std::cmp::Ordering;
6433 let ver = have.unwrap_or("?");
6434 let Some(cr) = latest else {
6435 return (format!("{path} {ver}"), true);
6436 };
6437 let source = if cr.cached {
6438 "crates.io (cached)"
6439 } else {
6440 "crates.io"
6441 };
6442 let word = match have.and_then(|v| cmp_semver(v, &cr.version)) {
6443 Some(Ordering::Less) => "behind ",
6444 Some(Ordering::Greater) => "ahead of ",
6445 _ => "",
6446 };
6447 (
6448 format!("{path} {ver} {word}{source} {}", cr.version),
6449 true,
6450 )
6451}
6452
6453fn crate_version_for(crate_name: &str, have: Option<&str>) -> Option<CrateVersion> {
6457 let first = crate_max_version(crate_name, false)?;
6458 let ahead = first.cached
6459 && have.is_some_and(|v| cmp_semver(v, &first.version) == Some(std::cmp::Ordering::Greater));
6460 if ahead {
6461 crate_max_version(crate_name, true).or(Some(first))
6462 } else {
6463 Some(first)
6464 }
6465}
6466
6467fn check_vissue_ballot_protocol(path: &Path) -> Result<()> {
6470 use std::process::{Command, Stdio};
6471 let said = Command::new("timeout")
6472 .arg("2")
6473 .arg(path)
6474 .args(["vote", "--help"])
6475 .stdin(Stdio::null())
6476 .output()
6477 .context("could not check vissue vote --help")?;
6478 if !said.status.success() {
6479 bail!("vissue vote --help failed ({})", said.status);
6480 }
6481 let help = String::from_utf8_lossy(&said.stdout);
6482 let missing: Vec<_> = ["--used", "--confidence"]
6483 .into_iter()
6484 .filter(|flag| !help.split_whitespace().any(|word| word == *flag))
6485 .collect();
6486 if !missing.is_empty() {
6487 bail!(
6488 "incompatible ballot protocol: missing {}; install vissue-cli >= 0.16.2",
6489 missing.join(", ")
6490 );
6491 }
6492 Ok(())
6493}
6494
6495pub fn doctor_seat() -> Vec<Habitat> {
6498 let mut out = Vec::new();
6499 for (bin, crate_name) in SEAT_BINS {
6500 let found = which::which(bin).ok();
6501 let have = found.as_ref().and_then(|_| bin_version(bin));
6502 let latest = crate_version_for(crate_name, have.as_deref());
6503 let ballot_protocol = found
6504 .as_deref()
6505 .filter(|_| *bin == "vissue")
6506 .map(check_vissue_ballot_protocol);
6507 let (mut state, mut ok) = match (found, have.as_deref(), latest.as_ref()) {
6508 (None, _, Some(cr)) => (
6509 format!(
6510 "not on PATH; cargo binstall {crate_name} (crates.io {})",
6511 cr.version
6512 ),
6513 false,
6514 ),
6515 (None, _, None) => ("not on PATH".into(), false),
6516 (Some(path), have, Some(cr)) => bin_health(&path.display().to_string(), have, Some(cr)),
6517 (Some(path), have, None) => {
6518 let ver = have.unwrap_or("?");
6519 (format!("{} {ver}", path.display()), true)
6520 }
6521 };
6522 if let Some(protocol) = ballot_protocol {
6523 match protocol {
6524 Ok(()) => state.push_str("; evidence ballots supported"),
6525 Err(error) => {
6526 state.push_str(&format!("; {error:#}"));
6527 ok = false;
6528 }
6529 }
6530 }
6531 out.push(Habitat {
6532 name: bin,
6533 state,
6534 ok,
6535 });
6536 }
6537 out.push(host_row());
6540 out.push(Habitat {
6543 name: "seat",
6544 state: format_seat_row(),
6545 ok: true,
6546 });
6547 load_seat_env();
6548 out.push(
6551 match PacksetClient::from_env().and_then(|c| c.status(None)) {
6552 Ok(status) => {
6553 let available = status["embedder"]["available"].as_bool().unwrap_or(false);
6554 let answering = status["embedder"]["answering"].as_bool();
6555 Habitat {
6556 name: "encoder",
6557 state: if available {
6558 "dense ballot on".to_string()
6559 } else if answering == Some(false) {
6560 "packset-embed did not answer its last call (killed or crashed); \
6561 ranking is lexical until packsetd restarts it on the next search"
6562 .to_string()
6563 } else {
6564 "down; cargo binstall packset-embed and put it beside packsetd".to_string()
6565 },
6566 ok: available,
6567 }
6568 }
6569 Err(e) => Habitat {
6570 name: "encoder",
6571 state: format!("pack does not answer: {e}"),
6572 ok: false,
6573 },
6574 },
6575 );
6576 out.push(match pack() {
6577 Ok(client) => match client.health() {
6578 Ok(_) => Habitat {
6579 name: "pack",
6580 state: format!("{} workspace {}", client.base(), client.workspace()),
6581 ok: true,
6582 },
6583 Err(e) => Habitat {
6584 name: "pack",
6585 state: format!("{} does not answer: {e}", client.base()),
6586 ok: false,
6587 },
6588 },
6589 Err(_) => Habitat {
6590 name: "pack",
6591 state: "PACKSET_URL=off: no pack on purpose".into(),
6592 ok: false,
6593 },
6594 });
6595 if let Ok(client) = pack() {
6598 if let Ok(status) = client.status(Some(&client.workspace())) {
6599 let live = status["live"].as_u64().unwrap_or(0);
6600 let cap = status["live_cap"].as_u64().unwrap_or(0);
6601 let forgotten: Vec<String> = status["forgotten_by_reason"]
6602 .as_object()
6603 .map(|m| {
6604 m.iter()
6605 .map(|(why, n)| format!("{} by {why}", n.as_u64().unwrap_or(0)))
6606 .collect()
6607 })
6608 .unwrap_or_default();
6609 let mut state = if cap > 0 {
6610 format!("{live} live of {cap}")
6611 } else {
6612 format!("{live} live, no cap")
6613 };
6614 if !forgotten.is_empty() {
6615 state.push_str(&format!("; forgotten {}", forgotten.join(", ")));
6616 }
6617 out.push(Habitat {
6618 name: "memory",
6619 state,
6620 ok: cap == 0 || live <= cap,
6621 });
6622 }
6623 }
6624 out.push(match host_key_path() {
6625 Some(path) => {
6626 let seed = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0) == 32;
6627 let unlisted = if seed {
6630 run_captured("deedar", &["host"])
6631 .err()
6632 .map(|e| e.to_string())
6633 .filter(|e| e.contains("is not a signer"))
6634 } else {
6635 None
6636 };
6637 Habitat {
6638 name: "host key",
6639 state: match (&unlisted, seed) {
6640 (Some(why), _) => format!(
6641 "{} (32-byte seed); {}",
6642 path.display(),
6643 why.lines().next().unwrap_or("").trim()
6644 ),
6645 (None, true) => format!("{} (32-byte seed)", path.display()),
6646 (None, false) => format!("{} is not a 32-byte seed", path.display()),
6647 },
6648 ok: seed && unlisted.is_none(),
6649 }
6650 }
6651 None => Habitat {
6652 name: "host key",
6653 state: "none at ~/.config/deedar/host.key and DEEDAR_HOST_SIGNING_KEY unset; \
6654 handovers go out unsigned"
6655 .into(),
6656 ok: false,
6657 },
6658 });
6659 for (name, bin, args) in [
6660 ("deed store", "deedar", &["log", "head"][..]),
6661 ("tracker", "vissue", &["identity"][..]),
6662 ("claim graph", "claimdag", &["list"][..]),
6663 ] {
6664 out.push(match run_captured(bin, args) {
6665 Ok(said) if name == "tracker" => {
6666 let (state, ok) = tracker_state(&said.stdout, &root_source());
6667 Habitat { name, state, ok }
6668 }
6669 Ok(said) => Habitat {
6670 name,
6671 state: said.stdout.lines().next().unwrap_or("").to_string(),
6672 ok: true,
6673 },
6674 Err(e) if name == "claim graph" && claim_graph_absent(&e.to_string()).is_some() => {
6675 let dir = claim_graph_absent(&e.to_string()).unwrap_or_default();
6676 Habitat {
6677 name,
6678 state: format!("none yet; the first claim creates it at {dir}"),
6679 ok: true,
6680 }
6681 }
6682 Err(e) => Habitat {
6683 name,
6684 state: e.to_string().lines().next().unwrap_or("").to_string(),
6685 ok: false,
6686 },
6687 });
6688 }
6689 out
6690}
6691
6692pub fn claim_graph_absent(said: &str) -> Option<String> {
6696 let rest = said.split("no work graph at ").nth(1)?;
6697 let (dir, why) = rest.split_once(": ")?;
6698 why.starts_with("the directory does not exist")
6699 .then(|| dir.trim().to_string())
6700}
6701
6702fn root_source() -> String {
6704 for var in ["ISSUE_ROOT", "VISSUE_ROOT"] {
6705 if let Some(v) = std::env::var_os(var).filter(|v| !v.is_empty()) {
6706 return format!("{var}={}", v.to_string_lossy());
6707 }
6708 }
6709 "seat config or working directory".into()
6710}
6711
6712pub fn tracker_state(identity: &str, source: &str) -> (String, bool) {
6718 let version = identity.lines().next().unwrap_or("").trim();
6719 let field = |key: &str| {
6720 identity
6721 .lines()
6722 .find_map(|l| l.strip_prefix(key))
6723 .map(str::trim)
6724 .filter(|v| !v.is_empty())
6725 };
6726 let (Some(root), Some(prefix)) = (field("root="), field("prefix=")) else {
6727 return (format!("{version}; no root in vissue identity"), false);
6728 };
6729 let path = std::path::Path::new(root);
6730 let problem = if !path.is_absolute() {
6731 Some("relative root: tickets land under the working directory")
6732 } else if !path.is_dir() {
6733 Some("root is not a directory")
6734 } else if !path.join(prefix).is_dir() {
6735 Some("no prefix directory under the root")
6736 } else {
6737 None
6738 };
6739 let base = format!("{version} root={root} prefix={prefix} from {source}");
6740 match problem {
6741 Some(why) => (format!("{base}; {why}"), false),
6742 None => match tracker_git_drift(path) {
6743 Some((extra, git_ok)) => (format!("{base}; {extra}"), git_ok),
6744 None => (base, true),
6745 },
6746 }
6747}
6748
6749fn git_in(dir: &Path, args: &[&str]) -> Option<std::process::Output> {
6750 std::process::Command::new("git")
6751 .arg("-C")
6752 .arg(dir)
6753 .args(args)
6754 .stdin(std::process::Stdio::null())
6755 .output()
6756 .ok()
6757}
6758
6759fn git_ok_stdout(dir: &Path, args: &[&str]) -> Option<String> {
6760 let o = git_in(dir, args)?;
6761 o.status
6762 .success()
6763 .then(|| String::from_utf8_lossy(&o.stdout).to_string())
6764}
6765
6766pub(crate) fn tracker_upstream(root: &Path) -> Option<String> {
6770 let inside = git_ok_stdout(root, &["rev-parse", "--is-inside-work-tree"])?;
6771 if inside.trim() != "true" {
6772 return None;
6773 }
6774 if let Some(up) = git_ok_stdout(
6775 root,
6776 &[
6777 "rev-parse",
6778 "--abbrev-ref",
6779 "--symbolic-full-name",
6780 "@{upstream}",
6781 ],
6782 ) {
6783 let up = up.trim().to_string();
6784 if !up.is_empty() {
6785 return Some(up);
6786 }
6787 }
6788 git_ok_stdout(root, &["rev-parse", "--verify", "origin/HEAD"]).map(|_| "origin/HEAD".into())
6789}
6790
6791fn pid_alive(pid: u32) -> bool {
6793 unsafe { libc::kill(pid as i32, 0) == 0 }
6795}
6796
6797fn tracker_push_logs() -> (bool, Option<(std::time::SystemTime, PathBuf)>) {
6801 let Ok(entries) = std::fs::read_dir(runtime_dir()) else {
6802 return (false, None);
6803 };
6804 let mut running = false;
6805 let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
6806 for ent in entries.flatten() {
6807 let name = ent.file_name();
6808 let name = name.to_string_lossy();
6809 let Some(rest) = name
6810 .strip_prefix("tracker-push-")
6811 .and_then(|s| s.strip_suffix(".log"))
6812 else {
6813 continue;
6814 };
6815 let Ok(pid) = rest.parse::<u32>() else {
6816 continue;
6817 };
6818 if pid_alive(pid) {
6819 running = true;
6820 continue;
6821 }
6822 let mtime = ent
6823 .metadata()
6824 .and_then(|m| m.modified())
6825 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
6826 let path = ent.path();
6827 if newest.as_ref().is_none_or(|(t, _)| mtime >= *t) {
6828 newest = Some((mtime, path));
6829 }
6830 }
6831 (running, newest)
6832}
6833
6834fn last_push_refusal() -> Option<String> {
6835 let path = tracker_push_logs().1?.1;
6836 let said = std::fs::read(path).ok()?;
6837 let line = first_line(&said);
6838 (!line.is_empty()).then_some(line)
6839}
6840
6841pub fn tracker_git_drift(root: &Path) -> Option<(String, bool)> {
6846 let up = tracker_upstream(root)?;
6847 let (mut state, mut ok) = unpushed_drift(root, &up)?;
6848 if let Some(split) = tracker_remote_split(root, &up) {
6849 state = format!("{state}; {split}");
6850 ok = false;
6851 }
6852 if let Some(missing) = tracker_merge_driver_missing(root) {
6853 state = format!("{state}; {missing}");
6854 ok = false;
6855 }
6856 Some((state, ok))
6857}
6858
6859fn tracker_merge_driver_missing(root: &Path) -> Option<String> {
6864 let top = git_ok_stdout(root, &["rev-parse", "--show-toplevel"])?;
6865 let attrs = std::fs::read_to_string(Path::new(top.trim()).join(".gitattributes")).ok()?;
6866 let named = attrs
6867 .lines()
6868 .any(|l| l.split_whitespace().any(|w| w == "merge=vissue"));
6869 if !named {
6870 return None;
6871 }
6872 let driver = git_ok_stdout(root, &["config", "--get", "merge.vissue.driver"]);
6873 driver.filter(|d| !d.trim().is_empty()).is_none().then(|| {
6874 ".gitattributes merges issues.org with vissue and this clone has no merge.vissue.driver; \
6875 `vissue merge-driver --install` in the tracker registers it"
6876 .to_string()
6877 })
6878}
6879
6880fn tracker_remote_split(root: &Path, up: &str) -> Option<String> {
6885 let (_, branch) = up.split_once('/')?;
6886 let refs = git_ok_stdout(
6887 root,
6888 &[
6889 "for-each-ref",
6890 "--format=%(refname:short) %(objectname)",
6891 "refs/remotes",
6892 ],
6893 )?;
6894 let heads: Vec<(&str, &str)> = refs
6895 .lines()
6896 .filter_map(|l| l.trim().split_once(' '))
6897 .filter(|(r, _)| r.split_once('/').is_some_and(|(_, b)| b == branch))
6898 .collect();
6899 let tip = heads.iter().find(|(r, _)| *r == up)?.1;
6900 let off: Vec<&str> = heads
6901 .iter()
6902 .filter(|(_, o)| *o != tip)
6903 .map(|(r, _)| *r)
6904 .collect();
6905 (!off.is_empty()).then(|| {
6906 format!(
6907 "{} differs from {up}; pull and push every remote until they agree",
6908 off.join(", ")
6909 )
6910 })
6911}
6912
6913pub(crate) fn tracker_mirrors(root: &Path, up: &str) -> Option<Vec<(String, String)>> {
6916 let (upstream, branch) = up.split_once('/')?;
6917 let plain = |s: &str| {
6918 !s.is_empty()
6919 && s.chars()
6920 .all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
6921 };
6922 let refs = git_ok_stdout(
6923 root,
6924 &["for-each-ref", "--format=%(refname:short)", "refs/remotes"],
6925 )?;
6926 Some(
6927 refs.lines()
6928 .filter_map(|r| r.trim().split_once('/'))
6929 .filter(|(r, b)| *r != upstream && *b == branch && plain(r) && plain(b))
6930 .map(|(r, b)| (r.to_string(), b.to_string()))
6931 .collect(),
6932 )
6933}
6934
6935fn unpushed_drift(root: &Path, up: &str) -> Option<(String, bool)> {
6936 let range = format!("{up}..HEAD");
6937 let count: u64 = git_ok_stdout(root, &["rev-list", "--count", &range])?
6938 .trim()
6939 .parse()
6940 .ok()?;
6941 if count == 0 {
6942 return Some(("0 unpushed".into(), true));
6943 }
6944 let (running, _) = tracker_push_logs();
6945 let oldest = git_ok_stdout(root, &["log", "--format=%ct", "--reverse", &range])
6946 .and_then(|s| {
6947 s.lines()
6948 .find(|l| !l.trim().is_empty())
6949 .map(|l| l.trim().to_string())
6950 })
6951 .and_then(|s| s.parse::<u64>().ok());
6952 let now = std::time::SystemTime::now()
6953 .duration_since(std::time::UNIX_EPOCH)
6954 .unwrap_or_default()
6955 .as_secs();
6956 let stuck = oldest.is_some_and(|t| now.saturating_sub(t) >= push_wait().as_secs());
6957 let unpushed = if count == 1 {
6958 "1 unpushed".to_string()
6959 } else {
6960 format!("{count} unpushed")
6961 };
6962 if running {
6963 return Some((format!("{unpushed}; push still running"), true));
6964 }
6965 if let Some(why) = last_push_refusal() {
6966 return Some((format!("{unpushed}; last push refused: {why}"), false));
6967 }
6968 Some((unpushed, !stuck))
6969}
6970
6971fn host_row() -> Habitat {
6975 let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease")
6976 .map(|s| s.trim().to_string())
6977 .unwrap_or_else(|_| "unknown kernel".into());
6978 let kills = oom_kills();
6979 let (servers, rss_kb) = ljos_mcp_servers();
6980 let mcp = format!("{servers} ljos-mcp, {} MB resident", rss_kb / 1024);
6981 match kills {
6982 Some(0) => Habitat {
6983 name: "host",
6984 state: format!("{kernel}; no OOM kills since boot; {mcp}"),
6985 ok: true,
6986 },
6987 Some(n) => Habitat {
6988 name: "host",
6989 state: format!(
6990 "{kernel}; {n} OOM kills since boot (/proc/vmstat oom_kill); {mcp}; \
6991 the kernel is killing processes, read `journalctl -k -b` before the load"
6992 ),
6993 ok: false,
6994 },
6995 None => Habitat {
6996 name: "host",
6997 state: format!("{kernel}; {mcp}"),
6998 ok: true,
6999 },
7000 }
7001}
7002
7003fn oom_kills() -> Option<u64> {
7005 parse_oom_kills(&std::fs::read_to_string("/proc/vmstat").ok()?)
7006}
7007
7008fn parse_oom_kills(vmstat: &str) -> Option<u64> {
7009 vmstat
7010 .lines()
7011 .find_map(|l| l.strip_prefix("oom_kill "))
7012 .and_then(|n| n.trim().parse().ok())
7013}
7014
7015fn ljos_mcp_servers() -> (usize, u64) {
7018 let uid = std::fs::read_to_string("/proc/self/status")
7019 .ok()
7020 .and_then(|s| status_field(&s, "Uid:"));
7021 let Ok(dir) = std::fs::read_dir("/proc") else {
7022 return (0, 0);
7023 };
7024 let mut count = 0;
7025 let mut rss = 0;
7026 for entry in dir.flatten() {
7027 let path = entry.path();
7028 if std::fs::read_to_string(path.join("comm")).map_or(true, |c| c.trim() != "ljos-mcp") {
7029 continue;
7030 }
7031 let Ok(status) = std::fs::read_to_string(path.join("status")) else {
7032 continue;
7033 };
7034 if status_field(&status, "Uid:") != uid {
7035 continue;
7036 }
7037 count += 1;
7038 rss += status_field(&status, "VmRSS:")
7039 .and_then(|v| v.parse::<u64>().ok())
7040 .unwrap_or(0);
7041 }
7042 (count, rss)
7043}
7044
7045fn status_field(status: &str, key: &str) -> Option<String> {
7047 status
7048 .lines()
7049 .find_map(|l| l.strip_prefix(key))
7050 .and_then(|rest| rest.split_whitespace().next())
7051 .map(str::to_string)
7052}
7053
7054pub fn healthy(rows: &[Habitat]) -> bool {
7056 rows.iter()
7057 .all(|h| h.ok || !REQUIRED.contains(&h.name) && h.name != "pack")
7058}
7059
7060pub fn format_doctor(rows: &[Habitat]) -> String {
7061 rows.iter()
7062 .map(|h| {
7063 format!(
7064 "{} {} {}
7065",
7066 if h.ok { "ok" } else { "no" },
7067 h.name,
7068 h.state
7069 )
7070 })
7071 .collect()
7072}
7073
7074pub fn needs_of(satchel_json: &str) -> Result<Vec<String>> {
7076 let v: Value = serde_json::from_str(satchel_json).context("satchel.json")?;
7077 Ok(v.get("needs")
7078 .and_then(Value::as_array)
7079 .map(|a| {
7080 a.iter()
7081 .filter_map(Value::as_str)
7082 .map(str::to_string)
7083 .collect()
7084 })
7085 .unwrap_or_default())
7086}
7087
7088pub fn enclose(needs: Vec<String>, cited: &str) -> Vec<String> {
7090 let mut all: Vec<String> = needs
7091 .into_iter()
7092 .chain(cited.lines().map(str::trim).map(str::to_string))
7093 .filter(|s| !s.is_empty())
7094 .collect();
7095 all.sort();
7096 all.dedup();
7097 all
7098}
7099
7100pub fn handover(out: &Path, projects: &[String], issues: &[String]) -> Result<Vec<String>> {
7103 if projects.is_empty() && issues.is_empty() {
7104 bail!("handover: name a project or an issue");
7105 }
7106 let mut lines = Vec::new();
7107 let mut args = vec![
7108 "satchel".to_string(),
7109 "--out".into(),
7110 out.display().to_string(),
7111 ];
7112 for p in projects {
7113 args.push("--project".into());
7114 args.push(p.clone());
7115 }
7116 for i in issues {
7117 args.push("--issue".into());
7118 args.push(i.clone());
7119 }
7120 lines.push(run_captured("vissue", &args)?.stdout.trim_end().to_string());
7121
7122 let mut cited = String::new();
7123 match PacksetClient::from_env() {
7124 Ok(client) => {
7125 let atoms_dir = out.join("data").join("atoms");
7126 match run_captured(
7127 "packset",
7128 &[
7129 "export",
7130 "--into",
7131 &atoms_dir.display().to_string(),
7132 &client.workspace(),
7133 ],
7134 ) {
7135 Ok(said) => {
7136 cited = said.stdout;
7137 lines.push(said.stderr.trim_end().to_string());
7138 }
7139 Err(e) => lines.push(format!("atoms not enclosed: {e}")),
7140 }
7141 }
7142 Err(_) => lines.push("no pack: PACKSET_URL=off, atoms not enclosed".into()),
7143 }
7144
7145 let description = std::fs::read_to_string(out.join("data").join("satchel.json"))
7146 .context("handover: the satchel has no description")?;
7147 let deeds = enclose(needs_of(&description)?, &cited);
7148 if deeds.is_empty() {
7149 lines.push("no deeds cited".into());
7150 } else {
7151 let deeds_dir = out.join("data").join("deeds");
7152 let said = run_fed(
7153 "deedar",
7154 &["export", "--into", &deeds_dir.display().to_string(), "-"],
7155 &format!(
7156 "{}
7157",
7158 deeds.join(
7159 "
7160"
7161 )
7162 ),
7163 )?;
7164 lines.push(said.stdout.trim_end().to_string());
7165 }
7166
7167 lines.push(
7168 run_captured("vissue", &["satchel", "--seal", &out.display().to_string()])?
7169 .stdout
7170 .trim_end()
7171 .to_string(),
7172 );
7173 if host_key_path().is_some() {
7176 let manifest = out.join("manifest-sha256.txt");
7177 let said = run_captured(
7178 "deedar",
7179 &["vouch", "sign", &manifest.display().to_string()],
7180 )?;
7181 lines.push(said.stdout.trim_end().to_string());
7182 } else {
7183 lines.push(
7184 "unsigned: no host key at ~/.config/deedar/host.key and DEEDAR_HOST_SIGNING_KEY unset; \
7185 `ljos onboard` writes one"
7186 .into(),
7187 );
7188 }
7189 Ok(lines)
7190}
7191
7192pub fn receive(dir: &Path, since: Option<&Path>, import: bool) -> Result<Vec<String>> {
7195 let mut lines = Vec::new();
7196 lines.push(
7197 run_captured(
7198 "vissue",
7199 &["satchel", "--verify", &dir.display().to_string()],
7200 )?
7201 .stdout
7202 .trim_end()
7203 .to_string(),
7204 );
7205 if dir.join("data").join("deeds").is_dir() {
7206 let mut args = vec!["check".to_string(), dir.display().to_string()];
7207 if let Some(bridge) = since {
7208 args.push("--since".into());
7209 args.push(bridge.display().to_string());
7210 }
7211 lines.push(run_captured("deedar", &args)?.stdout.trim_end().to_string());
7212 } else {
7213 lines.push("no deeds enclosed".into());
7214 }
7215 let manifest = dir.join("manifest-sha256.txt");
7216 let mut sender = "from:handover".to_string();
7220 if manifest.with_extension("txt.sig").is_file() {
7221 let said = run_captured(
7222 "deedar",
7223 &["vouch", "check", &manifest.display().to_string()],
7224 )?
7225 .stdout
7226 .trim_end()
7227 .to_string();
7228 if !said.starts_with("signed by ") {
7229 bail!("receive: satchel is not signed by an accepted key: {said}");
7230 }
7231 if let Some(hex) = said
7232 .strip_prefix("signed by ")
7233 .and_then(|rest| rest.split(|c: char| !c.is_ascii_hexdigit()).next())
7234 .filter(|h| h.len() >= 12)
7235 {
7236 sender = format!("from:{}", &hex[..12]);
7237 }
7238 lines.push(said);
7239 } else if import {
7240 bail!("receive: unsigned satchel; will not import");
7241 } else {
7242 lines.push("unsigned".into());
7243 }
7244
7245 let atoms = enclosed_atoms(dir)?;
7246 let rows = trust_rows(&atoms);
7247 lines.push(format!(
7248 "{} atoms enclosed, {} trust rows",
7249 atoms.len(),
7250 rows.len()
7251 ));
7252 if import {
7253 let client = pack()?;
7254 let workspace = client.workspace();
7255 let (mut kept, mut refused) = (0usize, Vec::new());
7256 for atom in &atoms {
7257 let mut atom = atom.clone();
7260 if let Some(map) = atom.as_object_mut() {
7261 map.insert("workspace".into(), Value::String(workspace.clone()));
7262 let mut entities: Vec<Value> = map
7263 .get("entities")
7264 .and_then(Value::as_array)
7265 .cloned()
7266 .unwrap_or_default();
7267 if !entities.iter().any(|e| e.as_str() == Some(sender.as_str())) {
7268 entities.push(Value::String(sender.clone()));
7269 }
7270 map.insert("entities".into(), Value::Array(entities));
7271 }
7272 match client.post_atom(&atom) {
7273 Ok(_) => kept += 1,
7274 Err(e) => refused.push(e.to_string()),
7275 }
7276 }
7277 lines.push(format!("{kept} atoms imported, {} refused", refused.len()));
7278 lines.extend(refused.into_iter().take(5));
7279 if kept > 0 {
7280 lines.push(
7281 "imported claims may rewrite held ones; `ljos consolidate` reports the pairs, `--apply` closes them"
7282 .to_string(),
7283 );
7284 }
7285 }
7286 Ok(lines)
7287}
7288
7289pub fn enclosed_atoms(dir: &Path) -> Result<Vec<Value>> {
7291 let atoms_dir = dir.join("data").join("atoms");
7292 let Ok(entries) = std::fs::read_dir(&atoms_dir) else {
7293 return Ok(Vec::new());
7294 };
7295 let mut out = Vec::new();
7296 for entry in entries.flatten() {
7297 let text = std::fs::read_to_string(entry.path())?;
7298 for line in text.lines().filter(|l| !l.trim().is_empty()) {
7299 out.push(
7300 serde_json::from_str(line).with_context(|| entry.path().display().to_string())?,
7301 );
7302 }
7303 }
7304 Ok(out)
7305}
7306
7307const UNREVIEWED_KINDS: &[&str] = &["trust", "persona", "playbook"];
7309
7310fn reviewable(a: &Value) -> bool {
7312 !UNREVIEWED_KINDS.contains(&a.get("kind").and_then(Value::as_str).unwrap_or(""))
7313}
7314
7315pub fn due_of(atoms: &[Value], now: &str) -> Vec<Value> {
7320 let mut due: Vec<Value> = atoms
7321 .iter()
7322 .filter(|a| reviewable(a))
7323 .filter(|a| {
7324 a.get("due_at")
7325 .and_then(Value::as_str)
7326 .is_none_or(|d| d.is_empty() || d <= now)
7327 })
7328 .cloned()
7329 .collect();
7330 due.sort_by(|a, b| {
7331 a["due_at"]
7332 .as_str()
7333 .unwrap_or("")
7334 .cmp(b["due_at"].as_str().unwrap_or(""))
7335 });
7336 due
7337}
7338
7339pub fn review_summary(atoms: &[Value], now: &str) -> String {
7344 let due = due_of(atoms, now).len();
7345 let mut later: Vec<&str> = atoms
7346 .iter()
7347 .filter(|a| reviewable(a))
7348 .filter_map(|a| a.get("due_at").and_then(Value::as_str))
7349 .filter(|d| !d.is_empty() && *d > now)
7350 .collect();
7351 later.sort_unstable();
7352 match later.first() {
7353 Some(next) => format!("{due} due; {} scheduled, next at {next}", later.len()),
7354 None if due == 0 => "0 due; nothing scheduled: this seat has remembered nothing yet".into(),
7355 None => format!("{due} due; nothing else scheduled"),
7356 }
7357}
7358
7359#[must_use]
7363pub fn due_on_island_first(due: Vec<Value>, island: &Value) -> Vec<Value> {
7364 if island["weak"].as_bool().unwrap_or(false) {
7366 return due;
7367 }
7368 let on: std::collections::BTreeSet<&str> = island["island"]
7369 .as_array()
7370 .into_iter()
7371 .flatten()
7372 .filter_map(|a| a["id"].as_str())
7373 .collect();
7374 let (mut first, rest): (Vec<Value>, Vec<Value>) = due
7375 .into_iter()
7376 .partition(|a| a["id"].as_str().is_some_and(|id| on.contains(id)));
7377 first.extend(rest);
7378 first
7379}
7380
7381pub const SITTING_DUE: usize = 8;
7383
7384pub const SITTING_TIMELINE: usize = 12;
7386
7387pub fn sitting_due_report(island: &Value) -> Result<String> {
7389 let client = pack()?;
7390 let swept = client.sweep(&client.workspace()).ok();
7393 let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7394 let now = now_utc();
7395 let due = due_on_island_first(due_of(&atoms, &now), island);
7396 let shown = due.len().min(SITTING_DUE);
7397 Ok(format!(
7398 "{}{}{}\n",
7399 format_due(&due[..shown]),
7400 review_summary(&atoms, &now),
7401 format_sweep(swept.as_ref())
7402 ))
7403}
7404
7405pub fn due_report() -> Result<String> {
7407 let client = pack()?;
7408 let swept = client.sweep(&client.workspace()).ok();
7411 let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7412 let now = now_utc();
7413 Ok(format!(
7414 "{}{}{}\n",
7415 format_due(&due_of(&atoms, &now)),
7416 review_summary(&atoms, &now),
7417 format_sweep(swept.as_ref())
7418 ))
7419}
7420
7421pub fn format_sweep(report: Option<&Value>) -> String {
7423 let Some(report) = report else {
7424 return String::new();
7425 };
7426 let lapsed = report.get("lapsed").and_then(Value::as_u64).unwrap_or(0);
7427 let forgotten = report.get("forgotten").and_then(Value::as_u64).unwrap_or(0);
7428 if lapsed == 0 && forgotten == 0 {
7429 return String::new();
7430 }
7431 format!(
7432 "\nswept: {lapsed} review{} lapsed past twice {} interval, {forgotten} never-recalled claim{} forgotten by neglect",
7433 if lapsed == 1 { "" } else { "s" },
7434 if lapsed == 1 { "its" } else { "their" },
7435 if forgotten == 1 { "" } else { "s" }
7436 )
7437}
7438
7439pub fn due() -> Result<Vec<Value>> {
7441 let client = pack()?;
7442 let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7443 Ok(due_of(&atoms, &now_utc()))
7444}
7445
7446pub fn due_page() -> Result<(Vec<Value>, usize, String)> {
7449 let client = pack()?;
7450 let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7451 let now = now_utc();
7452 let all = due_of(&atoms, &now);
7453 let total = all.len();
7454 let shown: Vec<Value> = all.into_iter().take(SITTING_DUE).collect();
7455 Ok((shown, total, review_summary(&atoms, &now)))
7456}
7457
7458pub const HABIT_ENTITY: &str = "habit:";
7462pub const HABIT_EVERY_S: i64 = 7 * 86_400;
7464
7465#[derive(Debug, Clone, PartialEq, serde::Serialize)]
7472pub struct Reading {
7473 pub name: String,
7474 pub value: f64,
7475 pub unit: String,
7476 pub source: String,
7477 pub every_s: i64,
7479 pub was: Option<f64>,
7481 pub was_ts: Option<String>,
7482 pub id: Option<String>,
7483 pub ts: Option<String>,
7484 pub due_at: Option<String>,
7485}
7486
7487pub fn parse_every(text: &str) -> Result<i64> {
7489 let t = text.trim();
7490 let split = t.trim_end_matches(|c: char| c.is_ascii_alphabetic()).len();
7491 let (num, unit) = t.split_at(split);
7492 let n: i64 = num
7493 .trim()
7494 .parse()
7495 .with_context(|| format!("habit: --every {t:?} is not a span; write 7d, 24h, 2w or 30m"))?;
7496 let each = match unit {
7497 "" | "s" => 1,
7498 "m" => 60,
7499 "h" => 3_600,
7500 "d" => 86_400,
7501 "w" => 7 * 86_400,
7502 other => bail!("habit: unknown unit {other:?} in --every; write d, h, w, m or s"),
7503 };
7504 if n <= 0 {
7505 bail!("habit: --every must be positive");
7506 }
7507 Ok(n * each)
7508}
7509
7510fn stamp_after(now: &str, secs: i64) -> Option<String> {
7513 let days = days_of_stamp(Some(now))?;
7514 let clock = now.get(11..19)?;
7515 let mut it = clock.split(':');
7516 let h: i64 = it.next()?.parse().ok()?;
7517 let m: i64 = it.next()?.parse().ok()?;
7518 let s: i64 = it.next()?.parse().ok()?;
7519 let total = days * 86_400 + h * 3_600 + m * 60 + s + secs;
7520 let day = total.div_euclid(86_400);
7521 let rem = total.rem_euclid(86_400);
7522 Some(format!(
7523 "{}T{:02}:{:02}:{:02}.000Z",
7524 civil_of_days(day),
7525 rem / 3_600,
7526 rem % 3_600 / 60,
7527 rem % 60
7528 ))
7529}
7530
7531#[must_use]
7533pub fn trim_num(v: f64) -> String {
7534 let s = format!("{v:.4}");
7535 let s = s.trim_end_matches('0').trim_end_matches('.');
7536 if s.is_empty() || s == "-" {
7537 "0".to_string()
7538 } else {
7539 s.to_string()
7540 }
7541}
7542
7543#[must_use]
7546pub fn habit_text(name: &str, value: f64, unit: &str, source: &str) -> String {
7547 let unit = unit.trim();
7548 let source = source.trim();
7549 let mut text = format!("habit {} stands at {}", name.trim(), trim_num(value));
7550 if !unit.is_empty() {
7551 text.push(' ');
7552 text.push_str(unit);
7553 }
7554 if !source.is_empty() {
7555 text.push_str(&format!(" ({source})"));
7556 }
7557 text.push('.');
7558 text
7559}
7560
7561fn reading_of(atom: &Value) -> Option<Reading> {
7562 if atom.get("kind").and_then(Value::as_str) != Some("habit") {
7563 return None;
7564 }
7565 let h = atom.get("habit")?;
7566 Some(Reading {
7567 name: h.get("name")?.as_str()?.to_string(),
7568 value: h.get("value")?.as_f64()?,
7569 unit: h
7570 .get("unit")
7571 .and_then(Value::as_str)
7572 .unwrap_or("")
7573 .to_string(),
7574 source: h
7575 .get("source")
7576 .and_then(Value::as_str)
7577 .unwrap_or("")
7578 .to_string(),
7579 every_s: h
7580 .get("every_s")
7581 .and_then(Value::as_i64)
7582 .unwrap_or(HABIT_EVERY_S),
7583 was: h.get("was").and_then(Value::as_f64),
7584 was_ts: h.get("was_ts").and_then(Value::as_str).map(str::to_string),
7585 id: atom.get("id").and_then(Value::as_str).map(str::to_string),
7586 ts: atom.get("ts").and_then(Value::as_str).map(str::to_string),
7587 due_at: atom
7588 .get("due_at")
7589 .and_then(Value::as_str)
7590 .map(str::to_string),
7591 })
7592}
7593
7594#[must_use]
7596pub fn readings_of(atoms: &[Value]) -> Vec<Reading> {
7597 let mut rows: Vec<Reading> = atoms.iter().filter_map(reading_of).collect();
7598 rows.sort_by(|a, b| a.name.cmp(&b.name).then(b.ts.cmp(&a.ts)));
7599 rows.dedup_by(|a, b| a.name == b.name);
7600 rows
7601}
7602
7603pub fn habits() -> Result<Vec<Reading>> {
7605 let client = pack()?;
7606 let atoms = atoms_lean(&client, &client.workspace()).context("habit: GET /v1/atoms failed")?;
7607 Ok(readings_of(&atoms))
7608}
7609
7610pub fn habit(
7614 name: &str,
7615 value: f64,
7616 unit: &str,
7617 every_s: i64,
7618 source: &str,
7619) -> Result<(Value, Option<Reading>)> {
7620 let name = name.trim();
7621 if name.is_empty() {
7622 bail!("habit: a reading needs a name");
7623 }
7624 if !value.is_finite() {
7625 bail!("habit: {value} is not a reading");
7626 }
7627 let client = pack()?;
7628 let workspace = client.workspace();
7629 let atoms = atoms_lean(&client, &workspace).context("habit: GET /v1/atoms failed")?;
7630 let prev = readings_of(&atoms).into_iter().find(|r| r.name == name);
7631 let now = now_utc();
7632 let mut atom = atom_body("habit", &habit_text(name, value, unit, source), &workspace);
7633 add_entities(&mut atom, [format!("{HABIT_ENTITY}{name}")]);
7634 if let Some(due) = stamp_after(&now, every_s) {
7635 atom["due_at"] = Value::String(due);
7636 }
7637 atom["habit"] = serde_json::json!({
7638 "name": name,
7639 "value": value,
7640 "unit": unit.trim(),
7641 "source": source.trim(),
7642 "every_s": every_s,
7643 "was": prev.as_ref().map(|p| p.value),
7644 "was_ts": prev.as_ref().and_then(|p| p.ts.clone()),
7645 });
7646 if let Some(id) = prev.as_ref().and_then(|p| p.id.clone()) {
7647 atom["supersedes"] = Value::Array(vec![Value::String(id)]);
7648 }
7649 let body = client
7650 .post_atom(&atom)
7651 .context("habit: POST /v1/atoms failed")?;
7652 Ok((body, prev))
7653}
7654
7655#[must_use]
7658pub fn format_change(r: &Reading, now: &str) -> String {
7659 match r.was {
7660 Some(was) => {
7661 let d = r.value - was;
7662 let sign = if d >= 0.0 { "+" } else { "" };
7663 format!(
7664 "{sign}{} since {} ({})",
7665 trim_num(d),
7666 trim_num(was),
7667 age_of(r.was_ts.as_deref(), now)
7668 )
7669 }
7670 None => "first reading".to_string(),
7671 }
7672}
7673
7674#[must_use]
7677pub fn format_readings(rows: &[Reading], now: &str) -> String {
7678 rows.iter()
7679 .map(|r| {
7680 let due = match r.due_at.as_deref() {
7681 Some(d) if d <= now => format!("next reading late ({})", age_of(Some(d), now)),
7682 Some(d) => format!("next reading {}", age_of(Some(d), now)),
7683 None => "no cadence".to_string(),
7684 };
7685 format!(
7686 "{}\t{}{}{}\t{}\t{}\t{}\t{}\n",
7687 r.name,
7688 trim_num(r.value),
7689 if r.unit.is_empty() { "" } else { " " },
7690 r.unit,
7691 format_change(r, now),
7692 age_of(r.ts.as_deref(), now),
7693 due,
7694 r.source
7695 )
7696 })
7697 .collect()
7698}
7699
7700pub fn format_due(atoms: &[Value]) -> String {
7701 atoms
7702 .iter()
7703 .map(|a| {
7704 format!(
7705 "{} {} {} {}
7706",
7707 a["due_at"]
7708 .as_str()
7709 .filter(|d| !d.is_empty())
7710 .unwrap_or("unreviewed"),
7711 a["kind"].as_str().unwrap_or(""),
7712 a["id"].as_str().unwrap_or("-"),
7713 a["text"].as_str().unwrap_or("")
7714 )
7715 })
7716 .collect()
7717}
7718
7719pub fn graded(id: &str, recalled: bool) -> Result<Value> {
7721 let id = id.trim();
7722 if id.is_empty() {
7723 bail!("graded: an atom id is required");
7724 }
7725 let client = pack()?;
7726 client
7727 .grade(&client.workspace(), id, recalled)
7728 .with_context(|| format!("graded: POST /v1/grade failed for {id}"))
7729}
7730
7731#[must_use]
7733pub fn now_utc() -> String {
7734 let secs = std::time::SystemTime::now()
7735 .duration_since(std::time::UNIX_EPOCH)
7736 .map(|d| d.as_secs())
7737 .unwrap_or(0);
7738 let days = secs / 86_400;
7739 let rem = secs % 86_400;
7740 let z = days as i64 + 719_468;
7742 let era = z.div_euclid(146_097);
7743 let doe = z.rem_euclid(146_097);
7744 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
7745 let y = yoe + era * 400;
7746 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
7747 let mp = (5 * doy + 2) / 153;
7748 let d = doy - (153 * mp + 2) / 5 + 1;
7749 let m = if mp < 10 { mp + 3 } else { mp - 9 };
7750 let y = if m <= 2 { y + 1 } else { y };
7751 format!(
7752 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
7753 rem / 3600,
7754 rem % 3600 / 60,
7755 rem % 60
7756 )
7757}
7758
7759pub fn run_fed(bin: &str, args: &[impl AsRef<str>], input: &str) -> Result<Said> {
7761 use std::io::Write;
7762 use std::process::{Command, Stdio};
7763 let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
7764 let mut cmd = Command::new(path);
7765 for a in args {
7766 cmd.arg(a.as_ref());
7767 }
7768 let mut child = cmd
7769 .stdin(Stdio::piped())
7770 .stdout(Stdio::piped())
7771 .stderr(Stdio::piped())
7772 .spawn()
7773 .with_context(|| format!("{bin}: could not start"))?;
7774 if let Some(mut stdin) = child.stdin.take() {
7775 stdin.write_all(input.as_bytes())?;
7776 }
7777 let out = child.wait_with_output()?;
7778 let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
7779 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
7780 if !out.status.success() {
7781 let why = if stderr.trim().is_empty() {
7782 stdout.trim().to_string()
7783 } else {
7784 stderr.trim().to_string()
7785 };
7786 bail!("{bin} exited {}: {why}", out.status);
7787 }
7788 Ok(Said { stdout, stderr })
7789}
7790
7791pub fn work_id(name: &str) -> String {
7794 let name = name.trim();
7795 if name.len() == 32 && name.bytes().all(|b| b.is_ascii_hexdigit()) {
7796 return name.to_ascii_lowercase();
7797 }
7798 const OFFSET: u128 = 0x6c62_272e_07bb_0142_62b8_2175_6295_c58d;
7799 const PRIME: u128 = 0x0000_0000_0100_0000_0000_0000_0000_013b;
7800 let mut h = OFFSET;
7801 for b in name.bytes() {
7802 h ^= u128::from(b);
7803 h = h.wrapping_mul(PRIME);
7804 }
7805 format!("{h:032x}")
7806}
7807
7808pub fn node_for(issue: &str) -> Result<String> {
7811 let id = work_id(issue);
7812 if id != issue.trim() && run_captured("claimdag", &["get", &id]).is_err() {
7813 run_captured(
7814 "claimdag",
7815 &["upsert", "--id", &id, "--summary", issue.trim()],
7816 )
7817 .with_context(|| format!("claim: could not mint a node for {issue}"))?;
7818 }
7819 Ok(id)
7820}
7821
7822pub fn packset_island(cue: &str, fire: bool) -> Result<Value> {
7825 packset_island_as(cue, fire, None)
7826}
7827
7828pub fn packset_island_as(cue: &str, fire: bool, lens: Option<&str>) -> Result<Value> {
7832 let cue = cue.trim();
7833 if cue.is_empty() {
7834 bail!("island: pass the task or question at hand");
7835 }
7836 let client = pack()?;
7837 let workspace = client.workspace();
7838 let lens = lens
7839 .map(str::trim)
7840 .filter(|l| !l.is_empty())
7841 .map(str::to_lowercase);
7842 let mut body = client
7843 .activate_as(&workspace, cue, 24, fire, lens.as_deref())
7844 .context("island: GET /v1/activate failed")?;
7845 if body["fired"].as_u64().unwrap_or(0) > 0 {
7846 match record_fire(cue, lens.as_deref(), &body) {
7847 Ok(id) => body["trace"] = Value::String(id),
7848 Err(err) => body["trace_error"] = Value::String(err.to_string()),
7849 }
7850 }
7851 Ok(body)
7852}
7853
7854fn record_fire(cue: &str, lens: Option<&str>, body: &Value) -> Result<String> {
7857 let fired = body["fired"].as_u64().unwrap_or(0);
7858 let who = lens.unwrap_or("seat");
7859 let ids: Vec<String> = body["island"]
7860 .as_array()
7861 .into_iter()
7862 .flatten()
7863 .filter_map(|row| row.get("id").and_then(Value::as_str).map(str::to_string))
7864 .take(8)
7865 .collect();
7866 let mut nonce = 0xcbf29ce484222325u64;
7867 for part in [cue, who].into_iter().chain(ids.iter().map(String::as_str)) {
7868 for byte in part.as_bytes() {
7869 nonce ^= u64::from(*byte);
7870 nonce = nonce.wrapping_mul(0x100000001b3);
7871 }
7872 }
7873 let text = format!(
7874 "Fire {:08x} under {who} strengthened {fired} links.",
7875 nonce as u32
7876 );
7877 let client = pack()?;
7878 let workspace = client.workspace();
7879 let mut atom = atom_body("trace", &text, &workspace);
7880 add_entities(&mut atom, ids);
7881 let posted = client
7882 .post_atom(&atom)
7883 .context("trace: POST /v1/atoms failed")?;
7884 Ok(posted
7885 .get("id")
7886 .and_then(Value::as_str)
7887 .unwrap_or("")
7888 .to_string())
7889}
7890
7891pub fn packset_hubs(limit: usize) -> Result<Value> {
7894 let client = pack()?;
7895 let workspace = client.workspace();
7896 client
7897 .hubs(&workspace, limit)
7898 .context("hubs: GET /v1/hubs failed")
7899}
7900
7901pub fn conflicts(limit: usize) -> Result<String> {
7916 if which::which("landscape").is_err() {
7917 bail!(
7918 "conflicts: `landscape` is not on PATH; it is the optional habitat that reads the pack's geometry (leidarljos/landscape)"
7919 );
7920 }
7921 let client = pack()?;
7922 let said = match run_captured(
7923 "landscape",
7924 &[
7925 "--atoms",
7926 client.base(),
7927 "--workspace",
7928 &client.workspace(),
7929 "--conflicts",
7930 ],
7931 ) {
7932 Ok(said) => said,
7933 Err(e) if e.to_string().contains("at least two") => {
7936 return Ok(
7937 "fewer than two memories with embeddings in the pack; conflicts by geometry need the encoder (`packset doctor` shows it)\n"
7938 .to_string(),
7939 );
7940 }
7941 Err(e) => return Err(e),
7942 };
7943 let v: Value =
7944 serde_json::from_str(&said.stdout).context("conflicts: landscape printed no JSON")?;
7945 let now = now_utc();
7946 let atoms = atoms_lean(&client, &client.workspace()).unwrap_or_default();
7947 let stamp_of = |id: &str| -> Option<String> {
7948 atoms
7949 .iter()
7950 .find(|a| a["id"].as_str() == Some(id))
7951 .and_then(|a| a["ts"].as_str().map(str::to_string))
7952 };
7953 let recalled = |id: &str| -> bool {
7956 atoms
7957 .iter()
7958 .find(|a| a["id"].as_str() == Some(id))
7959 .is_none_or(reviewable)
7960 };
7961 let mut out = String::new();
7962 for pair in v["pairs"]
7963 .as_array()
7964 .into_iter()
7965 .flatten()
7966 .filter(|p| {
7967 recalled(p["a"].as_str().unwrap_or("")) && recalled(p["b"].as_str().unwrap_or(""))
7968 })
7969 .take(limit)
7970 {
7971 let a = pair["a"].as_str().unwrap_or("-");
7972 let b = pair["b"].as_str().unwrap_or("-");
7973 out.push_str(&format!(
7974 "pass {:.3}\n {a} {} {}\n {b} {} {}\n",
7975 pair["barrier"].as_f64().unwrap_or(0.0),
7976 age_of(stamp_of(a).as_deref(), &now),
7977 pair["a_text"].as_str().unwrap_or("").trim(),
7978 age_of(stamp_of(b).as_deref(), &now),
7979 pair["b_text"].as_str().unwrap_or("").trim()
7980 ));
7981 }
7982 let n = v["pairs"].as_array().map_or(0, Vec::len);
7983 out.push_str(&format!(
7984 "{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",
7985 v["sigma"].as_f64().unwrap_or(0.0)
7986 ));
7987 Ok(out)
7988}
7989
7990pub fn packset_consolidate(apply: bool) -> Result<Value> {
7993 let client = pack()?;
7994 let workspace = client.workspace();
7995 client
7996 .consolidate(&workspace, apply)
7997 .context("consolidate: POST /v1/consolidate failed")
7998}
7999
8000pub fn format_consolidation(body: &Value) -> String {
8003 let mut out = String::new();
8004 for pair in body["pairs"].as_array().into_iter().flatten() {
8005 out.push_str(&format!(
8006 "closes {} {}\n for {} {}\n",
8007 pair["old"].as_str().unwrap_or("-"),
8008 pair["old_text"].as_str().unwrap_or("").trim(),
8009 pair["new"].as_str().unwrap_or("-"),
8010 pair["new_text"].as_str().unwrap_or("").trim()
8011 ));
8012 }
8013 let closed = body["closed"].as_u64().unwrap_or(0);
8014 let live = body["live"].as_u64().unwrap_or(0);
8015 if body["applied"].as_bool().unwrap_or(false) {
8016 out.push_str(&format!("{closed} of {live} live memories closed\n"));
8017 } else {
8018 out.push_str(&format!(
8019 "{closed} of {live} live memories would close; `ljos consolidate --apply` closes them\n"
8020 ));
8021 }
8022 out
8023}
8024
8025pub fn format_hubs(body: &Value) -> String {
8027 let mut out = String::new();
8028 for hub in body["hubs"]
8029 .as_array()
8030 .into_iter()
8031 .flatten()
8032 .filter(|a| reviewable(a))
8033 {
8034 out.push_str(&format!(
8035 "{:.4}\t{}\t{}\t{}\n",
8036 hub["score"].as_f64().unwrap_or(0.0),
8037 hub["links"].as_u64().unwrap_or(0),
8038 hub["id"].as_str().unwrap_or("-"),
8039 hub["text"].as_str().unwrap_or("")
8040 ));
8041 }
8042 out
8043}
8044
8045#[must_use]
8052pub fn island_reading(body: &Value) -> String {
8053 let lens = body["as"].as_str().unwrap_or("").trim();
8054 let fired = body["fired"].as_u64().unwrap_or(0);
8055 let held = body["held"].as_bool().unwrap_or(false);
8056 let weak = body["weak"].as_bool().unwrap_or(false);
8057 let rows = body["island"].as_array().is_some_and(|a| !a.is_empty());
8058 if !rows && !weak && fired == 0 && !held && lens.is_empty() {
8059 return String::new();
8060 }
8061 let mut out = String::new();
8062 if lens.is_empty() {
8063 out.push_str(
8064 "Seat island. Activation is spread from search seeds along links. It is not a relevance rank.\n",
8065 );
8066 } else {
8067 out.push_str(&format!(
8068 "Persona {lens} island. The spread follows the weights that persona fired, not the seat's. It is not a relevance rank.\n"
8069 ));
8070 }
8071 if weak {
8072 out.push_str(
8073 "Not fired: fewer than two seeds that two scorers agreed on, so firing would wire the wrong links.\n",
8074 );
8075 } else if held {
8076 out.push_str(
8077 "Not fired: this cue already fired inside the hour, so the weights were left as they were.\n",
8078 );
8079 } else if fired > 0 {
8080 let who = if lens.is_empty() { "the seat" } else { lens };
8081 out.push_str(&format!(
8082 "Fired: {fired} links gained weight under {who}. The next walk of this cue follows those links. Fire only after the island was used.\n"
8083 ));
8084 if let Some(id) = body["trace"].as_str().filter(|s| !s.is_empty()) {
8085 out.push_str(&format!(
8086 "Recorded as trace {id}: the links this fire strengthened.\n"
8087 ));
8088 } else if let Some(err) = body["trace_error"].as_str() {
8089 out.push_str(&format!("The fire was not recorded: {err}\n"));
8090 }
8091 } else {
8092 out.push_str(
8093 "Not fired. Pass fire after the island is used, so the links that served gain weight. Firing on the first look wires whatever the spread touched.\n",
8094 );
8095 }
8096 out
8097}
8098
8099pub fn format_island(body: &Value) -> String {
8101 let mut out = island_reading(body);
8102 let now = now_utc();
8103 if body["weak"].as_bool().unwrap_or(false) {
8104 out.push_str(&format!(
8105 "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",
8106 body["agreed_seeds"].as_u64().unwrap_or(0),
8107 if body["agreed_seeds"].as_u64().unwrap_or(0) == 1 { "" } else { "s" },
8108 if body["dense"].as_bool().unwrap_or(true) { "" } else { "; the encoder is down, ranking is lexical only" }
8109 ));
8110 }
8111 for atom in body["island"]
8112 .as_array()
8113 .into_iter()
8114 .flatten()
8115 .filter(|a| reviewable(a))
8116 {
8117 out.push_str(&format!(
8118 "{:.3}\t{}\t{}\t{}\t{}\n",
8119 atom["activation"].as_f64().unwrap_or(0.0),
8120 if atom["seed"].as_bool().unwrap_or(false) {
8121 "seed"
8122 } else {
8123 " "
8124 },
8125 atom["id"].as_str().unwrap_or("-"),
8126 age_of(atom["ts"].as_str(), &now),
8127 atom["text"].as_str().unwrap_or("")
8128 ));
8129 }
8130 out
8131}
8132
8133pub fn packset_search(query: &str) -> Result<Vec<Hit>> {
8134 packset_search_opts(query, 10, false)
8135}
8136
8137pub fn packset_search_opts(query: &str, limit: u32, rerank: bool) -> Result<Vec<Hit>> {
8142 packset_search_as_of(query, limit, None, rerank)
8143}
8144
8145pub fn packset_search_as_of(
8151 query: &str,
8152 limit: u32,
8153 as_of: Option<&str>,
8154 rerank: bool,
8155) -> Result<Vec<Hit>> {
8156 let q = query.trim();
8157 if q.is_empty() {
8158 bail!("search: empty query");
8159 }
8160 let as_of = as_of.map(str::trim).filter(|s| !s.is_empty());
8161 let stamp = match as_of {
8162 Some(at) if days_of_stamp(Some(at)).is_none() => {
8163 bail!("search: --as-of {at:?} is not a date; write YYYY-MM-DD or RFC 3339")
8164 }
8165 Some(at) if at.len() == 10 => Some(format!("{at}T00:00:00.000Z")),
8167 Some(at) => Some(at.to_string()),
8168 None => None,
8169 };
8170 with_writer(|| {
8171 let client = pack()?;
8172 let workspace = client.workspace();
8173 client
8174 .search_opts(&workspace, q, limit, stamp.as_deref(), rerank)
8175 .context("search: GET /v1/search failed")
8176 })
8177}
8178
8179fn gen_of(get_output: &str) -> Option<u64> {
8182 get_output
8183 .split_whitespace()
8184 .find_map(|w| w.strip_prefix("gen="))
8185 .and_then(|g| g.parse().ok())
8186}
8187
8188fn live_gen(id: &str, gen: Option<u64>) -> Result<u64> {
8192 if let Some(g) = gen {
8193 return Ok(g);
8194 }
8195 let got = run_captured("claimdag", &["get", id])?.stdout;
8196 gen_of(&got).ok_or_else(|| {
8197 anyhow::anyhow!("complete: no generation on the claim graph's line for {id}: {got}")
8198 })
8199}
8200
8201#[must_use]
8204pub fn held_by_another_message(node: &str, assignee: &str, hold: &Hold, running: &str) -> String {
8205 format!(
8206 "claim: {node} is held by another ({}, seat {}, {running}, since {}), not by {assignee} (this one). That conversation frees it with `ljos release {node}` or `ljos complete {node} --gen` from its sitting; when it is gone, `ljos release {node} --assignee {}` releases it under the name it held",
8207 hold.assignee,
8208 hold.seat,
8209 hold.since,
8210 hold.assignee
8211 )
8212}
8213
8214fn holder_of(get_output: &str) -> Option<String> {
8215 get_output
8216 .split_whitespace()
8217 .find_map(|w| w.strip_prefix("assignee="))
8218 .filter(|h| h.len() == 32 && *h != "00000000000000000000000000000000")
8219 .map(str::to_string)
8220}
8221
8222fn tracker_claim_needs_force(text: &str) -> bool {
8234 text.contains("pass --force") || text.contains("claimed by")
8235}
8236
8237fn stamp_tracker_claim(node: &str, assignee: &str, force: bool) -> Result<Said> {
8238 if force {
8239 run_captured_as("vissue", &["claim", node, "--force"], Some(assignee))
8240 } else {
8241 run_captured_as("vissue", &["claim", node], Some(assignee))
8242 }
8243}
8244
8245fn stamp_tracker(node: &str, assignee: &str) -> Result<Option<String>> {
8246 if run_captured("vissue", &["show", node, "--json"]).is_err() {
8247 return Ok(None);
8248 }
8249 let claimed = match stamp_tracker_claim(node, assignee, false) {
8250 Ok(said) => Ok(said),
8251 Err(e) => {
8252 let text = e.to_string();
8253 let after_reopen = if text.contains("already DONE")
8257 || text.contains("already CANCELLED")
8258 {
8259 run_captured("vissue", &["update", node, "-s", "STARTED"]).with_context(|| {
8260 format!(
8261 "claim: the claim graph took {node} but the tracker would not reopen {node} to STARTED under {assignee}"
8262 )
8263 })?;
8264 stamp_tracker_claim(node, assignee, false)
8265 } else {
8266 Err(e)
8267 };
8268 match after_reopen {
8269 Ok(said) => Ok(said),
8270 Err(e2) if tracker_claim_needs_force(&e2.to_string()) => {
8271 stamp_tracker_claim(node, assignee, true)
8272 }
8273 Err(e2) => Err(e2),
8274 }
8275 }
8276 };
8277 claimed
8278 .map(|_| Some(format!("tracker: {node} STARTED under {assignee}")))
8279 .with_context(|| {
8280 format!(
8281 "claim: the claim graph took {node} but the tracker refused to stamp it under {assignee}; `ljos release {node} --assignee {assignee}` frees the graph, or `vissue claim {node} --force` takes the tracker over"
8282 )
8283 })
8284}
8285
8286fn with_tracker(said: String, node: &str, assignee: &str) -> Result<String> {
8289 let mut out = said;
8290 if let Some(line) = stamp_tracker(node, assignee)? {
8291 if !out.is_empty() && !out.ends_with('\n') {
8292 out.push('\n');
8293 }
8294 out.push_str(&line);
8295 out.push('\n');
8296 }
8297 Ok(out)
8298}
8299
8300pub fn claim(node: &str, assignee: &str) -> Result<String> {
8309 let id = node_for(node)?;
8310 let actor = work_id(&occupancy_scope(assignee, node));
8311 match run_captured("claimdag", &["claim", &id, "--assignee", &actor]) {
8312 Ok(said) => {
8313 write_hold(&actor, assignee, node);
8314 with_tracker(said.stdout, node, assignee)
8315 }
8316 Err(e) => {
8317 let text = e.to_string();
8318 if ["status done", "status failed", "status cancelled"]
8321 .iter()
8322 .any(|s| text.contains(s))
8323 {
8324 run_captured("claimdag", &["reopen", &id, "--actor", &actor])?;
8325 let said = run_captured("claimdag", &["claim", &id, "--assignee", &actor])?;
8326 write_hold(&actor, assignee, node);
8327 return with_tracker(
8328 format!("reopened a finished session node\n{}", said.stdout),
8329 node,
8330 assignee,
8331 );
8332 }
8333 if text.contains("status claimed") {
8336 let got = run_captured("claimdag", &["get", &id])?.stdout;
8337 return match holder_of(&got) {
8338 Some(holder) if holder == actor => {
8339 let renewed = run_captured("claimdag", &["renew", &id, "--actor", &actor])
8340 .map(|s| s.stdout)
8341 .unwrap_or_default();
8342 write_hold(&actor, assignee, node);
8343 with_tracker(
8344 format!("already held by {assignee}; the sitting resumes\n{renewed}"),
8345 node,
8346 assignee,
8347 )
8348 }
8349 Some(holder) => match read_hold(&holder) {
8350 Some(h) if h.seat == seat_name() && !hold_alive(&h) => {
8355 run_captured("claimdag", &["release", &id, "--actor", &holder])?;
8356 drop_hold(&holder);
8357 let said =
8358 run_captured("claimdag", &["claim", &id, "--assignee", &actor])?;
8359 write_hold(&actor, assignee, node);
8360 with_tracker(
8361 format!(
8362 "took over from {}, this seat's conversation, gone (held since {})\n{}",
8363 h.assignee, h.since, said.stdout
8364 ),
8365 node,
8366 assignee,
8367 )
8368 }
8369 Some(h) => bail!(
8370 "{}",
8371 held_by_another_message(
8372 node,
8373 assignee,
8374 &h,
8375 if hold_alive(&h) {
8376 "still running"
8377 } else {
8378 "its runner is gone"
8379 }
8380 )
8381 ),
8382 None => bail!(
8383 "claim: {node} is held by another conversation, not by {assignee} (this one; `ljos seat` says where the name came from), and no record on this host names it. That conversation frees it with `ljos release {node}` or `ljos complete {node} --gen` from its sitting; a conversation that is gone is released with `ljos release {node} --assignee NAME` under the name it held"
8384 ),
8385 },
8386 None => Err(e),
8387 };
8388 }
8389 if !text.contains("assignee busy") {
8390 return Err(e);
8391 }
8392 let held: Vec<String> = text
8393 .split_whitespace()
8394 .filter(|w| w.len() == 32 && w.chars().all(|c| c.is_ascii_hexdigit()))
8395 .map(str::to_string)
8396 .collect();
8397 let mut lines = vec![format!(
8398 "claim: {assignee} already holds a live node; one live claim per assignee."
8399 )];
8400 for hex in &held {
8401 let name = run_captured("claimdag", &["get", hex])
8402 .ok()
8403 .and_then(|s| {
8404 s.stdout
8405 .lines()
8406 .next()
8407 .and_then(|l| l.split_whitespace().last())
8408 .map(str::to_string)
8409 })
8410 .unwrap_or_else(|| hex.clone());
8411 lines.push(format!(
8412 " holds {name}: `ljos complete {name} --status done` finishes it, \
8413 `ljos release {name} --assignee {assignee}` hands it back"
8414 ));
8415 }
8416 bail!("{}", lines.join("\n"))
8417 }
8418 }
8419}
8420
8421pub fn release(node: &str, assignee: &str) -> Result<String> {
8428 let id = node_for(node)?;
8429 let actor = work_id(&occupancy_scope(assignee, node));
8430 let said = run_captured("claimdag", &["release", &id, "--actor", &actor])?;
8431 drop_hold(&actor);
8432 drop_playbook(node);
8433 Ok(said.stdout)
8434}
8435
8436#[derive(Debug, Clone, PartialEq, Eq)]
8442pub struct Hold {
8443 pub assignee: String,
8444 pub seat: String,
8445 pub pid: u32,
8446 pub comm: String,
8447 pub since: String,
8448}
8449
8450fn hold_record_path(actor: &str) -> PathBuf {
8451 runtime_dir().join(format!("hold-{actor}"))
8452}
8453
8454fn conversation_process() -> (u32, String) {
8459 let chain = ancestry();
8460 chain
8461 .iter()
8462 .skip(1)
8463 .find(|(_, comm)| !WRAPPERS.contains(&comm.as_str()))
8464 .or_else(|| chain.get(1))
8465 .cloned()
8466 .unwrap_or((std::process::id(), String::new()))
8467}
8468
8469fn write_hold(actor: &str, assignee: &str, node: &str) {
8470 let (pid, comm) = conversation_process();
8471 let path = hold_record_path(actor);
8472 if let Some(dir) = path.parent() {
8473 let _ = std::fs::create_dir_all(dir);
8474 }
8475 let _ = std::fs::write(
8478 path,
8479 format!(
8480 "{assignee}\n{}\n{pid}\n{comm}\n{}\n{node}\n",
8481 seat_name(),
8482 now_utc()
8483 ),
8484 );
8485}
8486
8487fn held_from_records(holders: &[String]) -> Option<String> {
8491 let pids: Vec<String> = ancestry().iter().map(|(p, _)| p.to_string()).collect();
8492 let mut best: Option<(String, String)> = None;
8493 for entry in std::fs::read_dir(runtime_dir()).ok()?.flatten() {
8494 if !entry.file_name().to_string_lossy().starts_with("hold-") {
8495 continue;
8496 }
8497 let Ok(text) = std::fs::read_to_string(entry.path()) else {
8498 continue;
8499 };
8500 let lines: Vec<&str> = text.lines().map(str::trim).collect();
8501 let (Some(holder), Some(pid), Some(at), Some(node)) =
8502 (lines.first(), lines.get(2), lines.get(4), lines.get(5))
8503 else {
8504 continue;
8505 };
8506 let ours = holders.iter().any(|h| h == holder) || pids.iter().any(|p| p == pid);
8507 if ours && !node.is_empty() && best.as_ref().is_none_or(|(t, _)| *at > t.as_str()) {
8508 best = Some(((*at).to_string(), (*node).to_string()));
8509 }
8510 }
8511 best.map(|(_, node)| node)
8512}
8513
8514fn drop_hold(actor: &str) {
8515 let _ = std::fs::remove_file(hold_record_path(actor));
8516}
8517
8518fn read_hold(actor: &str) -> Option<Hold> {
8519 let text = std::fs::read_to_string(hold_record_path(actor)).ok()?;
8520 let mut lines = text.lines();
8521 Some(Hold {
8522 assignee: lines.next()?.to_string(),
8523 seat: lines.next()?.to_string(),
8524 pid: lines.next()?.trim().parse().ok()?,
8525 comm: lines.next()?.to_string(),
8526 since: lines.next()?.to_string(),
8527 })
8528}
8529
8530fn hold_alive(hold: &Hold) -> bool {
8534 match parent_and_comm(hold.pid) {
8535 Some((_, comm)) => comm == hold.comm,
8536 None => !cfg!(target_os = "linux"),
8537 }
8538}
8539
8540fn revision_note(body: &Value) -> String {
8544 match body["supersedes"].as_array().map(Vec::len).unwrap_or(0) {
8545 0 => String::new(),
8546 1 => "; revises 1 earlier memory, now closed".to_string(),
8547 n => format!("; revises {n} earlier memories, now closed"),
8548 }
8549}
8550
8551pub fn tracker_show_json(id: &str) -> Result<Value> {
8557 let layout = vissue_core::Layout::resolve(None, None).map_err(anyhow::Error::from)?;
8558 let found = vissue_core::Router::load(layout)
8559 .map_err(anyhow::Error::from)?
8560 .find_by_id(id)
8561 .map_err(anyhow::Error::from)?;
8562 vissue_core::agent::show_json(&found.layout, id).map_err(anyhow::Error::from)
8563}
8564
8565#[must_use]
8568pub fn is_decision(v: &Value) -> bool {
8569 let tagged = v["tags"]
8570 .as_array()
8571 .is_some_and(|t| t.iter().any(|x| x.as_str() == Some("decision")));
8572 let typed = v["properties"]["TYPE"].as_str() == Some("decision");
8573 let listed = v["body"]
8574 .as_str()
8575 .is_some_and(|b| b.lines().any(|l| l.trim_start().starts_with("Options:")));
8576 tagged || typed || listed
8577}
8578
8579fn issue_title(issue: &str) -> Result<String> {
8581 let v = tracker_show_json(issue)?;
8582 Ok(v.get("title")
8583 .and_then(Value::as_str)
8584 .unwrap_or(issue)
8585 .to_string())
8586}
8587
8588#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
8590pub struct Event {
8591 pub days: i64,
8593 pub clock: String,
8596 pub source: &'static str,
8598 pub text: String,
8600}
8601
8602pub fn timeline_events(issue: &str, limit: usize) -> Result<Vec<Event>> {
8612 Ok(timeline_of(issue, limit)?.1)
8613}
8614
8615fn timeline_of(issue: &str, limit: usize) -> Result<(String, Vec<Event>)> {
8616 let v = tracker_show_json(issue)?;
8617 let title = v["title"].as_str().unwrap_or(issue).to_string();
8618 let mut events = tracker_events(&v);
8619 for accession in v["deeds"].as_array().into_iter().flatten() {
8620 let Some(accession) = accession.as_str() else {
8621 continue;
8622 };
8623 if let Ok(said) = run_captured("deedar", &["evidence", accession]) {
8624 if let Some(ev) = deed_event(accession, &said.stdout, local_offset) {
8625 events.push(ev);
8626 }
8627 }
8628 }
8629 if let Ok(island) = packset_island(&title, false) {
8630 for atom in island["island"]
8631 .as_array()
8632 .into_iter()
8633 .flatten()
8634 .filter(|a| reviewable(a))
8635 .take(8)
8636 {
8637 if let Some((days, clock)) = stamp_key(atom["ts"].as_str().map(local_stamp).as_deref())
8638 {
8639 events.push(Event {
8640 days,
8641 clock,
8642 source: "memory",
8643 text: format!(
8644 "[{}] {}",
8645 atom["kind"].as_str().unwrap_or("claim"),
8646 atom["text"].as_str().unwrap_or("").trim()
8647 ),
8648 });
8649 }
8650 }
8651 }
8652 events.sort_by(|a, b| (a.days, &a.clock).cmp(&(b.days, &b.clock)));
8653 let skip = events.len().saturating_sub(limit);
8654 Ok((title, events[skip..].to_vec()))
8655}
8656
8657pub fn timeline(issue: &str, limit: usize) -> Result<String> {
8670 let (title, events) = timeline_of(issue, limit)?;
8671 Ok(format!(
8672 "timeline of {issue}: {title}
8673{}",
8674 format_events(&events, &now_local())
8675 ))
8676}
8677
8678fn local_offset(secs: i64) -> i64 {
8681 use chrono::{Local, Offset, TimeZone};
8682 Local
8683 .timestamp_opt(secs, 0)
8684 .single()
8685 .map_or(0, |t| i64::from(t.offset().fix().local_minus_utc()))
8686}
8687
8688fn now_local() -> String {
8691 chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string()
8692}
8693
8694fn local_stamp(ts: &str) -> String {
8697 chrono::DateTime::parse_from_rfc3339(ts.trim()).map_or_else(
8698 |_| ts.to_string(),
8699 |t| {
8700 t.with_timezone(&chrono::Local)
8701 .format("%Y-%m-%dT%H:%M")
8702 .to_string()
8703 },
8704 )
8705}
8706
8707fn tracker_events(v: &Value) -> Vec<Event> {
8710 let mut events = Vec::new();
8711 let mut push = |stamp: Option<&str>, source: &'static str, text: String| {
8712 if let Some((days, clock)) = stamp_key(stamp) {
8713 events.push(Event {
8714 days,
8715 clock,
8716 source,
8717 text,
8718 });
8719 }
8720 };
8721 push(
8722 v["properties"]["CREATED"].as_str(),
8723 "tracker",
8724 "created".to_string(),
8725 );
8726 if let Some(by) = v["claimed_by"].as_str() {
8727 push(
8728 v["claimed_at"].as_str(),
8729 "tracker",
8730 format!("claimed by {by}"),
8731 );
8732 }
8733 if let Some(d) = v["properties"]["DEADLINE"].as_str() {
8734 push(
8735 v["properties"]["DEADLINE"].as_str(),
8736 "tracker",
8737 format!("DEADLINE {d}"),
8738 );
8739 }
8740 if let Some(s) = v["properties"]["SCHEDULED"].as_str() {
8741 push(
8742 v["properties"]["SCHEDULED"].as_str(),
8743 "tracker",
8744 format!("SCHEDULED {s}"),
8745 );
8746 }
8747 for e in v["logbook"].as_array().into_iter().flatten().rev() {
8749 let stamp = e["timestamp"].as_str();
8750 if let Some(note) = e["note"].as_str() {
8751 push(stamp, "tracker", format!("note: {}", note.trim()));
8752 } else if let Some(to) = e["to_state"].as_str() {
8753 push(
8754 stamp,
8755 "tracker",
8756 format!("{} -> {to}", e["from_state"].as_str().unwrap_or("-")),
8757 );
8758 }
8759 }
8760 events
8761}
8762
8763fn deed_event(accession: &str, evidence: &str, offset_of: fn(i64) -> i64) -> Option<Event> {
8768 let utc: i64 = evidence
8769 .lines()
8770 .find_map(|l| l.strip_prefix("time="))?
8771 .trim()
8772 .parse()
8773 .ok()?;
8774 let secs = utc + offset_of(utc);
8775 let by = evidence
8776 .lines()
8777 .find_map(|l| l.strip_prefix("producedBy="))
8778 .map(str::trim)
8779 .unwrap_or("-");
8780 Some(Event {
8781 days: secs.div_euclid(86_400),
8782 clock: format!(
8783 "{:02}:{:02}",
8784 secs.rem_euclid(86_400) / 3600,
8785 secs.rem_euclid(86_400) % 3600 / 60
8786 ),
8787 source: "deed",
8788 text: format!("{accession} produced by {by}"),
8789 })
8790}
8791
8792fn stamp_key(stamp: Option<&str>) -> Option<(i64, String)> {
8796 let s = stamp?
8797 .trim()
8798 .trim_start_matches(['[', '<'])
8799 .trim_end_matches([']', '>']);
8800 let days = days_of_stamp(Some(s))?;
8801 let rest = &s[10..];
8802 let clock = rest
8803 .split(['T', ' '])
8804 .find(|t| t.len() >= 5 && t.as_bytes()[2] == b':')
8805 .map(|t| t[..5].to_string())
8806 .unwrap_or_default();
8807 Some((days, clock))
8808}
8809
8810fn format_events(events: &[Event], now: &str) -> String {
8812 let today = days_of_stamp(Some(now)).unwrap_or(0);
8813 let mut out = String::new();
8814 let mut last: Option<i64> = None;
8815 for e in events {
8816 let gap = match last {
8817 None => String::new(),
8818 Some(d) if e.days == d => "same day".to_string(),
8819 Some(d) => format!("+{} d", e.days - d),
8820 };
8821 last = Some(e.days);
8822 out.push_str(&format!(
8823 "{} {} {} {} {} {}
8824",
8825 civil_of_days(e.days),
8826 e.clock,
8827 age_of(Some(&civil_of_days(e.days)), &civil_of_days(today)),
8828 gap,
8829 e.source,
8830 e.text
8831 ));
8832 }
8833 out
8834}
8835
8836fn civil_of_days(days: i64) -> String {
8838 let z = days + 719_468;
8839 let era = z.div_euclid(146_097);
8840 let doe = z.rem_euclid(146_097);
8841 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
8842 let y = yoe + era * 400;
8843 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
8844 let mp = (5 * doy + 2) / 153;
8845 let d = doy - (153 * mp + 2) / 5 + 1;
8846 let m = if mp < 10 { mp + 3 } else { mp - 9 };
8847 let y = if m <= 2 { y + 1 } else { y };
8848 format!("{y:04}-{m:02}-{d:02}")
8849}
8850
8851pub fn sitting(issue: &str, assignee: &str, cards_dir: &Path) -> Result<String> {
8863 sitting_gated(issue, assignee, cards_dir, false, None)
8864}
8865
8866pub fn open_blockers(issue: &str) -> Vec<String> {
8870 let Ok(shown) = tracker_show_json(issue) else {
8871 return Vec::new();
8872 };
8873 let mut out = Vec::new();
8874 for id in shown["blocked_by"]
8875 .as_array()
8876 .into_iter()
8877 .flatten()
8878 .filter_map(Value::as_str)
8879 {
8880 let state = tracker_show_json(id)
8881 .ok()
8882 .and_then(|v| v["state"].as_str().map(str::to_string))
8883 .unwrap_or_else(|| "?".to_string());
8884 if !matches!(state.as_str(), "DONE" | "CANCELLED") {
8885 out.push(format!("{id} ({state})"));
8886 }
8887 }
8888 out
8889}
8890
8891pub fn sitting_gated(
8900 issue: &str,
8901 assignee: &str,
8902 cards_dir: &Path,
8903 anyway: bool,
8904 playbook: Option<&str>,
8905) -> Result<String> {
8906 let mut out = String::new();
8907 let rows = doctor_seat();
8908 out.push_str("== doctor\n");
8909 out.push_str(&format_doctor(&rows));
8910 if !healthy(&rows) {
8911 bail!("{out}sitting: a required habitat does not answer; nothing was claimed");
8912 }
8913 out.push_str("== sync\n");
8916 out.push_str(&sync::sync_repo(true, false).unwrap_or_else(|e| format!("sync: {e:#}\n")));
8917 out.push_str("== cards\n");
8918 out.push_str(&cards(cards_dir)?);
8919 let title = issue_title(issue)?;
8920 let island = packset_island(&title, false)?;
8921 out.push_str("== due\n");
8922 out.push_str(&sitting_due_report(&island)?);
8923 out.push_str(&format!("== island: {title}\n"));
8924 let mut top = island.clone();
8927 if let Some(rows) = top["island"].as_array_mut() {
8928 rows.truncate(8);
8929 }
8930 out.push_str(&format_island(&top));
8931 out.push_str("== blockers\n");
8932 let blockers = open_blockers(issue);
8933 if blockers.is_empty() {
8934 out.push_str("none open; the issue is workable\n");
8935 } else {
8936 out.push_str(&format!("open: {}\n", blockers.join(", ")));
8937 if !anyway {
8938 bail!(
8939 "{out}sitting: {issue} is blocked by {}; finish those first, or `ljos sitting {issue} --anyway` to sit on it regardless. Nothing was claimed",
8940 blockers.join(", ")
8941 );
8942 }
8943 out.push_str("sitting anyway, as asked\n");
8944 }
8945 let decision = tracker_show_json(issue).is_ok_and(|v| is_decision(&v));
8950 let name = match (playbook, decision) {
8951 (None, true) if bound_playbook(issue).is_none() => "company-panel".to_string(),
8952 _ => resolve_sitting_playbook(issue, &title, playbook)?,
8953 };
8954 out.push_str("== playbook\n");
8955 out.push_str(©_playbook(issue, &name)?);
8956 if decision {
8957 out.push_str("== panel\n");
8958 let dir = runtime_dir().join(format!("panel-{issue}"));
8959 match panel(issue, &dir) {
8960 Ok(said) => out.push_str(&format!(
8961 "{issue} is a decision. Run the panel before the work: one subagent per brief, each casts its ballot, then `ljos consensus {issue}`. `ljos finish {issue} --close` refuses with fewer than two ballots.\n{said}"
8962 )),
8963 Err(e) => out.push_str(&format!("{issue} is a decision, and the panel could not be written: {e:#}\n")),
8964 }
8965 }
8966 out.push_str("== recall\n");
8967 out.push_str(&run_captured("vissue", &["recall", issue])?.stdout);
8968 out.push_str("== timeline\n");
8971 out.push_str(&timeline(issue, SITTING_TIMELINE)?);
8972 out.push_str("== claim\n");
8973 out.push_str(&claim(issue, assignee)?);
8974 out.push_str(&persist_tracker(issue, "claimed"));
8975 Ok(out)
8976}
8977
8978pub fn complete(
8995 node: &str,
8996 status: Option<&str>,
8997 assignee: &str,
8998 gen: Option<u64>,
8999) -> Result<String> {
9000 let id = node_for(node)?;
9001 let actor = work_id(&occupancy_scope(assignee, node));
9002 let gen_s = live_gen(&id, gen)?.to_string();
9003 let mut args = vec![
9004 "complete",
9005 id.as_str(),
9006 "--actor",
9007 actor.as_str(),
9008 "--gen",
9009 gen_s.as_str(),
9010 ];
9011 if let Some(s) = status {
9012 args.push("--status");
9013 args.push(s);
9014 }
9015 let said = run_captured("claimdag", &args)?;
9016 drop_hold(&actor);
9017 drop_playbook(node);
9018 Ok(said.stdout)
9019}
9020
9021#[expect(
9022 clippy::too_many_arguments,
9023 reason = "The public finish signature preserves its independent command options"
9024)]
9025pub fn finish(
9026 issue: &str,
9027 status: &str,
9028 lesson: Option<&str>,
9029 outcome: Option<&str>,
9030 beta: f64,
9031 assignee: &str,
9032 gen: Option<u64>,
9033 close: bool,
9034) -> Result<String> {
9035 if close && tracker_show_json(issue).is_ok_and(|v| is_decision(&v)) {
9038 let said = run_captured("vissue", &["vote", issue, "--json"])?;
9039 let ballots = forecasts_from_json(&said.stdout)?.len();
9040 if ballots < 2 {
9041 bail!(
9042 "finish: {issue} is a decision and holds {ballots} ballot{}; run the panel \
9043 (`ljos panel {issue}`), have each persona cast `ljos vote {issue} --for OPTION --expect OPTION --as NAME`, \
9044 settle with `ljos consensus {issue}`, then --close. Nothing was written",
9045 if ballots == 1 { "" } else { "s" }
9046 );
9047 }
9048 }
9049 let mut out = String::new();
9050 match lesson.map(str::trim).filter(|l| !l.is_empty()) {
9051 Some(text) => {
9052 let scope = sync::scope_for_issue(issue);
9055 let body = packset_write_scoped("Remember", text, issue, scope.as_deref())?;
9056 out.push_str(&format!(
9057 "remembered {}{}\n",
9058 body.get("id").and_then(Value::as_str).unwrap_or("-"),
9059 revision_note(&body)
9060 ));
9061 }
9062 None => out.push_str(
9063 "no lesson remembered this sitting; `ljos remember` takes one in two sentences\n",
9064 ),
9065 }
9066 let title = issue_title(issue)?;
9067 let island = packset_island(&title, true)?;
9068 if island["weak"].as_bool().unwrap_or(false) {
9069 out.push_str(&format!(
9070 "did not fire the island for {title:?}: its seeds are hits no two scorers agreed on{}; wiring them would tighten the wrong links\n",
9071 if island["dense"].as_bool().unwrap_or(true) { "" } else { " (the encoder is down, ranking is lexical only)" }
9072 ));
9073 } else if island["held"].as_bool().unwrap_or(false) {
9074 out.push_str(&format!(
9077 "the island for {title:?} fired within the hour; not fired again\n"
9078 ));
9079 } else {
9080 let fired = island["island"].as_array().map_or(0, Vec::len);
9081 out.push_str(&format!(
9082 "fired the island for {title:?}: {fired} memories. Those links gained weight under the seat, not under a persona. The next walk of this title follows them.\n"
9083 ));
9084 }
9085 let terminal = ["done", "failed", "cancelled"];
9086 if !terminal.contains(&status) {
9087 bail!("finish: status {status:?} is not one of done, failed, cancelled");
9088 }
9089 complete(issue, Some(status), assignee, gen)?;
9090 out.push_str(&format!(
9091 "completed the session node for {issue} as {status}\n"
9092 ));
9093 if let Some(option) = outcome.map(str::trim).filter(|o| !o.is_empty()) {
9094 let said = run_captured("vissue", &["vote", issue, "--json"])?;
9095 let forecasts = forecasts_from_json(&said.stdout)?;
9096 if forecasts.len() < 2 {
9097 out.push_str("outcome named but fewer than two ballots; nothing to learn from\n");
9098 } else {
9099 let ballots: Vec<(String, String)> = forecasts
9100 .iter()
9101 .map(|f| (f.agent.clone(), f.choice.clone()))
9102 .collect();
9103 let about = island_entities(issue).unwrap_or_default();
9104 let (rows, moved, calibration) =
9105 learn_and_write(&ballots, option, beta, &about, &forecasts)?;
9106 out.push_str(&learn_reading(
9107 rows.len(),
9108 moved.len(),
9109 &forecasts,
9110 option,
9111 &calibration,
9112 ));
9113 out.push('\n');
9114 }
9115 }
9116 if close && status.eq_ignore_ascii_case("done") {
9120 run_as("vissue", &["update", issue, "-s", "DONE"], None)
9121 .with_context(|| format!("finish: could not close the ticket {issue}"))?;
9122 out.push_str(&format!("closed the ticket {issue}\n"));
9123 } else {
9124 out.push_str(&format!(
9125 "the ticket {issue} keeps its state; `ljos finish {issue} --close` or `vissue update {issue} -s DONE` closes it when the work is accepted\n"
9126 ));
9127 }
9128 out.push_str(&persist_tracker(issue, "finished"));
9129 out.push_str(&sync::sync_repo(false, true).unwrap_or_else(|e| format!("sync: {e:#}\n")));
9131 Ok(out)
9132}
9133
9134pub struct CommitLock(Option<std::fs::File>);
9138
9139impl CommitLock {
9140 #[must_use]
9141 pub fn acquire(path: &std::path::Path) -> Self {
9142 use std::os::unix::io::AsRawFd;
9143 let Ok(file) = std::fs::OpenOptions::new()
9144 .create(true)
9145 .append(true)
9146 .open(path)
9147 else {
9148 return Self(None);
9149 };
9150 let ok = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } == 0;
9152 Self(ok.then_some(file))
9153 }
9154}
9155
9156impl Drop for CommitLock {
9157 fn drop(&mut self) {
9158 use std::os::unix::io::AsRawFd;
9159 if let Some(file) = &self.0 {
9160 unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
9163 }
9164 }
9165}
9166
9167pub fn persist_tracker(issue: &str, verb: &str) -> String {
9175 let mode = std::env::var("LJOS_TRACKER_GIT").unwrap_or_default();
9176 if matches!(mode.as_str(), "off" | "0" | "false") {
9177 return "tracker git: off (LJOS_TRACKER_GIT)\n".into();
9178 }
9179 let path = match vissue_core::Layout::resolve(None, None)
9180 .and_then(vissue_core::Router::load)
9181 .and_then(|router| router.find_by_id(issue))
9182 {
9183 Ok(hit) => hit.path,
9184 Err(e) => return format!("tracker git: could not find {issue}: {e}\n"),
9185 };
9186 let Some(dir) = path.parent() else {
9187 return format!("tracker git: {} has no directory\n", path.display());
9188 };
9189 let git = |args: &[&str]| {
9190 std::process::Command::new("git")
9191 .arg("-C")
9192 .arg(dir)
9193 .args(args)
9194 .stdin(std::process::Stdio::null())
9195 .output()
9196 };
9197 let file = path.to_string_lossy().to_string();
9198 match git(&["rev-parse", "--is-inside-work-tree"]) {
9199 Ok(o) if o.status.success() => {}
9200 _ => return "tracker git: the tracker is not a git checkout\n".into(),
9201 }
9202 match git(&["status", "--porcelain", "--", &file]) {
9203 Ok(o) if o.status.success() && o.stdout.is_empty() => {
9204 return "tracker git: nothing to commit\n".into();
9205 }
9206 Ok(o) if o.status.success() => {}
9207 Ok(o) => return format!("tracker git: {}\n", first_line(&o.stderr)),
9208 Err(e) => return format!("tracker git: {e}\n"),
9209 }
9210 let message = format!("chore(issues): {issue} {verb}");
9211 let common = git(&["rev-parse", "--git-common-dir"])
9216 .ok()
9217 .filter(|o| o.status.success())
9218 .map(|o| dir.join(String::from_utf8_lossy(&o.stdout).trim()))
9219 .unwrap_or_else(|| dir.join(".git"));
9220 let _held = CommitLock::acquire(&common.join("ljos-commit.lock"));
9221 let mut committed = git(&["add", "--", &file])
9222 .and_then(|_| git(&["commit", "-q", "--only", "-m", &message, "--", &file]));
9223 for wait_ms in [200_u64, 400, 800, 1600, 3200] {
9224 let busy = matches!(&committed, Ok(o) if !o.status.success()
9225 && String::from_utf8_lossy(&o.stderr).contains("index.lock"));
9226 if !busy {
9227 break;
9228 }
9229 std::thread::sleep(std::time::Duration::from_millis(wait_ms));
9230 committed = git(&["add", "--", &file])
9231 .and_then(|_| git(&["commit", "-q", "--only", "-m", &message, "--", &file]));
9232 }
9233 drop(_held);
9234 match committed {
9235 Ok(o) if o.status.success() => {}
9236 Ok(o) => {
9237 return format!(
9238 "tracker git: commit refused: {}\n",
9239 first_line(if o.stderr.is_empty() {
9240 &o.stdout
9241 } else {
9242 &o.stderr
9243 })
9244 );
9245 }
9246 Err(e) => return format!("tracker git: {e}\n"),
9247 }
9248 if mode == "commit" {
9249 return format!("tracker git: committed {message}; not pushed (LJOS_TRACKER_GIT=commit)\n");
9250 }
9251 let log = runtime_dir().join(format!("tracker-push-{}.log", std::process::id()));
9255 let _ = std::fs::create_dir_all(runtime_dir());
9256 let Ok(out) = std::fs::File::create(&log) else {
9257 return format!("tracker git: committed {message}; push not started: no log file\n");
9258 };
9259 let err = out.try_clone();
9260 let mirrors = tracker_upstream(dir)
9264 .and_then(|up| tracker_mirrors(dir, &up))
9265 .unwrap_or_default();
9266 let mut script =
9271 String::from("git push -q || { git pull -q --no-rebase --no-edit && git push -q; }; rc=$?");
9272 for (remote, branch) in &mirrors {
9273 script.push_str(&format!(
9274 "; git push -q '{remote}' 'HEAD:refs/heads/{branch}' || rc=1"
9275 ));
9276 }
9277 script.push_str("; exit $rc");
9278 let mut push = std::process::Command::new("sh");
9279 push.current_dir(dir)
9280 .args(["-c", &script])
9281 .stdin(std::process::Stdio::null())
9282 .stdout(out);
9283 if let Ok(err) = err {
9284 push.stderr(err);
9285 }
9286 let mut child = match push.spawn() {
9287 Ok(c) => c,
9288 Err(e) => return format!("tracker git: committed {message}; push failed: {e}\n"),
9289 };
9290 let wait = push_wait();
9291 let started = std::time::Instant::now();
9292 loop {
9293 match child.try_wait() {
9294 Ok(Some(status)) if status.success() => {
9295 let _ = std::fs::remove_file(&log);
9296 return format!("tracker git: committed and pushed {message}\n");
9297 }
9298 Ok(Some(_)) => {
9299 let said = std::fs::read(&log).unwrap_or_default();
9300 return format!(
9301 "tracker git: committed {message}; push refused: {}\n",
9302 first_line(&said)
9303 );
9304 }
9305 Ok(None) if started.elapsed() < wait => {
9306 std::thread::sleep(std::time::Duration::from_millis(200));
9307 }
9308 Ok(None) => {
9309 return format!(
9310 "tracker git: committed {message}; push still running after {}s, finishing in the background (log {})\n",
9311 wait.as_secs(),
9312 log.display()
9313 );
9314 }
9315 Err(e) => return format!("tracker git: committed {message}; push failed: {e}\n"),
9316 }
9317 }
9318}
9319
9320fn push_wait() -> std::time::Duration {
9323 let secs = std::env::var("LJOS_TRACKER_PUSH_WAIT")
9324 .ok()
9325 .and_then(|v| v.trim().parse::<u64>().ok())
9326 .unwrap_or(5);
9327 std::time::Duration::from_secs(secs)
9328}
9329
9330fn first_line(bytes: &[u8]) -> String {
9331 String::from_utf8_lossy(bytes)
9332 .lines()
9333 .find(|l| !l.trim().is_empty())
9334 .unwrap_or("")
9335 .trim()
9336 .to_string()
9337}
9338
9339#[must_use]
9349pub fn calibration_weights(accuracy: &[(String, f64)]) -> Vec<(String, f64)> {
9350 let logit = |p: f64| {
9351 let p = p.clamp(0.01, 0.99);
9352 (p / (1.0 - p)).ln()
9353 };
9354 let raw: Vec<(String, f64)> = accuracy
9355 .iter()
9356 .map(|(who, p)| (who.clone(), logit(*p).max(0.0)))
9357 .collect();
9358 let top = raw.iter().map(|(_, w)| *w).fold(0.0_f64, f64::max);
9359 raw.into_iter()
9360 .map(|(who, w)| {
9361 let scaled = if top > 0.0 { w / top } else { 0.0 };
9362 (who, scaled.clamp(TRUST_FLOOR, 1.0))
9363 })
9364 .collect()
9365}
9366
9367pub fn calibrate(project: &str, rounds: usize) -> Result<Vec<Trust>> {
9381 let said = run_captured(
9382 "ljos-consensus",
9383 &[
9384 "reliability",
9385 "--project",
9386 project,
9387 "--rounds",
9388 &rounds.to_string(),
9389 ],
9390 )?;
9391 let v: Value = serde_json::from_str(&said.stdout).context("reliability: not JSON")?;
9392 let accuracy = v
9393 .get("accuracy")
9394 .and_then(Value::as_object)
9395 .context("reliability: no accuracy object")?;
9396 let mut voters: Vec<(String, f64)> = accuracy
9397 .iter()
9398 .filter_map(|(k, val)| val.as_f64().map(|a| (k.clone(), a)))
9399 .collect();
9400 voters.sort_by(|a, b| a.0.cmp(&b.0));
9401 if voters.len() < 2 {
9402 bail!("calibrate: fewer than two voters in {project}");
9403 }
9404 let weights = calibration_weights(&voters);
9405 let mut rows = Vec::new();
9406 for (from, _) in &voters {
9407 for (to, weight) in &weights {
9408 if from == to {
9409 continue;
9410 }
9411 rows.push(Trust {
9412 from: from.clone(),
9413 to: to.clone(),
9414 weight: *weight,
9415 about: Vec::new(),
9416 });
9417 }
9418 }
9419 for row in &rows {
9420 write_trust(row, &[])?;
9421 }
9422 Ok(rows)
9423}
9424
9425#[must_use]
9428pub fn search_reading(n: usize) -> &'static str {
9429 if n == 0 {
9430 "No hits. The pack holds nothing on this query. A failure would say the writer did not answer."
9431 } else {
9432 "Score is how the scorers ranked this query. The fraction is how many of them named the hit. Neither is whether the claim is true. A later line on the same matter supersedes an earlier one."
9433 }
9434}
9435
9436pub fn format_hits(hits: &[Hit]) -> String {
9440 let now = now_utc();
9441 let mine = seat_name();
9442 let mut out = format!("{}\n", search_reading(hits.len()));
9443 for h in hits {
9444 let id = h.id.as_deref().unwrap_or("-");
9445 let named = match (h.ballots, h.of) {
9446 (Some(b), Some(of)) => format!("{b}/{of}"),
9447 _ => "-".to_string(),
9448 };
9449 let from = other_seat(&h.entities, &mine)
9450 .map(|s| format!(" (from {s})"))
9451 .unwrap_or_default();
9452 out.push_str(&format!(
9453 "{:.4}\t{}\t{}\t{}\t{}{}\t{}\n",
9454 h.score,
9455 named,
9456 h.kind,
9457 id,
9458 age_of(h.ts.as_deref(), &now),
9459 from,
9460 h.text
9461 ));
9462 }
9463 out
9464}
9465
9466#[must_use]
9470pub fn other_seat(entities: &[String], mine: &str) -> Option<String> {
9471 entities
9472 .iter()
9473 .filter_map(|e| e.strip_prefix(SEAT_ENTITY))
9474 .find(|s| !s.is_empty() && *s != mine)
9475 .map(str::to_string)
9476}
9477
9478fn hit_line(h: &Hit, now: &str) -> String {
9481 let from = other_seat(&h.entities, &seat_name())
9482 .map(|s| format!(", from {s}"))
9483 .unwrap_or_default();
9484 format!(
9485 "- [{}{}{}] {}",
9486 if h.kind.is_empty() { "claim" } else { &h.kind },
9487 age_tag(h.ts.as_deref(), now),
9488 from,
9489 h.text.trim()
9490 )
9491}
9492
9493fn age_tag(ts: Option<&str>, now: &str) -> String {
9495 let age = age_of(ts, now);
9496 if age.is_empty() {
9497 age
9498 } else {
9499 format!(", {age}")
9500 }
9501}
9502
9503#[must_use]
9508pub fn age_of(ts: Option<&str>, now: &str) -> String {
9509 let (Some(then), Some(today)) = (days_of_stamp(ts), days_of_stamp(Some(now))) else {
9510 return String::new();
9511 };
9512 let days = today - then;
9513 match days {
9514 d if d < 0 => format!("in {} day{}", -d, if d == -1 { "" } else { "s" }),
9515 0 => "today".into(),
9516 1 => "yesterday".into(),
9517 d if d < 14 => format!("{d} days ago"),
9518 d if d < 61 => format!("{} weeks ago", d / 7),
9519 d if d < 730 => format!("{} months ago", d / 30),
9520 d => format!("{} years ago", d / 365),
9521 }
9522}
9523
9524fn days_of_stamp(ts: Option<&str>) -> Option<i64> {
9527 let ts = ts?;
9528 let date = ts.get(..10)?;
9529 let mut it = date.split('-');
9530 let y: i64 = it.next()?.parse().ok()?;
9531 let m: i64 = it.next()?.parse().ok()?;
9532 let d: i64 = it.next()?.parse().ok()?;
9533 if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
9534 return None;
9535 }
9536 let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
9538 let era = y.div_euclid(400);
9539 let yoe = y - era * 400;
9540 let doy = (153 * m + 2) / 5 + d - 1;
9541 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
9542 Some(era * 146_097 + doe - 719_468)
9543}
9544
9545pub fn cards(dir: &Path) -> Result<String> {
9547 let mut out = String::new();
9548 for name in CARD_NAMES {
9549 let p = dir.join(name);
9550 if p.is_file() {
9551 out.push_str(&format!("--- {} ---\n", p.display()));
9552 out.push_str(&std::fs::read_to_string(&p)?);
9553 }
9554 }
9555 Ok(out)
9556}
9557
9558pub fn policy_line(argv: &[String]) -> Result<String> {
9559 if argv.is_empty() {
9560 bail!("policy: pass the argv to check");
9561 }
9562 Ok(argv.join(" "))
9563}
9564
9565pub fn policy_with_memory(argv: &[String]) -> Result<String> {
9569 let line = policy_line(argv)?;
9570 let call = HookCall {
9571 event: "argv".into(),
9572 cue: line.clone(),
9573 session: None,
9574 shape: HookShape::Asks,
9575 };
9576 let context = hook_context(&call, 5);
9577 let rules = rules_from_pack().unwrap_or_default();
9580 let ruled = hook_output_ruled(&call, &context, verdict_for(&rules, &line));
9581 match tcb_check(argv) {
9582 Some(tcb) if !tcb.is_empty() => Ok(format!("{line}\n{tcb}\n{ruled}")),
9583 None if policyd_required() => Ok(format!("{line}\ndeny\tTCB required\n{ruled}")),
9584 _ => Ok(format!("{line}\n{ruled}")),
9585 }
9586}
9587
9588pub fn policyd_required() -> bool {
9590 matches!(
9591 std::env::var("POLICYD_REQUIRED").as_deref(),
9592 Ok("1") | Ok("true") | Ok("TRUE")
9593 )
9594}
9595
9596pub fn policyd_bin() -> Option<std::path::PathBuf> {
9598 std::env::var_os("POLICYD_BIN")
9599 .filter(|s| !s.is_empty())
9600 .map(std::path::PathBuf::from)
9601 .or_else(|| which::which("ljos-policyd").ok())
9602}
9603
9604pub fn tcb_check(argv: &[String]) -> Option<String> {
9607 let bin = policyd_bin()?;
9608 let out = std::process::Command::new(bin)
9609 .arg("check")
9610 .arg("--")
9611 .args(argv)
9612 .output()
9613 .ok()?;
9614 let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
9615 (!text.is_empty()).then_some(text)
9616}
9617
9618#[derive(Debug, Clone, PartialEq, Eq)]
9619pub struct ConsensusStep {
9620 pub bin: &'static str,
9621 pub args: Vec<String>,
9622}
9623
9624pub fn consensus_steps(
9627 id: &str,
9628 have_ljos: bool,
9629 have_vissue: bool,
9630 trust: &[Trust],
9631) -> Result<Vec<ConsensusStep>> {
9632 consensus_steps_anchored(id, have_ljos, have_vissue, trust, &[])
9633}
9634
9635pub const BROAD_TAG: &str = "broad";
9640
9641pub const BROAD_EPSILON: f64 = 1.0;
9644
9645#[must_use]
9648pub fn settle_flags_for(tags: &[String]) -> Vec<String> {
9649 if tags.iter().any(|t| t == BROAD_TAG) {
9650 vec!["--epsilon".into(), BROAD_EPSILON.to_string()]
9651 } else {
9652 Vec::new()
9653 }
9654}
9655
9656pub fn consensus_steps_for(
9659 id: &str,
9660 have_ljos: bool,
9661 have_vissue: bool,
9662 trust: &[Trust],
9663 personas: &[Persona],
9664 tags: &[String],
9665) -> Result<Vec<ConsensusStep>> {
9666 let mut steps = consensus_steps_anchored(id, have_ljos, have_vissue, trust, personas)?;
9667 let flags = settle_flags_for(tags);
9668 if !flags.is_empty() {
9669 for step in steps.iter_mut().filter(|s| s.bin == "ljos-consensus") {
9670 step.args.extend(flags.iter().cloned());
9671 }
9672 }
9673 Ok(steps)
9674}
9675
9676pub fn panel_steps(
9681 id: &str,
9682 have_ljos: bool,
9683 trust: &[Trust],
9684 predictions: &[Prediction],
9685) -> Vec<ConsensusStep> {
9686 let mut steps = Vec::new();
9687 if !have_ljos {
9688 return steps;
9689 }
9690 if predictions.len() >= 2 {
9691 steps.push(ConsensusStep {
9692 bin: "ljos-consensus",
9693 args: vec![
9694 "surprising".into(),
9695 "--issue".into(),
9696 id.into(),
9697 "--predictions".into(),
9698 predictions_json(predictions),
9699 ],
9700 });
9701 }
9702 if !trust.is_empty() {
9703 steps.push(ConsensusStep {
9704 bin: "ljos-consensus",
9705 args: vec!["reputation".into(), "--trust".into(), trust_json(trust)],
9706 });
9707 }
9708 steps
9709}
9710
9711pub fn consensus_steps_anchored(
9714 id: &str,
9715 have_ljos: bool,
9716 have_vissue: bool,
9717 trust: &[Trust],
9718 personas: &[Persona],
9719) -> Result<Vec<ConsensusStep>> {
9720 if !have_ljos && !have_vissue {
9721 bail!("neither ljos-consensus nor vissue is on PATH");
9722 }
9723 let mut steps = Vec::new();
9724 if have_ljos {
9725 let mut args = vec!["settle".to_string(), "--issue".into(), id.into()];
9726 if !trust.is_empty() {
9727 args.push("--trust".into());
9728 args.push(trust_json(trust));
9729 }
9730 if !personas.is_empty() {
9731 args.push("--susceptibility-of".into());
9732 args.push(anchors_json(personas));
9733 }
9734 steps.push(ConsensusStep {
9735 bin: "ljos-consensus",
9736 args,
9737 });
9738 }
9739 if have_vissue {
9740 let mut args = vec!["consensus".to_string(), id.into()];
9741 if !trust.is_empty() {
9742 args.push("--trust".into());
9743 args.push(trust_json(trust));
9744 }
9745 if !personas.is_empty() {
9746 args.push("--susceptibility-of".into());
9747 args.push(anchors_json(personas));
9748 }
9749 steps.push(ConsensusStep {
9750 bin: "vissue",
9751 args,
9752 });
9753 }
9754 Ok(steps)
9755}
9756
9757pub fn on_path(bin: &str) -> bool {
9758 which::which(bin).is_ok()
9759}
9760
9761pub fn run(bin: &str, args: &[impl AsRef<str>]) -> Result<()> {
9762 run_as(bin, args, None)
9763}
9764
9765#[must_use]
9769pub fn identity_or_seat(identity: Option<&str>) -> Option<String> {
9770 identity
9771 .map(str::trim)
9772 .filter(|w| !w.is_empty())
9773 .map(str::to_string)
9774 .or_else(|| Some(seat_name()))
9775}
9776
9777pub fn run_as(bin: &str, args: &[impl AsRef<str>], identity: Option<&str>) -> Result<()> {
9780 use std::process::{Command, Stdio};
9781 let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
9782 let mut cmd = Command::new(path);
9783 if let Some(who) = identity_or_seat(identity) {
9784 cmd.env("VISSUE_AGENT", who);
9785 }
9786 for a in args {
9787 cmd.arg(a.as_ref());
9788 }
9789 let st = cmd
9790 .stdin(Stdio::inherit())
9791 .stdout(Stdio::inherit())
9792 .stderr(Stdio::inherit())
9793 .status()?;
9794 #[cfg(unix)]
9798 {
9799 use std::os::unix::process::ExitStatusExt;
9800 if st.signal() == Some(libc::SIGPIPE) {
9801 return Ok(());
9802 }
9803 }
9804 if !st.success() {
9805 bail!("{bin} exited {st}");
9806 }
9807 Ok(())
9808}
9809
9810#[derive(Debug, Clone, PartialEq, Eq)]
9813pub struct Said {
9814 pub stdout: String,
9815 pub stderr: String,
9816}
9817
9818pub fn run_captured(bin: &str, args: &[impl AsRef<str>]) -> Result<Said> {
9819 run_captured_as(bin, args, None)
9820}
9821
9822pub fn run_captured_as(
9826 bin: &str,
9827 args: &[impl AsRef<str>],
9828 identity: Option<&str>,
9829) -> Result<Said> {
9830 use std::process::{Command, Stdio};
9831 let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
9832 let mut cmd = Command::new(path);
9833 if let Some(who) = identity {
9834 cmd.env("VISSUE_AGENT", who);
9835 }
9836 for a in args {
9837 cmd.arg(a.as_ref());
9838 }
9839 let out = cmd
9840 .stdin(Stdio::null())
9841 .stdout(Stdio::piped())
9842 .stderr(Stdio::piped())
9843 .output()
9844 .with_context(|| format!("{bin}: could not start"))?;
9845 let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
9846 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
9847 if !out.status.success() {
9848 let why = if stderr.trim().is_empty() {
9849 stdout.trim().to_string()
9850 } else {
9851 stderr.trim().to_string()
9852 };
9853 bail!("{bin} exited {}: {why}", out.status);
9854 }
9855 Ok(Said { stdout, stderr })
9856}
9857
9858pub fn card_paths(dir: &Path) -> Vec<PathBuf> {
9859 CARD_NAMES.iter().map(|n| dir.join(n)).collect()
9860}
9861
9862#[derive(Debug, Clone, PartialEq, Eq)]
9865pub struct Finding {
9866 pub id: String,
9867 pub status: String,
9868 pub class: String,
9869 pub disposition: String,
9870 pub stage: String,
9871 pub recipe: String,
9874 pub module: String,
9878 pub summary: String,
9879 pub error: String,
9881 pub action: String,
9883 pub changes: Vec<String>,
9884}
9885
9886#[derive(Debug, Clone, PartialEq, Eq)]
9888pub struct Campaign {
9889 pub package: String,
9890 pub version: String,
9891 pub target: String,
9892 pub status: String,
9893 pub attempts: u64,
9894 pub findings: Vec<Finding>,
9895}
9896
9897fn recipe_stem(path: &str) -> String {
9898 Path::new(path)
9899 .file_stem()
9900 .map(|s| s.to_string_lossy().into_owned())
9901 .unwrap_or_else(|| path.to_string())
9902}
9903
9904fn error_line(evidence: &str, summary: &str) -> String {
9907 let lower = |l: &str| l.to_ascii_lowercase();
9908 evidence
9909 .lines()
9910 .map(str::trim)
9911 .filter(|l| !l.is_empty())
9912 .filter(|l| {
9913 let l = lower(l);
9914 l.contains("error") || l.contains("fatal") || l.contains("failed")
9915 })
9916 .rfind(|l| !l.starts_with("srun:"))
9917 .map(str::to_string)
9918 .unwrap_or_else(|| summary.to_string())
9919}
9920
9921fn failed_module(evidence: &str) -> Option<String> {
9925 let installation = evidence.lines().rev().find_map(|l| {
9926 let rest = l.split("Installation of ").nth(1)?;
9927 let eb = rest.split(".eb failed").next()?;
9928 let name = eb.rsplit('/').next()?;
9931 (!name.is_empty() && !name.contains(' ')).then(|| name.to_string())
9932 });
9933 installation.or_else(|| {
9934 evidence.lines().rev().find_map(|l| {
9935 let rest = l.trim().strip_prefix("== building and installing ")?;
9936 let name = rest.trim_end_matches('.').trim();
9937 (!name.is_empty()).then(|| name.replacen('/', "-", 1))
9938 })
9939 })
9940}
9941
9942fn error_reason(error: &str) -> &str {
9944 error
9945 .split(".eb failed: ")
9946 .nth(1)
9947 .unwrap_or(error)
9948 .trim_start_matches("ERROR: ")
9949}
9950
9951fn text_of(v: &Value, key: &str) -> String {
9952 v.get(key)
9953 .and_then(Value::as_str)
9954 .unwrap_or_default()
9955 .to_string()
9956}
9957
9958pub fn read_campaign(state: &Path) -> Result<Campaign> {
9964 let text = std::fs::read_to_string(state)
9965 .with_context(|| format!("findings: cannot read {}", state.display()))?;
9966 let doc: Value = serde_json::from_str(&text)
9967 .with_context(|| format!("findings: {} is not JSON", state.display()))?;
9968 let rows = doc
9969 .get("findings")
9970 .and_then(Value::as_array)
9971 .with_context(|| format!("findings: {} has no findings list", state.display()))?;
9972 let findings = rows
9973 .iter()
9974 .map(|f| {
9975 let summary = text_of(f, "summary");
9976 let resolution = f.get("resolution");
9977 let evidence = text_of(f, "evidence");
9978 Finding {
9979 id: text_of(f, "id"),
9980 status: text_of(f, "status"),
9981 class: text_of(f, "class"),
9982 disposition: text_of(f, "disposition"),
9983 stage: text_of(f, "stage"),
9984 recipe: recipe_stem(&text_of(f, "recipe")),
9985 module: failed_module(&evidence).unwrap_or_default(),
9986 error: error_line(&evidence, &summary),
9987 summary,
9988 action: resolution.map(|r| text_of(r, "action")).unwrap_or_default(),
9989 changes: resolution
9990 .and_then(|r| r.get("changes"))
9991 .and_then(Value::as_array)
9992 .map(|c| {
9993 c.iter()
9994 .filter_map(Value::as_str)
9995 .map(str::to_string)
9996 .collect()
9997 })
9998 .unwrap_or_default(),
9999 }
10000 })
10001 .collect();
10002 Ok(Campaign {
10003 package: text_of(&doc, "package"),
10004 version: text_of(&doc, "version"),
10005 target: text_of(&doc, "target"),
10006 status: text_of(&doc, "status"),
10007 attempts: doc.get("attempts").and_then(Value::as_u64).unwrap_or(0),
10008 findings,
10009 })
10010}
10011
10012fn superseded_by_retry(f: &Finding) -> bool {
10015 f.status == "superseded" || f.action.contains("superseded this finding")
10016}
10017
10018fn clip_words(text: &str, n: usize) -> String {
10021 let text = text.replace(" ...", "").replace("...", "");
10024 let chars: Vec<char> = text.chars().collect();
10025 let mut flat = String::with_capacity(text.len());
10026 for (i, &c) in chars.iter().enumerate() {
10027 let ends_word = chars.get(i + 1).is_none_or(|n| n.is_whitespace());
10028 flat.push(match c {
10029 '.' | '!' | '?' | ';' if ends_word => ',',
10030 '\n' | '\t' => ' ',
10031 c => c,
10032 });
10033 }
10034 let words: Vec<&str> = flat.split_whitespace().collect();
10035 let mut out = words[..words.len().min(n)].join(" ");
10036 while out.ends_with([',', ':', ' ']) {
10037 out.pop();
10038 }
10039 out
10040}
10041
10042#[must_use]
10046pub fn finding_lesson(campaign: &Campaign, f: &Finding) -> String {
10047 let what = clip_words(error_reason(&f.error), 10);
10048 let subject = if f.module.is_empty() {
10049 f.recipe.clone()
10050 } else if f.module == f.recipe {
10051 f.module.clone()
10052 } else {
10053 format!("{} for {}", f.module, f.recipe)
10054 };
10055 let mut first = format!(
10056 "{subject} on {}: {} failed in the {} step",
10057 campaign.target, f.class, f.stage
10058 );
10059 if !what.is_empty() && what != f.summary {
10060 first.push_str(&format!(" with {what}"));
10061 }
10062 first.push('.');
10063 if superseded_by_retry(f) {
10064 return format!("{first} A later attempt got past it.");
10065 }
10066 let mut fix = clip_words(&f.action, 14);
10067 if !f.changes.is_empty() {
10068 let files: Vec<String> = f
10069 .changes
10070 .iter()
10071 .map(String::as_str)
10072 .map(recipe_stem)
10073 .collect();
10074 fix.push_str(&format!(" in {}", files.join(", ")));
10075 }
10076 if fix.is_empty() {
10077 first
10078 } else {
10079 format!("{first} Fix: {fix}.")
10080 }
10081}
10082
10083fn finding_entities(campaign: &Campaign, f: &Finding) -> Vec<String> {
10086 let mut out: Vec<String> = Vec::new();
10087 for stem in [&f.module, &f.recipe] {
10088 if stem.is_empty() || out.contains(stem) {
10089 continue;
10090 }
10091 out.push(stem.clone());
10092 if let Some(name) = stem.split('-').next() {
10093 if !name.is_empty() && name != stem && !out.iter().any(|e| e == name) {
10094 out.push(name.to_string());
10095 }
10096 }
10097 }
10098 if !campaign.package.is_empty() {
10099 out.push(campaign.package.clone());
10100 }
10101 out.push(f.class.clone());
10102 out.dedup();
10103 out
10104}
10105
10106#[must_use]
10109pub fn format_findings(campaign: &Campaign) -> String {
10110 let mut out = format!(
10111 "{} {} on {}: {} after {} attempt{}, {} finding{}\n",
10112 campaign.package,
10113 campaign.version,
10114 campaign.target,
10115 campaign.status,
10116 campaign.attempts,
10117 if campaign.attempts == 1 { "" } else { "s" },
10118 campaign.findings.len(),
10119 if campaign.findings.len() == 1 {
10120 ""
10121 } else {
10122 "s"
10123 },
10124 );
10125 for f in &campaign.findings {
10126 let tail = if f.action.is_empty() {
10127 f.summary.clone()
10128 } else {
10129 format!("fix: {}", f.action)
10130 };
10131 out.push_str(&format!(
10132 "{}\t{}\t{}/{}\t{}\t{}\t{}\n",
10133 f.id,
10134 f.status,
10135 f.class,
10136 f.disposition,
10137 f.stage,
10138 if f.module.is_empty() {
10139 &f.recipe
10140 } else {
10141 &f.module
10142 },
10143 tail
10144 ));
10145 }
10146 out
10147}
10148
10149#[derive(Debug, Clone, PartialEq, Eq)]
10151pub struct Remembered {
10152 pub id: String,
10153 pub lesson: String,
10154 pub result: String,
10157}
10158
10159pub fn remember_findings(state: &Path, issue: Option<&str>, all: bool) -> Result<Vec<Remembered>> {
10168 let campaign = read_campaign(state)?;
10169 let client = pack()?;
10170 let workspace = client.workspace();
10171 let mut out = Vec::new();
10172 for f in &campaign.findings {
10173 if !all && superseded_by_retry(f) {
10174 out.push(Remembered {
10175 id: f.id.clone(),
10176 lesson: String::new(),
10177 result: "skipped: a later attempt got past it, nothing was learned".into(),
10178 });
10179 continue;
10180 }
10181 if !all && f.status != "resolved" {
10182 out.push(Remembered {
10183 id: f.id.clone(),
10184 lesson: String::new(),
10185 result: format!("skipped: {}", f.status),
10186 });
10187 continue;
10188 }
10189 let lesson = finding_lesson(&campaign, f);
10190 let mut atom = atom_body("lesson", &lesson, &workspace);
10191 add_entities(&mut atom, finding_entities(&campaign, f));
10192 let result = match client.post_atom(&atom) {
10193 Ok(body) => format!(
10194 "{}{}",
10195 body["id"].as_str().unwrap_or("written"),
10196 revision_note(&body)
10197 ),
10198 Err(e) => format!("refused: {e}"),
10199 };
10200 out.push(Remembered {
10201 id: f.id.clone(),
10202 lesson,
10203 result,
10204 });
10205 }
10206 if let Some(issue) = issue.map(str::trim).filter(|i| !i.is_empty()) {
10207 let name = format!(
10208 "{} {} campaign state on {}, {} after {} attempts",
10209 campaign.package, campaign.version, campaign.target, campaign.status, campaign.attempts
10210 );
10211 let seat = seat_name();
10212 let said = match run_captured(
10215 "deedar",
10216 &[
10217 "create",
10218 "file",
10219 "--name",
10220 &name,
10221 "--path",
10222 &state.display().to_string(),
10223 "--agent",
10224 &seat,
10225 ],
10226 ) {
10227 Ok(said) => said.stdout,
10228 Err(e) if e.to_string().contains("deed frozen") => e.to_string(),
10229 Err(e) => return Err(e),
10230 };
10231 let accession = said
10234 .split_whitespace()
10235 .find_map(|w| {
10236 let at = w.find("deed-")?;
10237 let tail = &w[at..];
10238 let end = tail
10239 .find(|c: char| !c.is_ascii_alphanumeric() && c != '-')
10240 .unwrap_or(tail.len());
10241 Some(tail[..end].to_string())
10242 })
10243 .filter(|a| a.len() > "deed-".len())
10244 .context("findings: deedar create printed no accession")?;
10245 run_captured("vissue", &["deed", issue, "--add", &accession])?;
10246 let _ = persist_tracker(issue, "cited the campaign state");
10247 out.push(Remembered {
10248 id: "state".into(),
10249 lesson: name,
10250 result: format!("cited on {issue} as {accession}"),
10251 });
10252 }
10253 Ok(out)
10254}
10255
10256#[must_use]
10257pub fn format_remembered(rows: &[Remembered]) -> String {
10258 rows.iter()
10259 .map(|r| {
10260 if r.lesson.is_empty() {
10261 format!("{}\t{}\n", r.id, r.result)
10262 } else {
10263 format!("{}\t{}\n\t{}\n", r.id, r.result, r.lesson)
10264 }
10265 })
10266 .collect()
10267}
10268
10269#[derive(Debug, Clone, PartialEq, Eq)]
10271pub struct BumpRow {
10272 pub id: String,
10275 pub module: String,
10277 pub recipe: String,
10279 pub blockers: Vec<String>,
10281 pub result: String,
10283}
10284
10285fn module_stem(name: &str, version: &str, toolchain: Option<(&str, &str)>) -> String {
10287 match toolchain {
10288 Some((tn, tv)) if !tn.is_empty() && tn != "system" => {
10289 format!("{name}-{version}-{tn}-{tv}")
10290 }
10291 _ => format!("{name}-{version}"),
10292 }
10293}
10294
10295#[must_use]
10298pub fn bump_issue_id(project: &str, module: &str, generation: &str) -> String {
10299 let hex = work_id(&format!("bump:{module}:{generation}"));
10300 let mut n = u128::from_str_radix(&hex[..24], 16).unwrap_or(0);
10301 const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
10302 let mut out = Vec::new();
10303 for _ in 0..8 {
10304 out.push(DIGITS[(n % 36) as usize]);
10305 n /= 36;
10306 }
10307 format!("{project}-{}", String::from_utf8(out).unwrap_or_default())
10308}
10309
10310fn purl_name(purl: &str) -> String {
10312 purl.rsplit('/')
10313 .next()
10314 .unwrap_or(purl)
10315 .split('@')
10316 .next()
10317 .unwrap_or(purl)
10318 .to_string()
10319}
10320
10321pub fn bump_rows(
10329 bundle: &Path,
10330 project: &str,
10331 generation: Option<&str>,
10332) -> Result<(String, Vec<BumpRow>)> {
10333 let lock_path = bundle.join("locks").join("default.lock.json");
10334 let sbom_path = bundle.join("package.sbom.cdx.json");
10335 let lock: Value = serde_json::from_str(
10336 &std::fs::read_to_string(&lock_path)
10337 .with_context(|| format!("bump-plan: cannot read {}", lock_path.display()))?,
10338 )
10339 .with_context(|| format!("bump-plan: {} is not JSON", lock_path.display()))?;
10340 let sbom: Value = serde_json::from_str(
10341 &std::fs::read_to_string(&sbom_path)
10342 .with_context(|| format!("bump-plan: cannot read {}", sbom_path.display()))?,
10343 )
10344 .with_context(|| format!("bump-plan: {} is not JSON", sbom_path.display()))?;
10345 let tc = &lock["toolchain"];
10346 let generation = generation.map(str::to_string).unwrap_or_else(|| {
10347 format!(
10348 "{}/{}",
10349 tc["name"].as_str().unwrap_or("system"),
10350 tc["version"].as_str().unwrap_or("")
10351 )
10352 .trim_end_matches('/')
10353 .to_string()
10354 });
10355 let mut modules: Vec<(String, String, String)> = Vec::new(); let root_name = lock["package"].as_str().unwrap_or("").to_string();
10358 let root_stem = module_stem(
10359 &root_name,
10360 lock["version"].as_str().unwrap_or(""),
10361 Some((
10362 tc["name"].as_str().unwrap_or(""),
10363 tc["version"].as_str().unwrap_or(""),
10364 )),
10365 ) + lock["versionsuffix"].as_str().unwrap_or("");
10366 modules.push((root_name.clone(), root_stem, String::new()));
10367 for dep in lock["dependencies"].as_array().into_iter().flatten() {
10370 let name = dep["name"].as_str().unwrap_or("").to_string();
10371 let dtc = &dep["toolchain"];
10372 let stem = module_stem(
10373 &name,
10374 dep["version"].as_str().unwrap_or(""),
10375 Some((
10376 dtc["name"].as_str().unwrap_or(""),
10377 dtc["version"].as_str().unwrap_or(""),
10378 )),
10379 );
10380 let recipe = dep["easyconfig_path"].as_str().unwrap_or("").to_string();
10381 if !name.is_empty() && !modules.iter().any(|(n, _, _)| *n == name) {
10382 modules.push((name, stem, recipe));
10383 }
10384 }
10385 let id_of = |name: &str| -> Option<String> {
10386 modules
10387 .iter()
10388 .find(|(n, _, _)| n == name)
10389 .map(|(_, stem, _)| bump_issue_id(project, stem, &generation))
10390 };
10391 let mut edges: std::collections::BTreeMap<String, Vec<String>> = Default::default();
10393 for d in sbom["dependencies"].as_array().into_iter().flatten() {
10394 let from = purl_name(d["ref"].as_str().unwrap_or(""));
10395 for on in d["dependsOn"].as_array().into_iter().flatten() {
10396 let to = purl_name(on.as_str().unwrap_or(""));
10397 if let Some(id) = id_of(&to) {
10398 edges.entry(from.clone()).or_default().push(id);
10399 }
10400 }
10401 }
10402 let rows = modules
10403 .iter()
10404 .map(|(name, stem, recipe)| BumpRow {
10405 id: bump_issue_id(project, stem, &generation),
10406 module: stem.clone(),
10407 recipe: recipe.clone(),
10408 blockers: edges.get(name).cloned().unwrap_or_default(),
10409 result: "would make".into(),
10410 })
10411 .collect();
10412 Ok((generation, rows))
10413}
10414
10415pub fn bump_plan(
10425 bundle: &Path,
10426 project: &str,
10427 parent: &str,
10428 generation: Option<&str>,
10429 dry: bool,
10430) -> Result<(String, Vec<BumpRow>)> {
10431 let (generation, mut rows) = bump_rows(bundle, project, generation)?;
10432 if dry {
10433 return Ok((generation, rows));
10434 }
10435 for row in &mut rows {
10436 let exists = tracker_show_json(&row.id).is_ok();
10437 if exists {
10438 row.result = "held".into();
10439 } else {
10440 let title = format!("Bump {} onto {generation}", row.module);
10441 let body = if row.recipe.is_empty() {
10442 format!("The bundle at {} names this module. Ladder: recipe check, package bump, lint, then the campaign.", bundle.display())
10443 } else {
10444 format!("Recipe {} in the bundle at {}. Ladder: recipe check, package bump, lint, then the campaign.", row.recipe, bundle.display())
10445 };
10446 run_captured(
10447 "vissue",
10448 &[
10449 "create", "-p", project, "--id", &row.id, "--parent", parent, "-t", "task",
10450 "--quiet", "--body", &body, &title,
10451 ],
10452 )
10453 .with_context(|| format!("bump-plan: create {} ({})", row.id, row.module))?;
10454 row.result = "made".into();
10455 }
10456 }
10457 for row in &rows {
10459 let held: Vec<String> = tracker_show_json(&row.id)
10460 .ok()
10461 .and_then(|v| v["blocked_by"].as_array().cloned())
10462 .into_iter()
10463 .flatten()
10464 .filter_map(|v| v.as_str().map(str::to_string))
10465 .collect();
10466 for dep in &row.blockers {
10467 if held.iter().any(|h| h == dep) {
10468 continue;
10469 }
10470 run_captured("vissue", &["update", &row.id, "--block", dep])
10471 .with_context(|| format!("bump-plan: {} --block {dep}", row.id))?;
10472 }
10473 }
10474 if let Some(first) = rows.first() {
10476 let _ = persist_tracker(&first.id, "planned the bump");
10477 }
10478 Ok((generation, rows))
10479}
10480
10481#[must_use]
10482pub fn format_bump_rows(generation: &str, rows: &[BumpRow]) -> String {
10483 let mut out = format!(
10484 "{} module{} onto {generation}\n",
10485 rows.len(),
10486 if rows.len() == 1 { "" } else { "s" }
10487 );
10488 for r in rows {
10489 out.push_str(&format!(
10490 "{}\t{}\t{}\tafter {}\n",
10491 r.id,
10492 r.result,
10493 r.module,
10494 if r.blockers.is_empty() {
10495 "nothing".to_string()
10496 } else {
10497 r.blockers.join(" ")
10498 }
10499 ));
10500 }
10501 out
10502}
10503
10504#[cfg(test)]
10505mod tests {
10506 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
10509 static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
10510 ENV.lock().unwrap_or_else(|e| e.into_inner())
10511 }
10512
10513 #[test]
10515 fn a_tilde_tracker_root_expands_against_home() {
10516 use super::expand_leading_tilde as x;
10517 assert_eq!(x("~/vault", "/home/s"), Some("/home/s/vault".into()));
10518 assert_eq!(x("~", "/home/s/"), Some("/home/s".into()));
10519 assert_eq!(x("/abs/vault", "/home/s"), None);
10520 assert_eq!(x("~other/vault", "/home/s"), None);
10521 }
10522
10523 #[test]
10526 fn a_slow_tracker_push_finishes_in_the_background() {
10527 let _env = env_guard();
10528 let dir = tempfile::tempdir().unwrap();
10529 let (root, remote, hooks) = (
10530 dir.path().join("work"),
10531 dir.path().join("remote.git"),
10532 dir.path().join("hooks"),
10533 );
10534 let git = |cwd: &std::path::Path, args: &[&str]| {
10535 let o = std::process::Command::new("git")
10536 .arg("-C")
10537 .arg(cwd)
10538 .args(args)
10539 .output()
10540 .unwrap();
10541 assert!(
10542 o.status.success(),
10543 "git {args:?}: {}",
10544 String::from_utf8_lossy(&o.stderr)
10545 );
10546 };
10547 std::fs::create_dir_all(root.join("Software/probe")).unwrap();
10548 std::fs::create_dir_all(&hooks).unwrap();
10549 git(
10550 dir.path(),
10551 &["init", "-q", "--bare", remote.to_str().unwrap()],
10552 );
10553 git(&root, &["init", "-q"]);
10554 for (k, v) in [
10555 ("user.email", "seat@example.invalid"),
10556 ("user.name", "seat"),
10557 ("core.hooksPath", hooks.to_str().unwrap()),
10558 ] {
10559 git(&root, &["config", k, v]);
10560 }
10561 let hook = hooks.join("pre-push");
10562 std::fs::write(&hook, "#!/bin/sh\nsleep 4\n").unwrap();
10563 use std::os::unix::fs::PermissionsExt;
10564 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
10565 let issues = root.join("Software/probe/issues.org");
10566 let heading = "* TODO [#C] Probe\n:PROPERTIES:\n:ID: probe-c3d4\n:END:\n";
10567 std::fs::write(&issues, heading).unwrap();
10568 git(&root, &["add", "."]);
10569 git(&root, &["commit", "-q", "-m", "seed"]);
10570 git(
10571 &root,
10572 &["remote", "add", "origin", remote.to_str().unwrap()],
10573 );
10574 std::fs::write(&hook, "#!/bin/sh\nexit 0\n").unwrap();
10575 git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
10576 std::fs::write(&hook, "#!/bin/sh\nsleep 4\n").unwrap();
10577 std::env::set_var("VISSUE_ROOT", &root);
10578 std::env::set_var("VISSUE_NO_ROUTE", "1");
10579 std::env::remove_var("ISSUE_ROOT");
10580 std::env::remove_var("LJOS_TRACKER_GIT");
10581 std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "1");
10582 std::env::set_var("XDG_RUNTIME_DIR", dir.path());
10583
10584 std::fs::write(&issues, heading.replace("TODO", "STARTED")).unwrap();
10585 let started = std::time::Instant::now();
10586 let said = super::persist_tracker("probe-c3d4", "claimed");
10587 assert!(
10588 started.elapsed() < std::time::Duration::from_secs(3),
10589 "{said}"
10590 );
10591 assert!(said.contains("still running after 1s"), "{said}");
10592
10593 std::thread::sleep(std::time::Duration::from_secs(5));
10594 std::fs::write(&hook, "#!/bin/sh\nexit 0\n").unwrap();
10595 std::fs::write(&issues, heading.replace("TODO", "DONE")).unwrap();
10596 std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "10");
10597 let said = super::persist_tracker("probe-c3d4", "finished");
10598 assert!(said.contains("committed and pushed"), "{said}");
10599 for var in [
10600 "VISSUE_ROOT",
10601 "VISSUE_NO_ROUTE",
10602 "LJOS_TRACKER_PUSH_WAIT",
10603 "XDG_RUNTIME_DIR",
10604 ] {
10605 std::env::remove_var(var);
10606 }
10607 }
10608
10609 #[test]
10612 fn a_tracker_write_is_committed_alone() {
10613 let _env = env_guard();
10614 let dir = tempfile::tempdir().unwrap();
10615 let root = dir.path();
10616 let run = |args: &[&str]| {
10617 let o = std::process::Command::new("git")
10618 .arg("-C")
10619 .arg(root)
10620 .args(args)
10621 .output()
10622 .unwrap();
10623 assert!(
10624 o.status.success(),
10625 "git {args:?}: {}",
10626 String::from_utf8_lossy(&o.stderr)
10627 );
10628 String::from_utf8_lossy(&o.stdout).to_string()
10629 };
10630 run(&["init", "-q"]);
10631 run(&["config", "user.email", "seat@example.invalid"]);
10632 run(&["config", "user.name", "seat"]);
10633 run(&["config", "core.hooksPath", "/dev/null"]);
10634 std::fs::create_dir_all(root.join("Software/probe")).unwrap();
10635 let issues = root.join("Software/probe/issues.org");
10636 let heading = "* TODO [#C] Probe\n:PROPERTIES:\n:ID: probe-a1b2\n:END:\n";
10637 std::fs::write(&issues, heading).unwrap();
10638 std::fs::write(root.join("other.org"), "one\n").unwrap();
10639 run(&["add", "."]);
10640 run(&["commit", "-q", "-m", "seed"]);
10641 std::env::set_var("VISSUE_ROOT", root);
10642 std::env::set_var("VISSUE_NO_ROUTE", "1");
10643 std::env::remove_var("ISSUE_ROOT");
10644 std::env::set_var("LJOS_TRACKER_GIT", "commit");
10645 assert!(super::persist_tracker("probe-a1b2", "claimed").contains("nothing to commit"));
10646
10647 std::fs::write(&issues, heading.replace("TODO", "STARTED")).unwrap();
10648 std::fs::write(root.join("other.org"), "two\n").unwrap();
10649 run(&["add", "other.org"]);
10650 let said = super::persist_tracker("probe-a1b2", "claimed");
10651 assert!(
10652 said.contains("committed chore(issues): probe-a1b2 claimed"),
10653 "{said}"
10654 );
10655 assert_eq!(
10656 run(&["log", "-1", "--format=%s"]).trim(),
10657 "chore(issues): probe-a1b2 claimed"
10658 );
10659 assert_eq!(
10661 run(&["diff", "--cached", "--name-only"]).trim(),
10662 "other.org"
10663 );
10664
10665 std::fs::write(&issues, heading.replace("TODO", "DONE")).unwrap();
10666 std::env::set_var("LJOS_TRACKER_GIT", "off");
10667 assert!(super::persist_tracker("probe-a1b2", "finished").contains("off"));
10668 for var in ["VISSUE_ROOT", "VISSUE_NO_ROUTE", "LJOS_TRACKER_GIT"] {
10669 std::env::remove_var(var);
10670 }
10671 }
10672
10673 #[test]
10676 fn a_tracker_commit_with_no_remote_still_reports_the_commit() {
10677 let _env = env_guard();
10678 let dir = tempfile::tempdir().unwrap();
10679 let root = dir.path();
10680 let run = |args: &[&str]| {
10681 let o = std::process::Command::new("git")
10682 .arg("-C")
10683 .arg(root)
10684 .args(args)
10685 .output()
10686 .unwrap();
10687 assert!(
10688 o.status.success(),
10689 "git {args:?}: {}",
10690 String::from_utf8_lossy(&o.stderr)
10691 );
10692 String::from_utf8_lossy(&o.stdout).to_string()
10693 };
10694 run(&["init", "-q"]);
10695 run(&["config", "user.email", "seat@example.invalid"]);
10696 run(&["config", "user.name", "seat"]);
10697 run(&["config", "core.hooksPath", "/dev/null"]);
10698 std::fs::create_dir_all(root.join("Software/probe")).unwrap();
10699 let issues = root.join("Software/probe/issues.org");
10700 let heading = "* TODO [#C] Probe\n:PROPERTIES:\n:ID: probe-a1b2\n:END:\n";
10701 std::fs::write(&issues, heading).unwrap();
10702 run(&["add", "."]);
10703 run(&["commit", "-q", "-m", "seed"]);
10704 std::fs::write(&issues, heading.replace("TODO", "STARTED")).unwrap();
10705 std::env::set_var("VISSUE_ROOT", root);
10706 std::env::set_var("VISSUE_NO_ROUTE", "1");
10707 std::env::remove_var("ISSUE_ROOT");
10708 std::env::remove_var("LJOS_TRACKER_GIT");
10709 let said = super::persist_tracker("probe-a1b2", "claimed");
10710 assert!(
10711 said.contains("tracker git: committed chore(issues): probe-a1b2 claimed"),
10712 "{said}"
10713 );
10714 assert!(
10715 said.contains("push refused") || said.contains("not pushed"),
10716 "a missing remote must still name the commit: {said}"
10717 );
10718 assert_eq!(
10719 run(&["log", "-1", "--format=%s"]).trim(),
10720 "chore(issues): probe-a1b2 claimed"
10721 );
10722 for var in ["VISSUE_ROOT", "VISSUE_NO_ROUTE", "LJOS_TRACKER_GIT"] {
10723 std::env::remove_var(var);
10724 }
10725 }
10726
10727 #[test]
10730 fn a_claim_graph_nobody_made_yet_is_not_a_fault() {
10731 let fresh = "claimdag exited exit status: 1: no work graph at /h/claims: the directory does not exist, so nothing has been claimed on this seat. Set CLAIMDAG_DIR";
10732 assert_eq!(
10733 super::claim_graph_absent(fresh),
10734 Some("/h/claims".to_string())
10735 );
10736 assert_eq!(
10737 super::claim_graph_absent("claimdag exited exit status: 1: work.bin is corrupt"),
10738 None
10739 );
10740 assert_eq!(
10741 super::claim_graph_absent("no work graph at /h/claims: permission denied"),
10742 None
10743 );
10744 }
10745
10746 #[test]
10748 fn tracker_row_names_the_root_and_refuses_a_private_one() {
10749 let dir = tempfile::tempdir().unwrap();
10750 std::fs::create_dir(dir.path().join("Software")).unwrap();
10751 let id = |root: &str| format!("vissue 0.16.2\nprotocol: 1\nroot={root}\nprefix=Software\n");
10752 let root = dir.path().display().to_string();
10753
10754 let (state, ok) = super::tracker_state(&id(&root), "VISSUE_ROOT=x");
10755 assert!(ok, "{state}");
10756 assert!(state.contains(&format!("root={root}")), "{state}");
10757 assert!(state.contains("from VISSUE_ROOT=x"), "{state}");
10758
10759 let (state, ok) = super::tracker_state(&id("~/Git/vault"), "VISSUE_ROOT=~/Git/vault");
10760 assert!(!ok);
10761 assert!(state.contains("relative root"), "{state}");
10762
10763 let missing = dir.path().join("gone").display().to_string();
10764 assert!(!super::tracker_state(&id(&missing), "cwd").1);
10765
10766 std::fs::remove_dir(dir.path().join("Software")).unwrap();
10767 let (state, ok) = super::tracker_state(&id(&root), "cwd");
10768 assert!(!ok);
10769 assert!(state.contains("no prefix directory"), "{state}");
10770
10771 assert!(!super::tracker_state("vissue 0.16.1\n", "cwd").1);
10772 }
10773
10774 fn git_scratch(root: &std::path::Path) {
10775 let run = |args: &[&str]| {
10776 let o = std::process::Command::new("git")
10777 .arg("-C")
10778 .arg(root)
10779 .args(args)
10780 .output()
10781 .unwrap();
10782 assert!(
10783 o.status.success(),
10784 "git {args:?}: {}",
10785 String::from_utf8_lossy(&o.stderr)
10786 );
10787 };
10788 run(&["init", "-q"]);
10789 run(&["config", "user.email", "seat@example.invalid"]);
10790 run(&["config", "user.name", "seat"]);
10791 run(&["config", "core.hooksPath", "/dev/null"]);
10792 }
10793
10794 #[test]
10797 fn tracker_row_fails_when_two_remotes_disagree() {
10798 let _env = env_guard();
10799 let dir = tempfile::tempdir().unwrap();
10800 let root = dir.path().join("work");
10801 std::fs::create_dir_all(root.join("Software")).unwrap();
10802 let git = |cwd: &std::path::Path, args: &[&str]| {
10803 let o = std::process::Command::new("git")
10804 .arg("-C")
10805 .arg(cwd)
10806 .args(args)
10807 .output()
10808 .unwrap();
10809 assert!(
10810 o.status.success(),
10811 "git {args:?}: {}",
10812 String::from_utf8_lossy(&o.stderr)
10813 );
10814 };
10815 for bare in ["origin.git", "mirror.git"] {
10816 git(dir.path(), &["init", "-q", "--bare", bare]);
10817 }
10818 git_scratch(&root);
10819 std::fs::write(root.join("Software/.keep"), "").unwrap();
10820 git(&root, &["add", "."]);
10821 git(&root, &["commit", "-q", "-m", "seed"]);
10822 for name in ["origin", "mirror"] {
10823 let url = dir.path().join(format!("{name}.git"));
10824 git(&root, &["remote", "add", name, url.to_str().unwrap()]);
10825 git(&root, &["push", "-q", name, "HEAD:refs/heads/main"]);
10826 }
10827 git(&root, &["branch", "-q", "-M", "main"]);
10828 git(&root, &["fetch", "-q", "--all"]);
10829 git(&root, &["branch", "-q", "-u", "origin/main"]);
10830 let (state, ok) = super::tracker_git_drift(&root).unwrap();
10831 assert!(ok, "{state}");
10832 assert_eq!(
10833 super::tracker_mirrors(&root, "origin/main").unwrap(),
10834 vec![("mirror".to_string(), "main".to_string())],
10835 "a tracker push reaches the mirror too"
10836 );
10837
10838 std::fs::write(root.join("Software/.keep"), "one side\n").unwrap();
10839 git(&root, &["commit", "-qam", "only origin"]);
10840 git(&root, &["push", "-q", "origin", "main"]);
10841 git(&root, &["fetch", "-q", "--all"]);
10842 let (state, ok) = super::tracker_git_drift(&root).unwrap();
10843 assert!(!ok, "{state}");
10844 assert!(
10845 state.contains("mirror/main differs from origin/main"),
10846 "{state}"
10847 );
10848
10849 git(&root, &["push", "-q", "mirror", "main"]);
10850 git(&root, &["fetch", "-q", "--all"]);
10851 let (state, ok) = super::tracker_git_drift(&root).unwrap();
10852 assert!(ok, "{state}");
10853 }
10854
10855 #[test]
10858 fn tracker_row_fails_when_origin_never_got_the_commits() {
10859 let _env = env_guard();
10860 let dir = tempfile::tempdir().unwrap();
10861 let (root, remote) = (dir.path().join("work"), dir.path().join("remote.git"));
10862 std::fs::create_dir_all(root.join("Software")).unwrap();
10863 let git = |cwd: &std::path::Path, args: &[&str]| {
10864 let o = std::process::Command::new("git")
10865 .arg("-C")
10866 .arg(cwd)
10867 .args(args)
10868 .output()
10869 .unwrap();
10870 assert!(
10871 o.status.success(),
10872 "git {args:?}: {}",
10873 String::from_utf8_lossy(&o.stderr)
10874 );
10875 };
10876 git(
10877 dir.path(),
10878 &["init", "-q", "--bare", remote.to_str().unwrap()],
10879 );
10880 git_scratch(&root);
10881 std::fs::write(root.join("Software/.keep"), "").unwrap();
10882 git(&root, &["add", "."]);
10883 git(&root, &["commit", "-q", "-m", "seed"]);
10884 git(
10885 &root,
10886 &["remote", "add", "origin", remote.to_str().unwrap()],
10887 );
10888 git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
10889
10890 let id = |r: &str| format!("vissue 0.16.2\nprotocol: 1\nroot={r}\nprefix=Software\n");
10891 let root_s = root.display().to_string();
10892 std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "5");
10893 std::env::set_var("XDG_RUNTIME_DIR", dir.path());
10894
10895 let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
10896 assert!(ok, "{state}");
10897 assert!(state.contains("0 unpushed"), "{state}");
10898
10899 std::fs::write(root.join("Software/.keep"), "local\n").unwrap();
10900 git(&root, &["add", "."]);
10901 git(&root, &["commit", "-q", "-m", "ahead"]);
10902 let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
10903 assert!(ok, "a commit younger than the wait stays healthy: {state}");
10904 assert!(state.contains("1 unpushed"), "{state}");
10905
10906 std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "0");
10907 let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
10908 assert!(!ok, "{state}");
10909 assert!(state.contains("1 unpushed"), "{state}");
10910
10911 let mut dead = std::process::Command::new("true").spawn().unwrap();
10912 let dead_pid = dead.id();
10913 let _ = dead.wait();
10914 let logs = dir.path().join("ljos");
10915 std::fs::create_dir_all(&logs).unwrap();
10916 std::fs::write(
10917 logs.join(format!("tracker-push-{dead_pid}.log")),
10918 "remote: pre-push hook declined\nerror: failed to push some refs\n",
10919 )
10920 .unwrap();
10921 let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
10922 assert!(!ok, "{state}");
10923 assert!(state.contains("1 unpushed"), "{state}");
10924 assert!(
10925 state.contains("last push refused: remote: pre-push hook declined"),
10926 "{state}"
10927 );
10928
10929 for var in ["LJOS_TRACKER_PUSH_WAIT", "XDG_RUNTIME_DIR"] {
10930 std::env::remove_var(var);
10931 }
10932 }
10933
10934 #[test]
10935 fn tracker_row_stays_healthy_while_a_background_push_runs() {
10936 let _env = env_guard();
10937 let dir = tempfile::tempdir().unwrap();
10938 let (root, remote) = (dir.path().join("work"), dir.path().join("remote.git"));
10939 std::fs::create_dir_all(root.join("Software")).unwrap();
10940 let git = |cwd: &std::path::Path, args: &[&str]| {
10941 let o = std::process::Command::new("git")
10942 .arg("-C")
10943 .arg(cwd)
10944 .args(args)
10945 .output()
10946 .unwrap();
10947 assert!(
10948 o.status.success(),
10949 "git {args:?}: {}",
10950 String::from_utf8_lossy(&o.stderr)
10951 );
10952 };
10953 git(
10954 dir.path(),
10955 &["init", "-q", "--bare", remote.to_str().unwrap()],
10956 );
10957 git_scratch(&root);
10958 std::fs::write(root.join("Software/.keep"), "").unwrap();
10959 git(&root, &["add", "."]);
10960 git(&root, &["commit", "-q", "-m", "seed"]);
10961 git(
10962 &root,
10963 &["remote", "add", "origin", remote.to_str().unwrap()],
10964 );
10965 git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
10966 std::fs::write(root.join("Software/.keep"), "local\n").unwrap();
10967 git(&root, &["add", "."]);
10968 git(&root, &["commit", "-q", "-m", "ahead"]);
10969
10970 let mut sleeper = std::process::Command::new("sleep")
10971 .arg("8")
10972 .spawn()
10973 .unwrap();
10974 let pid = sleeper.id();
10975 let logs = dir.path().join("ljos");
10976 std::fs::create_dir_all(&logs).unwrap();
10977 std::fs::write(logs.join(format!("tracker-push-{pid}.log")), "").unwrap();
10978 std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "0");
10979 std::env::set_var("XDG_RUNTIME_DIR", dir.path());
10980 let id = format!(
10981 "vissue 0.16.2\nprotocol: 1\nroot={}\nprefix=Software\n",
10982 root.display()
10983 );
10984 let (state, ok) = super::tracker_state(&id, "VISSUE_ROOT=x");
10985 let _ = sleeper.kill();
10986 let _ = sleeper.wait();
10987 assert!(ok, "{state}");
10988 assert!(state.contains("1 unpushed; push still running"), "{state}");
10989 for var in ["LJOS_TRACKER_PUSH_WAIT", "XDG_RUNTIME_DIR"] {
10990 std::env::remove_var(var);
10991 }
10992 }
10993
10994 #[test]
10995 fn a_session_id_occupies_not_the_product_name_on_the_box() {
10996 let _g = env_guard();
10997 unsafe {
10998 std::env::remove_var("VISSUE_AGENT");
10999 std::env::set_var("LJOS_SEAT", "runner-x");
11000 std::env::set_var("GROK_SESSION_ID", "01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11001 }
11002 let holder = resolve_assignee(None);
11003 assert_eq!(
11004 holder, "01a09b25-ffe9-7972-881a-3cee2ea6efd6",
11005 "the session is the occupancy, not a prefix and not the seat"
11006 );
11007 assert_eq!(resolve_assignee(Some("seat")), holder);
11008 assert_eq!(
11009 resolve_assignee(Some("runner-x")),
11010 holder,
11011 "the process naming itself is omitted"
11012 );
11013 assert_eq!(resolve_assignee(Some("alice")), "alice");
11014 assert_eq!(seat_name(), "runner-x");
11015 unsafe {
11016 std::env::remove_var("GROK_SESSION_ID");
11017 std::env::remove_var("LJOS_SEAT");
11018 }
11019 }
11020
11021 #[test]
11022 fn two_session_ids_that_share_a_prefix_occupy_different_slots() {
11023 let _g = env_guard();
11024 unsafe {
11025 std::env::remove_var("LJOS_SEAT");
11026 std::env::remove_var("VISSUE_AGENT");
11027 std::env::set_var("GROK_SESSION_ID", "01a09b25-aaaa-7972-881a-3cee2ea6efd6");
11028 }
11029 let a = resolve_assignee(None);
11030 unsafe {
11031 std::env::set_var("GROK_SESSION_ID", "01a09b25-bbbb-7972-881a-3cee2ea6efd6");
11032 }
11033 let b = resolve_assignee(None);
11034 assert_ne!(
11035 a, b,
11036 "a shared eight-character prefix is not one conversation"
11037 );
11038 assert_eq!(a, "01a09b25-aaaa-7972-881a-3cee2ea6efd6");
11039 assert_eq!(b, "01a09b25-bbbb-7972-881a-3cee2ea6efd6");
11040 unsafe {
11041 std::env::remove_var("GROK_SESSION_ID");
11042 }
11043 }
11044
11045 #[test]
11046 fn a_named_holder_refusal_still_says_held_by_another() {
11047 let hold = Hold {
11048 assignee: "acme".into(),
11049 seat: "acme".into(),
11050 pid: 1,
11051 comm: "ljos".into(),
11052 since: "2026-01-01T00:00:00.000Z".into(),
11053 };
11054 let said = super::held_by_another_message("demo-aaaa", "brio", &hold, "still running");
11055 assert!(said.contains("held by another"), "{said}");
11056 assert!(said.contains("acme"), "{said}");
11057 assert!(said.contains("not by brio"), "{said}");
11058 }
11059
11060 #[test]
11062 fn two_seats_with_distinct_session_ids_are_distinct_holders() {
11063 let _g = env_guard();
11064 let dir = std::env::temp_dir().join(format!("ljos-rt-two-seat-{}", std::process::id()));
11065 std::fs::create_dir_all(&dir).unwrap();
11066 let session_keys: Vec<String> = std::env::vars()
11067 .map(|(k, _)| k)
11068 .filter(|k| k.ends_with("_SESSION_ID"))
11069 .collect();
11070 unsafe {
11071 std::env::set_var("XDG_RUNTIME_DIR", &dir);
11072 std::env::remove_var("VISSUE_AGENT");
11073 for k in &session_keys {
11074 std::env::remove_var(k);
11075 }
11076 std::env::set_var("LJOS_SEAT", "acme");
11077 std::env::set_var("ACME_SESSION_ID", "acme-sess-aaaaaa");
11078 }
11079 let a_seat = seat_name();
11080 let a_holder = resolve_assignee(None);
11081 unsafe {
11082 std::env::remove_var("ACME_SESSION_ID");
11083 std::env::set_var("LJOS_SEAT", "brio");
11084 std::env::set_var("BRIO_SESSION_ID", "brio-sess-bbbbbb");
11085 }
11086 let b_seat = seat_name();
11087 let b_holder = resolve_assignee(None);
11088 assert_eq!(a_seat, "acme");
11089 assert_eq!(b_seat, "brio");
11090 assert_eq!(a_holder, "acme-sess-aaaaaa");
11091 assert_eq!(b_holder, "brio-sess-bbbbbb");
11092 assert_ne!(a_holder, b_holder);
11093 unsafe {
11094 std::env::remove_var("LJOS_SEAT");
11095 std::env::remove_var("BRIO_SESSION_ID");
11096 std::env::remove_var("ACME_SESSION_ID");
11097 std::env::remove_var("XDG_RUNTIME_DIR");
11098 }
11099 }
11100
11101 #[test]
11102 fn occupancy_is_per_issue_so_two_sittings_do_not_unseat() {
11103 let _g = env_guard();
11104 unsafe {
11105 std::env::remove_var("LJOS_SEAT");
11106 std::env::remove_var("VISSUE_AGENT");
11107 }
11108 let holder = resolve_assignee(None);
11109 let a = occupancy_assignee(None, "ljos-aaaa");
11110 let b = occupancy_assignee(None, "ljos-bbbb");
11111 assert_ne!(
11112 a, b,
11113 "two issues under one conversation must not share a slot"
11114 );
11115 assert_eq!(a, format!("{holder}:ljos-aaaa"), "{a}");
11116 assert_eq!(b, format!("{holder}:ljos-bbbb"), "{b}");
11117 assert_eq!(
11118 occupancy_assignee(Some("alice"), "ljos-aaaa"),
11119 "alice:ljos-aaaa"
11120 );
11121 assert_eq!(
11122 occupancy_assignee(Some("alice"), "ljos-bbbb"),
11123 "alice:ljos-bbbb"
11124 );
11125 }
11126
11127 #[test]
11128 fn doctor_lists_ljos_hud_but_does_not_require_it() {
11129 assert!(SEAT_BINS
11130 .iter()
11131 .any(|(n, c)| *n == "ljos-hud" && *c == "ljos-hud"));
11132 assert!(!REQUIRED.contains(&"ljos-hud"));
11133 }
11134
11135 #[test]
11136 fn doctor_names_the_session_not_the_default_seat() {
11137 let _g = env_guard();
11138 let dir = std::env::temp_dir().join(format!("ljos-rt-doctor-{}", std::process::id()));
11141 std::fs::create_dir_all(&dir).unwrap();
11142 unsafe {
11143 std::env::set_var("XDG_RUNTIME_DIR", &dir);
11144 std::env::remove_var("LJOS_SEAT");
11145 std::env::remove_var("VISSUE_AGENT");
11146 std::env::set_var("GROK_SESSION_ID", "01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11147 }
11148 let row = format_seat_row();
11149 assert!(
11150 row.contains("01a09b25-ffe9-7972-881a-3cee2ea6efd6"),
11151 "doctor names the whole session: {row}"
11152 );
11153 assert!(
11154 row.contains("GROK_SESSION_ID"),
11155 "doctor names where the session came from: {row}"
11156 );
11157 assert!(!row.contains("the default"), "{row}");
11158 unsafe {
11159 std::env::remove_var("GROK_SESSION_ID");
11160 std::env::remove_var("XDG_RUNTIME_DIR");
11161 }
11162 let _ = std::fs::remove_dir_all(&dir);
11163 }
11164
11165 #[test]
11166 fn a_shared_name_does_not_occupy_the_whole_host() {
11167 let _g = env_guard();
11168 let holder = resolve_assignee(None);
11172 assert_eq!(resolve_assignee(Some("you")), holder);
11173 assert_eq!(resolve_assignee(Some("seat")), holder);
11174 assert_eq!(resolve_assignee(Some("agent")), holder);
11175 assert_ne!(holder, "seat");
11176 assert_eq!(resolve_assignee(Some("alice")), "alice");
11177 }
11178
11179 #[test]
11180 fn a_reading_supersedes_the_one_before_and_keeps_it_as_was() {
11181 assert_eq!(parse_every("7d").unwrap(), 7 * 86_400);
11182 assert_eq!(parse_every("24h").unwrap(), 86_400);
11183 assert_eq!(parse_every("2w").unwrap(), 14 * 86_400);
11184 assert_eq!(parse_every("90").unwrap(), 90);
11185 assert!(parse_every("soon").is_err());
11186 assert!(parse_every("0d").is_err());
11187 assert_eq!(
11188 stamp_after("2026-09-19T23:30:00.000Z", 3_600).as_deref(),
11189 Some("2026-09-20T00:30:00.000Z")
11190 );
11191 assert_eq!(trim_num(0.5790), "0.579");
11192 assert_eq!(trim_num(12.0), "12");
11193 assert_eq!(
11194 habit_text("mab cr all", 0.579, "acc", "job 11793"),
11195 "habit mab cr all stands at 0.579 acc (job 11793)."
11196 );
11197 let first = serde_json::json!({
11198 "id": "a1", "kind": "habit", "ts": "2026-09-12T10:00:00.000Z",
11199 "due_at": "2026-09-19T10:00:00.000Z",
11200 "habit": {"name": "mab cr all", "value": 0.535, "unit": "acc", "source": "11750", "every_s": 604800}
11201 });
11202 let second = serde_json::json!({
11203 "id": "a2", "kind": "habit", "ts": "2026-09-19T10:00:00.000Z",
11204 "due_at": "2026-09-26T10:00:00.000Z",
11205 "habit": {"name": "mab cr all", "value": 0.579, "unit": "acc", "source": "11793", "every_s": 604800,
11206 "was": 0.535, "was_ts": "2026-09-12T10:00:00.000Z"}
11207 });
11208 let other = serde_json::json!({
11209 "id": "l1", "kind": "lesson", "text": "not a habit", "ts": "2026-09-19T10:00:00.000Z"
11210 });
11211 let rows = readings_of(&[first.clone(), other, second]);
11213 assert_eq!(rows.len(), 1);
11214 assert_eq!(rows[0].id.as_deref(), Some("a2"));
11215 assert_eq!(rows[0].was, Some(0.535));
11216 let now = "2026-09-20T09:00:00.000Z";
11217 let line = format_readings(&rows, now);
11218 assert!(line.starts_with("mab cr all\t0.579 acc\t+0.044 since 0.535 (8 days ago)\tyesterday\tnext reading in 6 days\t11793\n"), "{line}");
11219 let late = readings_of(&[first]);
11220 assert!(format_readings(&late, now).contains("next reading late (yesterday)"));
11221 assert_eq!(format_change(&late[0], now), "first reading");
11222 }
11223
11224 #[test]
11225 fn a_program_is_named_by_its_path_not_its_version() {
11226 assert!(version_like("2.1.266"));
11227 assert!(version_like("v18.2.0"));
11228 assert!(!version_like("acme"));
11229 let me = program_name(std::process::id(), "comm");
11232 assert!(!me.is_empty() && !version_like(&me), "{me}");
11233 }
11234
11235 #[test]
11236 fn a_hit_names_the_seat_that_wrote_it_only_when_that_is_another() {
11237 let ents = vec!["seat:brio".to_string(), "habit:x".to_string()];
11238 assert_eq!(other_seat(&ents, "acme-cli").as_deref(), Some("brio"));
11239 assert_eq!(other_seat(&ents, "brio"), None);
11240 assert_eq!(other_seat(&["habit:x".to_string()], "brio"), None);
11241 }
11242
11243 #[test]
11244 fn two_session_ids_that_share_a_prefix_take_two_slots() {
11245 let a = session_tag("01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11246 let b = session_tag("01a09b25-ffe9-7972-881a-3cee2ea6efd7");
11247 assert_ne!(a, b);
11248 assert_eq!(a.len(), 10);
11249 assert_eq!(a, session_tag(" 01a09b25-ffe9-7972-881a-3cee2ea6efd6 "));
11250 }
11251
11252 #[test]
11255 fn a_record_from_another_conversation_is_not_this_ones() {
11256 let ble = "1000000000.000001/4242".to_string();
11257 let me = "01a09b25-ffe9-7972-881a-000000000001".to_string();
11258 let other = "01a09b25-ffe9-7972-881a-000000000002".to_string();
11259 let mine = vec![ble.clone(), me.clone()];
11260 let theirs = format!("acme-cli\nsess-other\nids\t{ble}\t{other}\n");
11261 assert!(super::record_for(&theirs, &mine, "t".into()).is_none());
11262 let ours = format!("acme-cli\nsess-mine\nids\t{ble}\t{me}\n");
11263 assert_eq!(
11264 super::record_for(&ours, &mine, "t".into()).unwrap().holder,
11265 "sess-mine"
11266 );
11267 let shell = vec![ble.clone(), me.clone(), "9f9f9f9f-extra".into()];
11269 assert!(super::record_for(&ours, &shell, "t".into()).is_some());
11270 assert!(super::record_for("acme-cli\nsess-old\n", &mine, "t".into()).is_some());
11272 }
11273
11274 #[test]
11275 fn the_host_row_reads_oom_kills_and_this_logins_servers() {
11276 assert_eq!(
11277 parse_oom_kills("pgfault 12\noom_kill 43\nnr_free_pages 1\n"),
11278 Some(43)
11279 );
11280 assert_eq!(parse_oom_kills("pgfault 12\n"), None);
11281 assert_eq!(
11282 status_field("Name:\tx\nVmRSS:\t 2692 kB\n", "VmRSS:").as_deref(),
11283 Some("2692")
11284 );
11285 let row = host_row();
11286 assert_eq!(row.name, "host");
11287 assert!(row.state.contains("ljos-mcp"), "{}", row.state);
11288 }
11289
11290 #[test]
11291 fn a_library_default_client_name_is_not_a_seat() {
11292 assert_eq!(seat_for_client("Acme CLI"), "acme-cli");
11293 for library in ["mcp", "MCP", "mcp-client"] {
11294 let seat = seat_for_client(library);
11295 assert!(
11296 !LIBRARY_CLIENT_NAMES.contains(&seat.as_str()) || ancestry().is_empty(),
11297 "{library} named the seat {seat}"
11298 );
11299 }
11300 }
11301
11302 #[test]
11303 fn a_runner_started_inside_another_keeps_its_own_holder() {
11304 let _g = env_guard();
11305 let dir = std::env::temp_dir().join(format!("ljos-nest-{}", std::process::id()));
11306 std::fs::create_dir_all(&dir).unwrap();
11307 unsafe {
11308 std::env::set_var("XDG_RUNTIME_DIR", &dir);
11309 std::env::set_var("ACME_SESSION_ID", "01a09b25-1111-7972-881a-3cee2ea6efd6");
11310 }
11311 let parent = announce_seat("Acme CLI", 5151);
11312 let child = announce_seat("Brio Agent", 5252);
11314 assert_eq!(child.seat, "brio-agent");
11315 assert_ne!(child.holder, parent.holder);
11316 assert_eq!(
11317 seat_from_session_records()
11318 .expect("the parent's record")
11319 .holder,
11320 parent.holder,
11321 "the child leaves the parent's record alone"
11322 );
11323 retire_seat(5252);
11324 assert_eq!(
11325 seat_from_session_records()
11326 .expect("still the parent's")
11327 .holder,
11328 parent.holder,
11329 "the child's exit does not take the parent's record"
11330 );
11331 retire_seat(5151);
11332 assert!(seat_from_session_records().is_none());
11333 unsafe {
11334 std::env::remove_var("ACME_SESSION_ID");
11335 std::env::remove_var("XDG_RUNTIME_DIR");
11336 }
11337 let _ = std::fs::remove_dir_all(&dir);
11338 }
11339
11340 #[test]
11341 fn a_thread_named_on_a_call_holds_as_its_shells_do() {
11342 let _g = env_guard();
11343 let dir = std::env::temp_dir().join(format!("ljos-thread-{}", std::process::id()));
11344 std::fs::create_dir_all(&dir).unwrap();
11345 unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
11346 assert!(runner_session_var("ACME_THREAD_ID", "0199a1b2-c3d4"));
11347 assert!(!runner_session_var("ACME_THREAD_ID", "short"));
11348 let first = seat_for_thread("0199a1b2-aaaa-thread");
11350 assert_eq!(first.holder, "0199a1b2-aaaa-thread");
11351 let text = std::fs::read_to_string(session_record_path("0199a1b2-aaaa-thread")).unwrap();
11352 assert_eq!(
11353 holder_naming(&text, "0199a1b2-aaaa-thread").as_deref(),
11354 Some("0199a1b2-aaaa-thread")
11355 );
11356 let shell = Seat {
11358 seat: "acme".into(),
11359 holder: "sess-shellfirst".into(),
11360 source: String::new(),
11361 };
11362 write_record_ids(
11363 &session_record_path("0199a1b2-bbbb-thread"),
11364 &shell,
11365 &["line-editor-id".into(), "0199a1b2-bbbb-thread".into()],
11366 );
11367 assert_eq!(
11368 seat_for_thread("0199a1b2-bbbb-thread").holder,
11369 "sess-shellfirst"
11370 );
11371 unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11372 let _ = std::fs::remove_dir_all(&dir);
11373 }
11374
11375 #[test]
11376 fn a_shell_with_one_more_session_variable_finds_the_servers_record() {
11377 let _g = env_guard();
11378 let dir = std::env::temp_dir().join(format!("ljos-rt-{}", std::process::id()));
11379 std::fs::create_dir_all(&dir).unwrap();
11380 unsafe {
11381 std::env::set_var("XDG_RUNTIME_DIR", &dir);
11382 std::env::set_var("ACME_SESSION_ID", "01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11383 }
11384 let server = announce_seat("Acme CLI", 4242);
11385 assert_eq!(server.seat, "acme-cli");
11386 unsafe {
11389 std::env::set_var(
11390 "AAA_LINE_EDITOR_SESSION_ID",
11391 "9f9f9f9f-0000-0000-0000-000000000000",
11392 );
11393 }
11394 let shell = seat_from_session_records().expect("the shared id finds the record");
11395 assert_eq!(shell.holder, server.holder);
11396 assert_eq!(shell.seat, server.seat);
11397 retire_seat(4242);
11398 assert!(seat_from_session_records().is_none());
11399 unsafe {
11400 std::env::remove_var("ACME_SESSION_ID");
11401 std::env::remove_var("AAA_LINE_EDITOR_SESSION_ID");
11402 std::env::remove_var("XDG_RUNTIME_DIR");
11403 }
11404 let _ = std::fs::remove_dir_all(&dir);
11405 assert_ne!(session_tag("01a09b25-aaaa"), session_tag("01a09b25-bbbb"));
11406 }
11407
11408 #[test]
11409 fn a_panel_seats_the_personas_that_speak_to_the_issue() {
11410 let mk = |name: &str, about: &[&str]| Persona {
11411 name: name.into(),
11412 anchor: 0.5,
11413 view: String::new(),
11414 entities: about.iter().map(|s| (*s).to_string()).collect(),
11415 };
11416 let all = vec![
11417 mk("reviewer", &["docs"]),
11418 mk("cuda", &["gpu", "kernels"]),
11419 mk("reader", &[]),
11420 ];
11421 let docs = personas_speaking_to(&all, &["Docs".to_string(), "site".to_string()]);
11422 assert_eq!(
11423 docs.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11424 ["reviewer"]
11425 );
11426 let nobody = personas_speaking_to(&all, &["fortran".to_string()]);
11427 assert_eq!(
11428 nobody.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11429 ["reader"],
11430 "no domain match seats only personas with no domains"
11431 );
11432 let specialists = vec![mk("reviewer", &["docs"]), mk("cuda", &["gpu"])];
11433 assert!(personas_speaking_to(&specialists, &["fortran".to_string()]).is_empty());
11434 let scoped = vec![
11435 mk("seatkeeper", &["seat", "ballot", "sync:rgsurflat"]),
11436 mk("cuda", &["gpu", "sync:rgsurflat"]),
11437 ];
11438 let seated = personas_speaking_to(
11439 &scoped,
11440 &["ballot".to_string(), "sync:rgsurflat".to_string()],
11441 );
11442 assert_eq!(
11443 seated.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11444 ["seatkeeper"],
11445 "a shared sync scope does not seat the roster"
11446 );
11447 let mut merger = mk("merger", &["git"]);
11448 merger.view = "Reads a merge for the writer it silently drops.".into();
11449 let mut other = mk("other", &["gpu"]);
11450 other.view = "Wants the kernel to be fast.".into();
11451 let by_view = personas_speaking_to(
11452 &[merger, other],
11453 &["merge".to_string(), "writers".to_string()],
11454 );
11455 assert_eq!(
11456 by_view.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11457 ["merger"],
11458 "a specialist whose view uses the issue's words is seated"
11459 );
11460 }
11461
11462 #[test]
11463 fn a_client_name_is_one_seat_however_it_is_spelt() {
11464 assert_eq!(seat_slug("Acme CLI"), "acme-cli");
11465 assert_eq!(seat_slug("acme_cli/1.2"), "acme-cli-1-2");
11466 assert_eq!(seat_slug(" -- "), "runner");
11467 assert_eq!(conversation_tag(4242), "39u");
11468 assert_eq!(conversation_tag(0), "0");
11469 }
11470
11471 #[test]
11472 fn the_server_leaves_a_record_a_shell_below_the_runner_reads() {
11473 let dir = std::env::temp_dir().join(format!("ljos-seat-{}", std::process::id()));
11474 std::fs::create_dir_all(&dir).unwrap();
11475 let path = dir.join("ljos").join("seat-4242");
11478 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
11479 let seat = Seat::tagged(
11480 seat_slug("Acme CLI"),
11481 &conversation_tag(4242),
11482 "test".to_string(),
11483 );
11484 std::fs::write(&path, format!("{}\n{}\n", seat.seat, seat.holder)).unwrap();
11485 let text = std::fs::read_to_string(&path).unwrap();
11486 let mut lines = text.lines();
11487 assert_eq!(lines.next(), Some("acme-cli"));
11488 assert_eq!(lines.next(), Some("acme-cli-39u"));
11489 assert_eq!(
11490 format_seat(&seat),
11491 "seat\tacme-cli\nholder\tacme-cli-39u\nsource\ttest\n"
11492 );
11493 let _ = std::fs::remove_dir_all(&dir);
11494 }
11495
11496 #[test]
11497 fn the_record_weighs_a_voter_by_what_it_got_right() {
11498 let ballots = vec![
11499 ("a".to_string(), "ship".to_string()),
11500 ("b".to_string(), "ship".to_string()),
11501 ("c".to_string(), "hold".to_string()),
11502 ];
11503 let (rows, records) =
11504 learn_record(&ballots, "ship", &std::collections::BTreeMap::new(), &[]).unwrap();
11505 assert_eq!(records["a"], (1.0, 0.0));
11506 assert_eq!(records["c"], (0.0, 1.0));
11507 let w = |to: &str| rows.iter().find(|r| r.to == to).unwrap().weight;
11508 assert_eq!(w("a"), 1.0, "a right voter stands at one");
11509 assert!(w("c") < w("a"), "a wrong voter stands lower");
11510 assert_eq!(rows.len(), 6, "complete over the voters");
11511 let (rows2, records2) = learn_record(&ballots, "ship", &records, &[]).unwrap();
11513 assert_eq!(records2["c"], (0.0, 2.0));
11514 let w2 = |to: &str| rows2.iter().find(|r| r.to == to).unwrap().weight;
11515 assert!(w2("c") <= w("c"));
11516 assert!(learn_record(&ballots, " ", &records, &[]).is_err());
11517 let atoms = vec![
11519 serde_json::json!({"kind": "trust", "from": "a", "to": "c", "weight": 0.2, "hits": 1.0, "misses": 3.0, "ts": "2026-09-13T01:00:00Z"}),
11520 serde_json::json!({"kind": "trust", "from": "b", "to": "c", "weight": 0.5, "hits": 1.0, "misses": 1.0, "ts": "2026-09-12T01:00:00Z"}),
11521 ];
11522 assert_eq!(records_from_atoms(&atoms)["c"], (1.0, 3.0));
11523 }
11524
11525 #[test]
11526 fn a_correction_is_nudged_once_a_session_and_only_on_a_prompt() {
11527 let _g = env_guard();
11528 let dir = std::env::temp_dir().join(format!("ljos-corr-{}", std::process::id()));
11530 std::fs::create_dir_all(&dir).unwrap();
11531 unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
11532 let prompt = HookCall {
11533 event: "UserPromptSubmit".into(),
11534 cue: "Do you not remember to use uv for scripts?".into(),
11535 session: Some("corr-test".into()),
11536 shape: HookShape::Asks,
11537 };
11538 let (key, first) = correction_nudge(&prompt).expect("a correction is nudged");
11539 assert!(first.contains("ljos prefer"), "{first}");
11540 assert!(
11541 correction_nudge(&prompt).is_some(),
11542 "unmarked until delivered"
11543 );
11544 mark_seen(Some("corr-test"), &[key]);
11545 assert!(correction_nudge(&prompt).is_none(), "once delivered");
11546 let tool = HookCall {
11547 event: "PreToolUse".into(),
11548 cue: "you should have used uv".into(),
11549 session: Some("corr-test".into()),
11550 shape: HookShape::Asks,
11551 };
11552 assert!(
11553 correction_nudge(&tool).is_none(),
11554 "tool calls are not prompts"
11555 );
11556 let plain = HookCall {
11557 event: "UserPromptSubmit".into(),
11558 cue: "add the timeline verb".into(),
11559 session: Some("corr-test-2".into()),
11560 shape: HookShape::Asks,
11561 };
11562 assert!(correction_nudge(&plain).is_none());
11563 }
11564
11565 #[test]
11566 fn a_subagent_is_told_its_parents_issue_and_held_once_at_stop() {
11567 let grok = r#"{"hookEventName":"subagent_stop","sessionId":"child","subagentType":"explore","stopHookActive":false}"#;
11568 assert_eq!(
11569 hook_subagent(grok),
11570 (Some("explore".into()), false, String::new())
11571 );
11572 let shared = r#"{"hook_event_name":"SubagentStop","session_id":"p","agent_id":"a1","agent_type":"review","stop_hook_active":true}"#;
11573 assert_eq!(
11574 hook_subagent(shared),
11575 (Some("review".into()), true, "a1".into())
11576 );
11577 assert_eq!(hook_subagent(r#"{"hook_event_name":"Stop"}"#).0, None);
11578 let brief = subagent_brief("explore", "acme-12ab", true);
11579 assert!(
11580 brief.contains("Do not open a sitting")
11581 && brief.contains("ljos vote acme-12ab")
11582 && brief.contains("--expect"),
11583 "{brief}"
11584 );
11585 let decide = subagent_stop_reason("explore", Some("acme-12ab"), true, false).unwrap();
11586 assert!(
11587 decide.contains("decision")
11588 && decide.contains("--expect")
11589 && decide.contains("--as ROLE"),
11590 "{decide}"
11591 );
11592 let plain = subagent_stop_reason("explore", Some("acme-12ab"), false, false).unwrap();
11593 assert!(plain.contains("Otherwise stop"), "{plain}");
11594 assert!(
11595 subagent_stop_reason("explore", Some("acme-12ab"), true, true).is_none(),
11596 "held once"
11597 );
11598 assert!(
11599 subagent_stop_reason("explore", None, true, false).is_none(),
11600 "no issue, no gate"
11601 );
11602 }
11603
11604 #[test]
11605 fn a_clone_without_the_named_merge_driver_is_reported() {
11606 let dir = tempfile::tempdir().unwrap();
11607 let git = |args: &[&str]| {
11608 std::process::Command::new("git")
11609 .arg("-C")
11610 .arg(dir.path())
11611 .args(args)
11612 .output()
11613 .unwrap()
11614 };
11615 git(&["init", "-q"]);
11616 assert!(
11617 tracker_merge_driver_missing(dir.path()).is_none(),
11618 "no attribute, no row"
11619 );
11620 std::fs::write(
11621 dir.path().join(".gitattributes"),
11622 "issues.org merge=vissue\n",
11623 )
11624 .unwrap();
11625 let said = tracker_merge_driver_missing(dir.path()).expect("named and missing");
11626 assert!(said.contains("vissue merge-driver --install"), "{said}");
11627 git(&[
11628 "config",
11629 "merge.vissue.driver",
11630 "vissue merge-driver %O %A %B %P",
11631 ]);
11632 assert!(tracker_merge_driver_missing(dir.path()).is_none());
11633 }
11634
11635 #[test]
11636 fn a_subagent_reads_its_parents_issue_from_the_hold_records() {
11637 let _g = env_guard();
11638 let dir = tempfile::tempdir().unwrap();
11639 unsafe { std::env::set_var("XDG_RUNTIME_DIR", dir.path()) };
11640 let ljos = dir.path().join("ljos");
11641 std::fs::create_dir_all(&ljos).unwrap();
11642 let rec = |name: &str, holder: &str, at: &str, node: &str| {
11643 std::fs::write(
11644 ljos.join(format!("hold-{name}")),
11645 format!("{holder}\nacme\n1\nacme\n{at}\n{node}\n"),
11646 )
11647 .unwrap();
11648 };
11649 rec("a", "sess-parent", "2026-09-27T10:00:00Z", "acme-old1");
11650 rec("b", "sess-parent", "2026-09-27T12:00:00Z", "acme-new2");
11651 rec("c", "sess-other", "2026-09-27T13:00:00Z", "brio-3c4d");
11652 std::fs::write(
11653 ljos.join("hold-d"),
11654 "sess-parent\nacme\n1\nacme\n2026-09-27T14:00:00Z\n",
11655 )
11656 .unwrap();
11657 assert_eq!(
11658 held_from_records(&["sess-parent".to_string()]).as_deref(),
11659 Some("acme-new2")
11660 );
11661 assert_eq!(held_from_records(&["sess-nobody".to_string()]), None);
11662 unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11663 }
11664
11665 #[test]
11666 fn a_long_run_without_the_seat_is_reminded_once_per_stretch() {
11667 let _g = env_guard();
11668 let dir = tempfile::tempdir().unwrap();
11669 unsafe { std::env::set_var("XDG_RUNTIME_DIR", dir.path()) };
11670 let call = |cue: &str, event: &str| HookCall {
11671 event: event.into(),
11672 cue: cue.into(),
11673 session: Some("work-test".into()),
11674 shape: HookShape::Asks,
11675 };
11676 for _ in 1..WORK_NUDGE_EVERY {
11677 assert!(work_nudge(&call("cargo test", "PostToolUse"), false).is_none());
11678 }
11679 let said =
11680 work_nudge(&call("cargo test", "PostToolUse"), false).expect("nudged at the count");
11681 assert!(
11682 said.contains("no issue held") || said.contains("vissue note"),
11683 "{said}"
11684 );
11685 assert!(
11686 work_nudge(&call("cargo test", "PostToolUse"), false).is_none(),
11687 "count starts over"
11688 );
11689 assert!(work_nudge(&call("ljos remember x", "PreToolUse"), false).is_none());
11690 assert!(
11691 work_nudge(&call("rg foo", "PostToolUse"), true).is_none(),
11692 "a subagent has its brief"
11693 );
11694 assert!(touches_seat("use_tool ljos__ljos_sitting"));
11695 assert!(!touches_seat("cargo build --release"));
11696 unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11697 }
11698
11699 #[test]
11700 fn a_twin_hook_call_is_answered_once() {
11701 let _g = env_guard();
11702 let dir = tempfile::tempdir().unwrap();
11703 unsafe { std::env::set_var("XDG_RUNTIME_DIR", dir.path()) };
11704 let call = |cue: &str| HookCall {
11705 event: "UserPromptSubmit".into(),
11706 cue: cue.into(),
11707 session: Some("twin".into()),
11708 shape: HookShape::CamelCase,
11709 };
11710 assert!(
11711 !hook_already_running(&call("fix the ci")),
11712 "the first answers"
11713 );
11714 assert!(
11715 hook_already_running(&call("fix the ci")),
11716 "its twin returns"
11717 );
11718 assert!(
11719 !hook_already_running(&call("another prompt")),
11720 "another prompt answers"
11721 );
11722 unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11723 }
11724
11725 #[test]
11726 fn a_second_commit_lock_waits_for_the_first() {
11727 let dir = tempfile::tempdir().unwrap();
11728 let path = dir.path().join("ljos-commit.lock");
11729 let first = CommitLock::acquire(&path);
11730 assert!(first.0.is_some(), "the lock opens");
11731 let other = path.clone();
11732 let started = std::time::Instant::now();
11733 let waiter = std::thread::spawn(move || {
11734 let _second = CommitLock::acquire(&other);
11735 started.elapsed()
11736 });
11737 std::thread::sleep(std::time::Duration::from_millis(300));
11738 drop(first);
11739 let waited = waiter.join().unwrap();
11740 assert!(
11741 waited >= std::time::Duration::from_millis(250),
11742 "{waited:?}"
11743 );
11744 }
11745
11746 #[test]
11747 fn a_verdict_from_jev_replaces_the_phrase_lists() {
11748 let call = |cue: &str, session: &str| HookCall {
11749 event: "UserPromptSubmit".into(),
11750 cue: cue.into(),
11751 session: Some(session.into()),
11752 shape: HookShape::Asks,
11753 };
11754 let plain = call("add the timeline verb", "verdict-1");
11755 assert!(decision_nudge_as(&plain, None).is_none(), "no cue word");
11756 assert!(
11757 decision_nudge_as(&plain, Some(true)).is_some(),
11758 "judged a choice"
11759 );
11760 let asked = call("should we seal with age or gpg?", "verdict-2");
11761 assert!(
11762 decision_nudge_as(&asked, Some(false)).is_none(),
11763 "judged not a choice"
11764 );
11765 let (key, _) = correction_nudge_as(&plain, Some(true)).expect("judged a correction");
11766 assert_eq!(key, "correction:judged");
11767 assert!(correction_nudge_as(&plain, Some(false)).is_none());
11768 }
11769
11770 #[test]
11771 fn a_choice_is_sent_to_a_panel_once_a_session() {
11772 let _g = env_guard();
11773 let dir = std::env::temp_dir().join(format!("ljos-dec-{}", std::process::id()));
11774 std::fs::create_dir_all(&dir).unwrap();
11775 unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
11776 let call = |cue: &str, session: &str, event: &str| HookCall {
11777 event: event.into(),
11778 cue: cue.into(),
11779 session: Some(session.into()),
11780 shape: HookShape::Asks,
11781 };
11782 let prompt = call(
11783 "should we seal with age or gpg?",
11784 "dec-test",
11785 "UserPromptSubmit",
11786 );
11787 let (key, first) = decision_nudge(&prompt).expect("a choice is nudged");
11788 assert!(
11789 first.contains("Options:") && first.contains("--as NAME"),
11790 "{first}"
11791 );
11792 assert!(
11793 decision_nudge(&prompt).is_some(),
11794 "unmarked until delivered"
11795 );
11796 mark_seen(Some("dec-test"), &[key]);
11797 assert!(decision_nudge(&prompt).is_none(), "once delivered");
11798 assert!(decision_nudge(&call("age vs gpg", "dec-test-2", "PreToolUse")).is_none());
11799 assert!(decision_nudge(&call(
11800 "add the timeline verb",
11801 "dec-test-3",
11802 "UserPromptSubmit"
11803 ))
11804 .is_none());
11805 assert!(
11806 decision_nudge(&call("go with option 2", "dec-test-4", "UserPromptSubmit")).is_some()
11807 );
11808 assert!(
11809 decision_nudge(&call(
11810 "tell me the option about caching",
11811 "dec-test-5",
11812 "UserPromptSubmit"
11813 ))
11814 .is_none(),
11815 "a cue ends at a word boundary"
11816 );
11817 let report = format!(
11818 "{} should we keep it?",
11819 "a long pasted report line. ".repeat(40)
11820 );
11821 assert!(
11822 decision_nudge(&call(&report, "dec-test-6", "UserPromptSubmit")).is_none(),
11823 "a cue past the opening is not a choice put to the agent"
11824 );
11825 }
11826
11827 #[test]
11828 fn calibration_weights_are_log_odds_with_the_best_at_one() {
11829 let w = calibration_weights(&[
11830 ("a".to_string(), 0.9),
11831 ("b".to_string(), 0.6),
11832 ("c".to_string(), 0.5),
11833 ("d".to_string(), 1.0),
11834 ]);
11835 let of = |who: &str| w.iter().find(|(n, _)| n == who).unwrap().1;
11836 assert_eq!(of("d"), 1.0, "a perfect record is the top of the scale");
11837 assert!((of("a") - 0.478).abs() < 0.01, "{}", of("a"));
11839 assert!((of("b") - 0.088).abs() < 0.01, "{}", of("b"));
11840 assert!(
11841 of("a") / of("b") > 5.0,
11842 "nine in ten outweighs six in ten by more than five"
11843 );
11844 assert_eq!(of("c"), TRUST_FLOOR, "chance earns the floor");
11845 }
11846
11847 #[test]
11848 fn a_consolidation_report_names_the_pairs() {
11849 let body = serde_json::json!({"live": 5, "closed": 1, "applied": false, "pairs": [
11850 {"old": "a", "old_text": "The default fuse is Borda.", "new": "b", "new_text": "The default fuse is CombMNZ."}
11851 ]});
11852 let text = format_consolidation(&body);
11853 assert!(
11854 text.starts_with(
11855 "closes a The default fuse is Borda.\n for b The default fuse is CombMNZ.\n"
11856 ),
11857 "{text}"
11858 );
11859 assert!(
11860 text.ends_with(
11861 "1 of 5 live memories would close; `ljos consolidate --apply` closes them\n"
11862 ),
11863 "{text}"
11864 );
11865 let applied = format_consolidation(
11866 &serde_json::json!({"live": 5, "closed": 0, "applied": true, "pairs": []}),
11867 );
11868 assert_eq!(applied, "0 of 5 live memories closed\n");
11869 }
11870
11871 #[test]
11872 fn the_hook_keeps_what_two_scorers_agreed_on() {
11873 let hit = |ballots, of| Hit {
11874 id: None,
11875 text: "x".into(),
11876 score: 1.0,
11877 kind: "lesson".into(),
11878 ts: None,
11879 entities: vec![],
11880 ballots,
11881 of,
11882 };
11883 assert!(agreed(&hit(Some(2), Some(3))));
11884 assert!(!agreed(&hit(Some(1), Some(3))));
11885 assert!(agreed(&hit(Some(1), Some(1))));
11886 assert!(agreed(&hit(None, None)));
11887 assert!(names_the_cue(
11888 "OpenCPMD Fortran calls the rgsaddle band API.",
11889 "plot the eon outputs with opencpmd and chemparseplot"
11890 ));
11891 assert!(!names_the_cue(
11892 "A submitted CQA packet uses the reviewer-edited Org quotes.",
11893 "plot the eon outputs with chemparseplot"
11894 ));
11895 assert!(!names_the_cue(
11896 "A doc comment states what an item does and one why.",
11897 "why are you not making real images"
11898 ));
11899 assert!(!names_the_cue("The fuse default is CombMNZ.", "why"));
11900 assert!(!names_a_numbered_pr(
11901 "A PR branch has to contain main before it merges."
11902 ));
11903 assert!(names_a_numbered_pr(
11904 "Pull requests 32 and 36 share one tree, and PR 32 replays PR 36."
11905 ));
11906 assert!(names_a_numbered_pr("rgpot #80 left a sibling behind main."));
11907 assert!(!names_a_numbered_pr(
11908 "The prompt hook holds the pack note until the first tool result."
11909 ));
11910 assert!(is_transient(
11911 "Pull requests 32 and 36 share one tree, and PR 32 replays PR 36."
11912 ));
11913 assert!(is_transient("The closure is on ljos-wgo8."));
11914 assert!(is_transient("The sweep was commit 80c73416c."));
11915 assert!(!is_transient(
11916 "A PR branch has to contain main before it merges."
11917 ));
11918 assert!(!is_transient("The prompt hook holds the pack note."));
11919 let standing = Hit {
11920 id: None,
11921 text: "Pull requests 32 and 36 share one tree.".into(),
11922 score: 1.0,
11923 kind: "lesson".into(),
11924 ts: None,
11925 entities: vec!["horizon:standing".into()],
11926 ballots: None,
11927 of: None,
11928 };
11929 assert!(is_refresher(&standing));
11930 let tagged = Hit {
11931 id: None,
11932 text: "A PR branch has to contain main.".into(),
11933 score: 1.0,
11934 kind: "lesson".into(),
11935 ts: None,
11936 entities: vec!["horizon:transient".into()],
11937 ballots: None,
11938 of: None,
11939 };
11940 assert!(!is_refresher(&tagged));
11941 let untagged = Hit {
11942 id: None,
11943 text: "A PR branch has to contain main.".into(),
11944 score: 1.0,
11945 kind: "lesson".into(),
11946 ts: None,
11947 entities: vec![],
11948 ballots: None,
11949 of: None,
11950 };
11951 assert!(!is_refresher(&untagged));
11952 }
11953
11954 #[test]
11955 fn the_generation_is_read_off_a_get_line() {
11956 let line = "a25a… claimed task unset gen=2 assignee=69f917124f757277b806e9a0f48c0318 parent=0 x-1";
11957 assert_eq!(gen_of(line), Some(2));
11958 assert_eq!(gen_of("deps -"), None);
11959 assert_eq!(gen_of("a ready task unset gen=x"), None);
11960 }
11961
11962 #[test]
11963 fn the_holder_is_read_off_a_get_line() {
11964 let line = "a25a… claimed task unset gen=2 assignee=69f917124f757277b806e9a0f48c0318 parent=0 x-1";
11965 assert_eq!(
11966 holder_of(line).as_deref(),
11967 Some("69f917124f757277b806e9a0f48c0318")
11968 );
11969 assert_eq!(
11970 holder_of("a ready task unset gen=1 assignee=00000000000000000000000000000000"),
11971 None
11972 );
11973 assert_eq!(holder_of("deps -"), None);
11974 }
11975
11976 #[test]
11977 fn a_registration_carries_the_runners_name() {
11978 let argv: Vec<String> = ["run", "-e", "LJOS_SEAT={name}", "{server}"]
11979 .iter()
11980 .map(|s| (*s).to_string())
11981 .collect();
11982 let filled = filled(&argv, Path::new("/x/ljos-mcp"), "runner-a");
11983 assert_eq!(filled, ["run", "-e", "LJOS_SEAT=runner-a", "/x/ljos-mcp"]);
11984 assert_eq!(
11985 identity_or_seat(Some(" reviewer ")).as_deref(),
11986 Some("reviewer")
11987 );
11988 }
11989
11990 #[test]
11991 fn a_timeline_reads_every_store_on_the_local_day() {
11992 let _g = env_guard();
11993 let before = std::env::var("TZ").ok();
11994 unsafe { std::env::set_var("TZ", "CET-1CEST,M3.5.0,M10.5.0/3") };
11995 assert_eq!(local_stamp("2026-09-26T22:28:12.170Z"), "2026-09-27T00:28");
11998 assert_eq!(local_stamp("[2026-09-27 Sun]"), "[2026-09-27 Sun]");
11999 assert_eq!(local_offset(1_788_566_400), 7200);
12000 let deed = deed_event("deed-x", "time=1790461680\n", local_offset).unwrap();
12001 let v = serde_json::json!({"properties": {"CREATED": "[2026-09-27 Sun]"}});
12002 let mut events = tracker_events(&v);
12003 events.push(deed);
12004 let text = format_events(&events, "2026-09-27T00:30:00");
12005 assert!(text.lines().all(|l| l.contains("\ttoday\t")), "{text}");
12006 unsafe {
12007 match before {
12008 Some(tz) => std::env::set_var("TZ", tz),
12009 None => std::env::remove_var("TZ"),
12010 }
12011 }
12012 }
12013
12014 #[test]
12015 fn a_timeline_merges_the_three_stores_oldest_first() {
12016 let v = serde_json::json!({
12017 "properties": {
12018 "CREATED": "[2026-09-01 Tue]",
12019 "SCHEDULED": "<2026-02-10 Tue>"
12020 },
12021 "claimed_by": "seat",
12022 "claimed_at": "[2026-09-03 Thu 11:48]",
12023 "logbook": [
12024 {"note": "second", "timestamp": "[2026-09-10 Thu 09:00]"},
12025 {"from_state": "TODO", "to_state": "STARTED", "timestamp": "[2026-09-03 Thu 11:48]"}
12026 ]
12027 });
12028 let mut events = tracker_events(&v);
12029 events.push(
12030 deed_event(
12031 "deed-x",
12032 "id=deed-x ok\nproducedBy=seat -\ntime=1788566400\n",
12033 |_| 0,
12034 )
12035 .unwrap(),
12036 );
12037 events.sort_by(|a, b| (a.days, &a.clock).cmp(&(b.days, &b.clock)));
12038 let text = format_events(&events, "2026-09-12T00:00:00Z");
12039 let lines: Vec<&str> = text.lines().collect();
12040 assert_eq!(lines.len(), 6, "{text}");
12041 assert!(
12042 lines[0].contains("tracker\tSCHEDULED <2026-02-10 Tue>"),
12043 "{}",
12044 lines[0]
12045 );
12046 assert!(
12047 lines[1].starts_with("2026-09-01 \t11 days ago"),
12048 "{}",
12049 lines[1]
12050 );
12051 assert!(lines[1].contains("tracker\tcreated"), "{}", lines[1]);
12052 assert!(
12053 lines[2].contains("+2 d\ttracker\tclaimed by seat"),
12054 "{}",
12055 lines[2]
12056 );
12057 assert!(
12058 lines[3].contains("same day\ttracker\tTODO -> STARTED"),
12059 "{}",
12060 lines[3]
12061 );
12062 assert!(
12063 lines[4]
12064 .starts_with("2026-09-05 00:00\t7 days ago\t+2 d\tdeed\tdeed-x produced by seat -"),
12065 "{}",
12066 lines[4]
12067 );
12068 assert!(
12069 lines[5].contains("2 days ago\t+5 d\ttracker\tnote: second"),
12070 "{}",
12071 lines[5]
12072 );
12073 }
12074
12075 #[test]
12076 fn sitting_caps_are_the_protocol_numbers() {
12077 assert_eq!(SITTING_DUE, 8);
12078 assert_eq!(SITTING_TIMELINE, 12);
12079 }
12080
12081 #[test]
12082 fn policyd_required_is_the_operator_switch() {
12083 let _g = env_guard();
12084 let before = std::env::var_os("POLICYD_REQUIRED");
12085 std::env::remove_var("POLICYD_REQUIRED");
12086 assert!(!policyd_required());
12087 std::env::set_var("POLICYD_REQUIRED", "1");
12088 assert!(policyd_required());
12089 std::env::set_var("POLICYD_REQUIRED", "0");
12090 assert!(!policyd_required());
12091 match before {
12092 Some(v) => std::env::set_var("POLICYD_REQUIRED", v),
12093 None => std::env::remove_var("POLICYD_REQUIRED"),
12094 }
12095 }
12096
12097 #[test]
12098 fn stamps_of_every_shape_key_the_same() {
12099 assert_eq!(
12100 stamp_key(Some("[2026-09-12 Sat 21:54]")),
12101 stamp_key(Some("2026-09-12T21:54:00.000Z"))
12102 );
12103 assert_eq!(stamp_key(Some("[2026-09-12 Sat]")).unwrap().1, "");
12104 assert_eq!(
12105 stamp_key(Some("<2026-02-10 Tue>")).map(|k| k.0),
12106 stamp_key(Some("2026-02-10")).map(|k| k.0)
12107 );
12108 assert_eq!(stamp_key(Some("soon")), None);
12109 assert_eq!(
12110 civil_of_days(days_of_stamp(Some("2026-09-12")).unwrap()),
12111 "2026-09-12"
12112 );
12113 }
12114
12115 #[test]
12116 fn ages_read_as_a_timeline() {
12117 let now = "2026-09-12T14:00:00.000Z";
12118 assert_eq!(age_of(Some("2026-09-12T01:00:00.000Z"), now), "today");
12119 assert_eq!(age_of(Some("2026-09-11T23:59:00.000Z"), now), "yesterday");
12120 assert_eq!(age_of(Some("2026-09-01T00:00:00.000Z"), now), "11 days ago");
12121 assert_eq!(age_of(Some("2026-08-01T00:00:00.000Z"), now), "6 weeks ago");
12122 assert_eq!(
12123 age_of(Some("2026-03-01T00:00:00.000Z"), now),
12124 "6 months ago"
12125 );
12126 assert_eq!(age_of(Some("2023-09-12T00:00:00.000Z"), now), "3 years ago");
12127 assert_eq!(age_of(Some("2026-09-13T00:00:00.000Z"), now), "in 1 day");
12128 assert_eq!(age_of(None, now), "");
12129 assert_eq!(age_of(Some("card"), now), "");
12130 }
12131
12132 #[test]
12133 fn a_hit_line_carries_kind_and_age() {
12134 let h = Hit {
12135 id: Some("a".into()),
12136 text: " keep the smoke green ".into(),
12137 score: 1.0,
12138 kind: "lesson".into(),
12139 ts: Some("2026-09-10T00:00:00.000Z".into()),
12140 entities: vec![],
12141 ballots: None,
12142 of: None,
12143 };
12144 assert_eq!(
12145 hit_line(&h, "2026-09-12T00:00:00.000Z"),
12146 "- [lesson, 2 days ago] keep the smoke green"
12147 );
12148 let bare = Hit {
12149 id: None,
12150 text: "x".into(),
12151 score: 1.0,
12152 kind: String::new(),
12153 ts: None,
12154 entities: vec![],
12155 ballots: None,
12156 of: None,
12157 };
12158 assert_eq!(hit_line(&bare, "2026-09-12T00:00:00.000Z"), "- [claim] x");
12159 }
12160
12161 #[test]
12164 fn hook_calls_are_read_and_answered_in_the_runners_shape() {
12165 let tool = hook_call(
12166 r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cargo test","description":"run"}}"#,
12167 );
12168 assert_eq!(tool.event, "PreToolUse");
12169 assert_eq!(tool.cue, "cargo test");
12170 let prompt = hook_call(r#"{"hook_event_name":"UserPromptSubmit","prompt":"fix the fuse"}"#);
12171 assert_eq!(prompt.cue, "fix the fuse");
12172 let grok = hook_call(r#"{"hookEventName":"post_tool_use","sessionId":"s1"}"#);
12173 assert_eq!(grok.event, "PostToolUse");
12174 assert_eq!(grok.session.as_deref(), Some("s1"));
12175 hold_hook_context(Some("s1"), "held pack");
12176 assert_eq!(take_hook_context(Some("s1")), "held pack");
12177 assert!(take_hook_context(Some("s1")).is_empty());
12178 let session = format!("hold-{}", std::process::id());
12179 hold_hook_note(Some(&session), "pack line", &["m1".to_string()]);
12180 hold_hook_context(Some(&session), "");
12181 assert_eq!(peek_hook_context(Some(&session)), "pack line");
12182 assert_eq!(
12183 prompt_hook_stdout(
12184 HookShape::CamelCase,
12185 Some(&session),
12186 "pack line",
12187 &["m1".to_string()]
12188 ),
12189 ""
12190 );
12191 let (echoed, echo_ids) = post_hook_stdout(HookShape::CamelCase, Some(&session));
12192 assert_eq!(echoed, "pack line");
12193 assert_eq!(echo_ids, ["m1"]);
12194 assert!(post_hook_stdout(HookShape::CamelCase, Some(&session))
12195 .0
12196 .is_empty());
12197 assert!(
12198 stop_hook_stdout(Some(&session), false).0.is_empty(),
12199 "a delivered tool result leaves Stop nothing to say"
12200 );
12201 let quiet = format!("quiet-{}", std::process::id());
12202 hold_hook_note(Some(&quiet), "no tool", &["m2".to_string()]);
12203 let (delivered, ids) = stop_hook_stdout(Some(&quiet), false);
12204 assert_eq!(delivered, "no tool");
12205 assert_eq!(ids, ["m2"]);
12206 assert!(stop_hook_stdout(Some(&quiet), true).0.is_empty());
12207 let argv = hook_call("rm -rf build");
12208 assert_eq!(argv.event, "argv");
12209 assert_eq!(argv.session, None);
12210 let with_session = hook_call(
12211 r#"{"session_id":"abc/../x 1","hook_event_name":"PreToolUse","tool_input":{"command":"ls"}}"#,
12212 );
12213 assert_eq!(with_session.session.as_deref(), Some("abc/../x 1"));
12214 assert!(seen_path("abc/../x 1")
12215 .unwrap()
12216 .file_name()
12217 .unwrap()
12218 .to_string_lossy()
12219 .ends_with("hook-seen-abcx1"));
12220 assert_eq!(seen_path("/../"), None);
12221 assert_eq!(hook_output(&argv, ""), "");
12222 assert_eq!(hook_output(&argv, "- [lesson] x"), "- [lesson] x\n");
12223 let out = hook_output(&tool, "- [preference] y");
12224 let v: Value = serde_json::from_str(out.trim()).unwrap();
12225 assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
12226 assert_eq!(
12227 v["hookSpecificOutput"]["additionalContext"],
12228 "- [preference] y"
12229 );
12230 assert!(
12231 hook_context(
12232 &HookCall {
12233 event: "argv".into(),
12234 cue: "ab".into(),
12235 session: None,
12236 shape: HookShape::Asks,
12237 },
12238 8
12239 )
12240 .is_empty(),
12241 "a cue too short asks nothing"
12242 );
12243 }
12244
12245 #[test]
12248 fn a_sessions_injected_memories_are_read_back_and_cleared() {
12249 let session = format!("end-test-{}", std::process::id());
12250 mark_seen(
12251 Some(&session),
12252 &["a".to_string(), "due-nudge".to_string(), "b".to_string()],
12253 );
12254 let (ids, path) = injected_ids(&session);
12255 assert_eq!(ids, ["a", "b"]);
12256 assert!(path.as_ref().is_some_and(|p| p.is_file()));
12257 let _ = session_end(Some(&session));
12259 assert!(!path.unwrap().is_file());
12260 assert_eq!(session_end(None), 0);
12261 }
12262
12263 #[test]
12266 fn the_memory_hook_is_merged_once() {
12267 let dir = std::env::temp_dir().join(format!("ljos-hook-{}", std::process::id()));
12268 let _ = std::fs::remove_dir_all(&dir);
12269 std::fs::create_dir_all(&dir).unwrap();
12270 let file = dir.join("settings.json");
12271 std::fs::write(
12272 &file,
12273 r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"other"}]}]},"theme":"dark"}"#,
12274 )
12275 .unwrap();
12276 let both: Vec<String> = vec!["UserPromptSubmit".into(), "PreToolUse".into()];
12277 let prompts: Vec<String> = HOOK_EVENTS.iter().map(|e| (*e).to_string()).collect();
12278 assert_eq!(
12279 prompts,
12280 ["UserPromptSubmit", "SessionEnd"],
12281 "the panel's default, and the session end that wires what it used"
12282 );
12283 assert!(!hook_installed(&file, &both));
12284 let dry = hook_step(&file, &both, true);
12285 assert!(
12286 dry.ok && dry.detail.starts_with("would add it on"),
12287 "{dry:?}"
12288 );
12289 let step = hook_step(&file, &both, false);
12290 assert!(step.ok, "{step:?}");
12291 assert!(hook_installed(&file, &both));
12292 let again = hook_step(&file, &both, false);
12293 assert!(
12294 again.detail.contains("carries the memory hook on"),
12295 "{again:?}"
12296 );
12297 let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
12298 assert_eq!(v["theme"], "dark", "the rest of the file is kept");
12299 assert_eq!(
12300 v["hooks"]["PreToolUse"].as_array().unwrap().len(),
12301 2,
12302 "the other hook stays"
12303 );
12304 assert_eq!(v["hooks"]["UserPromptSubmit"].as_array().unwrap().len(), 1);
12305 let narrowed = hook_step(&file, &prompts, false);
12308 assert!(
12309 narrowed.detail.contains("drop it from PreToolUse"),
12310 "{narrowed:?}"
12311 );
12312 let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
12313 assert_eq!(v["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
12314 assert_eq!(v["hooks"]["PreToolUse"][0]["hooks"][0]["command"], "other");
12315 assert!(hook_installed(&file, &prompts));
12316 assert!(!hook_installed(&file, &both));
12317 let _ = std::fs::remove_dir_all(&dir);
12318 }
12319
12320 #[test]
12323 fn rules_match_the_line_and_the_hook_carries_the_verdict() {
12324 assert!(glob_matches("rm -rf *", "rm -rf /tmp/x"));
12325 assert!(!glob_matches("rm -rf *", "ls -la"));
12326 assert!(glob_matches("*sudo*", "echo hi && sudo reboot"));
12327 assert!(glob_matches("git push*", "git push origin main"));
12328 assert!(!glob_matches("git push*", "git pull"));
12329 let rules = vec![
12330 Rule {
12331 pattern: "git push*".into(),
12332 verdict: "ask".into(),
12333 reason: "A push is the trust gate.".into(),
12334 },
12335 Rule {
12336 pattern: "*--force*".into(),
12337 verdict: "deny".into(),
12338 reason: "Never force push.".into(),
12339 },
12340 ];
12341 assert_eq!(
12342 verdict_for(&rules, "git push --force").unwrap().verdict,
12343 "deny"
12344 );
12345 assert_eq!(
12346 verdict_for(&rules, "git push origin x").unwrap().verdict,
12347 "ask"
12348 );
12349 assert!(verdict_for(&rules, "cargo test").is_none());
12350 let call = hook_call(
12351 r#"{"hook_event_name":"PreToolUse","tool_input":{"command":"git push --force"}}"#,
12352 );
12353 let out = hook_output_ruled(&call, "", verdict_for(&rules, &call.cue));
12354 let v: Value = serde_json::from_str(out.trim()).unwrap();
12355 assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
12356 assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
12357 .as_str()
12358 .unwrap()
12359 .contains("Never force push"));
12360 assert!(v["hookSpecificOutput"].get("additionalContext").is_none());
12361 let argv = HookCall {
12362 event: "argv".into(),
12363 cue: "git push origin x".into(),
12364 session: None,
12365 shape: HookShape::Asks,
12366 };
12367 assert!(
12368 hook_output_ruled(&argv, "", verdict_for(&rules, &argv.cue)).starts_with("ask: A push")
12369 );
12370 let grok = hook_call(
12372 r#"{"hookEventName":"pre_tool_use","sessionId":"g-1","toolName":"run_terminal_command","toolInput":{"command":"git push --force"}}"#,
12373 );
12374 assert_eq!(grok.shape, HookShape::CamelCase);
12375 assert_eq!(grok.event, "PreToolUse");
12376 assert_eq!(grok.cue, "git push --force");
12377 let v: Value = serde_json::from_str(
12378 hook_output_ruled(&grok, "", verdict_for(&rules, &grok.cue)).trim(),
12379 )
12380 .unwrap();
12381 assert_eq!(v["decision"], "deny");
12382 assert!(v["reason"].as_str().unwrap().contains("Never force push"));
12383 let turn = hook_call(
12385 r#"{"hook_event_name":"pre_llm_call","tool_name":null,"tool_input":null,"session_id":"h-1","extra":{"user_message":"fix the fuse"}}"#,
12386 );
12387 assert_eq!(turn.shape, HookShape::Context);
12388 assert_eq!(turn.event, "UserPromptSubmit");
12389 assert_eq!(turn.cue, "fix the fuse");
12390 let v: Value =
12391 serde_json::from_str(hook_output_ruled(&turn, "- [lesson] x", None).trim()).unwrap();
12392 assert_eq!(v["context"], "- [lesson] x");
12393 assert!(v.get("hookSpecificOutput").is_none());
12394 let tool = hook_call(
12395 r#"{"hook_event_name":"pre_tool_call","tool_name":"terminal","tool_input":{"command":"git push origin x"},"session_id":"h-1","extra":{}}"#,
12396 );
12397 assert_eq!(tool.event, "PreToolUse");
12398 let v: Value = serde_json::from_str(
12399 hook_output_ruled(&tool, "", verdict_for(&rules, &tool.cue)).trim(),
12400 )
12401 .unwrap();
12402 assert_eq!(v["decision"], "block");
12403 assert!(v["reason"]
12404 .as_str()
12405 .unwrap()
12406 .starts_with("ask the person before running this"));
12407 assert_eq!(
12408 hook_call(r#"{"hook_event_name":"on_session_end","session_id":"h-1","extra":{}}"#)
12409 .event,
12410 "TurnEnd"
12411 );
12412 assert_eq!(
12413 hook_call(r#"{"hook_event_name":"on_session_finalize","session_id":"h-1","extra":{}}"#)
12414 .event,
12415 "SessionEnd"
12416 );
12417 let deny_only = hook_call(
12419 r#"{"hook_event_name":"PreToolUse","session_id":"c-1","turn_id":"t-1","tool_name":"Bash","tool_input":{"command":"git push origin x"}}"#,
12420 );
12421 assert_eq!(deny_only.shape, HookShape::DenyOnly);
12422 let v: Value = serde_json::from_str(
12423 hook_output_ruled(&deny_only, "", verdict_for(&rules, &deny_only.cue)).trim(),
12424 )
12425 .unwrap();
12426 assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
12427 assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
12428 .as_str()
12429 .unwrap()
12430 .starts_with("ask the person before running this: A push"));
12431 assert!(v.get("decision").is_none());
12432 let asks = hook_call(
12433 r#"{"hook_event_name":"PreToolUse","session_id":"k-1","tool_name":"Bash","tool_input":{"command":"git push origin x"}}"#,
12434 );
12435 let v: Value = serde_json::from_str(
12436 hook_output_ruled(&asks, "", verdict_for(&rules, &asks.cue)).trim(),
12437 )
12438 .unwrap();
12439 assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "ask");
12440 let steps = panel_steps("x-1", true, &[], &[]);
12441 assert!(steps.is_empty());
12442 let preds = vec![
12443 Prediction {
12444 issue: "x-1".into(),
12445 agent: "a".into(),
12446 expect: Value::String("ship".into()),
12447 },
12448 Prediction {
12449 issue: "x-1".into(),
12450 agent: "b".into(),
12451 expect: serde_json::json!({"ship": 0.6, "hold": 0.4}),
12452 },
12453 ];
12454 let steps = panel_steps("x-1", true, &[row("a", "b", 0.5)], &preds);
12455 assert_eq!(steps.len(), 2);
12456 assert_eq!(steps[0].args[0], "surprising");
12457 assert_eq!(steps[1].args[0], "reputation");
12458 }
12459
12460 #[test]
12464 fn scoped_rows_apply_to_their_topic_and_learn_writes_in_scope() {
12465 let everywhere = row("a", "b", 0.9);
12466 let mut on_docs = row("a", "b", 0.2);
12467 on_docs.about = vec!["docs".into()];
12468 let rows = vec![everywhere.clone(), on_docs.clone()];
12469 let topic = topic_words("Rewrite the docs site");
12470 assert_eq!(topic, ["docs", "rewrite", "site", "the"]);
12471 assert_eq!(rows_about(&rows, &topic), vec![on_docs.clone()]);
12474 assert_eq!(
12475 rows_about(&rows, &topic_words("Fix the fuse")),
12476 vec![everywhere.clone()]
12477 );
12478
12479 let ballots = vec![
12480 ("a".to_string(), "ship".to_string()),
12481 ("b".to_string(), "hold".to_string()),
12482 ];
12483 let learned = learn_about(&ballots, "ship", &rows, 0.5, &["fuse".to_string()]).unwrap();
12484 let ab = learned
12485 .iter()
12486 .find(|r| r.from == "a" && r.to == "b")
12487 .unwrap();
12488 assert_eq!(ab.about, ["fuse"]);
12489 assert!(
12490 (ab.weight - 0.45).abs() < 1e-9,
12491 "starts from the unscoped 0.9: {ab:?}"
12492 );
12493 let ba = learned
12494 .iter()
12495 .find(|r| r.from == "b" && r.to == "a")
12496 .unwrap();
12497 assert!((ba.weight - 1.0).abs() < 1e-9, "a was right: {ba:?}");
12498
12499 let atoms = vec![
12501 trust_atom(&everywhere, &[], "ws").unwrap(),
12502 trust_atom(&on_docs, &[], "ws").unwrap(),
12503 ];
12504 let mut back = trust_rows(&atoms);
12505 back.sort_by(|x, y| x.about.cmp(&y.about));
12506 assert_eq!(back, vec![everywhere, on_docs]);
12507 }
12508
12509 #[test]
12512 fn personas_are_latest_per_name_and_anchor_the_settle() {
12513 let p = Persona {
12514 name: "reviewer".into(),
12515 anchor: 0.2,
12516 view: "Reads for what could break in production.".into(),
12517 entities: vec!["Release".into()],
12518 };
12519 let mut a = persona_atom(&p, "ws").unwrap();
12520 a["ts"] = Value::String("2026-01-01T00:00:00Z".into());
12521 let mut later = a.clone();
12522 later["anchor"] = serde_json::json!(0.4);
12523 later["ts"] = Value::String("2026-02-01T00:00:00Z".into());
12524 let got = personas_of(&[a, later]);
12525 assert_eq!(got.len(), 1);
12526 assert_eq!(got[0].anchor, 0.4);
12527 assert_eq!(got[0].entities, ["release"]);
12528 assert_eq!(anchors_json(&got), r#"{"reviewer":0.4}"#);
12529 let ballots = vec![
12532 ("reviewer".to_string(), "hold".to_string()),
12533 ("reader".to_string(), "ship".to_string()),
12534 ];
12535 let moved = learn_anchors(&got, &ballots, "ship", 0.5);
12536 assert_eq!(moved.len(), 1);
12537 assert!(
12538 (moved[0].anchor - 0.7).abs() < 1e-9,
12539 "0.4 + 0.6 * 0.5: {moved:?}"
12540 );
12541 assert!(learn_anchors(&got, &ballots, "hold", 0.5).is_empty());
12542 assert!(persona_atom(
12543 &Persona {
12544 anchor: 1.5,
12545 ..p.clone()
12546 },
12547 "ws"
12548 )
12549 .is_err());
12550 let steps = consensus_steps_anchored("x-1", true, true, &[], &got).unwrap();
12551 for step in &steps {
12552 assert!(
12553 step.args.contains(&"--susceptibility-of".to_string()),
12554 "{step:?}"
12555 );
12556 }
12557 let broad =
12561 consensus_steps_for("x-1", true, true, &[], &got, &["broad".to_string()]).unwrap();
12562 assert!(
12563 broad[0].args.contains(&"--epsilon".to_string()),
12564 "{:?}",
12565 broad[0]
12566 );
12567 assert!(
12568 !broad[1].args.contains(&"--epsilon".to_string()),
12569 "{:?}",
12570 broad[1]
12571 );
12572 assert!(settle_flags_for(&["feature".to_string()]).is_empty());
12573 }
12574
12575 #[test]
12579 fn playbooks_are_latest_per_name_and_stick_until_finish() {
12580 let _g = env_guard();
12581 let dir = std::env::temp_dir().join(format!("ljos-playbook-{}", std::process::id()));
12582 let _ = std::fs::remove_dir_all(&dir);
12583 std::fs::create_dir_all(&dir).unwrap();
12584 let before = std::env::var_os("XDG_RUNTIME_DIR");
12585 unsafe {
12586 std::env::set_var("XDG_RUNTIME_DIR", &dir);
12587 }
12588 let shipped = shipped_playbooks();
12589 let names: Vec<&str> = shipped.iter().map(|p| p.name.as_str()).collect();
12590 assert_eq!(names, SHIPPED_PLAYBOOK_NAMES);
12591 for p in shipped_playbooks() {
12592 assert!(!p.body.is_empty(), "{}", p.name);
12593 assert!(
12594 !p.body.contains("/poteto-mode") && !p.body.contains("poteto-agent"),
12595 "{}",
12596 p.name
12597 );
12598 let atom = playbook_atom(&p, "ws").unwrap();
12599 assert_eq!(atom["kind"], "playbook");
12600 assert_eq!(atom["name"], p.name);
12601 assert_eq!(atom["text"], p.body);
12602 assert!(!super::reviewable(&atom), "{}", p.name);
12603 }
12604 assert!(playbook_atom(
12605 &Playbook {
12606 name: "sit".into(),
12607 body: " ".into(),
12608 models: vec![],
12609 },
12610 "ws"
12611 )
12612 .is_err());
12613 let mut a = playbook_atom(
12614 &Playbook {
12615 name: "sit".into(),
12616 body: "first body".into(),
12617 models: vec![],
12618 },
12619 "ws",
12620 )
12621 .unwrap();
12622 a["ts"] = Value::String("2026-01-01T00:00:00Z".into());
12623 let mut later = a.clone();
12624 later["text"] = Value::String("second body".into());
12625 later["ts"] = Value::String("2026-02-01T00:00:00Z".into());
12626 let got = playbooks_of(&[a, later]);
12627 assert_eq!(got.len(), 1);
12628 assert_eq!(got[0].body, "second body");
12629 let copy = copy_playbook("proj-1a2b", "sit").unwrap();
12630 assert!(copy.starts_with("sit\n"), "{copy}");
12631 assert!(copy.contains("Grade due claims"), "{copy}");
12632 assert_eq!(bound_playbook("proj-1a2b").as_deref(), Some("sit"));
12633 let err = bind_playbook("proj-1a2b", "arena").unwrap_err().to_string();
12634 assert!(err.contains("bound to sit"), "{err}");
12635 assert!(err.contains("new sitting"), "{err}");
12636 let again = playbook_opening("proj-1a2b", None).unwrap();
12637 assert!(again.contains("Grade due claims"), "{again}");
12638 let blocks = brief_playbook_blocks("proj-1a2b");
12639 assert!(blocks.contains("== playbook"), "{blocks}");
12640 assert!(blocks.contains("Grade due claims"), "{blocks}");
12641 assert!(blocks.contains("== principles"), "{blocks}");
12642 assert!(blocks.contains("split-fence"), "{blocks}");
12643 assert!(blocks.contains("== rubric"), "{blocks}");
12644 assert!(blocks.contains("Ledger intact"), "{blocks}");
12645 drop_playbook("proj-1a2b");
12646 assert_eq!(bound_playbook("proj-1a2b"), None);
12647 let none = playbook_opening("proj-1a2b", None).unwrap();
12648 assert!(none.contains("none bound"), "{none}");
12649 assert!(none.contains("panel is refused"), "{none}");
12650 let err = panel("proj-1a2b", &dir.join("panel"))
12651 .unwrap_err()
12652 .to_string();
12653 assert!(err.contains("no playbook bound"), "{err}");
12654 let p = Persona {
12655 name: "reviewer".into(),
12656 anchor: 0.2,
12657 view: "Reads for what could break.".into(),
12658 entities: vec!["docs".into()],
12659 };
12660 let floor = inbound_floor(&p, "seat").unwrap();
12661 assert_eq!(floor.from, "seat");
12662 assert_eq!(floor.to, "reviewer");
12663 assert!((floor.weight - 1.0).abs() < 1e-9);
12664 assert!(floor.about.is_empty());
12665 assert!(inbound_floor(&p, "reviewer").is_none());
12666 assert!(has_unscoped_inbound(
12667 std::slice::from_ref(&floor),
12668 "reviewer",
12669 "seat"
12670 ));
12671 let scoped = Trust {
12672 about: vec!["docs".into()],
12673 ..floor
12674 };
12675 assert!(!has_unscoped_inbound(
12676 std::slice::from_ref(&scoped),
12677 "reviewer",
12678 "seat"
12679 ));
12680 let other = Trust {
12681 from: "other".into(),
12682 to: "reviewer".into(),
12683 weight: 1.0,
12684 about: Vec::new(),
12685 };
12686 assert!(
12687 !has_unscoped_inbound(std::slice::from_ref(&other), "reviewer", "seat"),
12688 "a third-party unscoped row is not the seat floor"
12689 );
12690 let arena_pb = shipped_playbooks()
12691 .into_iter()
12692 .find(|p| p.name == "arena")
12693 .unwrap();
12694 let arena = format_playbook_copy(&arena_pb);
12695 assert!(
12696 arena.contains("spawn hints (optional): judgment, instruction, fast"),
12697 "{arena}"
12698 );
12699 assert!(arena.contains("ljos vote --as"), "{arena}");
12700 assert!(
12701 COMPANY_PANEL_BODY.contains("--expect"),
12702 "a panel ballot carries the private forecast: {COMPANY_PANEL_BODY}"
12703 );
12704 match before {
12705 Some(v) => unsafe { std::env::set_var("XDG_RUNTIME_DIR", v) },
12706 None => unsafe { std::env::remove_var("XDG_RUNTIME_DIR") },
12707 }
12708 let _ = std::fs::remove_dir_all(&dir);
12709 }
12710
12711 #[test]
12712 fn playbook_note_latest_wins_and_empty_rest_drops() {
12713 let v = serde_json::json!({
12714 "logbook": [
12715 {"note": "playbook: land", "timestamp": "2026-09-21"},
12716 {"note": "playbook: sit", "timestamp": "2026-09-20"},
12717 {"note": "progress", "timestamp": "2026-09-19"}
12718 ]
12719 });
12720 assert_eq!(playbook_name_from_issue(&v).as_deref(), Some("land"));
12721 let empty = serde_json::json!({"logbook": []});
12722 assert_eq!(playbook_name_from_issue(&empty), None);
12723 let dropped = serde_json::json!({
12724 "logbook": [
12725 {"note": "playbook:", "timestamp": "2026-09-22T00:00:00Z"},
12726 {"note": "playbook: sit", "timestamp": "2026-09-21T00:00:00Z"}
12727 ]
12728 });
12729 assert_eq!(playbook_name_from_issue(&dropped), None);
12730 let undated = serde_json::json!({
12731 "logbook": [
12732 {"note": "playbook:"},
12733 {"note": "playbook: sit"}
12734 ]
12735 });
12736 assert_eq!(
12737 playbook_name_from_issue(&undated),
12738 None,
12739 "newest-first empty rest drops without walking back"
12740 );
12741 }
12742
12743 #[test]
12744 fn playbook_from_title_matches_a_closed_name_else_sit() {
12745 assert_eq!(playbook_from_title("Seat playbooks: routing"), "sit");
12746 assert_eq!(playbook_from_title("x5jz compose: land B"), "land");
12747 assert_eq!(
12748 playbook_from_title("Run the company-panel overnight"),
12749 "company-panel"
12750 );
12751 assert_eq!(playbook_from_title("sitting on a ticket"), "sit");
12752 assert_eq!(playbook_from_title("arena then compose"), "arena");
12753 assert_eq!(
12754 playbook_from_title("Benny and poteto-mode"),
12755 "sit",
12756 "title-match binds only closed-set tokens"
12757 );
12758 }
12759
12760 #[test]
12761 fn playbook_among_pack_latest_wins_and_unknown_names_are_refused() {
12762 let rewritten = Playbook {
12763 name: "sit".into(),
12764 body: "rewritten sit body".into(),
12765 models: vec![],
12766 };
12767 let got = playbook_among("sit", std::slice::from_ref(&rewritten)).unwrap();
12768 assert_eq!(got.body, "rewritten sit body");
12769 let seed = playbook_among("sit", &[]).unwrap();
12770 assert!(
12771 seed.body.contains("Grade due claims"),
12772 "shipped seed when the pack has no live atom: {}",
12773 seed.body
12774 );
12775 let err = playbook_among("Benny", &[]).unwrap_err().to_string();
12776 assert!(err.contains("unknown"), "{err}");
12777 let sneaky = Playbook {
12778 name: "poteto-mode".into(),
12779 body: "second roster".into(),
12780 models: vec![],
12781 };
12782 let err = playbook_among("poteto-mode", std::slice::from_ref(&sneaky))
12783 .unwrap_err()
12784 .to_string();
12785 assert!(err.contains("unknown"), "{err}");
12786 assert!(playbook_atom(&sneaky, "ws").is_err());
12787 assert!(parse_playbook_name("overnight").is_ok());
12788 assert!(parse_playbook_name("company-panel").is_ok());
12789 let listed = playbooks_of(&[serde_json::json!({
12790 "kind": "playbook",
12791 "name": "Benny",
12792 "text": "no",
12793 "ts": "2026-01-01T00:00:00Z"
12794 })]);
12795 assert!(listed.is_empty(), "{listed:?}");
12796 let err = bind_playbook("proj-1a2b", "Benny").unwrap_err().to_string();
12797 assert!(err.contains("unknown"), "{err}");
12798 }
12799
12800 #[test]
12801 fn sitting_resolves_asked_else_bound_else_title_else_sit() {
12802 let _g = env_guard();
12803 let dir =
12804 std::env::temp_dir().join(format!("ljos-playbook-resolve-{}", std::process::id()));
12805 let _ = std::fs::remove_dir_all(&dir);
12806 std::fs::create_dir_all(&dir).unwrap();
12807 let before = std::env::var_os("XDG_RUNTIME_DIR");
12808 unsafe {
12809 std::env::set_var("XDG_RUNTIME_DIR", &dir);
12810 }
12811 assert_eq!(
12812 resolve_sitting_playbook("proj-1a2b", "Seat playbooks", Some("arena")).unwrap(),
12813 "arena"
12814 );
12815 assert_eq!(
12816 resolve_sitting_playbook("proj-1a2b", "x5jz compose: land B", None).unwrap(),
12817 "land"
12818 );
12819 assert_eq!(
12820 resolve_sitting_playbook("proj-1a2b", "Ship the fuse change?", None).unwrap(),
12821 "sit"
12822 );
12823 bind_playbook("proj-1a2b", "sit").unwrap();
12824 assert_eq!(
12825 resolve_sitting_playbook("proj-1a2b", "x5jz compose: land B", None).unwrap(),
12826 "sit",
12827 "sticky wins over title"
12828 );
12829 drop_playbook("proj-1a2b");
12830 assert_eq!(bound_playbook("proj-1a2b"), None);
12831 match before {
12832 Some(v) => unsafe { std::env::set_var("XDG_RUNTIME_DIR", v) },
12833 None => unsafe { std::env::remove_var("XDG_RUNTIME_DIR") },
12834 }
12835 let _ = std::fs::remove_dir_all(&dir);
12836 }
12837
12838 #[test]
12841 fn unreviewed_claims_are_due_and_the_summary_says_if_the_clock_runs() {
12842 let atoms = vec![
12843 serde_json::json!({"id": "a", "kind": "conclusion", "text": "old", "due_at": ""}),
12844 serde_json::json!({"id": "b", "kind": "conclusion", "text": "older"}),
12845 serde_json::json!({"id": "c", "kind": "conclusion", "text": "later",
12846 "due_at": "2030-01-01T00:00:00Z"}),
12847 serde_json::json!({"id": "d", "kind": "conclusion", "text": "past",
12848 "due_at": "2020-01-01T00:00:00Z"}),
12849 serde_json::json!({"id": "t", "kind": "trust", "text": "x weighs y"}),
12850 serde_json::json!({"id": "p", "kind": "playbook", "text": "sit recipe", "name": "sit"}),
12851 ];
12852 let now = "2026-01-01T00:00:00Z";
12853 let due: Vec<String> = super::due_of(&atoms, now)
12854 .iter()
12855 .map(|a| a["id"].as_str().unwrap().to_string())
12856 .collect();
12857 assert_eq!(
12858 due,
12859 ["a", "b", "d"],
12860 "unreviewed first, then the past-due one"
12861 );
12862 assert_eq!(
12863 super::review_summary(&atoms, now),
12864 "3 due; 1 scheduled, next at 2030-01-01T00:00:00Z"
12865 );
12866 assert_eq!(
12867 super::review_summary(&[atoms[4].clone()], now),
12868 "0 due; nothing scheduled: this seat has remembered nothing yet"
12869 );
12870 assert!(super::format_due(&super::due_of(&atoms, now)).starts_with("unreviewed\t"));
12871 }
12872
12873 #[test]
12874 fn bumping_mcp_generation_respawns_without_rewriting_the_entry() {
12875 let dir = std::env::temp_dir().join(format!("ljos-gen-{}", std::process::id()));
12876 let _ = std::fs::remove_dir_all(&dir);
12877 std::fs::create_dir_all(&dir).expect("tempdir");
12878 let config = dir.join("config.toml");
12879 std::fs::write(
12880 &config,
12881 "[mcp_servers.ljos.env]\nLJOS_MCP_GENERATION = \"0.12.8\"\n",
12882 )
12883 .expect("write");
12884 let bumped = super::bump_ljos_mcp_generation(&config, "0.13.1", false)
12885 .expect("bumps")
12886 .expect("changed");
12887 assert_eq!(bumped, "0.13.1");
12888 let text = std::fs::read_to_string(&config).expect("read");
12889 assert!(text.contains("LJOS_MCP_GENERATION = \"0.13.1\""), "{text}");
12890 assert!(!text.contains("0.12.8"), "{text}");
12891 assert!(
12892 super::bump_ljos_mcp_generation(&config, "0.13.1", false)
12893 .expect("second")
12894 .is_none(),
12895 "a matching generation is left alone"
12896 );
12897 let _ = std::fs::remove_dir_all(&dir);
12898 }
12899
12900 #[test]
12901 fn a_client_name_listed_on_a_harness_is_that_runners_seat() {
12902 let dir = std::env::temp_dir().join(format!("ljos-clients-{}", std::process::id()));
12903 std::fs::create_dir_all(&dir).unwrap();
12904 let file = dir.join("harnesses.toml");
12905 std::fs::write(
12906 &file,
12907 "[[harness]]\nname = \"acme\"\nclients = [\"acme-mcp-client\"]\n\n[[harness]]\nname = \"brio\"\nclients = [\"brio-coding-agent\"]\n",
12908 )
12909 .unwrap();
12910 assert_eq!(
12911 runner_for_client(&file, "acme-mcp-client").as_deref(),
12912 Some("acme")
12913 );
12914 assert_eq!(
12915 runner_for_client(&file, &seat_slug("brio-coding-agent")).as_deref(),
12916 Some("brio")
12917 );
12918 assert!(runner_for_client(&file, "acme-cli").is_none());
12919 assert!(runner_for_client(&dir.join("absent.toml"), "acme-mcp-client").is_none());
12920 let _ = std::fs::remove_dir_all(&dir);
12921 }
12922
12923 #[test]
12924 fn an_issues_tags_are_words_it_speaks_in() {
12925 let v: Value = serde_json::from_str(r#"{"tags":["Decision","sharing","memory"]}"#).unwrap();
12926 assert_eq!(tags_of(&v), vec!["decision", "sharing", "memory"]);
12927 assert!(tags_of(&serde_json::json!({})).is_empty());
12928 }
12929
12930 #[test]
12931 fn a_jev_panel_stands_only_when_every_seat_is_sure_and_agrees() {
12932 let b = |choice: &str, confidence: f64| jev::Ballot {
12933 choice: choice.into(),
12934 confidence,
12935 probabilities: Default::default(),
12936 forecast: Default::default(),
12937 escalate_below: 0.8,
12938 };
12939 assert!(jev_panel_stands(&[b("age", 0.95), b("age", 0.9)]));
12940 assert!(!jev_panel_stands(&[b("age", 0.95), b("gpg", 0.9)]), "split");
12941 assert!(
12942 !jev_panel_stands(&[b("age", 0.95), b("age", 0.6)]),
12943 "one unsure"
12944 );
12945 assert!(!jev_panel_stands(&[]));
12946 }
12947
12948 #[test]
12949 fn a_turn_is_read_from_the_last_request_to_the_final_message() {
12950 let lines = [
12951 r#"{"type":"user","message":{"content":"old request"}}"#,
12952 r#"{"type":"user","message":{"content":"fix the parser and test it"}}"#,
12953 r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"cargo test -p brio"}}]}}"#,
12954 r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"test result: FAILED. 3 passed; 1 failed"}]}}"#,
12955 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"All done, the parser works."}]}}"#,
12956 ]
12957 .join("\n");
12958 let t = stop_turn_from_transcript(&lines);
12959 assert_eq!(t.request, "fix the parser and test it");
12960 assert!(t.test_ran);
12961 assert_eq!(t.commands, vec!["cargo test -p brio"]);
12962 assert!(t.outputs[0].contains("1 failed"));
12963 assert_eq!(t.final_message, "All done, the parser works.");
12964 assert!(t.state().contains("The agent's final message:\nAll done"));
12965 assert!(!runs_tests("git status"));
12966 }
12967
12968 #[test]
12969 fn options_come_from_a_line_or_its_bullets() {
12970 assert_eq!(
12971 issue_options("Why.\nOptions: age, gpg\n"),
12972 vec!["age", "gpg"]
12973 );
12974 assert_eq!(issue_options("Options:\n- a\n- b\n\nmore"), vec!["a", "b"]);
12975 assert!(
12976 issue_options("Options: only").is_empty(),
12977 "one option is no vote"
12978 );
12979 assert!(issue_options("no options").is_empty());
12980 }
12981
12982 #[test]
12983 fn a_decision_is_a_tag_a_type_or_an_options_line() {
12984 let v = |j: &str| -> Value { serde_json::from_str(j).unwrap() };
12985 assert!(is_decision(&v(r#"{"tags":["seat","decision"]}"#)));
12986 assert!(is_decision(&v(r#"{"properties":{"TYPE":"decision"}}"#)));
12987 assert!(is_decision(&v(
12988 r#"{"body":"Evidence.\n\nOptions:\n- a\n- b"}"#
12989 )));
12990 assert!(!is_decision(&v(
12991 r#"{"tags":["bug"],"properties":{"TYPE":"task"},"body":"no options here"}"#
12992 )));
12993 assert!(!is_decision(&v(
12994 r#"{"body":"We weighed the Options: none"}"#
12995 )));
12996 }
12997
12998 #[test]
12999 fn a_probe_passes_only_when_the_runner_lists_ljos() {
13000 let s = |v: &[&str]| v.iter().map(|x| (*x).to_string()).collect::<Vec<_>>();
13001 assert!(probe_lists_ljos(&s(&["sh", "-c", "echo ' ljos_sitting Call this'"])).is_ok());
13002 assert!(probe_lists_ljos(&s(&["sh", "-c", "echo 'MCP SDK not installed'"])).is_err());
13003 assert!(probe_lists_ljos(&s(&["sh", "-c", "echo ljos_sitting; exit 3"])).is_err());
13004 assert!(probe_lists_ljos(&s(&["/nonexistent/runner"])).is_err());
13005 let all: super::Harnesses = toml::from_str(super::HARNESSES_EXAMPLE).expect("parses");
13006 let hermes = all.harness.iter().find(|h| h.name == "hermes").unwrap();
13007 assert_eq!(hermes.probe, s(&["hermes", "mcp", "test", "ljos"]));
13008 }
13009
13010 #[test]
13011 fn a_plugin_runner_gets_its_bundled_plugin_with_ljos_filled() {
13012 let all: super::Harnesses = toml::from_str(super::HARNESSES_EXAMPLE).expect("parses");
13013 for name in ["opencode", "omp"] {
13014 let h = all.harness.iter().find(|h| h.name == name).expect(name);
13015 assert!(h.plugin.is_some(), "{name} names a plugin path");
13016 let text = super::plugin_text(h, Path::new("/opt/seat/bin/ljos")).expect(name);
13017 assert!(text.contains("\"/opt/seat/bin/ljos\""), "{name}");
13018 assert!(!text.contains("{ljos}"), "{name}");
13019 assert!(
13020 text.contains("PreToolUse") && text.contains("UserPromptSubmit"),
13021 "{name}"
13022 );
13023 }
13024 let unknown = super::Harness {
13025 name: "x".into(),
13026 plugin: Some("/tmp/x.ts".into()),
13027 plugin_template: Some("nobody".into()),
13028 ..Default::default()
13029 };
13030 assert!(super::plugin_text(&unknown, Path::new("/l")).is_none());
13031 let step = super::plugin_step(&unknown, Path::new("/tmp/x.ts"), true);
13032 assert!(!step.ok, "an unknown template writes nothing: {step:?}");
13033 }
13034
13035 #[test]
13039 fn onboarding_a_config_file_runner_writes_once() {
13040 let all: super::Harnesses = toml::from_str(super::HARNESSES_EXAMPLE).expect("parses");
13041 assert_eq!(all.harness.len(), 7);
13043 assert!(all.harness[3..].iter().all(|h| h.register.len()
13044 + usize::from(h.config.is_some())
13045 + usize::from(h.config_json.is_some())
13046 > 0));
13047 assert_eq!(all.harness[1].marker.as_deref(), Some("[mcp_servers.ljos]"));
13048 assert_eq!(all.harness[2].json_pointer.as_deref(), Some("/mcp/ljos"));
13049
13050 let dir = std::env::temp_dir().join(format!("ljos-onboard-{}", std::process::id()));
13051 let _ = std::fs::remove_dir_all(&dir);
13052 std::fs::create_dir_all(&dir).expect("tempdir");
13053 let config = dir.join("config.toml");
13054 let skills = dir.join("skills");
13055 let file = dir.join("harnesses.toml");
13056 std::fs::write(
13057 &file,
13058 format!(
13059 "[[harness]]\nname = \"r\"\nconfig = {config:?}\nmarker = \"[mcp_servers.ljos]\"\n\
13060 snippet = \"\\n[mcp_servers.ljos]\\ncommand = \\\"{{server}}\\\"\\n\"\nskills = {skills:?}\n",
13061 config = config.display().to_string(),
13062 skills = skills.display().to_string(),
13063 ),
13064 )
13065 .expect("write");
13066
13067 let refused = super::onboard_from(&file, "nobody", true)
13068 .unwrap_err()
13069 .to_string();
13070 assert!(
13071 refused.contains("no runner \"nobody\"") && refused.contains("names r"),
13072 "{refused}"
13073 );
13074
13075 let steps = match super::onboard_from(&file, "r", true) {
13076 Ok(steps) => steps,
13077 Err(e) => {
13080 assert!(e.to_string().contains("ljos-mcp not on PATH"), "{e}");
13081 return;
13082 }
13083 };
13084 assert!(steps.iter().all(|s| s.ok), "{steps:?}");
13085 assert!(
13086 steps[0].detail.starts_with("would append"),
13087 "{}",
13088 steps[0].detail
13089 );
13090 assert!(!config.exists() && !skills.exists(), "a dry run wrote");
13091
13092 let steps = super::onboard_from(&file, "r", false).expect("onboards");
13093 assert!(steps.iter().all(|s| s.ok), "{steps:?}");
13094 let written = std::fs::read_to_string(&config).expect("config written");
13095 assert_eq!(written.matches("[mcp_servers.ljos]").count(), 1);
13096 assert!(written.contains("ljos-mcp"), "{written}");
13097 let skill = std::fs::read_to_string(skills.join("ljos/SKILL.md")).expect("skill written");
13098 assert!(skill.starts_with("---\nname: ljos\n"));
13099 assert!(skill.contains("## Before the work"));
13100
13101 let again = super::onboard_from(&file, "r", false).expect("onboards again");
13102 assert_eq!(again[0].detail, "ljos registered");
13103 assert!(
13104 again[1].detail.ends_with("is current"),
13105 "{}",
13106 again[1].detail
13107 );
13108 assert_eq!(
13109 std::fs::read_to_string(&config)
13110 .expect("config")
13111 .matches("[mcp_servers.ljos]")
13112 .count(),
13113 1,
13114 "the entry was appended twice"
13115 );
13116 let _ = std::fs::remove_dir_all(&dir);
13117 }
13118
13119 #[test]
13120 fn grok_onboard_names_the_frozen_hook_file() {
13121 let file = std::env::temp_dir().join("ljos-missing-harnesses.toml");
13122 let steps = super::onboard_from(&file, "grok", true).expect("grok dry");
13123 assert!(steps[0].ok, "{steps:?}");
13124 assert!(
13125 steps[0].detail.contains(".grok/hooks/ljos.json"),
13126 "{}",
13127 steps[0].detail
13128 );
13129 }
13130
13131 #[test]
13132 fn the_grok_hook_file_runs_ljos_by_absolute_path() {
13133 let text = super::grok_hooks_json(Path::new("/opt/seat/bin/ljos"));
13134 let v: Value = serde_json::from_str(&text).expect("the hook file is JSON");
13135 let pre = &v["hooks"]["PreToolUse"][0]["hooks"][0];
13136 assert_eq!(pre["command"], "/opt/seat/bin/ljos hook");
13137 assert_eq!(pre["timeout"], 10);
13138 let stop = &v["hooks"]["Stop"][0]["hooks"][0];
13139 assert_eq!(stop["command"], "/opt/seat/bin/ljos hook");
13140 assert!(!text.contains("{ljos}"), "{text}");
13141 assert!(!text.contains("\"ljos hook\""), "{text}");
13142 }
13143
13144 use super::*;
13145 use std::io::{Read, Write};
13146 use std::net::TcpListener;
13147 use std::sync::{Arc, Mutex};
13148
13149 #[test]
13151 fn a_refusal_is_an_error_not_an_answer() {
13152 let err = run_captured("false", &[] as &[&str]).unwrap_err();
13153 assert!(err.to_string().contains("false exited"), "{err}");
13154 let said = run_captured("sh", &["-c", "echo answered; echo aside >&2"]).unwrap();
13155 assert_eq!(said.stdout.trim(), "answered");
13156 assert_eq!(said.stderr.trim(), "aside");
13157 let said = run_captured("sh", &["-c", "echo reason >&2; exit 3"]).unwrap_err();
13158 assert!(said.to_string().contains("reason"), "{said}");
13159 }
13160
13161 #[test]
13162 fn join_keeps_spaces() {
13163 assert_eq!(
13164 join(&["the default fuse".into(), "is CombMNZ".into()]),
13165 "the default fuse is CombMNZ"
13166 );
13167 }
13168
13169 #[test]
13170 fn remember_is_lesson_prefer_is_preference() {
13171 assert_eq!(atom_kind("Remember").unwrap(), "lesson");
13172 assert_eq!(atom_kind("Prefer").unwrap(), "preference");
13173 assert!(atom_kind("extract").is_err());
13174 }
13175
13176 #[test]
13177 fn a_sitting_lists_the_due_claims_its_island_holds_first() {
13178 let due = vec![
13179 serde_json::json!({"id": "old", "due_at": "2026-09-01"}),
13180 serde_json::json!({"id": "here", "due_at": "2026-09-05"}),
13181 serde_json::json!({"id": "older", "due_at": "2026-08-01"}),
13182 ];
13183 let island = serde_json::json!({"island": [{"id": "here"}, {"id": "absent"}]});
13184 let ids: Vec<String> = due_on_island_first(due, &island)
13185 .iter()
13186 .map(|a| a["id"].as_str().unwrap().to_string())
13187 .collect();
13188 assert_eq!(ids, ["here", "old", "older"]);
13189 let weak = serde_json::json!({"weak": true, "island": [{"id": "older"}]});
13190 let kept = due_on_island_first(
13191 vec![
13192 serde_json::json!({"id": "a"}),
13193 serde_json::json!({"id": "older"}),
13194 ],
13195 &weak,
13196 );
13197 assert_eq!(kept[0]["id"], "a", "a weak island does not reorder");
13198 }
13199
13200 #[test]
13201 fn atom_body_is_explicit_and_unextracted() {
13202 let v = atom_body("lesson", "the default fuse is CombMNZ", "ws");
13203 assert_eq!(v["schema"], "inside.atom/v1");
13204 assert_eq!(v["kind"], "lesson");
13205 assert_eq!(v["level"], "explicit");
13206 assert_eq!(v["text"], "the default fuse is CombMNZ");
13207 assert_eq!(v["workspace"], "ws");
13208 assert_eq!(v["source"]["via"], "ljos");
13210 assert!(!v["source"]["host"].as_str().unwrap_or("").is_empty());
13211 assert!(!v["source"]["session"].as_str().unwrap_or("").is_empty());
13212 let seat = v["entities"][0].as_str().unwrap();
13214 assert!(seat.starts_with(SEAT_ENTITY), "{seat}");
13215 let mut more = v.clone();
13216 add_entities(
13217 &mut more,
13218 ["persona:reviewer".to_string(), seat.to_string()],
13219 );
13220 assert_eq!(more["entities"].as_array().unwrap().len(), 2, "{more}");
13221 let raw = atom_body("lesson", "Remember: pin the review set", "ws");
13223 assert_eq!(raw["text"], "Remember: pin the review set");
13224 }
13225
13226 #[test]
13227 fn empty_claim_is_refused() {
13228 let client = PacksetClient::new("http://127.0.0.1:1");
13229 let err = post_claim(&client, "Remember", " ", "ws").unwrap_err();
13230 assert!(err.to_string().contains("empty text"));
13231 }
13232
13233 #[test]
13234 fn cards_are_the_two_named_files_only() {
13235 assert_eq!(CARD_NAMES, &["USER.md", "MEMORY.md"]);
13236 let dir = std::env::temp_dir().join(format!("ljos-cards-{}", std::process::id()));
13237 let _ = std::fs::remove_dir_all(&dir);
13238 std::fs::create_dir_all(&dir).unwrap();
13239 std::fs::write(dir.join("USER.md"), "user card\n").unwrap();
13240 std::fs::write(dir.join("MEMORY.md"), "memory card\n").unwrap();
13241 std::fs::write(dir.join("NOTES.md"), "must not appear\n").unwrap();
13242 let out = cards(&dir).unwrap();
13243 assert!(out.contains("user card"));
13244 assert!(out.contains("memory card"));
13245 assert!(!out.contains("must not appear"));
13246 assert!(!out.contains("NOTES.md"));
13247 let _ = std::fs::remove_dir_all(&dir);
13248 }
13249
13250 #[test]
13251 fn policy_prints_argv_and_does_not_reload() {
13252 assert!(policy_line(&[]).is_err());
13253 assert_eq!(policy_line(&["ls".into(), "-la".into()]).unwrap(), "ls -la");
13254 let note = POLICY_TCB.to_ascii_lowercase();
13255 assert!(note.contains("ljos-policyd"));
13256 assert!(note.contains("not a check"));
13257 assert!(!note.contains("grokos policy reload"));
13258 assert!(!note.contains("policy reload"));
13259 }
13260
13261 #[test]
13262 fn consensus_is_ljos_then_vissue() {
13263 let steps = consensus_steps("vissue-1a5a", true, true, &[]).unwrap();
13264 assert_eq!(steps.len(), 2);
13265 assert_eq!(steps[0].bin, "ljos-consensus");
13266 assert_eq!(steps[0].args, vec!["settle", "--issue", "vissue-1a5a"]);
13267 assert_eq!(steps[1].bin, "vissue");
13268 assert_eq!(steps[1].args, vec!["consensus", "vissue-1a5a"]);
13269 }
13270
13271 #[test]
13272 fn consensus_carries_the_packs_trust() {
13273 let rows = vec![row("a", "b", 0.5)];
13274 let steps = consensus_steps("id", true, true, &rows).unwrap();
13275 assert_eq!(steps[0].args[3], "--trust");
13276 assert_eq!(steps[0].args[4], r#"[["a","b",0.5]]"#);
13277 assert_eq!(
13278 steps[1].args,
13279 vec!["consensus", "id", "--trust", r#"[["a","b",0.5]]"#]
13280 );
13281 }
13282
13283 #[test]
13284 fn consensus_skips_a_missing_bin() {
13285 let only_v = consensus_steps("id", false, true, &[]).unwrap();
13286 assert_eq!(only_v.len(), 1);
13287 assert_eq!(only_v[0].bin, "vissue");
13288 let only_l = consensus_steps("id", true, false, &[]).unwrap();
13289 assert_eq!(only_l[0].bin, "ljos-consensus");
13290 assert!(consensus_steps("id", false, false, &[]).is_err());
13291 }
13292
13293 fn row(from: &str, to: &str, weight: f64) -> Trust {
13294 Trust {
13295 about: Vec::new(),
13296 from: from.into(),
13297 to: to.into(),
13298 weight,
13299 }
13300 }
13301
13302 #[test]
13303 fn a_trust_atom_is_one_edge_with_its_evidence() {
13304 let atom = trust_atom(&row("a", "b", 0.25), &["deed-x-y".into()], "ws").unwrap();
13305 assert_eq!(atom["kind"], "trust");
13306 assert_eq!(atom["from"], "a");
13307 assert_eq!(atom["to"], "b");
13308 assert_eq!(atom["weight"], 0.25);
13309 assert_eq!(atom["entities"], serde_json::json!(["deed-x-y"]));
13310 assert_eq!(atom["text"], "a weighs b at 0.250.");
13311 assert!(trust_atom(&row("a", "a", 0.5), &[], "ws").is_err());
13312 assert!(trust_atom(&row("a", "b", 0.0), &[], "ws").is_err());
13313 assert!(trust_atom(&row("a", "b", 1.5), &[], "ws").is_err());
13314 assert!(trust_atom(&row("", "b", 0.5), &[], "ws").is_err());
13315 }
13316
13317 #[test]
13318 fn the_latest_row_per_pair_wins() {
13319 let atoms = vec![
13320 serde_json::json!({"kind": "trust", "from": "a", "to": "b", "weight": 0.9, "ts": "2026-01-01T00:00:00Z"}),
13321 serde_json::json!({"kind": "trust", "from": "a", "to": "b", "weight": 0.3, "ts": "2026-02-01T00:00:00Z"}),
13322 serde_json::json!({"kind": "trust", "from": "b", "to": "a", "weight": 0.7}),
13323 serde_json::json!({"kind": "lesson", "text": "not a row"}),
13324 serde_json::json!({"kind": "trust", "from": "b", "weight": 0.7}),
13325 ];
13326 let rows = trust_rows(&atoms);
13327 assert_eq!(rows, vec![row("a", "b", 0.3), row("b", "a", 0.7)]);
13328 assert_eq!(trust_json(&rows), r#"[["a","b",0.3],["b","a",0.7]]"#);
13329 }
13330
13331 #[test]
13332 fn ballots_are_agent_and_choice() {
13333 let rows =
13334 ballots_from_json(r#"[{"agent":"a","choice":"ship","stamp":"[2026-01-01]"}]"#).unwrap();
13335 assert_eq!(rows, vec![("a".to_string(), "ship".to_string())]);
13336 assert!(ballots_from_json(r#"[{"agent":"a"}]"#).is_err());
13337 assert!(ballots_from_json("{}").is_err());
13338 }
13339
13340 #[test]
13343 fn learning_downweights_the_refuted_voter() {
13344 let ballots = vec![
13345 ("a".to_string(), "ship".to_string()),
13346 ("b".to_string(), "ship".to_string()),
13347 ("c".to_string(), "hold".to_string()),
13348 ];
13349 let rows = learn(&ballots, "ship", &[], 0.5).unwrap();
13350 assert_eq!(rows.len(), 6);
13351 let w = |from: &str, to: &str| {
13352 rows.iter()
13353 .find(|r| r.from == from && r.to == to)
13354 .unwrap()
13355 .weight
13356 };
13357 assert_eq!(w("a", "b"), 1.0);
13358 assert_eq!(w("a", "c"), 0.5);
13359 assert_eq!(w("b", "c"), 0.5);
13360 assert_eq!(w("c", "a"), 1.0);
13361
13362 let again = learn(&ballots, "ship", &rows, 0.5).unwrap();
13363 let w2 = |from: &str, to: &str| {
13364 again
13365 .iter()
13366 .find(|r| r.from == from && r.to == to)
13367 .unwrap()
13368 .weight
13369 };
13370 assert_eq!(w2("a", "c"), 0.25);
13371 assert_eq!(w2("a", "b"), 1.0);
13372
13373 let floored = learn(&ballots, "ship", &[row("a", "c", 0.015)], 0.5).unwrap();
13374 let low = floored
13375 .iter()
13376 .find(|r| r.from == "a" && r.to == "c")
13377 .unwrap();
13378 assert_eq!(low.weight, TRUST_FLOOR);
13379
13380 assert!(learn(&ballots, "ship", &[], 1.0).is_err());
13381 assert!(learn(&ballots, " ", &[], 0.5).is_err());
13382 assert!(learn(&ballots[..1], "ship", &[], 0.5).is_err());
13383
13384 let shared = learn_shared(&ballots, "ship", &rows, 0.5, &[], 0.1).unwrap();
13387 let w3 = |from: &str, to: &str| {
13388 shared
13389 .iter()
13390 .find(|r| r.from == from && r.to == to)
13391 .unwrap()
13392 .weight
13393 };
13394 assert!((w3("a", "c") - (0.25 + 0.75 * 0.1)).abs() < 1e-12);
13395 assert_eq!(w3("a", "b"), 1.0);
13396 assert!(learn_shared(&ballots, "ship", &[], 0.5, &[], 1.0).is_err());
13397 }
13398
13399 #[test]
13400 fn a_name_is_one_work_id_and_hex_passes_through() {
13401 let a = work_id("demo-riml");
13402 assert_eq!(a.len(), 32);
13403 assert!(a.bytes().all(|b| b.is_ascii_hexdigit()));
13404 assert_eq!(a, work_id(" demo-riml "));
13405 assert_ne!(a, work_id("demo-rimm"));
13406 assert_eq!(work_id(&a.to_ascii_uppercase()), a);
13407 assert_ne!(work_id("seat"), work_id("reader"));
13408 }
13409
13410 #[test]
13411 fn a_refusal_is_not_a_writer_that_is_down() {
13412 let refused = anyhow::Error::from(packset_client::Error::Bad("no".into()));
13413 assert!(!writer_unreachable(&refused));
13414 }
13415
13416 #[test]
13417 fn a_stated_probability_has_a_brier_score_and_a_hard_vote_does_not() {
13418 let rows = vec![
13419 Forecast {
13420 agent: "a".into(),
13421 choice: "ship".into(),
13422 confidence: Some(0.8),
13423 },
13424 Forecast {
13425 agent: "b".into(),
13426 choice: "hold".into(),
13427 confidence: None,
13428 },
13429 ];
13430 assert!((brier("ship", "ship", 0.8) - 0.04).abs() < 1e-12);
13431 assert!((brier("hold", "ship", 0.8) - 0.64).abs() < 1e-12);
13432 let (mean, n) = mean_brier(&rows, "ship").unwrap();
13433 assert_eq!(n, 1);
13434 assert!((mean - 0.04).abs() < 1e-12);
13435 let said = learn_reading(2, 0, &rows, "ship", &std::collections::BTreeMap::new());
13436 assert!(said.contains("Brier 0.040"), "{said}");
13437 assert!(said.contains("not a trust weight"), "{said}");
13438 let silent = learn_reading(2, 0, &rows[1..], "ship", &std::collections::BTreeMap::new());
13439 assert!(silent.contains("No stated probability"), "{silent}");
13440 assert!(log_score("ship", "ship", 0.8).unwrap() > 0.0);
13441 assert!(log_score("hold", "ship", 1.0).is_none());
13442 let mut cal = Calibration::default();
13443 cal = observe(&cal, "ship", "ship", 0.8);
13444 cal = observe(&cal, "ship", "hold", 0.8);
13445 let part = murphy(&cal).unwrap();
13446 let mean_b = cal.sum_brier / f64::from(cal.n);
13447 assert!((part.reliability - part.resolution + part.uncertainty - mean_b).abs() < 1e-9);
13448 assert!((cal.sum_p / f64::from(cal.n) - 0.8).abs() < 1e-12);
13449 assert!((cal.sum_o / f64::from(cal.n) - 0.5).abs() < 1e-12);
13450 }
13451
13452 #[test]
13453 fn an_island_prints_one_memory_a_line() {
13454 let body = serde_json::json!({"island": [
13455 {"id": "a", "text": "one", "activation": 1.0, "seed": true, "ts": now_utc()},
13456 {"id": "b", "text": "two", "activation": 0.25, "seed": false}
13457 ]});
13458 let printed = format_island(&body);
13459 assert!(
13460 printed.contains("Seat island") && printed.contains("Not fired"),
13461 "{printed}"
13462 );
13463 assert!(
13464 printed.contains("1.000\tseed\ta\ttoday\tone\n"),
13465 "{printed}"
13466 );
13467 assert!(printed.contains("0.250\t \tb\t\ttwo\n"), "{printed}");
13468 assert!(format_island(&serde_json::json!({})).is_empty());
13469 let persona = serde_json::json!({
13470 "as": "reviewer",
13471 "fired": 3,
13472 "island": [{"id": "a", "text": "one", "activation": 1.0, "seed": true, "ts": now_utc()}]
13473 });
13474 let walked = format_island(&persona);
13475 assert!(walked.contains("Persona reviewer"), "{walked}");
13476 assert!(walked.contains("Fired: 3"), "{walked}");
13477 assert!(!walked.contains("Seat island"), "{walked}");
13478 }
13479
13480 #[test]
13481 fn a_fed_verb_reads_its_stdin() {
13482 let said = run_fed("cat", &[] as &[&str], "one\ntwo\n").unwrap();
13483 assert_eq!(said.stdout, "one\ntwo\n");
13484 assert!(run_fed("sh", &["-c", "exit 2"], "").is_err());
13485 }
13486
13487 #[test]
13488 fn needs_and_cited_are_enclosed_once_each() {
13489 let needs = needs_of(r#"{"needs":["deed-b-2","deed-a-1"],"other":1}"#).unwrap();
13490 assert_eq!(needs, vec!["deed-b-2", "deed-a-1"]);
13491 assert_eq!(
13492 enclose(needs, "deed-a-1\n\ndeed-c-3\n"),
13493 vec!["deed-a-1", "deed-b-2", "deed-c-3"]
13494 );
13495 assert!(needs_of("{}").unwrap().is_empty());
13496 assert!(needs_of("not json").is_err());
13497 }
13498
13499 #[test]
13500 fn a_json_config_takes_the_entry_by_pointer() {
13501 let dir = std::env::temp_dir().join(format!("ljos-onboard-json-{}", std::process::id()));
13502 std::fs::create_dir_all(&dir).unwrap();
13503 let config = dir.join("runner.json");
13504 std::fs::write(&config, "{\"model\": \"x\"}\n").unwrap();
13505 let entry = serde_json::json!({"type": "local", "command": ["/bin/ljos-mcp"]});
13506 set_json_entry(&config, "/mcp/ljos", &entry).unwrap();
13507 let doc: Value = serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
13508 assert_eq!(doc["model"], "x", "the rest of the file stands");
13509 assert_eq!(doc["mcp"]["ljos"]["command"][0], "/bin/ljos-mcp");
13510 let h = Harness {
13511 name: "runner".into(),
13512 register: Vec::new(),
13513 registered: Vec::new(),
13514 config: None,
13515 marker: None,
13516 snippet: None,
13517 config_json: Some(config.display().to_string()),
13518 json_pointer: Some("/mcp/ljos".into()),
13519 json_entry: None,
13520 skills: None,
13521 hooks: None,
13522 hook_events: Vec::new(),
13523 plugin: None,
13524 plugin_template: None,
13525 probe: Vec::new(),
13526 clients: Vec::new(),
13527 };
13528 assert_eq!(is_registered(&h, Path::new("/bin/ljos-mcp")), Some(true));
13529 let _ = std::fs::remove_dir_all(&dir);
13530 }
13531
13532 #[test]
13533 fn a_persona_set_is_in_the_pack_alphabet() {
13534 assert_eq!(persona_set("Reviewer"), "persona-reviewer");
13535 assert_eq!(persona_set("first gpu:user"), "persona-first-gpu-user");
13536 assert!(persona_set("x".repeat(60).as_str()).len() <= 32);
13537 }
13538
13539 #[test]
13540 fn the_roster_lists_each_persona_on_one_line() {
13541 assert!(format_personas(&[]).starts_with("no personas;"));
13542 let roster = format_personas(&[
13543 Persona {
13544 name: "reviewer".into(),
13545 anchor: 0.2,
13546 view: "Reads for what breaks.".into(),
13547 entities: vec!["docs".into(), "release".into()],
13548 },
13549 Persona {
13550 name: "reader".into(),
13551 anchor: 0.8,
13552 view: "Reads as a first-time user.".into(),
13553 entities: Vec::new(),
13554 },
13555 ]);
13556 let lines: Vec<&str> = roster.lines().collect();
13557 assert_eq!(lines.len(), 2);
13558 assert!(
13559 lines[0].starts_with("reviewer anchor 0.20 about docs, release Reads"),
13560 "{}",
13561 lines[0]
13562 );
13563 assert!(lines[1].contains("about anything"), "{}", lines[1]);
13564 }
13565
13566 #[test]
13567 fn the_sweep_line_counts_what_moved_and_is_silent_otherwise() {
13568 assert_eq!(format_sweep(None), "");
13569 assert_eq!(
13570 format_sweep(Some(&serde_json::json!({"lapsed": 0, "forgotten": 0}))),
13571 ""
13572 );
13573 let line = format_sweep(Some(&serde_json::json!({"lapsed": 2, "forgotten": 1})));
13574 assert!(line.contains("2 reviews lapsed"), "{line}");
13575 assert!(line.contains("1 never-recalled claim forgotten"), "{line}");
13576 let one = format_sweep(Some(&serde_json::json!({"lapsed": 1, "forgotten": 0})));
13577 assert!(
13578 one.contains("1 review lapsed past twice its interval"),
13579 "{one}"
13580 );
13581 }
13582
13583 #[test]
13584 fn due_is_the_past_soonest_first() {
13585 let atoms = vec![
13586 serde_json::json!({"id": "late", "due_at": "2026-02-01T00:00:00.000Z"}),
13587 serde_json::json!({"id": "later", "due_at": "2026-03-01T00:00:00.000Z"}),
13588 serde_json::json!({"id": "future", "due_at": "2099-01-01T00:00:00.000Z"}),
13589 serde_json::json!({"id": "never"}),
13590 serde_json::json!({"id": "blank", "due_at": ""}),
13591 ];
13592 let due = due_of(&atoms, "2026-06-01T00:00:00.000Z");
13593 let ids: Vec<&str> = due.iter().map(|a| a["id"].as_str().unwrap()).collect();
13594 assert_eq!(ids, ["never", "blank", "late", "later"]);
13597 assert!(now_utc().ends_with(".000Z"));
13598 assert!(now_utc().as_str() > "2026-01-01T00:00:00.000Z");
13599 }
13600
13601 #[test]
13602 fn timeline_exposes_event_rows() {
13603 let src = include_str!("lib.rs");
13604 assert!(src.contains("pub fn timeline_events"));
13605 assert!(src.contains("Result<Vec<Event>>"));
13606 assert!(src.contains("pub fn pack_last_write_ts"));
13607 assert!(src.contains("GET /v1/status"));
13608 assert!(src.contains("vissue_core::agent::show_json"));
13609 }
13610
13611 #[test]
13612 fn timeline_of_does_not_shell_vissue() {
13613 let src = include_str!("lib.rs");
13614 let start = src.find("fn timeline_of").expect("timeline_of");
13615 let end = src[start..]
13616 .find("\npub fn timeline(")
13617 .map(|i| start + i)
13618 .expect("timeline after timeline_of");
13619 let body = &src[start..end];
13620 assert!(
13621 !body.contains("run_captured(\"vissue\""),
13622 "timeline_of must not shell vissue"
13623 );
13624 assert!(
13625 !body.contains("Command::new(\"vissue\")"),
13626 "timeline_of must not Command::new vissue"
13627 );
13628 assert!(
13629 body.contains("tracker_show_json"),
13630 "timeline_of should call the tracker library"
13631 );
13632 }
13633
13634 #[test]
13635 fn timeline_events_reads_the_tracker_without_shelling_vissue() {
13636 let _g = env_guard();
13637 let dir = tempfile::tempdir().unwrap();
13638 let project = dir.path().join("Software/sample");
13639 std::fs::create_dir_all(&project).unwrap();
13640 std::fs::write(
13641 project.join("issues.org"),
13642 "#+TITLE: sample issues\n#+VISSUE: 1\n#+CATEGORY: sample\n#+TODO: TODO STARTED BLOCKED | DONE CANCELLED\n\n* TODO [#B] Deed rail library show\n:PROPERTIES:\n:ID: sample-k2p2\n:CREATED: [2026-09-20 Sat]\n:END:\n",
13643 )
13644 .unwrap();
13645 let old_issue_root = std::env::var_os("ISSUE_ROOT");
13646 let old_vissue_root = std::env::var_os("VISSUE_ROOT");
13647 let old_no_route = std::env::var_os("VISSUE_NO_ROUTE");
13648 let old_path = std::env::var_os("PATH");
13649 unsafe {
13650 std::env::set_var("ISSUE_ROOT", dir.path());
13651 std::env::set_var("VISSUE_ROOT", dir.path());
13652 std::env::set_var("VISSUE_NO_ROUTE", "1");
13653 std::env::set_var("PATH", "/usr/bin");
13654 }
13655 let events = timeline_events("sample-k2p2", 12);
13656 unsafe {
13657 match old_issue_root {
13658 Some(v) => std::env::set_var("ISSUE_ROOT", v),
13659 None => std::env::remove_var("ISSUE_ROOT"),
13660 }
13661 match old_vissue_root {
13662 Some(v) => std::env::set_var("VISSUE_ROOT", v),
13663 None => std::env::remove_var("VISSUE_ROOT"),
13664 }
13665 match old_no_route {
13666 Some(v) => std::env::set_var("VISSUE_NO_ROUTE", v),
13667 None => std::env::remove_var("VISSUE_NO_ROUTE"),
13668 }
13669 match old_path {
13670 Some(v) => std::env::set_var("PATH", v),
13671 None => std::env::remove_var("PATH"),
13672 }
13673 }
13674 let events = events.expect("timeline_events should read the tracker library");
13675 assert!(
13676 events
13677 .iter()
13678 .any(|e| e.source == "tracker" && e.text == "created"),
13679 "{events:?}"
13680 );
13681 }
13682
13683 const EVIDENCE: &str = "stdout:\n== building and installing GCCcore/15.2.0...\nstderr:\nERROR: Installation of GCCcore-15.2.0.eb failed: shell command 'make ...' failed with exit code 2 in build step for GCCcore-15.2.0.eb\nsrun: error: task 0 exited";
13684
13685 #[test]
13686 fn a_bundle_becomes_rows_with_edges_and_steady_ids() {
13687 let dir = std::env::temp_dir().join(format!("ljos-bump-{}", std::process::id()));
13688 let _ = std::fs::remove_dir_all(&dir);
13689 std::fs::create_dir_all(dir.join("locks")).unwrap();
13690 std::fs::write(
13691 dir.join("locks/default.lock.json"),
13692 r#"{"package":"eOn","version":"2.17.10","toolchain":{"name":"foss","version":"2026.1"},"versionsuffix":"",
13693 "dependencies":[
13694 {"name":"CMake","version":"4.2.1","toolchain":{"name":"GCCcore","version":"15.2.0"},"easyconfig_path":"c/CMake/CMake-4.2.1-GCCcore-15.2.0.eb","build":true},
13695 {"name":"Eigen","version":"5.0.0","toolchain":{"name":"GCCcore","version":"15.2.0"},"easyconfig_path":"e/Eigen/Eigen-5.0.0-GCCcore-15.2.0.eb","build":true},
13696 {"name":"Python","version":"3.14.2","toolchain":{"name":"GCCcore","version":"15.2.0"},"easyconfig_path":"p/Python/Python-3.14.2-GCCcore-15.2.0.eb","build":false}]}"#,
13697 )
13698 .unwrap();
13699 std::fs::write(
13700 dir.join("package.sbom.cdx.json"),
13701 r#"{"components":[],"dependencies":[
13702 {"ref":"pkg:generic/eOn@2.17.10","dependsOn":["pkg:generic/CMake@==4.2.1","pkg:generic/Eigen@==5.0.0","pkg:generic/Python@==3.14.2"]},
13703 {"ref":"pkg:generic/Eigen@==5.0.0","dependsOn":["pkg:generic/CMake@==4.2.1"]},
13704 {"ref":"pkg:generic/CMake@==4.2.1"}]}"#,
13705 )
13706 .unwrap();
13707 let (generation, rows) = bump_rows(&dir, "ebstack", None).unwrap();
13708 assert_eq!(generation, "foss/2026.1");
13709 let modules: Vec<&str> = rows.iter().map(|r| r.module.as_str()).collect();
13710 assert_eq!(
13711 modules,
13712 [
13713 "eOn-2.17.10-foss-2026.1",
13714 "CMake-4.2.1-GCCcore-15.2.0",
13715 "Eigen-5.0.0-GCCcore-15.2.0",
13716 "Python-3.14.2-GCCcore-15.2.0"
13717 ],
13718 "the root first, then every module the lock names, build dependencies included"
13719 );
13720 let cmake = &rows[1];
13721 let eigen = &rows[2];
13722 let python = &rows[3];
13723 assert!(cmake.blockers.is_empty());
13724 assert_eq!(eigen.blockers, std::slice::from_ref(&cmake.id));
13725 assert_eq!(
13726 rows[0].blockers,
13727 [cmake.id.clone(), eigen.id.clone(), python.id.clone()],
13728 "the root is blocked by every module it depends on"
13729 );
13730 assert_eq!(
13731 rows[0].id,
13732 bump_issue_id("ebstack", "eOn-2.17.10-foss-2026.1", "foss/2026.1")
13733 );
13734 assert!(rows[0].id.starts_with("ebstack-") && rows[0].id.len() == "ebstack-".len() + 8);
13735 assert_ne!(
13736 rows[0].id,
13737 bump_issue_id("ebstack", "eOn-2.17.10-foss-2026.1", "foss/2027a")
13738 );
13739 assert!(rows.iter().all(|r| r.result == "would make"));
13740 let _ = std::fs::remove_dir_all(&dir);
13741 }
13742
13743 #[test]
13744 fn a_finding_lesson_is_two_short_sentences_about_the_recipe() {
13745 let campaign = Campaign {
13746 package: "eOn".into(),
13747 version: "2.17.10".into(),
13748 target: "terra".into(),
13749 status: "completed".into(),
13750 attempts: 29,
13751 findings: Vec::new(),
13752 };
13753 let f = Finding {
13754 id: "attempt:6:finding:6".into(),
13755 status: "resolved".into(),
13756 class: "compile".into(),
13757 disposition: "requires-judgment".into(),
13758 stage: "build".into(),
13759 recipe: recipe_stem("easyconfigs/e/eOn/eOn-2.17.10-foss-2026.1.eb"),
13760 module: failed_module(EVIDENCE).unwrap_or_default(),
13761 summary: "Compile failure from EasyBuild command (exit Some(1))".into(),
13762 error: error_line(EVIDENCE, "Compile failure"),
13763 action: "applied the GCC 14 libsanitizer kernel headers patch. Kept in the overlay"
13764 .into(),
13765 changes: vec!["overlay/g/GCCcore/GCCcore-15.2.0.eb".into()],
13766 };
13767 assert_eq!(f.module, "GCCcore-15.2.0");
13768 let lesson = finding_lesson(&campaign, &f);
13769 assert_eq!(
13770 lesson,
13771 "GCCcore-15.2.0 for eOn-2.17.10-foss-2026.1 on terra: compile failed in the build step \
13772 with shell command 'make' failed with exit code 2 in build. \
13773 Fix: applied the GCC 14 libsanitizer kernel headers patch, Kept in the overlay in GCCcore-15.2.0."
13774 );
13775 assert!(!lesson.contains("srun"));
13776 assert_eq!(
13777 finding_entities(&campaign, &f),
13778 [
13779 "GCCcore-15.2.0",
13780 "GCCcore",
13781 "eOn-2.17.10-foss-2026.1",
13782 "eOn",
13783 "compile"
13784 ]
13785 );
13786 let retry = Finding {
13787 action: "successful campaign retry superseded this finding".into(),
13788 ..f.clone()
13789 };
13790 assert!(superseded_by_retry(&retry));
13791 assert!(!superseded_by_retry(&f));
13792 assert!(finding_lesson(&campaign, &retry).ends_with("A later attempt got past it."));
13793 assert_eq!(
13794 failed_module("== building and installing gettext/0.26...\n== FAILED"),
13795 Some("gettext-0.26".into())
13796 );
13797 }
13798
13799 #[test]
13800 fn tracker_decimal_confidence_remains_a_scored_forecast() {
13801 let forecasts = super::forecasts_from_json(
13802 r#"[{"agent":"alice","choice":"accept","confidence":"0.8"},
13803 {"agent":"bob","choice":"reject","confidence":0.6},
13804 {"agent":"carol","choice":"accept","confidence":null},
13805 {"agent":"dana","choice":"accept"}]"#,
13806 )
13807 .unwrap();
13808 assert_eq!(forecasts[0].confidence, Some(0.8));
13809 assert_eq!(forecasts[1].confidence, Some(0.6));
13810 assert_eq!(forecasts[2].confidence, None);
13811 assert_eq!(forecasts[3].confidence, None);
13812 let (score, count) = super::mean_brier(&forecasts, "accept").unwrap();
13813 assert_eq!(count, 2);
13814 assert!((score - 0.2).abs() < 1e-14);
13815 }
13816
13817 #[test]
13818 fn invalid_tracker_confidence_is_not_silently_unscored() {
13819 for confidence in ["0", "-0.1", "1.1", "\"NaN\"", "\"oops\"", "true", "[]"] {
13820 let raw =
13821 format!(r#"[{{"agent":"alice","choice":"accept","confidence":{confidence}}}]"#);
13822 let error = super::forecasts_from_json(&raw).unwrap_err().to_string();
13823 assert!(error.contains("probability in (0, 1]"), "{error}");
13824 }
13825 }
13826
13827 #[test]
13828 fn ahead_of_a_cached_registry_answer_is_said() {
13829 let cached = super::CrateVersion {
13830 version: "0.12.16".into(),
13831 cached: true,
13832 };
13833 let (state, ok) = super::bin_health("/bin/ljos", Some("0.13.5"), Some(&cached));
13834 assert!(ok, "{state}");
13835 assert!(
13836 state.contains("ahead of crates.io (cached) 0.12.16"),
13837 "{state}"
13838 );
13839 let (same, _) = super::bin_health("/bin/ljos", Some("0.12.16"), Some(&cached));
13840 assert!(same.ends_with("crates.io (cached) 0.12.16"), "{same}");
13841 }
13842
13843 #[test]
13844 fn the_mcp_binary_tracks_the_ljos_crate() {
13845 let crate_name = super::SEAT_BINS
13846 .iter()
13847 .find(|(bin, _)| *bin == "ljos-mcp")
13848 .map(|(_, name)| *name);
13849 assert_eq!(crate_name, Some("ljos"));
13850 }
13851
13852 #[test]
13853 fn a_behind_required_bin_still_answers() {
13854 let latest = super::CrateVersion {
13855 version: "0.9.5".into(),
13856 cached: false,
13857 };
13858 let (state, ok) = super::bin_health("/bin/packsetd", Some("0.9.2"), Some(&latest));
13859 assert!(ok, "{state}");
13860 assert!(state.contains("behind crates.io 0.9.5"), "{state}");
13861 let rows = vec![Habitat {
13862 name: "packsetd",
13863 state,
13864 ok,
13865 }];
13866 assert!(
13867 healthy(&rows),
13868 "sitting must not refuse a stale but answering bin"
13869 );
13870 }
13871
13872 #[test]
13873 fn ballot_health_requires_both_evidence_and_confidence_arguments() {
13874 use std::os::unix::fs::PermissionsExt;
13875 let dir = tempfile::tempdir().unwrap();
13876 let path = dir.path().join("vissue");
13877 for (help, missing) in [
13878 ("--for OPTION --json", Some("--used, --confidence")),
13879 ("--for OPTION --used DEEDS", Some("--confidence")),
13880 ("--for OPTION --confidence P", Some("--used")),
13881 ("--for OPTION --used DEEDS --confidence P", None),
13882 ] {
13883 std::fs::write(
13884 &path,
13885 format!(
13886 "#!/bin/sh\n[ \"$*\" = 'vote --help' ] || exit 3\nprintf '%s\\n' '{help}'\n"
13887 ),
13888 )
13889 .unwrap();
13890 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
13891 let result = super::check_vissue_ballot_protocol(&path);
13892 if let Some(missing) = missing {
13893 let error = result.unwrap_err().to_string();
13894 assert!(error.contains(&format!("missing {missing};")), "{error}");
13895 let rows = vec![Habitat {
13896 name: "vissue",
13897 state: error,
13898 ok: false,
13899 }];
13900 assert!(!healthy(&rows));
13901 } else {
13902 result.unwrap();
13903 }
13904 }
13905 }
13906
13907 #[test]
13908 fn ballot_health_refuses_a_failed_help_command() {
13909 use std::os::unix::fs::PermissionsExt;
13910 let dir = tempfile::tempdir().unwrap();
13911 let path = dir.path().join("vissue");
13912 std::fs::write(
13913 &path,
13914 "#!/bin/sh\necho '--used DEEDS --confidence P'\nexit 2\n",
13915 )
13916 .unwrap();
13917 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
13918 let error = super::check_vissue_ballot_protocol(&path)
13919 .unwrap_err()
13920 .to_string();
13921 assert!(error.contains("vote --help failed"), "{error}");
13922 }
13923
13924 #[test]
13925 fn the_doctor_names_every_habitat_and_the_pack_gates_health() {
13926 let rows = doctor();
13927 let names: Vec<&str> = rows.iter().map(|h| h.name).collect();
13928 for want in [
13929 "ljos",
13930 "packset-embed",
13931 "vissue",
13932 "deedar",
13933 "packset",
13934 "pack",
13935 "encoder",
13936 "host key",
13937 "deed store",
13938 "tracker",
13939 ] {
13940 assert!(names.contains(&want), "{names:?}");
13941 }
13942 let table = format_doctor(&rows);
13943 assert_eq!(table.lines().count(), rows.len());
13944 let sick = vec![Habitat {
13945 name: "pack",
13946 state: "PACKSET_URL unset".into(),
13947 ok: false,
13948 }];
13949 assert!(!healthy(&sick));
13950 let fine = vec![Habitat {
13951 name: "landfold",
13952 state: "not on PATH".into(),
13953 ok: false,
13954 }];
13955 assert!(healthy(&fine));
13956 assert_eq!(
13957 super::format_write_ack(&serde_json::json!({
13958 "id": "ab",
13959 "kind": "lesson",
13960 "due_at": "2026-09-15T00:00:00Z",
13961 "text": "The encoder sits beside packsetd."
13962 })),
13963 "ab\tlesson\tdue 2026-09-15T00:00:00Z\tThe encoder sits beside packsetd."
13964 );
13965 assert_eq!(super::parse_semver("ljos 0.12.8"), Some("0.12.8"));
13966 assert_eq!(
13967 super::cmp_semver("0.4.1", "0.5.3"),
13968 Some(std::cmp::Ordering::Less)
13969 );
13970 }
13971
13972 #[test]
13973 fn enclosed_atoms_are_read_from_every_jsonl_in_the_bag() {
13974 let dir = std::env::temp_dir().join(format!("ljos-bag-{}", std::process::id()));
13975 let _ = std::fs::remove_dir_all(&dir);
13976 let atoms = dir.join("data").join("atoms");
13977 std::fs::create_dir_all(&atoms).unwrap();
13978 std::fs::write(
13979 atoms.join("a.jsonl"),
13980 "{\"kind\":\"lesson\",\"text\":\"one\"}\n\n{\"kind\":\"trust\",\"from\":\"a\",\"to\":\"b\",\"weight\":0.5}\n",
13981 )
13982 .unwrap();
13983 std::fs::write(
13984 atoms.join("b.jsonl"),
13985 "{\"kind\":\"preference\",\"text\":\"two\"}\n",
13986 )
13987 .unwrap();
13988 let read = enclosed_atoms(&dir).unwrap();
13989 assert_eq!(read.len(), 3);
13990 assert_eq!(trust_rows(&read).len(), 1);
13991 assert!(enclosed_atoms(&dir.join("nowhere")).unwrap().is_empty());
13992 std::fs::write(atoms.join("c.jsonl"), "not json\n").unwrap();
13993 assert!(enclosed_atoms(&dir).is_err());
13994 let _ = std::fs::remove_dir_all(&dir);
13995
13996 let table = format_due(&[serde_json::json!({
13997 "id": "x", "kind": "lesson", "text": "t", "due_at": "2026-01-01T00:00:00.000Z"
13998 })]);
13999 assert_eq!(table, "2026-01-01T00:00:00.000Z\tlesson\tx\tt\n");
14000 }
14001
14002 fn read_http(s: &mut impl Read) -> String {
14003 let mut buf = Vec::new();
14004 let mut tmp = [0u8; 1024];
14005 loop {
14006 let n = s.read(&mut tmp).unwrap_or(0);
14007 if n == 0 {
14008 break;
14009 }
14010 buf.extend_from_slice(&tmp[..n]);
14011 if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
14012 let headers = &buf[..at];
14013 let mut need = 0usize;
14014 for line in headers.split(|b| *b == b'\n') {
14015 let line = std::str::from_utf8(line).unwrap_or("").trim();
14016 if let Some(v) = line
14017 .split_once(':')
14018 .filter(|(k, _)| k.eq_ignore_ascii_case("content-length"))
14019 .map(|(_, v)| v.trim())
14020 {
14021 need = v.parse().unwrap_or(0);
14022 }
14023 }
14024 let have = buf.len().saturating_sub(at + 4);
14025 if have >= need {
14026 break;
14027 }
14028 }
14029 }
14030 String::from_utf8_lossy(&buf).into_owned()
14031 }
14032
14033 fn serve_capture() -> (String, Arc<Mutex<String>>) {
14034 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14035 let addr = listener.local_addr().unwrap();
14036 let captured = Arc::new(Mutex::new(String::new()));
14037 let slot = captured.clone();
14038 std::thread::spawn(move || {
14039 if let Ok((mut s, _)) = listener.accept() {
14040 *slot.lock().unwrap() = read_http(&mut s);
14041 let body =
14042 r#"{"id":"atom-1","kind":"lesson","text":"the default fuse is CombMNZ"}"#;
14043 let resp = format!(
14044 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
14045 body.len()
14046 );
14047 let _ = s.write_all(resp.as_bytes());
14048 }
14049 });
14050 (format!("http://{addr}"), captured)
14051 }
14052
14053 #[test]
14054 fn remember_posts_v1_atoms() {
14055 let (url, captured) = serve_capture();
14056 let client = PacksetClient::new(&url);
14057 let body = post_claim(&client, "Remember", "the default fuse is CombMNZ", "ws").unwrap();
14058 assert_eq!(body["id"], "atom-1");
14059 let req = captured.lock().unwrap().clone();
14060 assert!(req.contains("POST"), "{req}");
14061 assert!(req.contains("/v1/atoms"), "{req}");
14062 assert!(req.contains("\"kind\":\"lesson\""), "{req}");
14063 assert!(req.contains("the default fuse is CombMNZ"), "{req}");
14064 assert!(req.contains("\"level\":\"explicit\""), "{req}");
14065 assert!(req.contains("horizon:transient"), "{req}");
14066 assert!(!req.contains("extract"), "{req}");
14067 }
14068
14069 #[test]
14070 fn forget_posts_the_id_and_workspace() {
14071 let (url, captured) = serve_capture();
14072 let client = PacksetClient::new(&url);
14073 let body = client.delete_atom("ws", "atom-1", None).unwrap();
14074 assert_eq!(body["id"], "atom-1");
14075 let req = captured.lock().unwrap().clone();
14076 assert!(req.contains("POST"), "{req}");
14077 assert!(req.contains("/v1/atoms/delete"), "{req}");
14078 assert!(req.contains("\"id\":\"atom-1\""), "{req}");
14079 assert!(req.contains("\"workspace\":\"ws\""), "{req}");
14080 assert!(!req.contains("\"why\""), "{req}");
14083 }
14084
14085 #[test]
14088 fn forget_carries_the_deed_that_withdrew_the_claim() {
14089 let (url, captured) = serve_capture();
14090 let client = PacksetClient::new(&url);
14091 client
14092 .delete_atom("ws", "atom-1", Some("deed-patch-overlay"))
14093 .unwrap();
14094 let req = captured.lock().unwrap().clone();
14095 assert!(req.contains("\"why\":\"deed-patch-overlay\""), "{req}");
14096 }
14097
14098 #[test]
14101 fn forget_refuses_an_empty_id() {
14102 let err = packset_forget(" ", None).unwrap_err();
14103 assert!(err.to_string().contains("atom id is required"), "{err}");
14104 }
14105
14106 fn fake_vissue(dir: &std::path::Path, show_ok: bool, claim_ok: bool) -> std::path::PathBuf {
14109 let log = dir.join("calls.log");
14110 let script = format!(
14111 "#!/bin/sh\necho \"$* VISSUE_AGENT=${{VISSUE_AGENT:-}}\" >> '{}'\ncase \"$1\" in\n show) {} ;;\n claim) {} ;;\nesac\nexit 0\n",
14112 log.display(),
14113 if show_ok { "echo '{}'" } else { "exit 1" },
14114 if claim_ok { "echo claimed" } else { "echo refused >&2; exit 1" },
14115 );
14116 let path = dir.join("vissue");
14117 std::fs::write(&path, script).unwrap();
14118 #[cfg(unix)]
14119 {
14120 use std::os::unix::fs::PermissionsExt;
14121 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
14122 }
14123 log
14124 }
14125
14126 fn with_fake_on_path<T>(dir: &std::path::Path, f: impl FnOnce() -> T) -> T {
14128 let old = std::env::var_os("PATH").unwrap_or_default();
14129 let mut new = std::ffi::OsString::from(dir.as_os_str());
14130 new.push(":");
14131 new.push(&old);
14132 unsafe {
14133 std::env::set_var("PATH", &new);
14134 }
14135 let out = f();
14136 unsafe {
14137 std::env::set_var("PATH", old);
14138 }
14139 out
14140 }
14141
14142 #[test]
14143 fn a_claim_stamps_the_tracker_under_the_assignee() {
14144 let _g = env_guard();
14145 let dir = tempfile::tempdir().unwrap();
14146 let log = fake_vissue(dir.path(), true, true);
14147 let said = with_fake_on_path(dir.path(), || stamp_tracker("proj-1a2b", "alice")).unwrap();
14148 assert_eq!(
14149 said.as_deref(),
14150 Some("tracker: proj-1a2b STARTED under alice")
14151 );
14152 let calls = std::fs::read_to_string(log).unwrap();
14153 assert!(
14154 calls.contains("claim proj-1a2b VISSUE_AGENT=alice"),
14155 "{calls}"
14156 );
14157 }
14158
14159 #[test]
14160 fn a_node_the_tracker_does_not_know_stamps_nothing() {
14161 let _g = env_guard();
14162 let dir = tempfile::tempdir().unwrap();
14163 let log = fake_vissue(dir.path(), false, true);
14164 let said = with_fake_on_path(dir.path(), || stamp_tracker("deadbeef", "alice")).unwrap();
14165 assert_eq!(said, None);
14166 let calls = std::fs::read_to_string(log).unwrap();
14167 assert!(
14168 !calls.contains("claim"),
14169 "asked to claim a non-issue: {calls}"
14170 );
14171 }
14172
14173 #[test]
14174 fn a_closed_tracker_heading_is_reopened_when_the_graph_takes_it() {
14175 let _g = env_guard();
14176 let dir = tempfile::tempdir().unwrap();
14177 let log = dir.path().join("calls.log");
14178 let script = format!(
14179 "#!/bin/sh\necho \"$* VISSUE_AGENT=${{VISSUE_AGENT:-}}\" >> '{log}'\ncase \"$1\" in\n show) echo '{{}}'; exit 0 ;;\n update) echo updated; exit 0 ;;\n claim)\n echo \"$*\" | grep -q -- '--force' && {{ echo claimed; exit 0; }}\n if grep -q '^update ' '{log}'; then echo 'vissue: proj-1a2b is claimed by you since [2026-01-01]; pass --force to take it over' >&2; exit 1; fi\n echo 'vissue: proj-1a2b is already DONE; cannot claim' >&2\n exit 1\n ;;\nesac\nexit 1\n",
14180 log = log.display()
14181 );
14182 let path = dir.path().join("vissue");
14183 std::fs::write(&path, script).unwrap();
14184 #[cfg(unix)]
14185 {
14186 use std::os::unix::fs::PermissionsExt;
14187 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
14188 }
14189 let said = with_fake_on_path(dir.path(), || stamp_tracker("proj-1a2b", "alice")).unwrap();
14190 assert_eq!(
14191 said.as_deref(),
14192 Some("tracker: proj-1a2b STARTED under alice")
14193 );
14194 let calls = std::fs::read_to_string(&log).unwrap();
14195 assert!(
14196 calls.contains("update proj-1a2b -s STARTED"),
14197 "reopen the heading: {calls}"
14198 );
14199 assert!(
14200 calls.contains("claim proj-1a2b --force VISSUE_AGENT=alice"),
14201 "{calls}"
14202 );
14203 }
14204
14205 #[test]
14206 fn a_tracker_refusal_names_the_way_out() {
14207 let _g = env_guard();
14208 let dir = tempfile::tempdir().unwrap();
14209 let _log = fake_vissue(dir.path(), true, false);
14210 let err =
14211 with_fake_on_path(dir.path(), || stamp_tracker("proj-1a2b", "alice")).unwrap_err();
14212 let text = format!("{err:#}");
14213 assert!(text.contains("ljos release proj-1a2b"), "{text}");
14214 assert!(text.contains("refused"), "{text}");
14215 }
14216}