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", "herdr", "systemd", "init", "sshd", "login",
899];
900
901fn is_session(comm: &str) -> bool {
904 SESSION.iter().any(|s| comm.starts_with(s))
905}
906
907fn own_ancestry() -> Vec<(u32, String)> {
911 ancestry()
912 .into_iter()
913 .take_while(|(_, comm)| !is_session(comm))
914 .collect()
915}
916
917const PLACES: &[&str] = &[
919 "bin",
920 "sbin",
921 "versions",
922 "current",
923 "dist",
924 "build",
925 "target",
926 "release",
927 "debug",
928 "node_modules",
929 ".bin",
930 "lib",
931 "libexec",
932 "app",
933 "resources",
934];
935
936const INTERPRETERS: &[&str] = &[
938 "node", "nodejs", "bun", "deno", "python", "python3", "ruby", "perl", "java",
939];
940
941fn version_like(s: &str) -> bool {
942 let t = s.strip_prefix('v').unwrap_or(s);
943 t.chars().next().is_some_and(|c| c.is_ascii_digit())
944}
945
946#[cfg(target_os = "linux")]
951fn program_name(pid: u32, comm: &str) -> String {
952 let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default();
953 let args: Vec<String> = cmdline
954 .split(|b| *b == 0)
955 .filter(|a| !a.is_empty())
956 .map(|a| String::from_utf8_lossy(a).into_owned())
957 .collect();
958 let mut candidates: Vec<&str> = Vec::new();
959 if let Some(first) = args.first() {
960 let base = Path::new(first)
961 .file_name()
962 .and_then(|f| f.to_str())
963 .unwrap_or(first);
964 if INTERPRETERS.contains(&base) {
965 if let Some(script) = args.iter().skip(1).find(|a| !a.starts_with('-')) {
966 candidates.push(script);
967 }
968 }
969 candidates.push(first);
970 }
971 for path in candidates {
972 let mut parts: Vec<&str> = Path::new(path)
973 .components()
974 .filter_map(|c| c.as_os_str().to_str())
975 .collect();
976 while let Some(last) = parts.pop() {
977 let name = last.rsplit_once('.').map_or(last, |(stem, ext)| {
978 if ["js", "mjs", "cjs", "py", "rb", "pl", "jar", "exe"].contains(&ext) {
979 stem
980 } else {
981 last
982 }
983 });
984 if name.is_empty() || version_like(name) || PLACES.contains(&name) || name == "/" {
985 continue;
986 }
987 if name.starts_with('.') || name.contains(std::path::MAIN_SEPARATOR) {
988 continue;
989 }
990 return name.to_string();
991 }
992 }
993 comm.to_string()
994}
995
996#[cfg(not(target_os = "linux"))]
997fn program_name(_pid: u32, comm: &str) -> String {
998 comm.to_string()
999}
1000
1001fn seat_from_tree() -> Option<Seat> {
1007 if let Some(seat) = seat_from_tree_records() {
1008 return Some(seat);
1009 }
1010 let chain = ancestry();
1011 for (pid, comm) in &chain {
1012 let name = comm.as_str();
1013 if WRAPPERS.contains(&name) {
1014 continue;
1015 }
1016 if is_session(name) {
1017 return None;
1018 }
1019 let program = program_name(*pid, name);
1020 return Some(Seat::tagged(
1021 seat_slug(&program),
1022 &conversation_tag(*pid),
1023 format!("the process tree, {program} {pid}"),
1024 ));
1025 }
1026 None
1027}
1028
1029fn seat_from_tree_records() -> Option<Seat> {
1033 ancestry().into_iter().find_map(|(pid, _)| {
1034 read_record(
1035 &seat_record_path(pid),
1036 format!("the server the runner opened, process {pid}"),
1037 )
1038 })
1039}
1040
1041fn named_var(key: &str) -> Option<String> {
1042 std::env::var(key)
1043 .ok()
1044 .map(|v| v.trim().to_string())
1045 .filter(|v| !v.is_empty() && !omitted_actor_name(v))
1046}
1047
1048#[must_use]
1057pub fn whoami() -> Seat {
1058 if let Ok(seat) = CALL_SEAT.try_with(Clone::clone) {
1059 return seat;
1060 }
1061 let session = session_actor();
1062 let named = named_var("LJOS_SEAT")
1065 .map(|n| (n, "LJOS_SEAT"))
1066 .or_else(|| named_var("VISSUE_AGENT").map(|n| (n, "VISSUE_AGENT")));
1067 let record = seat_from_session_records().map(|by_id| {
1072 seat_from_tree_records()
1073 .filter(|above| above.seat != by_id.seat)
1074 .unwrap_or(by_id)
1075 });
1076 let program = ANNOUNCED
1077 .get()
1078 .cloned()
1079 .or_else(|| record.clone())
1080 .or_else(seat_from_tree);
1081 let agent = named_var("VISSUE_AGENT");
1082 let seat_name = named
1083 .as_ref()
1084 .map(|(n, _)| n.clone())
1085 .or_else(|| program.as_ref().map(|p| p.seat.clone()))
1086 .or_else(|| agent.clone())
1087 .unwrap_or_else(login_user);
1088 if let Some(record) = record {
1091 return Seat {
1092 seat: seat_name,
1093 holder: record.holder,
1094 source: record.source,
1095 };
1096 }
1097 if let Some((holder, keys)) = session {
1098 let seat = Seat {
1099 seat: seat_name,
1100 holder,
1101 source: keys,
1102 };
1103 #[cfg(not(test))]
1109 for (_, id) in stamped_sessions() {
1110 write_record(&session_record_path(&id), &seat);
1111 }
1112 return seat;
1113 }
1114 match (&named, &program) {
1115 (Some((name, key)), Some(p)) => Seat {
1116 seat: name.clone(),
1117 holder: p.holder.replacen(&p.seat, name, 1),
1118 source: format!("{key}, held by {}", p.source),
1119 },
1120 (Some((name, key)), None) => Seat::whole(name, key),
1121 (None, Some(p)) => p.clone(),
1122 (None, None) => {
1123 if let Some(name) = agent {
1124 Seat::whole(&name, "VISSUE_AGENT")
1125 } else {
1126 Seat::whole(&login_user(), "the login user")
1127 }
1128 }
1129 }
1130}
1131
1132fn login_user() -> String {
1134 std::env::var("USER")
1135 .ok()
1136 .map(|u| u.trim().to_string())
1137 .filter(|u| !u.is_empty())
1138 .unwrap_or_else(|| "seat".to_string())
1139}
1140
1141#[must_use]
1143pub fn seat_name() -> String {
1144 whoami().seat
1145}
1146
1147#[must_use]
1149pub fn holder_name() -> String {
1150 whoami().holder
1151}
1152
1153#[must_use]
1158pub fn resolve_assignee(passed: Option<&str>) -> String {
1159 match passed.map(str::trim).filter(|s| !s.is_empty()) {
1160 Some(n) if !omitted_actor_name(n) && !own_seat(n) => n.to_string(),
1161 _ => holder_name(),
1162 }
1163}
1164
1165#[must_use]
1169pub fn occupancy_assignee(passed: Option<&str>, issue: &str) -> String {
1170 occupancy_scope(&resolve_assignee(passed), issue)
1171}
1172
1173fn occupancy_scope(assignee: &str, issue: &str) -> String {
1174 let issue = issue.trim();
1175 if issue.is_empty() || assignee.contains(':') {
1176 assignee.to_string()
1177 } else {
1178 format!("{assignee}:{issue}")
1179 }
1180}
1181
1182#[must_use]
1185pub fn format_seat_row() -> String {
1186 let who = whoami();
1187 format!(
1188 "{}, holding as {} (from {})",
1189 who.seat, who.holder, who.source
1190 )
1191}
1192
1193#[must_use]
1195pub fn format_seat(seat: &Seat) -> String {
1196 format!(
1197 "seat\t{}\nholder\t{}\nsource\t{}\n",
1198 seat.seat, seat.holder, seat.source
1199 )
1200}
1201
1202fn is_registered(h: &Harness, server: &Path) -> Option<bool> {
1204 if !h.registered.is_empty() {
1205 let argv = filled(&h.registered, server, &h.name);
1206 return Some(
1207 argv.first().is_some_and(|bin| on_path(bin)) && {
1208 let (bin, rest) = (&argv[0], &argv[1..]);
1209 run_captured(bin, rest).is_ok()
1210 },
1211 );
1212 }
1213 if let (Some(config), Some(marker)) = (&h.config, &h.marker) {
1214 return Some(std::fs::read_to_string(expand(config)).is_ok_and(|t| t.contains(marker)));
1215 }
1216 if let (Some(config), Some(pointer)) = (&h.config_json, &h.json_pointer) {
1217 return Some(
1218 std::fs::read_to_string(expand(config))
1219 .ok()
1220 .and_then(|t| serde_json::from_str::<Value>(&t).ok())
1221 .is_some_and(|doc| doc.pointer(pointer).is_some()),
1222 );
1223 }
1224 None
1225}
1226
1227fn set_json_entry(config: &Path, pointer: &str, entry: &Value) -> Result<()> {
1230 let mut doc: Value = match std::fs::read_to_string(config) {
1231 Ok(t) if !t.trim().is_empty() => {
1232 serde_json::from_str(&t).with_context(|| format!("{}: not JSON", config.display()))?
1233 }
1234 _ => serde_json::json!({}),
1235 };
1236 let mut at = &mut doc;
1237 let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
1238 let (last, path) = parts
1239 .split_last()
1240 .context("onboard: an empty JSON pointer")?;
1241 for key in path {
1242 at = at
1243 .as_object_mut()
1244 .context("onboard: the pointer crosses a value that is not an object")?
1245 .entry((*key).to_string())
1246 .or_insert_with(|| serde_json::json!({}));
1247 }
1248 at.as_object_mut()
1249 .context("onboard: the pointer's parent is not an object")?
1250 .insert((*last).to_string(), entry.clone());
1251 if let Some(parent) = config.parent() {
1252 std::fs::create_dir_all(parent)?;
1253 }
1254 let mut text = serde_json::to_string_pretty(&doc)?;
1255 text.push('\n');
1256 std::fs::write(config, text)?;
1257 Ok(())
1258}
1259
1260fn bump_ljos_mcp_generation(config: &Path, version: &str, dry: bool) -> Result<Option<String>> {
1263 let text = match std::fs::read_to_string(config) {
1264 Ok(t) => t,
1265 Err(_) => return Ok(None),
1266 };
1267 let mut changed = false;
1268 let mut out = String::new();
1269 for line in text.lines() {
1270 let trimmed = line.trim_start();
1271 if let Some(rhs) = trimmed.strip_prefix("LJOS_MCP_GENERATION") {
1272 let rhs = rhs.trim_start().strip_prefix('=').unwrap_or("").trim();
1273 let val = rhs.trim_matches(|c| c == '"' || c == '\'');
1274 if val == version {
1275 out.push_str(line);
1276 } else {
1277 let indent_len = line.len() - trimmed.len();
1278 out.push_str(&line[..indent_len]);
1279 out.push_str("LJOS_MCP_GENERATION = \"");
1280 out.push_str(version);
1281 out.push('"');
1282 changed = true;
1283 }
1284 } else {
1285 out.push_str(line);
1286 }
1287 out.push('\n');
1288 }
1289 if !changed {
1290 return Ok(None);
1291 }
1292 if dry {
1293 return Ok(Some(version.to_string()));
1294 }
1295 std::fs::write(config, out).with_context(|| config.display().to_string())?;
1296 Ok(Some(version.to_string()))
1297}
1298
1299fn register_step(h: &Harness, server: &Path, dry: bool) -> Step {
1300 let what = format!("{} mcp", h.name);
1301 match is_registered(h, server) {
1302 Some(true) => {
1303 let config = expand(h.config.as_deref().unwrap_or_default());
1304 match bump_ljos_mcp_generation(&config, env!("CARGO_PKG_VERSION"), dry) {
1305 Ok(Some(v)) => Step {
1306 what,
1307 detail: format!("ljos registered; MCP generation {v}"),
1308 ok: true,
1309 },
1310 Ok(None) => Step {
1311 what,
1312 detail: "ljos registered".into(),
1313 ok: true,
1314 },
1315 Err(e) => Step {
1316 what,
1317 detail: format!("ljos registered; generation {e}"),
1318 ok: false,
1319 },
1320 }
1321 }
1322 None => Step {
1323 what,
1324 detail: "no register or config in harnesses.toml; paste `ljos onboard --harness json`"
1325 .into(),
1326 ok: false,
1327 },
1328 Some(false) if !h.register.is_empty() => {
1329 let argv = filled(&h.register, server, &h.name);
1330 if !on_path(&argv[0]) {
1331 return Step {
1332 what,
1333 detail: format!("{} not on PATH", argv[0]),
1334 ok: false,
1335 };
1336 }
1337 if dry {
1338 return Step {
1339 what,
1340 detail: format!("would run {}", argv.join(" ")),
1341 ok: true,
1342 };
1343 }
1344 match run_captured(&argv[0], &argv[1..]) {
1345 Ok(_) => Step {
1346 what,
1347 detail: format!("ran {}", argv.join(" ")),
1348 ok: true,
1349 },
1350 Err(e) => Step {
1351 what,
1352 detail: e.to_string().lines().next().unwrap_or("").to_string(),
1353 ok: false,
1354 },
1355 }
1356 }
1357 Some(false) if h.config_json.is_some() => {
1358 let config = expand(h.config_json.as_deref().unwrap_or_default());
1359 let pointer = h.json_pointer.clone().unwrap_or_default();
1360 let entry_text = h
1361 .json_entry
1362 .as_deref()
1363 .unwrap_or_default()
1364 .replace("{server}", &server.display().to_string())
1365 .replace("{name}", &h.name);
1366 let entry: Value = match serde_json::from_str(&entry_text) {
1367 Ok(v) => v,
1368 Err(e) => {
1369 return Step {
1370 what,
1371 detail: format!("json_entry is not JSON: {e}"),
1372 ok: false,
1373 }
1374 }
1375 };
1376 if dry {
1377 return Step {
1378 what,
1379 detail: format!("would set {pointer} in {}", config.display()),
1380 ok: true,
1381 };
1382 }
1383 match set_json_entry(&config, &pointer, &entry) {
1384 Ok(()) => Step {
1385 what,
1386 detail: format!("set {pointer} in {}", config.display()),
1387 ok: true,
1388 },
1389 Err(e) => Step {
1390 what,
1391 detail: format!("{}: {e}", config.display()),
1392 ok: false,
1393 },
1394 }
1395 }
1396 Some(false) => {
1397 let config = expand(h.config.as_deref().unwrap_or_default());
1398 let snippet = h
1399 .snippet
1400 .as_deref()
1401 .unwrap_or_default()
1402 .replace("{server}", &server.display().to_string())
1403 .replace("{name}", &h.name);
1404 if snippet.is_empty() {
1405 return Step {
1406 what,
1407 detail: format!("no snippet to append to {}", config.display()),
1408 ok: false,
1409 };
1410 }
1411 if dry {
1412 return Step {
1413 what,
1414 detail: format!("would append the entry to {}", config.display()),
1415 ok: true,
1416 };
1417 }
1418 let mut text = std::fs::read_to_string(&config).unwrap_or_default();
1419 if !text.is_empty() && !text.ends_with('\n') {
1420 text.push('\n');
1421 }
1422 text.push_str(&snippet);
1423 let written = config
1424 .parent()
1425 .map_or(Ok(()), std::fs::create_dir_all)
1426 .and_then(|()| std::fs::write(&config, text));
1427 match written {
1428 Ok(()) => Step {
1429 what,
1430 detail: format!("appended the entry to {}", config.display()),
1431 ok: true,
1432 },
1433 Err(e) => Step {
1434 what,
1435 detail: format!("{}: {e}", config.display()),
1436 ok: false,
1437 },
1438 }
1439 }
1440 }
1441}
1442
1443pub fn onboard(harness: &str, dry: bool) -> Result<Vec<Step>> {
1451 onboard_from(&harnesses_path(), harness, dry)
1452}
1453
1454const GROK_HOOKS_JSON: &str = include_str!("../assets/grok/ljos.json");
1456
1457fn ljos_path() -> Result<PathBuf> {
1461 let beside = server_path()?.with_file_name("ljos");
1462 if beside.is_file() {
1463 return Ok(beside);
1464 }
1465 which::which("ljos").context("ljos not on PATH")
1466}
1467
1468fn grok_hooks_json(ljos: &Path) -> String {
1470 GROK_HOOKS_JSON.replace("{ljos}", &ljos.display().to_string())
1471}
1472
1473fn write_grok_hooks(dry: bool) -> Result<Step> {
1474 let dest = home()?.join(".grok/hooks/ljos.json");
1475 if dry {
1476 return Ok(Step {
1477 what: "hook".into(),
1478 detail: format!("would write {}", dest.display()),
1479 ok: true,
1480 });
1481 }
1482 if let Some(dir) = dest.parent() {
1483 std::fs::create_dir_all(dir)?;
1484 }
1485 std::fs::write(&dest, grok_hooks_json(&ljos_path()?))?;
1486 Ok(Step {
1487 what: "hook".into(),
1488 detail: format!("wrote {}", dest.display()),
1489 ok: true,
1490 })
1491}
1492
1493pub fn onboard_from(file: &Path, harness: &str, dry: bool) -> Result<Vec<Step>> {
1494 if harness == "json" {
1495 return Ok(vec![Step {
1496 what: "json".into(),
1497 detail: serde_json::to_string_pretty(&server_entry()?)?,
1498 ok: true,
1499 }]);
1500 }
1501 if harness == "grok" {
1502 let mut steps = vec![write_grok_hooks(dry)?];
1503 if let Ok(all) = harnesses_from(file) {
1504 if let Some(h) = all.harness.iter().find(|h| h.name == "grok") {
1505 let server = server_path()?;
1506 steps.push(register_step(h, &server, dry));
1507 if let Some(dir) = &h.skills {
1508 steps.push(write_skill(&expand(dir), dry));
1509 }
1510 }
1511 }
1512 return Ok(steps);
1513 }
1514 let all = harnesses_from(file)?;
1515 let Some(h) = all.harness.iter().find(|h| h.name == harness) else {
1516 let names: Vec<&str> = all.harness.iter().map(|h| h.name.as_str()).collect();
1517 bail!(
1518 "onboard: no runner {harness:?} in {}; it names {}. `ljos onboard --example` \
1519 prints the file's shape, and `--harness json` prints the entry to paste anywhere.",
1520 file.display(),
1521 if names.is_empty() {
1522 "none".to_string()
1523 } else {
1524 names.join(", ")
1525 }
1526 );
1527 };
1528 let server = server_path()?;
1529 let dependencies = [pack_step(dry), host_key_step(dry)];
1530 let mut steps = vec![register_step(h, &server, dry)];
1531 if let Some(file) = &h.hooks {
1532 steps.push(hook_step(&expand(file), &hook_events_of(h), dry));
1533 }
1534 if let Some(dest) = &h.plugin {
1535 steps.push(plugin_step(h, &expand(dest), dry));
1536 }
1537 match &h.skills {
1538 Some(dir) => steps.push(write_skill(&expand(dir), dry)),
1539 None => steps.push(Step {
1540 what: "skill".into(),
1541 detail: "no skills directory in harnesses.toml; `ljos protocol` prints the text".into(),
1542 ok: false,
1543 }),
1544 }
1545 steps.extend(dependencies);
1546 Ok(steps)
1547}
1548
1549pub const HOOK_EVENTS: &[&str] = &["UserPromptSubmit", "SessionEnd"];
1555
1556pub const HOOK_MATCHERS: &[(&str, &str)] = &[
1558 ("PreToolUse", "Bash"),
1559 ("PostToolUse", "*"),
1560 ("UserPromptSubmit", "*"),
1561 ("Stop", "*"),
1562 ("SessionEnd", "*"),
1563 ("SubagentStop", "*"),
1564];
1565
1566fn normalize_hook_event(raw: &str) -> &str {
1569 match raw {
1570 "pre_llm_call" => "UserPromptSubmit",
1571 "pre_tool_call" => "PreToolUse",
1572 "post_tool_call" => "PostToolUse",
1573 "on_session_finalize" | "on_session_reset" => "SessionEnd",
1576 "on_session_end" => "TurnEnd",
1577 "pre_tool_use" | "PreToolUse" => "PreToolUse",
1578 "post_tool_use" | "PostToolUse" => "PostToolUse",
1579 "user_prompt_submit" | "UserPromptSubmit" => "UserPromptSubmit",
1580 "session_end" | "SessionEnd" => "SessionEnd",
1581 "session_start" | "SessionStart" => "SessionStart",
1582 "subagent_stop" | "SubagentStop" | "SubagentEnd" | "subagentStop" => "SubagentStop",
1583 "stop" | "Stop" => "Stop",
1584 other => other,
1585 }
1586}
1587
1588fn hook_matcher(event: &str) -> &'static str {
1589 HOOK_MATCHERS
1590 .iter()
1591 .find(|(e, _)| *e == event)
1592 .map_or("*", |(_, m)| m)
1593}
1594
1595fn hook_events_of(h: &Harness) -> Vec<String> {
1597 if h.name == "grok" {
1598 return [
1599 "UserPromptSubmit",
1600 "PostToolUse",
1601 "PreToolUse",
1602 "Stop",
1603 "SessionEnd",
1604 "SubagentStop",
1605 ]
1606 .into_iter()
1607 .map(str::to_string)
1608 .collect();
1609 }
1610 if h.hook_events.is_empty() {
1611 HOOK_EVENTS.iter().map(|e| (*e).to_string()).collect()
1612 } else {
1613 h.hook_events.clone()
1614 }
1615}
1616
1617fn is_seat_hook(h: &Value) -> bool {
1618 h["command"]
1619 .as_str()
1620 .is_some_and(|c| c.contains("ljos") && c.ends_with(" hook"))
1621}
1622
1623fn hook_command() -> String {
1625 which::which("ljos").map_or_else(
1626 |_| "ljos hook".to_string(),
1627 |p| format!("{} hook", p.display()),
1628 )
1629}
1630
1631fn hook_step(file: &Path, events: &[String], dry: bool) -> Step {
1636 let what = "hook".to_string();
1637 let mut root: Value = match std::fs::read_to_string(file) {
1638 Ok(text) if !text.trim().is_empty() => match serde_json::from_str(&text) {
1639 Ok(v) => v,
1640 Err(e) => {
1641 return Step {
1642 what,
1643 detail: format!("{}: not JSON: {e}", file.display()),
1644 ok: false,
1645 }
1646 }
1647 },
1648 _ => serde_json::json!({}),
1649 };
1650 let command = hook_command();
1651 let Some(obj) = root.as_object_mut() else {
1652 return Step {
1653 what,
1654 detail: format!("{}: not a JSON object", file.display()),
1655 ok: false,
1656 };
1657 };
1658 let hooks = obj.entry("hooks").or_insert_with(|| serde_json::json!({}));
1659 let Some(hooks) = hooks.as_object_mut() else {
1660 return Step {
1661 what,
1662 detail: format!("{}: hooks is not an object", file.display()),
1663 ok: false,
1664 };
1665 };
1666 let mut added = Vec::new();
1669 let mut removed = Vec::new();
1670 for event in events {
1671 let groups = hooks
1672 .entry(event.clone())
1673 .or_insert_with(|| serde_json::json!([]));
1674 let Some(groups) = groups.as_array_mut() else {
1675 continue;
1676 };
1677 let present = groups.iter().any(|g| {
1678 g["hooks"]
1679 .as_array()
1680 .into_iter()
1681 .flatten()
1682 .any(is_seat_hook)
1683 });
1684 if present {
1685 continue;
1686 }
1687 groups.push(serde_json::json!({
1688 "matcher": hook_matcher(event),
1689 "hooks": [{"type": "command", "command": command, "timeout": 20}]
1690 }));
1691 added.push(event.clone());
1692 }
1693 for (event, groups) in hooks.iter_mut() {
1694 if events.contains(event) {
1695 continue;
1696 }
1697 let Some(groups) = groups.as_array_mut() else {
1698 continue;
1699 };
1700 let before = groups.len();
1701 groups.retain(|g| {
1702 !g["hooks"]
1703 .as_array()
1704 .into_iter()
1705 .flatten()
1706 .any(is_seat_hook)
1707 });
1708 if groups.len() != before {
1709 removed.push(event.clone());
1710 }
1711 }
1712 if added.is_empty() && removed.is_empty() {
1713 return Step {
1714 what,
1715 detail: format!(
1716 "{} carries the memory hook on {}",
1717 file.display(),
1718 events.join(", ")
1719 ),
1720 ok: true,
1721 };
1722 }
1723 let mut change = Vec::new();
1724 if !added.is_empty() {
1725 change.push(format!("add it on {}", added.join(", ")));
1726 }
1727 if !removed.is_empty() {
1728 change.push(format!("drop it from {}", removed.join(", ")));
1729 }
1730 let change = change.join(" and ");
1731 if dry {
1732 return Step {
1733 what,
1734 detail: format!("would {change} in {}", file.display()),
1735 ok: true,
1736 };
1737 }
1738 let written = file
1739 .parent()
1740 .map_or(Ok(()), std::fs::create_dir_all)
1741 .and_then(|()| serde_json::to_string_pretty(&root).map_err(std::io::Error::other))
1742 .and_then(|text| std::fs::write(file, text + "\n"));
1743 match written {
1744 Ok(()) => Step {
1745 what,
1746 detail: format!("memory hook: {change} in {}", file.display()),
1747 ok: true,
1748 },
1749 Err(e) => Step {
1750 what,
1751 detail: format!("{}: {e}", file.display()),
1752 ok: false,
1753 },
1754 }
1755}
1756
1757fn hook_installed(file: &Path, events: &[String]) -> bool {
1759 let Ok(text) = std::fs::read_to_string(file) else {
1760 return false;
1761 };
1762 let Ok(root) = serde_json::from_str::<Value>(&text) else {
1763 return false;
1764 };
1765 events.iter().all(|event| {
1766 root["hooks"][event.as_str()]
1767 .as_array()
1768 .into_iter()
1769 .flatten()
1770 .any(|g| {
1771 g["hooks"]
1772 .as_array()
1773 .into_iter()
1774 .flatten()
1775 .any(is_seat_hook)
1776 })
1777 })
1778}
1779
1780#[derive(Debug, Clone, PartialEq, Eq)]
1784pub struct HookCall {
1785 pub event: String,
1786 pub cue: String,
1787 pub session: Option<String>,
1790 pub shape: HookShape,
1793}
1794
1795#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1799pub enum HookShape {
1800 #[default]
1802 Asks,
1803 DenyOnly,
1806 CamelCase,
1809 Context,
1813}
1814
1815impl HookShape {
1816 #[must_use]
1818 pub fn asks(self) -> bool {
1819 self == Self::Asks
1820 }
1821}
1822
1823#[must_use]
1828pub fn hook_call(input: &str) -> HookCall {
1829 let trimmed = input.trim();
1830 let Ok(v) = serde_json::from_str::<Value>(trimmed) else {
1831 return HookCall {
1832 event: "argv".into(),
1833 cue: trimmed.to_string(),
1834 session: None,
1835 shape: HookShape::Asks,
1836 };
1837 };
1838 let raw_event = v["hook_event_name"].as_str().unwrap_or("");
1839 let shape = if v.get("hookEventName").is_some() || v.get("toolInput").is_some() {
1840 HookShape::CamelCase
1841 } else if raw_event.starts_with("pre_")
1842 || raw_event.starts_with("post_")
1843 || raw_event.starts_with("on_")
1844 {
1845 HookShape::Context
1846 } else if v.get("turn_id").is_some() {
1847 HookShape::DenyOnly
1848 } else {
1849 HookShape::Asks
1850 };
1851 let input = if v["tool_input"].is_null() {
1852 &v["toolInput"]
1853 } else {
1854 &v["tool_input"]
1855 };
1856 let session = v["session_id"]
1857 .as_str()
1858 .or_else(|| v["sessionId"].as_str())
1859 .filter(|s| !s.is_empty())
1860 .map(str::to_string);
1861 let raw = v["hook_event_name"]
1862 .as_str()
1863 .or_else(|| v["hookEventName"].as_str())
1864 .unwrap_or("PreToolUse");
1865 let event = normalize_hook_event(raw).to_string();
1866 let cue = if let Some(p) = v["prompt"].as_str() {
1867 p.to_string()
1868 } else if let Some(p) = v["extra"]["user_message"].as_str() {
1869 p.to_string()
1870 } else if let Some(c) = input["command"].as_str() {
1871 c.to_string()
1872 } else if let Some(map) = input.as_object() {
1873 map.values()
1874 .filter_map(Value::as_str)
1875 .collect::<Vec<_>>()
1876 .join(" ")
1877 } else {
1878 String::new()
1879 };
1880 HookCall {
1881 event,
1882 cue,
1883 session,
1884 shape,
1885 }
1886}
1887
1888fn seen_path(session: &str) -> Option<PathBuf> {
1891 let safe: String = session
1892 .chars()
1893 .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
1894 .collect();
1895 if safe.is_empty() {
1896 return None;
1897 }
1898 let dir = std::env::var_os("XDG_RUNTIME_DIR")
1899 .filter(|r| !r.is_empty())
1900 .map(PathBuf::from)
1901 .unwrap_or_else(std::env::temp_dir)
1902 .join("ljos");
1903 Some(dir.join(format!("hook-seen-{safe}")))
1904}
1905
1906pub fn seen_ids(session: Option<&str>) -> std::collections::BTreeSet<String> {
1907 session
1908 .and_then(seen_path)
1909 .and_then(|p| std::fs::read_to_string(p).ok())
1910 .map(|t| t.lines().map(str::to_string).collect())
1911 .unwrap_or_default()
1912}
1913
1914fn injected_ids(session: &str) -> (Vec<String>, Option<PathBuf>) {
1917 let path = seen_path(session);
1918 let ids: Vec<String> = path
1919 .as_ref()
1920 .and_then(|p| std::fs::read_to_string(p).ok())
1921 .map(|t| {
1922 t.lines()
1923 .map(str::trim)
1924 .filter(|l| !l.is_empty() && *l != "due-nudge")
1925 .map(str::to_string)
1926 .collect()
1927 })
1928 .unwrap_or_default();
1929 (ids, path)
1930}
1931
1932pub fn session_end(session: Option<&str>) -> usize {
1939 let Some(session) = session else {
1940 return 0;
1941 };
1942 let (ids, path) = injected_ids(session);
1943 let fired = if ids.len() >= 2 {
1944 let top: Vec<String> = ids.into_iter().take(8).collect();
1945 pack()
1946 .ok()
1947 .and_then(|c| c.fire(&c.workspace(), &top).ok())
1948 .map_or(0, |_| top.len())
1949 } else {
1950 0
1951 };
1952 if let Some(p) = path {
1953 let _ = std::fs::remove_file(p);
1954 }
1955 fired
1956}
1957
1958fn hook_hold_path(session: Option<&str>) -> Option<PathBuf> {
1961 let dir = std::env::var_os("XDG_RUNTIME_DIR")
1962 .map(PathBuf::from)
1963 .or_else(|| std::env::var_os("TMPDIR").map(PathBuf::from))
1964 .unwrap_or_else(|| PathBuf::from("/tmp"));
1965 let name = session
1966 .filter(|s| !s.is_empty())
1967 .map(|s| {
1968 s.chars()
1969 .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
1970 .take(32)
1971 .collect::<String>()
1972 })
1973 .filter(|s| !s.is_empty())
1974 .unwrap_or_else(|| "default".into());
1975 Some(dir.join(format!("ljos-hook-hold-{name}")))
1976}
1977
1978fn hook_hold_ids_path(session: Option<&str>) -> Option<PathBuf> {
1979 hook_hold_path(session).map(|p| {
1980 let mut os = p.into_os_string();
1981 os.push(".ids");
1982 PathBuf::from(os)
1983 })
1984}
1985
1986pub fn hold_hook_context(session: Option<&str>, context: &str) {
1990 hold_hook_note(session, context, &[]);
1991}
1992
1993pub fn hold_hook_note(session: Option<&str>, context: &str, ids: &[String]) {
1995 let Some(path) = hook_hold_path(session) else {
1996 return;
1997 };
1998 if context.is_empty() {
1999 return;
2000 }
2001 let _ = std::fs::write(&path, context);
2002 if let Some(ids_path) = hook_hold_ids_path(session) {
2003 let _ = std::fs::write(ids_path, ids.join("\n"));
2004 }
2005}
2006
2007#[must_use]
2009pub fn peek_hook_context(session: Option<&str>) -> String {
2010 hook_hold_path(session)
2011 .and_then(|p| std::fs::read_to_string(p).ok())
2012 .unwrap_or_default()
2013}
2014
2015#[must_use]
2017pub fn take_hook_context(session: Option<&str>) -> String {
2018 take_hook_note(session).0
2019}
2020
2021#[must_use]
2023pub fn take_hook_note(session: Option<&str>) -> (String, Vec<String>) {
2024 let Some(path) = hook_hold_path(session) else {
2025 return (String::new(), Vec::new());
2026 };
2027 let text = std::fs::read_to_string(&path).unwrap_or_default();
2028 let _ = std::fs::remove_file(&path);
2029 let ids = hook_hold_ids_path(session)
2030 .and_then(|p| std::fs::read_to_string(p).ok())
2031 .map(|t| {
2032 let _ = hook_hold_ids_path(session).map(std::fs::remove_file);
2033 t.lines()
2034 .map(str::trim)
2035 .filter(|l| !l.is_empty())
2036 .map(str::to_string)
2037 .collect()
2038 })
2039 .unwrap_or_default();
2040 (text, ids)
2041}
2042
2043#[must_use]
2047pub fn prompt_hook_stdout(
2048 shape: HookShape,
2049 session: Option<&str>,
2050 text: &str,
2051 ids: &[String],
2052) -> String {
2053 if shape == HookShape::CamelCase {
2054 hold_hook_note(session, text, ids);
2055 String::new()
2056 } else {
2057 hold_hook_context(session, text);
2058 text.to_string()
2059 }
2060}
2061
2062#[must_use]
2068pub fn post_hook_stdout(shape: HookShape, session: Option<&str>) -> (String, Vec<String>) {
2069 if shape == HookShape::CamelCase {
2070 let key = "hold-echoed".to_string();
2071 if seen_ids(session).contains(&key) {
2072 return (String::new(), Vec::new());
2073 }
2074 let (text, ids) = take_hook_note(session);
2075 if !text.is_empty() {
2076 mark_seen(session, &[key]);
2077 }
2078 (text, ids)
2079 } else {
2080 (take_hook_context(session), Vec::new())
2081 }
2082}
2083
2084#[must_use]
2088pub fn stop_hook_stdout(session: Option<&str>, stop_active: bool) -> (String, Vec<String>) {
2089 if stop_active {
2090 return (String::new(), Vec::new());
2091 }
2092 take_hook_note(session)
2093}
2094
2095pub fn mark_seen(session: Option<&str>, ids: &[String]) {
2096 let Some(path) = session.and_then(seen_path) else {
2097 return;
2098 };
2099 if let Some(dir) = path.parent() {
2100 let _ = std::fs::create_dir_all(dir);
2101 }
2102 let mut text = std::fs::read_to_string(&path).unwrap_or_default();
2103 for id in ids {
2104 text.push_str(id);
2105 text.push('\n');
2106 }
2107 let _ = std::fs::write(path, text);
2108}
2109
2110pub const HOOK_SCORE_FLOOR: f64 = 0.6;
2116
2117const CUE_STOP: &[&str] = &[
2120 "about",
2121 "after",
2122 "also",
2123 "anything",
2124 "because",
2125 "been",
2126 "before",
2127 "being",
2128 "both",
2129 "could",
2130 "does",
2131 "doing",
2132 "each",
2133 "everything",
2134 "from",
2135 "have",
2136 "having",
2137 "into",
2138 "just",
2139 "like",
2140 "making",
2141 "more",
2142 "most",
2143 "need",
2144 "nothing",
2145 "only",
2146 "other",
2147 "over",
2148 "please",
2149 "really",
2150 "same",
2151 "should",
2152 "some",
2153 "something",
2154 "still",
2155 "such",
2156 "than",
2157 "that",
2158 "their",
2159 "them",
2160 "then",
2161 "there",
2162 "these",
2163 "they",
2164 "this",
2165 "those",
2166 "through",
2167 "using",
2168 "very",
2169 "want",
2170 "were",
2171 "what",
2172 "when",
2173 "where",
2174 "which",
2175 "while",
2176 "will",
2177 "with",
2178 "would",
2179 "your",
2180];
2181
2182fn cue_content_words(text: &str) -> Vec<String> {
2185 let mut words: Vec<String> = text
2186 .split(|c: char| !c.is_alphanumeric())
2187 .filter(|w| w.len() >= 4)
2188 .map(str::to_lowercase)
2189 .filter(|w| !CUE_STOP.contains(&w.as_str()))
2190 .collect();
2191 words.sort_unstable();
2192 words.dedup();
2193 words
2194}
2195
2196fn names_the_cue(text: &str, cue: &str) -> bool {
2199 let want = cue_content_words(cue);
2200 if want.is_empty() {
2201 return false;
2202 }
2203 let have = cue_content_words(text);
2204 want.iter().any(|w| have.binary_search(w).is_ok())
2205}
2206
2207#[cfg(test)]
2208fn names_a_numbered_pr(text: &str) -> bool {
2211 let t = text.to_lowercase();
2212 let b = t.as_bytes();
2213 let mut i = 0;
2214 while i < b.len() {
2215 if (i == 0 || !b[i - 1].is_ascii_alphanumeric())
2216 && (pr_number_at(&t[i..]) || hash_number_at(&t[i..]))
2217 {
2218 return true;
2219 }
2220 i += 1;
2221 }
2222 false
2223}
2224
2225#[cfg(test)]
2226fn pr_number_at(rest: &str) -> bool {
2228 let after = if let Some(s) = rest.strip_prefix("pull requests") {
2229 s
2230 } else if let Some(s) = rest.strip_prefix("pull request") {
2231 s
2232 } else if let Some(s) = rest.strip_prefix("prs") {
2233 if s.starts_with(|c: char| c.is_ascii_alphanumeric()) {
2234 return false;
2235 }
2236 s
2237 } else if let Some(s) = rest.strip_prefix("pr") {
2238 if s.starts_with(|c: char| c.is_ascii_alphabetic()) {
2239 return false;
2240 }
2241 s
2242 } else {
2243 return false;
2244 };
2245 let after = after.trim_start();
2246 let after = after.strip_prefix('#').unwrap_or(after).trim_start();
2247 after.starts_with(|c: char| c.is_ascii_digit())
2248}
2249
2250#[cfg(test)]
2251fn hash_number_at(rest: &str) -> bool {
2253 let Some(after) = rest.strip_prefix('#') else {
2254 return false;
2255 };
2256 after.starts_with(|c: char| c.is_ascii_digit())
2257}
2258
2259#[cfg(test)]
2260fn is_transient(text: &str) -> bool {
2263 names_a_numbered_pr(text) || names_a_ticket(text) || names_a_commit(text)
2264}
2265
2266#[cfg(test)]
2267fn names_a_ticket(text: &str) -> bool {
2269 text.split(|c: char| !c.is_ascii_alphanumeric() && c != '-')
2270 .any(|tok| {
2271 let Some((head, tail)) = tok.split_once('-') else {
2272 return false;
2273 };
2274 head.len() >= 2
2275 && head.chars().all(|c| c.is_ascii_alphabetic())
2276 && tail.len() == 4
2277 && tail.chars().all(|c| c.is_ascii_alphanumeric())
2278 && !tail.contains('-')
2279 })
2280}
2281
2282#[cfg(test)]
2283fn names_a_commit(text: &str) -> bool {
2285 text.split(|c: char| !c.is_ascii_alphanumeric()).any(|tok| {
2286 (7..=40).contains(&tok.len())
2287 && tok.chars().all(|c| c.is_ascii_hexdigit())
2288 && tok.chars().any(|c| c.is_ascii_digit())
2289 })
2290}
2291
2292fn is_refresher(hit: &Hit) -> bool {
2295 if hit.kind == "preference" {
2296 return true;
2297 }
2298 if hit.entities.iter().any(|e| e == "horizon:transient") {
2299 return false;
2300 }
2301 hit.entities.iter().any(|e| e == "horizon:standing")
2302}
2303
2304#[must_use]
2309pub fn hook_note(call: &HookCall, limit: usize) -> (String, Vec<String>) {
2310 let cue = call.cue.trim();
2311 if cue.len() < 3 {
2312 return (String::new(), Vec::new());
2313 }
2314 let (mut nudge, due_key) = due_nudge(call);
2318 let mut pending = Vec::new();
2319 if let Some(key) = due_key {
2320 pending.push(key);
2321 }
2322 let judged = judged_prompt(call, cue);
2326 let (correction, choice) = judged.as_ref().map_or((None, None), |(_, j)| {
2327 (Some(j.correction >= j.cue_at), Some(j.choice >= j.cue_at))
2328 });
2329 for (key, extra) in [
2330 correction_nudge_as(call, correction),
2331 decision_nudge_as(call, choice),
2332 ]
2333 .into_iter()
2334 .flatten()
2335 {
2336 pending.push(key);
2337 if !nudge.is_empty() {
2338 nudge.push('\n');
2339 }
2340 nudge.push_str(&extra);
2341 }
2342 let seen = seen_ids(call.session.as_deref());
2348 let hits: Vec<Hit>;
2349 let mut rows: Vec<&Hit> = if let Some((candidates, j)) = &judged {
2350 candidates
2353 .iter()
2354 .enumerate()
2355 .filter(|(i, _)| j.bears(*i))
2356 .map(|(_, h)| h)
2357 .filter(|h| h.id.as_ref().is_none_or(|id| !seen.contains(id)))
2358 .collect()
2359 } else {
2360 let rerank = !jev::enabled();
2363 let reranked = with_pack_timeout(HOOK_RERANK_BUDGET_MS, || {
2364 packset_search_opts(cue, 10, rerank)
2365 });
2366 let Ok(found) = reranked.or_else(|_| packset_search(cue)) else {
2367 return (nudge, pending);
2368 };
2369 hits = found;
2370 let top = hits.iter().map(|h| h.score).fold(0.0_f64, f64::max);
2371 if top <= 0.0 {
2372 return (nudge, pending);
2373 }
2374 hits.iter()
2375 .filter(|h| !UNREVIEWED_KINDS.contains(&h.kind.as_str()))
2376 .filter(|h| h.score >= top * HOOK_SCORE_FLOOR)
2377 .filter(|h| agreed(h))
2378 .filter(|h| names_the_cue(&h.text, cue))
2379 .filter(|h| is_refresher(h))
2380 .filter(|h| h.id.as_ref().is_none_or(|id| !seen.contains(id)))
2381 .collect()
2382 };
2383 let weight = |h: &Hit| -> f64 {
2385 judged
2386 .as_ref()
2387 .and_then(|(c, j)| {
2388 let i = c.iter().position(|x| x.id == h.id && x.text == h.text)?;
2389 j.bears.get(i).copied()
2390 })
2391 .unwrap_or(h.score)
2392 };
2393 rows.sort_by(|a, b| {
2394 let pa = a.kind == "preference";
2395 let pb = b.kind == "preference";
2396 pb.cmp(&pa).then(
2397 weight(b)
2398 .partial_cmp(&weight(a))
2399 .unwrap_or(std::cmp::Ordering::Equal),
2400 )
2401 });
2402 let mut rows: Vec<&Hit> = rows.into_iter().take(limit).collect();
2403 let now = now_utc();
2408 let split = rows.iter().filter(|h| h.kind == "preference").count();
2409 rows[split..].sort_by_key(|h| days_of_stamp(h.ts.as_deref()).unwrap_or(i64::MAX));
2410 let lines: Vec<String> = rows.iter().map(|h| hit_line(h, &now)).collect();
2411 let mut ids: Vec<String> = rows.iter().filter_map(|h| h.id.clone()).collect();
2412 ids.extend(pending);
2413 if lines.is_empty() {
2414 return (nudge, ids);
2415 }
2416 let mut out = format!(
2417 "What this seat already knows that bears on this (from the pack, each with its age, lessons oldest first; `ljos search` for more):\n{}",
2418 lines.join("\n")
2419 );
2420 if !nudge.is_empty() {
2421 out.push('\n');
2422 out.push_str(&nudge);
2423 }
2424 (out, ids)
2425}
2426
2427fn judged_prompt(call: &HookCall, cue: &str) -> Option<(Vec<Hit>, jev::Judgment)> {
2434 if call.event != "UserPromptSubmit" {
2435 return None;
2436 }
2437 let (cfg, _) = jev::config()?;
2438 if cue.split_whitespace().count() < cfg.min_words {
2439 return None;
2440 }
2441 let seen = seen_ids(call.session.as_deref());
2442 let hits = packset_search_opts(cue, 10, false).ok()?;
2443 let candidates: Vec<Hit> = hits
2444 .into_iter()
2445 .filter(|h| !UNREVIEWED_KINDS.contains(&h.kind.as_str()))
2446 .filter(is_refresher)
2447 .filter(|h| h.id.as_ref().is_none_or(|id| !seen.contains(id)))
2448 .take(10)
2449 .collect();
2450 if candidates.len() < cfg.min_candidates {
2451 return None;
2452 }
2453 let texts: Vec<&str> = candidates.iter().map(|h| h.text.as_str()).collect();
2454 let judged = jev::judge(cue, &texts)?;
2455 Some((candidates, judged))
2456}
2457
2458#[must_use]
2463pub fn hook_context(call: &HookCall, limit: usize) -> String {
2464 let (text, ids) = hook_note(call, limit);
2465 if call.shape != HookShape::CamelCase {
2466 mark_seen(call.session.as_deref(), &ids);
2467 }
2468 text
2469}
2470
2471fn agreed(h: &Hit) -> bool {
2477 match (h.ballots, h.of) {
2478 (Some(named), Some(of)) if of >= 2 => named >= 2,
2479 _ => true,
2480 }
2481}
2482
2483#[must_use]
2488pub fn hook_subagent(input: &str) -> (Option<String>, bool, String) {
2489 let Ok(v) = serde_json::from_str::<Value>(input.trim()) else {
2490 return (None, false, String::new());
2491 };
2492 let kind = v["subagentType"]
2493 .as_str()
2494 .or_else(|| v["subagent_type"].as_str())
2495 .or_else(|| v["agent_type"].as_str())
2496 .filter(|s| !s.is_empty())
2497 .map(str::to_string);
2498 let active = v["stopHookActive"]
2499 .as_bool()
2500 .or_else(|| v["stop_hook_active"].as_bool())
2501 .unwrap_or(false);
2502 let agent = v["agent_id"]
2503 .as_str()
2504 .or_else(|| v["agentId"].as_str())
2505 .unwrap_or("")
2506 .to_string();
2507 (kind, active, agent)
2508}
2509
2510#[must_use]
2513pub fn runs_tests(command: &str) -> bool {
2514 const RUNNERS: &[&str] = &[
2515 "cargo test",
2516 "cargo nextest",
2517 "pytest",
2518 "ctest",
2519 "meson test",
2520 "npm test",
2521 "npm run test",
2522 "pnpm test",
2523 "go test",
2524 "make check",
2525 "make test",
2526 "repo-test",
2527 "tox",
2528 "bats ",
2529 "prove ",
2530 "mix test",
2531 "gradle test",
2532 "mvn test",
2533 ];
2534 RUNNERS.iter().any(|r| command.contains(r))
2535}
2536
2537#[derive(Debug, Clone, Default, PartialEq)]
2542pub struct StopTurn {
2543 pub request: String,
2544 pub commands: Vec<String>,
2545 pub test_ran: bool,
2546 pub outputs: Vec<String>,
2547 pub final_message: String,
2548}
2549
2550fn tail_chars(s: &str, n: usize) -> String {
2551 let count = s.chars().count();
2552 s.chars().skip(count.saturating_sub(n)).collect()
2553}
2554
2555fn block_text(content: &Value) -> String {
2556 match content {
2557 Value::String(t) => t.clone(),
2558 Value::Array(parts) => parts
2559 .iter()
2560 .filter_map(|p| p["text"].as_str())
2561 .collect::<Vec<_>>()
2562 .join("\n"),
2563 _ => String::new(),
2564 }
2565}
2566
2567#[must_use]
2571pub fn stop_turn_from_transcript(text: &str) -> StopTurn {
2572 let entries: Vec<Value> = text
2573 .lines()
2574 .filter_map(|l| serde_json::from_str::<Value>(l).ok())
2575 .collect();
2576 let is_prompt = |e: &Value| {
2577 e["type"] == "user"
2578 && !e["isMeta"].as_bool().unwrap_or(false)
2579 && match &e["message"]["content"] {
2580 Value::String(t) => !t.trim_start().starts_with('<'),
2581 Value::Array(parts) => {
2582 parts.iter().any(|p| p["type"] == "text")
2583 && !parts.iter().any(|p| p["type"] == "tool_result")
2584 }
2585 _ => false,
2586 }
2587 };
2588 let start = entries.iter().rposition(is_prompt).unwrap_or(0);
2589 let mut turn = StopTurn {
2590 request: entries
2591 .get(start)
2592 .map(|e| block_text(&e["message"]["content"]))
2593 .unwrap_or_default(),
2594 ..StopTurn::default()
2595 };
2596 let mut pending: std::collections::BTreeMap<String, String> = Default::default();
2597 let mut outputs: Vec<(bool, String)> = Vec::new();
2598 for e in entries.iter().skip(start + 1) {
2599 let Value::Array(parts) = &e["message"]["content"] else {
2600 if e["type"] == "assistant" {
2601 turn.final_message = block_text(&e["message"]["content"]);
2602 }
2603 continue;
2604 };
2605 for part in parts {
2606 match part["type"].as_str() {
2607 Some("tool_use") => {
2608 if let Some(cmd) = part["input"]["command"].as_str() {
2609 let cmd: String = cmd.chars().take(200).collect();
2610 if let Some(id) = part["id"].as_str() {
2611 pending.insert(id.to_string(), cmd.clone());
2612 }
2613 turn.test_ran |= runs_tests(&cmd);
2614 turn.commands.push(cmd);
2615 }
2616 }
2617 Some("tool_result") => {
2618 let id = part["tool_use_id"].as_str().unwrap_or("");
2619 if let Some(cmd) = pending.remove(id) {
2620 let out = tail_chars(&block_text(&part["content"]), 1500);
2621 outputs.push((runs_tests(&cmd), format!("$ {cmd}\n{out}")));
2622 }
2623 }
2624 Some("text") if e["type"] == "assistant" => {
2625 turn.final_message = part["text"].as_str().unwrap_or("").to_string();
2626 }
2627 _ => {}
2628 }
2629 }
2630 }
2631 let tests: Vec<String> = outputs
2632 .iter()
2633 .filter(|o| o.0)
2634 .map(|o| o.1.clone())
2635 .collect();
2636 let chosen = if tests.is_empty() {
2637 outputs.into_iter().map(|o| o.1).collect::<Vec<_>>()
2638 } else {
2639 tests
2640 };
2641 turn.outputs = chosen.into_iter().rev().take(2).rev().collect();
2642 let n = turn.commands.len();
2643 turn.commands = turn.commands.split_off(n.saturating_sub(30));
2644 turn
2645}
2646
2647impl StopTurn {
2648 #[must_use]
2650 pub fn state(&self) -> String {
2651 format!(
2652 "The person asked:\n{}\n\nShell commands the agent ran since:\n{}\n\nLatest output:\n{}\n\nThe agent's final message:\n{}\n",
2653 tail_chars(&self.request, 1500),
2654 self.commands.join("\n"),
2655 self.outputs.join("\n---\n"),
2656 tail_chars(&self.final_message, 3000)
2657 )
2658 }
2659}
2660
2661#[must_use]
2666pub fn stop_audit(input: &str, stop_active: bool) -> Option<String> {
2667 if stop_active {
2668 return None;
2669 }
2670 jev::config()?;
2671 let v: Value = serde_json::from_str(input.trim()).ok()?;
2672 let path = v["transcript_path"]
2673 .as_str()
2674 .or_else(|| v["transcriptPath"].as_str());
2675 let mut turn = path
2676 .and_then(|p| std::fs::read_to_string(p).ok())
2677 .map(|t| stop_turn_from_transcript(&t))
2678 .unwrap_or_default();
2679 if let Some(last) = v["last_assistant_message"]
2680 .as_str()
2681 .or_else(|| v["lastAssistantMessage"].as_str())
2682 {
2683 turn.final_message = last.to_string();
2684 }
2685 if turn.final_message.chars().count() < 80 {
2686 return None;
2687 }
2688 let a = jev::audit(&turn.state())?;
2689 jev::audit_reason(&a, turn.test_ran)
2690}
2691
2692pub const WORK_NUDGE_EVERY: u64 = 40;
2696
2697#[must_use]
2699pub fn touches_seat(cue: &str) -> bool {
2700 cue.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2701 .any(|w| w == "ljos" || w == "vissue" || w.starts_with("ljos_") || w.starts_with("vissue_"))
2702}
2703
2704pub fn work_nudge(call: &HookCall, subagent: bool) -> Option<String> {
2709 let session = call.session.as_deref()?;
2710 let safe: String = session
2711 .chars()
2712 .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
2713 .collect();
2714 if safe.is_empty() || subagent {
2715 return None;
2716 }
2717 let path = runtime_dir().join(format!("work-{safe}"));
2718 if touches_seat(&call.cue) {
2719 let _ = std::fs::write(&path, "0");
2720 return None;
2721 }
2722 if call.event != "PostToolUse" {
2723 return None;
2724 }
2725 let count = std::fs::read_to_string(&path)
2726 .ok()
2727 .and_then(|t| t.trim().parse::<u64>().ok())
2728 .unwrap_or(0)
2729 + 1;
2730 if count < WORK_NUDGE_EVERY {
2731 let _ = std::fs::create_dir_all(runtime_dir());
2732 let _ = std::fs::write(&path, count.to_string());
2733 return None;
2734 }
2735 let _ = std::fs::write(&path, "0");
2736 Some(match held_issue() {
2737 Some(issue) => format!(
2738 "{count} tool calls on {issue} since the seat last heard from this conversation. \
2739 Record what the work has shown: progress is `vissue note {issue} \"...\"`, a lesson \
2740 that holds next time is `ljos remember \"...\"`, an artifact is `ljos deed {issue} \
2741 --add ACCESSION`; the work closes with `ljos finish {issue} --lesson \"...\"`."
2742 ),
2743 None => format!(
2744 "{count} tool calls in this conversation with no issue held. Work goes on an issue: \
2745 `vissue q -p PROJECT \"TITLE\"` prints an id, then `ljos sitting ID` opens it."
2746 ),
2747 })
2748}
2749
2750pub fn hook_trace(input: &str, call: &HookCall, subagent: Option<&str>) {
2756 let dir = runtime_dir();
2757 if !dir.join("hook-trace").exists() {
2758 return;
2759 }
2760 let v: Value = serde_json::from_str(input.trim()).unwrap_or(Value::Null);
2761 let keys: Vec<&str> = v
2762 .as_object()
2763 .map(|m| m.keys().map(String::as_str).collect())
2764 .unwrap_or_default();
2765 let raw = v["hook_event_name"]
2766 .as_str()
2767 .or_else(|| v["hookEventName"].as_str())
2768 .unwrap_or("");
2769 let line = serde_json::json!({
2770 "ts": now_utc(),
2771 "event": call.event,
2772 "raw": raw,
2773 "keys": keys,
2774 "session": call.session,
2775 "subagent": subagent,
2776 "holder": holder_name(),
2777 "tree_holder": runner_record_holders().first().cloned(),
2778 "held": subagent.and_then(|_| held_issue()),
2779 });
2780 use std::io::Write as _;
2781 if let Ok(mut f) = std::fs::OpenOptions::new()
2782 .create(true)
2783 .append(true)
2784 .open(dir.join("hook-trace.jsonl"))
2785 {
2786 let _ = writeln!(f, "{line}");
2787 }
2788}
2789
2790fn runner_record_holders() -> Vec<String> {
2796 let mut out = Vec::new();
2797 for (pid, _) in own_ancestry() {
2799 let Ok(text) = std::fs::read_to_string(seat_record_path(pid)) else {
2800 continue;
2801 };
2802 if let Some(holder) = text.lines().nth(1).map(str::trim).filter(|h| !h.is_empty()) {
2803 if !out.iter().any(|h| h == holder) {
2804 out.push(holder.to_string());
2805 }
2806 }
2807 }
2808 out
2809}
2810
2811#[must_use]
2815pub fn held_issue() -> Option<String> {
2816 let mut holders: Vec<String> = runner_record_holders();
2821 let own = holder_name();
2822 if !holders.contains(&own) {
2823 holders.push(own);
2824 }
2825 if let Some(node) = held_from_records(&holders) {
2828 return Some(node);
2829 }
2830 if std::env::var_os("LJOS_IN_HOOK").is_some() {
2831 return None;
2832 }
2833 holders.iter().find_map(|holder| {
2834 let out = run_captured("vissue", &["claims", "--by", holder, "--json"]).ok()?;
2835 let rows: Value = serde_json::from_str(&out.stdout).ok()?;
2836 rows.as_array()?
2837 .iter()
2838 .rfind(|c| c["state"].as_str() == Some("STARTED"))?["id"]
2839 .as_str()
2840 .map(str::to_string)
2841 })
2842}
2843
2844#[must_use]
2849pub fn subagent_brief(kind: &str, issue: &str, decision: bool) -> String {
2850 let judge = if decision {
2851 format!("{issue} is a decision: end with your ballot, `ljos vote {issue} --for OPTION --expect OPTION --as ROLE`.")
2852 } else {
2853 format!(
2854 "A judgement between options is a ballot: `ljos vote {issue} --for OPTION --expect OPTION --as ROLE`."
2855 )
2856 };
2857 format!(
2858 "You are a subagent ({kind}) working under {issue}, which your parent holds. Do not open a sitting \
2859 on it. {judge} A lesson that will hold next time is `ljos remember \"...\" --as ROLE`; a \
2860 finding is `vissue note {issue} \"...\"`. ROLE is a persona from `ljos personas` when one fits \
2861 your task, else `{kind}`."
2862 )
2863}
2864
2865#[must_use]
2869pub fn subagent_stop_reason(
2870 kind: &str,
2871 issue: Option<&str>,
2872 decision: bool,
2873 active: bool,
2874) -> Option<String> {
2875 if active {
2876 return None;
2877 }
2878 let issue = issue?;
2879 Some(if decision {
2880 format!(
2881 "{issue} is a decision your parent holds. Before you stop, cast your ballot: \
2882 `ljos vote {issue} --for OPTION --expect OPTION --as ROLE` (ROLE: your persona, else `{kind}`)."
2883 )
2884 } else {
2885 format!(
2886 "You worked under {issue}. Before you stop: if your result settles a choice, \
2887 `ljos vote {issue} --for OPTION --expect OPTION --as ROLE`; if it taught something that holds next time, \
2888 `ljos remember \"...\" --as ROLE`. Otherwise stop."
2889 )
2890 })
2891}
2892
2893pub const HOOK_DEADLINE_MS: u64 = 8000;
2897
2898pub fn hook_already_running(call: &HookCall) -> bool {
2903 let key = work_id(&format!(
2904 "{}|{}|{}",
2905 call.event,
2906 call.session.as_deref().unwrap_or(""),
2907 call.cue
2908 ));
2909 let dir = runtime_dir();
2910 let _ = std::fs::create_dir_all(&dir);
2911 if key.starts_with('0') {
2913 if let Ok(entries) = std::fs::read_dir(&dir) {
2914 for e in entries.flatten() {
2915 let old = e.file_name().to_string_lossy().starts_with("hook-once-")
2916 && e.metadata()
2917 .and_then(|m| m.modified())
2918 .ok()
2919 .and_then(|t| t.elapsed().ok())
2920 .is_some_and(|age| age > std::time::Duration::from_secs(60));
2921 if old {
2922 let _ = std::fs::remove_file(e.path());
2923 }
2924 }
2925 }
2926 }
2927 let path = dir.join(format!("hook-once-{key}"));
2928 match std::fs::OpenOptions::new()
2929 .write(true)
2930 .create_new(true)
2931 .open(&path)
2932 {
2933 Ok(_) => false,
2934 Err(_) => {
2935 let fresh = std::fs::metadata(&path)
2936 .and_then(|m| m.modified())
2937 .ok()
2938 .and_then(|t| t.elapsed().ok())
2939 .is_some_and(|age| age < std::time::Duration::from_secs(20));
2940 if !fresh {
2941 let _ = std::fs::write(&path, "");
2942 }
2943 fresh
2944 }
2945 }
2946}
2947
2948pub const HOOK_RERANK_BUDGET_MS: u64 = 2500;
2952
2953fn with_pack_timeout<R>(ms: u64, f: impl FnOnce() -> R) -> R {
2956 let before = std::env::var_os("PACKSET_TIMEOUT_MS");
2957 unsafe { std::env::set_var("PACKSET_TIMEOUT_MS", ms.to_string()) };
2960 let out = f();
2961 match before {
2962 Some(v) => unsafe { std::env::set_var("PACKSET_TIMEOUT_MS", v) },
2963 None => unsafe { std::env::remove_var("PACKSET_TIMEOUT_MS") },
2964 }
2965 out
2966}
2967
2968pub const CORRECTION_CUES: &[&str] = &[
2973 "do you not remember",
2974 "don't you remember",
2975 "dont you remember",
2976 "you should have",
2977 "why did you not",
2978 "why didn't you",
2979 "why havent you",
2980 "why haven't you",
2981 "you forgot",
2982 "i told you",
2983 "i've told you",
2984 "as i said",
2985 "again you",
2986 "still not",
2987 "not even able",
2988 "you never",
2989 "you keep",
2990];
2991
2992#[cfg(test)]
2993fn correction_nudge(call: &HookCall) -> Option<(String, String)> {
2998 correction_nudge_as(call, None)
2999}
3000
3001fn correction_nudge_as(call: &HookCall, verdict: Option<bool>) -> Option<(String, String)> {
3004 if call.event != "UserPromptSubmit" {
3005 return None;
3006 }
3007 let key = match verdict {
3008 Some(false) => return None,
3009 Some(true) => "correction:judged".to_string(),
3010 None => {
3011 let lower = call.cue.to_lowercase();
3012 let hit = CORRECTION_CUES.iter().find(|c| lower.contains(*c))?;
3013 format!("correction:{hit}")
3014 }
3015 };
3016 if seen_ids(call.session.as_deref()).contains(&key) {
3017 return None;
3018 }
3019 Some((
3020 key,
3021 "This prompt reads as a correction. Before the work: write what it corrects as one \
3022 `ljos prefer \"...\"` (a standing choice) or `ljos remember \"...\"` (a lesson), \
3023 so the pack holds it and the hook can raise it next time."
3024 .to_string(),
3025 ))
3026}
3027
3028pub const DECISION_CUES: &[&str] = &[
3031 "should we",
3032 "should i ",
3033 "or should",
3034 "which is better",
3035 "which one",
3036 "which approach",
3037 "which option",
3038 "pros and cons",
3039 "trade-off",
3040 "tradeoff",
3041 " versus ",
3042 " vs ",
3043 " vs. ",
3044 "what do you recommend",
3045 "do you think we",
3046 "option 1",
3047 "option 2",
3048 "option a",
3049 "option b",
3050];
3051
3052pub const DECISION_OPENING: usize = 400;
3054
3055fn cue_at_word_end(text: &str, cue: &str) -> bool {
3058 text.match_indices(cue).any(|(i, _)| {
3059 text[i + cue.len()..]
3060 .chars()
3061 .next()
3062 .is_none_or(|c| !c.is_alphanumeric())
3063 })
3064}
3065
3066#[cfg(test)]
3067fn decision_nudge(call: &HookCall) -> Option<(String, String)> {
3071 decision_nudge_as(call, None)
3072}
3073
3074fn decision_nudge_as(call: &HookCall, verdict: Option<bool>) -> Option<(String, String)> {
3076 if call.event != "UserPromptSubmit" {
3077 return None;
3078 }
3079 match verdict {
3080 Some(false) => return None,
3081 Some(true) => {}
3082 None => {
3083 let opening: String = call.cue.chars().take(DECISION_OPENING).collect();
3087 let lower = format!(" {} ", opening.to_lowercase());
3088 DECISION_CUES.iter().find(|c| cue_at_word_end(&lower, c))?;
3089 }
3090 }
3091 let key = "decision-nudge".to_string();
3092 if seen_ids(call.session.as_deref()).contains(&key) {
3093 return None;
3094 }
3095 Some((
3096 key,
3097 "This prompt puts a choice. Before choosing: put it on an issue whose body has an \
3098 `Options: A, B` line, then `ljos sitting ISSUE` writes one brief per persona the \
3099 title names; start one subagent per brief, each casting `ljos vote ISSUE --for \
3100 OPTION --expect OPTION --as NAME`, and settle with `ljos consensus ISSUE`."
3101 .to_string(),
3102 ))
3103}
3104
3105fn due_nudge(call: &HookCall) -> (String, Option<String>) {
3109 if call.event != "UserPromptSubmit" {
3110 return (String::new(), None);
3111 }
3112 let key = "due-nudge".to_string();
3113 if seen_ids(call.session.as_deref()).contains(&key) {
3114 return (String::new(), None);
3115 }
3116 let Ok(client) = pack() else {
3117 return (String::new(), None);
3118 };
3119 let Ok(atoms) = atoms_lean(&client, &client.workspace()) else {
3120 return (String::new(), None);
3121 };
3122 let due = due_of(&atoms, &now_utc()).len();
3123 if due == 0 {
3128 mark_seen(call.session.as_deref(), &[key]);
3129 return (String::new(), None);
3130 }
3131 (
3132 format!(
3133 "{due} claim{} due for review in this seat: `ljos due`, read each, then `ljos graded ID` (or `--lapsed`).",
3134 if due == 1 { " is" } else { "s are" }
3135 ),
3136 Some(key),
3137 )
3138}
3139
3140#[must_use]
3144pub fn hook_output(call: &HookCall, context: &str) -> String {
3145 hook_output_ruled(call, context, None)
3146}
3147
3148#[must_use]
3152pub fn hook_output_ruled(call: &HookCall, context: &str, verdict: Option<&Rule>) -> String {
3153 if context.is_empty() && verdict.is_none() {
3154 return String::new();
3155 }
3156 if call.event == "argv" {
3157 let mut out = String::new();
3158 if let Some(r) = verdict {
3159 out.push_str(&format!(
3160 "{}: {} (rule `{}`)\n",
3161 r.verdict, r.reason, r.pattern
3162 ));
3163 }
3164 if !context.is_empty() {
3165 out.push_str(context);
3166 out.push('\n');
3167 }
3168 return out;
3169 }
3170 if call.shape == HookShape::Context && verdict.is_none() {
3171 return if context.is_empty() {
3172 String::new()
3173 } else {
3174 serde_json::json!({ "context": context }).to_string() + "\n"
3175 };
3176 }
3177 let mut specific = serde_json::json!({ "hookEventName": call.event });
3178 if !context.is_empty() {
3179 specific["additionalContext"] = Value::String(context.to_string());
3180 }
3181 let mut top = serde_json::Map::new();
3182 if let Some(r) = verdict {
3183 if call.event == "PreToolUse" {
3184 let (decision, reason) = if r.verdict == "ask" && !call.shape.asks() {
3187 (
3188 "deny",
3189 format!(
3190 "ask the person before running this: {} (seat rule `{}`)",
3191 r.reason, r.pattern
3192 ),
3193 )
3194 } else {
3195 (
3196 r.verdict.as_str(),
3197 format!("{} (seat rule `{}`)", r.reason, r.pattern),
3198 )
3199 };
3200 if call.shape == HookShape::Context {
3201 let mut out = serde_json::json!({ "decision": "block", "reason": reason });
3203 if !context.is_empty() {
3204 out["context"] = Value::String(context.to_string());
3205 }
3206 return out.to_string() + "\n";
3207 }
3208 specific["permissionDecision"] = Value::String(decision.to_string());
3209 specific["permissionDecisionReason"] = Value::String(reason.clone());
3210 if call.shape == HookShape::CamelCase {
3211 top.insert("decision".into(), Value::String(decision.to_string()));
3212 top.insert("reason".into(), Value::String(reason));
3213 }
3214 }
3215 }
3216 top.insert("hookSpecificOutput".into(), specific);
3217 Value::Object(top).to_string() + "\n"
3218}
3219
3220pub fn format_steps(steps: &[Step]) -> String {
3221 steps
3222 .iter()
3223 .map(|s| {
3224 format!(
3225 "{}\t{}\t{}\n",
3226 if s.ok { "ok" } else { "no" },
3227 s.what,
3228 s.detail
3229 )
3230 })
3231 .collect()
3232}
3233
3234fn harness_rows() -> Vec<Habitat> {
3236 let path = harnesses_path();
3237 let all = match harnesses_from(&path) {
3238 Ok(all) => all,
3239 Err(e) => {
3240 return vec![Habitat {
3241 name: "runners",
3242 state: format!("{e:#}"),
3243 ok: false,
3244 }]
3245 }
3246 };
3247 if all.harness.is_empty() {
3248 return vec![Habitat {
3249 name: "runners",
3250 state: format!(
3251 "none named in {}; `ljos onboard --example` prints the shape",
3252 path.display()
3253 ),
3254 ok: false,
3255 }];
3256 }
3257 let server = server_path().unwrap_or_else(|_| PathBuf::from("ljos-mcp"));
3258 let mut rows = Vec::new();
3259 for h in &all.harness {
3260 let registered = is_registered(h, &server) == Some(true);
3261 let probed = (registered && !h.probe.is_empty()).then(|| probe_lists_ljos(&h.probe));
3262 rows.push(Habitat {
3263 name: "runner mcp",
3264 state: match (registered, &probed) {
3265 (false, _) => format!(
3266 "{}: not registered; ljos onboard --harness {}",
3267 h.name, h.name
3268 ),
3269 (true, Some(Err(why))) => format!(
3270 "{}: registered, but `{}` does not list ljos_sitting: {why}",
3271 h.name,
3272 h.probe.join(" ")
3273 ),
3274 (true, Some(Ok(()))) => format!("{}: ljos registered and loads", h.name),
3275 (true, None) => format!("{}: ljos registered", h.name),
3276 },
3277 ok: registered && !matches!(probed, Some(Err(_))),
3278 });
3279 let skill = h
3280 .skills
3281 .as_deref()
3282 .map(|d| expand(d).join("ljos").join("SKILL.md"));
3283 let current = skill
3284 .as_ref()
3285 .is_some_and(|p| std::fs::read_to_string(p).is_ok_and(|t| t == skill_text()));
3286 if let Some(file) = &h.hooks {
3287 let path = expand(file);
3288 let installed = hook_installed(&path, &hook_events_of(h));
3289 rows.push(Habitat {
3290 name: "runner hook",
3291 state: if installed {
3292 format!("{}: memory hook on {}", h.name, path.display())
3293 } else {
3294 format!(
3295 "{}: no memory hook; ljos onboard --harness {}",
3296 h.name, h.name
3297 )
3298 },
3299 ok: installed,
3300 });
3301 } else if h.plugin.is_none() {
3302 if let Some(cfg) = &h.config {
3303 let path = expand(cfg);
3304 let installed =
3305 std::fs::read_to_string(&path).is_ok_and(|t| t.contains("ljos hook"));
3306 rows.push(Habitat {
3307 name: "runner hook",
3308 state: if installed {
3309 format!("{}: memory hook in {}", h.name, path.display())
3310 } else {
3311 format!(
3312 "{}: no memory hook in {}; ljos onboard --harness {}",
3313 h.name,
3314 path.display(),
3315 h.name
3316 )
3317 },
3318 ok: installed,
3319 });
3320 }
3321 }
3322 if let Some(dest) = &h.plugin {
3323 let path = expand(dest);
3324 let want = ljos_path().ok().and_then(|l| plugin_text(h, &l));
3325 let current = want
3326 .as_ref()
3327 .is_some_and(|w| std::fs::read_to_string(&path).is_ok_and(|t| &t == w));
3328 rows.push(Habitat {
3329 name: "runner hook",
3330 state: if current {
3331 format!("{}: plugin {}", h.name, path.display())
3332 } else if path.is_file() {
3333 format!(
3334 "{}: plugin {} is stale; ljos onboard --harness {}",
3335 h.name,
3336 path.display(),
3337 h.name
3338 )
3339 } else {
3340 format!("{}: no plugin; ljos onboard --harness {}", h.name, h.name)
3341 },
3342 ok: current,
3343 });
3344 }
3345 rows.push(Habitat {
3346 name: "runner skill",
3347 state: match (&skill, current) {
3348 (Some(p), true) => format!("{}: {}", h.name, p.display()),
3349 (Some(p), false) if p.is_file() => {
3350 format!(
3351 "{}: {} is stale; ljos onboard --harness {}",
3352 h.name,
3353 p.display(),
3354 h.name
3355 )
3356 }
3357 (Some(_), false) => {
3358 format!("{}: absent; ljos onboard --harness {}", h.name, h.name)
3359 }
3360 (None, _) => format!("{}: no skills directory named", h.name),
3361 },
3362 ok: current,
3363 });
3364 }
3365 rows
3366}
3367
3368fn probe_lists_ljos(argv: &[String]) -> std::result::Result<(), String> {
3371 use std::io::Read;
3372 use std::process::{Command, Stdio};
3373 let (bin, args) = argv.split_first().ok_or("empty probe")?;
3374 let mut child = Command::new(expand(bin))
3375 .args(args)
3376 .stdin(Stdio::null())
3377 .stdout(Stdio::piped())
3378 .stderr(Stdio::piped())
3379 .spawn()
3380 .map_err(|e| format!("{bin}: {e}"))?;
3381 let started = std::time::Instant::now();
3382 let status = loop {
3383 match child.try_wait() {
3384 Ok(Some(status)) => break status,
3385 Ok(None) if started.elapsed() > std::time::Duration::from_secs(30) => {
3386 let _ = child.kill();
3387 let _ = child.wait();
3388 return Err("no answer in 30 s".into());
3389 }
3390 Ok(None) => std::thread::sleep(std::time::Duration::from_millis(100)),
3391 Err(e) => return Err(e.to_string()),
3392 }
3393 };
3394 let mut out = String::new();
3395 if let Some(mut o) = child.stdout.take() {
3396 let _ = o.read_to_string(&mut out);
3397 }
3398 if let Some(mut e) = child.stderr.take() {
3399 let _ = e.read_to_string(&mut out);
3400 }
3401 if !status.success() {
3402 return Err(format!("exit {}", status.code().unwrap_or(-1)));
3403 }
3404 if out.contains("ljos_sitting") {
3405 Ok(())
3406 } else {
3407 Err("its output names no ljos tool".into())
3408 }
3409}
3410
3411fn pack_step(dry: bool) -> Step {
3415 let what = "pack".to_string();
3416 if let Ok(client) = pack() {
3417 if client.health().is_ok() {
3418 return Step {
3419 what,
3420 detail: format!("writer up at {}", client.base()),
3421 ok: true,
3422 };
3423 }
3424 } else {
3425 return Step {
3426 what,
3427 detail: "PACKSET_URL=off; no pack on purpose".into(),
3428 ok: true,
3429 };
3430 }
3431 if !on_path("packset") {
3432 return Step {
3433 what,
3434 detail: "no writer answers and packset is not on PATH".into(),
3435 ok: false,
3436 };
3437 }
3438 if dry {
3439 return Step {
3440 what,
3441 detail: "would run packset ensure".into(),
3442 ok: true,
3443 };
3444 }
3445 match run_captured("packset", &["ensure"]) {
3446 Ok(said) => Step {
3447 what,
3448 detail: format!(
3449 "started a writer: {}",
3450 said.stdout.lines().next().unwrap_or("").trim()
3451 ),
3452 ok: true,
3453 },
3454 Err(e) => Step {
3455 what,
3456 detail: e.to_string().lines().next().unwrap_or("").to_string(),
3457 ok: false,
3458 },
3459 }
3460}
3461
3462fn host_key_step(dry: bool) -> Step {
3466 if let Some(path) = host_key_path() {
3467 return Step {
3468 what: "host key".into(),
3469 detail: format!("{} exists", path.display()),
3470 ok: true,
3471 };
3472 }
3473 if std::env::var_os("DEEDAR_HOST_SIGNING_KEY").is_some_and(|r| r == "off") {
3474 return Step {
3475 what: "host key".into(),
3476 detail: "DEEDAR_HOST_SIGNING_KEY=off; handovers go out unsigned on purpose".into(),
3477 ok: true,
3478 };
3479 }
3480 let Some(path) = default_host_key_path() else {
3481 return Step {
3482 what: "host key".into(),
3483 detail: "no home directory to keep a key in".into(),
3484 ok: false,
3485 };
3486 };
3487 if dry {
3488 return Step {
3489 what: "host key".into(),
3490 detail: format!("would write a 32-byte seed to {}", path.display()),
3491 ok: true,
3492 };
3493 }
3494 let made = (|| -> std::io::Result<()> {
3495 use std::io::Read;
3496 let mut seed = [0u8; 32];
3497 std::fs::File::open("/dev/urandom")?.read_exact(&mut seed)?;
3498 if let Some(dir) = path.parent() {
3499 std::fs::create_dir_all(dir)?;
3500 }
3501 std::fs::write(&path, seed)?;
3502 #[cfg(unix)]
3503 {
3504 use std::os::unix::fs::PermissionsExt;
3505 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
3506 }
3507 Ok(())
3508 })();
3509 match made {
3510 Ok(()) => Step {
3511 what: "host key".into(),
3512 detail: format!("wrote a 32-byte seed to {}", path.display()),
3513 ok: true,
3514 },
3515 Err(e) => Step {
3516 what: "host key".into(),
3517 detail: format!("{}: {e}", path.display()),
3518 ok: false,
3519 },
3520 }
3521}
3522
3523fn default_host_key_path() -> Option<PathBuf> {
3525 let config = std::env::var_os("XDG_CONFIG_HOME")
3526 .filter(|r| !r.is_empty())
3527 .map(PathBuf::from)
3528 .or_else(|| home().ok().map(|h| h.join(".config")))?;
3529 Some(config.join("deedar").join("host.key"))
3530}
3531
3532fn host_key_path() -> Option<PathBuf> {
3535 if let Some(raw) = std::env::var_os("DEEDAR_HOST_SIGNING_KEY").filter(|r| !r.is_empty()) {
3536 return (raw != "off").then(|| PathBuf::from(raw));
3537 }
3538 let path = default_host_key_path()?;
3539 path.is_file().then_some(path)
3540}
3541
3542pub fn expand_leading_tilde(raw: &str, home: &str) -> Option<String> {
3545 let home = home.trim_end_matches('/');
3546 if raw == "~" {
3547 return Some(home.to_string());
3548 }
3549 raw.strip_prefix("~/").map(|rest| format!("{home}/{rest}"))
3550}
3551
3552pub fn normalize_tracker_env() {
3557 let Some(home) = std::env::var_os("HOME").filter(|h| !h.is_empty()) else {
3558 return;
3559 };
3560 let home = home.to_string_lossy().to_string();
3561 for var in ["ISSUE_ROOT", "VISSUE_ROOT"] {
3562 if let Ok(raw) = std::env::var(var) {
3563 if let Some(expanded) = expand_leading_tilde(&raw, &home) {
3564 std::env::set_var(var, expanded);
3565 }
3566 }
3567 }
3568}
3569
3570pub const POLICY_TCB: &str =
3572 "argv law. ljos-policyd is the TCB when present. Reloading a pack is not a check.";
3573
3574pub const SEAT_WORKSPACE: &str = "seat";
3579
3580fn load_seat_env() {
3587 let Ok(home) = home() else {
3588 return;
3589 };
3590 let path = home.join(".config/ljos/env");
3591 let Ok(text) = std::fs::read_to_string(path) else {
3592 return;
3593 };
3594 for line in text.lines() {
3595 let line = line.trim();
3596 if line.is_empty() || line.starts_with('#') {
3597 continue;
3598 }
3599 let Some((k, v)) = line.split_once('=') else {
3600 continue;
3601 };
3602 let k = k.trim();
3603 if k.is_empty() || std::env::var_os(k).is_some() {
3604 continue;
3605 }
3606 std::env::set_var(k, v.trim());
3607 }
3608}
3609
3610fn writer_unreachable(err: &anyhow::Error) -> bool {
3612 err.chain().any(|cause| {
3613 cause
3614 .downcast_ref::<packset_client::Error>()
3615 .is_some_and(|inner| matches!(inner, packset_client::Error::Http(_)))
3616 })
3617}
3618
3619fn ensure_writer() -> Result<()> {
3623 if std::env::var("PACKSET_URL").ok().as_deref() == Some("off") {
3624 return Ok(());
3625 }
3626 if std::env::var("PACKSET_URL")
3627 .ok()
3628 .is_some_and(|url| !url.is_empty())
3629 {
3630 bail!(
3631 "the pack writer at PACKSET_URL is not answering. This seat is not pointed at the default writer, so it was not started"
3632 );
3633 }
3634 if !on_path("packset") {
3635 bail!("no pack writer is answering, and packset is not on PATH. cargo binstall packset");
3636 }
3637 run_captured("packset", &["ensure"]).context("packset ensure")?;
3638 Ok(())
3639}
3640
3641fn with_writer<T>(op: impl Fn() -> Result<T>) -> Result<T> {
3642 match op() {
3643 Ok(value) => Ok(value),
3644 Err(err) if writer_unreachable(&err) => {
3645 ensure_writer()?;
3646 op()
3647 }
3648 Err(err) => Err(err),
3649 }
3650}
3651
3652pub fn atoms_lean(client: &PacksetClient, workspace: &str) -> Result<Vec<Value>> {
3662 let url = format!("{}/v1/atoms", client.base());
3663 let mut body: Value = ureq::get(&url)
3664 .query("workspace", workspace)
3665 .query("embedding", "omit")
3666 .timeout(std::time::Duration::from_secs(30))
3667 .call()
3668 .map_err(|e| anyhow::anyhow!("{url}: {e}"))?
3669 .into_json()?;
3670 let atoms = body
3671 .get_mut("atoms")
3672 .map(Value::take)
3673 .unwrap_or(Value::Array(Vec::new()));
3674 Ok(serde_json::from_value(atoms)?)
3675}
3676
3677pub fn pack() -> Result<PacksetClient> {
3678 load_seat_env();
3679 let workspace = std::env::var("PACKSET_WORKSPACE")
3680 .ok()
3681 .filter(|w| !w.is_empty())
3682 .unwrap_or_else(|| SEAT_WORKSPACE.to_string());
3683 Ok(PacksetClient::from_env()
3684 .context("PACKSET_URL=off: this seat has no pack on purpose")?
3685 .with_workspace(workspace))
3686}
3687
3688pub fn pack_last_write_ts() -> Result<Option<String>> {
3695 let client = pack()?;
3696 let status = client
3697 .status(Some(&client.workspace()))
3698 .context("pack: GET /v1/status failed")?;
3699 Ok(status
3700 .get("last_write_ts")
3701 .and_then(Value::as_str)
3702 .filter(|s| !s.is_empty())
3703 .map(str::to_string))
3704}
3705
3706pub fn join(parts: &[String]) -> String {
3707 parts.join(" ")
3708}
3709
3710pub fn atom_kind(label: &str) -> Result<&'static str> {
3712 match label {
3713 "Remember" => Ok("lesson"),
3714 "Prefer" => Ok("preference"),
3715 other => bail!("unknown write kind {other}"),
3716 }
3717}
3718
3719pub const SEAT_ENTITY: &str = "seat:";
3722
3723pub fn atom_body(kind: &str, text: &str, workspace: &str) -> Value {
3726 serde_json::json!({
3727 "schema": "inside.atom/v1",
3728 "kind": kind,
3729 "level": "explicit",
3730 "text": text,
3731 "workspace": workspace,
3732 "entities": [format!("{SEAT_ENTITY}{}", seat_name())],
3733 "source": atom_source(),
3734 })
3735}
3736
3737#[must_use]
3741pub fn atom_source() -> Value {
3742 let seat = whoami();
3743 let mut source = serde_json::json!({
3744 "harness": seat.seat,
3745 "session": seat.holder,
3746 "host": sync::host(),
3747 "via": "ljos",
3748 });
3749 let turn = std::env::vars()
3750 .filter(|(k, v)| k.ends_with("_TURN_ID") && !v.trim().is_empty())
3751 .map(|(_, v)| v.trim().to_string())
3752 .next();
3753 if let Some(turn) = turn {
3754 source["turn"] = Value::String(turn);
3755 }
3756 source
3757}
3758
3759pub fn add_entities(atom: &mut Value, more: impl IntoIterator<Item = String>) {
3761 let list = atom["entities"]
3762 .as_array_mut()
3763 .map(std::mem::take)
3764 .unwrap_or_default();
3765 let mut list = list;
3766 for e in more {
3767 let v = Value::String(e);
3768 if !list.contains(&v) {
3769 list.push(v);
3770 }
3771 }
3772 atom["entities"] = Value::Array(list);
3773}
3774
3775pub fn post_claim(
3777 client: &PacksetClient,
3778 label: &str,
3779 text: &str,
3780 workspace: &str,
3781) -> Result<Value> {
3782 post_claim_horizon(client, label, text, workspace, None)
3783}
3784
3785fn post_claim_horizon(
3786 client: &PacksetClient,
3787 label: &str,
3788 text: &str,
3789 workspace: &str,
3790 transient: Option<bool>,
3791) -> Result<Value> {
3792 let trimmed = text.trim();
3793 if trimmed.is_empty() {
3794 bail!("{label}: empty text is not a claim");
3795 }
3796 let kind = atom_kind(label)?;
3797 let mut atom = atom_body(kind, trimmed, workspace);
3798 stamp_horizon(&mut atom, kind, trimmed, transient);
3799 with_writer(|| {
3800 client
3801 .post_atom(&atom)
3802 .with_context(|| format!("{label}: POST /v1/atoms failed"))
3803 })
3804}
3805
3806fn stamp_horizon(atom: &mut Value, kind: &str, _text: &str, force: Option<bool>) {
3810 let transient = match (kind, force) {
3811 ("preference", _) => false,
3812 (_, Some(flag)) => flag,
3813 _ => true,
3814 };
3815 let tag = if transient {
3816 "horizon:transient"
3817 } else {
3818 "horizon:standing"
3819 };
3820 add_entities(atom, [tag.to_string()]);
3821}
3822
3823pub fn packset_write(label: &str, text: &str) -> Result<Value> {
3824 packset_write_as(label, text, None, None)
3825}
3826
3827pub fn packset_write_scoped(
3836 label: &str,
3837 text: &str,
3838 issue: &str,
3839 scope: Option<&str>,
3840) -> Result<Value> {
3841 let client = pack()?;
3842 let workspace = client.workspace();
3843 let trimmed = text.trim();
3844 if trimmed.is_empty() {
3845 bail!("{label}: empty text is not a claim");
3846 }
3847 let kind = atom_kind(label)?;
3848 let mut atom = atom_body(kind, trimmed, &workspace);
3849 let mut tags = vec![format!("issue:{}", issue.trim())];
3850 if let Some(scope) = scope.map(str::trim).filter(|s| !s.is_empty()) {
3851 tags.push(format!("scope:{scope}"));
3852 }
3853 add_entities(&mut atom, tags);
3854 stamp_horizon(&mut atom, kind, trimmed, None);
3855 with_writer(|| {
3856 client
3857 .post_atom(&atom)
3858 .with_context(|| format!("{label}: POST /v1/atoms failed"))
3859 })
3860}
3861
3862#[must_use]
3864pub fn persona_entity(name: &str) -> String {
3865 format!("persona:{}", name.trim().to_lowercase())
3866}
3867
3868#[must_use]
3873pub fn persona_set(name: &str) -> String {
3874 let mut out = String::from("persona-");
3875 for c in name.trim().to_lowercase().chars() {
3876 if c.is_ascii_lowercase() || c.is_ascii_digit() {
3877 out.push(c);
3878 } else if !out.ends_with('-') {
3879 out.push('-');
3880 }
3881 }
3882 out.trim_end_matches('-').chars().take(32).collect()
3883}
3884
3885pub fn packset_write_as(
3890 label: &str,
3891 text: &str,
3892 persona: Option<&str>,
3893 transient: Option<bool>,
3894) -> Result<Value> {
3895 let client = pack()?;
3896 let workspace = client.workspace();
3897 let Some(name) = persona.map(str::trim).filter(|n| !n.is_empty()) else {
3898 return post_claim_horizon(&client, label, text, &workspace, transient);
3899 };
3900 let trimmed = text.trim();
3901 if trimmed.is_empty() {
3902 bail!("{label}: empty text is not a claim");
3903 }
3904 let kind = atom_kind(label)?;
3905 let mut atom = atom_body(kind, trimmed, &workspace);
3906 add_entities(&mut atom, [persona_entity(name)]);
3907 stamp_horizon(&mut atom, kind, trimmed, transient);
3908 atom["set"] = Value::String(persona_set(name));
3911 with_writer(|| {
3912 client
3913 .post_atom(&atom)
3914 .with_context(|| format!("{label}: POST /v1/atoms failed"))
3915 })
3916}
3917
3918pub fn packset_forget(id: &str, why: Option<&str>) -> Result<Value> {
3937 let trimmed = id.trim();
3938 if trimmed.is_empty() {
3939 bail!("forget: an atom id is required");
3940 }
3941 let why = why.map(str::trim).filter(|w| !w.is_empty());
3942 let client = pack()?;
3943 let workspace = client.workspace();
3944 client
3945 .delete_atom(&workspace, trimmed, why)
3946 .with_context(|| format!("forget: POST /v1/atoms/delete failed for {trimmed}"))
3947}
3948
3949#[derive(Debug, Clone, PartialEq, Default)]
3954pub struct Trust {
3955 pub from: String,
3956 pub to: String,
3957 pub weight: f64,
3958 pub about: Vec<String>,
3959}
3960
3961#[derive(Debug, Clone, PartialEq)]
3965pub struct Persona {
3966 pub name: String,
3967 pub anchor: f64,
3968 pub view: String,
3969 pub entities: Vec<String>,
3970}
3971
3972pub fn persona_atom(p: &Persona, workspace: &str) -> Result<Value> {
3978 let name = p.name.trim();
3979 if name.is_empty() {
3980 bail!("persona: a name is required");
3981 }
3982 if !(0.0..=1.0).contains(&p.anchor) {
3983 bail!("persona: anchor {} is not in [0, 1]", p.anchor);
3984 }
3985 let view = p.view.trim();
3986 if view.is_empty() {
3987 bail!("persona: say in a sentence or two how {name} reads the work");
3988 }
3989 let mut atom = atom_body("persona", view, workspace);
3990 atom["name"] = Value::String(name.into());
3991 atom["anchor"] = serde_json::json!(p.anchor);
3992 if !p.entities.is_empty() {
3993 add_entities(&mut atom, p.entities.iter().map(|e| e.to_lowercase()));
3994 }
3995 Ok(atom)
3996}
3997
3998pub fn write_persona(p: &Persona) -> Result<Value> {
4003 let client = pack()?;
4004 let workspace = client.workspace();
4005 let mut atom = persona_atom(p, &workspace)?;
4006 let previous: Vec<Value> = client
4007 .atoms_of_kind(&workspace, "persona")
4008 .unwrap_or_default()
4009 .into_iter()
4010 .filter(|a| a.get("name").and_then(Value::as_str) == Some(p.name.trim()))
4011 .filter_map(|a| {
4012 a.get("id")
4013 .and_then(Value::as_str)
4014 .map(|id| Value::String(id.to_string()))
4015 })
4016 .collect();
4017 if !previous.is_empty() {
4018 atom["supersedes"] = Value::Array(previous);
4019 }
4020 let posted = client
4021 .post_atom(&atom)
4022 .context("persona: POST /v1/atoms failed")?;
4023 ensure_unscoped_inbound(p)?;
4024 Ok(posted)
4025}
4026
4027#[must_use]
4031pub fn inbound_floor(p: &Persona, seat: &str) -> Option<Trust> {
4032 let to = p.name.trim();
4033 let from = seat.trim();
4034 if to.is_empty() || from.is_empty() || from == to {
4035 return None;
4036 }
4037 Some(Trust {
4038 from: from.to_string(),
4039 to: to.to_string(),
4040 weight: 1.0,
4041 about: Vec::new(),
4042 })
4043}
4044
4045#[must_use]
4048pub fn has_unscoped_inbound(rows: &[Trust], name: &str, seat: &str) -> bool {
4049 let name = name.trim();
4050 let seat = seat.trim();
4051 rows.iter()
4052 .any(|r| r.from == seat && r.to == name && r.about.is_empty() && r.weight > 0.0)
4053}
4054
4055fn ensure_unscoped_inbound(p: &Persona) -> Result<()> {
4056 let name = p.name.trim();
4057 let seat = seat_name();
4058 if has_unscoped_inbound(&trust_from_pack().unwrap_or_default(), name, &seat) {
4059 return Ok(());
4060 }
4061 let Some(row) = inbound_floor(p, &seat) else {
4062 return Ok(());
4063 };
4064 write_trust(&row, &[]).map(|_| ())
4065}
4066
4067pub fn personas_of(atoms: &[Value]) -> Vec<Persona> {
4069 let mut latest: std::collections::BTreeMap<String, (String, Persona)> =
4070 std::collections::BTreeMap::new();
4071 for atom in atoms {
4072 if atom.get("kind").and_then(Value::as_str) != Some("persona") {
4073 continue;
4074 }
4075 let (Some(name), Some(anchor)) = (
4076 atom.get("name").and_then(Value::as_str),
4077 atom.get("anchor").and_then(Value::as_f64),
4078 ) else {
4079 continue;
4080 };
4081 let ts = atom
4082 .get("ts")
4083 .and_then(Value::as_str)
4084 .unwrap_or("")
4085 .to_string();
4086 let p = Persona {
4087 name: name.to_string(),
4088 anchor,
4089 view: atom
4090 .get("text")
4091 .and_then(Value::as_str)
4092 .unwrap_or("")
4093 .to_string(),
4094 entities: domains_of(atom.get("entities")),
4095 };
4096 match latest.get(name) {
4097 Some((seen, _)) if *seen > ts => {}
4098 _ => {
4099 latest.insert(name.to_string(), (ts, p));
4100 }
4101 }
4102 }
4103 latest.into_values().map(|(_, p)| p).collect()
4104}
4105
4106pub fn personas_from_pack() -> Result<Vec<Persona>> {
4108 let client = pack()?;
4109 let atoms = client
4112 .atoms_of_kind(&client.workspace(), "persona")
4113 .context("persona: GET /v1/atoms?kind=persona failed")?;
4114 Ok(personas_of(&atoms))
4115}
4116
4117#[derive(Debug, Clone, PartialEq, Eq)]
4121pub struct Playbook {
4122 pub name: String,
4123 pub body: String,
4124 pub models: Vec<String>,
4125}
4126
4127pub const PLAYBOOK_NAMES: &[&str] = &["sit", "arena", "land", "company-panel", "overnight"];
4129
4130pub const SHIPPED_PLAYBOOK_NAMES: &[&str] = PLAYBOOK_NAMES;
4132
4133pub const PRINCIPLES: &str = "\
4135== principles
4136split-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.
4137prove-on-real-surface: measure on the host the users run. A cheaper substitute is not the result.
4138open-sibling-first: a second implementer opens a sibling leftover, not a rewrite of the first tree.
4139arena-then-compose: designs write scratch; the host writes a rubric on a compose child; personas vote the compose `--as`.
4140one-step-delegate: a subagent is one playbook step. No resume across phases. A new task is a new sitting.
4141";
4142
4143pub const RUBRIC: &str = "\
4146== rubric
41471. Ledger intact. `ljos vote --as` and DeGroot stay. No schema_yes, no BARMA, no host for-loop of accepts.
41482. Playbook before panel. Sitting names one recipe and copies it before personas enter.
41493. Rubric in brief. `ljos brief` carries the playbook step, these principles, and this sheet.
41504. One-step delegate. Subagent = one playbook step. No resume across phases.
41515. Unscoped inbound trust. Every panel persona has one unscoped inbound row; `--about` only adds weight.
41526. No second plugin. Do not copy 47 skills, poteto-mode, Benny, or Cursor model files.
41537. Small surface. Prefer pack atoms and brief fields over a new crate. Musl CLI stays iced-free.
41548. 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).
4155";
4156
4157const SIT_BODY: &str = "\
4158A 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.
4159
41601. Open with `ljos sitting ISSUE --playbook sit`. Read doctor, cards, due, island, this recipe, recall, timeline, claim.
41612. Grade due claims (`ljos graded ID`).
41623. Do the work on this claim only. Artefacts are deeds, then `ljos deed ISSUE --add ACCESSION`. Lessons are `ljos remember` in two sentences.
41634. One playbook step is the whole sitting. A subagent takes this recipe and this issue; it does not resume a later phase.
41645. Close with `ljos finish ISSUE --lesson \"...\"`. Completing the node does not close the ticket. `ljos finish ISSUE --close` does, when the work is accepted.
4165";
4166
4167const ARENA_BODY: &str = "\
4168Designs compete; the host writes a rubric; personas vote a compose, not the designs.
4169
41701. Bind this recipe: `ljos sitting ISSUE --playbook arena` or `ljos playbook ISSUE arena`.
41712. Each design writes scratch (summary and body). Do not vote the design children as accept-at-most-one.
41723. The host writes a compose child and a rubric with named axes. Personas vote the compose `--as`.
41734. 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.
41745. `ljos consensus ISSUE` settles under trust rows and DeGroot. `ljos vote --as` stays.
4175";
4176
4177const LAND_BODY: &str = "\
4178Land a chosen design on the real surface.
4179
41801. Bind `land`. Sitting copies this body before recall.
41812. Prove on the real surface: the host the users run, the crate they install. A cheaper substitute is not the result.
41823. Keep A's fence: no 47 skills, no poteto-mode, no Benny, musl iced-free, `ljos vote --as` and DeGroot stay.
41834. One step per subagent. Open a sibling first when a second implementer is in flight.
41845. Close with finish. Do not ship a count as consensus.
4185";
4186
4187const COMPANY_PANEL_BODY: &str = "\
4188A panel of personas on one bound recipe.
4189
41901. Bind `company-panel` before any persona enters. `ljos panel` refuses if none is bound.
41912. Every persona has one unscoped inbound trust row; `--about` only adds weight.
41923. `ljos brief NAME ISSUE` reprints this recipe in full, the five named principles, and the arena rubric.
41934. 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`.
41945. Do not resume across phases. A new task is a new sitting.
4195";
4196
4197const OVERNIGHT_BODY: &str = "\
4198Drive work while unattended, still one sitting.
4199
42001. Bind `overnight`. Name a checkable finish condition on the issue.
42012. One playbook step per subagent. No session-pickup, no resume across phases.
42023. Isolated worktree. Prove on the real surface before claiming done.
42034. Decision log is tracker notes and deeds, not a second ledger.
42045. `ljos finish` when the condition holds; otherwise `ljos release` and a new sitting.
4205";
4206
4207#[must_use]
4209pub fn shipped_playbooks() -> Vec<Playbook> {
4210 vec![
4211 Playbook {
4212 name: "sit".into(),
4213 body: SIT_BODY.trim().into(),
4214 models: Vec::new(),
4215 },
4216 Playbook {
4217 name: "arena".into(),
4218 body: ARENA_BODY.trim().into(),
4219 models: vec!["judgment".into(), "instruction".into(), "fast".into()],
4220 },
4221 Playbook {
4222 name: "land".into(),
4223 body: LAND_BODY.trim().into(),
4224 models: Vec::new(),
4225 },
4226 Playbook {
4227 name: "company-panel".into(),
4228 body: COMPANY_PANEL_BODY.trim().into(),
4229 models: vec!["judgment".into(), "instruction".into()],
4230 },
4231 Playbook {
4232 name: "overnight".into(),
4233 body: OVERNIGHT_BODY.trim().into(),
4234 models: Vec::new(),
4235 },
4236 ]
4237}
4238
4239pub fn parse_playbook_name(name: &str) -> Result<&'static str> {
4245 let n = name.trim();
4246 if n.is_empty() {
4247 bail!(
4248 "playbook: a name is required ({})",
4249 PLAYBOOK_NAMES.join(", ")
4250 );
4251 }
4252 PLAYBOOK_NAMES
4253 .iter()
4254 .copied()
4255 .find(|k| *k == n)
4256 .ok_or_else(|| {
4257 anyhow::anyhow!(
4258 "playbook: unknown name {n:?}; the closed set is {}",
4259 PLAYBOOK_NAMES.join(", ")
4260 )
4261 })
4262}
4263
4264pub fn playbook_atom(p: &Playbook, workspace: &str) -> Result<Value> {
4270 let name = parse_playbook_name(&p.name)?;
4271 let body = p.body.trim();
4272 if body.is_empty() {
4273 bail!("playbook: {name} needs a recipe body");
4274 }
4275 let mut atom = atom_body("playbook", body, workspace);
4276 atom["name"] = Value::String(name.into());
4277 if !p.models.is_empty() {
4278 atom["models"] = Value::Array(
4279 p.models
4280 .iter()
4281 .map(|m| m.trim())
4282 .filter(|m| !m.is_empty())
4283 .map(|m| Value::String(m.to_string()))
4284 .collect(),
4285 );
4286 }
4287 Ok(atom)
4288}
4289
4290pub fn write_playbook(p: &Playbook) -> Result<Value> {
4294 let client = pack()?;
4295 let workspace = client.workspace();
4296 let mut atom = playbook_atom(p, &workspace)?;
4297 let previous: Vec<Value> = client
4298 .atoms_of_kind(&workspace, "playbook")
4299 .unwrap_or_default()
4300 .into_iter()
4301 .filter(|a| a.get("name").and_then(Value::as_str) == Some(p.name.trim()))
4302 .filter_map(|a| {
4303 a.get("id")
4304 .and_then(Value::as_str)
4305 .map(|id| Value::String(id.to_string()))
4306 })
4307 .collect();
4308 if !previous.is_empty() {
4309 atom["supersedes"] = Value::Array(previous);
4310 }
4311 client
4312 .post_atom(&atom)
4313 .context("playbook: POST /v1/atoms failed")
4314}
4315
4316pub fn playbooks_of(atoms: &[Value]) -> Vec<Playbook> {
4318 let mut latest: std::collections::BTreeMap<String, (String, Playbook)> =
4319 std::collections::BTreeMap::new();
4320 for atom in atoms {
4321 if atom.get("kind").and_then(Value::as_str) != Some("playbook") {
4322 continue;
4323 }
4324 let Some(name) = atom.get("name").and_then(Value::as_str) else {
4325 continue;
4326 };
4327 if parse_playbook_name(name).is_err() {
4328 continue;
4329 }
4330 let ts = atom
4331 .get("ts")
4332 .and_then(Value::as_str)
4333 .unwrap_or("")
4334 .to_string();
4335 let p = Playbook {
4336 name: name.to_string(),
4337 body: atom
4338 .get("text")
4339 .and_then(Value::as_str)
4340 .unwrap_or("")
4341 .to_string(),
4342 models: atom
4343 .get("models")
4344 .and_then(Value::as_array)
4345 .into_iter()
4346 .flatten()
4347 .filter_map(Value::as_str)
4348 .map(str::to_string)
4349 .collect(),
4350 };
4351 match latest.get(name) {
4352 Some((seen, _)) if *seen > ts => {}
4353 _ => {
4354 latest.insert(name.to_string(), (ts, p));
4355 }
4356 }
4357 }
4358 latest.into_values().map(|(_, p)| p).collect()
4359}
4360
4361fn ensure_shipped_playbooks() {
4362 let have = pack()
4363 .ok()
4364 .and_then(|c| c.atoms_of_kind(&c.workspace(), "playbook").ok())
4365 .map(|atoms| playbooks_of(&atoms))
4366 .unwrap_or_default();
4367 for p in shipped_playbooks() {
4368 if have.iter().any(|h| h.name == p.name) {
4369 continue;
4370 }
4371 let _ = write_playbook(&p);
4372 }
4373}
4374
4375pub fn playbooks_from_pack() -> Result<Vec<Playbook>> {
4377 ensure_shipped_playbooks();
4378 let client = pack()?;
4379 let atoms = client
4380 .atoms_of_kind(&client.workspace(), "playbook")
4381 .context("playbook: GET /v1/atoms?kind=playbook failed")?;
4382 let mut got = playbooks_of(&atoms);
4383 for p in shipped_playbooks() {
4384 if !got.iter().any(|g| g.name == p.name) {
4385 got.push(p);
4386 }
4387 }
4388 got.sort_by(|a, b| a.name.cmp(&b.name));
4389 Ok(got)
4390}
4391
4392pub fn playbook_among(name: &str, pack: &[Playbook]) -> Result<Playbook> {
4399 let name = parse_playbook_name(name)?;
4400 if let Some(p) = pack.iter().find(|p| p.name == name) {
4401 return Ok(p.clone());
4402 }
4403 shipped_playbooks()
4404 .into_iter()
4405 .find(|p| p.name == name)
4406 .ok_or_else(|| {
4407 anyhow::anyhow!(
4408 "playbook: unknown name {name:?}; the closed set is {}",
4409 PLAYBOOK_NAMES.join(", ")
4410 )
4411 })
4412}
4413
4414pub fn playbook_named(name: &str) -> Result<Playbook> {
4421 let pack = playbooks_from_pack().unwrap_or_default();
4422 playbook_among(name, &pack)
4423}
4424
4425#[must_use]
4427pub fn format_playbook_copy(p: &Playbook) -> String {
4428 let mut out = format!("{}\n{}\n", p.name, p.body.trim());
4429 if !p.models.is_empty() {
4430 out.push_str("spawn hints (optional): ");
4431 out.push_str(&p.models.join(", "));
4432 out.push_str("; each subagent still ends with `ljos vote --as` then `ljos consensus`.\n");
4433 }
4434 out
4435}
4436
4437#[must_use]
4439pub fn format_playbooks(playbooks: &[Playbook]) -> String {
4440 if playbooks.is_empty() {
4441 return "no playbooks; the shipped recipes are sit, arena, land, company-panel, overnight\n"
4442 .to_string();
4443 }
4444 let width = playbooks.iter().map(|p| p.name.len()).max().unwrap_or(0);
4445 playbooks
4446 .iter()
4447 .map(|p| {
4448 let first = p
4449 .body
4450 .split_once('.')
4451 .map(|(s, _)| s.trim())
4452 .unwrap_or(p.body.trim());
4453 format!(
4454 "{:width$} {} {}\n",
4455 p.name,
4456 if p.models.is_empty() {
4457 "no spawn hints".to_string()
4458 } else {
4459 format!("hints {}", p.models.join(", "))
4460 },
4461 first
4462 )
4463 })
4464 .collect()
4465}
4466
4467pub const PLAYBOOK_NOTE_PREFIX: &str = "playbook:";
4470
4471fn playbook_key(issue: &str) -> String {
4472 issue
4473 .trim()
4474 .chars()
4475 .map(|c| {
4476 if c.is_ascii_alphanumeric() || c == '-' {
4477 c
4478 } else {
4479 '_'
4480 }
4481 })
4482 .collect()
4483}
4484
4485fn playbook_bind_path(issue: &str) -> PathBuf {
4486 runtime_dir().join(format!("playbook-{}", playbook_key(issue)))
4487}
4488
4489fn cached_playbook(issue: &str) -> Option<String> {
4490 let text = std::fs::read_to_string(playbook_bind_path(issue)).ok()?;
4491 let name = text.trim();
4492 if name.is_empty() {
4493 None
4494 } else {
4495 Some(name.to_string())
4496 }
4497}
4498
4499fn write_playbook_cache(issue: &str, name: &str) -> Result<()> {
4500 let path = playbook_bind_path(issue);
4501 if let Some(dir) = path.parent() {
4502 let _ = std::fs::create_dir_all(dir);
4503 }
4504 std::fs::write(&path, format!("{name}\n"))
4505 .with_context(|| format!("playbook: could not bind {name} on {issue}"))
4506}
4507
4508#[must_use]
4512pub fn playbook_name_from_issue(v: &Value) -> Option<String> {
4513 let mut dated: Vec<(String, Option<String>)> = Vec::new();
4514 for e in v["logbook"].as_array().into_iter().flatten() {
4515 let Some(note) = e["note"].as_str() else {
4516 continue;
4517 };
4518 let Some(rest) = note.trim().strip_prefix(PLAYBOOK_NOTE_PREFIX) else {
4519 continue;
4520 };
4521 let name = rest.trim();
4522 let live = if name.is_empty() {
4523 None
4524 } else {
4525 Some(name.to_string())
4526 };
4527 let ts = e["timestamp"].as_str().unwrap_or("").to_string();
4528 dated.push((ts, live));
4529 }
4530 if dated.iter().any(|(ts, _)| !ts.is_empty()) {
4531 dated
4532 .into_iter()
4533 .max_by_key(|(ts, _)| ts.clone())
4534 .and_then(|(_, n)| n)
4535 } else {
4536 dated.into_iter().next().and_then(|(_, n)| n)
4537 }
4538}
4539
4540pub fn playbook_named_on(issue: &str) -> Result<Option<String>> {
4546 let said = run_captured("vissue", &["show", issue, "--json"])?;
4547 let v: Value = serde_json::from_str(&said.stdout).context("vissue show --json")?;
4548 Ok(playbook_name_from_issue(&v))
4549}
4550
4551#[must_use]
4555pub fn bound_playbook(issue: &str) -> Option<String> {
4556 match playbook_named_on(issue) {
4557 Ok(name) => name,
4558 Err(_) => cached_playbook(issue),
4559 }
4560}
4561
4562pub fn drop_playbook(issue: &str) {
4566 if bound_playbook(issue).is_some() {
4567 let _ = run_captured("vissue", &["note", issue, PLAYBOOK_NOTE_PREFIX]);
4568 }
4569 let _ = std::fs::remove_file(playbook_bind_path(issue));
4570}
4571
4572pub fn bind_playbook(issue: &str, name: &str) -> Result<()> {
4579 let issue = issue.trim();
4580 let name = name.trim();
4581 if issue.is_empty() {
4582 bail!("playbook: an issue is required");
4583 }
4584 if name.is_empty() {
4585 bail!("playbook: a name is required");
4586 }
4587 let name = parse_playbook_name(name)?;
4588 if let Some(have) = bound_playbook(issue) {
4589 if have != name {
4590 bail!(
4591 "playbook: {issue} is bound to {have} until finish or release; \
4592 a new task is a new sitting"
4593 );
4594 }
4595 let _ = write_playbook_cache(issue, name);
4596 return Ok(());
4597 }
4598 let note = format!("{PLAYBOOK_NOTE_PREFIX} {name}");
4599 match run_captured("vissue", &["note", issue, ¬e]) {
4600 Ok(_) => {
4601 let _ = write_playbook_cache(issue, name);
4602 Ok(())
4603 }
4604 Err(_) => write_playbook_cache(issue, name),
4605 }
4606}
4607
4608pub fn copy_playbook(issue: &str, name: &str) -> Result<String> {
4611 let p = playbook_named(name)?;
4612 bind_playbook(issue, &p.name)?;
4613 Ok(format_playbook_copy(&p))
4614}
4615
4616#[must_use]
4619pub fn playbook_from_title(title: &str) -> &'static str {
4620 let tokens: Vec<String> = title
4621 .to_lowercase()
4622 .split(|c: char| !c.is_ascii_alphanumeric() && c != '-')
4623 .filter(|s| !s.is_empty())
4624 .map(str::to_string)
4625 .collect();
4626 let mut names: Vec<&'static str> = PLAYBOOK_NAMES.to_vec();
4627 names.sort_by_key(|n| std::cmp::Reverse(n.len()));
4628 for name in names {
4629 if tokens.iter().any(|t| t == name) {
4630 return name;
4631 }
4632 }
4633 "sit"
4634}
4635
4636pub fn resolve_sitting_playbook(issue: &str, title: &str, asked: Option<&str>) -> Result<String> {
4644 if let Some(name) = asked.map(str::trim).filter(|n| !n.is_empty()) {
4645 return Ok(playbook_named(name)?.name);
4646 }
4647 if let Some(name) = bound_playbook(issue) {
4648 return Ok(name);
4649 }
4650 Ok(playbook_from_title(title).to_string())
4651}
4652
4653pub fn playbook_opening(issue: &str, name: Option<&str>) -> Result<String> {
4656 match name.map(str::trim).filter(|n| !n.is_empty()) {
4657 Some(n) => copy_playbook(issue, n),
4658 None => match bound_playbook(issue) {
4659 Some(have) => {
4660 let p = playbook_named(&have)?;
4661 Ok(format_playbook_copy(&p))
4662 }
4663 None => Ok("none bound; `ljos sitting ISSUE --playbook NAME` or \
4664 `ljos playbook ISSUE NAME` names one. A panel is refused until then.\n"
4665 .to_string()),
4666 },
4667 }
4668}
4669
4670#[must_use]
4673pub fn brief_playbook_blocks(issue: &str) -> String {
4674 let copy = match bound_playbook(issue) {
4675 Some(name) => playbook_named(&name)
4676 .map(|p| format_playbook_copy(&p))
4677 .unwrap_or_else(|e| format!("{e}\n")),
4678 None => {
4679 "none bound; `ljos playbook ISSUE NAME` names one before personas enter.\n".to_string()
4680 }
4681 };
4682 format!("== playbook\n{copy}\n{PRINCIPLES}\n{RUBRIC}")
4683}
4684
4685pub fn brief(name: &str, issue: &str) -> Result<String> {
4694 let personas = personas_from_pack()?;
4695 let Some(p) = personas.iter().find(|p| p.name == name) else {
4696 let names: Vec<&str> = personas.iter().map(|p| p.name.as_str()).collect();
4697 bail!(
4698 "brief: no persona {name:?} in the pack; the pack holds {}",
4699 if names.is_empty() {
4700 "none".to_string()
4701 } else {
4702 names.join(", ")
4703 }
4704 );
4705 };
4706 let mut out = format!(
4707 "You are {}. {}\nYou hold your ballot at anchor {:.2}{}.\n\n{}",
4708 p.name,
4709 p.view,
4710 p.anchor,
4711 if p.entities.is_empty() {
4712 String::new()
4713 } else {
4714 format!("; you speak to {}", p.entities.join(", "))
4715 },
4716 brief_playbook_blocks(issue)
4717 );
4718 let mut seen = std::collections::BTreeSet::new();
4719 let mut lines = Vec::new();
4720 let now = now_utc();
4721 let client = pack()?;
4724 let own_tag = persona_entity(&p.name);
4725 let mut pool = client
4727 .atoms_in_set(&client.workspace(), &persona_set(&p.name))
4728 .unwrap_or_default();
4729 if let Ok(all) = client.atoms_of_kind(&client.workspace(), "lesson") {
4730 pool.extend(
4731 all.into_iter()
4732 .filter(|a| words_of(a.get("entities")).contains(&own_tag))
4733 .filter(|a| a.get("set").is_none()),
4734 );
4735 }
4736 {
4737 let atoms = pool;
4738 let mut own: Vec<&Value> = atoms.iter().filter(|a| reviewable(a)).collect();
4739 own.sort_by(|a, b| b["ts"].as_str().cmp(&a["ts"].as_str()));
4740 if !own.is_empty() {
4741 out.push_str("\nWhat you remembered yourself:\n");
4742 for a in own.iter().take(8) {
4743 if let Some(id) = a["id"].as_str() {
4744 seen.insert(id.to_string());
4745 }
4746 out.push_str(&format!(
4747 "- [{}{}] {}\n",
4748 a["kind"].as_str().unwrap_or("claim"),
4749 age_tag(a["ts"].as_str(), &now),
4750 a["text"].as_str().unwrap_or("").trim()
4751 ));
4752 }
4753 }
4754 }
4755 let cues: Vec<String> = if p.entities.is_empty() {
4756 vec![issue_title(issue)?]
4757 } else {
4758 p.entities.clone()
4759 };
4760 for cue in &cues {
4761 let Ok(hits) = packset_search(cue) else {
4762 continue;
4763 };
4764 for h in hits.into_iter().take(5) {
4765 if UNREVIEWED_KINDS.contains(&h.kind.as_str()) {
4766 continue;
4767 }
4768 if let Some(id) = &h.id {
4769 if !seen.insert(id.clone()) {
4770 continue;
4771 }
4772 }
4773 lines.push((h.kind == "preference", hit_line(&h, &now)));
4774 }
4775 }
4776 lines.sort_by_key(|row| std::cmp::Reverse(row.0));
4777 if !lines.is_empty() {
4778 out.push_str("\nWhat this seat knows on your domains:\n");
4779 for (_, l) in lines.iter().take(8) {
4780 out.push_str(l);
4781 out.push('\n');
4782 }
4783 }
4784 out.push_str("\nThe work:\n");
4785 out.push_str(&run_captured("vissue", &["recall", issue])?.stdout);
4786 out.push_str(&format!(
4787 "\nWalk the island as yourself before the ballot: `ljos island` on the work with `--as {}`. \
4788 The number on a row is spread along your links, not a rank of what is true. \
4789 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. \
4790 End with one ballot: `ljos vote {{issue}} --for OPTION --expect OPTION --confidence P --used deed-... --as {}`. \
4791 --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. \
4792 P is the probability you give that your own choice is the outcome. \
4793 --used none records that the ballot drew on no deed. \
4794 The line it prints is a count. `ljos consensus {{issue}}` is the settle. \
4795 A lesson of your own goes in with `ljos remember --as {} \"...\"`.\n",
4796 p.name, p.name, p.name
4797 ));
4798 Ok(out)
4799}
4800
4801#[must_use]
4814pub fn format_personas(personas: &[Persona]) -> String {
4817 if personas.is_empty() {
4818 return "no personas; `ljos persona NAME --anchor A --view \"...\" --about DOMAIN` writes one\n"
4819 .to_string();
4820 }
4821 let width = personas.iter().map(|p| p.name.len()).max().unwrap_or(0);
4822 personas
4823 .iter()
4824 .map(|p| {
4825 format!(
4826 "{:width$} anchor {:.2} {} {}\n",
4827 p.name,
4828 p.anchor,
4829 if p.entities.is_empty() {
4830 "about anything".to_string()
4831 } else {
4832 format!("about {}", p.entities.join(", "))
4833 },
4834 p.view
4835 )
4836 })
4837 .collect()
4838}
4839
4840fn is_scope_marker(word: &str) -> bool {
4843 word.to_lowercase().starts_with("sync:")
4844}
4845
4846const GENERIC_DOMAINS: &[&str] = &[
4849 "build",
4850 "test",
4851 "tests",
4852 "fix",
4853 "docs",
4854 "release",
4855 "review",
4856 "api",
4857 "ci",
4858 "performance",
4859 "design",
4860 "data",
4861 "web",
4862 "memory",
4863 "search",
4864 "sharing",
4865 "course",
4866 "training",
4867];
4868
4869pub fn personas_speaking_to(personas: &[Persona], words: &[String]) -> Vec<Persona> {
4870 let words: Vec<String> = words
4871 .iter()
4872 .map(|w| w.to_lowercase())
4873 .filter(|w| !is_scope_marker(w))
4874 .collect();
4875 let matched = |p: &Persona, generic: bool| {
4876 p.entities.iter().any(|d| {
4877 let d = d.to_lowercase();
4878 !is_scope_marker(&d)
4879 && GENERIC_DOMAINS.contains(&d.as_str()) == generic
4880 && words.iter().any(|w| w == &d)
4881 })
4882 };
4883 let specific: Vec<Persona> = personas
4887 .iter()
4888 .filter(|p| matched(p, false))
4889 .cloned()
4890 .collect();
4891 if !specific.is_empty() {
4892 return specific;
4893 }
4894 let speaking: Vec<Persona> = personas
4895 .iter()
4896 .filter(|p| matched(p, true))
4897 .cloned()
4898 .collect();
4899 if !speaking.is_empty() {
4900 return speaking;
4901 }
4902 let general: Vec<Persona> = personas
4905 .iter()
4906 .filter(|p| p.entities.is_empty())
4907 .cloned()
4908 .collect();
4909 if !general.is_empty() {
4910 return general;
4911 }
4912 let mut ranked: Vec<(usize, &Persona)> = personas
4915 .iter()
4916 .map(|p| {
4917 let view = p.view.to_lowercase();
4918 let hits = words
4919 .iter()
4920 .filter(|w| w.chars().count() > 3 && view.contains(w.as_str()))
4921 .count();
4922 (hits, p)
4923 })
4924 .filter(|(hits, _)| *hits > 0)
4925 .collect();
4926 ranked.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name)));
4927 ranked
4928 .into_iter()
4929 .take(PANEL_BY_VIEW)
4930 .map(|(_, p)| p.clone())
4931 .collect()
4932}
4933
4934pub const PANEL_BY_VIEW: usize = 5;
4937
4938pub fn issue_words(issue: &str) -> Vec<String> {
4942 let title = issue_title(issue).unwrap_or_default();
4943 let mut words = topic_words(&title);
4944 if let Ok(v) = tracker_show_json(issue) {
4946 words.extend(tags_of(&v));
4947 }
4948 if packset_island(&title, false).is_ok_and(|i| !i["weak"].as_bool().unwrap_or(false)) {
4952 words.extend(island_entities(issue).unwrap_or_default());
4953 }
4954 words
4955}
4956
4957fn tags_of(v: &Value) -> Vec<String> {
4959 v["tags"]
4960 .as_array()
4961 .into_iter()
4962 .flatten()
4963 .filter_map(Value::as_str)
4964 .map(str::to_lowercase)
4965 .collect()
4966}
4967
4968pub fn panel(issue: &str, out: &Path) -> Result<String> {
4969 if bound_playbook(issue).is_none() {
4970 bail!(
4971 "panel: no playbook bound on {issue}; `ljos playbook {issue} NAME` or \
4972 `ljos sitting {issue} --playbook NAME` names one before personas enter"
4973 );
4974 }
4975 let all = personas_from_pack()?;
4976 if all.is_empty() {
4977 bail!("panel: the pack holds no personas; `ljos persona NAME --anchor A --view ...` writes one");
4978 }
4979 let words = issue_words(issue);
4980 let personas = personas_speaking_to(&all, &words);
4981 if personas.is_empty() {
4982 bail!(
4983 "panel: none of the {} personas speaks to {issue}: none holds its words ({}) as a \
4984 domain or in its view. Tag the issue with a domain a persona holds, or write the \
4985 briefs by hand with `ljos brief NAME {issue}`",
4986 all.len(),
4987 words.join(", ")
4988 );
4989 }
4990 std::fs::create_dir_all(out)?;
4991 let mut lines = vec![format!(
4992 "{} of {} personas speak to {issue}; briefs in {}; start one subagent per file, each ends with its ballot, then:",
4993 personas.len(),
4994 all.len(),
4995 out.display()
4996 )];
4997 for p in &personas {
4998 let path = out.join(format!("{}.md", p.name));
4999 std::fs::write(&path, brief(&p.name, issue)?)?;
5000 lines.push(format!(" {}", path.display()));
5001 }
5002 lines.push(format!("ljos consensus {issue}"));
5003 Ok(lines.join("\n") + "\n")
5004}
5005
5006#[must_use]
5009pub fn issue_options(body: &str) -> Vec<String> {
5010 let mut lines = body.lines().map(str::trim);
5011 while let Some(line) = lines.next() {
5012 let Some(rest) = line.strip_prefix("Options:") else {
5013 continue;
5014 };
5015 let rest = rest.trim();
5016 let options: Vec<String> = if rest.is_empty() {
5017 lines
5018 .by_ref()
5019 .map_while(|l| l.strip_prefix("- ").or_else(|| l.strip_prefix("+ ")))
5020 .map(|o| o.trim().to_string())
5021 .collect()
5022 } else {
5023 rest.split(',').map(|o| o.trim().to_string()).collect()
5024 };
5025 let options: Vec<String> = options.into_iter().filter(|o| !o.is_empty()).collect();
5026 if options.len() >= 2 {
5027 return options;
5028 }
5029 }
5030 Vec::new()
5031}
5032
5033pub fn jev_ballot(name: &str, issue: &str) -> Result<jev::Ballot> {
5042 let v = tracker_show_json(issue)?;
5043 let options = issue_options(v["body"].as_str().unwrap_or(""));
5044 if options.len() < 2 {
5045 bail!("vote --jev: {issue} has no `Options: A, B` line with two options or more");
5046 }
5047 let full = brief(name, issue)?;
5048 let state = full
5049 .split("\nWalk the island as yourself")
5050 .next()
5051 .unwrap_or(&full);
5052 let state: String = state.chars().take(JEV_BRIEF_CHARS).collect();
5053 let state = format!("{state}\nOptions: {}\n", options.join(", "));
5054 jev::ballot(name, issue, &state, &options).with_context(|| {
5055 format!(
5056 "vote --jev: Jev did not answer (off, no key, over the month's cap, or past its budget); \
5057 `ljos brief {name} {issue}` starts a subagent instead"
5058 )
5059 })
5060}
5061
5062fn odds(m: &std::collections::BTreeMap<String, f64>) -> String {
5063 m.iter()
5064 .map(|(k, p)| format!("{k} {p:.2}"))
5065 .collect::<Vec<_>>()
5066 .join(", ")
5067}
5068
5069pub fn cast_jev(name: &str, issue: &str, b: &jev::Ballot) -> Result<()> {
5079 let p = b
5080 .probabilities
5081 .get(&b.choice)
5082 .copied()
5083 .unwrap_or(b.confidence);
5084 let p = format!("{:.3}", p.clamp(0.01, 1.0));
5085 run_captured_as(
5086 "vissue",
5087 &[
5088 "vote",
5089 issue,
5090 "--for",
5091 &b.choice,
5092 "--used",
5093 "none",
5094 "--confidence",
5095 &p,
5096 ],
5097 Some(name),
5098 )?;
5099 write_prediction(issue, name, &serde_json::to_string(&b.forecast)?)?;
5100 note_jev(
5101 issue,
5102 &format!(
5103 "{name}: ballot from Jev, {} ({}); forecast {}",
5104 b.choice,
5105 odds(&b.probabilities),
5106 odds(&b.forecast)
5107 ),
5108 );
5109 Ok(())
5110}
5111
5112fn note_jev(issue: &str, text: &str) {
5113 let _ = run_captured("vissue", &["note", issue, text]);
5114}
5115
5116#[derive(Debug, Clone, PartialEq)]
5119pub enum JevVote {
5120 Cast(jev::Ballot),
5121 Escalated(jev::Ballot),
5122}
5123
5124pub fn jev_vote(name: &str, issue: &str) -> Result<JevVote> {
5131 let b = jev_ballot(name, issue)?;
5132 if b.escalates() {
5133 note_jev(
5134 issue,
5135 &format!(
5136 "{name}: Jev leaned {} at confidence {:.2} ({}), under the {:.2} cut; the ballot goes to a subagent",
5137 b.choice,
5138 b.confidence,
5139 odds(&b.probabilities),
5140 b.escalate_below
5141 ),
5142 );
5143 return Ok(JevVote::Escalated(b));
5144 }
5145 cast_jev(name, issue, &b)?;
5146 Ok(JevVote::Cast(b))
5147}
5148
5149#[must_use]
5154pub fn jev_panel_stands(ballots: &[jev::Ballot]) -> bool {
5155 !ballots.is_empty()
5156 && ballots.iter().all(|b| !b.escalates())
5157 && ballots.iter().all(|b| b.choice == ballots[0].choice)
5158}
5159
5160const JEV_BRIEF_CHARS: usize = 8000;
5162
5163pub fn panel_jev(issue: &str, out: &Path) -> Result<String> {
5172 let all = personas_from_pack()?;
5173 let personas = personas_speaking_to(&all, &issue_words(issue));
5174 if personas.is_empty() {
5175 bail!("panel --jev: no persona speaks to {issue}");
5176 }
5177 let mut ballots = Vec::new();
5178 for p in &personas {
5179 ballots.push(jev_ballot(&p.name, issue)?);
5180 }
5181 let rows: Vec<String> = personas
5182 .iter()
5183 .zip(&ballots)
5184 .map(|(p, b)| {
5185 format!(
5186 " {} {} at confidence {:.2}",
5187 p.name, b.choice, b.confidence
5188 )
5189 })
5190 .collect();
5191 let mut lines = Vec::new();
5192 if jev_panel_stands(&ballots) {
5193 for (p, b) in personas.iter().zip(&ballots) {
5194 cast_jev(&p.name, issue, b)?;
5195 }
5196 lines.push(format!(
5197 "{} personas on {issue} through Jev: all sure, all {}; cast",
5198 personas.len(),
5199 ballots[0].choice
5200 ));
5201 lines.extend(rows);
5202 } else {
5203 std::fs::create_dir_all(out)?;
5204 lines.push(format!(
5205 "{} personas on {issue} through Jev: split or unsure, none cast; start one subagent per brief in {}",
5206 personas.len(),
5207 out.display()
5208 ));
5209 lines.extend(rows);
5210 for (p, b) in personas.iter().zip(&ballots) {
5211 let path = out.join(format!("{}.md", p.name));
5212 std::fs::write(&path, brief(&p.name, issue)?)?;
5213 lines.push(format!(" {}", path.display()));
5214 note_jev(
5215 issue,
5216 &format!(
5217 "{}: Jev leaned {} ({}); panel split or unsure, ballot goes to a subagent",
5218 p.name,
5219 b.choice,
5220 odds(&b.probabilities)
5221 ),
5222 );
5223 }
5224 }
5225 lines.push(format!("ljos consensus {issue}"));
5226 Ok(lines.join("\n") + "\n")
5227}
5228
5229#[derive(Debug, Clone, PartialEq)]
5232pub struct Prediction {
5233 pub issue: String,
5234 pub agent: String,
5235 pub expect: Value,
5236}
5237
5238pub fn write_prediction(issue: &str, agent: &str, expect: &str) -> Result<Value> {
5240 let (issue, agent, expect) = (issue.trim(), agent.trim(), expect.trim());
5241 if issue.is_empty() || agent.is_empty() || expect.is_empty() {
5242 bail!("predict: an issue, an identity and an expectation are required");
5243 }
5244 let expect_value: Value = match serde_json::from_str::<Value>(expect) {
5245 Ok(v @ Value::Object(_)) => v,
5246 _ => Value::String(expect.to_string()),
5247 };
5248 let client = pack()?;
5249 let workspace = client.workspace();
5250 let mut atom = atom_body(
5251 "prediction",
5252 &format!("{agent} expects {expect} on {issue}."),
5253 &workspace,
5254 );
5255 atom["issue"] = Value::String(issue.into());
5256 atom["agent"] = Value::String(agent.into());
5257 atom["expect"] = expect_value;
5258 client
5259 .post_atom(&atom)
5260 .context("predict: POST /v1/atoms failed")
5261}
5262
5263pub fn predictions_of(atoms: &[Value], issue: &str) -> Vec<Prediction> {
5265 let mut latest: std::collections::BTreeMap<String, (String, Prediction)> =
5266 std::collections::BTreeMap::new();
5267 for atom in atoms {
5268 if atom.get("kind").and_then(Value::as_str) != Some("prediction")
5269 || atom.get("issue").and_then(Value::as_str) != Some(issue)
5270 {
5271 continue;
5272 }
5273 let (Some(agent), Some(expect)) = (
5274 atom.get("agent").and_then(Value::as_str),
5275 atom.get("expect"),
5276 ) else {
5277 continue;
5278 };
5279 let ts = atom
5280 .get("ts")
5281 .and_then(Value::as_str)
5282 .unwrap_or("")
5283 .to_string();
5284 let p = Prediction {
5285 issue: issue.to_string(),
5286 agent: agent.to_string(),
5287 expect: expect.clone(),
5288 };
5289 match latest.get(agent) {
5290 Some((seen, _)) if *seen > ts => {}
5291 _ => {
5292 latest.insert(agent.to_string(), (ts, p));
5293 }
5294 }
5295 }
5296 latest.into_values().map(|(_, p)| p).collect()
5297}
5298
5299pub fn withdraw_prediction(issue: &str, agent: &str) -> Result<usize> {
5307 let client = pack()?;
5308 let workspace = client.workspace();
5309 let atoms = client
5310 .atoms_of_kind(&workspace, "prediction")
5311 .context("predict: GET /v1/atoms failed")?;
5312 let mut gone = 0;
5313 for atom in atoms {
5314 if atom["issue"].as_str() != Some(issue) || atom["agent"].as_str() != Some(agent) {
5315 continue;
5316 }
5317 let Some(id) = atom["id"].as_str() else {
5318 continue;
5319 };
5320 client
5321 .delete_atom(&workspace, id, None)
5322 .with_context(|| format!("predict: delete {id} failed"))?;
5323 gone += 1;
5324 }
5325 Ok(gone)
5326}
5327
5328pub fn predictions_json(predictions: &[Prediction]) -> String {
5330 Value::Array(
5331 predictions
5332 .iter()
5333 .map(|p| serde_json::json!({"agent": p.agent, "expect": p.expect}))
5334 .collect(),
5335 )
5336 .to_string()
5337}
5338
5339#[derive(Debug, Clone, PartialEq, Eq)]
5343pub struct Rule {
5344 pub pattern: String,
5345 pub verdict: String,
5346 pub reason: String,
5347}
5348
5349pub fn write_rule(rule: &Rule) -> Result<Value> {
5351 let pattern = rule.pattern.trim();
5352 if pattern.is_empty() {
5353 bail!("rule: a pattern over the command line is required");
5354 }
5355 if !matches!(rule.verdict.as_str(), "deny" | "ask") {
5356 bail!("rule: the verdict is deny or ask, not {:?}", rule.verdict);
5357 }
5358 let reason = rule.reason.trim();
5359 if reason.is_empty() {
5360 bail!("rule: say in a sentence why, so the reader who is stopped knows");
5361 }
5362 let client = pack()?;
5363 let workspace = client.workspace();
5364 let mut atom = atom_body("rule", reason, &workspace);
5365 atom["pattern"] = Value::String(pattern.into());
5366 atom["verdict"] = Value::String(rule.verdict.clone());
5367 client
5368 .post_atom(&atom)
5369 .context("rule: POST /v1/atoms failed")
5370}
5371
5372pub fn rules_of(atoms: &[Value]) -> Vec<Rule> {
5374 atoms
5375 .iter()
5376 .filter(|a| a.get("kind").and_then(Value::as_str) == Some("rule"))
5377 .filter_map(|a| {
5378 Some(Rule {
5379 pattern: a.get("pattern")?.as_str()?.to_string(),
5380 verdict: a.get("verdict")?.as_str()?.to_string(),
5381 reason: a
5382 .get("text")
5383 .and_then(Value::as_str)
5384 .unwrap_or("")
5385 .to_string(),
5386 })
5387 })
5388 .collect()
5389}
5390
5391pub fn rules_from_pack() -> Result<Vec<Rule>> {
5393 let client = pack()?;
5394 let atoms = atoms_lean(&client, &client.workspace()).context("rules: GET /v1/atoms failed")?;
5395 Ok(rules_of(&atoms))
5396}
5397
5398#[must_use]
5402pub fn glob_matches(pattern: &str, line: &str) -> bool {
5403 fn go(p: &[char], l: &[char]) -> bool {
5404 match (p.first(), l.first()) {
5405 (None, None) => true,
5406 (Some('*'), _) => go(&p[1..], l) || (!l.is_empty() && go(p, &l[1..])),
5407 (Some('?'), Some(_)) => go(&p[1..], &l[1..]),
5408 (Some(a), Some(b)) if a == b => go(&p[1..], &l[1..]),
5409 _ => false,
5410 }
5411 }
5412 let p: Vec<char> = pattern.chars().collect();
5413 let l: Vec<char> = line.trim().chars().collect();
5414 go(&p, &l)
5415}
5416
5417#[must_use]
5420pub fn verdict_for<'a>(rules: &'a [Rule], line: &str) -> Option<&'a Rule> {
5421 rules
5422 .iter()
5423 .find(|r| r.verdict == "deny" && glob_matches(&r.pattern, line))
5424 .or_else(|| {
5425 rules
5426 .iter()
5427 .find(|r| r.verdict == "ask" && glob_matches(&r.pattern, line))
5428 })
5429}
5430
5431pub fn anchors_json(personas: &[Persona]) -> String {
5433 let map: serde_json::Map<String, Value> = personas
5434 .iter()
5435 .map(|p| (p.name.clone(), serde_json::json!(p.anchor)))
5436 .collect();
5437 Value::Object(map).to_string()
5438}
5439
5440fn domains_of(v: Option<&Value>) -> Vec<String> {
5443 words_of(v)
5444 .into_iter()
5445 .filter(|e| !e.starts_with(SEAT_ENTITY))
5446 .collect()
5447}
5448
5449fn words_of(v: Option<&Value>) -> Vec<String> {
5450 v.and_then(Value::as_array)
5451 .into_iter()
5452 .flatten()
5453 .filter_map(Value::as_str)
5454 .map(str::to_lowercase)
5455 .collect()
5456}
5457
5458pub fn island_entities(issue: &str) -> Result<Vec<String>> {
5466 let title = issue_title(issue)?;
5467 let island = packset_island(&title, false)?;
5468 let ids: Vec<&str> = island["island"]
5469 .as_array()
5470 .into_iter()
5471 .flatten()
5472 .filter_map(|a| a["id"].as_str())
5473 .collect();
5474 if ids.is_empty() {
5475 return Ok(Vec::new());
5476 }
5477 let client = pack()?;
5478 let atoms = atoms_lean(&client, &client.workspace()).context("island: GET /v1/atoms failed")?;
5479 let mut count: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
5480 for atom in &atoms {
5481 if atom
5482 .get("id")
5483 .and_then(Value::as_str)
5484 .is_some_and(|id| ids.contains(&id))
5485 {
5486 for e in words_of(atom.get("entities")) {
5487 *count.entry(e).or_insert(0) += 1;
5488 }
5489 }
5490 }
5491 let mut ranked: Vec<(String, usize)> = count.into_iter().collect();
5492 ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
5493 Ok(ranked.into_iter().take(8).map(|(e, _)| e).collect())
5494}
5495
5496pub fn topic_words(title: &str) -> Vec<String> {
5499 let mut words: Vec<String> = title
5500 .split(|c: char| !c.is_alphanumeric())
5501 .filter(|w| w.len() >= 3)
5502 .map(str::to_lowercase)
5503 .collect();
5504 words.sort_unstable();
5505 words.dedup();
5506 words
5507}
5508
5509pub fn rows_about(rows: &[Trust], topic: &[String]) -> Vec<Trust> {
5512 let mut chosen: std::collections::BTreeMap<(String, String), Trust> =
5515 std::collections::BTreeMap::new();
5516 for r in rows {
5517 let applies = r.about.is_empty() || r.about.iter().any(|a| topic.contains(a));
5518 if !applies {
5519 continue;
5520 }
5521 let key = (r.from.clone(), r.to.clone());
5522 match chosen.get(&key) {
5523 Some(have) if !have.about.is_empty() && r.about.is_empty() => {}
5524 _ => {
5525 chosen.insert(key, r.clone());
5526 }
5527 }
5528 }
5529 chosen.into_values().collect()
5530}
5531
5532#[must_use]
5539pub fn learn_anchors(
5540 personas: &[Persona],
5541 ballots: &[(String, String)],
5542 outcome: &str,
5543 beta: f64,
5544) -> Vec<Persona> {
5545 let outcome = outcome.trim();
5546 personas
5547 .iter()
5548 .filter(|p| {
5549 ballots
5550 .iter()
5551 .any(|(agent, choice)| *agent == p.name && choice != outcome)
5552 })
5553 .map(|p| Persona {
5554 anchor: (p.anchor + (1.0 - p.anchor) * (1.0 - beta)).min(1.0),
5555 ..p.clone()
5556 })
5557 .collect()
5558}
5559
5560#[derive(Debug, Clone, PartialEq)]
5569pub struct Forecast {
5570 pub agent: String,
5571 pub choice: String,
5572 pub confidence: Option<f64>,
5573}
5574
5575#[must_use]
5583pub fn brier(choice: &str, outcome: &str, p: f64) -> f64 {
5584 let o = if choice == outcome { 1.0 } else { 0.0 };
5585 let d = p - o;
5586 d * d
5587}
5588
5589#[must_use]
5596pub fn log_score(choice: &str, outcome: &str, p: f64) -> Option<f64> {
5597 let assigned = if choice == outcome { p } else { 1.0 - p };
5598 if assigned <= 0.0 {
5599 None
5600 } else {
5601 Some(-assigned.ln())
5602 }
5603}
5604
5605#[must_use]
5608pub fn mean_log(rows: &[Forecast], outcome: &str) -> (Option<f64>, usize, usize) {
5609 let mut sum = 0.0;
5610 let mut finite = 0usize;
5611 let mut unbounded = 0usize;
5612 for row in rows {
5613 let Some(p) = row.confidence else { continue };
5614 match log_score(&row.choice, outcome, p) {
5615 Some(score) => {
5616 sum += score;
5617 finite += 1;
5618 }
5619 None => unbounded += 1,
5620 }
5621 }
5622 let mean = (finite > 0).then_some(sum / finite as f64);
5623 (mean, finite, unbounded)
5624}
5625
5626#[derive(Debug, Clone, Default, PartialEq)]
5631pub struct Calibration {
5632 pub n: u32,
5633 pub sum_p: f64,
5634 pub sum_o: f64,
5635 pub sum_brier: f64,
5636 pub sum_log: f64,
5637 pub log_n: u32,
5638 pub bins: std::collections::BTreeMap<u16, (u32, u32)>,
5639}
5640
5641#[derive(Debug, Clone, Copy, PartialEq)]
5645pub struct Partition {
5646 pub reliability: f64,
5647 pub resolution: f64,
5648 pub uncertainty: f64,
5649}
5650
5651#[must_use]
5653pub fn observe(cal: &Calibration, choice: &str, outcome: &str, p: f64) -> Calibration {
5654 let mut next = cal.clone();
5655 let occurred = choice == outcome;
5656 let o = if occurred { 1.0 } else { 0.0 };
5657 next.n += 1;
5658 next.sum_p += p;
5659 next.sum_o += o;
5660 next.sum_brier += brier(choice, outcome, p);
5661 if let Some(score) = log_score(choice, outcome, p) {
5662 next.sum_log += score;
5663 next.log_n += 1;
5664 }
5665 let key = (p.clamp(0.0, 1.0) * 1000.0).round() as u16;
5666 let slot = next.bins.entry(key).or_insert((0, 0));
5667 slot.0 += 1;
5668 if occurred {
5669 slot.1 += 1;
5670 }
5671 next
5672}
5673
5674#[must_use]
5677pub fn murphy(cal: &Calibration) -> Option<Partition> {
5678 if cal.n < 2 || cal.bins.is_empty() {
5679 return None;
5680 }
5681 let n = f64::from(cal.n);
5682 let base = cal.sum_o / n;
5683 let mut reliability = 0.0;
5684 let mut resolution = 0.0;
5685 for (thou, (count, occurred)) in &cal.bins {
5686 let nk = f64::from(*count);
5687 if nk == 0.0 {
5688 continue;
5689 }
5690 let forecast = f64::from(*thou) / 1000.0;
5691 let rate = f64::from(*occurred) / nk;
5692 reliability += nk * (forecast - rate) * (forecast - rate);
5693 resolution += nk * (rate - base) * (rate - base);
5694 }
5695 Some(Partition {
5696 reliability: reliability / n,
5697 resolution: resolution / n,
5698 uncertainty: base * (1.0 - base),
5699 })
5700}
5701
5702#[must_use]
5705pub fn mean_brier(rows: &[Forecast], outcome: &str) -> Option<(f64, usize)> {
5706 let scores: Vec<f64> = rows
5707 .iter()
5708 .filter_map(|r| r.confidence.map(|p| brier(&r.choice, outcome, p)))
5709 .collect();
5710 if scores.is_empty() {
5711 None
5712 } else {
5713 Some((
5714 scores.iter().sum::<f64>() / scores.len() as f64,
5715 scores.len(),
5716 ))
5717 }
5718}
5719
5720pub fn forecasts_from_json(raw: &str) -> Result<Vec<Forecast>> {
5722 let rows: Vec<Value> = serde_json::from_str(raw).context("ballots: not a JSON array")?;
5723 rows.iter()
5724 .map(|row| {
5725 let agent = row.get("agent").and_then(Value::as_str);
5726 let choice = row.get("choice").and_then(Value::as_str);
5727 let confidence = match row.get("confidence") {
5728 None | Some(Value::Null) => None,
5729 Some(value) => {
5730 let probability = value
5731 .as_f64()
5732 .or_else(|| value.as_str()?.parse::<f64>().ok())
5733 .context("ballots: confidence must be a probability in (0, 1]")?;
5734 if !probability.is_finite() || probability <= 0.0 || probability > 1.0 {
5735 bail!("ballots: confidence must be a probability in (0, 1]");
5736 }
5737 Some(probability)
5738 }
5739 };
5740 match (agent, choice) {
5741 (Some(a), Some(c)) => Ok(Forecast {
5742 agent: a.to_string(),
5743 choice: c.to_string(),
5744 confidence,
5745 }),
5746 _ => bail!("ballots: a row without agent and choice"),
5747 }
5748 })
5749 .collect()
5750}
5751
5752#[must_use]
5756pub fn learn_reading(
5757 rows: usize,
5758 moved: usize,
5759 forecasts: &[Forecast],
5760 outcome: &str,
5761 calibration: &std::collections::BTreeMap<String, Calibration>,
5762) -> String {
5763 let mut out = format!(
5764 "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."
5765 );
5766 match mean_brier(forecasts, outcome) {
5767 Some((mean, n)) => {
5768 let silent = forecasts.len().saturating_sub(n);
5769 out.push_str(&format!(
5770 " 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."
5771 ));
5772 }
5773 None => out.push_str(
5774 " No stated probability, so there is no Brier score. A hard vote is not a claim of certainty.",
5775 ),
5776 }
5777 let (mean_log, finite, unbounded) = mean_log(forecasts, outcome);
5778 if let Some(mean) = mean_log {
5779 out.push_str(&format!(
5780 " Logarithmic score {mean:.3} over {finite} (doi:10.1111/j.2517-6161.1952.tb00104.x)."
5781 ));
5782 }
5783 if unbounded > 0 {
5784 out.push_str(&format!(
5785 " {unbounded} assigned probability 0 to the event that occurred, so those logarithmic scores are unbounded."
5786 ));
5787 }
5788 let mut named: Vec<(&str, &Calibration)> = forecasts
5789 .iter()
5790 .filter(|f| f.confidence.is_some())
5791 .filter_map(|f| calibration.get(&f.agent).map(|cal| (f.agent.as_str(), cal)))
5792 .collect();
5793 named.sort_by(|a, b| {
5794 let gap = |c: &Calibration| {
5795 if c.n == 0 {
5796 0.0
5797 } else {
5798 (c.sum_p / f64::from(c.n) - c.sum_o / f64::from(c.n)).abs()
5799 }
5800 };
5801 gap(b.1)
5802 .partial_cmp(&gap(a.1))
5803 .unwrap_or(std::cmp::Ordering::Equal)
5804 .then(a.0.cmp(b.0))
5805 });
5806 named.dedup_by_key(|row| row.0);
5807 for (name, cal) in named.into_iter().take(8) {
5808 if cal.n == 0 {
5809 continue;
5810 }
5811 let n = f64::from(cal.n);
5812 let mean_p = cal.sum_p / n;
5813 let rate = cal.sum_o / n;
5814 out.push_str(&format!(
5815 " {name}: {} forecasts, mean probability {mean_p:.3}, event rate {rate:.3} (doi:10.1080/01621459.1982.10477856)",
5816 cal.n
5817 ));
5818 if let Some(part) = murphy(cal) {
5819 out.push_str(&format!(
5820 "; reliability {:.3}, resolution {:.3}, uncertainty {:.3} (doi:10.1175/1520-0450(1973)012<0595:ANVPOT>2.0.CO;2)",
5821 part.reliability, part.resolution, part.uncertainty
5822 ));
5823 }
5824 out.push('.');
5825 }
5826 out
5827}
5828
5829pub type LearnedState = (
5831 Vec<Trust>,
5832 Vec<Persona>,
5833 std::collections::BTreeMap<String, Calibration>,
5834);
5835
5836pub fn learn_and_write(
5837 ballots: &[(String, String)],
5838 outcome: &str,
5839 beta: f64,
5840 about: &[String],
5841 forecasts: &[Forecast],
5842) -> Result<LearnedState> {
5843 let client = pack()?;
5844 let atoms = atoms_lean(&client, &client.workspace()).context("learn: GET /v1/atoms failed")?;
5845 let (rows, records) = learn_record(ballots, outcome, &records_from_atoms(&atoms), about)?;
5846 let mut calibration = calibration_from_atoms(&atoms);
5847 for forecast in forecasts {
5848 let Some(p) = forecast.confidence else {
5849 continue;
5850 };
5851 let slot = calibration.entry(forecast.agent.clone()).or_default();
5852 *slot = observe(slot, &forecast.choice, outcome, p);
5853 }
5854 let moved = learn_anchors(&personas_from_pack()?, ballots, outcome, beta);
5855 for row in &rows {
5858 write_trust_record(
5859 row,
5860 &[],
5861 records.get(&row.to).copied(),
5862 calibration.get(&row.to),
5863 )?;
5864 }
5865 for p in &moved {
5866 write_persona(p)?;
5867 }
5868 Ok((rows, moved, calibration))
5869}
5870
5871pub type Standing = (f64, f64);
5874
5875#[must_use]
5877pub fn records_from_atoms(atoms: &[Value]) -> std::collections::BTreeMap<String, Standing> {
5878 let mut latest: std::collections::BTreeMap<String, (String, Standing)> =
5879 std::collections::BTreeMap::new();
5880 for atom in atoms {
5881 if atom.get("kind").and_then(Value::as_str) != Some("trust") {
5882 continue;
5883 }
5884 let (Some(to), Some(hits), Some(misses)) = (
5885 atom.get("to").and_then(Value::as_str),
5886 atom.get("hits").and_then(Value::as_f64),
5887 atom.get("misses").and_then(Value::as_f64),
5888 ) else {
5889 continue;
5890 };
5891 let ts = atom
5892 .get("ts")
5893 .and_then(Value::as_str)
5894 .unwrap_or("")
5895 .to_string();
5896 match latest.get(to) {
5897 Some((seen, _)) if *seen > ts => {}
5898 _ => {
5899 latest.insert(to.to_string(), (ts, (hits, misses)));
5900 }
5901 }
5902 }
5903 latest.into_iter().map(|(k, (_, r))| (k, r)).collect()
5904}
5905
5906pub fn learn_record(
5919 ballots: &[(String, String)],
5920 outcome: &str,
5921 records: &std::collections::BTreeMap<String, Standing>,
5922 about: &[String],
5923) -> Result<(Vec<Trust>, std::collections::BTreeMap<String, Standing>)> {
5924 let outcome = outcome.trim();
5925 if outcome.is_empty() {
5926 bail!("learn: an outcome is required");
5927 }
5928 let mut agents: Vec<&str> = ballots.iter().map(|(a, _)| a.as_str()).collect();
5929 agents.sort_unstable();
5930 agents.dedup();
5931 if agents.len() < 2 {
5932 bail!("learn: fewer than two voters, nothing to weigh");
5933 }
5934 let mut next = records.clone();
5935 for (agent, choice) in ballots {
5936 let r = next.entry(agent.clone()).or_insert((0.0, 0.0));
5937 if choice == outcome {
5938 r.0 += 1.0;
5939 } else {
5940 r.1 += 1.0;
5941 }
5942 }
5943 let accuracy: Vec<(String, f64)> = agents
5944 .iter()
5945 .map(|a| {
5946 let (h, m) = next.get(*a).copied().unwrap_or((0.0, 0.0));
5947 ((*a).to_string(), (h + 1.0) / (h + m + 2.0))
5948 })
5949 .collect();
5950 let weights = calibration_weights(&accuracy);
5951 let mut out = Vec::new();
5952 for from in &agents {
5953 for (to, weight) in &weights {
5954 if *from == to {
5955 continue;
5956 }
5957 out.push(Trust {
5958 from: (*from).to_string(),
5959 to: to.clone(),
5960 weight: *weight,
5961 about: about.to_vec(),
5962 });
5963 }
5964 }
5965 Ok((out, next))
5966}
5967
5968pub fn write_trust_record(
5970 row: &Trust,
5971 why: &[String],
5972 record: Option<Standing>,
5973 calibration: Option<&Calibration>,
5974) -> Result<Value> {
5975 let client = pack()?;
5976 let workspace = client.workspace();
5977 let mut atom = trust_atom(row, why, &workspace)?;
5978 if let Some((hits, misses)) = record {
5979 atom["hits"] = serde_json::json!(hits);
5980 atom["misses"] = serde_json::json!(misses);
5981 }
5982 if let Some(cal) = calibration.filter(|c| c.n > 0) {
5983 atom["forecast_n"] = serde_json::json!(cal.n);
5984 atom["forecast_sum_p"] = serde_json::json!(cal.sum_p);
5985 atom["forecast_sum_o"] = serde_json::json!(cal.sum_o);
5986 atom["forecast_sum_brier"] = serde_json::json!(cal.sum_brier);
5987 atom["forecast_sum_log"] = serde_json::json!(cal.sum_log);
5988 atom["forecast_log_n"] = serde_json::json!(cal.log_n);
5989 let mut bins = serde_json::Map::new();
5990 for (key, (count, occurred)) in &cal.bins {
5991 bins.insert(key.to_string(), serde_json::json!([count, occurred]));
5992 }
5993 atom["forecast_bins"] = Value::Object(bins);
5994 }
5995 client
5996 .post_atom(&atom)
5997 .context("trust: POST /v1/atoms failed")
5998}
5999
6000#[must_use]
6002pub fn calibration_from_atoms(atoms: &[Value]) -> std::collections::BTreeMap<String, Calibration> {
6003 let mut latest: std::collections::BTreeMap<String, (String, Calibration)> =
6004 std::collections::BTreeMap::new();
6005 for atom in atoms {
6006 if atom.get("kind").and_then(Value::as_str) != Some("trust") {
6007 continue;
6008 }
6009 let Some(to) = atom.get("to").and_then(Value::as_str) else {
6010 continue;
6011 };
6012 let Some(n) = atom.get("forecast_n").and_then(Value::as_u64) else {
6013 continue;
6014 };
6015 let ts = atom
6016 .get("ts")
6017 .and_then(Value::as_str)
6018 .unwrap_or("")
6019 .to_string();
6020 let cal = Calibration {
6021 n: n as u32,
6022 sum_p: atom
6023 .get("forecast_sum_p")
6024 .and_then(Value::as_f64)
6025 .unwrap_or(0.0),
6026 sum_o: atom
6027 .get("forecast_sum_o")
6028 .and_then(Value::as_f64)
6029 .unwrap_or(0.0),
6030 sum_brier: atom
6031 .get("forecast_sum_brier")
6032 .and_then(Value::as_f64)
6033 .unwrap_or(0.0),
6034 sum_log: atom
6035 .get("forecast_sum_log")
6036 .and_then(Value::as_f64)
6037 .unwrap_or(0.0),
6038 log_n: atom
6039 .get("forecast_log_n")
6040 .and_then(Value::as_u64)
6041 .unwrap_or(0) as u32,
6042 bins: bins_of(atom.get("forecast_bins")),
6043 };
6044 match latest.get(to) {
6045 Some((seen, _)) if *seen > ts => {}
6046 _ => {
6047 latest.insert(to.to_string(), (ts, cal));
6048 }
6049 }
6050 }
6051 latest.into_iter().map(|(k, (_, cal))| (k, cal)).collect()
6052}
6053
6054fn bins_of(value: Option<&Value>) -> std::collections::BTreeMap<u16, (u32, u32)> {
6055 let mut out = std::collections::BTreeMap::new();
6056 let Some(obj) = value.and_then(Value::as_object) else {
6057 return out;
6058 };
6059 for (key, row) in obj {
6060 let Ok(thou) = key.parse::<u16>() else {
6061 continue;
6062 };
6063 let Some(pair) = row.as_array() else { continue };
6064 let count = pair.first().and_then(Value::as_u64).unwrap_or(0) as u32;
6065 let occurred = pair.get(1).and_then(Value::as_u64).unwrap_or(0) as u32;
6066 out.insert(thou, (count, occurred));
6067 }
6068 out
6069}
6070
6071pub const LEARN_BETA: f64 = 0.5;
6073
6074pub const TRUST_FLOOR: f64 = 0.01;
6076
6077pub fn trust_atom(row: &Trust, why: &[String], workspace: &str) -> Result<Value> {
6079 let (from, to) = (row.from.trim(), row.to.trim());
6080 if from.is_empty() || to.is_empty() {
6081 bail!("trust: from and to are required");
6082 }
6083 if from == to {
6084 bail!("trust: {from} cannot weigh itself; self weight is the settle's");
6085 }
6086 if !(row.weight > 0.0 && row.weight <= 1.0) {
6087 bail!("trust: weight {} is not in (0, 1]", row.weight);
6088 }
6089 let mut atom = atom_body(
6090 "trust",
6091 &format!("{from} weighs {to} at {:.3}.", row.weight),
6092 workspace,
6093 );
6094 atom["from"] = Value::String(from.into());
6095 atom["to"] = Value::String(to.into());
6096 atom["weight"] = serde_json::json!(row.weight);
6097 for w in why {
6100 if !w.starts_with("deed-") && !w.starts_with("sha256:") {
6101 bail!("trust: {w} is not a deed accession");
6102 }
6103 }
6104 atom["entities"] = Value::Array(why.iter().map(|w| Value::String(w.clone())).collect());
6105 if !row.about.is_empty() {
6106 atom["about"] = Value::Array(
6107 row.about
6108 .iter()
6109 .map(|w| Value::String(w.to_lowercase()))
6110 .collect(),
6111 );
6112 }
6113 Ok(atom)
6114}
6115
6116pub fn trust_rows(atoms: &[Value]) -> Vec<Trust> {
6118 let mut latest: std::collections::BTreeMap<(String, String, Vec<String>), (String, f64)> =
6122 std::collections::BTreeMap::new();
6123 for atom in atoms {
6124 if atom.get("kind").and_then(Value::as_str) != Some("trust") {
6125 continue;
6126 }
6127 let (Some(from), Some(to), Some(weight)) = (
6128 atom.get("from").and_then(Value::as_str),
6129 atom.get("to").and_then(Value::as_str),
6130 atom.get("weight").and_then(Value::as_f64),
6131 ) else {
6132 continue;
6133 };
6134 let ts = atom
6135 .get("ts")
6136 .and_then(Value::as_str)
6137 .unwrap_or("")
6138 .to_string();
6139 let mut about = words_of(atom.get("about"));
6140 about.sort_unstable();
6141 let key = (from.to_string(), to.to_string(), about);
6142 match latest.get(&key) {
6143 Some((seen, _)) if *seen > ts => {}
6144 _ => {
6145 latest.insert(key, (ts, weight));
6146 }
6147 }
6148 }
6149 latest
6150 .into_iter()
6151 .map(|((from, to, about), (_, weight))| Trust {
6152 from,
6153 to,
6154 weight,
6155 about,
6156 })
6157 .collect()
6158}
6159
6160pub fn trust_json(rows: &[Trust]) -> String {
6162 let tuples: Vec<Value> = rows
6163 .iter()
6164 .map(|r| serde_json::json!([r.from, r.to, r.weight]))
6165 .collect();
6166 Value::Array(tuples).to_string()
6167}
6168
6169pub fn ballots_from_json(raw: &str) -> Result<Vec<(String, String)>> {
6171 let rows: Vec<Value> = serde_json::from_str(raw).context("ballots: not a JSON array")?;
6172 rows.iter()
6173 .map(|row| {
6174 let agent = row.get("agent").and_then(Value::as_str);
6175 let choice = row.get("choice").and_then(Value::as_str);
6176 match (agent, choice) {
6177 (Some(a), Some(c)) => Ok((a.to_string(), c.to_string())),
6178 _ => bail!("ballots: a row without agent and choice"),
6179 }
6180 })
6181 .collect()
6182}
6183
6184pub fn learn(
6189 ballots: &[(String, String)],
6190 outcome: &str,
6191 rows: &[Trust],
6192 beta: f64,
6193) -> Result<Vec<Trust>> {
6194 learn_about(ballots, outcome, rows, beta, &[])
6195}
6196
6197pub fn learn_about(
6201 ballots: &[(String, String)],
6202 outcome: &str,
6203 rows: &[Trust],
6204 beta: f64,
6205 about: &[String],
6206) -> Result<Vec<Trust>> {
6207 learn_shared(ballots, outcome, rows, beta, about, 0.0)
6208}
6209
6210pub fn learn_shared(
6216 ballots: &[(String, String)],
6217 outcome: &str,
6218 rows: &[Trust],
6219 beta: f64,
6220 about: &[String],
6221 share: f64,
6222) -> Result<Vec<Trust>> {
6223 if !(beta > 0.0 && beta < 1.0) {
6224 bail!("learn: beta {beta} is not in (0, 1)");
6225 }
6226 if !(0.0..1.0).contains(&share) {
6227 bail!("learn: share {share} is not in [0, 1)");
6228 }
6229 let outcome = outcome.trim();
6230 if outcome.is_empty() {
6231 bail!("learn: an outcome is required");
6232 }
6233 let mut agents: Vec<&str> = ballots.iter().map(|(a, _)| a.as_str()).collect();
6234 agents.sort_unstable();
6235 agents.dedup();
6236 if agents.len() < 2 {
6237 bail!("learn: fewer than two voters, nothing to weigh");
6238 }
6239 let refuted = |agent: &str| {
6240 ballots
6241 .iter()
6242 .any(|(a, choice)| a == agent && choice != outcome)
6243 };
6244 let mut out = Vec::new();
6245 for from in &agents {
6246 for to in &agents {
6247 if from == to {
6248 continue;
6249 }
6250 let current = rows
6253 .iter()
6254 .find(|r| r.from == *from && r.to == *to && r.about == about)
6255 .or_else(|| {
6256 rows.iter()
6257 .find(|r| r.from == *from && r.to == *to && r.about.is_empty())
6258 })
6259 .map_or(1.0, |r| r.weight);
6260 let stepped = if refuted(to) {
6261 (current * beta).max(TRUST_FLOOR)
6262 } else {
6263 current
6264 };
6265 let next = stepped + (1.0 - stepped) * share;
6266 out.push(Trust {
6267 from: (*from).to_string(),
6268 to: (*to).to_string(),
6269 weight: next,
6270 about: about.to_vec(),
6271 });
6272 }
6273 }
6274 Ok(out)
6275}
6276
6277pub fn trust_from_pack() -> Result<Vec<Trust>> {
6279 let client = pack()?;
6280 let workspace = client.workspace();
6281 let atoms = atoms_lean(&client, &workspace).context("trust: GET /v1/atoms failed")?;
6282 Ok(trust_rows(&atoms))
6283}
6284
6285pub fn write_trust(row: &Trust, why: &[String]) -> Result<Value> {
6287 let client = pack()?;
6288 let workspace = client.workspace();
6289 client
6290 .post_atom(&trust_atom(row, why, &workspace)?)
6291 .context("trust: POST /v1/atoms failed")
6292}
6293
6294#[derive(Debug, Clone, PartialEq, Eq)]
6296pub struct Habitat {
6297 pub name: &'static str,
6298 pub state: String,
6299 pub ok: bool,
6300}
6301
6302#[must_use]
6304pub fn format_write_ack(body: &serde_json::Value) -> String {
6305 format!(
6306 "{}\t{}\tdue {}\t{}",
6307 body["id"].as_str().unwrap_or("?"),
6308 body["kind"].as_str().unwrap_or("?"),
6309 body["due_at"].as_str().unwrap_or("-"),
6310 body["text"].as_str().unwrap_or("").replace('\n', " "),
6311 )
6312}
6313
6314pub const REQUIRED: &[&str] = &[
6316 "ljos",
6317 "ljos-mcp",
6318 "ljos-policyd",
6319 "vissue",
6320 "deedar",
6321 "claimdag",
6322 "packset",
6323 "packsetd",
6324 "packset-embed",
6325 "pack",
6326 "encoder",
6327];
6328
6329const SEAT_BINS: &[(&str, &str)] = &[
6331 ("ljos", "ljos"),
6332 ("ljos-mcp", "ljos"),
6335 ("ljos-policyd", "ljos-policyd"),
6336 ("ljos-consensus", "ljos-consensus"),
6337 ("vissue", "vissue-cli"),
6338 ("deedar", "deedar-cli"),
6339 ("claimdag", "claimdag-cli"),
6340 ("packset", "packset"),
6341 ("packsetd", "packset"),
6342 ("packset-embed", "packset-embed"),
6343 ("packset-mcp", "packset"),
6344 ("ljos-hud", "ljos-hud"),
6345];
6346
6347#[must_use]
6349pub fn parse_semver(text: &str) -> Option<&str> {
6350 let bytes = text.as_bytes();
6351 let mut i = 0;
6352 while i + 4 < bytes.len() {
6353 if bytes[i].is_ascii_digit() {
6354 let start = i;
6355 let mut dots = 0;
6356 while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
6357 if bytes[i] == b'.' {
6358 dots += 1;
6359 }
6360 i += 1;
6361 }
6362 if dots >= 2 {
6363 return Some(&text[start..i]);
6364 }
6365 }
6366 i += 1;
6367 }
6368 None
6369}
6370
6371fn bin_version(bin: &str) -> Option<String> {
6372 use std::process::{Command, Stdio};
6373 let path = which::which(bin).ok()?;
6374 let mut cmd = if bin.ends_with("-mcp") {
6377 let mut c = Command::new("timeout");
6378 c.args(["0.4", path.to_str()?, "--version"]);
6379 c
6380 } else {
6381 let mut c = Command::new(&path);
6382 c.arg("--version");
6383 c
6384 };
6385 let said = cmd
6386 .stdin(Stdio::null())
6387 .stdout(Stdio::piped())
6388 .stderr(Stdio::piped())
6389 .output()
6390 .ok()?;
6391 let stdout = String::from_utf8_lossy(&said.stdout);
6392 let stderr = String::from_utf8_lossy(&said.stderr);
6393 parse_semver(&stdout)
6394 .or_else(|| parse_semver(&stderr))
6395 .map(str::to_string)
6396}
6397
6398const CRATE_VERSION_TTL_S: u64 = 86_400;
6400
6401fn crate_version_cache(name: &str) -> Option<PathBuf> {
6405 let dir = std::env::var_os("XDG_CACHE_HOME")
6406 .filter(|r| !r.is_empty())
6407 .map(PathBuf::from)
6408 .or_else(|| home().ok().map(|h| h.join(".cache")))?
6409 .join("ljos");
6410 Some(dir.join(format!("crate-{name}")))
6411}
6412
6413#[derive(Debug, Clone, PartialEq, Eq)]
6416pub struct CrateVersion {
6417 pub version: String,
6418 pub cached: bool,
6419}
6420
6421fn crate_max_version(name: &str, refresh: bool) -> Option<CrateVersion> {
6425 use std::collections::HashMap;
6426 use std::sync::{Mutex, OnceLock};
6427 static CACHE: OnceLock<Mutex<HashMap<String, Option<CrateVersion>>>> = OnceLock::new();
6428 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
6429 if !refresh {
6430 if let Ok(guard) = cache.lock() {
6431 if let Some(hit) = guard.get(name) {
6432 return hit.clone();
6433 }
6434 }
6435 }
6436 let on_disk = crate_version_cache(name);
6437 if let Some(path) = on_disk.as_ref().filter(|_| !refresh) {
6438 let fresh = std::fs::metadata(path)
6439 .and_then(|m| m.modified())
6440 .ok()
6441 .and_then(|t| t.elapsed().ok())
6442 .is_some_and(|age| age.as_secs() < CRATE_VERSION_TTL_S);
6443 if fresh {
6444 if let Ok(text) = std::fs::read_to_string(path) {
6445 let v = text.trim();
6446 let got = (!v.is_empty()).then(|| CrateVersion {
6447 version: v.to_string(),
6448 cached: true,
6449 });
6450 if let Ok(mut guard) = cache.lock() {
6451 guard.insert(name.to_string(), got.clone());
6452 }
6453 return got;
6454 }
6455 }
6456 }
6457 let url = format!("https://crates.io/api/v1/crates/{name}");
6458 let said = std::process::Command::new("curl")
6459 .args(["-sS", "-A", "ljos-doctor", "--max-time", "3", &url])
6460 .output()
6461 .ok();
6462 let got = said.and_then(|said| {
6463 if !said.status.success() {
6464 return None;
6465 }
6466 let v: serde_json::Value = serde_json::from_slice(&said.stdout).ok()?;
6467 v["crate"]["max_version"].as_str().map(|v| CrateVersion {
6468 version: v.to_string(),
6469 cached: false,
6470 })
6471 });
6472 if let (Some(path), Some(v)) = (&on_disk, &got) {
6473 if let Some(dir) = path.parent() {
6474 let _ = std::fs::create_dir_all(dir);
6475 }
6476 let _ = std::fs::write(path, format!("{}\n", v.version));
6477 }
6478 if let Ok(mut guard) = cache.lock() {
6479 guard.insert(name.to_string(), got.clone());
6480 }
6481 got
6482}
6483
6484fn cmp_semver(a: &str, b: &str) -> Option<std::cmp::Ordering> {
6485 let parse = |s: &str| -> Option<[u64; 3]> {
6486 let mut it = s.split('.');
6487 Some([
6488 it.next()?.parse().ok()?,
6489 it.next()?.parse().ok()?,
6490 it.next()?.parse().ok()?,
6491 ])
6492 };
6493 Some(parse(a)?.cmp(&parse(b)?))
6494}
6495
6496pub fn doctor() -> Vec<Habitat> {
6499 let (mut out, runners) = std::thread::scope(|s| {
6502 let runners = s.spawn(harness_rows);
6503 let seat = doctor_seat();
6504 (seat, runners.join().unwrap_or_default())
6505 });
6506 out.extend(runners);
6507 out.extend(jev::doctor_row());
6508 out
6509}
6510
6511fn bin_health(path: &str, have: Option<&str>, latest: Option<&CrateVersion>) -> (String, bool) {
6515 use std::cmp::Ordering;
6516 let ver = have.unwrap_or("?");
6517 let Some(cr) = latest else {
6518 return (format!("{path} {ver}"), true);
6519 };
6520 let source = if cr.cached {
6521 "crates.io (cached)"
6522 } else {
6523 "crates.io"
6524 };
6525 let word = match have.and_then(|v| cmp_semver(v, &cr.version)) {
6526 Some(Ordering::Less) => "behind ",
6527 Some(Ordering::Greater) => "ahead of ",
6528 _ => "",
6529 };
6530 (
6531 format!("{path} {ver} {word}{source} {}", cr.version),
6532 true,
6533 )
6534}
6535
6536fn crate_version_for(crate_name: &str, have: Option<&str>) -> Option<CrateVersion> {
6540 let first = crate_max_version(crate_name, false)?;
6541 let ahead = first.cached
6542 && have.is_some_and(|v| cmp_semver(v, &first.version) == Some(std::cmp::Ordering::Greater));
6543 if ahead {
6544 crate_max_version(crate_name, true).or(Some(first))
6545 } else {
6546 Some(first)
6547 }
6548}
6549
6550fn check_vissue_ballot_protocol(path: &Path) -> Result<()> {
6553 use std::process::{Command, Stdio};
6554 let said = Command::new("timeout")
6555 .arg("2")
6556 .arg(path)
6557 .args(["vote", "--help"])
6558 .stdin(Stdio::null())
6559 .output()
6560 .context("could not check vissue vote --help")?;
6561 if !said.status.success() {
6562 bail!("vissue vote --help failed ({})", said.status);
6563 }
6564 let help = String::from_utf8_lossy(&said.stdout);
6565 let missing: Vec<_> = ["--used", "--confidence"]
6566 .into_iter()
6567 .filter(|flag| !help.split_whitespace().any(|word| word == *flag))
6568 .collect();
6569 if !missing.is_empty() {
6570 bail!(
6571 "incompatible ballot protocol: missing {}; install vissue-cli >= 0.16.2",
6572 missing.join(", ")
6573 );
6574 }
6575 Ok(())
6576}
6577
6578pub fn doctor_seat() -> Vec<Habitat> {
6581 let mut out = Vec::new();
6582 for (bin, crate_name) in SEAT_BINS {
6583 let found = which::which(bin).ok();
6584 let have = found.as_ref().and_then(|_| bin_version(bin));
6585 let latest = crate_version_for(crate_name, have.as_deref());
6586 let ballot_protocol = found
6587 .as_deref()
6588 .filter(|_| *bin == "vissue")
6589 .map(check_vissue_ballot_protocol);
6590 let (mut state, mut ok) = match (found, have.as_deref(), latest.as_ref()) {
6591 (None, _, Some(cr)) => (
6592 format!(
6593 "not on PATH; cargo binstall {crate_name} (crates.io {})",
6594 cr.version
6595 ),
6596 false,
6597 ),
6598 (None, _, None) => ("not on PATH".into(), false),
6599 (Some(path), have, Some(cr)) => bin_health(&path.display().to_string(), have, Some(cr)),
6600 (Some(path), have, None) => {
6601 let ver = have.unwrap_or("?");
6602 (format!("{} {ver}", path.display()), true)
6603 }
6604 };
6605 if let Some(protocol) = ballot_protocol {
6606 match protocol {
6607 Ok(()) => state.push_str("; evidence ballots supported"),
6608 Err(error) => {
6609 state.push_str(&format!("; {error:#}"));
6610 ok = false;
6611 }
6612 }
6613 }
6614 out.push(Habitat {
6615 name: bin,
6616 state,
6617 ok,
6618 });
6619 }
6620 out.push(host_row());
6623 out.push(Habitat {
6626 name: "seat",
6627 state: format_seat_row(),
6628 ok: true,
6629 });
6630 load_seat_env();
6631 out.push(
6634 match PacksetClient::from_env().and_then(|c| c.status(None)) {
6635 Ok(status) => {
6636 let available = status["embedder"]["available"].as_bool().unwrap_or(false);
6637 let answering = status["embedder"]["answering"].as_bool();
6638 Habitat {
6639 name: "encoder",
6640 state: if available {
6641 "dense ballot on".to_string()
6642 } else if answering == Some(false) {
6643 "packset-embed did not answer its last call (killed or crashed); \
6644 ranking is lexical until packsetd restarts it on the next search"
6645 .to_string()
6646 } else {
6647 "down; cargo binstall packset-embed and put it beside packsetd".to_string()
6648 },
6649 ok: available,
6650 }
6651 }
6652 Err(e) => Habitat {
6653 name: "encoder",
6654 state: format!("pack does not answer: {e}"),
6655 ok: false,
6656 },
6657 },
6658 );
6659 out.push(match pack() {
6660 Ok(client) => match client.health() {
6661 Ok(_) => Habitat {
6662 name: "pack",
6663 state: format!("{} workspace {}", client.base(), client.workspace()),
6664 ok: true,
6665 },
6666 Err(e) => Habitat {
6667 name: "pack",
6668 state: format!("{} does not answer: {e}", client.base()),
6669 ok: false,
6670 },
6671 },
6672 Err(_) => Habitat {
6673 name: "pack",
6674 state: "PACKSET_URL=off: no pack on purpose".into(),
6675 ok: false,
6676 },
6677 });
6678 if let Ok(client) = pack() {
6681 if let Ok(status) = client.status(Some(&client.workspace())) {
6682 let live = status["live"].as_u64().unwrap_or(0);
6683 let cap = status["live_cap"].as_u64().unwrap_or(0);
6684 let forgotten: Vec<String> = status["forgotten_by_reason"]
6685 .as_object()
6686 .map(|m| {
6687 m.iter()
6688 .map(|(why, n)| format!("{} by {why}", n.as_u64().unwrap_or(0)))
6689 .collect()
6690 })
6691 .unwrap_or_default();
6692 let mut state = if cap > 0 {
6693 format!("{live} live of {cap}")
6694 } else {
6695 format!("{live} live, no cap")
6696 };
6697 if !forgotten.is_empty() {
6698 state.push_str(&format!("; forgotten {}", forgotten.join(", ")));
6699 }
6700 out.push(Habitat {
6701 name: "memory",
6702 state,
6703 ok: cap == 0 || live <= cap,
6704 });
6705 }
6706 }
6707 out.push(match host_key_path() {
6708 Some(path) => {
6709 let seed = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0) == 32;
6710 let unlisted = if seed {
6713 run_captured("deedar", &["host"])
6714 .err()
6715 .map(|e| e.to_string())
6716 .filter(|e| e.contains("is not a signer"))
6717 } else {
6718 None
6719 };
6720 Habitat {
6721 name: "host key",
6722 state: match (&unlisted, seed) {
6723 (Some(why), _) => format!(
6724 "{} (32-byte seed); {}",
6725 path.display(),
6726 why.lines().next().unwrap_or("").trim()
6727 ),
6728 (None, true) => format!("{} (32-byte seed)", path.display()),
6729 (None, false) => format!("{} is not a 32-byte seed", path.display()),
6730 },
6731 ok: seed && unlisted.is_none(),
6732 }
6733 }
6734 None => Habitat {
6735 name: "host key",
6736 state: "none at ~/.config/deedar/host.key and DEEDAR_HOST_SIGNING_KEY unset; \
6737 handovers go out unsigned"
6738 .into(),
6739 ok: false,
6740 },
6741 });
6742 for (name, bin, args) in [
6743 ("deed store", "deedar", &["log", "head"][..]),
6744 ("tracker", "vissue", &["identity"][..]),
6745 ("claim graph", "claimdag", &["list"][..]),
6746 ] {
6747 out.push(match run_captured(bin, args) {
6748 Ok(said) if name == "tracker" => {
6749 let (state, ok) = tracker_state(&said.stdout, &root_source());
6750 Habitat { name, state, ok }
6751 }
6752 Ok(said) => Habitat {
6753 name,
6754 state: said.stdout.lines().next().unwrap_or("").to_string(),
6755 ok: true,
6756 },
6757 Err(e) if name == "claim graph" && claim_graph_absent(&e.to_string()).is_some() => {
6758 let dir = claim_graph_absent(&e.to_string()).unwrap_or_default();
6759 Habitat {
6760 name,
6761 state: format!("none yet; the first claim creates it at {dir}"),
6762 ok: true,
6763 }
6764 }
6765 Err(e) => Habitat {
6766 name,
6767 state: e.to_string().lines().next().unwrap_or("").to_string(),
6768 ok: false,
6769 },
6770 });
6771 }
6772 out
6773}
6774
6775pub fn claim_graph_absent(said: &str) -> Option<String> {
6779 let rest = said.split("no work graph at ").nth(1)?;
6780 let (dir, why) = rest.split_once(": ")?;
6781 why.starts_with("the directory does not exist")
6782 .then(|| dir.trim().to_string())
6783}
6784
6785fn root_source() -> String {
6787 for var in ["ISSUE_ROOT", "VISSUE_ROOT"] {
6788 if let Some(v) = std::env::var_os(var).filter(|v| !v.is_empty()) {
6789 return format!("{var}={}", v.to_string_lossy());
6790 }
6791 }
6792 "seat config or working directory".into()
6793}
6794
6795pub fn tracker_state(identity: &str, source: &str) -> (String, bool) {
6801 let version = identity.lines().next().unwrap_or("").trim();
6802 let field = |key: &str| {
6803 identity
6804 .lines()
6805 .find_map(|l| l.strip_prefix(key))
6806 .map(str::trim)
6807 .filter(|v| !v.is_empty())
6808 };
6809 let (Some(root), Some(prefix)) = (field("root="), field("prefix=")) else {
6810 return (format!("{version}; no root in vissue identity"), false);
6811 };
6812 let path = std::path::Path::new(root);
6813 let problem = if !path.is_absolute() {
6814 Some("relative root: tickets land under the working directory")
6815 } else if !path.is_dir() {
6816 Some("root is not a directory")
6817 } else if !path.join(prefix).is_dir() {
6818 Some("no prefix directory under the root")
6819 } else {
6820 None
6821 };
6822 let base = format!("{version} root={root} prefix={prefix} from {source}");
6823 match problem {
6824 Some(why) => (format!("{base}; {why}"), false),
6825 None => match tracker_git_drift(path) {
6826 Some((extra, git_ok)) => (format!("{base}; {extra}"), git_ok),
6827 None => (base, true),
6828 },
6829 }
6830}
6831
6832fn git_in(dir: &Path, args: &[&str]) -> Option<std::process::Output> {
6833 std::process::Command::new("git")
6834 .arg("-C")
6835 .arg(dir)
6836 .args(args)
6837 .stdin(std::process::Stdio::null())
6838 .output()
6839 .ok()
6840}
6841
6842fn git_ok_stdout(dir: &Path, args: &[&str]) -> Option<String> {
6843 let o = git_in(dir, args)?;
6844 o.status
6845 .success()
6846 .then(|| String::from_utf8_lossy(&o.stdout).to_string())
6847}
6848
6849pub(crate) fn tracker_upstream(root: &Path) -> Option<String> {
6853 let inside = git_ok_stdout(root, &["rev-parse", "--is-inside-work-tree"])?;
6854 if inside.trim() != "true" {
6855 return None;
6856 }
6857 if let Some(up) = git_ok_stdout(
6858 root,
6859 &[
6860 "rev-parse",
6861 "--abbrev-ref",
6862 "--symbolic-full-name",
6863 "@{upstream}",
6864 ],
6865 ) {
6866 let up = up.trim().to_string();
6867 if !up.is_empty() {
6868 return Some(up);
6869 }
6870 }
6871 git_ok_stdout(root, &["rev-parse", "--verify", "origin/HEAD"]).map(|_| "origin/HEAD".into())
6872}
6873
6874fn pid_alive(pid: u32) -> bool {
6876 unsafe { libc::kill(pid as i32, 0) == 0 }
6878}
6879
6880fn tracker_push_logs() -> (bool, Option<(std::time::SystemTime, PathBuf)>) {
6884 let Ok(entries) = std::fs::read_dir(runtime_dir()) else {
6885 return (false, None);
6886 };
6887 let mut running = false;
6888 let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
6889 for ent in entries.flatten() {
6890 let name = ent.file_name();
6891 let name = name.to_string_lossy();
6892 let Some(rest) = name
6893 .strip_prefix("tracker-push-")
6894 .and_then(|s| s.strip_suffix(".log"))
6895 else {
6896 continue;
6897 };
6898 let Ok(pid) = rest.parse::<u32>() else {
6899 continue;
6900 };
6901 if pid_alive(pid) {
6902 running = true;
6903 continue;
6904 }
6905 let mtime = ent
6906 .metadata()
6907 .and_then(|m| m.modified())
6908 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
6909 let path = ent.path();
6910 if newest.as_ref().is_none_or(|(t, _)| mtime >= *t) {
6911 newest = Some((mtime, path));
6912 }
6913 }
6914 (running, newest)
6915}
6916
6917fn last_push_refusal() -> Option<String> {
6918 let path = tracker_push_logs().1?.1;
6919 let said = std::fs::read(path).ok()?;
6920 let line = first_line(&said);
6921 (!line.is_empty()).then_some(line)
6922}
6923
6924pub fn tracker_git_drift(root: &Path) -> Option<(String, bool)> {
6929 let up = tracker_upstream(root)?;
6930 let (mut state, mut ok) = unpushed_drift(root, &up)?;
6931 if let Some(split) = tracker_remote_split(root, &up) {
6932 state = format!("{state}; {split}");
6933 ok = false;
6934 }
6935 if let Some(missing) = tracker_merge_driver_missing(root) {
6936 state = format!("{state}; {missing}");
6937 ok = false;
6938 }
6939 Some((state, ok))
6940}
6941
6942fn tracker_merge_driver_missing(root: &Path) -> Option<String> {
6947 let top = git_ok_stdout(root, &["rev-parse", "--show-toplevel"])?;
6948 let attrs = std::fs::read_to_string(Path::new(top.trim()).join(".gitattributes")).ok()?;
6949 let named = attrs
6950 .lines()
6951 .any(|l| l.split_whitespace().any(|w| w == "merge=vissue"));
6952 if !named {
6953 return None;
6954 }
6955 let driver = git_ok_stdout(root, &["config", "--get", "merge.vissue.driver"]);
6956 driver.filter(|d| !d.trim().is_empty()).is_none().then(|| {
6957 ".gitattributes merges issues.org with vissue and this clone has no merge.vissue.driver; \
6958 `vissue merge-driver --install` in the tracker registers it"
6959 .to_string()
6960 })
6961}
6962
6963fn tracker_remote_split(root: &Path, up: &str) -> Option<String> {
6968 let (_, branch) = up.split_once('/')?;
6969 let refs = git_ok_stdout(
6970 root,
6971 &[
6972 "for-each-ref",
6973 "--format=%(refname:short) %(objectname)",
6974 "refs/remotes",
6975 ],
6976 )?;
6977 let heads: Vec<(&str, &str)> = refs
6978 .lines()
6979 .filter_map(|l| l.trim().split_once(' '))
6980 .filter(|(r, _)| r.split_once('/').is_some_and(|(_, b)| b == branch))
6981 .collect();
6982 let tip = heads.iter().find(|(r, _)| *r == up)?.1;
6983 let off: Vec<&str> = heads
6984 .iter()
6985 .filter(|(_, o)| *o != tip)
6986 .map(|(r, _)| *r)
6987 .collect();
6988 (!off.is_empty()).then(|| {
6989 format!(
6990 "{} differs from {up}; pull and push every remote until they agree",
6991 off.join(", ")
6992 )
6993 })
6994}
6995
6996pub(crate) fn tracker_mirrors(root: &Path, up: &str) -> Option<Vec<(String, String)>> {
6999 let (upstream, branch) = up.split_once('/')?;
7000 let plain = |s: &str| {
7001 !s.is_empty()
7002 && s.chars()
7003 .all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
7004 };
7005 let refs = git_ok_stdout(
7006 root,
7007 &["for-each-ref", "--format=%(refname:short)", "refs/remotes"],
7008 )?;
7009 Some(
7010 refs.lines()
7011 .filter_map(|r| r.trim().split_once('/'))
7012 .filter(|(r, b)| *r != upstream && *b == branch && plain(r) && plain(b))
7013 .map(|(r, b)| (r.to_string(), b.to_string()))
7014 .collect(),
7015 )
7016}
7017
7018fn unpushed_drift(root: &Path, up: &str) -> Option<(String, bool)> {
7019 let range = format!("{up}..HEAD");
7020 let count: u64 = git_ok_stdout(root, &["rev-list", "--count", &range])?
7021 .trim()
7022 .parse()
7023 .ok()?;
7024 if count == 0 {
7025 return Some(("0 unpushed".into(), true));
7026 }
7027 let (running, _) = tracker_push_logs();
7028 let oldest = git_ok_stdout(root, &["log", "--format=%ct", "--reverse", &range])
7029 .and_then(|s| {
7030 s.lines()
7031 .find(|l| !l.trim().is_empty())
7032 .map(|l| l.trim().to_string())
7033 })
7034 .and_then(|s| s.parse::<u64>().ok());
7035 let now = std::time::SystemTime::now()
7036 .duration_since(std::time::UNIX_EPOCH)
7037 .unwrap_or_default()
7038 .as_secs();
7039 let stuck = oldest.is_some_and(|t| now.saturating_sub(t) >= push_wait().as_secs());
7040 let unpushed = if count == 1 {
7041 "1 unpushed".to_string()
7042 } else {
7043 format!("{count} unpushed")
7044 };
7045 if running {
7046 return Some((format!("{unpushed}; push still running"), true));
7047 }
7048 if let Some(why) = last_push_refusal() {
7049 return Some((format!("{unpushed}; last push refused: {why}"), false));
7050 }
7051 Some((unpushed, !stuck))
7052}
7053
7054fn host_row() -> Habitat {
7058 let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease")
7059 .map(|s| s.trim().to_string())
7060 .unwrap_or_else(|_| "unknown kernel".into());
7061 let kills = oom_kills();
7062 let (servers, rss_kb) = ljos_mcp_servers();
7063 let mcp = format!("{servers} ljos-mcp, {} MB resident", rss_kb / 1024);
7064 match kills {
7065 Some(0) => Habitat {
7066 name: "host",
7067 state: format!("{kernel}; no OOM kills since boot; {mcp}"),
7068 ok: true,
7069 },
7070 Some(n) => Habitat {
7071 name: "host",
7072 state: format!(
7073 "{kernel}; {n} OOM kills since boot (/proc/vmstat oom_kill); {mcp}; \
7074 the kernel is killing processes, read `journalctl -k -b` before the load"
7075 ),
7076 ok: false,
7077 },
7078 None => Habitat {
7079 name: "host",
7080 state: format!("{kernel}; {mcp}"),
7081 ok: true,
7082 },
7083 }
7084}
7085
7086fn oom_kills() -> Option<u64> {
7088 parse_oom_kills(&std::fs::read_to_string("/proc/vmstat").ok()?)
7089}
7090
7091fn parse_oom_kills(vmstat: &str) -> Option<u64> {
7092 vmstat
7093 .lines()
7094 .find_map(|l| l.strip_prefix("oom_kill "))
7095 .and_then(|n| n.trim().parse().ok())
7096}
7097
7098fn ljos_mcp_servers() -> (usize, u64) {
7101 let uid = std::fs::read_to_string("/proc/self/status")
7102 .ok()
7103 .and_then(|s| status_field(&s, "Uid:"));
7104 let Ok(dir) = std::fs::read_dir("/proc") else {
7105 return (0, 0);
7106 };
7107 let mut count = 0;
7108 let mut rss = 0;
7109 for entry in dir.flatten() {
7110 let path = entry.path();
7111 if std::fs::read_to_string(path.join("comm")).map_or(true, |c| c.trim() != "ljos-mcp") {
7112 continue;
7113 }
7114 let Ok(status) = std::fs::read_to_string(path.join("status")) else {
7115 continue;
7116 };
7117 if status_field(&status, "Uid:") != uid {
7118 continue;
7119 }
7120 count += 1;
7121 rss += status_field(&status, "VmRSS:")
7122 .and_then(|v| v.parse::<u64>().ok())
7123 .unwrap_or(0);
7124 }
7125 (count, rss)
7126}
7127
7128fn status_field(status: &str, key: &str) -> Option<String> {
7130 status
7131 .lines()
7132 .find_map(|l| l.strip_prefix(key))
7133 .and_then(|rest| rest.split_whitespace().next())
7134 .map(str::to_string)
7135}
7136
7137pub fn healthy(rows: &[Habitat]) -> bool {
7139 rows.iter()
7140 .all(|h| h.ok || !REQUIRED.contains(&h.name) && h.name != "pack")
7141}
7142
7143pub fn format_doctor(rows: &[Habitat]) -> String {
7144 rows.iter()
7145 .map(|h| {
7146 format!(
7147 "{} {} {}
7148",
7149 if h.ok { "ok" } else { "no" },
7150 h.name,
7151 h.state
7152 )
7153 })
7154 .collect()
7155}
7156
7157pub fn needs_of(satchel_json: &str) -> Result<Vec<String>> {
7159 let v: Value = serde_json::from_str(satchel_json).context("satchel.json")?;
7160 Ok(v.get("needs")
7161 .and_then(Value::as_array)
7162 .map(|a| {
7163 a.iter()
7164 .filter_map(Value::as_str)
7165 .map(str::to_string)
7166 .collect()
7167 })
7168 .unwrap_or_default())
7169}
7170
7171pub fn enclose(needs: Vec<String>, cited: &str) -> Vec<String> {
7173 let mut all: Vec<String> = needs
7174 .into_iter()
7175 .chain(cited.lines().map(str::trim).map(str::to_string))
7176 .filter(|s| !s.is_empty())
7177 .collect();
7178 all.sort();
7179 all.dedup();
7180 all
7181}
7182
7183pub fn handover(out: &Path, projects: &[String], issues: &[String]) -> Result<Vec<String>> {
7186 if projects.is_empty() && issues.is_empty() {
7187 bail!("handover: name a project or an issue");
7188 }
7189 let mut lines = Vec::new();
7190 let mut args = vec![
7191 "satchel".to_string(),
7192 "--out".into(),
7193 out.display().to_string(),
7194 ];
7195 for p in projects {
7196 args.push("--project".into());
7197 args.push(p.clone());
7198 }
7199 for i in issues {
7200 args.push("--issue".into());
7201 args.push(i.clone());
7202 }
7203 lines.push(run_captured("vissue", &args)?.stdout.trim_end().to_string());
7204
7205 let mut cited = String::new();
7206 match PacksetClient::from_env() {
7207 Ok(client) => {
7208 let atoms_dir = out.join("data").join("atoms");
7209 match run_captured(
7210 "packset",
7211 &[
7212 "export",
7213 "--into",
7214 &atoms_dir.display().to_string(),
7215 &client.workspace(),
7216 ],
7217 ) {
7218 Ok(said) => {
7219 cited = said.stdout;
7220 lines.push(said.stderr.trim_end().to_string());
7221 }
7222 Err(e) => lines.push(format!("atoms not enclosed: {e}")),
7223 }
7224 }
7225 Err(_) => lines.push("no pack: PACKSET_URL=off, atoms not enclosed".into()),
7226 }
7227
7228 let description = std::fs::read_to_string(out.join("data").join("satchel.json"))
7229 .context("handover: the satchel has no description")?;
7230 let deeds = enclose(needs_of(&description)?, &cited);
7231 if deeds.is_empty() {
7232 lines.push("no deeds cited".into());
7233 } else {
7234 let deeds_dir = out.join("data").join("deeds");
7235 let said = run_fed(
7236 "deedar",
7237 &["export", "--into", &deeds_dir.display().to_string(), "-"],
7238 &format!(
7239 "{}
7240",
7241 deeds.join(
7242 "
7243"
7244 )
7245 ),
7246 )?;
7247 lines.push(said.stdout.trim_end().to_string());
7248 }
7249
7250 lines.push(
7251 run_captured("vissue", &["satchel", "--seal", &out.display().to_string()])?
7252 .stdout
7253 .trim_end()
7254 .to_string(),
7255 );
7256 if host_key_path().is_some() {
7259 let manifest = out.join("manifest-sha256.txt");
7260 let said = run_captured(
7261 "deedar",
7262 &["vouch", "sign", &manifest.display().to_string()],
7263 )?;
7264 lines.push(said.stdout.trim_end().to_string());
7265 } else {
7266 lines.push(
7267 "unsigned: no host key at ~/.config/deedar/host.key and DEEDAR_HOST_SIGNING_KEY unset; \
7268 `ljos onboard` writes one"
7269 .into(),
7270 );
7271 }
7272 Ok(lines)
7273}
7274
7275pub fn receive(dir: &Path, since: Option<&Path>, import: bool) -> Result<Vec<String>> {
7278 let mut lines = Vec::new();
7279 lines.push(
7280 run_captured(
7281 "vissue",
7282 &["satchel", "--verify", &dir.display().to_string()],
7283 )?
7284 .stdout
7285 .trim_end()
7286 .to_string(),
7287 );
7288 if dir.join("data").join("deeds").is_dir() {
7289 let mut args = vec!["check".to_string(), dir.display().to_string()];
7290 if let Some(bridge) = since {
7291 args.push("--since".into());
7292 args.push(bridge.display().to_string());
7293 }
7294 lines.push(run_captured("deedar", &args)?.stdout.trim_end().to_string());
7295 } else {
7296 lines.push("no deeds enclosed".into());
7297 }
7298 let manifest = dir.join("manifest-sha256.txt");
7299 let mut sender = "from:handover".to_string();
7303 if manifest.with_extension("txt.sig").is_file() {
7304 let said = run_captured(
7305 "deedar",
7306 &["vouch", "check", &manifest.display().to_string()],
7307 )?
7308 .stdout
7309 .trim_end()
7310 .to_string();
7311 if !said.starts_with("signed by ") {
7312 bail!("receive: satchel is not signed by an accepted key: {said}");
7313 }
7314 if let Some(hex) = said
7315 .strip_prefix("signed by ")
7316 .and_then(|rest| rest.split(|c: char| !c.is_ascii_hexdigit()).next())
7317 .filter(|h| h.len() >= 12)
7318 {
7319 sender = format!("from:{}", &hex[..12]);
7320 }
7321 lines.push(said);
7322 } else if import {
7323 bail!("receive: unsigned satchel; will not import");
7324 } else {
7325 lines.push("unsigned".into());
7326 }
7327
7328 let atoms = enclosed_atoms(dir)?;
7329 let rows = trust_rows(&atoms);
7330 lines.push(format!(
7331 "{} atoms enclosed, {} trust rows",
7332 atoms.len(),
7333 rows.len()
7334 ));
7335 if import {
7336 let client = pack()?;
7337 let workspace = client.workspace();
7338 let (mut kept, mut refused) = (0usize, Vec::new());
7339 for atom in &atoms {
7340 let mut atom = atom.clone();
7343 if let Some(map) = atom.as_object_mut() {
7344 map.insert("workspace".into(), Value::String(workspace.clone()));
7345 let mut entities: Vec<Value> = map
7346 .get("entities")
7347 .and_then(Value::as_array)
7348 .cloned()
7349 .unwrap_or_default();
7350 if !entities.iter().any(|e| e.as_str() == Some(sender.as_str())) {
7351 entities.push(Value::String(sender.clone()));
7352 }
7353 map.insert("entities".into(), Value::Array(entities));
7354 }
7355 match client.post_atom(&atom) {
7356 Ok(_) => kept += 1,
7357 Err(e) => refused.push(e.to_string()),
7358 }
7359 }
7360 lines.push(format!("{kept} atoms imported, {} refused", refused.len()));
7361 lines.extend(refused.into_iter().take(5));
7362 if kept > 0 {
7363 lines.push(
7364 "imported claims may rewrite held ones; `ljos consolidate` reports the pairs, `--apply` closes them"
7365 .to_string(),
7366 );
7367 }
7368 }
7369 Ok(lines)
7370}
7371
7372pub fn enclosed_atoms(dir: &Path) -> Result<Vec<Value>> {
7374 let atoms_dir = dir.join("data").join("atoms");
7375 let Ok(entries) = std::fs::read_dir(&atoms_dir) else {
7376 return Ok(Vec::new());
7377 };
7378 let mut out = Vec::new();
7379 for entry in entries.flatten() {
7380 let text = std::fs::read_to_string(entry.path())?;
7381 for line in text.lines().filter(|l| !l.trim().is_empty()) {
7382 out.push(
7383 serde_json::from_str(line).with_context(|| entry.path().display().to_string())?,
7384 );
7385 }
7386 }
7387 Ok(out)
7388}
7389
7390const UNREVIEWED_KINDS: &[&str] = &["trust", "persona", "playbook", "prediction"];
7395
7396fn reviewable(a: &Value) -> bool {
7398 !UNREVIEWED_KINDS.contains(&a.get("kind").and_then(Value::as_str).unwrap_or(""))
7399}
7400
7401pub fn due_of(atoms: &[Value], now: &str) -> Vec<Value> {
7406 let mut due: Vec<Value> = atoms
7407 .iter()
7408 .filter(|a| reviewable(a))
7409 .filter(|a| {
7410 a.get("due_at")
7411 .and_then(Value::as_str)
7412 .is_none_or(|d| d.is_empty() || d <= now)
7413 })
7414 .cloned()
7415 .collect();
7416 due.sort_by(|a, b| {
7417 a["due_at"]
7418 .as_str()
7419 .unwrap_or("")
7420 .cmp(b["due_at"].as_str().unwrap_or(""))
7421 });
7422 due
7423}
7424
7425pub fn review_summary(atoms: &[Value], now: &str) -> String {
7430 let due = due_of(atoms, now).len();
7431 let mut later: Vec<&str> = atoms
7432 .iter()
7433 .filter(|a| reviewable(a))
7434 .filter_map(|a| a.get("due_at").and_then(Value::as_str))
7435 .filter(|d| !d.is_empty() && *d > now)
7436 .collect();
7437 later.sort_unstable();
7438 match later.first() {
7439 Some(next) => format!("{due} due; {} scheduled, next at {next}", later.len()),
7440 None if due == 0 => "0 due; nothing scheduled: this seat has remembered nothing yet".into(),
7441 None => format!("{due} due; nothing else scheduled"),
7442 }
7443}
7444
7445#[must_use]
7449pub fn due_on_island_first(due: Vec<Value>, island: &Value) -> Vec<Value> {
7450 if island["weak"].as_bool().unwrap_or(false) {
7452 return due;
7453 }
7454 let on: std::collections::BTreeSet<&str> = island["island"]
7455 .as_array()
7456 .into_iter()
7457 .flatten()
7458 .filter_map(|a| a["id"].as_str())
7459 .collect();
7460 let (mut first, rest): (Vec<Value>, Vec<Value>) = due
7461 .into_iter()
7462 .partition(|a| a["id"].as_str().is_some_and(|id| on.contains(id)));
7463 first.extend(rest);
7464 first
7465}
7466
7467pub const SITTING_DUE: usize = 8;
7469
7470pub const SITTING_TIMELINE: usize = 12;
7472
7473pub fn sitting_due_report(island: &Value) -> Result<String> {
7475 let client = pack()?;
7476 let swept = client.sweep(&client.workspace()).ok();
7479 let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7480 let now = now_utc();
7481 let due = due_on_island_first(due_of(&atoms, &now), island);
7482 let shown = due.len().min(SITTING_DUE);
7483 Ok(format!(
7484 "{}{}{}\n",
7485 format_due(&due[..shown]),
7486 review_summary(&atoms, &now),
7487 format_sweep(swept.as_ref())
7488 ))
7489}
7490
7491pub fn due_report() -> Result<String> {
7493 let client = pack()?;
7494 let swept = client.sweep(&client.workspace()).ok();
7497 let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7498 let now = now_utc();
7499 Ok(format!(
7500 "{}{}{}\n",
7501 format_due(&due_of(&atoms, &now)),
7502 review_summary(&atoms, &now),
7503 format_sweep(swept.as_ref())
7504 ))
7505}
7506
7507pub fn format_sweep(report: Option<&Value>) -> String {
7509 let Some(report) = report else {
7510 return String::new();
7511 };
7512 let lapsed = report.get("lapsed").and_then(Value::as_u64).unwrap_or(0);
7513 let forgotten = report.get("forgotten").and_then(Value::as_u64).unwrap_or(0);
7514 if lapsed == 0 && forgotten == 0 {
7515 return String::new();
7516 }
7517 format!(
7518 "\nswept: {lapsed} review{} lapsed past twice {} interval, {forgotten} never-recalled claim{} forgotten by neglect",
7519 if lapsed == 1 { "" } else { "s" },
7520 if lapsed == 1 { "its" } else { "their" },
7521 if forgotten == 1 { "" } else { "s" }
7522 )
7523}
7524
7525pub fn due() -> Result<Vec<Value>> {
7527 let client = pack()?;
7528 let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7529 Ok(due_of(&atoms, &now_utc()))
7530}
7531
7532pub fn due_page() -> Result<(Vec<Value>, usize, String)> {
7535 let client = pack()?;
7536 let atoms = atoms_lean(&client, &client.workspace()).context("due: GET /v1/atoms failed")?;
7537 let now = now_utc();
7538 let all = due_of(&atoms, &now);
7539 let total = all.len();
7540 let shown: Vec<Value> = all.into_iter().take(SITTING_DUE).collect();
7541 Ok((shown, total, review_summary(&atoms, &now)))
7542}
7543
7544pub const HABIT_ENTITY: &str = "habit:";
7548pub const HABIT_EVERY_S: i64 = 7 * 86_400;
7550
7551#[derive(Debug, Clone, PartialEq, serde::Serialize)]
7558pub struct Reading {
7559 pub name: String,
7560 pub value: f64,
7561 pub unit: String,
7562 pub source: String,
7563 pub every_s: i64,
7565 pub was: Option<f64>,
7567 pub was_ts: Option<String>,
7568 pub id: Option<String>,
7569 pub ts: Option<String>,
7570 pub due_at: Option<String>,
7571}
7572
7573pub fn parse_every(text: &str) -> Result<i64> {
7575 let t = text.trim();
7576 let split = t.trim_end_matches(|c: char| c.is_ascii_alphabetic()).len();
7577 let (num, unit) = t.split_at(split);
7578 let n: i64 = num
7579 .trim()
7580 .parse()
7581 .with_context(|| format!("habit: --every {t:?} is not a span; write 7d, 24h, 2w or 30m"))?;
7582 let each = match unit {
7583 "" | "s" => 1,
7584 "m" => 60,
7585 "h" => 3_600,
7586 "d" => 86_400,
7587 "w" => 7 * 86_400,
7588 other => bail!("habit: unknown unit {other:?} in --every; write d, h, w, m or s"),
7589 };
7590 if n <= 0 {
7591 bail!("habit: --every must be positive");
7592 }
7593 Ok(n * each)
7594}
7595
7596fn stamp_after(now: &str, secs: i64) -> Option<String> {
7599 let days = days_of_stamp(Some(now))?;
7600 let clock = now.get(11..19)?;
7601 let mut it = clock.split(':');
7602 let h: i64 = it.next()?.parse().ok()?;
7603 let m: i64 = it.next()?.parse().ok()?;
7604 let s: i64 = it.next()?.parse().ok()?;
7605 let total = days * 86_400 + h * 3_600 + m * 60 + s + secs;
7606 let day = total.div_euclid(86_400);
7607 let rem = total.rem_euclid(86_400);
7608 Some(format!(
7609 "{}T{:02}:{:02}:{:02}.000Z",
7610 civil_of_days(day),
7611 rem / 3_600,
7612 rem % 3_600 / 60,
7613 rem % 60
7614 ))
7615}
7616
7617#[must_use]
7619pub fn trim_num(v: f64) -> String {
7620 let s = format!("{v:.4}");
7621 let s = s.trim_end_matches('0').trim_end_matches('.');
7622 if s.is_empty() || s == "-" {
7623 "0".to_string()
7624 } else {
7625 s.to_string()
7626 }
7627}
7628
7629#[must_use]
7632pub fn habit_text(name: &str, value: f64, unit: &str, source: &str) -> String {
7633 let unit = unit.trim();
7634 let source = source.trim();
7635 let mut text = format!("habit {} stands at {}", name.trim(), trim_num(value));
7636 if !unit.is_empty() {
7637 text.push(' ');
7638 text.push_str(unit);
7639 }
7640 if !source.is_empty() {
7641 text.push_str(&format!(" ({source})"));
7642 }
7643 text.push('.');
7644 text
7645}
7646
7647fn reading_of(atom: &Value) -> Option<Reading> {
7648 if atom.get("kind").and_then(Value::as_str) != Some("habit") {
7649 return None;
7650 }
7651 let h = atom.get("habit")?;
7652 Some(Reading {
7653 name: h.get("name")?.as_str()?.to_string(),
7654 value: h.get("value")?.as_f64()?,
7655 unit: h
7656 .get("unit")
7657 .and_then(Value::as_str)
7658 .unwrap_or("")
7659 .to_string(),
7660 source: h
7661 .get("source")
7662 .and_then(Value::as_str)
7663 .unwrap_or("")
7664 .to_string(),
7665 every_s: h
7666 .get("every_s")
7667 .and_then(Value::as_i64)
7668 .unwrap_or(HABIT_EVERY_S),
7669 was: h.get("was").and_then(Value::as_f64),
7670 was_ts: h.get("was_ts").and_then(Value::as_str).map(str::to_string),
7671 id: atom.get("id").and_then(Value::as_str).map(str::to_string),
7672 ts: atom.get("ts").and_then(Value::as_str).map(str::to_string),
7673 due_at: atom
7674 .get("due_at")
7675 .and_then(Value::as_str)
7676 .map(str::to_string),
7677 })
7678}
7679
7680#[must_use]
7682pub fn readings_of(atoms: &[Value]) -> Vec<Reading> {
7683 let mut rows: Vec<Reading> = atoms.iter().filter_map(reading_of).collect();
7684 rows.sort_by(|a, b| a.name.cmp(&b.name).then(b.ts.cmp(&a.ts)));
7685 rows.dedup_by(|a, b| a.name == b.name);
7686 rows
7687}
7688
7689pub fn habits() -> Result<Vec<Reading>> {
7691 let client = pack()?;
7692 let atoms = atoms_lean(&client, &client.workspace()).context("habit: GET /v1/atoms failed")?;
7693 Ok(readings_of(&atoms))
7694}
7695
7696pub fn habit(
7700 name: &str,
7701 value: f64,
7702 unit: &str,
7703 every_s: i64,
7704 source: &str,
7705) -> Result<(Value, Option<Reading>)> {
7706 let name = name.trim();
7707 if name.is_empty() {
7708 bail!("habit: a reading needs a name");
7709 }
7710 if !value.is_finite() {
7711 bail!("habit: {value} is not a reading");
7712 }
7713 let client = pack()?;
7714 let workspace = client.workspace();
7715 let atoms = atoms_lean(&client, &workspace).context("habit: GET /v1/atoms failed")?;
7716 let prev = readings_of(&atoms).into_iter().find(|r| r.name == name);
7717 let now = now_utc();
7718 let mut atom = atom_body("habit", &habit_text(name, value, unit, source), &workspace);
7719 add_entities(&mut atom, [format!("{HABIT_ENTITY}{name}")]);
7720 if let Some(due) = stamp_after(&now, every_s) {
7721 atom["due_at"] = Value::String(due);
7722 }
7723 atom["habit"] = serde_json::json!({
7724 "name": name,
7725 "value": value,
7726 "unit": unit.trim(),
7727 "source": source.trim(),
7728 "every_s": every_s,
7729 "was": prev.as_ref().map(|p| p.value),
7730 "was_ts": prev.as_ref().and_then(|p| p.ts.clone()),
7731 });
7732 if let Some(id) = prev.as_ref().and_then(|p| p.id.clone()) {
7733 atom["supersedes"] = Value::Array(vec![Value::String(id)]);
7734 }
7735 let body = client
7736 .post_atom(&atom)
7737 .context("habit: POST /v1/atoms failed")?;
7738 Ok((body, prev))
7739}
7740
7741#[must_use]
7744pub fn format_change(r: &Reading, now: &str) -> String {
7745 match r.was {
7746 Some(was) => {
7747 let d = r.value - was;
7748 let sign = if d >= 0.0 { "+" } else { "" };
7749 format!(
7750 "{sign}{} since {} ({})",
7751 trim_num(d),
7752 trim_num(was),
7753 age_of(r.was_ts.as_deref(), now)
7754 )
7755 }
7756 None => "first reading".to_string(),
7757 }
7758}
7759
7760#[must_use]
7763pub fn format_readings(rows: &[Reading], now: &str) -> String {
7764 rows.iter()
7765 .map(|r| {
7766 let due = match r.due_at.as_deref() {
7767 Some(d) if d <= now => format!("next reading late ({})", age_of(Some(d), now)),
7768 Some(d) => format!("next reading {}", age_of(Some(d), now)),
7769 None => "no cadence".to_string(),
7770 };
7771 format!(
7772 "{}\t{}{}{}\t{}\t{}\t{}\t{}\n",
7773 r.name,
7774 trim_num(r.value),
7775 if r.unit.is_empty() { "" } else { " " },
7776 r.unit,
7777 format_change(r, now),
7778 age_of(r.ts.as_deref(), now),
7779 due,
7780 r.source
7781 )
7782 })
7783 .collect()
7784}
7785
7786pub fn format_due(atoms: &[Value]) -> String {
7787 atoms
7788 .iter()
7789 .map(|a| {
7790 format!(
7791 "{} {} {} {}
7792",
7793 a["due_at"]
7794 .as_str()
7795 .filter(|d| !d.is_empty())
7796 .unwrap_or("unreviewed"),
7797 a["kind"].as_str().unwrap_or(""),
7798 a["id"].as_str().unwrap_or("-"),
7799 a["text"].as_str().unwrap_or("")
7800 )
7801 })
7802 .collect()
7803}
7804
7805pub fn graded(id: &str, recalled: bool) -> Result<Value> {
7807 let id = id.trim();
7808 if id.is_empty() {
7809 bail!("graded: an atom id is required");
7810 }
7811 let client = pack()?;
7812 client
7813 .grade(&client.workspace(), id, recalled)
7814 .map_err(|e| {
7815 let said = e.to_string();
7816 if said.contains("no current atom") {
7817 anyhow::anyhow!(
7819 "graded: {id} is no longer current: it was superseded, withdrawn or \
7820 forgotten after the due list was read; nothing to grade, and \
7821 `ljos due` shows what is due now"
7822 )
7823 } else {
7824 anyhow::Error::from(e).context(format!("graded: POST /v1/grade failed for {id}"))
7825 }
7826 })
7827}
7828
7829#[must_use]
7831pub fn now_utc() -> String {
7832 let secs = std::time::SystemTime::now()
7833 .duration_since(std::time::UNIX_EPOCH)
7834 .map(|d| d.as_secs())
7835 .unwrap_or(0);
7836 let days = secs / 86_400;
7837 let rem = secs % 86_400;
7838 let z = days as i64 + 719_468;
7840 let era = z.div_euclid(146_097);
7841 let doe = z.rem_euclid(146_097);
7842 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
7843 let y = yoe + era * 400;
7844 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
7845 let mp = (5 * doy + 2) / 153;
7846 let d = doy - (153 * mp + 2) / 5 + 1;
7847 let m = if mp < 10 { mp + 3 } else { mp - 9 };
7848 let y = if m <= 2 { y + 1 } else { y };
7849 format!(
7850 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
7851 rem / 3600,
7852 rem % 3600 / 60,
7853 rem % 60
7854 )
7855}
7856
7857pub fn run_fed(bin: &str, args: &[impl AsRef<str>], input: &str) -> Result<Said> {
7859 use std::io::Write;
7860 use std::process::{Command, Stdio};
7861 let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
7862 let mut cmd = Command::new(path);
7863 for a in args {
7864 cmd.arg(a.as_ref());
7865 }
7866 let mut child = cmd
7867 .stdin(Stdio::piped())
7868 .stdout(Stdio::piped())
7869 .stderr(Stdio::piped())
7870 .spawn()
7871 .with_context(|| format!("{bin}: could not start"))?;
7872 if let Some(mut stdin) = child.stdin.take() {
7873 stdin.write_all(input.as_bytes())?;
7874 }
7875 let out = child.wait_with_output()?;
7876 let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
7877 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
7878 if !out.status.success() {
7879 let why = if stderr.trim().is_empty() {
7880 stdout.trim().to_string()
7881 } else {
7882 stderr.trim().to_string()
7883 };
7884 bail!("{bin} exited {}: {why}", out.status);
7885 }
7886 Ok(Said { stdout, stderr })
7887}
7888
7889pub fn work_id(name: &str) -> String {
7892 let name = name.trim();
7893 if name.len() == 32 && name.bytes().all(|b| b.is_ascii_hexdigit()) {
7894 return name.to_ascii_lowercase();
7895 }
7896 const OFFSET: u128 = 0x6c62_272e_07bb_0142_62b8_2175_6295_c58d;
7897 const PRIME: u128 = 0x0000_0000_0100_0000_0000_0000_0000_013b;
7898 let mut h = OFFSET;
7899 for b in name.bytes() {
7900 h ^= u128::from(b);
7901 h = h.wrapping_mul(PRIME);
7902 }
7903 format!("{h:032x}")
7904}
7905
7906pub fn node_for(issue: &str) -> Result<String> {
7909 let id = work_id(issue);
7910 if id != issue.trim() && run_captured("claimdag", &["get", &id]).is_err() {
7911 run_captured(
7912 "claimdag",
7913 &["upsert", "--id", &id, "--summary", issue.trim()],
7914 )
7915 .with_context(|| format!("claim: could not mint a node for {issue}"))?;
7916 }
7917 Ok(id)
7918}
7919
7920pub fn packset_island(cue: &str, fire: bool) -> Result<Value> {
7923 packset_island_as(cue, fire, None)
7924}
7925
7926pub fn packset_island_as(cue: &str, fire: bool, lens: Option<&str>) -> Result<Value> {
7930 let cue = cue.trim();
7931 if cue.is_empty() {
7932 bail!("island: pass the task or question at hand");
7933 }
7934 let client = pack()?;
7935 let workspace = client.workspace();
7936 let lens = lens
7937 .map(str::trim)
7938 .filter(|l| !l.is_empty())
7939 .map(str::to_lowercase);
7940 let mut body = client
7941 .activate_as(&workspace, cue, 24, fire, lens.as_deref())
7942 .context("island: GET /v1/activate failed")?;
7943 if body["fired"].as_u64().unwrap_or(0) > 0 {
7944 match record_fire(cue, lens.as_deref(), &body) {
7945 Ok(id) => body["trace"] = Value::String(id),
7946 Err(err) => body["trace_error"] = Value::String(err.to_string()),
7947 }
7948 }
7949 Ok(body)
7950}
7951
7952fn record_fire(cue: &str, lens: Option<&str>, body: &Value) -> Result<String> {
7955 let fired = body["fired"].as_u64().unwrap_or(0);
7956 let who = lens.unwrap_or("seat");
7957 let ids: Vec<String> = body["island"]
7958 .as_array()
7959 .into_iter()
7960 .flatten()
7961 .filter_map(|row| row.get("id").and_then(Value::as_str).map(str::to_string))
7962 .take(8)
7963 .collect();
7964 let mut nonce = 0xcbf29ce484222325u64;
7965 for part in [cue, who].into_iter().chain(ids.iter().map(String::as_str)) {
7966 for byte in part.as_bytes() {
7967 nonce ^= u64::from(*byte);
7968 nonce = nonce.wrapping_mul(0x100000001b3);
7969 }
7970 }
7971 let text = format!(
7972 "Fire {:08x} under {who} strengthened {fired} links.",
7973 nonce as u32
7974 );
7975 let client = pack()?;
7976 let workspace = client.workspace();
7977 let mut atom = atom_body("trace", &text, &workspace);
7978 add_entities(&mut atom, ids);
7979 let posted = client
7980 .post_atom(&atom)
7981 .context("trace: POST /v1/atoms failed")?;
7982 Ok(posted
7983 .get("id")
7984 .and_then(Value::as_str)
7985 .unwrap_or("")
7986 .to_string())
7987}
7988
7989pub fn packset_hubs(limit: usize) -> Result<Value> {
7992 let client = pack()?;
7993 let workspace = client.workspace();
7994 client
7995 .hubs(&workspace, limit)
7996 .context("hubs: GET /v1/hubs failed")
7997}
7998
7999pub fn conflicts(limit: usize) -> Result<String> {
8014 if which::which("landscape").is_err() {
8015 bail!(
8016 "conflicts: `landscape` is not on PATH; it is the optional habitat that reads the pack's geometry (leidarljos/landscape)"
8017 );
8018 }
8019 let client = pack()?;
8020 let said = match run_captured(
8021 "landscape",
8022 &[
8023 "--atoms",
8024 client.base(),
8025 "--workspace",
8026 &client.workspace(),
8027 "--conflicts",
8028 ],
8029 ) {
8030 Ok(said) => said,
8031 Err(e) if e.to_string().contains("at least two") => {
8034 return Ok(
8035 "fewer than two memories with embeddings in the pack; conflicts by geometry need the encoder (`packset doctor` shows it)\n"
8036 .to_string(),
8037 );
8038 }
8039 Err(e) => return Err(e),
8040 };
8041 let v: Value =
8042 serde_json::from_str(&said.stdout).context("conflicts: landscape printed no JSON")?;
8043 let now = now_utc();
8044 let atoms = atoms_lean(&client, &client.workspace()).unwrap_or_default();
8045 let stamp_of = |id: &str| -> Option<String> {
8046 atoms
8047 .iter()
8048 .find(|a| a["id"].as_str() == Some(id))
8049 .and_then(|a| a["ts"].as_str().map(str::to_string))
8050 };
8051 let recalled = |id: &str| -> bool {
8054 atoms
8055 .iter()
8056 .find(|a| a["id"].as_str() == Some(id))
8057 .is_none_or(reviewable)
8058 };
8059 let mut out = String::new();
8060 for pair in v["pairs"]
8061 .as_array()
8062 .into_iter()
8063 .flatten()
8064 .filter(|p| {
8065 recalled(p["a"].as_str().unwrap_or("")) && recalled(p["b"].as_str().unwrap_or(""))
8066 })
8067 .take(limit)
8068 {
8069 let a = pair["a"].as_str().unwrap_or("-");
8070 let b = pair["b"].as_str().unwrap_or("-");
8071 out.push_str(&format!(
8072 "pass {:.3}\n {a} {} {}\n {b} {} {}\n",
8073 pair["barrier"].as_f64().unwrap_or(0.0),
8074 age_of(stamp_of(a).as_deref(), &now),
8075 pair["a_text"].as_str().unwrap_or("").trim(),
8076 age_of(stamp_of(b).as_deref(), &now),
8077 pair["b_text"].as_str().unwrap_or("").trim()
8078 ));
8079 }
8080 let n = v["pairs"].as_array().map_or(0, Vec::len);
8081 out.push_str(&format!(
8082 "{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",
8083 v["sigma"].as_f64().unwrap_or(0.0)
8084 ));
8085 Ok(out)
8086}
8087
8088pub fn packset_consolidate(apply: bool) -> Result<Value> {
8091 let client = pack()?;
8092 let workspace = client.workspace();
8093 client
8094 .consolidate(&workspace, apply)
8095 .context("consolidate: POST /v1/consolidate failed")
8096}
8097
8098pub fn format_consolidation(body: &Value) -> String {
8101 let mut out = String::new();
8102 for pair in body["pairs"].as_array().into_iter().flatten() {
8103 out.push_str(&format!(
8104 "closes {} {}\n for {} {}\n",
8105 pair["old"].as_str().unwrap_or("-"),
8106 pair["old_text"].as_str().unwrap_or("").trim(),
8107 pair["new"].as_str().unwrap_or("-"),
8108 pair["new_text"].as_str().unwrap_or("").trim()
8109 ));
8110 }
8111 let closed = body["closed"].as_u64().unwrap_or(0);
8112 let live = body["live"].as_u64().unwrap_or(0);
8113 if body["applied"].as_bool().unwrap_or(false) {
8114 out.push_str(&format!("{closed} of {live} live memories closed\n"));
8115 } else {
8116 out.push_str(&format!(
8117 "{closed} of {live} live memories would close; `ljos consolidate --apply` closes them\n"
8118 ));
8119 }
8120 out
8121}
8122
8123pub fn format_hubs(body: &Value) -> String {
8125 let mut out = String::new();
8126 for hub in body["hubs"]
8127 .as_array()
8128 .into_iter()
8129 .flatten()
8130 .filter(|a| reviewable(a))
8131 {
8132 out.push_str(&format!(
8133 "{:.4}\t{}\t{}\t{}\n",
8134 hub["score"].as_f64().unwrap_or(0.0),
8135 hub["links"].as_u64().unwrap_or(0),
8136 hub["id"].as_str().unwrap_or("-"),
8137 hub["text"].as_str().unwrap_or("")
8138 ));
8139 }
8140 out
8141}
8142
8143#[must_use]
8150pub fn island_reading(body: &Value) -> String {
8151 let lens = body["as"].as_str().unwrap_or("").trim();
8152 let fired = body["fired"].as_u64().unwrap_or(0);
8153 let held = body["held"].as_bool().unwrap_or(false);
8154 let weak = body["weak"].as_bool().unwrap_or(false);
8155 let rows = body["island"].as_array().is_some_and(|a| !a.is_empty());
8156 if !rows && !weak && fired == 0 && !held && lens.is_empty() {
8157 return String::new();
8158 }
8159 let mut out = String::new();
8160 if lens.is_empty() {
8161 out.push_str(
8162 "Seat island. Activation is spread from search seeds along links. It is not a relevance rank.\n",
8163 );
8164 } else {
8165 out.push_str(&format!(
8166 "Persona {lens} island. The spread follows the weights that persona fired, not the seat's. It is not a relevance rank.\n"
8167 ));
8168 }
8169 if weak {
8170 out.push_str(
8171 "Not fired: fewer than two seeds that two scorers agreed on, so firing would wire the wrong links.\n",
8172 );
8173 } else if held {
8174 out.push_str(
8175 "Not fired: this cue already fired inside the hour, so the weights were left as they were.\n",
8176 );
8177 } else if fired > 0 {
8178 let who = if lens.is_empty() { "the seat" } else { lens };
8179 out.push_str(&format!(
8180 "Fired: {fired} links gained weight under {who}. The next walk of this cue follows those links. Fire only after the island was used.\n"
8181 ));
8182 if let Some(id) = body["trace"].as_str().filter(|s| !s.is_empty()) {
8183 out.push_str(&format!(
8184 "Recorded as trace {id}: the links this fire strengthened.\n"
8185 ));
8186 } else if let Some(err) = body["trace_error"].as_str() {
8187 out.push_str(&format!("The fire was not recorded: {err}\n"));
8188 }
8189 } else {
8190 out.push_str(
8191 "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",
8192 );
8193 }
8194 out
8195}
8196
8197pub fn format_island(body: &Value) -> String {
8199 let mut out = island_reading(body);
8200 let now = now_utc();
8201 if body["weak"].as_bool().unwrap_or(false) {
8202 out.push_str(&format!(
8203 "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",
8204 body["agreed_seeds"].as_u64().unwrap_or(0),
8205 if body["agreed_seeds"].as_u64().unwrap_or(0) == 1 { "" } else { "s" },
8206 if body["dense"].as_bool().unwrap_or(true) { "" } else { "; the encoder is down, ranking is lexical only" }
8207 ));
8208 }
8209 for atom in body["island"]
8210 .as_array()
8211 .into_iter()
8212 .flatten()
8213 .filter(|a| reviewable(a))
8214 {
8215 out.push_str(&format!(
8216 "{:.3}\t{}\t{}\t{}\t{}\n",
8217 atom["activation"].as_f64().unwrap_or(0.0),
8218 if atom["seed"].as_bool().unwrap_or(false) {
8219 "seed"
8220 } else {
8221 " "
8222 },
8223 atom["id"].as_str().unwrap_or("-"),
8224 age_of(atom["ts"].as_str(), &now),
8225 atom["text"].as_str().unwrap_or("")
8226 ));
8227 }
8228 out
8229}
8230
8231pub fn packset_search(query: &str) -> Result<Vec<Hit>> {
8232 packset_search_opts(query, 10, false)
8233}
8234
8235pub fn packset_search_opts(query: &str, limit: u32, rerank: bool) -> Result<Vec<Hit>> {
8240 packset_search_as_of(query, limit, None, rerank)
8241}
8242
8243pub fn packset_search_as_of(
8249 query: &str,
8250 limit: u32,
8251 as_of: Option<&str>,
8252 rerank: bool,
8253) -> Result<Vec<Hit>> {
8254 let q = query.trim();
8255 if q.is_empty() {
8256 bail!("search: empty query");
8257 }
8258 let as_of = as_of.map(str::trim).filter(|s| !s.is_empty());
8259 let stamp = match as_of {
8260 Some(at) if days_of_stamp(Some(at)).is_none() => {
8261 bail!("search: --as-of {at:?} is not a date; write YYYY-MM-DD or RFC 3339")
8262 }
8263 Some(at) if at.len() == 10 => Some(format!("{at}T00:00:00.000Z")),
8265 Some(at) => Some(at.to_string()),
8266 None => None,
8267 };
8268 with_writer(|| {
8269 let client = pack()?;
8270 let workspace = client.workspace();
8271 client
8272 .search_opts(&workspace, q, limit, stamp.as_deref(), rerank)
8273 .context("search: GET /v1/search failed")
8274 })
8275}
8276
8277fn gen_of(get_output: &str) -> Option<u64> {
8280 get_output
8281 .split_whitespace()
8282 .find_map(|w| w.strip_prefix("gen="))
8283 .and_then(|g| g.parse().ok())
8284}
8285
8286fn live_gen(id: &str, gen: Option<u64>) -> Result<u64> {
8290 if let Some(g) = gen {
8291 return Ok(g);
8292 }
8293 let got = run_captured("claimdag", &["get", id])?.stdout;
8294 gen_of(&got).ok_or_else(|| {
8295 anyhow::anyhow!("complete: no generation on the claim graph's line for {id}: {got}")
8296 })
8297}
8298
8299#[must_use]
8302pub fn held_by_another_message(node: &str, assignee: &str, hold: &Hold, running: &str) -> String {
8303 format!(
8304 "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",
8305 hold.assignee,
8306 hold.seat,
8307 hold.since,
8308 hold.assignee
8309 )
8310}
8311
8312fn holder_of(get_output: &str) -> Option<String> {
8313 get_output
8314 .split_whitespace()
8315 .find_map(|w| w.strip_prefix("assignee="))
8316 .filter(|h| h.len() == 32 && *h != "00000000000000000000000000000000")
8317 .map(str::to_string)
8318}
8319
8320fn tracker_claim_needs_force(text: &str) -> bool {
8332 text.contains("pass --force") || text.contains("claimed by")
8333}
8334
8335fn stamp_tracker_claim(node: &str, assignee: &str, force: bool) -> Result<Said> {
8336 if force {
8337 run_captured_as("vissue", &["claim", node, "--force"], Some(assignee))
8338 } else {
8339 run_captured_as("vissue", &["claim", node], Some(assignee))
8340 }
8341}
8342
8343fn stamp_tracker(node: &str, assignee: &str) -> Result<Option<String>> {
8344 if run_captured("vissue", &["show", node, "--json"]).is_err() {
8345 return Ok(None);
8346 }
8347 let claimed = match stamp_tracker_claim(node, assignee, false) {
8348 Ok(said) => Ok(said),
8349 Err(e) => {
8350 let text = e.to_string();
8351 let after_reopen = if text.contains("already DONE")
8355 || text.contains("already CANCELLED")
8356 {
8357 run_captured("vissue", &["update", node, "-s", "STARTED"]).with_context(|| {
8358 format!(
8359 "claim: the claim graph took {node} but the tracker would not reopen {node} to STARTED under {assignee}"
8360 )
8361 })?;
8362 stamp_tracker_claim(node, assignee, false)
8363 } else {
8364 Err(e)
8365 };
8366 match after_reopen {
8367 Ok(said) => Ok(said),
8368 Err(e2) if tracker_claim_needs_force(&e2.to_string()) => {
8369 stamp_tracker_claim(node, assignee, true)
8370 }
8371 Err(e2) => Err(e2),
8372 }
8373 }
8374 };
8375 claimed
8376 .map(|_| Some(format!("tracker: {node} STARTED under {assignee}")))
8377 .with_context(|| {
8378 format!(
8379 "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"
8380 )
8381 })
8382}
8383
8384fn with_tracker(said: String, node: &str, assignee: &str) -> Result<String> {
8387 let mut out = said;
8388 if let Some(line) = stamp_tracker(node, assignee)? {
8389 if !out.is_empty() && !out.ends_with('\n') {
8390 out.push('\n');
8391 }
8392 out.push_str(&line);
8393 out.push('\n');
8394 }
8395 Ok(out)
8396}
8397
8398pub fn claim(node: &str, assignee: &str) -> Result<String> {
8407 let id = node_for(node)?;
8408 let actor = work_id(&occupancy_scope(assignee, node));
8409 match run_captured("claimdag", &["claim", &id, "--assignee", &actor]) {
8410 Ok(said) => {
8411 write_hold(&actor, assignee, node);
8412 with_tracker(said.stdout, node, assignee)
8413 }
8414 Err(e) => {
8415 let text = e.to_string();
8416 if ["status done", "status failed", "status cancelled"]
8419 .iter()
8420 .any(|s| text.contains(s))
8421 {
8422 run_captured("claimdag", &["reopen", &id, "--actor", &actor])?;
8423 let said = run_captured("claimdag", &["claim", &id, "--assignee", &actor])?;
8424 write_hold(&actor, assignee, node);
8425 return with_tracker(
8426 format!("reopened a finished session node\n{}", said.stdout),
8427 node,
8428 assignee,
8429 );
8430 }
8431 if text.contains("status claimed") {
8434 let got = run_captured("claimdag", &["get", &id])?.stdout;
8435 return match holder_of(&got) {
8436 Some(holder) if holder == actor => {
8437 let renewed = run_captured("claimdag", &["renew", &id, "--actor", &actor])
8438 .map(|s| s.stdout)
8439 .unwrap_or_default();
8440 write_hold(&actor, assignee, node);
8441 with_tracker(
8442 format!("already held by {assignee}; the sitting resumes\n{renewed}"),
8443 node,
8444 assignee,
8445 )
8446 }
8447 Some(holder) => match read_hold(&holder) {
8448 Some(h) if h.seat == seat_name() && !hold_alive(&h) => {
8453 run_captured("claimdag", &["release", &id, "--actor", &holder])?;
8454 drop_hold(&holder);
8455 let said =
8456 run_captured("claimdag", &["claim", &id, "--assignee", &actor])?;
8457 write_hold(&actor, assignee, node);
8458 with_tracker(
8459 format!(
8460 "took over from {}, this seat's conversation, gone (held since {})\n{}",
8461 h.assignee, h.since, said.stdout
8462 ),
8463 node,
8464 assignee,
8465 )
8466 }
8467 Some(h) => bail!(
8468 "{}",
8469 held_by_another_message(
8470 node,
8471 assignee,
8472 &h,
8473 if hold_alive(&h) {
8474 "still running"
8475 } else {
8476 "its runner is gone"
8477 }
8478 )
8479 ),
8480 None => bail!(
8481 "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"
8482 ),
8483 },
8484 None => Err(e),
8485 };
8486 }
8487 if !text.contains("assignee busy") {
8488 return Err(e);
8489 }
8490 let held: Vec<String> = text
8491 .split_whitespace()
8492 .filter(|w| w.len() == 32 && w.chars().all(|c| c.is_ascii_hexdigit()))
8493 .map(str::to_string)
8494 .collect();
8495 let mut lines = vec![format!(
8496 "claim: {assignee} already holds a live node; one live claim per assignee."
8497 )];
8498 for hex in &held {
8499 let name = run_captured("claimdag", &["get", hex])
8500 .ok()
8501 .and_then(|s| {
8502 s.stdout
8503 .lines()
8504 .next()
8505 .and_then(|l| l.split_whitespace().last())
8506 .map(str::to_string)
8507 })
8508 .unwrap_or_else(|| hex.clone());
8509 lines.push(format!(
8510 " holds {name}: `ljos complete {name} --status done` finishes it, \
8511 `ljos release {name} --assignee {assignee}` hands it back"
8512 ));
8513 }
8514 bail!("{}", lines.join("\n"))
8515 }
8516 }
8517}
8518
8519pub fn release(node: &str, assignee: &str) -> Result<String> {
8526 let id = node_for(node)?;
8527 let actor = work_id(&occupancy_scope(assignee, node));
8528 let said = run_captured("claimdag", &["release", &id, "--actor", &actor])?;
8529 drop_hold(&actor);
8530 drop_playbook(node);
8531 Ok(said.stdout)
8532}
8533
8534#[derive(Debug, Clone, PartialEq, Eq)]
8540pub struct Hold {
8541 pub assignee: String,
8542 pub seat: String,
8543 pub pid: u32,
8544 pub comm: String,
8545 pub since: String,
8546}
8547
8548fn hold_record_path(actor: &str) -> PathBuf {
8549 runtime_dir().join(format!("hold-{actor}"))
8550}
8551
8552fn conversation_process() -> (u32, String) {
8557 let chain = ancestry();
8558 let mut below = chain.get(1);
8562 for entry in chain.iter().skip(1) {
8563 if is_session(&entry.1) {
8564 break;
8565 }
8566 if !WRAPPERS.contains(&entry.1.as_str()) {
8567 return entry.clone();
8568 }
8569 below = Some(entry);
8570 }
8571 below
8572 .cloned()
8573 .unwrap_or((std::process::id(), String::new()))
8574}
8575
8576fn write_hold(actor: &str, assignee: &str, node: &str) {
8577 let (pid, comm) = conversation_process();
8578 let path = hold_record_path(actor);
8579 if let Some(dir) = path.parent() {
8580 let _ = std::fs::create_dir_all(dir);
8581 }
8582 let _ = std::fs::write(
8585 path,
8586 format!(
8587 "{assignee}\n{}\n{pid}\n{comm}\n{}\n{node}\n",
8588 seat_name(),
8589 now_utc()
8590 ),
8591 );
8592}
8593
8594fn held_from_records(holders: &[String]) -> Option<String> {
8598 held_from_records_in(holders, &runtime_dir(), &own_ancestry())
8599}
8600
8601fn held_from_records_in(
8605 holders: &[String],
8606 dir: &std::path::Path,
8607 chain: &[(u32, String)],
8608) -> Option<String> {
8609 let pids: Vec<String> = chain.iter().map(|(p, _)| p.to_string()).collect();
8610 let mut best: Option<(String, String)> = None;
8611 for entry in std::fs::read_dir(dir).ok()?.flatten() {
8612 if !entry.file_name().to_string_lossy().starts_with("hold-") {
8613 continue;
8614 }
8615 let Ok(text) = std::fs::read_to_string(entry.path()) else {
8616 continue;
8617 };
8618 let lines: Vec<&str> = text.lines().map(str::trim).collect();
8619 let (Some(holder), Some(pid), Some(comm), Some(at), Some(node)) = (
8620 lines.first(),
8621 lines.get(2),
8622 lines.get(3),
8623 lines.get(4),
8624 lines.get(5),
8625 ) else {
8626 continue;
8627 };
8628 let by_process = !is_session(comm) && pids.iter().any(|p| p == pid);
8629 let ours = holders.iter().any(|h| h == holder) || by_process;
8630 if ours && !node.is_empty() && best.as_ref().is_none_or(|(t, _)| *at > t.as_str()) {
8631 best = Some(((*at).to_string(), (*node).to_string()));
8632 }
8633 }
8634 best.map(|(_, node)| node)
8635}
8636
8637fn drop_hold(actor: &str) {
8638 let _ = std::fs::remove_file(hold_record_path(actor));
8639}
8640
8641fn read_hold(actor: &str) -> Option<Hold> {
8642 let text = std::fs::read_to_string(hold_record_path(actor)).ok()?;
8643 let mut lines = text.lines();
8644 Some(Hold {
8645 assignee: lines.next()?.to_string(),
8646 seat: lines.next()?.to_string(),
8647 pid: lines.next()?.trim().parse().ok()?,
8648 comm: lines.next()?.to_string(),
8649 since: lines.next()?.to_string(),
8650 })
8651}
8652
8653fn hold_alive(hold: &Hold) -> bool {
8657 match parent_and_comm(hold.pid) {
8658 Some((_, comm)) => comm == hold.comm,
8659 None => !cfg!(target_os = "linux"),
8660 }
8661}
8662
8663fn revision_note(body: &Value) -> String {
8667 match body["supersedes"].as_array().map(Vec::len).unwrap_or(0) {
8668 0 => String::new(),
8669 1 => "; revises 1 earlier memory, now closed".to_string(),
8670 n => format!("; revises {n} earlier memories, now closed"),
8671 }
8672}
8673
8674pub fn tracker_show_json(id: &str) -> Result<Value> {
8680 let layout = vissue_core::Layout::resolve(None, None).map_err(anyhow::Error::from)?;
8681 let found = vissue_core::Router::load(layout)
8682 .map_err(anyhow::Error::from)?
8683 .find_by_id(id)
8684 .map_err(anyhow::Error::from)?;
8685 vissue_core::agent::show_json(&found.layout, id).map_err(anyhow::Error::from)
8686}
8687
8688#[must_use]
8691pub fn is_decision(v: &Value) -> bool {
8692 let tagged = v["tags"]
8693 .as_array()
8694 .is_some_and(|t| t.iter().any(|x| x.as_str() == Some("decision")));
8695 let typed = v["properties"]["TYPE"].as_str() == Some("decision");
8696 let listed = v["body"]
8697 .as_str()
8698 .is_some_and(|b| b.lines().any(|l| l.trim_start().starts_with("Options:")));
8699 tagged || typed || listed
8700}
8701
8702fn issue_title(issue: &str) -> Result<String> {
8704 let v = tracker_show_json(issue)?;
8705 Ok(v.get("title")
8706 .and_then(Value::as_str)
8707 .unwrap_or(issue)
8708 .to_string())
8709}
8710
8711#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
8713pub struct Event {
8714 pub days: i64,
8716 pub clock: String,
8719 pub source: &'static str,
8721 pub text: String,
8723}
8724
8725pub fn timeline_events(issue: &str, limit: usize) -> Result<Vec<Event>> {
8735 Ok(timeline_of(issue, limit)?.1)
8736}
8737
8738fn timeline_of(issue: &str, limit: usize) -> Result<(String, Vec<Event>)> {
8739 let v = tracker_show_json(issue)?;
8740 let title = v["title"].as_str().unwrap_or(issue).to_string();
8741 let mut events = tracker_events(&v);
8742 for accession in v["deeds"].as_array().into_iter().flatten() {
8743 let Some(accession) = accession.as_str() else {
8744 continue;
8745 };
8746 if let Ok(said) = run_captured("deedar", &["evidence", accession]) {
8747 if let Some(ev) = deed_event(accession, &said.stdout, local_offset) {
8748 events.push(ev);
8749 }
8750 }
8751 }
8752 if let Ok(island) = packset_island(&title, false) {
8753 for atom in island["island"]
8754 .as_array()
8755 .into_iter()
8756 .flatten()
8757 .filter(|a| reviewable(a))
8758 .take(8)
8759 {
8760 if let Some((days, clock)) = stamp_key(atom["ts"].as_str().map(local_stamp).as_deref())
8761 {
8762 events.push(Event {
8763 days,
8764 clock,
8765 source: "memory",
8766 text: format!(
8767 "[{}] {}",
8768 atom["kind"].as_str().unwrap_or("claim"),
8769 atom["text"].as_str().unwrap_or("").trim()
8770 ),
8771 });
8772 }
8773 }
8774 }
8775 events.sort_by(|a, b| (a.days, &a.clock).cmp(&(b.days, &b.clock)));
8776 let skip = events.len().saturating_sub(limit);
8777 Ok((title, events[skip..].to_vec()))
8778}
8779
8780pub fn timeline(issue: &str, limit: usize) -> Result<String> {
8793 let (title, events) = timeline_of(issue, limit)?;
8794 Ok(format!(
8795 "timeline of {issue}: {title}
8796{}",
8797 format_events(&events, &now_local())
8798 ))
8799}
8800
8801fn local_offset(secs: i64) -> i64 {
8804 use chrono::{Local, Offset, TimeZone};
8805 Local
8806 .timestamp_opt(secs, 0)
8807 .single()
8808 .map_or(0, |t| i64::from(t.offset().fix().local_minus_utc()))
8809}
8810
8811fn now_local() -> String {
8814 chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string()
8815}
8816
8817fn local_stamp(ts: &str) -> String {
8820 chrono::DateTime::parse_from_rfc3339(ts.trim()).map_or_else(
8821 |_| ts.to_string(),
8822 |t| {
8823 t.with_timezone(&chrono::Local)
8824 .format("%Y-%m-%dT%H:%M")
8825 .to_string()
8826 },
8827 )
8828}
8829
8830fn tracker_events(v: &Value) -> Vec<Event> {
8833 let mut events = Vec::new();
8834 let mut push = |stamp: Option<&str>, source: &'static str, text: String| {
8835 if let Some((days, clock)) = stamp_key(stamp) {
8836 events.push(Event {
8837 days,
8838 clock,
8839 source,
8840 text,
8841 });
8842 }
8843 };
8844 push(
8845 v["properties"]["CREATED"].as_str(),
8846 "tracker",
8847 "created".to_string(),
8848 );
8849 if let Some(by) = v["claimed_by"].as_str() {
8850 push(
8851 v["claimed_at"].as_str(),
8852 "tracker",
8853 format!("claimed by {by}"),
8854 );
8855 }
8856 if let Some(d) = v["properties"]["DEADLINE"].as_str() {
8857 push(
8858 v["properties"]["DEADLINE"].as_str(),
8859 "tracker",
8860 format!("DEADLINE {d}"),
8861 );
8862 }
8863 if let Some(s) = v["properties"]["SCHEDULED"].as_str() {
8864 push(
8865 v["properties"]["SCHEDULED"].as_str(),
8866 "tracker",
8867 format!("SCHEDULED {s}"),
8868 );
8869 }
8870 for e in v["logbook"].as_array().into_iter().flatten().rev() {
8872 let stamp = e["timestamp"].as_str();
8873 if let Some(note) = e["note"].as_str() {
8874 push(stamp, "tracker", format!("note: {}", note.trim()));
8875 } else if let Some(to) = e["to_state"].as_str() {
8876 push(
8877 stamp,
8878 "tracker",
8879 format!("{} -> {to}", e["from_state"].as_str().unwrap_or("-")),
8880 );
8881 }
8882 }
8883 events
8884}
8885
8886fn deed_event(accession: &str, evidence: &str, offset_of: fn(i64) -> i64) -> Option<Event> {
8891 let utc: i64 = evidence
8892 .lines()
8893 .find_map(|l| l.strip_prefix("time="))?
8894 .trim()
8895 .parse()
8896 .ok()?;
8897 let secs = utc + offset_of(utc);
8898 let by = evidence
8899 .lines()
8900 .find_map(|l| l.strip_prefix("producedBy="))
8901 .map(str::trim)
8902 .unwrap_or("-");
8903 Some(Event {
8904 days: secs.div_euclid(86_400),
8905 clock: format!(
8906 "{:02}:{:02}",
8907 secs.rem_euclid(86_400) / 3600,
8908 secs.rem_euclid(86_400) % 3600 / 60
8909 ),
8910 source: "deed",
8911 text: format!("{accession} produced by {by}"),
8912 })
8913}
8914
8915fn stamp_key(stamp: Option<&str>) -> Option<(i64, String)> {
8919 let s = stamp?
8920 .trim()
8921 .trim_start_matches(['[', '<'])
8922 .trim_end_matches([']', '>']);
8923 let days = days_of_stamp(Some(s))?;
8924 let rest = &s[10..];
8925 let clock = rest
8926 .split(['T', ' '])
8927 .find(|t| t.len() >= 5 && t.as_bytes()[2] == b':')
8928 .map(|t| t[..5].to_string())
8929 .unwrap_or_default();
8930 Some((days, clock))
8931}
8932
8933fn format_events(events: &[Event], now: &str) -> String {
8935 let today = days_of_stamp(Some(now)).unwrap_or(0);
8936 let mut out = String::new();
8937 let mut last: Option<i64> = None;
8938 for e in events {
8939 let gap = match last {
8940 None => String::new(),
8941 Some(d) if e.days == d => "same day".to_string(),
8942 Some(d) => format!("+{} d", e.days - d),
8943 };
8944 last = Some(e.days);
8945 out.push_str(&format!(
8946 "{} {} {} {} {} {}
8947",
8948 civil_of_days(e.days),
8949 e.clock,
8950 age_of(Some(&civil_of_days(e.days)), &civil_of_days(today)),
8951 gap,
8952 e.source,
8953 e.text
8954 ));
8955 }
8956 out
8957}
8958
8959fn civil_of_days(days: i64) -> String {
8961 let z = days + 719_468;
8962 let era = z.div_euclid(146_097);
8963 let doe = z.rem_euclid(146_097);
8964 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
8965 let y = yoe + era * 400;
8966 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
8967 let mp = (5 * doy + 2) / 153;
8968 let d = doy - (153 * mp + 2) / 5 + 1;
8969 let m = if mp < 10 { mp + 3 } else { mp - 9 };
8970 let y = if m <= 2 { y + 1 } else { y };
8971 format!("{y:04}-{m:02}-{d:02}")
8972}
8973
8974pub fn sitting(issue: &str, assignee: &str, cards_dir: &Path) -> Result<String> {
8986 sitting_gated(issue, assignee, cards_dir, false, None)
8987}
8988
8989pub fn open_blockers(issue: &str) -> Vec<String> {
8993 let Ok(shown) = tracker_show_json(issue) else {
8994 return Vec::new();
8995 };
8996 let mut out = Vec::new();
8997 for id in shown["blocked_by"]
8998 .as_array()
8999 .into_iter()
9000 .flatten()
9001 .filter_map(Value::as_str)
9002 {
9003 let state = tracker_show_json(id)
9004 .ok()
9005 .and_then(|v| v["state"].as_str().map(str::to_string))
9006 .unwrap_or_else(|| "?".to_string());
9007 if !matches!(state.as_str(), "DONE" | "CANCELLED") {
9008 out.push(format!("{id} ({state})"));
9009 }
9010 }
9011 out
9012}
9013
9014pub fn sitting_gated(
9023 issue: &str,
9024 assignee: &str,
9025 cards_dir: &Path,
9026 anyway: bool,
9027 playbook: Option<&str>,
9028) -> Result<String> {
9029 let mut out = String::new();
9030 let rows = doctor_seat();
9031 out.push_str("== doctor\n");
9032 out.push_str(&format_doctor(&rows));
9033 if !healthy(&rows) {
9034 bail!("{out}sitting: a required habitat does not answer; nothing was claimed");
9035 }
9036 out.push_str("== sync\n");
9039 out.push_str(&sync::sync_repo(true, false).unwrap_or_else(|e| format!("sync: {e:#}\n")));
9040 out.push_str("== cards\n");
9041 out.push_str(&cards(cards_dir)?);
9042 let title = issue_title(issue)?;
9043 let island = packset_island(&title, false)?;
9044 out.push_str("== due\n");
9045 out.push_str(&sitting_due_report(&island)?);
9046 out.push_str(&format!("== island: {title}\n"));
9047 let mut top = island.clone();
9050 if let Some(rows) = top["island"].as_array_mut() {
9051 rows.truncate(8);
9052 }
9053 out.push_str(&format_island(&top));
9054 out.push_str("== blockers\n");
9055 let blockers = open_blockers(issue);
9056 if blockers.is_empty() {
9057 out.push_str("none open; the issue is workable\n");
9058 } else {
9059 out.push_str(&format!("open: {}\n", blockers.join(", ")));
9060 if !anyway {
9061 bail!(
9062 "{out}sitting: {issue} is blocked by {}; finish those first, or `ljos sitting {issue} --anyway` to sit on it regardless. Nothing was claimed",
9063 blockers.join(", ")
9064 );
9065 }
9066 out.push_str("sitting anyway, as asked\n");
9067 }
9068 let decision = tracker_show_json(issue).is_ok_and(|v| is_decision(&v));
9073 let name = match (playbook, decision) {
9074 (None, true) if bound_playbook(issue).is_none() => "company-panel".to_string(),
9075 _ => resolve_sitting_playbook(issue, &title, playbook)?,
9076 };
9077 out.push_str("== playbook\n");
9078 out.push_str(©_playbook(issue, &name)?);
9079 if decision {
9080 out.push_str("== panel\n");
9081 let dir = runtime_dir().join(format!("panel-{issue}"));
9082 match panel(issue, &dir) {
9083 Ok(said) => out.push_str(&format!(
9084 "{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}"
9085 )),
9086 Err(e) => out.push_str(&format!("{issue} is a decision, and the panel could not be written: {e:#}\n")),
9087 }
9088 }
9089 out.push_str("== recall\n");
9090 out.push_str(&run_captured("vissue", &["recall", issue])?.stdout);
9091 out.push_str("== timeline\n");
9094 out.push_str(&timeline(issue, SITTING_TIMELINE)?);
9095 out.push_str("== claim\n");
9096 out.push_str(&claim(issue, assignee)?);
9097 out.push_str(&persist_tracker(issue, "claimed"));
9098 Ok(out)
9099}
9100
9101pub fn complete(
9118 node: &str,
9119 status: Option<&str>,
9120 assignee: &str,
9121 gen: Option<u64>,
9122) -> Result<String> {
9123 let id = node_for(node)?;
9124 let actor = work_id(&occupancy_scope(assignee, node));
9125 let gen_s = live_gen(&id, gen)?.to_string();
9126 let mut args = vec![
9127 "complete",
9128 id.as_str(),
9129 "--actor",
9130 actor.as_str(),
9131 "--gen",
9132 gen_s.as_str(),
9133 ];
9134 if let Some(s) = status {
9135 args.push("--status");
9136 args.push(s);
9137 }
9138 let said = run_captured("claimdag", &args)?;
9139 drop_hold(&actor);
9140 drop_playbook(node);
9141 Ok(said.stdout)
9142}
9143
9144#[expect(
9145 clippy::too_many_arguments,
9146 reason = "The public finish signature preserves its independent command options"
9147)]
9148pub fn finish(
9149 issue: &str,
9150 status: &str,
9151 lesson: Option<&str>,
9152 outcome: Option<&str>,
9153 beta: f64,
9154 assignee: &str,
9155 gen: Option<u64>,
9156 close: bool,
9157) -> Result<String> {
9158 if close && tracker_show_json(issue).is_ok_and(|v| is_decision(&v)) {
9161 let said = run_captured("vissue", &["vote", issue, "--json"])?;
9162 let ballots = forecasts_from_json(&said.stdout)?.len();
9163 if ballots < 2 {
9164 bail!(
9165 "finish: {issue} is a decision and holds {ballots} ballot{}; run the panel \
9166 (`ljos panel {issue}`), have each persona cast `ljos vote {issue} --for OPTION --expect OPTION --as NAME`, \
9167 settle with `ljos consensus {issue}`, then --close. Nothing was written",
9168 if ballots == 1 { "" } else { "s" }
9169 );
9170 }
9171 }
9172 let mut out = String::new();
9173 match lesson.map(str::trim).filter(|l| !l.is_empty()) {
9174 Some(text) => {
9175 let scope = sync::scope_for_issue(issue);
9178 let body = packset_write_scoped("Remember", text, issue, scope.as_deref())?;
9179 out.push_str(&format!(
9180 "remembered {}{}\n",
9181 body.get("id").and_then(Value::as_str).unwrap_or("-"),
9182 revision_note(&body)
9183 ));
9184 }
9185 None => out.push_str(
9186 "no lesson remembered this sitting; `ljos remember` takes one in two sentences\n",
9187 ),
9188 }
9189 let title = issue_title(issue)?;
9190 let island = packset_island(&title, true)?;
9191 if island["weak"].as_bool().unwrap_or(false) {
9192 out.push_str(&format!(
9193 "did not fire the island for {title:?}: its seeds are hits no two scorers agreed on{}; wiring them would tighten the wrong links\n",
9194 if island["dense"].as_bool().unwrap_or(true) { "" } else { " (the encoder is down, ranking is lexical only)" }
9195 ));
9196 } else if island["held"].as_bool().unwrap_or(false) {
9197 out.push_str(&format!(
9200 "the island for {title:?} fired within the hour; not fired again\n"
9201 ));
9202 } else {
9203 let fired = island["island"].as_array().map_or(0, Vec::len);
9204 out.push_str(&format!(
9205 "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"
9206 ));
9207 }
9208 let terminal = ["done", "failed", "cancelled"];
9209 if !terminal.contains(&status) {
9210 bail!("finish: status {status:?} is not one of done, failed, cancelled");
9211 }
9212 complete(issue, Some(status), assignee, gen)?;
9213 out.push_str(&format!(
9214 "completed the session node for {issue} as {status}\n"
9215 ));
9216 if let Some(option) = outcome.map(str::trim).filter(|o| !o.is_empty()) {
9217 let said = run_captured("vissue", &["vote", issue, "--json"])?;
9218 let forecasts = forecasts_from_json(&said.stdout)?;
9219 if forecasts.len() < 2 {
9220 out.push_str("outcome named but fewer than two ballots; nothing to learn from\n");
9221 } else {
9222 let ballots: Vec<(String, String)> = forecasts
9223 .iter()
9224 .map(|f| (f.agent.clone(), f.choice.clone()))
9225 .collect();
9226 let about = island_entities(issue).unwrap_or_default();
9227 let (rows, moved, calibration) =
9228 learn_and_write(&ballots, option, beta, &about, &forecasts)?;
9229 out.push_str(&learn_reading(
9230 rows.len(),
9231 moved.len(),
9232 &forecasts,
9233 option,
9234 &calibration,
9235 ));
9236 out.push('\n');
9237 }
9238 }
9239 if close && status.eq_ignore_ascii_case("done") {
9243 run_as("vissue", &["update", issue, "-s", "DONE"], None)
9244 .with_context(|| format!("finish: could not close the ticket {issue}"))?;
9245 out.push_str(&format!("closed the ticket {issue}\n"));
9246 } else {
9247 out.push_str(&format!(
9248 "the ticket {issue} keeps its state; `ljos finish {issue} --close` or `vissue update {issue} -s DONE` closes it when the work is accepted\n"
9249 ));
9250 }
9251 out.push_str(&persist_tracker(issue, "finished"));
9252 out.push_str(&sync::sync_repo(false, true).unwrap_or_else(|e| format!("sync: {e:#}\n")));
9254 Ok(out)
9255}
9256
9257pub struct CommitLock(Option<std::fs::File>);
9261
9262impl CommitLock {
9263 #[must_use]
9264 pub fn acquire(path: &std::path::Path) -> Self {
9265 use std::os::unix::io::AsRawFd;
9266 let Ok(file) = std::fs::OpenOptions::new()
9267 .create(true)
9268 .append(true)
9269 .open(path)
9270 else {
9271 return Self(None);
9272 };
9273 let ok = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } == 0;
9275 Self(ok.then_some(file))
9276 }
9277}
9278
9279impl Drop for CommitLock {
9280 fn drop(&mut self) {
9281 use std::os::unix::io::AsRawFd;
9282 if let Some(file) = &self.0 {
9283 unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
9286 }
9287 }
9288}
9289
9290pub fn persist_tracker(issue: &str, verb: &str) -> String {
9298 let mode = std::env::var("LJOS_TRACKER_GIT").unwrap_or_default();
9299 if matches!(mode.as_str(), "off" | "0" | "false") {
9300 return "tracker git: off (LJOS_TRACKER_GIT)\n".into();
9301 }
9302 let path = match vissue_core::Layout::resolve(None, None)
9303 .and_then(vissue_core::Router::load)
9304 .and_then(|router| router.find_by_id(issue))
9305 {
9306 Ok(hit) => hit.path,
9307 Err(e) => return format!("tracker git: could not find {issue}: {e}\n"),
9308 };
9309 let Some(dir) = path.parent() else {
9310 return format!("tracker git: {} has no directory\n", path.display());
9311 };
9312 let git = |args: &[&str]| {
9313 std::process::Command::new("git")
9314 .arg("-C")
9315 .arg(dir)
9316 .args(args)
9317 .stdin(std::process::Stdio::null())
9318 .output()
9319 };
9320 let file = path.to_string_lossy().to_string();
9321 match git(&["rev-parse", "--is-inside-work-tree"]) {
9322 Ok(o) if o.status.success() => {}
9323 _ => return "tracker git: the tracker is not a git checkout\n".into(),
9324 }
9325 match git(&["status", "--porcelain", "--", &file]) {
9326 Ok(o) if o.status.success() && o.stdout.is_empty() => {
9327 return "tracker git: nothing to commit\n".into();
9328 }
9329 Ok(o) if o.status.success() => {}
9330 Ok(o) => return format!("tracker git: {}\n", first_line(&o.stderr)),
9331 Err(e) => return format!("tracker git: {e}\n"),
9332 }
9333 let message = format!("chore(issues): {issue} {verb}");
9334 let common = git(&["rev-parse", "--git-common-dir"])
9339 .ok()
9340 .filter(|o| o.status.success())
9341 .map(|o| dir.join(String::from_utf8_lossy(&o.stdout).trim()))
9342 .unwrap_or_else(|| dir.join(".git"));
9343 let _held = CommitLock::acquire(&common.join("ljos-commit.lock"));
9344 let mut committed = git(&["add", "--", &file])
9345 .and_then(|_| git(&["commit", "-q", "--only", "-m", &message, "--", &file]));
9346 for wait_ms in [200_u64, 400, 800, 1600, 3200] {
9347 let busy = matches!(&committed, Ok(o) if !o.status.success()
9348 && String::from_utf8_lossy(&o.stderr).contains("index.lock"));
9349 if !busy {
9350 break;
9351 }
9352 std::thread::sleep(std::time::Duration::from_millis(wait_ms));
9353 committed = git(&["add", "--", &file])
9354 .and_then(|_| git(&["commit", "-q", "--only", "-m", &message, "--", &file]));
9355 }
9356 drop(_held);
9357 match committed {
9358 Ok(o) if o.status.success() => {}
9359 Ok(o) => {
9360 return format!(
9361 "tracker git: commit refused: {}\n",
9362 first_line(if o.stderr.is_empty() {
9363 &o.stdout
9364 } else {
9365 &o.stderr
9366 })
9367 );
9368 }
9369 Err(e) => return format!("tracker git: {e}\n"),
9370 }
9371 if mode == "commit" {
9372 return format!("tracker git: committed {message}; not pushed (LJOS_TRACKER_GIT=commit)\n");
9373 }
9374 let log = runtime_dir().join(format!("tracker-push-{}.log", std::process::id()));
9378 let _ = std::fs::create_dir_all(runtime_dir());
9379 let Ok(out) = std::fs::File::create(&log) else {
9380 return format!("tracker git: committed {message}; push not started: no log file\n");
9381 };
9382 let err = out.try_clone();
9383 let mirrors = tracker_upstream(dir)
9387 .and_then(|up| tracker_mirrors(dir, &up))
9388 .unwrap_or_default();
9389 let mut script =
9394 String::from("git push -q || { git pull -q --no-rebase --no-edit && git push -q; }; rc=$?");
9395 for (remote, branch) in &mirrors {
9396 script.push_str(&format!(
9397 "; git push -q '{remote}' 'HEAD:refs/heads/{branch}' || rc=1"
9398 ));
9399 }
9400 script.push_str("; exit $rc");
9401 let mut push = std::process::Command::new("sh");
9402 push.current_dir(dir)
9403 .args(["-c", &script])
9404 .stdin(std::process::Stdio::null())
9405 .stdout(out);
9406 if let Ok(err) = err {
9407 push.stderr(err);
9408 }
9409 let mut child = match push.spawn() {
9410 Ok(c) => c,
9411 Err(e) => return format!("tracker git: committed {message}; push failed: {e}\n"),
9412 };
9413 let wait = push_wait();
9414 let started = std::time::Instant::now();
9415 loop {
9416 match child.try_wait() {
9417 Ok(Some(status)) if status.success() => {
9418 let _ = std::fs::remove_file(&log);
9419 return format!("tracker git: committed and pushed {message}\n");
9420 }
9421 Ok(Some(_)) => {
9422 let said = std::fs::read(&log).unwrap_or_default();
9423 return format!(
9424 "tracker git: committed {message}; push refused: {}\n",
9425 first_line(&said)
9426 );
9427 }
9428 Ok(None) if started.elapsed() < wait => {
9429 std::thread::sleep(std::time::Duration::from_millis(200));
9430 }
9431 Ok(None) => {
9432 return format!(
9433 "tracker git: committed {message}; push still running after {}s, finishing in the background (log {})\n",
9434 wait.as_secs(),
9435 log.display()
9436 );
9437 }
9438 Err(e) => return format!("tracker git: committed {message}; push failed: {e}\n"),
9439 }
9440 }
9441}
9442
9443fn push_wait() -> std::time::Duration {
9446 let secs = std::env::var("LJOS_TRACKER_PUSH_WAIT")
9447 .ok()
9448 .and_then(|v| v.trim().parse::<u64>().ok())
9449 .unwrap_or(5);
9450 std::time::Duration::from_secs(secs)
9451}
9452
9453fn first_line(bytes: &[u8]) -> String {
9454 String::from_utf8_lossy(bytes)
9455 .lines()
9456 .find(|l| !l.trim().is_empty())
9457 .unwrap_or("")
9458 .trim()
9459 .to_string()
9460}
9461
9462#[must_use]
9472pub fn calibration_weights(accuracy: &[(String, f64)]) -> Vec<(String, f64)> {
9473 let logit = |p: f64| {
9474 let p = p.clamp(0.01, 0.99);
9475 (p / (1.0 - p)).ln()
9476 };
9477 let raw: Vec<(String, f64)> = accuracy
9478 .iter()
9479 .map(|(who, p)| (who.clone(), logit(*p).max(0.0)))
9480 .collect();
9481 let top = raw.iter().map(|(_, w)| *w).fold(0.0_f64, f64::max);
9482 raw.into_iter()
9483 .map(|(who, w)| {
9484 let scaled = if top > 0.0 { w / top } else { 0.0 };
9485 (who, scaled.clamp(TRUST_FLOOR, 1.0))
9486 })
9487 .collect()
9488}
9489
9490pub fn calibrate(project: &str, rounds: usize) -> Result<Vec<Trust>> {
9504 let said = run_captured(
9505 "ljos-consensus",
9506 &[
9507 "reliability",
9508 "--project",
9509 project,
9510 "--rounds",
9511 &rounds.to_string(),
9512 ],
9513 )?;
9514 let v: Value = serde_json::from_str(&said.stdout).context("reliability: not JSON")?;
9515 let accuracy = v
9516 .get("accuracy")
9517 .and_then(Value::as_object)
9518 .context("reliability: no accuracy object")?;
9519 let mut voters: Vec<(String, f64)> = accuracy
9520 .iter()
9521 .filter_map(|(k, val)| val.as_f64().map(|a| (k.clone(), a)))
9522 .collect();
9523 voters.sort_by(|a, b| a.0.cmp(&b.0));
9524 if voters.len() < 2 {
9525 bail!("calibrate: fewer than two voters in {project}");
9526 }
9527 let weights = calibration_weights(&voters);
9528 let mut rows = Vec::new();
9529 for (from, _) in &voters {
9530 for (to, weight) in &weights {
9531 if from == to {
9532 continue;
9533 }
9534 rows.push(Trust {
9535 from: from.clone(),
9536 to: to.clone(),
9537 weight: *weight,
9538 about: Vec::new(),
9539 });
9540 }
9541 }
9542 for row in &rows {
9543 write_trust(row, &[])?;
9544 }
9545 Ok(rows)
9546}
9547
9548#[must_use]
9551pub fn search_reading(n: usize) -> &'static str {
9552 if n == 0 {
9553 "No hits. The pack holds nothing on this query. A failure would say the writer did not answer."
9554 } else {
9555 "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."
9556 }
9557}
9558
9559pub fn format_hits(hits: &[Hit]) -> String {
9563 let now = now_utc();
9564 let mine = seat_name();
9565 let mut out = format!("{}\n", search_reading(hits.len()));
9566 for h in hits {
9567 let id = h.id.as_deref().unwrap_or("-");
9568 let named = match (h.ballots, h.of) {
9569 (Some(b), Some(of)) => format!("{b}/{of}"),
9570 _ => "-".to_string(),
9571 };
9572 let from = other_seat(&h.entities, &mine)
9573 .map(|s| format!(" (from {s})"))
9574 .unwrap_or_default();
9575 out.push_str(&format!(
9576 "{:.4}\t{}\t{}\t{}\t{}{}\t{}\n",
9577 h.score,
9578 named,
9579 h.kind,
9580 id,
9581 age_of(h.ts.as_deref(), &now),
9582 from,
9583 h.text
9584 ));
9585 }
9586 out
9587}
9588
9589#[must_use]
9593pub fn other_seat(entities: &[String], mine: &str) -> Option<String> {
9594 entities
9595 .iter()
9596 .filter_map(|e| e.strip_prefix(SEAT_ENTITY))
9597 .find(|s| !s.is_empty() && *s != mine)
9598 .map(str::to_string)
9599}
9600
9601fn hit_line(h: &Hit, now: &str) -> String {
9604 let from = other_seat(&h.entities, &seat_name())
9605 .map(|s| format!(", from {s}"))
9606 .unwrap_or_default();
9607 format!(
9608 "- [{}{}{}] {}",
9609 if h.kind.is_empty() { "claim" } else { &h.kind },
9610 age_tag(h.ts.as_deref(), now),
9611 from,
9612 h.text.trim()
9613 )
9614}
9615
9616fn age_tag(ts: Option<&str>, now: &str) -> String {
9618 let age = age_of(ts, now);
9619 if age.is_empty() {
9620 age
9621 } else {
9622 format!(", {age}")
9623 }
9624}
9625
9626#[must_use]
9631pub fn age_of(ts: Option<&str>, now: &str) -> String {
9632 let (Some(then), Some(today)) = (days_of_stamp(ts), days_of_stamp(Some(now))) else {
9633 return String::new();
9634 };
9635 let days = today - then;
9636 match days {
9637 d if d < 0 => format!("in {} day{}", -d, if d == -1 { "" } else { "s" }),
9638 0 => "today".into(),
9639 1 => "yesterday".into(),
9640 d if d < 14 => format!("{d} days ago"),
9641 d if d < 61 => format!("{} weeks ago", d / 7),
9642 d if d < 730 => format!("{} months ago", d / 30),
9643 d => format!("{} years ago", d / 365),
9644 }
9645}
9646
9647fn days_of_stamp(ts: Option<&str>) -> Option<i64> {
9650 let ts = ts?;
9651 let date = ts.get(..10)?;
9652 let mut it = date.split('-');
9653 let y: i64 = it.next()?.parse().ok()?;
9654 let m: i64 = it.next()?.parse().ok()?;
9655 let d: i64 = it.next()?.parse().ok()?;
9656 if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
9657 return None;
9658 }
9659 let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
9661 let era = y.div_euclid(400);
9662 let yoe = y - era * 400;
9663 let doy = (153 * m + 2) / 5 + d - 1;
9664 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
9665 Some(era * 146_097 + doe - 719_468)
9666}
9667
9668pub fn cards(dir: &Path) -> Result<String> {
9670 let mut out = String::new();
9671 for name in CARD_NAMES {
9672 let p = dir.join(name);
9673 if p.is_file() {
9674 out.push_str(&format!("--- {} ---\n", p.display()));
9675 out.push_str(&std::fs::read_to_string(&p)?);
9676 }
9677 }
9678 Ok(out)
9679}
9680
9681pub fn policy_line(argv: &[String]) -> Result<String> {
9682 if argv.is_empty() {
9683 bail!("policy: pass the argv to check");
9684 }
9685 Ok(argv.join(" "))
9686}
9687
9688pub fn policy_with_memory(argv: &[String]) -> Result<String> {
9692 let line = policy_line(argv)?;
9693 let call = HookCall {
9694 event: "argv".into(),
9695 cue: line.clone(),
9696 session: None,
9697 shape: HookShape::Asks,
9698 };
9699 let context = hook_context(&call, 5);
9700 let rules = rules_from_pack().unwrap_or_default();
9703 let ruled = hook_output_ruled(&call, &context, verdict_for(&rules, &line));
9704 match tcb_check(argv) {
9705 Some(tcb) if !tcb.is_empty() => Ok(format!("{line}\n{tcb}\n{ruled}")),
9706 None if policyd_required() => Ok(format!("{line}\ndeny\tTCB required\n{ruled}")),
9707 _ => Ok(format!("{line}\n{ruled}")),
9708 }
9709}
9710
9711pub fn policyd_required() -> bool {
9713 matches!(
9714 std::env::var("POLICYD_REQUIRED").as_deref(),
9715 Ok("1") | Ok("true") | Ok("TRUE")
9716 )
9717}
9718
9719pub fn policyd_bin() -> Option<std::path::PathBuf> {
9721 std::env::var_os("POLICYD_BIN")
9722 .filter(|s| !s.is_empty())
9723 .map(std::path::PathBuf::from)
9724 .or_else(|| which::which("ljos-policyd").ok())
9725}
9726
9727pub fn tcb_check(argv: &[String]) -> Option<String> {
9730 let bin = policyd_bin()?;
9731 let out = std::process::Command::new(bin)
9732 .arg("check")
9733 .arg("--")
9734 .args(argv)
9735 .output()
9736 .ok()?;
9737 let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
9738 (!text.is_empty()).then_some(text)
9739}
9740
9741#[derive(Debug, Clone, PartialEq, Eq)]
9742pub struct ConsensusStep {
9743 pub bin: &'static str,
9744 pub args: Vec<String>,
9745}
9746
9747pub fn consensus_steps(
9750 id: &str,
9751 have_ljos: bool,
9752 have_vissue: bool,
9753 trust: &[Trust],
9754) -> Result<Vec<ConsensusStep>> {
9755 consensus_steps_anchored(id, have_ljos, have_vissue, trust, &[])
9756}
9757
9758pub const BROAD_TAG: &str = "broad";
9763
9764pub const BROAD_EPSILON: f64 = 1.0;
9767
9768#[must_use]
9771pub fn settle_flags_for(tags: &[String]) -> Vec<String> {
9772 if tags.iter().any(|t| t == BROAD_TAG) {
9773 vec!["--epsilon".into(), BROAD_EPSILON.to_string()]
9774 } else {
9775 Vec::new()
9776 }
9777}
9778
9779pub fn consensus_steps_for(
9782 id: &str,
9783 have_ljos: bool,
9784 have_vissue: bool,
9785 trust: &[Trust],
9786 personas: &[Persona],
9787 tags: &[String],
9788) -> Result<Vec<ConsensusStep>> {
9789 let mut steps = consensus_steps_anchored(id, have_ljos, have_vissue, trust, personas)?;
9790 let flags = settle_flags_for(tags);
9791 if !flags.is_empty() {
9792 for step in steps.iter_mut().filter(|s| s.bin == "ljos-consensus") {
9793 step.args.extend(flags.iter().cloned());
9794 }
9795 }
9796 Ok(steps)
9797}
9798
9799pub fn panel_steps(
9804 id: &str,
9805 have_ljos: bool,
9806 trust: &[Trust],
9807 predictions: &[Prediction],
9808) -> Vec<ConsensusStep> {
9809 let mut steps = Vec::new();
9810 if !have_ljos {
9811 return steps;
9812 }
9813 if predictions.len() >= 2 {
9814 steps.push(ConsensusStep {
9815 bin: "ljos-consensus",
9816 args: vec![
9817 "surprising".into(),
9818 "--issue".into(),
9819 id.into(),
9820 "--predictions".into(),
9821 predictions_json(predictions),
9822 ],
9823 });
9824 }
9825 if !trust.is_empty() {
9826 steps.push(ConsensusStep {
9827 bin: "ljos-consensus",
9828 args: vec!["reputation".into(), "--trust".into(), trust_json(trust)],
9829 });
9830 }
9831 steps
9832}
9833
9834pub fn consensus_steps_anchored(
9837 id: &str,
9838 have_ljos: bool,
9839 have_vissue: bool,
9840 trust: &[Trust],
9841 personas: &[Persona],
9842) -> Result<Vec<ConsensusStep>> {
9843 if !have_ljos && !have_vissue {
9844 bail!("neither ljos-consensus nor vissue is on PATH");
9845 }
9846 let mut steps = Vec::new();
9847 if have_ljos {
9848 let mut args = vec!["settle".to_string(), "--issue".into(), id.into()];
9849 if !trust.is_empty() {
9850 args.push("--trust".into());
9851 args.push(trust_json(trust));
9852 }
9853 if !personas.is_empty() {
9854 args.push("--susceptibility-of".into());
9855 args.push(anchors_json(personas));
9856 }
9857 steps.push(ConsensusStep {
9858 bin: "ljos-consensus",
9859 args,
9860 });
9861 }
9862 if have_vissue {
9863 let mut args = vec!["consensus".to_string(), id.into()];
9864 if !trust.is_empty() {
9865 args.push("--trust".into());
9866 args.push(trust_json(trust));
9867 }
9868 if !personas.is_empty() {
9869 args.push("--susceptibility-of".into());
9870 args.push(anchors_json(personas));
9871 }
9872 steps.push(ConsensusStep {
9873 bin: "vissue",
9874 args,
9875 });
9876 }
9877 Ok(steps)
9878}
9879
9880pub fn on_path(bin: &str) -> bool {
9881 which::which(bin).is_ok()
9882}
9883
9884pub fn run(bin: &str, args: &[impl AsRef<str>]) -> Result<()> {
9885 run_as(bin, args, None)
9886}
9887
9888#[must_use]
9892pub fn identity_or_seat(identity: Option<&str>) -> Option<String> {
9893 identity
9894 .map(str::trim)
9895 .filter(|w| !w.is_empty())
9896 .map(str::to_string)
9897 .or_else(|| Some(seat_name()))
9898}
9899
9900pub fn run_as(bin: &str, args: &[impl AsRef<str>], identity: Option<&str>) -> Result<()> {
9903 use std::process::{Command, Stdio};
9904 let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
9905 let mut cmd = Command::new(path);
9906 if let Some(who) = identity_or_seat(identity) {
9907 cmd.env("VISSUE_AGENT", who);
9908 }
9909 for a in args {
9910 cmd.arg(a.as_ref());
9911 }
9912 let st = cmd
9913 .stdin(Stdio::inherit())
9914 .stdout(Stdio::inherit())
9915 .stderr(Stdio::inherit())
9916 .status()?;
9917 #[cfg(unix)]
9921 {
9922 use std::os::unix::process::ExitStatusExt;
9923 if st.signal() == Some(libc::SIGPIPE) {
9924 return Ok(());
9925 }
9926 }
9927 if !st.success() {
9928 bail!("{bin} exited {st}");
9929 }
9930 Ok(())
9931}
9932
9933#[derive(Debug, Clone, PartialEq, Eq)]
9936pub struct Said {
9937 pub stdout: String,
9938 pub stderr: String,
9939}
9940
9941pub fn run_captured(bin: &str, args: &[impl AsRef<str>]) -> Result<Said> {
9942 run_captured_as(bin, args, None)
9943}
9944
9945pub fn run_captured_as(
9949 bin: &str,
9950 args: &[impl AsRef<str>],
9951 identity: Option<&str>,
9952) -> Result<Said> {
9953 use std::process::{Command, Stdio};
9954 let path = which::which(bin).with_context(|| format!("{bin} not on PATH"))?;
9955 let mut cmd = Command::new(path);
9956 if let Some(who) = identity {
9957 cmd.env("VISSUE_AGENT", who);
9958 }
9959 for a in args {
9960 cmd.arg(a.as_ref());
9961 }
9962 let out = cmd
9963 .stdin(Stdio::null())
9964 .stdout(Stdio::piped())
9965 .stderr(Stdio::piped())
9966 .output()
9967 .with_context(|| format!("{bin}: could not start"))?;
9968 let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
9969 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
9970 if !out.status.success() {
9971 let why = if stderr.trim().is_empty() {
9972 stdout.trim().to_string()
9973 } else {
9974 stderr.trim().to_string()
9975 };
9976 bail!("{bin} exited {}: {why}", out.status);
9977 }
9978 Ok(Said { stdout, stderr })
9979}
9980
9981pub fn card_paths(dir: &Path) -> Vec<PathBuf> {
9982 CARD_NAMES.iter().map(|n| dir.join(n)).collect()
9983}
9984
9985#[derive(Debug, Clone, PartialEq, Eq)]
9988pub struct Finding {
9989 pub id: String,
9990 pub status: String,
9991 pub class: String,
9992 pub disposition: String,
9993 pub stage: String,
9994 pub recipe: String,
9997 pub module: String,
10001 pub summary: String,
10002 pub error: String,
10004 pub action: String,
10006 pub changes: Vec<String>,
10007}
10008
10009#[derive(Debug, Clone, PartialEq, Eq)]
10011pub struct Campaign {
10012 pub package: String,
10013 pub version: String,
10014 pub target: String,
10015 pub status: String,
10016 pub attempts: u64,
10017 pub findings: Vec<Finding>,
10018}
10019
10020fn recipe_stem(path: &str) -> String {
10021 Path::new(path)
10022 .file_stem()
10023 .map(|s| s.to_string_lossy().into_owned())
10024 .unwrap_or_else(|| path.to_string())
10025}
10026
10027fn error_line(evidence: &str, summary: &str) -> String {
10030 let lower = |l: &str| l.to_ascii_lowercase();
10031 evidence
10032 .lines()
10033 .map(str::trim)
10034 .filter(|l| !l.is_empty())
10035 .filter(|l| {
10036 let l = lower(l);
10037 l.contains("error") || l.contains("fatal") || l.contains("failed")
10038 })
10039 .rfind(|l| !l.starts_with("srun:"))
10040 .map(str::to_string)
10041 .unwrap_or_else(|| summary.to_string())
10042}
10043
10044fn failed_module(evidence: &str) -> Option<String> {
10048 let installation = evidence.lines().rev().find_map(|l| {
10049 let rest = l.split("Installation of ").nth(1)?;
10050 let eb = rest.split(".eb failed").next()?;
10051 let name = eb.rsplit('/').next()?;
10054 (!name.is_empty() && !name.contains(' ')).then(|| name.to_string())
10055 });
10056 installation.or_else(|| {
10057 evidence.lines().rev().find_map(|l| {
10058 let rest = l.trim().strip_prefix("== building and installing ")?;
10059 let name = rest.trim_end_matches('.').trim();
10060 (!name.is_empty()).then(|| name.replacen('/', "-", 1))
10061 })
10062 })
10063}
10064
10065fn error_reason(error: &str) -> &str {
10067 error
10068 .split(".eb failed: ")
10069 .nth(1)
10070 .unwrap_or(error)
10071 .trim_start_matches("ERROR: ")
10072}
10073
10074fn text_of(v: &Value, key: &str) -> String {
10075 v.get(key)
10076 .and_then(Value::as_str)
10077 .unwrap_or_default()
10078 .to_string()
10079}
10080
10081pub fn read_campaign(state: &Path) -> Result<Campaign> {
10087 let text = std::fs::read_to_string(state)
10088 .with_context(|| format!("findings: cannot read {}", state.display()))?;
10089 let doc: Value = serde_json::from_str(&text)
10090 .with_context(|| format!("findings: {} is not JSON", state.display()))?;
10091 let rows = doc
10092 .get("findings")
10093 .and_then(Value::as_array)
10094 .with_context(|| format!("findings: {} has no findings list", state.display()))?;
10095 let findings = rows
10096 .iter()
10097 .map(|f| {
10098 let summary = text_of(f, "summary");
10099 let resolution = f.get("resolution");
10100 let evidence = text_of(f, "evidence");
10101 Finding {
10102 id: text_of(f, "id"),
10103 status: text_of(f, "status"),
10104 class: text_of(f, "class"),
10105 disposition: text_of(f, "disposition"),
10106 stage: text_of(f, "stage"),
10107 recipe: recipe_stem(&text_of(f, "recipe")),
10108 module: failed_module(&evidence).unwrap_or_default(),
10109 error: error_line(&evidence, &summary),
10110 summary,
10111 action: resolution.map(|r| text_of(r, "action")).unwrap_or_default(),
10112 changes: resolution
10113 .and_then(|r| r.get("changes"))
10114 .and_then(Value::as_array)
10115 .map(|c| {
10116 c.iter()
10117 .filter_map(Value::as_str)
10118 .map(str::to_string)
10119 .collect()
10120 })
10121 .unwrap_or_default(),
10122 }
10123 })
10124 .collect();
10125 Ok(Campaign {
10126 package: text_of(&doc, "package"),
10127 version: text_of(&doc, "version"),
10128 target: text_of(&doc, "target"),
10129 status: text_of(&doc, "status"),
10130 attempts: doc.get("attempts").and_then(Value::as_u64).unwrap_or(0),
10131 findings,
10132 })
10133}
10134
10135fn superseded_by_retry(f: &Finding) -> bool {
10138 f.status == "superseded" || f.action.contains("superseded this finding")
10139}
10140
10141fn clip_words(text: &str, n: usize) -> String {
10144 let text = text.replace(" ...", "").replace("...", "");
10147 let chars: Vec<char> = text.chars().collect();
10148 let mut flat = String::with_capacity(text.len());
10149 for (i, &c) in chars.iter().enumerate() {
10150 let ends_word = chars.get(i + 1).is_none_or(|n| n.is_whitespace());
10151 flat.push(match c {
10152 '.' | '!' | '?' | ';' if ends_word => ',',
10153 '\n' | '\t' => ' ',
10154 c => c,
10155 });
10156 }
10157 let words: Vec<&str> = flat.split_whitespace().collect();
10158 let mut out = words[..words.len().min(n)].join(" ");
10159 while out.ends_with([',', ':', ' ']) {
10160 out.pop();
10161 }
10162 out
10163}
10164
10165#[must_use]
10169pub fn finding_lesson(campaign: &Campaign, f: &Finding) -> String {
10170 let what = clip_words(error_reason(&f.error), 10);
10171 let subject = if f.module.is_empty() {
10172 f.recipe.clone()
10173 } else if f.module == f.recipe {
10174 f.module.clone()
10175 } else {
10176 format!("{} for {}", f.module, f.recipe)
10177 };
10178 let mut first = format!(
10179 "{subject} on {}: {} failed in the {} step",
10180 campaign.target, f.class, f.stage
10181 );
10182 if !what.is_empty() && what != f.summary {
10183 first.push_str(&format!(" with {what}"));
10184 }
10185 first.push('.');
10186 if superseded_by_retry(f) {
10187 return format!("{first} A later attempt got past it.");
10188 }
10189 let mut fix = clip_words(&f.action, 14);
10190 if !f.changes.is_empty() {
10191 let files: Vec<String> = f
10192 .changes
10193 .iter()
10194 .map(String::as_str)
10195 .map(recipe_stem)
10196 .collect();
10197 fix.push_str(&format!(" in {}", files.join(", ")));
10198 }
10199 if fix.is_empty() {
10200 first
10201 } else {
10202 format!("{first} Fix: {fix}.")
10203 }
10204}
10205
10206fn finding_entities(campaign: &Campaign, f: &Finding) -> Vec<String> {
10209 let mut out: Vec<String> = Vec::new();
10210 for stem in [&f.module, &f.recipe] {
10211 if stem.is_empty() || out.contains(stem) {
10212 continue;
10213 }
10214 out.push(stem.clone());
10215 if let Some(name) = stem.split('-').next() {
10216 if !name.is_empty() && name != stem && !out.iter().any(|e| e == name) {
10217 out.push(name.to_string());
10218 }
10219 }
10220 }
10221 if !campaign.package.is_empty() {
10222 out.push(campaign.package.clone());
10223 }
10224 out.push(f.class.clone());
10225 out.dedup();
10226 out
10227}
10228
10229#[must_use]
10232pub fn format_findings(campaign: &Campaign) -> String {
10233 let mut out = format!(
10234 "{} {} on {}: {} after {} attempt{}, {} finding{}\n",
10235 campaign.package,
10236 campaign.version,
10237 campaign.target,
10238 campaign.status,
10239 campaign.attempts,
10240 if campaign.attempts == 1 { "" } else { "s" },
10241 campaign.findings.len(),
10242 if campaign.findings.len() == 1 {
10243 ""
10244 } else {
10245 "s"
10246 },
10247 );
10248 for f in &campaign.findings {
10249 let tail = if f.action.is_empty() {
10250 f.summary.clone()
10251 } else {
10252 format!("fix: {}", f.action)
10253 };
10254 out.push_str(&format!(
10255 "{}\t{}\t{}/{}\t{}\t{}\t{}\n",
10256 f.id,
10257 f.status,
10258 f.class,
10259 f.disposition,
10260 f.stage,
10261 if f.module.is_empty() {
10262 &f.recipe
10263 } else {
10264 &f.module
10265 },
10266 tail
10267 ));
10268 }
10269 out
10270}
10271
10272#[derive(Debug, Clone, PartialEq, Eq)]
10274pub struct Remembered {
10275 pub id: String,
10276 pub lesson: String,
10277 pub result: String,
10280}
10281
10282pub fn remember_findings(state: &Path, issue: Option<&str>, all: bool) -> Result<Vec<Remembered>> {
10291 let campaign = read_campaign(state)?;
10292 let client = pack()?;
10293 let workspace = client.workspace();
10294 let mut out = Vec::new();
10295 for f in &campaign.findings {
10296 if !all && superseded_by_retry(f) {
10297 out.push(Remembered {
10298 id: f.id.clone(),
10299 lesson: String::new(),
10300 result: "skipped: a later attempt got past it, nothing was learned".into(),
10301 });
10302 continue;
10303 }
10304 if !all && f.status != "resolved" {
10305 out.push(Remembered {
10306 id: f.id.clone(),
10307 lesson: String::new(),
10308 result: format!("skipped: {}", f.status),
10309 });
10310 continue;
10311 }
10312 let lesson = finding_lesson(&campaign, f);
10313 let mut atom = atom_body("lesson", &lesson, &workspace);
10314 add_entities(&mut atom, finding_entities(&campaign, f));
10315 let result = match client.post_atom(&atom) {
10316 Ok(body) => format!(
10317 "{}{}",
10318 body["id"].as_str().unwrap_or("written"),
10319 revision_note(&body)
10320 ),
10321 Err(e) => format!("refused: {e}"),
10322 };
10323 out.push(Remembered {
10324 id: f.id.clone(),
10325 lesson,
10326 result,
10327 });
10328 }
10329 if let Some(issue) = issue.map(str::trim).filter(|i| !i.is_empty()) {
10330 let name = format!(
10331 "{} {} campaign state on {}, {} after {} attempts",
10332 campaign.package, campaign.version, campaign.target, campaign.status, campaign.attempts
10333 );
10334 let seat = seat_name();
10335 let said = match run_captured(
10338 "deedar",
10339 &[
10340 "create",
10341 "file",
10342 "--name",
10343 &name,
10344 "--path",
10345 &state.display().to_string(),
10346 "--agent",
10347 &seat,
10348 ],
10349 ) {
10350 Ok(said) => said.stdout,
10351 Err(e) if e.to_string().contains("deed frozen") => e.to_string(),
10352 Err(e) => return Err(e),
10353 };
10354 let accession = said
10357 .split_whitespace()
10358 .find_map(|w| {
10359 let at = w.find("deed-")?;
10360 let tail = &w[at..];
10361 let end = tail
10362 .find(|c: char| !c.is_ascii_alphanumeric() && c != '-')
10363 .unwrap_or(tail.len());
10364 Some(tail[..end].to_string())
10365 })
10366 .filter(|a| a.len() > "deed-".len())
10367 .context("findings: deedar create printed no accession")?;
10368 run_captured("vissue", &["deed", issue, "--add", &accession])?;
10369 let _ = persist_tracker(issue, "cited the campaign state");
10370 out.push(Remembered {
10371 id: "state".into(),
10372 lesson: name,
10373 result: format!("cited on {issue} as {accession}"),
10374 });
10375 }
10376 Ok(out)
10377}
10378
10379#[must_use]
10380pub fn format_remembered(rows: &[Remembered]) -> String {
10381 rows.iter()
10382 .map(|r| {
10383 if r.lesson.is_empty() {
10384 format!("{}\t{}\n", r.id, r.result)
10385 } else {
10386 format!("{}\t{}\n\t{}\n", r.id, r.result, r.lesson)
10387 }
10388 })
10389 .collect()
10390}
10391
10392#[derive(Debug, Clone, PartialEq, Eq)]
10394pub struct BumpRow {
10395 pub id: String,
10398 pub module: String,
10400 pub recipe: String,
10402 pub blockers: Vec<String>,
10404 pub result: String,
10406}
10407
10408fn module_stem(name: &str, version: &str, toolchain: Option<(&str, &str)>) -> String {
10410 match toolchain {
10411 Some((tn, tv)) if !tn.is_empty() && tn != "system" => {
10412 format!("{name}-{version}-{tn}-{tv}")
10413 }
10414 _ => format!("{name}-{version}"),
10415 }
10416}
10417
10418#[must_use]
10421pub fn bump_issue_id(project: &str, module: &str, generation: &str) -> String {
10422 let hex = work_id(&format!("bump:{module}:{generation}"));
10423 let mut n = u128::from_str_radix(&hex[..24], 16).unwrap_or(0);
10424 const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
10425 let mut out = Vec::new();
10426 for _ in 0..8 {
10427 out.push(DIGITS[(n % 36) as usize]);
10428 n /= 36;
10429 }
10430 format!("{project}-{}", String::from_utf8(out).unwrap_or_default())
10431}
10432
10433fn purl_name(purl: &str) -> String {
10435 purl.rsplit('/')
10436 .next()
10437 .unwrap_or(purl)
10438 .split('@')
10439 .next()
10440 .unwrap_or(purl)
10441 .to_string()
10442}
10443
10444pub fn bump_rows(
10452 bundle: &Path,
10453 project: &str,
10454 generation: Option<&str>,
10455) -> Result<(String, Vec<BumpRow>)> {
10456 let lock_path = bundle.join("locks").join("default.lock.json");
10457 let sbom_path = bundle.join("package.sbom.cdx.json");
10458 let lock: Value = serde_json::from_str(
10459 &std::fs::read_to_string(&lock_path)
10460 .with_context(|| format!("bump-plan: cannot read {}", lock_path.display()))?,
10461 )
10462 .with_context(|| format!("bump-plan: {} is not JSON", lock_path.display()))?;
10463 let sbom: Value = serde_json::from_str(
10464 &std::fs::read_to_string(&sbom_path)
10465 .with_context(|| format!("bump-plan: cannot read {}", sbom_path.display()))?,
10466 )
10467 .with_context(|| format!("bump-plan: {} is not JSON", sbom_path.display()))?;
10468 let tc = &lock["toolchain"];
10469 let generation = generation.map(str::to_string).unwrap_or_else(|| {
10470 format!(
10471 "{}/{}",
10472 tc["name"].as_str().unwrap_or("system"),
10473 tc["version"].as_str().unwrap_or("")
10474 )
10475 .trim_end_matches('/')
10476 .to_string()
10477 });
10478 let mut modules: Vec<(String, String, String)> = Vec::new(); let root_name = lock["package"].as_str().unwrap_or("").to_string();
10481 let root_stem = module_stem(
10482 &root_name,
10483 lock["version"].as_str().unwrap_or(""),
10484 Some((
10485 tc["name"].as_str().unwrap_or(""),
10486 tc["version"].as_str().unwrap_or(""),
10487 )),
10488 ) + lock["versionsuffix"].as_str().unwrap_or("");
10489 modules.push((root_name.clone(), root_stem, String::new()));
10490 for dep in lock["dependencies"].as_array().into_iter().flatten() {
10493 let name = dep["name"].as_str().unwrap_or("").to_string();
10494 let dtc = &dep["toolchain"];
10495 let stem = module_stem(
10496 &name,
10497 dep["version"].as_str().unwrap_or(""),
10498 Some((
10499 dtc["name"].as_str().unwrap_or(""),
10500 dtc["version"].as_str().unwrap_or(""),
10501 )),
10502 );
10503 let recipe = dep["easyconfig_path"].as_str().unwrap_or("").to_string();
10504 if !name.is_empty() && !modules.iter().any(|(n, _, _)| *n == name) {
10505 modules.push((name, stem, recipe));
10506 }
10507 }
10508 let id_of = |name: &str| -> Option<String> {
10509 modules
10510 .iter()
10511 .find(|(n, _, _)| n == name)
10512 .map(|(_, stem, _)| bump_issue_id(project, stem, &generation))
10513 };
10514 let mut edges: std::collections::BTreeMap<String, Vec<String>> = Default::default();
10516 for d in sbom["dependencies"].as_array().into_iter().flatten() {
10517 let from = purl_name(d["ref"].as_str().unwrap_or(""));
10518 for on in d["dependsOn"].as_array().into_iter().flatten() {
10519 let to = purl_name(on.as_str().unwrap_or(""));
10520 if let Some(id) = id_of(&to) {
10521 edges.entry(from.clone()).or_default().push(id);
10522 }
10523 }
10524 }
10525 let rows = modules
10526 .iter()
10527 .map(|(name, stem, recipe)| BumpRow {
10528 id: bump_issue_id(project, stem, &generation),
10529 module: stem.clone(),
10530 recipe: recipe.clone(),
10531 blockers: edges.get(name).cloned().unwrap_or_default(),
10532 result: "would make".into(),
10533 })
10534 .collect();
10535 Ok((generation, rows))
10536}
10537
10538pub fn bump_plan(
10548 bundle: &Path,
10549 project: &str,
10550 parent: &str,
10551 generation: Option<&str>,
10552 dry: bool,
10553) -> Result<(String, Vec<BumpRow>)> {
10554 let (generation, mut rows) = bump_rows(bundle, project, generation)?;
10555 if dry {
10556 return Ok((generation, rows));
10557 }
10558 for row in &mut rows {
10559 let exists = tracker_show_json(&row.id).is_ok();
10560 if exists {
10561 row.result = "held".into();
10562 } else {
10563 let title = format!("Bump {} onto {generation}", row.module);
10564 let body = if row.recipe.is_empty() {
10565 format!("The bundle at {} names this module. Ladder: recipe check, package bump, lint, then the campaign.", bundle.display())
10566 } else {
10567 format!("Recipe {} in the bundle at {}. Ladder: recipe check, package bump, lint, then the campaign.", row.recipe, bundle.display())
10568 };
10569 run_captured(
10570 "vissue",
10571 &[
10572 "create", "-p", project, "--id", &row.id, "--parent", parent, "-t", "task",
10573 "--quiet", "--body", &body, &title,
10574 ],
10575 )
10576 .with_context(|| format!("bump-plan: create {} ({})", row.id, row.module))?;
10577 row.result = "made".into();
10578 }
10579 }
10580 for row in &rows {
10582 let held: Vec<String> = tracker_show_json(&row.id)
10583 .ok()
10584 .and_then(|v| v["blocked_by"].as_array().cloned())
10585 .into_iter()
10586 .flatten()
10587 .filter_map(|v| v.as_str().map(str::to_string))
10588 .collect();
10589 for dep in &row.blockers {
10590 if held.iter().any(|h| h == dep) {
10591 continue;
10592 }
10593 run_captured("vissue", &["update", &row.id, "--block", dep])
10594 .with_context(|| format!("bump-plan: {} --block {dep}", row.id))?;
10595 }
10596 }
10597 if let Some(first) = rows.first() {
10599 let _ = persist_tracker(&first.id, "planned the bump");
10600 }
10601 Ok((generation, rows))
10602}
10603
10604#[must_use]
10605pub fn format_bump_rows(generation: &str, rows: &[BumpRow]) -> String {
10606 let mut out = format!(
10607 "{} module{} onto {generation}\n",
10608 rows.len(),
10609 if rows.len() == 1 { "" } else { "s" }
10610 );
10611 for r in rows {
10612 out.push_str(&format!(
10613 "{}\t{}\t{}\tafter {}\n",
10614 r.id,
10615 r.result,
10616 r.module,
10617 if r.blockers.is_empty() {
10618 "nothing".to_string()
10619 } else {
10620 r.blockers.join(" ")
10621 }
10622 ));
10623 }
10624 out
10625}
10626
10627#[cfg(test)]
10628mod tests {
10629 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
10632 static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
10633 ENV.lock().unwrap_or_else(|e| e.into_inner())
10634 }
10635
10636 #[test]
10638 fn a_tilde_tracker_root_expands_against_home() {
10639 use super::expand_leading_tilde as x;
10640 assert_eq!(x("~/vault", "/home/s"), Some("/home/s/vault".into()));
10641 assert_eq!(x("~", "/home/s/"), Some("/home/s".into()));
10642 assert_eq!(x("/abs/vault", "/home/s"), None);
10643 assert_eq!(x("~other/vault", "/home/s"), None);
10644 }
10645
10646 #[test]
10649 fn a_slow_tracker_push_finishes_in_the_background() {
10650 let _env = env_guard();
10651 let dir = tempfile::tempdir().unwrap();
10652 let (root, remote, hooks) = (
10653 dir.path().join("work"),
10654 dir.path().join("remote.git"),
10655 dir.path().join("hooks"),
10656 );
10657 let git = |cwd: &std::path::Path, args: &[&str]| {
10658 let o = std::process::Command::new("git")
10659 .arg("-C")
10660 .arg(cwd)
10661 .args(args)
10662 .output()
10663 .unwrap();
10664 assert!(
10665 o.status.success(),
10666 "git {args:?}: {}",
10667 String::from_utf8_lossy(&o.stderr)
10668 );
10669 };
10670 std::fs::create_dir_all(root.join("Software/probe")).unwrap();
10671 std::fs::create_dir_all(&hooks).unwrap();
10672 git(
10673 dir.path(),
10674 &["init", "-q", "--bare", remote.to_str().unwrap()],
10675 );
10676 git(&root, &["init", "-q"]);
10677 for (k, v) in [
10678 ("user.email", "seat@example.invalid"),
10679 ("user.name", "seat"),
10680 ("core.hooksPath", hooks.to_str().unwrap()),
10681 ] {
10682 git(&root, &["config", k, v]);
10683 }
10684 let hook = hooks.join("pre-push");
10685 std::fs::write(&hook, "#!/bin/sh\nsleep 4\n").unwrap();
10686 use std::os::unix::fs::PermissionsExt;
10687 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
10688 let issues = root.join("Software/probe/issues.org");
10689 let heading = "* TODO [#C] Probe\n:PROPERTIES:\n:ID: probe-c3d4\n:END:\n";
10690 std::fs::write(&issues, heading).unwrap();
10691 git(&root, &["add", "."]);
10692 git(&root, &["commit", "-q", "-m", "seed"]);
10693 git(
10694 &root,
10695 &["remote", "add", "origin", remote.to_str().unwrap()],
10696 );
10697 std::fs::write(&hook, "#!/bin/sh\nexit 0\n").unwrap();
10698 git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
10699 std::fs::write(&hook, "#!/bin/sh\nsleep 4\n").unwrap();
10700 std::env::set_var("VISSUE_ROOT", &root);
10701 std::env::set_var("VISSUE_NO_ROUTE", "1");
10702 std::env::remove_var("ISSUE_ROOT");
10703 std::env::remove_var("LJOS_TRACKER_GIT");
10704 std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "1");
10705 std::env::set_var("XDG_RUNTIME_DIR", dir.path());
10706
10707 std::fs::write(&issues, heading.replace("TODO", "STARTED")).unwrap();
10708 let started = std::time::Instant::now();
10709 let said = super::persist_tracker("probe-c3d4", "claimed");
10710 assert!(
10711 started.elapsed() < std::time::Duration::from_secs(3),
10712 "{said}"
10713 );
10714 assert!(said.contains("still running after 1s"), "{said}");
10715
10716 std::thread::sleep(std::time::Duration::from_secs(5));
10717 std::fs::write(&hook, "#!/bin/sh\nexit 0\n").unwrap();
10718 std::fs::write(&issues, heading.replace("TODO", "DONE")).unwrap();
10719 std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "10");
10720 let said = super::persist_tracker("probe-c3d4", "finished");
10721 assert!(said.contains("committed and pushed"), "{said}");
10722 for var in [
10723 "VISSUE_ROOT",
10724 "VISSUE_NO_ROUTE",
10725 "LJOS_TRACKER_PUSH_WAIT",
10726 "XDG_RUNTIME_DIR",
10727 ] {
10728 std::env::remove_var(var);
10729 }
10730 }
10731
10732 #[test]
10735 fn a_tracker_write_is_committed_alone() {
10736 let _env = env_guard();
10737 let dir = tempfile::tempdir().unwrap();
10738 let root = dir.path();
10739 let run = |args: &[&str]| {
10740 let o = std::process::Command::new("git")
10741 .arg("-C")
10742 .arg(root)
10743 .args(args)
10744 .output()
10745 .unwrap();
10746 assert!(
10747 o.status.success(),
10748 "git {args:?}: {}",
10749 String::from_utf8_lossy(&o.stderr)
10750 );
10751 String::from_utf8_lossy(&o.stdout).to_string()
10752 };
10753 run(&["init", "-q"]);
10754 run(&["config", "user.email", "seat@example.invalid"]);
10755 run(&["config", "user.name", "seat"]);
10756 run(&["config", "core.hooksPath", "/dev/null"]);
10757 std::fs::create_dir_all(root.join("Software/probe")).unwrap();
10758 let issues = root.join("Software/probe/issues.org");
10759 let heading = "* TODO [#C] Probe\n:PROPERTIES:\n:ID: probe-a1b2\n:END:\n";
10760 std::fs::write(&issues, heading).unwrap();
10761 std::fs::write(root.join("other.org"), "one\n").unwrap();
10762 run(&["add", "."]);
10763 run(&["commit", "-q", "-m", "seed"]);
10764 std::env::set_var("VISSUE_ROOT", root);
10765 std::env::set_var("VISSUE_NO_ROUTE", "1");
10766 std::env::remove_var("ISSUE_ROOT");
10767 std::env::set_var("LJOS_TRACKER_GIT", "commit");
10768 assert!(super::persist_tracker("probe-a1b2", "claimed").contains("nothing to commit"));
10769
10770 std::fs::write(&issues, heading.replace("TODO", "STARTED")).unwrap();
10771 std::fs::write(root.join("other.org"), "two\n").unwrap();
10772 run(&["add", "other.org"]);
10773 let said = super::persist_tracker("probe-a1b2", "claimed");
10774 assert!(
10775 said.contains("committed chore(issues): probe-a1b2 claimed"),
10776 "{said}"
10777 );
10778 assert_eq!(
10779 run(&["log", "-1", "--format=%s"]).trim(),
10780 "chore(issues): probe-a1b2 claimed"
10781 );
10782 assert_eq!(
10784 run(&["diff", "--cached", "--name-only"]).trim(),
10785 "other.org"
10786 );
10787
10788 std::fs::write(&issues, heading.replace("TODO", "DONE")).unwrap();
10789 std::env::set_var("LJOS_TRACKER_GIT", "off");
10790 assert!(super::persist_tracker("probe-a1b2", "finished").contains("off"));
10791 for var in ["VISSUE_ROOT", "VISSUE_NO_ROUTE", "LJOS_TRACKER_GIT"] {
10792 std::env::remove_var(var);
10793 }
10794 }
10795
10796 #[test]
10799 fn a_tracker_commit_with_no_remote_still_reports_the_commit() {
10800 let _env = env_guard();
10801 let dir = tempfile::tempdir().unwrap();
10802 let root = dir.path();
10803 let run = |args: &[&str]| {
10804 let o = std::process::Command::new("git")
10805 .arg("-C")
10806 .arg(root)
10807 .args(args)
10808 .output()
10809 .unwrap();
10810 assert!(
10811 o.status.success(),
10812 "git {args:?}: {}",
10813 String::from_utf8_lossy(&o.stderr)
10814 );
10815 String::from_utf8_lossy(&o.stdout).to_string()
10816 };
10817 run(&["init", "-q"]);
10818 run(&["config", "user.email", "seat@example.invalid"]);
10819 run(&["config", "user.name", "seat"]);
10820 run(&["config", "core.hooksPath", "/dev/null"]);
10821 std::fs::create_dir_all(root.join("Software/probe")).unwrap();
10822 let issues = root.join("Software/probe/issues.org");
10823 let heading = "* TODO [#C] Probe\n:PROPERTIES:\n:ID: probe-a1b2\n:END:\n";
10824 std::fs::write(&issues, heading).unwrap();
10825 run(&["add", "."]);
10826 run(&["commit", "-q", "-m", "seed"]);
10827 std::fs::write(&issues, heading.replace("TODO", "STARTED")).unwrap();
10828 std::env::set_var("VISSUE_ROOT", root);
10829 std::env::set_var("VISSUE_NO_ROUTE", "1");
10830 std::env::remove_var("ISSUE_ROOT");
10831 std::env::remove_var("LJOS_TRACKER_GIT");
10832 let said = super::persist_tracker("probe-a1b2", "claimed");
10833 assert!(
10834 said.contains("tracker git: committed chore(issues): probe-a1b2 claimed"),
10835 "{said}"
10836 );
10837 assert!(
10838 said.contains("push refused") || said.contains("not pushed"),
10839 "a missing remote must still name the commit: {said}"
10840 );
10841 assert_eq!(
10842 run(&["log", "-1", "--format=%s"]).trim(),
10843 "chore(issues): probe-a1b2 claimed"
10844 );
10845 for var in ["VISSUE_ROOT", "VISSUE_NO_ROUTE", "LJOS_TRACKER_GIT"] {
10846 std::env::remove_var(var);
10847 }
10848 }
10849
10850 #[test]
10853 fn a_claim_graph_nobody_made_yet_is_not_a_fault() {
10854 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";
10855 assert_eq!(
10856 super::claim_graph_absent(fresh),
10857 Some("/h/claims".to_string())
10858 );
10859 assert_eq!(
10860 super::claim_graph_absent("claimdag exited exit status: 1: work.bin is corrupt"),
10861 None
10862 );
10863 assert_eq!(
10864 super::claim_graph_absent("no work graph at /h/claims: permission denied"),
10865 None
10866 );
10867 }
10868
10869 #[test]
10871 fn tracker_row_names_the_root_and_refuses_a_private_one() {
10872 let dir = tempfile::tempdir().unwrap();
10873 std::fs::create_dir(dir.path().join("Software")).unwrap();
10874 let id = |root: &str| format!("vissue 0.16.2\nprotocol: 1\nroot={root}\nprefix=Software\n");
10875 let root = dir.path().display().to_string();
10876
10877 let (state, ok) = super::tracker_state(&id(&root), "VISSUE_ROOT=x");
10878 assert!(ok, "{state}");
10879 assert!(state.contains(&format!("root={root}")), "{state}");
10880 assert!(state.contains("from VISSUE_ROOT=x"), "{state}");
10881
10882 let (state, ok) = super::tracker_state(&id("~/Git/vault"), "VISSUE_ROOT=~/Git/vault");
10883 assert!(!ok);
10884 assert!(state.contains("relative root"), "{state}");
10885
10886 let missing = dir.path().join("gone").display().to_string();
10887 assert!(!super::tracker_state(&id(&missing), "cwd").1);
10888
10889 std::fs::remove_dir(dir.path().join("Software")).unwrap();
10890 let (state, ok) = super::tracker_state(&id(&root), "cwd");
10891 assert!(!ok);
10892 assert!(state.contains("no prefix directory"), "{state}");
10893
10894 assert!(!super::tracker_state("vissue 0.16.1\n", "cwd").1);
10895 }
10896
10897 fn git_scratch(root: &std::path::Path) {
10898 let run = |args: &[&str]| {
10899 let o = std::process::Command::new("git")
10900 .arg("-C")
10901 .arg(root)
10902 .args(args)
10903 .output()
10904 .unwrap();
10905 assert!(
10906 o.status.success(),
10907 "git {args:?}: {}",
10908 String::from_utf8_lossy(&o.stderr)
10909 );
10910 };
10911 run(&["init", "-q"]);
10912 run(&["config", "user.email", "seat@example.invalid"]);
10913 run(&["config", "user.name", "seat"]);
10914 run(&["config", "core.hooksPath", "/dev/null"]);
10915 }
10916
10917 #[test]
10920 fn tracker_row_fails_when_two_remotes_disagree() {
10921 let _env = env_guard();
10922 let dir = tempfile::tempdir().unwrap();
10923 let root = dir.path().join("work");
10924 std::fs::create_dir_all(root.join("Software")).unwrap();
10925 let git = |cwd: &std::path::Path, args: &[&str]| {
10926 let o = std::process::Command::new("git")
10927 .arg("-C")
10928 .arg(cwd)
10929 .args(args)
10930 .output()
10931 .unwrap();
10932 assert!(
10933 o.status.success(),
10934 "git {args:?}: {}",
10935 String::from_utf8_lossy(&o.stderr)
10936 );
10937 };
10938 for bare in ["origin.git", "mirror.git"] {
10939 git(dir.path(), &["init", "-q", "--bare", bare]);
10940 }
10941 git_scratch(&root);
10942 std::fs::write(root.join("Software/.keep"), "").unwrap();
10943 git(&root, &["add", "."]);
10944 git(&root, &["commit", "-q", "-m", "seed"]);
10945 for name in ["origin", "mirror"] {
10946 let url = dir.path().join(format!("{name}.git"));
10947 git(&root, &["remote", "add", name, url.to_str().unwrap()]);
10948 git(&root, &["push", "-q", name, "HEAD:refs/heads/main"]);
10949 }
10950 git(&root, &["branch", "-q", "-M", "main"]);
10951 git(&root, &["fetch", "-q", "--all"]);
10952 git(&root, &["branch", "-q", "-u", "origin/main"]);
10953 let (state, ok) = super::tracker_git_drift(&root).unwrap();
10954 assert!(ok, "{state}");
10955 assert_eq!(
10956 super::tracker_mirrors(&root, "origin/main").unwrap(),
10957 vec![("mirror".to_string(), "main".to_string())],
10958 "a tracker push reaches the mirror too"
10959 );
10960
10961 std::fs::write(root.join("Software/.keep"), "one side\n").unwrap();
10962 git(&root, &["commit", "-qam", "only origin"]);
10963 git(&root, &["push", "-q", "origin", "main"]);
10964 git(&root, &["fetch", "-q", "--all"]);
10965 let (state, ok) = super::tracker_git_drift(&root).unwrap();
10966 assert!(!ok, "{state}");
10967 assert!(
10968 state.contains("mirror/main differs from origin/main"),
10969 "{state}"
10970 );
10971
10972 git(&root, &["push", "-q", "mirror", "main"]);
10973 git(&root, &["fetch", "-q", "--all"]);
10974 let (state, ok) = super::tracker_git_drift(&root).unwrap();
10975 assert!(ok, "{state}");
10976 }
10977
10978 #[test]
10981 fn tracker_row_fails_when_origin_never_got_the_commits() {
10982 let _env = env_guard();
10983 let dir = tempfile::tempdir().unwrap();
10984 let (root, remote) = (dir.path().join("work"), dir.path().join("remote.git"));
10985 std::fs::create_dir_all(root.join("Software")).unwrap();
10986 let git = |cwd: &std::path::Path, args: &[&str]| {
10987 let o = std::process::Command::new("git")
10988 .arg("-C")
10989 .arg(cwd)
10990 .args(args)
10991 .output()
10992 .unwrap();
10993 assert!(
10994 o.status.success(),
10995 "git {args:?}: {}",
10996 String::from_utf8_lossy(&o.stderr)
10997 );
10998 };
10999 git(
11000 dir.path(),
11001 &["init", "-q", "--bare", remote.to_str().unwrap()],
11002 );
11003 git_scratch(&root);
11004 std::fs::write(root.join("Software/.keep"), "").unwrap();
11005 git(&root, &["add", "."]);
11006 git(&root, &["commit", "-q", "-m", "seed"]);
11007 git(
11008 &root,
11009 &["remote", "add", "origin", remote.to_str().unwrap()],
11010 );
11011 git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
11012
11013 let id = |r: &str| format!("vissue 0.16.2\nprotocol: 1\nroot={r}\nprefix=Software\n");
11014 let root_s = root.display().to_string();
11015 std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "5");
11016 std::env::set_var("XDG_RUNTIME_DIR", dir.path());
11017
11018 let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
11019 assert!(ok, "{state}");
11020 assert!(state.contains("0 unpushed"), "{state}");
11021
11022 std::fs::write(root.join("Software/.keep"), "local\n").unwrap();
11023 git(&root, &["add", "."]);
11024 git(&root, &["commit", "-q", "-m", "ahead"]);
11025 let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
11026 assert!(ok, "a commit younger than the wait stays healthy: {state}");
11027 assert!(state.contains("1 unpushed"), "{state}");
11028
11029 std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "0");
11030 let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
11031 assert!(!ok, "{state}");
11032 assert!(state.contains("1 unpushed"), "{state}");
11033
11034 let mut dead = std::process::Command::new("true").spawn().unwrap();
11035 let dead_pid = dead.id();
11036 let _ = dead.wait();
11037 let logs = dir.path().join("ljos");
11038 std::fs::create_dir_all(&logs).unwrap();
11039 std::fs::write(
11040 logs.join(format!("tracker-push-{dead_pid}.log")),
11041 "remote: pre-push hook declined\nerror: failed to push some refs\n",
11042 )
11043 .unwrap();
11044 let (state, ok) = super::tracker_state(&id(&root_s), "VISSUE_ROOT=x");
11045 assert!(!ok, "{state}");
11046 assert!(state.contains("1 unpushed"), "{state}");
11047 assert!(
11048 state.contains("last push refused: remote: pre-push hook declined"),
11049 "{state}"
11050 );
11051
11052 for var in ["LJOS_TRACKER_PUSH_WAIT", "XDG_RUNTIME_DIR"] {
11053 std::env::remove_var(var);
11054 }
11055 }
11056
11057 #[test]
11058 fn tracker_row_stays_healthy_while_a_background_push_runs() {
11059 let _env = env_guard();
11060 let dir = tempfile::tempdir().unwrap();
11061 let (root, remote) = (dir.path().join("work"), dir.path().join("remote.git"));
11062 std::fs::create_dir_all(root.join("Software")).unwrap();
11063 let git = |cwd: &std::path::Path, args: &[&str]| {
11064 let o = std::process::Command::new("git")
11065 .arg("-C")
11066 .arg(cwd)
11067 .args(args)
11068 .output()
11069 .unwrap();
11070 assert!(
11071 o.status.success(),
11072 "git {args:?}: {}",
11073 String::from_utf8_lossy(&o.stderr)
11074 );
11075 };
11076 git(
11077 dir.path(),
11078 &["init", "-q", "--bare", remote.to_str().unwrap()],
11079 );
11080 git_scratch(&root);
11081 std::fs::write(root.join("Software/.keep"), "").unwrap();
11082 git(&root, &["add", "."]);
11083 git(&root, &["commit", "-q", "-m", "seed"]);
11084 git(
11085 &root,
11086 &["remote", "add", "origin", remote.to_str().unwrap()],
11087 );
11088 git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
11089 std::fs::write(root.join("Software/.keep"), "local\n").unwrap();
11090 git(&root, &["add", "."]);
11091 git(&root, &["commit", "-q", "-m", "ahead"]);
11092
11093 let mut sleeper = std::process::Command::new("sleep")
11094 .arg("8")
11095 .spawn()
11096 .unwrap();
11097 let pid = sleeper.id();
11098 let logs = dir.path().join("ljos");
11099 std::fs::create_dir_all(&logs).unwrap();
11100 std::fs::write(logs.join(format!("tracker-push-{pid}.log")), "").unwrap();
11101 std::env::set_var("LJOS_TRACKER_PUSH_WAIT", "0");
11102 std::env::set_var("XDG_RUNTIME_DIR", dir.path());
11103 let id = format!(
11104 "vissue 0.16.2\nprotocol: 1\nroot={}\nprefix=Software\n",
11105 root.display()
11106 );
11107 let (state, ok) = super::tracker_state(&id, "VISSUE_ROOT=x");
11108 let _ = sleeper.kill();
11109 let _ = sleeper.wait();
11110 assert!(ok, "{state}");
11111 assert!(state.contains("1 unpushed; push still running"), "{state}");
11112 for var in ["LJOS_TRACKER_PUSH_WAIT", "XDG_RUNTIME_DIR"] {
11113 std::env::remove_var(var);
11114 }
11115 }
11116
11117 #[test]
11118 fn a_session_id_occupies_not_the_product_name_on_the_box() {
11119 let _g = env_guard();
11120 unsafe {
11121 std::env::remove_var("VISSUE_AGENT");
11122 std::env::set_var("LJOS_SEAT", "runner-x");
11123 std::env::set_var("GROK_SESSION_ID", "01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11124 }
11125 let holder = resolve_assignee(None);
11126 assert_eq!(
11127 holder, "01a09b25-ffe9-7972-881a-3cee2ea6efd6",
11128 "the session is the occupancy, not a prefix and not the seat"
11129 );
11130 assert_eq!(resolve_assignee(Some("seat")), holder);
11131 assert_eq!(
11132 resolve_assignee(Some("runner-x")),
11133 holder,
11134 "the process naming itself is omitted"
11135 );
11136 assert_eq!(resolve_assignee(Some("alice")), "alice");
11137 assert_eq!(seat_name(), "runner-x");
11138 unsafe {
11139 std::env::remove_var("GROK_SESSION_ID");
11140 std::env::remove_var("LJOS_SEAT");
11141 }
11142 }
11143
11144 #[test]
11145 fn two_session_ids_that_share_a_prefix_occupy_different_slots() {
11146 let _g = env_guard();
11147 unsafe {
11148 std::env::remove_var("LJOS_SEAT");
11149 std::env::remove_var("VISSUE_AGENT");
11150 std::env::set_var("GROK_SESSION_ID", "01a09b25-aaaa-7972-881a-3cee2ea6efd6");
11151 }
11152 let a = resolve_assignee(None);
11153 unsafe {
11154 std::env::set_var("GROK_SESSION_ID", "01a09b25-bbbb-7972-881a-3cee2ea6efd6");
11155 }
11156 let b = resolve_assignee(None);
11157 assert_ne!(
11158 a, b,
11159 "a shared eight-character prefix is not one conversation"
11160 );
11161 assert_eq!(a, "01a09b25-aaaa-7972-881a-3cee2ea6efd6");
11162 assert_eq!(b, "01a09b25-bbbb-7972-881a-3cee2ea6efd6");
11163 unsafe {
11164 std::env::remove_var("GROK_SESSION_ID");
11165 }
11166 }
11167
11168 #[test]
11169 fn a_named_holder_refusal_still_says_held_by_another() {
11170 let hold = Hold {
11171 assignee: "acme".into(),
11172 seat: "acme".into(),
11173 pid: 1,
11174 comm: "ljos".into(),
11175 since: "2026-01-01T00:00:00.000Z".into(),
11176 };
11177 let said = super::held_by_another_message("demo-aaaa", "brio", &hold, "still running");
11178 assert!(said.contains("held by another"), "{said}");
11179 assert!(said.contains("acme"), "{said}");
11180 assert!(said.contains("not by brio"), "{said}");
11181 }
11182
11183 #[test]
11185 fn two_seats_with_distinct_session_ids_are_distinct_holders() {
11186 let _g = env_guard();
11187 let dir = std::env::temp_dir().join(format!("ljos-rt-two-seat-{}", std::process::id()));
11188 std::fs::create_dir_all(&dir).unwrap();
11189 let session_keys: Vec<String> = std::env::vars()
11190 .map(|(k, _)| k)
11191 .filter(|k| k.ends_with("_SESSION_ID"))
11192 .collect();
11193 unsafe {
11194 std::env::set_var("XDG_RUNTIME_DIR", &dir);
11195 std::env::remove_var("VISSUE_AGENT");
11196 for k in &session_keys {
11197 std::env::remove_var(k);
11198 }
11199 std::env::set_var("LJOS_SEAT", "acme");
11200 std::env::set_var("ACME_SESSION_ID", "acme-sess-aaaaaa");
11201 }
11202 let a_seat = seat_name();
11203 let a_holder = resolve_assignee(None);
11204 unsafe {
11205 std::env::remove_var("ACME_SESSION_ID");
11206 std::env::set_var("LJOS_SEAT", "brio");
11207 std::env::set_var("BRIO_SESSION_ID", "brio-sess-bbbbbb");
11208 }
11209 let b_seat = seat_name();
11210 let b_holder = resolve_assignee(None);
11211 assert_eq!(a_seat, "acme");
11212 assert_eq!(b_seat, "brio");
11213 assert_eq!(a_holder, "acme-sess-aaaaaa");
11214 assert_eq!(b_holder, "brio-sess-bbbbbb");
11215 assert_ne!(a_holder, b_holder);
11216 unsafe {
11217 std::env::remove_var("LJOS_SEAT");
11218 std::env::remove_var("BRIO_SESSION_ID");
11219 std::env::remove_var("ACME_SESSION_ID");
11220 std::env::remove_var("XDG_RUNTIME_DIR");
11221 }
11222 }
11223
11224 #[test]
11225 fn occupancy_is_per_issue_so_two_sittings_do_not_unseat() {
11226 let _g = env_guard();
11227 unsafe {
11228 std::env::remove_var("LJOS_SEAT");
11229 std::env::remove_var("VISSUE_AGENT");
11230 }
11231 let holder = resolve_assignee(None);
11232 let a = occupancy_assignee(None, "ljos-aaaa");
11233 let b = occupancy_assignee(None, "ljos-bbbb");
11234 assert_ne!(
11235 a, b,
11236 "two issues under one conversation must not share a slot"
11237 );
11238 assert_eq!(a, format!("{holder}:ljos-aaaa"), "{a}");
11239 assert_eq!(b, format!("{holder}:ljos-bbbb"), "{b}");
11240 assert_eq!(
11241 occupancy_assignee(Some("alice"), "ljos-aaaa"),
11242 "alice:ljos-aaaa"
11243 );
11244 assert_eq!(
11245 occupancy_assignee(Some("alice"), "ljos-bbbb"),
11246 "alice:ljos-bbbb"
11247 );
11248 }
11249
11250 #[test]
11251 fn doctor_lists_ljos_hud_but_does_not_require_it() {
11252 assert!(SEAT_BINS
11253 .iter()
11254 .any(|(n, c)| *n == "ljos-hud" && *c == "ljos-hud"));
11255 assert!(!REQUIRED.contains(&"ljos-hud"));
11256 }
11257
11258 #[test]
11259 fn doctor_names_the_session_not_the_default_seat() {
11260 let _g = env_guard();
11261 let dir = std::env::temp_dir().join(format!("ljos-rt-doctor-{}", std::process::id()));
11264 std::fs::create_dir_all(&dir).unwrap();
11265 unsafe {
11266 std::env::set_var("XDG_RUNTIME_DIR", &dir);
11267 std::env::remove_var("LJOS_SEAT");
11268 std::env::remove_var("VISSUE_AGENT");
11269 std::env::set_var("GROK_SESSION_ID", "01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11270 }
11271 let row = format_seat_row();
11272 assert!(
11273 row.contains("01a09b25-ffe9-7972-881a-3cee2ea6efd6"),
11274 "doctor names the whole session: {row}"
11275 );
11276 assert!(
11277 row.contains("GROK_SESSION_ID"),
11278 "doctor names where the session came from: {row}"
11279 );
11280 assert!(!row.contains("the default"), "{row}");
11281 unsafe {
11282 std::env::remove_var("GROK_SESSION_ID");
11283 std::env::remove_var("XDG_RUNTIME_DIR");
11284 }
11285 let _ = std::fs::remove_dir_all(&dir);
11286 }
11287
11288 #[test]
11289 fn a_shared_name_does_not_occupy_the_whole_host() {
11290 let _g = env_guard();
11291 let holder = resolve_assignee(None);
11295 assert_eq!(resolve_assignee(Some("you")), holder);
11296 assert_eq!(resolve_assignee(Some("seat")), holder);
11297 assert_eq!(resolve_assignee(Some("agent")), holder);
11298 assert_ne!(holder, "seat");
11299 assert_eq!(resolve_assignee(Some("alice")), "alice");
11300 }
11301
11302 #[test]
11303 fn a_reading_supersedes_the_one_before_and_keeps_it_as_was() {
11304 assert_eq!(parse_every("7d").unwrap(), 7 * 86_400);
11305 assert_eq!(parse_every("24h").unwrap(), 86_400);
11306 assert_eq!(parse_every("2w").unwrap(), 14 * 86_400);
11307 assert_eq!(parse_every("90").unwrap(), 90);
11308 assert!(parse_every("soon").is_err());
11309 assert!(parse_every("0d").is_err());
11310 assert_eq!(
11311 stamp_after("2026-09-19T23:30:00.000Z", 3_600).as_deref(),
11312 Some("2026-09-20T00:30:00.000Z")
11313 );
11314 assert_eq!(trim_num(0.5790), "0.579");
11315 assert_eq!(trim_num(12.0), "12");
11316 assert_eq!(
11317 habit_text("mab cr all", 0.579, "acc", "job 11793"),
11318 "habit mab cr all stands at 0.579 acc (job 11793)."
11319 );
11320 let first = serde_json::json!({
11321 "id": "a1", "kind": "habit", "ts": "2026-09-12T10:00:00.000Z",
11322 "due_at": "2026-09-19T10:00:00.000Z",
11323 "habit": {"name": "mab cr all", "value": 0.535, "unit": "acc", "source": "11750", "every_s": 604800}
11324 });
11325 let second = serde_json::json!({
11326 "id": "a2", "kind": "habit", "ts": "2026-09-19T10:00:00.000Z",
11327 "due_at": "2026-09-26T10:00:00.000Z",
11328 "habit": {"name": "mab cr all", "value": 0.579, "unit": "acc", "source": "11793", "every_s": 604800,
11329 "was": 0.535, "was_ts": "2026-09-12T10:00:00.000Z"}
11330 });
11331 let other = serde_json::json!({
11332 "id": "l1", "kind": "lesson", "text": "not a habit", "ts": "2026-09-19T10:00:00.000Z"
11333 });
11334 let rows = readings_of(&[first.clone(), other, second]);
11336 assert_eq!(rows.len(), 1);
11337 assert_eq!(rows[0].id.as_deref(), Some("a2"));
11338 assert_eq!(rows[0].was, Some(0.535));
11339 let now = "2026-09-20T09:00:00.000Z";
11340 let line = format_readings(&rows, now);
11341 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}");
11342 let late = readings_of(&[first]);
11343 assert!(format_readings(&late, now).contains("next reading late (yesterday)"));
11344 assert_eq!(format_change(&late[0], now), "first reading");
11345 }
11346
11347 #[test]
11348 fn a_program_is_named_by_its_path_not_its_version() {
11349 assert!(version_like("2.1.266"));
11350 assert!(version_like("v18.2.0"));
11351 assert!(!version_like("acme"));
11352 let me = program_name(std::process::id(), "comm");
11355 assert!(!me.is_empty() && !version_like(&me), "{me}");
11356 }
11357
11358 #[test]
11359 fn a_hit_names_the_seat_that_wrote_it_only_when_that_is_another() {
11360 let ents = vec!["seat:brio".to_string(), "habit:x".to_string()];
11361 assert_eq!(other_seat(&ents, "acme-cli").as_deref(), Some("brio"));
11362 assert_eq!(other_seat(&ents, "brio"), None);
11363 assert_eq!(other_seat(&["habit:x".to_string()], "brio"), None);
11364 }
11365
11366 #[test]
11367 fn two_session_ids_that_share_a_prefix_take_two_slots() {
11368 let a = session_tag("01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11369 let b = session_tag("01a09b25-ffe9-7972-881a-3cee2ea6efd7");
11370 assert_ne!(a, b);
11371 assert_eq!(a.len(), 10);
11372 assert_eq!(a, session_tag(" 01a09b25-ffe9-7972-881a-3cee2ea6efd6 "));
11373 }
11374
11375 #[test]
11378 fn a_record_from_another_conversation_is_not_this_ones() {
11379 let ble = "1000000000.000001/4242".to_string();
11380 let me = "01a09b25-ffe9-7972-881a-000000000001".to_string();
11381 let other = "01a09b25-ffe9-7972-881a-000000000002".to_string();
11382 let mine = vec![ble.clone(), me.clone()];
11383 let theirs = format!("acme-cli\nsess-other\nids\t{ble}\t{other}\n");
11384 assert!(super::record_for(&theirs, &mine, "t".into()).is_none());
11385 let ours = format!("acme-cli\nsess-mine\nids\t{ble}\t{me}\n");
11386 assert_eq!(
11387 super::record_for(&ours, &mine, "t".into()).unwrap().holder,
11388 "sess-mine"
11389 );
11390 let shell = vec![ble.clone(), me.clone(), "9f9f9f9f-extra".into()];
11392 assert!(super::record_for(&ours, &shell, "t".into()).is_some());
11393 assert!(super::record_for("acme-cli\nsess-old\n", &mine, "t".into()).is_some());
11395 }
11396
11397 #[test]
11398 fn the_host_row_reads_oom_kills_and_this_logins_servers() {
11399 assert_eq!(
11400 parse_oom_kills("pgfault 12\noom_kill 43\nnr_free_pages 1\n"),
11401 Some(43)
11402 );
11403 assert_eq!(parse_oom_kills("pgfault 12\n"), None);
11404 assert_eq!(
11405 status_field("Name:\tx\nVmRSS:\t 2692 kB\n", "VmRSS:").as_deref(),
11406 Some("2692")
11407 );
11408 let row = host_row();
11409 assert_eq!(row.name, "host");
11410 assert!(row.state.contains("ljos-mcp"), "{}", row.state);
11411 }
11412
11413 #[test]
11414 fn a_library_default_client_name_is_not_a_seat() {
11415 assert_eq!(seat_for_client("Acme CLI"), "acme-cli");
11416 for library in ["mcp", "MCP", "mcp-client"] {
11417 let seat = seat_for_client(library);
11418 assert!(
11419 !LIBRARY_CLIENT_NAMES.contains(&seat.as_str()) || ancestry().is_empty(),
11420 "{library} named the seat {seat}"
11421 );
11422 }
11423 }
11424
11425 #[test]
11426 fn a_runner_started_inside_another_keeps_its_own_holder() {
11427 let _g = env_guard();
11428 let dir = std::env::temp_dir().join(format!("ljos-nest-{}", std::process::id()));
11429 std::fs::create_dir_all(&dir).unwrap();
11430 unsafe {
11431 std::env::set_var("XDG_RUNTIME_DIR", &dir);
11432 std::env::set_var("ACME_SESSION_ID", "01a09b25-1111-7972-881a-3cee2ea6efd6");
11433 }
11434 let parent = announce_seat("Acme CLI", 5151);
11435 let child = announce_seat("Brio Agent", 5252);
11437 assert_eq!(child.seat, "brio-agent");
11438 assert_ne!(child.holder, parent.holder);
11439 assert_eq!(
11440 seat_from_session_records()
11441 .expect("the parent's record")
11442 .holder,
11443 parent.holder,
11444 "the child leaves the parent's record alone"
11445 );
11446 retire_seat(5252);
11447 assert_eq!(
11448 seat_from_session_records()
11449 .expect("still the parent's")
11450 .holder,
11451 parent.holder,
11452 "the child's exit does not take the parent's record"
11453 );
11454 retire_seat(5151);
11455 assert!(seat_from_session_records().is_none());
11456 unsafe {
11457 std::env::remove_var("ACME_SESSION_ID");
11458 std::env::remove_var("XDG_RUNTIME_DIR");
11459 }
11460 let _ = std::fs::remove_dir_all(&dir);
11461 }
11462
11463 #[test]
11464 fn a_thread_named_on_a_call_holds_as_its_shells_do() {
11465 let _g = env_guard();
11466 let dir = std::env::temp_dir().join(format!("ljos-thread-{}", std::process::id()));
11467 std::fs::create_dir_all(&dir).unwrap();
11468 unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
11469 assert!(runner_session_var("ACME_THREAD_ID", "0199a1b2-c3d4"));
11470 assert!(!runner_session_var("ACME_THREAD_ID", "short"));
11471 let first = seat_for_thread("0199a1b2-aaaa-thread");
11473 assert_eq!(first.holder, "0199a1b2-aaaa-thread");
11474 let text = std::fs::read_to_string(session_record_path("0199a1b2-aaaa-thread")).unwrap();
11475 assert_eq!(
11476 holder_naming(&text, "0199a1b2-aaaa-thread").as_deref(),
11477 Some("0199a1b2-aaaa-thread")
11478 );
11479 let shell = Seat {
11481 seat: "acme".into(),
11482 holder: "sess-shellfirst".into(),
11483 source: String::new(),
11484 };
11485 write_record_ids(
11486 &session_record_path("0199a1b2-bbbb-thread"),
11487 &shell,
11488 &["line-editor-id".into(), "0199a1b2-bbbb-thread".into()],
11489 );
11490 assert_eq!(
11491 seat_for_thread("0199a1b2-bbbb-thread").holder,
11492 "sess-shellfirst"
11493 );
11494 unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11495 let _ = std::fs::remove_dir_all(&dir);
11496 }
11497
11498 #[test]
11499 fn a_shell_with_one_more_session_variable_finds_the_servers_record() {
11500 let _g = env_guard();
11501 let dir = std::env::temp_dir().join(format!("ljos-rt-{}", std::process::id()));
11502 std::fs::create_dir_all(&dir).unwrap();
11503 unsafe {
11504 std::env::set_var("XDG_RUNTIME_DIR", &dir);
11505 std::env::set_var("ACME_SESSION_ID", "01a09b25-ffe9-7972-881a-3cee2ea6efd6");
11506 }
11507 let server = announce_seat("Acme CLI", 4242);
11508 assert_eq!(server.seat, "acme-cli");
11509 unsafe {
11512 std::env::set_var(
11513 "AAA_LINE_EDITOR_SESSION_ID",
11514 "9f9f9f9f-0000-0000-0000-000000000000",
11515 );
11516 }
11517 let shell = seat_from_session_records().expect("the shared id finds the record");
11518 assert_eq!(shell.holder, server.holder);
11519 assert_eq!(shell.seat, server.seat);
11520 retire_seat(4242);
11521 assert!(seat_from_session_records().is_none());
11522 unsafe {
11523 std::env::remove_var("ACME_SESSION_ID");
11524 std::env::remove_var("AAA_LINE_EDITOR_SESSION_ID");
11525 std::env::remove_var("XDG_RUNTIME_DIR");
11526 }
11527 let _ = std::fs::remove_dir_all(&dir);
11528 assert_ne!(session_tag("01a09b25-aaaa"), session_tag("01a09b25-bbbb"));
11529 }
11530
11531 #[test]
11532 fn a_panel_seats_the_personas_that_speak_to_the_issue() {
11533 let mk = |name: &str, about: &[&str]| Persona {
11534 name: name.into(),
11535 anchor: 0.5,
11536 view: String::new(),
11537 entities: about.iter().map(|s| (*s).to_string()).collect(),
11538 };
11539 let all = vec![
11540 mk("reviewer", &["docs"]),
11541 mk("cuda", &["gpu", "kernels"]),
11542 mk("reader", &[]),
11543 ];
11544 let docs = personas_speaking_to(&all, &["Docs".to_string(), "site".to_string()]);
11545 assert_eq!(
11546 docs.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11547 ["reviewer"]
11548 );
11549 let nobody = personas_speaking_to(&all, &["fortran".to_string()]);
11550 assert_eq!(
11551 nobody.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11552 ["reader"],
11553 "no domain match seats only personas with no domains"
11554 );
11555 let specialists = vec![mk("reviewer", &["docs"]), mk("cuda", &["gpu"])];
11556 assert!(personas_speaking_to(&specialists, &["fortran".to_string()]).is_empty());
11557 let scoped = vec![
11558 mk("seatkeeper", &["seat", "ballot", "sync:rgsurflat"]),
11559 mk("cuda", &["gpu", "sync:rgsurflat"]),
11560 ];
11561 let seated = personas_speaking_to(
11562 &scoped,
11563 &["ballot".to_string(), "sync:rgsurflat".to_string()],
11564 );
11565 assert_eq!(
11566 seated.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11567 ["seatkeeper"],
11568 "a shared sync scope does not seat the roster"
11569 );
11570 let mut merger = mk("merger", &["git"]);
11571 merger.view = "Reads a merge for the writer it silently drops.".into();
11572 let mut other = mk("other", &["gpu"]);
11573 other.view = "Wants the kernel to be fast.".into();
11574 let by_view = personas_speaking_to(
11575 &[merger, other],
11576 &["merge".to_string(), "writers".to_string()],
11577 );
11578 assert_eq!(
11579 by_view.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
11580 ["merger"],
11581 "a specialist whose view uses the issue's words is seated"
11582 );
11583 }
11584
11585 #[test]
11586 fn a_client_name_is_one_seat_however_it_is_spelt() {
11587 assert_eq!(seat_slug("Acme CLI"), "acme-cli");
11588 assert_eq!(seat_slug("acme_cli/1.2"), "acme-cli-1-2");
11589 assert_eq!(seat_slug(" -- "), "runner");
11590 assert_eq!(conversation_tag(4242), "39u");
11591 assert_eq!(conversation_tag(0), "0");
11592 }
11593
11594 #[test]
11595 fn the_server_leaves_a_record_a_shell_below_the_runner_reads() {
11596 let dir = std::env::temp_dir().join(format!("ljos-seat-{}", std::process::id()));
11597 std::fs::create_dir_all(&dir).unwrap();
11598 let path = dir.join("ljos").join("seat-4242");
11601 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
11602 let seat = Seat::tagged(
11603 seat_slug("Acme CLI"),
11604 &conversation_tag(4242),
11605 "test".to_string(),
11606 );
11607 std::fs::write(&path, format!("{}\n{}\n", seat.seat, seat.holder)).unwrap();
11608 let text = std::fs::read_to_string(&path).unwrap();
11609 let mut lines = text.lines();
11610 assert_eq!(lines.next(), Some("acme-cli"));
11611 assert_eq!(lines.next(), Some("acme-cli-39u"));
11612 assert_eq!(
11613 format_seat(&seat),
11614 "seat\tacme-cli\nholder\tacme-cli-39u\nsource\ttest\n"
11615 );
11616 let _ = std::fs::remove_dir_all(&dir);
11617 }
11618
11619 #[test]
11620 fn the_record_weighs_a_voter_by_what_it_got_right() {
11621 let ballots = vec![
11622 ("a".to_string(), "ship".to_string()),
11623 ("b".to_string(), "ship".to_string()),
11624 ("c".to_string(), "hold".to_string()),
11625 ];
11626 let (rows, records) =
11627 learn_record(&ballots, "ship", &std::collections::BTreeMap::new(), &[]).unwrap();
11628 assert_eq!(records["a"], (1.0, 0.0));
11629 assert_eq!(records["c"], (0.0, 1.0));
11630 let w = |to: &str| rows.iter().find(|r| r.to == to).unwrap().weight;
11631 assert_eq!(w("a"), 1.0, "a right voter stands at one");
11632 assert!(w("c") < w("a"), "a wrong voter stands lower");
11633 assert_eq!(rows.len(), 6, "complete over the voters");
11634 let (rows2, records2) = learn_record(&ballots, "ship", &records, &[]).unwrap();
11636 assert_eq!(records2["c"], (0.0, 2.0));
11637 let w2 = |to: &str| rows2.iter().find(|r| r.to == to).unwrap().weight;
11638 assert!(w2("c") <= w("c"));
11639 assert!(learn_record(&ballots, " ", &records, &[]).is_err());
11640 let atoms = vec![
11642 serde_json::json!({"kind": "trust", "from": "a", "to": "c", "weight": 0.2, "hits": 1.0, "misses": 3.0, "ts": "2026-09-13T01:00:00Z"}),
11643 serde_json::json!({"kind": "trust", "from": "b", "to": "c", "weight": 0.5, "hits": 1.0, "misses": 1.0, "ts": "2026-09-12T01:00:00Z"}),
11644 ];
11645 assert_eq!(records_from_atoms(&atoms)["c"], (1.0, 3.0));
11646 }
11647
11648 #[test]
11649 fn a_correction_is_nudged_once_a_session_and_only_on_a_prompt() {
11650 let _g = env_guard();
11651 let dir = std::env::temp_dir().join(format!("ljos-corr-{}", std::process::id()));
11653 std::fs::create_dir_all(&dir).unwrap();
11654 unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
11655 let prompt = HookCall {
11656 event: "UserPromptSubmit".into(),
11657 cue: "Do you not remember to use uv for scripts?".into(),
11658 session: Some("corr-test".into()),
11659 shape: HookShape::Asks,
11660 };
11661 let (key, first) = correction_nudge(&prompt).expect("a correction is nudged");
11662 assert!(first.contains("ljos prefer"), "{first}");
11663 assert!(
11664 correction_nudge(&prompt).is_some(),
11665 "unmarked until delivered"
11666 );
11667 mark_seen(Some("corr-test"), &[key]);
11668 assert!(correction_nudge(&prompt).is_none(), "once delivered");
11669 let tool = HookCall {
11670 event: "PreToolUse".into(),
11671 cue: "you should have used uv".into(),
11672 session: Some("corr-test".into()),
11673 shape: HookShape::Asks,
11674 };
11675 assert!(
11676 correction_nudge(&tool).is_none(),
11677 "tool calls are not prompts"
11678 );
11679 let plain = HookCall {
11680 event: "UserPromptSubmit".into(),
11681 cue: "add the timeline verb".into(),
11682 session: Some("corr-test-2".into()),
11683 shape: HookShape::Asks,
11684 };
11685 assert!(correction_nudge(&plain).is_none());
11686 }
11687
11688 #[test]
11689 fn a_subagent_is_told_its_parents_issue_and_held_once_at_stop() {
11690 let grok = r#"{"hookEventName":"subagent_stop","sessionId":"child","subagentType":"explore","stopHookActive":false}"#;
11691 assert_eq!(
11692 hook_subagent(grok),
11693 (Some("explore".into()), false, String::new())
11694 );
11695 let shared = r#"{"hook_event_name":"SubagentStop","session_id":"p","agent_id":"a1","agent_type":"review","stop_hook_active":true}"#;
11696 assert_eq!(
11697 hook_subagent(shared),
11698 (Some("review".into()), true, "a1".into())
11699 );
11700 assert_eq!(hook_subagent(r#"{"hook_event_name":"Stop"}"#).0, None);
11701 let brief = subagent_brief("explore", "acme-12ab", true);
11702 assert!(
11703 brief.contains("Do not open a sitting")
11704 && brief.contains("ljos vote acme-12ab")
11705 && brief.contains("--expect"),
11706 "{brief}"
11707 );
11708 let decide = subagent_stop_reason("explore", Some("acme-12ab"), true, false).unwrap();
11709 assert!(
11710 decide.contains("decision")
11711 && decide.contains("--expect")
11712 && decide.contains("--as ROLE"),
11713 "{decide}"
11714 );
11715 let plain = subagent_stop_reason("explore", Some("acme-12ab"), false, false).unwrap();
11716 assert!(plain.contains("Otherwise stop"), "{plain}");
11717 assert!(
11718 subagent_stop_reason("explore", Some("acme-12ab"), true, true).is_none(),
11719 "held once"
11720 );
11721 assert!(
11722 subagent_stop_reason("explore", None, true, false).is_none(),
11723 "no issue, no gate"
11724 );
11725 }
11726
11727 #[test]
11728 fn a_clone_without_the_named_merge_driver_is_reported() {
11729 let dir = tempfile::tempdir().unwrap();
11730 let git = |args: &[&str]| {
11731 std::process::Command::new("git")
11732 .arg("-C")
11733 .arg(dir.path())
11734 .args(args)
11735 .output()
11736 .unwrap()
11737 };
11738 git(&["init", "-q"]);
11739 assert!(
11740 tracker_merge_driver_missing(dir.path()).is_none(),
11741 "no attribute, no row"
11742 );
11743 std::fs::write(
11744 dir.path().join(".gitattributes"),
11745 "issues.org merge=vissue\n",
11746 )
11747 .unwrap();
11748 let said = tracker_merge_driver_missing(dir.path()).expect("named and missing");
11749 assert!(said.contains("vissue merge-driver --install"), "{said}");
11750 git(&[
11751 "config",
11752 "merge.vissue.driver",
11753 "vissue merge-driver %O %A %B %P",
11754 ]);
11755 assert!(tracker_merge_driver_missing(dir.path()).is_none());
11756 }
11757
11758 #[test]
11759 fn a_subagent_reads_its_parents_issue_from_the_hold_records() {
11760 let _g = env_guard();
11761 let dir = tempfile::tempdir().unwrap();
11762 unsafe { std::env::set_var("XDG_RUNTIME_DIR", dir.path()) };
11763 let ljos = dir.path().join("ljos");
11764 std::fs::create_dir_all(&ljos).unwrap();
11765 let rec = |name: &str, holder: &str, at: &str, node: &str| {
11766 std::fs::write(
11767 ljos.join(format!("hold-{name}")),
11768 format!("{holder}\nacme\n1\nacme\n{at}\n{node}\n"),
11769 )
11770 .unwrap();
11771 };
11772 rec("a", "sess-parent", "2026-09-27T10:00:00Z", "acme-old1");
11773 rec("b", "sess-parent", "2026-09-27T12:00:00Z", "acme-new2");
11774 rec("c", "sess-other", "2026-09-27T13:00:00Z", "brio-3c4d");
11775 std::fs::write(
11776 ljos.join("hold-d"),
11777 "sess-parent\nacme\n1\nacme\n2026-09-27T14:00:00Z\n",
11778 )
11779 .unwrap();
11780 assert_eq!(
11781 held_from_records(&["sess-parent".to_string()]).as_deref(),
11782 Some("acme-new2")
11783 );
11784 assert_eq!(held_from_records(&["sess-nobody".to_string()]), None);
11785 unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11786 }
11787
11788 #[test]
11789 fn a_long_run_without_the_seat_is_reminded_once_per_stretch() {
11790 let _g = env_guard();
11791 let dir = tempfile::tempdir().unwrap();
11792 unsafe { std::env::set_var("XDG_RUNTIME_DIR", dir.path()) };
11793 let call = |cue: &str, event: &str| HookCall {
11794 event: event.into(),
11795 cue: cue.into(),
11796 session: Some("work-test".into()),
11797 shape: HookShape::Asks,
11798 };
11799 for _ in 1..WORK_NUDGE_EVERY {
11800 assert!(work_nudge(&call("cargo test", "PostToolUse"), false).is_none());
11801 }
11802 let said =
11803 work_nudge(&call("cargo test", "PostToolUse"), false).expect("nudged at the count");
11804 assert!(
11805 said.contains("no issue held") || said.contains("vissue note"),
11806 "{said}"
11807 );
11808 assert!(
11809 work_nudge(&call("cargo test", "PostToolUse"), false).is_none(),
11810 "count starts over"
11811 );
11812 assert!(work_nudge(&call("ljos remember x", "PreToolUse"), false).is_none());
11813 assert!(
11814 work_nudge(&call("rg foo", "PostToolUse"), true).is_none(),
11815 "a subagent has its brief"
11816 );
11817 assert!(touches_seat("use_tool ljos__ljos_sitting"));
11818 assert!(!touches_seat("cargo build --release"));
11819 unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11820 }
11821
11822 #[test]
11823 fn a_twin_hook_call_is_answered_once() {
11824 let _g = env_guard();
11825 let dir = tempfile::tempdir().unwrap();
11826 unsafe { std::env::set_var("XDG_RUNTIME_DIR", dir.path()) };
11827 let call = |cue: &str| HookCall {
11828 event: "UserPromptSubmit".into(),
11829 cue: cue.into(),
11830 session: Some("twin".into()),
11831 shape: HookShape::CamelCase,
11832 };
11833 assert!(
11834 !hook_already_running(&call("fix the ci")),
11835 "the first answers"
11836 );
11837 assert!(
11838 hook_already_running(&call("fix the ci")),
11839 "its twin returns"
11840 );
11841 assert!(
11842 !hook_already_running(&call("another prompt")),
11843 "another prompt answers"
11844 );
11845 unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
11846 }
11847
11848 #[test]
11849 fn a_second_commit_lock_waits_for_the_first() {
11850 let dir = tempfile::tempdir().unwrap();
11851 let path = dir.path().join("ljos-commit.lock");
11852 let first = CommitLock::acquire(&path);
11853 assert!(first.0.is_some(), "the lock opens");
11854 let other = path.clone();
11855 let started = std::time::Instant::now();
11856 let waiter = std::thread::spawn(move || {
11857 let _second = CommitLock::acquire(&other);
11858 started.elapsed()
11859 });
11860 std::thread::sleep(std::time::Duration::from_millis(300));
11861 drop(first);
11862 let waited = waiter.join().unwrap();
11863 assert!(
11864 waited >= std::time::Duration::from_millis(250),
11865 "{waited:?}"
11866 );
11867 }
11868
11869 #[test]
11870 fn a_verdict_from_jev_replaces_the_phrase_lists() {
11871 let call = |cue: &str, session: &str| HookCall {
11872 event: "UserPromptSubmit".into(),
11873 cue: cue.into(),
11874 session: Some(session.into()),
11875 shape: HookShape::Asks,
11876 };
11877 let plain = call("add the timeline verb", "verdict-1");
11878 assert!(decision_nudge_as(&plain, None).is_none(), "no cue word");
11879 assert!(
11880 decision_nudge_as(&plain, Some(true)).is_some(),
11881 "judged a choice"
11882 );
11883 let asked = call("should we seal with age or gpg?", "verdict-2");
11884 assert!(
11885 decision_nudge_as(&asked, Some(false)).is_none(),
11886 "judged not a choice"
11887 );
11888 let (key, _) = correction_nudge_as(&plain, Some(true)).expect("judged a correction");
11889 assert_eq!(key, "correction:judged");
11890 assert!(correction_nudge_as(&plain, Some(false)).is_none());
11891 }
11892
11893 #[test]
11894 fn a_choice_is_sent_to_a_panel_once_a_session() {
11895 let _g = env_guard();
11896 let dir = std::env::temp_dir().join(format!("ljos-dec-{}", std::process::id()));
11897 std::fs::create_dir_all(&dir).unwrap();
11898 unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
11899 let call = |cue: &str, session: &str, event: &str| HookCall {
11900 event: event.into(),
11901 cue: cue.into(),
11902 session: Some(session.into()),
11903 shape: HookShape::Asks,
11904 };
11905 let prompt = call(
11906 "should we seal with age or gpg?",
11907 "dec-test",
11908 "UserPromptSubmit",
11909 );
11910 let (key, first) = decision_nudge(&prompt).expect("a choice is nudged");
11911 assert!(
11912 first.contains("Options:") && first.contains("--as NAME"),
11913 "{first}"
11914 );
11915 assert!(
11916 decision_nudge(&prompt).is_some(),
11917 "unmarked until delivered"
11918 );
11919 mark_seen(Some("dec-test"), &[key]);
11920 assert!(decision_nudge(&prompt).is_none(), "once delivered");
11921 assert!(decision_nudge(&call("age vs gpg", "dec-test-2", "PreToolUse")).is_none());
11922 assert!(decision_nudge(&call(
11923 "add the timeline verb",
11924 "dec-test-3",
11925 "UserPromptSubmit"
11926 ))
11927 .is_none());
11928 assert!(
11929 decision_nudge(&call("go with option 2", "dec-test-4", "UserPromptSubmit")).is_some()
11930 );
11931 assert!(
11932 decision_nudge(&call(
11933 "tell me the option about caching",
11934 "dec-test-5",
11935 "UserPromptSubmit"
11936 ))
11937 .is_none(),
11938 "a cue ends at a word boundary"
11939 );
11940 let report = format!(
11941 "{} should we keep it?",
11942 "a long pasted report line. ".repeat(40)
11943 );
11944 assert!(
11945 decision_nudge(&call(&report, "dec-test-6", "UserPromptSubmit")).is_none(),
11946 "a cue past the opening is not a choice put to the agent"
11947 );
11948 }
11949
11950 #[test]
11951 fn calibration_weights_are_log_odds_with_the_best_at_one() {
11952 let w = calibration_weights(&[
11953 ("a".to_string(), 0.9),
11954 ("b".to_string(), 0.6),
11955 ("c".to_string(), 0.5),
11956 ("d".to_string(), 1.0),
11957 ]);
11958 let of = |who: &str| w.iter().find(|(n, _)| n == who).unwrap().1;
11959 assert_eq!(of("d"), 1.0, "a perfect record is the top of the scale");
11960 assert!((of("a") - 0.478).abs() < 0.01, "{}", of("a"));
11962 assert!((of("b") - 0.088).abs() < 0.01, "{}", of("b"));
11963 assert!(
11964 of("a") / of("b") > 5.0,
11965 "nine in ten outweighs six in ten by more than five"
11966 );
11967 assert_eq!(of("c"), TRUST_FLOOR, "chance earns the floor");
11968 }
11969
11970 #[test]
11971 fn a_consolidation_report_names_the_pairs() {
11972 let body = serde_json::json!({"live": 5, "closed": 1, "applied": false, "pairs": [
11973 {"old": "a", "old_text": "The default fuse is Borda.", "new": "b", "new_text": "The default fuse is CombMNZ."}
11974 ]});
11975 let text = format_consolidation(&body);
11976 assert!(
11977 text.starts_with(
11978 "closes a The default fuse is Borda.\n for b The default fuse is CombMNZ.\n"
11979 ),
11980 "{text}"
11981 );
11982 assert!(
11983 text.ends_with(
11984 "1 of 5 live memories would close; `ljos consolidate --apply` closes them\n"
11985 ),
11986 "{text}"
11987 );
11988 let applied = format_consolidation(
11989 &serde_json::json!({"live": 5, "closed": 0, "applied": true, "pairs": []}),
11990 );
11991 assert_eq!(applied, "0 of 5 live memories closed\n");
11992 }
11993
11994 #[test]
11995 fn the_hook_keeps_what_two_scorers_agreed_on() {
11996 let hit = |ballots, of| Hit {
11997 id: None,
11998 text: "x".into(),
11999 score: 1.0,
12000 kind: "lesson".into(),
12001 ts: None,
12002 entities: vec![],
12003 ballots,
12004 of,
12005 };
12006 assert!(agreed(&hit(Some(2), Some(3))));
12007 assert!(!agreed(&hit(Some(1), Some(3))));
12008 assert!(agreed(&hit(Some(1), Some(1))));
12009 assert!(agreed(&hit(None, None)));
12010 assert!(names_the_cue(
12011 "OpenCPMD Fortran calls the rgsaddle band API.",
12012 "plot the eon outputs with opencpmd and chemparseplot"
12013 ));
12014 assert!(!names_the_cue(
12015 "A submitted CQA packet uses the reviewer-edited Org quotes.",
12016 "plot the eon outputs with chemparseplot"
12017 ));
12018 assert!(!names_the_cue(
12019 "A doc comment states what an item does and one why.",
12020 "why are you not making real images"
12021 ));
12022 assert!(!names_the_cue("The fuse default is CombMNZ.", "why"));
12023 assert!(!names_a_numbered_pr(
12024 "A PR branch has to contain main before it merges."
12025 ));
12026 assert!(names_a_numbered_pr(
12027 "Pull requests 32 and 36 share one tree, and PR 32 replays PR 36."
12028 ));
12029 assert!(names_a_numbered_pr("rgpot #80 left a sibling behind main."));
12030 assert!(!names_a_numbered_pr(
12031 "The prompt hook holds the pack note until the first tool result."
12032 ));
12033 assert!(is_transient(
12034 "Pull requests 32 and 36 share one tree, and PR 32 replays PR 36."
12035 ));
12036 assert!(is_transient("The closure is on ljos-wgo8."));
12037 assert!(is_transient("The sweep was commit 80c73416c."));
12038 assert!(!is_transient(
12039 "A PR branch has to contain main before it merges."
12040 ));
12041 assert!(!is_transient("The prompt hook holds the pack note."));
12042 let standing = Hit {
12043 id: None,
12044 text: "Pull requests 32 and 36 share one tree.".into(),
12045 score: 1.0,
12046 kind: "lesson".into(),
12047 ts: None,
12048 entities: vec!["horizon:standing".into()],
12049 ballots: None,
12050 of: None,
12051 };
12052 assert!(is_refresher(&standing));
12053 let tagged = Hit {
12054 id: None,
12055 text: "A PR branch has to contain main.".into(),
12056 score: 1.0,
12057 kind: "lesson".into(),
12058 ts: None,
12059 entities: vec!["horizon:transient".into()],
12060 ballots: None,
12061 of: None,
12062 };
12063 assert!(!is_refresher(&tagged));
12064 let untagged = Hit {
12065 id: None,
12066 text: "A PR branch has to contain main.".into(),
12067 score: 1.0,
12068 kind: "lesson".into(),
12069 ts: None,
12070 entities: vec![],
12071 ballots: None,
12072 of: None,
12073 };
12074 assert!(!is_refresher(&untagged));
12075 }
12076
12077 #[test]
12078 fn the_generation_is_read_off_a_get_line() {
12079 let line = "a25a… claimed task unset gen=2 assignee=69f917124f757277b806e9a0f48c0318 parent=0 x-1";
12080 assert_eq!(gen_of(line), Some(2));
12081 assert_eq!(gen_of("deps -"), None);
12082 assert_eq!(gen_of("a ready task unset gen=x"), None);
12083 }
12084
12085 #[test]
12086 fn the_holder_is_read_off_a_get_line() {
12087 let line = "a25a… claimed task unset gen=2 assignee=69f917124f757277b806e9a0f48c0318 parent=0 x-1";
12088 assert_eq!(
12089 holder_of(line).as_deref(),
12090 Some("69f917124f757277b806e9a0f48c0318")
12091 );
12092 assert_eq!(
12093 holder_of("a ready task unset gen=1 assignee=00000000000000000000000000000000"),
12094 None
12095 );
12096 assert_eq!(holder_of("deps -"), None);
12097 }
12098
12099 #[test]
12100 fn a_registration_carries_the_runners_name() {
12101 let argv: Vec<String> = ["run", "-e", "LJOS_SEAT={name}", "{server}"]
12102 .iter()
12103 .map(|s| (*s).to_string())
12104 .collect();
12105 let filled = filled(&argv, Path::new("/x/ljos-mcp"), "runner-a");
12106 assert_eq!(filled, ["run", "-e", "LJOS_SEAT=runner-a", "/x/ljos-mcp"]);
12107 assert_eq!(
12108 identity_or_seat(Some(" reviewer ")).as_deref(),
12109 Some("reviewer")
12110 );
12111 }
12112
12113 #[test]
12114 fn a_timeline_reads_every_store_on_the_local_day() {
12115 let _g = env_guard();
12116 let before = std::env::var("TZ").ok();
12117 unsafe { std::env::set_var("TZ", "CET-1CEST,M3.5.0,M10.5.0/3") };
12118 assert_eq!(local_stamp("2026-09-26T22:28:12.170Z"), "2026-09-27T00:28");
12121 assert_eq!(local_stamp("[2026-09-27 Sun]"), "[2026-09-27 Sun]");
12122 assert_eq!(local_offset(1_788_566_400), 7200);
12123 let deed = deed_event("deed-x", "time=1790461680\n", local_offset).unwrap();
12124 let v = serde_json::json!({"properties": {"CREATED": "[2026-09-27 Sun]"}});
12125 let mut events = tracker_events(&v);
12126 events.push(deed);
12127 let text = format_events(&events, "2026-09-27T00:30:00");
12128 assert!(text.lines().all(|l| l.contains("\ttoday\t")), "{text}");
12129 unsafe {
12130 match before {
12131 Some(tz) => std::env::set_var("TZ", tz),
12132 None => std::env::remove_var("TZ"),
12133 }
12134 }
12135 }
12136
12137 #[test]
12138 fn a_timeline_merges_the_three_stores_oldest_first() {
12139 let v = serde_json::json!({
12140 "properties": {
12141 "CREATED": "[2026-09-01 Tue]",
12142 "SCHEDULED": "<2026-02-10 Tue>"
12143 },
12144 "claimed_by": "seat",
12145 "claimed_at": "[2026-09-03 Thu 11:48]",
12146 "logbook": [
12147 {"note": "second", "timestamp": "[2026-09-10 Thu 09:00]"},
12148 {"from_state": "TODO", "to_state": "STARTED", "timestamp": "[2026-09-03 Thu 11:48]"}
12149 ]
12150 });
12151 let mut events = tracker_events(&v);
12152 events.push(
12153 deed_event(
12154 "deed-x",
12155 "id=deed-x ok\nproducedBy=seat -\ntime=1788566400\n",
12156 |_| 0,
12157 )
12158 .unwrap(),
12159 );
12160 events.sort_by(|a, b| (a.days, &a.clock).cmp(&(b.days, &b.clock)));
12161 let text = format_events(&events, "2026-09-12T00:00:00Z");
12162 let lines: Vec<&str> = text.lines().collect();
12163 assert_eq!(lines.len(), 6, "{text}");
12164 assert!(
12165 lines[0].contains("tracker\tSCHEDULED <2026-02-10 Tue>"),
12166 "{}",
12167 lines[0]
12168 );
12169 assert!(
12170 lines[1].starts_with("2026-09-01 \t11 days ago"),
12171 "{}",
12172 lines[1]
12173 );
12174 assert!(lines[1].contains("tracker\tcreated"), "{}", lines[1]);
12175 assert!(
12176 lines[2].contains("+2 d\ttracker\tclaimed by seat"),
12177 "{}",
12178 lines[2]
12179 );
12180 assert!(
12181 lines[3].contains("same day\ttracker\tTODO -> STARTED"),
12182 "{}",
12183 lines[3]
12184 );
12185 assert!(
12186 lines[4]
12187 .starts_with("2026-09-05 00:00\t7 days ago\t+2 d\tdeed\tdeed-x produced by seat -"),
12188 "{}",
12189 lines[4]
12190 );
12191 assert!(
12192 lines[5].contains("2 days ago\t+5 d\ttracker\tnote: second"),
12193 "{}",
12194 lines[5]
12195 );
12196 }
12197
12198 #[test]
12199 fn sitting_caps_are_the_protocol_numbers() {
12200 assert_eq!(SITTING_DUE, 8);
12201 assert_eq!(SITTING_TIMELINE, 12);
12202 }
12203
12204 #[test]
12205 fn policyd_required_is_the_operator_switch() {
12206 let _g = env_guard();
12207 let before = std::env::var_os("POLICYD_REQUIRED");
12208 std::env::remove_var("POLICYD_REQUIRED");
12209 assert!(!policyd_required());
12210 std::env::set_var("POLICYD_REQUIRED", "1");
12211 assert!(policyd_required());
12212 std::env::set_var("POLICYD_REQUIRED", "0");
12213 assert!(!policyd_required());
12214 match before {
12215 Some(v) => std::env::set_var("POLICYD_REQUIRED", v),
12216 None => std::env::remove_var("POLICYD_REQUIRED"),
12217 }
12218 }
12219
12220 #[test]
12221 fn stamps_of_every_shape_key_the_same() {
12222 assert_eq!(
12223 stamp_key(Some("[2026-09-12 Sat 21:54]")),
12224 stamp_key(Some("2026-09-12T21:54:00.000Z"))
12225 );
12226 assert_eq!(stamp_key(Some("[2026-09-12 Sat]")).unwrap().1, "");
12227 assert_eq!(
12228 stamp_key(Some("<2026-02-10 Tue>")).map(|k| k.0),
12229 stamp_key(Some("2026-02-10")).map(|k| k.0)
12230 );
12231 assert_eq!(stamp_key(Some("soon")), None);
12232 assert_eq!(
12233 civil_of_days(days_of_stamp(Some("2026-09-12")).unwrap()),
12234 "2026-09-12"
12235 );
12236 }
12237
12238 #[test]
12239 fn ages_read_as_a_timeline() {
12240 let now = "2026-09-12T14:00:00.000Z";
12241 assert_eq!(age_of(Some("2026-09-12T01:00:00.000Z"), now), "today");
12242 assert_eq!(age_of(Some("2026-09-11T23:59:00.000Z"), now), "yesterday");
12243 assert_eq!(age_of(Some("2026-09-01T00:00:00.000Z"), now), "11 days ago");
12244 assert_eq!(age_of(Some("2026-08-01T00:00:00.000Z"), now), "6 weeks ago");
12245 assert_eq!(
12246 age_of(Some("2026-03-01T00:00:00.000Z"), now),
12247 "6 months ago"
12248 );
12249 assert_eq!(age_of(Some("2023-09-12T00:00:00.000Z"), now), "3 years ago");
12250 assert_eq!(age_of(Some("2026-09-13T00:00:00.000Z"), now), "in 1 day");
12251 assert_eq!(age_of(None, now), "");
12252 assert_eq!(age_of(Some("card"), now), "");
12253 }
12254
12255 #[test]
12256 fn a_hit_line_carries_kind_and_age() {
12257 let h = Hit {
12258 id: Some("a".into()),
12259 text: " keep the smoke green ".into(),
12260 score: 1.0,
12261 kind: "lesson".into(),
12262 ts: Some("2026-09-10T00:00:00.000Z".into()),
12263 entities: vec![],
12264 ballots: None,
12265 of: None,
12266 };
12267 assert_eq!(
12268 hit_line(&h, "2026-09-12T00:00:00.000Z"),
12269 "- [lesson, 2 days ago] keep the smoke green"
12270 );
12271 let bare = Hit {
12272 id: None,
12273 text: "x".into(),
12274 score: 1.0,
12275 kind: String::new(),
12276 ts: None,
12277 entities: vec![],
12278 ballots: None,
12279 of: None,
12280 };
12281 assert_eq!(hit_line(&bare, "2026-09-12T00:00:00.000Z"), "- [claim] x");
12282 }
12283
12284 #[test]
12287 fn hook_calls_are_read_and_answered_in_the_runners_shape() {
12288 let tool = hook_call(
12289 r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cargo test","description":"run"}}"#,
12290 );
12291 assert_eq!(tool.event, "PreToolUse");
12292 assert_eq!(tool.cue, "cargo test");
12293 let prompt = hook_call(r#"{"hook_event_name":"UserPromptSubmit","prompt":"fix the fuse"}"#);
12294 assert_eq!(prompt.cue, "fix the fuse");
12295 let grok = hook_call(r#"{"hookEventName":"post_tool_use","sessionId":"s1"}"#);
12296 assert_eq!(grok.event, "PostToolUse");
12297 assert_eq!(grok.session.as_deref(), Some("s1"));
12298 hold_hook_context(Some("s1"), "held pack");
12299 assert_eq!(take_hook_context(Some("s1")), "held pack");
12300 assert!(take_hook_context(Some("s1")).is_empty());
12301 let session = format!("hold-{}", std::process::id());
12302 hold_hook_note(Some(&session), "pack line", &["m1".to_string()]);
12303 hold_hook_context(Some(&session), "");
12304 assert_eq!(peek_hook_context(Some(&session)), "pack line");
12305 assert_eq!(
12306 prompt_hook_stdout(
12307 HookShape::CamelCase,
12308 Some(&session),
12309 "pack line",
12310 &["m1".to_string()]
12311 ),
12312 ""
12313 );
12314 let (echoed, echo_ids) = post_hook_stdout(HookShape::CamelCase, Some(&session));
12315 assert_eq!(echoed, "pack line");
12316 assert_eq!(echo_ids, ["m1"]);
12317 assert!(post_hook_stdout(HookShape::CamelCase, Some(&session))
12318 .0
12319 .is_empty());
12320 assert!(
12321 stop_hook_stdout(Some(&session), false).0.is_empty(),
12322 "a delivered tool result leaves Stop nothing to say"
12323 );
12324 let quiet = format!("quiet-{}", std::process::id());
12325 hold_hook_note(Some(&quiet), "no tool", &["m2".to_string()]);
12326 let (delivered, ids) = stop_hook_stdout(Some(&quiet), false);
12327 assert_eq!(delivered, "no tool");
12328 assert_eq!(ids, ["m2"]);
12329 assert!(stop_hook_stdout(Some(&quiet), true).0.is_empty());
12330 let argv = hook_call("rm -rf build");
12331 assert_eq!(argv.event, "argv");
12332 assert_eq!(argv.session, None);
12333 let with_session = hook_call(
12334 r#"{"session_id":"abc/../x 1","hook_event_name":"PreToolUse","tool_input":{"command":"ls"}}"#,
12335 );
12336 assert_eq!(with_session.session.as_deref(), Some("abc/../x 1"));
12337 assert!(seen_path("abc/../x 1")
12338 .unwrap()
12339 .file_name()
12340 .unwrap()
12341 .to_string_lossy()
12342 .ends_with("hook-seen-abcx1"));
12343 assert_eq!(seen_path("/../"), None);
12344 assert_eq!(hook_output(&argv, ""), "");
12345 assert_eq!(hook_output(&argv, "- [lesson] x"), "- [lesson] x\n");
12346 let out = hook_output(&tool, "- [preference] y");
12347 let v: Value = serde_json::from_str(out.trim()).unwrap();
12348 assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
12349 assert_eq!(
12350 v["hookSpecificOutput"]["additionalContext"],
12351 "- [preference] y"
12352 );
12353 assert!(
12354 hook_context(
12355 &HookCall {
12356 event: "argv".into(),
12357 cue: "ab".into(),
12358 session: None,
12359 shape: HookShape::Asks,
12360 },
12361 8
12362 )
12363 .is_empty(),
12364 "a cue too short asks nothing"
12365 );
12366 }
12367
12368 #[test]
12371 fn a_sessions_injected_memories_are_read_back_and_cleared() {
12372 let _g = env_guard();
12374 let session = format!("end-test-{}", std::process::id());
12375 mark_seen(
12376 Some(&session),
12377 &["a".to_string(), "due-nudge".to_string(), "b".to_string()],
12378 );
12379 let (ids, path) = injected_ids(&session);
12380 assert_eq!(ids, ["a", "b"]);
12381 assert!(path.as_ref().is_some_and(|p| p.is_file()));
12382 let _ = session_end(Some(&session));
12384 assert!(!path.unwrap().is_file());
12385 assert_eq!(session_end(None), 0);
12386 }
12387
12388 #[test]
12391 fn the_memory_hook_is_merged_once() {
12392 let dir = std::env::temp_dir().join(format!("ljos-hook-{}", std::process::id()));
12393 let _ = std::fs::remove_dir_all(&dir);
12394 std::fs::create_dir_all(&dir).unwrap();
12395 let file = dir.join("settings.json");
12396 std::fs::write(
12397 &file,
12398 r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"other"}]}]},"theme":"dark"}"#,
12399 )
12400 .unwrap();
12401 let both: Vec<String> = vec!["UserPromptSubmit".into(), "PreToolUse".into()];
12402 let prompts: Vec<String> = HOOK_EVENTS.iter().map(|e| (*e).to_string()).collect();
12403 assert_eq!(
12404 prompts,
12405 ["UserPromptSubmit", "SessionEnd"],
12406 "the panel's default, and the session end that wires what it used"
12407 );
12408 assert!(!hook_installed(&file, &both));
12409 let dry = hook_step(&file, &both, true);
12410 assert!(
12411 dry.ok && dry.detail.starts_with("would add it on"),
12412 "{dry:?}"
12413 );
12414 let step = hook_step(&file, &both, false);
12415 assert!(step.ok, "{step:?}");
12416 assert!(hook_installed(&file, &both));
12417 let again = hook_step(&file, &both, false);
12418 assert!(
12419 again.detail.contains("carries the memory hook on"),
12420 "{again:?}"
12421 );
12422 let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
12423 assert_eq!(v["theme"], "dark", "the rest of the file is kept");
12424 assert_eq!(
12425 v["hooks"]["PreToolUse"].as_array().unwrap().len(),
12426 2,
12427 "the other hook stays"
12428 );
12429 assert_eq!(v["hooks"]["UserPromptSubmit"].as_array().unwrap().len(), 1);
12430 let narrowed = hook_step(&file, &prompts, false);
12433 assert!(
12434 narrowed.detail.contains("drop it from PreToolUse"),
12435 "{narrowed:?}"
12436 );
12437 let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
12438 assert_eq!(v["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
12439 assert_eq!(v["hooks"]["PreToolUse"][0]["hooks"][0]["command"], "other");
12440 assert!(hook_installed(&file, &prompts));
12441 assert!(!hook_installed(&file, &both));
12442 let _ = std::fs::remove_dir_all(&dir);
12443 }
12444
12445 #[test]
12448 fn rules_match_the_line_and_the_hook_carries_the_verdict() {
12449 assert!(glob_matches("rm -rf *", "rm -rf /tmp/x"));
12450 assert!(!glob_matches("rm -rf *", "ls -la"));
12451 assert!(glob_matches("*sudo*", "echo hi && sudo reboot"));
12452 assert!(glob_matches("git push*", "git push origin main"));
12453 assert!(!glob_matches("git push*", "git pull"));
12454 let rules = vec![
12455 Rule {
12456 pattern: "git push*".into(),
12457 verdict: "ask".into(),
12458 reason: "A push is the trust gate.".into(),
12459 },
12460 Rule {
12461 pattern: "*--force*".into(),
12462 verdict: "deny".into(),
12463 reason: "Never force push.".into(),
12464 },
12465 ];
12466 assert_eq!(
12467 verdict_for(&rules, "git push --force").unwrap().verdict,
12468 "deny"
12469 );
12470 assert_eq!(
12471 verdict_for(&rules, "git push origin x").unwrap().verdict,
12472 "ask"
12473 );
12474 assert!(verdict_for(&rules, "cargo test").is_none());
12475 let call = hook_call(
12476 r#"{"hook_event_name":"PreToolUse","tool_input":{"command":"git push --force"}}"#,
12477 );
12478 let out = hook_output_ruled(&call, "", verdict_for(&rules, &call.cue));
12479 let v: Value = serde_json::from_str(out.trim()).unwrap();
12480 assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
12481 assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
12482 .as_str()
12483 .unwrap()
12484 .contains("Never force push"));
12485 assert!(v["hookSpecificOutput"].get("additionalContext").is_none());
12486 let argv = HookCall {
12487 event: "argv".into(),
12488 cue: "git push origin x".into(),
12489 session: None,
12490 shape: HookShape::Asks,
12491 };
12492 assert!(
12493 hook_output_ruled(&argv, "", verdict_for(&rules, &argv.cue)).starts_with("ask: A push")
12494 );
12495 let grok = hook_call(
12497 r#"{"hookEventName":"pre_tool_use","sessionId":"g-1","toolName":"run_terminal_command","toolInput":{"command":"git push --force"}}"#,
12498 );
12499 assert_eq!(grok.shape, HookShape::CamelCase);
12500 assert_eq!(grok.event, "PreToolUse");
12501 assert_eq!(grok.cue, "git push --force");
12502 let v: Value = serde_json::from_str(
12503 hook_output_ruled(&grok, "", verdict_for(&rules, &grok.cue)).trim(),
12504 )
12505 .unwrap();
12506 assert_eq!(v["decision"], "deny");
12507 assert!(v["reason"].as_str().unwrap().contains("Never force push"));
12508 let turn = hook_call(
12510 r#"{"hook_event_name":"pre_llm_call","tool_name":null,"tool_input":null,"session_id":"h-1","extra":{"user_message":"fix the fuse"}}"#,
12511 );
12512 assert_eq!(turn.shape, HookShape::Context);
12513 assert_eq!(turn.event, "UserPromptSubmit");
12514 assert_eq!(turn.cue, "fix the fuse");
12515 let v: Value =
12516 serde_json::from_str(hook_output_ruled(&turn, "- [lesson] x", None).trim()).unwrap();
12517 assert_eq!(v["context"], "- [lesson] x");
12518 assert!(v.get("hookSpecificOutput").is_none());
12519 let tool = hook_call(
12520 r#"{"hook_event_name":"pre_tool_call","tool_name":"terminal","tool_input":{"command":"git push origin x"},"session_id":"h-1","extra":{}}"#,
12521 );
12522 assert_eq!(tool.event, "PreToolUse");
12523 let v: Value = serde_json::from_str(
12524 hook_output_ruled(&tool, "", verdict_for(&rules, &tool.cue)).trim(),
12525 )
12526 .unwrap();
12527 assert_eq!(v["decision"], "block");
12528 assert!(v["reason"]
12529 .as_str()
12530 .unwrap()
12531 .starts_with("ask the person before running this"));
12532 assert_eq!(
12533 hook_call(r#"{"hook_event_name":"on_session_end","session_id":"h-1","extra":{}}"#)
12534 .event,
12535 "TurnEnd"
12536 );
12537 assert_eq!(
12538 hook_call(r#"{"hook_event_name":"on_session_finalize","session_id":"h-1","extra":{}}"#)
12539 .event,
12540 "SessionEnd"
12541 );
12542 let deny_only = hook_call(
12544 r#"{"hook_event_name":"PreToolUse","session_id":"c-1","turn_id":"t-1","tool_name":"Bash","tool_input":{"command":"git push origin x"}}"#,
12545 );
12546 assert_eq!(deny_only.shape, HookShape::DenyOnly);
12547 let v: Value = serde_json::from_str(
12548 hook_output_ruled(&deny_only, "", verdict_for(&rules, &deny_only.cue)).trim(),
12549 )
12550 .unwrap();
12551 assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
12552 assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
12553 .as_str()
12554 .unwrap()
12555 .starts_with("ask the person before running this: A push"));
12556 assert!(v.get("decision").is_none());
12557 let asks = hook_call(
12558 r#"{"hook_event_name":"PreToolUse","session_id":"k-1","tool_name":"Bash","tool_input":{"command":"git push origin x"}}"#,
12559 );
12560 let v: Value = serde_json::from_str(
12561 hook_output_ruled(&asks, "", verdict_for(&rules, &asks.cue)).trim(),
12562 )
12563 .unwrap();
12564 assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "ask");
12565 let steps = panel_steps("x-1", true, &[], &[]);
12566 assert!(steps.is_empty());
12567 let preds = vec![
12568 Prediction {
12569 issue: "x-1".into(),
12570 agent: "a".into(),
12571 expect: Value::String("ship".into()),
12572 },
12573 Prediction {
12574 issue: "x-1".into(),
12575 agent: "b".into(),
12576 expect: serde_json::json!({"ship": 0.6, "hold": 0.4}),
12577 },
12578 ];
12579 let steps = panel_steps("x-1", true, &[row("a", "b", 0.5)], &preds);
12580 assert_eq!(steps.len(), 2);
12581 assert_eq!(steps[0].args[0], "surprising");
12582 assert_eq!(steps[1].args[0], "reputation");
12583 }
12584
12585 #[test]
12589 fn scoped_rows_apply_to_their_topic_and_learn_writes_in_scope() {
12590 let everywhere = row("a", "b", 0.9);
12591 let mut on_docs = row("a", "b", 0.2);
12592 on_docs.about = vec!["docs".into()];
12593 let rows = vec![everywhere.clone(), on_docs.clone()];
12594 let topic = topic_words("Rewrite the docs site");
12595 assert_eq!(topic, ["docs", "rewrite", "site", "the"]);
12596 assert_eq!(rows_about(&rows, &topic), vec![on_docs.clone()]);
12599 assert_eq!(
12600 rows_about(&rows, &topic_words("Fix the fuse")),
12601 vec![everywhere.clone()]
12602 );
12603
12604 let ballots = vec![
12605 ("a".to_string(), "ship".to_string()),
12606 ("b".to_string(), "hold".to_string()),
12607 ];
12608 let learned = learn_about(&ballots, "ship", &rows, 0.5, &["fuse".to_string()]).unwrap();
12609 let ab = learned
12610 .iter()
12611 .find(|r| r.from == "a" && r.to == "b")
12612 .unwrap();
12613 assert_eq!(ab.about, ["fuse"]);
12614 assert!(
12615 (ab.weight - 0.45).abs() < 1e-9,
12616 "starts from the unscoped 0.9: {ab:?}"
12617 );
12618 let ba = learned
12619 .iter()
12620 .find(|r| r.from == "b" && r.to == "a")
12621 .unwrap();
12622 assert!((ba.weight - 1.0).abs() < 1e-9, "a was right: {ba:?}");
12623
12624 let atoms = vec![
12626 trust_atom(&everywhere, &[], "ws").unwrap(),
12627 trust_atom(&on_docs, &[], "ws").unwrap(),
12628 ];
12629 let mut back = trust_rows(&atoms);
12630 back.sort_by(|x, y| x.about.cmp(&y.about));
12631 assert_eq!(back, vec![everywhere, on_docs]);
12632 }
12633
12634 #[test]
12637 fn personas_are_latest_per_name_and_anchor_the_settle() {
12638 let p = Persona {
12639 name: "reviewer".into(),
12640 anchor: 0.2,
12641 view: "Reads for what could break in production.".into(),
12642 entities: vec!["Release".into()],
12643 };
12644 let mut a = persona_atom(&p, "ws").unwrap();
12645 a["ts"] = Value::String("2026-01-01T00:00:00Z".into());
12646 let mut later = a.clone();
12647 later["anchor"] = serde_json::json!(0.4);
12648 later["ts"] = Value::String("2026-02-01T00:00:00Z".into());
12649 let got = personas_of(&[a, later]);
12650 assert_eq!(got.len(), 1);
12651 assert_eq!(got[0].anchor, 0.4);
12652 assert_eq!(got[0].entities, ["release"]);
12653 assert_eq!(anchors_json(&got), r#"{"reviewer":0.4}"#);
12654 let ballots = vec![
12657 ("reviewer".to_string(), "hold".to_string()),
12658 ("reader".to_string(), "ship".to_string()),
12659 ];
12660 let moved = learn_anchors(&got, &ballots, "ship", 0.5);
12661 assert_eq!(moved.len(), 1);
12662 assert!(
12663 (moved[0].anchor - 0.7).abs() < 1e-9,
12664 "0.4 + 0.6 * 0.5: {moved:?}"
12665 );
12666 assert!(learn_anchors(&got, &ballots, "hold", 0.5).is_empty());
12667 assert!(persona_atom(
12668 &Persona {
12669 anchor: 1.5,
12670 ..p.clone()
12671 },
12672 "ws"
12673 )
12674 .is_err());
12675 let steps = consensus_steps_anchored("x-1", true, true, &[], &got).unwrap();
12676 for step in &steps {
12677 assert!(
12678 step.args.contains(&"--susceptibility-of".to_string()),
12679 "{step:?}"
12680 );
12681 }
12682 let broad =
12686 consensus_steps_for("x-1", true, true, &[], &got, &["broad".to_string()]).unwrap();
12687 assert!(
12688 broad[0].args.contains(&"--epsilon".to_string()),
12689 "{:?}",
12690 broad[0]
12691 );
12692 assert!(
12693 !broad[1].args.contains(&"--epsilon".to_string()),
12694 "{:?}",
12695 broad[1]
12696 );
12697 assert!(settle_flags_for(&["feature".to_string()]).is_empty());
12698 }
12699
12700 #[test]
12704 fn playbooks_are_latest_per_name_and_stick_until_finish() {
12705 let _g = env_guard();
12706 let dir = std::env::temp_dir().join(format!("ljos-playbook-{}", std::process::id()));
12707 let _ = std::fs::remove_dir_all(&dir);
12708 std::fs::create_dir_all(&dir).unwrap();
12709 let before = std::env::var_os("XDG_RUNTIME_DIR");
12710 unsafe {
12711 std::env::set_var("XDG_RUNTIME_DIR", &dir);
12712 }
12713 let shipped = shipped_playbooks();
12714 let names: Vec<&str> = shipped.iter().map(|p| p.name.as_str()).collect();
12715 assert_eq!(names, SHIPPED_PLAYBOOK_NAMES);
12716 for p in shipped_playbooks() {
12717 assert!(!p.body.is_empty(), "{}", p.name);
12718 assert!(
12719 !p.body.contains("/poteto-mode") && !p.body.contains("poteto-agent"),
12720 "{}",
12721 p.name
12722 );
12723 let atom = playbook_atom(&p, "ws").unwrap();
12724 assert_eq!(atom["kind"], "playbook");
12725 assert_eq!(atom["name"], p.name);
12726 assert_eq!(atom["text"], p.body);
12727 assert!(!super::reviewable(&atom), "{}", p.name);
12728 }
12729 assert!(playbook_atom(
12730 &Playbook {
12731 name: "sit".into(),
12732 body: " ".into(),
12733 models: vec![],
12734 },
12735 "ws"
12736 )
12737 .is_err());
12738 let mut a = playbook_atom(
12739 &Playbook {
12740 name: "sit".into(),
12741 body: "first body".into(),
12742 models: vec![],
12743 },
12744 "ws",
12745 )
12746 .unwrap();
12747 a["ts"] = Value::String("2026-01-01T00:00:00Z".into());
12748 let mut later = a.clone();
12749 later["text"] = Value::String("second body".into());
12750 later["ts"] = Value::String("2026-02-01T00:00:00Z".into());
12751 let got = playbooks_of(&[a, later]);
12752 assert_eq!(got.len(), 1);
12753 assert_eq!(got[0].body, "second body");
12754 let copy = copy_playbook("proj-1a2b", "sit").unwrap();
12755 assert!(copy.starts_with("sit\n"), "{copy}");
12756 assert!(copy.contains("Grade due claims"), "{copy}");
12757 assert_eq!(bound_playbook("proj-1a2b").as_deref(), Some("sit"));
12758 let err = bind_playbook("proj-1a2b", "arena").unwrap_err().to_string();
12759 assert!(err.contains("bound to sit"), "{err}");
12760 assert!(err.contains("new sitting"), "{err}");
12761 let again = playbook_opening("proj-1a2b", None).unwrap();
12762 assert!(again.contains("Grade due claims"), "{again}");
12763 let blocks = brief_playbook_blocks("proj-1a2b");
12764 assert!(blocks.contains("== playbook"), "{blocks}");
12765 assert!(blocks.contains("Grade due claims"), "{blocks}");
12766 assert!(blocks.contains("== principles"), "{blocks}");
12767 assert!(blocks.contains("split-fence"), "{blocks}");
12768 assert!(blocks.contains("== rubric"), "{blocks}");
12769 assert!(blocks.contains("Ledger intact"), "{blocks}");
12770 drop_playbook("proj-1a2b");
12771 assert_eq!(bound_playbook("proj-1a2b"), None);
12772 let none = playbook_opening("proj-1a2b", None).unwrap();
12773 assert!(none.contains("none bound"), "{none}");
12774 assert!(none.contains("panel is refused"), "{none}");
12775 let err = panel("proj-1a2b", &dir.join("panel"))
12776 .unwrap_err()
12777 .to_string();
12778 assert!(err.contains("no playbook bound"), "{err}");
12779 let p = Persona {
12780 name: "reviewer".into(),
12781 anchor: 0.2,
12782 view: "Reads for what could break.".into(),
12783 entities: vec!["docs".into()],
12784 };
12785 let floor = inbound_floor(&p, "seat").unwrap();
12786 assert_eq!(floor.from, "seat");
12787 assert_eq!(floor.to, "reviewer");
12788 assert!((floor.weight - 1.0).abs() < 1e-9);
12789 assert!(floor.about.is_empty());
12790 assert!(inbound_floor(&p, "reviewer").is_none());
12791 assert!(has_unscoped_inbound(
12792 std::slice::from_ref(&floor),
12793 "reviewer",
12794 "seat"
12795 ));
12796 let scoped = Trust {
12797 about: vec!["docs".into()],
12798 ..floor
12799 };
12800 assert!(!has_unscoped_inbound(
12801 std::slice::from_ref(&scoped),
12802 "reviewer",
12803 "seat"
12804 ));
12805 let other = Trust {
12806 from: "other".into(),
12807 to: "reviewer".into(),
12808 weight: 1.0,
12809 about: Vec::new(),
12810 };
12811 assert!(
12812 !has_unscoped_inbound(std::slice::from_ref(&other), "reviewer", "seat"),
12813 "a third-party unscoped row is not the seat floor"
12814 );
12815 let arena_pb = shipped_playbooks()
12816 .into_iter()
12817 .find(|p| p.name == "arena")
12818 .unwrap();
12819 let arena = format_playbook_copy(&arena_pb);
12820 assert!(
12821 arena.contains("spawn hints (optional): judgment, instruction, fast"),
12822 "{arena}"
12823 );
12824 assert!(arena.contains("ljos vote --as"), "{arena}");
12825 assert!(
12826 COMPANY_PANEL_BODY.contains("--expect"),
12827 "a panel ballot carries the private forecast: {COMPANY_PANEL_BODY}"
12828 );
12829 match before {
12830 Some(v) => unsafe { std::env::set_var("XDG_RUNTIME_DIR", v) },
12831 None => unsafe { std::env::remove_var("XDG_RUNTIME_DIR") },
12832 }
12833 let _ = std::fs::remove_dir_all(&dir);
12834 }
12835
12836 #[test]
12837 fn playbook_note_latest_wins_and_empty_rest_drops() {
12838 let v = serde_json::json!({
12839 "logbook": [
12840 {"note": "playbook: land", "timestamp": "2026-09-21"},
12841 {"note": "playbook: sit", "timestamp": "2026-09-20"},
12842 {"note": "progress", "timestamp": "2026-09-19"}
12843 ]
12844 });
12845 assert_eq!(playbook_name_from_issue(&v).as_deref(), Some("land"));
12846 let empty = serde_json::json!({"logbook": []});
12847 assert_eq!(playbook_name_from_issue(&empty), None);
12848 let dropped = serde_json::json!({
12849 "logbook": [
12850 {"note": "playbook:", "timestamp": "2026-09-22T00:00:00Z"},
12851 {"note": "playbook: sit", "timestamp": "2026-09-21T00:00:00Z"}
12852 ]
12853 });
12854 assert_eq!(playbook_name_from_issue(&dropped), None);
12855 let undated = serde_json::json!({
12856 "logbook": [
12857 {"note": "playbook:"},
12858 {"note": "playbook: sit"}
12859 ]
12860 });
12861 assert_eq!(
12862 playbook_name_from_issue(&undated),
12863 None,
12864 "newest-first empty rest drops without walking back"
12865 );
12866 }
12867
12868 #[test]
12869 fn playbook_from_title_matches_a_closed_name_else_sit() {
12870 assert_eq!(playbook_from_title("Seat playbooks: routing"), "sit");
12871 assert_eq!(playbook_from_title("x5jz compose: land B"), "land");
12872 assert_eq!(
12873 playbook_from_title("Run the company-panel overnight"),
12874 "company-panel"
12875 );
12876 assert_eq!(playbook_from_title("sitting on a ticket"), "sit");
12877 assert_eq!(playbook_from_title("arena then compose"), "arena");
12878 assert_eq!(
12879 playbook_from_title("Benny and poteto-mode"),
12880 "sit",
12881 "title-match binds only closed-set tokens"
12882 );
12883 }
12884
12885 #[test]
12886 fn playbook_among_pack_latest_wins_and_unknown_names_are_refused() {
12887 let rewritten = Playbook {
12888 name: "sit".into(),
12889 body: "rewritten sit body".into(),
12890 models: vec![],
12891 };
12892 let got = playbook_among("sit", std::slice::from_ref(&rewritten)).unwrap();
12893 assert_eq!(got.body, "rewritten sit body");
12894 let seed = playbook_among("sit", &[]).unwrap();
12895 assert!(
12896 seed.body.contains("Grade due claims"),
12897 "shipped seed when the pack has no live atom: {}",
12898 seed.body
12899 );
12900 let err = playbook_among("Benny", &[]).unwrap_err().to_string();
12901 assert!(err.contains("unknown"), "{err}");
12902 let sneaky = Playbook {
12903 name: "poteto-mode".into(),
12904 body: "second roster".into(),
12905 models: vec![],
12906 };
12907 let err = playbook_among("poteto-mode", std::slice::from_ref(&sneaky))
12908 .unwrap_err()
12909 .to_string();
12910 assert!(err.contains("unknown"), "{err}");
12911 assert!(playbook_atom(&sneaky, "ws").is_err());
12912 assert!(parse_playbook_name("overnight").is_ok());
12913 assert!(parse_playbook_name("company-panel").is_ok());
12914 let listed = playbooks_of(&[serde_json::json!({
12915 "kind": "playbook",
12916 "name": "Benny",
12917 "text": "no",
12918 "ts": "2026-01-01T00:00:00Z"
12919 })]);
12920 assert!(listed.is_empty(), "{listed:?}");
12921 let err = bind_playbook("proj-1a2b", "Benny").unwrap_err().to_string();
12922 assert!(err.contains("unknown"), "{err}");
12923 }
12924
12925 #[test]
12926 fn sitting_resolves_asked_else_bound_else_title_else_sit() {
12927 let _g = env_guard();
12928 let dir =
12929 std::env::temp_dir().join(format!("ljos-playbook-resolve-{}", std::process::id()));
12930 let _ = std::fs::remove_dir_all(&dir);
12931 std::fs::create_dir_all(&dir).unwrap();
12932 let before = std::env::var_os("XDG_RUNTIME_DIR");
12933 unsafe {
12934 std::env::set_var("XDG_RUNTIME_DIR", &dir);
12935 }
12936 assert_eq!(
12937 resolve_sitting_playbook("proj-1a2b", "Seat playbooks", Some("arena")).unwrap(),
12938 "arena"
12939 );
12940 assert_eq!(
12941 resolve_sitting_playbook("proj-1a2b", "x5jz compose: land B", None).unwrap(),
12942 "land"
12943 );
12944 assert_eq!(
12945 resolve_sitting_playbook("proj-1a2b", "Ship the fuse change?", None).unwrap(),
12946 "sit"
12947 );
12948 bind_playbook("proj-1a2b", "sit").unwrap();
12949 assert_eq!(
12950 resolve_sitting_playbook("proj-1a2b", "x5jz compose: land B", None).unwrap(),
12951 "sit",
12952 "sticky wins over title"
12953 );
12954 drop_playbook("proj-1a2b");
12955 assert_eq!(bound_playbook("proj-1a2b"), None);
12956 match before {
12957 Some(v) => unsafe { std::env::set_var("XDG_RUNTIME_DIR", v) },
12958 None => unsafe { std::env::remove_var("XDG_RUNTIME_DIR") },
12959 }
12960 let _ = std::fs::remove_dir_all(&dir);
12961 }
12962
12963 #[test]
12965 fn a_prediction_is_never_due() {
12966 let atoms = vec![
12967 serde_json::json!({"id": "f", "kind": "prediction", "text": "brio expects ship on acme-1."}),
12968 serde_json::json!({"id": "l", "kind": "lesson", "text": "a lesson"}),
12969 ];
12970 let due: Vec<String> = super::due_of(&atoms, "2026-01-01T00:00:00Z")
12971 .iter()
12972 .map(|a| a["id"].as_str().unwrap().to_string())
12973 .collect();
12974 assert_eq!(due, vec!["l"]);
12975 }
12976
12977 #[test]
12980 fn unreviewed_claims_are_due_and_the_summary_says_if_the_clock_runs() {
12981 let atoms = vec![
12982 serde_json::json!({"id": "a", "kind": "conclusion", "text": "old", "due_at": ""}),
12983 serde_json::json!({"id": "b", "kind": "conclusion", "text": "older"}),
12984 serde_json::json!({"id": "c", "kind": "conclusion", "text": "later",
12985 "due_at": "2030-01-01T00:00:00Z"}),
12986 serde_json::json!({"id": "d", "kind": "conclusion", "text": "past",
12987 "due_at": "2020-01-01T00:00:00Z"}),
12988 serde_json::json!({"id": "t", "kind": "trust", "text": "x weighs y"}),
12989 serde_json::json!({"id": "p", "kind": "playbook", "text": "sit recipe", "name": "sit"}),
12990 ];
12991 let now = "2026-01-01T00:00:00Z";
12992 let due: Vec<String> = super::due_of(&atoms, now)
12993 .iter()
12994 .map(|a| a["id"].as_str().unwrap().to_string())
12995 .collect();
12996 assert_eq!(
12997 due,
12998 ["a", "b", "d"],
12999 "unreviewed first, then the past-due one"
13000 );
13001 assert_eq!(
13002 super::review_summary(&atoms, now),
13003 "3 due; 1 scheduled, next at 2030-01-01T00:00:00Z"
13004 );
13005 assert_eq!(
13006 super::review_summary(&[atoms[4].clone()], now),
13007 "0 due; nothing scheduled: this seat has remembered nothing yet"
13008 );
13009 assert!(super::format_due(&super::due_of(&atoms, now)).starts_with("unreviewed\t"));
13010 }
13011
13012 #[test]
13013 fn bumping_mcp_generation_respawns_without_rewriting_the_entry() {
13014 let dir = std::env::temp_dir().join(format!("ljos-gen-{}", std::process::id()));
13015 let _ = std::fs::remove_dir_all(&dir);
13016 std::fs::create_dir_all(&dir).expect("tempdir");
13017 let config = dir.join("config.toml");
13018 std::fs::write(
13019 &config,
13020 "[mcp_servers.ljos.env]\nLJOS_MCP_GENERATION = \"0.12.8\"\n",
13021 )
13022 .expect("write");
13023 let bumped = super::bump_ljos_mcp_generation(&config, "0.13.1", false)
13024 .expect("bumps")
13025 .expect("changed");
13026 assert_eq!(bumped, "0.13.1");
13027 let text = std::fs::read_to_string(&config).expect("read");
13028 assert!(text.contains("LJOS_MCP_GENERATION = \"0.13.1\""), "{text}");
13029 assert!(!text.contains("0.12.8"), "{text}");
13030 assert!(
13031 super::bump_ljos_mcp_generation(&config, "0.13.1", false)
13032 .expect("second")
13033 .is_none(),
13034 "a matching generation is left alone"
13035 );
13036 let _ = std::fs::remove_dir_all(&dir);
13037 }
13038
13039 #[test]
13040 fn a_client_name_listed_on_a_harness_is_that_runners_seat() {
13041 let dir = std::env::temp_dir().join(format!("ljos-clients-{}", std::process::id()));
13042 std::fs::create_dir_all(&dir).unwrap();
13043 let file = dir.join("harnesses.toml");
13044 std::fs::write(
13045 &file,
13046 "[[harness]]\nname = \"acme\"\nclients = [\"acme-mcp-client\"]\n\n[[harness]]\nname = \"brio\"\nclients = [\"brio-coding-agent\"]\n",
13047 )
13048 .unwrap();
13049 assert_eq!(
13050 runner_for_client(&file, "acme-mcp-client").as_deref(),
13051 Some("acme")
13052 );
13053 assert_eq!(
13054 runner_for_client(&file, &seat_slug("brio-coding-agent")).as_deref(),
13055 Some("brio")
13056 );
13057 assert!(runner_for_client(&file, "acme-cli").is_none());
13058 assert!(runner_for_client(&dir.join("absent.toml"), "acme-mcp-client").is_none());
13059 let _ = std::fs::remove_dir_all(&dir);
13060 }
13061
13062 #[test]
13063 fn an_issues_tags_are_words_it_speaks_in() {
13064 let v: Value = serde_json::from_str(r#"{"tags":["Decision","sharing","memory"]}"#).unwrap();
13065 assert_eq!(tags_of(&v), vec!["decision", "sharing", "memory"]);
13066 assert!(tags_of(&serde_json::json!({})).is_empty());
13067 }
13068
13069 #[test]
13070 fn a_jev_panel_stands_only_when_every_seat_is_sure_and_agrees() {
13071 let b = |choice: &str, confidence: f64| jev::Ballot {
13072 choice: choice.into(),
13073 confidence,
13074 probabilities: Default::default(),
13075 forecast: Default::default(),
13076 escalate_below: 0.8,
13077 };
13078 assert!(jev_panel_stands(&[b("age", 0.95), b("age", 0.9)]));
13079 assert!(!jev_panel_stands(&[b("age", 0.95), b("gpg", 0.9)]), "split");
13080 assert!(
13081 !jev_panel_stands(&[b("age", 0.95), b("age", 0.6)]),
13082 "one unsure"
13083 );
13084 assert!(!jev_panel_stands(&[]));
13085 }
13086
13087 #[test]
13088 fn a_turn_is_read_from_the_last_request_to_the_final_message() {
13089 let lines = [
13090 r#"{"type":"user","message":{"content":"old request"}}"#,
13091 r#"{"type":"user","message":{"content":"fix the parser and test it"}}"#,
13092 r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"cargo test -p brio"}}]}}"#,
13093 r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"test result: FAILED. 3 passed; 1 failed"}]}}"#,
13094 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"All done, the parser works."}]}}"#,
13095 ]
13096 .join("\n");
13097 let t = stop_turn_from_transcript(&lines);
13098 assert_eq!(t.request, "fix the parser and test it");
13099 assert!(t.test_ran);
13100 assert_eq!(t.commands, vec!["cargo test -p brio"]);
13101 assert!(t.outputs[0].contains("1 failed"));
13102 assert_eq!(t.final_message, "All done, the parser works.");
13103 assert!(t.state().contains("The agent's final message:\nAll done"));
13104 assert!(!runs_tests("git status"));
13105 }
13106
13107 #[test]
13108 fn a_hold_the_multiplexer_owns_names_no_conversation_under_it() {
13109 let dir = tempfile::tempdir().unwrap();
13110 let hold = |name: &str, holder: &str, pid: u32, comm: &str, at: &str, node: &str| {
13111 std::fs::write(
13112 dir.path().join(format!("hold-{name}")),
13113 format!("{holder}\nseat\n{pid}\n{comm}\n{at}\n{node}\n"),
13114 )
13115 .unwrap();
13116 };
13117 hold(
13120 "other",
13121 "sess-other",
13122 3142,
13123 "herdr",
13124 "2026-09-29T09:16:06Z",
13125 "acme-5i5r",
13126 );
13127 hold(
13129 "mine",
13130 "sess-mine",
13131 4901,
13132 "acme",
13133 "2026-09-29T08:00:00Z",
13134 "brio-k6yq",
13135 );
13136 let chain = [
13137 (9001, "ljos".to_string()),
13138 (9000, "sh".to_string()),
13139 (4901, "acme".to_string()),
13140 ];
13141 assert_eq!(
13142 held_from_records_in(&[], dir.path(), &chain).as_deref(),
13143 Some("brio-k6yq"),
13144 "the runner's own record, not the multiplexer's"
13145 );
13146 let under_herdr = [(9001, "ljos".to_string()), (3142, "herdr".to_string())];
13147 assert_eq!(held_from_records_in(&[], dir.path(), &under_herdr), None);
13148 assert_eq!(
13149 held_from_records_in(&["sess-other".to_string()], dir.path(), &under_herdr).as_deref(),
13150 Some("acme-5i5r"),
13151 "a holder named outright still matches"
13152 );
13153 assert!(is_session("herdr") && is_session("tmux: server") && !is_session("acme"));
13154 }
13155
13156 #[test]
13157 fn a_generic_domain_gives_way_to_a_specific_one() {
13158 let persona = |name: &str, about: &[&str]| Persona {
13159 name: name.into(),
13160 anchor: 0.5,
13161 view: String::new(),
13162 entities: about.iter().map(|s| (*s).to_string()).collect(),
13163 };
13164 let pack = vec![
13165 persona("agentuser", &["seat", "hook"]),
13166 persona("build-meson", &["eon", "build"]),
13167 ];
13168 let words = |t: &str| topic_words(t);
13169 let seated = |t: &str| -> Vec<String> {
13170 personas_speaking_to(&pack, &words(t))
13171 .into_iter()
13172 .map(|p| p.name)
13173 .collect()
13174 };
13175 assert_eq!(
13176 seated("Which Jev hook integration to build next"),
13177 vec!["agentuser"]
13178 );
13179 assert_eq!(seated("Meson build breaks on Windows"), vec!["build-meson"]);
13180 assert_eq!(
13181 seated("eOn build flags"),
13182 vec!["build-meson"],
13183 "eon is specific"
13184 );
13185 }
13186
13187 #[test]
13188 fn options_come_from_a_line_or_its_bullets() {
13189 assert_eq!(
13190 issue_options("Why.\nOptions: age, gpg\n"),
13191 vec!["age", "gpg"]
13192 );
13193 assert_eq!(issue_options("Options:\n- a\n- b\n\nmore"), vec!["a", "b"]);
13194 assert!(
13195 issue_options("Options: only").is_empty(),
13196 "one option is no vote"
13197 );
13198 assert!(issue_options("no options").is_empty());
13199 }
13200
13201 #[test]
13202 fn a_decision_is_a_tag_a_type_or_an_options_line() {
13203 let v = |j: &str| -> Value { serde_json::from_str(j).unwrap() };
13204 assert!(is_decision(&v(r#"{"tags":["seat","decision"]}"#)));
13205 assert!(is_decision(&v(r#"{"properties":{"TYPE":"decision"}}"#)));
13206 assert!(is_decision(&v(
13207 r#"{"body":"Evidence.\n\nOptions:\n- a\n- b"}"#
13208 )));
13209 assert!(!is_decision(&v(
13210 r#"{"tags":["bug"],"properties":{"TYPE":"task"},"body":"no options here"}"#
13211 )));
13212 assert!(!is_decision(&v(
13213 r#"{"body":"We weighed the Options: none"}"#
13214 )));
13215 }
13216
13217 #[test]
13218 fn a_probe_passes_only_when_the_runner_lists_ljos() {
13219 let s = |v: &[&str]| v.iter().map(|x| (*x).to_string()).collect::<Vec<_>>();
13220 assert!(probe_lists_ljos(&s(&["sh", "-c", "echo ' ljos_sitting Call this'"])).is_ok());
13221 assert!(probe_lists_ljos(&s(&["sh", "-c", "echo 'MCP SDK not installed'"])).is_err());
13222 assert!(probe_lists_ljos(&s(&["sh", "-c", "echo ljos_sitting; exit 3"])).is_err());
13223 assert!(probe_lists_ljos(&s(&["/nonexistent/runner"])).is_err());
13224 let all: super::Harnesses = toml::from_str(super::HARNESSES_EXAMPLE).expect("parses");
13225 let hermes = all.harness.iter().find(|h| h.name == "hermes").unwrap();
13226 assert_eq!(hermes.probe, s(&["hermes", "mcp", "test", "ljos"]));
13227 }
13228
13229 #[test]
13230 fn a_plugin_runner_gets_its_bundled_plugin_with_ljos_filled() {
13231 let all: super::Harnesses = toml::from_str(super::HARNESSES_EXAMPLE).expect("parses");
13232 for name in ["opencode", "omp"] {
13233 let h = all.harness.iter().find(|h| h.name == name).expect(name);
13234 assert!(h.plugin.is_some(), "{name} names a plugin path");
13235 let text = super::plugin_text(h, Path::new("/opt/seat/bin/ljos")).expect(name);
13236 assert!(text.contains("\"/opt/seat/bin/ljos\""), "{name}");
13237 assert!(!text.contains("{ljos}"), "{name}");
13238 assert!(
13239 text.contains("PreToolUse") && text.contains("UserPromptSubmit"),
13240 "{name}"
13241 );
13242 }
13243 let unknown = super::Harness {
13244 name: "x".into(),
13245 plugin: Some("/tmp/x.ts".into()),
13246 plugin_template: Some("nobody".into()),
13247 ..Default::default()
13248 };
13249 assert!(super::plugin_text(&unknown, Path::new("/l")).is_none());
13250 let step = super::plugin_step(&unknown, Path::new("/tmp/x.ts"), true);
13251 assert!(!step.ok, "an unknown template writes nothing: {step:?}");
13252 }
13253
13254 #[test]
13258 fn onboarding_a_config_file_runner_writes_once() {
13259 let all: super::Harnesses = toml::from_str(super::HARNESSES_EXAMPLE).expect("parses");
13260 assert_eq!(all.harness.len(), 7);
13262 assert!(all.harness[3..].iter().all(|h| h.register.len()
13263 + usize::from(h.config.is_some())
13264 + usize::from(h.config_json.is_some())
13265 > 0));
13266 assert_eq!(all.harness[1].marker.as_deref(), Some("[mcp_servers.ljos]"));
13267 assert_eq!(all.harness[2].json_pointer.as_deref(), Some("/mcp/ljos"));
13268
13269 let dir = std::env::temp_dir().join(format!("ljos-onboard-{}", std::process::id()));
13270 let _ = std::fs::remove_dir_all(&dir);
13271 std::fs::create_dir_all(&dir).expect("tempdir");
13272 let config = dir.join("config.toml");
13273 let skills = dir.join("skills");
13274 let file = dir.join("harnesses.toml");
13275 std::fs::write(
13276 &file,
13277 format!(
13278 "[[harness]]\nname = \"r\"\nconfig = {config:?}\nmarker = \"[mcp_servers.ljos]\"\n\
13279 snippet = \"\\n[mcp_servers.ljos]\\ncommand = \\\"{{server}}\\\"\\n\"\nskills = {skills:?}\n",
13280 config = config.display().to_string(),
13281 skills = skills.display().to_string(),
13282 ),
13283 )
13284 .expect("write");
13285
13286 let refused = super::onboard_from(&file, "nobody", true)
13287 .unwrap_err()
13288 .to_string();
13289 assert!(
13290 refused.contains("no runner \"nobody\"") && refused.contains("names r"),
13291 "{refused}"
13292 );
13293
13294 let steps = match super::onboard_from(&file, "r", true) {
13295 Ok(steps) => steps,
13296 Err(e) => {
13299 assert!(e.to_string().contains("ljos-mcp not on PATH"), "{e}");
13300 return;
13301 }
13302 };
13303 assert!(steps.iter().all(|s| s.ok), "{steps:?}");
13304 assert!(
13305 steps[0].detail.starts_with("would append"),
13306 "{}",
13307 steps[0].detail
13308 );
13309 assert!(!config.exists() && !skills.exists(), "a dry run wrote");
13310
13311 let steps = super::onboard_from(&file, "r", false).expect("onboards");
13312 assert!(steps.iter().all(|s| s.ok), "{steps:?}");
13313 let written = std::fs::read_to_string(&config).expect("config written");
13314 assert_eq!(written.matches("[mcp_servers.ljos]").count(), 1);
13315 assert!(written.contains("ljos-mcp"), "{written}");
13316 let skill = std::fs::read_to_string(skills.join("ljos/SKILL.md")).expect("skill written");
13317 assert!(skill.starts_with("---\nname: ljos\n"));
13318 assert!(skill.contains("## Before the work"));
13319
13320 let again = super::onboard_from(&file, "r", false).expect("onboards again");
13321 assert_eq!(again[0].detail, "ljos registered");
13322 assert!(
13323 again[1].detail.ends_with("is current"),
13324 "{}",
13325 again[1].detail
13326 );
13327 assert_eq!(
13328 std::fs::read_to_string(&config)
13329 .expect("config")
13330 .matches("[mcp_servers.ljos]")
13331 .count(),
13332 1,
13333 "the entry was appended twice"
13334 );
13335 let _ = std::fs::remove_dir_all(&dir);
13336 }
13337
13338 #[test]
13339 fn grok_onboard_names_the_frozen_hook_file() {
13340 let file = std::env::temp_dir().join("ljos-missing-harnesses.toml");
13341 let steps = super::onboard_from(&file, "grok", true).expect("grok dry");
13342 assert!(steps[0].ok, "{steps:?}");
13343 assert!(
13344 steps[0].detail.contains(".grok/hooks/ljos.json"),
13345 "{}",
13346 steps[0].detail
13347 );
13348 }
13349
13350 #[test]
13351 fn the_grok_hook_file_runs_ljos_by_absolute_path() {
13352 let text = super::grok_hooks_json(Path::new("/opt/seat/bin/ljos"));
13353 let v: Value = serde_json::from_str(&text).expect("the hook file is JSON");
13354 let pre = &v["hooks"]["PreToolUse"][0]["hooks"][0];
13355 assert_eq!(pre["command"], "/opt/seat/bin/ljos hook");
13356 assert_eq!(pre["timeout"], 10);
13357 let stop = &v["hooks"]["Stop"][0]["hooks"][0];
13358 assert_eq!(stop["command"], "/opt/seat/bin/ljos hook");
13359 assert!(!text.contains("{ljos}"), "{text}");
13360 assert!(!text.contains("\"ljos hook\""), "{text}");
13361 }
13362
13363 use super::*;
13364 use std::io::{Read, Write};
13365 use std::net::TcpListener;
13366 use std::sync::{Arc, Mutex};
13367
13368 #[test]
13370 fn a_refusal_is_an_error_not_an_answer() {
13371 let err = run_captured("false", &[] as &[&str]).unwrap_err();
13372 assert!(err.to_string().contains("false exited"), "{err}");
13373 let said = run_captured("sh", &["-c", "echo answered; echo aside >&2"]).unwrap();
13374 assert_eq!(said.stdout.trim(), "answered");
13375 assert_eq!(said.stderr.trim(), "aside");
13376 let said = run_captured("sh", &["-c", "echo reason >&2; exit 3"]).unwrap_err();
13377 assert!(said.to_string().contains("reason"), "{said}");
13378 }
13379
13380 #[test]
13381 fn join_keeps_spaces() {
13382 assert_eq!(
13383 join(&["the default fuse".into(), "is CombMNZ".into()]),
13384 "the default fuse is CombMNZ"
13385 );
13386 }
13387
13388 #[test]
13389 fn remember_is_lesson_prefer_is_preference() {
13390 assert_eq!(atom_kind("Remember").unwrap(), "lesson");
13391 assert_eq!(atom_kind("Prefer").unwrap(), "preference");
13392 assert!(atom_kind("extract").is_err());
13393 }
13394
13395 #[test]
13396 fn a_sitting_lists_the_due_claims_its_island_holds_first() {
13397 let due = vec![
13398 serde_json::json!({"id": "old", "due_at": "2026-09-01"}),
13399 serde_json::json!({"id": "here", "due_at": "2026-09-05"}),
13400 serde_json::json!({"id": "older", "due_at": "2026-08-01"}),
13401 ];
13402 let island = serde_json::json!({"island": [{"id": "here"}, {"id": "absent"}]});
13403 let ids: Vec<String> = due_on_island_first(due, &island)
13404 .iter()
13405 .map(|a| a["id"].as_str().unwrap().to_string())
13406 .collect();
13407 assert_eq!(ids, ["here", "old", "older"]);
13408 let weak = serde_json::json!({"weak": true, "island": [{"id": "older"}]});
13409 let kept = due_on_island_first(
13410 vec![
13411 serde_json::json!({"id": "a"}),
13412 serde_json::json!({"id": "older"}),
13413 ],
13414 &weak,
13415 );
13416 assert_eq!(kept[0]["id"], "a", "a weak island does not reorder");
13417 }
13418
13419 #[test]
13420 fn atom_body_is_explicit_and_unextracted() {
13421 let v = atom_body("lesson", "the default fuse is CombMNZ", "ws");
13422 assert_eq!(v["schema"], "inside.atom/v1");
13423 assert_eq!(v["kind"], "lesson");
13424 assert_eq!(v["level"], "explicit");
13425 assert_eq!(v["text"], "the default fuse is CombMNZ");
13426 assert_eq!(v["workspace"], "ws");
13427 assert_eq!(v["source"]["via"], "ljos");
13429 assert!(!v["source"]["host"].as_str().unwrap_or("").is_empty());
13430 assert!(!v["source"]["session"].as_str().unwrap_or("").is_empty());
13431 let seat = v["entities"][0].as_str().unwrap();
13433 assert!(seat.starts_with(SEAT_ENTITY), "{seat}");
13434 let mut more = v.clone();
13435 add_entities(
13436 &mut more,
13437 ["persona:reviewer".to_string(), seat.to_string()],
13438 );
13439 assert_eq!(more["entities"].as_array().unwrap().len(), 2, "{more}");
13440 let raw = atom_body("lesson", "Remember: pin the review set", "ws");
13442 assert_eq!(raw["text"], "Remember: pin the review set");
13443 }
13444
13445 #[test]
13446 fn empty_claim_is_refused() {
13447 let client = PacksetClient::new("http://127.0.0.1:1");
13448 let err = post_claim(&client, "Remember", " ", "ws").unwrap_err();
13449 assert!(err.to_string().contains("empty text"));
13450 }
13451
13452 #[test]
13453 fn cards_are_the_two_named_files_only() {
13454 assert_eq!(CARD_NAMES, &["USER.md", "MEMORY.md"]);
13455 let dir = std::env::temp_dir().join(format!("ljos-cards-{}", std::process::id()));
13456 let _ = std::fs::remove_dir_all(&dir);
13457 std::fs::create_dir_all(&dir).unwrap();
13458 std::fs::write(dir.join("USER.md"), "user card\n").unwrap();
13459 std::fs::write(dir.join("MEMORY.md"), "memory card\n").unwrap();
13460 std::fs::write(dir.join("NOTES.md"), "must not appear\n").unwrap();
13461 let out = cards(&dir).unwrap();
13462 assert!(out.contains("user card"));
13463 assert!(out.contains("memory card"));
13464 assert!(!out.contains("must not appear"));
13465 assert!(!out.contains("NOTES.md"));
13466 let _ = std::fs::remove_dir_all(&dir);
13467 }
13468
13469 #[test]
13470 fn policy_prints_argv_and_does_not_reload() {
13471 assert!(policy_line(&[]).is_err());
13472 assert_eq!(policy_line(&["ls".into(), "-la".into()]).unwrap(), "ls -la");
13473 let note = POLICY_TCB.to_ascii_lowercase();
13474 assert!(note.contains("ljos-policyd"));
13475 assert!(note.contains("not a check"));
13476 assert!(!note.contains("grokos policy reload"));
13477 assert!(!note.contains("policy reload"));
13478 }
13479
13480 #[test]
13481 fn consensus_is_ljos_then_vissue() {
13482 let steps = consensus_steps("vissue-1a5a", true, true, &[]).unwrap();
13483 assert_eq!(steps.len(), 2);
13484 assert_eq!(steps[0].bin, "ljos-consensus");
13485 assert_eq!(steps[0].args, vec!["settle", "--issue", "vissue-1a5a"]);
13486 assert_eq!(steps[1].bin, "vissue");
13487 assert_eq!(steps[1].args, vec!["consensus", "vissue-1a5a"]);
13488 }
13489
13490 #[test]
13491 fn consensus_carries_the_packs_trust() {
13492 let rows = vec![row("a", "b", 0.5)];
13493 let steps = consensus_steps("id", true, true, &rows).unwrap();
13494 assert_eq!(steps[0].args[3], "--trust");
13495 assert_eq!(steps[0].args[4], r#"[["a","b",0.5]]"#);
13496 assert_eq!(
13497 steps[1].args,
13498 vec!["consensus", "id", "--trust", r#"[["a","b",0.5]]"#]
13499 );
13500 }
13501
13502 #[test]
13503 fn consensus_skips_a_missing_bin() {
13504 let only_v = consensus_steps("id", false, true, &[]).unwrap();
13505 assert_eq!(only_v.len(), 1);
13506 assert_eq!(only_v[0].bin, "vissue");
13507 let only_l = consensus_steps("id", true, false, &[]).unwrap();
13508 assert_eq!(only_l[0].bin, "ljos-consensus");
13509 assert!(consensus_steps("id", false, false, &[]).is_err());
13510 }
13511
13512 fn row(from: &str, to: &str, weight: f64) -> Trust {
13513 Trust {
13514 about: Vec::new(),
13515 from: from.into(),
13516 to: to.into(),
13517 weight,
13518 }
13519 }
13520
13521 #[test]
13522 fn a_trust_atom_is_one_edge_with_its_evidence() {
13523 let atom = trust_atom(&row("a", "b", 0.25), &["deed-x-y".into()], "ws").unwrap();
13524 assert_eq!(atom["kind"], "trust");
13525 assert_eq!(atom["from"], "a");
13526 assert_eq!(atom["to"], "b");
13527 assert_eq!(atom["weight"], 0.25);
13528 assert_eq!(atom["entities"], serde_json::json!(["deed-x-y"]));
13529 assert_eq!(atom["text"], "a weighs b at 0.250.");
13530 assert!(trust_atom(&row("a", "a", 0.5), &[], "ws").is_err());
13531 assert!(trust_atom(&row("a", "b", 0.0), &[], "ws").is_err());
13532 assert!(trust_atom(&row("a", "b", 1.5), &[], "ws").is_err());
13533 assert!(trust_atom(&row("", "b", 0.5), &[], "ws").is_err());
13534 }
13535
13536 #[test]
13537 fn the_latest_row_per_pair_wins() {
13538 let atoms = vec![
13539 serde_json::json!({"kind": "trust", "from": "a", "to": "b", "weight": 0.9, "ts": "2026-01-01T00:00:00Z"}),
13540 serde_json::json!({"kind": "trust", "from": "a", "to": "b", "weight": 0.3, "ts": "2026-02-01T00:00:00Z"}),
13541 serde_json::json!({"kind": "trust", "from": "b", "to": "a", "weight": 0.7}),
13542 serde_json::json!({"kind": "lesson", "text": "not a row"}),
13543 serde_json::json!({"kind": "trust", "from": "b", "weight": 0.7}),
13544 ];
13545 let rows = trust_rows(&atoms);
13546 assert_eq!(rows, vec![row("a", "b", 0.3), row("b", "a", 0.7)]);
13547 assert_eq!(trust_json(&rows), r#"[["a","b",0.3],["b","a",0.7]]"#);
13548 }
13549
13550 #[test]
13551 fn ballots_are_agent_and_choice() {
13552 let rows =
13553 ballots_from_json(r#"[{"agent":"a","choice":"ship","stamp":"[2026-01-01]"}]"#).unwrap();
13554 assert_eq!(rows, vec![("a".to_string(), "ship".to_string())]);
13555 assert!(ballots_from_json(r#"[{"agent":"a"}]"#).is_err());
13556 assert!(ballots_from_json("{}").is_err());
13557 }
13558
13559 #[test]
13562 fn learning_downweights_the_refuted_voter() {
13563 let ballots = vec![
13564 ("a".to_string(), "ship".to_string()),
13565 ("b".to_string(), "ship".to_string()),
13566 ("c".to_string(), "hold".to_string()),
13567 ];
13568 let rows = learn(&ballots, "ship", &[], 0.5).unwrap();
13569 assert_eq!(rows.len(), 6);
13570 let w = |from: &str, to: &str| {
13571 rows.iter()
13572 .find(|r| r.from == from && r.to == to)
13573 .unwrap()
13574 .weight
13575 };
13576 assert_eq!(w("a", "b"), 1.0);
13577 assert_eq!(w("a", "c"), 0.5);
13578 assert_eq!(w("b", "c"), 0.5);
13579 assert_eq!(w("c", "a"), 1.0);
13580
13581 let again = learn(&ballots, "ship", &rows, 0.5).unwrap();
13582 let w2 = |from: &str, to: &str| {
13583 again
13584 .iter()
13585 .find(|r| r.from == from && r.to == to)
13586 .unwrap()
13587 .weight
13588 };
13589 assert_eq!(w2("a", "c"), 0.25);
13590 assert_eq!(w2("a", "b"), 1.0);
13591
13592 let floored = learn(&ballots, "ship", &[row("a", "c", 0.015)], 0.5).unwrap();
13593 let low = floored
13594 .iter()
13595 .find(|r| r.from == "a" && r.to == "c")
13596 .unwrap();
13597 assert_eq!(low.weight, TRUST_FLOOR);
13598
13599 assert!(learn(&ballots, "ship", &[], 1.0).is_err());
13600 assert!(learn(&ballots, " ", &[], 0.5).is_err());
13601 assert!(learn(&ballots[..1], "ship", &[], 0.5).is_err());
13602
13603 let shared = learn_shared(&ballots, "ship", &rows, 0.5, &[], 0.1).unwrap();
13606 let w3 = |from: &str, to: &str| {
13607 shared
13608 .iter()
13609 .find(|r| r.from == from && r.to == to)
13610 .unwrap()
13611 .weight
13612 };
13613 assert!((w3("a", "c") - (0.25 + 0.75 * 0.1)).abs() < 1e-12);
13614 assert_eq!(w3("a", "b"), 1.0);
13615 assert!(learn_shared(&ballots, "ship", &[], 0.5, &[], 1.0).is_err());
13616 }
13617
13618 #[test]
13619 fn a_name_is_one_work_id_and_hex_passes_through() {
13620 let a = work_id("demo-riml");
13621 assert_eq!(a.len(), 32);
13622 assert!(a.bytes().all(|b| b.is_ascii_hexdigit()));
13623 assert_eq!(a, work_id(" demo-riml "));
13624 assert_ne!(a, work_id("demo-rimm"));
13625 assert_eq!(work_id(&a.to_ascii_uppercase()), a);
13626 assert_ne!(work_id("seat"), work_id("reader"));
13627 }
13628
13629 #[test]
13630 fn a_refusal_is_not_a_writer_that_is_down() {
13631 let refused = anyhow::Error::from(packset_client::Error::Bad("no".into()));
13632 assert!(!writer_unreachable(&refused));
13633 }
13634
13635 #[test]
13636 fn a_stated_probability_has_a_brier_score_and_a_hard_vote_does_not() {
13637 let rows = vec![
13638 Forecast {
13639 agent: "a".into(),
13640 choice: "ship".into(),
13641 confidence: Some(0.8),
13642 },
13643 Forecast {
13644 agent: "b".into(),
13645 choice: "hold".into(),
13646 confidence: None,
13647 },
13648 ];
13649 assert!((brier("ship", "ship", 0.8) - 0.04).abs() < 1e-12);
13650 assert!((brier("hold", "ship", 0.8) - 0.64).abs() < 1e-12);
13651 let (mean, n) = mean_brier(&rows, "ship").unwrap();
13652 assert_eq!(n, 1);
13653 assert!((mean - 0.04).abs() < 1e-12);
13654 let said = learn_reading(2, 0, &rows, "ship", &std::collections::BTreeMap::new());
13655 assert!(said.contains("Brier 0.040"), "{said}");
13656 assert!(said.contains("not a trust weight"), "{said}");
13657 let silent = learn_reading(2, 0, &rows[1..], "ship", &std::collections::BTreeMap::new());
13658 assert!(silent.contains("No stated probability"), "{silent}");
13659 assert!(log_score("ship", "ship", 0.8).unwrap() > 0.0);
13660 assert!(log_score("hold", "ship", 1.0).is_none());
13661 let mut cal = Calibration::default();
13662 cal = observe(&cal, "ship", "ship", 0.8);
13663 cal = observe(&cal, "ship", "hold", 0.8);
13664 let part = murphy(&cal).unwrap();
13665 let mean_b = cal.sum_brier / f64::from(cal.n);
13666 assert!((part.reliability - part.resolution + part.uncertainty - mean_b).abs() < 1e-9);
13667 assert!((cal.sum_p / f64::from(cal.n) - 0.8).abs() < 1e-12);
13668 assert!((cal.sum_o / f64::from(cal.n) - 0.5).abs() < 1e-12);
13669 }
13670
13671 #[test]
13672 fn an_island_prints_one_memory_a_line() {
13673 let body = serde_json::json!({"island": [
13674 {"id": "a", "text": "one", "activation": 1.0, "seed": true, "ts": now_utc()},
13675 {"id": "b", "text": "two", "activation": 0.25, "seed": false}
13676 ]});
13677 let printed = format_island(&body);
13678 assert!(
13679 printed.contains("Seat island") && printed.contains("Not fired"),
13680 "{printed}"
13681 );
13682 assert!(
13683 printed.contains("1.000\tseed\ta\ttoday\tone\n"),
13684 "{printed}"
13685 );
13686 assert!(printed.contains("0.250\t \tb\t\ttwo\n"), "{printed}");
13687 assert!(format_island(&serde_json::json!({})).is_empty());
13688 let persona = serde_json::json!({
13689 "as": "reviewer",
13690 "fired": 3,
13691 "island": [{"id": "a", "text": "one", "activation": 1.0, "seed": true, "ts": now_utc()}]
13692 });
13693 let walked = format_island(&persona);
13694 assert!(walked.contains("Persona reviewer"), "{walked}");
13695 assert!(walked.contains("Fired: 3"), "{walked}");
13696 assert!(!walked.contains("Seat island"), "{walked}");
13697 }
13698
13699 #[test]
13700 fn a_fed_verb_reads_its_stdin() {
13701 let said = run_fed("cat", &[] as &[&str], "one\ntwo\n").unwrap();
13702 assert_eq!(said.stdout, "one\ntwo\n");
13703 assert!(run_fed("sh", &["-c", "exit 2"], "").is_err());
13704 }
13705
13706 #[test]
13707 fn needs_and_cited_are_enclosed_once_each() {
13708 let needs = needs_of(r#"{"needs":["deed-b-2","deed-a-1"],"other":1}"#).unwrap();
13709 assert_eq!(needs, vec!["deed-b-2", "deed-a-1"]);
13710 assert_eq!(
13711 enclose(needs, "deed-a-1\n\ndeed-c-3\n"),
13712 vec!["deed-a-1", "deed-b-2", "deed-c-3"]
13713 );
13714 assert!(needs_of("{}").unwrap().is_empty());
13715 assert!(needs_of("not json").is_err());
13716 }
13717
13718 #[test]
13719 fn a_json_config_takes_the_entry_by_pointer() {
13720 let dir = std::env::temp_dir().join(format!("ljos-onboard-json-{}", std::process::id()));
13721 std::fs::create_dir_all(&dir).unwrap();
13722 let config = dir.join("runner.json");
13723 std::fs::write(&config, "{\"model\": \"x\"}\n").unwrap();
13724 let entry = serde_json::json!({"type": "local", "command": ["/bin/ljos-mcp"]});
13725 set_json_entry(&config, "/mcp/ljos", &entry).unwrap();
13726 let doc: Value = serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
13727 assert_eq!(doc["model"], "x", "the rest of the file stands");
13728 assert_eq!(doc["mcp"]["ljos"]["command"][0], "/bin/ljos-mcp");
13729 let h = Harness {
13730 name: "runner".into(),
13731 register: Vec::new(),
13732 registered: Vec::new(),
13733 config: None,
13734 marker: None,
13735 snippet: None,
13736 config_json: Some(config.display().to_string()),
13737 json_pointer: Some("/mcp/ljos".into()),
13738 json_entry: None,
13739 skills: None,
13740 hooks: None,
13741 hook_events: Vec::new(),
13742 plugin: None,
13743 plugin_template: None,
13744 probe: Vec::new(),
13745 clients: Vec::new(),
13746 };
13747 assert_eq!(is_registered(&h, Path::new("/bin/ljos-mcp")), Some(true));
13748 let _ = std::fs::remove_dir_all(&dir);
13749 }
13750
13751 #[test]
13752 fn a_persona_set_is_in_the_pack_alphabet() {
13753 assert_eq!(persona_set("Reviewer"), "persona-reviewer");
13754 assert_eq!(persona_set("first gpu:user"), "persona-first-gpu-user");
13755 assert!(persona_set("x".repeat(60).as_str()).len() <= 32);
13756 }
13757
13758 #[test]
13759 fn the_roster_lists_each_persona_on_one_line() {
13760 assert!(format_personas(&[]).starts_with("no personas;"));
13761 let roster = format_personas(&[
13762 Persona {
13763 name: "reviewer".into(),
13764 anchor: 0.2,
13765 view: "Reads for what breaks.".into(),
13766 entities: vec!["docs".into(), "release".into()],
13767 },
13768 Persona {
13769 name: "reader".into(),
13770 anchor: 0.8,
13771 view: "Reads as a first-time user.".into(),
13772 entities: Vec::new(),
13773 },
13774 ]);
13775 let lines: Vec<&str> = roster.lines().collect();
13776 assert_eq!(lines.len(), 2);
13777 assert!(
13778 lines[0].starts_with("reviewer anchor 0.20 about docs, release Reads"),
13779 "{}",
13780 lines[0]
13781 );
13782 assert!(lines[1].contains("about anything"), "{}", lines[1]);
13783 }
13784
13785 #[test]
13786 fn the_sweep_line_counts_what_moved_and_is_silent_otherwise() {
13787 assert_eq!(format_sweep(None), "");
13788 assert_eq!(
13789 format_sweep(Some(&serde_json::json!({"lapsed": 0, "forgotten": 0}))),
13790 ""
13791 );
13792 let line = format_sweep(Some(&serde_json::json!({"lapsed": 2, "forgotten": 1})));
13793 assert!(line.contains("2 reviews lapsed"), "{line}");
13794 assert!(line.contains("1 never-recalled claim forgotten"), "{line}");
13795 let one = format_sweep(Some(&serde_json::json!({"lapsed": 1, "forgotten": 0})));
13796 assert!(
13797 one.contains("1 review lapsed past twice its interval"),
13798 "{one}"
13799 );
13800 }
13801
13802 #[test]
13803 fn due_is_the_past_soonest_first() {
13804 let atoms = vec![
13805 serde_json::json!({"id": "late", "due_at": "2026-02-01T00:00:00.000Z"}),
13806 serde_json::json!({"id": "later", "due_at": "2026-03-01T00:00:00.000Z"}),
13807 serde_json::json!({"id": "future", "due_at": "2099-01-01T00:00:00.000Z"}),
13808 serde_json::json!({"id": "never"}),
13809 serde_json::json!({"id": "blank", "due_at": ""}),
13810 ];
13811 let due = due_of(&atoms, "2026-06-01T00:00:00.000Z");
13812 let ids: Vec<&str> = due.iter().map(|a| a["id"].as_str().unwrap()).collect();
13813 assert_eq!(ids, ["never", "blank", "late", "later"]);
13816 assert!(now_utc().ends_with(".000Z"));
13817 assert!(now_utc().as_str() > "2026-01-01T00:00:00.000Z");
13818 }
13819
13820 #[test]
13821 fn timeline_exposes_event_rows() {
13822 let src = include_str!("lib.rs");
13823 assert!(src.contains("pub fn timeline_events"));
13824 assert!(src.contains("Result<Vec<Event>>"));
13825 assert!(src.contains("pub fn pack_last_write_ts"));
13826 assert!(src.contains("GET /v1/status"));
13827 assert!(src.contains("vissue_core::agent::show_json"));
13828 }
13829
13830 #[test]
13831 fn timeline_of_does_not_shell_vissue() {
13832 let src = include_str!("lib.rs");
13833 let start = src.find("fn timeline_of").expect("timeline_of");
13834 let end = src[start..]
13835 .find("\npub fn timeline(")
13836 .map(|i| start + i)
13837 .expect("timeline after timeline_of");
13838 let body = &src[start..end];
13839 assert!(
13840 !body.contains("run_captured(\"vissue\""),
13841 "timeline_of must not shell vissue"
13842 );
13843 assert!(
13844 !body.contains("Command::new(\"vissue\")"),
13845 "timeline_of must not Command::new vissue"
13846 );
13847 assert!(
13848 body.contains("tracker_show_json"),
13849 "timeline_of should call the tracker library"
13850 );
13851 }
13852
13853 #[test]
13854 fn timeline_events_reads_the_tracker_without_shelling_vissue() {
13855 let _g = env_guard();
13856 let dir = tempfile::tempdir().unwrap();
13857 let project = dir.path().join("Software/sample");
13858 std::fs::create_dir_all(&project).unwrap();
13859 std::fs::write(
13860 project.join("issues.org"),
13861 "#+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",
13862 )
13863 .unwrap();
13864 let old_issue_root = std::env::var_os("ISSUE_ROOT");
13865 let old_vissue_root = std::env::var_os("VISSUE_ROOT");
13866 let old_no_route = std::env::var_os("VISSUE_NO_ROUTE");
13867 let old_path = std::env::var_os("PATH");
13868 unsafe {
13869 std::env::set_var("ISSUE_ROOT", dir.path());
13870 std::env::set_var("VISSUE_ROOT", dir.path());
13871 std::env::set_var("VISSUE_NO_ROUTE", "1");
13872 std::env::set_var("PATH", "/usr/bin");
13873 }
13874 let events = timeline_events("sample-k2p2", 12);
13875 unsafe {
13876 match old_issue_root {
13877 Some(v) => std::env::set_var("ISSUE_ROOT", v),
13878 None => std::env::remove_var("ISSUE_ROOT"),
13879 }
13880 match old_vissue_root {
13881 Some(v) => std::env::set_var("VISSUE_ROOT", v),
13882 None => std::env::remove_var("VISSUE_ROOT"),
13883 }
13884 match old_no_route {
13885 Some(v) => std::env::set_var("VISSUE_NO_ROUTE", v),
13886 None => std::env::remove_var("VISSUE_NO_ROUTE"),
13887 }
13888 match old_path {
13889 Some(v) => std::env::set_var("PATH", v),
13890 None => std::env::remove_var("PATH"),
13891 }
13892 }
13893 let events = events.expect("timeline_events should read the tracker library");
13894 assert!(
13895 events
13896 .iter()
13897 .any(|e| e.source == "tracker" && e.text == "created"),
13898 "{events:?}"
13899 );
13900 }
13901
13902 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";
13903
13904 #[test]
13905 fn a_bundle_becomes_rows_with_edges_and_steady_ids() {
13906 let dir = std::env::temp_dir().join(format!("ljos-bump-{}", std::process::id()));
13907 let _ = std::fs::remove_dir_all(&dir);
13908 std::fs::create_dir_all(dir.join("locks")).unwrap();
13909 std::fs::write(
13910 dir.join("locks/default.lock.json"),
13911 r#"{"package":"eOn","version":"2.17.10","toolchain":{"name":"foss","version":"2026.1"},"versionsuffix":"",
13912 "dependencies":[
13913 {"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},
13914 {"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},
13915 {"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}]}"#,
13916 )
13917 .unwrap();
13918 std::fs::write(
13919 dir.join("package.sbom.cdx.json"),
13920 r#"{"components":[],"dependencies":[
13921 {"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"]},
13922 {"ref":"pkg:generic/Eigen@==5.0.0","dependsOn":["pkg:generic/CMake@==4.2.1"]},
13923 {"ref":"pkg:generic/CMake@==4.2.1"}]}"#,
13924 )
13925 .unwrap();
13926 let (generation, rows) = bump_rows(&dir, "ebstack", None).unwrap();
13927 assert_eq!(generation, "foss/2026.1");
13928 let modules: Vec<&str> = rows.iter().map(|r| r.module.as_str()).collect();
13929 assert_eq!(
13930 modules,
13931 [
13932 "eOn-2.17.10-foss-2026.1",
13933 "CMake-4.2.1-GCCcore-15.2.0",
13934 "Eigen-5.0.0-GCCcore-15.2.0",
13935 "Python-3.14.2-GCCcore-15.2.0"
13936 ],
13937 "the root first, then every module the lock names, build dependencies included"
13938 );
13939 let cmake = &rows[1];
13940 let eigen = &rows[2];
13941 let python = &rows[3];
13942 assert!(cmake.blockers.is_empty());
13943 assert_eq!(eigen.blockers, std::slice::from_ref(&cmake.id));
13944 assert_eq!(
13945 rows[0].blockers,
13946 [cmake.id.clone(), eigen.id.clone(), python.id.clone()],
13947 "the root is blocked by every module it depends on"
13948 );
13949 assert_eq!(
13950 rows[0].id,
13951 bump_issue_id("ebstack", "eOn-2.17.10-foss-2026.1", "foss/2026.1")
13952 );
13953 assert!(rows[0].id.starts_with("ebstack-") && rows[0].id.len() == "ebstack-".len() + 8);
13954 assert_ne!(
13955 rows[0].id,
13956 bump_issue_id("ebstack", "eOn-2.17.10-foss-2026.1", "foss/2027a")
13957 );
13958 assert!(rows.iter().all(|r| r.result == "would make"));
13959 let _ = std::fs::remove_dir_all(&dir);
13960 }
13961
13962 #[test]
13963 fn a_finding_lesson_is_two_short_sentences_about_the_recipe() {
13964 let campaign = Campaign {
13965 package: "eOn".into(),
13966 version: "2.17.10".into(),
13967 target: "terra".into(),
13968 status: "completed".into(),
13969 attempts: 29,
13970 findings: Vec::new(),
13971 };
13972 let f = Finding {
13973 id: "attempt:6:finding:6".into(),
13974 status: "resolved".into(),
13975 class: "compile".into(),
13976 disposition: "requires-judgment".into(),
13977 stage: "build".into(),
13978 recipe: recipe_stem("easyconfigs/e/eOn/eOn-2.17.10-foss-2026.1.eb"),
13979 module: failed_module(EVIDENCE).unwrap_or_default(),
13980 summary: "Compile failure from EasyBuild command (exit Some(1))".into(),
13981 error: error_line(EVIDENCE, "Compile failure"),
13982 action: "applied the GCC 14 libsanitizer kernel headers patch. Kept in the overlay"
13983 .into(),
13984 changes: vec!["overlay/g/GCCcore/GCCcore-15.2.0.eb".into()],
13985 };
13986 assert_eq!(f.module, "GCCcore-15.2.0");
13987 let lesson = finding_lesson(&campaign, &f);
13988 assert_eq!(
13989 lesson,
13990 "GCCcore-15.2.0 for eOn-2.17.10-foss-2026.1 on terra: compile failed in the build step \
13991 with shell command 'make' failed with exit code 2 in build. \
13992 Fix: applied the GCC 14 libsanitizer kernel headers patch, Kept in the overlay in GCCcore-15.2.0."
13993 );
13994 assert!(!lesson.contains("srun"));
13995 assert_eq!(
13996 finding_entities(&campaign, &f),
13997 [
13998 "GCCcore-15.2.0",
13999 "GCCcore",
14000 "eOn-2.17.10-foss-2026.1",
14001 "eOn",
14002 "compile"
14003 ]
14004 );
14005 let retry = Finding {
14006 action: "successful campaign retry superseded this finding".into(),
14007 ..f.clone()
14008 };
14009 assert!(superseded_by_retry(&retry));
14010 assert!(!superseded_by_retry(&f));
14011 assert!(finding_lesson(&campaign, &retry).ends_with("A later attempt got past it."));
14012 assert_eq!(
14013 failed_module("== building and installing gettext/0.26...\n== FAILED"),
14014 Some("gettext-0.26".into())
14015 );
14016 }
14017
14018 #[test]
14019 fn tracker_decimal_confidence_remains_a_scored_forecast() {
14020 let forecasts = super::forecasts_from_json(
14021 r#"[{"agent":"alice","choice":"accept","confidence":"0.8"},
14022 {"agent":"bob","choice":"reject","confidence":0.6},
14023 {"agent":"carol","choice":"accept","confidence":null},
14024 {"agent":"dana","choice":"accept"}]"#,
14025 )
14026 .unwrap();
14027 assert_eq!(forecasts[0].confidence, Some(0.8));
14028 assert_eq!(forecasts[1].confidence, Some(0.6));
14029 assert_eq!(forecasts[2].confidence, None);
14030 assert_eq!(forecasts[3].confidence, None);
14031 let (score, count) = super::mean_brier(&forecasts, "accept").unwrap();
14032 assert_eq!(count, 2);
14033 assert!((score - 0.2).abs() < 1e-14);
14034 }
14035
14036 #[test]
14037 fn invalid_tracker_confidence_is_not_silently_unscored() {
14038 for confidence in ["0", "-0.1", "1.1", "\"NaN\"", "\"oops\"", "true", "[]"] {
14039 let raw =
14040 format!(r#"[{{"agent":"alice","choice":"accept","confidence":{confidence}}}]"#);
14041 let error = super::forecasts_from_json(&raw).unwrap_err().to_string();
14042 assert!(error.contains("probability in (0, 1]"), "{error}");
14043 }
14044 }
14045
14046 #[test]
14047 fn ahead_of_a_cached_registry_answer_is_said() {
14048 let cached = super::CrateVersion {
14049 version: "0.12.16".into(),
14050 cached: true,
14051 };
14052 let (state, ok) = super::bin_health("/bin/ljos", Some("0.13.5"), Some(&cached));
14053 assert!(ok, "{state}");
14054 assert!(
14055 state.contains("ahead of crates.io (cached) 0.12.16"),
14056 "{state}"
14057 );
14058 let (same, _) = super::bin_health("/bin/ljos", Some("0.12.16"), Some(&cached));
14059 assert!(same.ends_with("crates.io (cached) 0.12.16"), "{same}");
14060 }
14061
14062 #[test]
14063 fn the_mcp_binary_tracks_the_ljos_crate() {
14064 let crate_name = super::SEAT_BINS
14065 .iter()
14066 .find(|(bin, _)| *bin == "ljos-mcp")
14067 .map(|(_, name)| *name);
14068 assert_eq!(crate_name, Some("ljos"));
14069 }
14070
14071 #[test]
14072 fn a_behind_required_bin_still_answers() {
14073 let latest = super::CrateVersion {
14074 version: "0.9.5".into(),
14075 cached: false,
14076 };
14077 let (state, ok) = super::bin_health("/bin/packsetd", Some("0.9.2"), Some(&latest));
14078 assert!(ok, "{state}");
14079 assert!(state.contains("behind crates.io 0.9.5"), "{state}");
14080 let rows = vec![Habitat {
14081 name: "packsetd",
14082 state,
14083 ok,
14084 }];
14085 assert!(
14086 healthy(&rows),
14087 "sitting must not refuse a stale but answering bin"
14088 );
14089 }
14090
14091 #[test]
14092 fn ballot_health_requires_both_evidence_and_confidence_arguments() {
14093 use std::os::unix::fs::PermissionsExt;
14094 let dir = tempfile::tempdir().unwrap();
14095 let path = dir.path().join("vissue");
14096 for (help, missing) in [
14097 ("--for OPTION --json", Some("--used, --confidence")),
14098 ("--for OPTION --used DEEDS", Some("--confidence")),
14099 ("--for OPTION --confidence P", Some("--used")),
14100 ("--for OPTION --used DEEDS --confidence P", None),
14101 ] {
14102 std::fs::write(
14103 &path,
14104 format!(
14105 "#!/bin/sh\n[ \"$*\" = 'vote --help' ] || exit 3\nprintf '%s\\n' '{help}'\n"
14106 ),
14107 )
14108 .unwrap();
14109 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
14110 let result = super::check_vissue_ballot_protocol(&path);
14111 if let Some(missing) = missing {
14112 let error = result.unwrap_err().to_string();
14113 assert!(error.contains(&format!("missing {missing};")), "{error}");
14114 let rows = vec![Habitat {
14115 name: "vissue",
14116 state: error,
14117 ok: false,
14118 }];
14119 assert!(!healthy(&rows));
14120 } else {
14121 result.unwrap();
14122 }
14123 }
14124 }
14125
14126 #[test]
14127 fn ballot_health_refuses_a_failed_help_command() {
14128 use std::os::unix::fs::PermissionsExt;
14129 let dir = tempfile::tempdir().unwrap();
14130 let path = dir.path().join("vissue");
14131 std::fs::write(
14132 &path,
14133 "#!/bin/sh\necho '--used DEEDS --confidence P'\nexit 2\n",
14134 )
14135 .unwrap();
14136 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
14137 let error = super::check_vissue_ballot_protocol(&path)
14138 .unwrap_err()
14139 .to_string();
14140 assert!(error.contains("vote --help failed"), "{error}");
14141 }
14142
14143 #[test]
14144 fn the_doctor_names_every_habitat_and_the_pack_gates_health() {
14145 let rows = doctor();
14146 let names: Vec<&str> = rows.iter().map(|h| h.name).collect();
14147 for want in [
14148 "ljos",
14149 "packset-embed",
14150 "vissue",
14151 "deedar",
14152 "packset",
14153 "pack",
14154 "encoder",
14155 "host key",
14156 "deed store",
14157 "tracker",
14158 ] {
14159 assert!(names.contains(&want), "{names:?}");
14160 }
14161 let table = format_doctor(&rows);
14162 assert_eq!(table.lines().count(), rows.len());
14163 let sick = vec![Habitat {
14164 name: "pack",
14165 state: "PACKSET_URL unset".into(),
14166 ok: false,
14167 }];
14168 assert!(!healthy(&sick));
14169 let fine = vec![Habitat {
14170 name: "landfold",
14171 state: "not on PATH".into(),
14172 ok: false,
14173 }];
14174 assert!(healthy(&fine));
14175 assert_eq!(
14176 super::format_write_ack(&serde_json::json!({
14177 "id": "ab",
14178 "kind": "lesson",
14179 "due_at": "2026-09-15T00:00:00Z",
14180 "text": "The encoder sits beside packsetd."
14181 })),
14182 "ab\tlesson\tdue 2026-09-15T00:00:00Z\tThe encoder sits beside packsetd."
14183 );
14184 assert_eq!(super::parse_semver("ljos 0.12.8"), Some("0.12.8"));
14185 assert_eq!(
14186 super::cmp_semver("0.4.1", "0.5.3"),
14187 Some(std::cmp::Ordering::Less)
14188 );
14189 }
14190
14191 #[test]
14192 fn enclosed_atoms_are_read_from_every_jsonl_in_the_bag() {
14193 let dir = std::env::temp_dir().join(format!("ljos-bag-{}", std::process::id()));
14194 let _ = std::fs::remove_dir_all(&dir);
14195 let atoms = dir.join("data").join("atoms");
14196 std::fs::create_dir_all(&atoms).unwrap();
14197 std::fs::write(
14198 atoms.join("a.jsonl"),
14199 "{\"kind\":\"lesson\",\"text\":\"one\"}\n\n{\"kind\":\"trust\",\"from\":\"a\",\"to\":\"b\",\"weight\":0.5}\n",
14200 )
14201 .unwrap();
14202 std::fs::write(
14203 atoms.join("b.jsonl"),
14204 "{\"kind\":\"preference\",\"text\":\"two\"}\n",
14205 )
14206 .unwrap();
14207 let read = enclosed_atoms(&dir).unwrap();
14208 assert_eq!(read.len(), 3);
14209 assert_eq!(trust_rows(&read).len(), 1);
14210 assert!(enclosed_atoms(&dir.join("nowhere")).unwrap().is_empty());
14211 std::fs::write(atoms.join("c.jsonl"), "not json\n").unwrap();
14212 assert!(enclosed_atoms(&dir).is_err());
14213 let _ = std::fs::remove_dir_all(&dir);
14214
14215 let table = format_due(&[serde_json::json!({
14216 "id": "x", "kind": "lesson", "text": "t", "due_at": "2026-01-01T00:00:00.000Z"
14217 })]);
14218 assert_eq!(table, "2026-01-01T00:00:00.000Z\tlesson\tx\tt\n");
14219 }
14220
14221 fn read_http(s: &mut impl Read) -> String {
14222 let mut buf = Vec::new();
14223 let mut tmp = [0u8; 1024];
14224 loop {
14225 let n = s.read(&mut tmp).unwrap_or(0);
14226 if n == 0 {
14227 break;
14228 }
14229 buf.extend_from_slice(&tmp[..n]);
14230 if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
14231 let headers = &buf[..at];
14232 let mut need = 0usize;
14233 for line in headers.split(|b| *b == b'\n') {
14234 let line = std::str::from_utf8(line).unwrap_or("").trim();
14235 if let Some(v) = line
14236 .split_once(':')
14237 .filter(|(k, _)| k.eq_ignore_ascii_case("content-length"))
14238 .map(|(_, v)| v.trim())
14239 {
14240 need = v.parse().unwrap_or(0);
14241 }
14242 }
14243 let have = buf.len().saturating_sub(at + 4);
14244 if have >= need {
14245 break;
14246 }
14247 }
14248 }
14249 String::from_utf8_lossy(&buf).into_owned()
14250 }
14251
14252 fn serve_capture() -> (String, Arc<Mutex<String>>) {
14253 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14254 let addr = listener.local_addr().unwrap();
14255 let captured = Arc::new(Mutex::new(String::new()));
14256 let slot = captured.clone();
14257 std::thread::spawn(move || {
14258 if let Ok((mut s, _)) = listener.accept() {
14259 *slot.lock().unwrap() = read_http(&mut s);
14260 let body =
14261 r#"{"id":"atom-1","kind":"lesson","text":"the default fuse is CombMNZ"}"#;
14262 let resp = format!(
14263 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
14264 body.len()
14265 );
14266 let _ = s.write_all(resp.as_bytes());
14267 }
14268 });
14269 (format!("http://{addr}"), captured)
14270 }
14271
14272 #[test]
14273 fn remember_posts_v1_atoms() {
14274 let (url, captured) = serve_capture();
14275 let client = PacksetClient::new(&url);
14276 let body = post_claim(&client, "Remember", "the default fuse is CombMNZ", "ws").unwrap();
14277 assert_eq!(body["id"], "atom-1");
14278 let req = captured.lock().unwrap().clone();
14279 assert!(req.contains("POST"), "{req}");
14280 assert!(req.contains("/v1/atoms"), "{req}");
14281 assert!(req.contains("\"kind\":\"lesson\""), "{req}");
14282 assert!(req.contains("the default fuse is CombMNZ"), "{req}");
14283 assert!(req.contains("\"level\":\"explicit\""), "{req}");
14284 assert!(req.contains("horizon:transient"), "{req}");
14285 assert!(!req.contains("extract"), "{req}");
14286 }
14287
14288 #[test]
14289 fn forget_posts_the_id_and_workspace() {
14290 let (url, captured) = serve_capture();
14291 let client = PacksetClient::new(&url);
14292 let body = client.delete_atom("ws", "atom-1", None).unwrap();
14293 assert_eq!(body["id"], "atom-1");
14294 let req = captured.lock().unwrap().clone();
14295 assert!(req.contains("POST"), "{req}");
14296 assert!(req.contains("/v1/atoms/delete"), "{req}");
14297 assert!(req.contains("\"id\":\"atom-1\""), "{req}");
14298 assert!(req.contains("\"workspace\":\"ws\""), "{req}");
14299 assert!(!req.contains("\"why\""), "{req}");
14302 }
14303
14304 #[test]
14307 fn forget_carries_the_deed_that_withdrew_the_claim() {
14308 let (url, captured) = serve_capture();
14309 let client = PacksetClient::new(&url);
14310 client
14311 .delete_atom("ws", "atom-1", Some("deed-patch-overlay"))
14312 .unwrap();
14313 let req = captured.lock().unwrap().clone();
14314 assert!(req.contains("\"why\":\"deed-patch-overlay\""), "{req}");
14315 }
14316
14317 #[test]
14320 fn forget_refuses_an_empty_id() {
14321 let err = packset_forget(" ", None).unwrap_err();
14322 assert!(err.to_string().contains("atom id is required"), "{err}");
14323 }
14324
14325 fn fake_vissue(dir: &std::path::Path, show_ok: bool, claim_ok: bool) -> std::path::PathBuf {
14328 let log = dir.join("calls.log");
14329 let script = format!(
14330 "#!/bin/sh\necho \"$* VISSUE_AGENT=${{VISSUE_AGENT:-}}\" >> '{}'\ncase \"$1\" in\n show) {} ;;\n claim) {} ;;\nesac\nexit 0\n",
14331 log.display(),
14332 if show_ok { "echo '{}'" } else { "exit 1" },
14333 if claim_ok { "echo claimed" } else { "echo refused >&2; exit 1" },
14334 );
14335 let path = dir.join("vissue");
14336 std::fs::write(&path, script).unwrap();
14337 #[cfg(unix)]
14338 {
14339 use std::os::unix::fs::PermissionsExt;
14340 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
14341 }
14342 log
14343 }
14344
14345 fn with_fake_on_path<T>(dir: &std::path::Path, f: impl FnOnce() -> T) -> T {
14347 let old = std::env::var_os("PATH").unwrap_or_default();
14348 let mut new = std::ffi::OsString::from(dir.as_os_str());
14349 new.push(":");
14350 new.push(&old);
14351 unsafe {
14352 std::env::set_var("PATH", &new);
14353 }
14354 let out = f();
14355 unsafe {
14356 std::env::set_var("PATH", old);
14357 }
14358 out
14359 }
14360
14361 #[test]
14362 fn a_claim_stamps_the_tracker_under_the_assignee() {
14363 let _g = env_guard();
14364 let dir = tempfile::tempdir().unwrap();
14365 let log = fake_vissue(dir.path(), true, true);
14366 let said = with_fake_on_path(dir.path(), || stamp_tracker("proj-1a2b", "alice")).unwrap();
14367 assert_eq!(
14368 said.as_deref(),
14369 Some("tracker: proj-1a2b STARTED under alice")
14370 );
14371 let calls = std::fs::read_to_string(log).unwrap();
14372 assert!(
14373 calls.contains("claim proj-1a2b VISSUE_AGENT=alice"),
14374 "{calls}"
14375 );
14376 }
14377
14378 #[test]
14379 fn a_node_the_tracker_does_not_know_stamps_nothing() {
14380 let _g = env_guard();
14381 let dir = tempfile::tempdir().unwrap();
14382 let log = fake_vissue(dir.path(), false, true);
14383 let said = with_fake_on_path(dir.path(), || stamp_tracker("deadbeef", "alice")).unwrap();
14384 assert_eq!(said, None);
14385 let calls = std::fs::read_to_string(log).unwrap();
14386 assert!(
14387 !calls.contains("claim"),
14388 "asked to claim a non-issue: {calls}"
14389 );
14390 }
14391
14392 #[test]
14393 fn a_closed_tracker_heading_is_reopened_when_the_graph_takes_it() {
14394 let _g = env_guard();
14395 let dir = tempfile::tempdir().unwrap();
14396 let log = dir.path().join("calls.log");
14397 let script = format!(
14398 "#!/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",
14399 log = log.display()
14400 );
14401 let path = dir.path().join("vissue");
14402 std::fs::write(&path, script).unwrap();
14403 #[cfg(unix)]
14404 {
14405 use std::os::unix::fs::PermissionsExt;
14406 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
14407 }
14408 let said = with_fake_on_path(dir.path(), || stamp_tracker("proj-1a2b", "alice")).unwrap();
14409 assert_eq!(
14410 said.as_deref(),
14411 Some("tracker: proj-1a2b STARTED under alice")
14412 );
14413 let calls = std::fs::read_to_string(&log).unwrap();
14414 assert!(
14415 calls.contains("update proj-1a2b -s STARTED"),
14416 "reopen the heading: {calls}"
14417 );
14418 assert!(
14419 calls.contains("claim proj-1a2b --force VISSUE_AGENT=alice"),
14420 "{calls}"
14421 );
14422 }
14423
14424 #[test]
14425 fn a_tracker_refusal_names_the_way_out() {
14426 let _g = env_guard();
14427 let dir = tempfile::tempdir().unwrap();
14428 let _log = fake_vissue(dir.path(), true, false);
14429 let err =
14430 with_fake_on_path(dir.path(), || stamp_tracker("proj-1a2b", "alice")).unwrap_err();
14431 let text = format!("{err:#}");
14432 assert!(text.contains("ljos release proj-1a2b"), "{text}");
14433 assert!(text.contains("refused"), "{text}");
14434 }
14435}