1use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
13use std::time::{Duration, Instant};
14
15use serde_json::Value;
16
17const COMMAND_DEADLINE: Duration = Duration::from_secs(2);
21
22static LAST_ACTIVITY: AtomicU64 = AtomicU64::new(0);
26
27static DURABLE_WRITES: AtomicUsize = AtomicUsize::new(0);
31
32pub struct DurableWrite(());
34
35impl Drop for DurableWrite {
36 fn drop(&mut self) {
37 DURABLE_WRITES.fetch_sub(1, Ordering::AcqRel);
38 }
39}
40
41#[must_use]
44pub fn durable_write() -> DurableWrite {
45 DURABLE_WRITES.fetch_add(1, Ordering::AcqRel);
46 DurableWrite(())
47}
48
49#[must_use]
51pub fn durable_pending() -> bool {
52 DURABLE_WRITES.load(Ordering::Acquire) > 0
53}
54
55const BOOK_EVENTS: &[&str] = &[
58 "SessionStart",
59 "UserPromptSubmit",
60 "PreToolUse",
61 "PostToolUse",
62 "PostToolUseFailure",
63 "Stop",
64 "SessionEnd",
65];
66
67const EDIT_TOOLS: &[&str] = &[
72 "Edit",
73 "Write",
74 "MultiEdit",
75 "apply_patch",
76 "StrReplace",
77 "ApplyPatch",
78 "write_to_file",
79 "write_file",
80 "replace_file_content",
81 "edit_file",
82 "NotebookEdit",
83];
84
85const MCP_WRITES: &[&str] = &[
91 "ljos_remember",
92 "ljos_prefer",
93 "ljos_forget",
94 "ljos_deed",
95 "ljos_rule",
96 "ljos_vote",
97 "ljos_learn",
98 "ljos_trust",
99 "ljos_persona",
100 "ljos_playbook",
101 "ljos_graded",
102 "ljos_consolidate",
103 "ljos_calibrate",
104 "ljos_habit",
105];
106
107#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
111struct Book {
112 #[serde(default)]
113 prompt: String,
114 #[serde(default)]
115 repos: BTreeMap<String, RepoBook>,
116 #[serde(default)]
121 last_unix: u64,
122 #[serde(default)]
126 prompt_repo: String,
127 #[serde(default)]
129 prompt_unix: u64,
130 #[serde(default)]
134 owner_pid: u32,
135 #[serde(default)]
140 owner_start: u64,
141 #[serde(default)]
144 cwd_repo: BTreeMap<String, String>,
145 #[serde(default)]
148 last_repo: String,
149}
150
151#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
153struct RepoBook {
154 #[serde(default)]
155 issue: Option<String>,
156 #[serde(default)]
160 started: bool,
161 #[serde(default)]
167 filed: bool,
168 #[serde(default)]
171 deeds: Vec<String>,
172 #[serde(default)]
175 landed: Vec<String>,
176 #[serde(default)]
178 landed_head: Option<String>,
179 #[serde(default)]
181 failures: Vec<String>,
182 #[serde(default)]
184 asks: Vec<String>,
185 #[serde(default)]
187 lesson: bool,
188 #[serde(default)]
190 lesson_id: Option<String>,
191 #[serde(default)]
194 lesson_title: Option<String>,
195 #[serde(default)]
196 finished: bool,
197 #[serde(default)]
200 claim_failed: bool,
201 #[serde(default)]
202 recall: String,
203 #[serde(default)]
208 start_sha: Option<String>,
209 #[serde(default)]
212 title: Option<String>,
213 #[serde(default)]
217 last_unix: u64,
218 #[serde(default)]
224 named: bool,
225 #[serde(default)]
228 also: Vec<String>,
229 #[serde(default)]
232 elsewhere: Vec<String>,
233}
234
235#[must_use]
243pub fn guard_suspended() -> bool {
244 ljos_off() || (temp_home().is_some() && !guard_forced_on())
245}
246
247#[must_use]
251pub fn guard_forced_on() -> bool {
252 matches!(
253 std::env::var("LJOS_GUARD").ok().as_deref(),
254 Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
255 )
256}
257
258fn ljos_off() -> bool {
260 matches!(
261 std::env::var("LJOS_OFF").ok().as_deref(),
262 Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
263 )
264}
265
266#[must_use]
268pub fn ljos_is_off() -> bool {
269 ljos_off()
270}
271
272#[must_use]
274pub fn temp_home() -> Option<PathBuf> {
275 std::env::var_os("HOME")
276 .map(PathBuf::from)
277 .filter(|home| path_is_temp(Some(home.as_path())))
278}
279
280#[must_use]
283pub fn guard_off_reason() -> Option<String> {
284 if ljos_off() {
285 return Some("off: LJOS_OFF is set".to_string());
286 }
287 if guard_forced_on() {
288 return None;
289 }
290 temp_home().map(|home| {
291 format!(
292 "off: HOME ({}) is a temporary directory, and ljos stays out of test sandboxes. \
293 `LJOS_GUARD=on` keeps it on",
294 home.display()
295 )
296 })
297}
298
299#[must_use]
302pub fn path_is_temp(path: Option<&Path>) -> bool {
303 let Some(path) = path else {
304 return false;
305 };
306 if path.as_os_str().is_empty() {
307 return false;
308 }
309 let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
310 resolved_path_is_temp(&path)
311}
312
313#[must_use]
316pub fn resolved_path_is_temp(path: &Path) -> bool {
317 temp_roots().iter().any(|root| path.starts_with(root))
318}
319
320#[cfg(test)]
321thread_local! {
322 pub(crate) static TEST_TEMP_ROOTS: std::cell::RefCell<Option<Vec<PathBuf>>> =
325 const { std::cell::RefCell::new(None) };
326}
327
328fn temp_roots() -> Vec<PathBuf> {
329 #[cfg(test)]
330 if let Some(roots) = TEST_TEMP_ROOTS.with(|r| r.borrow().clone()) {
331 return roots;
332 }
333 let mut roots = vec![
334 std::env::temp_dir(),
335 PathBuf::from("/tmp"),
336 PathBuf::from("/var/tmp"),
337 PathBuf::from("/private/tmp"),
338 PathBuf::from("/dev/shm"),
339 ];
340 if let Some(tmp) = std::env::var_os("TMPDIR").filter(|t| !t.is_empty()) {
341 roots.push(PathBuf::from(tmp));
342 }
343 roots
344 .into_iter()
345 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
346 .collect()
347}
348
349pub(crate) const BOOK_LOCK_WAIT: Duration = Duration::from_secs(1);
352
353fn book_lock() -> Option<super::CommitLock> {
356 let asked = std::time::Instant::now();
357 let held = super::hook_timed("book_lock", || {
358 super::CommitLock::acquire_by(
359 &super::runtime_dir().join("book.lock"),
360 asked + BOOK_LOCK_WAIT,
361 )
362 });
363 super::hookd::note_time("book", asked.elapsed(), asked.elapsed());
364 held
365}
366
367#[must_use]
369pub fn books(event: &str) -> bool {
370 BOOK_EVENTS.contains(&event)
371}
372
373#[must_use]
379pub fn observe(raw: &str, call: &super::HookCall) -> (String, Vec<String>) {
380 if books(&call.event) && super::book_queue::has_pending() {
381 super::book_queue::enqueue(raw, call);
382 return (String::new(), Vec::new());
383 }
384 let raw = raw.to_string();
385 let call = call.clone();
386 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
387 observe_inner(&raw, &call, false)
388 })) {
389 Ok(Some(text)) => (text, Vec::new()),
390 Ok(None) => {
391 super::book_queue::enqueue(&raw, &call);
392 (String::new(), Vec::new())
393 }
394 Err(_) => {
395 log_line("bookkeep panicked");
396 (String::new(), Vec::new())
397 }
398 }
399}
400
401pub(crate) fn observe_queued(raw: &str, call: &super::HookCall) -> bool {
404 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
405 observe_inner(raw, call, true)
406 })) {
407 Ok(done) => done.is_some(),
408 Err(_) => {
409 log_line("bookkeep panicked");
410 true
411 }
412 }
413}
414
415#[must_use]
419pub fn recall_for(raw: &str, call: &super::HookCall) -> String {
420 if !matches!(call.event.as_str(), "SessionStart" | "UserPromptSubmit") {
421 return String::new();
422 }
423 let Ok(seat) = super::write_seat("hook") else {
424 return String::new();
425 };
426 let _seated = super::Seated::enter(&seat);
427 let value: Value = serde_json::from_str(raw.trim()).unwrap_or(Value::Null);
428 let mut book = load_book(&book_path(&session_key(call)));
429 let repo = repo_key(&mut book, &value);
430 let held = book.repos.get(&repo).map(|row| {
431 (
432 row.issue.clone().filter(|_| !row.finished),
433 row.recall.clone(),
434 )
435 });
436 match held {
437 Some((Some(issue), cached)) => claimed_recall(&issue, &cached),
438 Some((None, cached)) => cached,
439 None => String::new(),
440 }
441}
442
443static ACTING_FOR: std::sync::Mutex<Option<ActingFor>> = std::sync::Mutex::new(None);
447
448pub(crate) type ActingFor = ((u32, u64), Option<String>);
450
451pub(crate) fn act_for(owner: Option<ActingFor>) {
452 *ACTING_FOR
453 .lock()
454 .unwrap_or_else(std::sync::PoisonError::into_inner) = owner;
455}
456
457fn acting_for() -> Option<ActingFor> {
458 ACTING_FOR
459 .lock()
460 .unwrap_or_else(std::sync::PoisonError::into_inner)
461 .clone()
462}
463
464fn observe_inner(raw: &str, call: &super::HookCall, queued: bool) -> Option<String> {
466 if !BOOK_EVENTS.contains(&call.event.as_str()) {
467 return Some(String::new());
468 }
469 let seat = match super::write_seat("hook") {
471 Ok(seat) => seat,
472 Err(e) => {
473 log_line(&format!("{}: nothing booked: {e:#}", call.event));
474 return Some(String::new());
475 }
476 };
477 let _seated = super::Seated::enter(&seat);
478 let session = session_key(call);
479 let value: Value = serde_json::from_str(raw.trim()).unwrap_or(Value::Null);
480 let Some(_held) = book_lock() else {
481 log_line(&format!(
482 "{}: book lock busy past {} ms; the job waits",
483 call.event,
484 BOOK_LOCK_WAIT.as_millis()
485 ));
486 return None;
487 };
488 if matches!(call.event.as_str(), "SessionStart" | "UserPromptSubmit") {
492 let deadline = std::time::Instant::now() + SWEEP_BUDGET;
493 super::hook_timed("finish_orphans", || finish_orphans(&session, deadline));
494 super::hook_timed("refresh_landed_deeds", || refresh_landed_deeds(deadline));
495 }
496 let path = book_path(&session);
500 let repo_hint = {
501 let mut scout = load_book(&path);
502 repo_key(&mut scout, &value)
503 };
504 let hint = (!repo_hint.is_empty()).then_some(repo_hint.as_str());
505 close_idle_sittings(unix_secs(), Some(session.as_str()), hint);
506 let mut book = load_book(&path);
507 (book.owner_pid, book.owner_start) = next_owner(
508 &call.event,
509 (book.owner_pid, book.owner_start),
510 hook_owner(),
511 );
512 let mut claim_later: Option<Option<String>> = None;
513 let mut prompt_set = false;
514 if call.event == "UserPromptSubmit" {
515 let prompt = collapse(&call.cue);
516 if !prompt.is_empty() {
517 book.prompt = prompt.chars().take(4000).collect();
518 prompt_set = true;
519 }
520 }
521 if book.prompt.trim().is_empty() {
524 let prompt = collapse(&super::prompt_of(&value));
525 if !prompt.is_empty() {
526 book.prompt = prompt.chars().take(4000).collect();
527 prompt_set = true;
528 }
529 }
530 let repo = repo_key(&mut book, &value);
531 let ending_elsewhere =
535 matches!(call.event.as_str(), "Stop" | "SessionEnd") && !book.repos.contains_key(&repo);
536 let now = unix_secs();
537 if prompt_set {
538 bind_prompt(&mut book, &repo, now);
539 }
540 let git_hook = value
545 .get("ljos_git_hook")
546 .and_then(Value::as_bool)
547 .unwrap_or(false);
548 let subject_title = git_hook && !prompt_live_for(&book, &repo, now);
549 let offered = if subject_title {
550 super::prompt_of(&value)
551 } else {
552 String::new()
553 };
554 if subject_title {
555 let open = book
558 .repos
559 .get(&repo)
560 .is_some_and(|row| row.issue.is_some() && !row.finished);
561 if !open {
562 let subject = collapse(&offered);
563 if !subject.is_empty() && !repo.is_empty() {
564 let row = book.repos.entry(repo.clone()).or_default();
565 hold_lesson_title(row);
566 row.title = Some(subject.chars().take(4000).collect());
567 }
568 }
569 } else if !ending_elsewhere {
570 remember_title(&mut book, &repo);
571 }
572 let commit = is_git_commit(&call.cue);
573 let exit = if call.event == "PostToolUse" {
580 command_exit(&value)
581 } else {
582 CommandExit::Absent
583 };
584 let start = book.repos.get(&repo).and_then(|row| row.start_sha.clone());
585 let landed = call.event == "PostToolUse"
586 && commit
587 && commit_landed(exit, &value, &repo, start.as_deref());
588 if !landed && !ending_elsewhere {
592 note_start(&mut book, &repo);
593 }
594 if call.event == "PostToolUseFailure"
595 || (call.event == "PostToolUse" && command_failed(exit) && !commit)
596 {
597 note_failure(&mut book, &repo, &failure_text(&value, &call.cue));
598 }
599 if matches!(call.event.as_str(), "PreToolUse" | "PostToolUse") {
600 let names_open = commit && !git_hook && named_open_issue(&call.cue).is_some();
606 if is_edit(&value, &call.cue)
607 || (commit && !names_open)
608 || writes_worktree(&repo, &call.cue)
609 {
610 let offered = subject_title.then_some(offered.as_str());
611 if queued && call.event == "PreToolUse" {
614 claim_later = Some(offered.map(str::to_string));
615 } else {
616 let _ = ensure_claim(&mut book, &path, &repo, offered);
617 }
618 }
619 if landed {
625 let named = if git_hook { None } else { named_head(&repo) };
626 if let Some((issue, head)) = named {
627 if book.repos.get(&repo).is_none_or(|row| row.issue.is_none()) {
630 let _ = take_started_row(&mut book, &path, &repo);
631 }
632 book_named_in(&mut book, &repo, &repo, &head, &issue);
633 } else if let Some(issue) = named_open_issue(&call.cue) {
634 let head = head_sha(&repo).unwrap_or_default();
638 if !head.is_empty() {
639 if book.repos.get(&repo).is_none_or(|row| row.issue.is_none()) {
640 let _ = take_started_row(&mut book, &path, &repo);
641 }
642 book_named_in(&mut book, &repo, &repo, &head, &issue);
643 }
644 }
645 let _ = ensure_deed(&mut book, &repo);
646 } else if call.event == "PostToolUse" && commit && command_failed(exit) {
647 note_failure(&mut book, &repo, "git commit exited non-zero");
648 }
649 }
650 if matches!(call.event.as_str(), "Stop" | "SessionEnd") {
651 cite_written_commits(&mut book);
652 finish_open(&mut book);
653 drop_unclaimed_rows(&mut book);
654 }
655 let keep_baseline = (commit && !landed) || claim_later.is_some();
660 if !keep_baseline {
661 drop_unclaimed_rows(&mut book);
662 }
663 let held = if matches!(call.event.as_str(), "SessionStart" | "UserPromptSubmit") {
664 book.repos.get(&repo).map(|row| {
665 (
666 row.issue.clone().filter(|_| !row.finished),
667 row.recall.clone(),
668 )
669 })
670 } else {
671 None
672 };
673 if landed && !repo.is_empty() {
674 book.last_repo.clone_from(&repo);
675 }
676 stamp_touch(&mut book, &repo, unix_secs());
677 store_book(&path, &book);
678 drop(_held);
681 if let Some(offered) = claim_later {
682 claim_unlocked(&path, &repo, offered.as_deref());
683 }
684 if queued {
685 return Some(String::new());
686 }
687 Some(match held {
688 Some((Some(issue), cached)) => claimed_recall(&issue, &cached),
689 Some((None, cached)) => cached,
690 None => String::new(),
691 })
692}
693
694fn claimed_recall(issue: &str, cached: &str) -> String {
699 if super::pack().is_err() {
700 return cached.to_string();
701 }
702 format!(
703 "{}{}",
704 recall_line(issue),
705 super::recall_memories_for_prompt(issue)
706 )
707}
708
709#[must_use]
711pub fn mcp_tool_writes(name: &str) -> bool {
712 MCP_WRITES.contains(&name)
713}
714
715pub fn before_mcp_write(name: &str, args: Option<&serde_json::Map<String, Value>>) {
722 if !mcp_tool_writes(name) {
723 return;
724 }
725 let session = sitting_key();
726 let explicit = args.and_then(|map| map.get("repo")).and_then(Value::as_str);
727 let Some(place) = place_for_claim(explicit) else {
728 log_line(&format!("{name}: no checkout here; ran unattached"));
729 return;
730 };
731 {
732 let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
733 let path = book_path(&session);
734 let mut book = load_book(&path);
735 let open = book
736 .repos
737 .get(&place)
738 .is_some_and(|row| row.issue.is_some() && !row.finished);
739 if open || take_held_issue(&mut book, &path, &place) {
740 store_book(&path, &book);
741 } else {
742 log_line(&format!("{name}: nothing held in {place}; ran unattached"));
743 }
744 }
745 stamp_sitting(&session, unix_secs());
746}
747
748pub fn mcp_shutdown() {
750 let session = sitting_key();
751 let cwd = std::env::current_dir()
752 .ok()
753 .map(|p| p.display().to_string())
754 .unwrap_or_default();
755 let raw = serde_json::json!({
756 "cwd": cwd,
757 "session_id": session,
758 "hook_event_name": "SessionEnd",
759 })
760 .to_string();
761 let call = super::HookCall {
762 event: "SessionEnd".into(),
763 cue: String::new(),
764 session: Some(session),
765 shape: super::HookShape::Asks,
766 };
767 let _ = observe(&raw, &call);
768 match super::sync::sync_repo_in_passing(false, true) {
769 Ok(line) if !line.trim().is_empty() => log_line(line.trim()),
770 Err(e) => log_line(&format!("sync: {e:#}")),
771 Ok(_) => {}
772 }
773 let drained = super::drain_tracker_pushes();
774 if !drained.trim().is_empty() {
775 log_line(drained.trim());
776 }
777}
778
779pub fn touch_activity() {
781 LAST_ACTIVITY.store(unix_secs(), Ordering::Relaxed);
782}
783
784#[must_use]
786pub fn last_activity() -> u64 {
787 LAST_ACTIVITY.load(Ordering::Relaxed)
788}
789
790#[must_use]
792pub fn now_secs() -> u64 {
793 unix_secs()
794}
795
796static MCP_CALLS: AtomicU64 = AtomicU64::new(0);
801
802pub fn note_mcp_process_call() {
804 MCP_CALLS.fetch_add(1, Ordering::Relaxed);
805}
806
807pub fn resume_seat() {
811 resume_seat_at(unix_secs());
812}
813
814fn resume_seat_at(now: u64) {
815 let key = sitting_key();
816 {
817 let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
818 reap_stale_locked(Some(key.as_str()), now, None);
819 }
820 if let Some(limit) = idle_limit() {
821 let book = load_book(&book_path(&key));
822 let open = book
823 .repos
824 .values()
825 .any(|row| row.issue.is_some() && !row.finished);
826 if open && idle_due(now, book.last_unix, limit) {
827 mcp_shutdown();
828 }
829 }
830 stamp_sitting(&key, now);
831}
832
833fn stamp_sitting(key: &str, now: u64) {
834 let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
835 let path = book_path(key);
836 let mut book = load_book(&path);
837 book.last_unix = now;
838 store_book(&path, &book);
839}
840
841#[must_use]
844pub fn idle_limit() -> Option<Duration> {
845 idle_limit_from(std::env::var("LJOS_MCP_IDLE").ok().as_deref())
846}
847
848#[must_use]
850pub fn idle_limit_from(raw: Option<&str>) -> Option<Duration> {
851 let Some(raw) = raw.map(str::trim).filter(|text| !text.is_empty()) else {
852 return Some(Duration::from_secs(30 * 60));
853 };
854 let lowered = raw.to_ascii_lowercase();
855 if matches!(lowered.as_str(), "0" | "off" | "false" | "no") {
856 return None;
857 }
858 match super::reap::parse_idle(raw) {
859 Ok(window) if window.is_zero() => None,
860 Ok(window) => Some(window),
861 Err(_) => Some(Duration::from_secs(30 * 60)),
862 }
863}
864
865#[must_use]
868pub fn idle_due(now: u64, last: u64, limit: Duration) -> bool {
869 last > 0 && now.saturating_sub(last) >= limit.as_secs()
870}
871
872fn sitting_idle() -> Option<Duration> {
875 sitting_idle_from(std::env::var("LJOS_SITTING_IDLE").ok().as_deref())
876}
877
878fn sitting_idle_from(raw: Option<&str>) -> Option<Duration> {
879 let Some(raw) = raw.map(str::trim).filter(|text| !text.is_empty()) else {
880 return Some(Duration::from_secs(2 * 60 * 60));
881 };
882 let lowered = raw.to_ascii_lowercase();
883 if matches!(lowered.as_str(), "0" | "off" | "false" | "no") {
884 return None;
885 }
886 match super::reap::parse_idle(raw) {
887 Ok(window) if window.is_zero() => None,
888 Ok(window) => Some(window),
889 Err(_) => Some(Duration::from_secs(2 * 60 * 60)),
890 }
891}
892
893pub(crate) fn sweep_idle_sittings(session: &str, current: &str) {
902 let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
903 let current = (!current.is_empty()).then_some(current);
904 close_idle_sittings(unix_secs(), Some(session), current);
905}
906
907fn close_idle_sittings(now: u64, session: Option<&str>, current: Option<&str>) {
910 let Some(limit) = sitting_idle() else {
911 return;
912 };
913 let current_path = session.map(book_path);
914 for path in book_files() {
915 let mut book = load_book(&path);
916 let here = current_path.as_ref().is_some_and(|want| want == &path);
917 let hint = if here { current } else { None };
918 let idle: Vec<String> = book
919 .repos
920 .iter()
921 .filter(|(repo, row)| row_is_idle(now, &book, repo, row, limit, hint))
922 .map(|(repo, _)| repo.clone())
923 .collect();
924 let unclaimed = book.repos.len();
925 let touched = book.last_unix;
926 book.repos.retain(|_, row| {
927 !unclaimed_row(row) || !idle_due(now, row.last_unix.max(touched), limit)
928 });
929 let dropped = book.repos.len() != unclaimed;
930 if idle.is_empty() {
931 if dropped {
932 book.cwd_repo
933 .retain(|_, repo| book.repos.contains_key(repo));
934 store_book(&path, &book);
935 }
936 continue;
937 }
938 finish_rows(&mut book, &idle);
939 book.repos.retain(|_, row| !row.finished);
940 book.cwd_repo
941 .retain(|_, repo| book.repos.contains_key(repo));
942 release_spent_prompt(&mut book);
943 store_book(&path, &book);
944 }
945}
946
947fn unix_secs() -> u64 {
948 std::time::SystemTime::now()
949 .duration_since(std::time::UNIX_EPOCH)
950 .map(|elapsed| elapsed.as_secs())
951 .unwrap_or(0)
952}
953
954pub fn spawn_idle_finish() {
957 static ONCE: std::sync::Once = std::sync::Once::new();
958 ONCE.call_once(|| {
959 let Some(limit) = idle_limit() else {
960 return;
961 };
962 touch_activity();
963 let _ = std::thread::Builder::new()
964 .name("ljos-mcp-idle".into())
965 .spawn(move || {
966 let tick = Duration::from_secs(30.min(limit.as_secs().max(1)));
967 loop {
968 std::thread::sleep(tick);
969 let last = LAST_ACTIVITY.load(Ordering::Relaxed);
970 if idle_due(unix_secs(), last, limit) {
971 mcp_shutdown();
972 touch_activity();
973 }
974 }
975 });
976 });
977}
978
979#[must_use]
981pub fn blank_issue(id: &str) -> bool {
982 matches!(
983 id.trim().to_ascii_lowercase().as_str(),
984 "" | "none"
985 | "null"
986 | "nil"
987 | "n/a"
988 | "na"
989 | "undefined"
990 | "unknown"
991 | "-"
992 | "n.a."
993 | "n.a"
994 )
995}
996
997#[must_use]
1000pub fn canonical_issue(raw: Option<&str>, session: Option<&str>) -> Option<String> {
1001 if let Some(id) = raw.map(str::trim).filter(|id| !blank_issue(id)) {
1002 return Some(id.to_string());
1003 }
1004 claimed_issue(session)
1005}
1006
1007#[must_use]
1010pub fn session_issue(session: Option<&str>) -> Option<String> {
1011 issue_in_sessions(session)
1012}
1013
1014#[must_use]
1017pub fn claimed_issue(session: Option<&str>) -> Option<String> {
1018 issue_in_sessions(session)
1019 .or_else(open_issue)
1020 .or_else(latest_cwd_issue)
1021}
1022
1023fn issue_in_sessions(session: Option<&str>) -> Option<String> {
1024 let mut seen = std::collections::BTreeSet::new();
1025 let mut keys = Vec::new();
1026 let mut push = |raw: String| {
1027 let key = raw.trim().to_string();
1028 if key.is_empty() || !seen.insert(key.clone()) {
1029 return;
1030 }
1031 keys.push(key);
1032 };
1033 if let Some(session) = session {
1034 push(session.to_string());
1035 }
1036 if let Some(session) = crate::ballot_sig::call_session() {
1037 push(session);
1038 }
1039 if let Ok(session) = std::env::var("LJOS_HARNESS_SESSION") {
1040 push(session);
1041 }
1042 if let Ok(session) = std::env::var("LJOS_SESSION_ID") {
1043 push(session);
1044 }
1045 if let Some((id, _)) = super::session_actor() {
1046 push(id);
1047 }
1048 for key in &keys {
1049 if let Some(issue) = open_in_book(&load_book(&book_path(key))) {
1050 return Some(issue);
1051 }
1052 }
1053 None
1054}
1055
1056fn open_in_book(book: &Book) -> Option<String> {
1057 let cwd = std::env::current_dir()
1058 .ok()
1059 .map(|path| path.display().to_string())
1060 .unwrap_or_default();
1061 if let Some(key) = book.cwd_repo.get(&cwd) {
1062 if let Some(issue) = unfinished(book, key) {
1063 return Some(issue);
1064 }
1065 }
1066 let mut found = None;
1067 for key in book.repos.keys() {
1068 if let Some(issue) = unfinished(book, key) {
1069 if found.is_some() {
1070 return None;
1071 }
1072 found = Some(issue);
1073 }
1074 }
1075 found
1076}
1077
1078fn latest_cwd_issue() -> Option<String> {
1080 let cwd = std::env::current_dir().ok()?;
1081 let dir = super::runtime_dir();
1082 let mut hits = Vec::new();
1083 for entry in std::fs::read_dir(dir).ok()?.flatten() {
1084 let name = entry.file_name();
1085 let name = name.to_string_lossy();
1086 if !name.starts_with("book-") || !name.ends_with(".json") {
1087 continue;
1088 }
1089 let book = load_book(&entry.path());
1090 let Some(issue) = issue_for_cwd(&book, &cwd) else {
1091 continue;
1092 };
1093 let when = entry
1094 .metadata()
1095 .and_then(|meta| meta.modified())
1096 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1097 hits.push((when, issue));
1098 }
1099 hits.sort_by_key(|hit| hit.0);
1100 hits.pop().map(|hit| hit.1)
1101}
1102
1103fn issue_for_cwd(book: &Book, cwd: &Path) -> Option<String> {
1104 let cwd_s = cwd.display().to_string();
1105 if let Some(key) = book.cwd_repo.get(&cwd_s) {
1106 if let Some(issue) = unfinished(book, key) {
1107 return Some(issue);
1108 }
1109 }
1110 for (dir, key) in &book.cwd_repo {
1111 if paths_match(dir, cwd) {
1112 if let Some(issue) = unfinished(book, key) {
1113 return Some(issue);
1114 }
1115 }
1116 }
1117 for key in book.repos.keys() {
1118 if paths_match(key, cwd) {
1119 if let Some(issue) = unfinished(book, key) {
1120 return Some(issue);
1121 }
1122 }
1123 }
1124 None
1125}
1126
1127fn paths_match(raw: &str, cwd: &Path) -> bool {
1128 let path = Path::new(raw);
1129 if path == cwd {
1130 return true;
1131 }
1132 match (
1133 std::fs::canonicalize(path).ok(),
1134 std::fs::canonicalize(cwd).ok(),
1135 ) {
1136 (Some(left), Some(right)) => left == right,
1137 _ => false,
1138 }
1139}
1140
1141#[must_use]
1143pub fn open_issue() -> Option<String> {
1144 let book = load_book(&book_path(&sitting_key()));
1145 let cwd = std::env::current_dir()
1146 .ok()
1147 .map(|path| path.display().to_string())
1148 .unwrap_or_default();
1149 if let Some(key) = book.cwd_repo.get(&cwd) {
1150 if let Some(issue) = unfinished(&book, key) {
1151 return Some(issue);
1152 }
1153 }
1154 book.repos
1155 .iter()
1156 .find_map(|(key, _)| unfinished(&book, key))
1157}
1158
1159fn unfinished(book: &Book, key: &str) -> Option<String> {
1160 let row = book.repos.get(key)?;
1161 if row.finished {
1162 return None;
1163 }
1164 row.issue.clone()
1165}
1166
1167pub fn note_finished(issue: &str) {
1169 let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
1170 let path = book_path(&sitting_key());
1171 let mut book = load_book(&path);
1172 let mut changed = false;
1173 for row in book.repos.values_mut() {
1174 if row.issue.as_deref() == Some(issue) && !row.finished {
1175 row.finished = true;
1176 changed = true;
1177 }
1178 }
1179 if changed {
1180 store_book(&path, &book);
1181 }
1182}
1183
1184pub fn start_work(issue: Option<&str>, repo: Option<&str>, task: &str) -> Result<String, String> {
1192 let task = collapse(task);
1193 if task.is_empty() {
1194 return Err("ljos_start: pass the task in one sentence".into());
1195 }
1196 let cwd = std::env::current_dir()
1197 .ok()
1198 .map(|path| path.display().to_string())
1199 .unwrap_or_default();
1200 let named = repo.map(str::trim).filter(|path| !path.is_empty());
1201 let Some(place) = place_for_claim(named) else {
1202 return Err(if named.is_some() {
1203 "ljos_start: repo is not a git checkout".into()
1204 } else {
1205 "ljos_start: pass repo, a git checkout".into()
1206 });
1207 };
1208 let asked = issue.map(str::trim).filter(|id| !blank_issue(id));
1209 if asked.is_none() {
1210 if let Some(said) = start_on_held(&place, &task, &cwd) {
1211 return Ok(said);
1212 }
1213 if let Some(said) = start_on_recorded_hold(&place, &task, &cwd) {
1214 return Ok(said);
1215 }
1216 }
1217 let (issue_id, filed_here) = match asked {
1218 Some(id) => {
1219 claim_existing(id)?;
1220 (id.to_string(), false)
1221 }
1222 None => (file_and_claim(&project_for(&place), &task)?, true),
1223 };
1224 let persisted = super::persist_tracker(&issue_id, "claimed");
1225 if persisted.contains("could not") || persisted.contains("refused") {
1226 log_line(persisted.trim());
1227 }
1228 let recall = format!(
1229 "{}{}",
1230 recall_line(&issue_id),
1231 super::recall_memories_for_prompt(&issue_id)
1232 );
1233 let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
1234 let path = book_path(&sitting_key());
1235 let mut book = load_book(&path);
1236 let now = unix_secs();
1237 book.last_unix = now;
1238 if book.prompt.trim().is_empty() {
1239 book.prompt = task.chars().take(4000).collect();
1240 bind_prompt(&mut book, &place, now);
1241 }
1242 let row = book.repos.entry(place.clone()).or_default();
1243 row.last_unix = now;
1244 let same_issue = row.issue.as_deref() == Some(issue_id.as_str());
1245 let rebound = row.issue.is_some() && !same_issue;
1246 clear_issue_fields_if_issue_changes(row, &issue_id);
1247 row.issue = Some(issue_id.clone());
1248 if !same_issue {
1251 row.filed = filed_here;
1252 row.named = false;
1253 }
1254 row.finished = false;
1255 row.started = true;
1256 row.claim_failed = false;
1257 row.recall.clone_from(&recall);
1258 if rebound {
1259 row.start_sha = head_sha(&place).ok();
1262 }
1263 if !cwd.is_empty() {
1264 book.cwd_repo.insert(cwd, place);
1265 }
1266 store_book(&path, &book);
1267 drop(_held);
1268 Ok(format!("claimed {issue_id}\n{recall}"))
1269}
1270
1271fn claim_existing(issue: &str) -> Result<(), String> {
1272 let assignee =
1273 super::claim_holder(&super::resolve_assignee(None)).map_err(|e| e.to_string())?;
1274 if which::which("claimdag").is_ok() && super::claim(issue, &assignee).is_ok() {
1275 return Ok(());
1276 }
1277 let first = run_timed(
1278 "vissue",
1279 &["claim", issue],
1280 &[("VISSUE_AGENT", assignee.as_str())],
1281 );
1282 match first {
1283 Ok(_) => Ok(()),
1284 Err(e)
1287 if e.to_string().contains("pass --force")
1288 && tracker_holder(issue).is_some_and(|by| {
1289 super::same_seat_here(&by, &assignee) || booking_alias(&by, &assignee)
1290 }) =>
1291 {
1292 run_timed(
1293 "vissue",
1294 &["claim", issue, "--force"],
1295 &[("VISSUE_AGENT", assignee.as_str())],
1296 )
1297 .map(|_| ())
1298 .map_err(|e| e.to_string())
1299 }
1300 Err(e) => Err(e.to_string()),
1301 }
1302}
1303
1304pub struct ShutdownFinish;
1307
1308impl Drop for ShutdownFinish {
1309 fn drop(&mut self) {
1310 if MCP_CALLS.load(Ordering::Relaxed) > 1 {
1315 mcp_shutdown();
1316 }
1317 }
1318}
1319
1320fn sitting_key() -> String {
1324 let named = super::hook_actor().or_else(|| {
1325 std::env::var("LJOS_SEAT")
1326 .ok()
1327 .map(|raw| raw.trim().to_string())
1328 .filter(|raw| !raw.is_empty())
1329 });
1330 if let Some(name) = named {
1331 if !super::is_raw_subagent_name(&name) {
1332 let slug = super::canonical_seat_name(&name);
1333 if !slug.is_empty() && slug != "runner" && !super::omitted_actor_name(&slug) {
1334 return format!("seat:{slug}");
1335 }
1336 }
1337 }
1338 mcp_session()
1339}
1340
1341fn mcp_session() -> String {
1342 std::env::var("LJOS_SESSION_ID")
1343 .ok()
1344 .map(|s| s.trim().to_string())
1345 .filter(|s| !s.is_empty())
1346 .unwrap_or_else(|| format!("mcp-{}", std::process::id()))
1347}
1348
1349fn env_session(key: &str) -> Option<String> {
1350 std::env::var(key)
1351 .ok()
1352 .map(|s| s.trim().to_string())
1353 .filter(|s| !s.is_empty())
1354}
1355
1356pub(crate) fn session_key(call: &super::HookCall) -> String {
1357 call.session
1358 .as_deref()
1359 .map(str::trim)
1360 .filter(|s| !s.is_empty())
1361 .map(str::to_string)
1362 .or_else(|| env_session("LJOS_SESSION_ID"))
1363 .or_else(|| env_session("OPENCODE_SESSION_ID"))
1364 .unwrap_or_else(|| std::process::id().to_string())
1365}
1366
1367fn book_path(session: &str) -> PathBuf {
1368 super::runtime_dir().join(format!("book-{}.json", super::session_tag(session)))
1369}
1370
1371fn load_book(path: &Path) -> Book {
1372 std::fs::read_to_string(path)
1373 .ok()
1374 .and_then(|text| serde_json::from_str(&text).ok())
1375 .unwrap_or_default()
1376}
1377
1378pub(crate) fn replace_whole(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1383 use std::io::Write;
1384 if let Some(dir) = path.parent() {
1385 std::fs::create_dir_all(dir)?;
1386 }
1387 let dir = path.parent().unwrap_or_else(|| Path::new("."));
1388 let stamp = std::time::SystemTime::now()
1389 .duration_since(std::time::UNIX_EPOCH)
1390 .unwrap_or_default()
1391 .as_nanos();
1392 let mut tmp_name = path
1393 .file_name()
1394 .map(|name| name.to_os_string())
1395 .unwrap_or_else(|| "book".into());
1396 tmp_name.push(format!(".tmp-{}-{stamp}", std::process::id()));
1397 let tmp = dir.join(tmp_name);
1398 #[cfg(unix)]
1399 let mode = existing_mode(path);
1400 {
1401 let mut options = std::fs::OpenOptions::new();
1402 options.create_new(true).write(true);
1403 #[cfg(unix)]
1404 {
1405 use std::os::unix::fs::OpenOptionsExt;
1406 options.mode(0o600);
1407 }
1408 let mut out = options.open(&tmp)?;
1409 out.write_all(bytes)?;
1410 out.sync_all()?;
1411 }
1412 #[cfg(unix)]
1413 {
1414 use std::os::unix::fs::PermissionsExt;
1415 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
1416 }
1417 if std::env::var("LJOS_REPLACE_HOLD").ok().as_deref() == Some("1") {
1418 std::thread::sleep(std::time::Duration::from_secs(30));
1419 }
1420 if let Err(err) = std::fs::rename(&tmp, path) {
1421 let _ = std::fs::remove_file(&tmp);
1422 return Err(err);
1423 }
1424 Ok(())
1425}
1426
1427#[cfg(unix)]
1428fn existing_mode(path: &Path) -> u32 {
1429 use std::os::unix::fs::PermissionsExt;
1430 std::fs::symlink_metadata(path)
1431 .ok()
1432 .filter(|meta| meta.file_type().is_file())
1433 .map(|meta| meta.permissions().mode() & 0o777)
1434 .unwrap_or(0o600)
1435}
1436
1437fn store_book(path: &Path, book: &Book) {
1438 let _durable = durable_write();
1439 let Ok(text) = serde_json::to_string(book) else {
1440 return;
1441 };
1442 if std::fs::read_to_string(path).is_ok_and(|prev| prev == text) {
1443 return;
1444 }
1445 if let Err(e) = replace_whole(path, text.as_bytes()) {
1446 log_line(&format!("book: {e}"));
1447 return;
1448 }
1449 sync_claim_index(path, book);
1452}
1453
1454pub fn log_line(line: &str) {
1456 let dir = super::runtime_dir();
1457 let _ = std::fs::create_dir_all(&dir);
1458 let Ok(mut file) = std::fs::OpenOptions::new()
1459 .create(true)
1460 .append(true)
1461 .open(dir.join("book.log"))
1462 else {
1463 return;
1464 };
1465 use std::io::Write;
1466 let _ = writeln!(file, "{line}");
1467}
1468
1469fn collapse(text: &str) -> String {
1470 text.split_whitespace().collect::<Vec<_>>().join(" ")
1471}
1472
1473fn repo_key(book: &mut Book, raw: &Value) -> String {
1476 let cwd = raw["cwd"]
1477 .as_str()
1478 .or_else(|| raw["workspacePaths"][0].as_str())
1479 .map(str::to_string)
1480 .filter(|s| !s.is_empty())
1481 .or_else(|| {
1482 std::env::current_dir()
1483 .ok()
1484 .map(|p| p.display().to_string())
1485 })
1486 .unwrap_or_default();
1487 if let Some(hit) = book.cwd_repo.get(&cwd) {
1488 return hit.clone();
1489 }
1490 let key = git_toplevel(Path::new(&cwd)).unwrap_or_else(|| {
1491 let path = PathBuf::from(&cwd);
1492 std::fs::canonicalize(&path)
1493 .unwrap_or(path)
1494 .display()
1495 .to_string()
1496 });
1497 if !cwd.is_empty() {
1498 book.cwd_repo.insert(cwd, key.clone());
1499 }
1500 key
1501}
1502
1503fn place_for_claim(explicit: Option<&str>) -> Option<String> {
1508 place_for_claim_in(
1509 explicit,
1510 last_booked_repo().as_deref(),
1511 std::env::current_dir().ok().as_deref(),
1512 )
1513}
1514
1515fn place_for_claim_in(
1516 explicit: Option<&str>,
1517 last: Option<&str>,
1518 cwd: Option<&Path>,
1519) -> Option<String> {
1520 if let Some(path) = explicit.map(str::trim).filter(|path| !path.is_empty()) {
1521 return git_toplevel(Path::new(path));
1522 }
1523 if let Some(path) = last.map(str::trim).filter(|path| !path.is_empty()) {
1524 if let Some(top) = git_toplevel(Path::new(path)) {
1525 return Some(top);
1526 }
1527 }
1528 cwd.and_then(git_toplevel)
1529}
1530
1531fn last_booked_repo() -> Option<String> {
1533 let book = load_book(&book_path(&sitting_key()));
1534 let repo = book.last_repo.trim();
1535 if repo.is_empty() {
1536 None
1537 } else {
1538 Some(repo.to_string())
1539 }
1540}
1541
1542#[must_use]
1544pub fn seat_session() -> String {
1545 sitting_key()
1546}
1547
1548fn git_toplevel(cwd: &Path) -> Option<String> {
1549 if cwd.as_os_str().is_empty() {
1550 return None;
1551 }
1552 let said = run_timed(
1553 "git",
1554 &[
1555 "-C",
1556 &cwd.display().to_string(),
1557 "rev-parse",
1558 "--show-toplevel",
1559 ],
1560 &[],
1561 )
1562 .ok()?;
1563 let top = said.stdout.trim();
1564 (!top.is_empty()).then(|| top.to_string())
1565}
1566
1567fn ensure_claim(
1568 book: &mut Book,
1569 own: &Path,
1570 repo: &str,
1571 offered: Option<&str>,
1572) -> Result<(), String> {
1573 let Some((project, title)) = claim_plan(book, own, repo, offered) else {
1574 return Ok(());
1575 };
1576 claim_record(book, repo, file_and_claim(&project, &title));
1577 Ok(())
1578}
1579
1580fn claim_unlocked(own: &Path, repo: &str, offered: Option<&str>) {
1584 let plan = {
1585 let Some(_held) = book_lock() else {
1586 log_line("claim: book lock busy; the next write claims");
1587 return;
1588 };
1589 let mut book = load_book(own);
1590 let plan = claim_plan(&mut book, own, repo, offered);
1591 store_book(own, &book);
1592 plan
1593 };
1594 let Some((project, title)) = plan else {
1595 return;
1596 };
1597 let filed = file_and_claim(&project, &title);
1598 let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
1599 let mut book = load_book(own);
1600 if let (Ok(issue), Some(row)) = (&filed, book.repos.get(repo)) {
1601 if row.issue.as_deref().is_some_and(|held| held != issue) {
1602 log_line(&format!(
1603 "claim: {issue} filed while the book took {}; kept the book's",
1604 row.issue.as_deref().unwrap_or_default()
1605 ));
1606 return;
1607 }
1608 }
1609 claim_record(&mut book, repo, filed);
1610 store_book(own, &book);
1611}
1612
1613fn claim_plan(
1617 book: &mut Book,
1618 own: &Path,
1619 repo: &str,
1620 offered: Option<&str>,
1621) -> Option<(String, String)> {
1622 let row = book.repos.entry(repo.to_string()).or_default();
1623 if row.claim_failed {
1624 return None;
1625 }
1626 if let Some(issue) = row.issue.clone() {
1627 if row.recall.is_empty() {
1628 row.recall = recall_line(&issue);
1629 }
1630 return None;
1631 }
1632 if take_held_issue(book, own, repo) {
1636 return None;
1637 }
1638 let title = match offered.map(str::trim).filter(|text| !text.is_empty()) {
1643 Some(subject) => title_from(subject, repo),
1644 None => title_from(&book.prompt, repo),
1645 };
1646 Some((project_for(repo), title))
1647}
1648
1649fn claim_record(book: &mut Book, repo: &str, filed: Result<String, String>) {
1650 match filed {
1651 Ok(issue) => {
1652 let line = super::persist_tracker(&issue, "claimed");
1653 if line.contains("could not") || line.contains("refused") {
1654 log_line(line.trim());
1655 }
1656 let row = book.repos.entry(repo.to_string()).or_default();
1657 clear_issue_fields_if_issue_changes(row, &issue);
1658 row.issue = Some(issue.clone());
1659 row.filed = true;
1660 row.named = false;
1661 row.recall = recall_line(&issue);
1662 }
1663 Err(e) => {
1664 log_line(&format!("claim: {e}"));
1665 book.repos.entry(repo.to_string()).or_default().claim_failed = true;
1666 }
1667 }
1668}
1669
1670fn start_on_held(place: &str, task: &str, cwd: &str) -> Option<String> {
1677 let held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
1678 let own = book_path(&sitting_key());
1679 let mut found: Option<((u64, u128), PathBuf, String, String)> = None;
1680 for path in book_files() {
1681 let other = load_book(&path);
1682 let rank = row_rank(&path, &other);
1683 for (key, row) in &other.repos {
1684 let Some(issue) = row.issue.as_deref() else {
1685 continue;
1686 };
1687 if row.finished || !same_checkout(key, place) {
1688 continue;
1689 }
1690 if found.as_ref().is_some_and(|(when, ..)| *when > rank) {
1691 continue;
1692 }
1693 if this_seat_booked(issue) {
1694 found = Some((rank, path.clone(), key.clone(), issue.to_string()));
1695 }
1696 }
1697 }
1698 let (_, path, key, issue) = found?;
1699 let mut book = load_book(&own);
1700 let moved = if path == own {
1701 book.repos.remove(&key).unwrap_or_default()
1702 } else {
1703 let mut other = load_book(&path);
1704 let moved = other.repos.remove(&key).unwrap_or_default();
1705 store_book(&path, &other);
1706 moved
1707 };
1708 let filed = moved.filed;
1709 let now = unix_secs();
1710 book.last_unix = now;
1711 if book.prompt.trim().is_empty() {
1712 book.prompt = task.chars().take(4000).collect();
1713 bind_prompt(&mut book, place, now);
1714 }
1715 adopt_row(&mut book, place, &issue, moved);
1716 let row = book.repos.entry(place.to_string()).or_default();
1717 row.started = true;
1718 row.last_unix = now;
1719 if !cwd.is_empty() {
1720 book.cwd_repo.insert(cwd.to_string(), place.to_string());
1721 }
1722 store_book(&own, &book);
1723 drop(held);
1724 log_line(&format!("start: kept {issue}, already held in {place}"));
1725 if filed && !same_words(task, &issue_title(&issue)) {
1726 let note = format!("ljos_start task: {task}");
1727 match run_timed("vissue", &["note", &issue, ¬e], &[]) {
1728 Ok(_) => {
1729 let line = super::persist_tracker(&issue, "noted");
1730 if line.contains("could not") || line.contains("refused") {
1731 log_line(line.trim());
1732 }
1733 }
1734 Err(e) => log_line(&format!("start: note on {issue}: {e}")),
1735 }
1736 }
1737 let recall = format!(
1738 "{}{}",
1739 recall_line(&issue),
1740 super::recall_memories_for_prompt(&issue)
1741 );
1742 Some(format!("claimed {issue}\n{recall}"))
1743}
1744
1745fn start_on_recorded_hold(place: &str, task: &str, cwd: &str) -> Option<String> {
1749 let project = project_for(place);
1750 let issue = super::held_issue().filter(|id| {
1751 id.rsplit_once('-').is_some_and(|(p, _)| p == project) && this_seat_booked(id)
1752 })?;
1753 let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
1754 let own = book_path(&sitting_key());
1755 if owned_elsewhere(&own, &issue) {
1756 return None;
1757 }
1758 let mut book = load_book(&own);
1759 let now = unix_secs();
1760 book.last_unix = now;
1761 if book.prompt.trim().is_empty() {
1762 book.prompt = task.chars().take(4000).collect();
1763 bind_prompt(&mut book, place, now);
1764 }
1765 adopt_row(&mut book, place, &issue, RepoBook::default());
1766 let row = book.repos.entry(place.to_string()).or_default();
1767 row.started = true;
1768 row.last_unix = now;
1769 if !cwd.is_empty() {
1770 book.cwd_repo.insert(cwd.to_string(), place.to_string());
1771 }
1772 store_book(&own, &book);
1773 drop(_held);
1774 log_line(&format!("start: kept {issue}, already held in {place}"));
1775 let recall = format!(
1776 "{}{}",
1777 recall_line(&issue),
1778 super::recall_memories_for_prompt(&issue)
1779 );
1780 Some(format!("claimed {issue}\n{recall}"))
1781}
1782
1783pub fn note_issue(raw: Option<&str>) -> Result<String, String> {
1791 if let Some(id) = raw.map(str::trim).filter(|id| is_issue_id(id)) {
1792 return Ok(id.to_string());
1793 }
1794 if let Some(id) = claimed_issue(None).filter(|id| this_seat_booked(id) || held_by_this_seat(id))
1795 {
1796 return Ok(id);
1797 }
1798 if let Some(id) = live_issue_for_seat() {
1799 return Ok(id);
1800 }
1801 Err(
1802 "nothing is held to note on: pass issue, or ljos_start first; a note never files an issue"
1803 .into(),
1804 )
1805}
1806
1807fn live_issue_for_seat() -> Option<String> {
1811 let mut best: Option<(u64, String)> = None;
1812 for path in book_files() {
1813 let book = load_book(&path);
1814 for row in book.repos.values() {
1815 let Some(issue) = row.issue.as_deref() else {
1816 continue;
1817 };
1818 if row.finished || !this_seat_booked(issue) {
1819 continue;
1820 }
1821 let rank = row.last_unix.max(book.last_unix);
1822 let take = best.as_ref().is_none_or(|(when, _)| rank >= *when);
1823 if take {
1824 best = Some((rank, issue.to_string()));
1825 }
1826 }
1827 }
1828 best.map(|(_, issue)| issue)
1829}
1830
1831fn issue_title(issue: &str) -> String {
1833 super::issue_record(issue)
1834 .ok()
1835 .and_then(|v| v["title"].as_str().map(str::to_string))
1836 .unwrap_or_default()
1837}
1838
1839fn same_words(a: &str, b: &str) -> bool {
1841 let words = |text: &str| -> Vec<String> {
1842 text.split(|c: char| !c.is_alphanumeric() && c != '_')
1843 .filter(|w| !w.is_empty())
1844 .map(str::to_lowercase)
1845 .collect()
1846 };
1847 words(a) == words(b)
1848}
1849
1850fn take_held_issue(book: &mut Book, own: &Path, repo: &str) -> bool {
1866 let _claim = claim_lock();
1867 if take_started_row(book, own, repo) {
1868 store_book(own, book);
1869 return true;
1870 }
1871 let project = project_for(repo);
1872 let Some(issue) = super::held_issue().filter(|id| {
1873 id.rsplit_once('-').is_some_and(|(p, _)| p == project) && this_seat_booked(id)
1874 }) else {
1875 return false;
1876 };
1877 if owned_elsewhere(own, &issue) {
1878 return false;
1879 }
1880 adopt_row(book, repo, &issue, RepoBook::default());
1881 store_book(own, book);
1882 log_line(&format!("claim: kept {issue}, held by this seat"));
1883 true
1884}
1885
1886fn claim_lock() -> super::CommitLock {
1889 super::CommitLock::acquire(&super::runtime_dir().join("claim.lock"))
1890}
1891
1892#[cfg(test)]
1895static CLAIM_DIR_SCANS: AtomicU64 = AtomicU64::new(0);
1896
1897fn claim_index(issue: &str) -> PathBuf {
1899 super::runtime_dir().join(format!("claim-at-{}", super::session_tag(issue)))
1900}
1901
1902fn remember_owner(issue: &str, path: &Path) {
1903 let _durable = durable_write();
1904 let index = claim_index(issue);
1905 let body = format!("{}\n", path.display());
1906 let _ = replace_whole(&index, body.as_bytes());
1907}
1908
1909fn remembered_owner(issue: &str) -> Option<PathBuf> {
1910 let text = std::fs::read_to_string(claim_index(issue)).ok()?;
1911 let path = text.trim();
1912 if path.is_empty() {
1913 None
1914 } else {
1915 Some(PathBuf::from(path))
1916 }
1917}
1918
1919fn sync_claim_index(path: &Path, book: &Book) {
1920 for row in book.repos.values() {
1921 let Some(issue) = row
1922 .issue
1923 .as_deref()
1924 .map(str::trim)
1925 .filter(|id| !id.is_empty())
1926 else {
1927 continue;
1928 };
1929 if row.finished {
1930 if remembered_owner(issue).as_deref() == Some(path) {
1931 let _ = std::fs::remove_file(claim_index(issue));
1932 }
1933 } else {
1934 remember_owner(issue, path);
1935 }
1936 }
1937}
1938
1939fn live_claim(row: &RepoBook, issue: &str) -> bool {
1942 !row.finished && row.issue.as_deref() == Some(issue)
1943}
1944
1945fn book_holds_live(path: &Path, issue: &str) -> bool {
1946 path.is_file()
1947 && load_book(path)
1948 .repos
1949 .values()
1950 .any(|row| live_claim(row, issue))
1951}
1952
1953fn owned_elsewhere(own: &Path, issue: &str) -> bool {
1959 if let Some(path) = remembered_owner(issue) {
1960 if path != own && book_holds_live(&path, issue) {
1961 return true;
1962 }
1963 }
1964 let seat = book_path(&sitting_key());
1965 if seat != own && book_holds_live(&seat, issue) {
1966 remember_owner(issue, &seat);
1967 return true;
1968 }
1969 #[cfg(test)]
1970 CLAIM_DIR_SCANS.fetch_add(1, Ordering::Relaxed);
1971 for path in book_files() {
1972 if path != own && path != seat && book_holds_live(&path, issue) {
1973 remember_owner(issue, &path);
1974 return true;
1975 }
1976 }
1977 false
1978}
1979
1980fn take_started_row(book: &mut Book, own: &Path, repo: &str) -> bool {
1983 let mut found: Option<((u64, u128), PathBuf, String, String)> = None;
1984 for path in book_files() {
1985 if path == own {
1986 continue;
1987 }
1988 let other = load_book(&path);
1989 let rank = row_rank(&path, &other);
1990 for (key, row) in &other.repos {
1991 let Some(issue) = row.issue.as_deref() else {
1992 continue;
1993 };
1994 if row.finished || !row.started || !same_checkout(key, repo) {
1995 continue;
1996 }
1997 if found.as_ref().is_some_and(|(when, ..)| *when > rank) {
1998 continue;
1999 }
2000 if this_seat_booked(issue) {
2001 found = Some((rank, path.clone(), key.clone(), issue.to_string()));
2002 }
2003 }
2004 }
2005 if let Some((_, path, key, issue)) = found {
2006 let mut other = load_book(&path);
2007 let moved = other.repos.remove(&key).unwrap_or_default();
2008 store_book(&path, &other);
2009 adopt_row(book, repo, &issue, moved);
2010 log_line(&format!("claim: kept {issue}, already held in {repo}"));
2011 return true;
2012 }
2013 false
2014}
2015
2016fn held_by_this_seat(issue: &str) -> bool {
2022 let Ok(v) = super::issue_record(issue) else {
2023 return false;
2024 };
2025 if v["id"].as_str() != Some(issue) || v["state"].as_str() != Some("STARTED") {
2026 return false;
2027 }
2028 match v["claimed_by"].as_str().map(str::trim) {
2029 Some(by) if !by.is_empty() => super::same_seat_here(by, &super::holder_name()),
2030 _ => true,
2031 }
2032}
2033
2034fn this_seat_booked(issue: &str) -> bool {
2040 if held_by_this_seat(issue) {
2041 return true;
2042 }
2043 let Some(by) = tracker_holder(issue) else {
2044 return false;
2045 };
2046 let Ok(v) = super::issue_record(issue) else {
2047 return false;
2048 };
2049 v["state"].as_str() == Some("STARTED") && booking_alias(&by, &super::holder_name())
2050}
2051
2052fn seat_slug_now() -> Option<String> {
2053 let named = super::hook_actor().or_else(|| {
2054 std::env::var("LJOS_SEAT")
2055 .ok()
2056 .map(|raw| raw.trim().to_string())
2057 .filter(|raw| !raw.is_empty())
2058 })?;
2059 let slug = super::seat_slug(&named);
2060 if slug == "runner" || super::omitted_actor_name(&slug) {
2061 None
2062 } else {
2063 Some(slug)
2064 }
2065}
2066
2067fn raw_conversation(name: &str) -> bool {
2070 let name = name.trim();
2071 name.len() >= 8
2072 && !name.contains('@')
2073 && super::holder_parts(name).1.is_none()
2074 && name.chars().any(|c| c.is_ascii_digit())
2075 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
2076}
2077
2078fn booking_alias(claimed: &str, holder: &str) -> bool {
2084 if super::same_holder(claimed, holder) || super::same_seat_here(claimed, holder) {
2085 return true;
2086 }
2087 let Some(seat) = seat_slug_now() else {
2088 return false;
2089 };
2090 let seat_of = |name: &str| super::seat_slug(&super::holder_parts(name).0);
2091 (raw_conversation(claimed) && seat_of(holder) == seat)
2092 || (raw_conversation(holder) && seat_of(claimed) == seat)
2093}
2094
2095fn tracker_holder(issue: &str) -> Option<String> {
2097 super::issue_record(issue).ok().and_then(|v| {
2098 v["claimed_by"]
2099 .as_str()
2100 .map(str::trim)
2101 .filter(|by| !by.is_empty())
2102 .map(str::to_string)
2103 })
2104}
2105
2106fn adopt_row(book: &mut Book, repo: &str, issue: &str, moved: RepoBook) {
2109 let row = book.repos.entry(repo.to_string()).or_default();
2110 let rebound = row.issue.as_deref().is_some_and(|old| old != issue);
2111 clear_issue_fields_if_issue_changes(row, issue);
2112 row.issue = Some(issue.to_string());
2113 row.finished = false;
2114 row.claim_failed = false;
2115 row.recall = if moved.recall.is_empty() {
2116 recall_line(issue)
2117 } else {
2118 moved.recall
2119 };
2120 if rebound {
2121 row.start_sha = head_sha(repo).ok();
2122 } else if moved.start_sha.is_some() {
2123 row.start_sha = moved.start_sha;
2124 }
2125 if row.title.is_none() {
2126 row.title = moved.title;
2127 }
2128 for sha in moved.deeds {
2129 if !row.deeds.contains(&sha) {
2130 row.deeds.push(sha);
2131 }
2132 }
2133 for sha in moved.landed {
2134 if !row.landed.contains(&sha) {
2135 row.landed.push(sha);
2136 }
2137 }
2138 row.failures.extend(moved.failures);
2139 row.asks.extend(moved.asks);
2140 row.named |= moved.named;
2141 row.filed = moved.filed;
2142 for other in moved.also {
2143 if !row.also.contains(&other) {
2144 row.also.push(other);
2145 }
2146 }
2147 for sha in moved.elsewhere {
2148 if !row.elsewhere.contains(&sha) {
2149 row.elsewhere.push(sha);
2150 }
2151 }
2152}
2153
2154fn file_and_claim(project: &str, title: &str) -> Result<String, String> {
2155 let assignee =
2156 super::claim_holder(&super::resolve_assignee(None)).map_err(|e| e.to_string())?;
2157 let said = run_timed(
2158 "vissue",
2159 &["create", "-p", project, title, "-q"],
2160 &[("VISSUE_AGENT", assignee.as_str())],
2161 )
2162 .map_err(|e| e.to_string())?;
2163 let issue = parse_issue_id(&said.stdout)
2164 .ok_or_else(|| format!("vissue create printed no issue id: {}", said.stdout.trim()))?;
2165 if which::which("claimdag").is_ok() {
2166 if let Err(e) = super::claim(&issue, &assignee) {
2167 log_line(&format!("claim graph: {e:#}"));
2168 run_timed(
2169 "vissue",
2170 &["claim", &issue],
2171 &[("VISSUE_AGENT", assignee.as_str())],
2172 )
2173 .map_err(|e| e.to_string())?;
2174 }
2175 } else {
2176 run_timed(
2177 "vissue",
2178 &["claim", &issue],
2179 &[("VISSUE_AGENT", assignee.as_str())],
2180 )
2181 .map_err(|e| e.to_string())?;
2182 }
2183 Ok(issue)
2184}
2185
2186fn ensure_deed(book: &mut Book, repo: &str) -> Result<(), String> {
2187 cite_row(book, repo, repo)
2188}
2189
2190fn cite_row(book: &mut Book, row_key: &str, git_repo: &str) -> Result<(), String> {
2194 cite_row_where(book, row_key, git_repo, |_| true)
2195}
2196
2197fn cite_row_where(
2199 book: &mut Book,
2200 row_key: &str,
2201 git_repo: &str,
2202 keep: impl Fn(&str) -> bool,
2203) -> Result<(), String> {
2204 let head = match head_sha(git_repo) {
2205 Ok(sha) => sha,
2206 Err(e) => {
2207 log_line(&format!("deed: {e}"));
2208 return Ok(());
2209 }
2210 };
2211 let start = book
2212 .repos
2213 .get(row_key)
2214 .and_then(|row| row.start_sha.clone());
2215 let Some(issue) = book.repos.get(row_key).and_then(|row| row.issue.clone()) else {
2216 return Ok(());
2217 };
2218 for sha in commits_since(git_repo, start.as_deref(), &head)
2219 .into_iter()
2220 .filter(|sha| keep(sha))
2221 {
2222 if book.repos.get(row_key).is_some_and(|row| {
2223 row.deeds
2224 .iter()
2225 .chain(&row.elsewhere)
2226 .any(|seen| seen == &sha)
2227 }) {
2228 continue;
2229 }
2230 match cite_deed(&issue, &sha) {
2231 Ok(()) => {
2232 book.repos
2233 .entry(row_key.to_string())
2234 .or_default()
2235 .deeds
2236 .push(sha);
2237 }
2238 Err(e) => {
2239 log_line(&format!("deed: {e}"));
2240 return Ok(());
2241 }
2242 }
2243 }
2244 Ok(())
2245}
2246
2247fn commits_since(repo: &str, start: Option<&str>, head: &str) -> Vec<String> {
2250 let Some(start) = start.map(str::trim).filter(|sha| !sha.is_empty()) else {
2251 return vec![head.to_string()];
2252 };
2253 if start == head {
2254 return Vec::new();
2255 }
2256 let range = format!("{start}..{head}");
2257 let shas = run_timed("git", &["-C", repo, "rev-list", "--reverse", &range], &[])
2258 .ok()
2259 .map(|said| {
2260 said.stdout
2261 .lines()
2262 .map(str::trim)
2263 .filter(|sha| sha.len() >= 7 && sha.chars().all(|c| c.is_ascii_hexdigit()))
2264 .map(str::to_string)
2265 .collect::<Vec<_>>()
2266 });
2267 match shas {
2268 Some(shas) if !shas.is_empty() => shas,
2269 _ => vec![head.to_string()],
2270 }
2271}
2272
2273fn cite_written_commits(book: &mut Book) {
2279 let rows: Vec<(String, String)> = book
2280 .repos
2281 .iter()
2282 .filter(|(_, row)| row.issue.is_some() && !row.finished)
2283 .filter_map(|(key, row)| Some((key.clone(), row.start_sha.clone()?)))
2284 .collect();
2285 for (repo, start) in rows {
2286 let written = written_since(&repo, &start);
2287 if !written.is_empty() {
2288 let _ = cite_row_where(book, &repo, &repo, |sha| written.iter().any(|w| w == sha));
2289 }
2290 }
2291}
2292
2293fn written_since(repo: &str, start: &str) -> Vec<String> {
2297 let Ok(said) = run_timed(
2298 "git",
2299 &[
2300 "-C",
2301 repo,
2302 "reflog",
2303 "show",
2304 "-n",
2305 "200",
2306 "--format=%H %gs",
2307 "HEAD",
2308 ],
2309 &[],
2310 ) else {
2311 return Vec::new();
2312 };
2313 let mut written = Vec::new();
2314 for line in said.stdout.lines() {
2315 let (sha, subject) = line.split_once(' ').unwrap_or((line, ""));
2316 if sha == start.trim() {
2317 return written;
2318 }
2319 let verb = subject.split([':', ' ']).next().unwrap_or("");
2320 if matches!(verb, "commit" | "cherry-pick" | "revert") {
2321 written.push(sha.to_string());
2322 }
2323 }
2324 Vec::new()
2325}
2326
2327pub(crate) fn cite_into_open_sitting(repo: &str) -> bool {
2331 let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
2332 let Some((path, key)) = open_row_for_repo(repo) else {
2333 return false;
2334 };
2335 let mut book = load_book(&path);
2336 let open = book
2337 .repos
2338 .get(&key)
2339 .is_some_and(|row| !row.finished && row.issue.is_some());
2340 if !open {
2341 return false;
2342 }
2343 let _ = cite_row(&mut book, &key, repo);
2346 stamp_touch(&mut book, &key, unix_secs());
2347 store_book(&path, &book);
2348 true
2349}
2350
2351fn open_row_for_repo(repo: &str) -> Option<(PathBuf, String)> {
2352 let mut best: Option<((u64, u128), PathBuf, String)> = None;
2353 for path in book_files() {
2354 let book = load_book(&path);
2355 let rank = row_rank(&path, &book);
2356 for (key, row) in &book.repos {
2357 if row.finished || row.issue.is_none() || !same_checkout(key, repo) {
2358 continue;
2359 }
2360 let take = best.as_ref().is_none_or(|(when, _, _)| rank >= *when);
2361 if take {
2362 best = Some((rank, path.clone(), key.clone()));
2363 }
2364 }
2365 }
2366 best.map(|(_, path, key)| (path, key))
2367}
2368
2369fn row_rank(path: &Path, book: &Book) -> (u64, u128) {
2370 let modified = std::fs::metadata(path)
2371 .and_then(|meta| meta.modified())
2372 .ok()
2373 .and_then(|when| when.duration_since(std::time::UNIX_EPOCH).ok())
2374 .map(|elapsed| elapsed.as_nanos())
2375 .unwrap_or(0);
2376 (book.last_unix, modified)
2377}
2378
2379fn same_checkout(left: &str, right: &str) -> bool {
2380 if left == right {
2381 return true;
2382 }
2383 match (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
2384 (Ok(left), Ok(right)) => left == right,
2385 _ => false,
2386 }
2387}
2388
2389fn deed_id(sha: &str) -> String {
2392 format!("deed-file-commit-{sha}")
2393}
2394
2395fn cite_deed(issue: &str, sha: &str) -> Result<(), String> {
2396 let dir = super::runtime_dir();
2397 let _ = std::fs::create_dir_all(&dir);
2398 let path = dir.join(format!("deed-{sha}.txt"));
2399 std::fs::write(&path, format!("{sha}\n")).map_err(|e| e.to_string())?;
2400 let seat = super::holder_name();
2401 let id = deed_id(sha);
2402 let said = run_timed(
2403 "deedar",
2404 &[
2405 "create",
2406 "file",
2407 "--id",
2408 &id,
2409 "--name",
2410 sha,
2411 "--path",
2412 &path.display().to_string(),
2413 "--agent",
2414 &seat,
2415 ],
2416 &[("VISSUE_AGENT", seat.as_str())],
2417 )
2418 .map_err(|e| e.to_string())?;
2419 let accession = parse_deed(&said.stdout)
2420 .ok_or_else(|| format!("deedar printed no accession: {}", said.stdout.trim()))?;
2421 run_timed(
2422 "vissue",
2423 &["deed", issue, "--add", &accession],
2424 &[("VISSUE_AGENT", seat.as_str())],
2425 )
2426 .map_err(|e| e.to_string())?;
2427 let line = super::persist_tracker(issue, "cited a deed");
2428 if line.contains("could not") || line.contains("refused") {
2429 return Err(line.trim().to_string());
2430 }
2431 Ok(())
2432}
2433
2434fn drop_citation(issue: &str, sha: &str) -> Result<(), String> {
2437 let seat = super::holder_name();
2438 let id = deed_id(sha);
2439 run_timed(
2440 "vissue",
2441 &["deed", issue, "--remove", &id],
2442 &[("VISSUE_AGENT", seat.as_str())],
2443 )
2444 .map_err(|e| e.to_string())?;
2445 Ok(())
2446}
2447
2448fn null_sha(sha: &str) -> bool {
2449 !sha.is_empty() && sha.chars().all(|c| c == '0')
2450}
2451
2452struct LessonRewrite {
2453 issue: String,
2454 title: String,
2455 deeds: Vec<String>,
2456 failures: Vec<String>,
2457 asks: Vec<String>,
2458 replaces: Option<String>,
2459}
2460
2461pub(crate) fn repoint_commits(repo: &str, pairs: &[(String, String)]) {
2465 let pairs: Vec<(String, String)> = pairs
2466 .iter()
2467 .filter(|(old, new)| old != new && !old.is_empty() && !new.is_empty())
2468 .cloned()
2469 .collect();
2470 if pairs.is_empty() || repo.is_empty() {
2471 return;
2472 }
2473 let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
2474 for path in book_files() {
2475 let mut book = load_book(&path);
2476 let keys: Vec<String> = book
2477 .repos
2478 .iter()
2479 .filter(|(key, row)| row.issue.is_some() && same_checkout(key, repo))
2480 .map(|(key, _)| key.clone())
2481 .collect();
2482 let mut changed = false;
2483 let mut rewrites = Vec::new();
2484 for key in keys {
2485 if let Some(rewrite) = repoint_row(&mut book, &key, &pairs) {
2486 changed = true;
2487 if let Some(rewrite) = rewrite {
2488 rewrites.push(rewrite);
2489 }
2490 }
2491 }
2492 if changed {
2493 store_book(&path, &book);
2494 }
2495 for rewrite in rewrites {
2496 match write_lesson(
2497 &rewrite.issue,
2498 &rewrite.title,
2499 &rewrite.deeds,
2500 &rewrite.failures,
2501 &rewrite.asks,
2502 rewrite.replaces.as_deref(),
2503 ) {
2504 Ok(id) => {
2505 if let Some(row) = book
2506 .repos
2507 .values_mut()
2508 .find(|row| row.issue.as_deref() == Some(rewrite.issue.as_str()))
2509 {
2510 lesson_written(row, &rewrite.title, id);
2511 }
2512 store_book(&path, &book);
2513 }
2514 Err(e) => log_line(&format!("lesson: {e}")),
2515 }
2516 }
2517 }
2518}
2519
2520fn repoint_row(
2523 book: &mut Book,
2524 key: &str,
2525 pairs: &[(String, String)],
2526) -> Option<Option<LessonRewrite>> {
2527 let row = book.repos.get(key)?;
2528 let issue = row.issue.clone()?;
2529 let mut deeds = row.deeds.clone();
2530 let mut touched = false;
2531 for (old, new) in pairs {
2532 if !deeds.iter().any(|sha| sha == old) {
2533 continue;
2534 }
2535 deeds.retain(|sha| sha != old);
2536 if let Err(e) = drop_citation(&issue, old) {
2537 log_line(&format!("deed: {e}"));
2538 }
2539 touched = true;
2540 if !null_sha(new) && !deeds.iter().any(|sha| sha == new) {
2541 match cite_deed(&issue, new) {
2542 Ok(()) => deeds.push(new.clone()),
2543 Err(e) => log_line(&format!("deed: {e}")),
2544 }
2545 }
2546 }
2547 if !touched {
2548 return None;
2549 }
2550 let rewrite = {
2551 let row = book.repos.get(key)?;
2552 if row.finished && row.lesson {
2553 let title = lesson_title_of(book, key, row);
2554 Some(LessonRewrite {
2555 issue: issue.clone(),
2556 title,
2557 deeds: deeds.clone(),
2558 failures: row.failures.clone(),
2559 asks: row.asks.clone(),
2560 replaces: row.lesson_id.clone(),
2561 })
2562 } else {
2563 None
2564 }
2565 };
2566 if let Some(row) = book.repos.get_mut(key) {
2567 row.deeds.clone_from(&deeds);
2568 row.landed.retain(|sha| deeds.contains(sha));
2569 row.landed_head = None;
2570 if rewrite.is_some() {
2571 row.lesson = false;
2572 }
2573 }
2574 Some(rewrite)
2575}
2576
2577fn next_owner(event: &str, current: (u32, u64), incoming: (u32, u64)) -> (u32, u64) {
2582 if current.0 == 0 || matches!(event, "SessionStart" | "UserPromptSubmit") {
2583 incoming
2584 } else {
2585 current
2586 }
2587}
2588
2589pub(crate) fn hook_owner() -> (u32, u64) {
2598 if let Some((owner, _)) = acting_for() {
2599 return owner;
2600 }
2601 let chain = super::proc_tree::ancestors();
2602 if chain.is_empty() {
2603 return (unsafe { libc::getppid() as u32 }, 0);
2605 }
2606 super::proc_tree::runner(&chain)
2607 .cloned()
2608 .or_else(|| super::proc_tree::of(std::process::id()))
2609 .map_or((std::process::id(), 0), |owner| (owner.pid, owner.start))
2610}
2611
2612fn owner_alive(book: &Book) -> bool {
2616 if book.owner_start == 0 {
2617 return super::pid_alive(book.owner_pid);
2618 }
2619 super::proc_tree::alive(&super::proc_tree::Proc {
2620 pid: book.owner_pid,
2621 start: book.owner_start,
2622 comm: String::new(),
2623 })
2624}
2625const SWEEP_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000);
2630
2631fn finish_orphans(current: &str, deadline: std::time::Instant) {
2638 reap_stale_locked(Some(current), unix_secs(), Some(deadline));
2639}
2640
2641fn reap_stale_locked(current: Option<&str>, now: u64, deadline: Option<std::time::Instant>) {
2642 let keep = current.map(book_path);
2643 let limit = sitting_idle();
2644 for path in book_files() {
2645 if keep.as_ref().is_some_and(|want| want == &path) {
2646 continue;
2647 }
2648 if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) {
2649 break;
2650 }
2651 let mut book = load_book(&path);
2652 let open = book.repos.values().any(sitting_open);
2653 if !open {
2654 continue;
2655 }
2656 let dead = book.owner_pid != 0 && !owner_alive(&book);
2657 if dead {
2658 finish_open(&mut book);
2659 store_book(&path, &book);
2660 continue;
2661 }
2662 let Some(limit) = limit else {
2663 continue;
2664 };
2665 let idle: Vec<String> = book
2666 .repos
2667 .iter()
2668 .filter(|(repo, row)| row_is_idle(now, &book, repo, row, limit, None))
2669 .map(|(repo, _)| repo.clone())
2670 .collect();
2671 if idle.is_empty() {
2672 continue;
2673 }
2674 finish_rows(&mut book, &idle);
2675 book.repos.retain(|_, row| !row.finished);
2676 book.cwd_repo
2677 .retain(|_, repo| book.repos.contains_key(repo));
2678 release_spent_prompt(&mut book);
2679 store_book(&path, &book);
2680 }
2681}
2682
2683fn refresh_landed_deeds(deadline: std::time::Instant) {
2686 let mut heads = Heads::new();
2687 for path in book_files() {
2688 if std::time::Instant::now() >= deadline {
2689 break;
2690 }
2691 let mut book = load_book(&path);
2692 let repos: Vec<String> = book.repos.keys().cloned().collect();
2693 let mut changed = false;
2694 let mut cached = false;
2695 for repo in repos {
2696 changed |= reconcile_deeds(&mut book, &repo, deadline, &mut heads, &mut cached);
2697 }
2698 if !changed {
2699 if cached {
2700 store_book(&path, &book);
2701 }
2702 continue;
2703 }
2704 let repos: Vec<String> = book.repos.keys().cloned().collect();
2705 for repo in repos {
2706 let Some(row) = book.repos.get(&repo) else {
2707 continue;
2708 };
2709 if !row.finished || row.lesson {
2710 continue;
2711 }
2712 let Some(issue) = row.issue.clone() else {
2713 continue;
2714 };
2715 let title = lesson_title_of(&book, &repo, row);
2716 let deeds = row.deeds.clone();
2717 let failures = row.failures.clone();
2718 let asks = row.asks.clone();
2719 let replaces = row.lesson_id.clone();
2720 match write_lesson(
2721 &issue,
2722 &title,
2723 &deeds,
2724 &failures,
2725 &asks,
2726 replaces.as_deref(),
2727 ) {
2728 Ok(id) => {
2729 if let Some(row) = book.repos.get_mut(&repo) {
2730 lesson_written(row, &title, id);
2731 }
2732 }
2733 Err(e) => log_line(&format!("lesson: {e}")),
2734 }
2735 }
2736 store_book(&path, &book);
2737 }
2738}
2739
2740#[must_use]
2743pub fn prompt_claims() -> String {
2744 let mut ids = Vec::new();
2745 for path in book_files() {
2746 let book = load_book(&path);
2747 for row in book.repos.values() {
2748 if row.finished {
2749 continue;
2750 }
2751 if let Some(issue) = row
2752 .issue
2753 .as_deref()
2754 .map(str::trim)
2755 .filter(|id| !id.is_empty())
2756 {
2757 ids.push(issue.to_string());
2758 }
2759 }
2760 }
2761 ids.sort();
2762 ids.dedup();
2763 if ids.is_empty() {
2764 String::new()
2765 } else {
2766 format!("claims: {}", ids.join(" "))
2767 }
2768}
2769
2770fn book_files() -> Vec<PathBuf> {
2773 let Ok(entries) = std::fs::read_dir(super::runtime_dir()) else {
2774 return Vec::new();
2775 };
2776 let mut out = Vec::new();
2777 for entry in entries.flatten() {
2778 let name = entry.file_name();
2779 let name = name.to_string_lossy();
2780 if name.starts_with("book-") && name.ends_with(".json") {
2781 let written = entry
2782 .metadata()
2783 .and_then(|m| m.modified())
2784 .unwrap_or(std::time::UNIX_EPOCH);
2785 out.push((written, entry.path()));
2786 }
2787 }
2788 out.sort_by_key(|row| std::cmp::Reverse(row.0));
2789 out.into_iter().map(|(_, path)| path).collect()
2790}
2791
2792type Heads = std::collections::HashMap<String, Option<String>>;
2795
2796fn reconcile_deeds(
2802 book: &mut Book,
2803 repo: &str,
2804 deadline: std::time::Instant,
2805 heads: &mut Heads,
2806 cached: &mut bool,
2807) -> bool {
2808 let Some(row) = book.repos.get(repo) else {
2809 return false;
2810 };
2811 if row.deeds.is_empty() || row.issue.is_none() {
2812 return false;
2813 }
2814 let head = heads
2815 .entry(repo.to_string())
2816 .or_insert_with(|| head_sha(repo).ok())
2817 .clone();
2818 let same_head = head.is_some() && row.landed_head == head;
2819 let mut kept = Vec::new();
2820 for sha in &row.deeds {
2821 if same_head && row.landed.contains(sha) {
2822 kept.push(sha.clone());
2823 continue;
2824 }
2825 if std::time::Instant::now() >= deadline {
2826 return false;
2827 }
2828 match sha_landed(repo, sha) {
2829 Some(true) => kept.push(sha.clone()),
2830 Some(false) => {}
2831 None => return false,
2832 }
2833 }
2834 let issue = row.issue.clone();
2835 let dropped: Vec<String> = row
2836 .deeds
2837 .iter()
2838 .filter(|sha| !kept.contains(sha))
2839 .cloned()
2840 .collect();
2841 let all_landed = kept.len() == row.deeds.len();
2842 if head.is_some() && !(same_head && all_landed) {
2843 if let Some(row) = book.repos.get_mut(repo) {
2844 row.landed.clone_from(&kept);
2845 row.landed_head = head;
2846 }
2847 *cached = true;
2848 }
2849 if all_landed {
2850 return false;
2851 }
2852 if let Some(row) = book.repos.get_mut(repo) {
2853 row.deeds.clone_from(&kept);
2854 row.lesson = false;
2855 row.landed.retain(|sha| kept.contains(sha));
2856 }
2857 if let Some(issue) = issue {
2858 for sha in &dropped {
2859 if let Err(e) = drop_citation(&issue, sha) {
2860 log_line(&format!("deed: {e}"));
2861 }
2862 }
2863 }
2864 let _ = ensure_deed(book, repo);
2865 true
2866}
2867
2868fn sha_landed(repo: &str, sha: &str) -> Option<bool> {
2871 match run_timed(
2872 "git",
2873 &["-C", repo, "merge-base", "--is-ancestor", sha, "HEAD"],
2874 &[],
2875 ) {
2876 Ok(_) => Some(true),
2877 Err(e) => {
2878 let text = format!("{e:#}");
2879 if text.contains("not a git repository") || text.contains("not on PATH") {
2880 None
2881 } else {
2882 Some(false)
2883 }
2884 }
2885 }
2886}
2887
2888fn note_start(book: &mut Book, repo: &str) {
2890 if repo.is_empty() {
2891 return;
2892 }
2893 if book
2894 .repos
2895 .get(repo)
2896 .and_then(|row| row.start_sha.as_deref())
2897 .is_some()
2898 {
2899 return;
2900 }
2901 let at_call = acting_for().and_then(|(_, head)| head);
2904 let Some(sha) = at_call.or_else(|| head_sha(repo).ok()) else {
2905 return;
2906 };
2907 book.repos.entry(repo.to_string()).or_default().start_sha = Some(sha);
2908}
2909
2910fn sitting_open(row: &RepoBook) -> bool {
2914 let notes = !row.failures.is_empty() || !row.asks.is_empty();
2915 (row.issue.is_some() && !row.finished) || (notes && !row.lesson)
2916}
2917
2918fn head_sha(repo: &str) -> Result<String, String> {
2919 let said =
2920 run_timed("git", &["-C", repo, "rev-parse", "HEAD"], &[]).map_err(|e| e.to_string())?;
2921 let sha = said.stdout.trim().to_string();
2922 if sha.len() < 7 || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
2923 return Err(format!("HEAD is not a sha: {sha}"));
2924 }
2925 Ok(sha)
2926}
2927
2928fn unclaimed_row(row: &RepoBook) -> bool {
2933 row.issue.is_none()
2934 && !row.claim_failed
2935 && row.deeds.is_empty()
2936 && row.failures.is_empty()
2937 && row.asks.is_empty()
2938}
2939
2940fn drop_unclaimed_rows(book: &mut Book) {
2943 book.repos.retain(|_, row| !unclaimed_row(row));
2944 book.cwd_repo
2945 .retain(|_, repo| book.repos.contains_key(repo));
2946}
2947
2948fn finish_open(book: &mut Book) {
2949 let repos: Vec<String> = book.repos.keys().cloned().collect();
2950 finish_rows(book, &repos);
2951 book.prompt.clear();
2952 book.prompt_repo.clear();
2953 book.prompt_unix = 0;
2954}
2955
2956fn finish_rows(book: &mut Book, repos: &[String]) {
2957 for repo in repos {
2958 let mut cached = false;
2959 let in_a_minute = std::time::Instant::now() + std::time::Duration::from_secs(60);
2960 let _ = reconcile_deeds(book, repo, in_a_minute, &mut Heads::new(), &mut cached);
2961 let Some(row) = book.repos.get(repo) else {
2962 continue;
2963 };
2964 let issue = row.issue.clone();
2965 let notes = !row.failures.is_empty() || !row.asks.is_empty();
2966 let finished = row.finished;
2967 if !row.lesson && (issue.is_some() || notes) {
2970 let title = lesson_title_of(book, repo, row);
2971 let deeds = row.deeds.clone();
2972 let failures = row.failures.clone();
2973 let asks = row.asks.clone();
2974 let replaces = row.lesson_id.clone();
2975 let label = issue.clone().unwrap_or_default();
2976 match write_lesson(
2977 &label,
2978 &title,
2979 &deeds,
2980 &failures,
2981 &asks,
2982 replaces.as_deref(),
2983 ) {
2984 Ok(id) => {
2985 if let Some(row) = book.repos.get_mut(repo) {
2986 lesson_written(row, &title, id);
2987 }
2988 }
2989 Err(e) => log_line(&format!("lesson: {e}")),
2990 }
2991 }
2992 let also = book
2993 .repos
2994 .get_mut(repo)
2995 .map(|row| std::mem::take(&mut row.also))
2996 .unwrap_or_default();
2997 for other in &also {
2998 if let Err(e) = hand_back(other) {
2999 log_line(&format!("finish: {e}"));
3000 }
3001 }
3002 let Some(issue) = issue else {
3003 continue;
3004 };
3005 if finished {
3006 continue;
3007 }
3008 let named = book.repos.get(repo).is_some_and(|row| row.named);
3009 let done = if named {
3010 hand_back(&issue)
3011 } else {
3012 close_issue(&issue)
3013 };
3014 match done {
3015 Ok(()) => {
3016 if let Some(row) = book.repos.get_mut(repo) {
3017 row.finished = true;
3018 }
3019 }
3020 Err(e) => log_line(&format!("finish: {e}")),
3021 }
3022 }
3023}
3024
3025fn close_issue(issue: &str) -> Result<(), String> {
3028 run_timed("vissue", &["update", issue, "-s", "DONE"], &[]).map_err(|e| e.to_string())?;
3029 let assignee = super::resolve_assignee(None);
3030 if which::which("claimdag").is_ok() {
3031 if let Err(e) = super::release(issue, &assignee) {
3032 log_line(&format!("release: {e:#}"));
3033 }
3034 }
3035 let line = super::persist_tracker(issue, "finished");
3036 if line.contains("could not") || line.contains("refused") {
3037 return Err(line.trim().to_string());
3038 }
3039 Ok(())
3040}
3041
3042fn hand_back(issue: &str) -> Result<(), String> {
3047 let assignee = super::resolve_assignee(None);
3048 if which::which("claimdag").is_ok() {
3049 if let Err(e) = super::release(issue, &assignee) {
3050 log_line(&format!("release: {e:#}"));
3051 if let Err(e) = super::unstamp_tracker(issue, &assignee, true) {
3052 log_line(&format!("release: {e:#}"));
3053 }
3054 }
3055 } else if let Err(e) = super::unstamp_tracker(issue, &assignee, true) {
3056 log_line(&format!("release: {e:#}"));
3057 }
3058 let line = super::persist_tracker(issue, "released");
3059 if line.contains("could not") || line.contains("refused") {
3060 return Err(line.trim().to_string());
3061 }
3062 Ok(())
3063}
3064
3065const NAMED_IDS_MAX: usize = 8;
3067
3068#[must_use]
3073pub fn named_open_issue(message: &str) -> Option<String> {
3074 named_open_issue_with(message, |id| {
3075 super::issue_record(id).ok().and_then(|v| {
3076 (v["id"].as_str() == Some(id)).then(|| v["state"].as_str().unwrap_or("").to_string())
3077 })
3078 })
3079}
3080
3081fn named_open_issue_with(message: &str, state: impl Fn(&str) -> Option<String>) -> Option<String> {
3084 let mut seen: Vec<String> = Vec::new();
3085 for raw in message.split_whitespace() {
3086 let token = raw.trim_matches(|c: char| {
3087 matches!(
3088 c,
3089 ',' | '.'
3090 | ':'
3091 | ';'
3092 | '!'
3093 | '?'
3094 | '"'
3095 | '\''
3096 | '`'
3097 | '('
3098 | ')'
3099 | '['
3100 | ']'
3101 | '{'
3102 | '}'
3103 | '<'
3104 | '>'
3105 | '#'
3106 )
3107 });
3108 if !is_issue_id(token) || seen.iter().any(|s| s == token) {
3109 continue;
3110 }
3111 if seen.len() >= NAMED_IDS_MAX {
3112 break;
3113 }
3114 seen.push(token.to_string());
3115 if !matches!(state(token).as_deref(), Some("DONE" | "CANCELLED") | None) {
3116 return Some(token.to_string());
3117 }
3118 }
3119 None
3120}
3121
3122pub(crate) fn book_named_commit(repo: &str, head: &str, issue: &str) {
3129 let _held = super::CommitLock::acquire(&super::runtime_dir().join("book.lock"));
3130 let (path, key) =
3131 open_row_for_repo(repo).unwrap_or_else(|| (book_path(&sitting_key()), repo.to_string()));
3132 let mut book = load_book(&path);
3133 book_named_in(&mut book, &key, repo, head, issue);
3134 store_book(&path, &book);
3135}
3136
3137fn named_head(repo: &str) -> Option<(String, String)> {
3140 let said = run_timed("git", &["-C", repo, "log", "-1", "--format=%H%n%B"], &[]).ok()?;
3141 let (sha, message) = said.stdout.split_once('\n')?;
3142 let sha = sha.trim();
3143 if sha.is_empty() {
3144 return None;
3145 }
3146 named_open_issue(message).map(|issue| (issue, sha.to_string()))
3147}
3148
3149fn book_named_in(book: &mut Book, key: &str, repo: &str, head: &str, issue: &str) {
3154 let key = key.to_string();
3155 let now = unix_secs();
3156 let row = book.repos.entry(key.clone()).or_default();
3157 let open = row.issue.clone().filter(|_| !row.finished);
3158 let holds = open.as_deref() == Some(issue);
3159 let held_also = row.also.iter().any(|other| other == issue);
3160 let placeholder = open
3165 .clone()
3166 .filter(|held| held != issue && !held_also && empty_filed_row(row, held));
3167 if let Some(auto) = &placeholder {
3168 cancel_placeholder(auto, issue);
3169 }
3170 let open = if placeholder.is_some() { None } else { open };
3171 if !holds && !held_also {
3172 match claim_existing(issue) {
3173 Ok(()) => {
3174 let line = super::persist_tracker(issue, "claimed");
3175 if line.contains("could not") || line.contains("refused") {
3176 log_line(line.trim());
3177 }
3178 }
3179 Err(e) => log_line(&format!("claim: {issue}: {e}")),
3180 }
3181 }
3182 let cited = cite_deed(issue, head);
3183 if let Err(e) = &cited {
3184 log_line(&format!("deed: {e}"));
3185 }
3186 let row = book.repos.entry(key.clone()).or_default();
3187 match open {
3188 Some(ref held) if held == issue => {
3189 if cited.is_ok() && !row.deeds.iter().any(|seen| seen == head) {
3190 row.deeds.push(head.to_string());
3191 }
3192 }
3193 Some(_) => {
3194 if !held_also {
3195 row.also.push(issue.to_string());
3196 }
3197 if !row.elsewhere.iter().any(|seen| seen == head) {
3198 row.elsewhere.push(head.to_string());
3199 }
3200 }
3201 None if placeholder.is_some() => {
3202 row.lesson = false;
3206 row.lesson_id = None;
3207 row.lesson_title = None;
3208 row.issue = Some(issue.to_string());
3209 row.named = true;
3210 row.filed = false;
3211 row.started = false;
3212 row.finished = false;
3213 row.recall = recall_line(issue);
3214 row.elsewhere.retain(|seen| seen != head);
3215 if cited.is_ok() && !row.deeds.iter().any(|seen| seen == head) {
3216 row.deeds.push(head.to_string());
3217 }
3218 }
3219 None => {
3220 *row = RepoBook {
3221 issue: Some(issue.to_string()),
3222 named: true,
3223 deeds: if cited.is_ok() {
3224 vec![head.to_string()]
3225 } else {
3226 Vec::new()
3227 },
3228 start_sha: Some(head.to_string()),
3229 recall: recall_line(issue),
3230 ..RepoBook::default()
3231 };
3232 }
3233 }
3234 book.last_unix = now;
3235 if !repo.is_empty() {
3236 book.last_repo = repo.to_string();
3237 }
3238 stamp_touch(book, &key, now);
3239}
3240
3241fn empty_filed_row(row: &RepoBook, held: &str) -> bool {
3246 if !row.filed || row.named || row.started || !row.deeds.is_empty() {
3247 return false;
3248 }
3249 if !this_seat_booked(held) {
3250 return false;
3251 }
3252 super::issue_record(held)
3253 .is_ok_and(|v| v["deeds"].as_array().is_none_or(|deeds| deeds.is_empty()))
3254}
3255
3256fn cancel_placeholder(auto: &str, named: &str) {
3261 let assignee = super::resolve_assignee(None);
3262 if which::which("claimdag").is_ok() {
3263 if let Err(e) = super::release(auto, &assignee) {
3264 log_line(&format!("release: {e:#}"));
3265 }
3266 }
3267 let state = super::issue_record(auto)
3268 .ok()
3269 .and_then(|v| v["state"].as_str().map(str::to_string))
3270 .unwrap_or_default();
3271 if !matches!(state.as_str(), "STARTED" | "TODO") {
3272 log_line(&format!(
3273 "claim: left {auto} {state}, not a placeholder now"
3274 ));
3275 return;
3276 }
3277 match run_timed(
3278 "vissue",
3279 &["update", auto, "-s", "CANCELLED", "--if-state", &state],
3280 &[],
3281 ) {
3282 Ok(_) => {
3283 let line = super::persist_tracker(auto, "cancelled");
3284 if line.contains("could not") || line.contains("refused") {
3285 log_line(line.trim());
3286 }
3287 log_line(&format!(
3288 "claim: cancelled {auto}, filed for this sitting with no deed; the commit named {named}"
3289 ));
3290 }
3291 Err(e) => log_line(&format!("claim: could not cancel {auto}: {e}")),
3292 }
3293}
3294
3295pub fn note_ask(raw: &str, call: &super::HookCall, reason: &str) {
3298 if !note_ask_within(raw, call, reason) {
3299 super::book_queue::enqueue_ask(raw, call, reason);
3300 }
3301}
3302
3303pub(crate) fn note_ask_within(raw: &str, call: &super::HookCall, reason: &str) -> bool {
3306 if !matches!(call.event.as_str(), "PreToolUse" | "argv") {
3307 return true;
3308 }
3309 let reason = collapse(reason);
3310 if reason.is_empty() {
3311 return true;
3312 }
3313 let value: Value = serde_json::from_str(raw.trim()).unwrap_or(Value::Null);
3314 let Some(_held) = book_lock() else {
3315 return false;
3316 };
3317 let path = book_path(&session_key(call));
3318 let mut book = load_book(&path);
3319 let repo = repo_key(&mut book, &value);
3320 note_start(&mut book, &repo);
3323 let now = unix_secs();
3324 let row = book.repos.entry(repo).or_default();
3325 row.last_unix = now;
3326 let short: String = reason.chars().take(160).collect();
3327 if row.asks.len() < 4 && !row.asks.iter().any(|seen| seen == &short) {
3328 row.asks.push(short);
3329 }
3330 book.last_unix = now;
3331 store_book(&path, &book);
3332 true
3333}
3334
3335fn note_failure(book: &mut Book, repo: &str, text: &str) {
3336 let text = collapse(text);
3337 if text.is_empty() {
3338 return;
3339 }
3340 let short: String = text.chars().take(160).collect();
3341 let row = book.repos.entry(repo.to_string()).or_default();
3342 if row.failures.len() < 4 && !row.failures.iter().any(|seen| seen == &short) {
3343 row.failures.push(short);
3344 }
3345}
3346
3347fn failure_text(raw: &Value, cue: &str) -> String {
3348 let err = raw
3349 .pointer("/tool_response/error")
3350 .and_then(Value::as_str)
3351 .or_else(|| raw.get("error").and_then(Value::as_str))
3352 .or_else(|| raw.get("error_message").and_then(Value::as_str))
3353 .unwrap_or("")
3354 .trim();
3355 if !err.is_empty() {
3356 return err.to_string();
3357 }
3358 if !cue.trim().is_empty() {
3359 return cue.trim().to_string();
3360 }
3361 "a tool failed".to_string()
3362}
3363
3364fn write_lesson(
3376 issue: &str,
3377 title: &str,
3378 shas: &[String],
3379 failures: &[String],
3380 asks: &[String],
3381 replaces: Option<&str>,
3382) -> Result<Option<String>, String> {
3383 let text = lesson_text(title, issue, shas, failures, asks);
3384 super::packset_write_agent_sourced(
3385 "Remember",
3386 &text,
3387 Some(false),
3388 title,
3389 &join_notes(asks),
3390 &join_notes(failures),
3391 issue,
3392 replaces,
3393 )
3394 .map(|posted| posted["id"].as_str().map(str::to_string))
3395 .map_err(|e| noted_err(&e))
3396}
3397
3398fn join_notes(notes: &[String]) -> String {
3401 notes
3402 .iter()
3403 .map(|line| line.trim())
3404 .filter(|line| !line.is_empty())
3405 .collect::<Vec<_>>()
3406 .join("\n")
3407}
3408
3409fn noted_err(err: &anyhow::Error) -> String {
3412 let mut out = String::new();
3413 for cause in err.chain() {
3414 let text = cause.to_string();
3415 if text.is_empty() {
3416 continue;
3417 }
3418 if !out.is_empty() {
3419 out.push_str(": ");
3420 }
3421 out.push_str(&text);
3422 }
3423 if out.is_empty() {
3424 err.to_string()
3425 } else {
3426 out
3427 }
3428}
3429
3430fn lesson_text(
3437 title: &str,
3438 issue: &str,
3439 shas: &[String],
3440 failures: &[String],
3441 asks: &[String],
3442) -> String {
3443 let issue = issue.trim();
3444 let shas: Vec<String> = shas
3445 .iter()
3446 .take(3)
3447 .map(|sha| sha.chars().take(7).collect::<String>())
3448 .filter(|sha| !sha.is_empty())
3449 .collect();
3450 let verb = failures.first().map(|text| command_verb(text));
3451 let rule = asks.iter().find_map(|ask| rule_name(ask));
3452 let second = second_sentence(verb.as_deref(), rule.as_deref());
3453 let mut words = title_words(title);
3454 let mut shortening = true;
3455 for _ in 0..256 {
3456 let text = lesson_sentence(&words, issue, &shas, &second);
3457 if lesson_prose_ok(&text) {
3458 return text;
3459 }
3460 if shortening && shorten_longest(&mut words) {
3461 continue;
3462 }
3463 shortening = false;
3464 if words.len() <= 1 {
3465 return text;
3466 }
3467 let drop_at = words
3468 .iter()
3469 .enumerate()
3470 .min_by_key(|(i, word)| (word.chars().count(), *i))
3471 .map(|(i, _)| i)
3472 .unwrap_or(0);
3473 words.remove(drop_at);
3474 }
3475 lesson_sentence(&words, issue, &shas, &second)
3476}
3477
3478const COMMAND_RUNNERS: &[&str] = &[
3480 "git", "cargo", "npm", "pnpm", "yarn", "bun", "npx", "pip", "uv", "go", "make", "python",
3481 "python3", "node",
3482];
3483
3484fn status_word(word: &str) -> bool {
3486 matches!(
3487 word.to_ascii_lowercase().as_str(),
3488 "exited" | "failed" | "failure" | "error" | "nonzero" | "non-zero"
3489 )
3490}
3491
3492fn command_verb(text: &str) -> String {
3496 let words = super::shell_words(text);
3497 if let Some(verb) = runner_subcommand(&words) {
3498 return verb;
3499 }
3500 words
3501 .into_iter()
3502 .find(|word| !super::is_lesson_filler(word) && !status_word(word))
3503 .map(|word| word.to_ascii_lowercase())
3504 .unwrap_or_else(|| "tool".to_string())
3505}
3506
3507fn runner_subcommand(words: &[String]) -> Option<String> {
3509 let mut i = 0usize;
3510 if words
3511 .first()
3512 .is_some_and(|word| word.eq_ignore_ascii_case("sudo"))
3513 {
3514 i += 1;
3515 }
3516 let bin = words.get(i)?.rsplit('/').next().unwrap_or("");
3517 let bin_name = bin.to_ascii_lowercase();
3518 if !COMMAND_RUNNERS.contains(&bin_name.as_str()) {
3519 return None;
3520 }
3521 i += 1;
3522 if bin_name == "git" {
3523 while i < words.len() {
3524 match words[i].as_str() {
3525 "-C" | "-c" | "--git-dir" | "--work-tree" => i += 2,
3526 flag if flag.starts_with("--git-dir=") || flag.starts_with("--work-tree=") => {
3527 i += 1;
3528 }
3529 flag if flag.starts_with('-') => i += 1,
3530 other => return Some(other.to_ascii_lowercase()),
3531 }
3532 }
3533 return Some(bin_name);
3534 }
3535 Some(
3536 words
3537 .iter()
3538 .skip(i)
3539 .find(|word| !word.starts_with('-'))
3540 .map_or(bin_name, |word| word.to_ascii_lowercase()),
3541 )
3542}
3543
3544const ASK_BOILER: &[&str] = &["guard", "asked", "about", "tool", "failed", "failure"];
3547
3548fn rule_token(token: &str) -> bool {
3550 !token.is_empty()
3551 && token
3552 .chars()
3553 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
3554 && token.chars().any(|c| c.is_ascii_alphabetic())
3555 && !super::is_lesson_filler(token)
3556}
3557
3558fn rule_name(ask: &str) -> Option<String> {
3561 let ask = ask.trim();
3562 if ask.is_empty() {
3563 return None;
3564 }
3565 if let Some((head, _)) = ask.split_once(':') {
3566 let head = head.trim();
3567 if rule_token(head) {
3568 return Some(head.to_string());
3569 }
3570 }
3571 for raw in ask.split_whitespace() {
3572 let token = raw.trim_matches(|c: char| !c.is_ascii_alphanumeric());
3573 if token.contains('-') && rule_token(token) {
3574 return Some(token.to_string());
3575 }
3576 }
3577 let named: Vec<String> = title_words(ask)
3578 .into_iter()
3579 .filter(|word| !ASK_BOILER.contains(&word.to_ascii_lowercase().as_str()))
3580 .take(2)
3581 .collect();
3582 if named.is_empty() {
3583 None
3584 } else {
3585 Some(named.join(" "))
3586 }
3587}
3588
3589fn second_sentence(verb: Option<&str>, rule: Option<&str>) -> String {
3592 match (verb, rule) {
3593 (Some(verb), Some(rule)) => {
3594 format!(
3595 "{} failed and the guard asked about {rule}.",
3596 capitalize_word(verb)
3597 )
3598 }
3599 (Some(verb), None) => format!("{} failed.", capitalize_word(verb)),
3600 (None, Some(rule)) => format!("The guard asked about {rule}."),
3601 (None, None) => String::new(),
3602 }
3603}
3604
3605fn capitalize_word(word: &str) -> String {
3606 let mut chars = word.chars();
3607 match chars.next() {
3608 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
3609 None => String::new(),
3610 }
3611}
3612
3613fn title_words(title: &str) -> Vec<String> {
3616 let words: Vec<String> = title
3617 .split(|c: char| !c.is_alphanumeric())
3618 .filter(|word| !word.is_empty() && !super::is_lesson_filler(word))
3619 .map(str::to_string)
3620 .collect();
3621 if !words.is_empty() {
3622 return words;
3623 }
3624 title
3625 .split(|c: char| !c.is_alphanumeric())
3626 .filter(|word| !word.is_empty())
3627 .take(3)
3628 .map(str::to_string)
3629 .collect()
3630}
3631
3632fn shorten_longest(words: &mut [String]) -> bool {
3635 let Some(at) = words
3636 .iter()
3637 .enumerate()
3638 .filter(|(_, word)| word.chars().count() > 4)
3639 .max_by_key(|(i, word)| (word.chars().count(), *i))
3640 .map(|(i, _)| i)
3641 else {
3642 return false;
3643 };
3644 words[at].pop();
3645 true
3646}
3647
3648fn lesson_sentence(words: &[String], issue: &str, shas: &[String], second: &str) -> String {
3649 let head = words.join(" ");
3650 let issue = issue.trim();
3651 let first = if issue.is_empty() {
3652 if shas.is_empty() {
3655 if head.is_empty() {
3656 String::new()
3657 } else {
3658 format!("The session worked on {head}.")
3659 }
3660 } else if head.is_empty() {
3661 format!("Committed {}.", shas.join(" "))
3662 } else {
3663 format!("{head} committed {}.", shas.join(" "))
3664 }
3665 } else if shas.is_empty() {
3666 if head.is_empty() {
3667 format!("Work {issue} closed.")
3668 } else {
3669 format!("{head} ({issue}) closed.")
3670 }
3671 } else if head.is_empty() {
3672 format!("Work {issue} committed {}.", shas.join(" "))
3673 } else {
3674 format!("{head} ({issue}) committed {}.", shas.join(" "))
3675 };
3676 if second.is_empty() {
3677 first
3678 } else if first.is_empty() {
3679 second.to_string()
3680 } else {
3681 format!("{first} {second}")
3682 }
3683}
3684
3685fn lesson_prose_ok(text: &str) -> bool {
3688 packset_core::prose_refuse(text, packset_core::ProseRole::Atom).is_ok()
3689}
3690
3691fn bind_prompt(book: &mut Book, repo: &str, now: u64) {
3694 if repo.is_empty() || book.prompt.trim().is_empty() {
3695 return;
3696 }
3697 book.prompt_repo = repo.to_string();
3698 book.prompt_unix = now;
3699}
3700
3701fn clear_issue_fields_if_issue_changes(row: &mut RepoBook, issue: &str) {
3707 if row.issue.as_deref() == Some(issue) {
3708 return;
3709 }
3710 if row.issue.is_none() {
3711 row.lesson = false;
3712 row.lesson_id = None;
3713 row.lesson_title = None;
3714 return;
3715 }
3716 row.deeds.clear();
3717 row.landed.clear();
3718 row.landed_head = None;
3719 row.failures.clear();
3720 row.asks.clear();
3721 row.lesson = false;
3722 row.lesson_id = None;
3723 row.lesson_title = None;
3724 row.title = None;
3725 row.named = false;
3726 row.also.clear();
3727 row.elsewhere.clear();
3728 row.filed = false;
3729 row.start_sha = None;
3730}
3731
3732fn hold_lesson_title(row: &mut RepoBook) {
3735 if !(row.lesson || row.lesson_id.is_some()) {
3736 return;
3737 }
3738 if row
3739 .lesson_title
3740 .as_deref()
3741 .map(str::trim)
3742 .unwrap_or("")
3743 .is_empty()
3744 {
3745 if let Some(title) = row.title.clone().filter(|title| !title.trim().is_empty()) {
3746 row.lesson_title = Some(title);
3747 }
3748 }
3749}
3750
3751fn lesson_written(row: &mut RepoBook, title: &str, id: Option<String>) {
3753 if row
3754 .lesson_title
3755 .as_deref()
3756 .map(str::trim)
3757 .unwrap_or("")
3758 .is_empty()
3759 && !title.trim().is_empty()
3760 {
3761 row.lesson_title = Some(title.to_string());
3762 }
3763 row.lesson = true;
3764 row.lesson_id = id;
3765}
3766
3767fn lesson_title_of(book: &Book, repo: &str, row: &RepoBook) -> String {
3770 let kept = if row.lesson || row.lesson_id.is_some() {
3771 row.lesson_title
3772 .as_deref()
3773 .map(str::trim)
3774 .filter(|title| !title.is_empty())
3775 } else {
3776 None
3777 };
3778 let heard = match kept {
3779 Some(title) => title.to_string(),
3780 None => heard_title(book, repo, row),
3781 };
3782 title_from(&heard, repo)
3783}
3784
3785fn heard_title(book: &Book, repo: &str, row: &RepoBook) -> String {
3788 if prompt_belongs_to(book, repo) {
3789 let prompt = book.prompt.trim();
3790 if !prompt.is_empty() {
3791 return prompt.to_string();
3792 }
3793 }
3794 row.title.clone().unwrap_or_default()
3795}
3796
3797fn prompt_live_for(book: &Book, repo: &str, now: u64) -> bool {
3801 if !prompt_belongs_to(book, repo) {
3802 return false;
3803 }
3804 let Some(limit) = sitting_idle() else {
3805 return true;
3806 };
3807 let earlier = book.repos.iter().any(|(key, row)| {
3808 same_checkout(key, repo) && row_is_idle(now, book, key, row, limit, Some(repo))
3809 });
3810 if earlier {
3811 return false;
3812 }
3813 let open = book
3814 .repos
3815 .iter()
3816 .any(|(key, row)| same_checkout(key, repo) && !row.finished && sitting_open(row));
3817 if open {
3818 return true;
3819 }
3820 let stamped = if book.prompt_unix > 0 {
3824 book.prompt_unix
3825 } else {
3826 book.last_unix
3827 };
3828 !idle_due(now, stamped, limit)
3829}
3830
3831fn prompt_belongs_to(book: &Book, repo: &str) -> bool {
3832 let prompt = book.prompt.trim();
3833 if prompt.is_empty() || repo.is_empty() {
3834 return false;
3835 }
3836 let recorded = book.prompt_repo.trim();
3837 if !recorded.is_empty() {
3838 return same_checkout(recorded, repo);
3839 }
3840 book.repos.iter().any(|(key, row)| {
3841 !row.finished
3842 && same_checkout(key, repo)
3843 && row
3844 .title
3845 .as_deref()
3846 .is_some_and(|title| title.trim() == prompt)
3847 })
3848}
3849
3850fn release_spent_prompt(book: &mut Book) {
3852 if book.prompt.trim().is_empty() {
3853 return;
3854 }
3855 let recorded = book.prompt_repo.trim();
3856 let still = if !recorded.is_empty() {
3857 book.repos.keys().any(|repo| same_checkout(repo, recorded))
3858 } else {
3859 let prompt = book.prompt.trim();
3860 book.repos.values().any(|row| {
3861 !row.finished
3862 && row
3863 .title
3864 .as_deref()
3865 .is_some_and(|title| title.trim() == prompt)
3866 })
3867 };
3868 if !still {
3869 book.prompt.clear();
3870 book.prompt_repo.clear();
3871 book.prompt_unix = 0;
3872 }
3873}
3874
3875fn row_is_idle(
3880 now: u64,
3881 book: &Book,
3882 repo: &str,
3883 row: &RepoBook,
3884 limit: Duration,
3885 current: Option<&str>,
3886) -> bool {
3887 if !sitting_open(row) {
3888 return false;
3889 }
3890 if row.last_unix > 0 {
3891 return idle_due(now, row.last_unix, limit);
3892 }
3893 if idle_due(now, book.last_unix, limit) {
3894 return true;
3895 }
3896 matches!(
3897 current,
3898 Some(cur) if !cur.is_empty() && book.last_unix > 0 && !same_checkout(cur, repo)
3899 )
3900}
3901
3902fn stamp_touch(book: &mut Book, repo: &str, now: u64) {
3903 if !repo.is_empty() {
3904 if let Some(row) = book.repos.get_mut(repo) {
3905 row.last_unix = now;
3906 }
3907 }
3908 book.last_unix = now;
3909}
3910
3911fn remember_title(book: &mut Book, repo: &str) {
3915 let prompt: String = collapse(&book.prompt).chars().take(4000).collect();
3916 if prompt.is_empty() || repo.is_empty() {
3917 return;
3918 }
3919 let row = book.repos.entry(repo.to_string()).or_default();
3920 hold_lesson_title(row);
3921 row.title = Some(prompt);
3922}
3923
3924fn title_from(prompt: &str, repo: &str) -> String {
3925 let forced = std::env::var("LJOS_PROJECT").ok();
3926 let tracker = configured_tracker();
3927 title_at(prompt, repo, forced.as_deref(), tracker.as_deref())
3928}
3929
3930fn title_at(prompt: &str, repo: &str, forced: Option<&str>, tracker: Option<&Path>) -> String {
3934 let words: String = prompt
3935 .split_whitespace()
3936 .take(12)
3937 .collect::<Vec<_>>()
3938 .join(" ");
3939 let title: String = words.chars().take(80).collect();
3940 if !title.is_empty() {
3941 return title;
3942 }
3943 if repo.trim().is_empty() {
3944 return "agent work".to_string();
3945 }
3946 let project = project_for_at(repo, forced, tracker);
3947 if project.trim().is_empty() {
3948 "agent work".to_string()
3949 } else {
3950 format!("Work in {project}")
3951 }
3952}
3953
3954fn project_for(repo: &str) -> String {
3955 let forced = std::env::var("LJOS_PROJECT").ok();
3956 let tracker = configured_tracker();
3957 project_for_at(repo, forced.as_deref(), tracker.as_deref())
3958}
3959
3960fn configured_tracker() -> Option<PathBuf> {
3965 for key in ["ISSUE_ROOT", "VISSUE_ROOT"] {
3966 if let Some(path) = env_path(key) {
3967 return Some(expand_home_path(&path));
3968 }
3969 }
3970 let path = vissue_config_path()?;
3971 let text = std::fs::read_to_string(path).ok()?;
3972 let root = tracker_root_from_config(&text)?;
3973 root.is_dir().then_some(root)
3974}
3975
3976fn env_path(key: &str) -> Option<PathBuf> {
3977 let value = std::env::var_os(key)?;
3978 if value.is_empty() {
3979 None
3980 } else {
3981 Some(PathBuf::from(value))
3982 }
3983}
3984
3985fn vissue_config_path() -> Option<PathBuf> {
3986 if let Some(path) = env_path("VISSUE_CONFIG") {
3987 return Some(path);
3988 }
3989 let base = std::env::var_os("XDG_CONFIG_HOME")
3990 .filter(|value| !value.is_empty())
3991 .map(PathBuf::from)
3992 .or_else(|| {
3993 std::env::var_os("HOME")
3994 .filter(|home| !home.is_empty())
3995 .map(|home| PathBuf::from(home).join(".config"))
3996 })?;
3997 Some(base.join("vissue").join("config.toml"))
3998}
3999
4000fn tracker_root_from_config(text: &str) -> Option<PathBuf> {
4003 for line in text.lines() {
4004 let line = line.trim();
4005 if line.is_empty() || line.starts_with('#') {
4006 continue;
4007 }
4008 if line.starts_with('[') {
4009 return None;
4010 }
4011 let (key, _) = line.split_once('=')?;
4012 if key.trim() != "root" {
4013 continue;
4014 }
4015 let value: toml::Value = toml::from_str(line).ok()?;
4016 let root = value.get("root").and_then(toml::Value::as_str)?;
4017 if root.is_empty() {
4018 return None;
4019 }
4020 return Some(expand_home(root));
4021 }
4022 None
4023}
4024
4025fn expand_home(raw: &str) -> PathBuf {
4026 if raw == "~" || raw.starts_with("~/") {
4027 if let Some(home) = std::env::var_os("HOME").filter(|home| !home.is_empty()) {
4028 let rest = raw.strip_prefix("~/").unwrap_or("");
4029 return PathBuf::from(home).join(rest);
4030 }
4031 }
4032 PathBuf::from(raw)
4033}
4034
4035fn expand_home_path(path: &Path) -> PathBuf {
4036 path.to_str()
4037 .map(expand_home)
4038 .unwrap_or_else(|| path.to_path_buf())
4039}
4040
4041fn project_for_at(repo: &str, forced: Option<&str>, tracker: Option<&Path>) -> String {
4042 if let Some(project) = forced.map(str::trim).filter(|project| !project.is_empty()) {
4043 return project.to_string();
4044 }
4045 let remote = git_remote_url(repo);
4046 if let Some(tracker) = tracker {
4047 if let Some(project) = match_remote_project(repo, tracker) {
4048 return project;
4049 }
4050 }
4051 if let Some(name) = remote.as_deref().and_then(remote_repo_name) {
4055 return name;
4056 }
4057 if let Some(tracker) = tracker {
4058 if let Some(project) = sole_project_in(tracker) {
4059 return project;
4060 }
4061 }
4062 Path::new(repo)
4063 .file_name()
4064 .and_then(|name| name.to_str())
4065 .filter(|name| !name.is_empty())
4066 .unwrap_or("agent")
4067 .to_string()
4068}
4069
4070fn match_remote_project(repo: &str, tracker: &Path) -> Option<String> {
4074 let url = git_remote_url(repo)?;
4075 let want = normalize_remote(&url);
4076 if want.is_empty() {
4077 return None;
4078 }
4079 let projects = projects_in(tracker);
4080 for (name, dir) in &projects {
4081 if project_records_remote(dir, &want) {
4082 return Some(name.clone());
4083 }
4084 if tracker_project_remote(tracker, name).is_some_and(|url| normalize_remote(&url) == want) {
4085 return Some(name.clone());
4086 }
4087 }
4088 let slug = remote_slug(&want);
4089 projects
4090 .into_iter()
4091 .find(|(name, _)| name.eq_ignore_ascii_case(&slug))
4092 .map(|(name, _)| name)
4093}
4094
4095fn remote_repo_name(url: &str) -> Option<String> {
4102 let raw = url.trim().trim_end_matches('/');
4103 if raw.starts_with(['/', '.', '~']) || raw.starts_with("file:") {
4104 return None;
4105 }
4106 let raw = raw.strip_suffix(".git").unwrap_or(raw);
4107 let name = raw.rsplit(['/', ':']).next()?.trim();
4108 let ok = !name.is_empty()
4109 && name
4110 .chars()
4111 .next()
4112 .is_some_and(|c| c.is_ascii_alphanumeric())
4113 && name
4114 .chars()
4115 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
4116 ok.then(|| name.to_string())
4117}
4118
4119fn git_remote_url(repo: &str) -> Option<String> {
4120 if repo.is_empty() {
4121 return None;
4122 }
4123 if let Some(url) = origin_from_checkout(repo) {
4128 return Some(url);
4129 }
4130 git_remote_from_command(repo)
4131}
4132
4133fn origin_from_checkout(repo: &str) -> Option<String> {
4137 let git_dir = checkout_git_dir(Path::new(repo))?;
4138 let common = common_git_dir(&git_dir);
4139 let config = common.join("config");
4140 if config.is_file() {
4141 return origin_url_in(&config);
4142 }
4143 origin_url_in(&git_dir.join("config"))
4144}
4145
4146fn checkout_git_dir(repo: &Path) -> Option<PathBuf> {
4147 let dot = repo.join(".git");
4148 if dot.is_dir() {
4149 return Some(dot);
4150 }
4151 if !dot.is_file() {
4152 return None;
4153 }
4154 let text = std::fs::read_to_string(&dot).ok()?;
4155 let line = text
4156 .lines()
4157 .find(|line| line.trim_start().starts_with("gitdir:"))?;
4158 let raw = line.trim().trim_start_matches("gitdir:").trim();
4159 if raw.is_empty() {
4160 return None;
4161 }
4162 Some(join_git_path(repo, raw))
4163}
4164
4165fn common_git_dir(git_dir: &Path) -> PathBuf {
4166 let Ok(text) = std::fs::read_to_string(git_dir.join("commondir")) else {
4167 return git_dir.to_path_buf();
4168 };
4169 let raw = text.trim();
4170 if raw.is_empty() {
4171 git_dir.to_path_buf()
4172 } else {
4173 join_git_path(git_dir, raw)
4174 }
4175}
4176
4177fn join_git_path(base: &Path, raw: &str) -> PathBuf {
4178 let path = Path::new(raw);
4179 let joined = if path.is_absolute() {
4180 path.to_path_buf()
4181 } else {
4182 base.join(path)
4183 };
4184 std::fs::canonicalize(&joined).unwrap_or(joined)
4185}
4186
4187fn origin_url_in(path: &Path) -> Option<String> {
4188 origin_url_in_config(&std::fs::read_to_string(path).ok()?)
4189}
4190
4191fn origin_url_in_config(text: &str) -> Option<String> {
4194 let mut origin = false;
4195 for raw in text.lines() {
4196 let line = raw.trim();
4197 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
4198 continue;
4199 }
4200 if line.starts_with('[') && line.ends_with(']') {
4201 origin = section_is_origin(line);
4202 continue;
4203 }
4204 if !origin {
4205 continue;
4206 }
4207 let Some((key, value)) = line.split_once('=') else {
4208 continue;
4209 };
4210 if !key.trim().eq_ignore_ascii_case("url") {
4211 continue;
4212 }
4213 let value = unquote_config(value.trim());
4214 if !value.is_empty() {
4215 return Some(value);
4216 }
4217 }
4218 None
4219}
4220
4221fn section_is_origin(header: &str) -> bool {
4222 let inner = header
4223 .trim()
4224 .trim_start_matches('[')
4225 .trim_end_matches(']')
4226 .trim();
4227 let mut parts = inner.split_whitespace();
4228 if !parts
4229 .next()
4230 .is_some_and(|name| name.eq_ignore_ascii_case("remote"))
4231 {
4232 return false;
4233 }
4234 let Some(name) = parts.next() else {
4235 return false;
4236 };
4237 name.trim_matches(|c| c == '"' || c == '\'')
4238 .eq_ignore_ascii_case("origin")
4239 && parts.next().is_none()
4240}
4241
4242fn unquote_config(raw: &str) -> String {
4243 if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') {
4244 return raw[1..raw.len() - 1]
4245 .replace("\\\"", "\"")
4246 .replace("\\\\", "\\");
4247 }
4248 raw.to_string()
4249}
4250
4251fn git_remote_from_command(repo: &str) -> Option<String> {
4256 use std::io::Read;
4257 use std::process::{Command, Stdio};
4258 let path = which::which("git").ok()?;
4259 let mut cmd = Command::new(path);
4260 cmd.args(["-C", repo, "config", "--get", "remote.origin.url"])
4261 .stdin(Stdio::null())
4262 .stdout(Stdio::piped())
4263 .stderr(Stdio::null());
4264 crate::apply_git_command_env(&mut cmd);
4267 for key in [
4268 "GIT_DIR",
4269 "GIT_WORK_TREE",
4270 "GIT_INDEX_FILE",
4271 "GIT_PREFIX",
4272 "GIT_OBJECT_DIRECTORY",
4273 "GIT_COMMON_DIR",
4274 "GIT_NAMESPACE",
4275 ] {
4276 cmd.env_remove(key);
4277 }
4278 let mut child = cmd.spawn().ok()?;
4279 let started = Instant::now();
4280 loop {
4281 match child.try_wait() {
4282 Ok(Some(status)) => {
4283 if !status.success() {
4284 return None;
4285 }
4286 let mut stdout = String::new();
4287 if let Some(mut out) = child.stdout.take() {
4288 let _ = out.read_to_string(&mut stdout);
4289 }
4290 let url = stdout.lines().next().unwrap_or("").trim();
4291 return (!url.is_empty()).then(|| url.to_string());
4292 }
4293 Ok(None) if started.elapsed() > COMMAND_DEADLINE => {
4294 let _ = child.kill();
4295 let _ = child.wait();
4296 return None;
4297 }
4298 Ok(None) => std::thread::sleep(Duration::from_millis(10)),
4299 Err(_) => return None,
4300 }
4301 }
4302}
4303
4304fn normalize_remote(raw: &str) -> String {
4305 let mut raw = raw.trim().trim_end_matches('/').to_string();
4306 if let Some(stripped) = raw.strip_suffix(".git") {
4307 raw = stripped.to_string();
4308 }
4309 for prefix in ["ssh://", "git://", "https://", "http://"] {
4310 if let Some(rest) = raw.strip_prefix(prefix) {
4311 raw = rest.to_string();
4312 break;
4313 }
4314 }
4315 if let Some((user, host)) = raw.split_once('@') {
4318 if looks_like_userinfo(user)
4322 && (host.contains('/') || host.contains(':') || host.contains('.'))
4323 {
4324 raw = host.to_string();
4325 }
4326 }
4327 if let Some((host, path)) = raw.split_once(':') {
4328 if !host.contains('/') {
4329 raw = format!("{host}/{}", path.trim_start_matches('/'));
4330 }
4331 }
4332 raw.to_ascii_lowercase()
4333}
4334
4335fn looks_like_userinfo(user: &str) -> bool {
4338 if user.is_empty() || user.contains(' ') {
4339 return false;
4340 }
4341 match user.find('/') {
4342 None => true,
4343 Some(slash) => user[..slash].contains(':'),
4344 }
4345}
4346
4347fn remote_slug(normalized: &str) -> String {
4348 normalized
4349 .rsplit('/')
4350 .next()
4351 .unwrap_or(normalized)
4352 .to_string()
4353}
4354
4355fn project_records_remote(dir: &Path, want: &str) -> bool {
4356 recorded_remotes(dir)
4357 .iter()
4358 .any(|url| normalize_remote(url) == want)
4359}
4360
4361fn recorded_remotes(dir: &Path) -> Vec<String> {
4365 let mut out = Vec::new();
4366 if let Ok(text) = std::fs::read_to_string(dir.join("issues.org")) {
4367 for line in text.lines() {
4368 if let Some(url) = header_remote(line) {
4369 out.push(url);
4370 }
4371 }
4372 }
4373 for name in ["remote.url", "repo.url"] {
4374 let Ok(text) = std::fs::read_to_string(dir.join(name)) else {
4375 continue;
4376 };
4377 for line in text.lines() {
4378 let line = line.trim();
4379 if line.is_empty() || line.starts_with('#') || line.contains(char::is_whitespace) {
4380 continue;
4381 }
4382 out.push(line.to_string());
4383 }
4384 }
4385 out.extend(toml_remotes(&dir.join("project.toml")));
4386 out
4387}
4388
4389fn header_remote(line: &str) -> Option<String> {
4391 let rest = line.trim().strip_prefix("#+")?;
4392 let (key, value) = rest.split_once(':')?;
4393 if !key.eq_ignore_ascii_case("REMOTE") {
4394 return None;
4395 }
4396 let value = value.trim().trim_matches('"').trim();
4397 if value.is_empty() {
4398 None
4399 } else {
4400 Some(value.to_string())
4401 }
4402}
4403
4404fn toml_remotes(path: &Path) -> Vec<String> {
4405 let Ok(text) = std::fs::read_to_string(path) else {
4406 return Vec::new();
4407 };
4408 let Ok(value) = toml::from_str::<toml::Value>(&text) else {
4409 return Vec::new();
4410 };
4411 let mut out = Vec::new();
4412 push_remote(&mut out, &value);
4413 if let Some(project) = value.get("project") {
4414 push_remote(&mut out, project);
4415 }
4416 out
4417}
4418
4419fn push_remote(out: &mut Vec<String>, value: &toml::Value) {
4420 let Some(url) = value.get("remote").and_then(toml::Value::as_str) else {
4421 return;
4422 };
4423 let url = url.trim();
4424 if !url.is_empty() {
4425 out.push(url.to_string());
4426 }
4427}
4428
4429fn tracker_project_remote(tracker: &Path, project: &str) -> Option<String> {
4431 let text = std::fs::read_to_string(tracker.join("vissue.toml")).ok()?;
4432 let value: toml::Value = toml::from_str(&text).ok()?;
4433 let url = value
4434 .get("projects")?
4435 .get(project)?
4436 .get("remote")?
4437 .as_str()?
4438 .trim();
4439 if url.is_empty() {
4440 None
4441 } else {
4442 Some(url.to_string())
4443 }
4444}
4445
4446fn projects_in(root: &Path) -> Vec<(String, PathBuf)> {
4447 let mut found = Vec::new();
4448 let mut consider = |dir: PathBuf| {
4449 if dir.join("issues.org").is_file() {
4450 if let Some(name) = dir.file_name().and_then(|name| name.to_str()) {
4451 found.push((name.to_string(), dir));
4452 }
4453 }
4454 };
4455 let Ok(entries) = std::fs::read_dir(root) else {
4456 return found;
4457 };
4458 for entry in entries.flatten() {
4459 let path = entry.path();
4460 if entry.file_name() == "Software" {
4461 if let Ok(children) = std::fs::read_dir(&path) {
4462 for child in children.flatten() {
4463 consider(child.path());
4464 }
4465 }
4466 } else {
4467 consider(path);
4468 }
4469 }
4470 found
4471}
4472
4473fn sole_project_in(root: &Path) -> Option<String> {
4474 let mut found: Vec<String> = projects_in(root)
4475 .into_iter()
4476 .map(|(name, _)| name)
4477 .collect();
4478 found.sort();
4479 found.dedup();
4480 (found.len() == 1).then(|| found.remove(0))
4481}
4482
4483fn recall_line(issue: &str) -> String {
4488 let head = format!("Recall for {issue} (`ljos recall {issue}` shows its full working set):");
4489 match super::tracker_show_json(issue) {
4490 Ok(card) => match card.get("title").and_then(Value::as_str) {
4491 Some(title) if !title.trim().is_empty() => format!("{head}\n{title}"),
4492 _ => head,
4493 },
4494 Err(_) => head,
4495 }
4496}
4497
4498pub fn print_brief_once() {
4502 if guard_suspended() {
4503 return;
4504 }
4505 let path = brief_mark(&brief_key());
4506 if path.exists() {
4507 return;
4508 }
4509 if let Some(dir) = path.parent() {
4510 let _ = std::fs::create_dir_all(dir);
4511 }
4512 if std::fs::OpenOptions::new()
4513 .write(true)
4514 .create_new(true)
4515 .open(&path)
4516 .is_err()
4517 {
4518 return;
4519 }
4520 let text = seat_brief();
4521 if text.is_empty() {
4522 return;
4523 }
4524 eprintln!("{text}");
4525 let _ = std::io::Write::flush(&mut std::io::stderr());
4526}
4527
4528fn brief_key() -> String {
4529 let seat = std::env::var("LJOS_SEAT")
4530 .ok()
4531 .map(|value| value.trim().to_string())
4532 .filter(|value| !value.is_empty())
4533 .unwrap_or_else(|| "seat".into());
4534 let convo = std::env::var("CURSOR_CONVERSATION_ID")
4535 .ok()
4536 .map(|value| value.trim().to_string())
4537 .filter(|value| !value.is_empty())
4538 .or_else(|| {
4539 std::env::var("LJOS_SHELL_SESSION")
4540 .ok()
4541 .map(|value| value.trim().to_string())
4542 .filter(|value| !value.is_empty())
4543 })
4544 .unwrap_or_else(process_sid);
4545 format!("{seat}:{convo}")
4546}
4547
4548fn process_sid() -> String {
4549 let sid = unsafe { libc::getsid(0) };
4551 if sid > 0 {
4552 sid.to_string()
4553 } else {
4554 std::process::id().to_string()
4555 }
4556}
4557
4558fn brief_mark(key: &str) -> PathBuf {
4559 let safe: String = key
4560 .chars()
4561 .map(|c| {
4562 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
4563 c
4564 } else {
4565 '_'
4566 }
4567 })
4568 .collect();
4569 super::runtime_dir().join("brief").join(safe)
4570}
4571
4572fn seat_brief() -> String {
4573 format!(
4574 "claims: {}\nrecall: {}\nmail: {}",
4575 held_claims_line(),
4576 brief_recall_line(),
4577 brief_mail_line()
4578 )
4579}
4580
4581fn held_claims_line() -> String {
4582 let book = load_book(&book_path(&sitting_key()));
4583 let mut ids: Vec<String> = book
4584 .repos
4585 .values()
4586 .filter(|row| !row.finished)
4587 .filter_map(|row| row.issue.clone())
4588 .collect();
4589 ids.sort();
4590 ids.dedup();
4591 if ids.is_empty() {
4592 "none".into()
4593 } else {
4594 ids.join(" ")
4595 }
4596}
4597
4598fn brief_recall_line() -> String {
4599 let book = load_book(&book_path(&sitting_key()));
4600 for row in book.repos.values() {
4601 if row.finished {
4602 continue;
4603 }
4604 if let Some(title) = row
4605 .title
4606 .as_deref()
4607 .map(str::trim)
4608 .filter(|t| !t.is_empty())
4609 {
4610 return one_line(title);
4611 }
4612 if !row.recall.trim().is_empty() {
4613 return one_line(&row.recall);
4614 }
4615 }
4616 "none".into()
4617}
4618
4619fn brief_mail_line() -> String {
4620 let url = std::env::var("PACKSET_URL").unwrap_or_default();
4621 let url = url.trim();
4622 if url.is_empty() || url == "off" {
4623 return "none".into();
4624 }
4625 let (text, _) = super::mail::prompt_note();
4626 let line = one_line(&text);
4627 if line.is_empty() || line.contains("nothing unread") {
4628 "none".into()
4629 } else {
4630 line
4631 }
4632}
4633
4634#[must_use]
4637pub fn commit_recall_line(session: &str, repo: &str, subject: &str) -> Option<String> {
4638 let book = load_book(&book_path(session));
4639 let row = book.repos.get(repo).filter(|row| row.issue.is_some())?;
4640 let title = row
4641 .title
4642 .clone()
4643 .filter(|title| !title.trim().is_empty())
4644 .unwrap_or_else(|| subject.trim().to_string());
4645 let body = format!("{title}\n{}", row.recall);
4646 if !relevant(subject, &body) {
4647 return None;
4648 }
4649 let line = one_line(&title);
4650 if line.is_empty() {
4651 None
4652 } else {
4653 Some(format!("recall: {line}"))
4654 }
4655}
4656
4657fn relevant(subject: &str, body: &str) -> bool {
4658 let subject = subject.trim();
4659 if subject.is_empty() {
4660 return false;
4661 }
4662 let body = body.to_ascii_lowercase();
4663 if body.contains(&subject.to_ascii_lowercase()) {
4664 return true;
4665 }
4666 subject
4667 .split(|c: char| !c.is_alphanumeric())
4668 .filter(|word| word.len() >= 4)
4669 .any(|word| body.contains(&word.to_ascii_lowercase()))
4670}
4671
4672fn one_line(text: &str) -> String {
4673 text.split_whitespace().collect::<Vec<_>>().join(" ")
4674}
4675
4676#[derive(Clone, Copy)]
4680enum CommandExit {
4681 Code(i64),
4683 Unknown,
4686 Absent,
4688}
4689
4690fn command_failed(exit: CommandExit) -> bool {
4692 matches!(exit, CommandExit::Code(code) if code != 0)
4693}
4694
4695fn commit_landed(exit: CommandExit, raw: &Value, repo: &str, start: Option<&str>) -> bool {
4701 match exit {
4702 CommandExit::Code(0) => return true,
4703 CommandExit::Code(_) => return false,
4704 CommandExit::Unknown | CommandExit::Absent => {}
4705 }
4706 if raw
4707 .pointer("/tool_response/interrupted")
4708 .and_then(Value::as_bool)
4709 == Some(true)
4710 || permission_block(raw)
4711 {
4712 return false;
4713 }
4714 git_operation_committed(raw) || stdout_has_commit_banner(raw) || head_moved(repo, start)
4715}
4716
4717fn git_operation_committed(raw: &Value) -> bool {
4720 [
4721 "/gitOperation",
4722 "/git_operation",
4723 "/tool_response/gitOperation",
4724 "/tool_response/git_operation",
4725 "/toolResponse/gitOperation",
4726 ]
4727 .iter()
4728 .any(|slot| raw.pointer(slot).is_some_and(commit_record_landed))
4729}
4730
4731fn commit_record_landed(value: &Value) -> bool {
4732 if value.get("commit").is_some_and(commit_record_landed) {
4733 return true;
4734 }
4735 let sha = value
4736 .get("sha")
4737 .and_then(Value::as_str)
4738 .unwrap_or("")
4739 .trim();
4740 if sha_token(sha) {
4741 return true;
4742 }
4743 let kind = value
4744 .get("kind")
4745 .and_then(Value::as_str)
4746 .unwrap_or("")
4747 .trim();
4748 matches!(kind, "committed" | "amended" | "cherry-picked")
4749}
4750
4751fn sha_token(text: &str) -> bool {
4752 (7..=40).contains(&text.len()) && text.chars().all(|c| c.is_ascii_hexdigit())
4753}
4754
4755fn branch_token(text: &str) -> bool {
4756 !text.is_empty()
4757 && text
4758 .chars()
4759 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'))
4760}
4761
4762fn stdout_has_commit_banner(raw: &Value) -> bool {
4764 commit_output(raw)
4765 .iter()
4766 .any(|text| text.lines().any(line_has_commit_banner))
4767}
4768
4769fn commit_output(raw: &Value) -> Vec<&str> {
4770 let mut out = Vec::new();
4771 for key in [
4772 "tool_response",
4773 "toolResponse",
4774 "stdout",
4775 "stderr",
4776 "output",
4777 ] {
4778 if let Some(text) = raw.get(key).and_then(Value::as_str) {
4779 out.push(text);
4780 }
4781 }
4782 for slot in [
4783 "/tool_response/stdout",
4784 "/tool_response/output",
4785 "/tool_response/stderr",
4786 "/toolResponse/stdout",
4787 "/toolResponse/output",
4788 "/toolResponse/stderr",
4789 ] {
4790 if let Some(text) = raw.pointer(slot).and_then(Value::as_str) {
4791 out.push(text);
4792 }
4793 }
4794 out
4795}
4796
4797fn line_has_commit_banner(line: &str) -> bool {
4798 let mut from = 0;
4799 while let Some(rel) = line[from..].find('[') {
4800 let start = from + rel + 1;
4801 let Some(end_rel) = line[start..].find(']') else {
4802 break;
4803 };
4804 if banner_inside(&line[start..start + end_rel]) {
4805 return true;
4806 }
4807 from = start;
4808 }
4809 false
4810}
4811
4812fn banner_inside(inside: &str) -> bool {
4813 let mut rest = inside.trim();
4814 if rest.is_empty() {
4815 return false;
4816 }
4817 if let Some(after) = rest.strip_prefix("detached HEAD") {
4818 rest = after.trim_start();
4819 } else {
4820 let Some((branch, after)) = rest.split_once(|c: char| c.is_whitespace()) else {
4821 return false;
4822 };
4823 if !branch_token(branch) {
4824 return false;
4825 }
4826 rest = after.trim_start();
4827 }
4828 if let Some(after) = rest.strip_prefix("(root-commit)") {
4829 rest = after.trim_start();
4830 }
4831 let Some((sha, tail)) = rest.split_once(|c: char| c.is_whitespace()) else {
4832 return sha_token(rest);
4833 };
4834 tail.trim().is_empty() && sha_token(sha)
4835}
4836
4837fn head_moved(repo: &str, start: Option<&str>) -> bool {
4841 let Some(start) = start.map(str::trim).filter(|sha| !sha.is_empty()) else {
4842 return false;
4843 };
4844 head_sha(repo).is_ok_and(|head| head != start)
4845}
4846
4847fn command_exit(raw: &Value) -> CommandExit {
4851 if let Some(code) = explicit_exit(raw) {
4852 return CommandExit::Code(code);
4853 }
4854 if let Some(code) = transcript_exit(raw) {
4855 return CommandExit::Code(code);
4856 }
4857 if response_is_text(raw) {
4858 return CommandExit::Unknown;
4859 }
4860 CommandExit::Absent
4861}
4862
4863fn response_is_text(raw: &Value) -> bool {
4866 raw.get("tool_response")
4867 .or_else(|| raw.get("toolResponse"))
4868 .is_some_and(Value::is_string)
4869}
4870
4871fn tool_use_id(raw: &Value) -> Option<&str> {
4872 ["tool_use_id", "toolUseId", "tool_call_id", "toolCallId"]
4873 .iter()
4874 .find_map(|key| raw.get(*key).and_then(Value::as_str))
4875 .map(str::trim)
4876 .filter(|id| !id.is_empty())
4877}
4878
4879fn transcript_exit(raw: &Value) -> Option<i64> {
4885 let path = raw
4886 .get("transcript_path")
4887 .and_then(Value::as_str)
4888 .or_else(|| raw.get("transcriptPath").and_then(Value::as_str))
4889 .map(str::trim)
4890 .filter(|path| !path.is_empty())?;
4891 let call_id = tool_use_id(raw)?;
4892 let text = read_transcript_tail(&super::expand(path))?;
4893 exit_for_call(&text, call_id)
4894}
4895
4896const TRANSCRIPT_DEADLINE: Duration = COMMAND_DEADLINE;
4899
4900const TRANSCRIPT_TAIL_BYTES: u64 = 1024 * 1024;
4903
4904fn read_transcript_tail(path: &Path) -> Option<String> {
4905 read_bounded(path, TRANSCRIPT_TAIL_BYTES, true)
4906}
4907
4908pub(crate) fn read_text_bounded(path: &Path) -> Option<String> {
4912 read_transcript_tail(path)
4913}
4914
4915fn read_bounded(path: &Path, max_bytes: u64, tail: bool) -> Option<String> {
4919 use std::os::unix::fs::FileTypeExt;
4920 let mut file = open_nonblock(path)?;
4921 let kind = file.metadata().ok().map(|meta| meta.file_type());
4922 if kind.as_ref().is_some_and(|ty| ty.is_fifo()) {
4926 return read_available(&mut file, max_bytes, Duration::ZERO);
4927 }
4928 if kind.as_ref().is_some_and(|ty| ty.is_file()) {
4929 if tail {
4930 return read_file_tail(&mut file, max_bytes);
4931 }
4932 return read_available(&mut file, max_bytes, TRANSCRIPT_DEADLINE);
4933 }
4934 read_available(&mut file, max_bytes, Duration::ZERO)
4935}
4936
4937fn open_nonblock(path: &Path) -> Option<std::fs::File> {
4938 use std::os::unix::ffi::OsStrExt;
4939 use std::os::unix::io::FromRawFd;
4940 let bytes = path.as_os_str().as_bytes();
4941 let c_path = std::ffi::CString::new(bytes).ok()?;
4942 let fd = unsafe {
4944 libc::open(
4945 c_path.as_ptr(),
4946 libc::O_RDONLY | libc::O_NONBLOCK | libc::O_CLOEXEC,
4947 )
4948 };
4949 if fd < 0 {
4950 return None;
4951 }
4952 Some(unsafe { std::fs::File::from_raw_fd(fd) })
4954}
4955
4956fn wait_readable(fd: i32, deadline: Duration) -> bool {
4957 use std::io::ErrorKind;
4958 let start = Instant::now();
4959 loop {
4960 let elapsed = start.elapsed();
4961 let left = deadline.saturating_sub(elapsed);
4962 let ms = i32::try_from(left.as_millis()).unwrap_or(i32::MAX);
4963 let mut polled = libc::pollfd {
4964 fd,
4965 events: libc::POLLIN,
4966 revents: 0,
4967 };
4968 let rc = unsafe { libc::poll(&mut polled, 1, ms) };
4970 if rc < 0 {
4971 let err = std::io::Error::last_os_error();
4972 if err.kind() == ErrorKind::Interrupted && start.elapsed() < deadline {
4973 continue;
4974 }
4975 return false;
4976 }
4977 if rc == 0 {
4978 return false;
4979 }
4980 let rev = polled.revents;
4981 if (rev & libc::POLLIN) != 0 {
4982 return true;
4983 }
4984 if (rev & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL)) != 0 {
4985 return false;
4986 }
4987 if start.elapsed() >= deadline {
4988 return false;
4989 }
4990 }
4991}
4992
4993fn read_file_tail(file: &mut std::fs::File, max_bytes: u64) -> Option<String> {
4994 use std::io::{Read, Seek, SeekFrom};
4995 let len = file.metadata().ok()?.len();
4996 let start = len.saturating_sub(max_bytes);
4997 file.seek(SeekFrom::Start(start)).ok()?;
4998 let mut buf = Vec::new();
4999 file.take(max_bytes).read_to_end(&mut buf).ok()?;
5000 let mut text = String::from_utf8_lossy(&buf).into_owned();
5001 if start > 0 {
5002 if let Some(pos) = text.find('\n') {
5003 text.drain(..=pos);
5004 }
5005 }
5006 Some(text)
5007}
5008
5009fn read_available(file: &mut std::fs::File, max_bytes: u64, deadline: Duration) -> Option<String> {
5010 use std::io::{ErrorKind, Read};
5011 use std::os::unix::io::AsRawFd;
5012 let start = Instant::now();
5013 let cap = usize::try_from(max_bytes).unwrap_or(usize::MAX);
5014 let mut buf = Vec::new();
5015 let mut tmp = [0u8; 8192];
5016 while buf.len() < cap {
5017 let room = cap - buf.len();
5018 let nread = room.min(tmp.len());
5019 match file.read(&mut tmp[..nread]) {
5020 Ok(0) => break,
5021 Ok(n) => {
5022 buf.extend_from_slice(&tmp[..n]);
5023 if !deadline.is_zero() && start.elapsed() >= deadline {
5024 break;
5025 }
5026 }
5027 Err(err) if err.kind() == ErrorKind::Interrupted => {
5028 if !deadline.is_zero() && start.elapsed() >= deadline {
5029 break;
5030 }
5031 continue;
5032 }
5033 Err(err) if err.kind() == ErrorKind::WouldBlock => {
5034 let left = deadline.saturating_sub(start.elapsed());
5035 if left.is_zero() || !wait_readable(file.as_raw_fd(), left) {
5036 break;
5037 }
5038 }
5039 Err(_) => break,
5040 }
5041 }
5042 if buf.is_empty() {
5043 None
5044 } else {
5045 Some(String::from_utf8_lossy(&buf).into_owned())
5046 }
5047}
5048
5049struct TranscriptExit {
5052 item: Option<i64>,
5053 spoken: Option<i64>,
5054}
5055
5056impl TranscriptExit {
5057 fn code(self) -> Option<i64> {
5058 self.item.or(self.spoken)
5059 }
5060}
5061
5062fn exit_for_call(text: &str, call_id: &str) -> Option<i64> {
5066 let mut found = TranscriptExit {
5067 item: None,
5068 spoken: None,
5069 };
5070 for line in text.lines() {
5071 note_line_exit(line, call_id, &mut found);
5072 }
5073 found.code()
5074}
5075
5076fn note_line_exit(line: &str, call_id: &str, found: &mut TranscriptExit) {
5077 if !line.contains(call_id) {
5078 return;
5079 }
5080 if let Ok(value) = serde_json::from_str::<Value>(line) {
5081 walk_exit(&value, call_id, found);
5082 } else {
5083 exit_in_text(line, call_id, found);
5084 }
5085}
5086
5087fn walk_exit(value: &Value, call_id: &str, found: &mut TranscriptExit) {
5088 match value {
5089 Value::Array(items) => {
5090 for item in items {
5091 walk_exit(item, call_id, found);
5092 }
5093 }
5094 Value::Object(map) => {
5095 if let Some(code) = command_execution_code(map, call_id) {
5096 found.item = Some(code);
5097 }
5098 if let Some(code) = function_output_code(map, call_id) {
5099 found.spoken = Some(code);
5100 }
5101 for child in map.values() {
5102 walk_exit(child, call_id, found);
5103 }
5104 }
5105 _ => {}
5106 }
5107}
5108
5109fn command_execution_code(map: &serde_json::Map<String, Value>, call_id: &str) -> Option<i64> {
5111 if map.get("type").and_then(Value::as_str) != Some("CommandExecution") {
5112 return None;
5113 }
5114 if map.get("id").and_then(Value::as_str) != Some(call_id) {
5115 return None;
5116 }
5117 ["exit_code", "exitCode"]
5118 .iter()
5119 .find_map(|key| map.get(*key).and_then(exit_number))
5120}
5121
5122fn function_output_code(map: &serde_json::Map<String, Value>, call_id: &str) -> Option<i64> {
5126 if map.get("type").and_then(Value::as_str) != Some("function_call_output") {
5127 return None;
5128 }
5129 let matched = map
5130 .get("call_id")
5131 .or_else(|| map.get("callId"))
5132 .and_then(Value::as_str)
5133 == Some(call_id);
5134 if !matched {
5135 return None;
5136 }
5137 map.get("output")
5138 .and_then(Value::as_str)
5139 .and_then(process_exit_code)
5140}
5141
5142fn process_exit_code(text: &str) -> Option<i64> {
5143 let marker = "Process exited with code ";
5144 let rest = text.get(text.find(marker)? + marker.len()..)?;
5145 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
5146 if digits.is_empty() {
5147 None
5148 } else {
5149 digits.parse().ok()
5150 }
5151}
5152
5153fn exit_number(slot: &Value) -> Option<i64> {
5154 if let Some(code) = slot.as_i64() {
5155 return Some(code);
5156 }
5157 slot.as_str().and_then(|text| text.trim().parse().ok())
5158}
5159
5160fn exit_in_text(line: &str, call_id: &str, found: &mut TranscriptExit) {
5164 let mut from = 0;
5165 while let Some(rel) = line[from..].find("CommandExecution") {
5166 let start = from + rel;
5167 let rest = start + "CommandExecution".len();
5168 let end = line[rest..]
5169 .find("CommandExecution")
5170 .map(|rel| rest + rel)
5171 .unwrap_or(line.len());
5172 let window = &line[start..end];
5173 if find_key(window, "\"id\"", call_id).is_some() {
5174 if let Some(code) = exit_code_in(window) {
5175 found.item = Some(code);
5176 }
5177 }
5178 from = rest;
5179 }
5180 let mut from = 0;
5181 while let Some(start) = find_key(&line[from..], "\"call_id\"", call_id) {
5182 let start = from + start;
5183 let after = start + "\"call_id\"".len();
5184 let end = line[after..]
5185 .find("\"call_id\"")
5186 .map(|rel| after + rel)
5187 .unwrap_or(line.len());
5188 if let Some(code) = process_exit_code(&line[start..end]) {
5189 found.spoken = Some(code);
5190 }
5191 from = after;
5192 }
5193}
5194
5195fn find_key(text: &str, key: &str, want: &str) -> Option<usize> {
5196 let mut from = 0;
5197 while from < text.len() {
5198 let Some(rel) = text[from..].find(key) else {
5199 break;
5200 };
5201 let at = from + rel;
5202 let after = at + key.len();
5203 if json_string_is(text.get(after..).unwrap_or(""), want) {
5204 return Some(at);
5205 }
5206 from = after;
5207 }
5208 None
5209}
5210
5211fn json_string_is(text: &str, want: &str) -> bool {
5212 let rest = text.trim_start();
5213 let Some(rest) = rest.strip_prefix(':') else {
5214 return false;
5215 };
5216 let Some(rest) = rest.trim_start().strip_prefix('"') else {
5217 return false;
5218 };
5219 rest.strip_prefix(want)
5220 .is_some_and(|rest| rest.starts_with('"'))
5221}
5222
5223fn exit_code_in(text: &str) -> Option<i64> {
5224 for key in ["\"exit_code\"", "\"exitCode\""] {
5225 let Some(at) = text.find(key) else {
5226 continue;
5227 };
5228 let rest = text[at + key.len()..].trim_start();
5229 let Some(rest) = rest.strip_prefix(':') else {
5230 continue;
5231 };
5232 if let Some(code) = parse_exit_token(rest.trim_start()) {
5233 return Some(code);
5234 }
5235 }
5236 None
5237}
5238
5239fn parse_exit_token(rest: &str) -> Option<i64> {
5240 let digits = |text: &str| -> Option<i64> {
5241 let num: String = text
5242 .chars()
5243 .take_while(|c| c.is_ascii_digit() || *c == '-')
5244 .collect();
5245 if num.is_empty() {
5246 None
5247 } else {
5248 num.parse().ok()
5249 }
5250 };
5251 if let Some(rest) = rest.strip_prefix('"') {
5252 return digits(rest);
5253 }
5254 digits(rest)
5255}
5256
5257fn explicit_exit(raw: &Value) -> Option<i64> {
5258 let slots = [
5259 raw.get("exit_code"),
5260 raw.get("exit"),
5261 raw.pointer("/tool_response/exit_code"),
5262 raw.pointer("/tool_response/exit"),
5263 raw.pointer("/tool_response/exitCode"),
5264 raw.pointer("/tool_output/exit_code"),
5265 ];
5266 for slot in slots.into_iter().flatten() {
5267 if let Some(code) = slot.as_i64() {
5268 return Some(code);
5269 }
5270 if let Some(code) = slot.as_str() {
5271 return code.trim().parse().ok();
5272 }
5273 }
5274 None
5275}
5276
5277fn permission_block(raw: &Value) -> bool {
5281 let decisions = [
5282 raw.get("permissionDecision"),
5283 raw.get("permission_decision"),
5284 raw.pointer("/hookSpecificOutput/permissionDecision"),
5285 raw.pointer("/tool_response/permissionDecision"),
5286 raw.get("decision"),
5287 ];
5288 decisions.into_iter().flatten().any(|slot| {
5289 slot.as_str()
5290 .is_some_and(|word| matches!(word.trim(), "deny" | "ask" | "block"))
5291 })
5292}
5293
5294fn redirect_targets(line: &str) -> Vec<String> {
5296 let mut out = Vec::new();
5297 for seg in super::nested_lines(line, 0)
5298 .iter()
5299 .flat_map(|l| super::raw_segments(l))
5300 {
5301 let words = super::shell_words(&seg);
5302 let mut i = 0;
5303 while i < words.len() {
5304 let word = words[i].as_str();
5305 if word == ">" || word == ">>" {
5306 if let Some(target) = words.get(i + 1) {
5307 if let Some(target) = redirect_path(target) {
5308 out.push(target);
5309 }
5310 }
5311 i += 2;
5312 continue;
5313 }
5314 if let Some(target) = glued_redirect(word) {
5315 out.push(target);
5316 }
5317 i += 1;
5318 }
5319 }
5320 out
5321}
5322
5323fn redirect_path(word: &str) -> Option<String> {
5324 let word = word.trim_matches(|c| c == '"' || c == '\'');
5325 if word.is_empty() || word.starts_with('&') {
5326 return None;
5327 }
5328 Some(word.to_string())
5329}
5330
5331fn glued_redirect(word: &str) -> Option<String> {
5332 let rest = word.trim_start_matches(|c: char| c.is_ascii_digit());
5333 let body = rest
5334 .strip_prefix(">>")
5335 .or_else(|| rest.strip_prefix("&>"))
5336 .or_else(|| rest.strip_prefix('>'))?;
5337 redirect_path(body)
5338}
5339
5340fn writes_worktree(repo: &str, line: &str) -> bool {
5345 let repo = Path::new(repo);
5346 if repo.as_os_str().is_empty() {
5347 return false;
5348 }
5349 redirect_targets(line)
5350 .into_iter()
5351 .any(|target| path_in_worktree(repo, &target))
5352}
5353
5354fn path_in_worktree(repo: &Path, target: &str) -> bool {
5355 if target.is_empty()
5356 || target.starts_with('~')
5357 || target.contains('$')
5358 || target.contains('*')
5359 || target.starts_with('&')
5360 {
5361 return false;
5362 }
5363 let target = Path::new(target);
5364 let joined = if target.is_absolute() {
5365 target.to_path_buf()
5366 } else {
5367 repo.join(target)
5368 };
5369 let repo = lexical_norm(repo);
5370 let joined = lexical_norm(&joined);
5371 joined.starts_with(&repo) && joined != repo
5372}
5373
5374fn lexical_norm(path: &Path) -> PathBuf {
5377 let mut out = PathBuf::new();
5378 for component in path.components() {
5379 match component {
5380 std::path::Component::CurDir => {}
5381 std::path::Component::ParentDir => {
5382 out.pop();
5383 }
5384 other => out.push(other.as_os_str()),
5385 }
5386 }
5387 out
5388}
5389
5390fn is_edit(raw: &Value, cue: &str) -> bool {
5391 if edit_name(&tool_name(raw)) || edit_name(cue.split_whitespace().next().unwrap_or("")) {
5392 return true;
5393 }
5394 command_is_apply_patch(cue)
5395}
5396
5397fn tool_name(raw: &Value) -> String {
5398 raw["tool_name"]
5399 .as_str()
5400 .or_else(|| raw["toolName"].as_str())
5401 .or_else(|| raw["toolCall"]["name"].as_str())
5402 .unwrap_or("")
5403 .to_string()
5404}
5405
5406fn edit_name(name: &str) -> bool {
5407 let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
5408 EDIT_TOOLS
5409 .iter()
5410 .any(|tool| tool.eq_ignore_ascii_case(base))
5411}
5412
5413fn command_is_apply_patch(line: &str) -> bool {
5414 super::nested_lines(line, 0)
5415 .iter()
5416 .flat_map(|l| super::raw_segments(l))
5417 .any(|seg| {
5418 let words = super::shell_words(&seg);
5419 let (at, _) = super::prefix_end(&words);
5420 words.get(at).is_some_and(|word| {
5421 word.rsplit('/')
5422 .next()
5423 .unwrap_or(word)
5424 .eq_ignore_ascii_case("apply_patch")
5425 })
5426 })
5427}
5428
5429fn is_git_commit(line: &str) -> bool {
5433 super::nested_lines(line, 0)
5434 .iter()
5435 .flat_map(|l| super::raw_segments(l))
5436 .any(|seg| {
5437 let words = super::shell_words(&seg);
5438 let (at, _) = super::prefix_end(&words);
5439 git_commit_argv(&words[at..])
5440 })
5441}
5442
5443fn git_commit_argv(words: &[String]) -> bool {
5444 let Some(bin) = words.first() else {
5445 return false;
5446 };
5447 if bin.rsplit('/').next().unwrap_or(bin) != "git" {
5448 return false;
5449 }
5450 let mut i = 1;
5451 while i < words.len() {
5452 match words[i].as_str() {
5453 "-C" | "-c" | "--git-dir" | "--work-tree" => i += 2,
5454 flag if flag.starts_with("--git-dir=") || flag.starts_with("--work-tree=") => i += 1,
5455 "commit" => return true,
5456 _ => return false,
5457 }
5458 }
5459 false
5460}
5461
5462fn parse_issue_id(text: &str) -> Option<String> {
5463 let mut found = None;
5464 for raw in text.split_whitespace() {
5465 let token = raw.trim_matches(|c: char| matches!(c, ',' | '.' | ':' | '"' | '\''));
5466 if is_issue_id(token) {
5467 found = Some(token.to_string());
5468 }
5469 }
5470 found
5471}
5472
5473fn is_issue_id(token: &str) -> bool {
5480 let Some((project, suffix)) = token.rsplit_once('-') else {
5481 return false;
5482 };
5483 issue_project(project) && issue_suffix(suffix)
5484}
5485
5486fn issue_project(project: &str) -> bool {
5487 let mut chars = project.chars();
5488 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic())
5489 && project
5490 .chars()
5491 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/'))
5492 && !project.ends_with('-')
5493 && !project.ends_with('/')
5494 && !project.contains("--")
5495}
5496
5497fn issue_suffix(suffix: &str) -> bool {
5498 suffix.len() >= 4
5501 && suffix
5502 .bytes()
5503 .all(|b| b.is_ascii_digit() || b.is_ascii_lowercase())
5504}
5505
5506fn parse_deed(text: &str) -> Option<String> {
5507 for raw in text.split(|c: char| c.is_whitespace() || matches!(c, '=' | '"' | '\'' | ',')) {
5508 let Some(rest) = raw.strip_prefix("deed-") else {
5509 continue;
5510 };
5511 if !rest.is_empty()
5512 && rest
5513 .chars()
5514 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
5515 {
5516 return Some(format!("deed-{rest}"));
5517 }
5518 }
5519 None
5520}
5521
5522struct Said {
5523 stdout: String,
5524}
5525
5526fn run_timed(bin: &str, args: &[&str], env: &[(&str, &str)]) -> anyhow::Result<Said> {
5527 use std::io::Read;
5528 use std::process::{Command, Stdio};
5529 let path = which::which(bin).map_err(|_| anyhow::anyhow!("{bin} not on PATH"))?;
5530 let mut cmd = Command::new(path);
5531 for (key, value) in env {
5532 cmd.env(key, value);
5533 }
5534 crate::git_hooks::hook_child_env(&mut cmd);
5535 let mut child = cmd
5536 .args(args)
5537 .stdin(Stdio::null())
5538 .stdout(Stdio::piped())
5539 .stderr(Stdio::piped())
5540 .spawn()
5541 .map_err(|e| anyhow::anyhow!("{bin}: {e}"))?;
5542 let started = std::time::Instant::now();
5543 loop {
5544 match child.try_wait() {
5545 Ok(Some(status)) => {
5546 let mut stdout = String::new();
5547 let mut stderr = String::new();
5548 if let Some(mut out) = child.stdout.take() {
5549 let _ = out.read_to_string(&mut stdout);
5550 }
5551 if let Some(mut err) = child.stderr.take() {
5552 let _ = err.read_to_string(&mut stderr);
5553 }
5554 if !status.success() {
5555 let why = if stderr.trim().is_empty() {
5556 stdout.trim()
5557 } else {
5558 stderr.trim()
5559 };
5560 anyhow::bail!("{bin} exited {status}: {why}");
5561 }
5562 return Ok(Said { stdout });
5563 }
5564 Ok(None) if started.elapsed() > COMMAND_DEADLINE => {
5565 let _ = child.kill();
5566 let _ = child.wait();
5567 anyhow::bail!("{bin} exceeded 2s");
5568 }
5569 Ok(None) => std::thread::sleep(Duration::from_millis(10)),
5570 Err(e) => anyhow::bail!("{bin}: {e}"),
5571 }
5572 }
5573}
5574
5575#[cfg(test)]
5576mod tests {
5577 use super::*;
5578 use std::sync::{Arc, Mutex};
5579
5580 fn lock() -> crate::EnvLock {
5581 crate::test_env_lock()
5582 }
5583
5584 #[test]
5587 fn a_rewritten_lesson_keeps_the_title_it_was_written_under() {
5588 let _lock = lock();
5589 let (dir, tracker, repo) = scratch();
5590 let _bins = arm(dir.path(), &tracker);
5591 let state = dir.path().join("state");
5592 std::fs::create_dir_all(&state).unwrap();
5593 unsafe { std::env::set_var("XDG_STATE_HOME", &state) };
5595 let proj = tracker.join("Software/probe");
5596 std::fs::create_dir_all(&proj).unwrap();
5597 std::fs::write(
5598 proj.join("issues.org"),
5599 "* TODO [#C] Add a slugify function\n:PROPERTIES:\n:ID: probe-00aa\n:END:\n",
5600 )
5601 .unwrap();
5602 let repo_s = git_toplevel(&repo).expect("toplevel");
5603 let old = head_sha(&repo_s).expect("head");
5604 let new = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string();
5605 let session = "jld5-title-session";
5606 let now = unix_secs();
5607 let mut book = Book {
5608 prompt: "Add a slugify function".into(),
5609 prompt_repo: repo_s.clone(),
5610 prompt_unix: now,
5611 last_unix: now,
5612 ..Book::default()
5613 };
5614 book.repos.insert(
5615 repo_s.clone(),
5616 RepoBook {
5617 issue: Some("probe-00aa".into()),
5618 title: Some("Add a slugify function".into()),
5619 deeds: vec![old.clone()],
5620 finished: true,
5621 lesson: true,
5622 lesson_id: Some("atom-old".into()),
5623 last_unix: now,
5624 ..RepoBook::default()
5625 },
5626 );
5627 store_book(&book_path(session), &book);
5628
5629 let (raw, call) = payload(
5630 "UserPromptSubmit",
5631 session,
5632 Path::new(&repo_s),
5633 serde_json::json!({"prompt": "Reword the message to say something else entirely"}),
5634 );
5635 let _ = observe(&raw, &call);
5636 repoint_commits(&repo_s, &[(old, new)]);
5637
5638 let queued =
5639 std::fs::read_to_string(state.join("ljos/note-queue.jsonl")).unwrap_or_default();
5640 assert!(
5641 queued.contains("slugify"),
5642 "the rewrite dropped the original title\n{queued}"
5643 );
5644 assert!(
5645 !queued.contains("Reword"),
5646 "the rewrite took the later prompt\n{queued}"
5647 );
5648 assert!(
5649 queued.contains("atom-old"),
5650 "the queued rewrite names nothing it closes\n{queued}"
5651 );
5652 }
5653
5654 #[test]
5658 fn a_second_issue_does_not_rewrite_with_the_first_issue_lesson() {
5659 let _g = lock();
5660 let _saved = Restore::new(&[
5661 "PATH",
5662 "VISSUE_ROOT",
5663 "VISSUE_NO_ROUTE",
5664 "LJOS_TRACKER_GIT",
5665 "LJOS_PROJECT",
5666 "XDG_RUNTIME_DIR",
5667 "XDG_STATE_HOME",
5668 "LJOS_IN_HOOK",
5669 "PACKSET_URL",
5670 "ISSUE_ROOT",
5671 "LJOS_OFF",
5672 "LJOS_SESSION_ID",
5673 ]);
5674 let (dir, tracker, repo) = scratch();
5675 let _bins = arm(dir.path(), &tracker);
5676 let state = dir.path().join("state");
5677 std::fs::create_dir_all(&state).unwrap();
5678 unsafe {
5680 std::env::set_var("XDG_STATE_HOME", &state);
5681 std::env::set_var("LJOS_SESSION_ID", "jld5-second-issue");
5682 std::env::remove_var("LJOS_SEAT");
5683 }
5684 let proj = tracker.join("Software/probe");
5685 std::fs::create_dir_all(&proj).unwrap();
5686 std::fs::write(
5687 proj.join("issues.org"),
5688 "\
5689* TODO [#C] Add a slugify function
5690:PROPERTIES:
5691:ID: probe-00aa
5692:END:
5693* TODO [#C] Now add a kebab-case helper
5694:PROPERTIES:
5695:ID: probe-00ab
5696:END:
5697",
5698 )
5699 .unwrap();
5700 let repo_s = git_toplevel(&repo).expect("toplevel");
5701 let old = head_sha(&repo_s).expect("head");
5702 let new = "cccccccccccccccccccccccccccccccccccccccc".to_string();
5703 let now = unix_secs();
5704 let mut book = Book {
5705 prompt: "Add a slugify function".into(),
5706 prompt_repo: repo_s.clone(),
5707 prompt_unix: now,
5708 last_unix: now,
5709 ..Book::default()
5710 };
5711 book.repos.insert(
5712 repo_s.clone(),
5713 RepoBook {
5714 issue: Some("probe-00aa".into()),
5715 title: Some("Add a slugify function".into()),
5716 deeds: vec![old.clone()],
5717 finished: true,
5718 lesson: true,
5719 lesson_id: Some("atom-a".into()),
5720 lesson_title: Some("Add a slugify function".into()),
5721 last_unix: now,
5722 ..RepoBook::default()
5723 },
5724 );
5725 store_book(&book_path("jld5-second-issue"), &book);
5726
5727 let said = start_work(
5728 Some("probe-00ab"),
5729 Some(repo.to_str().unwrap()),
5730 "Now add a kebab-case helper",
5731 )
5732 .expect("start the second issue");
5733 assert!(said.contains("probe-00ab"), "{said}");
5734
5735 let path = book_path("jld5-second-issue");
5736 let mut switched = load_book(&path);
5737 switched.prompt = "Now add a kebab-case helper".into();
5738 bind_prompt(&mut switched, &repo_s, unix_secs());
5739 let row = switched.repos.get_mut(&repo_s).expect("repo row");
5740 row.title = Some("Now add a kebab-case helper".into());
5741 row.deeds = vec![old.clone()];
5742 row.finished = true;
5743 row.lesson = true;
5744 store_book(&path, &switched);
5745
5746 repoint_commits(&repo_s, &[(old, new)]);
5747 let queued =
5748 std::fs::read_to_string(state.join("ljos/note-queue.jsonl")).unwrap_or_default();
5749 assert!(
5750 queued.contains("kebab case helper"),
5751 "the second issue title was lost\n{queued}"
5752 );
5753 assert!(
5754 !queued.contains("slugify"),
5755 "the first issue title leaked into the rewrite\n{queued}"
5756 );
5757 assert!(
5758 !queued.contains("atom-a"),
5759 "the second issue rewrite superseded the first issue atom\n{queued}"
5760 );
5761 }
5762
5763 #[test]
5767 fn a_rebind_does_not_cite_the_previous_issues_commits() {
5768 let _g = lock();
5769 let _saved = Restore::new(&[
5770 "PATH",
5771 "VISSUE_ROOT",
5772 "VISSUE_NO_ROUTE",
5773 "LJOS_TRACKER_GIT",
5774 "LJOS_PROJECT",
5775 "XDG_RUNTIME_DIR",
5776 "LJOS_IN_HOOK",
5777 "PACKSET_URL",
5778 "ISSUE_ROOT",
5779 "LJOS_OFF",
5780 "LJOS_SESSION_ID",
5781 "LJOS_SEAT",
5782 "LJOS_HARNESS_SESSION",
5783 ]);
5784 let (dir, tracker, repo) = scratch();
5785 let _bins = arm(dir.path(), &tracker);
5786 let session = "zdnr4a-rebind";
5787 unsafe {
5788 std::env::set_var("LJOS_SESSION_ID", session);
5789 std::env::remove_var("LJOS_SEAT");
5790 std::env::remove_var("LJOS_HARNESS_SESSION");
5791 }
5792 let proj = tracker.join("Software/probe");
5793 std::fs::create_dir_all(&proj).unwrap();
5794 std::fs::write(
5795 proj.join("issues.org"),
5796 "\
5797* TODO [#C] First issue
5798:PROPERTIES:
5799:ID: probe-00aa
5800:END:
5801* TODO [#C] Second issue
5802:PROPERTIES:
5803:ID: probe-00ab
5804:END:
5805",
5806 )
5807 .unwrap();
5808 let repo_s = git_toplevel(&repo).expect("toplevel");
5809 let seed = head_sha(&repo_s).expect("seed");
5810 let first = land_commit(&repo, "first.txt", "work for the first issue");
5811 let now = unix_secs();
5812 let mut book = Book {
5813 last_unix: now,
5814 ..Book::default()
5815 };
5816 book.repos.insert(
5817 repo_s.clone(),
5818 RepoBook {
5819 issue: Some("probe-00aa".into()),
5820 start_sha: Some(seed),
5821 deeds: vec![first.clone()],
5822 started: true,
5823 last_unix: now,
5824 ..RepoBook::default()
5825 },
5826 );
5827 store_book(&book_path(session), &book);
5828
5829 let said = start_work(
5830 Some("probe-00ab"),
5831 Some(repo.to_str().unwrap()),
5832 "Continue on the second issue",
5833 )
5834 .expect("rebind");
5835 assert!(said.contains("probe-00ab"), "{said}");
5836 assert!(
5837 cite_into_open_sitting(&repo_s),
5838 "the open sitting was not cited"
5839 );
5840 let row = load_book(&book_path(session))
5841 .repos
5842 .get(&repo_s)
5843 .cloned()
5844 .expect("row");
5845 assert_eq!(row.issue.as_deref(), Some("probe-00ab"));
5846 assert!(
5847 !row.deeds.iter().any(|sha| sha == &first),
5848 "rebind cited the previous issue's commit: {:?}",
5849 row.deeds
5850 );
5851 let later = land_commit(&repo, "later.txt", "work on the second issue");
5852 assert!(cite_into_open_sitting(&repo_s));
5853 let row = load_book(&book_path(session))
5854 .repos
5855 .get(&repo_s)
5856 .cloned()
5857 .expect("row after later commit");
5858 assert!(
5859 row.deeds.iter().any(|sha| sha == &later),
5860 "the commit after the rebind was not cited: {:?}",
5861 row.deeds
5862 );
5863 assert!(
5864 !row.deeds.iter().any(|sha| sha == &first),
5865 "the later citation still carried the previous commit: {:?}",
5866 row.deeds
5867 );
5868 }
5869
5870 #[test]
5873 fn adopt_row_rebind_does_not_cite_the_previous_issues_commits() {
5874 let _g = lock();
5875 let _saved = Restore::new(&[
5876 "PATH",
5877 "VISSUE_ROOT",
5878 "VISSUE_NO_ROUTE",
5879 "LJOS_TRACKER_GIT",
5880 "LJOS_PROJECT",
5881 "XDG_RUNTIME_DIR",
5882 "LJOS_IN_HOOK",
5883 "PACKSET_URL",
5884 "ISSUE_ROOT",
5885 "LJOS_OFF",
5886 "LJOS_SESSION_ID",
5887 "LJOS_SEAT",
5888 ]);
5889 let (dir, tracker, repo) = scratch();
5890 let _bins = arm(dir.path(), &tracker);
5891 let proj = tracker.join("Software/probe");
5892 std::fs::create_dir_all(&proj).unwrap();
5893 std::fs::write(
5894 proj.join("issues.org"),
5895 "\
5896* TODO [#C] First issue
5897:PROPERTIES:
5898:ID: probe-00aa
5899:END:
5900* TODO [#C] Second issue
5901:PROPERTIES:
5902:ID: probe-00ab
5903:END:
5904",
5905 )
5906 .unwrap();
5907 let repo_s = git_toplevel(&repo).expect("toplevel");
5908 let seed = head_sha(&repo_s).expect("seed");
5909 let first = land_commit(&repo, "first.txt", "work for the first issue");
5910 let mut book = Book::default();
5911 book.repos.insert(
5912 repo_s.clone(),
5913 RepoBook {
5914 issue: Some("probe-00aa".into()),
5915 start_sha: Some(seed.clone()),
5916 deeds: vec![first.clone()],
5917 ..RepoBook::default()
5918 },
5919 );
5920 adopt_row(
5921 &mut book,
5922 &repo_s,
5923 "probe-00ab",
5924 RepoBook {
5925 start_sha: Some(seed),
5926 ..RepoBook::default()
5927 },
5928 );
5929 cite_row(&mut book, &repo_s, &repo_s).expect("cite");
5930 let row = book.repos.get(&repo_s).expect("row");
5931 assert_eq!(row.issue.as_deref(), Some("probe-00ab"));
5932 assert!(
5933 !row.deeds.iter().any(|sha| sha == &first),
5934 "adopt cited the previous issue's commit: {:?}",
5935 row.deeds
5936 );
5937 }
5938
5939 #[test]
5943 fn start_work_on_the_same_issue_keeps_named_and_filed() {
5944 let _g = lock();
5945 let _saved = Restore::new(&[
5946 "PATH",
5947 "VISSUE_ROOT",
5948 "VISSUE_NO_ROUTE",
5949 "LJOS_TRACKER_GIT",
5950 "LJOS_PROJECT",
5951 "XDG_RUNTIME_DIR",
5952 "LJOS_IN_HOOK",
5953 "PACKSET_URL",
5954 "ISSUE_ROOT",
5955 "LJOS_OFF",
5956 "LJOS_SESSION_ID",
5957 "LJOS_SEAT",
5958 "LJOS_HARNESS_SESSION",
5959 ]);
5960 let (dir, tracker, repo) = scratch();
5961 let _bins = arm(dir.path(), &tracker);
5962 let session = "zdnr4a-same-issue";
5963 unsafe {
5964 std::env::set_var("LJOS_SESSION_ID", session);
5965 std::env::remove_var("LJOS_SEAT");
5966 std::env::remove_var("LJOS_HARNESS_SESSION");
5967 }
5968 let proj = tracker.join("Software/probe");
5969 std::fs::create_dir_all(&proj).unwrap();
5970 std::fs::write(
5971 proj.join("issues.org"),
5972 "\
5973* TODO [#C] Named ticket
5974:PROPERTIES:
5975:ID: probe-00aa
5976:END:
5977* TODO [#C] Other ticket
5978:PROPERTIES:
5979:ID: probe-00ab
5980:END:
5981",
5982 )
5983 .unwrap();
5984 let repo_s = git_toplevel(&repo).expect("toplevel");
5985 let mut book = Book::default();
5986 book.repos.insert(
5987 repo_s.clone(),
5988 RepoBook {
5989 issue: Some("probe-00aa".into()),
5990 named: true,
5991 filed: true,
5992 started: true,
5993 ..RepoBook::default()
5994 },
5995 );
5996 store_book(&book_path(session), &book);
5997 start_work(
5998 Some("probe-00aa"),
5999 Some(repo.to_str().unwrap()),
6000 "Keep working on the named ticket",
6001 )
6002 .expect("start the same issue");
6003 let row = load_book(&book_path(session))
6004 .repos
6005 .get(&repo_s)
6006 .cloned()
6007 .expect("row");
6008 assert!(row.named, "same-issue start cleared named");
6009 assert!(row.filed, "same-issue start cleared filed");
6010 let mut book = load_book(&book_path(session));
6011 finish_open(&mut book);
6012 assert_ne!(
6013 issue_state(&tracker, "probe-00aa"),
6014 "DONE",
6015 "a named ticket was closed instead of handed back"
6016 );
6017
6018 let mut again = Book::default();
6019 again.repos.insert(
6020 repo_s.clone(),
6021 RepoBook {
6022 issue: Some("probe-00aa".into()),
6023 named: true,
6024 filed: true,
6025 started: true,
6026 ..RepoBook::default()
6027 },
6028 );
6029 store_book(&book_path(session), &again);
6030 start_work(
6031 Some("probe-00ab"),
6032 Some(repo.to_str().unwrap()),
6033 "Move the sitting to the other ticket",
6034 )
6035 .expect("rebind");
6036 let row = load_book(&book_path(session))
6037 .repos
6038 .get(&repo_s)
6039 .cloned()
6040 .expect("rebound row");
6041 assert!(!row.named, "a rebind kept named");
6042 assert!(!row.filed, "a rebind kept filed");
6043 }
6044
6045 #[test]
6048 fn ensure_claim_clears_stale_lesson_before_setting_new_issue() {
6049 let _g = lock();
6050 let (dir, tracker, repo) = scratch();
6051 let _bins = arm(dir.path(), &tracker);
6052 let repo_s = git_toplevel(&repo).expect("repo toplevel");
6053 let mut book = Book {
6054 prompt: "write a kebab-case helper".into(),
6055 ..Book::default()
6056 };
6057 book.repos.insert(
6058 repo_s.clone(),
6059 RepoBook {
6060 lesson: true,
6061 lesson_id: Some("atom-old".into()),
6062 lesson_title: Some("old lesson".into()),
6063 deeds: vec!["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into()],
6064 failures: vec!["old failure".into()],
6065 asks: vec!["old ask".into()],
6066 title: Some("old title".into()),
6067 issue: None,
6068 ..RepoBook::default()
6069 },
6070 );
6071 ensure_claim(&mut book, &book_path("ensure-claim-clear"), &repo_s, None).unwrap();
6072 let row = book.repos.get(&repo_s).expect("repo row");
6073 let issue = row.issue.clone().expect("new issue");
6074 assert!(issue.starts_with("probe-"), "{issue}");
6075 assert!(!row.lesson);
6076 assert!(row.lesson_id.is_none());
6077 assert!(row.lesson_title.is_none());
6078 }
6079
6080 #[test]
6084 fn every_agy_file_tool_is_an_edit() {
6085 for name in [
6086 "write_file",
6087 "edit_file",
6088 "write_to_file",
6089 "replace_file_content",
6090 ] {
6091 let raw =
6092 serde_json::json!({"toolCall": {"name": name, "args": {"Path": "/w/slug.py"}}});
6093 assert!(is_edit(&raw, name), "{name}");
6094 }
6095 let read = serde_json::json!({"toolCall": {"name": "view_file", "args": {}}});
6096 assert!(!is_edit(&read, "view_file"));
6097 }
6098
6099 struct Restore {
6100 pairs: Vec<(String, Option<std::ffi::OsString>)>,
6101 }
6102
6103 impl Restore {
6104 fn new(keys: &[&str]) -> Self {
6105 Self {
6106 pairs: keys
6107 .iter()
6108 .map(|k| ((*k).to_string(), std::env::var_os(k)))
6109 .collect(),
6110 }
6111 }
6112 }
6113
6114 impl Drop for Restore {
6115 fn drop(&mut self) {
6116 for (key, value) in &self.pairs {
6117 unsafe {
6119 match value {
6120 Some(v) => std::env::set_var(key, v),
6121 None => std::env::remove_var(key),
6122 }
6123 }
6124 }
6125 }
6126 }
6127
6128 #[test]
6129 fn a_commit_names_the_first_open_issue_the_tracker_holds() {
6130 let state = |id: &str| match id {
6131 "ljos-policyd-32fx" | "ljos-xsor" => Some("TODO".to_string()),
6132 "ljos-cjd8" => Some("STARTED".to_string()),
6133 "ljos-done" => Some("DONE".to_string()),
6134 _ => None,
6135 };
6136 let pick = |m: &str| named_open_issue_with(m, state);
6137 assert_eq!(
6138 pick("ljos-policyd-32fx: claim the named id").as_deref(),
6139 Some("ljos-policyd-32fx")
6140 );
6141 assert_eq!(
6142 pick("tidy the hook\n\nFixes ljos-xsor.").as_deref(),
6143 Some("ljos-xsor")
6144 );
6145 assert_eq!(pick("(ljos-cjd8) reopen").as_deref(), Some("ljos-cjd8"));
6146 assert_eq!(
6147 pick("fix-hook: ljos-done then ljos-xsor").as_deref(),
6148 Some("ljos-xsor")
6149 );
6150 assert!(pick("fix-hook: ljos-done and ljos-gone").is_none());
6151 assert!(pick("no id here").is_none());
6152 }
6153
6154 #[test]
6155 fn a_hyphenated_project_id_is_a_vissue_id() {
6156 for id in [
6157 "ljos-policyd-c0jc",
6158 "lgio-1792329616-q4uh",
6159 "probe-0001",
6160 "acme/cli-1a2b",
6161 ] {
6162 assert_eq!(parse_issue_id(id).as_deref(), Some(id), "{id}");
6163 }
6164 assert_eq!(
6165 parse_issue_id("created ljos-policyd-c0jc.").as_deref(),
6166 Some("ljos-policyd-c0jc")
6167 );
6168 assert!(parse_issue_id("ljos-policyd-C0JC").is_none());
6169 assert!(parse_issue_id("not-an-id").is_none());
6170 assert!(parse_issue_id("well").is_none());
6171 }
6172
6173 #[test]
6174 fn a_bookkeep_test_does_not_read_the_login_user_rules() {
6175 let home = Path::new("/home/someone");
6176 let got = super::super::resolve_config_base(None, Some(home)).expect("isolated base");
6177 assert_ne!(got, home.join(".config"));
6178 let tmp = std::env::temp_dir();
6181 if got.starts_with(home) {
6182 assert!(
6183 got.starts_with(&tmp),
6184 "{got:?} is under {home:?} and not the temp directory"
6185 );
6186 }
6187 let rules = super::super::user_rules::rules_path().expect("rules path");
6188 if std::env::var_os("XDG_CONFIG_HOME")
6189 .filter(|value| !value.is_empty())
6190 .is_none()
6191 {
6192 let login = std::env::var_os("HOME").map(PathBuf::from);
6193 if let Some(login) = login {
6194 let login_rules = login.join(".config").join("ljos").join("user-rules.json");
6195 assert_ne!(rules, login_rules, "{rules:?} is the login config");
6196 if !rules.starts_with(&tmp) {
6197 assert!(!rules.starts_with(&login), "{rules:?} is the login config");
6198 }
6199 }
6200 }
6201 }
6202
6203 #[test]
6204 fn a_temp_path_is_the_directory_not_a_prefix() {
6205 assert!(path_is_temp(Some(Path::new("/tmp/scratch"))));
6206 assert!(!path_is_temp(Some(Path::new("/tmpfoo"))));
6207 assert!(!path_is_temp(Some(Path::new("/home/ubuntu"))));
6208 assert!(!path_is_temp(None));
6209 }
6210
6211 #[test]
6212 fn ljos_off_and_a_temp_home_suspend_the_guard() {
6213 let _g = lock();
6214 let _saved = Restore::new(&["HOME", "LJOS_OFF", "LJOS_GUARD"]);
6215 unsafe {
6216 std::env::remove_var("LJOS_OFF");
6217 std::env::remove_var("LJOS_GUARD");
6218 std::env::set_var("HOME", "/home/ubuntu");
6219 }
6220 assert!(!guard_suspended(), "a normal home is a seat");
6221 unsafe { std::env::set_var("LJOS_OFF", "1") };
6222 assert!(guard_suspended());
6223 unsafe {
6224 std::env::set_var("LJOS_OFF", "yes");
6225 }
6226 assert!(guard_suspended());
6227 unsafe {
6228 std::env::remove_var("LJOS_OFF");
6229 std::env::set_var("HOME", "/tmp/ljos-not-a-seat");
6230 }
6231 assert!(guard_suspended(), "a temp HOME is not a seat");
6232 unsafe { std::env::set_var("LJOS_GUARD", "on") };
6233 assert!(!guard_suspended(), "LJOS_GUARD=on judges a temp HOME");
6234 assert!(guard_off_reason().is_none());
6235 unsafe { std::env::set_var("LJOS_OFF", "1") };
6236 assert!(guard_suspended(), "LJOS_OFF wins over LJOS_GUARD");
6237 unsafe {
6238 std::env::remove_var("LJOS_OFF");
6239 std::env::remove_var("LJOS_GUARD");
6240 std::env::set_var("HOME", "/tmpfoo");
6241 }
6242 assert!(!guard_suspended(), "/tmpfoo is a name, not /tmp");
6243 }
6244
6245 #[test]
6246 fn a_commit_is_the_commit_subcommand() {
6247 assert!(is_git_commit("git commit -m \"readme\""));
6248 assert!(is_git_commit("git -C /src commit -m readme"));
6249 assert!(is_git_commit("FOO=1 git --git-dir=/src/.git commit"));
6250 assert!(!is_git_commit("git commit-tree HEAD"));
6251 assert!(!is_git_commit("git status"));
6252 assert!(!is_git_commit("echo git commit"));
6253 assert!(command_is_apply_patch("apply_patch <<'EOF'\n***\nEOF"));
6254 assert!(!command_is_apply_patch("git apply patch"));
6255 }
6256
6257 #[test]
6258 fn a_prompt_field_does_not_hide_the_commit_command() {
6259 let raw = r#"{"hook_event_name":"PostToolUse","prompt":"please commit the readme","tool_input":{"command":"git commit -m readme"}}"#;
6260 let call = crate::hook_call(raw);
6261 assert!(
6262 is_git_commit(&call.cue),
6263 "the prompt hid the commit: {}",
6264 call.cue
6265 );
6266 }
6267
6268 #[test]
6269 fn owner_pid_stays_put_on_a_tool_hook() {
6270 assert_eq!(next_owner("SessionStart", (0, 0), (10, 1)), (10, 1));
6271 assert_eq!(next_owner("UserPromptSubmit", (10, 1), (42, 2)), (42, 2));
6272 assert_eq!(next_owner("PostToolUse", (10, 1), (99, 3)), (10, 1));
6273 assert_eq!(next_owner("PreToolUse", (10, 1), (99, 3)), (10, 1));
6274 assert_eq!(next_owner("Stop", (10, 1), (99, 3)), (10, 1));
6275 assert_eq!(next_owner("PostToolUse", (0, 0), (99, 3)), (99, 3));
6276 }
6277
6278 #[test]
6279 fn an_owner_pid_that_started_again_is_gone() {
6280 let me = crate::proc_tree::of(std::process::id()).unwrap();
6281 let owned = |pid, start| Book {
6282 owner_pid: pid,
6283 owner_start: start,
6284 ..Book::default()
6285 };
6286 assert!(owner_alive(&owned(me.pid, me.start)));
6287 assert!(!owner_alive(&owned(me.pid, me.start + 1)), "a reused pid");
6288 assert!(owner_alive(&owned(me.pid, 0)), "a book with no start");
6289 }
6290
6291 #[test]
6292 fn an_old_row_idles_while_the_book_clock_is_fresh() {
6293 let limit = Duration::from_secs(2 * 60 * 60);
6294 let now = 1_000_000_u64;
6295 let mut book = Book {
6296 last_unix: now,
6297 ..Book::default()
6298 };
6299 book.repos.insert(
6300 "/repo-a".into(),
6301 RepoBook {
6302 issue: Some("probe-aaaa".into()),
6303 last_unix: 10,
6304 ..RepoBook::default()
6305 },
6306 );
6307 book.repos.insert(
6308 "/repo-b".into(),
6309 RepoBook {
6310 issue: Some("probe-bbbb".into()),
6311 last_unix: now,
6312 ..RepoBook::default()
6313 },
6314 );
6315 let idle = row_is_idle(
6316 now,
6317 &book,
6318 "/repo-a",
6319 book.repos.get("/repo-a").unwrap(),
6320 limit,
6321 Some("/repo-b"),
6322 );
6323 let fresh = row_is_idle(
6324 now,
6325 &book,
6326 "/repo-b",
6327 book.repos.get("/repo-b").unwrap(),
6328 limit,
6329 Some("/repo-b"),
6330 );
6331 assert!(idle, "repo A was last touched at 10");
6332 assert!(!fresh, "repo B was touched with the book");
6333
6334 let unclocked = RepoBook {
6335 issue: Some("probe-cccc".into()),
6336 ..RepoBook::default()
6337 };
6338 assert!(
6339 row_is_idle(now, &book, "/repo-c", &unclocked, limit, Some("/repo-b")),
6340 "a row with no clock is not the checkout that refreshed the book"
6341 );
6342 assert!(
6343 !row_is_idle(now, &book, "/repo-b", &unclocked, limit, Some("/repo-b")),
6344 "the current checkout keeps the fresh book clock"
6345 );
6346 }
6347
6348 #[test]
6349 fn a_prompt_for_another_repo_is_not_a_live_title() {
6350 let _g = lock();
6351 let _saved = Restore::new(&["LJOS_SITTING_IDLE"]);
6352 unsafe { std::env::remove_var("LJOS_SITTING_IDLE") };
6353 let now = 1_000_000_u64;
6354 let mut book = Book {
6355 prompt: "notes: add a second line".into(),
6356 prompt_repo: "/other".into(),
6357 prompt_unix: now,
6358 last_unix: now,
6359 ..Book::default()
6360 };
6361 assert!(
6362 !prompt_live_for(&book, "/repo", now),
6363 "the prompt belongs to /other"
6364 );
6365 book.prompt_repo = "/repo".into();
6366 book.repos.insert(
6367 "/repo".into(),
6368 RepoBook {
6369 issue: Some("probe-aaaa".into()),
6370 title: Some(book.prompt.clone()),
6371 last_unix: now,
6372 ..RepoBook::default()
6373 },
6374 );
6375 assert!(
6376 prompt_live_for(&book, "/repo", now),
6377 "the current sitting in this checkout keeps its prompt"
6378 );
6379 book.repos.get_mut("/repo").unwrap().last_unix = 10;
6380 assert!(
6381 !prompt_live_for(&book, "/repo", now),
6382 "an earlier sitting's prompt does not title the next claim"
6383 );
6384 }
6385
6386 #[test]
6387 fn a_commit_only_sitting_idles_after_two_hours() {
6388 assert_eq!(
6389 sitting_idle_from(None),
6390 Some(Duration::from_secs(2 * 60 * 60))
6391 );
6392 assert_eq!(
6393 sitting_idle_from(Some("")),
6394 Some(Duration::from_secs(2 * 60 * 60))
6395 );
6396 assert_eq!(sitting_idle_from(Some("0")), None);
6397 assert_eq!(sitting_idle_from(Some("off")), None);
6398 assert_eq!(
6399 sitting_idle_from(Some("30m")),
6400 Some(Duration::from_secs(1800))
6401 );
6402 assert!(idle_due(10_000, 10, Duration::from_secs(2 * 60 * 60)));
6403 assert!(!idle_due(100, 10, Duration::from_secs(2 * 60 * 60)));
6404 assert!(!idle_due(10_000, 0, Duration::from_secs(1)));
6405 }
6406
6407 #[test]
6408 fn a_claim_uses_a_checkout_or_the_last_booked_repo() {
6409 let _g = lock();
6412 let tmp = tempfile::tempdir().unwrap();
6413 let root = tmp.path().join("ljos-place-test");
6414 let repo = root.join("repo");
6415 let other = root.join("other");
6416 let plain = root.join("plain");
6417 std::fs::create_dir_all(&repo).unwrap();
6418 std::fs::create_dir_all(&other).unwrap();
6419 std::fs::create_dir_all(&plain).unwrap();
6420 git(&repo, &["init", "-q", "-b", "main"]);
6421 git(&other, &["init", "-q", "-b", "main"]);
6422 let top = git_toplevel(&repo).unwrap();
6423 let other_top = git_toplevel(&other).unwrap();
6424 assert_eq!(
6425 place_for_claim_in(Some(repo.to_str().unwrap()), None, Some(&plain)).as_deref(),
6426 Some(top.as_str())
6427 );
6428 assert_eq!(
6429 place_for_claim_in(Some(plain.to_str().unwrap()), Some(&other_top), Some(&repo)),
6430 None,
6431 "a named path that is not a checkout does not fall back"
6432 );
6433 assert_eq!(
6434 place_for_claim_in(None, Some(&other_top), Some(&plain)).as_deref(),
6435 Some(other_top.as_str())
6436 );
6437 assert_eq!(place_for_claim_in(None, None, Some(&plain)), None);
6438 assert_eq!(
6439 place_for_claim_in(None, None, Some(&repo)).as_deref(),
6440 Some(top.as_str())
6441 );
6442 }
6443
6444 #[test]
6445 fn project_for_matches_the_remote_before_the_basename() {
6446 let _g = lock();
6449 let tmp = tempfile::tempdir().unwrap();
6450 let root = tmp.path().join("ljos-project-test");
6451 let tracker = root.join("tracker");
6452 let repo = root.join("workspace");
6453 std::fs::create_dir_all(tracker.join("Software/ljos")).unwrap();
6454 std::fs::create_dir_all(tracker.join("Software/other")).unwrap();
6455 std::fs::write(
6456 tracker.join("Software/ljos/issues.org"),
6457 "#+REMOTE: https://github.com/acme/ljos.git\n",
6458 )
6459 .unwrap();
6460 std::fs::write(tracker.join("Software/other/issues.org"), "* TODO other\n").unwrap();
6461 std::fs::create_dir_all(&repo).unwrap();
6462 git(&repo, &["init", "-q", "-b", "main"]);
6463 git(
6464 &repo,
6465 &["remote", "add", "origin", "git@github.com:acme/ljos.git"],
6466 );
6467 assert_eq!(
6468 project_for_at(repo.to_str().unwrap(), None, Some(&tracker)),
6469 "ljos"
6470 );
6471 assert_eq!(
6472 project_for_at(repo.to_str().unwrap(), Some("probe"), Some(&tracker)),
6473 "probe"
6474 );
6475 let bare = root.join("bare");
6476 std::fs::create_dir_all(&bare).unwrap();
6477 git(&bare, &["init", "-q", "-b", "main"]);
6478 assert_eq!(
6479 project_for_at(bare.to_str().unwrap(), None, Some(&tracker)),
6480 "bare",
6481 "no remote and more than one project uses the basename"
6482 );
6483 }
6484
6485 #[test]
6489 fn a_clone_folder_is_not_the_project_when_origin_names_the_repo() {
6490 let _g = lock();
6491 let tmp = tempfile::tempdir().unwrap();
6492 let root = tmp.path().join("ljos-clone-folder");
6493 let tracker = root.join("tracker");
6494 std::fs::create_dir_all(tracker.join("Software/ljos")).unwrap();
6495 std::fs::write(tracker.join("Software/ljos/issues.org"), "* TODO ljos\n").unwrap();
6496 let repo = root.join("bench-w18b-tfn7");
6497 std::fs::create_dir_all(&repo).unwrap();
6498 git(&repo, &["init", "-q", "-b", "main"]);
6499 git(
6500 &repo,
6501 &[
6502 "remote",
6503 "add",
6504 "origin",
6505 "https://github.com/leidarljos/bench.git",
6506 ],
6507 );
6508 assert_eq!(
6509 project_for_at(repo.to_str().unwrap(), None, Some(&tracker)),
6510 "bench"
6511 );
6512 assert_eq!(
6513 title_at("", repo.to_str().unwrap(), None, Some(&tracker)),
6514 "Work in bench"
6515 );
6516 assert_eq!(title_at(" ", "", None, Some(&tracker)), "agent work");
6517 let mixed = root.join("orpc-clone");
6518 std::fs::create_dir_all(tracker.join("Software/OmniPotentRPC")).unwrap();
6519 std::fs::write(
6520 tracker.join("Software/OmniPotentRPC/issues.org"),
6521 "* TODO orpc\n",
6522 )
6523 .unwrap();
6524 std::fs::create_dir_all(&mixed).unwrap();
6525 git(&mixed, &["init", "-q", "-b", "main"]);
6526 git(
6527 &mixed,
6528 &[
6529 "remote",
6530 "add",
6531 "origin",
6532 "git@github.com:HaoZeke/omnipotentrpc.git",
6533 ],
6534 );
6535 assert_eq!(
6536 project_for_at(mixed.to_str().unwrap(), None, Some(&tracker)),
6537 "OmniPotentRPC",
6538 "an existing project matches whatever its case"
6539 );
6540 let bare = root.join("scratch-folder");
6541 std::fs::create_dir_all(&bare).unwrap();
6542 git(&bare, &["init", "-q", "-b", "main"]);
6543 assert_eq!(
6544 project_for_at(bare.to_str().unwrap(), None, Some(&tracker)),
6545 "scratch-folder"
6546 );
6547 for (url, name) in [
6548 ("https://github.com/leidarljos/bench.git", Some("bench")),
6549 (
6550 "git@github.com:leidarljos/ljos-policyd",
6551 Some("ljos-policyd"),
6552 ),
6553 (
6554 "https://github.com/HaoZeke/haozeke.github.io/",
6555 Some("haozeke.github.io"),
6556 ),
6557 ("/srv/git/bench.git", None),
6558 ("file:///srv/git/bench.git", None),
6559 ("", None),
6560 ] {
6561 assert_eq!(remote_repo_name(url).as_deref(), name, "{url}");
6562 }
6563 }
6564
6565 #[test]
6568 fn a_real_target_subtree_keeps_the_checkout_root() {
6569 let _g = lock();
6570 let tmp = tempfile::tempdir().unwrap();
6571 let root = tmp.path().join("qemu");
6572 let arm = root.join("target/arm");
6573 std::fs::create_dir_all(&arm).unwrap();
6574 git(&root, &["init", "-q", "-b", "main"]);
6575 git(&root, &["config", "user.email", "seat@example.invalid"]);
6576 git(&root, &["config", "user.name", "seat"]);
6577 std::fs::write(arm.join("cpu.c"), "int cpu(void) { return 0; }\n").unwrap();
6578 git(&root, &["add", "target/arm/cpu.c"]);
6579 git(&root, &["commit", "-q", "-m", "seed"]);
6580
6581 let root_s = root.canonicalize().unwrap().display().to_string();
6582 assert_eq!(git_toplevel(&arm).as_deref(), Some(root_s.as_str()));
6583
6584 let mut book = Book::default();
6585 let raw = serde_json::json!({"cwd": arm.display().to_string()});
6586 assert_eq!(repo_key(&mut book, &raw), root_s);
6587 }
6588
6589 #[test]
6595 fn a_job_folder_files_on_the_remote_or_the_tracker() {
6596 use std::os::unix::fs::PermissionsExt;
6597 let _g = lock();
6598 let _saved = Restore::new(&[
6599 "PATH",
6600 "HOME",
6601 "XDG_CONFIG_HOME",
6602 "VISSUE_ROOT",
6603 "ISSUE_ROOT",
6604 "VISSUE_CONFIG",
6605 "LJOS_PROJECT",
6606 "GIT_DIR",
6607 "GIT_WORK_TREE",
6608 ]);
6609 let tmp = tempfile::tempdir().unwrap();
6610 let root = tmp.path().join("ljos-job-folder");
6611 let bin = root.join("bin");
6612 std::fs::create_dir_all(&bin).unwrap();
6613 std::fs::write(bin.join("git"), "#!/bin/sh\nexit 1\n").unwrap();
6614 std::fs::set_permissions(bin.join("git"), std::fs::Permissions::from_mode(0o755)).unwrap();
6615 let real_path = std::env::var("PATH").unwrap_or_default();
6616 set_var("PATH", &format!("{}:{}", bin.display(), real_path));
6617 for key in [
6618 "VISSUE_ROOT",
6619 "ISSUE_ROOT",
6620 "VISSUE_CONFIG",
6621 "LJOS_PROJECT",
6622 "GIT_DIR",
6623 "GIT_WORK_TREE",
6624 ] {
6625 unsafe { std::env::remove_var(key) };
6626 }
6627
6628 let repo = root.join("ljos-rohit-main-audit");
6629 std::fs::create_dir_all(repo.join(".git")).unwrap();
6630 std::fs::write(
6631 repo.join(".git/config"),
6632 "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = https://github.com/leidarljos/ljos.git\n",
6633 )
6634 .unwrap();
6635 assert_eq!(project_for_at(repo.to_str().unwrap(), None, None), "ljos");
6636 assert_eq!(
6637 title_at("", repo.to_str().unwrap(), None, None),
6638 "Work in ljos"
6639 );
6640
6641 let main = root.join("main");
6642 let common = main.join(".git");
6643 let wt = common.join("worktrees/audit");
6644 std::fs::create_dir_all(&wt).unwrap();
6645 std::fs::write(
6646 common.join("config"),
6647 "[remote \"origin\"]\n\turl = \"https://github.com/leidarljos/ljos.git\"\n",
6648 )
6649 .unwrap();
6650 std::fs::write(wt.join("commondir"), "../..\n").unwrap();
6651 std::fs::write(
6652 wt.join("config"),
6653 "[remote \"origin\"]\n\turl = https://github.com/acme/decoy.git\n",
6654 )
6655 .unwrap();
6656 let linked = root.join("ljos-rohit-main-audit-wt");
6657 std::fs::create_dir_all(&linked).unwrap();
6658 std::fs::write(linked.join(".git"), format!("gitdir: {}\n", wt.display())).unwrap();
6659 assert_eq!(
6660 project_for_at(linked.to_str().unwrap(), None, None),
6661 "ljos",
6662 "a worktree config must not hide the common remote"
6663 );
6664
6665 let tracker = root.join("tracker");
6666 std::fs::create_dir_all(tracker.join("Software/atlas")).unwrap();
6667 std::fs::write(
6668 tracker.join("Software/atlas/issues.org"),
6669 "#+REMOTE: https://github.com/acme/widget.git\n* TODO atlas\n",
6670 )
6671 .unwrap();
6672 std::fs::create_dir_all(tracker.join("Software/other")).unwrap();
6673 std::fs::write(tracker.join("Software/other/issues.org"), "* TODO other\n").unwrap();
6674 let home = root.join("home");
6675 let config = home.join(".config/vissue");
6676 std::fs::create_dir_all(&config).unwrap();
6677 let line = format!(
6678 "root = {}\n",
6679 toml::Value::String(tracker.display().to_string())
6680 );
6681 std::fs::write(config.join("config.toml"), &line).unwrap();
6682 set_var("HOME", home.to_str().unwrap());
6683 unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
6684 let widget = root.join("widget-checkout");
6685 std::fs::create_dir_all(widget.join(".git")).unwrap();
6686 std::fs::write(
6687 widget.join(".git/config"),
6688 "[remote \"origin\"]\n\turl = https://github.com/acme/widget.git\n",
6689 )
6690 .unwrap();
6691 assert_eq!(project_for(widget.to_str().unwrap()), "atlas");
6692 assert_eq!(title_from("", widget.to_str().unwrap()), "Work in atlas");
6693 assert_eq!(
6694 tracker_root_from_config("root = \"~/vault\"\n").as_deref(),
6695 Some(home.join("vault").as_path())
6696 );
6697 assert!(tracker_root_from_config("[store]\nroot = \"/nope\"\n").is_none());
6698
6699 unsafe { std::env::remove_var("HOME") };
6703 set_var("PATH", &real_path);
6704 let included = root.join("included");
6705 std::fs::create_dir_all(&included).unwrap();
6706 git(&included, &["init", "-q", "-b", "main"]);
6707 std::fs::write(
6708 included.join(".git/remotes"),
6709 "[remote \"origin\"]\n\turl = https://github.com/leidarljos/ljos.git\n",
6710 )
6711 .unwrap();
6712 git(&included, &["config", "--local", "include.path", "remotes"]);
6713 let decoy = root.join("decoy");
6714 std::fs::create_dir_all(&decoy).unwrap();
6715 git(&decoy, &["init", "-q", "-b", "main"]);
6716 git(
6717 &decoy,
6718 &[
6719 "remote",
6720 "add",
6721 "origin",
6722 "https://github.com/acme/widget.git",
6723 ],
6724 );
6725 let before_dir = std::env::var_os("GIT_DIR");
6726 {
6727 let _decoy = crate::GitCommandEnv::set([("GIT_DIR", decoy.join(".git").as_os_str())]);
6728 assert_eq!(
6729 project_for_at(included.to_str().unwrap(), None, None),
6730 "ljos",
6731 "GIT_DIR named the decoy"
6732 );
6733 }
6734 assert_eq!(std::env::var_os("GIT_DIR"), before_dir);
6735 let rewritten = root.join("bench-w18b-tfn7");
6736 std::fs::create_dir_all(&rewritten).unwrap();
6737 git(&rewritten, &["init", "-q", "-b", "main"]);
6738 git(
6739 &rewritten,
6740 &[
6741 "remote",
6742 "add",
6743 "origin",
6744 "https://github.com/leidarljos/bench.git",
6745 ],
6746 );
6747 git(
6748 &rewritten,
6749 &[
6750 "config",
6751 "--local",
6752 "url./srv/git/.insteadOf",
6753 "https://github.com/leidarljos/",
6754 ],
6755 );
6756 assert_eq!(
6757 project_for_at(rewritten.to_str().unwrap(), None, None),
6758 "bench",
6759 "insteadOf rewrote the origin into a path, so the folder became the project"
6760 );
6761 assert_eq!(
6762 title_at("", rewritten.to_str().unwrap(), None, None),
6763 "Work in bench"
6764 );
6765 assert_eq!(
6766 normalize_remote("https://x-access-token:abc/def@github.com/leidarljos/ljos.git"),
6767 "github.com/leidarljos/ljos"
6768 );
6769 assert_eq!(
6770 normalize_remote("example.com/user@host/repo"),
6771 "example.com/user@host/repo"
6772 );
6773 }
6774
6775 #[test]
6779 fn a_decoy_note_does_not_take_the_project() {
6780 let _g = lock();
6783 let tmp = tempfile::tempdir().unwrap();
6784 let root = tmp.path().join("ljos-decoy-remote");
6785 let tracker = root.join("tracker");
6786 let repo = root.join("workspace");
6787 std::fs::create_dir_all(tracker.join("Software/ljos")).unwrap();
6788 std::fs::create_dir_all(tracker.join("Software/promo")).unwrap();
6789 std::fs::write(tracker.join("Software/ljos/issues.org"), "* TODO ljos\n").unwrap();
6790 std::fs::write(
6791 tracker.join("Software/promo/issues.org"),
6792 "The notes mention https://github.com/leidarljos/ljos as the tool.\n* TODO promo\n",
6793 )
6794 .unwrap();
6795 std::fs::create_dir_all(&repo).unwrap();
6796 git(&repo, &["init", "-q", "-b", "main"]);
6797 git(
6798 &repo,
6799 &[
6800 "remote",
6801 "add",
6802 "origin",
6803 "git@github.com:leidarljos/ljos.git",
6804 ],
6805 );
6806 assert_eq!(
6807 project_for_at(repo.to_str().unwrap(), None, Some(&tracker)),
6808 "ljos",
6809 "a note that names the url took the project"
6810 );
6811
6812 let atlas = tracker.join("Software/atlas");
6813 std::fs::create_dir_all(&atlas).unwrap();
6814 std::fs::write(atlas.join("issues.org"), "* TODO atlas\n").unwrap();
6815 std::fs::write(
6816 atlas.join("project.toml"),
6817 "remote = \"ssh://git@github.com/leidarljos/ljos.git\"\n",
6818 )
6819 .unwrap();
6820 std::fs::remove_dir_all(tracker.join("Software/ljos")).unwrap();
6821 assert_eq!(
6822 project_for_at(repo.to_str().unwrap(), None, Some(&tracker)),
6823 "atlas",
6824 "the recorded remote lost to a note"
6825 );
6826 }
6827
6828 fn git(dir: &Path, args: &[&str]) {
6829 let out = std::process::Command::new("git")
6830 .arg("-C")
6831 .arg(dir)
6832 .args(args)
6833 .env("PATH", std::env::var_os("PATH").unwrap_or_default())
6834 .output()
6835 .unwrap();
6836 assert!(
6837 out.status.success(),
6838 "git {args:?}: {}",
6839 String::from_utf8_lossy(&out.stderr)
6840 );
6841 }
6842
6843 fn habitat_bin(dir: &Path) {
6844 std::fs::create_dir_all(dir).unwrap();
6845 std::fs::write(
6846 dir.join("vissue"),
6847 r#"#!/usr/bin/env python3
6848import os, sys, pathlib, re
6849root = pathlib.Path(os.environ["VISSUE_ROOT"])
6850log = root / "vissue.log"
6851args = sys.argv[1:]
6852with log.open("a") as fh:
6853 fh.write(" ".join(args) + "\n")
6854cmd, rest = args[0], args[1:]
6855
6856def files():
6857 return list(root.rglob("issues.org"))
6858
6859def rewrite(iid, state):
6860 pat = re.compile(r"(\* )[A-Z]+( \[#[A-Z]\] [^\n]*\n:PROPERTIES:\n:ID:\s+" + re.escape(iid) + r"\n)")
6861 for f in files():
6862 text = f.read_text()
6863 new, n = pat.subn(r"\1" + state + r"\2", text, count=1)
6864 if n:
6865 f.write_text(new)
6866 return
6867 sys.exit("no heading for " + iid)
6868
6869if cmd == "create":
6870 proj, title, i = "agent", "agent work", 0
6871 while i < len(rest):
6872 if rest[i] == "-p" and i + 1 < len(rest):
6873 proj = rest[i + 1]
6874 i += 2
6875 continue
6876 if rest[i] == "-q":
6877 i += 1
6878 continue
6879 title = rest[i]
6880 i += 1
6881 seq = root / ".seq"
6882 n = int(seq.read_text()) + 1 if seq.exists() else 1
6883 seq.write_text(str(n))
6884 iid = f"{proj}-{n:04x}"
6885 dest = root / "Software" / proj
6886 dest.mkdir(parents=True, exist_ok=True)
6887 with (dest / "issues.org").open("a") as fh:
6888 fh.write(f"* TODO [#C] {title}\n:PROPERTIES:\n:ID: {iid}\n:END:\n")
6889 print(iid)
6890elif cmd == "claim":
6891 rewrite(rest[0], "STARTED")
6892elif cmd == "update":
6893 state = rest[rest.index("-s") + 1]
6894 rewrite(rest[0], state)
6895elif cmd == "deed":
6896 iid = rest[0]
6897 if "--remove" in rest:
6898 acc = rest[rest.index("--remove") + 1]
6899 line = f"cited {acc}\n"
6900 for f in files():
6901 text = f.read_text()
6902 if f":ID: {iid}\n" in text:
6903 f.write_text(text.replace(line, "", 1))
6904 sys.exit(0)
6905 sys.exit(0)
6906 acc = rest[rest.index("--add") + 1]
6907 needle = f":ID: {iid}\n:END:\n"
6908 for f in files():
6909 text = f.read_text()
6910 if needle in text:
6911 f.write_text(text.replace(needle, needle + f"cited {acc}\n", 1))
6912 sys.exit(0)
6913 sys.exit("no issue " + iid)
6914elif cmd == "claims":
6915 print("[]")
6916elif cmd == "show":
6917 print("{}")
6918else:
6919 sys.exit("unknown " + cmd)
6920"#,
6921 )
6922 .unwrap();
6923 std::fs::write(
6924 dir.join("deedar"),
6925 r#"#!/usr/bin/env python3
6926import os, pathlib, sys
6927root = pathlib.Path(os.environ["VISSUE_ROOT"])
6928args = sys.argv[1:]
6929(root / "deedar.log").open("a").write(" ".join(args) + "\n")
6930ident = None
6931i = 0
6932while i < len(args):
6933 if args[i] == "--id" and i + 1 < len(args):
6934 ident = args[i + 1]
6935 if args[i] == "--path" and i + 1 < len(args):
6936 (root / "deedar-body.log").open("a").write(pathlib.Path(args[i + 1]).read_text())
6937 i += 1
6938if not ident:
6939 seq = root / ".deedseq"
6940 n = int(seq.read_text()) + 1 if seq.exists() else 1
6941 seq.write_text(str(n))
6942 ident = f"deed-{n:04x}"
6943print(f"id={ident}")
6944"#,
6945 )
6946 .unwrap();
6947 use std::os::unix::fs::PermissionsExt;
6948 for name in ["vissue", "deedar"] {
6949 std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(0o755))
6950 .unwrap();
6951 }
6952 }
6953
6954 fn scratch() -> (tempfile::TempDir, PathBuf, PathBuf) {
6955 let dir = tempfile::tempdir().unwrap();
6956 let tracker = dir.path().join("tracker");
6957 let repo = dir.path().join("repo");
6958 std::fs::create_dir_all(&tracker).unwrap();
6959 std::fs::create_dir_all(&repo).unwrap();
6960 for root in [&tracker, &repo] {
6961 git(root, &["init", "-q"]);
6962 git(root, &["config", "user.email", "seat@example.invalid"]);
6963 git(root, &["config", "user.name", "seat"]);
6964 git(root, &["config", "core.hooksPath", "/dev/null"]);
6965 git(root, &["config", "commit.gpgsign", "false"]);
6968 git(root, &["config", "core.fsmonitor", "false"]);
6969 }
6970 std::fs::write(repo.join("README.md"), "seed\n").unwrap();
6971 git(&repo, &["add", "README.md"]);
6972 git(&repo, &["commit", "-q", "-m", "seed"]);
6973 habitat_bin(&dir.path().join("bin"));
6974 (dir, tracker, repo)
6975 }
6976
6977 fn name_the_derived_seat() {
6981 unsafe {
6983 std::env::remove_var("LJOS_SEAT");
6984 std::env::set_var("LJOS_SEAT", crate::whoami().seat);
6985 }
6986 }
6987
6988 fn arm(dir: &Path, tracker: &Path) -> (crate::PathGuard, Restore) {
6990 let seat = Restore::new(&["LJOS_SEAT"]);
6991 let bins = crate::prepend_path(&dir.join("bin"));
6992 unsafe {
6993 std::env::set_var("VISSUE_ROOT", tracker);
6994 std::env::set_var("VISSUE_NO_ROUTE", "1");
6995 std::env::set_var("LJOS_TRACKER_GIT", "commit");
6996 std::env::set_var("LJOS_PROJECT", "probe");
6997 std::env::set_var("XDG_RUNTIME_DIR", dir.join("run"));
6998 std::env::set_var("LJOS_IN_HOOK", "1");
6999 std::env::set_var("PACKSET_URL", "off");
7000 std::env::remove_var("ISSUE_ROOT");
7001 std::env::remove_var("LJOS_OFF");
7002 std::env::remove_var("LJOS_SESSION_ID");
7003 name_the_derived_seat();
7004 }
7005 (bins, seat)
7006 }
7007
7008 fn payload(event: &str, session: &str, cwd: &Path, extra: Value) -> (String, crate::HookCall) {
7009 let mut raw = serde_json::json!({
7010 "hook_event_name": event,
7011 "session_id": session,
7012 "cwd": cwd,
7013 });
7014 if let (Value::Object(dst), Value::Object(src)) = (&mut raw, extra) {
7015 for (k, v) in src {
7016 dst.insert(k, v);
7017 }
7018 }
7019 let text = raw.to_string();
7020 let call = crate::hook_call(&text);
7021 (text, call)
7022 }
7023
7024 fn creates(tracker: &Path) -> usize {
7025 let log = std::fs::read_to_string(tracker.join("vissue.log")).unwrap_or_default();
7026 log.matches("\ncreate").count() + usize::from(log.starts_with("create"))
7027 }
7028
7029 fn assert_redirect_claims(repo: &Path, tracker: &Path) {
7033 let cwd = repo.display().to_string();
7034 let repo_s = cwd.clone();
7035 let claim = |session: &str, raw: Value, file: &str, want: bool| {
7036 let text = raw.to_string();
7037 let call = crate::hook_call(&text);
7038 assert!(
7039 matches!(call.event.as_str(), "PreToolUse" | "PostToolUse"),
7040 "{session}: {}",
7041 call.event
7042 );
7043 assert!(
7044 call.cue.contains(file),
7045 "{session} dropped the redirect: {}",
7046 call.cue
7047 );
7048 assert_eq!(
7049 writes_worktree(&repo_s, &call.cue),
7050 want,
7051 "{session} worktree {}",
7052 call.cue
7053 );
7054 let before = creates(tracker);
7055 let _ = observe(&text, &call);
7056 assert_eq!(
7057 creates(tracker) > before,
7058 want,
7059 "{session} claim {before} -> {}",
7060 creates(tracker)
7061 );
7062 };
7063 claim(
7064 "claude-pre",
7065 serde_json::json!({
7066 "hook_event_name": "PreToolUse",
7067 "session_id": "redir-claude-pre",
7068 "cwd": cwd,
7069 "tool_name": "Bash",
7070 "tool_input": {"command": "echo hi > kebab_claude.py"}
7071 }),
7072 "kebab_claude.py",
7073 true,
7074 );
7075 claim(
7076 "opencode-post",
7077 serde_json::json!({
7078 "hook_event_name": "PostToolUse",
7079 "session_id": "redir-opencode-post",
7080 "cwd": cwd,
7081 "tool_name": "Bash",
7082 "tool_input": {"command": "cat > fresh_opencode.txt"},
7083 "tool_response": {"exit_code": 0}
7084 }),
7085 "fresh_opencode.txt",
7086 true,
7087 );
7088 claim(
7089 "codex-pre",
7090 serde_json::json!({
7091 "hook_event_name": "PreToolUse",
7092 "session_id": "redir-codex-pre",
7093 "turn_id": "t-redir-pre",
7094 "cwd": cwd,
7095 "tool_name": "Bash",
7096 "tool_input": {"command": "echo hi > kebab_codex.py"}
7097 }),
7098 "kebab_codex.py",
7099 true,
7100 );
7101 claim(
7102 "codex-post",
7103 serde_json::json!({
7104 "hook_event_name": "PostToolUse",
7105 "session_id": "redir-codex-post",
7106 "turn_id": "t-redir-post",
7107 "cwd": cwd,
7108 "tool_name": "Bash",
7109 "tool_input": {"command": "cat > fresh_codex.txt"},
7110 "tool_response": {"exit_code": 0}
7111 }),
7112 "fresh_codex.txt",
7113 true,
7114 );
7115 claim(
7116 "agy-pre",
7117 serde_json::json!({
7118 "conversationId": "redir-agy-pre",
7119 "cwd": cwd,
7120 "toolCall": {"name": "run_command", "args": {"CommandLine": "echo hi > kebab_agy.py"}}
7121 }),
7122 "kebab_agy.py",
7123 true,
7124 );
7125 claim(
7126 "agy-post",
7127 serde_json::json!({
7128 "conversationId": "redir-agy-post",
7129 "cwd": cwd,
7130 "error": "",
7131 "toolCall": {"name": "run_command", "args": {"CommandLine": "cat > fresh_agy.txt"}}
7132 }),
7133 "fresh_agy.txt",
7134 true,
7135 );
7136 claim(
7137 "cursor-shell",
7138 serde_json::json!({
7139 "cursor_version": "3.2.0",
7140 "conversation_id": "redir-cursor-shell",
7141 "hook_event_name": "beforeShellExecution",
7142 "command": "echo hi > kebab_cursor.py",
7143 "cwd": cwd
7144 }),
7145 "kebab_cursor.py",
7146 true,
7147 );
7148 claim(
7149 "cursor-post",
7150 serde_json::json!({
7151 "cursor_version": "3.2.0",
7152 "conversation_id": "redir-cursor-post",
7153 "hook_event_name": "postToolUse",
7154 "cwd": cwd,
7155 "tool_name": "Shell",
7156 "tool_input": {"command": "cat > fresh_cursor.txt"}
7157 }),
7158 "fresh_cursor.txt",
7159 true,
7160 );
7161 claim(
7162 "grok-pre",
7163 serde_json::json!({
7164 "hookEventName": "preToolUse",
7165 "sessionId": "redir-grok-pre",
7166 "cwd": cwd,
7167 "toolName": "run_terminal_command",
7168 "toolInput": {"command": "echo hi > kebab_grok.py"}
7169 }),
7170 "kebab_grok.py",
7171 true,
7172 );
7173 claim(
7174 "grok-post",
7175 serde_json::json!({
7176 "hookEventName": "postToolUse",
7177 "sessionId": "redir-grok-post",
7178 "cwd": cwd,
7179 "toolInput": {"command": "cat > fresh_grok.txt"}
7180 }),
7181 "fresh_grok.txt",
7182 true,
7183 );
7184 claim(
7185 "gemini-pre",
7186 serde_json::json!({
7187 "hook_event_name": "pre_tool_call",
7188 "session_id": "redir-gem-pre",
7189 "cwd": cwd,
7190 "tool_name": "terminal",
7191 "tool_input": {"command": "echo hi>kebab_gem.py"}
7192 }),
7193 "kebab_gem.py",
7194 true,
7195 );
7196 claim(
7197 "gemini-post",
7198 serde_json::json!({
7199 "hook_event_name": "post_tool_call",
7200 "session_id": "redir-gem-post",
7201 "cwd": cwd,
7202 "tool_name": "terminal",
7203 "tool_input": {"command": "echo more >> fresh_gem.txt"}
7204 }),
7205 "fresh_gem.txt",
7206 true,
7207 );
7208 claim(
7209 "outside-tmp",
7210 serde_json::json!({
7211 "hook_event_name": "PreToolUse",
7212 "session_id": "redir-outside-tmp",
7213 "cwd": cwd,
7214 "tool_name": "Bash",
7215 "tool_input": {"command": "echo hi > /tmp/ljos-not-in-the-worktree.txt"}
7216 }),
7217 "/tmp/ljos-not-in-the-worktree.txt",
7218 false,
7219 );
7220 claim(
7221 "outside-parent",
7222 serde_json::json!({
7223 "hook_event_name": "PreToolUse",
7224 "session_id": "redir-outside-parent",
7225 "cwd": cwd,
7226 "tool_name": "Bash",
7227 "tool_input": {"command": "echo hi > ../outside.txt"}
7228 }),
7229 "../outside.txt",
7230 false,
7231 );
7232 claim(
7233 "outside-home",
7234 serde_json::json!({
7235 "hook_event_name": "PreToolUse",
7236 "session_id": "redir-outside-home",
7237 "cwd": cwd,
7238 "tool_name": "Bash",
7239 "tool_input": {"command": "echo hi > \"$HOME/.bashrc\""}
7240 }),
7241 "$HOME/.bashrc",
7242 false,
7243 );
7244 claim(
7245 "outside-tilde",
7246 serde_json::json!({
7247 "hook_event_name": "PreToolUse",
7248 "session_id": "redir-outside-tilde",
7249 "cwd": cwd,
7250 "tool_name": "Bash",
7251 "tool_input": {"command": "echo hi > ~/notes.txt"}
7252 }),
7253 "~/notes.txt",
7254 false,
7255 );
7256 }
7257
7258 #[test]
7259 fn an_ending_sitting_opens_no_row_in_a_checkout_it_never_worked_in() {
7260 let _g = lock();
7261 let _saved = saved_env();
7262 let (dir, tracker, repo) = scratch();
7263 let _bins = arm(dir.path(), &tracker);
7264 let session = "seat:probe-guard";
7267 for event in ["Stop", "SessionEnd"] {
7268 let (raw, call) = payload(event, session, &repo, Value::Null);
7269 let _ = observe(&raw, &call);
7270 }
7271 let book = load_book(&book_path(session));
7272 assert!(
7273 book.repos.is_empty(),
7274 "no row with a null issue: {:?}",
7275 book.repos.keys().collect::<Vec<_>>()
7276 );
7277 assert_eq!(creates(&tracker), 0);
7278 }
7279
7280 #[test]
7281 fn a_shell_redirect_into_the_work_tree_claims_on_that_call() {
7282 let _g = lock();
7283 let _saved = saved_env();
7284 let (dir, tracker, repo) = scratch();
7285 let _bins = arm(dir.path(), &tracker);
7286 assert_redirect_claims(&repo, &tracker);
7287 }
7288
7289 #[test]
7290 fn a_session_files_a_claim_a_deed_and_a_finish_without_being_asked() {
7291 let _g = lock();
7292 let _saved = Restore::new(&[
7293 "PATH",
7294 "VISSUE_ROOT",
7295 "VISSUE_NO_ROUTE",
7296 "LJOS_TRACKER_GIT",
7297 "LJOS_PROJECT",
7298 "XDG_RUNTIME_DIR",
7299 "LJOS_IN_HOOK",
7300 "PACKSET_URL",
7301 "ISSUE_ROOT",
7302 "LJOS_OFF",
7303 "LJOS_SESSION_ID",
7304 ]);
7305 let (dir, tracker, repo) = scratch();
7306 let _bins = arm(dir.path(), &tracker);
7307 let session = "book-session-1";
7308 let (prompt_raw, prompt_call) = payload(
7309 "UserPromptSubmit",
7310 session,
7311 &repo,
7312 serde_json::json!({"prompt": "Add a readme line"}),
7313 );
7314 assert!(
7315 !prompt_raw.contains("ljos"),
7316 "the prompt the test sends does not name the seat"
7317 );
7318 assert!(observe(&prompt_raw, &prompt_call).0.is_empty());
7319
7320 let (edit_raw, edit_call) = payload(
7321 "PreToolUse",
7322 session,
7323 &repo,
7324 serde_json::json!({"tool_name": "Edit", "tool_input": {"file_path": "README.md"}}),
7325 );
7326 let _ = observe(&edit_raw, &edit_call);
7327
7328 let (start_raw, start_call) = payload("SessionStart", session, &repo, Value::Null);
7329 let (recall, _) = observe(&start_raw, &start_call);
7330 assert!(recall.contains("Recall for probe-"), "{recall}");
7331 assert!(recall.contains("Add a readme line"), "{recall}");
7332
7333 let (commit_raw, commit_call) = payload(
7334 "PreToolUse",
7335 session,
7336 &repo,
7337 serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m \"readme\""}}),
7338 );
7339 let _ = observe(&commit_raw, &commit_call);
7340 let deedar_before = std::fs::read_to_string(tracker.join("deedar.log")).unwrap_or_default();
7341 assert!(
7342 deedar_before.is_empty(),
7343 "the pre-hook does not cite a commit that has not run\n{deedar_before}"
7344 );
7345 commit_file(&repo, "README.md", "hello from the session\n");
7348 let (post_raw, post_call) = payload(
7350 "PostToolUse",
7351 session,
7352 &repo,
7353 serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m \"readme\""}}),
7354 );
7355 let _ = observe(&post_raw, &post_call);
7356 let _ = observe(&post_raw, &post_call);
7357
7358 let (stop_raw, stop_call) = payload("Stop", session, &repo, Value::Null);
7359 let _ = observe(&stop_raw, &stop_call);
7360 let log_before = git_log(&tracker);
7361 let _ = observe(&stop_raw, &stop_call);
7362 let (end_raw, end_call) = payload("SessionEnd", session, &repo, Value::Null);
7363 let _ = observe(&end_raw, &end_call);
7364 let log = git_log(&tracker);
7365 assert_eq!(log, log_before, "a second stop does not finish again");
7366 assert!(log.contains("claimed"), "{log}");
7367 assert!(log.contains("cited a deed"), "{log}");
7368 assert!(log.contains("finished"), "{log}");
7369 assert_eq!(log.matches("finished").count(), 1, "{log}");
7370 let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
7371 assert!(org.contains("Add a readme line"), "{org}");
7372 let sha = String::from_utf8(
7373 std::process::Command::new("git")
7374 .arg("-C")
7375 .arg(&repo)
7376 .args(["rev-parse", "HEAD"])
7377 .output()
7378 .unwrap()
7379 .stdout,
7380 )
7381 .unwrap();
7382 let sha = sha.trim();
7383 assert!(org.contains(&format!("deed-file-commit-{sha}")), "{org}");
7384 let body = std::fs::read_to_string(tracker.join("deedar-body.log")).unwrap();
7385 assert!(body.contains(sha), "{body}");
7386 assert_eq!(
7387 std::fs::read_to_string(tracker.join("deedar.log"))
7388 .unwrap()
7389 .lines()
7390 .count(),
7391 1
7392 );
7393
7394 let vissue_log = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
7395 let creates =
7396 vissue_log.matches("\ncreate").count() + usize::from(vissue_log.starts_with("create"));
7397 assert_eq!(creates, 1, "{vissue_log}");
7398 let started = std::time::Instant::now();
7399 let _ = observe(&edit_raw, &edit_call);
7400 assert!(
7401 started.elapsed() < Duration::from_millis(100),
7402 "warm edit took {:?}",
7403 started.elapsed()
7404 );
7405 let after = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
7406 assert_eq!(after, vissue_log, "a warm edit does not ask vissue");
7407 }
7408
7409 #[test]
7410 fn a_missing_tracker_does_not_block_the_next_edit() {
7411 let _g = lock();
7412 let _saved = Restore::new(&[
7413 "PATH",
7414 "VISSUE_ROOT",
7415 "XDG_RUNTIME_DIR",
7416 "LJOS_IN_HOOK",
7417 "PACKSET_URL",
7418 "LJOS_OFF",
7419 "LJOS_PROJECT",
7420 "LJOS_SEAT",
7421 ]);
7422 let dir = tempfile::tempdir().unwrap();
7423 let repo = dir.path().join("repo");
7424 std::fs::create_dir_all(&repo).unwrap();
7425 git(&repo, &["init", "-q"]);
7426 name_the_derived_seat();
7427 let _bins = crate::set_path(crate::path_without(&["vissue"]));
7430 unsafe {
7431 std::env::set_var("XDG_RUNTIME_DIR", dir.path().join("run"));
7432 std::env::set_var("LJOS_IN_HOOK", "1");
7433 std::env::set_var("PACKSET_URL", "off");
7434 std::env::remove_var("LJOS_OFF");
7435 std::env::set_var("VISSUE_ROOT", dir.path().join("tracker"));
7436 std::env::set_var("LJOS_PROJECT", "probe");
7437 }
7438 let (raw, call) = payload(
7439 "PreToolUse",
7440 "book-missing",
7441 &repo,
7442 serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "a.txt"}, "prompt": "Write the notes"}),
7443 );
7444 let (text, _) = observe(&raw, &call);
7447 assert!(text.is_empty());
7448 let started = std::time::Instant::now();
7449 let (again, _) = observe(&raw, &call);
7450 assert!(again.is_empty());
7451 assert!(
7452 started.elapsed() < Duration::from_millis(100),
7453 "second edit took {:?}",
7454 started.elapsed()
7455 );
7456 let logged =
7457 std::fs::read_to_string(dir.path().join("run/ljos/book.log")).unwrap_or_default();
7458 assert_eq!(logged.matches("claim:").count(), 1, "{logged}");
7459 }
7460
7461 #[test]
7465 fn a_write_tool_files_no_issue_and_joins_the_held_one() {
7466 let _g = lock();
7467 let _saved = Restore::new(&[
7468 "PATH",
7469 "VISSUE_ROOT",
7470 "VISSUE_NO_ROUTE",
7471 "LJOS_TRACKER_GIT",
7472 "LJOS_PROJECT",
7473 "XDG_RUNTIME_DIR",
7474 "LJOS_IN_HOOK",
7475 "PACKSET_URL",
7476 "LJOS_SESSION_ID",
7477 "LJOS_OFF",
7478 ]);
7479 let (dir, tracker, repo) = scratch();
7480 let _bins = arm(dir.path(), &tracker);
7481 unsafe {
7482 std::env::set_var("LJOS_SESSION_ID", "mcp-session-9");
7483 }
7484 assert!(!mcp_tool_writes("ljos_claim"));
7485 assert!(!mcp_tool_writes("ljos_file"));
7486 assert!(!mcp_tool_writes("ljos_finish"));
7487 assert!(mcp_tool_writes("ljos_remember"));
7488 assert!(mcp_tool_writes("ljos_prefer"));
7489 let mut args = serde_json::Map::new();
7490 args.insert(
7491 "text".into(),
7492 Value::String("the readme stays short".into()),
7493 );
7494 args.insert("repo".into(), Value::String(repo.display().to_string()));
7495 for tool in MCP_WRITES {
7496 before_mcp_write(tool, Some(&args));
7497 }
7498 assert_eq!(creates(&tracker), 0, "a write tool filed an issue");
7499 let logged =
7500 std::fs::read_to_string(dir.path().join("run/ljos/book.log")).unwrap_or_default();
7501 assert!(logged.contains("ran unattached"), "{logged}");
7502 let session = "hook-edit-then-remember";
7503 let (raw, call) = payload(
7504 "PostToolUse",
7505 session,
7506 &repo,
7507 serde_json::json!({"tool_name": "Edit", "tool_input": {"file_path": "slug.py"}}),
7508 );
7509 let _ = observe(&raw, &call);
7510 assert_eq!(creates(&tracker), 1);
7511 args.insert("text".into(), Value::String("the notes stay dated".into()));
7512 before_mcp_write("ljos_remember", Some(&args));
7513 before_mcp_write("ljos_prefer", Some(&args));
7514 assert_eq!(creates(&tracker), 1, "a write tool filed a second issue");
7515 }
7516
7517 #[test]
7518 fn mail_and_notes_from_the_server_file_no_claim() {
7519 let _g = lock();
7520 let _saved = Restore::new(&[
7521 "PATH",
7522 "VISSUE_ROOT",
7523 "VISSUE_NO_ROUTE",
7524 "LJOS_TRACKER_GIT",
7525 "LJOS_PROJECT",
7526 "XDG_RUNTIME_DIR",
7527 "LJOS_IN_HOOK",
7528 "PACKSET_URL",
7529 "LJOS_SESSION_ID",
7530 "LJOS_OFF",
7531 ]);
7532 let (dir, tracker, _repo) = scratch();
7533 let _bins = arm(dir.path(), &tracker);
7534 unsafe {
7535 std::env::set_var("LJOS_SESSION_ID", "mcp-mail-1");
7536 }
7537 assert!(!mcp_tool_writes("ljos_send"));
7538 assert!(!mcp_tool_writes("ljos_reply"));
7539 let mut args = serde_json::Map::new();
7540 args.insert("to".into(), Value::String("disk-saver".into()));
7541 args.insert(
7542 "text".into(),
7543 Value::String(
7544 "Hourly poke from Inky: pick up your outstanding queue now and make progress"
7545 .into(),
7546 ),
7547 );
7548 before_mcp_write("ljos_send", Some(&args));
7549 before_mcp_write("ljos_reply", Some(&args));
7550 before_mcp_write("ljos_note", Some(&args));
7553 let log = std::fs::read_to_string(tracker.join("vissue.log")).unwrap_or_default();
7554 assert!(
7555 !log.contains("create"),
7556 "mail or a note filed an issue\n{log}"
7557 );
7558 let book = std::fs::read_to_string(book_path(&sitting_key())).unwrap_or_default();
7559 assert!(
7560 !book.contains("Hourly poke"),
7561 "mail text became the prompt\n{book}"
7562 );
7563 }
7564
7565 #[test]
7566 fn idle_defaults_to_half_an_hour_and_zero_turns_it_off() {
7567 assert_eq!(idle_limit_from(None), Some(Duration::from_secs(1800)));
7568 assert_eq!(idle_limit_from(Some("")), Some(Duration::from_secs(1800)));
7569 assert_eq!(idle_limit_from(Some("0")), None);
7570 assert_eq!(idle_limit_from(Some("OFF")), None);
7571 assert_eq!(idle_limit_from(Some("15m")), Some(Duration::from_secs(900)));
7572 assert_eq!(
7573 idle_limit_from(Some("nope")),
7574 Some(Duration::from_secs(1800))
7575 );
7576 assert!(idle_due(2_000, 100, Duration::from_secs(1800)));
7577 assert!(!idle_due(1_000, 100, Duration::from_secs(1800)));
7578 assert!(!idle_due(5_000, 0, Duration::from_secs(1)));
7579 }
7580
7581 #[test]
7582 fn an_empty_guard_is_refused() {
7583 let err = crate::guard_command(" ").unwrap_err().to_string();
7584 assert!(err.contains("pass the command"), "{err}");
7585 assert!(crate::command_refused("deny\tTCB required\n"));
7586 assert!(crate::command_refused("ask: wipe\n"));
7587 assert!(!crate::command_refused("echo hi\n"));
7588 assert!(!crate::command_refused("the person may ask later\n"));
7589 }
7590
7591 #[test]
7592 fn a_call_seat_must_be_one_the_tracker_knows() {
7593 let _g = lock();
7594 let _saved = Restore::new(&["VISSUE_ROOT", "ISSUE_ROOT", "VISSUE_NO_ROUTE", "LJOS_SEAT"]);
7595 let dir = tempfile::tempdir().unwrap();
7596 std::fs::write(
7597 dir.path().join("vissue.toml"),
7598 "seats = [\"inky\", \"Disk Saver\", \"saddle\"]\n",
7599 )
7600 .unwrap();
7601 std::fs::create_dir_all(dir.path().join("Software/probe")).unwrap();
7602 std::fs::write(
7603 dir.path().join("Software/probe/issues.org"),
7604 "* STARTED [#C] held\n:PROPERTIES:\n:ID: probe-abcd\n:CLAIMED_BY: eessi@box\n:END:\n",
7605 )
7606 .unwrap();
7607 unsafe {
7608 std::env::set_var("VISSUE_ROOT", dir.path());
7609 std::env::set_var("VISSUE_NO_ROUTE", "1");
7610 std::env::remove_var("ISSUE_ROOT");
7611 std::env::remove_var("LJOS_SEAT");
7612 }
7613 let err = crate::resolve_call_seat(None).unwrap_err().to_string();
7614 assert!(err.contains("LJOS_SEAT"), "{err}");
7615 unsafe {
7616 std::env::set_var("LJOS_SEAT", "inky");
7617 }
7618 assert_eq!(crate::resolve_call_seat(None).unwrap().seat, "inky");
7619 assert_eq!(
7620 crate::resolve_call_seat(Some("Disk Saver")).unwrap().seat,
7621 "disk-saver"
7622 );
7623 let err = crate::resolve_call_seat(Some("sadle"))
7624 .unwrap_err()
7625 .to_string();
7626 assert!(err.contains("Did you mean saddle"), "{err}");
7627 assert!(err.contains("inky"), "{err}");
7628 assert!(err.contains("disk-saver"), "{err}");
7629 assert!(err.contains("eessi"), "{err}");
7630 }
7631
7632 #[test]
7638 fn a_harness_or_environment_seat_is_never_refused() {
7639 let _g = lock();
7640 let _saved = Restore::new(&[
7641 "HOME",
7642 "VISSUE_ROOT",
7643 "ISSUE_ROOT",
7644 "VISSUE_NO_ROUTE",
7645 "LJOS_SEAT",
7646 "VISSUE_AGENT",
7647 "XDG_RUNTIME_DIR",
7648 ]);
7649 let dir = tempfile::tempdir().unwrap();
7650 let run = dir.path().join("run");
7651 std::fs::create_dir_all(&run).unwrap();
7652 std::fs::write(
7653 dir.path().join("vissue.toml"),
7654 "seats = [\"inky\", \"free-claude\"]\n",
7655 )
7656 .unwrap();
7657 std::fs::create_dir_all(dir.path().join("Software/probe")).unwrap();
7658 std::fs::write(
7659 dir.path().join("Software/probe/issues.org"),
7660 "* STARTED [#C] one\n:PROPERTIES:\n:ID: probe-abcd\n:CLAIMED_BY: codex-vax0\n:END:\n\
7661 * STARTED [#C] two\n:PROPERTIES:\n:ID: probe-efgh\n\
7662 :CLAIMED_BY: 01a0dab3-895e-7533-93ca-c9807d5cb680\n:END:\n",
7663 )
7664 .unwrap();
7665 unsafe {
7666 std::env::set_var("HOME", dir.path().join("home"));
7667 std::env::set_var("VISSUE_ROOT", dir.path());
7668 std::env::set_var("VISSUE_NO_ROUTE", "1");
7669 std::env::set_var("XDG_RUNTIME_DIR", &run);
7670 std::env::remove_var("ISSUE_ROOT");
7671 std::env::remove_var("LJOS_SEAT");
7672 std::env::remove_var("VISSUE_AGENT");
7673 }
7674 let known = crate::tracker_known_seats();
7675 assert!(known.contains("codex"), "{known:?}");
7676 assert!(!known.contains("codex-vax0"), "{known:?}");
7677 assert!(!known.iter().any(|s| s.contains("01a0dab3")), "{known:?}");
7678 let seat = |name: &str| crate::resolve_call_seat(Some(name)).unwrap().seat;
7679 assert_eq!(seat("codex"), "codex", "a legacy holder names the seat");
7680 assert_eq!(seat("claude"), "free-claude", "the harness's one seat here");
7681 assert_eq!(seat("opencode"), "opencode", "a harness with no seat here");
7682 unsafe {
7683 std::env::set_var("LJOS_SEAT", "newbot");
7684 }
7685 assert_eq!(crate::resolve_call_seat(None).unwrap().seat, "newbot");
7686 assert_eq!(
7687 seat("codex"),
7688 "newbot",
7689 "LJOS_SEAT wins over the harness id"
7690 );
7691 unsafe {
7692 std::env::remove_var("LJOS_SEAT");
7693 }
7694 let err = crate::resolve_call_seat(Some("nobody"))
7695 .unwrap_err()
7696 .to_string();
7697 assert!(
7698 err.starts_with("no seat named nobody on this tracker."),
7699 "{err}"
7700 );
7701 assert!(err.contains("codex, free-claude, inky"), "{err}");
7702 assert!(err.contains("LJOS_SEAT"), "{err}");
7703 assert!(!err.contains("01a0dab3"), "{err}");
7704 assert!(!err.contains("codex-vax0"), "{err}");
7705 }
7706
7707 #[test]
7711 fn a_raw_holder_on_the_roster_is_the_seat_and_the_file_stays() {
7712 let _g = lock();
7713 let _saved = Restore::new(&[
7714 "HOME",
7715 "XDG_CONFIG_HOME",
7716 "VISSUE_ROOT",
7717 "ISSUE_ROOT",
7718 "VISSUE_NO_ROUTE",
7719 "LJOS_SEAT",
7720 "VISSUE_AGENT",
7721 "LJOS_HOLD",
7722 ]);
7723 let dir = tempfile::tempdir().unwrap();
7724 let home = dir.path().join("home");
7725 std::fs::create_dir_all(&home).unwrap();
7726 let toml = "seats = [\"ljos-bot\", \"inky-n9o\", \"grokbot-sand-sub\", \"disk-saver\"]\n";
7727 let org = "* STARTED [#C] one\n:PROPERTIES:\n:ID: probe-abcd\n:CLAIMED_BY: disk-saver-t4t\n:END:\n\
7728 * STARTED [#C] two\n:PROPERTIES:\n:ID: probe-efgh\n:CLAIMED_BY: grokbot-sand-sub\n:END:\n";
7729 let toml_path = dir.path().join("vissue.toml");
7730 let org_path = dir.path().join("issues.org");
7731 std::fs::write(&toml_path, toml).unwrap();
7732 std::fs::write(&org_path, org).unwrap();
7733 let before_toml = std::fs::read(&toml_path).unwrap();
7734 let before_org = std::fs::read(&org_path).unwrap();
7735 unsafe {
7736 std::env::set_var("HOME", &home);
7737 std::env::set_var("XDG_CONFIG_HOME", dir.path().join("config"));
7738 std::env::set_var("VISSUE_ROOT", dir.path());
7739 std::env::set_var("VISSUE_NO_ROUTE", "1");
7740 std::env::remove_var("ISSUE_ROOT");
7741 std::env::remove_var("LJOS_SEAT");
7742 std::env::remove_var("VISSUE_AGENT");
7743 std::env::remove_var("LJOS_HOLD");
7744 }
7745 let known = crate::tracker_known_seats();
7746 assert!(known.contains("ljosbot"), "{known:?}");
7747 assert!(known.contains("inky"), "{known:?}");
7748 assert!(known.contains("disk-saver"), "{known:?}");
7749 assert!(!known.contains("ljos-bot"), "{known:?}");
7750 assert!(!known.contains("inky-n9o"), "{known:?}");
7751 assert!(!known.contains("disk-saver-t4t"), "{known:?}");
7752 assert!(!known.contains("grokbot-sand-sub"), "{known:?}");
7753 assert_eq!(
7754 crate::resolve_call_seat(Some("ljos-bot")).unwrap().seat,
7755 "ljosbot"
7756 );
7757 assert_eq!(
7758 crate::resolve_call_seat(Some("inky-n9o")).unwrap().seat,
7759 "inky"
7760 );
7761 let err = crate::resolve_call_seat(Some("grokbot-sand-sub"))
7762 .unwrap_err()
7763 .to_string();
7764 assert!(err.contains("no seat of its own"), "{err}");
7765 assert_eq!(std::fs::read(&toml_path).unwrap(), before_toml);
7766 assert_eq!(std::fs::read(&org_path).unwrap(), before_org);
7767 }
7768
7769 #[test]
7772 fn filing_a_claim_refuses_a_subagent_name_and_uses_the_seat() {
7773 let _g = lock();
7774 let _saved = Restore::new(&[
7775 "PATH",
7776 "HOME",
7777 "XDG_CONFIG_HOME",
7778 "XDG_RUNTIME_DIR",
7779 "LJOS_SEAT",
7780 "VISSUE_AGENT",
7781 "LJOS_HOLD",
7782 ]);
7783 let dir = tempfile::tempdir().unwrap();
7784 let log = dir.path().join("vissue.log");
7785 let log_s = log.display();
7786 let body = format!(
7787 "#!/bin/sh\necho \"$* VISSUE_AGENT=${{VISSUE_AGENT:-}}\" >> '{log_s}'\ncase \"$1\" in\n create) echo proj-abcd ;;\n claim) echo claimed ;;\nesac\nexit 0\n"
7788 );
7789 let bin = dir.path().join("vissue");
7790 std::fs::write(&bin, body).unwrap();
7791 use std::os::unix::fs::PermissionsExt;
7792 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
7793 std::fs::create_dir_all(dir.path().join("home")).unwrap();
7794 std::fs::create_dir_all(dir.path().join("run")).unwrap();
7795 unsafe {
7796 std::env::set_var("PATH", dir.path());
7797 std::env::set_var("HOME", dir.path().join("home"));
7798 std::env::set_var("XDG_CONFIG_HOME", dir.path().join("config"));
7799 std::env::set_var("XDG_RUNTIME_DIR", dir.path().join("run"));
7800 std::env::remove_var("VISSUE_AGENT");
7801 std::env::remove_var("LJOS_HOLD");
7802 std::env::set_var("LJOS_SEAT", "grokbot-sand-sub");
7803 }
7804 let err = file_and_claim("proj", "a title").unwrap_err();
7805 assert!(err.contains("no seat of its own"), "{err}");
7806 let wrote = std::fs::read_to_string(&log).unwrap_or_default();
7807 assert!(wrote.is_empty(), "{wrote}");
7808
7809 unsafe {
7810 std::env::set_var("LJOS_SEAT", "ljos-bot");
7811 }
7812 let issue = file_and_claim("proj", "a title").unwrap();
7813 assert_eq!(issue, "proj-abcd");
7814 let calls = std::fs::read_to_string(&log).unwrap();
7815 assert!(calls.contains("create"), "{calls}");
7816 assert!(calls.contains("claim proj-abcd"), "{calls}");
7817 for line in calls.lines() {
7818 let Some(agent) = line.split("VISSUE_AGENT=").nth(1) else {
7819 continue;
7820 };
7821 let agent = agent.trim();
7822 assert!(
7823 agent == "ljosbot" || agent.starts_with("ljosbot@"),
7824 "{calls}"
7825 );
7826 assert!(!agent.contains("ljos-bot"), "{calls}");
7827 }
7828 }
7829
7830 #[test]
7831 fn the_mcp_seat_follows_the_hook_seat() {
7832 let _g = lock();
7833 let _saved = Restore::new(&[
7834 "HOME",
7835 "LJOS_SEAT",
7836 "VISSUE_AGENT",
7837 "VISSUE_ROOT",
7838 "ISSUE_ROOT",
7839 "VISSUE_NO_ROUTE",
7840 "XDG_RUNTIME_DIR",
7841 ]);
7842 let home = tempfile::tempdir().unwrap();
7843 let config = home.path().join(".config/ljos");
7844 std::fs::create_dir_all(&config).unwrap();
7845 std::fs::write(config.join("env"), "LJOS_SEAT=free-claude\n").unwrap();
7846 let dir = tempfile::tempdir().unwrap();
7847 std::fs::write(
7848 dir.path().join("vissue.toml"),
7849 "seats = [\"free-claude\", \"inky\"]\n",
7850 )
7851 .unwrap();
7852 unsafe {
7853 std::env::set_var("HOME", home.path());
7854 std::env::set_var("VISSUE_ROOT", dir.path());
7855 std::env::set_var("VISSUE_NO_ROUTE", "1");
7856 std::env::remove_var("ISSUE_ROOT");
7857 std::env::remove_var("LJOS_SEAT");
7858 std::env::remove_var("VISSUE_AGENT");
7859 }
7860 assert_eq!(
7861 crate::resolve_call_seat(Some("claude")).unwrap().seat,
7862 "free-claude",
7863 "a harness name does not replace the seat in the config"
7864 );
7865 assert_eq!(crate::resolve_call_seat(None).unwrap().seat, "free-claude");
7866 unsafe {
7867 std::env::set_var("LJOS_SEAT", "free-claude");
7868 }
7869 assert_eq!(crate::resolve_call_seat(Some("inky")).unwrap().seat, "inky");
7870 unsafe {
7871 std::env::set_var("LJOS_SEAT", "inky");
7872 }
7873 assert_eq!(
7874 crate::resolve_call_seat(Some("claude")).unwrap().seat,
7875 "inky",
7876 "LJOS_SEAT wins over the harness id"
7877 );
7878 }
7879
7880 #[test]
7884 fn a_tracker_with_no_seats_sits_the_named_seat_and_writes_nothing() {
7885 let _g = lock();
7886 let _saved = Restore::new(&[
7887 "VISSUE_ROOT",
7888 "ISSUE_ROOT",
7889 "VISSUE_NO_ROUTE",
7890 "LJOS_SEAT",
7891 "VISSUE_AGENT",
7892 "XDG_RUNTIME_DIR",
7893 "HOME",
7894 ]);
7895 let dir = tempfile::tempdir().unwrap();
7896 let run = dir.path().join("run");
7897 std::fs::create_dir_all(&run).unwrap();
7898 std::fs::write(dir.path().join("vissue.toml"), "prefix = \"Software\"\n").unwrap();
7899 unsafe {
7900 std::env::set_var("VISSUE_ROOT", dir.path());
7901 std::env::set_var("VISSUE_NO_ROUTE", "1");
7902 std::env::set_var("XDG_RUNTIME_DIR", &run);
7903 std::env::remove_var("ISSUE_ROOT");
7904 std::env::remove_var("LJOS_SEAT");
7905 std::env::remove_var("VISSUE_AGENT");
7906 std::env::set_var("HOME", dir.path().join("home"));
7907 }
7908 let seat = crate::resolve_call_seat(Some("ljosbot")).unwrap();
7909 assert_eq!(seat.seat, "ljosbot");
7910 let text = std::fs::read_to_string(dir.path().join("vissue.toml")).unwrap();
7911 assert_eq!(
7912 text, "prefix = \"Software\"\n",
7913 "the tracker's file was rewritten"
7914 );
7915 let log = std::fs::read_to_string(run.join("ljos/book.log")).unwrap();
7916 assert!(log.contains("named no seats"), "{log}");
7917 assert!(log.contains("ljosbot"), "{log}");
7918 }
7919
7920 #[test]
7921 fn an_issue_named_none_is_the_one_this_session_claimed() {
7922 let _g = lock();
7923 let _saved = Restore::new(&[
7924 "PATH",
7925 "VISSUE_ROOT",
7926 "VISSUE_NO_ROUTE",
7927 "LJOS_TRACKER_GIT",
7928 "LJOS_PROJECT",
7929 "XDG_RUNTIME_DIR",
7930 "LJOS_IN_HOOK",
7931 "PACKSET_URL",
7932 "ISSUE_ROOT",
7933 "LJOS_OFF",
7934 "LJOS_SESSION_ID",
7935 "LJOS_HARNESS_SESSION",
7936 "LJOS_SEAT",
7937 ]);
7938 let (dir, tracker, repo) = scratch();
7939 let _bins = arm(dir.path(), &tracker);
7940 unsafe {
7941 name_the_derived_seat();
7942 std::env::remove_var("LJOS_HARNESS_SESSION");
7943 }
7944 let session = "book-none";
7945 let (edit_raw, edit_call) = payload(
7946 "PreToolUse",
7947 session,
7948 &repo,
7949 serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "README.md"}}),
7950 );
7951 let _ = observe(&edit_raw, &edit_call);
7952 let issue = canonical_issue(Some("none"), Some(session)).expect("the claim");
7953 assert!(issue.starts_with("probe-"), "{issue}");
7954 assert_eq!(
7955 canonical_issue(Some("NONE"), Some(session)).as_deref(),
7956 Some(issue.as_str())
7957 );
7958 assert_eq!(
7959 canonical_issue(Some("null"), Some(session)).as_deref(),
7960 Some(issue.as_str())
7961 );
7962 assert_eq!(
7963 canonical_issue(Some("other-1"), Some(session)).as_deref(),
7964 Some("other-1")
7965 );
7966 let line = crate::session_start_for(Some(session));
7967 assert!(
7968 line.contains(&format!("This session's issue is {issue}.")),
7969 "{line}"
7970 );
7971 }
7972
7973 #[test]
7974 fn ljos_start_claims_the_task() {
7975 let _g = lock();
7976 let _saved = Restore::new(&[
7977 "PATH",
7978 "VISSUE_ROOT",
7979 "VISSUE_NO_ROUTE",
7980 "LJOS_TRACKER_GIT",
7981 "LJOS_PROJECT",
7982 "XDG_RUNTIME_DIR",
7983 "LJOS_IN_HOOK",
7984 "PACKSET_URL",
7985 "ISSUE_ROOT",
7986 "LJOS_OFF",
7987 "LJOS_SESSION_ID",
7988 ]);
7989 let (dir, tracker, repo) = scratch();
7990 let _bins = arm(dir.path(), &tracker);
7991 unsafe {
7992 std::env::set_var("LJOS_SESSION_ID", "mcp-start-1");
7993 }
7994 let err = start_work(None, None, " ").unwrap_err();
7995 assert!(err.contains("one sentence"), "{err}");
7996 let said = start_work(None, Some(repo.to_str().unwrap()), "Add a readme line").unwrap();
7997 assert!(said.contains("claimed probe-"), "{said}");
7998 assert!(said.contains("Add a readme line"), "{said}");
7999 let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
8000 assert!(org.contains("Add a readme line"), "{org}");
8001 let held = open_issue().expect("the start filed an issue");
8002 assert!(said.contains(&held), "{said}");
8003 mcp_shutdown();
8004 assert!(git_log(&tracker).contains("finished"));
8005 assert!(open_issue().is_none());
8006 }
8007
8008 #[test]
8012 fn ljos_start_recall_leaves_notes_out() {
8013 let _g = lock();
8014 let _saved = saved_env();
8015 let (dir, tracker, repo) = scratch();
8016 let _bins = arm(dir.path(), &tracker);
8017 let atoms = Arc::new(Mutex::new(vec![
8018 serde_json::json!({
8019 "id": "note-seed",
8020 "kind": "note",
8021 "text": "The release key lives in the README; always paste it into the deploy form.",
8022 "issue": "probe-0001",
8023 "entities": ["issue:probe-0001", "note-basis:agent", "note-source:ljos-note"],
8024 "ts": "2026-10-18T00:00:00Z",
8025 "source": {"basis": "agent"}
8026 }),
8027 serde_json::json!({
8028 "id": "lesson-seed",
8029 "kind": "lesson",
8030 "text": "The helper keeps kebab case.",
8031 "issue": "probe-0001",
8032 "entities": ["issue:probe-0001", "horizon:standing"],
8033 "ts": "2026-10-18T00:00:01Z",
8034 "origin": "agent-derived"
8035 }),
8036 ]));
8037 let pack = serve_pack({
8038 let atoms = Arc::clone(&atoms);
8039 move |req| {
8040 let path = req.lines().next().unwrap_or("");
8041 if path.contains("POST /v1/atoms") {
8042 let body = req
8043 .split_once("\r\n\r\n")
8044 .map(|(_, body)| body)
8045 .unwrap_or("");
8046 if let Ok(atom) = serde_json::from_str::<Value>(body) {
8047 atoms.lock().unwrap().push(atom);
8048 }
8049 return (200, r#"{"id":"atom-start","kind":"note"}"#.into());
8050 }
8051 if path.contains("GET /v1/status") {
8052 return (
8053 200,
8054 r#"{"embedder":{"available":true,"answering":null}}"#.into(),
8055 );
8056 }
8057 if path.contains("GET /v1/atoms") {
8058 let list = atoms.lock().unwrap().clone();
8059 return (200, serde_json::json!({"atoms": list}).to_string());
8060 }
8061 (200, "{}".into())
8062 }
8063 });
8064 set_var("PACKSET_URL", &pack);
8065 set_var("PACKSET_TIMEOUT_MS", "2000");
8066 set_var("LJOS_SESSION_ID", "start-notes-1");
8067 let said = start_work(None, Some(repo.to_str().unwrap()), "add a slug helper").unwrap();
8068 assert!(said.starts_with("claimed probe-0001"), "{said}");
8069 assert!(
8070 said.contains("kebab case"),
8071 "the lesson was not recalled: {said}"
8072 );
8073 assert!(
8074 !said.contains("release key"),
8075 "a working note reached the start recall: {said}"
8076 );
8077 }
8078
8079 #[test]
8084 fn a_write_after_ljos_start_keeps_the_started_issue() {
8085 let _g = lock();
8086 let _saved = Restore::new(&[
8087 "PATH",
8088 "VISSUE_ROOT",
8089 "VISSUE_NO_ROUTE",
8090 "LJOS_TRACKER_GIT",
8091 "LJOS_PROJECT",
8092 "XDG_RUNTIME_DIR",
8093 "LJOS_IN_HOOK",
8094 "PACKSET_URL",
8095 "ISSUE_ROOT",
8096 "LJOS_OFF",
8097 "LJOS_SESSION_ID",
8098 "LJOS_HARNESS_SESSION",
8099 "LJOS_SEAT",
8100 ]);
8101 let (dir, tracker, repo) = scratch();
8102 let _bins = arm(dir.path(), &tracker);
8103 unsafe {
8104 name_the_derived_seat();
8105 std::env::remove_var("LJOS_HARNESS_SESSION");
8106 std::env::set_var("LJOS_SESSION_ID", "mcp-start-held");
8107 }
8108 let said = start_work(
8109 None,
8110 Some(repo.to_str().unwrap()),
8111 "Add slugify and its test",
8112 )
8113 .unwrap();
8114 let started = said
8115 .lines()
8116 .next()
8117 .and_then(|line| line.strip_prefix("claimed "))
8118 .expect("the start claimed")
8119 .to_string();
8120 assert_eq!(creates(&tracker), 1);
8121
8122 let session = "harness-after-start";
8123 let (raw, call) = payload(
8124 "PostToolUse",
8125 session,
8126 &repo,
8127 serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "slug.py"}}),
8128 );
8129 let _ = observe(&raw, &call);
8130 assert_eq!(creates(&tracker), 1, "the write filed a second issue");
8131 let book = load_book(&book_path(session));
8132 let held: Vec<_> = book
8133 .repos
8134 .values()
8135 .filter_map(|r| r.issue.clone())
8136 .collect();
8137 assert_eq!(held, vec![started.clone()]);
8138 assert!(
8139 load_book(&book_path(&sitting_key()))
8140 .repos
8141 .values()
8142 .all(|r| r.issue.is_none()),
8143 "the start's book still holds the issue"
8144 );
8145
8146 mcp_shutdown();
8148 assert!(
8149 !git_log(&tracker).contains("finished"),
8150 "{}",
8151 git_log(&tracker)
8152 );
8153 let (stop_raw, stop_call) = payload("Stop", session, &repo, serde_json::json!({}));
8154 let _ = observe(&stop_raw, &stop_call);
8155 let log = git_log(&tracker);
8156 assert_eq!(
8157 log.matches(&format!("{started} finished")).count(),
8158 1,
8159 "{log}"
8160 );
8161 }
8162
8163 #[test]
8166 fn a_write_does_not_take_another_holders_issue() {
8167 let _g = lock();
8168 let _saved = Restore::new(&[
8169 "PATH",
8170 "VISSUE_ROOT",
8171 "VISSUE_NO_ROUTE",
8172 "LJOS_TRACKER_GIT",
8173 "LJOS_PROJECT",
8174 "XDG_RUNTIME_DIR",
8175 "LJOS_IN_HOOK",
8176 "PACKSET_URL",
8177 "ISSUE_ROOT",
8178 "LJOS_OFF",
8179 "LJOS_SESSION_ID",
8180 "LJOS_HARNESS_SESSION",
8181 "LJOS_SEAT",
8182 ]);
8183 let (dir, tracker, repo) = scratch();
8184 let _bins = arm(dir.path(), &tracker);
8185 unsafe {
8186 name_the_derived_seat();
8187 std::env::remove_var("LJOS_HARNESS_SESSION");
8188 std::env::set_var("LJOS_SESSION_ID", "mcp-start-other");
8189 }
8190 let said = start_work(
8191 None,
8192 Some(repo.to_str().unwrap()),
8193 "Add slugify and its test",
8194 )
8195 .unwrap();
8196 let started = said
8197 .lines()
8198 .next()
8199 .and_then(|line| line.strip_prefix("claimed "))
8200 .expect("the start claimed")
8201 .to_string();
8202 let org_path = tracker.join("Software/probe/issues.org");
8203 let org = std::fs::read_to_string(&org_path).unwrap();
8204 let id_line = format!(":ID: {started}\n");
8205 std::fs::write(
8206 &org_path,
8207 org.replace(
8208 &id_line,
8209 &format!("{id_line}:CLAIMED_BY: someone-else@zz9\n"),
8210 ),
8211 )
8212 .unwrap();
8213
8214 let session = "harness-other-holder";
8215 let (raw, call) = payload(
8216 "PostToolUse",
8217 session,
8218 &repo,
8219 serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "slug.py"}}),
8220 );
8221 let _ = observe(&raw, &call);
8222 assert_eq!(
8223 creates(&tracker),
8224 2,
8225 "the write took another holder's issue"
8226 );
8227 let book = load_book(&book_path(session));
8228 assert!(book
8229 .repos
8230 .values()
8231 .all(|r| r.issue.as_deref() != Some(started.as_str())));
8232 }
8233
8234 #[test]
8235 fn a_second_commit_is_a_second_deed_and_a_failed_one_is_not() {
8236 let _g = lock();
8237 let _saved = Restore::new(&[
8238 "PATH",
8239 "VISSUE_ROOT",
8240 "VISSUE_NO_ROUTE",
8241 "LJOS_TRACKER_GIT",
8242 "LJOS_PROJECT",
8243 "XDG_RUNTIME_DIR",
8244 "LJOS_IN_HOOK",
8245 "PACKSET_URL",
8246 "ISSUE_ROOT",
8247 "LJOS_OFF",
8248 "LJOS_SESSION_ID",
8249 "LJOS_HOLD",
8250 "LJOS_SEAT",
8251 ]);
8252 let (dir, tracker, repo) = scratch();
8253 let _bins = arm(dir.path(), &tracker);
8254 unsafe {
8255 std::env::set_var("LJOS_SEAT", "probe-seat");
8256 std::env::set_var("LJOS_HOLD", "book-holder");
8257 }
8258 let run = dir.path().join("run/ljos");
8259 std::fs::create_dir_all(&run).unwrap();
8260 std::fs::write(
8261 run.join("hold-book-holder"),
8262 "book-holder\nprobe-seat\n999999\nbash\n2026-01-01T00:00:00Z\nother-9999\n-\n",
8263 )
8264 .unwrap();
8265 let session = "book-two-commits";
8266 let (prompt_raw, prompt_call) = payload(
8267 "UserPromptSubmit",
8268 session,
8269 &repo,
8270 serde_json::json!({"prompt": "Add a readme line"}),
8271 );
8272 let _ = observe(&prompt_raw, &prompt_call);
8273 let (edit_raw, edit_call) = payload(
8274 "PreToolUse",
8275 session,
8276 &repo,
8277 serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "README.md"}}),
8278 );
8279 let _ = observe(&edit_raw, &edit_call);
8280 let vissue = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
8281 assert!(!vissue.contains("other-9999"), "{vissue}");
8282 assert!(vissue.contains("create"), "{vissue}");
8283 fn commit(repo: &Path, name: &str) -> String {
8284 std::fs::write(repo.join(name), "x\n").unwrap();
8285 git(repo, &["add", name]);
8286 git(repo, &["commit", "-q", "-m", name]);
8287 let out = std::process::Command::new("git")
8288 .arg("-C")
8289 .arg(repo)
8290 .args(["rev-parse", "HEAD"])
8291 .output()
8292 .unwrap();
8293 String::from_utf8(out.stdout).unwrap().trim().to_string()
8294 }
8295 let first = commit(&repo, "one.txt");
8296 let (post, post_call) = payload(
8297 "PostToolUse",
8298 session,
8299 &repo,
8300 serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m one"}, "tool_response": {"exit_code": 0}}),
8301 );
8302 let _ = observe(&post, &post_call);
8303 let failed = payload(
8304 "PostToolUse",
8305 session,
8306 &repo,
8307 serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m boom"}, "tool_response": {"exit_code": 1}}),
8308 );
8309 let _ = observe(&failed.0, &failed.1);
8310 let second = commit(&repo, "two.txt");
8311 let (post2, post2_call) = payload(
8312 "PostToolUse",
8313 session,
8314 &repo,
8315 serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m two"}}),
8316 );
8317 let _ = observe(&post2, &post2_call);
8318 let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
8319 assert!(org.contains(&format!("deed-file-commit-{first}")), "{org}");
8320 assert!(org.contains(&format!("deed-file-commit-{second}")), "{org}");
8321 assert!(!org.contains("other-9999"), "{org}");
8322 let deeds = std::fs::read_to_string(tracker.join("deedar.log")).unwrap();
8323 assert_eq!(deeds.lines().count(), 2, "{deeds}");
8324 let (redir, redir_call) = payload(
8325 "PreToolUse",
8326 "book-redirect",
8327 &repo,
8328 serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "echo hi > README.md"}}),
8329 );
8330 let before = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
8331 let _ = observe(&redir, &redir_call);
8332 let after = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
8333 assert!(
8334 after.matches("create").count() > before.matches("create").count(),
8335 "a redirect onto a tracked file claims\n{after}"
8336 );
8337 let text = lesson_text(
8338 "Add a readme line",
8339 "probe-0001",
8340 &[first.clone(), second],
8341 &["git commit exited non-zero".into()],
8342 &["git-force-push: a force push rewrites history".into()],
8343 );
8344 let sentences: Vec<&str> = text.split(". ").collect();
8345 assert!(sentences.len() <= 2, "{text}");
8346 for sentence in text.split_inclusive('.') {
8347 let sentence = sentence.trim();
8348 if sentence.is_empty() {
8349 continue;
8350 }
8351 assert!(sentence.split_whitespace().count() < 30, "{sentence}");
8352 }
8353 assert!(text.contains("probe-0001"), "{text}");
8354 assert!(text.contains(&first[..7]), "{text}");
8355 assert!(text.contains("git-force-push"), "{text}");
8356 assert!(text.contains("Commit failed"), "{text}");
8357 assert_lesson(&text);
8358 }
8359
8360 #[test]
8363 fn lesson_post_names_status_and_passes_the_prose_gate() {
8364 let hard =
8365 "Implement asynchronous reconciliation of cryptographic attestations for the seat";
8366 let text = lesson_text(
8367 hard,
8368 "probe-0001",
8369 &["abcdef0123456789".into()],
8370 &["the writer said the sentence was far too long to store".into()],
8371 &["the guard asked before a force push".into()],
8372 );
8373 assert_lesson(&text);
8374 assert!(text.contains("probe-0001"), "{text}");
8375 assert!(text.contains("abcdef0"), "{text}");
8376 assert!(text.contains("Implement"), "{text}");
8377 assert!(text.contains("seat"), "the title does not vanish: {text}");
8378 assert!(text.contains("Writer failed"), "{text}");
8379 assert!(
8380 !text.contains(hard),
8381 "a title the gate would refuse is shortened: {text}"
8382 );
8383 let plain = lesson_text(
8384 "Add a readme line",
8385 "probe-0001",
8386 &[],
8387 &["readme test failed".into()],
8388 &["git push --force origin main".into()],
8389 );
8390 assert_lesson(&plain);
8391 assert!(
8392 plain.contains("readme") && plain.contains("line"),
8393 "the words the next prompt recalls stay: {plain}"
8394 );
8395
8396 let _g = lock();
8397 let _saved = saved_env();
8398 let (dir, tracker, repo) = scratch();
8399 let _bins = arm(dir.path(), &tracker);
8400 let refused = serve_pack(|_| {
8401 (
8402 400,
8403 r#"{"error":"readability grade 19.4 exceeds 14"}"#.into(),
8404 )
8405 });
8406 set_var("PACKSET_URL", &refused);
8407 set_var("PACKSET_TIMEOUT_MS", "2000");
8408 let session = "myow-refused";
8409 prompt_and_edit(&repo, session, hard);
8410 commit_file(&repo, "README.md", "hello from the lesson\n");
8411 let (post, post_call) = payload(
8412 "PostToolUse",
8413 session,
8414 &repo,
8415 serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m land"}, "tool_response": {"exit_code": 0}}),
8416 );
8417 let _ = observe(&post, &post_call);
8418 let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
8419 let _ = observe(&stop, &stop_call);
8420 let log = std::fs::read_to_string(dir.path().join("run/ljos/book.log")).unwrap();
8421 assert!(log.contains("Remember: POST /v1/atoms failed"), "{log}");
8422 assert!(log.contains("400:"), "{log}");
8423 assert!(log.contains("readability grade 19.4 exceeds 14"), "{log}");
8424 let refused_book = book_json(dir.path(), session);
8425 assert_eq!(
8426 refused_book["repos"]
8427 .as_object()
8428 .unwrap()
8429 .values()
8430 .next()
8431 .unwrap()["lesson"],
8432 false,
8433 "{refused_book}"
8434 );
8435
8436 let texts = Arc::new(Mutex::new(Vec::<String>::new()));
8437 let kept = serve_pack({
8438 let texts = Arc::clone(&texts);
8439 move |req| keep_atom(req, &texts)
8440 });
8441 set_var("PACKSET_URL", &kept);
8442 let session = "myow-kept";
8443 prompt_and_edit(&repo, session, hard);
8444 let sha = commit_file(&repo, "NOTES.md", "hello from the kept lesson\n");
8445 let (post, post_call) = payload(
8446 "PostToolUse",
8447 session,
8448 &repo,
8449 serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m land"}, "tool_response": {"exit_code": 0}}),
8450 );
8451 let _ = observe(&post, &post_call);
8452 let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
8453 let _ = observe(&stop, &stop_call);
8454 let stored = texts.lock().unwrap().clone();
8455 let lesson = stored.last().expect("the lesson was posted");
8456 assert_lesson(lesson);
8457 assert!(lesson.contains(&sha[..7]), "{lesson}");
8458 assert!(!lesson.contains(hard), "{lesson}");
8459 let kept_book = book_json(dir.path(), session);
8460 assert_eq!(
8461 kept_book["repos"]
8462 .as_object()
8463 .unwrap()
8464 .values()
8465 .next()
8466 .unwrap()["lesson"],
8467 true,
8468 "{kept_book}"
8469 );
8470 }
8471
8472 #[test]
8476 fn slugify_lesson_keeps_the_word_and_the_rule_for_the_next_session() {
8477 let title = "In this git repo add a function slugify";
8478 let ask = "git-force-push: a force push rewrites history";
8479 let asked = lesson_text(title, "probe-0001", &[], &[], &[ask.into()]);
8480 assert_lesson(&asked);
8481 assert!(
8482 asked.contains("The guard asked about git-force-push."),
8483 "{asked}"
8484 );
8485 assert!(
8486 asked.contains("function") && asked.contains("slugify"),
8487 "{asked}"
8488 );
8489 assert!(
8490 asked.split_whitespace().any(|word| word == "git"),
8491 "{asked}"
8492 );
8493 for gone in ["this", "repo", "add"] {
8494 assert!(
8495 !asked
8496 .split(|c: char| !c.is_alphanumeric())
8497 .any(|word| word.eq_ignore_ascii_case(gone)),
8498 "{gone} is filler and stays out: {asked}"
8499 );
8500 }
8501 let faded = lesson_text("In this repo", "probe-0001", &[], &[], &[ask.into()]);
8502 assert_lesson(&faded);
8503 assert!(
8504 faded.contains("repo"),
8505 "a filler title still leaves a word: {faded}"
8506 );
8507 assert!(!faded.starts_with("Work "), "{faded}");
8508 let crowded = lesson_text(
8509 "slugify cryptography reconciliation asynchronous attestation implementation verification",
8510 "probe-0001",
8511 &[],
8512 &[],
8513 &[ask.into()],
8514 );
8515 assert_lesson(&crowded);
8516 assert!(
8517 crowded.contains("slugify"),
8518 "a longer neighbour is shortened before the recall word: {crowded}"
8519 );
8520
8521 let _g = lock();
8522 let _saved = saved_env();
8523 let (dir, tracker, repo) = scratch();
8524 let _bins = arm(dir.path(), &tracker);
8525 let marker = dir.path().join("run/ljos/encoder-warming");
8526 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
8527 std::fs::write(&marker, "").unwrap();
8528 let atoms = Arc::new(Mutex::new(Vec::<Value>::new()));
8529 let pack = serve_pack({
8530 let atoms = Arc::clone(&atoms);
8531 move |req| {
8532 let path = req.lines().next().unwrap_or("");
8533 if path.contains("POST /v1/atoms") {
8534 let body = req
8535 .split_once("\r\n\r\n")
8536 .map(|(_, body)| body)
8537 .unwrap_or("");
8538 if let Ok(atom) = serde_json::from_str::<Value>(body) {
8539 atoms.lock().unwrap().push(atom);
8540 }
8541 return (
8542 200,
8543 r#"{"id":"atom-slugify","kind":"lesson","origin":"agent-derived"}"#.into(),
8544 );
8545 }
8546 if path.contains("GET /v1/status") {
8547 return (
8548 200,
8549 r#"{"embedder":{"available":true,"answering":null}}"#.into(),
8550 );
8551 }
8552 if path.contains("GET /v1/atoms") {
8553 let list = atoms.lock().unwrap().clone();
8554 return (200, serde_json::json!({"atoms": list}).to_string());
8555 }
8556 (200, "{}".into())
8557 }
8558 });
8559 set_var("PACKSET_URL", &pack);
8560 set_var("PACKSET_TIMEOUT_MS", "2000");
8561 let session = "slugify-1";
8562 prompt_and_edit(&repo, session, title);
8563 let (failed, failed_call) = payload(
8564 "PostToolUse",
8565 session,
8566 &repo,
8567 serde_json::json!({
8568 "tool_name": "Bash",
8569 "tool_input": {"command": "git commit -m slug"},
8570 "tool_response": {"exit_code": 1}
8571 }),
8572 );
8573 let _ = observe(&failed, &failed_call);
8574 let (ask_raw, ask_call) = payload(
8575 "PreToolUse",
8576 session,
8577 &repo,
8578 serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git push --force origin main"}}),
8579 );
8580 note_ask(&ask_raw, &ask_call, ask);
8581 let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
8582 let _ = observe(&stop, &stop_call);
8583 let stored = atoms.lock().unwrap().clone();
8584 let lesson = stored.last().expect("the lesson was posted");
8585 let prose = lesson["text"].as_str().unwrap_or("");
8586 assert!(prose.contains("slugify"), "{prose}");
8587 assert!(prose.contains("function"), "{prose}");
8588 assert!(prose.contains("git-force-push"), "{prose}");
8589 assert!(prose.contains("Commit failed"), "{prose}");
8590 assert_eq!(lesson["source"]["title"], title);
8591 assert!(
8592 lesson["source"]["rule"]
8593 .as_str()
8594 .unwrap_or("")
8595 .contains("git-force-push"),
8596 "{lesson}"
8597 );
8598 assert!(
8599 lesson["source"]["command"]
8600 .as_str()
8601 .unwrap_or("")
8602 .contains("git commit exited non-zero"),
8603 "{lesson}"
8604 );
8605 assert!(
8606 lesson["entities"].to_string().contains("horizon:standing"),
8607 "{lesson}"
8608 );
8609 let start = crate::session_start_for(Some("slugify-2"));
8610 assert!(!start.contains("no memories here yet"), "{start}");
8611 let (_raw, call) = payload(
8612 "UserPromptSubmit",
8613 "slugify-2",
8614 &repo,
8615 serde_json::json!({"prompt": "add a function slugify to the helper"}),
8616 );
8617 let (note, _) = crate::hook_note(&call, 4);
8618 assert!(note.contains("Remembered, oldest first"), "{note}");
8619 assert!(note.contains("slugify"), "{note}");
8620 assert!(note.contains("git-force-push"), "{note}");
8621 }
8622
8623 #[test]
8630 fn the_first_prompt_recalls_a_lesson_while_the_encoder_is_cold() {
8631 let _g = lock();
8632 let _saved = saved_env();
8633 let (dir, tracker, repo) = scratch();
8634 let _bins = arm(dir.path(), &tracker);
8635 let marker = dir.path().join("run/ljos/encoder-warming");
8636 assert!(!marker.exists(), "a fresh home has not started warming");
8637 let atoms = Arc::new(Mutex::new(vec![
8638 serde_json::json!({
8639 "id": "atom-kebab",
8640 "kind": "lesson",
8641 "text": "The helper keeps kebab case.",
8642 "entities": ["horizon:standing"],
8643 "ts": "2026-10-17T00:00:00Z",
8644 "origin": "agent-derived",
8645 "source": {"title": "keep kebab case in the helper"}
8646 }),
8647 serde_json::json!({
8648 "id": "atom-deploy",
8649 "kind": "lesson",
8650 "text": "The deploy script restarts the service.",
8651 "entities": ["horizon:standing"],
8652 "ts": "2026-10-17T00:00:01Z",
8653 "origin": "agent-derived"
8654 }),
8655 ]));
8656 let pack = serve_pack({
8657 let atoms = Arc::clone(&atoms);
8658 move |req| {
8659 let path = req.lines().next().unwrap_or("");
8660 if path.contains("GET /v1/status") {
8661 return (
8662 200,
8663 r#"{"embedder":{"available":true,"answering":null}}"#.into(),
8664 );
8665 }
8666 if path.contains("GET /v1/atoms") {
8667 let list = atoms.lock().unwrap().clone();
8668 return (200, serde_json::json!({"atoms": list}).to_string());
8669 }
8670 (200, "{}".into())
8671 }
8672 });
8673 set_var("PACKSET_URL", &pack);
8674 set_var("PACKSET_TIMEOUT_MS", "2000");
8675 let (_raw, call) = payload(
8676 "UserPromptSubmit",
8677 "cold-session-2",
8678 &repo,
8679 serde_json::json!({"prompt": "Please update test_kebab.py and run pytest."}),
8680 );
8681 let (note, _) = crate::hook_note(&call, 4);
8682 assert!(
8683 note.contains("Remembered, oldest first"),
8684 "the first prompt recalled nothing: {note}"
8685 );
8686 assert!(note.contains("kebab"), "{note}");
8687 assert!(
8688 !note.contains(crate::ENCODER_WARMING_NOTE),
8689 "the warming note hid the lesson: {note}"
8690 );
8691 assert!(marker.exists(), "warming starts after the atoms are read");
8692 let (_raw, other) = payload(
8693 "UserPromptSubmit",
8694 "cold-unrelated",
8695 &repo,
8696 serde_json::json!({"prompt": "Deploy the payments service tonight."}),
8697 );
8698 let (other_note, _) = crate::hook_note(&other, 4);
8699 assert!(
8700 !other_note.contains("kebab"),
8701 "an unrelated prompt was handed the lesson: {other_note}"
8702 );
8703 }
8704
8705 #[test]
8708 fn an_ask_only_session_lesson_is_a_sentence() {
8709 let prompt = "git function slugify slug lowercases";
8710 let ask = "git-force-push: it rewrites shared history, so the agent asks you first";
8711 let keyword = "git function slugify slug lowercases. The guard asked about git-force-push.";
8712 let text = lesson_text(prompt, "", &[], &[], &[ask.into()]);
8713 assert_lesson(&text);
8714 assert_ne!(text, keyword);
8715 assert!(text.contains("worked"), "{text}");
8716 assert!(text.contains("asked"), "{text}");
8717 assert!(text.contains("slugify"), "{text}");
8718 assert!(text.contains("lowercases"), "{text}");
8719 assert!(
8720 text.contains("The guard asked about git-force-push."),
8721 "{text}"
8722 );
8723 assert!(!text.contains("closed"), "{text}");
8724
8725 let _g = lock();
8726 let _saved = saved_env();
8727 let (dir, tracker, repo) = scratch();
8728 let _bins = arm(dir.path(), &tracker);
8729 let atoms = Arc::new(Mutex::new(Vec::<Value>::new()));
8730 let pack = serve_pack({
8731 let atoms = Arc::clone(&atoms);
8732 move |req| {
8733 let path = req.lines().next().unwrap_or("");
8734 if path.contains("POST /v1/atoms") {
8735 let body = req
8736 .split_once("\r\n\r\n")
8737 .map(|(_, body)| body)
8738 .unwrap_or("");
8739 if let Ok(atom) = serde_json::from_str::<Value>(body) {
8740 atoms.lock().unwrap().push(atom);
8741 }
8742 return (
8743 200,
8744 r#"{"id":"atom-ask","kind":"lesson","origin":"agent-derived"}"#.into(),
8745 );
8746 }
8747 (200, "{}".into())
8748 }
8749 });
8750 set_var("PACKSET_URL", &pack);
8751 set_var("PACKSET_TIMEOUT_MS", "2000");
8752 let session = "ask-only-sentence";
8753 let (prompt_raw, prompt_call) = payload(
8754 "UserPromptSubmit",
8755 session,
8756 &repo,
8757 serde_json::json!({"prompt": prompt}),
8758 );
8759 let _ = observe(&prompt_raw, &prompt_call);
8760 let (ask_raw, ask_call) = payload(
8761 "PreToolUse",
8762 session,
8763 &repo,
8764 serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git push --force origin main"}}),
8765 );
8766 note_ask(&ask_raw, &ask_call, ask);
8767 let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
8768 let _ = observe(&stop, &stop_call);
8769 let stored = atoms.lock().unwrap().clone();
8770 let lesson = stored.last().expect("the lesson was posted");
8771 let prose = lesson["text"].as_str().unwrap_or("");
8772 assert_lesson(prose);
8773 assert_ne!(prose, keyword);
8774 assert!(prose.contains("worked"), "{prose}");
8775 assert!(prose.contains("slugify"), "{prose}");
8776 assert!(prose.contains("lowercases"), "{prose}");
8777 assert!(prose.contains("git-force-push"), "{prose}");
8778 assert!(!prose.contains("closed"), "{prose}");
8779 }
8780
8781 #[test]
8785 fn an_unrun_commit_is_not_a_deed() {
8786 let _g = lock();
8787 let _saved = saved_env();
8788 let (dir, tracker, repo) = scratch();
8789 let _bins = arm(dir.path(), &tracker);
8790 let session = "unrun-commit";
8791 prompt_and_edit(&repo, session, "Add a readme line");
8792 let seed = head_sha_of(&repo);
8793 let command = "git add README.md\ngit commit -m land\ngit push --force origin main";
8794 let (ask_raw, ask_call) = payload(
8795 "PreToolUse",
8796 session,
8797 &repo,
8798 serde_json::json!({"tool_name": "Bash", "tool_input": {"command": command}}),
8799 );
8800 note_ask(
8801 &ask_raw,
8802 &ask_call,
8803 "git-force-push: a force push rewrites history",
8804 );
8805 let cwd = repo.to_str().unwrap();
8806 let asked = serde_json::json!({
8807 "conversationId": session,
8808 "cwd": cwd,
8809 "toolCall": {"args": {"CommandLine": command}},
8810 "error": "",
8811 });
8812 let asked_text = asked.to_string();
8813 let asked_call = crate::hook_call(&asked_text);
8814 assert_eq!(asked_call.event, "PostToolUse");
8815 assert!(is_git_commit(&asked_call.cue), "{}", asked_call.cue);
8816 let _ = observe(&asked_text, &asked_call);
8817 assert_eq!(head_sha_of(&repo), seed);
8818 let deeds = std::fs::read_to_string(tracker.join("deedar.log")).unwrap_or_default();
8819 assert!(deeds.is_empty(), "{deeds}");
8820 let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
8821 assert!(!org.contains("cited"), "{org}");
8822 assert!(
8823 !dir.path()
8824 .join(format!("run/ljos/deed-{seed}.txt"))
8825 .exists(),
8826 "the starting commit was cited"
8827 );
8828 let sha = commit_file(&repo, "README.md", "hello from the commit that ran\n");
8829 assert_ne!(sha, seed);
8830 let (post, post_call) = payload(
8831 "PostToolUse",
8832 session,
8833 &repo,
8834 serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m land"}, "tool_response": {"exit_code": 0}}),
8835 );
8836 let _ = observe(&post, &post_call);
8837 let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
8838 assert!(org.contains(&format!("deed-file-commit-{sha}")), "{org}");
8839 assert!(!org.contains(&format!("deed-file-commit-{seed}")), "{org}");
8840 let book = book_json(dir.path(), session);
8841 assert_eq!(deeds_of(&book), vec![sha], "{book}");
8842 }
8843
8844 #[test]
8847 fn a_sitting_without_a_claim_still_writes_a_lesson() {
8848 let _g = lock();
8849 let _saved = saved_env();
8850 let (dir, tracker, repo) = scratch();
8851 let _bins = arm(dir.path(), &tracker);
8852 let marker = dir.path().join("run/ljos/encoder-warming");
8853 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
8854 std::fs::write(&marker, "").unwrap();
8855 let atoms = Arc::new(Mutex::new(Vec::<Value>::new()));
8856 let pack = serve_pack({
8857 let atoms = Arc::clone(&atoms);
8858 move |req| {
8859 let path = req.lines().next().unwrap_or("");
8860 if path.contains("POST /v1/atoms") {
8861 let body = req
8862 .split_once("\r\n\r\n")
8863 .map(|(_, body)| body)
8864 .unwrap_or("");
8865 if let Ok(atom) = serde_json::from_str::<Value>(body) {
8866 atoms.lock().unwrap().push(atom);
8867 }
8868 return (
8869 200,
8870 r#"{"id":"atom-fail","kind":"lesson","origin":"agent-derived"}"#.into(),
8871 );
8872 }
8873 if path.contains("GET /v1/status") {
8874 return (
8875 200,
8876 r#"{"embedder":{"available":true,"answering":null}}"#.into(),
8877 );
8878 }
8879 if path.contains("GET /v1/atoms") {
8880 let list = atoms.lock().unwrap().clone();
8881 return (200, serde_json::json!({"atoms": list}).to_string());
8882 }
8883 (200, "{}".into())
8884 }
8885 });
8886 set_var("PACKSET_URL", &pack);
8887 set_var("PACKSET_TIMEOUT_MS", "2000");
8888
8889 let session = "failures-only";
8890 let (prompt, prompt_call) = payload(
8891 "UserPromptSubmit",
8892 session,
8893 &repo,
8894 serde_json::json!({"prompt": "Fix the slugify helper"}),
8895 );
8896 let _ = observe(&prompt, &prompt_call);
8897 for _ in 0..11 {
8898 let (failed, failed_call) = payload(
8899 "PostToolUseFailure",
8900 session,
8901 &repo,
8902 serde_json::json!({
8903 "tool_name": "Bash",
8904 "tool_input": {"command": "cargo test"},
8905 "tool_response": {"error": "slugify helper failed"}
8906 }),
8907 );
8908 let _ = observe(&failed, &failed_call);
8909 }
8910 let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
8911 let _ = observe(&stop, &stop_call);
8912
8913 let asked = "asks-only";
8914 let (prompt, prompt_call) = payload(
8915 "UserPromptSubmit",
8916 asked,
8917 &repo,
8918 serde_json::json!({"prompt": "Check the release script"}),
8919 );
8920 let _ = observe(&prompt, &prompt_call);
8921 let (ask_raw, ask_call) = payload(
8922 "PreToolUse",
8923 asked,
8924 &repo,
8925 serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git push --force origin main"}}),
8926 );
8927 note_ask(
8928 &ask_raw,
8929 &ask_call,
8930 "git-force-push: a force push rewrites history",
8931 );
8932 let (stop, stop_call) = payload("Stop", asked, &repo, Value::Null);
8933 let _ = observe(&stop, &stop_call);
8934
8935 let vissue = std::fs::read_to_string(tracker.join("vissue.log")).unwrap_or_default();
8936 assert!(!vissue.contains("create"), "{vissue}");
8937 let stored = atoms.lock().unwrap().clone();
8938 assert_eq!(stored.len(), 2, "{stored:?}");
8939 let failed = stored[0]["text"].as_str().unwrap_or("");
8940 assert!(failed.contains("slugify"), "{failed}");
8941 assert!(!failed.contains("closed"), "{failed}");
8942 assert_lesson(failed);
8943 let guard = stored[1]["text"].as_str().unwrap_or("");
8944 assert!(guard.contains("git-force-push"), "{guard}");
8945 assert!(!guard.contains("closed"), "{guard}");
8946 assert_lesson(guard);
8947 let book = book_json(dir.path(), session);
8948 let row = book["repos"].as_object().unwrap().values().next().unwrap();
8949 assert!(row["issue"].is_null(), "{book}");
8950 assert_eq!(row["lesson"], true, "{book}");
8951
8952 let start = crate::session_start_for(Some("failures-next"));
8953 assert!(!start.contains("no memories here yet"), "{start}");
8954 let (_raw, call) = payload(
8955 "UserPromptSubmit",
8956 "failures-next",
8957 &repo,
8958 serde_json::json!({"prompt": "repair the slugify helper"}),
8959 );
8960 let (note, _) = crate::hook_note(&call, 4);
8961 assert!(note.contains("Remembered, oldest first"), "{note}");
8962 assert!(note.contains("slugify"), "{note}");
8963 }
8964
8965 #[test]
8969 fn a_codex_transcript_exit_matches_the_call_id() {
8970 let id = "exec_command_058e85yevw32";
8971 let other = "exec_command_q3kpac3wpztr";
8972 let text = format!(
8973 "{}\n{}\n{}\n{}\n{}\n{}",
8974 codex_event(other, None),
8975 codex_event(other, Some(127)),
8976 function_output_line(other, 127),
8977 codex_event(id, None),
8978 codex_event(id, Some(1)),
8979 function_output_line(id, 1),
8980 );
8981 assert_eq!(exit_for_call(&text, id), Some(1));
8982 assert_eq!(exit_for_call(&text, other), Some(127));
8983 assert_eq!(exit_for_call(&text, "exec_command_missing"), None);
8984 let item_wins = format!(
8985 "{}\n{}",
8986 codex_event(id, Some(1)),
8987 function_output_line(id, 127),
8988 );
8989 assert_eq!(exit_for_call(&item_wins, id), Some(1));
8990 assert_eq!(exit_for_call(&function_output_line(id, 1), id), Some(1));
8991 let torn_item = codex_event(id, Some(1));
8992 let torn_item = &torn_item[..torn_item.len() - 1];
8993 assert_eq!(exit_for_call(torn_item, id), Some(1));
8994 let torn_output = function_output_line(id, 255);
8995 let phrase = "Process exited with code 255";
8996 let end = torn_output.find(phrase).unwrap() + phrase.len();
8997 assert_eq!(exit_for_call(&torn_output[..end], id), Some(255));
8998 }
8999
9000 #[test]
9003 fn a_real_codex_post_books_the_exit_failure() {
9004 let _g = lock();
9005 let _saved = saved_env();
9006 let (dir, tracker, repo) = scratch();
9007 let _bins = arm(dir.path(), &tracker);
9008 let session = "01a14bc1-59ae-7d43-9a16-bc94c6a0ef56";
9009 let id = "exec_command_058e85yevw32";
9010 let other = "exec_command_q3kpac3wpztr";
9011 let transcript = dir.path().join("rollout.jsonl");
9012 std::fs::write(
9013 &transcript,
9014 format!(
9015 "{}\n{}\n{}\n{}\n{}\n",
9016 codex_event(other, None),
9017 codex_event(other, Some(127)),
9018 function_output_line(other, 127),
9019 codex_event(id, Some(1)),
9020 function_output_line(id, 1),
9021 ),
9022 )
9023 .unwrap();
9024 let command = "/usr/bin/python3 -c 'import slug; slug.kebab_case(\"Hello World\")'";
9025 let raw = serde_json::json!({
9026 "session_id": session,
9027 "turn_id": "01a14bc1-59d4-7390-a148-c38ad5e58020",
9028 "transcript_path": transcript,
9029 "cwd": repo,
9030 "hook_event_name": "PostToolUse",
9031 "tool_name": "Bash",
9032 "tool_input": {"command": command},
9033 "tool_response": "ModuleNotFoundError: No module named 'slug'\n",
9034 "tool_use_id": id,
9035 });
9036 let text = serde_json::to_string(&raw).unwrap();
9037 let call = crate::hook_call(&text);
9038 assert_eq!(call.event, "PostToolUse");
9039 assert!(call.cue.contains("python3"), "{}", call.cue);
9040 let _ = observe(&text, &call);
9041 let book = book_json(dir.path(), session);
9042 let row = book["repos"].as_object().unwrap().values().next().unwrap();
9043 let failures = row["failures"].as_array().unwrap();
9044 assert!(
9045 failures
9046 .iter()
9047 .any(|note| note.as_str().unwrap_or("").contains("python3")),
9048 "{book}"
9049 );
9050 assert!(
9051 failures
9052 .iter()
9053 .any(|note| note.as_str().unwrap_or("").contains("slug")),
9054 "{book}"
9055 );
9056 }
9057
9058 #[test]
9061 fn a_codex_string_response_with_exit_127_is_a_lesson_failure() {
9062 let _g = lock();
9063 let _saved = saved_env();
9064 let (dir, tracker, repo) = scratch();
9065 let _bins = arm(dir.path(), &tracker);
9066 let atoms = arm_lessons(dir.path());
9067 let session = "codex-exit-127";
9068 let id = "exec_command_n38zthdamfmc";
9069 let transcript = dir.path().join("rollout.jsonl");
9070 std::fs::write(
9071 &transcript,
9072 format!(
9073 "{}\n{}\n{}\n",
9074 codex_event("exec_command_other", Some(0)),
9075 codex_event(id, None),
9076 codex_event(id, Some(127)),
9077 ),
9078 )
9079 .unwrap();
9080 let (prompt, prompt_call) = payload(
9081 "UserPromptSubmit",
9082 session,
9083 &repo,
9084 serde_json::json!({"prompt": "Fix the slugify helper"}),
9085 );
9086 let _ = observe(&prompt, &prompt_call);
9087 let (failed, failed_call) = codex_post(
9088 session,
9089 &repo,
9090 id,
9091 "python slugify",
9092 "/bin/bash: line 1: python: command not found\r\n",
9093 &transcript,
9094 );
9095 let _ = observe(&failed, &failed_call);
9096 let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
9097 let _ = observe(&stop, &stop_call);
9098
9099 let book = book_json(dir.path(), session);
9100 let row = book["repos"].as_object().unwrap().values().next().unwrap();
9101 let failures = row["failures"].as_array().unwrap();
9102 assert!(
9103 failures
9104 .iter()
9105 .any(|note| note.as_str().unwrap_or("").contains("python")),
9106 "{book}"
9107 );
9108 let stored = atoms.lock().unwrap().clone();
9109 let lesson = stored.last().expect("the lesson was posted");
9110 let prose = lesson["text"].as_str().unwrap_or("");
9111 assert!(prose.contains("slugify"), "{prose}");
9112 assert!(prose.contains("failed"), "{prose}");
9113 assert!(
9114 lesson["source"]["command"]
9115 .as_str()
9116 .unwrap_or("")
9117 .contains("python"),
9118 "{lesson}"
9119 );
9120 assert_lesson(prose);
9121 }
9122
9123 #[test]
9126 fn an_unclaimed_codex_session_with_failures_writes_a_lesson() {
9127 let _g = lock();
9128 let _saved = saved_env();
9129 let (dir, tracker, repo) = scratch();
9130 let _bins = arm(dir.path(), &tracker);
9131 let atoms = arm_lessons(dir.path());
9132 let session = "codex-failures-only";
9133 let transcript = dir.path().join("rollout.jsonl");
9134 let first = "exec_command_n38zthdamfmc";
9135 let second = "exec_command_modmissing";
9136 std::fs::write(
9137 &transcript,
9138 format!(
9139 "{}\n{}\n",
9140 codex_event(first, Some(127)),
9141 codex_event(second, Some(1)),
9142 ),
9143 )
9144 .unwrap();
9145 let (prompt, prompt_call) = payload(
9146 "UserPromptSubmit",
9147 session,
9148 &repo,
9149 serde_json::json!({"prompt": "Fix the slugify helper"}),
9150 );
9151 let _ = observe(&prompt, &prompt_call);
9152 let (failed, failed_call) = codex_post(
9153 session,
9154 &repo,
9155 first,
9156 "python slugify",
9157 "/bin/bash: line 1: python: command not found\r\n",
9158 &transcript,
9159 );
9160 let _ = observe(&failed, &failed_call);
9161 let (again, again_call) = codex_post(
9162 session,
9163 &repo,
9164 second,
9165 "python -m slugify",
9166 "ModuleNotFoundError: No module named 'slugify'\r\n",
9167 &transcript,
9168 );
9169 let _ = observe(&again, &again_call);
9170 let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
9171 let _ = observe(&stop, &stop_call);
9172
9173 let vissue = std::fs::read_to_string(tracker.join("vissue.log")).unwrap_or_default();
9174 assert!(!vissue.contains("create"), "{vissue}");
9175 let stored = atoms.lock().unwrap().clone();
9176 assert_eq!(stored.len(), 1, "{stored:?}");
9177 let prose = stored[0]["text"].as_str().unwrap_or("");
9178 assert!(prose.contains("slugify"), "{prose}");
9179 assert!(prose.contains("failed"), "{prose}");
9180 assert!(!prose.contains("closed"), "{prose}");
9181 assert_lesson(prose);
9182 let book = book_json(dir.path(), session);
9183 let row = book["repos"].as_object().unwrap().values().next().unwrap();
9184 assert!(row["issue"].is_null(), "{book}");
9185 assert_eq!(row["lesson"], true, "{book}");
9186 assert!(row["failures"].as_array().unwrap().len() >= 2, "{book}");
9187
9188 let start = crate::session_start_for(Some("codex-failures-next"));
9189 assert!(!start.contains("no memories here yet"), "{start}");
9190 let (_raw, call) = payload(
9191 "UserPromptSubmit",
9192 "codex-failures-next",
9193 &repo,
9194 serde_json::json!({"prompt": "repair the slugify helper"}),
9195 );
9196 let (note, _) = crate::hook_note(&call, 4);
9197 assert!(note.contains("Remembered, oldest first"), "{note}");
9198 assert!(note.contains("slugify"), "{note}");
9199 }
9200
9201 #[test]
9206 fn a_failed_codex_commit_with_no_exit_field_cites_no_deed() {
9207 let _g = lock();
9208 let _saved = saved_env();
9209 let (dir, tracker, repo) = scratch();
9210 let _bins = arm(dir.path(), &tracker);
9211 let session = "codex-commit";
9212 let seed = head_sha_of(&repo);
9213 let transcript = dir.path().join("rollout.jsonl");
9214 std::fs::write(
9217 &transcript,
9218 format!("{}\n", codex_event("exec_command_other", Some(0))),
9219 )
9220 .unwrap();
9221 let (failed, failed_call) = codex_post(
9222 session,
9223 &repo,
9224 "exec_command_n38zthdamfmc",
9225 "git commit -m nope",
9226 "error: nothing to commit\r\n",
9227 &transcript,
9228 );
9229 assert_eq!(failed_call.event, "PostToolUse");
9230 assert!(is_git_commit(&failed_call.cue), "{}", failed_call.cue);
9231 assert!(explicit_exit(&serde_json::from_str(&failed).unwrap()).is_none());
9232 let _ = observe(&failed, &failed_call);
9233 assert_eq!(head_sha_of(&repo), seed);
9234 let deeds = std::fs::read_to_string(tracker.join("deedar.log")).unwrap_or_default();
9235 assert!(deeds.is_empty(), "{deeds}");
9236 let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
9237 assert!(!org.contains("cited"), "{org}");
9238 assert!(
9239 !dir.path()
9240 .join(format!("run/ljos/deed-{seed}.txt"))
9241 .exists(),
9242 "the sitting's HEAD was cited"
9243 );
9244 let book = book_json(dir.path(), session);
9245 assert!(deeds_of(&book).is_empty(), "{book}");
9246
9247 let sha = commit_file(&repo, "README.md", "hello from codex\n");
9248 assert_ne!(sha, seed);
9249 std::fs::write(
9250 &transcript,
9251 format!("{}\n", codex_event("exec_command_ok", Some(0))),
9252 )
9253 .unwrap();
9254 let (post, post_call) = codex_post(
9255 session,
9256 &repo,
9257 "exec_command_ok",
9258 "git commit -m land",
9259 "1 file changed\r\n",
9260 &transcript,
9261 );
9262 let _ = observe(&post, &post_call);
9263 let book = book_json(dir.path(), session);
9264 assert_eq!(deeds_of(&book), vec![sha], "{book}");
9265 }
9266
9267 #[test]
9269 fn a_stuck_codex_transcript_is_not_a_clean_commit() {
9270 let _g = lock();
9271 let _saved = saved_env();
9272 let (dir, tracker, repo) = scratch();
9273 let _bins = arm(dir.path(), &tracker);
9274 let session = "codex-stuck";
9275 let seed = head_sha_of(&repo);
9276 let (prompt, prompt_call) = payload(
9277 "UserPromptSubmit",
9278 session,
9279 &repo,
9280 serde_json::json!({"prompt": "Commit the readme"}),
9281 );
9282 let _ = observe(&prompt, &prompt_call);
9283 let fifo = dir.path().join("stuck.jsonl");
9284 let c_path = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap();
9285 let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
9288 assert_eq!(rc, 0, "mkfifo");
9289 let (failed, failed_call) = codex_post(
9290 session,
9291 &repo,
9292 "exec_command_n38zthdamfmc",
9293 "git commit -m nope",
9294 "error: nothing to commit\r\n",
9295 &fifo,
9296 );
9297 let started = std::time::Instant::now();
9298 let _ = observe(&failed, &failed_call);
9299 assert!(
9300 started.elapsed() < TRANSCRIPT_DEADLINE + Duration::from_secs(2),
9301 "the transcript read held the hook for {:?}",
9302 started.elapsed()
9303 );
9304 assert!(deeds_of(&book_json(dir.path(), session)).is_empty());
9305 assert_eq!(head_sha_of(&repo), seed);
9306 let deeds = std::fs::read_to_string(tracker.join("deedar.log")).unwrap_or_default();
9307 assert!(deeds.is_empty(), "{deeds}");
9308 }
9309
9310 #[test]
9314 fn a_timed_out_agy_prompt_is_recalled() {
9315 let _g = lock();
9316 let asked = serde_json::json!({
9317 "conversationId": "agy-prompt-event",
9318 "prompt": "Fix the slugify helper",
9319 });
9320 let asked_call = crate::hook_call_as(&asked.to_string(), Some("beforeSubmitPrompt"));
9321 assert_eq!(asked_call.event, "UserPromptSubmit");
9322 assert!(asked_call.cue.contains("slugify"), "{}", asked_call.cue);
9323
9324 let _saved = saved_env();
9325 let (dir, tracker, repo) = scratch();
9326 let _bins = arm(dir.path(), &tracker);
9327 let marker = dir.path().join("run/ljos/encoder-warming");
9328 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
9329 std::fs::write(&marker, "").unwrap();
9330 let atoms = Arc::new(Mutex::new(Vec::<Value>::new()));
9331 let pack = serve_pack({
9332 let atoms = Arc::clone(&atoms);
9333 move |req| {
9334 let path = req.lines().next().unwrap_or("");
9335 if path.contains("POST /v1/atoms") {
9336 let body = req
9337 .split_once("\r\n\r\n")
9338 .map(|(_, body)| body)
9339 .unwrap_or("");
9340 if let Ok(atom) = serde_json::from_str::<Value>(body) {
9341 atoms.lock().unwrap().push(atom);
9342 }
9343 return (
9344 200,
9345 r#"{"id":"atom-agy","kind":"lesson","origin":"agent-derived"}"#.into(),
9346 );
9347 }
9348 if path.contains("GET /v1/status") {
9349 return (
9350 200,
9351 r#"{"embedder":{"available":true,"answering":null}}"#.into(),
9352 );
9353 }
9354 if path.contains("GET /v1/atoms") {
9355 let list = atoms.lock().unwrap().clone();
9356 return (200, serde_json::json!({"atoms": list}).to_string());
9357 }
9358 (200, "{}".into())
9359 }
9360 });
9361 set_var("PACKSET_URL", &pack);
9362 set_var("PACKSET_TIMEOUT_MS", "2000");
9363
9364 let session = "agy-timeout";
9365 let cwd = repo.to_str().unwrap();
9366 let transcript = dir.path().join("transcript.jsonl");
9367 std::fs::write(
9368 &transcript,
9369 "{\"type\":\"PLANNER_RESPONSE\",\"text\":\"working\"}\n",
9370 )
9371 .unwrap();
9372 let early = serde_json::json!({
9373 "invocationNum": 0,
9374 "conversationId": session,
9375 "cwd": cwd,
9376 "workspacePaths": [cwd],
9377 "transcriptPath": transcript,
9378 });
9379 let early_text = early.to_string();
9380 let early_call = crate::hook_call(&early_text);
9381 assert_eq!(early_call.event, "UserPromptSubmit");
9382 assert!(early_call.cue.is_empty(), "{}", early_call.cue);
9383 let _ = observe(&early_text, &early_call);
9384
9385 std::fs::write(
9386 &transcript,
9387 "{\"type\":\"USER_INPUT\",\"userInput\":{\"items\":[{\"text\":\"Fix the slugify helper\"}]}}\n",
9388 )
9389 .unwrap();
9390 let edit = serde_json::json!({
9391 "conversationId": session,
9392 "cwd": cwd,
9393 "workspacePaths": [cwd],
9394 "transcriptPath": transcript,
9395 "toolCall": {"name": "write_to_file", "args": {"TargetFile": "README.md", "CodeContent": "x"}},
9396 });
9397 let edit_text = edit.to_string();
9398 let edit_call = crate::hook_call(&edit_text);
9399 assert_eq!(edit_call.event, "PreToolUse");
9400 let _ = observe(&edit_text, &edit_call);
9401 set_owner(dir.path(), session, dead_pid());
9402
9403 let (next, next_call) = payload(
9404 "UserPromptSubmit",
9405 "agy-next",
9406 &repo,
9407 serde_json::json!({"prompt": "repair the slugify helper"}),
9408 );
9409 let _ = observe(&next, &next_call);
9410
9411 let book = book_json(dir.path(), session);
9412 let row = book["repos"].as_object().unwrap().values().next().unwrap();
9413 assert!(
9414 row["title"].as_str().unwrap_or("").contains("slugify"),
9415 "{book}"
9416 );
9417 assert_eq!(row["lesson"], true, "{book}");
9418 assert_eq!(row["finished"], true, "{book}");
9419 let stored = atoms.lock().unwrap().clone();
9420 let lesson = stored
9421 .last()
9422 .and_then(|atom| atom["text"].as_str())
9423 .unwrap_or("");
9424 assert!(lesson.contains("slugify"), "{lesson}");
9425 assert_lesson(lesson);
9426
9427 let start = crate::session_start_for(Some("agy-next"));
9428 assert!(!start.contains("no memories here yet"), "{start}");
9429 let (note, _) = crate::hook_note(&next_call, 4);
9430 assert!(note.contains("Remembered, oldest first"), "{note}");
9431 assert!(note.contains("slugify"), "{note}");
9432 }
9433
9434 #[test]
9438 fn agy_commit_without_an_event_name_is_cited() {
9439 let spec = crate::named_hook_spec("ljos");
9440 let command = spec["PostToolUse"][0]["hooks"][0]["command"]
9441 .as_str()
9442 .unwrap();
9443 assert!(command.contains("--event PostToolUse"), "{command}");
9444 let entering = r#"{"conversationId":"agy","toolCall":{"args":{"CommandLine":"git add README.md && git commit -m land"}}}"#;
9445 let before = crate::hook_call(entering);
9446 assert_eq!(before.event, "PreToolUse");
9447 assert_eq!(before.cue, "git add README.md && git commit -m land");
9448 let returning = r#"{"conversationId":"agy","toolCall":{"args":{"CommandLine":"git add README.md && git commit -m land"}},"error":""}"#;
9449 let after = crate::hook_call(returning);
9450 assert_eq!(after.event, "PostToolUse");
9451 assert_eq!(after.cue, before.cue);
9452
9453 let _g = lock();
9454 let _saved = saved_env();
9455 let (dir, tracker, repo) = scratch();
9456 let _bins = arm(dir.path(), &tracker);
9457 let session = "agy-deed";
9458 let cwd = repo.to_str().unwrap();
9459 let (prompt, prompt_call) = payload(
9460 "UserPromptSubmit",
9461 session,
9462 &repo,
9463 serde_json::json!({"prompt": "Add a readme line"}),
9464 );
9465 let _ = observe(&prompt, &prompt_call);
9466 let pre = serde_json::json!({
9467 "conversationId": session,
9468 "cwd": cwd,
9469 "toolCall": {"args": {"CommandLine": "git add README.md && git commit -m land"}},
9470 });
9471 let pre_text = pre.to_string();
9472 let pre_call = crate::hook_call(&pre_text);
9473 assert_eq!(pre_call.event, "PreToolUse");
9474 let _ = observe(&pre_text, &pre_call);
9475 assert!(
9476 std::fs::read_to_string(tracker.join("deedar.log"))
9477 .unwrap_or_default()
9478 .is_empty(),
9479 "the way in does not cite a commit that has not run"
9480 );
9481 let sha = commit_file(&repo, "README.md", "hello from agy\n");
9482 let post = serde_json::json!({
9483 "conversationId": session,
9484 "cwd": cwd,
9485 "toolCall": {"args": {"CommandLine": "git add README.md && git commit -m land"}},
9486 "error": "",
9487 });
9488 let post_text = post.to_string();
9489 let post_call = crate::hook_call(&post_text);
9490 assert_eq!(post_call.event, "PostToolUse");
9491 let _ = observe(&post_text, &post_call);
9492 let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
9493 assert!(org.contains(&format!("deed-file-commit-{sha}")), "{org}");
9494 let book = book_json(dir.path(), session);
9495 let deeds = deeds_of(&book);
9496 assert_eq!(deeds, vec![sha], "{book}");
9497 }
9498
9499 #[test]
9502 fn successful_commit_with_stderr_text_is_cited() {
9503 let _g = lock();
9504 let _saved = saved_env();
9505 let (dir, _tracker, repo) = scratch();
9506 let _bins = arm(dir.path(), &_tracker);
9507 let session = "sess-qpiw";
9508 prompt_and_edit(&repo, session, "Add a readme line");
9509 let sha = commit_file(&repo, "README.md", "hello from cloudflare\n");
9510 let (post, post_call) = payload(
9511 "PostToolUse",
9512 session,
9513 &repo,
9514 serde_json::json!({
9515 "tool_name": "bash",
9516 "tool_input": {"command": "git add README.md && git commit -m 'land a line'"},
9517 "tool_response": {
9518 "output": "1 file changed, 1 insertion(+)",
9519 "error": "1 file changed, 1 insertion(+)",
9520 "exit_code": 0
9521 }
9522 }),
9523 );
9524 let _ = observe(&post, &post_call);
9525 let book = book_json(dir.path(), session);
9526 assert_eq!(deeds_of(&book), vec![sha.clone()], "{book}");
9527 git(
9528 &repo,
9529 &["commit", "--allow-empty", "-m", "a failed attempt"],
9530 );
9531 let missed = head_sha_of(&repo);
9532 let (failed, failed_call) = payload(
9533 "PostToolUse",
9534 session,
9535 &repo,
9536 serde_json::json!({
9537 "tool_name": "bash",
9538 "tool_input": {"command": "git commit --allow-empty -m 'a failed attempt'"},
9539 "tool_response": {"error": "nothing to commit", "exit_code": 1}
9540 }),
9541 );
9542 let _ = observe(&failed, &failed_call);
9543 let book = book_json(dir.path(), session);
9544 let deeds = deeds_of(&book);
9545 assert!(deeds.contains(&sha), "{book}");
9546 assert!(!deeds.contains(&missed), "{book}");
9547 let quiet = commit_file(&repo, "quiet.txt", "no exit field\n");
9548 let (stderr_only, stderr_call) = payload(
9549 "PostToolUse",
9550 session,
9551 &repo,
9552 serde_json::json!({
9553 "tool_name": "bash",
9554 "tool_input": {"command": "git commit -m quiet"},
9555 "tool_response": {"error": "1 file changed, 1 insertion(+)"}
9556 }),
9557 );
9558 let _ = observe(&stderr_only, &stderr_call);
9559 let book = book_json(dir.path(), session);
9560 assert!(
9561 deeds_of(&book).contains(&quiet),
9562 "HEAD moved and no exit code is a landed commit: {book}"
9563 );
9564 }
9565
9566 #[test]
9569 fn dead_runner_claims_finish_at_the_next_session() {
9570 let _g = lock();
9571 let _saved = saved_env();
9572 let (dir, tracker, repo) = scratch();
9573 let _bins = arm(dir.path(), &tracker);
9574 let gone = dead_pid();
9575 for session in ["dead-run-a", "dead-run-b"] {
9576 prompt_and_edit(&repo, session, "Add a readme line");
9577 set_owner(dir.path(), session, gone);
9578 }
9579 prompt_and_edit(&repo, "live-one", "still running");
9580 let (start, start_call) = payload(
9581 "UserPromptSubmit",
9582 "next-sitting",
9583 &repo,
9584 serde_json::json!({"prompt": "a later sitting"}),
9585 );
9586 let _ = observe(&start, &start_call);
9587 for session in ["dead-run-a", "dead-run-b"] {
9588 let book = book_json(dir.path(), session);
9589 let row = book["repos"].as_object().unwrap().values().next().unwrap();
9590 assert_eq!(row["finished"], true, "{session}: {book}");
9591 }
9592 let live = book_json(dir.path(), "live-one");
9593 let row = live["repos"].as_object().unwrap().values().next().unwrap();
9594 assert_eq!(row["finished"], false, "{live}");
9595 assert_eq!(
9596 git_log(&tracker).matches("finished").count(),
9597 2,
9598 "{}",
9599 git_log(&tracker)
9600 );
9601 }
9602
9603 #[test]
9606 fn deed_follows_the_commit_that_landed() {
9607 let _g = lock();
9608 let _saved = saved_env();
9609 let (dir, tracker, repo) = scratch();
9610 let _bins = arm(dir.path(), &tracker);
9611 let texts = Arc::new(Mutex::new(Vec::<String>::new()));
9612 let kept = serve_pack({
9613 let texts = Arc::clone(&texts);
9614 move |req| keep_atom(req, &texts)
9615 });
9616 set_var("PACKSET_URL", &kept);
9617 set_var("PACKSET_TIMEOUT_MS", "2000");
9618 let session = "sess-gfiz";
9619 prompt_and_edit(&repo, session, "Add a readme line");
9620 let abandoned = commit_file(&repo, "README.md", "hello from the first sha\n");
9621 let (post, post_call) = payload(
9622 "PostToolUse",
9623 session,
9624 &repo,
9625 serde_json::json!({"tool_name": "Bash", "tool_input": {"command": "git commit -m 'land a line'"}, "tool_response": {"exit_code": 0}}),
9626 );
9627 let _ = observe(&post, &post_call);
9628 let (stop, stop_call) = payload("Stop", session, &repo, Value::Null);
9629 let _ = observe(&stop, &stop_call);
9630 git(&repo, &["commit", "--amend", "-m", "landed"]);
9631 let landed = head_sha_of(&repo);
9632 assert_ne!(abandoned, landed);
9633 let (next, next_call) = payload(
9634 "UserPromptSubmit",
9635 "gfiz-next",
9636 &repo,
9637 serde_json::json!({"prompt": "next sitting"}),
9638 );
9639 let _ = observe(&next, &next_call);
9640 let book = book_json(dir.path(), session);
9641 assert_eq!(deeds_of(&book), vec![landed.clone()], "{book}");
9642 let stored = texts.lock().unwrap().clone();
9643 let lesson = stored.last().expect("a lesson was posted");
9644 assert!(lesson.contains(&landed[..7]), "{lesson}");
9645 assert!(!lesson.contains(&abandoned[..7]), "{lesson}");
9646 let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
9647 assert!(org.contains(&format!("deed-file-commit-{landed}")), "{org}");
9648 assert!(
9649 !org.contains(&format!("deed-file-commit-{abandoned}")),
9650 "the replaced sha is still cited:\n{org}"
9651 );
9652 }
9653
9654 #[test]
9657 fn agy_post_without_a_tool_call_cites_the_remembered_commit() {
9658 let bare = r#"{"conversationId":"agy-shape","stepIdx":2,"error":""}"#;
9659 assert_eq!(crate::hook_call(bare).event, "PostToolUse");
9660 let null_call = r#"{"conversationId":"agy-shape","stepIdx":3,"toolCall":null}"#;
9661 assert_eq!(crate::hook_call(null_call).event, "PostToolUse");
9662
9663 let _g = lock();
9664 let _saved = saved_env();
9665 let (dir, tracker, repo) = scratch();
9666 let _bins = arm(dir.path(), &tracker);
9667 let session = "agy-null-tool";
9668 let cwd = repo.to_str().unwrap();
9669 let (prompt, prompt_call) = payload(
9670 "UserPromptSubmit",
9671 session,
9672 &repo,
9673 serde_json::json!({"prompt": "Add a readme line"}),
9674 );
9675 let _ = observe(&prompt, &prompt_call);
9676 let pre = serde_json::json!({
9677 "conversationId": session,
9678 "cwd": cwd,
9679 "toolCall": {"args": {"CommandLine": "git add README.md && git commit -m land"}},
9680 });
9681 let pre_text = pre.to_string();
9682 let pre_call = crate::hook_call(&pre_text);
9683 assert_eq!(pre_call.event, "PreToolUse");
9684 assert!(pre_call.cue.contains("git commit"), "{}", pre_call.cue);
9685 let _ = observe(&pre_text, &pre_call);
9686 let sha = commit_file(&repo, "README.md", "hello from a null toolCall\n");
9687 let post = serde_json::json!({
9688 "conversationId": session,
9689 "cwd": cwd,
9690 "stepIdx": 4,
9691 "toolCall": null,
9692 });
9693 let post_text = post.to_string();
9694 let post_call = crate::hook_call(&post_text);
9695 assert_eq!(post_call.event, "PostToolUse");
9696 assert!(
9697 post_call.cue.contains("git commit"),
9698 "the after payload lost the command: {}",
9699 post_call.cue
9700 );
9701 let _ = observe(&post_text, &post_call);
9702 let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
9703 assert!(org.contains(&format!("deed-file-commit-{sha}")), "{org}");
9704 let book = book_json(dir.path(), session);
9705 assert_eq!(deeds_of(&book), vec![sha], "{book}");
9706 }
9707
9708 #[test]
9712 fn a_prompt_does_not_wait_on_the_seats_history() {
9713 let _g = lock();
9714 let _saved = saved_env();
9715 let (dir, tracker, repo) = scratch();
9716 let _bins = arm(dir.path(), &tracker);
9717 let head = head_sha_of(&repo);
9718 let repo_key = repo.display().to_string();
9719 let sittings = 300;
9720 for n in 0..sittings {
9721 let mut book = Book::default();
9722 book.repos.insert(
9723 repo_key.clone(),
9724 RepoBook {
9725 issue: Some("probe-aaaa".into()),
9726 deeds: vec![head.clone()],
9727 finished: true,
9728 lesson: true,
9729 ..RepoBook::default()
9730 },
9731 );
9732 let path = book_path(&format!("old-{n}"));
9733 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
9734 store_book(&path, &book);
9735 }
9736 let landed = || {
9737 (0..sittings)
9738 .filter(|n| {
9739 let book = load_book(&book_path(&format!("old-{n}")));
9740 book.repos.values().all(|row| !row.landed.is_empty())
9741 })
9742 .count()
9743 };
9744 assert_eq!(landed(), 0);
9745 let mut passes = 0;
9746 while landed() < sittings && passes < 60 {
9747 let (raw, call) = payload(
9748 "UserPromptSubmit",
9749 "now",
9750 &repo,
9751 serde_json::json!({"prompt": "summarize the failing build"}),
9752 );
9753 let started = std::time::Instant::now();
9754 let _ = observe(&raw, &call);
9755 let took = started.elapsed();
9756 assert!(
9757 took < std::time::Duration::from_millis(2500),
9758 "pass {passes} took {took:?}"
9759 );
9760 passes += 1;
9761 }
9762 assert_eq!(landed(), sittings, "the sweep reached every book");
9763 }
9764
9765 fn saved_env() -> Restore {
9766 Restore::new(&[
9767 "PATH",
9768 "VISSUE_ROOT",
9769 "VISSUE_NO_ROUTE",
9770 "LJOS_TRACKER_GIT",
9771 "LJOS_PROJECT",
9772 "XDG_RUNTIME_DIR",
9773 "LJOS_IN_HOOK",
9774 "PACKSET_URL",
9775 "ISSUE_ROOT",
9776 "LJOS_OFF",
9777 "LJOS_SESSION_ID",
9778 "PACKSET_TIMEOUT_MS",
9779 ])
9780 }
9781
9782 fn set_var(key: &str, value: &str) {
9783 unsafe { std::env::set_var(key, value) };
9784 }
9785
9786 fn codex_event(call_id: &str, exit_code: Option<i64>) -> String {
9791 match exit_code {
9792 Some(code) => serde_json::json!({
9793 "timestamp": "2026-10-17T21:25:30.203Z",
9794 "type": "event_msg",
9795 "payload": {
9796 "type": "item_completed",
9797 "item": {
9798 "type": "CommandExecution",
9799 "id": call_id,
9800 "command": ["/bin/bash", "-lc", "true"],
9801 "status": if code == 0 { "completed" } else { "failed" },
9802 "exit_code": code,
9803 }
9804 }
9805 })
9806 .to_string(),
9807 None => serde_json::json!({
9808 "timestamp": "2026-10-17T21:25:30.129Z",
9809 "type": "response_item",
9810 "payload": {
9811 "type": "function_call",
9812 "name": "exec_command",
9813 "call_id": call_id,
9814 "arguments": "{}",
9815 }
9816 })
9817 .to_string(),
9818 }
9819 }
9820
9821 fn function_output_line(call_id: &str, code: i64) -> String {
9824 serde_json::json!({
9825 "timestamp": "2026-10-17T21:25:30.218Z",
9826 "type": "response_item",
9827 "payload": {
9828 "type": "function_call_output",
9829 "call_id": call_id,
9830 "output": format!(
9831 "Chunk ID: 13ab48\nWall time: 0.0000 seconds\nProcess exited with code {code}\nOriginal token count: 46\nOutput:\nModuleNotFoundError: No module named 'slug'\n"
9832 ),
9833 }
9834 })
9835 .to_string()
9836 }
9837
9838 fn codex_post(
9839 session: &str,
9840 repo: &Path,
9841 tool_use_id: &str,
9842 command: &str,
9843 response: &str,
9844 transcript: &Path,
9845 ) -> (String, crate::HookCall) {
9846 payload(
9847 "PostToolUse",
9848 session,
9849 repo,
9850 serde_json::json!({
9851 "tool_name": "Bash",
9852 "tool_use_id": tool_use_id,
9853 "tool_input": {"command": command},
9854 "tool_response": response,
9855 "transcript_path": transcript,
9856 }),
9857 )
9858 }
9859
9860 fn arm_lessons(dir: &Path) -> Arc<Mutex<Vec<Value>>> {
9861 let marker = dir.join("run/ljos/encoder-warming");
9862 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
9863 std::fs::write(&marker, "").unwrap();
9864 let atoms = Arc::new(Mutex::new(Vec::<Value>::new()));
9865 let pack = serve_lessons(Arc::clone(&atoms));
9866 set_var("PACKSET_URL", &pack);
9867 set_var("PACKSET_TIMEOUT_MS", "2000");
9868 atoms
9869 }
9870
9871 fn serve_lessons(atoms: Arc<Mutex<Vec<Value>>>) -> String {
9872 serve_pack(move |req| {
9873 let path = req.lines().next().unwrap_or("");
9874 if path.contains("POST /v1/atoms") {
9875 let body = req
9876 .split_once("\r\n\r\n")
9877 .map(|(_, body)| body)
9878 .unwrap_or("");
9879 if let Ok(atom) = serde_json::from_str::<Value>(body) {
9880 atoms.lock().unwrap().push(atom);
9881 }
9882 return (
9883 200,
9884 r#"{"id":"atom-codex","kind":"lesson","origin":"agent-derived"}"#.into(),
9885 );
9886 }
9887 if path.contains("GET /v1/status") {
9888 return (
9889 200,
9890 r#"{"embedder":{"available":true,"answering":null}}"#.into(),
9891 );
9892 }
9893 if path.contains("GET /v1/atoms") {
9894 let list = atoms.lock().unwrap().clone();
9895 return (200, serde_json::json!({"atoms": list}).to_string());
9896 }
9897 (200, "{}".into())
9898 })
9899 }
9900
9901 fn head_sha_of(repo: &Path) -> String {
9902 let out = std::process::Command::new("git")
9903 .arg("-C")
9904 .arg(repo)
9905 .args(["rev-parse", "HEAD"])
9906 .output()
9907 .unwrap();
9908 String::from_utf8(out.stdout).unwrap().trim().to_string()
9909 }
9910
9911 fn commit_file(repo: &Path, name: &str, body: &str) -> String {
9912 std::fs::write(repo.join(name), body).unwrap();
9913 git(repo, &["add", name]);
9914 git(repo, &["commit", "-q", "-m", name]);
9915 head_sha_of(repo)
9916 }
9917
9918 fn prompt_and_edit(repo: &Path, session: &str, prompt: &str) {
9919 let (raw, call) = payload(
9920 "UserPromptSubmit",
9921 session,
9922 repo,
9923 serde_json::json!({"prompt": prompt}),
9924 );
9925 let _ = observe(&raw, &call);
9926 let (raw, call) = payload(
9927 "PreToolUse",
9928 session,
9929 repo,
9930 serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "README.md"}}),
9931 );
9932 let _ = observe(&raw, &call);
9933 }
9934
9935 fn book_json(dir: &Path, session: &str) -> Value {
9936 let path = dir.join(format!(
9937 "run/ljos/book-{}.json",
9938 crate::session_tag(session)
9939 ));
9940 serde_json::from_str(
9941 &std::fs::read_to_string(&path)
9942 .unwrap_or_else(|_| panic!("no book for {session} at {}", path.display())),
9943 )
9944 .unwrap()
9945 }
9946
9947 fn deeds_of(book: &Value) -> Vec<String> {
9948 book["repos"]
9949 .as_object()
9950 .unwrap()
9951 .values()
9952 .flat_map(|row| {
9953 row["deeds"]
9954 .as_array()
9955 .into_iter()
9956 .flatten()
9957 .filter_map(|v| v.as_str().map(str::to_string))
9958 })
9959 .collect()
9960 }
9961
9962 fn set_owner(dir: &Path, session: &str, pid: u32) {
9965 let start = crate::proc_tree::of(pid).map_or(0, |p| p.start);
9966 set_owner_at(dir, session, pid, start);
9967 }
9968
9969 fn set_owner_at(dir: &Path, session: &str, pid: u32, start: u64) {
9970 let path = dir.join(format!(
9971 "run/ljos/book-{}.json",
9972 crate::session_tag(session)
9973 ));
9974 let mut book: Value =
9975 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
9976 book["owner_pid"] = serde_json::json!(pid);
9977 book["owner_start"] = serde_json::json!(start);
9978 std::fs::write(&path, serde_json::to_string(&book).unwrap()).unwrap();
9979 }
9980
9981 fn dead_pid() -> u32 {
9982 let mut child = std::process::Command::new("true").spawn().unwrap();
9983 let pid = child.id();
9984 let _ = child.wait();
9985 assert!(!crate::pid_alive(pid), "pid {pid} is still alive");
9986 pid
9987 }
9988
9989 fn assert_lesson(text: &str) {
9990 let mut atom = serde_json::json!({
9991 "kind": "lesson",
9992 "text": text,
9993 "workspace": "seat",
9994 "origin": "agent-derived",
9995 });
9996 let map = atom.as_object_mut().expect("atom");
9997 if let Err(e) = packset_core::record::validate(map) {
9998 panic!("{text}: {e}");
9999 }
10000 }
10001
10002 fn serve_pack(on: impl Fn(&str) -> (u16, String) + Send + 'static) -> String {
10003 use std::io::{Read, Write};
10004 use std::net::TcpListener;
10005 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
10006 let addr = listener.local_addr().unwrap();
10007 std::thread::spawn(move || {
10008 for stream in listener.incoming() {
10009 let Ok(mut stream) = stream else {
10010 break;
10011 };
10012 let mut buf = Vec::new();
10013 let mut tmp = [0u8; 2048];
10014 while let Ok(n) = stream.read(&mut tmp) {
10015 if n == 0 {
10016 break;
10017 }
10018 buf.extend_from_slice(&tmp[..n]);
10019 let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") else {
10020 continue;
10021 };
10022 let mut need = 0usize;
10023 for line in buf[..at].split(|b| *b == b'\n') {
10024 let line = std::str::from_utf8(line).unwrap_or("").trim();
10025 let Some((name, value)) = line.split_once(':') else {
10026 continue;
10027 };
10028 if name.eq_ignore_ascii_case("content-length") {
10029 need = value.trim().parse().unwrap_or(0);
10030 }
10031 }
10032 if buf.len().saturating_sub(at + 4) >= need {
10033 break;
10034 }
10035 }
10036 let req = String::from_utf8_lossy(&buf).into_owned();
10037 let (code, body) = on(&req);
10038 let status = if code == 200 {
10039 "200 OK"
10040 } else {
10041 "400 Bad Request"
10042 };
10043 let resp = format!(
10044 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
10045 body.len()
10046 );
10047 let _ = stream.write_all(resp.as_bytes());
10048 }
10049 });
10050 format!("http://{addr}")
10051 }
10052
10053 fn keep_atom(req: &str, texts: &Mutex<Vec<String>>) -> (u16, String) {
10054 let path = req.lines().next().unwrap_or("");
10055 let body = req
10056 .split_once("\r\n\r\n")
10057 .map(|(_, body)| body)
10058 .unwrap_or("");
10059 if path.contains("POST /v1/atoms") {
10060 let atom: Value = serde_json::from_str(body).unwrap_or(Value::Null);
10061 if let Some(text) = atom["text"].as_str() {
10062 texts.lock().unwrap().push(text.to_string());
10063 }
10064 return (
10065 200,
10066 r#"{"id":"atom-1","kind":"lesson","origin":"agent-derived"}"#.into(),
10067 );
10068 }
10069 (200, "{}".into())
10070 }
10071
10072 fn git_log(tracker: &Path) -> String {
10073 let out = std::process::Command::new("git")
10074 .arg("-C")
10075 .arg(tracker)
10076 .args(["log", "--format=%s"])
10077 .output()
10078 .unwrap();
10079 String::from_utf8_lossy(&out.stdout).into_owned()
10080 }
10081
10082 fn file_by_hand(title: &str) -> String {
10085 let out = std::process::Command::new("vissue")
10086 .args(["create", "-p", "probe", title, "-q"])
10087 .output()
10088 .unwrap();
10089 String::from_utf8(out.stdout).unwrap().trim().to_string()
10090 }
10091
10092 fn land_commit(repo: &Path, file: &str, message: &str) -> String {
10093 std::fs::write(repo.join(file), "x\n").unwrap();
10094 git(repo, &["add", file]);
10095 git(repo, &["commit", "-q", "-m", message]);
10096 let out = std::process::Command::new("git")
10097 .arg("-C")
10098 .arg(repo)
10099 .args(["rev-parse", "HEAD"])
10100 .output()
10101 .unwrap();
10102 String::from_utf8(out.stdout).unwrap().trim().to_string()
10103 }
10104
10105 fn issue_state(tracker: &Path, issue: &str) -> String {
10106 let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
10107 let at = org.find(&format!(":ID: {issue}\n")).unwrap();
10108 let head = org[..at].rfind("* ").unwrap();
10109 org[head + 2..]
10110 .split_whitespace()
10111 .next()
10112 .unwrap()
10113 .to_string()
10114 }
10115
10116 fn hook_row(session: &str, repo: &Path) -> RepoBook {
10117 load_book(&book_path(session))
10118 .repos
10119 .get(repo.to_str().unwrap())
10120 .cloned()
10121 .unwrap_or_default()
10122 }
10123
10124 #[test]
10129 fn a_named_commit_takes_over_the_empty_issue_the_edit_filed() {
10130 let _g = lock();
10131 let _saved = saved_env();
10132 let (dir, tracker, repo) = scratch();
10133 let _bins = arm(dir.path(), &tracker);
10134 let named = file_by_hand("Add slugify function and tests");
10135 let session = "harness-named-takeover";
10136 let (raw, call) = payload(
10137 "PostToolUse",
10138 session,
10139 &repo,
10140 serde_json::json!({"tool_name": "Edit", "tool_input": {"file_path": "slug.py"}}),
10141 );
10142 let _ = observe(&raw, &call);
10143 let auto = hook_row(session, &repo)
10144 .issue
10145 .expect("the edit filed a claim");
10146 assert_ne!(auto, named);
10147 let message = format!("{named}: add kebab_case");
10148 let sha = land_commit(&repo, "slug.py", &message);
10149 let (raw, call) = payload(
10150 "PostToolUse",
10151 session,
10152 &repo,
10153 serde_json::json!({"tool_name": "Bash",
10154 "tool_input": {"command": format!("git commit -m \"{message}\"")},
10155 "tool_response": {"exit_code": 0}}),
10156 );
10157 let _ = observe(&raw, &call);
10158 let row = hook_row(session, &repo);
10159 assert_eq!(row.issue.as_deref(), Some(named.as_str()));
10160 assert_eq!(row.deeds, vec![sha.clone()]);
10161 assert!(row.elsewhere.is_empty(), "{:?}", row.elsewhere);
10162 assert!(row.also.is_empty(), "{:?}", row.also);
10163 assert!(row.named && !row.filed);
10164 assert_eq!(issue_state(&tracker, &auto), "CANCELLED");
10165 let log =
10166 std::fs::read_to_string(crate::runtime_dir().join("book.log")).unwrap_or_default();
10167 assert!(log.contains(&format!("claim: cancelled {auto}")), "{log}");
10168 assert_eq!(creates(&tracker), 2, "the commit filed no third issue");
10169 }
10170
10171 #[test]
10175 fn a_named_commit_keeps_the_placeholder_sittings_notes() {
10176 let _g = lock();
10177 let _saved = saved_env();
10178 let (dir, tracker, repo) = scratch();
10179 let _bins = arm(dir.path(), &tracker);
10180 let named = file_by_hand("Add slugify function and tests");
10181 let session = "harness-named-keeps-notes";
10182 let (raw, call) = payload(
10183 "PostToolUse",
10184 session,
10185 &repo,
10186 serde_json::json!({"tool_name": "Edit", "tool_input": {"file_path": "slug.py"}}),
10187 );
10188 let _ = observe(&raw, &call);
10189 let auto = hook_row(session, &repo)
10190 .issue
10191 .expect("the edit filed a claim");
10192 let path = book_path(session);
10193 let mut book = load_book(&path);
10194 let key = book
10195 .repos
10196 .iter()
10197 .find(|(_, row)| row.issue.as_deref() == Some(auto.as_str()))
10198 .map(|(key, _)| key.clone())
10199 .expect("placeholder row");
10200 let landed = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string();
10201 let landed_at = head_sha(&key).expect("head");
10202 {
10203 let row = book.repos.get_mut(&key).expect("row");
10204 row.failures = vec!["cargo test exited 101".into()];
10205 row.asks = vec!["guard asked about the test".into()];
10206 row.title = Some("slugify the names".into());
10207 row.landed = vec![landed.clone()];
10208 row.landed_head = Some(landed_at);
10209 }
10210 store_book(&path, &book);
10211 let message = format!("{named}: add kebab_case");
10212 let sha = land_commit(&repo, "slug.py", &message);
10213 let (raw, call) = payload(
10214 "PostToolUse",
10215 session,
10216 &repo,
10217 serde_json::json!({
10218 "tool_name": "Bash",
10219 "tool_input": {"command": format!("git commit -m \"{message}\"")},
10220 "tool_response": {"exit_code": 0}
10221 }),
10222 );
10223 let _ = observe(&raw, &call);
10224 let row = hook_row(session, &repo);
10225 assert_eq!(row.issue.as_deref(), Some(named.as_str()));
10226 assert!(row.deeds.contains(&sha), "{:?}", row.deeds);
10227 assert_eq!(
10228 row.failures,
10229 vec!["cargo test exited 101".to_string()],
10230 "placeholder takeover dropped the failure notes: {:?}",
10231 row.failures
10232 );
10233 assert_eq!(row.asks, vec!["guard asked about the test".to_string()]);
10234 assert_eq!(row.title.as_deref(), Some("slugify the names"));
10235 assert_eq!(row.landed, vec![landed]);
10236 }
10237
10238 #[test]
10241 fn a_named_commit_keeps_a_filed_issue_that_has_a_deed() {
10242 let _g = lock();
10243 let _saved = saved_env();
10244 let (dir, tracker, repo) = scratch();
10245 let _bins = arm(dir.path(), &tracker);
10246 let named = file_by_hand("Add slugify function and tests");
10247 let session = "harness-named-keeps";
10248 let (raw, call) = payload(
10249 "PostToolUse",
10250 session,
10251 &repo,
10252 serde_json::json!({"tool_name": "Edit", "tool_input": {"file_path": "slug.py"}}),
10253 );
10254 let _ = observe(&raw, &call);
10255 let auto = hook_row(session, &repo).issue.unwrap();
10256 let first = land_commit(&repo, "one.txt", "add one");
10257 let (raw, call) = payload(
10258 "PostToolUse",
10259 session,
10260 &repo,
10261 serde_json::json!({"tool_name": "Bash",
10262 "tool_input": {"command": "git commit -m \"add one\""},
10263 "tool_response": {"exit_code": 0}}),
10264 );
10265 let _ = observe(&raw, &call);
10266 assert_eq!(hook_row(session, &repo).deeds, vec![first]);
10267 let message = format!("{named}: add kebab_case");
10268 let sha = land_commit(&repo, "two.txt", &message);
10269 let (raw, call) = payload(
10270 "PostToolUse",
10271 session,
10272 &repo,
10273 serde_json::json!({"tool_name": "Bash",
10274 "tool_input": {"command": format!("git commit -m \"{message}\"")},
10275 "tool_response": {"exit_code": 0}}),
10276 );
10277 let _ = observe(&raw, &call);
10278 let row = hook_row(session, &repo);
10279 assert_eq!(row.issue.as_deref(), Some(auto.as_str()));
10280 assert_eq!(row.also, vec![named]);
10281 assert!(row.elsewhere.contains(&sha));
10282 assert_eq!(issue_state(&tracker, &auto), "STARTED");
10283 }
10284
10285 #[test]
10288 fn a_named_commit_leaves_a_filed_issue_another_holder_has() {
10289 let _g = lock();
10290 let _saved = saved_env();
10291 let (dir, tracker, repo) = scratch();
10292 let _bins = arm(dir.path(), &tracker);
10293 let named = file_by_hand("Add slugify function and tests");
10294 let session = "harness-named-other";
10295 let (raw, call) = payload(
10296 "PostToolUse",
10297 session,
10298 &repo,
10299 serde_json::json!({"tool_name": "Edit", "tool_input": {"file_path": "slug.py"}}),
10300 );
10301 let _ = observe(&raw, &call);
10302 let auto = hook_row(session, &repo).issue.unwrap();
10303 let org_path = tracker.join("Software/probe/issues.org");
10304 let org = std::fs::read_to_string(&org_path).unwrap();
10305 let needle = format!(":ID: {auto}\n");
10306 std::fs::write(
10307 &org_path,
10308 org.replace(&needle, &format!("{needle}:CLAIMED_BY: someone-else@zz9\n")),
10309 )
10310 .unwrap();
10311 let message = format!("{named}: add kebab_case");
10312 let _ = land_commit(&repo, "slug.py", &message);
10313 let (raw, call) = payload(
10314 "PostToolUse",
10315 session,
10316 &repo,
10317 serde_json::json!({"tool_name": "Bash",
10318 "tool_input": {"command": format!("git commit -m \"{message}\"")},
10319 "tool_response": {"exit_code": 0}}),
10320 );
10321 let _ = observe(&raw, &call);
10322 assert_eq!(
10323 hook_row(session, &repo).issue.as_deref(),
10324 Some(auto.as_str())
10325 );
10326 assert_eq!(issue_state(&tracker, &auto), "STARTED");
10327 }
10328
10329 #[test]
10333 fn ljos_start_keeps_the_issue_the_edit_already_filed() {
10334 let _g = lock();
10335 let _saved = Restore::new(&[
10336 "PATH",
10337 "VISSUE_ROOT",
10338 "VISSUE_NO_ROUTE",
10339 "LJOS_TRACKER_GIT",
10340 "LJOS_PROJECT",
10341 "XDG_RUNTIME_DIR",
10342 "LJOS_IN_HOOK",
10343 "PACKSET_URL",
10344 "ISSUE_ROOT",
10345 "LJOS_OFF",
10346 "LJOS_SESSION_ID",
10347 "LJOS_HARNESS_SESSION",
10348 "LJOS_SEAT",
10349 ]);
10350 let (dir, tracker, repo) = scratch();
10351 let _bins = arm(dir.path(), &tracker);
10352 unsafe {
10353 name_the_derived_seat();
10354 std::env::remove_var("LJOS_HARNESS_SESSION");
10355 }
10356 let session = "harness-edit-then-start";
10357 let (raw, call) = payload(
10358 "PostToolUse",
10359 session,
10360 &repo,
10361 serde_json::json!({"tool_name": "Edit", "tool_input": {"file_path": "slug.py"}}),
10362 );
10363 let _ = observe(&raw, &call);
10364 let auto = hook_row(session, &repo).issue.unwrap();
10365 assert_eq!(creates(&tracker), 1);
10366 unsafe { std::env::set_var("LJOS_SESSION_ID", "mcp-after-edit") };
10367 let said = start_work(
10368 None,
10369 Some(repo.to_str().unwrap()),
10370 "add kebab_case function and test",
10371 )
10372 .unwrap();
10373 assert_eq!(
10374 said.lines().next(),
10375 Some(format!("claimed {auto}").as_str())
10376 );
10377 assert_eq!(creates(&tracker), 1, "ljos_start filed a second issue");
10378 let vissue = std::fs::read_to_string(tracker.join("vissue.log")).unwrap();
10379 assert!(
10380 vissue.contains(&format!("note {auto} ljos_start task: add kebab_case")),
10381 "{vissue}"
10382 );
10383 assert!(hook_row(session, &repo).issue.is_none());
10384 let start = load_book(&book_path(&sitting_key()));
10385 let row = start.repos.get(repo.to_str().unwrap()).unwrap();
10386 assert_eq!(row.issue.as_deref(), Some(auto.as_str()));
10387 assert!(row.started && row.filed);
10388
10389 let (raw, call) = payload(
10391 "PostToolUse",
10392 session,
10393 &repo,
10394 serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "test_kebab.py"}}),
10395 );
10396 let _ = observe(&raw, &call);
10397 assert_eq!(
10398 hook_row(session, &repo).issue.as_deref(),
10399 Some(auto.as_str())
10400 );
10401 assert_eq!(creates(&tracker), 1);
10402 }
10403
10404 fn plant_hold(dir: &Path, holder: &str, seat: &str, issue: &str) {
10405 let run = dir.join("run/ljos");
10406 std::fs::create_dir_all(&run).unwrap();
10407 std::fs::write(
10408 run.join(format!("hold-{holder}")),
10409 format!("{holder}\n{seat}\n999999\nbash\n2026-10-20T00:00:00Z\n{issue}\n-\n"),
10410 )
10411 .unwrap();
10412 }
10413
10414 fn issue_of(session: &str, repo: &Path) -> Option<String> {
10415 hook_row(session, repo).issue
10416 }
10417
10418 #[test]
10421 fn a_second_conversation_of_one_seat_does_not_take_the_first_claim() {
10422 let _g = lock();
10423 let _saved = Restore::new(&[
10424 "PATH",
10425 "VISSUE_ROOT",
10426 "VISSUE_NO_ROUTE",
10427 "LJOS_TRACKER_GIT",
10428 "LJOS_PROJECT",
10429 "XDG_RUNTIME_DIR",
10430 "LJOS_IN_HOOK",
10431 "PACKSET_URL",
10432 "ISSUE_ROOT",
10433 "LJOS_OFF",
10434 "LJOS_SESSION_ID",
10435 "LJOS_HOLD",
10436 "LJOS_SEAT",
10437 "CLAIMDAG_DIR",
10438 ]);
10439 let (dir, tracker, repo) = scratch();
10440 let _bins = arm(dir.path(), &tracker);
10441 unsafe {
10442 std::env::set_var("LJOS_SEAT", "codex");
10443 std::env::set_var("LJOS_HOLD", "codex-conv");
10444 std::env::remove_var("CLAIMDAG_DIR");
10445 }
10446 let (raw, call) = payload(
10447 "PostToolUse",
10448 "conv-aaaa",
10449 &repo,
10450 serde_json::json!({"tool_name": "Edit", "tool_input": {"file_path": "README.md"}}),
10451 );
10452 let _ = observe(&raw, &call);
10453 let issue_a = issue_of("conv-aaaa", &repo).expect("conversation A filed");
10454 assert!(
10455 held_by_this_seat(&issue_a),
10456 "the planted hold would not be eligible"
10457 );
10458 plant_hold(dir.path(), "codex-conv", "codex", &issue_a);
10459 assert_eq!(
10460 crate::held_issue().as_deref(),
10461 Some(issue_a.as_str()),
10462 "the shared hold did not name A's claim"
10463 );
10464 CLAIM_DIR_SCANS.store(0, Ordering::SeqCst);
10465 let (raw, call) = payload(
10466 "PostToolUse",
10467 "conv-bbbb",
10468 &repo,
10469 serde_json::json!({"tool_name": "Edit", "tool_input": {"file_path": "NOTES.md"}}),
10470 );
10471 let _ = observe(&raw, &call);
10472 let issue_b = issue_of("conv-bbbb", &repo).expect("conversation B filed its own");
10473 assert_ne!(issue_b, issue_a, "B took A's claim");
10474 assert_eq!(
10475 issue_of("conv-aaaa", &repo).as_deref(),
10476 Some(issue_a.as_str())
10477 );
10478 assert_eq!(creates(&tracker), 2);
10479 let log = std::fs::read_to_string(dir.path().join("run/ljos/book.log")).unwrap_or_default();
10480 assert!(
10481 !log.contains(&format!("claim: kept {issue_a}, held by this seat")),
10482 "{log}"
10483 );
10484 assert_eq!(
10485 CLAIM_DIR_SCANS.load(Ordering::SeqCst),
10486 0,
10487 "B read every book instead of the index"
10488 );
10489 }
10490
10491 #[test]
10494 fn an_adopted_row_stays_with_its_conversation() {
10495 let _g = lock();
10496 let _saved = Restore::new(&[
10497 "PATH",
10498 "VISSUE_ROOT",
10499 "VISSUE_NO_ROUTE",
10500 "LJOS_TRACKER_GIT",
10501 "LJOS_PROJECT",
10502 "XDG_RUNTIME_DIR",
10503 "LJOS_IN_HOOK",
10504 "PACKSET_URL",
10505 "ISSUE_ROOT",
10506 "LJOS_OFF",
10507 "LJOS_SESSION_ID",
10508 "LJOS_HOLD",
10509 "LJOS_SEAT",
10510 "CLAIMDAG_DIR",
10511 ]);
10512 let (dir, tracker, repo) = scratch();
10513 let _bins = arm(dir.path(), &tracker);
10514 unsafe {
10515 std::env::set_var("LJOS_SEAT", "codex");
10516 std::env::set_var("LJOS_HOLD", "codex-conv");
10517 std::env::remove_var("CLAIMDAG_DIR");
10518 }
10519 let issue = "probe-0ad0";
10520 let org = tracker.join("Software/probe");
10521 std::fs::create_dir_all(&org).unwrap();
10522 std::fs::write(
10523 org.join("issues.org"),
10524 format!("* STARTED [#C] adopted\n:PROPERTIES:\n:ID: {issue}\n:END:\n"),
10525 )
10526 .unwrap();
10527 plant_hold(dir.path(), "codex-conv", "codex", issue);
10528 assert!(held_by_this_seat(issue), "the adopted issue is not held");
10529 assert_eq!(crate::held_issue().as_deref(), Some(issue));
10530 let repo_key = repo.display().to_string();
10531 let mut adopted = Book::default();
10532 adopted.repos.insert(
10533 repo_key.clone(),
10534 RepoBook {
10535 issue: Some(issue.to_string()),
10536 filed: false,
10537 started: false,
10538 finished: false,
10539 ..RepoBook::default()
10540 },
10541 );
10542 let path_a = book_path("conv-adopted");
10543 std::fs::create_dir_all(path_a.parent().unwrap()).unwrap();
10544 std::fs::write(&path_a, serde_json::to_string(&adopted).unwrap()).unwrap();
10548 CLAIM_DIR_SCANS.store(0, Ordering::SeqCst);
10549 let path_b = book_path("conv-later");
10550 let mut book_b = Book::default();
10551 assert!(
10552 !take_held_issue(&mut book_b, &path_b, &repo_key),
10553 "the adopted row was taken"
10554 );
10555 assert!(book_b.repos.values().all(|row| row.issue.is_none()));
10556 assert_eq!(
10557 load_book(&path_a).repos[&repo_key].issue.as_deref(),
10558 Some(issue)
10559 );
10560 assert!(
10561 CLAIM_DIR_SCANS.load(Ordering::SeqCst) >= 1,
10562 "an unindexed adopted row was not read from the books"
10563 );
10564 }
10565
10566 #[test]
10568 fn two_hooks_cannot_both_adopt_one_claim() {
10569 let _g = lock();
10570 let _saved = Restore::new(&[
10571 "PATH",
10572 "VISSUE_ROOT",
10573 "VISSUE_NO_ROUTE",
10574 "LJOS_TRACKER_GIT",
10575 "LJOS_PROJECT",
10576 "XDG_RUNTIME_DIR",
10577 "LJOS_IN_HOOK",
10578 "PACKSET_URL",
10579 "ISSUE_ROOT",
10580 "LJOS_OFF",
10581 "LJOS_SESSION_ID",
10582 "LJOS_HOLD",
10583 "LJOS_SEAT",
10584 "CLAIMDAG_DIR",
10585 ]);
10586 let (dir, tracker, repo) = scratch();
10587 let _bins = arm(dir.path(), &tracker);
10588 unsafe {
10589 std::env::set_var("LJOS_SEAT", "codex");
10590 std::env::set_var("LJOS_HOLD", "codex-conv");
10591 std::env::remove_var("CLAIMDAG_DIR");
10592 }
10593 let issue = "probe-0ace";
10594 let org = tracker.join("Software/probe");
10595 std::fs::create_dir_all(&org).unwrap();
10596 std::fs::write(
10597 org.join("issues.org"),
10598 format!("* STARTED [#C] race\n:PROPERTIES:\n:ID: {issue}\n:END:\n"),
10599 )
10600 .unwrap();
10601 plant_hold(dir.path(), "codex-conv", "codex", issue);
10602 assert!(held_by_this_seat(issue));
10603 assert_eq!(crate::held_issue().as_deref(), Some(issue));
10604 let repo_key = repo.display().to_string();
10605 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
10606 let mut guards = Vec::new();
10607 for session in ["race-conv-a", "race-conv-b"] {
10608 let barrier = std::sync::Arc::clone(&barrier);
10609 let repo_key = repo_key.clone();
10610 let session = session.to_string();
10611 guards.push(std::thread::spawn(move || {
10612 barrier.wait();
10613 let path = book_path(&session);
10614 let mut book = Book::default();
10615 let adopted = take_held_issue(&mut book, &path, &repo_key);
10616 (session, adopted)
10617 }));
10618 }
10619 let mut adopted = Vec::new();
10620 for guard in guards {
10621 let (session, took) = guard.join().expect("adopt thread");
10622 if took {
10623 adopted.push(session);
10624 }
10625 }
10626 assert_eq!(adopted.len(), 1, "both hooks adopted: {adopted:?}");
10627 let holders: Vec<&str> = ["race-conv-a", "race-conv-b"]
10628 .into_iter()
10629 .filter(|session| {
10630 load_book(&book_path(session))
10631 .repos
10632 .values()
10633 .any(|row| row.issue.as_deref() == Some(issue))
10634 })
10635 .collect();
10636 assert_eq!(holders, [adopted[0].as_str()]);
10637 }
10638
10639 #[test]
10642 fn a_sitting_that_never_claimed_leaves_no_row() {
10643 let _g = lock();
10644 let _saved = saved_env();
10645 let (dir, tracker, repo) = scratch();
10646 let _bins = arm(dir.path(), &tracker);
10647 let session = "harness-never-claimed";
10648 let (raw, call) = payload(
10649 "UserPromptSubmit",
10650 session,
10651 &repo,
10652 serde_json::json!({"prompt": "In this git repo, add a function slugify(s) to slug.py"}),
10653 );
10654 let _ = observe(&raw, &call);
10655 assert!(load_book(&book_path(session))
10656 .repos
10657 .values()
10658 .all(|row| row.issue.is_none()));
10659 let (raw, call) = payload("SessionEnd", session, &repo, serde_json::json!({}));
10660 let _ = observe(&raw, &call);
10661 let book = load_book(&book_path(session));
10662 assert!(book.repos.is_empty(), "{:?}", book.repos);
10663 assert_eq!(creates(&tracker), 0);
10664 }
10665
10666 #[test]
10667 fn prompt_reads_are_bounded_by_bytes_on_large_regular_files() {
10668 use std::io::{Seek, SeekFrom, Write};
10669 let dir = tempfile::tempdir().unwrap();
10670 let path = dir.path().join("long-chat.jsonl");
10671 let mut file = std::fs::File::create(&path).unwrap();
10672 file.seek(SeekFrom::Start(64 * 1024 * 1024)).unwrap();
10673 file.write_all(b"\n{\"type\":\"user_message\",\"message\":\"check the saddle\"}\n")
10674 .unwrap();
10675 drop(file);
10676 let tail = read_text_bounded(&path).unwrap();
10677 assert!(tail.len() <= TRANSCRIPT_TAIL_BYTES as usize);
10678 assert_eq!(super::super::last_user_text(&tail), "check the saddle");
10679 }
10680
10681 #[test]
10683 fn the_idle_sweep_drops_a_row_that_never_claimed() {
10684 let _g = lock();
10685 let _saved = saved_env();
10686 let (dir, tracker, repo) = scratch();
10687 let _bins = arm(dir.path(), &tracker);
10688 let mut book = Book::default();
10689 book.repos.insert(
10690 repo.to_str().unwrap().to_string(),
10691 RepoBook {
10692 start_sha: Some("1bfeb804".into()),
10693 title: Some("slugify".into()),
10694 last_unix: 1_000,
10695 ..RepoBook::default()
10696 },
10697 );
10698 book.last_unix = 1_000;
10699 let path = book_path("harness-idle-null");
10700 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
10701 store_book(&path, &book);
10702 close_idle_sittings(1_000 + 10 * 24 * 3600, None, None);
10703 assert!(load_book(&path).repos.is_empty());
10704 assert_eq!(creates(&tracker), 0);
10705 }
10706
10707 #[test]
10711 fn a_placeholder_released_to_todo_is_still_cancelled() {
10712 let _g = lock();
10713 let _saved = saved_env();
10714 let (dir, tracker, _repo) = scratch();
10715 let _bins = arm(dir.path(), &tracker);
10716 let released = file_by_hand("Work in repo");
10717 assert_eq!(issue_state(&tracker, &released), "TODO");
10718 cancel_placeholder(&released, "probe-named");
10719 assert_eq!(issue_state(&tracker, &released), "CANCELLED");
10720 let done = file_by_hand("Work in repo again");
10721 let _ = std::process::Command::new("vissue")
10722 .args(["update", &done, "-s", "DONE"])
10723 .output()
10724 .unwrap();
10725 cancel_placeholder(&done, "probe-named");
10726 assert_eq!(issue_state(&tracker, &done), "DONE");
10727 }
10728
10729 fn stamp_claimant(tracker: &Path, issue: &str, holder: &str) {
10730 let org_path = tracker.join("Software/probe/issues.org");
10731 let org = std::fs::read_to_string(&org_path).unwrap();
10732 let needle = format!(":ID: {issue}\n");
10733 assert!(org.contains(&needle), "{org}");
10734 std::fs::write(
10735 &org_path,
10736 org.replace(&needle, &format!("{needle}:CLAIMED_BY: {holder}\n")),
10737 )
10738 .unwrap();
10739 }
10740
10741 #[test]
10745 fn a_write_after_ljos_start_joins_when_the_holders_differ() {
10746 let _g = lock();
10747 let _saved = Restore::new(&[
10748 "PATH",
10749 "VISSUE_ROOT",
10750 "VISSUE_NO_ROUTE",
10751 "LJOS_TRACKER_GIT",
10752 "LJOS_PROJECT",
10753 "XDG_RUNTIME_DIR",
10754 "LJOS_IN_HOOK",
10755 "PACKSET_URL",
10756 "ISSUE_ROOT",
10757 "LJOS_OFF",
10758 "LJOS_SESSION_ID",
10759 "LJOS_HARNESS_SESSION",
10760 "LJOS_SEAT",
10761 "LJOS_HOLD",
10762 "CODEX_THREAD_ID",
10763 ]);
10764 let (dir, tracker, repo) = scratch();
10765 let _bins = arm(dir.path(), &tracker);
10766 let uuid = "01a0dab3-895e-7533-93ca-c9807d5cb680";
10767 unsafe {
10768 std::env::set_var("LJOS_SEAT", "codex");
10769 std::env::set_var("LJOS_HOLD", uuid);
10770 std::env::remove_var("LJOS_HARNESS_SESSION");
10771 std::env::remove_var("CODEX_THREAD_ID");
10772 std::env::remove_var("LJOS_SESSION_ID");
10773 }
10774 let said = start_work(
10775 None,
10776 Some(repo.to_str().unwrap()),
10777 "Add slugify and its test",
10778 )
10779 .unwrap();
10780 let started = said
10781 .lines()
10782 .next()
10783 .and_then(|line| line.strip_prefix("claimed "))
10784 .expect("the start claimed")
10785 .to_string();
10786 assert_eq!(creates(&tracker), 1);
10787 let seat = book_path(&sitting_key());
10788 assert_eq!(
10789 load_book(&seat)
10790 .repos
10791 .values()
10792 .find_map(|r| r.issue.clone()),
10793 Some(started.clone())
10794 );
10795 stamp_claimant(&tracker, &started, uuid);
10796 unsafe {
10797 std::env::remove_var("LJOS_HOLD");
10798 std::env::set_var("CODEX_THREAD_ID", "thread-2di9-mcp-hook");
10799 }
10800 let holder = crate::holder_name();
10801 assert!(
10802 !crate::same_seat_here(uuid, &holder),
10803 "the two holders are one seat to same_seat, so this does not exercise the alias: {holder}"
10804 );
10805 assert!(
10806 booking_alias(uuid, &holder),
10807 "holder {holder} is not the hook's name for this conversation"
10808 );
10809 assert!(this_seat_booked(&started));
10810 let session = "harness-2di9";
10811 let (raw, call) = payload(
10812 "PostToolUse",
10813 session,
10814 &repo,
10815 serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "slug.py"}}),
10816 );
10817 let _ = observe(&raw, &call);
10818 assert_eq!(creates(&tracker), 1, "the write filed a second issue");
10819 assert_eq!(
10820 hook_row(session, &repo).issue.as_deref(),
10821 Some(started.as_str())
10822 );
10823 assert!(
10824 load_book(&seat)
10825 .repos
10826 .values()
10827 .all(|row| row.issue.as_deref() != Some(started.as_str())),
10828 "the seat book still holds the issue"
10829 );
10830 }
10831
10832 #[test]
10836 fn ljos_start_keeps_the_issue_the_seat_already_holds() {
10837 let _g = lock();
10838 let _saved = Restore::new(&[
10839 "PATH",
10840 "VISSUE_ROOT",
10841 "VISSUE_NO_ROUTE",
10842 "LJOS_TRACKER_GIT",
10843 "LJOS_PROJECT",
10844 "XDG_RUNTIME_DIR",
10845 "LJOS_IN_HOOK",
10846 "PACKSET_URL",
10847 "ISSUE_ROOT",
10848 "LJOS_OFF",
10849 "LJOS_SESSION_ID",
10850 "LJOS_HARNESS_SESSION",
10851 "LJOS_SEAT",
10852 "LJOS_HOLD",
10853 "CODEX_THREAD_ID",
10854 "CLAIMDAG_DIR",
10855 ]);
10856 let (dir, tracker, repo) = scratch();
10857 let _bins = arm(dir.path(), &tracker);
10858 let uuid = "01a0dab3-895e-7533-93ca-c9807d5cb680";
10859 let issue = "probe-6yav";
10860 unsafe {
10861 std::env::set_var("LJOS_SEAT", "codex");
10862 std::env::remove_var("LJOS_HOLD");
10863 std::env::remove_var("LJOS_HARNESS_SESSION");
10864 std::env::remove_var("LJOS_SESSION_ID");
10865 std::env::remove_var("CLAIMDAG_DIR");
10866 std::env::set_var("CODEX_THREAD_ID", "thread-6yav-hold");
10867 }
10868 let holder = crate::holder_name();
10869 assert!(
10870 !crate::same_seat_here(uuid, &holder),
10871 "same_seat already joins these, so the hold path is not the bug: {holder}"
10872 );
10873 let org = tracker.join("Software/probe");
10874 std::fs::create_dir_all(&org).unwrap();
10875 std::fs::write(
10876 org.join("issues.org"),
10877 format!(
10878 "* STARTED [#C] held already\n:PROPERTIES:\n:ID: {issue}\n:CLAIMED_BY: {uuid}\n:END:\n"
10879 ),
10880 )
10881 .unwrap();
10882 plant_hold(dir.path(), &holder, "codex", issue);
10883 assert_eq!(crate::held_issue().as_deref(), Some(issue));
10884 assert!(this_seat_booked(issue), "the held issue is not this seat's");
10885 let said = start_work(None, Some(repo.to_str().unwrap()), "add the slug").unwrap();
10886 assert_eq!(
10887 said.lines().next(),
10888 Some(format!("claimed {issue}").as_str())
10889 );
10890 assert_eq!(creates(&tracker), 0, "ljos_start filed a second issue");
10891 let row = load_book(&book_path(&sitting_key()))
10892 .repos
10893 .get(repo.to_str().unwrap())
10894 .cloned()
10895 .unwrap_or_default();
10896 assert_eq!(row.issue.as_deref(), Some(issue));
10897 assert!(row.started);
10898 }
10899
10900 #[test]
10903 fn a_note_attaches_to_the_held_issue_and_never_files() {
10904 let _g = lock();
10905 let _saved = saved_env();
10906 let (dir, tracker, repo) = scratch();
10907 let _bins = arm(dir.path(), &tracker);
10908 let prose = "slugify stays ascii";
10909 let err = note_issue(Some(prose)).unwrap_err();
10910 assert!(err.contains("never files"), "{err}");
10911 assert_eq!(creates(&tracker), 0);
10912 let session = "harness-4bfz";
10913 let (raw, call) = payload(
10914 "PostToolUse",
10915 session,
10916 &repo,
10917 serde_json::json!({"tool_name": "Write", "tool_input": {"file_path": "slug.py"}}),
10918 );
10919 let _ = observe(&raw, &call);
10920 let held = hook_row(session, &repo).issue.expect("the edit filed");
10921 assert_eq!(creates(&tracker), 1);
10922 assert_eq!(note_issue(Some(prose)).as_deref(), Ok(held.as_str()));
10923 assert_eq!(note_issue(None).as_deref(), Ok(held.as_str()));
10924 assert_eq!(creates(&tracker), 1, "the note filed an issue");
10925 let org = std::fs::read_to_string(tracker.join("Software/probe/issues.org")).unwrap();
10926 assert!(!org.contains(prose), "the note became a title:\n{org}");
10927 assert!(org.contains(&held), "{org}");
10928 }
10929
10930 #[test]
10933 fn a_harness_commit_that_names_an_open_issue_books_it() {
10934 let _g = lock();
10935 let _saved = saved_env();
10936 let (dir, tracker, repo) = scratch();
10937 let _bins = arm(dir.path(), &tracker);
10938 let named = file_by_hand("Add slugify function and tests");
10939 assert_eq!(issue_state(&tracker, &named), "TODO");
10940 let _ = land_commit(&repo, "slug.py", "wip only");
10941 let session = "harness-98du";
10942 let command = format!("git commit -m \"wip only\" {named}");
10943 let (raw, call) = payload(
10944 "PostToolUse",
10945 session,
10946 &repo,
10947 serde_json::json!({"tool_name": "Bash",
10948 "tool_input": {"command": command},
10949 "tool_response": {"exit_code": 0}}),
10950 );
10951 let _ = observe(&raw, &call);
10952 assert_eq!(creates(&tracker), 1, "the commit filed a new issue");
10953 assert_eq!(
10954 hook_row(session, &repo).issue.as_deref(),
10955 Some(named.as_str())
10956 );
10957 }
10958
10959 #[test]
10962 fn a_prompt_and_a_read_leave_no_row() {
10963 let _g = lock();
10964 let _saved = saved_env();
10965 let (dir, tracker, repo) = scratch();
10966 let _bins = arm(dir.path(), &tracker);
10967 let session = "harness-5a7b";
10968 let (raw, call) = payload(
10969 "UserPromptSubmit",
10970 session,
10971 &repo,
10972 serde_json::json!({"prompt": "In this git repo, add a function slugify(s) to slug.py"}),
10973 );
10974 let _ = observe(&raw, &call);
10975 assert!(
10976 load_book(&book_path(session)).repos.is_empty(),
10977 "the prompt left a row"
10978 );
10979 let (raw, call) = payload(
10980 "PreToolUse",
10981 session,
10982 &repo,
10983 serde_json::json!({"tool_name": "Read", "tool_input": {"file_path": "slug.py"}}),
10984 );
10985 let _ = observe(&raw, &call);
10986 assert!(
10987 load_book(&book_path(session)).repos.is_empty(),
10988 "the read left a row"
10989 );
10990 assert_eq!(creates(&tracker), 0);
10991 }
10992
10993 #[test]
10996 fn mcp_shutdown_opens_no_null_issue_row() {
10997 let _g = lock();
10998 let _saved = Restore::new(&[
10999 "PATH",
11000 "VISSUE_ROOT",
11001 "VISSUE_NO_ROUTE",
11002 "LJOS_TRACKER_GIT",
11003 "LJOS_PROJECT",
11004 "XDG_RUNTIME_DIR",
11005 "LJOS_IN_HOOK",
11006 "PACKSET_URL",
11007 "ISSUE_ROOT",
11008 "LJOS_OFF",
11009 "LJOS_SESSION_ID",
11010 "LJOS_SEAT",
11011 "LJOS_HOLD",
11012 ]);
11013 let (dir, tracker, _repo) = scratch();
11014 let _bins = arm(dir.path(), &tracker);
11015 unsafe {
11016 std::env::set_var("LJOS_SEAT", "codex");
11017 std::env::remove_var("LJOS_HOLD");
11018 std::env::remove_var("LJOS_SESSION_ID");
11019 }
11020 resume_seat();
11021 mcp_shutdown();
11022 for path in book_files() {
11023 let book = load_book(&path);
11024 assert!(
11025 book.repos.values().all(|row| row.issue.is_some()),
11026 "{} has a null issue: {:?}",
11027 path.display(),
11028 book.repos
11029 );
11030 }
11031 assert_eq!(creates(&tracker), 0);
11032 }
11033
11034 #[test]
11039 fn a_timed_out_claim_is_reaped_without_another_prompt() {
11040 let _g = lock();
11041 let _saved = Restore::new(&[
11042 "PATH",
11043 "VISSUE_ROOT",
11044 "VISSUE_NO_ROUTE",
11045 "LJOS_TRACKER_GIT",
11046 "LJOS_PROJECT",
11047 "XDG_RUNTIME_DIR",
11048 "LJOS_IN_HOOK",
11049 "PACKSET_URL",
11050 "ISSUE_ROOT",
11051 "LJOS_OFF",
11052 "LJOS_SESSION_ID",
11053 "LJOS_SEAT",
11054 "LJOS_HOLD",
11055 "LJOS_SITTING_IDLE",
11056 "LJOS_MCP_IDLE",
11057 ]);
11058 let (dir, tracker, repo) = scratch();
11059 let _bins = arm(dir.path(), &tracker);
11060 unsafe {
11061 name_the_derived_seat();
11062 std::env::remove_var("LJOS_HOLD");
11063 std::env::remove_var("LJOS_SESSION_ID");
11064 std::env::remove_var("LJOS_SITTING_IDLE");
11065 std::env::remove_var("LJOS_MCP_IDLE");
11066 }
11067 prompt_and_edit(&repo, "tha1-dead", "Add a readme line");
11068 prompt_and_edit(&repo, "tha1-old", "Add a readme line");
11069 prompt_and_edit(&repo, "tha1-live", "Add a readme line");
11070 prompt_and_edit(&repo, "tha1-reused", "Add a readme line");
11071 let dead_issue = hook_row("tha1-dead", &repo).issue.unwrap();
11072 let old_issue = hook_row("tha1-old", &repo).issue.unwrap();
11073 let live_issue = hook_row("tha1-live", &repo).issue.unwrap();
11074 let reused_issue = hook_row("tha1-reused", &repo).issue.unwrap();
11075 let me = crate::proc_tree::of(std::process::id()).unwrap();
11076 set_owner(dir.path(), "tha1-dead", dead_pid());
11077 set_owner(dir.path(), "tha1-live", me.pid);
11078 set_owner_at(dir.path(), "tha1-reused", me.pid, me.start + 1);
11079 let path = book_path("tha1-old");
11080 let mut book = load_book(&path);
11081 book.owner_pid = me.pid;
11082 book.owner_start = me.start;
11083 book.last_unix = 1;
11084 for row in book.repos.values_mut() {
11085 row.last_unix = 1;
11086 }
11087 store_book(&path, &book);
11088 resume_seat();
11089 let dead = load_book(&book_path("tha1-dead"));
11090 let dead_row = dead.repos.values().next().expect("the dead row stays");
11091 assert!(dead_row.finished, "a dead owner was not finished");
11092 assert_eq!(dead_row.issue.as_deref(), Some(dead_issue.as_str()));
11093 assert_eq!(issue_state(&tracker, &dead_issue), "DONE");
11094 assert!(
11095 load_book(&book_path("tha1-old")).repos.is_empty(),
11096 "an old row was kept"
11097 );
11098 assert_eq!(issue_state(&tracker, &old_issue), "DONE");
11099 let live = hook_row("tha1-live", &repo);
11100 assert!(!live.finished);
11101 assert_eq!(live.issue.as_deref(), Some(live_issue.as_str()));
11102 assert_eq!(issue_state(&tracker, &live_issue), "STARTED");
11103 let reused = load_book(&book_path("tha1-reused"));
11104 let reused_row = reused.repos.values().next().expect("the reused row stays");
11105 assert!(reused_row.finished, "a reused owner pid was taken as live");
11106 assert_eq!(issue_state(&tracker, &reused_issue), "DONE");
11107 assert_eq!(
11108 git_log(&tracker).matches("finished").count(),
11109 3,
11110 "{}",
11111 git_log(&tracker)
11112 );
11113 assert_eq!(creates(&tracker), 4);
11114 }
11115}
11116
11117#[cfg(all(test, unix))]
11119#[test]
11120fn sigterm_during_replace_leaves_old_or_new() {
11121 const OLD: &[u8] = b"OLDOLDOLDOLDOLDOLD";
11122 const NEW: &[u8] = b"NEWNEWNEWNEWNEWNEW";
11123 if std::env::var_os("LJOS_REPLACE_CHILD").is_some() {
11124 std::env::set_var("LJOS_REPLACE_HOLD", "1");
11125 let path = std::env::var("LJOS_REPLACE_PATH").expect("path");
11126 let _ = replace_whole(Path::new(&path), NEW);
11127 return;
11128 }
11129 let dir = tempfile::tempdir().unwrap();
11130 let path = dir.path().join("book.json");
11131 std::fs::write(&path, OLD).unwrap();
11132 let mut child = std::process::Command::new(std::env::current_exe().unwrap())
11133 .args([
11134 "--exact",
11135 "bookkeep::sigterm_during_replace_leaves_old_or_new",
11136 ])
11137 .env("LJOS_REPLACE_CHILD", "1")
11138 .env("LJOS_REPLACE_PATH", &path)
11139 .env_remove("LJOS_REPLACE_HOLD")
11140 .spawn()
11141 .unwrap();
11142 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
11143 let mut saw_temp = false;
11144 while std::time::Instant::now() < deadline {
11145 saw_temp = std::fs::read_dir(dir.path()).unwrap().any(|entry| {
11146 entry
11147 .ok()
11148 .and_then(|entry| entry.file_name().into_string().ok())
11149 .is_some_and(|name| name.contains(".tmp-"))
11150 });
11151 if saw_temp {
11152 break;
11153 }
11154 std::thread::sleep(std::time::Duration::from_millis(20));
11155 }
11156 assert!(saw_temp, "the replace never reached its temp file");
11157 let pid = i32::try_from(child.id()).expect("pid");
11158 unsafe { libc::kill(pid, libc::SIGTERM) };
11160 let started = std::time::Instant::now();
11161 let status = loop {
11162 if let Some(status) = child.try_wait().unwrap() {
11163 break status;
11164 }
11165 if started.elapsed() > std::time::Duration::from_secs(5) {
11166 let _ = child.kill();
11167 panic!("SIGTERM did not end the replace");
11168 }
11169 std::thread::sleep(std::time::Duration::from_millis(20));
11170 };
11171 assert!(
11172 !status.success(),
11173 "the signal did not stop the child: {status}"
11174 );
11175 let got = std::fs::read(&path).unwrap();
11176 assert!(
11177 got.as_slice() == OLD || got.as_slice() == NEW,
11178 "torn book: {}",
11179 String::from_utf8_lossy(&got)
11180 );
11181}