use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use serde_json::Value;
const COMMAND_DEADLINE: Duration = Duration::from_secs(2);
static LAST_ACTIVITY: AtomicU64 = AtomicU64::new(0);
const BOOK_EVENTS: &[&str] = &[
"SessionStart",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"Stop",
"SessionEnd",
];
const EDIT_TOOLS: &[&str] = &[
"Edit",
"Write",
"MultiEdit",
"apply_patch",
"StrReplace",
"ApplyPatch",
"write_to_file",
"replace_file_content",
"edit_file",
"NotebookEdit",
];
const MCP_WRITES: &[&str] = &[
"ljos_remember",
"ljos_prefer",
"ljos_forget",
"ljos_note",
"ljos_deed",
"ljos_rule",
"ljos_vote",
"ljos_send",
"ljos_reply",
"ljos_learn",
"ljos_trust",
"ljos_persona",
"ljos_playbook",
"ljos_graded",
"ljos_consolidate",
"ljos_calibrate",
"ljos_habit",
];
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
struct Book {
#[serde(default)]
prompt: String,
#[serde(default)]
repos: BTreeMap<String, RepoBook>,
#[serde(default)]
last_unix: u64,
#[serde(default)]
prompt_repo: String,
#[serde(default)]
prompt_unix: u64,
#[serde(default)]
owner_pid: u32,
#[serde(default)]
cwd_repo: BTreeMap<String, String>,
#[serde(default)]
last_repo: String,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
struct RepoBook {
#[serde(default)]
issue: Option<String>,
#[serde(default)]
started: bool,
#[serde(default)]
filed: bool,
#[serde(default)]
deeds: Vec<String>,
#[serde(default)]
landed: Vec<String>,
#[serde(default)]
landed_head: Option<String>,
#[serde(default)]
failures: Vec<String>,
#[serde(default)]
asks: Vec<String>,
#[serde(default)]
lesson: bool,
#[serde(default)]
finished: bool,
#[serde(default)]
claim_failed: bool,
#[serde(default)]
recall: String,
#[serde(default)]
start_sha: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
last_unix: u64,
#[serde(default)]
named: bool,
#[serde(default)]
also: Vec<String>,
#[serde(default)]
elsewhere: Vec<String>,
}
#[must_use]
pub fn guard_suspended() -> bool {
ljos_off() || home_or_cwd_is_temp()
}
fn ljos_off() -> bool {
matches!(
std::env::var("LJOS_OFF").ok().as_deref(),
Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
)
}
fn home_or_cwd_is_temp() -> bool {
let home = std::env::var_os("HOME").map(PathBuf::from);
let cwd = std::env::current_dir().ok();
path_is_temp(home.as_deref()) || path_is_temp(cwd.as_deref())
}
#[must_use]
pub fn path_is_temp(path: Option<&Path>) -> bool {
let Some(path) = path else {
return false;
};
if path.as_os_str().is_empty() {
return false;
}
let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
temp_roots().iter().any(|root| path.starts_with(root))
}
fn temp_roots() -> Vec<PathBuf> {
let mut roots = vec![
std::env::temp_dir(),
PathBuf::from("/tmp"),
PathBuf::from("/var/tmp"),
PathBuf::from("/private/tmp"),
PathBuf::from("/dev/shm"),
];
if let Some(tmp) = std::env::var_os("TMPDIR").filter(|t| !t.is_empty()) {
roots.push(PathBuf::from(tmp));
}
roots
.into_iter()
.map(|root| std::fs::canonicalize(&root).unwrap_or(root))
.collect()
}
#[must_use]
pub fn observe(raw: &str, call: &super::HookCall) -> (String, Vec<String>) {
let raw = raw.to_string();
let call = call.clone();
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| observe_inner(&raw, &call))) {
Ok(text) => (text, Vec::new()),
Err(_) => {
log_line("bookkeep panicked");
(String::new(), Vec::new())
}
}
}
fn observe_inner(raw: &str, call: &super::HookCall) -> String {
if !BOOK_EVENTS.contains(&call.event.as_str()) {
return String::new();
}
let session = session_key(call);
let value: Value = serde_json::from_str(raw.trim()).unwrap_or(Value::Null);
let _held = super::hook_timed("book_lock", || {
super::CommitLock::acquire(&super::runtime_dir().join("book.lock"))
});
if matches!(call.event.as_str(), "SessionStart" | "UserPromptSubmit") {
let deadline = std::time::Instant::now() + SWEEP_BUDGET;
super::hook_timed("finish_orphans", || finish_orphans(&session, deadline));
super::hook_timed("refresh_landed_deeds", || refresh_landed_deeds(deadline));
}
let path = book_path(&session);
let repo_hint = {
let mut scout = load_book(&path);
repo_key(&mut scout, &value)
};
let hint = (!repo_hint.is_empty()).then_some(repo_hint.as_str());
close_idle_sittings(unix_secs(), Some(session.as_str()), hint);
let mut book = load_book(&path);
book.owner_pid = next_owner_pid(&call.event, book.owner_pid, hook_owner());
let mut prompt_set = false;
if call.event == "UserPromptSubmit" {
let prompt = collapse(&call.cue);
if !prompt.is_empty() {
book.prompt = prompt.chars().take(4000).collect();
prompt_set = true;
}
}
if book.prompt.trim().is_empty() {
let prompt = collapse(&super::prompt_of(&value));
if !prompt.is_empty() {
book.prompt = prompt.chars().take(4000).collect();
prompt_set = true;
}
}
let repo = repo_key(&mut book, &value);
let ending_elsewhere =
matches!(call.event.as_str(), "Stop" | "SessionEnd") && !book.repos.contains_key(&repo);
let now = unix_secs();
if prompt_set {
bind_prompt(&mut book, &repo, now);
}
let git_hook = value
.get("ljos_git_hook")
.and_then(Value::as_bool)
.unwrap_or(false);
let offered = super::prompt_of(&value);
let subject_title = git_hook && !prompt_live_for(&book, &repo, now);
if subject_title {
let open = book
.repos
.get(&repo)
.is_some_and(|row| row.issue.is_some() && !row.finished);
if !open {
let subject = collapse(&offered);
if !subject.is_empty() && !repo.is_empty() {
book.repos.entry(repo.clone()).or_default().title =
Some(subject.chars().take(4000).collect());
}
}
} else if !ending_elsewhere {
remember_title(&mut book, &repo);
}
let commit = is_git_commit(&call.cue);
let exit = if call.event == "PostToolUse" {
command_exit(&value)
} else {
CommandExit::Absent
};
let start = book.repos.get(&repo).and_then(|row| row.start_sha.clone());
let landed = call.event == "PostToolUse"
&& commit
&& commit_landed(exit, &value, &repo, start.as_deref());
if !landed && !ending_elsewhere {
note_start(&mut book, &repo);
}
if call.event == "PostToolUseFailure"
|| (call.event == "PostToolUse" && command_failed(exit) && !commit)
{
note_failure(&mut book, &repo, &failure_text(&value, &call.cue));
}
if matches!(call.event.as_str(), "PreToolUse" | "PostToolUse") {
let names_open = commit && !git_hook && named_open_issue(&call.cue).is_some();
if is_edit(&value, &call.cue)
|| (commit && !names_open)
|| writes_worktree(&repo, &call.cue)
{
let offered = subject_title.then_some(offered.as_str());
let _ = ensure_claim(&mut book, &path, &repo, offered);
}
if landed {
let named = if git_hook { None } else { named_head(&repo) };
if let Some((issue, head)) = named {
if book.repos.get(&repo).is_none_or(|row| row.issue.is_none()) {
let _ = take_started_row(&mut book, &path, &repo);
}
book_named_in(&mut book, &repo, &repo, &head, &issue);
} else if names_open {
let offered = subject_title.then_some(offered.as_str());
let _ = ensure_claim(&mut book, &path, &repo, offered);
}
let _ = ensure_deed(&mut book, &repo);
} else if call.event == "PostToolUse" && commit && command_failed(exit) {
note_failure(&mut book, &repo, "git commit exited non-zero");
}
}
if matches!(call.event.as_str(), "Stop" | "SessionEnd") {
finish_open(&mut book);
drop_unclaimed_rows(&mut book);
}
let held = if matches!(call.event.as_str(), "SessionStart" | "UserPromptSubmit") {
book.repos.get(&repo).map(|row| {
(
row.issue.clone().filter(|_| !row.finished),
row.recall.clone(),
)
})
} else {
None
};
if landed && !repo.is_empty() {
book.last_repo.clone_from(&repo);
}
stamp_touch(&mut book, &repo, unix_secs());
store_book(&path, &book);
drop(_held);
match held {
Some((Some(issue), cached)) => claimed_recall(&issue, &cached),
Some((None, cached)) => cached,
None => String::new(),
}
}
fn claimed_recall(issue: &str, cached: &str) -> String {
if super::pack().is_err() {
return cached.to_string();
}
format!("{}{}", recall_line(issue), super::recall_memories(issue))
}
#[must_use]
pub fn mcp_tool_writes(name: &str) -> bool {
MCP_WRITES.contains(&name)
}
pub fn before_mcp_write(name: &str, args: Option<&serde_json::Map<String, Value>>) {
if !mcp_tool_writes(name) {
return;
}
let session = sitting_key();
if let Some(title) = args.and_then(arg_title) {
let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
let path = book_path(&session);
let mut book = load_book(&path);
if book.prompt.trim().is_empty() {
book.prompt = collapse(&title).chars().take(4000).collect();
}
store_book(&path, &book);
}
let explicit = args.and_then(|map| map.get("repo")).and_then(Value::as_str);
let Some(place) = place_for_claim(explicit) else {
log_line("claim: refused a directory that is not a git checkout");
return;
};
reopen_finished(&session);
let cwd = place;
let raw = serde_json::json!({
"cwd": cwd,
"session_id": session,
"hook_event_name": "PreToolUse",
"tool_name": "Write",
})
.to_string();
let call = super::HookCall {
event: "PreToolUse".into(),
cue: "Write note".into(),
session: Some(session.clone()),
shape: super::HookShape::Asks,
};
let _ = observe(&raw, &call);
stamp_sitting(&session, unix_secs());
}
pub fn mcp_shutdown() {
let session = sitting_key();
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string())
.unwrap_or_default();
let raw = serde_json::json!({
"cwd": cwd,
"session_id": session,
"hook_event_name": "SessionEnd",
})
.to_string();
let call = super::HookCall {
event: "SessionEnd".into(),
cue: String::new(),
session: Some(session),
shape: super::HookShape::Asks,
};
let _ = observe(&raw, &call);
match super::sync::sync_repo_in_passing(false, true) {
Ok(line) if !line.trim().is_empty() => log_line(line.trim()),
Err(e) => log_line(&format!("sync: {e:#}")),
Ok(_) => {}
}
}
pub fn touch_activity() {
LAST_ACTIVITY.store(unix_secs(), Ordering::Relaxed);
}
static MCP_CALLS: AtomicU64 = AtomicU64::new(0);
pub fn note_mcp_process_call() {
MCP_CALLS.fetch_add(1, Ordering::Relaxed);
}
pub fn resume_seat() {
resume_seat_at(unix_secs());
}
fn resume_seat_at(now: u64) {
let key = sitting_key();
if let Some(limit) = idle_limit() {
let book = load_book(&book_path(&key));
let open = book
.repos
.values()
.any(|row| row.issue.is_some() && !row.finished);
if open && idle_due(now, book.last_unix, limit) {
mcp_shutdown();
}
}
stamp_sitting(&key, now);
}
fn stamp_sitting(key: &str, now: u64) {
let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
let path = book_path(key);
let mut book = load_book(&path);
book.last_unix = now;
store_book(&path, &book);
}
#[must_use]
pub fn idle_limit() -> Option<Duration> {
idle_limit_from(std::env::var("LJOS_MCP_IDLE").ok().as_deref())
}
#[must_use]
pub fn idle_limit_from(raw: Option<&str>) -> Option<Duration> {
let Some(raw) = raw.map(str::trim).filter(|text| !text.is_empty()) else {
return Some(Duration::from_secs(30 * 60));
};
let lowered = raw.to_ascii_lowercase();
if matches!(lowered.as_str(), "0" | "off" | "false" | "no") {
return None;
}
match super::reap::parse_idle(raw) {
Ok(window) if window.is_zero() => None,
Ok(window) => Some(window),
Err(_) => Some(Duration::from_secs(30 * 60)),
}
}
#[must_use]
pub fn idle_due(now: u64, last: u64, limit: Duration) -> bool {
last > 0 && now.saturating_sub(last) >= limit.as_secs()
}
fn sitting_idle() -> Option<Duration> {
sitting_idle_from(std::env::var("LJOS_SITTING_IDLE").ok().as_deref())
}
fn sitting_idle_from(raw: Option<&str>) -> Option<Duration> {
let Some(raw) = raw.map(str::trim).filter(|text| !text.is_empty()) else {
return Some(Duration::from_secs(2 * 60 * 60));
};
let lowered = raw.to_ascii_lowercase();
if matches!(lowered.as_str(), "0" | "off" | "false" | "no") {
return None;
}
match super::reap::parse_idle(raw) {
Ok(window) if window.is_zero() => None,
Ok(window) => Some(window),
Err(_) => Some(Duration::from_secs(2 * 60 * 60)),
}
}
pub(crate) fn sweep_idle_sittings(session: &str, current: &str) {
let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
let current = (!current.is_empty()).then_some(current);
close_idle_sittings(unix_secs(), Some(session), current);
}
fn close_idle_sittings(now: u64, session: Option<&str>, current: Option<&str>) {
let Some(limit) = sitting_idle() else {
return;
};
let current_path = session.map(book_path);
for path in book_files() {
let mut book = load_book(&path);
let here = current_path.as_ref().is_some_and(|want| want == &path);
let hint = if here { current } else { None };
let idle: Vec<String> = book
.repos
.iter()
.filter(|(repo, row)| row_is_idle(now, &book, repo, row, limit, hint))
.map(|(repo, _)| repo.clone())
.collect();
let unclaimed = book.repos.len();
let touched = book.last_unix;
book.repos.retain(|_, row| {
!unclaimed_row(row) || !idle_due(now, row.last_unix.max(touched), limit)
});
let dropped = book.repos.len() != unclaimed;
if idle.is_empty() {
if dropped {
book.cwd_repo
.retain(|_, repo| book.repos.contains_key(repo));
store_book(&path, &book);
}
continue;
}
finish_rows(&mut book, &idle);
book.repos.retain(|_, row| !row.finished);
book.cwd_repo
.retain(|_, repo| book.repos.contains_key(repo));
release_spent_prompt(&mut book);
store_book(&path, &book);
}
}
fn unix_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or(0)
}
pub fn spawn_idle_finish() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let Some(limit) = idle_limit() else {
return;
};
touch_activity();
let _ = std::thread::Builder::new()
.name("ljos-mcp-idle".into())
.spawn(move || {
let tick = Duration::from_secs(30.min(limit.as_secs().max(1)));
loop {
std::thread::sleep(tick);
let last = LAST_ACTIVITY.load(Ordering::Relaxed);
if idle_due(unix_secs(), last, limit) {
mcp_shutdown();
touch_activity();
}
}
});
});
}
#[must_use]
pub fn blank_issue(id: &str) -> bool {
matches!(
id.trim().to_ascii_lowercase().as_str(),
"" | "none"
| "null"
| "nil"
| "n/a"
| "na"
| "undefined"
| "unknown"
| "-"
| "n.a."
| "n.a"
)
}
#[must_use]
pub fn canonical_issue(raw: Option<&str>, session: Option<&str>) -> Option<String> {
if let Some(id) = raw.map(str::trim).filter(|id| !blank_issue(id)) {
return Some(id.to_string());
}
claimed_issue(session)
}
#[must_use]
pub fn session_issue(session: Option<&str>) -> Option<String> {
issue_in_sessions(session)
}
#[must_use]
pub fn claimed_issue(session: Option<&str>) -> Option<String> {
issue_in_sessions(session)
.or_else(open_issue)
.or_else(latest_cwd_issue)
}
fn issue_in_sessions(session: Option<&str>) -> Option<String> {
let mut seen = std::collections::BTreeSet::new();
let mut keys = Vec::new();
let mut push = |raw: String| {
let key = raw.trim().to_string();
if key.is_empty() || !seen.insert(key.clone()) {
return;
}
keys.push(key);
};
if let Some(session) = session {
push(session.to_string());
}
if let Some(session) = crate::ballot_sig::call_session() {
push(session);
}
if let Ok(session) = std::env::var("LJOS_HARNESS_SESSION") {
push(session);
}
if let Ok(session) = std::env::var("LJOS_SESSION_ID") {
push(session);
}
if let Some((id, _)) = super::session_actor() {
push(id);
}
for key in &keys {
if let Some(issue) = open_in_book(&load_book(&book_path(key))) {
return Some(issue);
}
}
None
}
fn open_in_book(book: &Book) -> Option<String> {
let cwd = std::env::current_dir()
.ok()
.map(|path| path.display().to_string())
.unwrap_or_default();
if let Some(key) = book.cwd_repo.get(&cwd) {
if let Some(issue) = unfinished(book, key) {
return Some(issue);
}
}
let mut found = None;
for key in book.repos.keys() {
if let Some(issue) = unfinished(book, key) {
if found.is_some() {
return None;
}
found = Some(issue);
}
}
found
}
fn latest_cwd_issue() -> Option<String> {
let cwd = std::env::current_dir().ok()?;
let dir = super::runtime_dir();
let mut hits = Vec::new();
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.starts_with("book-") || !name.ends_with(".json") {
continue;
}
let book = load_book(&entry.path());
let Some(issue) = issue_for_cwd(&book, &cwd) else {
continue;
};
let when = entry
.metadata()
.and_then(|meta| meta.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
hits.push((when, issue));
}
hits.sort_by_key(|hit| hit.0);
hits.pop().map(|hit| hit.1)
}
fn issue_for_cwd(book: &Book, cwd: &Path) -> Option<String> {
let cwd_s = cwd.display().to_string();
if let Some(key) = book.cwd_repo.get(&cwd_s) {
if let Some(issue) = unfinished(book, key) {
return Some(issue);
}
}
for (dir, key) in &book.cwd_repo {
if paths_match(dir, cwd) {
if let Some(issue) = unfinished(book, key) {
return Some(issue);
}
}
}
for key in book.repos.keys() {
if paths_match(key, cwd) {
if let Some(issue) = unfinished(book, key) {
return Some(issue);
}
}
}
None
}
fn paths_match(raw: &str, cwd: &Path) -> bool {
let path = Path::new(raw);
if path == cwd {
return true;
}
match (
std::fs::canonicalize(path).ok(),
std::fs::canonicalize(cwd).ok(),
) {
(Some(left), Some(right)) => left == right,
_ => false,
}
}
#[must_use]
pub fn open_issue() -> Option<String> {
let book = load_book(&book_path(&sitting_key()));
let cwd = std::env::current_dir()
.ok()
.map(|path| path.display().to_string())
.unwrap_or_default();
if let Some(key) = book.cwd_repo.get(&cwd) {
if let Some(issue) = unfinished(&book, key) {
return Some(issue);
}
}
book.repos
.iter()
.find_map(|(key, _)| unfinished(&book, key))
}
fn unfinished(book: &Book, key: &str) -> Option<String> {
let row = book.repos.get(key)?;
if row.finished {
return None;
}
row.issue.clone()
}
pub fn note_finished(issue: &str) {
let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
let path = book_path(&sitting_key());
let mut book = load_book(&path);
let mut changed = false;
for row in book.repos.values_mut() {
if row.issue.as_deref() == Some(issue) && !row.finished {
row.finished = true;
changed = true;
}
}
if changed {
store_book(&path, &book);
}
}
pub fn start_work(issue: Option<&str>, repo: Option<&str>, task: &str) -> Result<String, String> {
let task = collapse(task);
if task.is_empty() {
return Err("ljos_start: pass the task in one sentence".into());
}
let cwd = std::env::current_dir()
.ok()
.map(|path| path.display().to_string())
.unwrap_or_default();
let named = repo.map(str::trim).filter(|path| !path.is_empty());
let Some(place) = place_for_claim(named) else {
return Err(if named.is_some() {
"ljos_start: repo is not a git checkout".into()
} else {
"ljos_start: pass repo, a git checkout".into()
});
};
let asked = issue.map(str::trim).filter(|id| !blank_issue(id));
if asked.is_none() {
if let Some(said) = start_on_held(&place, &task, &cwd) {
return Ok(said);
}
}
let issue_id = match asked {
Some(id) => {
claim_existing(id)?;
id.to_string()
}
None => file_and_claim(&project_for(&place), &task)?,
};
let persisted = super::persist_tracker(&issue_id, "claimed");
if persisted.contains("could not") || persisted.contains("refused") {
log_line(persisted.trim());
}
let recall = format!(
"{}{}",
recall_line(&issue_id),
super::recall_memories(&issue_id)
);
let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
let path = book_path(&sitting_key());
let mut book = load_book(&path);
let now = unix_secs();
book.last_unix = now;
if book.prompt.trim().is_empty() {
book.prompt = task.chars().take(4000).collect();
bind_prompt(&mut book, &place, now);
}
let row = book.repos.entry(place.clone()).or_default();
row.last_unix = now;
row.issue = Some(issue_id.clone());
row.finished = false;
row.started = true;
row.claim_failed = false;
row.recall.clone_from(&recall);
if !cwd.is_empty() {
book.cwd_repo.insert(cwd, place);
}
store_book(&path, &book);
drop(_held);
Ok(format!("claimed {issue_id}\n{recall}"))
}
fn claim_existing(issue: &str) -> Result<(), String> {
let assignee = super::resolve_assignee(None);
if which::which("claimdag").is_ok() && super::claim(issue, &assignee).is_ok() {
return Ok(());
}
run_timed(
"vissue",
&["claim", issue],
&[("VISSUE_AGENT", assignee.as_str())],
)
.map(|_| ())
.map_err(|e| e.to_string())
}
fn reopen_finished(session: &str) {
let cwd = std::env::current_dir()
.ok()
.map(|path| path.display().to_string())
.unwrap_or_default();
let raw = serde_json::json!({ "cwd": cwd });
let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
let path = book_path(session);
let mut book = load_book(&path);
let repo = repo_key(&mut book, &raw);
if book.repos.get(&repo).is_some_and(|row| row.finished) {
book.repos.insert(repo, RepoBook::default());
}
store_book(&path, &book);
}
pub struct ShutdownFinish;
impl Drop for ShutdownFinish {
fn drop(&mut self) {
if MCP_CALLS.load(Ordering::Relaxed) > 1 {
mcp_shutdown();
}
}
}
fn sitting_key() -> String {
let named = super::hook_actor().or_else(|| {
std::env::var("LJOS_SEAT")
.ok()
.map(|raw| raw.trim().to_string())
.filter(|raw| !raw.is_empty())
});
if let Some(name) = named {
let slug = super::seat_slug(&name);
if slug != "runner" && !super::omitted_actor_name(&slug) {
return format!("seat:{slug}");
}
}
mcp_session()
}
fn mcp_session() -> String {
std::env::var("LJOS_SESSION_ID")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| format!("mcp-{}", std::process::id()))
}
fn arg_title(args: &serde_json::Map<String, Value>) -> Option<String> {
for key in ["title", "text", "query", "body", "note"] {
if let Some(text) = args.get(key).and_then(Value::as_str) {
let text = text.trim();
if !text.is_empty() {
return Some(text.to_string());
}
}
}
None
}
fn session_key(call: &super::HookCall) -> String {
call.session
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| {
std::env::var("LJOS_SESSION_ID")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
.unwrap_or_else(|| std::process::id().to_string())
}
fn book_path(session: &str) -> PathBuf {
super::runtime_dir().join(format!("book-{}.json", super::session_tag(session)))
}
fn load_book(path: &Path) -> Book {
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
fn store_book(path: &Path, book: &Book) {
let Ok(text) = serde_json::to_string(book) else {
return;
};
if std::fs::read_to_string(path).is_ok_and(|prev| prev == text) {
return;
}
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Err(e) = std::fs::write(path, text) {
log_line(&format!("book: {e}"));
}
}
pub(crate) fn log_line(line: &str) {
let dir = super::runtime_dir();
let _ = std::fs::create_dir_all(&dir);
let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(dir.join("book.log"))
else {
return;
};
use std::io::Write;
let _ = writeln!(file, "{line}");
}
fn collapse(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn repo_key(book: &mut Book, raw: &Value) -> String {
let cwd = raw["cwd"]
.as_str()
.or_else(|| raw["workspacePaths"][0].as_str())
.map(str::to_string)
.filter(|s| !s.is_empty())
.or_else(|| {
std::env::current_dir()
.ok()
.map(|p| p.display().to_string())
})
.unwrap_or_default();
if let Some(hit) = book.cwd_repo.get(&cwd) {
return hit.clone();
}
let key = git_toplevel(Path::new(&cwd)).unwrap_or_else(|| {
let path = PathBuf::from(&cwd);
std::fs::canonicalize(&path)
.unwrap_or(path)
.display()
.to_string()
});
if !cwd.is_empty() {
book.cwd_repo.insert(cwd, key.clone());
}
key
}
fn place_for_claim(explicit: Option<&str>) -> Option<String> {
place_for_claim_in(
explicit,
last_booked_repo().as_deref(),
std::env::current_dir().ok().as_deref(),
)
}
fn place_for_claim_in(
explicit: Option<&str>,
last: Option<&str>,
cwd: Option<&Path>,
) -> Option<String> {
if let Some(path) = explicit.map(str::trim).filter(|path| !path.is_empty()) {
return git_toplevel(Path::new(path));
}
if let Some(path) = last.map(str::trim).filter(|path| !path.is_empty()) {
if let Some(top) = git_toplevel(Path::new(path)) {
return Some(top);
}
}
cwd.and_then(git_toplevel)
}
fn last_booked_repo() -> Option<String> {
let book = load_book(&book_path(&sitting_key()));
let repo = book.last_repo.trim();
if repo.is_empty() {
None
} else {
Some(repo.to_string())
}
}
#[must_use]
pub fn seat_session() -> String {
sitting_key()
}
fn git_toplevel(cwd: &Path) -> Option<String> {
if cwd.as_os_str().is_empty() {
return None;
}
let said = run_timed(
"git",
&[
"-C",
&cwd.display().to_string(),
"rev-parse",
"--show-toplevel",
],
&[],
)
.ok()?;
let top = said.stdout.trim();
(!top.is_empty()).then(|| top.to_string())
}
fn ensure_claim(
book: &mut Book,
own: &Path,
repo: &str,
offered: Option<&str>,
) -> Result<(), String> {
let row = book.repos.entry(repo.to_string()).or_default();
if row.claim_failed {
return Ok(());
}
if let Some(issue) = row.issue.clone() {
if row.recall.is_empty() {
row.recall = recall_line(&issue);
}
return Ok(());
}
if take_held_issue(book, own, repo) {
return Ok(());
}
let title = match offered.map(str::trim).filter(|text| !text.is_empty()) {
Some(subject) => title_from(subject, repo),
None => title_from(&book.prompt, repo),
};
let project = project_for(repo);
match file_and_claim(&project, &title) {
Ok(issue) => {
let line = super::persist_tracker(&issue, "claimed");
if line.contains("could not") || line.contains("refused") {
log_line(line.trim());
}
let row = book.repos.entry(repo.to_string()).or_default();
row.issue = Some(issue.clone());
row.filed = true;
row.recall = recall_line(&issue);
Ok(())
}
Err(e) => {
log_line(&format!("claim: {e}"));
book.repos.entry(repo.to_string()).or_default().claim_failed = true;
Ok(())
}
}
}
fn start_on_held(place: &str, task: &str, cwd: &str) -> Option<String> {
let held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
let own = book_path(&sitting_key());
let mut found: Option<((u64, u128), PathBuf, String, String)> = None;
for path in book_files() {
let other = load_book(&path);
let rank = row_rank(&path, &other);
for (key, row) in &other.repos {
let Some(issue) = row.issue.as_deref() else {
continue;
};
if row.finished || !same_checkout(key, place) {
continue;
}
if found.as_ref().is_some_and(|(when, ..)| *when > rank) {
continue;
}
if held_by_this_seat(issue) {
found = Some((rank, path.clone(), key.clone(), issue.to_string()));
}
}
}
let (_, path, key, issue) = found?;
let mut book = load_book(&own);
let moved = if path == own {
book.repos.remove(&key).unwrap_or_default()
} else {
let mut other = load_book(&path);
let moved = other.repos.remove(&key).unwrap_or_default();
store_book(&path, &other);
moved
};
let filed = moved.filed;
let now = unix_secs();
book.last_unix = now;
if book.prompt.trim().is_empty() {
book.prompt = task.chars().take(4000).collect();
bind_prompt(&mut book, place, now);
}
adopt_row(&mut book, place, &issue, moved);
let row = book.repos.entry(place.to_string()).or_default();
row.started = true;
row.last_unix = now;
if !cwd.is_empty() {
book.cwd_repo.insert(cwd.to_string(), place.to_string());
}
store_book(&own, &book);
drop(held);
log_line(&format!("start: kept {issue}, already held in {place}"));
if filed && !same_words(task, &issue_title(&issue)) {
let note = format!("ljos_start task: {task}");
match run_timed("vissue", &["note", &issue, ¬e], &[]) {
Ok(_) => {
let line = super::persist_tracker(&issue, "noted");
if line.contains("could not") || line.contains("refused") {
log_line(line.trim());
}
}
Err(e) => log_line(&format!("start: note on {issue}: {e}")),
}
}
let recall = format!("{}{}", recall_line(&issue), super::recall_memories(&issue));
Some(format!("claimed {issue}\n{recall}"))
}
fn issue_title(issue: &str) -> String {
super::issue_record(issue)
.ok()
.and_then(|v| v["title"].as_str().map(str::to_string))
.unwrap_or_default()
}
fn same_words(a: &str, b: &str) -> bool {
let words = |text: &str| -> Vec<String> {
text.split(|c: char| !c.is_alphanumeric() && c != '_')
.filter(|w| !w.is_empty())
.map(str::to_lowercase)
.collect()
};
words(a) == words(b)
}
fn take_held_issue(book: &mut Book, own: &Path, repo: &str) -> bool {
if take_started_row(book, own, repo) {
return true;
}
let project = project_for(repo);
let Some(issue) = super::held_issue().filter(|id| {
id.rsplit_once('-').is_some_and(|(p, _)| p == project) && held_by_this_seat(id)
}) else {
return false;
};
adopt_row(book, repo, &issue, RepoBook::default());
log_line(&format!("claim: kept {issue}, held by this seat"));
true
}
fn take_started_row(book: &mut Book, own: &Path, repo: &str) -> bool {
let mut found: Option<((u64, u128), PathBuf, String, String)> = None;
for path in book_files() {
if path == own {
continue;
}
let other = load_book(&path);
let rank = row_rank(&path, &other);
for (key, row) in &other.repos {
let Some(issue) = row.issue.as_deref() else {
continue;
};
if row.finished || !row.started || !same_checkout(key, repo) {
continue;
}
if found.as_ref().is_some_and(|(when, ..)| *when > rank) {
continue;
}
if held_by_this_seat(issue) {
found = Some((rank, path.clone(), key.clone(), issue.to_string()));
}
}
}
if let Some((_, path, key, issue)) = found {
let mut other = load_book(&path);
let moved = other.repos.remove(&key).unwrap_or_default();
store_book(&path, &other);
adopt_row(book, repo, &issue, moved);
log_line(&format!("claim: kept {issue}, already held in {repo}"));
return true;
}
false
}
fn held_by_this_seat(issue: &str) -> bool {
let Ok(v) = super::issue_record(issue) else {
return false;
};
if v["id"].as_str() != Some(issue) || v["state"].as_str() != Some("STARTED") {
return false;
}
match v["claimed_by"].as_str().map(str::trim) {
Some(by) if !by.is_empty() => super::same_holder(by, &super::holder_name()),
_ => true,
}
}
fn adopt_row(book: &mut Book, repo: &str, issue: &str, moved: RepoBook) {
let row = book.repos.entry(repo.to_string()).or_default();
row.issue = Some(issue.to_string());
row.finished = false;
row.claim_failed = false;
row.recall = if moved.recall.is_empty() {
recall_line(issue)
} else {
moved.recall
};
if moved.start_sha.is_some() {
row.start_sha = moved.start_sha;
}
if row.title.is_none() {
row.title = moved.title;
}
for sha in moved.deeds {
if !row.deeds.contains(&sha) {
row.deeds.push(sha);
}
}
for sha in moved.landed {
if !row.landed.contains(&sha) {
row.landed.push(sha);
}
}
row.failures.extend(moved.failures);
row.asks.extend(moved.asks);
row.named |= moved.named;
row.filed = moved.filed;
for other in moved.also {
if !row.also.contains(&other) {
row.also.push(other);
}
}
for sha in moved.elsewhere {
if !row.elsewhere.contains(&sha) {
row.elsewhere.push(sha);
}
}
}
fn file_and_claim(project: &str, title: &str) -> Result<String, String> {
let assignee = super::resolve_assignee(None);
let said = run_timed(
"vissue",
&["create", "-p", project, title, "-q"],
&[("VISSUE_AGENT", assignee.as_str())],
)
.map_err(|e| e.to_string())?;
let issue = parse_issue_id(&said.stdout)
.ok_or_else(|| format!("vissue create printed no issue id: {}", said.stdout.trim()))?;
if which::which("claimdag").is_ok() {
if let Err(e) = super::claim(&issue, &assignee) {
log_line(&format!("claim graph: {e:#}"));
run_timed(
"vissue",
&["claim", &issue],
&[("VISSUE_AGENT", assignee.as_str())],
)
.map_err(|e| e.to_string())?;
}
} else {
run_timed(
"vissue",
&["claim", &issue],
&[("VISSUE_AGENT", assignee.as_str())],
)
.map_err(|e| e.to_string())?;
}
Ok(issue)
}
fn ensure_deed(book: &mut Book, repo: &str) -> Result<(), String> {
cite_row(book, repo, repo)
}
fn cite_row(book: &mut Book, row_key: &str, git_repo: &str) -> Result<(), String> {
let head = match head_sha(git_repo) {
Ok(sha) => sha,
Err(e) => {
log_line(&format!("deed: {e}"));
return Ok(());
}
};
let start = book
.repos
.get(row_key)
.and_then(|row| row.start_sha.clone());
let Some(issue) = book.repos.get(row_key).and_then(|row| row.issue.clone()) else {
return Ok(());
};
for sha in commits_since(git_repo, start.as_deref(), &head) {
if book.repos.get(row_key).is_some_and(|row| {
row.deeds
.iter()
.chain(&row.elsewhere)
.any(|seen| seen == &sha)
}) {
continue;
}
match cite_deed(&issue, &sha) {
Ok(()) => {
book.repos
.entry(row_key.to_string())
.or_default()
.deeds
.push(sha);
}
Err(e) => {
log_line(&format!("deed: {e}"));
return Ok(());
}
}
}
Ok(())
}
fn commits_since(repo: &str, start: Option<&str>, head: &str) -> Vec<String> {
let Some(start) = start.map(str::trim).filter(|sha| !sha.is_empty()) else {
return vec![head.to_string()];
};
if start == head {
return Vec::new();
}
let range = format!("{start}..{head}");
let shas = run_timed("git", &["-C", repo, "rev-list", "--reverse", &range], &[])
.ok()
.map(|said| {
said.stdout
.lines()
.map(str::trim)
.filter(|sha| sha.len() >= 7 && sha.chars().all(|c| c.is_ascii_hexdigit()))
.map(str::to_string)
.collect::<Vec<_>>()
});
match shas {
Some(shas) if !shas.is_empty() => shas,
_ => vec![head.to_string()],
}
}
pub(crate) fn cite_into_open_sitting(repo: &str) -> bool {
let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
let Some((path, key)) = open_row_for_repo(repo) else {
return false;
};
let mut book = load_book(&path);
let open = book
.repos
.get(&key)
.is_some_and(|row| !row.finished && row.issue.is_some());
if !open {
return false;
}
let _ = cite_row(&mut book, &key, repo);
stamp_touch(&mut book, &key, unix_secs());
store_book(&path, &book);
true
}
fn open_row_for_repo(repo: &str) -> Option<(PathBuf, String)> {
let mut best: Option<((u64, u128), PathBuf, String)> = None;
for path in book_files() {
let book = load_book(&path);
let rank = row_rank(&path, &book);
for (key, row) in &book.repos {
if row.finished || row.issue.is_none() || !same_checkout(key, repo) {
continue;
}
let take = best.as_ref().is_none_or(|(when, _, _)| rank >= *when);
if take {
best = Some((rank, path.clone(), key.clone()));
}
}
}
best.map(|(_, path, key)| (path, key))
}
fn row_rank(path: &Path, book: &Book) -> (u64, u128) {
let modified = std::fs::metadata(path)
.and_then(|meta| meta.modified())
.ok()
.and_then(|when| when.duration_since(std::time::UNIX_EPOCH).ok())
.map(|elapsed| elapsed.as_nanos())
.unwrap_or(0);
(book.last_unix, modified)
}
fn same_checkout(left: &str, right: &str) -> bool {
if left == right {
return true;
}
match (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
(Ok(left), Ok(right)) => left == right,
_ => false,
}
}
fn deed_id(sha: &str) -> String {
format!("deed-file-commit-{sha}")
}
fn cite_deed(issue: &str, sha: &str) -> Result<(), String> {
let dir = super::runtime_dir();
let _ = std::fs::create_dir_all(&dir);
let path = dir.join(format!("deed-{sha}.txt"));
std::fs::write(&path, format!("{sha}\n")).map_err(|e| e.to_string())?;
let seat = super::holder_name();
let id = deed_id(sha);
let said = run_timed(
"deedar",
&[
"create",
"file",
"--id",
&id,
"--name",
sha,
"--path",
&path.display().to_string(),
"--agent",
&seat,
],
&[("VISSUE_AGENT", seat.as_str())],
)
.map_err(|e| e.to_string())?;
let accession = parse_deed(&said.stdout)
.ok_or_else(|| format!("deedar printed no accession: {}", said.stdout.trim()))?;
run_timed(
"vissue",
&["deed", issue, "--add", &accession],
&[("VISSUE_AGENT", seat.as_str())],
)
.map_err(|e| e.to_string())?;
let line = super::persist_tracker(issue, "cited a deed");
if line.contains("could not") || line.contains("refused") {
return Err(line.trim().to_string());
}
Ok(())
}
fn next_owner_pid(event: &str, current: u32, incoming: u32) -> u32 {
if current == 0 || matches!(event, "SessionStart" | "UserPromptSubmit") {
incoming
} else {
current
}
}
fn hook_owner() -> u32 {
unsafe { libc::getppid() as u32 }
}
const SWEEP_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000);
fn finish_orphans(current: &str, deadline: std::time::Instant) {
let keep = book_path(current);
for path in book_files() {
if path == keep {
continue;
}
if std::time::Instant::now() >= deadline {
break;
}
let mut book = load_book(&path);
let open = book.repos.values().any(sitting_open);
if !open || book.owner_pid == 0 || super::pid_alive(book.owner_pid) {
continue;
}
finish_open(&mut book);
store_book(&path, &book);
}
}
fn refresh_landed_deeds(deadline: std::time::Instant) {
let mut heads = Heads::new();
for path in book_files() {
if std::time::Instant::now() >= deadline {
break;
}
let mut book = load_book(&path);
let repos: Vec<String> = book.repos.keys().cloned().collect();
let mut changed = false;
let mut cached = false;
for repo in repos {
changed |= reconcile_deeds(&mut book, &repo, deadline, &mut heads, &mut cached);
}
if !changed {
if cached {
store_book(&path, &book);
}
continue;
}
let repos: Vec<String> = book.repos.keys().cloned().collect();
for repo in repos {
let Some(row) = book.repos.get(&repo) else {
continue;
};
if !row.finished || row.lesson {
continue;
}
let Some(issue) = row.issue.clone() else {
continue;
};
let heard = heard_title(&book, &repo, row);
let title = title_from(&heard, &repo);
let deeds = row.deeds.clone();
let failures = row.failures.clone();
let asks = row.asks.clone();
match write_lesson(&issue, &title, &deeds, &failures, &asks) {
Ok(()) => {
if let Some(row) = book.repos.get_mut(&repo) {
row.lesson = true;
}
}
Err(e) => log_line(&format!("lesson: {e}")),
}
}
store_book(&path, &book);
}
}
#[must_use]
pub fn prompt_claims() -> String {
let mut ids = Vec::new();
for path in book_files() {
let book = load_book(&path);
for row in book.repos.values() {
if row.finished {
continue;
}
if let Some(issue) = row
.issue
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
{
ids.push(issue.to_string());
}
}
}
ids.sort();
ids.dedup();
if ids.is_empty() {
String::new()
} else {
format!("claims: {}", ids.join(" "))
}
}
fn book_files() -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(super::runtime_dir()) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with("book-") && name.ends_with(".json") {
let written = entry
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH);
out.push((written, entry.path()));
}
}
out.sort_by_key(|row| std::cmp::Reverse(row.0));
out.into_iter().map(|(_, path)| path).collect()
}
type Heads = std::collections::HashMap<String, Option<String>>;
fn reconcile_deeds(
book: &mut Book,
repo: &str,
deadline: std::time::Instant,
heads: &mut Heads,
cached: &mut bool,
) -> bool {
let Some(row) = book.repos.get(repo) else {
return false;
};
if row.deeds.is_empty() || row.issue.is_none() {
return false;
}
let head = heads
.entry(repo.to_string())
.or_insert_with(|| head_sha(repo).ok())
.clone();
let same_head = head.is_some() && row.landed_head == head;
let mut kept = Vec::new();
for sha in &row.deeds {
if same_head && row.landed.contains(sha) {
kept.push(sha.clone());
continue;
}
if std::time::Instant::now() >= deadline {
return false;
}
match sha_landed(repo, sha) {
Some(true) => kept.push(sha.clone()),
Some(false) => {}
None => return false,
}
}
let all_landed = kept.len() == row.deeds.len();
if head.is_some() && !(same_head && all_landed) {
if let Some(row) = book.repos.get_mut(repo) {
row.landed.clone_from(&kept);
row.landed_head = head;
}
*cached = true;
}
if all_landed {
return false;
}
if let Some(row) = book.repos.get_mut(repo) {
row.deeds = kept;
row.lesson = false;
}
let _ = ensure_deed(book, repo);
true
}
fn sha_landed(repo: &str, sha: &str) -> Option<bool> {
match run_timed(
"git",
&["-C", repo, "merge-base", "--is-ancestor", sha, "HEAD"],
&[],
) {
Ok(_) => Some(true),
Err(e) => {
let text = format!("{e:#}");
if text.contains("not a git repository") || text.contains("not on PATH") {
None
} else {
Some(false)
}
}
}
}
fn note_start(book: &mut Book, repo: &str) {
if repo.is_empty() {
return;
}
if book
.repos
.get(repo)
.and_then(|row| row.start_sha.as_deref())
.is_some()
{
return;
}
let Ok(sha) = head_sha(repo) else {
return;
};
book.repos.entry(repo.to_string()).or_default().start_sha = Some(sha);
}
fn sitting_open(row: &RepoBook) -> bool {
let notes = !row.failures.is_empty() || !row.asks.is_empty();
(row.issue.is_some() && !row.finished) || (notes && !row.lesson)
}
fn head_sha(repo: &str) -> Result<String, String> {
let said =
run_timed("git", &["-C", repo, "rev-parse", "HEAD"], &[]).map_err(|e| e.to_string())?;
let sha = said.stdout.trim().to_string();
if sha.len() < 7 || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("HEAD is not a sha: {sha}"));
}
Ok(sha)
}
fn unclaimed_row(row: &RepoBook) -> bool {
row.issue.is_none() && row.deeds.is_empty() && row.failures.is_empty() && row.asks.is_empty()
}
fn drop_unclaimed_rows(book: &mut Book) {
book.repos.retain(|_, row| !unclaimed_row(row));
book.cwd_repo
.retain(|_, repo| book.repos.contains_key(repo));
}
fn finish_open(book: &mut Book) {
let repos: Vec<String> = book.repos.keys().cloned().collect();
finish_rows(book, &repos);
book.prompt.clear();
book.prompt_repo.clear();
book.prompt_unix = 0;
}
fn finish_rows(book: &mut Book, repos: &[String]) {
for repo in repos {
let mut cached = false;
let in_a_minute = std::time::Instant::now() + std::time::Duration::from_secs(60);
let _ = reconcile_deeds(book, repo, in_a_minute, &mut Heads::new(), &mut cached);
let Some(row) = book.repos.get(repo) else {
continue;
};
let issue = row.issue.clone();
let notes = !row.failures.is_empty() || !row.asks.is_empty();
let finished = row.finished;
if !row.lesson && (issue.is_some() || notes) {
let heard = heard_title(book, repo, row);
let title = title_from(&heard, repo);
let deeds = row.deeds.clone();
let failures = row.failures.clone();
let asks = row.asks.clone();
let label = issue.clone().unwrap_or_default();
match write_lesson(&label, &title, &deeds, &failures, &asks) {
Ok(()) => {
if let Some(row) = book.repos.get_mut(repo) {
row.lesson = true;
}
}
Err(e) => log_line(&format!("lesson: {e}")),
}
}
let also = book
.repos
.get_mut(repo)
.map(|row| std::mem::take(&mut row.also))
.unwrap_or_default();
for other in &also {
if let Err(e) = hand_back(other) {
log_line(&format!("finish: {e}"));
}
}
let Some(issue) = issue else {
continue;
};
if finished {
continue;
}
let named = book.repos.get(repo).is_some_and(|row| row.named);
let done = if named {
hand_back(&issue)
} else {
close_issue(&issue)
};
match done {
Ok(()) => {
if let Some(row) = book.repos.get_mut(repo) {
row.finished = true;
}
}
Err(e) => log_line(&format!("finish: {e}")),
}
}
}
fn close_issue(issue: &str) -> Result<(), String> {
run_timed("vissue", &["update", issue, "-s", "DONE"], &[]).map_err(|e| e.to_string())?;
let assignee = super::resolve_assignee(None);
if which::which("claimdag").is_ok() {
if let Err(e) = super::release(issue, &assignee) {
log_line(&format!("release: {e:#}"));
}
}
let line = super::persist_tracker(issue, "finished");
if line.contains("could not") || line.contains("refused") {
return Err(line.trim().to_string());
}
Ok(())
}
fn hand_back(issue: &str) -> Result<(), String> {
let assignee = super::resolve_assignee(None);
if which::which("claimdag").is_ok() {
if let Err(e) = super::release(issue, &assignee) {
log_line(&format!("release: {e:#}"));
if let Err(e) = super::unstamp_tracker(issue, &assignee, true) {
log_line(&format!("release: {e:#}"));
}
}
} else if let Err(e) = super::unstamp_tracker(issue, &assignee, true) {
log_line(&format!("release: {e:#}"));
}
let line = super::persist_tracker(issue, "released");
if line.contains("could not") || line.contains("refused") {
return Err(line.trim().to_string());
}
Ok(())
}
const NAMED_IDS_MAX: usize = 8;
#[must_use]
pub fn named_open_issue(message: &str) -> Option<String> {
named_open_issue_with(message, |id| {
super::issue_record(id).ok().and_then(|v| {
(v["id"].as_str() == Some(id)).then(|| v["state"].as_str().unwrap_or("").to_string())
})
})
}
fn named_open_issue_with(message: &str, state: impl Fn(&str) -> Option<String>) -> Option<String> {
let mut seen: Vec<String> = Vec::new();
for raw in message.split_whitespace() {
let token = raw.trim_matches(|c: char| {
matches!(
c,
',' | '.'
| ':'
| ';'
| '!'
| '?'
| '"'
| '\''
| '`'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
| '<'
| '>'
| '#'
)
});
if !is_issue_id(token) || seen.iter().any(|s| s == token) {
continue;
}
if seen.len() >= NAMED_IDS_MAX {
break;
}
seen.push(token.to_string());
if !matches!(state(token).as_deref(), Some("DONE" | "CANCELLED") | None) {
return Some(token.to_string());
}
}
None
}
pub(crate) fn book_named_commit(repo: &str, head: &str, issue: &str) {
let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
let (path, key) =
open_row_for_repo(repo).unwrap_or_else(|| (book_path(&sitting_key()), repo.to_string()));
let mut book = load_book(&path);
book_named_in(&mut book, &key, repo, head, issue);
store_book(&path, &book);
}
fn named_head(repo: &str) -> Option<(String, String)> {
let said = run_timed("git", &["-C", repo, "log", "-1", "--format=%H%n%B"], &[]).ok()?;
let (sha, message) = said.stdout.split_once('\n')?;
let sha = sha.trim();
if sha.is_empty() {
return None;
}
named_open_issue(message).map(|issue| (issue, sha.to_string()))
}
fn book_named_in(book: &mut Book, key: &str, repo: &str, head: &str, issue: &str) {
let key = key.to_string();
let now = unix_secs();
let row = book.repos.entry(key.clone()).or_default();
let open = row.issue.clone().filter(|_| !row.finished);
let holds = open.as_deref() == Some(issue);
let held_also = row.also.iter().any(|other| other == issue);
let placeholder = open
.clone()
.filter(|held| held != issue && !held_also && empty_filed_row(row, held));
if let Some(auto) = &placeholder {
cancel_placeholder(auto, issue);
}
let open = if placeholder.is_some() { None } else { open };
if !holds && !held_also {
match claim_existing(issue) {
Ok(()) => {
let line = super::persist_tracker(issue, "claimed");
if line.contains("could not") || line.contains("refused") {
log_line(line.trim());
}
}
Err(e) => log_line(&format!("claim: {issue}: {e}")),
}
}
let cited = cite_deed(issue, head);
if let Err(e) = &cited {
log_line(&format!("deed: {e}"));
}
let row = book.repos.entry(key.clone()).or_default();
match open {
Some(ref held) if held == issue => {
if cited.is_ok() && !row.deeds.iter().any(|seen| seen == head) {
row.deeds.push(head.to_string());
}
}
Some(_) => {
if !held_also {
row.also.push(issue.to_string());
}
if !row.elsewhere.iter().any(|seen| seen == head) {
row.elsewhere.push(head.to_string());
}
}
None if placeholder.is_some() => {
row.issue = Some(issue.to_string());
row.named = true;
row.filed = false;
row.started = false;
row.finished = false;
row.lesson = false;
row.recall = recall_line(issue);
row.elsewhere.retain(|seen| seen != head);
if cited.is_ok() && !row.deeds.iter().any(|seen| seen == head) {
row.deeds.push(head.to_string());
}
}
None => {
*row = RepoBook {
issue: Some(issue.to_string()),
named: true,
deeds: if cited.is_ok() {
vec![head.to_string()]
} else {
Vec::new()
},
start_sha: Some(head.to_string()),
recall: recall_line(issue),
..RepoBook::default()
};
}
}
book.last_unix = now;
if !repo.is_empty() {
book.last_repo = repo.to_string();
}
stamp_touch(book, &key, now);
}
fn empty_filed_row(row: &RepoBook, held: &str) -> bool {
if !row.filed || row.named || row.started || !row.deeds.is_empty() {
return false;
}
if !held_by_this_seat(held) {
return false;
}
super::issue_record(held)
.is_ok_and(|v| v["deeds"].as_array().is_none_or(|deeds| deeds.is_empty()))
}
fn cancel_placeholder(auto: &str, named: &str) {
let assignee = super::resolve_assignee(None);
if which::which("claimdag").is_ok() {
if let Err(e) = super::release(auto, &assignee) {
log_line(&format!("release: {e:#}"));
}
}
let state = super::issue_record(auto)
.ok()
.and_then(|v| v["state"].as_str().map(str::to_string))
.unwrap_or_default();
if !matches!(state.as_str(), "STARTED" | "TODO") {
log_line(&format!(
"claim: left {auto} {state}, not a placeholder now"
));
return;
}
match run_timed(
"vissue",
&["update", auto, "-s", "CANCELLED", "--if-state", &state],
&[],
) {
Ok(_) => {
let line = super::persist_tracker(auto, "cancelled");
if line.contains("could not") || line.contains("refused") {
log_line(line.trim());
}
log_line(&format!(
"claim: cancelled {auto}, filed for this sitting with no deed; the commit named {named}"
));
}
Err(e) => log_line(&format!("claim: could not cancel {auto}: {e}")),
}
}
pub fn note_ask(raw: &str, call: &super::HookCall, reason: &str) {
if !matches!(call.event.as_str(), "PreToolUse" | "argv") {
return;
}
let reason = collapse(reason);
if reason.is_empty() {
return;
}
let value: Value = serde_json::from_str(raw.trim()).unwrap_or(Value::Null);
let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
let path = book_path(&session_key(call));
let mut book = load_book(&path);
let repo = repo_key(&mut book, &value);
note_start(&mut book, &repo);
let now = unix_secs();
let row = book.repos.entry(repo).or_default();
row.last_unix = now;
let short: String = reason.chars().take(160).collect();
if row.asks.len() < 4 && !row.asks.iter().any(|seen| seen == &short) {
row.asks.push(short);
}
book.last_unix = now;
store_book(&path, &book);
}
fn note_failure(book: &mut Book, repo: &str, text: &str) {
let text = collapse(text);
if text.is_empty() {
return;
}
let short: String = text.chars().take(160).collect();
let row = book.repos.entry(repo.to_string()).or_default();
if row.failures.len() < 4 && !row.failures.iter().any(|seen| seen == &short) {
row.failures.push(short);
}
}
fn failure_text(raw: &Value, cue: &str) -> String {
let err = raw
.pointer("/tool_response/error")
.and_then(Value::as_str)
.or_else(|| raw.get("error").and_then(Value::as_str))
.or_else(|| raw.get("error_message").and_then(Value::as_str))
.unwrap_or("")
.trim();
if !err.is_empty() {
return err.to_string();
}
if !cue.trim().is_empty() {
return cue.trim().to_string();
}
"a tool failed".to_string()
}
fn write_lesson(
issue: &str,
title: &str,
shas: &[String],
failures: &[String],
asks: &[String],
) -> Result<(), String> {
let text = lesson_text(title, issue, shas, failures, asks);
super::packset_write_agent_sourced(
"Remember",
&text,
Some(false),
title,
&join_notes(asks),
&join_notes(failures),
issue,
)
.map(|_| ())
.map_err(|e| noted_err(&e))
}
fn join_notes(notes: &[String]) -> String {
notes
.iter()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn noted_err(err: &anyhow::Error) -> String {
let mut out = String::new();
for cause in err.chain() {
let text = cause.to_string();
if text.is_empty() {
continue;
}
if !out.is_empty() {
out.push_str(": ");
}
out.push_str(&text);
}
if out.is_empty() {
err.to_string()
} else {
out
}
}
fn lesson_text(
title: &str,
issue: &str,
shas: &[String],
failures: &[String],
asks: &[String],
) -> String {
let issue = issue.trim();
let shas: Vec<String> = shas
.iter()
.take(3)
.map(|sha| sha.chars().take(7).collect::<String>())
.filter(|sha| !sha.is_empty())
.collect();
let verb = failures.first().map(|text| command_verb(text));
let rule = asks.iter().find_map(|ask| rule_name(ask));
let second = second_sentence(verb.as_deref(), rule.as_deref());
let mut words = title_words(title);
let mut shortening = true;
for _ in 0..256 {
let text = lesson_sentence(&words, issue, &shas, &second);
if lesson_prose_ok(&text) {
return text;
}
if shortening && shorten_longest(&mut words) {
continue;
}
shortening = false;
if words.len() <= 1 {
return text;
}
let drop_at = words
.iter()
.enumerate()
.min_by_key(|(i, word)| (word.chars().count(), *i))
.map(|(i, _)| i)
.unwrap_or(0);
words.remove(drop_at);
}
lesson_sentence(&words, issue, &shas, &second)
}
const COMMAND_RUNNERS: &[&str] = &[
"git", "cargo", "npm", "pnpm", "yarn", "bun", "npx", "pip", "uv", "go", "make", "python",
"python3", "node",
];
fn status_word(word: &str) -> bool {
matches!(
word.to_ascii_lowercase().as_str(),
"exited" | "failed" | "failure" | "error" | "nonzero" | "non-zero"
)
}
fn command_verb(text: &str) -> String {
let words = super::shell_words(text);
if let Some(verb) = runner_subcommand(&words) {
return verb;
}
words
.into_iter()
.find(|word| !super::is_lesson_filler(word) && !status_word(word))
.map(|word| word.to_ascii_lowercase())
.unwrap_or_else(|| "tool".to_string())
}
fn runner_subcommand(words: &[String]) -> Option<String> {
let mut i = 0usize;
if words
.first()
.is_some_and(|word| word.eq_ignore_ascii_case("sudo"))
{
i += 1;
}
let bin = words.get(i)?.rsplit('/').next().unwrap_or("");
let bin_name = bin.to_ascii_lowercase();
if !COMMAND_RUNNERS.contains(&bin_name.as_str()) {
return None;
}
i += 1;
if bin_name == "git" {
while i < words.len() {
match words[i].as_str() {
"-C" | "-c" | "--git-dir" | "--work-tree" => i += 2,
flag if flag.starts_with("--git-dir=") || flag.starts_with("--work-tree=") => {
i += 1;
}
flag if flag.starts_with('-') => i += 1,
other => return Some(other.to_ascii_lowercase()),
}
}
return Some(bin_name);
}
Some(
words
.iter()
.skip(i)
.find(|word| !word.starts_with('-'))
.map_or(bin_name, |word| word.to_ascii_lowercase()),
)
}
const ASK_BOILER: &[&str] = &["guard", "asked", "about", "tool", "failed", "failure"];
fn rule_token(token: &str) -> bool {
!token.is_empty()
&& token
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
&& token.chars().any(|c| c.is_ascii_alphabetic())
&& !super::is_lesson_filler(token)
}
fn rule_name(ask: &str) -> Option<String> {
let ask = ask.trim();
if ask.is_empty() {
return None;
}
if let Some((head, _)) = ask.split_once(':') {
let head = head.trim();
if rule_token(head) {
return Some(head.to_string());
}
}
for raw in ask.split_whitespace() {
let token = raw.trim_matches(|c: char| !c.is_ascii_alphanumeric());
if token.contains('-') && rule_token(token) {
return Some(token.to_string());
}
}
let named: Vec<String> = title_words(ask)
.into_iter()
.filter(|word| !ASK_BOILER.contains(&word.to_ascii_lowercase().as_str()))
.take(2)
.collect();
if named.is_empty() {
None
} else {
Some(named.join(" "))
}
}
fn second_sentence(verb: Option<&str>, rule: Option<&str>) -> String {
match (verb, rule) {
(Some(verb), Some(rule)) => {
format!(
"{} failed and the guard asked about {rule}.",
capitalize_word(verb)
)
}
(Some(verb), None) => format!("{} failed.", capitalize_word(verb)),
(None, Some(rule)) => format!("The guard asked about {rule}."),
(None, None) => String::new(),
}
}
fn capitalize_word(word: &str) -> String {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn title_words(title: &str) -> Vec<String> {
let words: Vec<String> = title
.split(|c: char| !c.is_alphanumeric())
.filter(|word| !word.is_empty() && !super::is_lesson_filler(word))
.map(str::to_string)
.collect();
if !words.is_empty() {
return words;
}
title
.split(|c: char| !c.is_alphanumeric())
.filter(|word| !word.is_empty())
.take(3)
.map(str::to_string)
.collect()
}
fn shorten_longest(words: &mut [String]) -> bool {
let Some(at) = words
.iter()
.enumerate()
.filter(|(_, word)| word.chars().count() > 4)
.max_by_key(|(i, word)| (word.chars().count(), *i))
.map(|(i, _)| i)
else {
return false;
};
words[at].pop();
true
}
fn lesson_sentence(words: &[String], issue: &str, shas: &[String], second: &str) -> String {
let head = words.join(" ");
let issue = issue.trim();
let first = if issue.is_empty() {
if shas.is_empty() {
if head.is_empty() {
String::new()
} else {
format!("The session worked on {head}.")
}
} else if head.is_empty() {
format!("Committed {}.", shas.join(" "))
} else {
format!("{head} committed {}.", shas.join(" "))
}
} else if shas.is_empty() {
if head.is_empty() {
format!("Work {issue} closed.")
} else {
format!("{head} ({issue}) closed.")
}
} else if head.is_empty() {
format!("Work {issue} committed {}.", shas.join(" "))
} else {
format!("{head} ({issue}) committed {}.", shas.join(" "))
};
if second.is_empty() {
first
} else if first.is_empty() {
second.to_string()
} else {
format!("{first} {second}")
}
}
fn lesson_prose_ok(text: &str) -> bool {
packset_core::prose_refuse(text, packset_core::ProseRole::Atom).is_ok()
}
fn bind_prompt(book: &mut Book, repo: &str, now: u64) {
if repo.is_empty() || book.prompt.trim().is_empty() {
return;
}
book.prompt_repo = repo.to_string();
book.prompt_unix = now;
}
fn heard_title(book: &Book, repo: &str, row: &RepoBook) -> String {
if prompt_belongs_to(book, repo) {
let prompt = book.prompt.trim();
if !prompt.is_empty() {
return prompt.to_string();
}
}
row.title.clone().unwrap_or_default()
}
fn prompt_live_for(book: &Book, repo: &str, now: u64) -> bool {
if !prompt_belongs_to(book, repo) {
return false;
}
let Some(limit) = sitting_idle() else {
return true;
};
let earlier = book.repos.iter().any(|(key, row)| {
same_checkout(key, repo) && row_is_idle(now, book, key, row, limit, Some(repo))
});
if earlier {
return false;
}
let open = book
.repos
.iter()
.any(|(key, row)| same_checkout(key, repo) && !row.finished && sitting_open(row));
if open {
return true;
}
let stamped = if book.prompt_unix > 0 {
book.prompt_unix
} else {
book.last_unix
};
!idle_due(now, stamped, limit)
}
fn prompt_belongs_to(book: &Book, repo: &str) -> bool {
let prompt = book.prompt.trim();
if prompt.is_empty() || repo.is_empty() {
return false;
}
let recorded = book.prompt_repo.trim();
if !recorded.is_empty() {
return same_checkout(recorded, repo);
}
book.repos.iter().any(|(key, row)| {
!row.finished
&& same_checkout(key, repo)
&& row
.title
.as_deref()
.is_some_and(|title| title.trim() == prompt)
})
}
fn release_spent_prompt(book: &mut Book) {
if book.prompt.trim().is_empty() {
return;
}
let recorded = book.prompt_repo.trim();
let still = if !recorded.is_empty() {
book.repos.keys().any(|repo| same_checkout(repo, recorded))
} else {
let prompt = book.prompt.trim();
book.repos.values().any(|row| {
!row.finished
&& row
.title
.as_deref()
.is_some_and(|title| title.trim() == prompt)
})
};
if !still {
book.prompt.clear();
book.prompt_repo.clear();
book.prompt_unix = 0;
}
}
fn row_is_idle(
now: u64,
book: &Book,
repo: &str,
row: &RepoBook,
limit: Duration,
current: Option<&str>,
) -> bool {
if !sitting_open(row) {
return false;
}
if row.last_unix > 0 {
return idle_due(now, row.last_unix, limit);
}
if idle_due(now, book.last_unix, limit) {
return true;
}
matches!(
current,
Some(cur) if !cur.is_empty() && book.last_unix > 0 && !same_checkout(cur, repo)
)
}
fn stamp_touch(book: &mut Book, repo: &str, now: u64) {
if !repo.is_empty() {
if let Some(row) = book.repos.get_mut(repo) {
row.last_unix = now;
}
}
book.last_unix = now;
}
fn remember_title(book: &mut Book, repo: &str) {
let prompt: String = collapse(&book.prompt).chars().take(4000).collect();
if prompt.is_empty() || repo.is_empty() {
return;
}
book.repos.entry(repo.to_string()).or_default().title = Some(prompt);
}
fn title_from(prompt: &str, repo: &str) -> String {
let forced = std::env::var("LJOS_PROJECT").ok();
let tracker = std::env::var_os("VISSUE_ROOT").map(PathBuf::from);
title_at(prompt, repo, forced.as_deref(), tracker.as_deref())
}
fn title_at(prompt: &str, repo: &str, forced: Option<&str>, tracker: Option<&Path>) -> String {
let words: String = prompt
.split_whitespace()
.take(12)
.collect::<Vec<_>>()
.join(" ");
let title: String = words.chars().take(80).collect();
if !title.is_empty() {
return title;
}
if repo.trim().is_empty() {
return "agent work".to_string();
}
let project = project_for_at(repo, forced, tracker);
if project.trim().is_empty() {
"agent work".to_string()
} else {
format!("Work in {project}")
}
}
fn project_for(repo: &str) -> String {
let forced = std::env::var("LJOS_PROJECT").ok();
let tracker = std::env::var_os("VISSUE_ROOT").map(PathBuf::from);
project_for_at(repo, forced.as_deref(), tracker.as_deref())
}
fn project_for_at(repo: &str, forced: Option<&str>, tracker: Option<&Path>) -> String {
if let Some(project) = forced.map(str::trim).filter(|project| !project.is_empty()) {
return project.to_string();
}
let remote = git_remote_url(repo);
if let Some(tracker) = tracker {
if let Some(project) = match_remote_project(repo, tracker) {
return project;
}
}
if let Some(name) = remote.as_deref().and_then(remote_repo_name) {
return name;
}
if let Some(tracker) = tracker {
if let Some(project) = sole_project_in(tracker) {
return project;
}
}
Path::new(repo)
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or("agent")
.to_string()
}
fn match_remote_project(repo: &str, tracker: &Path) -> Option<String> {
let url = git_remote_url(repo)?;
let want = normalize_remote(&url);
if want.is_empty() {
return None;
}
let projects = projects_in(tracker);
for (name, dir) in &projects {
if project_records_remote(dir, &want) {
return Some(name.clone());
}
if tracker_project_remote(tracker, name).is_some_and(|url| normalize_remote(&url) == want) {
return Some(name.clone());
}
}
let slug = remote_slug(&want);
projects
.into_iter()
.find(|(name, _)| name.eq_ignore_ascii_case(&slug))
.map(|(name, _)| name)
}
fn remote_repo_name(url: &str) -> Option<String> {
let raw = url.trim().trim_end_matches('/');
if raw.starts_with(['/', '.', '~']) || raw.starts_with("file:") {
return None;
}
let raw = raw.strip_suffix(".git").unwrap_or(raw);
let name = raw.rsplit(['/', ':']).next()?.trim();
let ok = !name.is_empty()
&& name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric())
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
ok.then(|| name.to_string())
}
fn git_remote_url(repo: &str) -> Option<String> {
if repo.is_empty() {
return None;
}
let said = run_timed("git", &["-C", repo, "remote", "get-url", "origin"], &[]).ok()?;
let url = said.stdout.trim();
if url.is_empty() {
None
} else {
Some(url.to_string())
}
}
fn normalize_remote(raw: &str) -> String {
let mut raw = raw.trim().trim_end_matches('/').to_string();
if let Some(stripped) = raw.strip_suffix(".git") {
raw = stripped.to_string();
}
for prefix in ["ssh://", "git://", "https://", "http://"] {
if let Some(rest) = raw.strip_prefix(prefix) {
raw = rest.to_string();
break;
}
}
if let Some((user, host)) = raw.split_once('@') {
if !user.contains('/') {
raw = host.to_string();
}
}
if let Some((host, path)) = raw.split_once(':') {
if !host.contains('/') {
raw = format!("{host}/{}", path.trim_start_matches('/'));
}
}
raw.to_ascii_lowercase()
}
fn remote_slug(normalized: &str) -> String {
normalized
.rsplit('/')
.next()
.unwrap_or(normalized)
.to_string()
}
fn project_records_remote(dir: &Path, want: &str) -> bool {
recorded_remotes(dir)
.iter()
.any(|url| normalize_remote(url) == want)
}
fn recorded_remotes(dir: &Path) -> Vec<String> {
let mut out = Vec::new();
if let Ok(text) = std::fs::read_to_string(dir.join("issues.org")) {
for line in text.lines() {
if let Some(url) = header_remote(line) {
out.push(url);
}
}
}
for name in ["remote.url", "repo.url"] {
let Ok(text) = std::fs::read_to_string(dir.join(name)) else {
continue;
};
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.contains(char::is_whitespace) {
continue;
}
out.push(line.to_string());
}
}
out.extend(toml_remotes(&dir.join("project.toml")));
out
}
fn header_remote(line: &str) -> Option<String> {
let rest = line.trim().strip_prefix("#+")?;
let (key, value) = rest.split_once(':')?;
if !key.eq_ignore_ascii_case("REMOTE") {
return None;
}
let value = value.trim().trim_matches('"').trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
fn toml_remotes(path: &Path) -> Vec<String> {
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
let Ok(value) = toml::from_str::<toml::Value>(&text) else {
return Vec::new();
};
let mut out = Vec::new();
push_remote(&mut out, &value);
if let Some(project) = value.get("project") {
push_remote(&mut out, project);
}
out
}
fn push_remote(out: &mut Vec<String>, value: &toml::Value) {
let Some(url) = value.get("remote").and_then(toml::Value::as_str) else {
return;
};
let url = url.trim();
if !url.is_empty() {
out.push(url.to_string());
}
}
fn tracker_project_remote(tracker: &Path, project: &str) -> Option<String> {
let text = std::fs::read_to_string(tracker.join("vissue.toml")).ok()?;
let value: toml::Value = toml::from_str(&text).ok()?;
let url = value
.get("projects")?
.get(project)?
.get("remote")?
.as_str()?
.trim();
if url.is_empty() {
None
} else {
Some(url.to_string())
}
}
fn projects_in(root: &Path) -> Vec<(String, PathBuf)> {
let mut found = Vec::new();
let mut consider = |dir: PathBuf| {
if dir.join("issues.org").is_file() {
if let Some(name) = dir.file_name().and_then(|name| name.to_str()) {
found.push((name.to_string(), dir));
}
}
};
let Ok(entries) = std::fs::read_dir(root) else {
return found;
};
for entry in entries.flatten() {
let path = entry.path();
if entry.file_name() == "Software" {
if let Ok(children) = std::fs::read_dir(&path) {
for child in children.flatten() {
consider(child.path());
}
}
} else {
consider(path);
}
}
found
}
fn sole_project_in(root: &Path) -> Option<String> {
let mut found: Vec<String> = projects_in(root)
.into_iter()
.map(|(name, _)| name)
.collect();
found.sort();
found.dedup();
(found.len() == 1).then(|| found.remove(0))
}
fn recall_line(issue: &str) -> String {
let head = format!("Recall for {issue} (`ljos recall {issue}` shows its full working set):");
match super::tracker_show_json(issue) {
Ok(card) => match card.get("title").and_then(Value::as_str) {
Some(title) if !title.trim().is_empty() => format!("{head}\n{title}"),
_ => head,
},
Err(_) => head,
}
}
pub fn print_brief_once() {
if ljos_off() || home_or_cwd_is_temp() {
return;
}
let path = brief_mark(&brief_key());
if path.exists() {
return;
}
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
if std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.is_err()
{
return;
}
let text = seat_brief();
if text.is_empty() {
return;
}
eprintln!("{text}");
let _ = std::io::Write::flush(&mut std::io::stderr());
}
fn brief_key() -> String {
let seat = std::env::var("LJOS_SEAT")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "seat".into());
let convo = std::env::var("CURSOR_CONVERSATION_ID")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| {
std::env::var("LJOS_SHELL_SESSION")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
.unwrap_or_else(process_sid);
format!("{seat}:{convo}")
}
fn process_sid() -> String {
let sid = unsafe { libc::getsid(0) };
if sid > 0 {
sid.to_string()
} else {
std::process::id().to_string()
}
}
fn brief_mark(key: &str) -> PathBuf {
let safe: String = key
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
super::runtime_dir().join("brief").join(safe)
}
fn seat_brief() -> String {
format!(
"claims: {}\nrecall: {}\nmail: {}",
held_claims_line(),
brief_recall_line(),
brief_mail_line()
)
}
fn held_claims_line() -> String {
let book = load_book(&book_path(&sitting_key()));
let mut ids: Vec<String> = book
.repos
.values()
.filter(|row| !row.finished)
.filter_map(|row| row.issue.clone())
.collect();
ids.sort();
ids.dedup();
if ids.is_empty() {
"none".into()
} else {
ids.join(" ")
}
}
fn brief_recall_line() -> String {
let book = load_book(&book_path(&sitting_key()));
for row in book.repos.values() {
if row.finished {
continue;
}
if let Some(title) = row
.title
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
{
return one_line(title);
}
if !row.recall.trim().is_empty() {
return one_line(&row.recall);
}
}
"none".into()
}
fn brief_mail_line() -> String {
let url = std::env::var("PACKSET_URL").unwrap_or_default();
let url = url.trim();
if url.is_empty() || url == "off" {
return "none".into();
}
let (text, _) = super::mail::prompt_note();
let line = one_line(&text);
if line.is_empty() || line.contains("nothing unread") {
"none".into()
} else {
line
}
}
#[must_use]
pub fn commit_recall_line(session: &str, repo: &str, subject: &str) -> Option<String> {
let book = load_book(&book_path(session));
let row = book.repos.get(repo).filter(|row| row.issue.is_some())?;
let title = row
.title
.clone()
.filter(|title| !title.trim().is_empty())
.unwrap_or_else(|| subject.trim().to_string());
let body = format!("{title}\n{}", row.recall);
if !relevant(subject, &body) {
return None;
}
let line = one_line(&title);
if line.is_empty() {
None
} else {
Some(format!("recall: {line}"))
}
}
fn relevant(subject: &str, body: &str) -> bool {
let subject = subject.trim();
if subject.is_empty() {
return false;
}
let body = body.to_ascii_lowercase();
if body.contains(&subject.to_ascii_lowercase()) {
return true;
}
subject
.split(|c: char| !c.is_alphanumeric())
.filter(|word| word.len() >= 4)
.any(|word| body.contains(&word.to_ascii_lowercase()))
}
fn one_line(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[derive(Clone, Copy)]
enum CommandExit {
Code(i64),
Unknown,
Absent,
}
fn command_failed(exit: CommandExit) -> bool {
matches!(exit, CommandExit::Code(code) if code != 0)
}
fn commit_landed(exit: CommandExit, raw: &Value, repo: &str, start: Option<&str>) -> bool {
match exit {
CommandExit::Code(0) => return true,
CommandExit::Code(_) => return false,
CommandExit::Unknown | CommandExit::Absent => {}
}
if raw
.pointer("/tool_response/interrupted")
.and_then(Value::as_bool)
== Some(true)
|| permission_block(raw)
{
return false;
}
git_operation_committed(raw) || stdout_has_commit_banner(raw) || head_moved(repo, start)
}
fn git_operation_committed(raw: &Value) -> bool {
[
"/gitOperation",
"/git_operation",
"/tool_response/gitOperation",
"/tool_response/git_operation",
"/toolResponse/gitOperation",
]
.iter()
.any(|slot| raw.pointer(slot).is_some_and(commit_record_landed))
}
fn commit_record_landed(value: &Value) -> bool {
if value.get("commit").is_some_and(commit_record_landed) {
return true;
}
let sha = value
.get("sha")
.and_then(Value::as_str)
.unwrap_or("")
.trim();
if sha_token(sha) {
return true;
}
let kind = value
.get("kind")
.and_then(Value::as_str)
.unwrap_or("")
.trim();
matches!(kind, "committed" | "amended" | "cherry-picked")
}
fn sha_token(text: &str) -> bool {
(7..=40).contains(&text.len()) && text.chars().all(|c| c.is_ascii_hexdigit())
}
fn branch_token(text: &str) -> bool {
!text.is_empty()
&& text
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'))
}
fn stdout_has_commit_banner(raw: &Value) -> bool {
commit_output(raw)
.iter()
.any(|text| text.lines().any(line_has_commit_banner))
}
fn commit_output(raw: &Value) -> Vec<&str> {
let mut out = Vec::new();
for key in [
"tool_response",
"toolResponse",
"stdout",
"stderr",
"output",
] {
if let Some(text) = raw.get(key).and_then(Value::as_str) {
out.push(text);
}
}
for slot in [
"/tool_response/stdout",
"/tool_response/output",
"/tool_response/stderr",
"/toolResponse/stdout",
"/toolResponse/output",
"/toolResponse/stderr",
] {
if let Some(text) = raw.pointer(slot).and_then(Value::as_str) {
out.push(text);
}
}
out
}
fn line_has_commit_banner(line: &str) -> bool {
let mut from = 0;
while let Some(rel) = line[from..].find('[') {
let start = from + rel + 1;
let Some(end_rel) = line[start..].find(']') else {
break;
};
if banner_inside(&line[start..start + end_rel]) {
return true;
}
from = start;
}
false
}
fn banner_inside(inside: &str) -> bool {
let mut rest = inside.trim();
if rest.is_empty() {
return false;
}
if let Some(after) = rest.strip_prefix("detached HEAD") {
rest = after.trim_start();
} else {
let Some((branch, after)) = rest.split_once(|c: char| c.is_whitespace()) else {
return false;
};
if !branch_token(branch) {
return false;
}
rest = after.trim_start();
}
if let Some(after) = rest.strip_prefix("(root-commit)") {
rest = after.trim_start();
}
let Some((sha, tail)) = rest.split_once(|c: char| c.is_whitespace()) else {
return sha_token(rest);
};
tail.trim().is_empty() && sha_token(sha)
}
fn head_moved(repo: &str, start: Option<&str>) -> bool {
let Some(start) = start.map(str::trim).filter(|sha| !sha.is_empty()) else {
return false;
};
head_sha(repo).is_ok_and(|head| head != start)
}
fn command_exit(raw: &Value) -> CommandExit {
if let Some(code) = explicit_exit(raw) {
return CommandExit::Code(code);
}
if let Some(code) = transcript_exit(raw) {
return CommandExit::Code(code);
}
if response_is_text(raw) {
return CommandExit::Unknown;
}
CommandExit::Absent
}
fn response_is_text(raw: &Value) -> bool {
raw.get("tool_response")
.or_else(|| raw.get("toolResponse"))
.is_some_and(Value::is_string)
}
fn tool_use_id(raw: &Value) -> Option<&str> {
["tool_use_id", "toolUseId", "tool_call_id", "toolCallId"]
.iter()
.find_map(|key| raw.get(*key).and_then(Value::as_str))
.map(str::trim)
.filter(|id| !id.is_empty())
}
fn transcript_exit(raw: &Value) -> Option<i64> {
let path = raw
.get("transcript_path")
.and_then(Value::as_str)
.or_else(|| raw.get("transcriptPath").and_then(Value::as_str))
.map(str::trim)
.filter(|path| !path.is_empty())?;
let call_id = tool_use_id(raw)?;
let text = read_transcript_tail(&super::expand(path))?;
exit_for_call(&text, call_id)
}
const TRANSCRIPT_DEADLINE: Duration = COMMAND_DEADLINE;
const TRANSCRIPT_TAIL_BYTES: u64 = 1024 * 1024;
fn read_transcript_tail(path: &Path) -> Option<String> {
read_bounded(path, TRANSCRIPT_TAIL_BYTES, true)
}
pub(crate) fn read_text_bounded(path: &Path) -> Option<String> {
read_bounded(path, u64::MAX, false)
}
fn read_bounded(path: &Path, max_bytes: u64, tail: bool) -> Option<String> {
use std::os::unix::fs::FileTypeExt;
let mut file = open_nonblock(path)?;
let kind = file.metadata().ok().map(|meta| meta.file_type());
if kind.as_ref().is_some_and(|ty| ty.is_fifo()) {
return read_available(&mut file, max_bytes, Duration::ZERO);
}
if kind.as_ref().is_some_and(|ty| ty.is_file()) {
if tail {
return read_file_tail(&mut file, max_bytes);
}
return read_available(&mut file, max_bytes, TRANSCRIPT_DEADLINE);
}
read_available(&mut file, max_bytes, Duration::ZERO)
}
fn open_nonblock(path: &Path) -> Option<std::fs::File> {
use std::os::unix::ffi::OsStrExt;
use std::os::unix::io::FromRawFd;
let bytes = path.as_os_str().as_bytes();
let c_path = std::ffi::CString::new(bytes).ok()?;
let fd = unsafe {
libc::open(
c_path.as_ptr(),
libc::O_RDONLY | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if fd < 0 {
return None;
}
Some(unsafe { std::fs::File::from_raw_fd(fd) })
}
fn wait_readable(fd: i32, deadline: Duration) -> bool {
use std::io::ErrorKind;
let start = Instant::now();
loop {
let elapsed = start.elapsed();
let left = deadline.saturating_sub(elapsed);
let ms = i32::try_from(left.as_millis()).unwrap_or(i32::MAX);
let mut polled = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
let rc = unsafe { libc::poll(&mut polled, 1, ms) };
if rc < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == ErrorKind::Interrupted && start.elapsed() < deadline {
continue;
}
return false;
}
if rc == 0 {
return false;
}
let rev = polled.revents;
if (rev & libc::POLLIN) != 0 {
return true;
}
if (rev & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL)) != 0 {
return false;
}
if start.elapsed() >= deadline {
return false;
}
}
}
fn read_file_tail(file: &mut std::fs::File, max_bytes: u64) -> Option<String> {
use std::io::{Read, Seek, SeekFrom};
let len = file.metadata().ok()?.len();
let start = len.saturating_sub(max_bytes);
file.seek(SeekFrom::Start(start)).ok()?;
let mut buf = Vec::new();
file.take(max_bytes).read_to_end(&mut buf).ok()?;
let mut text = String::from_utf8_lossy(&buf).into_owned();
if start > 0 {
if let Some(pos) = text.find('\n') {
text.drain(..=pos);
}
}
Some(text)
}
fn read_available(file: &mut std::fs::File, max_bytes: u64, deadline: Duration) -> Option<String> {
use std::io::{ErrorKind, Read};
use std::os::unix::io::AsRawFd;
let start = Instant::now();
let cap = usize::try_from(max_bytes).unwrap_or(usize::MAX);
let mut buf = Vec::new();
let mut tmp = [0u8; 8192];
while buf.len() < cap {
let room = cap - buf.len();
let nread = room.min(tmp.len());
match file.read(&mut tmp[..nread]) {
Ok(0) => break,
Ok(n) => buf.extend_from_slice(&tmp[..n]),
Err(err) if err.kind() == ErrorKind::Interrupted => continue,
Err(err) if err.kind() == ErrorKind::WouldBlock => {
let left = deadline.saturating_sub(start.elapsed());
if left.is_zero() || !wait_readable(file.as_raw_fd(), left) {
break;
}
}
Err(_) => break,
}
}
if buf.is_empty() {
None
} else {
Some(String::from_utf8_lossy(&buf).into_owned())
}
}
struct TranscriptExit {
item: Option<i64>,
spoken: Option<i64>,
}
impl TranscriptExit {
fn code(self) -> Option<i64> {
self.item.or(self.spoken)
}
}
fn exit_for_call(text: &str, call_id: &str) -> Option<i64> {
let mut found = TranscriptExit {
item: None,
spoken: None,
};
for line in text.lines() {
note_line_exit(line, call_id, &mut found);
}
found.code()
}
fn note_line_exit(line: &str, call_id: &str, found: &mut TranscriptExit) {
if !line.contains(call_id) {
return;
}
if let Ok(value) = serde_json::from_str::<Value>(line) {
walk_exit(&value, call_id, found);
} else {
exit_in_text(line, call_id, found);
}
}
fn walk_exit(value: &Value, call_id: &str, found: &mut TranscriptExit) {
match value {
Value::Array(items) => {
for item in items {
walk_exit(item, call_id, found);
}
}
Value::Object(map) => {
if let Some(code) = command_execution_code(map, call_id) {
found.item = Some(code);
}
if let Some(code) = function_output_code(map, call_id) {
found.spoken = Some(code);
}
for child in map.values() {
walk_exit(child, call_id, found);
}
}
_ => {}
}
}
fn command_execution_code(map: &serde_json::Map<String, Value>, call_id: &str) -> Option<i64> {
if map.get("type").and_then(Value::as_str) != Some("CommandExecution") {
return None;
}
if map.get("id").and_then(Value::as_str) != Some(call_id) {
return None;
}
["exit_code", "exitCode"]
.iter()
.find_map(|key| map.get(*key).and_then(exit_number))
}
fn function_output_code(map: &serde_json::Map<String, Value>, call_id: &str) -> Option<i64> {
if map.get("type").and_then(Value::as_str) != Some("function_call_output") {
return None;
}
let matched = map
.get("call_id")
.or_else(|| map.get("callId"))
.and_then(Value::as_str)
== Some(call_id);
if !matched {
return None;
}
map.get("output")
.and_then(Value::as_str)
.and_then(process_exit_code)
}
fn process_exit_code(text: &str) -> Option<i64> {
let marker = "Process exited with code ";
let rest = text.get(text.find(marker)? + marker.len()..)?;
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
None
} else {
digits.parse().ok()
}
}
fn exit_number(slot: &Value) -> Option<i64> {
if let Some(code) = slot.as_i64() {
return Some(code);
}
slot.as_str().and_then(|text| text.trim().parse().ok())
}
fn exit_in_text(line: &str, call_id: &str, found: &mut TranscriptExit) {
let mut from = 0;
while let Some(rel) = line[from..].find("CommandExecution") {
let start = from + rel;
let rest = start + "CommandExecution".len();
let end = line[rest..]
.find("CommandExecution")
.map(|rel| rest + rel)
.unwrap_or(line.len());
let window = &line[start..end];
if find_key(window, "\"id\"", call_id).is_some() {
if let Some(code) = exit_code_in(window) {
found.item = Some(code);
}
}
from = rest;
}
let mut from = 0;
while let Some(start) = find_key(&line[from..], "\"call_id\"", call_id) {
let start = from + start;
let after = start + "\"call_id\"".len();
let end = line[after..]
.find("\"call_id\"")
.map(|rel| after + rel)
.unwrap_or(line.len());
if let Some(code) = process_exit_code(&line[start..end]) {
found.spoken = Some(code);
}
from = after;
}
}
fn find_key(text: &str, key: &str, want: &str) -> Option<usize> {
let mut from = 0;
while from < text.len() {
let Some(rel) = text[from..].find(key) else {
break;
};
let at = from + rel;
let after = at + key.len();
if json_string_is(text.get(after..).unwrap_or(""), want) {
return Some(at);
}
from = after;
}
None
}
fn json_string_is(text: &str, want: &str) -> bool {
let rest = text.trim_start();
let Some(rest) = rest.strip_prefix(':') else {
return false;
};
let Some(rest) = rest.trim_start().strip_prefix('"') else {
return false;
};
rest.strip_prefix(want)
.is_some_and(|rest| rest.starts_with('"'))
}
fn exit_code_in(text: &str) -> Option<i64> {
for key in ["\"exit_code\"", "\"exitCode\""] {
let Some(at) = text.find(key) else {
continue;
};
let rest = text[at + key.len()..].trim_start();
let Some(rest) = rest.strip_prefix(':') else {
continue;
};
if let Some(code) = parse_exit_token(rest.trim_start()) {
return Some(code);
}
}
None
}
fn parse_exit_token(rest: &str) -> Option<i64> {
let digits = |text: &str| -> Option<i64> {
let num: String = text
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '-')
.collect();
if num.is_empty() {
None
} else {
num.parse().ok()
}
};
if let Some(rest) = rest.strip_prefix('"') {
return digits(rest);
}
digits(rest)
}
fn explicit_exit(raw: &Value) -> Option<i64> {
let slots = [
raw.get("exit_code"),
raw.get("exit"),
raw.pointer("/tool_response/exit_code"),
raw.pointer("/tool_response/exit"),
raw.pointer("/tool_response/exitCode"),
raw.pointer("/tool_output/exit_code"),
];
for slot in slots.into_iter().flatten() {
if let Some(code) = slot.as_i64() {
return Some(code);
}
if let Some(code) = slot.as_str() {
return code.trim().parse().ok();
}
}
None
}
fn permission_block(raw: &Value) -> bool {
let decisions = [
raw.get("permissionDecision"),
raw.get("permission_decision"),
raw.pointer("/hookSpecificOutput/permissionDecision"),
raw.pointer("/tool_response/permissionDecision"),
raw.get("decision"),
];
decisions.into_iter().flatten().any(|slot| {
slot.as_str()
.is_some_and(|word| matches!(word.trim(), "deny" | "ask" | "block"))
})
}
fn redirect_targets(line: &str) -> Vec<String> {
let mut out = Vec::new();
for seg in super::nested_lines(line, 0)
.iter()
.flat_map(|l| super::raw_segments(l))
{
let words = super::shell_words(&seg);
let mut i = 0;
while i < words.len() {
let word = words[i].as_str();
if word == ">" || word == ">>" {
if let Some(target) = words.get(i + 1) {
if let Some(target) = redirect_path(target) {
out.push(target);
}
}
i += 2;
continue;
}
if let Some(target) = glued_redirect(word) {
out.push(target);
}
i += 1;
}
}
out
}
fn redirect_path(word: &str) -> Option<String> {
let word = word.trim_matches(|c| c == '"' || c == '\'');
if word.is_empty() || word.starts_with('&') {
return None;
}
Some(word.to_string())
}
fn glued_redirect(word: &str) -> Option<String> {
let rest = word.trim_start_matches(|c: char| c.is_ascii_digit());
let body = rest
.strip_prefix(">>")
.or_else(|| rest.strip_prefix("&>"))
.or_else(|| rest.strip_prefix('>'))?;
redirect_path(body)
}
fn writes_worktree(repo: &str, line: &str) -> bool {
let repo = Path::new(repo);
if repo.as_os_str().is_empty() {
return false;
}
redirect_targets(line)
.into_iter()
.any(|target| path_in_worktree(repo, &target))
}
fn path_in_worktree(repo: &Path, target: &str) -> bool {
if target.is_empty()
|| target.starts_with('~')
|| target.contains('$')
|| target.contains('*')
|| target.starts_with('&')
{
return false;
}
let target = Path::new(target);
let joined = if target.is_absolute() {
target.to_path_buf()
} else {
repo.join(target)
};
let repo = lexical_norm(repo);
let joined = lexical_norm(&joined);
joined.starts_with(&repo) && joined != repo
}
fn lexical_norm(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
fn is_edit(raw: &Value, cue: &str) -> bool {
if edit_name(&tool_name(raw)) || edit_name(cue.split_whitespace().next().unwrap_or("")) {
return true;
}
command_is_apply_patch(cue)
}
fn tool_name(raw: &Value) -> String {
raw["tool_name"]
.as_str()
.or_else(|| raw["toolName"].as_str())
.or_else(|| raw["toolCall"]["name"].as_str())
.unwrap_or("")
.to_string()
}
fn edit_name(name: &str) -> bool {
let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
EDIT_TOOLS
.iter()
.any(|tool| tool.eq_ignore_ascii_case(base))
}
fn command_is_apply_patch(line: &str) -> bool {
super::nested_lines(line, 0)
.iter()
.flat_map(|l| super::raw_segments(l))
.any(|seg| {
let words = super::shell_words(&seg);
let (at, _) = super::prefix_end(&words);
words.get(at).is_some_and(|word| {
word.rsplit('/')
.next()
.unwrap_or(word)
.eq_ignore_ascii_case("apply_patch")
})
})
}
fn is_git_commit(line: &str) -> bool {
super::nested_lines(line, 0)
.iter()
.flat_map(|l| super::raw_segments(l))
.any(|seg| {
let words = super::shell_words(&seg);
let (at, _) = super::prefix_end(&words);
git_commit_argv(&words[at..])
})
}
fn git_commit_argv(words: &[String]) -> bool {
let Some(bin) = words.first() else {
return false;
};
if bin.rsplit('/').next().unwrap_or(bin) != "git" {
return false;
}
let mut i = 1;
while i < words.len() {
match words[i].as_str() {
"-C" | "-c" | "--git-dir" | "--work-tree" => i += 2,
flag if flag.starts_with("--git-dir=") || flag.starts_with("--work-tree=") => i += 1,
"commit" => return true,
_ => return false,
}
}
false
}
fn parse_issue_id(text: &str) -> Option<String> {
let mut found = None;
for raw in text.split_whitespace() {
let token = raw.trim_matches(|c: char| matches!(c, ',' | '.' | ':' | '"' | '\''));
if is_issue_id(token) {
found = Some(token.to_string());
}
}
found
}
fn is_issue_id(token: &str) -> bool {
let Some((project, suffix)) = token.rsplit_once('-') else {
return false;
};
issue_project(project) && issue_suffix(suffix)
}
fn issue_project(project: &str) -> bool {
let mut chars = project.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic())
&& project
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/'))
&& !project.ends_with('-')
&& !project.ends_with('/')
&& !project.contains("--")
}
fn issue_suffix(suffix: &str) -> bool {
suffix.len() >= 4
&& suffix
.bytes()
.all(|b| b.is_ascii_digit() || b.is_ascii_lowercase())
}
fn parse_deed(text: &str) -> Option<String> {
for raw in text.split(|c: char| c.is_whitespace() || matches!(c, '=' | '"' | '\'' | ',')) {
let Some(rest) = raw.strip_prefix("deed-") else {
continue;
};
if !rest.is_empty()
&& rest
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Some(format!("deed-{rest}"));
}
}
None
}
struct Said {
stdout: String,
}
fn run_timed(bin: &str, args: &[&str], env: &[(&str, &str)]) -> anyhow::Result<Said> {
use std::io::Read;
use std::process::{Command, Stdio};
let path = which::which(bin).map_err(|_| anyhow::anyhow!("{bin} not on PATH"))?;
let mut cmd = Command::new(path);
for (key, value) in env {
cmd.env(key, value);
}
crate::git_hooks::hook_child_env(&mut cmd);
let mut child = cmd
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| anyhow::anyhow!("{bin}: {e}"))?;
let started = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => {
let mut stdout = String::new();
let mut stderr = String::new();
if let Some(mut out) = child.stdout.take() {
let _ = out.read_to_string(&mut stdout);
}
if let Some(mut err) = child.stderr.take() {
let _ = err.read_to_string(&mut stderr);
}
if !status.success() {
let why = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
anyhow::bail!("{bin} exited {status}: {why}");
}
return Ok(Said { stdout });
}
Ok(None) if started.elapsed() > COMMAND_DEADLINE => {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!("{bin} exceeded 2s");
}
Ok(None) => std::thread::sleep(Duration::from_millis(10)),
Err(e) => anyhow::bail!("{bin}: {e}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
fn lock() -> crate::EnvLock {
crate::test_env_lock()
}
struct Restore {
pairs: Vec<(String, Option<std::ffi::OsString>)>,
}
impl Restore {
fn new(keys: &[&str]) -> Self {
Self {
pairs: keys
.iter()
.map(|k| ((*k).to_string(), std::env::var_os(k)))
.collect(),
}
}
}
impl Drop for Restore {
fn drop(&mut self) {
for (key, value) in &self.pairs {
unsafe {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}
}
}
#[test]
fn a_commit_names_the_first_open_issue_the_tracker_holds() {
let state = |id: &str| match id {
"ljos-policyd-32fx" | "ljos-xsor" => Some("TODO".to_string()),
"ljos-cjd8" => Some("STARTED".to_string()),
"ljos-done" => Some("DONE".to_string()),
_ => None,
};
let pick = |m: &str| named_open_issue_with(m, state);
assert_eq!(
pick("ljos-policyd-32fx: claim the named id").as_deref(),
Some("ljos-policyd-32fx")
);
assert_eq!(
pick("tidy the hook\n\nFixes ljos-xsor.").as_deref(),
Some("ljos-xsor")
);
assert_eq!(pick("(ljos-cjd8) reopen").as_deref(), Some("ljos-cjd8"));
assert_eq!(
pick("fix-hook: ljos-done then ljos-xsor").as_deref(),
Some("ljos-xsor")
);
assert!(pick("fix-hook: ljos-done and ljos-gone").is_none());
assert!(pick("no id here").is_none());
}
#[test]
fn a_hyphenated_project_id_is_a_vissue_id() {
for id in [
"ljos-policyd-c0jc",
"lgio-1792329616-q4uh",
"probe-0001",
"acme/cli-1a2b",
] {
assert_eq!(parse_issue_id(id).as_deref(), Some(id), "{id}");
}
assert_eq!(
parse_issue_id("created ljos-policyd-c0jc.").as_deref(),
Some("ljos-policyd-c0jc")
);
assert!(parse_issue_id("ljos-policyd-C0JC").is_none());
assert!(parse_issue_id("not-an-id").is_none());
assert!(parse_issue_id("well").is_none());
}
#[test]
fn a_bookkeep_test_does_not_read_the_login_user_rules() {
let home = Path::new("/home/someone");
let got = super::super::resolve_config_base(None, Some(home)).expect("isolated base");
assert_ne!(got, home.join(".config"));
assert!(!got.starts_with(home), "{got:?}");
let rules = super::super::user_rules::rules_path().expect("rules path");
if std::env::var_os("XDG_CONFIG_HOME")
.filter(|value| !value.is_empty())
.is_none()
{
let login = std::env::var_os("HOME").map(PathBuf::from);
if let Some(login) = login {
assert!(!rules.starts_with(&login), "{rules:?} is the login config");
}
}
}
#[test]
fn a_temp_path_is_the_directory_not_a_prefix() {
assert!(path_is_temp(Some(Path::new("/tmp/scratch"))));
assert!(!path_is_temp(Some(Path::new("/tmpfoo"))));
assert!(!path_is_temp(Some(Path::new("/home/ubuntu"))));
assert!(!path_is_temp(None));
}
#[test]
fn ljos_off_and_a_temp_home_suspend_the_guard() {
let _g = lock();
let _saved = Restore::new(&["HOME", "LJOS_OFF"]);
unsafe {
std::env::remove_var("LJOS_OFF");
std::env::set_var("HOME", "/home/ubuntu");
}
assert!(!guard_suspended(), "a normal home is a seat");
unsafe { std::env::set_var("LJOS_OFF", "1") };
assert!(guard_suspended());
unsafe {
std::env::set_var("LJOS_OFF", "yes");
}
assert!(guard_suspended());
unsafe {
std::env::remove_var("LJOS_OFF");
std::env::set_var("HOME", "/tmp/ljos-not-a-seat");
}
assert!(guard_suspended(), "a temp HOME is not a seat");
unsafe { std::env::set_var("HOME", "/tmpfoo") };
assert!(!guard_suspended(), "/tmpfoo is a name, not /tmp");
}
#[test]
fn a_commit_is_the_commit_subcommand() {
assert!(is_git_commit("git commit -m \"readme\""));
assert!(is_git_commit("git -C /src commit -m readme"));
assert!(is_git_commit("FOO=1 git --git-dir=/src/.git commit"));
assert!(!is_git_commit("git commit-tree HEAD"));
assert!(!is_git_commit("git status"));
assert!(!is_git_commit("echo git commit"));
assert!(command_is_apply_patch("apply_patch <<'EOF'\n***\nEOF"));
assert!(!command_is_apply_patch("git apply patch"));
}
#[test]
fn a_prompt_field_does_not_hide_the_commit_command() {
let raw = r#"{"hook_event_name":"PostToolUse","prompt":"please commit the readme","tool_input":{"command":"git commit -m readme"}}"#;
let call = crate::hook_call(raw);
assert!(
is_git_commit(&call.cue),
"the prompt hid the commit: {}",
call.cue
);
}
#[test]
fn owner_pid_stays_put_on_a_tool_hook() {
assert_eq!(next_owner_pid("SessionStart", 0, 10), 10);
assert_eq!(next_owner_pid("UserPromptSubmit", 10, 42), 42);
assert_eq!(next_owner_pid("PostToolUse", 10, 99), 10);
assert_eq!(next_owner_pid("PreToolUse", 10, 99), 10);
assert_eq!(next_owner_pid("Stop", 10, 99), 10);
assert_eq!(next_owner_pid("PostToolUse", 0, 99), 99);
}
#[test]
fn an_old_row_idles_while_the_book_clock_is_fresh() {
let limit = Duration::from_secs(2 * 60 * 60);
let now = 1_000_000_u64;
let mut book = Book {
last_unix: now,
..Book::default()
};
book.repos.insert(
"/repo-a".into(),
RepoBook {
issue: Some("probe-aaaa".into()),
last_unix: 10,
..RepoBook::default()
},
);
book.repos.insert(
"/repo-b".into(),
RepoBook {
issue: Some("probe-bbbb".into()),
last_unix: now,
..RepoBook::default()
},
);
let idle = row_is_idle(
now,
&book,
"/repo-a",
book.repos.get("/repo-a").unwrap(),
limit,
Some("/repo-b"),
);
let fresh = row_is_idle(
now,
&book,
"/repo-b",
book.repos.get("/repo-b").unwrap(),
limit,
Some("/repo-b"),
);
assert!(idle, "repo A was last touched at 10");
assert!(!fresh, "repo B was touched with the book");
let unclocked = RepoBook {
issue: Some("probe-cccc".into()),
..RepoBook::default()
};
assert!(
row_is_idle(now, &book, "/repo-c", &unclocked, limit, Some("/repo-b")),
"a row with no clock is not the checkout that refreshed the book"
);
assert!(
!row_is_idle(now, &book, "/repo-b", &unclocked, limit, Some("/repo-b")),
"the current checkout keeps the fresh book clock"
);
}
#[test]
fn a_prompt_for_another_repo_is_not_a_live_title() {
let _g = lock();
let _saved = Restore::new(&["LJOS_SITTING_IDLE"]);
unsafe { std::env::remove_var("LJOS_SITTING_IDLE") };
let now = 1_000_000_u64;
let mut book = Book {
prompt: "notes: add a second line".into(),
prompt_repo: "/other".into(),
prompt_unix: now,
last_unix: now,
..Book::default()
};
assert!(
!prompt_live_for(&book, "/repo", now),
"the prompt belongs to /other"
);
book.prompt_repo = "/repo".into();
book.repos.insert(
"/repo".into(),
RepoBook {
issue: Some("probe-aaaa".into()),
title: Some(book.prompt.clone()),
last_unix: now,
..RepoBook::default()
},
);
assert!(
prompt_live_for(&book, "/repo", now),
"the current sitting in this checkout keeps its prompt"
);
book.repos.get_mut("/repo").unwrap().last_unix = 10;
assert!(
!prompt_live_for(&book, "/repo", now),
"an earlier sitting's prompt does not title the next claim"
);
}
#[test]
fn a_commit_only_sitting_idles_after_two_hours() {
assert_eq!(
sitting_idle_from(None),
Some(Duration::from_secs(2 * 60 * 60))
);
assert_eq!(
sitting_idle_from(Some("")),
Some(Duration::from_secs(2 * 60 * 60))
);
assert_eq!(sitting_idle_from(Some("0")), None);
assert_eq!(sitting_idle_from(Some("off")), None);
assert_eq!(
sitting_idle_from(Some("30m")),
Some(Duration::from_secs(1800))
);
assert!(idle_due(10_000, 10, Duration::from_secs(2 * 60 * 60)));
assert!(!idle_due(100, 10, Duration::from_secs(2 * 60 * 60)));
assert!(!idle_due(10_000, 0, Duration::from_secs(1)));
}
#[test]
fn a_claim_uses_a_checkout_or_the_last_booked_repo() {
let _g = lock();
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/ljos-place-test");
let _ = std::fs::remove_dir_all(&root);
let repo = root.join("repo");
let other = root.join("other");
let plain = root.join("plain");
std::fs::create_dir_all(&repo).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join(".git"), "gitdir: /dev/null\n").unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
git(&other, &["init", "-q", "-b", "main"]);
let top = git_toplevel(&repo).unwrap();
let other_top = git_toplevel(&other).unwrap();
assert_eq!(
place_for_claim_in(Some(repo.to_str().unwrap()), None, Some(&plain)).as_deref(),
Some(top.as_str())
);
assert_eq!(
place_for_claim_in(Some(plain.to_str().unwrap()), Some(&other_top), Some(&repo)),
None,
"a named path that is not a checkout does not fall back"
);
assert_eq!(
place_for_claim_in(None, Some(&other_top), Some(&plain)).as_deref(),
Some(other_top.as_str())
);
assert_eq!(place_for_claim_in(None, None, Some(&plain)), None);
assert_eq!(
place_for_claim_in(None, None, Some(&repo)).as_deref(),
Some(top.as_str())
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn project_for_matches_the_remote_before_the_basename() {
let _g = lock();
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/ljos-project-test");
let _ = std::fs::remove_dir_all(&root);
let tracker = root.join("tracker");
let repo = root.join("workspace");
std::fs::create_dir_all(tracker.join("Software/ljos")).unwrap();
std::fs::create_dir_all(tracker.join("Software/other")).unwrap();
std::fs::write(
tracker.join("Software/ljos/issues.org"),
"#+REMOTE: https://github.com/acme/ljos.git\n",
)
.unwrap();
std::fs::write(tracker.join("Software/other/issues.org"), "* TODO other\n").unwrap();
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
git(
&repo,
&["remote", "add", "origin", "git@github.com:acme/ljos.git"],
);
assert_eq!(
project_for_at(repo.to_str().unwrap(), None, Some(&tracker)),
"ljos"
);
assert_eq!(
project_for_at(repo.to_str().unwrap(), Some("probe"), Some(&tracker)),
"probe"
);
let bare = root.join("bare");
std::fs::create_dir_all(&bare).unwrap();
git(&bare, &["init", "-q", "-b", "main"]);
assert_eq!(
project_for_at(bare.to_str().unwrap(), None, Some(&tracker)),
"bare",
"no remote and more than one project uses the basename"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_clone_folder_is_not_the_project_when_origin_names_the_repo() {
let _g = lock();
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/ljos-clone-folder");
let _ = std::fs::remove_dir_all(&root);
let tracker = root.join("tracker");
std::fs::create_dir_all(tracker.join("Software/ljos")).unwrap();
std::fs::write(tracker.join("Software/ljos/issues.org"), "* TODO ljos\n").unwrap();
let repo = root.join("bench-w18b-tfn7");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
git(
&repo,
&[
"remote",
"add",
"origin",
"https://github.com/leidarljos/bench.git",
],
);
assert_eq!(
project_for_at(repo.to_str().unwrap(), None, Some(&tracker)),
"bench"
);
assert_eq!(
title_at("", repo.to_str().unwrap(), None, Some(&tracker)),
"Work in bench"
);
assert_eq!(title_at(" ", "", None, Some(&tracker)), "agent work");
let mixed = root.join("orpc-clone");
std::fs::create_dir_all(tracker.join("Software/OmniPotentRPC")).unwrap();
std::fs::write(
tracker.join("Software/OmniPotentRPC/issues.org"),
"* TODO orpc\n",
)
.unwrap();
std::fs::create_dir_all(&mixed).unwrap();
git(&mixed, &["init", "-q", "-b", "main"]);
git(
&mixed,
&[
"remote",
"add",
"origin",
"git@github.com:HaoZeke/omnipotentrpc.git",
],
);
assert_eq!(
project_for_at(mixed.to_str().unwrap(), None, Some(&tracker)),
"OmniPotentRPC",
"an existing project matches whatever its case"
);
let bare = root.join("scratch-folder");
std::fs::create_dir_all(&bare).unwrap();
git(&bare, &["init", "-q", "-b", "main"]);
assert_eq!(
project_for_at(bare.to_str().unwrap(), None, Some(&tracker)),
"scratch-folder"
);
for (url, name) in [
("https://github.com/leidarljos/bench.git", Some("bench")),
(
"git@github.com:leidarljos/ljos-policyd",
Some("ljos-policyd"),
),
(
"https://github.com/HaoZeke/haozeke.github.io/",
Some("haozeke.github.io"),
),
("/srv/git/bench.git", None),
("file:///srv/git/bench.git", None),
("", None),
] {
assert_eq!(remote_repo_name(url).as_deref(), name, "{url}");
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_decoy_note_does_not_take_the_project() {
let _g = lock();
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/ljos-decoy-remote");
let _ = std::fs::remove_dir_all(&root);
let tracker = root.join("tracker");
let repo = root.join("workspace");
std::fs::create_dir_all(tracker.join("Software/ljos")).unwrap();
std::fs::create_dir_all(tracker.join("Software/promo")).unwrap();
std::fs::write(tracker.join("Software/ljos/issues.org"), "* TODO ljos\n").unwrap();
std::fs::write(
tracker.join("Software/promo/issues.org"),
"The notes mention https://github.com/leidarljos/ljos as the tool.\n* TODO promo\n",
)
.unwrap();
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
git(
&repo,
&[
"remote",
"add",
"origin",
"git@github.com:leidarljos/ljos.git",
],
);
assert_eq!(
project_for_at(repo.to_str().unwrap(), None, Some(&tracker)),
"ljos",
"a note that names the url took the project"
);
let atlas = tracker.join("Software/atlas");
std::fs::create_dir_all(&atlas).unwrap();
std::fs::write(atlas.join("issues.org"), "* TODO atlas\n").unwrap();
std::fs::write(
atlas.join("project.toml"),
"remote = \"ssh://git@github.com/leidarljos/ljos.git\"\n",
)
.unwrap();
std::fs::remove_dir_all(tracker.join("Software/ljos")).unwrap();
assert_eq!(
project_for_at(repo.to_str().unwrap(), None, Some(&tracker)),
"atlas",
"the recorded remote lost to a note"
);
let _ = std::fs::remove_dir_all(&root);
}
fn git(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn habitat_bin(dir: &Path) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(
dir.join("vissue"),
r#"#!/usr/bin/env python3
import os, sys, pathlib, re
root = pathlib.Path(os.environ["VISSUE_ROOT"])
log = root / "vissue.log"
args = sys.argv[1:]
with log.open("a") as fh:
fh.write(" ".join(args) + "\n")
cmd, rest = args[0], args[1:]
def files():
return list(root.rglob("issues.org"))
def rewrite(iid, state):
pat = re.compile(r"(\* )[A-Z]+( \[#[A-Z]\] [^\n]*\n:PROPERTIES:\n:ID:\s+" + re.escape(iid) + r"\n)")
for f in files():
text = f.read_text()
new, n = pat.subn(r"\1" + state + r"\2", text, count=1)
if n:
f.write_text(new)
return
sys.exit("no heading for " + iid)
if cmd == "create":
proj, title, i = "agent", "agent work", 0
while i < len(rest):
if rest[i] == "-p" and i + 1 < len(rest):
proj = rest[i + 1]
i += 2
continue
if rest[i] == "-q":
i += 1
continue
title = rest[i]
i += 1
seq = root / ".seq"
n = int(seq.read_text()) + 1 if seq.exists() else 1
seq.write_text(str(n))
iid = f"{proj}-{n:04x}"
dest = root / "Software" / proj
dest.mkdir(parents=True, exist_ok=True)
with (dest / "issues.org").open("a") as fh:
fh.write(f"* TODO [#C] {title}\n:PROPERTIES:\n:ID: {iid}\n:END:\n")
print(iid)
elif cmd == "claim":
rewrite(rest[0], "STARTED")
elif cmd == "update":
state = rest[rest.index("-s") + 1]
rewrite(rest[0], state)
elif cmd == "deed":
iid, acc = rest[0], rest[rest.index("--add") + 1]
needle = f":ID: {iid}\n:END:\n"
for f in files():
text = f.read_text()
if needle in text:
f.write_text(text.replace(needle, needle + f"cited {acc}\n", 1))
sys.exit(0)
sys.exit("no issue " + iid)
elif cmd == "claims":
print("[]")
elif cmd == "show":
print("{}")
else:
sys.exit("unknown " + cmd)
"#,
)
.unwrap();
std::fs::write(
dir.join("deedar"),
r#"#!/usr/bin/env python3
import os, pathlib, sys
root = pathlib.Path(os.environ["VISSUE_ROOT"])
args = sys.argv[1:]
(root / "deedar.log").open("a").write(" ".join(args) + "\n")
ident = None
i = 0
while i < len(args):
if args[i] == "--id" and i + 1 < len(args):
ident = args[i + 1]
if args[i] == "--path" and i + 1 < len(args):
(root / "deedar-body.log").open("a").write(pathlib.Path(args[i + 1]).read_text())
i += 1
if not ident:
seq = root / ".deedseq"
n = int(seq.read_text()) + 1 if seq.exists() else 1
seq.write_text(str(n))
ident = f"deed-{n:04x}"
print(f"id={ident}")
"#,
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
for name in ["vissue", "deedar"] {
std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(0o755))
.unwrap();
}
}
fn scratch() -> (tempfile::TempDir, PathBuf, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let tracker = dir.path().join("tracker");
let repo = dir.path().join("repo");
std::fs::create_dir_all(&tracker).unwrap();
std::fs::create_dir_all(&repo).unwrap();
for root in [&tracker, &repo] {
git(root, &["init", "-q"]);
git(root, &["config", "user.email", "seat@example.invalid"]);
git(root, &["config", "user.name", "seat"]);
git(root, &["config", "core.hooksPath", "/dev/null"]);
git(root, &["config", "commit.gpgsign", "false"]);
git(root, &["config", "core.fsmonitor", "false"]);
}
std::fs::write(repo.join("README.md"), "seed\n").unwrap();
git(&repo, &["add", "README.md"]);
git(&repo, &["commit", "-q", "-m", "seed"]);
habitat_bin(&dir.path().join("bin"));
(dir, tracker, repo)
}
fn arm(dir: &Path, tracker: &Path) -> crate::PathGuard {
let bins = crate::prepend_path(&dir.join("bin"));
unsafe {
std::env::set_var("VISSUE_ROOT", tracker);
std::env::set_var("VISSUE_NO_ROUTE", "1");
std::env::set_var("LJOS_TRACKER_GIT", "commit");
std::env::set_var("LJOS_PROJECT", "probe");
std::env::set_var("XDG_RUNTIME_DIR", dir.join("run"));
std::env::set_var("LJOS_IN_HOOK", "1");
std::env::set_var("PACKSET_URL", "off");
std::env::remove_var("ISSUE_ROOT");
std::env::remove_var("LJOS_OFF");
std::env::remove_var("LJOS_SESSION_ID");
}
bins
}
fn payload(event: &str, session: &str, cwd: &Path, extra: Value) -> (String, crate::HookCall) {
let mut raw = serde_json::json!({
"hook_event_name": event,
"session_id": session,
"cwd": cwd,
});
if let (Value::Object(dst), Value::Object(src)) = (&mut raw, extra) {
for (k, v) in src {
dst.insert(k, v);
}
}
let text = raw.to_string();
let call = crate::hook_call(&text);
(text, call)
}
fn creates(tracker: &Path) -> usize {
let log = std::fs::read_to_string(tracker.join("vissue.log")).unwrap_or_default();
log.matches("\ncreate").count() + usize::from(log.starts_with("create"))
}
fn assert_redirect_claims(repo: &Path, tracker: &Path) {
let cwd = repo.display().to_string();
let repo_s = cwd.clone();
let claim = |session: &str, raw: Value, file: &str, want: bool| {
let text = raw.to_string();
let call = crate::hook_call(&text);
assert!(
matches!(call.event.as_str(), "PreToolUse" | "PostToolUse"),
"{session}: {}",
call.event
);
assert!(
call.cue.contains(file),
"{session} dropped the redirect: {}",
call.cue
);
assert_eq!(
writes_worktree(&repo_s, &call.cue),
want,
"{session} worktree {}",
call.cue
);
let before = creates(tracker);
let _ = observe(&text, &call);
assert_eq!(
creates(tracker) > before,
want,
"{session} claim {before} -> {}",
creates(tracker)
);
};
claim(
"claude-pre",
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "redir-claude-pre",
"cwd": cwd,
"tool_name": "Bash",
"tool_input": {"command": "echo hi > kebab_claude.py"}
}),
"kebab_claude.py",
true,
);
claim(
"opencode-post",
serde_json::json!({
"hook_event_name": "PostToolUse",
"session_id": "redir-opencode-post",
"cwd": cwd,
"tool_name": "Bash",
"tool_input": {"command": "cat > fresh_opencode.txt"},
"tool_response": {"exit_code": 0}
}),
"fresh_opencode.txt",
true,
);
claim(
"codex-pre",
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "redir-codex-pre",
"turn_id": "t-redir-pre",
"cwd": cwd,
"tool_name": "Bash",
"tool_input": {"command": "echo hi > kebab_codex.py"}
}),
"kebab_codex.py",
true,
);
claim(
"codex-post",
serde_json::json!({
"hook_event_name": "PostToolUse",
"session_id": "redir-codex-post",
"turn_id": "t-redir-post",
"cwd": cwd,
"tool_name": "Bash",
"tool_input": {"command": "cat > fresh_codex.txt"},
"tool_response": {"exit_code": 0}
}),
"fresh_codex.txt",
true,
);
claim(
"agy-pre",
serde_json::json!({
"conversationId": "redir-agy-pre",
"cwd": cwd,
"toolCall": {"name": "run_command", "args": {"CommandLine": "echo hi > kebab_agy.py"}}
}),
"kebab_agy.py",
true,
);
claim(
"agy-post",
serde_json::json!({
"conversationId": "redir-agy-post",
"cwd": cwd,
"error": "",
"toolCall": {"name": "run_command", "args": {"CommandLine": "cat > fresh_agy.txt"}}
}),
"fresh_agy.txt",
true,
);
claim(
"cursor-shell",
serde_json::json!({
"cursor_version": "3.2.0",
"conversation_id": "redir-cursor-shell",
"hook_event_name": "beforeShellExecution",
"command": "echo hi > kebab_cursor.py",
"cwd": cwd
}),
"kebab_cursor.py",
true,
);
claim(
"cursor-post",
serde_json::json!({
"cursor_version": "3.2.0",
"conversation_id": "redir-cursor-post",
"hook_event_name": "postToolUse",
"cwd": cwd,
"tool_name": "Shell",
"tool_input": {"command": "cat > fresh_cursor.txt"}
}),
"fresh_cursor.txt",
true,
);
claim(
"grok-pre",
serde_json::json!({
"hookEventName": "preToolUse",
"sessionId": "redir-grok-pre",
"cwd": cwd,
"toolName": "run_terminal_command",
"toolInput": {"command": "echo hi > kebab_grok.py"}
}),
"kebab_grok.py",
true,
);
claim(
"grok-post",
serde_json::json!({
"hookEventName": "postToolUse",
"sessionId": "redir-grok-post",
"cwd": cwd,
"toolInput": {"command": "cat > fresh_grok.txt"}
}),
"fresh_grok.txt",
true,
);
claim(
"gemini-pre",
serde_json::json!({
"hook_event_name": "pre_tool_call",
"session_id": "redir-gem-pre",
"cwd": cwd,
"tool_name": "terminal",
"tool_input": {"command": "echo hi>kebab_gem.py"}
}),
"kebab_gem.py",
true,
);
claim(
"gemini-post",
serde_json::json!({
"hook_event_name": "post_tool_call",
"session_id": "redir-gem-post",
"cwd": cwd,
"tool_name": "terminal",
"tool_input": {"command": "echo more >> fresh_gem.txt"}
}),
"fresh_gem.txt",
true,
);
claim(
"outside-tmp",
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "redir-outside-tmp",
"cwd": cwd,
"tool_name": "Bash",
"tool_input": {"command": "echo hi > /tmp/ljos-not-in-the-worktree.txt"}
}),
"/tmp/ljos-not-in-the-worktree.txt",
false,
);
claim(
"outside-parent",
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "redir-outside-parent",
"cwd": cwd,
"tool_name": "Bash",
"tool_input": {"command": "echo hi > ../outside.txt"}
}),
"../outside.txt",
false,
);
claim(
"outside-home",
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "redir-outside-home",
"cwd": cwd,
"tool_name": "Bash",
"tool_input": {"command": "echo hi > \"$HOME/.bashrc\""}
}),
"$HOME/.bashrc",
false,
);
claim(
"outside-tilde",
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "redir-outside-tilde",
"cwd": cwd,
"tool_name": "Bash",
"tool_input": {"command": "echo hi > ~/notes.txt"}
}),
"~/notes.txt",
false,
);
}
#[test]
fn an_ending_sitting_opens_no_row_in_a_checkout_it_never_worked_in() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let session = "seat:probe-guard";
for event in ["Stop", "SessionEnd"] {
let (raw, call) = payload(event, session, &repo, Value::Null);
let _ = observe(&raw, &call);
}
let book = load_book(&book_path(session));
assert!(
book.repos.is_empty(),
"no row with a null issue: {:?}",
book.repos.keys().collect::<Vec<_>>()
);
assert_eq!(creates(&tracker), 0);
}
#[test]
fn a_shell_redirect_into_the_work_tree_claims_on_that_call() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
assert_redirect_claims(&repo, &tracker);
}
#[test]
fn a_session_files_a_claim_a_deed_and_a_finish_without_being_asked() {
let _g = lock();
let _saved = Restore::new(&[
"PATH",
"VISSUE_ROOT",
"VISSUE_NO_ROUTE",
"LJOS_TRACKER_GIT",
"LJOS_PROJECT",
"XDG_RUNTIME_DIR",
"LJOS_IN_HOOK",
"PACKSET_URL",
"ISSUE_ROOT",
"LJOS_OFF",
"LJOS_SESSION_ID",
]);
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let session = "book-session-1";
let (prompt_raw, prompt_call) = payload(
"UserPromptSubmit",
session,
&repo,
serde_json::json!({"prompt": "Add a readme line"}),
);
assert!(
!prompt_raw.contains("ljos"),
"the prompt the test sends does not name the seat"
);
assert!(observe(&prompt_raw, &prompt_call).0.is_empty());
let (edit_raw, edit_call) = payload(
"PreToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Edit", "tool_input": {"file_path": "README.md"}}),
);
let _ = observe(&edit_raw, &edit_call);
let (start_raw, start_call) = payload("SessionStart", session, &repo, Value::Null);
let (recall, _) = observe(&start_raw, &start_call);
assert!(recall.contains("Recall for probe-"), "{recall}");
assert!(recall.contains("Add a readme line"), "{recall}");
let (commit_raw, commit_call) = payload(
"PreToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m \"readme\""}}),
);
let _ = observe(&commit_raw, &commit_call);
let deedar_before = std::fs::read_to_string(tracker.join("deedar.log")).unwrap_or_default();
assert!(
deedar_before.is_empty(),
"the pre-hook does not cite a commit that has not run\n{deedar_before}"
);
commit_file(&repo, "README.md", "hello from the session\n");
let (post_raw, post_call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m \"readme\""}}),
);
let _ = observe(&post_raw, &post_call);
let _ = observe(&post_raw, &post_call);
let (stop_raw, stop_call) = payload("Stop", session, &repo, Value::Null);
let _ = observe(&stop_raw, &stop_call);
let log_before = git_log(&tracker);
let _ = observe(&stop_raw, &stop_call);
let (end_raw, end_call) = payload("SessionEnd", session, &repo, Value::Null);
let _ = observe(&end_raw, &end_call);
let log = git_log(&tracker);
assert_eq!(log, log_before, "a second stop does not finish again");
assert!(log.contains("claimed"), "{log}");
assert!(log.contains("cited a deed"), "{log}");
assert!(log.contains("finished"), "{log}");
assert_eq!(log.matches("finished").count(), 1, "{log}");
let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
assert!(org.contains("Add a readme line"), "{org}");
let sha = String::from_utf8(
std::process::Command::new("git")
.arg("-C")
.arg(&repo)
.args(["rev-parse", "HEAD"])
.output()
.unwrap()
.stdout,
)
.unwrap();
let sha = sha.trim();
assert!(org.contains(&format!("deed-file-commit-{sha}")), "{org}");
let body = std::fs::read_to_string(tracker.join("deedar-body.log")).unwrap();
assert!(body.contains(sha), "{body}");
assert_eq!(
std::fs::read_to_string(tracker.join("deedar.log"))
.unwrap()
.lines()
.count(),
1
);
let vissue_log = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
let creates =
vissue_log.matches("\ncreate").count() + usize::from(vissue_log.starts_with("create"));
assert_eq!(creates, 1, "{vissue_log}");
let started = std::time::Instant::now();
let _ = observe(&edit_raw, &edit_call);
assert!(
started.elapsed() < Duration::from_millis(100),
"warm edit took {:?}",
started.elapsed()
);
let after = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
assert_eq!(after, vissue_log, "a warm edit does not ask vissue");
}
#[test]
fn a_missing_tracker_does_not_block_the_next_edit() {
let _g = lock();
let _saved = Restore::new(&[
"PATH",
"VISSUE_ROOT",
"XDG_RUNTIME_DIR",
"LJOS_IN_HOOK",
"PACKSET_URL",
"LJOS_OFF",
"LJOS_PROJECT",
]);
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q"]);
let _bins = crate::set_path(crate::path_without(&["vissue"]));
unsafe {
std::env::set_var("XDG_RUNTIME_DIR", dir.path().join("run"));
std::env::set_var("LJOS_IN_HOOK", "1");
std::env::set_var("PACKSET_URL", "off");
std::env::remove_var("LJOS_OFF");
std::env::set_var("VISSUE_ROOT", dir.path().join("tracker"));
std::env::set_var("LJOS_PROJECT", "probe");
}
let (raw, call) = payload(
"PreToolUse",
"book-missing",
&repo,
serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "a.txt"}, "prompt": "Write the notes"}),
);
let (text, _) = observe(&raw, &call);
assert!(text.is_empty());
let started = std::time::Instant::now();
let (again, _) = observe(&raw, &call);
assert!(again.is_empty());
assert!(
started.elapsed() < Duration::from_millis(100),
"second edit took {:?}",
started.elapsed()
);
let logged =
std::fs::read_to_string(dir.path().join("run/ljos/book.log")).unwrap_or_default();
assert_eq!(logged.matches("claim:").count(), 1, "{logged}");
}
#[test]
fn the_server_claims_on_a_write_and_finishes_when_it_stops() {
let _g = lock();
let _saved = Restore::new(&[
"PATH",
"VISSUE_ROOT",
"VISSUE_NO_ROUTE",
"LJOS_TRACKER_GIT",
"LJOS_PROJECT",
"XDG_RUNTIME_DIR",
"LJOS_IN_HOOK",
"PACKSET_URL",
"LJOS_SESSION_ID",
"LJOS_OFF",
]);
let (dir, tracker, _repo) = scratch();
let _bins = arm(dir.path(), &tracker);
unsafe {
std::env::set_var("LJOS_SESSION_ID", "mcp-session-9");
}
assert!(!mcp_tool_writes("ljos_claim"));
assert!(!mcp_tool_writes("ljos_file"));
assert!(!mcp_tool_writes("ljos_finish"));
assert!(mcp_tool_writes("ljos_remember"));
let mut args = serde_json::Map::new();
args.insert(
"text".into(),
Value::String("the readme stays short".into()),
);
before_mcp_write("ljos_remember", Some(&args));
before_mcp_write("ljos_claim", Some(&args));
let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
assert!(org.contains("the readme stays short"), "{org}");
mcp_shutdown();
let log = git_log(&tracker);
assert!(log.contains("claimed"), "{log}");
assert!(log.contains("finished"), "{log}");
mcp_shutdown();
assert_eq!(git_log(&tracker), log);
let before = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
args.insert("text".into(), Value::String("the notes stay dated".into()));
before_mcp_write("ljos_remember", Some(&args));
let after = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
let creates =
|log: &str| log.matches("\ncreate").count() + usize::from(log.starts_with("create"));
assert_eq!(
creates(&after),
creates(&before) + 1,
"a write after shutdown files a new claim\n{after}"
);
}
#[test]
fn a_per_call_server_keeps_one_sitting_for_the_seat() {
let _g = lock();
let _saved = Restore::new(&[
"PATH",
"VISSUE_ROOT",
"VISSUE_NO_ROUTE",
"LJOS_TRACKER_GIT",
"LJOS_PROJECT",
"XDG_RUNTIME_DIR",
"LJOS_IN_HOOK",
"PACKSET_URL",
"LJOS_SESSION_ID",
"LJOS_SEAT",
"LJOS_MCP_IDLE",
"LJOS_OFF",
]);
let (dir, tracker, _repo) = scratch();
let _bins = arm(dir.path(), &tracker);
unsafe {
std::env::set_var("LJOS_SEAT", "Disk Saver");
std::env::set_var("LJOS_SESSION_ID", "mcp-call-1");
std::env::set_var("LJOS_MCP_IDLE", "30m");
}
let mut args = serde_json::Map::new();
args.insert("text".into(), Value::String("note on rtrash-adz0".into()));
before_mcp_write("ljos_note", Some(&args));
unsafe {
std::env::set_var("LJOS_SESSION_ID", "mcp-call-2");
}
args.insert(
"text".into(),
Value::String("still noting rtrash-adz0".into()),
);
before_mcp_write("ljos_note", Some(&args));
let log = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
let creates =
|text: &str| text.matches("\ncreate").count() + usize::from(text.starts_with("create"));
assert_eq!(
creates(&log),
1,
"a second call from the same seat does not file another claim\n{log}"
);
assert!(
!git_log(&tracker).contains("finished"),
"the per-call exit has not happened"
);
stamp_sitting(&sitting_key(), 1_000);
resume_seat_at(1_000 + 30 * 60);
let finished = git_log(&tracker);
assert!(
finished.contains("finished"),
"the next start closes the idle sitting\n{finished}"
);
before_mcp_write("ljos_note", Some(&args));
let after = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
assert_eq!(
creates(&after),
2,
"a write after the idle close files the next claim\n{after}"
);
}
#[test]
fn idle_defaults_to_half_an_hour_and_zero_turns_it_off() {
assert_eq!(idle_limit_from(None), Some(Duration::from_secs(1800)));
assert_eq!(idle_limit_from(Some("")), Some(Duration::from_secs(1800)));
assert_eq!(idle_limit_from(Some("0")), None);
assert_eq!(idle_limit_from(Some("OFF")), None);
assert_eq!(idle_limit_from(Some("15m")), Some(Duration::from_secs(900)));
assert_eq!(
idle_limit_from(Some("nope")),
Some(Duration::from_secs(1800))
);
assert!(idle_due(2_000, 100, Duration::from_secs(1800)));
assert!(!idle_due(1_000, 100, Duration::from_secs(1800)));
assert!(!idle_due(5_000, 0, Duration::from_secs(1)));
}
#[test]
fn an_empty_guard_is_refused() {
let err = crate::guard_command(" ").unwrap_err().to_string();
assert!(err.contains("pass the command"), "{err}");
assert!(crate::command_refused("deny\tTCB required\n"));
assert!(crate::command_refused("ask: wipe\n"));
assert!(!crate::command_refused("echo hi\n"));
assert!(!crate::command_refused("the person may ask later\n"));
}
#[test]
fn a_call_seat_must_be_one_the_tracker_knows() {
let _g = lock();
let _saved = Restore::new(&["VISSUE_ROOT", "ISSUE_ROOT", "VISSUE_NO_ROUTE", "LJOS_SEAT"]);
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("vissue.toml"),
"seats = [\"inky\", \"Disk Saver\", \"saddle\"]\n",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("Software/probe")).unwrap();
std::fs::write(
dir.path().join("Software/probe/issues.org"),
"* STARTED [#C] held\n:PROPERTIES:\n:ID: probe-abcd\n:CLAIMED_BY: eessi@box\n:END:\n",
)
.unwrap();
unsafe {
std::env::set_var("VISSUE_ROOT", dir.path());
std::env::set_var("VISSUE_NO_ROUTE", "1");
std::env::remove_var("ISSUE_ROOT");
std::env::remove_var("LJOS_SEAT");
}
let err = crate::resolve_call_seat(None).unwrap_err().to_string();
assert!(err.contains("LJOS_SEAT"), "{err}");
unsafe {
std::env::set_var("LJOS_SEAT", "inky");
}
assert_eq!(crate::resolve_call_seat(None).unwrap().seat, "inky");
assert_eq!(
crate::resolve_call_seat(Some("Disk Saver")).unwrap().seat,
"disk-saver"
);
let err = crate::resolve_call_seat(Some("sadle"))
.unwrap_err()
.to_string();
assert!(err.contains("Did you mean saddle"), "{err}");
assert!(err.contains("inky"), "{err}");
assert!(err.contains("disk-saver"), "{err}");
assert!(err.contains("eessi"), "{err}");
}
#[test]
fn the_mcp_seat_follows_the_hook_seat() {
let _g = lock();
let _saved = Restore::new(&[
"HOME",
"LJOS_SEAT",
"VISSUE_AGENT",
"VISSUE_ROOT",
"ISSUE_ROOT",
"VISSUE_NO_ROUTE",
"XDG_RUNTIME_DIR",
]);
let home = tempfile::tempdir().unwrap();
let config = home.path().join(".config/ljos");
std::fs::create_dir_all(&config).unwrap();
std::fs::write(config.join("env"), "LJOS_SEAT=free-claude\n").unwrap();
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("vissue.toml"),
"seats = [\"free-claude\", \"inky\"]\n",
)
.unwrap();
unsafe {
std::env::set_var("HOME", home.path());
std::env::set_var("VISSUE_ROOT", dir.path());
std::env::set_var("VISSUE_NO_ROUTE", "1");
std::env::remove_var("ISSUE_ROOT");
std::env::remove_var("LJOS_SEAT");
std::env::remove_var("VISSUE_AGENT");
}
assert_eq!(
crate::resolve_call_seat(Some("claude")).unwrap().seat,
"free-claude",
"a harness name does not replace the seat in the config"
);
assert_eq!(crate::resolve_call_seat(None).unwrap().seat, "free-claude");
unsafe {
std::env::set_var("LJOS_SEAT", "free-claude");
}
assert_eq!(crate::resolve_call_seat(Some("inky")).unwrap().seat, "inky");
unsafe {
std::env::set_var("LJOS_SEAT", "inky");
}
assert_eq!(
crate::resolve_call_seat(Some("claude")).unwrap().seat,
"inky",
"LJOS_SEAT wins over the harness id"
);
}
#[test]
fn a_tracker_with_no_seats_registers_the_harness_seat() {
let _g = lock();
let _saved = Restore::new(&[
"VISSUE_ROOT",
"ISSUE_ROOT",
"VISSUE_NO_ROUTE",
"LJOS_SEAT",
"VISSUE_AGENT",
"XDG_RUNTIME_DIR",
"HOME",
]);
let dir = tempfile::tempdir().unwrap();
let run = dir.path().join("run");
std::fs::create_dir_all(&run).unwrap();
std::fs::write(dir.path().join("vissue.toml"), "prefix = \"Software\"\n").unwrap();
unsafe {
std::env::set_var("VISSUE_ROOT", dir.path());
std::env::set_var("VISSUE_NO_ROUTE", "1");
std::env::set_var("XDG_RUNTIME_DIR", &run);
std::env::remove_var("ISSUE_ROOT");
std::env::remove_var("LJOS_SEAT");
std::env::remove_var("VISSUE_AGENT");
std::env::set_var("HOME", dir.path().join("home"));
}
let seat = crate::resolve_call_seat(Some("ljosbot")).unwrap();
assert_eq!(seat.seat, "ljosbot");
let text = std::fs::read_to_string(dir.path().join("vissue.toml")).unwrap();
assert!(text.contains("ljosbot"), "{text}");
assert!(text.contains("seats"), "{text}");
assert!(text.contains("Software"), "{text}");
let log = std::fs::read_to_string(run.join("ljos/book.log")).unwrap();
assert!(log.contains("registered"), "{log}");
assert!(log.contains("ljosbot"), "{log}");
}
#[test]
fn an_issue_named_none_is_the_one_this_session_claimed() {
let _g = lock();
let _saved = Restore::new(&[
"PATH",
"VISSUE_ROOT",
"VISSUE_NO_ROUTE",
"LJOS_TRACKER_GIT",
"LJOS_PROJECT",
"XDG_RUNTIME_DIR",
"LJOS_IN_HOOK",
"PACKSET_URL",
"ISSUE_ROOT",
"LJOS_OFF",
"LJOS_SESSION_ID",
"LJOS_HARNESS_SESSION",
"LJOS_SEAT",
]);
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
unsafe {
std::env::remove_var("LJOS_SEAT");
std::env::remove_var("LJOS_HARNESS_SESSION");
}
let session = "book-none";
let (edit_raw, edit_call) = payload(
"PreToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "README.md"}}),
);
let _ = observe(&edit_raw, &edit_call);
let issue = canonical_issue(Some("none"), Some(session)).expect("the claim");
assert!(issue.starts_with("probe-"), "{issue}");
assert_eq!(
canonical_issue(Some("NONE"), Some(session)).as_deref(),
Some(issue.as_str())
);
assert_eq!(
canonical_issue(Some("null"), Some(session)).as_deref(),
Some(issue.as_str())
);
assert_eq!(
canonical_issue(Some("other-1"), Some(session)).as_deref(),
Some("other-1")
);
let line = crate::session_start_for(Some(session));
assert!(
line.contains(&format!("This session's issue is {issue}.")),
"{line}"
);
}
#[test]
fn ljos_start_claims_the_task() {
let _g = lock();
let _saved = Restore::new(&[
"PATH",
"VISSUE_ROOT",
"VISSUE_NO_ROUTE",
"LJOS_TRACKER_GIT",
"LJOS_PROJECT",
"XDG_RUNTIME_DIR",
"LJOS_IN_HOOK",
"PACKSET_URL",
"ISSUE_ROOT",
"LJOS_OFF",
"LJOS_SESSION_ID",
]);
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
unsafe {
std::env::set_var("LJOS_SESSION_ID", "mcp-start-1");
}
let err = start_work(None, None, " ").unwrap_err();
assert!(err.contains("one sentence"), "{err}");
let said = start_work(None, Some(repo.to_str().unwrap()), "Add a readme line").unwrap();
assert!(said.contains("claimed probe-"), "{said}");
assert!(said.contains("Add a readme line"), "{said}");
let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
assert!(org.contains("Add a readme line"), "{org}");
let held = open_issue().expect("the start filed an issue");
assert!(said.contains(&held), "{said}");
mcp_shutdown();
assert!(git_log(&tracker).contains("finished"));
assert!(open_issue().is_none());
}
#[test]
fn a_write_after_ljos_start_keeps_the_started_issue() {
let _g = lock();
let _saved = Restore::new(&[
"PATH",
"VISSUE_ROOT",
"VISSUE_NO_ROUTE",
"LJOS_TRACKER_GIT",
"LJOS_PROJECT",
"XDG_RUNTIME_DIR",
"LJOS_IN_HOOK",
"PACKSET_URL",
"ISSUE_ROOT",
"LJOS_OFF",
"LJOS_SESSION_ID",
"LJOS_HARNESS_SESSION",
"LJOS_SEAT",
]);
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
unsafe {
std::env::remove_var("LJOS_SEAT");
std::env::remove_var("LJOS_HARNESS_SESSION");
std::env::set_var("LJOS_SESSION_ID", "mcp-start-held");
}
let said = start_work(
None,
Some(repo.to_str().unwrap()),
"Add slugify and its test",
)
.unwrap();
let started = said
.lines()
.next()
.and_then(|line| line.strip_prefix("claimed "))
.expect("the start claimed")
.to_string();
assert_eq!(creates(&tracker), 1);
let session = "harness-after-start";
let (raw, call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "slug.py"}}),
);
let _ = observe(&raw, &call);
assert_eq!(creates(&tracker), 1, "the write filed a second issue");
let book = load_book(&book_path(session));
let held: Vec<_> = book
.repos
.values()
.filter_map(|r| r.issue.clone())
.collect();
assert_eq!(held, vec![started.clone()]);
assert!(
load_book(&book_path(&sitting_key()))
.repos
.values()
.all(|r| r.issue.is_none()),
"the start's book still holds the issue"
);
mcp_shutdown();
assert!(
!git_log(&tracker).contains("finished"),
"{}",
git_log(&tracker)
);
let (stop_raw, stop_call) = payload("Stop", session, &repo, serde_json::json!({}));
let _ = observe(&stop_raw, &stop_call);
let log = git_log(&tracker);
assert_eq!(
log.matches(&format!("{started} finished")).count(),
1,
"{log}"
);
}
#[test]
fn a_write_does_not_take_another_holders_issue() {
let _g = lock();
let _saved = Restore::new(&[
"PATH",
"VISSUE_ROOT",
"VISSUE_NO_ROUTE",
"LJOS_TRACKER_GIT",
"LJOS_PROJECT",
"XDG_RUNTIME_DIR",
"LJOS_IN_HOOK",
"PACKSET_URL",
"ISSUE_ROOT",
"LJOS_OFF",
"LJOS_SESSION_ID",
"LJOS_HARNESS_SESSION",
"LJOS_SEAT",
]);
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
unsafe {
std::env::remove_var("LJOS_SEAT");
std::env::remove_var("LJOS_HARNESS_SESSION");
std::env::set_var("LJOS_SESSION_ID", "mcp-start-other");
}
let said = start_work(
None,
Some(repo.to_str().unwrap()),
"Add slugify and its test",
)
.unwrap();
let started = said
.lines()
.next()
.and_then(|line| line.strip_prefix("claimed "))
.expect("the start claimed")
.to_string();
let org_path = tracker.join("Software/probe/issues.org");
let org = std::fs::read_to_string(&org_path).unwrap();
let id_line = format!(":ID: {started}\n");
std::fs::write(
&org_path,
org.replace(
&id_line,
&format!("{id_line}:CLAIMED_BY: someone-else@zz9\n"),
),
)
.unwrap();
let session = "harness-other-holder";
let (raw, call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "slug.py"}}),
);
let _ = observe(&raw, &call);
assert_eq!(
creates(&tracker),
2,
"the write took another holder's issue"
);
let book = load_book(&book_path(session));
assert!(book
.repos
.values()
.all(|r| r.issue.as_deref() != Some(started.as_str())));
}
#[test]
fn a_second_commit_is_a_second_deed_and_a_failed_one_is_not() {
let _g = lock();
let _saved = Restore::new(&[
"PATH",
"VISSUE_ROOT",
"VISSUE_NO_ROUTE",
"LJOS_TRACKER_GIT",
"LJOS_PROJECT",
"XDG_RUNTIME_DIR",
"LJOS_IN_HOOK",
"PACKSET_URL",
"ISSUE_ROOT",
"LJOS_OFF",
"LJOS_SESSION_ID",
"LJOS_HOLD",
"LJOS_SEAT",
]);
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
unsafe {
std::env::set_var("LJOS_SEAT", "probe-seat");
std::env::set_var("LJOS_HOLD", "book-holder");
}
let run = dir.path().join("run/ljos");
std::fs::create_dir_all(&run).unwrap();
std::fs::write(
run.join("hold-book-holder"),
"book-holder\nprobe-seat\n999999\nbash\n2026-01-01T00:00:00Z\nother-9999\n-\n",
)
.unwrap();
let session = "book-two-commits";
let (prompt_raw, prompt_call) = payload(
"UserPromptSubmit",
session,
&repo,
serde_json::json!({"prompt": "Add a readme line"}),
);
let _ = observe(&prompt_raw, &prompt_call);
let (edit_raw, edit_call) = payload(
"PreToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "README.md"}}),
);
let _ = observe(&edit_raw, &edit_call);
let vissue = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
assert!(!vissue.contains("other-9999"), "{vissue}");
assert!(vissue.contains("create"), "{vissue}");
fn commit(repo: &Path, name: &str) -> String {
std::fs::write(repo.join(name), "x\n").unwrap();
git(repo, &["add", name]);
git(repo, &["commit", "-q", "-m", name]);
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
let first = commit(&repo, "one.txt");
let (post, post_call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m one"}, "tool_response": {"exit_code": 0}}),
);
let _ = observe(&post, &post_call);
let failed = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m boom"}, "tool_response": {"exit_code": 1}}),
);
let _ = observe(&failed.0, &failed.1);
let second = commit(&repo, "two.txt");
let (post2, post2_call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m two"}}),
);
let _ = observe(&post2, &post2_call);
let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
assert!(org.contains(&format!("deed-file-commit-{first}")), "{org}");
assert!(org.contains(&format!("deed-file-commit-{second}")), "{org}");
assert!(!org.contains("other-9999"), "{org}");
let deeds = std::fs::read_to_string(tracker.join("deedar.log")).unwrap();
assert_eq!(deeds.lines().count(), 2, "{deeds}");
let (redir, redir_call) = payload(
"PreToolUse",
"book-redirect",
&repo,
serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "echo hi > README.md"}}),
);
let before = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
let _ = observe(&redir, &redir_call);
let after = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
assert!(
after.matches("create").count() > before.matches("create").count(),
"a redirect onto a tracked file claims\n{after}"
);
let text = lesson_text(
"Add a readme line",
"probe-0001",
&[first.clone(), second],
&["git commit exited non-zero".into()],
&["git-force-push: a force push rewrites history".into()],
);
let sentences: Vec<&str> = text.split(". ").collect();
assert!(sentences.len() <= 2, "{text}");
for sentence in text.split_inclusive('.') {
let sentence = sentence.trim();
if sentence.is_empty() {
continue;
}
assert!(sentence.split_whitespace().count() < 30, "{sentence}");
}
assert!(text.contains("probe-0001"), "{text}");
assert!(text.contains(&first[..7]), "{text}");
assert!(text.contains("git-force-push"), "{text}");
assert!(text.contains("Commit failed"), "{text}");
assert_lesson(&text);
}
#[test]
fn lesson_post_names_status_and_passes_the_prose_gate() {
let hard =
"Implement asynchronous reconciliation of cryptographic attestations for the seat";
let text = lesson_text(
hard,
"probe-0001",
&["abcdef0123456789".into()],
&["the writer said the sentence was far too long to store".into()],
&["the guard asked before a force push".into()],
);
assert_lesson(&text);
assert!(text.contains("probe-0001"), "{text}");
assert!(text.contains("abcdef0"), "{text}");
assert!(text.contains("Implement"), "{text}");
assert!(text.contains("seat"), "the title does not vanish: {text}");
assert!(text.contains("Writer failed"), "{text}");
assert!(
!text.contains(hard),
"a title the gate would refuse is shortened: {text}"
);
let plain = lesson_text(
"Add a readme line",
"probe-0001",
&[],
&["readme test failed".into()],
&["git push --force origin main".into()],
);
assert_lesson(&plain);
assert!(
plain.contains("readme") && plain.contains("line"),
"the words the next prompt recalls stay: {plain}"
);
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let refused = serve_pack(|_| {
(
400,
r#"{"error":"readability grade 19.4 exceeds 14"}"#.into(),
)
});
set_var("PACKSET_URL", &refused);
set_var("PACKSET_TIMEOUT_MS", "2000");
let session = "myow-refused";
prompt_and_edit(&repo, session, hard);
commit_file(&repo, "README.md", "hello from the lesson\n");
let (post, post_call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m land"}, "tool_response": {"exit_code": 0}}),
);
let _ = observe(&post, &post_call);
let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
let _ = observe(&stop, &stop_call);
let log = std::fs::read_to_string(dir.path().join("run/ljos/book.log")).unwrap();
assert!(log.contains("Remember: POST /v1/atoms failed"), "{log}");
assert!(log.contains("400:"), "{log}");
assert!(log.contains("readability grade 19.4 exceeds 14"), "{log}");
let refused_book = book_json(dir.path(), session);
assert_eq!(
refused_book["repos"]
.as_object()
.unwrap()
.values()
.next()
.unwrap()["lesson"],
false,
"{refused_book}"
);
let texts = Arc::new(Mutex::new(Vec::<String>::new()));
let kept = serve_pack({
let texts = Arc::clone(&texts);
move |req| keep_atom(req, &texts)
});
set_var("PACKSET_URL", &kept);
let session = "myow-kept";
prompt_and_edit(&repo, session, hard);
let sha = commit_file(&repo, "NOTES.md", "hello from the kept lesson\n");
let (post, post_call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m land"}, "tool_response": {"exit_code": 0}}),
);
let _ = observe(&post, &post_call);
let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
let _ = observe(&stop, &stop_call);
let stored = texts.lock().unwrap().clone();
let lesson = stored.last().expect("the lesson was posted");
assert_lesson(lesson);
assert!(lesson.contains(&sha[..7]), "{lesson}");
assert!(!lesson.contains(hard), "{lesson}");
let kept_book = book_json(dir.path(), session);
assert_eq!(
kept_book["repos"]
.as_object()
.unwrap()
.values()
.next()
.unwrap()["lesson"],
true,
"{kept_book}"
);
}
#[test]
fn slugify_lesson_keeps_the_word_and_the_rule_for_the_next_session() {
let title = "In this git repo add a function slugify";
let ask = "git-force-push: a force push rewrites history";
let asked = lesson_text(title, "probe-0001", &[], &[], &[ask.into()]);
assert_lesson(&asked);
assert!(
asked.contains("The guard asked about git-force-push."),
"{asked}"
);
assert!(
asked.contains("function") && asked.contains("slugify"),
"{asked}"
);
assert!(
asked.split_whitespace().any(|word| word == "git"),
"{asked}"
);
for gone in ["this", "repo", "add"] {
assert!(
!asked
.split(|c: char| !c.is_alphanumeric())
.any(|word| word.eq_ignore_ascii_case(gone)),
"{gone} is filler and stays out: {asked}"
);
}
let faded = lesson_text("In this repo", "probe-0001", &[], &[], &[ask.into()]);
assert_lesson(&faded);
assert!(
faded.contains("repo"),
"a filler title still leaves a word: {faded}"
);
assert!(!faded.starts_with("Work "), "{faded}");
let crowded = lesson_text(
"slugify cryptography reconciliation asynchronous attestation implementation verification",
"probe-0001",
&[],
&[],
&[ask.into()],
);
assert_lesson(&crowded);
assert!(
crowded.contains("slugify"),
"a longer neighbour is shortened before the recall word: {crowded}"
);
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let marker = dir.path().join("run/ljos/encoder-warming");
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
std::fs::write(&marker, "").unwrap();
let atoms = Arc::new(Mutex::new(Vec::<Value>::new()));
let pack = serve_pack({
let atoms = Arc::clone(&atoms);
move |req| {
let path = req.lines().next().unwrap_or("");
if path.contains("POST /v1/atoms") {
let body = req
.split_once("\r\n\r\n")
.map(|(_, body)| body)
.unwrap_or("");
if let Ok(atom) = serde_json::from_str::<Value>(body) {
atoms.lock().unwrap().push(atom);
}
return (
200,
r#"{"id":"atom-slugify","kind":"lesson","origin":"agent-derived"}"#.into(),
);
}
if path.contains("GET /v1/status") {
return (
200,
r#"{"embedder":{"available":true,"answering":null}}"#.into(),
);
}
if path.contains("GET /v1/atoms") {
let list = atoms.lock().unwrap().clone();
return (200, serde_json::json!({"atoms": list}).to_string());
}
(200, "{}".into())
}
});
set_var("PACKSET_URL", &pack);
set_var("PACKSET_TIMEOUT_MS", "2000");
let session = "slugify-1";
prompt_and_edit(&repo, session, title);
let (failed, failed_call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({
"tool_name": "Bash",
"tool_input": {"command": "git commit -m slug"},
"tool_response": {"exit_code": 1}
}),
);
let _ = observe(&failed, &failed_call);
let (ask_raw, ask_call) = payload(
"PreToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git push --force origin main"}}),
);
note_ask(&ask_raw, &ask_call, ask);
let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
let _ = observe(&stop, &stop_call);
let stored = atoms.lock().unwrap().clone();
let lesson = stored.last().expect("the lesson was posted");
let prose = lesson["text"].as_str().unwrap_or("");
assert!(prose.contains("slugify"), "{prose}");
assert!(prose.contains("function"), "{prose}");
assert!(prose.contains("git-force-push"), "{prose}");
assert!(prose.contains("Commit failed"), "{prose}");
assert_eq!(lesson["source"]["title"], title);
assert!(
lesson["source"]["rule"]
.as_str()
.unwrap_or("")
.contains("git-force-push"),
"{lesson}"
);
assert!(
lesson["source"]["command"]
.as_str()
.unwrap_or("")
.contains("git commit exited non-zero"),
"{lesson}"
);
assert!(
lesson["entities"].to_string().contains("horizon:standing"),
"{lesson}"
);
let start = crate::session_start_for(Some("slugify-2"));
assert!(!start.contains("no memories here yet"), "{start}");
let (_raw, call) = payload(
"UserPromptSubmit",
"slugify-2",
&repo,
serde_json::json!({"prompt": "add a function slugify to the helper"}),
);
let (note, _) = crate::hook_note(&call, 4);
assert!(note.contains("What this seat already knows"), "{note}");
assert!(note.contains("slugify"), "{note}");
assert!(note.contains("git-force-push"), "{note}");
}
#[test]
fn the_first_prompt_recalls_a_lesson_while_the_encoder_is_cold() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let marker = dir.path().join("run/ljos/encoder-warming");
assert!(!marker.exists(), "a fresh home has not started warming");
let atoms = Arc::new(Mutex::new(vec![
serde_json::json!({
"id": "atom-kebab",
"kind": "lesson",
"text": "The helper keeps kebab case.",
"entities": ["horizon:standing"],
"ts": "2026-10-17T00:00:00Z",
"origin": "agent-derived",
"source": {"title": "keep kebab case in the helper"}
}),
serde_json::json!({
"id": "atom-deploy",
"kind": "lesson",
"text": "The deploy script restarts the service.",
"entities": ["horizon:standing"],
"ts": "2026-10-17T00:00:01Z",
"origin": "agent-derived"
}),
]));
let pack = serve_pack({
let atoms = Arc::clone(&atoms);
move |req| {
let path = req.lines().next().unwrap_or("");
if path.contains("GET /v1/status") {
return (
200,
r#"{"embedder":{"available":true,"answering":null}}"#.into(),
);
}
if path.contains("GET /v1/atoms") {
let list = atoms.lock().unwrap().clone();
return (200, serde_json::json!({"atoms": list}).to_string());
}
(200, "{}".into())
}
});
set_var("PACKSET_URL", &pack);
set_var("PACKSET_TIMEOUT_MS", "2000");
let (_raw, call) = payload(
"UserPromptSubmit",
"cold-session-2",
&repo,
serde_json::json!({"prompt": "Please update test_kebab.py and run pytest."}),
);
let (note, _) = crate::hook_note(&call, 4);
assert!(
note.contains("What this seat already knows"),
"the first prompt recalled nothing: {note}"
);
assert!(note.contains("kebab"), "{note}");
assert!(
!note.contains(crate::ENCODER_WARMING_NOTE),
"the warming note hid the lesson: {note}"
);
assert!(marker.exists(), "warming starts after the atoms are read");
let (_raw, other) = payload(
"UserPromptSubmit",
"cold-unrelated",
&repo,
serde_json::json!({"prompt": "Deploy the payments service tonight."}),
);
let (other_note, _) = crate::hook_note(&other, 4);
assert!(
!other_note.contains("kebab"),
"an unrelated prompt was handed the lesson: {other_note}"
);
}
#[test]
fn an_ask_only_session_lesson_is_a_sentence() {
let prompt = "git function slugify slug lowercases";
let ask =
"git-force-push: a force push rewrites history that others may already have pulled";
let keyword = "git function slugify slug lowercases. The guard asked about git-force-push.";
let text = lesson_text(prompt, "", &[], &[], &[ask.into()]);
assert_lesson(&text);
assert_ne!(text, keyword);
assert!(text.contains("worked"), "{text}");
assert!(text.contains("asked"), "{text}");
assert!(text.contains("slugify"), "{text}");
assert!(text.contains("lowercases"), "{text}");
assert!(
text.contains("The guard asked about git-force-push."),
"{text}"
);
assert!(!text.contains("closed"), "{text}");
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let atoms = Arc::new(Mutex::new(Vec::<Value>::new()));
let pack = serve_pack({
let atoms = Arc::clone(&atoms);
move |req| {
let path = req.lines().next().unwrap_or("");
if path.contains("POST /v1/atoms") {
let body = req
.split_once("\r\n\r\n")
.map(|(_, body)| body)
.unwrap_or("");
if let Ok(atom) = serde_json::from_str::<Value>(body) {
atoms.lock().unwrap().push(atom);
}
return (
200,
r#"{"id":"atom-ask","kind":"lesson","origin":"agent-derived"}"#.into(),
);
}
(200, "{}".into())
}
});
set_var("PACKSET_URL", &pack);
set_var("PACKSET_TIMEOUT_MS", "2000");
let session = "ask-only-sentence";
let (prompt_raw, prompt_call) = payload(
"UserPromptSubmit",
session,
&repo,
serde_json::json!({"prompt": prompt}),
);
let _ = observe(&prompt_raw, &prompt_call);
let (ask_raw, ask_call) = payload(
"PreToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git push --force origin main"}}),
);
note_ask(&ask_raw, &ask_call, ask);
let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
let _ = observe(&stop, &stop_call);
let stored = atoms.lock().unwrap().clone();
let lesson = stored.last().expect("the lesson was posted");
let prose = lesson["text"].as_str().unwrap_or("");
assert_lesson(prose);
assert_ne!(prose, keyword);
assert!(prose.contains("worked"), "{prose}");
assert!(prose.contains("slugify"), "{prose}");
assert!(prose.contains("lowercases"), "{prose}");
assert!(prose.contains("git-force-push"), "{prose}");
assert!(!prose.contains("closed"), "{prose}");
}
#[test]
fn an_unrun_commit_is_not_a_deed() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let session = "unrun-commit";
prompt_and_edit(&repo, session, "Add a readme line");
let seed = head_sha_of(&repo);
let command = "git add README.md\ngit commit -m land\ngit push --force origin main";
let (ask_raw, ask_call) = payload(
"PreToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash", "tool_input": {"command": command}}),
);
note_ask(
&ask_raw,
&ask_call,
"git-force-push: a force push rewrites history",
);
let cwd = repo.to_str().unwrap();
let asked = serde_json::json!({
"conversationId": session,
"cwd": cwd,
"toolCall": {"args": {"CommandLine": command}},
"error": "",
});
let asked_text = asked.to_string();
let asked_call = crate::hook_call(&asked_text);
assert_eq!(asked_call.event, "PostToolUse");
assert!(is_git_commit(&asked_call.cue), "{}", asked_call.cue);
let _ = observe(&asked_text, &asked_call);
assert_eq!(head_sha_of(&repo), seed);
let deeds = std::fs::read_to_string(tracker.join("deedar.log")).unwrap_or_default();
assert!(deeds.is_empty(), "{deeds}");
let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
assert!(!org.contains("cited"), "{org}");
assert!(
!dir.path()
.join(format!("run/ljos/deed-{seed}.txt"))
.exists(),
"the starting commit was cited"
);
let sha = commit_file(&repo, "README.md", "hello from the commit that ran\n");
assert_ne!(sha, seed);
let (post, post_call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m land"}, "tool_response": {"exit_code": 0}}),
);
let _ = observe(&post, &post_call);
let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
assert!(org.contains(&format!("deed-file-commit-{sha}")), "{org}");
assert!(!org.contains(&format!("deed-file-commit-{seed}")), "{org}");
let book = book_json(dir.path(), session);
assert_eq!(deeds_of(&book), vec![sha], "{book}");
}
#[test]
fn a_sitting_without_a_claim_still_writes_a_lesson() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let marker = dir.path().join("run/ljos/encoder-warming");
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
std::fs::write(&marker, "").unwrap();
let atoms = Arc::new(Mutex::new(Vec::<Value>::new()));
let pack = serve_pack({
let atoms = Arc::clone(&atoms);
move |req| {
let path = req.lines().next().unwrap_or("");
if path.contains("POST /v1/atoms") {
let body = req
.split_once("\r\n\r\n")
.map(|(_, body)| body)
.unwrap_or("");
if let Ok(atom) = serde_json::from_str::<Value>(body) {
atoms.lock().unwrap().push(atom);
}
return (
200,
r#"{"id":"atom-fail","kind":"lesson","origin":"agent-derived"}"#.into(),
);
}
if path.contains("GET /v1/status") {
return (
200,
r#"{"embedder":{"available":true,"answering":null}}"#.into(),
);
}
if path.contains("GET /v1/atoms") {
let list = atoms.lock().unwrap().clone();
return (200, serde_json::json!({"atoms": list}).to_string());
}
(200, "{}".into())
}
});
set_var("PACKSET_URL", &pack);
set_var("PACKSET_TIMEOUT_MS", "2000");
let session = "failures-only";
let (prompt, prompt_call) = payload(
"UserPromptSubmit",
session,
&repo,
serde_json::json!({"prompt": "Fix the slugify helper"}),
);
let _ = observe(&prompt, &prompt_call);
for _ in 0..11 {
let (failed, failed_call) = payload(
"PostToolUseFailure",
session,
&repo,
serde_json::json!({
"tool_name": "Bash",
"tool_input": {"command": "cargo test"},
"tool_response": {"error": "slugify helper failed"}
}),
);
let _ = observe(&failed, &failed_call);
}
let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
let _ = observe(&stop, &stop_call);
let asked = "asks-only";
let (prompt, prompt_call) = payload(
"UserPromptSubmit",
asked,
&repo,
serde_json::json!({"prompt": "Check the release script"}),
);
let _ = observe(&prompt, &prompt_call);
let (ask_raw, ask_call) = payload(
"PreToolUse",
asked,
&repo,
serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git push --force origin main"}}),
);
note_ask(
&ask_raw,
&ask_call,
"git-force-push: a force push rewrites history",
);
let (stop, stop_call) = payload("Stop", asked, &repo, Value::Null);
let _ = observe(&stop, &stop_call);
let vissue = std::fs::read_to_string(tracker.join("vissue.log")).unwrap_or_default();
assert!(!vissue.contains("create"), "{vissue}");
let stored = atoms.lock().unwrap().clone();
assert_eq!(stored.len(), 2, "{stored:?}");
let failed = stored[0]["text"].as_str().unwrap_or("");
assert!(failed.contains("slugify"), "{failed}");
assert!(!failed.contains("closed"), "{failed}");
assert_lesson(failed);
let guard = stored[1]["text"].as_str().unwrap_or("");
assert!(guard.contains("git-force-push"), "{guard}");
assert!(!guard.contains("closed"), "{guard}");
assert_lesson(guard);
let book = book_json(dir.path(), session);
let row = book["repos"].as_object().unwrap().values().next().unwrap();
assert!(row["issue"].is_null(), "{book}");
assert_eq!(row["lesson"], true, "{book}");
let start = crate::session_start_for(Some("failures-next"));
assert!(!start.contains("no memories here yet"), "{start}");
let (_raw, call) = payload(
"UserPromptSubmit",
"failures-next",
&repo,
serde_json::json!({"prompt": "repair the slugify helper"}),
);
let (note, _) = crate::hook_note(&call, 4);
assert!(note.contains("What this seat already knows"), "{note}");
assert!(note.contains("slugify"), "{note}");
}
#[test]
fn a_codex_transcript_exit_matches_the_call_id() {
let id = "exec_command_058e85yevw32";
let other = "exec_command_q3kpac3wpztr";
let text = format!(
"{}\n{}\n{}\n{}\n{}\n{}",
codex_event(other, None),
codex_event(other, Some(127)),
function_output_line(other, 127),
codex_event(id, None),
codex_event(id, Some(1)),
function_output_line(id, 1),
);
assert_eq!(exit_for_call(&text, id), Some(1));
assert_eq!(exit_for_call(&text, other), Some(127));
assert_eq!(exit_for_call(&text, "exec_command_missing"), None);
let item_wins = format!(
"{}\n{}",
codex_event(id, Some(1)),
function_output_line(id, 127),
);
assert_eq!(exit_for_call(&item_wins, id), Some(1));
assert_eq!(exit_for_call(&function_output_line(id, 1), id), Some(1));
let torn_item = codex_event(id, Some(1));
let torn_item = &torn_item[..torn_item.len() - 1];
assert_eq!(exit_for_call(torn_item, id), Some(1));
let torn_output = function_output_line(id, 255);
let phrase = "Process exited with code 255";
let end = torn_output.find(phrase).unwrap() + phrase.len();
assert_eq!(exit_for_call(&torn_output[..end], id), Some(255));
}
#[test]
fn a_real_codex_post_books_the_exit_failure() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let session = "01a14bc1-59ae-7d43-9a16-bc94c6a0ef56";
let id = "exec_command_058e85yevw32";
let other = "exec_command_q3kpac3wpztr";
let transcript = dir.path().join("rollout.jsonl");
std::fs::write(
&transcript,
format!(
"{}\n{}\n{}\n{}\n{}\n",
codex_event(other, None),
codex_event(other, Some(127)),
function_output_line(other, 127),
codex_event(id, Some(1)),
function_output_line(id, 1),
),
)
.unwrap();
let command = "/usr/bin/python3 -c 'import slug; slug.kebab_case(\"Hello World\")'";
let raw = serde_json::json!({
"session_id": session,
"turn_id": "01a14bc1-59d4-7390-a148-c38ad5e58020",
"transcript_path": transcript,
"cwd": repo,
"hook_event_name": "PostToolUse",
"tool_name": "Bash",
"tool_input": {"command": command},
"tool_response": "ModuleNotFoundError: No module named 'slug'\n",
"tool_use_id": id,
});
let text = serde_json::to_string(&raw).unwrap();
let call = crate::hook_call(&text);
assert_eq!(call.event, "PostToolUse");
assert!(call.cue.contains("python3"), "{}", call.cue);
let _ = observe(&text, &call);
let book = book_json(dir.path(), session);
let row = book["repos"].as_object().unwrap().values().next().unwrap();
let failures = row["failures"].as_array().unwrap();
assert!(
failures
.iter()
.any(|note| note.as_str().unwrap_or("").contains("python3")),
"{book}"
);
assert!(
failures
.iter()
.any(|note| note.as_str().unwrap_or("").contains("slug")),
"{book}"
);
}
#[test]
fn a_codex_string_response_with_exit_127_is_a_lesson_failure() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let atoms = arm_lessons(dir.path());
let session = "codex-exit-127";
let id = "exec_command_n38zthdamfmc";
let transcript = dir.path().join("rollout.jsonl");
std::fs::write(
&transcript,
format!(
"{}\n{}\n{}\n",
codex_event("exec_command_other", Some(0)),
codex_event(id, None),
codex_event(id, Some(127)),
),
)
.unwrap();
let (prompt, prompt_call) = payload(
"UserPromptSubmit",
session,
&repo,
serde_json::json!({"prompt": "Fix the slugify helper"}),
);
let _ = observe(&prompt, &prompt_call);
let (failed, failed_call) = codex_post(
session,
&repo,
id,
"python slugify",
"/bin/bash: line 1: python: command not found\r\n",
&transcript,
);
let _ = observe(&failed, &failed_call);
let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
let _ = observe(&stop, &stop_call);
let book = book_json(dir.path(), session);
let row = book["repos"].as_object().unwrap().values().next().unwrap();
let failures = row["failures"].as_array().unwrap();
assert!(
failures
.iter()
.any(|note| note.as_str().unwrap_or("").contains("python")),
"{book}"
);
let stored = atoms.lock().unwrap().clone();
let lesson = stored.last().expect("the lesson was posted");
let prose = lesson["text"].as_str().unwrap_or("");
assert!(prose.contains("slugify"), "{prose}");
assert!(prose.contains("failed"), "{prose}");
assert!(
lesson["source"]["command"]
.as_str()
.unwrap_or("")
.contains("python"),
"{lesson}"
);
assert_lesson(prose);
}
#[test]
fn an_unclaimed_codex_session_with_failures_writes_a_lesson() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let atoms = arm_lessons(dir.path());
let session = "codex-failures-only";
let transcript = dir.path().join("rollout.jsonl");
let first = "exec_command_n38zthdamfmc";
let second = "exec_command_modmissing";
std::fs::write(
&transcript,
format!(
"{}\n{}\n",
codex_event(first, Some(127)),
codex_event(second, Some(1)),
),
)
.unwrap();
let (prompt, prompt_call) = payload(
"UserPromptSubmit",
session,
&repo,
serde_json::json!({"prompt": "Fix the slugify helper"}),
);
let _ = observe(&prompt, &prompt_call);
let (failed, failed_call) = codex_post(
session,
&repo,
first,
"python slugify",
"/bin/bash: line 1: python: command not found\r\n",
&transcript,
);
let _ = observe(&failed, &failed_call);
let (again, again_call) = codex_post(
session,
&repo,
second,
"python -m slugify",
"ModuleNotFoundError: No module named 'slugify'\r\n",
&transcript,
);
let _ = observe(&again, &again_call);
let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
let _ = observe(&stop, &stop_call);
let vissue = std::fs::read_to_string(tracker.join("vissue.log")).unwrap_or_default();
assert!(!vissue.contains("create"), "{vissue}");
let stored = atoms.lock().unwrap().clone();
assert_eq!(stored.len(), 1, "{stored:?}");
let prose = stored[0]["text"].as_str().unwrap_or("");
assert!(prose.contains("slugify"), "{prose}");
assert!(prose.contains("failed"), "{prose}");
assert!(!prose.contains("closed"), "{prose}");
assert_lesson(prose);
let book = book_json(dir.path(), session);
let row = book["repos"].as_object().unwrap().values().next().unwrap();
assert!(row["issue"].is_null(), "{book}");
assert_eq!(row["lesson"], true, "{book}");
assert!(row["failures"].as_array().unwrap().len() >= 2, "{book}");
let start = crate::session_start_for(Some("codex-failures-next"));
assert!(!start.contains("no memories here yet"), "{start}");
let (_raw, call) = payload(
"UserPromptSubmit",
"codex-failures-next",
&repo,
serde_json::json!({"prompt": "repair the slugify helper"}),
);
let (note, _) = crate::hook_note(&call, 4);
assert!(note.contains("What this seat already knows"), "{note}");
assert!(note.contains("slugify"), "{note}");
}
#[test]
fn a_failed_codex_commit_with_no_exit_field_cites_no_deed() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let session = "codex-commit";
let seed = head_sha_of(&repo);
let transcript = dir.path().join("rollout.jsonl");
std::fs::write(
&transcript,
format!("{}\n", codex_event("exec_command_other", Some(0))),
)
.unwrap();
let (failed, failed_call) = codex_post(
session,
&repo,
"exec_command_n38zthdamfmc",
"git commit -m nope",
"error: nothing to commit\r\n",
&transcript,
);
assert_eq!(failed_call.event, "PostToolUse");
assert!(is_git_commit(&failed_call.cue), "{}", failed_call.cue);
assert!(explicit_exit(&serde_json::from_str(&failed).unwrap()).is_none());
let _ = observe(&failed, &failed_call);
assert_eq!(head_sha_of(&repo), seed);
let deeds = std::fs::read_to_string(tracker.join("deedar.log")).unwrap_or_default();
assert!(deeds.is_empty(), "{deeds}");
let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
assert!(!org.contains("cited"), "{org}");
assert!(
!dir.path()
.join(format!("run/ljos/deed-{seed}.txt"))
.exists(),
"the sitting's HEAD was cited"
);
let book = book_json(dir.path(), session);
assert!(deeds_of(&book).is_empty(), "{book}");
let sha = commit_file(&repo, "README.md", "hello from codex\n");
assert_ne!(sha, seed);
std::fs::write(
&transcript,
format!("{}\n", codex_event("exec_command_ok", Some(0))),
)
.unwrap();
let (post, post_call) = codex_post(
session,
&repo,
"exec_command_ok",
"git commit -m land",
"1 file changed\r\n",
&transcript,
);
let _ = observe(&post, &post_call);
let book = book_json(dir.path(), session);
assert_eq!(deeds_of(&book), vec![sha], "{book}");
}
#[test]
fn a_stuck_codex_transcript_is_not_a_clean_commit() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let session = "codex-stuck";
let seed = head_sha_of(&repo);
let (prompt, prompt_call) = payload(
"UserPromptSubmit",
session,
&repo,
serde_json::json!({"prompt": "Commit the readme"}),
);
let _ = observe(&prompt, &prompt_call);
let fifo = dir.path().join("stuck.jsonl");
let c_path = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap();
let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
assert_eq!(rc, 0, "mkfifo");
let (failed, failed_call) = codex_post(
session,
&repo,
"exec_command_n38zthdamfmc",
"git commit -m nope",
"error: nothing to commit\r\n",
&fifo,
);
let started = std::time::Instant::now();
let _ = observe(&failed, &failed_call);
assert!(
started.elapsed() < TRANSCRIPT_DEADLINE + Duration::from_secs(2),
"the transcript read held the hook for {:?}",
started.elapsed()
);
assert!(deeds_of(&book_json(dir.path(), session)).is_empty());
assert_eq!(head_sha_of(&repo), seed);
let deeds = std::fs::read_to_string(tracker.join("deedar.log")).unwrap_or_default();
assert!(deeds.is_empty(), "{deeds}");
}
#[test]
fn a_timed_out_agy_prompt_is_recalled() {
let _g = lock();
let asked = serde_json::json!({
"conversationId": "agy-prompt-event",
"prompt": "Fix the slugify helper",
});
let asked_call = crate::hook_call_as(&asked.to_string(), Some("beforeSubmitPrompt"));
assert_eq!(asked_call.event, "UserPromptSubmit");
assert!(asked_call.cue.contains("slugify"), "{}", asked_call.cue);
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let marker = dir.path().join("run/ljos/encoder-warming");
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
std::fs::write(&marker, "").unwrap();
let atoms = Arc::new(Mutex::new(Vec::<Value>::new()));
let pack = serve_pack({
let atoms = Arc::clone(&atoms);
move |req| {
let path = req.lines().next().unwrap_or("");
if path.contains("POST /v1/atoms") {
let body = req
.split_once("\r\n\r\n")
.map(|(_, body)| body)
.unwrap_or("");
if let Ok(atom) = serde_json::from_str::<Value>(body) {
atoms.lock().unwrap().push(atom);
}
return (
200,
r#"{"id":"atom-agy","kind":"lesson","origin":"agent-derived"}"#.into(),
);
}
if path.contains("GET /v1/status") {
return (
200,
r#"{"embedder":{"available":true,"answering":null}}"#.into(),
);
}
if path.contains("GET /v1/atoms") {
let list = atoms.lock().unwrap().clone();
return (200, serde_json::json!({"atoms": list}).to_string());
}
(200, "{}".into())
}
});
set_var("PACKSET_URL", &pack);
set_var("PACKSET_TIMEOUT_MS", "2000");
let session = "agy-timeout";
let cwd = repo.to_str().unwrap();
let transcript = dir.path().join("transcript.jsonl");
std::fs::write(
&transcript,
"{\"type\":\"PLANNER_RESPONSE\",\"text\":\"working\"}\n",
)
.unwrap();
let early = serde_json::json!({
"invocationNum": 0,
"conversationId": session,
"cwd": cwd,
"workspacePaths": [cwd],
"transcriptPath": transcript,
});
let early_text = early.to_string();
let early_call = crate::hook_call(&early_text);
assert_eq!(early_call.event, "UserPromptSubmit");
assert!(early_call.cue.is_empty(), "{}", early_call.cue);
let _ = observe(&early_text, &early_call);
std::fs::write(
&transcript,
"{\"type\":\"USER_INPUT\",\"userInput\":{\"items\":[{\"text\":\"Fix the slugify helper\"}]}}\n",
)
.unwrap();
let edit = serde_json::json!({
"conversationId": session,
"cwd": cwd,
"workspacePaths": [cwd],
"transcriptPath": transcript,
"toolCall": {"name": "write_to_file", "args": {"TargetFile": "README.md", "CodeContent": "x"}},
});
let edit_text = edit.to_string();
let edit_call = crate::hook_call(&edit_text);
assert_eq!(edit_call.event, "PreToolUse");
let _ = observe(&edit_text, &edit_call);
set_owner(dir.path(), session, dead_pid());
let (next, next_call) = payload(
"UserPromptSubmit",
"agy-next",
&repo,
serde_json::json!({"prompt": "repair the slugify helper"}),
);
let _ = observe(&next, &next_call);
let book = book_json(dir.path(), session);
let row = book["repos"].as_object().unwrap().values().next().unwrap();
assert!(
row["title"].as_str().unwrap_or("").contains("slugify"),
"{book}"
);
assert_eq!(row["lesson"], true, "{book}");
assert_eq!(row["finished"], true, "{book}");
let stored = atoms.lock().unwrap().clone();
let lesson = stored
.last()
.and_then(|atom| atom["text"].as_str())
.unwrap_or("");
assert!(lesson.contains("slugify"), "{lesson}");
assert_lesson(lesson);
let start = crate::session_start_for(Some("agy-next"));
assert!(!start.contains("no memories here yet"), "{start}");
let (note, _) = crate::hook_note(&next_call, 4);
assert!(note.contains("What this seat already knows"), "{note}");
assert!(note.contains("slugify"), "{note}");
}
#[test]
fn agy_commit_without_an_event_name_is_cited() {
let spec = crate::named_hook_spec("ljos");
let command = spec["PostToolUse"][0]["hooks"][0]["command"]
.as_str()
.unwrap();
assert!(command.contains("--event PostToolUse"), "{command}");
let entering = r#"{"conversationId":"agy","toolCall":{"args":{"CommandLine":"git add README.md && git commit -m land"}}}"#;
let before = crate::hook_call(entering);
assert_eq!(before.event, "PreToolUse");
assert_eq!(before.cue, "git add README.md && git commit -m land");
let returning = r#"{"conversationId":"agy","toolCall":{"args":{"CommandLine":"git add README.md && git commit -m land"}},"error":""}"#;
let after = crate::hook_call(returning);
assert_eq!(after.event, "PostToolUse");
assert_eq!(after.cue, before.cue);
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let session = "agy-deed";
let cwd = repo.to_str().unwrap();
let (prompt, prompt_call) = payload(
"UserPromptSubmit",
session,
&repo,
serde_json::json!({"prompt": "Add a readme line"}),
);
let _ = observe(&prompt, &prompt_call);
let pre = serde_json::json!({
"conversationId": session,
"cwd": cwd,
"toolCall": {"args": {"CommandLine": "git add README.md && git commit -m land"}},
});
let pre_text = pre.to_string();
let pre_call = crate::hook_call(&pre_text);
assert_eq!(pre_call.event, "PreToolUse");
let _ = observe(&pre_text, &pre_call);
assert!(
std::fs::read_to_string(tracker.join("deedar.log"))
.unwrap_or_default()
.is_empty(),
"the way in does not cite a commit that has not run"
);
let sha = commit_file(&repo, "README.md", "hello from agy\n");
let post = serde_json::json!({
"conversationId": session,
"cwd": cwd,
"toolCall": {"args": {"CommandLine": "git add README.md && git commit -m land"}},
"error": "",
});
let post_text = post.to_string();
let post_call = crate::hook_call(&post_text);
assert_eq!(post_call.event, "PostToolUse");
let _ = observe(&post_text, &post_call);
let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
assert!(org.contains(&format!("deed-file-commit-{sha}")), "{org}");
let book = book_json(dir.path(), session);
let deeds = deeds_of(&book);
assert_eq!(deeds, vec![sha], "{book}");
}
#[test]
fn successful_commit_with_stderr_text_is_cited() {
let _g = lock();
let _saved = saved_env();
let (dir, _tracker, repo) = scratch();
let _bins = arm(dir.path(), &_tracker);
let session = "qpiw";
prompt_and_edit(&repo, session, "Add a readme line");
let sha = commit_file(&repo, "README.md", "hello from cloudflare\n");
let (post, post_call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({
"tool_name": "bash",
"tool_input": {"command": "git add README.md && git commit -m 'land a line'"},
"tool_response": {
"output": "1 file changed, 1 insertion(+)",
"error": "1 file changed, 1 insertion(+)",
"exit_code": 0
}
}),
);
let _ = observe(&post, &post_call);
let book = book_json(dir.path(), session);
assert_eq!(deeds_of(&book), vec![sha.clone()], "{book}");
git(
&repo,
&["commit", "--allow-empty", "-m", "a failed attempt"],
);
let missed = head_sha_of(&repo);
let (failed, failed_call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({
"tool_name": "bash",
"tool_input": {"command": "git commit --allow-empty -m 'a failed attempt'"},
"tool_response": {"error": "nothing to commit", "exit_code": 1}
}),
);
let _ = observe(&failed, &failed_call);
let book = book_json(dir.path(), session);
let deeds = deeds_of(&book);
assert!(deeds.contains(&sha), "{book}");
assert!(!deeds.contains(&missed), "{book}");
let quiet = commit_file(&repo, "quiet.txt", "no exit field\n");
let (stderr_only, stderr_call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({
"tool_name": "bash",
"tool_input": {"command": "git commit -m quiet"},
"tool_response": {"error": "1 file changed, 1 insertion(+)"}
}),
);
let _ = observe(&stderr_only, &stderr_call);
let book = book_json(dir.path(), session);
assert!(
deeds_of(&book).contains(&quiet),
"HEAD moved and no exit code is a landed commit: {book}"
);
}
#[test]
fn dead_runner_claims_finish_at_the_next_session() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let gone = dead_pid();
for session in ["dead-a", "dead-b"] {
prompt_and_edit(&repo, session, "Add a readme line");
set_owner(dir.path(), session, gone);
}
prompt_and_edit(&repo, "live-one", "still running");
let (start, start_call) = payload(
"UserPromptSubmit",
"next-sitting",
&repo,
serde_json::json!({"prompt": "a later sitting"}),
);
let _ = observe(&start, &start_call);
for session in ["dead-a", "dead-b"] {
let book = book_json(dir.path(), session);
let row = book["repos"].as_object().unwrap().values().next().unwrap();
assert_eq!(row["finished"], true, "{session}: {book}");
}
let live = book_json(dir.path(), "live-one");
let row = live["repos"].as_object().unwrap().values().next().unwrap();
assert_eq!(row["finished"], false, "{live}");
assert_eq!(
git_log(&tracker).matches("finished").count(),
2,
"{}",
git_log(&tracker)
);
}
#[test]
fn deed_follows_the_commit_that_landed() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let texts = Arc::new(Mutex::new(Vec::<String>::new()));
let kept = serve_pack({
let texts = Arc::clone(&texts);
move |req| keep_atom(req, &texts)
});
set_var("PACKSET_URL", &kept);
set_var("PACKSET_TIMEOUT_MS", "2000");
let session = "gfiz";
prompt_and_edit(&repo, session, "Add a readme line");
let abandoned = commit_file(&repo, "README.md", "hello from the first sha\n");
let (post, post_call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m 'land a line'"}, "tool_response": {"exit_code": 0}}),
);
let _ = observe(&post, &post_call);
let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
let _ = observe(&stop, &stop_call);
git(&repo, &["commit", "--amend", "-m", "landed"]);
let landed = head_sha_of(&repo);
assert_ne!(abandoned, landed);
let (next, next_call) = payload(
"UserPromptSubmit",
"gfiz-next",
&repo,
serde_json::json!({"prompt": "next sitting"}),
);
let _ = observe(&next, &next_call);
let book = book_json(dir.path(), session);
assert_eq!(deeds_of(&book), vec![landed.clone()], "{book}");
let stored = texts.lock().unwrap().clone();
let lesson = stored.last().expect("a lesson was posted");
assert!(lesson.contains(&landed[..7]), "{lesson}");
assert!(!lesson.contains(&abandoned[..7]), "{lesson}");
let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
assert!(org.contains(&format!("deed-file-commit-{landed}")), "{org}");
}
#[test]
fn a_prompt_does_not_wait_on_the_seats_history() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let head = head_sha_of(&repo);
let repo_key = repo.display().to_string();
let sittings = 300;
for n in 0..sittings {
let mut book = Book::default();
book.repos.insert(
repo_key.clone(),
RepoBook {
issue: Some("probe-aaaa".into()),
deeds: vec![head.clone()],
finished: true,
lesson: true,
..RepoBook::default()
},
);
let path = book_path(&format!("old-{n}"));
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
store_book(&path, &book);
}
let landed = || {
(0..sittings)
.filter(|n| {
let book = load_book(&book_path(&format!("old-{n}")));
book.repos.values().all(|row| !row.landed.is_empty())
})
.count()
};
assert_eq!(landed(), 0);
let mut passes = 0;
while landed() < sittings && passes < 60 {
let (raw, call) = payload(
"UserPromptSubmit",
"now",
&repo,
serde_json::json!({"prompt": "summarize the failing build"}),
);
let started = std::time::Instant::now();
let _ = observe(&raw, &call);
let took = started.elapsed();
assert!(
took < std::time::Duration::from_millis(2500),
"pass {passes} took {took:?}"
);
passes += 1;
}
assert_eq!(landed(), sittings, "the sweep reached every book");
}
fn saved_env() -> Restore {
Restore::new(&[
"PATH",
"VISSUE_ROOT",
"VISSUE_NO_ROUTE",
"LJOS_TRACKER_GIT",
"LJOS_PROJECT",
"XDG_RUNTIME_DIR",
"LJOS_IN_HOOK",
"PACKSET_URL",
"ISSUE_ROOT",
"LJOS_OFF",
"LJOS_SESSION_ID",
"PACKSET_TIMEOUT_MS",
])
}
fn set_var(key: &str, value: &str) {
unsafe { std::env::set_var(key, value) };
}
fn codex_event(call_id: &str, exit_code: Option<i64>) -> String {
match exit_code {
Some(code) => serde_json::json!({
"timestamp": "2026-10-17T21:25:30.203Z",
"type": "event_msg",
"payload": {
"type": "item_completed",
"item": {
"type": "CommandExecution",
"id": call_id,
"command": ["/bin/bash", "-lc", "true"],
"status": if code == 0 { "completed" } else { "failed" },
"exit_code": code,
}
}
})
.to_string(),
None => serde_json::json!({
"timestamp": "2026-10-17T21:25:30.129Z",
"type": "response_item",
"payload": {
"type": "function_call",
"name": "exec_command",
"call_id": call_id,
"arguments": "{}",
}
})
.to_string(),
}
}
fn function_output_line(call_id: &str, code: i64) -> String {
serde_json::json!({
"timestamp": "2026-10-17T21:25:30.218Z",
"type": "response_item",
"payload": {
"type": "function_call_output",
"call_id": call_id,
"output": format!(
"Chunk ID: 13ab48\nWall time: 0.0000 seconds\nProcess exited with code {code}\nOriginal token count: 46\nOutput:\nModuleNotFoundError: No module named 'slug'\n"
),
}
})
.to_string()
}
fn codex_post(
session: &str,
repo: &Path,
tool_use_id: &str,
command: &str,
response: &str,
transcript: &Path,
) -> (String, crate::HookCall) {
payload(
"PostToolUse",
session,
repo,
serde_json::json!({
"tool_name": "Bash",
"tool_use_id": tool_use_id,
"tool_input": {"command": command},
"tool_response": response,
"transcript_path": transcript,
}),
)
}
fn arm_lessons(dir: &Path) -> Arc<Mutex<Vec<Value>>> {
let marker = dir.join("run/ljos/encoder-warming");
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
std::fs::write(&marker, "").unwrap();
let atoms = Arc::new(Mutex::new(Vec::<Value>::new()));
let pack = serve_lessons(Arc::clone(&atoms));
set_var("PACKSET_URL", &pack);
set_var("PACKSET_TIMEOUT_MS", "2000");
atoms
}
fn serve_lessons(atoms: Arc<Mutex<Vec<Value>>>) -> String {
serve_pack(move |req| {
let path = req.lines().next().unwrap_or("");
if path.contains("POST /v1/atoms") {
let body = req
.split_once("\r\n\r\n")
.map(|(_, body)| body)
.unwrap_or("");
if let Ok(atom) = serde_json::from_str::<Value>(body) {
atoms.lock().unwrap().push(atom);
}
return (
200,
r#"{"id":"atom-codex","kind":"lesson","origin":"agent-derived"}"#.into(),
);
}
if path.contains("GET /v1/status") {
return (
200,
r#"{"embedder":{"available":true,"answering":null}}"#.into(),
);
}
if path.contains("GET /v1/atoms") {
let list = atoms.lock().unwrap().clone();
return (200, serde_json::json!({"atoms": list}).to_string());
}
(200, "{}".into())
})
}
fn head_sha_of(repo: &Path) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn commit_file(repo: &Path, name: &str, body: &str) -> String {
std::fs::write(repo.join(name), body).unwrap();
git(repo, &["add", name]);
git(repo, &["commit", "-q", "-m", name]);
head_sha_of(repo)
}
fn prompt_and_edit(repo: &Path, session: &str, prompt: &str) {
let (raw, call) = payload(
"UserPromptSubmit",
session,
repo,
serde_json::json!({"prompt": prompt}),
);
let _ = observe(&raw, &call);
let (raw, call) = payload(
"PreToolUse",
session,
repo,
serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "README.md"}}),
);
let _ = observe(&raw, &call);
}
fn book_json(dir: &Path, session: &str) -> Value {
let path = dir.join(format!(
"run/ljos/book-{}.json",
crate::session_tag(session)
));
serde_json::from_str(
&std::fs::read_to_string(&path)
.unwrap_or_else(|_| panic!("no book for {session} at {}", path.display())),
)
.unwrap()
}
fn deeds_of(book: &Value) -> Vec<String> {
book["repos"]
.as_object()
.unwrap()
.values()
.flat_map(|row| {
row["deeds"]
.as_array()
.into_iter()
.flatten()
.filter_map(|v| v.as_str().map(str::to_string))
})
.collect()
}
fn set_owner(dir: &Path, session: &str, pid: u32) {
let path = dir.join(format!(
"run/ljos/book-{}.json",
crate::session_tag(session)
));
let mut book: Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
book["owner_pid"] = serde_json::json!(pid);
std::fs::write(&path, serde_json::to_string(&book).unwrap()).unwrap();
}
fn dead_pid() -> u32 {
let mut child = std::process::Command::new("true").spawn().unwrap();
let pid = child.id();
let _ = child.wait();
assert!(!crate::pid_alive(pid), "pid {pid} is still alive");
pid
}
fn assert_lesson(text: &str) {
let mut atom = serde_json::json!({
"kind": "lesson",
"text": text,
"workspace": "seat",
"origin": "agent-derived",
});
let map = atom.as_object_mut().expect("atom");
if let Err(e) = packset_core::record::validate(map) {
panic!("{text}: {e}");
}
}
fn serve_pack(on: impl Fn(&str) -> (u16, String) + Send + 'static) -> String {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else {
break;
};
let mut buf = Vec::new();
let mut tmp = [0u8; 2048];
while let Ok(n) = stream.read(&mut tmp) {
if n == 0 {
break;
}
buf.extend_from_slice(&tmp[..n]);
let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") else {
continue;
};
let mut need = 0usize;
for line in buf[..at].split(|b| *b == b'\n') {
let line = std::str::from_utf8(line).unwrap_or("").trim();
let Some((name, value)) = line.split_once(':') else {
continue;
};
if name.eq_ignore_ascii_case("content-length") {
need = value.trim().parse().unwrap_or(0);
}
}
if buf.len().saturating_sub(at + 4) >= need {
break;
}
}
let req = String::from_utf8_lossy(&buf).into_owned();
let (code, body) = on(&req);
let status = if code == 200 {
"200 OK"
} else {
"400 Bad Request"
};
let resp = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes());
}
});
format!("http://{addr}")
}
fn keep_atom(req: &str, texts: &Mutex<Vec<String>>) -> (u16, String) {
let path = req.lines().next().unwrap_or("");
let body = req
.split_once("\r\n\r\n")
.map(|(_, body)| body)
.unwrap_or("");
if path.contains("POST /v1/atoms") {
let atom: Value = serde_json::from_str(body).unwrap_or(Value::Null);
if let Some(text) = atom["text"].as_str() {
texts.lock().unwrap().push(text.to_string());
}
return (
200,
r#"{"id":"atom-1","kind":"lesson","origin":"agent-derived"}"#.into(),
);
}
(200, "{}".into())
}
fn git_log(tracker: &Path) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(tracker)
.args(["log", "--format=%s"])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn file_by_hand(title: &str) -> String {
let out = std::process::Command::new("vissue")
.args(["create", "-p", "probe", title, "-q"])
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn land_commit(repo: &Path, file: &str, message: &str) -> String {
std::fs::write(repo.join(file), "x\n").unwrap();
git(repo, &["add", file]);
git(repo, &["commit", "-q", "-m", message]);
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn issue_state(tracker: &Path, issue: &str) -> String {
let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
let at = org.find(&format!(":ID: {issue}\n")).unwrap();
let head = org[..at].rfind("* ").unwrap();
org[head + 2..]
.split_whitespace()
.next()
.unwrap()
.to_string()
}
fn hook_row(session: &str, repo: &Path) -> RepoBook {
load_book(&book_path(session))
.repos
.get(repo.to_str().unwrap())
.cloned()
.unwrap_or_default()
}
#[test]
fn a_named_commit_takes_over_the_empty_issue_the_edit_filed() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let named = file_by_hand("Add slugify function and tests");
let session = "harness-named-takeover";
let (raw, call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Edit", "tool_input": {"file_path": "slug.py"}}),
);
let _ = observe(&raw, &call);
let auto = hook_row(session, &repo)
.issue
.expect("the edit filed a claim");
assert_ne!(auto, named);
let message = format!("{named}: add kebab_case");
let sha = land_commit(&repo, "slug.py", &message);
let (raw, call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash",
"tool_input": {"command": format!("git commit -m \"{message}\"")},
"tool_response": {"exit_code": 0}}),
);
let _ = observe(&raw, &call);
let row = hook_row(session, &repo);
assert_eq!(row.issue.as_deref(), Some(named.as_str()));
assert_eq!(row.deeds, vec![sha.clone()]);
assert!(row.elsewhere.is_empty(), "{:?}", row.elsewhere);
assert!(row.also.is_empty(), "{:?}", row.also);
assert!(row.named && !row.filed);
assert_eq!(issue_state(&tracker, &auto), "CANCELLED");
let log =
std::fs::read_to_string(crate::runtime_dir().join("book.log")).unwrap_or_default();
assert!(log.contains(&format!("claim: cancelled {auto}")), "{log}");
assert_eq!(creates(&tracker), 2, "the commit filed no third issue");
}
#[test]
fn a_named_commit_keeps_a_filed_issue_that_has_a_deed() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let named = file_by_hand("Add slugify function and tests");
let session = "harness-named-keeps";
let (raw, call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Edit", "tool_input": {"file_path": "slug.py"}}),
);
let _ = observe(&raw, &call);
let auto = hook_row(session, &repo).issue.unwrap();
let first = land_commit(&repo, "one.txt", "add one");
let (raw, call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash",
"tool_input": {"command": "git commit -m \"add one\""},
"tool_response": {"exit_code": 0}}),
);
let _ = observe(&raw, &call);
assert_eq!(hook_row(session, &repo).deeds, vec![first]);
let message = format!("{named}: add kebab_case");
let sha = land_commit(&repo, "two.txt", &message);
let (raw, call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash",
"tool_input": {"command": format!("git commit -m \"{message}\"")},
"tool_response": {"exit_code": 0}}),
);
let _ = observe(&raw, &call);
let row = hook_row(session, &repo);
assert_eq!(row.issue.as_deref(), Some(auto.as_str()));
assert_eq!(row.also, vec![named]);
assert!(row.elsewhere.contains(&sha));
assert_eq!(issue_state(&tracker, &auto), "STARTED");
}
#[test]
fn a_named_commit_leaves_a_filed_issue_another_holder_has() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let named = file_by_hand("Add slugify function and tests");
let session = "harness-named-other";
let (raw, call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Edit", "tool_input": {"file_path": "slug.py"}}),
);
let _ = observe(&raw, &call);
let auto = hook_row(session, &repo).issue.unwrap();
let org_path = tracker.join("Software/probe/issues.org");
let org = std::fs::read_to_string(&org_path).unwrap();
let needle = format!(":ID: {auto}\n");
std::fs::write(
&org_path,
org.replace(&needle, &format!("{needle}:CLAIMED_BY: someone-else@zz9\n")),
)
.unwrap();
let message = format!("{named}: add kebab_case");
let _ = land_commit(&repo, "slug.py", &message);
let (raw, call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Bash",
"tool_input": {"command": format!("git commit -m \"{message}\"")},
"tool_response": {"exit_code": 0}}),
);
let _ = observe(&raw, &call);
assert_eq!(
hook_row(session, &repo).issue.as_deref(),
Some(auto.as_str())
);
assert_eq!(issue_state(&tracker, &auto), "STARTED");
}
#[test]
fn ljos_start_keeps_the_issue_the_edit_already_filed() {
let _g = lock();
let _saved = Restore::new(&[
"PATH",
"VISSUE_ROOT",
"VISSUE_NO_ROUTE",
"LJOS_TRACKER_GIT",
"LJOS_PROJECT",
"XDG_RUNTIME_DIR",
"LJOS_IN_HOOK",
"PACKSET_URL",
"ISSUE_ROOT",
"LJOS_OFF",
"LJOS_SESSION_ID",
"LJOS_HARNESS_SESSION",
"LJOS_SEAT",
]);
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
unsafe {
std::env::remove_var("LJOS_SEAT");
std::env::remove_var("LJOS_HARNESS_SESSION");
}
let session = "harness-edit-then-start";
let (raw, call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Edit", "tool_input": {"file_path": "slug.py"}}),
);
let _ = observe(&raw, &call);
let auto = hook_row(session, &repo).issue.unwrap();
assert_eq!(creates(&tracker), 1);
unsafe { std::env::set_var("LJOS_SESSION_ID", "mcp-after-edit") };
let said = start_work(
None,
Some(repo.to_str().unwrap()),
"add kebab_case function and test",
)
.unwrap();
assert_eq!(
said.lines().next(),
Some(format!("claimed {auto}").as_str())
);
assert_eq!(creates(&tracker), 1, "ljos_start filed a second issue");
let vissue = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
assert!(
vissue.contains(&format!("note {auto} ljos_start task: add kebab_case")),
"{vissue}"
);
assert!(hook_row(session, &repo).issue.is_none());
let start = load_book(&book_path(&sitting_key()));
let row = start.repos.get(repo.to_str().unwrap()).unwrap();
assert_eq!(row.issue.as_deref(), Some(auto.as_str()));
assert!(row.started && row.filed);
let (raw, call) = payload(
"PostToolUse",
session,
&repo,
serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "test_kebab.py"}}),
);
let _ = observe(&raw, &call);
assert_eq!(
hook_row(session, &repo).issue.as_deref(),
Some(auto.as_str())
);
assert_eq!(creates(&tracker), 1);
}
#[test]
fn a_sitting_that_never_claimed_leaves_no_row() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let session = "harness-never-claimed";
let (raw, call) = payload(
"UserPromptSubmit",
session,
&repo,
serde_json::json!({"prompt": "In this git repo, add a function slugify(s) to slug.py"}),
);
let _ = observe(&raw, &call);
assert!(load_book(&book_path(session))
.repos
.values()
.all(|row| row.issue.is_none()));
let (raw, call) = payload("SessionEnd", session, &repo, serde_json::json!({}));
let _ = observe(&raw, &call);
let book = load_book(&book_path(session));
assert!(book.repos.is_empty(), "{:?}", book.repos);
assert_eq!(creates(&tracker), 0);
}
#[test]
fn the_idle_sweep_drops_a_row_that_never_claimed() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let mut book = Book::default();
book.repos.insert(
repo.to_str().unwrap().to_string(),
RepoBook {
start_sha: Some("1bfeb804".into()),
title: Some("slugify".into()),
last_unix: 1_000,
..RepoBook::default()
},
);
book.last_unix = 1_000;
let path = book_path("harness-idle-null");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
store_book(&path, &book);
close_idle_sittings(1_000 + 10 * 24 * 3600, None, None);
assert!(load_book(&path).repos.is_empty());
assert_eq!(creates(&tracker), 0);
}
#[test]
fn a_placeholder_released_to_todo_is_still_cancelled() {
let _g = lock();
let _saved = saved_env();
let (dir, tracker, _repo) = scratch();
let _bins = arm(dir.path(), &tracker);
let released = file_by_hand("Work in repo");
assert_eq!(issue_state(&tracker, &released), "TODO");
cancel_placeholder(&released, "probe-named");
assert_eq!(issue_state(&tracker, &released), "CANCELLED");
let done = file_by_hand("Work in repo again");
let _ = std::process::Command::new("vissue")
.args(["update", &done, "-s", "DONE"])
.output()
.unwrap();
cancel_placeholder(&done, "probe-named");
assert_eq!(issue_state(&tracker, &done), "DONE");
}
}