Skip to main content

ljos_cli/
capture.rs

1//! A standing choice the person states in chat.
2//!
3//! "From now on always use bun, never yarn" is the person telling the seat
4//! something to keep. The prompt hook saves that sentence, in the person's
5//! words, as a standing preference, and says so in one line with the id
6//! that takes it back. It stands at once only when the runner's own
7//! program spawned this hook. A payload piped into `ljos hook` from a
8//! shell, including one that detached from the runner, is not saved.
9//!
10//! An agent's `ljos remember` of words the person typed in their latest
11//! prompt is the same case, so [`said_in_latest_prompt`] lets it stand.
12//!
13//! `LJOS_CAPTURE` picks what happens: `save` (the default), `nudge` (one
14//! line asking the agent to write it), or `off`.
15
16use serde_json::Value;
17use sha2::Digest as _;
18
19use super::{
20    add_entities, admit, atom_body, mark_seen, pack, pasted_or_quoted, runtime_dir, seen_ids,
21    stamp_horizon, with_pack_timeout, HookCall,
22};
23
24/// What the prompt hook does with a standing choice it reads.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CaptureMode {
27    /// Write it as the person's preference and say so.
28    Save,
29    /// Ask the agent, in one line, to write it.
30    Nudge,
31    /// Leave it.
32    Off,
33}
34
35/// The mode `LJOS_CAPTURE` names. Unset or unknown is [`CaptureMode::Save`].
36#[must_use]
37pub fn capture_mode() -> CaptureMode {
38    match std::env::var("LJOS_CAPTURE")
39        .unwrap_or_default()
40        .trim()
41        .to_ascii_lowercase()
42        .as_str()
43    {
44        "nudge" => CaptureMode::Nudge,
45        "off" | "0" | "no" | "false" => CaptureMode::Off,
46        _ => CaptureMode::Save,
47    }
48}
49
50/// A standing choice read from a prompt: what kind of claim it is and the
51/// person's sentence.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Stated {
54    /// `preference`, or `lesson` for "remember that ...".
55    pub kind: &'static str,
56    pub text: String,
57}
58
59/// The longest sentence read as a standing choice. A longer one is a
60/// paragraph of context, not a rule.
61pub const STATED_MAX_CHARS: usize = 200;
62
63/// Openings that state a standing choice when a clause starts with them.
64const LEADS: &[&str] = &[
65    "from now on",
66    "from here on",
67    "henceforth",
68    "always ",
69    "never ",
70    "remember that ",
71    "remember to ",
72    "do not ever ",
73    "don't ever ",
74    "dont ever ",
75    "make sure to always ",
76    "make sure you always ",
77    "make sure you never ",
78    "i prefer ",
79    "we prefer ",
80    "we always ",
81    "we never ",
82    "stop using ",
83];
84
85/// Openings that state a standing choice when a comma follows them:
86/// "going forward, ..." but not "going forward is ...".
87const COMMA_LEADS: &[&str] = &["going forward,", "in future,", "in the future,"];
88
89/// Phrases that make a sentence standing wherever they sit in it.
90const ANYWHERE: &[&str] = &["from now on", "from here on", "henceforth"];
91
92/// Phrases that make a sentence standing when they end it: "use bun going
93/// forward".
94const ENDINGS: &[&str] = &["from now on", "going forward", "in future", "from here on"];
95
96/// Words after "always" or "never" that make it a remark, not a rule:
97/// "never mind", "always the same", "never seen this".
98const NOT_A_RULE: &[&str] = &[
99    "mind", "again", "the", "a", "an", "been", "seen", "heard", "was", "were", "is", "are", "had",
100    "did", "worked", "works", "work", "happened", "happens", "thought", "knew", "got", "gets",
101    "fails", "failed", "breaks", "broke", "on", "in", "at", "for", "with", "by", "when", "since",
102    "before", "after", "really", "quite", "sure", "ever", "once", "seems", "seemed",
103];
104
105/// Words that open a sentence and some filler before it.
106const FILLER: &[&str] = &[
107    "ok", "okay", "so", "also", "and", "btw", "oh", "hey", "note", "fyi", "please", "but",
108];
109
110/// Openings of a question that the person did not end with `?`.
111const QUESTION_OPENINGS: &[&str] = &[
112    "what", "why", "how", "can", "could", "should", "would", "do", "does", "did", "is", "are",
113    "will", "which", "who", "where", "when", "was", "were", "shall",
114];
115
116/// Whether `word` is a negation left hanging at the end of a line, so the
117/// next line is part of the same clause. Typographic apostrophes count.
118fn dangling_negation(word: &str) -> bool {
119    let word = plain(word).to_lowercase();
120    let word = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'');
121    matches!(
122        word,
123        "not" | "never" | "no" | "nor" | "dont" | "don't" | "cannot" | "without" | "avoid"
124    ) || word.ends_with("n't")
125}
126
127/// A line that has not finished its clause. A dangling negation, or a
128/// line that ends on `:`, `,`, `;` or a dash, continues onto the next
129/// non-empty line. A line that already ends `.`, `!` or `?` does not,
130/// and neither does a blank line.
131fn line_continues(line: &str) -> bool {
132    let line = line.trim_end();
133    if line.is_empty() {
134        return false;
135    }
136    let last = line.chars().next_back().unwrap_or(' ');
137    if matches!(last, '.' | '!' | '?') {
138        return false;
139    }
140    if matches!(last, ':' | ',' | ';' | '\u{2014}' | '\u{2013}') {
141        return true;
142    }
143    let word = line
144        .rsplit(|c: char| {
145            !c.is_alphanumeric() && !matches!(c, '\'' | '\u{2019}' | '\u{02bc}' | '\u{ff07}')
146        })
147        .next()
148        .unwrap_or("");
149    dangling_negation(word)
150}
151
152/// The prompt's sentences. A line that continues is joined with the next
153/// before the split, so "Do not" at the end of a line still governs the
154/// line after it. Lines then split after `.`, `!` and `?` that a space or
155/// the line's end follows. `Cargo.toml` and `e.g.` stay whole. A blank
156/// line is a break and is not joined across.
157fn sentences(prompt: &str) -> Vec<String> {
158    let mut out = Vec::new();
159    let lines: Vec<&str> = prompt.lines().collect();
160    let mut index = 0;
161    while index < lines.len() {
162        if lines[index].trim().is_empty() {
163            index += 1;
164            continue;
165        }
166        let mut joined = lines[index].trim().to_string();
167        index += 1;
168        while line_continues(&joined) && index < lines.len() && !lines[index].trim().is_empty() {
169            joined.push(' ');
170            joined.push_str(lines[index].trim());
171            index += 1;
172        }
173        let mut cur = String::new();
174        let chars: Vec<char> = joined.chars().collect();
175        for (i, c) in chars.iter().enumerate() {
176            cur.push(*c);
177            let ends =
178                matches!(c, '.' | '!' | '?') && chars.get(i + 1).is_none_or(|n| n.is_whitespace());
179            if ends {
180                let s = cur.trim().to_string();
181                if !s.is_empty() {
182                    out.push(s);
183                }
184                cur.clear();
185            }
186        }
187        let s = cur.trim().to_string();
188        if !s.is_empty() {
189            out.push(s);
190        }
191    }
192    out
193}
194
195/// `text` with every backtick span blanked, so a lead inside code
196/// (`` `--always-retry` ``) does not count.
197fn outside_code(text: &str) -> String {
198    let mut out = String::with_capacity(text.len());
199    let mut inside = false;
200    for c in text.chars() {
201        if c == '`' {
202            inside = !inside;
203            out.push(' ');
204        } else if inside {
205            out.push('x');
206        } else {
207            out.push(c);
208        }
209    }
210    out
211}
212
213/// The sentence with filler words at its front taken off: "ok, from now
214/// on ..." reads as "from now on ...".
215fn strip_filler(s: &str) -> &str {
216    let mut rest = s.trim_start();
217    loop {
218        let word_end = rest
219            .find(|c: char| !c.is_alphanumeric() && c != '\'')
220            .unwrap_or(rest.len());
221        let word = rest[..word_end].to_ascii_lowercase();
222        if word.is_empty() || !FILLER.contains(&word.as_str()) {
223            return rest;
224        }
225        let after = &rest[word_end..];
226        let after = after.trim_start_matches(|c: char| c == ',' || c == ':' || c.is_whitespace());
227        if after.len() == rest.len() || after.is_empty() {
228            return rest;
229        }
230        rest = after;
231    }
232}
233
234/// Whether one clause, lowercased, opens with a standing lead.
235fn clause_leads(clause: &str) -> bool {
236    let c = clause.trim_start();
237    LEADS.iter().any(|lead| {
238        let Some(rest) = c.strip_prefix(lead) else {
239            return false;
240        };
241        if matches!(*lead, "always " | "never ") {
242            let next = rest
243                .split(|ch: char| !ch.is_alphanumeric() && ch != '\'')
244                .next()
245                .unwrap_or("");
246            return !next.is_empty() && !NOT_A_RULE.contains(&next);
247        }
248        true
249    })
250}
251
252/// Whether a sentence, as typed, states a standing choice.
253fn states_a_rule(sentence: &str) -> bool {
254    let words = sentence.split_whitespace().count();
255    if !(3..=40).contains(&words) || sentence.chars().count() > STATED_MAX_CHARS {
256        return false;
257    }
258    if sentence.trim_end().ends_with('?') {
259        return false;
260    }
261    let plain = outside_code(strip_filler(sentence)).to_lowercase();
262    let first = plain
263        .split(|c: char| !c.is_alphanumeric() && c != '\'')
264        .next()
265        .unwrap_or("");
266    if QUESTION_OPENINGS.contains(&first) || first == "you" {
267        return false;
268    }
269    let bare = plain.trim_end_matches(|c: char| !c.is_alphanumeric());
270    if ANYWHERE.iter().any(|p| plain.contains(p))
271        || ENDINGS.iter().any(|p| bare.ends_with(p))
272        || COMMA_LEADS
273            .iter()
274            .any(|p| plain.trim_start().starts_with(p))
275    {
276        return true;
277    }
278    plain.split([',', ';']).any(clause_leads)
279}
280
281/// The words saved for a stated choice: the sentence with its filler
282/// taken off, capitalized. "Remember that X" saves X as a lesson.
283fn saved_words(sentence: &str) -> Stated {
284    let s = strip_filler(sentence).trim();
285    let lower = s.to_lowercase();
286    let (kind, body) = match lower.strip_prefix("remember that ") {
287        Some(_) => ("lesson", &s["remember that ".len()..]),
288        None => ("preference", s),
289    };
290    let mut chars = body.trim().chars();
291    let text = match chars.next() {
292        Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
293        None => String::new(),
294    };
295    Stated { kind, text }
296}
297
298/// The standing choice a prompt states, if it states one: first-person or
299/// imperative phrasing such as "from now on", "always", "never", "going
300/// forward" or "remember that", outside code and quotes, in a sentence of
301/// at most [`STATED_MAX_CHARS`] characters. A question, pasted output and
302/// a quoted rule state nothing.
303#[must_use]
304pub fn stated_preference(prompt: &str) -> Option<Stated> {
305    if pasted_or_quoted(prompt) {
306        return None;
307    }
308    sentences(prompt)
309        .into_iter()
310        .find(|s| states_a_rule(s))
311        .map(|s| saved_words(&s))
312        .filter(|s| s.text.split_whitespace().count() >= 2)
313}
314
315/// Whether `LJOS_UNATTENDED` says no person is at this session: `1`,
316/// `true`, `yes` or `on`. An overnight runner or a script driving an agent
317/// sets it, and its prompts are then nobody's stated choice.
318#[must_use]
319pub fn unattended() -> bool {
320    std::env::var("LJOS_UNATTENDED").is_ok_and(|v| {
321        matches!(
322            v.trim().to_ascii_lowercase().as_str(),
323            "1" | "true" | "yes" | "on"
324        )
325    })
326}
327
328/// The thread a Codex rollout file is for, from a name such as
329/// `rollout-2026-10-11T09-30-00-<uuid>.jsonl`, or `None` for any other
330/// name.
331fn rollout_thread(path: &str) -> Option<String> {
332    let name = path.rsplit(['/', '\\']).next()?;
333    let rest = name.strip_prefix("rollout-")?;
334    let stem = &rest[..rest.find(".jsonl")?];
335    // The stamp is `YYYY-MM-DDTHH-MM-SS-`, twenty characters.
336    let id = stem.get(20..56)?;
337    let tail = &stem[56..];
338    let uuid_shape = id.char_indices().all(|(i, c)| {
339        if matches!(i, 8 | 13 | 18 | 23) {
340            c == '-'
341        } else {
342            c.is_ascii_hexdigit()
343        }
344    });
345    (uuid_shape && (tail.is_empty() || tail.starts_with('_'))).then(|| id.to_ascii_lowercase())
346}
347
348/// Why a prompt event does not carry the person's words, or `None` when
349/// it does. A prompt event is not always typed by a person: Codex fires it
350/// inside a subagent, with the brief the parent agent wrote as `prompt`.
351/// A spawned agent's payload names it with `agent_id`, as Claude Code's
352/// does inside a subagent, and Codex's own review thread names a rollout
353/// file for a thread other than the session's. [`unattended`] covers a
354/// session with nobody there at all.
355#[must_use]
356pub fn not_the_person(input: &str) -> Option<&'static str> {
357    if unattended() {
358        return Some("LJOS_UNATTENDED is set");
359    }
360    let v: Value = serde_json::from_str(input.trim()).ok()?;
361    let named = |key: &str| {
362        v.get(key)
363            .and_then(Value::as_str)
364            .is_some_and(|s| !s.trim().is_empty())
365    };
366    if named("agent_id") || named("agentId") {
367        return Some("a subagent's brief");
368    }
369    let session = v
370        .get("session_id")
371        .and_then(Value::as_str)
372        .map(|s| s.trim().to_ascii_lowercase())
373        .filter(|s| !s.is_empty());
374    let thread = v
375        .get("transcript_path")
376        .and_then(Value::as_str)
377        .and_then(rollout_thread);
378    match (session, thread) {
379        (Some(session), Some(thread)) if session != thread => Some("another thread's prompt"),
380        _ => None,
381    }
382}
383
384/// How long a prompt counts as the latest one for [`said_in_latest_prompt`].
385pub const LATEST_PROMPT_SECS: u64 = 3600;
386
387/// The longest prompt kept for [`said_in_latest_prompt`]. The bind also
388/// stores a hash of the whole prompt, so the rest is not lost.
389const LATEST_PROMPT_CHARS: usize = crate::turn_record::KEPT_PROMPT_CHARS;
390
391fn latest_prompt_path() -> std::path::PathBuf {
392    runtime_dir().join("latest-prompt")
393}
394
395/// Keep the person's prompt, so an agent's `ljos remember` of their words
396/// can be told from the agent's own lesson. The file sits in the login's
397/// runtime directory, readable by its owner only, and each prompt
398/// replaces it. Off when [`capture_mode`] is off. A bind beside the file
399/// records the hash, mtime and session, and [`said_in_latest_prompt`]
400/// requires it: a plain write of the prompt file does not count, and
401/// neither does a prompt bound to another session or to none.
402pub fn note_latest_prompt(prompt: &str) {
403    note_latest_prompt_for(prompt, None);
404}
405
406/// [`note_latest_prompt`] recording `session`, the hook's session id.
407pub fn note_latest_prompt_for(prompt: &str, session: Option<&str>) {
408    let sessions: Vec<String> = session.map(str::to_string).into_iter().collect();
409    note_latest_prompt_as(prompt, &sessions);
410}
411
412/// [`note_latest_prompt`] recording every id in `sessions`: a runner may
413/// name one conversation by several, and the process that checks the
414/// bind sees whichever the runner stamped on it.
415pub fn note_latest_prompt_as(prompt: &str, sessions: &[String]) {
416    note_latest_prompt_from(prompt, sessions, None, None);
417}
418
419/// Drop the kept prompt and its bind. The prompt hook calls this on every
420/// prompt before it keeps one, so a prompt it skips or does not take as
421/// the person's leaves no earlier turn's words behind to quote.
422pub fn forget_latest_prompt() {
423    let _ = std::fs::remove_file(latest_prompt_path());
424    let _ = std::fs::remove_file(latest_prompt_bind_path());
425}
426
427/// [`note_latest_prompt_as`] recording `runner`, the process that spawned
428/// the hook ([`crate::proc_tree::hook_runner`]): a process under that
429/// very runner is in its conversation whatever its environment names.
430/// `transcript` is the runner's own record of the conversation, as its
431/// hook payload names it.
432pub fn note_latest_prompt_from(
433    prompt: &str,
434    sessions: &[String],
435    runner: Option<&crate::proc_tree::Proc>,
436    transcript: Option<&str>,
437) {
438    if capture_mode() == CaptureMode::Off || pasted_or_quoted(prompt) {
439        return;
440    }
441    let path = latest_prompt_path();
442    if let Some(dir) = path.parent() {
443        let _ = std::fs::create_dir_all(dir);
444    }
445    let text: String = prompt.chars().take(LATEST_PROMPT_CHARS).collect();
446    use std::io::Write as _;
447    use std::os::unix::fs::OpenOptionsExt as _;
448    let wrote = std::fs::OpenOptions::new()
449        .write(true)
450        .create(true)
451        .truncate(true)
452        .mode(0o600)
453        .open(&path)
454        .and_then(|mut f| f.write_all(text.as_bytes()));
455    if wrote.is_ok() {
456        let bind = Bind {
457            runner: runner.map_or_else(|| "-".to_string(), crate::proc_tree::token),
458            at: now_nanos(),
459            transcript: bind_session(transcript),
460        };
461        let full_hash = crate::turn_record::folded_hash(prompt);
462        let _ = write_prompt_bind(&path, text.as_bytes(), &bind, sessions, &full_hash);
463    }
464}
465
466/// What the hook records beside the hash: the runner that spawned it,
467/// when it wrote the prompt, and the runner's record of the conversation.
468struct Bind {
469    runner: String,
470    at: u128,
471    transcript: String,
472}
473
474fn now_nanos() -> u128 {
475    std::time::SystemTime::now()
476        .duration_since(std::time::UNIX_EPOCH)
477        .map_or(0, |d| d.as_nanos())
478}
479
480/// The transcript a prompt hook payload names, as Claude Code, Codex and
481/// Cursor send it.
482#[must_use]
483pub fn payload_transcript(input: &str) -> Option<String> {
484    let v: Value = serde_json::from_str(input.trim()).ok()?;
485    v["transcript_path"]
486        .as_str()
487        .or_else(|| v["transcriptPath"].as_str())
488        .map(str::trim)
489        .filter(|s| !s.is_empty())
490        .map(str::to_string)
491}
492
493/// How long after the hook's time the bind's own write may land.
494const BIND_WRITE_NANOS: u128 = 500_000_000;
495
496/// The latest of `path`'s mtime and ctime in nanoseconds. A process can
497/// set the mtime it likes; the kernel sets the ctime on every change.
498fn changed_nanos(path: &std::path::Path) -> Option<u128> {
499    use std::os::unix::fs::MetadataExt as _;
500    let m = std::fs::metadata(path).ok()?;
501    let at = |s: i64, ns: i64| -> Option<u128> {
502        Some(u128::try_from(s).ok()? * 1_000_000_000 + u128::try_from(ns).ok()?)
503    };
504    Some(at(m.ctime(), m.ctime_nsec())?.max(at(m.mtime(), m.mtime_nsec())?))
505}
506
507/// What the runner's own record says about the kept prompt.
508enum RecordWord {
509    /// The record holds the prompt, or this runner is Claude Code or Codex
510    /// and has no directory to read (the old path). No runner above is this
511    /// too: an in-process check has nothing to disagree with.
512    Agrees,
513    /// Claude Code or Codex named a record that is missing or differs.
514    /// The refusal is the one a missing prompt already uses.
515    Disagrees,
516    /// Cursor has no readable transcript that holds the prompt, or Grok,
517    /// OpenCode, Gemini or Antigravity has no positive record. The prompt
518    /// is not the person's.
519    Unverified,
520}
521
522/// Runners that do not attribute a prompt unless a record of their own
523/// holds it. Claude Code and Codex keep the older rule: a missing record
524/// disagrees, and a stated choice in the hook is still saved.
525fn needs_positive_record(kind: &str) -> bool {
526    matches!(
527        kind,
528        "cursor" | "cursor-agent" | "grok" | "opencode" | "gemini" | "agy"
529    )
530}
531
532/// Whether `kept` agrees with the runner's own record of the person's
533/// turn, when the runner above keeps one ([`crate::turn_record`]). The
534/// record is the one the bind names, inside that runner's own directory,
535/// or for Cursor the transcript of a conversation the runner's
536/// environment names. Claude Code and Codex always name theirs, so a bind
537/// that names none, or names a file elsewhere, disagrees under them.
538/// Cursor without a transcript that holds the prompt, and a runner that
539/// keeps no record, are [`RecordWord::Unverified`].
540fn agrees_with_record(
541    kept: &[u8],
542    named: &str,
543    runner_ids: &[String],
544    full_hash: &str,
545) -> RecordWord {
546    use crate::turn_record;
547    let Some((runner, kind)) = crate::proc_tree::runner_above() else {
548        return RecordWord::Agrees;
549    };
550    let positive = needs_positive_record(kind);
551    let word = |ok: bool| {
552        if positive {
553            RecordWord::Unverified
554        } else if ok {
555            RecordWord::Agrees
556        } else {
557            RecordWord::Disagrees
558        }
559    };
560    let env = crate::proc_tree::environ(runner.pid).unwrap_or_default();
561    let roots = turn_record::roots(kind, &env);
562    if roots.is_empty() {
563        return word(true);
564    }
565    let record = if named == "-" {
566        roots
567            .iter()
568            .find_map(|r| turn_record::cursor_record(r, runner_ids))
569            .filter(|_| matches!(kind, "cursor" | "cursor-agent"))
570    } else {
571        turn_record::within(std::path::Path::new(named), &roots)
572    };
573    let Some(record) = record else {
574        return word(named == "-" && !turn_record::required(kind));
575    };
576    // A turn's tool output can push the person's entry out of the tail.
577    let Some(text) = crate::bookkeep::read_text_bounded(&record)
578        .filter(|t| !turn_record::latest_turn(t).is_empty())
579        .or_else(|| {
580            record
581                .is_file()
582                .then(|| std::fs::read_to_string(&record).ok())
583                .flatten()
584        })
585    else {
586        return word(false);
587    };
588    if !turn_record::required(kind) && turn_record::latest_turn(&text).is_empty() {
589        return word(true);
590    }
591    if turn_record::agrees_truncated(&text, &String::from_utf8_lossy(kept), full_hash) {
592        RecordWord::Agrees
593    } else {
594        word(false)
595    }
596}
597
598fn latest_prompt_bind_path() -> std::path::PathBuf {
599    runtime_dir().join("latest-prompt.bind")
600}
601
602fn mtime_nanos(path: &std::path::Path) -> Option<u128> {
603    std::fs::metadata(path)
604        .ok()?
605        .modified()
606        .ok()?
607        .duration_since(std::time::UNIX_EPOCH)
608        .ok()
609        .map(|age| age.as_nanos())
610}
611
612fn bind_session(session: Option<&str>) -> String {
613    match session
614        .map(str::trim)
615        .filter(|s| !s.is_empty() && !s.contains(['\n', '\r']))
616    {
617        Some(s) => s.to_string(),
618        None => "-".to_string(),
619    }
620}
621
622/// The bind the hook writes beside the prompt: version, sha256 of the
623/// kept bytes, mtime in nanoseconds, the runner that spawned the hook as a
624/// [`crate::proc_tree::token`] or `-`, the hook's time in nanoseconds,
625/// the runner's transcript or `-`, and the session ids, one a line. A
626/// final line `full <sha256>` is the folded whole prompt, so a prompt
627/// longer than the kept text can still agree with the transcript. The
628/// gate reads it back, so a later write of the prompt file no longer
629/// matches. The `full` line is after the session ids and is not one of
630/// them: a plant that rewrites the kept hash leaves it in place.
631fn write_prompt_bind(
632    path: &std::path::Path,
633    bytes: &[u8],
634    bind: &Bind,
635    sessions: &[String],
636    full_hash: &str,
637) -> std::io::Result<()> {
638    use std::io::Write as _;
639    use std::os::unix::fs::OpenOptionsExt as _;
640    let Some(mtime) = mtime_nanos(path) else {
641        return Err(std::io::Error::other("prompt mtime"));
642    };
643    let hash = hex::encode(sha2::Sha256::digest(bytes));
644    let mut ids: Vec<String> = Vec::new();
645    for s in sessions {
646        let s = bind_session(Some(s));
647        if s != "-" && !ids.contains(&s) {
648            ids.push(s);
649        }
650    }
651    if ids.is_empty() {
652        ids.push(bind_session(None));
653    }
654    let body = format!(
655        "3\n{hash}\n{mtime}\n{}\n{}\n{}\n{}\nfull {full_hash}\n",
656        bind.runner,
657        bind.at,
658        bind.transcript,
659        ids.join("\n")
660    );
661    let mut f = std::fs::OpenOptions::new()
662        .write(true)
663        .create(true)
664        .truncate(true)
665        .mode(0o600)
666        .open(latest_prompt_bind_path())?;
667    f.write_all(body.as_bytes())
668}
669
670/// How the kept prompt stands against the bind and the runner's record.
671enum Kept {
672    /// The bind matches and the record agrees.
673    Yes,
674    /// The bind matches, and the record is [`RecordWord::Unverified`].
675    Unverified,
676    /// The bind does not match, or the record disagrees.
677    No,
678}
679
680/// Whether the prompt file still matches the bind the hook wrote, and the
681/// bind is this conversation's: it names a session from the environment
682/// of the runner above this process ([`crate::ballot_sig::runner_sessions`]),
683/// or the runner it records is above this process. A bind with any other
684/// session, or none, is a prompt from somewhere else, and with no runner
685/// above nothing matches. An MCP call's `_meta` session only narrows
686/// this: one the runner above did not give, or the bind does not name
687/// when the runner gave none, is another conversation's call.
688///
689/// The prompt must also agree with the runner's own record of the turn
690/// ([`agrees_with_record`]), and neither file may have changed after the
691/// hook's time: a tool call that rewrites them leaves a later ctime. A
692/// match whose record is unverified is [`Kept::Unverified`], not a miss:
693/// the words were kept, and they are still not the person's.
694fn kept_prompt(path: &std::path::Path) -> Kept {
695    let Ok(bytes) = std::fs::read(path) else {
696        return Kept::No;
697    };
698    let Some(mtime) = mtime_nanos(path) else {
699        return Kept::No;
700    };
701    let bind_path = latest_prompt_bind_path();
702    let Ok(bind) = std::fs::read_to_string(&bind_path) else {
703        return Kept::No;
704    };
705    let mut lines = bind.lines();
706    let (Some("3"), Some(hash), Some(stored), Some(spawner), Some(at), Some(transcript)) = (
707        lines.next(),
708        lines.next(),
709        lines.next(),
710        lines.next(),
711        lines.next().and_then(|t| t.parse::<u128>().ok()),
712        lines.next(),
713    ) else {
714        return Kept::No;
715    };
716    let late = |p: &std::path::Path| {
717        changed_nanos(p).is_none_or(|c| c > at.saturating_add(BIND_WRITE_NANOS))
718    };
719    if late(path) || late(&bind_path) {
720        return Kept::No;
721    }
722    let (bound, full_hash) = bind_tail(lines);
723    let runner = crate::ballot_sig::runner_sessions();
724    let above = spawner != "-"
725        && crate::proc_tree::ancestors()
726            .iter()
727            .any(|p| crate::proc_tree::token(p) == spawner);
728    if !(above || bound.iter().any(|s| runner.contains(s))) {
729        return Kept::No;
730    }
731    if let Some(call) = crate::ballot_sig::call_session() {
732        let within = if runner.is_empty() { &bound } else { &runner };
733        if !within.contains(&call) {
734            return Kept::No;
735        }
736    }
737    if hash != hex::encode(sha2::Sha256::digest(&bytes)) || stored != mtime.to_string() {
738        return Kept::No;
739    }
740    match agrees_with_record(&bytes, transcript, &runner, &full_hash) {
741        RecordWord::Agrees => Kept::Yes,
742        RecordWord::Unverified => Kept::Unverified,
743        RecordWord::Disagrees => Kept::No,
744    }
745}
746
747/// Lowercase words and nothing else, for comparing a sentence with a
748/// prompt whatever their spacing and punctuation.
749fn words_only(text: &str) -> String {
750    text.split(|c: char| !c.is_alphanumeric() && c != '\'')
751        .filter(|w| !w.is_empty())
752        .map(str::to_lowercase)
753        .collect::<Vec<_>>()
754        .join(" ")
755}
756
757/// One word of a prompt, lowercased, where it sits in the text, and
758/// whether a sentence, a line or a clause ends before it. `stop_before`
759/// is a sentence's end only: `.`, `!`, `?`, `;` or a blank line, not the
760/// line break or colon a person puts inside one sentence.
761struct Word {
762    word: String,
763    start: usize,
764    end: usize,
765    sentence_before: bool,
766    stop_before: bool,
767    clause_before: bool,
768}
769
770fn prompt_words(text: &str) -> Vec<Word> {
771    let mut out: Vec<Word> = Vec::new();
772    let (mut sentence, mut stop, mut clause) = (true, true, true);
773    let mut line_ended = false;
774    let mut at = None;
775    let close =
776        |out: &mut Vec<Word>, from: usize, to: usize, s: &mut bool, p: &mut bool, c: &mut bool| {
777            let raw = &text[from..to];
778            let lead = raw.len() - raw.trim_start_matches('\'').len();
779            let word = raw.trim_matches('\'');
780            if !word.is_empty() {
781                out.push(Word {
782                    word: word.to_lowercase(),
783                    start: from + lead,
784                    end: from + lead + word.len(),
785                    sentence_before: std::mem::take(s),
786                    stop_before: std::mem::take(p),
787                    clause_before: std::mem::take(c),
788                });
789            }
790        };
791    for (i, ch) in text.char_indices() {
792        if ch.is_alphanumeric() || ch == '\'' {
793            at.get_or_insert(i);
794            continue;
795        }
796        if let Some(from) = at.take() {
797            close(&mut out, from, i, &mut sentence, &mut stop, &mut clause);
798            line_ended = false;
799        }
800        if matches!(ch, '.' | '!' | '?' | ';' | '\n') {
801            sentence = true;
802        }
803        if matches!(ch, '.' | '!' | '?' | ';') || (ch == '\n' && line_ended) {
804            stop = true;
805        }
806        if ch == '\n' {
807            line_ended = true;
808        }
809        if matches!(
810            ch,
811            ',' | ':'
812                | '('
813                | ')'
814                | '['
815                | ']'
816                | '"'
817                | '\u{201c}'
818                | '\u{201d}'
819                | '\u{2013}'
820                | '\u{2014}'
821        ) {
822            clause = true;
823        }
824    }
825    if let Some(from) = at {
826        close(
827            &mut out,
828            from,
829            text.len(),
830            &mut sentence,
831            &mut stop,
832            &mut clause,
833        );
834    }
835    out
836}
837
838/// Words that turn a clause around: a quote that leaves one of them
839/// behind in its sentence says something the person did not.
840fn turns_it_around(word: &str) -> bool {
841    matches!(
842        word,
843        "not"
844            | "never"
845            | "no"
846            | "nor"
847            | "dont"
848            | "cannot"
849            | "without"
850            | "avoid"
851            | "unless"
852            | "except"
853            | "if"
854            | "only"
855            | "until"
856    ) || word.ends_with("n't")
857}
858
859/// `text` with case, spacing and plain punctuation set aside, and every
860/// other mark kept: a quote may drop a comma, not add a backtick.
861fn marks_kept(text: &str) -> String {
862    text.chars()
863        .filter(|c| {
864            !matches!(
865                c,
866                '.' | ','
867                    | ';'
868                    | ':'
869                    | '!'
870                    | '?'
871                    | '\''
872                    | '"'
873                    | '('
874                    | ')'
875                    | '-'
876                    | '\u{2018}'
877                    | '\u{2019}'
878                    | '\u{201c}'
879                    | '\u{201d}'
880                    | '\u{2013}'
881                    | '\u{2014}'
882            )
883        })
884        .collect::<String>()
885        .split_whitespace()
886        .map(str::to_lowercase)
887        .collect::<Vec<_>>()
888        .join(" ")
889}
890
891/// `text` in NFKC with every apostrophe a keyboard or an editor types
892/// read as `'`, so `Don’t` is the word `don't` and turns a clause around.
893fn plain(text: &str) -> String {
894    use unicode_normalization::UnicodeNormalization as _;
895    text.nfkc()
896        .map(|c| match c {
897            '\u{2019}' | '\u{02bc}' | '\u{ff07}' => '\'',
898            c => c,
899        })
900        .collect()
901}
902
903/// Whether `quote` is whole clauses of `prompt`, starting and ending where
904/// a clause, a line or a sentence does, with no word in the rest of its
905/// sentences that turns it around, and no mark the person did not write.
906/// A sentence here runs on across a line break or a colon, so "do not:"
907/// on one line governs the line after it. Both are read [`plain`].
908fn quotes(prompt: &str, quote: &str) -> bool {
909    let (prompt, quote) = (&plain(prompt), &plain(quote));
910    let words = prompt_words(prompt);
911    let want: Vec<String> = prompt_words(quote).into_iter().map(|w| w.word).collect();
912    let n = want.len();
913    if n == 0 || n > words.len() {
914        return false;
915    }
916    let ends = |i: usize| {
917        words
918            .get(i)
919            .is_none_or(|w| w.clause_before || w.sentence_before)
920    };
921    (0..=words.len() - n).any(|i| {
922        let j = i + n;
923        if !ends(i) || !ends(j) || words[i..j].iter().zip(&want).any(|(w, q)| w.word != *q) {
924            return false;
925        }
926        let from = (0..=i).rev().find(|&k| words[k].stop_before).unwrap_or(0);
927        let to = (j..words.len())
928            .find(|&k| words[k].stop_before)
929            .unwrap_or(words.len());
930        let dropped = words[from..i].iter().chain(&words[j..to]);
931        !dropped.into_iter().any(|w| turns_it_around(&w.word))
932            && marks_kept(&prompt[words[i].start..words[j - 1].end]) == marks_kept(quote)
933    })
934}
935
936/// How `text` stands in the latest prompt: the person's words, words the
937/// hook kept but whose runner has no record that holds them, or neither.
938enum Latest {
939    Said,
940    Unverified,
941    No,
942}
943
944fn latest_words(text: &str) -> Latest {
945    let want = words_only(text);
946    if want.split(' ').count() < 4 {
947        return Latest::No;
948    }
949    let path = latest_prompt_path();
950    let fresh = std::fs::metadata(&path)
951        .and_then(|m| m.modified())
952        .ok()
953        .and_then(|t| t.elapsed().ok())
954        .is_some_and(|age| age.as_secs() <= LATEST_PROMPT_SECS);
955    if !fresh {
956        return Latest::No;
957    }
958    let kept = kept_prompt(&path);
959    if matches!(kept, Kept::No) {
960        return Latest::No;
961    }
962    let Ok(prompt) = std::fs::read_to_string(&path) else {
963        return Latest::No;
964    };
965    if !quotes(&prompt, text) {
966        return Latest::No;
967    }
968    match kept {
969        Kept::Yes => Latest::Said,
970        Kept::Unverified => Latest::Unverified,
971        Kept::No => Latest::No,
972    }
973}
974
975/// Whether `text` is the person's words: whole clauses of the prompt they
976/// sent last ([`quotes`]), within [`LATEST_PROMPT_SECS`], and the file
977/// still matches the bind the hook wrote for this runner session. Four
978/// words or more, so "use bun" alone is not taken as a quote. A file
979/// written without that bind does not count. A runner with no positive
980/// record of the prompt is not this: see [`unverified_latest_prompt`].
981#[must_use]
982pub fn said_in_latest_prompt(text: &str) -> bool {
983    matches!(latest_words(text), Latest::Said)
984}
985
986/// Whether `text` is in the prompt the hook kept, the bind matches, and
987/// the runner's record does not hold it. Cursor without a readable
988/// transcript, and Grok, OpenCode, Gemini and Antigravity, are this.
989/// Claude Code and Codex are not: a missing record there is a refusal
990/// without this word.
991#[must_use]
992pub fn unverified_latest_prompt(text: &str) -> bool {
993    matches!(latest_words(text), Latest::Unverified)
994}
995
996/// The session key that keeps one stated choice from being saved twice.
997fn capture_key(text: &str) -> String {
998    use std::hash::{Hash, Hasher};
999    let mut h = std::collections::hash_map::DefaultHasher::new();
1000    words_only(text).hash(&mut h);
1001    format!("capture:{:016x}", h.finish())
1002}
1003
1004/// How long the prompt hook waits for the pack to take a stated choice.
1005/// Runners cut a hook off at 10 to 20 s, and the search behind it needs
1006/// its own share.
1007pub const CAPTURE_BUDGET_MS: u64 = 3000;
1008
1009/// Write a stated choice as the person's own claim: origin user, standing,
1010/// tagged `via:prompt`. The atom the pack answered with.
1011fn save(stated: &Stated) -> Option<Value> {
1012    with_pack_timeout(CAPTURE_BUDGET_MS, || {
1013        let client = pack().ok()?;
1014        let workspace = client.workspace();
1015        let mut atom = atom_body(stated.kind, &stated.text, &workspace);
1016        stamp_horizon(&mut atom, stated.kind, &stated.text, Some(false));
1017        // The caller already required the runner's own program as parent.
1018        // A shell in between, or a detached process, never reaches here.
1019        add_entities(&mut atom, [super::SAID_ENTITY.to_string()]);
1020        admit::stamp_person(&mut atom);
1021        let posted = admit::post_kept(&client, &atom).ok()?;
1022        admit::satisfy_text(&stated.text);
1023        Some(posted)
1024    })
1025}
1026
1027/// The transcript and sessions the hook's bind named, or none.
1028fn bind_record_name() -> (String, Vec<String>) {
1029    let Ok(bind) = std::fs::read_to_string(latest_prompt_bind_path()) else {
1030        return ("-".to_string(), Vec::new());
1031    };
1032    let mut lines = bind.lines();
1033    if lines.next() != Some("3") {
1034        return ("-".to_string(), Vec::new());
1035    }
1036    let _hash = lines.next();
1037    let _mtime = lines.next();
1038    let _runner = lines.next();
1039    let _at = lines.next();
1040    let named = lines.next().filter(|s| !s.is_empty()).unwrap_or("-");
1041    let (sessions, _) = bind_tail(lines);
1042    (named.to_string(), sessions)
1043}
1044
1045/// Session ids, and the `full <64 hex>` line when the bind has one.
1046/// Anything else in the tail is a session. An older bind has no full line.
1047fn bind_tail<'a>(lines: impl Iterator<Item = &'a str>) -> (Vec<String>, String) {
1048    let mut sessions = Vec::new();
1049    let mut full = String::new();
1050    for line in lines {
1051        if let Some(hash) = full_prompt_hash(line) {
1052            full = hash;
1053            continue;
1054        }
1055        let session = crate::ballot_sig::field(line);
1056        if session != "-" {
1057            sessions.push(session);
1058        }
1059    }
1060    (sessions, full)
1061}
1062
1063fn full_prompt_hash(line: &str) -> Option<String> {
1064    let hash = line.strip_prefix("full ")?.trim();
1065    (hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_string())
1066}
1067
1068/// Whether saving `prompt` as the person's would trust a runner that has
1069/// no positive record of it. No runner above this process is not that:
1070/// the in-process check has no runner record to require.
1071fn capture_unverified(prompt: &str) -> bool {
1072    if crate::proc_tree::runner_above().is_none() {
1073        return false;
1074    }
1075    let (named, sessions) = bind_record_name();
1076    matches!(
1077        agrees_with_record(prompt.as_bytes(), &named, &sessions, ""),
1078        RecordWord::Unverified
1079    )
1080}
1081
1082/// On a prompt that states a standing choice, save it (or nudge, per
1083/// [`capture_mode`]) and return the one line the note carries and the
1084/// keys to mark seen when the note is delivered. The saved atom's id is
1085/// marked seen at once, so the same prompt's note does not repeat it.
1086/// A runner with no positive record of the prompt saves nothing.
1087#[must_use]
1088pub fn capture_stated(call: &HookCall) -> Option<(String, Vec<String>)> {
1089    if call.event != "UserPromptSubmit" {
1090        return None;
1091    }
1092    // Cursor without a transcript, and a runner that keeps no record,
1093    // does not save a stated choice as the person's. Claude Code and
1094    // Codex still do: their missing record is not this.
1095    if capture_unverified(&call.cue) {
1096        return None;
1097    }
1098    let mode = capture_mode();
1099    if mode == CaptureMode::Off {
1100        return None;
1101    }
1102    let stated = stated_preference(&call.cue)?;
1103    let key = capture_key(&stated.text);
1104    let session = call.session.as_deref();
1105    if seen_ids(session).contains(&key) {
1106        return None;
1107    }
1108    let verb = if stated.kind == "lesson" {
1109        "remember"
1110    } else {
1111        "prefer"
1112    };
1113    if mode == CaptureMode::Nudge {
1114        return Some((
1115            format!(
1116                "This prompt states a standing choice. Write it in the person's words with `ljos {verb} \"{}\"` so the hook raises it next time.",
1117                stated.text.replace('"', "'")
1118            ),
1119            vec![key],
1120        ));
1121    }
1122    let Some(posted) = save(&stated) else {
1123        return Some((
1124            format!(
1125                "This prompt states a standing choice and the pack did not take it. Write it with `ljos {verb} \"{}\"`.",
1126                stated.text.replace('"', "'")
1127            ),
1128            vec![key],
1129        ));
1130    };
1131    let id = posted["id"].as_str().unwrap_or("").to_string();
1132    let mut now = vec![key.clone()];
1133    if !id.is_empty() {
1134        now.push(id.clone());
1135    }
1136    // The correction hook would file the same words as a proposal and say
1137    // so; the person's words are already saved.
1138    if let Some(cue) = super::correction_cue(&call.cue) {
1139        now.push(format!("correction:{cue}"));
1140        now.push(format!("correction-stored:{cue}"));
1141    }
1142    mark_seen(session, &now);
1143    let what = if stated.kind == "lesson" {
1144        "lesson"
1145    } else {
1146        "preference"
1147    };
1148    let undo = if id.is_empty() {
1149        String::new()
1150    } else {
1151        format!(" `ljos forget {id}` takes it back.")
1152    };
1153    let persons = posted.pointer("/source/basis").and_then(Value::as_str) == Some("person");
1154    let line = if persons {
1155        format!(
1156            "Saved the person's {what} in their words, standing from now: \"{}\".{undo}",
1157            stated.text
1158        )
1159    } else {
1160        format!(
1161            "Held that sentence for review, not as the person's {what}: \"{}\". \
1162             `ljos memories review` keeps it.",
1163            stated.text
1164        )
1165    };
1166    Some((line, vec![key]))
1167}
1168
1169/// How much of the person's latest prompt a memory's provenance keeps.
1170const PROMPT_EXCERPT_CHARS: usize = 160;
1171
1172/// The opening of the person's latest prompt, as the reason an agent wrote
1173/// a memory now: whitespace folded, at most [`PROMPT_EXCERPT_CHARS`], and
1174/// any word shaped like a key, token or password replaced by `[redacted]`.
1175/// `None` when no prompt was kept in the last few minutes.
1176#[must_use]
1177pub fn latest_prompt_excerpt() -> Option<String> {
1178    let path = latest_prompt_path();
1179    let fresh = std::fs::metadata(&path)
1180        .and_then(|m| m.modified())
1181        .ok()
1182        .and_then(|t| t.elapsed().ok())
1183        .is_some_and(|age| age.as_secs() <= LATEST_PROMPT_SECS);
1184    if !fresh {
1185        return None;
1186    }
1187    let prompt = std::fs::read_to_string(&path).ok()?;
1188    let out = excerpt_of(&prompt);
1189    (!out.is_empty()).then_some(out)
1190}
1191
1192/// [`latest_prompt_excerpt`] of one prompt's text.
1193#[must_use]
1194pub fn excerpt_of(prompt: &str) -> String {
1195    let words: Vec<String> = prompt
1196        .split_whitespace()
1197        .map(|w| {
1198            if secret_shaped(w) {
1199                "[redacted]".to_string()
1200            } else {
1201                w.to_string()
1202            }
1203        })
1204        .collect();
1205    let joined = words.join(" ");
1206    if joined.chars().count() <= PROMPT_EXCERPT_CHARS {
1207        return joined;
1208    }
1209    let mut cut: String = joined.chars().take(PROMPT_EXCERPT_CHARS - 1).collect();
1210    cut.push('…');
1211    cut
1212}
1213
1214/// A word that may be a credential: a `key=`/`token:` style assignment, a
1215/// known key prefix, or a long run of mixed letters and digits.
1216fn secret_shaped(word: &str) -> bool {
1217    let lower = word.to_ascii_lowercase();
1218    if [
1219        "key=",
1220        "token=",
1221        "secret=",
1222        "password=",
1223        "passwd=",
1224        "key:",
1225        "token:",
1226        "secret:",
1227        "password:",
1228    ]
1229    .iter()
1230    .any(|p| lower.contains(p))
1231    {
1232        return true;
1233    }
1234    if [
1235        "sk-",
1236        "ghp_",
1237        "gho_",
1238        "github_pat_",
1239        "xox",
1240        "akia",
1241        "glpat-",
1242    ]
1243    .iter()
1244    .any(|p| lower.starts_with(p))
1245    {
1246        return true;
1247    }
1248    let core: String = word.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
1249    core.len() >= 24
1250        && core.chars().any(|c| c.is_ascii_digit())
1251        && core.chars().any(|c| c.is_ascii_alphabetic())
1252        && !word.contains('/')
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use super::*;
1258
1259    const SAVES: &[&str] = &[
1260        "From now on always use bun, never yarn, in this repo",
1261        "Always run cargo fmt before committing.",
1262        "Never push directly to main.",
1263        "Going forward, write commit messages in the imperative mood.",
1264        "Please always use pnpm for installs.",
1265        "Remember that the staging database is read-only on Fridays.",
1266        "Use bun, never yarn.",
1267        "From now on, run the linter before every push.",
1268        "Don't ever commit the .env file.",
1269        "Never use force push on shared branches.",
1270        "ok, from now on use tabs in Go files",
1271        "We always squash merge pull requests here.",
1272        "Always ask before running a database migration.",
1273        "In future, put new tests next to the code they cover.",
1274        "Stop using unwrap in library code.",
1275        "I prefer small commits with one change each.",
1276        "Henceforth all scripts use bash strict mode.",
1277        "Remember to bump the version in Cargo.toml before tagging.",
1278        "Never deploy on a Friday afternoon.",
1279        "Keep it short, and from now on skip the summary at the end.",
1280        "Always write `set -euo pipefail` at the top of shell scripts.",
1281        "Make sure you always run the tests first.",
1282        "We never edit an applied migration.",
1283        "Thanks. From now on use uv instead of pip.",
1284        "Use rg instead of grep going forward.",
1285        "Always prefer rtrash over rm.",
1286        "Never commit secrets to the repo.",
1287        "Please never run npm install in this project.",
1288        "From here on, write docs one sentence per line.",
1289        "Also, always pin action versions by SHA.",
1290    ];
1291
1292    const SAVES_NOTHING: &[&str] = &[
1293        "Why do you always use yarn?",
1294        "Should we always run the full suite?",
1295        "Does this ever fail on CI?",
1296        "Fix the failing test in parser.rs",
1297        "You never listen to me",
1298        "It always fails on the second run",
1299        "Never mind, that was my mistake.",
1300        "```\nalways_on = true\nnever_retry = false\n```",
1301        "> Never push directly to main\nwhat does this rule mean",
1302        "The doc says \"always use bun, never yarn, for this repo\" but is that right",
1303        "error: always-run hook failed\nwarning: never-again flag set\nerror: exit 1",
1304        "This could break in the future.",
1305        "Run the tests and tell me what fails",
1306        "I always forget how this works, can you explain it",
1307        "Never seen this error before, what is it",
1308        "Always the same problem with this build",
1309        "What's the best way to always keep the cache warm?",
1310        "Add a flag `--always-retry` to the CLI",
1311        "Rename never_called to unused in lib.rs",
1312        "Can you remember that function we wrote yesterday?",
1313        "It never worked on macOS anyway",
1314        "Remember when the build broke last week",
1315        "Is it true that git never forgets a commit",
1316        "Please look at the failing job",
1317        "Traceback (most recent call last):\n  File \"a.py\", line 3\nerror: always broken\nwarning: never set",
1318        "Always?",
1319        "never",
1320        "The upstream README says to always use yarn",
1321        "Explain why we never got the webhook",
1322        "Going forward is the only option for the migration",
1323    ];
1324
1325    /// The full-prompt hash sits after the session ids and is not a session.
1326    /// An older bind, with no such line, still yields its sessions.
1327    #[test]
1328    fn the_full_prompt_line_is_not_a_session() {
1329        let hash = "ab".repeat(32);
1330        let (sessions, full) = bind_tail(
1331            format!("sess-1\nfull {hash}\n-")
1332                .lines()
1333                .collect::<Vec<_>>()
1334                .into_iter(),
1335        );
1336        assert_eq!(sessions, ["sess-1"]);
1337        assert_eq!(full, hash);
1338        let (sessions, full) = bind_tail("sess-1\n".lines());
1339        assert_eq!(sessions, ["sess-1"]);
1340        assert!(full.is_empty());
1341        assert!(full_prompt_hash("full not-a-hash").is_none());
1342    }
1343
1344    /// A Codex subagent's brief, Codex's review thread and an unattended
1345    /// run are not the person; the root thread's prompt and Claude Code's
1346    /// main thread are.
1347    #[test]
1348    fn a_subagent_brief_or_unattended_run_is_not_the_person() {
1349        let _g = crate::tests::env_guard();
1350        unsafe { std::env::remove_var("LJOS_UNATTENDED") };
1351        let root = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
1352        let other = "019a1b2c-3d4e-7f80-9a1b-ffffffffffff";
1353        let codex = |extra: &str, thread: &str| {
1354            format!(
1355                r#"{{"hook_event_name":"UserPromptSubmit","session_id":"{root}","turn_id":"t-1","prompt":"Always run the tests with --release","transcript_path":"/home/u/.codex/sessions/2026/10/11/rollout-2026-10-11T09-30-00-{thread}.jsonl"{extra}}}"#
1356            )
1357        };
1358        assert_eq!(not_the_person(&codex("", root)), None);
1359        assert_eq!(
1360            not_the_person(&codex(
1361                r#","agent_id":"019a-sub","agent_type":"worker""#,
1362                root
1363            )),
1364            Some("a subagent's brief")
1365        );
1366        assert_eq!(
1367            not_the_person(&codex("", other)),
1368            Some("another thread's prompt")
1369        );
1370        let claude_main = r#"{"hook_event_name":"UserPromptSubmit","session_id":"abc","prompt":"From now on use uv","transcript_path":"/home/u/.claude/projects/x/abc.jsonl","agent_type":"reviewer"}"#;
1371        assert_eq!(
1372            not_the_person(claude_main),
1373            None,
1374            "--agent alone is the main thread"
1375        );
1376        let claude_sub =
1377            claude_main.replace(r#""agent_type""#, r#""agent_id":"sub-1","agent_type""#);
1378        assert_eq!(not_the_person(&claude_sub), Some("a subagent's brief"));
1379        unsafe { std::env::set_var("LJOS_UNATTENDED", "1") };
1380        assert_eq!(not_the_person(claude_main), Some("LJOS_UNATTENDED is set"));
1381        unsafe { std::env::set_var("LJOS_UNATTENDED", "0") };
1382        assert_eq!(not_the_person(claude_main), None);
1383        unsafe { std::env::remove_var("LJOS_UNATTENDED") };
1384    }
1385
1386    #[test]
1387    fn a_rollout_name_gives_its_thread() {
1388        let id = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
1389        assert_eq!(
1390            rollout_thread(&format!("/x/rollout-2026-10-11T09-30-00-{id}.jsonl")).as_deref(),
1391            Some(id)
1392        );
1393        assert_eq!(
1394            rollout_thread(&format!("rollout-2026-10-11T09-30-00-{id}_0042.jsonl")).as_deref(),
1395            Some(id)
1396        );
1397        assert_eq!(rollout_thread("/x/abc.jsonl"), None);
1398        assert_eq!(
1399            rollout_thread("rollout-2026-10-11T09-30-00-not-a-uuid.jsonl"),
1400            None
1401        );
1402    }
1403
1404    #[test]
1405    fn thirty_stated_choices_are_read() {
1406        assert_eq!(SAVES.len(), 30);
1407        let missed: Vec<&&str> = SAVES
1408            .iter()
1409            .filter(|p| stated_preference(p).is_none())
1410            .collect();
1411        assert!(
1412            missed.is_empty(),
1413            "not read as a standing choice: {missed:#?}"
1414        );
1415    }
1416
1417    #[test]
1418    fn thirty_other_prompts_save_nothing() {
1419        assert_eq!(SAVES_NOTHING.len(), 30);
1420        let saved: Vec<(&&str, Stated)> = SAVES_NOTHING
1421            .iter()
1422            .filter_map(|p| stated_preference(p).map(|s| (p, s)))
1423            .collect();
1424        assert!(saved.is_empty(), "read as a standing choice: {saved:#?}");
1425    }
1426
1427    #[test]
1428    fn the_saved_words_are_the_persons_sentence() {
1429        let s = stated_preference("From now on always use bun, never yarn, in this repo").unwrap();
1430        assert_eq!(s.kind, "preference");
1431        assert_eq!(
1432            s.text,
1433            "From now on always use bun, never yarn, in this repo"
1434        );
1435        let s = stated_preference("ok, from now on use tabs in Go files").unwrap();
1436        assert_eq!(s.text, "From now on use tabs in Go files");
1437        let s = stated_preference("Thanks. From now on use uv instead of pip.").unwrap();
1438        assert_eq!(s.text, "From now on use uv instead of pip.");
1439        let s = stated_preference("Remember that the staging database is read-only on Fridays.")
1440            .unwrap();
1441        assert_eq!(s.kind, "lesson");
1442        assert_eq!(s.text, "The staging database is read-only on Fridays.");
1443    }
1444
1445    #[test]
1446    fn the_mode_reads_ljos_capture() {
1447        let _g = crate::tests::env_guard();
1448        let prev = std::env::var_os("LJOS_CAPTURE");
1449        for (v, want) in [
1450            ("", CaptureMode::Save),
1451            ("save", CaptureMode::Save),
1452            ("nudge", CaptureMode::Nudge),
1453            ("off", CaptureMode::Off),
1454            ("bogus", CaptureMode::Save),
1455        ] {
1456            unsafe { std::env::set_var("LJOS_CAPTURE", v) };
1457            assert_eq!(capture_mode(), want, "{v}");
1458        }
1459        match prev {
1460            Some(v) => unsafe { std::env::set_var("LJOS_CAPTURE", v) },
1461            None => unsafe { std::env::remove_var("LJOS_CAPTURE") },
1462        }
1463    }
1464
1465    #[test]
1466    fn words_compare_whatever_the_punctuation() {
1467        assert_eq!(words_only("Use bun,  never   yarn!"), "use bun never yarn");
1468    }
1469
1470    #[test]
1471    fn a_typographic_apostrophe_still_turns_the_clause_around() {
1472        let cut = "always paste the release key into the deploy form";
1473        for apostrophe in ['\u{2019}', '\u{02bc}', '\u{ff07}'] {
1474            let prompt = format!(
1475                "Don{apostrophe}t do this: always paste the release key into the deploy form."
1476            );
1477            assert!(!quotes(&prompt, cut), "accepted under {apostrophe:?}");
1478            assert!(
1479                quotes(
1480                    &prompt,
1481                    "Don't do this: always paste the release key into the deploy form."
1482                ),
1483                "refused the whole sentence under {apostrophe:?}"
1484            );
1485        }
1486        assert!(quotes(
1487            "Use bun. Always paste the release key into the deploy form.",
1488            cut
1489        ));
1490    }
1491
1492    #[test]
1493    fn a_dangling_negation_is_joined_before_the_rule_is_read() {
1494        assert!(stated_preference("Do not\nalways use npm").is_none());
1495        assert!(stated_preference("Do not do this:\nalways use npm").is_none());
1496        for prompt in ["Don\u{2019}t\nalways use npm", "Don't\nalways use npm"] {
1497            if let Some(saved) = stated_preference(prompt) {
1498                let text = saved.text.to_lowercase();
1499                assert!(
1500                    text.contains("not") || text.contains("n't"),
1501                    "{prompt:?} saved {text}"
1502                );
1503                assert_ne!(text, "always use npm", "{prompt:?}");
1504            }
1505        }
1506        let saved = stated_preference("Never\npush directly to main.").unwrap();
1507        assert_eq!(saved.text, "Never push directly to main.");
1508        let saved = stated_preference("Always use npm.").unwrap();
1509        assert_eq!(saved.text, "Always use npm.");
1510        let saved = stated_preference("Do not\n\nAlways use npm.").unwrap();
1511        assert_eq!(saved.text, "Always use npm.");
1512        let saved = stated_preference("Always use npm.\nNever push directly to main.").unwrap();
1513        assert_eq!(saved.text, "Always use npm.");
1514    }
1515
1516    #[test]
1517    fn a_negation_on_the_line_before_still_governs_the_quote() {
1518        let cut = "always paste the release key into the deploy form";
1519        for prompt in [
1520            "Do not\nalways paste the release key into the deploy form.",
1521            "Do not do this:\nalways paste the release key into the deploy form.",
1522            "Please never:\n\talways paste the release key into the deploy form",
1523            "Always paste the release key into the deploy form\nunless asked.",
1524        ] {
1525            assert!(!quotes(prompt, cut), "accepted from {prompt:?}");
1526        }
1527        assert!(quotes(
1528            "Do not\nalways paste the release key into the deploy form.",
1529            "Do not always paste the release key into the deploy form."
1530        ));
1531        for prompt in [
1532            "Use bun.\nAlways paste the release key into the deploy form.",
1533            "Do not use yarn\n\nAlways paste the release key into the deploy form.",
1534        ] {
1535            assert!(quotes(prompt, cut), "refused from {prompt:?}");
1536        }
1537    }
1538}