Skip to main content

ljos_cli/
bookkeep.rs

1//! The seat keeps the book when the model never calls ljos.
2//!
3//! A harness hook files a claim on the first edit in a session and
4//! repository, cites a deed when a git commit has landed, and finishes at
5//! Stop or the end of the session. The finish writes one lesson from the
6//! sitting. `ljos-mcp` does the same on the first write and on shutdown,
7//! for a client that has no hooks. A failure is written to the runtime
8//! log and never becomes a deny: the agent's tool call goes on.
9
10use 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
17/// How long one habitat command may run from a hook. A runner that waits
18/// on PreToolUse treats a slow hook as a refusal, so the book stays inside
19/// this and a warm call does not spawn anything.
20const COMMAND_DEADLINE: Duration = Duration::from_secs(2);
21
22/// Unix seconds of the last MCP tool call. Zero means the server has not
23/// been touched, and the idle watcher does not finish a session it has
24/// not seen.
25static LAST_ACTIVITY: AtomicU64 = AtomicU64::new(0);
26
27/// Writes that must finish or not start. The server's shutdown cap stays
28/// short unless one of these is still inside the write, because a blocked
29/// stdin read would otherwise hold the process for the whole drain.
30static DURABLE_WRITES: AtomicUsize = AtomicUsize::new(0);
31
32/// Held for the body of a write that replaces a file or posts a pack atom.
33pub struct DurableWrite(());
34
35impl Drop for DurableWrite {
36    fn drop(&mut self) {
37        DURABLE_WRITES.fetch_sub(1, Ordering::AcqRel);
38    }
39}
40
41/// Mark one write in progress. Drop the guard when the write returns,
42/// including when the task that started it has already been dropped.
43#[must_use]
44pub fn durable_write() -> DurableWrite {
45    DURABLE_WRITES.fetch_add(1, Ordering::AcqRel);
46    DurableWrite(())
47}
48
49/// A [`durable_write`] guard is still held.
50#[must_use]
51pub fn durable_pending() -> bool {
52    DURABLE_WRITES.load(Ordering::Acquire) > 0
53}
54
55/// Events that keep the book. A subagent stop and a bare argv line do not:
56/// the parent session is the one that claimed.
57const BOOK_EVENTS: &[&str] = &[
58    "SessionStart",
59    "UserPromptSubmit",
60    "PreToolUse",
61    "PostToolUse",
62    "PostToolUseFailure",
63    "Stop",
64    "SessionEnd",
65];
66
67/// File tools. The first one in a session files the claim. Names are matched
68/// without regard to case, so `edit` and `Edit` are the same tool. agy 1.4.3
69/// writes a new file with `write_file` (arguments `Path`, `Content`,
70/// `Overwrite`) and changes one with `edit_file`.
71const 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
85/// MCP tools that write a habitat. The claim, the sitting and the finish
86/// are the bookkeeping verbs themselves, and `ljos_file` opens its own issue.
87/// Before one of these runs the server joins the issue this seat holds
88/// ([`before_mcp_write`]); it never files one. Mail (`ljos_send`,
89/// `ljos_reply`) and `ljos_note` are not on the list.
90const 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/// One sitting's book: the prompt the title came from, and one row per
108/// repository the sitting has touched. An MCP server spawned per call
109/// keeps this under the seat, so the next process sees the open claim.
110#[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    /// Unix seconds of the last call for this sitting. Zero means the
117    /// sitting has not been touched, and an idle check does not finish it.
118    /// This is not a row's age: a commit in one repository refreshes it
119    /// while another repository's row stays idle.
120    #[serde(default)]
121    last_unix: u64,
122    /// Repository `prompt` was recorded for. A git-hook claim uses the
123    /// prompt as its title only when the commit is in this checkout and
124    /// the sitting is still open.
125    #[serde(default)]
126    prompt_repo: String,
127    /// Unix seconds when `prompt` was recorded. Zero means it was not stamped.
128    #[serde(default)]
129    prompt_unix: u64,
130    /// Pid of the runner that last wrote this book. A later session finishes
131    /// the claim when that pid is gone, which is how a timeout (the runner
132    /// exits 124 before Stop) still closes.
133    #[serde(default)]
134    owner_pid: u32,
135    /// When `owner_pid` started, in clock ticks after boot, so a pid the
136    /// kernel has since handed to another process counts as gone. Zero is
137    /// a book written before this was kept, or a system with no `/proc`:
138    /// the pid alone is checked then.
139    #[serde(default)]
140    owner_start: u64,
141    /// `cwd` as the hook named it, to the repository key. The warm path is
142    /// a lookup, not another `git rev-parse`.
143    #[serde(default)]
144    cwd_repo: BTreeMap<String, String>,
145    /// The last repository a git hook booked for this seat. An MCP claim
146    /// with no `repo` argument files there instead of under the server's cwd.
147    #[serde(default)]
148    last_repo: String,
149}
150
151/// What this session has already recorded for one repository.
152#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
153struct RepoBook {
154    #[serde(default)]
155    issue: Option<String>,
156    /// `ljos_start` claimed this row's issue. A hook in another sitting
157    /// takes over only such a row. Any other live row, one this sitting
158    /// filed or one it adopted from a hold record, is that conversation's.
159    #[serde(default)]
160    started: bool,
161    /// This sitting filed the issue: a hook on the first edit or commit, or
162    /// `ljos_start` with no ticket named. A commit naming another open issue
163    /// before any deed lands here takes the row over, and this issue is
164    /// cancelled. Starting again on the same issue leaves `filed` as it is.
165    /// A rebind sets it for the issue that start claims.
166    #[serde(default)]
167    filed: bool,
168    /// Commit shas already cited. The id is `deed-file-commit-<sha>`, so a
169    /// second commit is a second deed, and the same sha is not cited twice.
170    #[serde(default)]
171    deeds: Vec<String>,
172    /// Cited shas found in HEAD by a sweep. A sweep asks git once for a
173    /// sha, not on every prompt.
174    #[serde(default)]
175    landed: Vec<String>,
176    /// The HEAD those shas were found in.
177    #[serde(default)]
178    landed_head: Option<String>,
179    /// Short notes from tool failures, for the lesson written at Stop.
180    #[serde(default)]
181    failures: Vec<String>,
182    /// Short notes from guard asks, for the same lesson.
183    #[serde(default)]
184    asks: Vec<String>,
185    /// The sitting's lesson was written. Stop and SessionEnd share one.
186    #[serde(default)]
187    lesson: bool,
188    /// The pack's id for that lesson. A rewrite supersedes it.
189    #[serde(default)]
190    lesson_id: Option<String>,
191    /// The title that lesson was written under. A later prompt is another
192    /// turn and does not replace it; the rewrite closes the old atom by id.
193    #[serde(default)]
194    lesson_title: Option<String>,
195    #[serde(default)]
196    finished: bool,
197    /// `vissue` failed once. Later edits stay on the warm path instead of
198    /// asking again on every tool call.
199    #[serde(default)]
200    claim_failed: bool,
201    #[serde(default)]
202    recall: String,
203    /// HEAD the first time this sitting saw the repository, before a commit
204    /// ran. A deed is cited only when HEAD has moved past it. A rebind to
205    /// another issue moves this to HEAD then, so the previous issue's
206    /// commits are not cited on the new one.
207    #[serde(default)]
208    start_sha: Option<String>,
209    /// The prompt this sitting is working from. agy sends no title of its
210    /// own; the words come from the prompt payload.
211    #[serde(default)]
212    title: Option<String>,
213    /// Unix seconds of the last hook call that touched this repository.
214    /// Zero means the row has no clock of its own. The book's `last_unix`
215    /// is the last touch of whichever repository the seat used, not this one.
216    #[serde(default)]
217    last_unix: u64,
218    /// The issue is one a commit named, not one this sitting filed. The
219    /// sweep hands it back with a release instead of closing it: the
220    /// commit cited work on someone's open ticket, it did not accept it.
221    /// Starting again on the same issue keeps `named`. Only a rebind to a
222    /// different issue clears it.
223    #[serde(default)]
224    named: bool,
225    /// Other open issues this row's commits named while it held `issue`.
226    /// The sweep releases each of them too.
227    #[serde(default)]
228    also: Vec<String>,
229    /// Shas already cited on an issue in `also`, so they are not cited on
230    /// `issue` as well.
231    #[serde(default)]
232    elsewhere: Vec<String>,
233}
234
235/// Whether the guard and the pack daemon should stay out of this process.
236/// `LJOS_OFF` and a temporary `HOME` are not seats: a test sandbox sets
237/// its own home, and a daemon must not write under it. A temporary working
238/// directory under a real home is a seat, so `cd /tmp && rm -rf ~/proj` is
239/// judged. `LJOS_GUARD=on` keeps the guard on under a temporary home. The
240/// book is not suspended here. A hook in a scratch directory still files
241/// its claim.
242#[must_use]
243pub fn guard_suspended() -> bool {
244    ljos_off() || (temp_home().is_some() && !guard_forced_on())
245}
246
247/// `LJOS_GUARD` set to one of the on-switches: the guard judges commands
248/// under a temporary `HOME` too. Tests use it in place of a home moved out
249/// of `/tmp`. `LJOS_OFF` still wins.
250#[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
258/// `LJOS_OFF` set to one of the on-switches.
259fn 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/// Whether `LJOS_OFF` is set to one of its on-switches.
267#[must_use]
268pub fn ljos_is_off() -> bool {
269    ljos_off()
270}
271
272/// `HOME` when it is a temporary directory or sits inside one.
273#[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/// Why [`guard_suspended`] holds in this process, in words a person reads
281/// in `ljos doctor`. `None` when the guard is on.
282#[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/// Whether `path` is a temporary directory or sits inside one.
300/// A missing path is judged as written, so `/tmpfoo` is not `/tmp`.
301#[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/// [`path_is_temp`] for a path already resolved, read as written so the
314/// name is not resolved a second time.
315#[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    /// Temp roots that stand in for the machine's on one test thread, so a
323    /// judgment of the working tree reads the same from a checkout in `/tmp`.
324    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
349/// A book lock that is not free within this long is not waited for: the
350/// job stays queued, or a direct call queues one.
351pub(crate) const BOOK_LOCK_WAIT: Duration = Duration::from_secs(1);
352
353/// The book lock within [`BOOK_LOCK_WAIT`], with the wait logged for
354/// `ljos doctor`. `None` when another holder kept it.
355fn 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/// Whether `event` is one the book keeps.
368#[must_use]
369pub fn books(event: &str) -> bool {
370    BOOK_EVENTS.contains(&event)
371}
372
373/// Keep the book for this hook call. The string is recall to inject, and
374/// only on session start and on a prompt. Empty when there is nothing to
375/// say. Never an error: a habitat that did not answer is a line in the
376/// book log. When jobs are queued, or the book lock stays taken, the call
377/// is queued behind them instead.
378#[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
401/// [`observe`] for a job the book queue holds. False when the book lock
402/// stayed taken, so the job is tried again later.
403pub(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/// The recall a SessionStart or prompt hook hands the model, read from
416/// the book as it stands, without its lock. The call's own bookkeeping is
417/// queued, and the issue it would name is the one the book already holds.
418#[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
443/// The runner and HEAD a queued job was made under. The drainer runs
444/// later and under another parent, so these stand in for what
445/// [`hook_owner`] and [`note_start`] would read now.
446static ACTING_FOR: std::sync::Mutex<Option<ActingFor>> = std::sync::Mutex::new(None);
447
448/// The owner [`hook_owner`] names, as (pid, start time), and HEAD.
449pub(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
464/// `None` when the book lock stayed taken past [`BOOK_LOCK_WAIT`].
465fn 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    // Every claim, note, deed and finish below is written as this seat.
470    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    // A runner killed before Stop leaves its claims open. The next session
489    // finishes those whose pid is gone, and replaces a deed whose commit is
490    // no longer in the checkout with the one that is.
491    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    // A sitting that only commits never sees SessionStart. Close every row
497    // that has been idle, including one in another repository, before this
498    // call stamps the book again.
499    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    // agy's first model call often runs before the transcript has the
522    // turn. A later payload still names that file, or carries `prompt`.
523    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    // Stop and SessionEnd close what the sitting opened. In a checkout the
532    // book has no row for, they open nothing: an MCP server that answered
533    // only guard checks used to leave a row with no issue at its exit.
534    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    // `ljos hook --git-commit` puts the commit subject in `prompt`. That
541    // subject titles the claim when this record has no live prompt for
542    // the checkout. A prompt from the current sitting in this repository
543    // still wins, and is left on the book for that sitting.
544    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        // An issue already open in this checkout keeps the title it was
556        // filed under. The subject is for the claim this commit opens.
557        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    // A clean PostToolUse is the commit that just ran. Record the starting
574    // HEAD on every other call, including the ask that stopped the command,
575    // so the after hook cannot cite the commit the sitting began on.
576    // A missing exit code is unknown, never a failure: Claude omits it on
577    // a commit that landed, and Codex leaves it in the transcript. Read
578    // the baseline before this call can record one.
579    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    // A commit that did not land must not become the baseline, and a
589    // failed one must not finish the sitting or replace the start already
590    // recorded. note_start writes the sha only when this row has none.
591    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        // A write tool claims even for a new file. A shell redirect claims
601        // when it writes a file inside this work tree, tracked or not.
602        // A commit claims too, so a sitting that only commits still has a ticket.
603        // A harness commit whose command names an open issue claims that
604        // issue once it lands, through the git hook's path, not a new one.
605        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            // Nothing later in a queued tool call reads the issue, so the
612            // claim is filed once the book lock is let go.
613            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        // The commit has not happened on the way in. Cite it on the way
620        // out when the evidence says it landed, every new sha since the
621        // recorded start. An explicit non-zero exit is the failure. A
622        // missing exit, with nothing else that says the commit landed, is
623        // not one.
624        if landed {
625            let named = if git_hook { None } else { named_head(&repo) };
626            if let Some((issue, head)) = named {
627                // An `ljos_start` row for this checkout comes back first, so
628                // the named commit sees the issue the sitting holds.
629                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                // The harness command named an open issue the landed message
635                // does not. Book that issue. Filing a new one leaves the
636                // named ticket unclaimed and this sitting with a second id.
637                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    // A prompt, a read, or a shutdown that never claimed must not leave a
656    // row whose issue is null. The commit's before-hook is the exception:
657    // it records HEAD so the after-hook can tell the new sha from the one
658    // the sitting began on.
659    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    // The book is stored; the pack read below must not hold other hooks
679    // on the lock.
680    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
694/// The recall a SessionStart or prompt hook hands the model for the issue
695/// this checkout holds: the title line and [`super::recall_memories`], the
696/// same memories `ljos recall` lists, read now rather than at the claim. A
697/// seat with no pack keeps the line stored at the claim.
698fn 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/// Whether this MCP tool writes a habitat, so the server joins the held issue first.
710#[must_use]
711pub fn mcp_tool_writes(name: &str) -> bool {
712    MCP_WRITES.contains(&name)
713}
714
715/// Attach a write tool's call to the issue this seat holds, before it runs.
716/// A write tool (`ljos_remember`, `ljos_prefer`, ...) never files or claims
717/// an issue: its text titled the claims it used to file. With an issue held
718/// in the checkout, by this book or another book of this seat (a hook's
719/// edit, `ljos_start`), the call joins that sitting. With none, it runs
720/// unattached.
721pub 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
748/// Finish every claim this MCP process opened. Called when the server exits.
749pub 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
779/// Note that a tool ran, so the idle watcher waits another window.
780pub fn touch_activity() {
781    LAST_ACTIVITY.store(unix_secs(), Ordering::Relaxed);
782}
783
784/// Unix seconds of the last [`touch_activity`], or zero.
785#[must_use]
786pub fn last_activity() -> u64 {
787    LAST_ACTIVITY.load(Ordering::Relaxed)
788}
789
790/// Unix seconds now, the clock [`last_activity`] is on.
791#[must_use]
792pub fn now_secs() -> u64 {
793    unix_secs()
794}
795
796/// Tool calls this process has answered. A process that answered one call
797/// and exited was spawned per call: finishing in `Drop` would close the
798/// sitting before the next call. The book stays, and the next start
799/// finishes it once the idle window has passed.
800static MCP_CALLS: AtomicU64 = AtomicU64::new(0);
801
802/// Count one tool call in this process, for [`ShutdownFinish`].
803pub fn note_mcp_process_call() {
804    MCP_CALLS.fetch_add(1, Ordering::Relaxed);
805}
806
807/// Finish the seat's open claims when the last call is older than the
808/// idle window, then stamp this call. A per-call server has no thread
809/// left alive to notice the idle; the next process does.
810pub 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/// How long a silent MCP server sits before it finishes. Unset is thirty
842/// minutes. `0` and `off` leave the session open until the process exits.
843#[must_use]
844pub fn idle_limit() -> Option<Duration> {
845    idle_limit_from(std::env::var("LJOS_MCP_IDLE").ok().as_deref())
846}
847
848/// [`idle_limit`] from an already-read value. `None` is the default window.
849#[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/// Whether `now` is at least `limit` past `last`. A `last` of zero has
866/// never been touched, so it is not idle.
867#[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
872/// How long a sitting may go without a hook call before the next call
873/// finishes it. Unset is two hours. `0` and `off` leave it open.
874fn 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
893/// Finish idle rows on the next hook call.
894///
895/// Each repository has its own clock. A fresh `last_unix` on the book,
896/// from a commit in one checkout, does not keep another checkout's row
897/// open. `session` and `current` name the book and repository this call
898/// is in. A row that predates the per-repository clock is finished when
899/// this call is in a different repository: the book's clock was refreshed
900/// by that other checkout, not by the row.
901pub(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
907/// Finish rows idle longer than [`sitting_idle`], and drop the finished
908/// ones so the next call in that repository files its own claim.
909fn 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
954/// Finish the open claims when no tool has run for [`idle_limit`]. One
955/// thread for the process. A disabled window starts nothing.
956pub 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/// Words a model passes when it does not know the issue id.
980#[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/// The issue `raw` names, or the one this session claimed when `raw` is
998/// missing or a placeholder such as `none`.
999#[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/// The unfinished issue this conversation's book holds. A new session does
1008/// not inherit another conversation's ticket.
1009#[must_use]
1010pub fn session_issue(session: Option<&str>) -> Option<String> {
1011    issue_in_sessions(session)
1012}
1013
1014/// The issue to use when a tool did not name one: this session's claim,
1015/// the MCP sitting, or the newest unfinished claim in this directory.
1016#[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
1078/// The newest unfinished claim whose book names this working directory.
1079fn 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/// The issue this MCP sitting still holds, for a finish that did not name one.
1142#[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
1167/// The explicit finish closed `issue`. Shutdown then has nothing left to close.
1168pub 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
1184/// Recall and claim before repo work. `issue` claims that ticket. Without
1185/// it, `task` is filed under `repo` (or the working directory) and claimed.
1186/// The issue is the one shutdown and an issue-less finish close.
1187///
1188/// # Errors
1189///
1190/// `task` is empty, or the tracker did not file or claim.
1191pub 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    // A later start on the issue this row already holds does not change
1249    // how the sitting got it. Only a rebind resets `named` and `filed`.
1250    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        // Commits already made belong to the issue this row just left.
1260        // HEAD now is the baseline, so the next citation starts after them.
1261        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        // The same seat under another name (the hook's tag, the server's
1285        // bare seat) holds it: this conversation takes its own claim back.
1286        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
1304/// Finishes the MCP session's claims when the server process returns.
1305/// Bind it to a name. `let _ = ShutdownFinish` drops it at that statement.
1306pub struct ShutdownFinish;
1307
1308impl Drop for ShutdownFinish {
1309    fn drop(&mut self) {
1310        // One call, then the process is gone: this was spawned per call.
1311        // The sitting stays on disk under the seat. The next start finishes
1312        // it once the idle window has passed. A server that answered more
1313        // than one call is the long-lived process, and its exit closes.
1314        if MCP_CALLS.load(Ordering::Relaxed) > 1 {
1315            mcp_shutdown();
1316        }
1317    }
1318}
1319
1320/// The book key. An MCP call is keyed by the seat, so a server spawned
1321/// once per tool call keeps the claim and the idle clock. A hook, and an
1322/// MCP process with no seat yet, stay on the session id.
1323fn 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
1378/// Write `bytes` beside `path` and rename them into place. A cut leaves
1379/// the previous file or the new one, not a mix of the two. `LJOS_REPLACE_HOLD=1`
1380/// pauses after the temp is durable and before the rename, for the test
1381/// that sends a signal in that window.
1382pub(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    // The directory of books is the slow lookup. The file names the book
1450    // that holds each live issue, so the next claim reads one path.
1451    sync_claim_index(path, book);
1452}
1453
1454/// Append one line to the seat's `book.log` under the runtime directory.
1455pub 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
1473/// The repository this call is about: `git rev-parse` once per cwd, then
1474/// the canonical directory when this cwd is not a checkout.
1475fn 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
1503/// Where a new claim is filed. An explicit path is used only when it is a
1504/// checkout: it does not fall back to the working directory. With no path,
1505/// the last repository this seat's git hook booked, then the working
1506/// directory when that is a checkout.
1507fn 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
1531/// The checkout the git hook last booked for this seat.
1532fn 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/// The book key for this seat, shared by the git hook and the MCP server.
1543#[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
1580/// [`ensure_claim`] for a queued job, with the book lock let go while the
1581/// tracker files and claims the issue: that reads the whole tracker, and
1582/// every other bookkeeper on the host waits on the lock.
1583fn 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
1613/// What [`ensure_claim`] would file for `repo`: the project and the title.
1614/// `None` when the row already holds an issue, a claim failed there, or
1615/// the seat's hold in this checkout was taken instead.
1616fn 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    // The seat may already hold an issue in this checkout under another
1633    // book: `ljos_start` in the MCP server keys its book by the seat, the
1634    // harness hook by the harness session. That issue is this sitting's.
1635    if take_held_issue(book, own, repo) {
1636        return None;
1637    }
1638    // A hold from another repository is a different ticket. This sitting
1639    // files its own claim for the repository the tool just wrote.
1640    // A git hook passes the commit subject when the saved prompt is not
1641    // this sitting's.
1642    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
1670/// `ljos_start` with no issue, when this seat already holds one in the
1671/// checkout `place`: a hook claimed it for an edit, or an earlier start
1672/// did. That row moves to the server's book, marked as started, so an
1673/// issue-less finish closes it and the next hook call takes it back. The
1674/// task goes on the issue as a note when it differs from the title the
1675/// hook filed it under. `None` when nothing is held there.
1676fn 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, &note], &[]) {
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
1745/// `ljos_start` when this seat already holds an issue for the checkout and
1746/// no book row names it: `ljos claim` left a hold record. Filing a second
1747/// issue would put two STARTED tickets on one sitting.
1748fn 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
1783/// The issue a note attaches to. A blank argument, or prose that is not an
1784/// id, is the live issue this seat holds. A note never files an issue, so
1785/// there is no title to take from the note text.
1786///
1787/// # Errors
1788///
1789/// Nothing is held, and the argument is not an issue id.
1790pub 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
1807/// The newest live issue this seat holds, in any book. `claimed_issue`
1808/// only sees the working directory's book, and a hook's checkout is not
1809/// always this process's cwd.
1810fn 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
1831/// The title the tracker holds for `issue`, or nothing.
1832fn 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
1839/// Whether two titles say the same words, case and punctuation aside.
1840fn 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
1850/// Put the issue this seat already holds in `repo` on this book's row, and
1851/// say whether there was one. A row another book keeps for the checkout
1852/// (`ljos_start`, a named commit) moves here whole, so one book finishes
1853/// it and the other does not finish it a second time without its deeds.
1854/// Else a claim the seat's hold record names in this checkout's project
1855/// (`ljos claim`) is taken as the row's issue. Either issue has to be
1856/// STARTED in the tracker under this holder, in either form: the bare
1857/// seat or `SEAT@TAG`.
1858///
1859/// A hold record is shared by every conversation of the runner. A live
1860/// row in another conversation's book stays there, whether that row was
1861/// filed or adopted. Only a started sitting moves, through
1862/// [`take_started_row`]. The hook already holds `book.lock`; taking it
1863/// again on this thread would wait on itself, so the decision and the
1864/// write share [`claim_lock`].
1865fn 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
1886/// Exclusive lock for the adopt decision. Distinct from `book.lock`,
1887/// which the caller may already hold.
1888fn claim_lock() -> super::CommitLock {
1889    super::CommitLock::acquire(&super::runtime_dir().join("claim.lock"))
1890}
1891
1892/// How many times the claim check read the book directory. The indexed
1893/// book and the seat's book answer without that read.
1894#[cfg(test)]
1895static CLAIM_DIR_SCANS: AtomicU64 = AtomicU64::new(0);
1896
1897/// The file that names the book holding `issue`.
1898fn 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
1939/// A row that still holds `issue`. Filed and adopted rows both count;
1940/// a finished row does not.
1941fn 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
1953/// Whether another conversation's book holds `issue` live.
1954///
1955/// The index written with the book is one file. The seat's book is the
1956/// other path this process can name without listing the directory. The
1957/// directory is read only when neither of those holds the issue.
1958fn 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
1980/// The book scan of [`take_held_issue`]: an `ljos_start` row another book
1981/// keeps for the checkout moves to `book`.
1982fn 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
2016/// Whether the tracker has `issue` STARTED and claimed by this seat. The
2017/// callers look at one checkout, so a holder of this seat under another
2018/// tag is this conversation: the git hook books `SEAT@TAG` (`SEAT-TAG`
2019/// before 0.32) and `ljos_start` books the bare seat ([`super::same_seat`]).
2020/// A tracker that records no holder answers by the state alone.
2021fn 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
2034/// [`held_by_this_seat`], and also the two holder strings one sitting
2035/// stamps: the hook's `SEAT@TAG` and the MCP server's raw conversation id.
2036/// Those are not the same seat to [`super::same_seat`], so a write after
2037/// `ljos_start` filed a second issue. A different seat's name still does not
2038/// match.
2039fn 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
2067/// A conversation id a runner stamped, as opposed to a bare seat name.
2068/// It carries a digit, so `someone-else` is not one and `01a0dab3-…` is.
2069fn 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
2078/// Whether `claimed` and `holder` are the hook's name and the MCP server's
2079/// name for one sitting. [`super::same_seat`] already joins `SEAT` and
2080/// `SEAT@TAG`. This joins either of those with the raw conversation id the
2081/// server stamps when `LJOS_SESSION_ID` is the only session variable, or
2082/// when `LJOS_HOLD` is that id.
2083fn 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
2095/// The tracker's holder of `issue`, when the record names one.
2096fn 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
2106/// Fold `moved`, another book's row for the checkout, into this book's
2107/// row, which then holds `issue`.
2108fn 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
2190/// Cite every commit that landed after the row's baseline. A compound
2191/// command can write more than one. No baseline means this post is the
2192/// first time the sitting saw the repository, so the sha is the one git wrote.
2193fn 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
2197/// [`cite_row`] for the landed shas `keep` accepts.
2198fn 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
2247/// Commits strictly after `start`, oldest first. Without a baseline, the
2248/// current HEAD is the commit this post is about.
2249fn 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
2273/// Cite each open row's commits that no post named: a script or an alias
2274/// commits with no `git commit` on the command line, and a commit chained
2275/// before a step that fails exits non-zero. Only the shas HEAD's reflog
2276/// says this checkout wrote since the row's start count, so a pull, a
2277/// merge, or a branch switch brings in no deed.
2278fn 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
2293/// Shas HEAD's reflog records as a commit, a cherry-pick, or a revert
2294/// since HEAD was last at `start`, newest first. Empty when the reflog
2295/// does not reach `start`: an expired or disabled reflog cites nothing.
2296fn 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
2327/// Cite `repo`'s new commits on the open sitting that already holds it.
2328/// A git hook books here instead of opening a seat book whose start is the
2329/// commit that just landed. False when no sitting holds the checkout.
2330pub(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    // The failure notes and the baseline stay. A retry cites the new sha
2344    // on this issue; it does not finish the row or replace its start.
2345    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
2389/// A file deed's accession has to start with `deed-file-`. The sha keeps
2390/// each commit's id distinct; `deed-file-commit` alone is one frozen deed.
2391fn 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
2434/// Drop the citation of `sha` from `issue`. Absent is success: an amend
2435/// that already moved the deed must not fail the hook.
2436fn 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
2461/// Move citations off commits that an amend or a rebase replaced.
2462/// An all-zero new sha drops the old citation and cites nothing.
2463/// A sha already in the book is not cited again.
2464pub(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
2520/// `Some(None)` when the deeds moved and no lesson was waiting.
2521/// `Some(Some(...))` when a finished lesson has to be written again.
2522fn 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
2577/// A short-lived hook must not replace the runner that owns the sitting.
2578/// Session start and a new prompt are that runner. A pid of zero has never
2579/// been set, so the first call of any kind may set it. Each owner is a pid
2580/// and its start time.
2581fn 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
2589/// The runner that spawned this hook, as its pid and start time. When it
2590/// is gone, the next session finishes the claims this book still has
2591/// open. A runner starts the hook command through `sh -c` or a wrapper
2592/// that exits with the hook, so the parent alone is gone by the next
2593/// call. A terminal, a multiplexer or init outlives every sitting under
2594/// it and never owns one: when one of those ran the hook itself, the hook
2595/// is the owner, and its sitting is orphaned once it exits. With no
2596/// `/proc` (macOS) the walk is empty and the parent is the owner.
2597pub(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        // SAFETY: getppid only reads the parent pid.
2604        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
2612/// Whether the runner that owns `book` still runs: the same pid with the
2613/// same start time, and not a zombie. A book with no start time checks
2614/// the pid alone.
2615fn 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}
2625/// How long the sweeps over past sittings may take in one hook call. They
2626/// walk every book the seat has kept, and the landed check runs git once per
2627/// cited commit, so the walk grows with the seat's history. Books the walk
2628/// does not reach are the next call's, most recently written first.
2629const SWEEP_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000);
2630
2631/// Finish every other sitting whose runner has exited, and every row that
2632/// has sat past [`sitting_idle`]. A timeout kills the runner (exit 124)
2633/// before Stop and often leaves the parent pid alive, so liveness alone
2634/// never releases the claim. Age does. A sitting that never claimed still
2635/// counts when a tool failed or the guard asked and the lesson has not
2636/// been written. The caller holds `book.lock`.
2637fn 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
2683/// Replace a cited commit that is not in HEAD with the commit that is, and
2684/// write the lesson again when it already named the one that did not land.
2685fn 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/// Unfinished claims already on disk, for a prompt that has not waited on
2741/// the pack. Empty when none are held, so a quiet seat does not say `none`.
2742#[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
2770/// The books, most recently written first: a sitting that ended a moment ago
2771/// is the one a sweep is likely to find work in.
2772fn 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
2792/// HEAD of each checkout, asked once per sweep: the books of a seat share a
2793/// few checkouts, and a row's cached answer stands while its HEAD does.
2794type Heads = std::collections::HashMap<String, Option<String>>;
2795
2796/// Drop deed shas that are not ancestors of HEAD, then cite HEAD when the
2797/// sitting had cited one that never landed. `false` when nothing changed,
2798/// or when git could not say. The shas found in HEAD are remembered with
2799/// the HEAD they were found in, so a later sweep asks git again only when
2800/// HEAD has moved (an amend, a rebase); `cached` says a row grew.
2801fn 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
2868/// `Some` when git could answer. `None` when this directory is not a
2869/// checkout, so a missing git does not wipe a deed.
2870fn 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
2888/// Remember HEAD the first time this sitting sees the repository.
2889fn 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    // A queued job records HEAD as it was when the call came, not after
2902    // the commits that ran while the job waited.
2903    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
2910/// A claim still open, or failures and asks with no lesson yet. A sitting
2911/// that never claimed still has a lesson to write when a tool failed or
2912/// the guard asked.
2913fn 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
2928/// A row that never held an issue and has nothing to say: the prompt hook
2929/// opened it with a start sha and a title, and no edit or commit claimed.
2930/// A row with failures or asks stays, since its lesson and the failures it
2931/// booked are the sitting's record.
2932fn 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
2940/// Drop the rows of a sitting that ended without a claim, so no book keeps
2941/// a row whose issue is null. The next prompt opens a fresh one.
2942fn 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        // A claim closes with a lesson. So does a sitting that only failed
2968        // or was asked: the next session otherwise has nothing to recall.
2969        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
3025/// The ticket is done, the claim is released, the tracker commit says
3026/// finished. The lesson was written just before this, from the sitting.
3027fn 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
3042/// Release a claim the git hook took on an issue a commit named. The
3043/// issue goes back to the state it had before the claim, as `ljos release`
3044/// leaves it, and stays open: naming a ticket in a commit is not accepting
3045/// the work on it.
3046fn 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
3065/// The most ids a commit message is checked for. Each one is a tracker read.
3066const NAMED_IDS_MAX: usize = 8;
3067
3068/// The first id in `message` that names an issue the tracker holds and
3069/// has not closed: `ljos-policyd-32fx: ...` in a subject, `Fixes ljos-xsor`
3070/// in a body. A word shaped like an id (`fix-hook`) that the tracker does
3071/// not know is not one, and a DONE or CANCELLED issue is not reopened.
3072#[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
3081/// [`named_open_issue`] with the tracker read passed in: `state` gives the
3082/// issue's state, `None` when the tracker does not hold it.
3083fn 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
3122/// Book the commit at `head` in `repo` on `issue`, an open issue its
3123/// message named, instead of filing a new one. The sitting that already
3124/// holds the checkout keeps its row; otherwise the seat's book does. The
3125/// claim is taken unless that row already holds the issue, and the deed is
3126/// cited on the issue. A row holding another issue keeps it, and the named
3127/// one is released with it at the end of the sitting.
3128pub(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
3137/// The commit `repo`'s HEAD message names an open issue: that id and the
3138/// sha. A harness hook reads it once the commit has landed.
3139fn 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
3149/// [`book_named_commit`] on a book already loaded under the book lock, at
3150/// row `key`. The git hook and a harness hook both book through this, so a
3151/// commit naming an open issue is claimed and cited the same way whichever
3152/// of them saw it.
3153fn 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    // The issue a hook filed for this sitting's first edit, with no deed
3161    // yet, was a placeholder: the commit says which ticket the work is on.
3162    // It is cancelled before the named one is claimed, so a claim graph
3163    // that holds one node per assignee takes the named one.
3164    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            // The cancelled ticket was a placeholder for this sitting.
3203            // Failures, asks, the title and commits a sweep already found
3204            // stay. Only the lesson was about that ticket.
3205            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
3241/// Whether `row` holds `held` as a placeholder: a hook filed it, no commit
3242/// is booked on it here or in the tracker, and the tracker has it STARTED
3243/// under this holder. An issue someone else holds, or one with a deed, is
3244/// real work and stays.
3245fn 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
3256/// Cancel `auto`, the placeholder a hook filed, now that a commit named
3257/// `named`. The claim is released first, and the tracker commit and the
3258/// log say why. A claim graph's release hands the issue back to TODO, so
3259/// the cancel takes STARTED or TODO and leaves any other state alone.
3260fn 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
3295/// Remember a guard ask on this repository. The book file is the only
3296/// write: a slow tracker must not sit on the path that is about to ask.
3297pub 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
3303/// [`note_ask`] within [`BOOK_LOCK_WAIT`]. False when the lock stayed
3304/// taken and nothing was written.
3305pub(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    // The ask is the call that did not run. Remember HEAD now, because the
3321    // after hook is a later process and must not cite this commit.
3322    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
3364/// One standing lesson, agent-derived, with the writer in the atom source.
3365/// Two short sentences: the ticket and its commits, then the failing
3366/// command and the guard's rule when the sitting had them. The full title,
3367/// rule and command also ride in `source`, which the prose gate does not
3368/// read. The issue id rides in the `issue` field, so a later lesson for
3369/// another issue does not close this one. A pack that is off is an error
3370/// here; the caller logs it and still closes the ticket. `replaces` is the
3371/// id of the lesson this sitting wrote before, which the pack closes.
3372/// The title stays the one that lesson was written under. A later prompt
3373/// is another turn, and supersedes is what closes the old atom. The new
3374/// lesson's id comes back for the next rewrite.
3375fn 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
3398/// The sitting's notes, one per line, for an atom field the prose gate
3399/// does not read.
3400fn 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
3409/// The error and its causes, so a refused `POST /v1/atoms` keeps the HTTP
3410/// status and the body. `Display` on the context alone drops both.
3411fn 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
3430/// At most two short sentences. The pack refuses a third sentence, a
3431/// sentence of thirty words or more, and a readability grade past 14 once
3432/// the text has twelve words. The title keeps the words a later prompt can
3433/// match: stopwords and filler go first, and a word is shortened only when
3434/// the gate still refuses. The rule name and the failing command's verb
3435/// stay whole. A title that is only filler still leaves a word.
3436fn 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
3478/// Runners whose next word is the command a failure should name.
3479const COMMAND_RUNNERS: &[&str] = &[
3480    "git", "cargo", "npm", "pnpm", "yarn", "bun", "npx", "pip", "uv", "go", "make", "python",
3481    "python3", "node",
3482];
3483
3484/// Words that describe a failure and are not the command that failed.
3485fn 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
3492/// The verb of a failing command: `git commit exited non-zero` is `commit`,
3493/// and `cargo test` is `test`. An error that is not a command names its
3494/// first distinctive word.
3495fn 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
3507/// The subcommand of `git`, `cargo`, `npm` and the other [`COMMAND_RUNNERS`].
3508fn 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
3544/// Words of an ask that the sentence already says, so the rule name is the
3545/// rest: `the guard asked before a force push` names `force push`.
3546const ASK_BOILER: &[&str] = &["guard", "asked", "about", "tool", "failed", "failure"];
3547
3548/// One rule token: letters, digits, hyphens, and not a filler word.
3549fn 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
3558/// The guard's rule, as `git-force-push` from `git-force-push: a force push…`.
3559/// Without that token, the first distinctive words of the ask.
3560fn 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
3589/// `Commit failed and the guard asked about git-force-push.` The rule token
3590/// and the verb are not shortened with the title.
3591fn 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
3613/// Distinctive words, in the order the prompt said them. When every word is
3614/// filler, the first few raw tokens, so the title cannot vanish.
3615fn 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
3632/// One character off the longest title word, and not below four characters.
3633/// The rule name and the command verb are not in this list.
3634fn 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        // No ticket was filed. The lesson is a sentence about the work,
3653        // then what went wrong.
3654        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
3685/// The pack's prose gate for one atom. A lesson that fails it is not stored,
3686/// and the next session then has nothing to recall.
3687fn lesson_prose_ok(text: &str) -> bool {
3688    packset_core::prose_refuse(text, packset_core::ProseRole::Atom).is_ok()
3689}
3690
3691/// Record that `prompt` belongs to `repo`. A later git hook in another
3692/// checkout can tell the words are not its title.
3693fn 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
3701/// A row that moves to another issue must drop issue-bound state from the
3702/// prior issue. A rewrite must not carry old deeds, failures or lesson links
3703/// onto the new ticket. The commit baseline goes with them. The caller
3704/// plants HEAD at the rebind, so a later citation does not walk the
3705/// previous issue's range.
3706fn 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
3732/// Copy `row.title` into `lesson_title` once, when a lesson already exists.
3733/// The row title may then follow a later prompt. The rewrite does not.
3734fn 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
3751/// Remember the title a lesson was stored under, the first time it is written.
3752fn 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
3767/// The title a rewrite keeps. A lesson that already exists uses the title
3768/// it was written under, not the prompt that arrived afterwards.
3769fn 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
3785/// The prompt the lesson and the claim use for `repo`. The book's prompt
3786/// is that text only when it was recorded for this checkout.
3787fn 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
3797/// Whether the saved prompt is the live one for `repo`. A git hook may
3798/// title a claim with it. Another repository's prompt, or one from a
3799/// sitting that has already gone idle, may not.
3800fn 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    // The prompt was recorded for this checkout and no claim is open.
3821    // An old stamp is the previous sitting; a fresh one is this sitting,
3822    // still waiting for its first claim.
3823    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
3850/// Drop the prompt once the repository it belongs to no longer has an open row.
3851fn 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
3875/// Whether `row` has been idle past `limit`. A row clock wins. A row with
3876/// no clock of its own follows the book when that clock is idle, and is
3877/// idle on a call in a different repository when the book clock is fresh:
3878/// that fresh clock is the other repository's touch.
3879fn 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
3911/// Keep the prompt on the repository row. A later claim reads it from
3912/// there. A lesson already written keeps the title it was stored under.
3913/// An empty prompt leaves a title that was already stored.
3914fn 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
3930/// [`title_from`] with the project inputs passed in. With no prompt the
3931/// title names the project the issue files under, as [`project_for_at`]
3932/// picks it, so a clone in `bench-w18b-tfn7` is "Work in bench".
3933fn 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
3960/// The tracker vissue would use: `ISSUE_ROOT`, then `VISSUE_ROOT`, then
3961/// `root` in the vissue config. A hook often has neither variable, and
3962/// the config is how vissue itself finds the tracker. A root that is not
3963/// a directory is ignored, as vissue ignores it.
3964fn 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
4000/// Top-level `root` in a vissue config. A `root` under a `[table]` is a
4001/// different key. A leading `~` is this user's home.
4002fn 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    // A clone in a folder of its own name (`bench-w18b-tfn7`) is still the
4052    // repository its origin names. The folder is the project only when the
4053    // checkout has no origin.
4054    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
4070/// The project whose recorded remote is this checkout's, else the project
4071/// named like the repository in that URL. A checkout called `workspace`
4072/// then files on the existing project instead of a new basename.
4073fn 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
4095/// The repository name in an origin URL, as the forge spells it:
4096/// `https://github.com/leidarljos/bench.git` and `git@github.com:leidarljos/bench`
4097/// are `bench`. vissue names a project by its directory under the tracker,
4098/// and `vissue create -p` matches an existing one whatever its case.
4099/// A local path or `file:` origin names no forge repository, and the
4100/// folder stays the fallback there.
4101fn 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    // The checkout's config names the remote. `git remote get-url` applies
4124    // insteadOf, follows GIT_DIR instead of -C, and can sit on a credential
4125    // helper until the hook's deadline, after which the folder name was
4126    // used as the project. The file is the remote the forge has.
4127    if let Some(url) = origin_from_checkout(repo) {
4128        return Some(url);
4129    }
4130    git_remote_from_command(repo)
4131}
4132
4133/// `remote.origin.url` in the checkout's git config. A worktree keeps
4134/// remotes in the common git dir, named by `commondir`, not in the
4135/// worktree git dir. A `.git` file's `gitdir:` is followed.
4136fn 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
4191/// The `url` of `[remote "origin"]` in a git config. An included file is
4192/// left to git; this reads only the config in front of it.
4193fn 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
4251/// `git config --get remote.origin.url`, with the hook's `GIT_DIR` removed
4252/// so `-C` is the checkout. `remote get-url` rewrites the URL through
4253/// insteadOf and can hide the repository name. An included config is the
4254/// case the file read above does not see.
4255fn 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    // A test may have named a decoy on this command. Drop it with the
4265    // variables a hook would have exported, so `-C` is the checkout.
4266    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    // userinfo@host, including a token a credential helper wrote in front
4316    // of the host. The host and the path are the remote.
4317    if let Some((user, host)) = raw.split_once('@') {
4318        // A token from a credential helper can itself contain a slash.
4319        // Drop it when the left side is userinfo, and keep an `@` that
4320        // sits in the path (`example.com/user@host/repo`).
4321        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
4335/// Userinfo in front of a host: `git`, or `x-access-token:abc/def`.
4336/// A slash that appears before any colon is a path, not a token.
4337fn 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
4361/// Remotes this project recorded on purpose. A `#+REMOTE:` header, a
4362/// one-url line in `remote.url` or `repo.url`, or a `remote` field in
4363/// `project.toml`. A URL in a note is not a record.
4364fn 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
4389/// `#+REMOTE: url` in an org file. The rest of the file is notes.
4390fn 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
4429/// `remote` under `[projects.<name>]` in the tracker's `vissue.toml`.
4430fn 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
4483/// Recall for the claim this session filed. The pack is not searched:
4484/// that walk is what makes a cold prompt hook slow, and an issue with no
4485/// memories would then add nothing. The title is the working set the hook
4486/// can name without the pack.
4487fn 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
4498/// The SessionStart brief, once per conversation, on stderr. A second call
4499/// in the same conversation prints nothing. A temp sandbox and `LJOS_OFF`
4500/// print nothing. This does not wait on the pack when it is off.
4501pub 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    // SAFETY: getsid reads this process's session id.
4550    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/// One line for the commit hook when the sitting's title bears on the
4635/// message. Empty when nothing filed matches.
4636#[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/// How a PostToolUse names the command's exit. Codex 0.163 sends the
4677/// output as a string and records `exit_code` in the transcript, under the
4678/// same `call_id` as the hook's `tool_use_id`.
4679#[derive(Clone, Copy)]
4680enum CommandExit {
4681    /// A code from the payload or the transcript.
4682    Code(i64),
4683    /// The output was text, and neither the payload nor the transcript
4684    /// named a code. Unknown is not a failure. Other evidence decides.
4685    Unknown,
4686    /// The runner sent an object, or omitted the output, and named no code.
4687    Absent,
4688}
4689
4690/// A code the transcript or the payload named, and it was not zero.
4691fn command_failed(exit: CommandExit) -> bool {
4692    matches!(exit, CommandExit::Code(code) if code != 0)
4693}
4694
4695/// Whether this commit landed. An explicit code wins, including a zero
4696/// beside an error string and a non-zero beside a banner. A missing code
4697/// is unknown: a git operation, a `[branch sha]` line, then HEAD moving
4698/// past the recorded start. A guard refusal or an interrupt blocks only
4699/// that missing-code path, so a command that never ran is not a deed.
4700fn 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
4717/// `gitOperation.commit` names a sha or a kind that means the commit was
4718/// written. Claude sends this beside the stdout, and omits the exit code.
4719fn 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
4762/// Git's `[branch sha]` line, including a root commit and a detached HEAD.
4763fn 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
4837/// HEAD is past the sha recorded before this commit. No baseline means
4838/// this post has not been shown an earlier commit, so movement is not
4839/// evidence by itself.
4840fn 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
4847/// The exit code for this call. The payload wins. Otherwise the Codex
4848/// transcript at `transcript_path` is read, time-bounded, and matched on
4849/// `tool_use_id`. A text `tool_response` with no code is [`CommandExit::Unknown`].
4850fn 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
4863/// Codex sends the shell output as a string. Other runners send an object,
4864/// or omit the field, and a missing code there is still success.
4865fn 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
4879/// `exit_code` for `tool_use_id` in the Codex transcript. Codex 0.163 puts
4880/// the code on a `CommandExecution` item whose `id` is the call, and only
4881/// as text (`Process exited with code N`) on the `function_call_output`
4882/// with that `call_id`. The read is the tail of the file and gives up at
4883/// [`TRANSCRIPT_DEADLINE`], so a stuck transcript cannot hold the hook.
4884fn 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
4896/// How long a transcript read may take. The same budget as one habitat
4897/// command: a hook that waits longer is treated as a refusal by the runner.
4898const TRANSCRIPT_DEADLINE: Duration = COMMAND_DEADLINE;
4899
4900/// Bytes read from the end of a transcript. The call that just finished is
4901/// the last one, and a command's output can be long.
4902const 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
4908/// The transcript's tail, bounded in bytes like the exit lookup. Regular
4909/// files remain readable regardless of their size, so polling alone cannot
4910/// limit the work. A missing prompt in this window leaves the stored one in use.
4911pub(crate) fn read_text_bounded(path: &Path) -> Option<String> {
4912    read_transcript_tail(path)
4913}
4914
4915/// Open `path` without blocking and read at most `max_bytes`. A regular
4916/// file is ready at once. A fifo with no writer reports hangup at once.
4917/// `tail` keeps the end of a regular file and drops the partial first line.
4918fn 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    // A fifo with no writer reads as immediate EOF. Poll does not say so
4923    // on this kernel: POLLHUP shows up only after a writer has opened the
4924    // fifo, so waiting on POLLIN spends the whole deadline.
4925    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    // SAFETY: `c_path` is NUL-terminated and lives until `open` returns.
4943    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    // SAFETY: `fd` is open and this function owns it.
4953    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        // SAFETY: `polled` is one valid `pollfd` and lives until `poll` returns.
4969        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
5049/// A code read from the transcript. The command item wins. The sentence in
5050/// the function output is only used when that item is absent.
5051struct 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
5062/// The exit code for `call_id`. A later `CommandExecution` for the same id
5063/// replaces an earlier one. `Process exited with code N` does not replace
5064/// a code the item already named.
5065fn 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
5109/// `item.type == CommandExecution` and `item.id` is the hook's `tool_use_id`.
5110fn 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
5122/// The matching `function_call_output`. The code is the first
5123/// `Process exited with code N` in its `output`, which is the harness
5124/// header rather than a line the command printed later.
5125fn 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
5160/// A tail that starts in the middle of a line is not valid JSON. The item
5161/// still names `CommandExecution`, its `id`, and `exit_code`. A cut-off
5162/// function output still says `Process exited with code N` after `call_id`.
5163fn 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
5277/// A guard that already refused this call. A missing exit is not one of
5278/// these. An error string is not a guard decision: a commit is judged from
5279/// its exit code, then from what git wrote.
5280fn 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
5294/// `>` and `>>` targets on the line, not `2>&1` and not a quoted sentence.
5295fn 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
5340/// Whether `line` redirects into a file inside `repo`. A new file is not
5341/// tracked yet; `git ls-files` used to wait for a later git command before
5342/// the sitting had a claim. A path outside the checkout, a home path, or
5343/// a glob is not this work tree.
5344fn 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
5374/// `..` and `.` collapsed, without asking the filesystem. A missing file
5375/// is still inside the work tree when its path says so.
5376fn 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
5429/// A git commit, not `commit-tree` and not a command that merely mentions
5430/// the word. Global options that change the repository are skipped; any
5431/// other flag means this is not the commit subcommand.
5432fn 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
5473/// A vissue id, as `vissue-core` reads one in `is_vissue_id`: `{project}-{suffix}`
5474/// with the suffix after the last hyphen. `generate_id` mints that suffix from
5475/// `ID_ALPHABET` (`0-9a-z`) at `issues.id_length`, which defaults to 4 and is
5476/// at least 2. The project is the rest, so it may contain hyphens and digits
5477/// (`ljos-policyd-c0jc`, `lgio-1792329616-q4uh`). A nested project may contain
5478/// `/` (`acme/cli-1a2b`).
5479fn 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    // Shorter than the default mint stays out. A longer configured
5499    // `id_length` is still the same id.
5500    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    /// A lesson already stored keeps the title it was written under when a
5585    /// later prompt rewrites it. The pack closes the earlier lesson by id.
5586    #[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        // SAFETY: lock() restores the environment when the test returns.
5594        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    /// A row that once held another issue does not carry that issue's lesson
5655    /// title or superseded atom into the next issue's rewrite. The rewrite
5656    /// names the second issue's own title.
5657    #[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        // SAFETY: lock() restores the environment when the test returns.
5679        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    /// A sitting that moves from one issue to another must not book the
5764    /// first issue's commits on the second. The baseline stays at the old
5765    /// HEAD otherwise, and the next citation walks that range again.
5766    #[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    /// Folding another issue into a row must not keep the baseline the
5871    /// previous issue was citing from.
5872    #[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    /// Starting the issue a sitting already holds must not clear `named`
5940    /// or `filed`. Finish then releases a named ticket instead of closing
5941    /// it DONE. A real rebind still resets both flags.
5942    #[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    /// A fresh claim on a row that had no issue clears stale lesson linkage
6046    /// before it binds the new issue.
6047    #[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    /// willma-20261020f w4, w8 and w12: agy 1.4.3's `write_file` filed no
6081    /// claim, and the claim came later with `edit_file` or a shell command.
6082    /// Every agy file tool those cells sent is an edit; a read is not.
6083    #[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                // SAFETY: the caller holds test_env_lock, dropped after this.
6118                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        // The isolated directory follows TMPDIR. A TMPDIR under a home
6179        // still is not that home's config.
6180        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        // git_toplevel looks git up with which(), so this holds the same
6410        // lock as the other PATH readers.
6411        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        // The remote lookup runs git through which(), so this holds the
6447        // same lock as the other PATH readers.
6448        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    /// A clone in a folder named for the job (`bench-w18b-tfn7`) files on
6486    /// the repository its origin names, even before the tracker holds a
6487    /// project of that name. The folder is the fallback only with no origin.
6488    #[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    /// A checkout that really tracks `target/arm` is still that checkout.
6566    /// The row key and saved prompt repository must stay on the repo root.
6567    #[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    /// A job folder such as `ljos-rohit-main-audit` is not the project.
6590    /// The origin is read from the git config, including a worktree's
6591    /// common dir, even when `git` on PATH fails and `GIT_DIR` names
6592    /// another checkout. A recorded remote still wins when the tracker
6593    /// root comes from the vissue config and no variable names it.
6594    #[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        // An origin that lives in an included file is still the checkout's,
6700        // and GIT_DIR must not send the lookup at a different repository.
6701        // `remote get-url` would also apply insteadOf and hide the name.
6702        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    /// A URL in a project's notes is not its remote. promo's board mentions
6776    /// the ljos repository; the commit still files on the slug, or on the
6777    /// project whose metadata records that remote.
6778    #[test]
6779    fn a_decoy_note_does_not_take_the_project() {
6780        // The remote lookup runs git through which(), so this holds the
6781        // same lock as the other PATH readers.
6782        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            // The host signs every commit, and that signer takes a lock the
6966            // whole machine shares. A transcript budget must not include it.
6967            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    /// Name the seat the process tree gives in `LJOS_SEAT`, as a runner's
6978    /// env file does, so a hook books under the name it always had. A seat
6979    /// nobody named books nothing. The caller restores `LJOS_SEAT`.
6980    fn name_the_derived_seat() {
6981        // SAFETY: the caller holds test_env_lock.
6982        unsafe {
6983            std::env::remove_var("LJOS_SEAT");
6984            std::env::set_var("LJOS_SEAT", crate::whoami().seat);
6985        }
6986    }
6987
6988    /// The habitat's binaries and environment, the seat named.
6989    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    /// A redirect into this checkout claims on the call that writes it, for
7030    /// every harness shell shape, including a file git does not track yet.
7031    /// A redirect outside the checkout does not.
7032    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        // The shape an MCP server leaves at exit after answering only
7265        // guard checks: SessionEnd on the seat's book, which holds no row.
7266        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        // The commit runs between the hooks and moves HEAD past the sha this
7346        // sitting started on. The post cites that new sha.
7347        commit_file(&repo, "README.md", "hello from the session\n");
7348        // The same commit on the way out cites the sha once.
7349        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        // Hide vissue, which lives outside the coreutils directories.
7428        // cat and sh stay on PATH.
7429        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        // The prompt is a separate event; the edit still has a title from it
7445        // when it was stored. Here the edit arrives with no prompt stored.
7446        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    /// A write tool files and claims nothing. With nothing held it runs
7462    /// unattached; after a hook's edit claimed an issue in the checkout,
7463    /// it joins that issue and still files nothing.
7464    #[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        // A note names its issue; it never files one titled by its text
7551        // (willma-20261018e w4 work-d3pl, w11 work-0v1j).
7552        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    /// Codex onboarded with `LJOS_SEAT=codex` on a tracker whose only
7633    /// codex claims were `codex-vax0` (the `SEAT-TAG` of ljos 0.31) and
7634    /// bare thread ids. Every call failed with "no seat named codex" and
7635    /// a list of thread ids. A harness id and a name from the environment
7636    /// are the seat; only a name the call made up is refused, briefly.
7637    #[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    /// A roster that still lists a raw holder, and a `CLAIMED_BY` of one,
7708    /// names the seat. The subagent name is not a seat. Neither file is
7709    /// rewritten.
7710    #[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    /// Filing goes through `vissue` when the claim graph is absent. A
7770    /// subagent name writes nothing. `ljos-bot` is filed as `ljosbot`.
7771    #[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    /// A tracker that names no seats sits the seat the call named and
7881    /// writes nothing: a `seats` list a tool call wrote would sit untracked
7882    /// in the checkout and lock every other seat out of the tracker.
7883    #[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    /// The recall `ljos_start` hands the model is prompt context, so a note
8009    /// the seat wrote on the held issue must not ride back into it as a
8010    /// fact. A lesson on the same issue still reaches the start.
8011    #[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    /// `ljos_start` in the MCP server books under the seat's key and the
8080    /// harness hook under the harness session. The first write after the
8081    /// start stays on the started issue instead of filing a second one,
8082    /// and the issue is finished once, by the book it moved to.
8083    #[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        // The server's shutdown has nothing left to finish.
8147        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    /// A STARTED row another holder keeps in the same checkout is that
8164    /// holder's ticket: the write files its own.
8165    #[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    /// ljos-myow: a refused lesson names the HTTP status and the response
8361    /// body, and a lesson the pack will keep is one its prose gate accepts.
8362    #[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    /// ljos-myow: the lesson is built from the words a later prompt can match.
8473    /// Stopwords go first, the rule keeps its name, and the next session's
8474    /// cold encoder still finds the lesson by those words.
8475    #[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    /// A lesson is already stored and the encoder has never answered. No
8624    /// warm-up marker stands yet. The first prompt of the next session
8625    /// ranks that lesson from the atoms and recalls it; the warming note
8626    /// is what a prompt with nothing to recall hears. The marker is
8627    /// written after the atoms are read, so the load does not sit in
8628    /// front of the listing.
8629    #[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    /// A session that only met a force-push ask writes a sentence about that
8706    /// ask. The prompt's words stay, and the lesson is not a keyword list.
8707    #[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    /// A commit the guard only asked about never ran. The after hook still
8782    /// looks like a clean PostToolUse, and HEAD is the commit the sitting
8783    /// started on. That sha is not a deed. A later commit that moves HEAD is.
8784    #[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    /// Failed commands and a guard ask are a lesson even when the sitting
8845    /// never claimed. The next session can recall it.
8846    #[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    /// Codex 0.163 names the call as `CommandExecution.id`. The matching
8966    /// `function_call_output` only spells the code in its text, and that
8967    /// sentence does not replace a code the item already named.
8968    #[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    /// A Codex 0.163 PostToolUse whose `tool_response` is text books the
9001    /// failure from the transcript the hook was given.
9002    #[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    /// A string `tool_response` plus a transcript exit of 127 is a failure,
9059    /// and the lesson keeps it.
9060    #[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    /// Commands that only fail, on a sitting that never claimed, still leave
9124    /// a lesson. Codex reports those failures as PostToolUse with a string body.
9125    #[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    /// A git commit whose payload has no exit field is not a success. The
9202    /// sitting's current HEAD is not a deed, including when this post is the
9203    /// first time the sitting saw the repository. A later commit whose
9204    /// transcript says 0, and whose HEAD moved, is cited.
9205    #[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        // Another call exited 0. This commit's id is not in the file, and
9215        // the payload has no exit field, so the code stays unknown.
9216        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    /// A transcript that does not answer inside the hook budget is not exit 0.
9268    #[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        // SAFETY: mkfifo takes a NUL-terminated path and a mode. The path
9286        // lives until the call returns, and the mode is a plain permission.
9287        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    /// A timed-out agy sitting never gets Stop. Its prompt arrives on the
9311    /// payload after the first model call, and the lesson keeps those words
9312    /// so the next prompt can recall them.
9313    #[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    /// ljos-6x14: agy sends no event name. Onboarding tells the after-command
9435    /// hook `--event PostToolUse`, and a payload that carries `error` is
9436    /// that same return when the flag is absent. Either way the commit is cited.
9437    #[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    /// ljos-qpiw: git's own stderr on exit 0 is still a commit to cite.
9500    /// A non-zero exit is not. A missing exit whose HEAD moved is.
9501    #[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    /// ljos-tha1: a session killed before Stop leaves its claim open.
9567    /// The next session finishes a book whose runner pid is dead.
9568    #[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    /// ljos-gfiz: a cited commit that never landed is replaced by the
9604    /// commit that did, and the lesson names that one.
9605    #[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    /// ljos-6x14: a PostToolUse whose `toolCall` is null names no command.
9655    /// The before-hook's command is the commit, and the deed is cited.
9656    #[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    /// A prompt answers inside the sweep budget however many past sittings
9709    /// the seat keeps, and the sweeps still reach every book: a sha found in
9710    /// HEAD is remembered, so a later pass does not ask git again.
9711    #[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    /// A Codex 0.163 transcript line, trimmed to the fields the exit lookup
9787    /// reads. `Some` is the completed `CommandExecution` item (`id`, not
9788    /// `call_id`). `None` is the `function_call`, which names the id and
9789    /// carries no code.
9790    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    /// The `function_call_output` whose `call_id` matches. The code is only
9822    /// the harness sentence, as Codex 0.163 writes it.
9823    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    /// Make `pid` the runner that owns `session`'s book, under the start
9963    /// time it runs with now. A pid that is gone has none.
9964    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    /// File an issue in the stub tracker the way a person would, outside
10083    /// any sitting, and give its id.
10084    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    /// The willma w1 shape: session 2's first edit files a placeholder,
10125    /// then the commit names the reopened session 1 issue. The row moves to
10126    /// the named issue with the sha in `deeds`, and the placeholder is
10127    /// cancelled.
10128    #[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    /// Taking over a placeholder keeps the sitting's notes. The commit
10172    /// names the real ticket; it does not wipe failures, asks, the title
10173    /// or commits the sweep already found.
10174    #[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    /// A placeholder that already has a deed is real work: the named
10239    /// commit is booked beside it, and it is not cancelled.
10240    #[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    /// A placeholder another holder has on the tracker is theirs: the
10286    /// named commit leaves it alone.
10287    #[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    /// The willma w1 session 2 shape: the edit filed an issue, then
10330    /// `ljos_start` ran with a task of its own. The start keeps that issue,
10331    /// files nothing, and notes the task on it.
10332    #[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        // The next write takes it back, and nothing is filed.
10390        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    /// Two hook conversations, one seat, one shared hold record. The claim
10419    /// conversation A filed stays in A's book. B files its own.
10420    #[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    /// A row adopted from the hold record has `filed` clear. It still
10492    /// belongs to the conversation that adopted it.
10493    #[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        // Not store_book: that would write the index. The row has to be
10545        // found by reading the book, the way a row adopted before the
10546        // index existed is found.
10547        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    /// Two hooks decide together. One adopts; the other sees that row.
10567    #[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    /// The willma w5 shape: a prompt, then the session ends before any
10640    /// edit. No row with a null issue is left.
10641    #[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    /// The idle sweep drops a row that never claimed, once it is idle.
10682    #[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    /// A claim graph's release hands the placeholder back to TODO before
10708    /// the cancel runs. The cancel takes it from TODO too, and leaves an
10709    /// issue that is DONE by then alone.
10710    #[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    /// ljos-2di9 / ljos-3ocl: `ljos_start` books under the seat, and the
10742    /// hook under the harness session, and the two stamp different holder
10743    /// strings. The write joins the started issue.
10744    #[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    /// ljos-6yav: `ljos_start` files nothing when this seat already holds an
10833    /// issue for the checkout. The hold record is the claim; the tracker
10834    /// names the hook's conversation id, which is not `SEAT@TAG`.
10835    #[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    /// ljos-4bfz: a note whose argument is prose attaches to the issue this
10901    /// seat holds. It does not file, and the prose is not the issue id.
10902    #[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    /// ljos-98du: the harness command names an open issue and the commit
10931    /// message does not. The sitting books that issue. It does not file.
10932    #[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    /// ljos-5a7b: a prompt and a read, and no claim. The book keeps no row
10960    /// whose issue is null.
10961    #[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    /// Software-njbz: resuming the seat and shutting the MCP server down
10994    /// leave no row whose issue is null.
10995    #[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    /// ljos-tha1: a timeout kills the runner and leaves the parent alive, so
11035    /// the next prompt never comes. A later MCP call finishes a dead owner
11036    /// at once, finishes a live owner whose row is old, and leaves a fresh
11037    /// live claim. An owner pid another process now runs under is dead.
11038    #[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/// A signal during the replace leaves the previous file or the new one.
11118#[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    // SAFETY: kill takes a pid and a signal number, no pointers.
11159    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}