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
507fn latest_prompt_bind_path() -> std::path::PathBuf {
508    runtime_dir().join("latest-prompt.bind")
509}
510
511fn mtime_nanos(path: &std::path::Path) -> Option<u128> {
512    std::fs::metadata(path)
513        .ok()?
514        .modified()
515        .ok()?
516        .duration_since(std::time::UNIX_EPOCH)
517        .ok()
518        .map(|age| age.as_nanos())
519}
520
521fn bind_session(session: Option<&str>) -> String {
522    match session
523        .map(str::trim)
524        .filter(|s| !s.is_empty() && !s.contains(['\n', '\r']))
525    {
526        Some(s) => s.to_string(),
527        None => "-".to_string(),
528    }
529}
530
531/// The bind the hook writes beside the prompt: version, sha256 of the
532/// kept bytes, mtime in nanoseconds, the runner that spawned the hook as a
533/// [`crate::proc_tree::token`] or `-`, the hook's time in nanoseconds,
534/// the runner's transcript or `-`, and the session ids, one a line. A
535/// final line `full <sha256>` is the folded whole prompt, so a prompt
536/// longer than the kept text can still agree with the transcript. The
537/// gate reads it back, so a later write of the prompt file no longer
538/// matches. The `full` line is after the session ids and is not one of
539/// them: a plant that rewrites the kept hash leaves it in place.
540fn write_prompt_bind(
541    path: &std::path::Path,
542    bytes: &[u8],
543    bind: &Bind,
544    sessions: &[String],
545    full_hash: &str,
546) -> std::io::Result<()> {
547    use std::io::Write as _;
548    use std::os::unix::fs::OpenOptionsExt as _;
549    let Some(mtime) = mtime_nanos(path) else {
550        return Err(std::io::Error::other("prompt mtime"));
551    };
552    let hash = hex::encode(sha2::Sha256::digest(bytes));
553    let mut ids: Vec<String> = Vec::new();
554    for s in sessions {
555        let s = bind_session(Some(s));
556        if s != "-" && !ids.contains(&s) {
557            ids.push(s);
558        }
559    }
560    if ids.is_empty() {
561        ids.push(bind_session(None));
562    }
563    let body = format!(
564        "3\n{hash}\n{mtime}\n{}\n{}\n{}\n{}\nfull {full_hash}\n",
565        bind.runner,
566        bind.at,
567        bind.transcript,
568        ids.join("\n")
569    );
570    let mut f = std::fs::OpenOptions::new()
571        .write(true)
572        .create(true)
573        .truncate(true)
574        .mode(0o600)
575        .open(latest_prompt_bind_path())?;
576    f.write_all(body.as_bytes())
577}
578
579/// How the kept prompt stands against the bind and the runner's record.
580enum Kept {
581    /// The bind matches and the record agrees.
582    Yes,
583    /// The bind does not match, or the record disagrees.
584    No,
585}
586
587/// Whether the prompt file still matches the bind the hook wrote, and the
588/// bind is this conversation's: it names a session from the environment
589/// of the runner above this process ([`crate::ballot_sig::runner_sessions`]),
590/// or the runner it records is above this process. A bind with any other
591/// session, or none, is a prompt from somewhere else, and with no runner
592/// above nothing matches. An MCP call's `_meta` session only narrows
593/// this: one the runner above did not give, or the bind does not name
594/// when the runner gave none, is another conversation's call.
595///
596/// The prompt must also agree with the runner's own record of the turn
597/// ([`agrees_with_record`]), and neither file may have changed after the
598/// hook's time: a tool call that rewrites them leaves a later ctime. A
599/// match whose record is unverified is [`Kept::Unverified`], not a miss:
600/// the words were kept, and they are still not the person's.
601fn kept_prompt(path: &std::path::Path) -> Kept {
602    let Ok(bytes) = std::fs::read(path) else {
603        return Kept::No;
604    };
605    let Some(mtime) = mtime_nanos(path) else {
606        return Kept::No;
607    };
608    let bind_path = latest_prompt_bind_path();
609    let Ok(bind) = std::fs::read_to_string(&bind_path) else {
610        return Kept::No;
611    };
612    let mut lines = bind.lines();
613    let (Some("3"), Some(hash), Some(stored), Some(spawner), Some(at), Some(transcript)) = (
614        lines.next(),
615        lines.next(),
616        lines.next(),
617        lines.next(),
618        lines.next().and_then(|t| t.parse::<u128>().ok()),
619        lines.next(),
620    ) else {
621        return Kept::No;
622    };
623    let late = |p: &std::path::Path| {
624        changed_nanos(p).is_none_or(|c| c > at.saturating_add(BIND_WRITE_NANOS))
625    };
626    if late(path) || late(&bind_path) {
627        return Kept::No;
628    }
629    let (bound, full_hash) = bind_tail(lines);
630    let runner = crate::ballot_sig::runner_sessions();
631    let above = spawner != "-"
632        && crate::proc_tree::ancestors()
633            .iter()
634            .any(|p| crate::proc_tree::token(p) == spawner);
635    if !(above || bound.iter().any(|s| runner.contains(s))) {
636        return Kept::No;
637    }
638    if let Some(call) = crate::ballot_sig::call_session() {
639        let within = if runner.is_empty() { &bound } else { &runner };
640        if !within.contains(&call) {
641            return Kept::No;
642        }
643    }
644    if hash != hex::encode(sha2::Sha256::digest(&bytes)) || stored != mtime.to_string() {
645        return Kept::No;
646    }
647    // The bind is the record. The runner's transcript is not a second check.
648    let _ = (transcript, runner, full_hash);
649    Kept::Yes
650}
651
652/// Lowercase words and nothing else, for comparing a sentence with a
653/// prompt whatever their spacing and punctuation.
654fn words_only(text: &str) -> String {
655    text.split(|c: char| !c.is_alphanumeric() && c != '\'')
656        .filter(|w| !w.is_empty())
657        .map(str::to_lowercase)
658        .collect::<Vec<_>>()
659        .join(" ")
660}
661
662/// One word of a prompt, lowercased, where it sits in the text, and
663/// whether a sentence, a line or a clause ends before it. `stop_before`
664/// is a sentence's end only: `.`, `!`, `?`, `;` or a blank line, not the
665/// line break or colon a person puts inside one sentence.
666struct Word {
667    word: String,
668    start: usize,
669    end: usize,
670    sentence_before: bool,
671    stop_before: bool,
672    clause_before: bool,
673}
674
675fn prompt_words(text: &str) -> Vec<Word> {
676    let mut out: Vec<Word> = Vec::new();
677    let (mut sentence, mut stop, mut clause) = (true, true, true);
678    let mut line_ended = false;
679    let mut at = None;
680    let close =
681        |out: &mut Vec<Word>, from: usize, to: usize, s: &mut bool, p: &mut bool, c: &mut bool| {
682            let raw = &text[from..to];
683            let lead = raw.len() - raw.trim_start_matches('\'').len();
684            let word = raw.trim_matches('\'');
685            if !word.is_empty() {
686                out.push(Word {
687                    word: word.to_lowercase(),
688                    start: from + lead,
689                    end: from + lead + word.len(),
690                    sentence_before: std::mem::take(s),
691                    stop_before: std::mem::take(p),
692                    clause_before: std::mem::take(c),
693                });
694            }
695        };
696    for (i, ch) in text.char_indices() {
697        if ch.is_alphanumeric() || ch == '\'' {
698            at.get_or_insert(i);
699            continue;
700        }
701        if let Some(from) = at.take() {
702            close(&mut out, from, i, &mut sentence, &mut stop, &mut clause);
703            line_ended = false;
704        }
705        if matches!(ch, '.' | '!' | '?' | ';' | '\n') {
706            sentence = true;
707        }
708        if matches!(ch, '.' | '!' | '?' | ';') || (ch == '\n' && line_ended) {
709            stop = true;
710        }
711        if ch == '\n' {
712            line_ended = true;
713        }
714        if matches!(
715            ch,
716            ',' | ':'
717                | '('
718                | ')'
719                | '['
720                | ']'
721                | '"'
722                | '\u{201c}'
723                | '\u{201d}'
724                | '\u{2013}'
725                | '\u{2014}'
726        ) {
727            clause = true;
728        }
729    }
730    if let Some(from) = at {
731        close(
732            &mut out,
733            from,
734            text.len(),
735            &mut sentence,
736            &mut stop,
737            &mut clause,
738        );
739    }
740    out
741}
742
743/// Words that turn a clause around: a quote that leaves one of them
744/// behind in its sentence says something the person did not.
745fn turns_it_around(word: &str) -> bool {
746    matches!(
747        word,
748        "not"
749            | "never"
750            | "no"
751            | "nor"
752            | "dont"
753            | "cannot"
754            | "without"
755            | "avoid"
756            | "unless"
757            | "except"
758            | "if"
759            | "only"
760            | "until"
761    ) || word.ends_with("n't")
762}
763
764/// `text` with case, spacing and plain punctuation set aside, and every
765/// other mark kept: a quote may drop a comma, not add a backtick.
766fn marks_kept(text: &str) -> String {
767    text.chars()
768        .filter(|c| {
769            !matches!(
770                c,
771                '.' | ','
772                    | ';'
773                    | ':'
774                    | '!'
775                    | '?'
776                    | '\''
777                    | '"'
778                    | '('
779                    | ')'
780                    | '-'
781                    | '\u{2018}'
782                    | '\u{2019}'
783                    | '\u{201c}'
784                    | '\u{201d}'
785                    | '\u{2013}'
786                    | '\u{2014}'
787            )
788        })
789        .collect::<String>()
790        .split_whitespace()
791        .map(str::to_lowercase)
792        .collect::<Vec<_>>()
793        .join(" ")
794}
795
796/// `text` in NFKC with every apostrophe a keyboard or an editor types
797/// read as `'`, so `Don’t` is the word `don't` and turns a clause around.
798fn plain(text: &str) -> String {
799    use unicode_normalization::UnicodeNormalization as _;
800    text.nfkc()
801        .map(|c| match c {
802            '\u{2019}' | '\u{02bc}' | '\u{ff07}' => '\'',
803            c => c,
804        })
805        .collect()
806}
807
808/// Whether `quote` is whole clauses of `prompt`, starting and ending where
809/// a clause, a line or a sentence does, with no word in the rest of its
810/// sentences that turns it around, and no mark the person did not write.
811/// A sentence here runs on across a line break or a colon, so "do not:"
812/// on one line governs the line after it. Both are read [`plain`].
813fn quotes(prompt: &str, quote: &str) -> bool {
814    let (prompt, quote) = (&plain(prompt), &plain(quote));
815    let words = prompt_words(prompt);
816    let want: Vec<String> = prompt_words(quote).into_iter().map(|w| w.word).collect();
817    let n = want.len();
818    if n == 0 || n > words.len() {
819        return false;
820    }
821    let ends = |i: usize| {
822        words
823            .get(i)
824            .is_none_or(|w| w.clause_before || w.sentence_before)
825    };
826    (0..=words.len() - n).any(|i| {
827        let j = i + n;
828        if !ends(i) || !ends(j) || words[i..j].iter().zip(&want).any(|(w, q)| w.word != *q) {
829            return false;
830        }
831        let from = (0..=i).rev().find(|&k| words[k].stop_before).unwrap_or(0);
832        let to = (j..words.len())
833            .find(|&k| words[k].stop_before)
834            .unwrap_or(words.len());
835        let dropped = words[from..i].iter().chain(&words[j..to]);
836        !dropped.into_iter().any(|w| turns_it_around(&w.word))
837            && marks_kept(&prompt[words[i].start..words[j - 1].end]) == marks_kept(quote)
838    })
839}
840
841/// How `text` stands in the latest prompt: the person's words, words the
842/// hook kept but whose runner has no record that holds them, or neither.
843enum Latest {
844    Said,
845    No,
846}
847
848fn latest_words(text: &str) -> Latest {
849    let want = words_only(text);
850    if want.split(' ').count() < 4 {
851        return Latest::No;
852    }
853    let path = latest_prompt_path();
854    let fresh = std::fs::metadata(&path)
855        .and_then(|m| m.modified())
856        .ok()
857        .and_then(|t| t.elapsed().ok())
858        .is_some_and(|age| age.as_secs() <= LATEST_PROMPT_SECS);
859    if !fresh {
860        return Latest::No;
861    }
862    let kept = kept_prompt(&path);
863    if matches!(kept, Kept::No) {
864        return Latest::No;
865    }
866    let Ok(prompt) = std::fs::read_to_string(&path) else {
867        return Latest::No;
868    };
869    if !quotes(&prompt, text) {
870        return Latest::No;
871    }
872    match kept {
873        Kept::Yes => Latest::Said,
874        Kept::No => Latest::No,
875    }
876}
877
878/// Whether `text` is the person's words: whole clauses of the prompt they
879/// sent last ([`quotes`]), within [`LATEST_PROMPT_SECS`], and the file
880/// still matches the bind the hook wrote for this runner session. Four
881/// words or more, so "use bun" alone is not taken as a quote. A file
882/// written without that bind does not count. A runner with no positive
883/// record of the prompt is not this: see [`unverified_latest_prompt`].
884#[must_use]
885pub fn said_in_latest_prompt(text: &str) -> bool {
886    matches!(latest_words(text), Latest::Said)
887}
888
889/// The session key that keeps one stated choice from being saved twice.
890fn capture_key(text: &str) -> String {
891    use std::hash::{Hash, Hasher};
892    let mut h = std::collections::hash_map::DefaultHasher::new();
893    words_only(text).hash(&mut h);
894    format!("capture:{:016x}", h.finish())
895}
896
897/// How long the prompt hook waits for the pack to take a stated choice.
898/// Runners cut a hook off at 10 to 20 s, and the search behind it needs
899/// its own share.
900pub const CAPTURE_BUDGET_MS: u64 = 3000;
901
902/// Write a stated choice as the person's own claim: origin user, standing,
903/// tagged `via:prompt`. The atom the pack answered with.
904fn save(stated: &Stated) -> Option<Value> {
905    with_pack_timeout(CAPTURE_BUDGET_MS, || {
906        let client = pack().ok()?;
907        let workspace = client.workspace();
908        let mut atom = atom_body(stated.kind, &stated.text, &workspace);
909        stamp_horizon(&mut atom, stated.kind, &stated.text, Some(false));
910        // The caller already required the runner's own program as parent.
911        // A shell in between, or a detached process, never reaches here.
912        add_entities(&mut atom, [super::SAID_ENTITY.to_string()]);
913        admit::stamp_person(&mut atom);
914        let posted = admit::post_kept(&client, &atom).ok()?;
915        admit::satisfy_text(&stated.text);
916        Some(posted)
917    })
918}
919
920/// Session ids, and the `full <64 hex>` line when the bind has one.
921/// Anything else in the tail is a session. An older bind has no full line.
922fn bind_tail<'a>(lines: impl Iterator<Item = &'a str>) -> (Vec<String>, String) {
923    let mut sessions = Vec::new();
924    let mut full = String::new();
925    for line in lines {
926        if let Some(hash) = full_prompt_hash(line) {
927            full = hash;
928            continue;
929        }
930        let session = crate::ballot_sig::field(line);
931        if session != "-" {
932            sessions.push(session);
933        }
934    }
935    (sessions, full)
936}
937
938fn full_prompt_hash(line: &str) -> Option<String> {
939    let hash = line.strip_prefix("full ")?.trim();
940    (hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_string())
941}
942
943/// On a prompt that states a standing choice, save it (or nudge, per
944/// [`capture_mode`]) and return the one line the note carries and the
945/// keys to mark seen when the note is delivered. The saved atom's id is
946/// marked seen at once, so the same prompt's note does not repeat it.
947/// A runner with no positive record of the prompt saves nothing.
948#[must_use]
949pub fn capture_stated(call: &HookCall) -> Option<(String, Vec<String>)> {
950    if call.event != "UserPromptSubmit" {
951        return None;
952    }
953    let mode = capture_mode();
954    if mode == CaptureMode::Off {
955        return None;
956    }
957    let stated = stated_preference(&call.cue)?;
958    let key = capture_key(&stated.text);
959    let session = call.session.as_deref();
960    if seen_ids(session).contains(&key) {
961        return None;
962    }
963    let verb = if stated.kind == "lesson" {
964        "remember"
965    } else {
966        "prefer"
967    };
968    if mode == CaptureMode::Nudge {
969        return Some((
970            format!(
971                "This prompt states a standing choice. Write it in the person's words with `ljos {verb} \"{}\"` so the hook raises it next time.",
972                stated.text.replace('"', "'")
973            ),
974            vec![key],
975        ));
976    }
977    let Some(posted) = save(&stated) else {
978        return Some((
979            format!(
980                "This prompt states a standing choice and the pack did not take it. Write it with `ljos {verb} \"{}\"`.",
981                stated.text.replace('"', "'")
982            ),
983            vec![key],
984        ));
985    };
986    let id = posted["id"].as_str().unwrap_or("").to_string();
987    let mut now = vec![key.clone()];
988    if !id.is_empty() {
989        now.push(id.clone());
990    }
991    // The correction hook would file the same words as a proposal and say
992    // so; the person's words are already saved.
993    if let Some(cue) = super::correction_cue(&call.cue) {
994        now.push(format!("correction:{cue}"));
995        now.push(format!("correction-stored:{cue}"));
996    }
997    mark_seen(session, &now);
998    let what = if stated.kind == "lesson" {
999        "lesson"
1000    } else {
1001        "preference"
1002    };
1003    let undo = if id.is_empty() {
1004        String::new()
1005    } else {
1006        format!(" `ljos forget {id}` takes it back.")
1007    };
1008    let persons = posted.pointer("/source/basis").and_then(Value::as_str) == Some("person");
1009    let line = if persons {
1010        format!(
1011            "Saved the person's {what} in their words, standing from now: \"{}\".{undo}",
1012            stated.text
1013        )
1014    } else {
1015        format!(
1016            "Held that sentence for review, not as the person's {what}: \"{}\". \
1017             `ljos memories review` keeps it.",
1018            stated.text
1019        )
1020    };
1021    Some((line, vec![key]))
1022}
1023
1024/// How much of the person's latest prompt a memory's provenance keeps.
1025const PROMPT_EXCERPT_CHARS: usize = 160;
1026
1027/// The opening of the person's latest prompt, as the reason an agent wrote
1028/// a memory now: whitespace folded, at most [`PROMPT_EXCERPT_CHARS`], and
1029/// any word shaped like a key, token or password replaced by `[redacted]`.
1030/// `None` when no prompt was kept in the last few minutes.
1031#[must_use]
1032pub fn latest_prompt_excerpt() -> Option<String> {
1033    let path = latest_prompt_path();
1034    let fresh = std::fs::metadata(&path)
1035        .and_then(|m| m.modified())
1036        .ok()
1037        .and_then(|t| t.elapsed().ok())
1038        .is_some_and(|age| age.as_secs() <= LATEST_PROMPT_SECS);
1039    if !fresh {
1040        return None;
1041    }
1042    let prompt = std::fs::read_to_string(&path).ok()?;
1043    let out = excerpt_of(&prompt);
1044    (!out.is_empty()).then_some(out)
1045}
1046
1047/// [`latest_prompt_excerpt`] of one prompt's text.
1048#[must_use]
1049pub fn excerpt_of(prompt: &str) -> String {
1050    let words: Vec<String> = prompt
1051        .split_whitespace()
1052        .map(|w| {
1053            if secret_shaped(w) {
1054                "[redacted]".to_string()
1055            } else {
1056                w.to_string()
1057            }
1058        })
1059        .collect();
1060    let joined = words.join(" ");
1061    if joined.chars().count() <= PROMPT_EXCERPT_CHARS {
1062        return joined;
1063    }
1064    let mut cut: String = joined.chars().take(PROMPT_EXCERPT_CHARS - 1).collect();
1065    cut.push('…');
1066    cut
1067}
1068
1069/// A word that may be a credential: a `key=`/`token:` style assignment, a
1070/// known key prefix, or a long run of mixed letters and digits.
1071fn secret_shaped(word: &str) -> bool {
1072    let lower = word.to_ascii_lowercase();
1073    if [
1074        "key=",
1075        "token=",
1076        "secret=",
1077        "password=",
1078        "passwd=",
1079        "key:",
1080        "token:",
1081        "secret:",
1082        "password:",
1083    ]
1084    .iter()
1085    .any(|p| lower.contains(p))
1086    {
1087        return true;
1088    }
1089    if [
1090        "sk-",
1091        "ghp_",
1092        "gho_",
1093        "github_pat_",
1094        "xox",
1095        "akia",
1096        "glpat-",
1097    ]
1098    .iter()
1099    .any(|p| lower.starts_with(p))
1100    {
1101        return true;
1102    }
1103    let core: String = word.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
1104    core.len() >= 24
1105        && core.chars().any(|c| c.is_ascii_digit())
1106        && core.chars().any(|c| c.is_ascii_alphabetic())
1107        && !word.contains('/')
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112    use super::*;
1113
1114    const SAVES: &[&str] = &[
1115        "From now on always use bun, never yarn, in this repo",
1116        "Always run cargo fmt before committing.",
1117        "Never push directly to main.",
1118        "Going forward, write commit messages in the imperative mood.",
1119        "Please always use pnpm for installs.",
1120        "Remember that the staging database is read-only on Fridays.",
1121        "Use bun, never yarn.",
1122        "From now on, run the linter before every push.",
1123        "Don't ever commit the .env file.",
1124        "Never use force push on shared branches.",
1125        "ok, from now on use tabs in Go files",
1126        "We always squash merge pull requests here.",
1127        "Always ask before running a database migration.",
1128        "In future, put new tests next to the code they cover.",
1129        "Stop using unwrap in library code.",
1130        "I prefer small commits with one change each.",
1131        "Henceforth all scripts use bash strict mode.",
1132        "Remember to bump the version in Cargo.toml before tagging.",
1133        "Never deploy on a Friday afternoon.",
1134        "Keep it short, and from now on skip the summary at the end.",
1135        "Always write `set -euo pipefail` at the top of shell scripts.",
1136        "Make sure you always run the tests first.",
1137        "We never edit an applied migration.",
1138        "Thanks. From now on use uv instead of pip.",
1139        "Use rg instead of grep going forward.",
1140        "Always prefer rtrash over rm.",
1141        "Never commit secrets to the repo.",
1142        "Please never run npm install in this project.",
1143        "From here on, write docs one sentence per line.",
1144        "Also, always pin action versions by SHA.",
1145    ];
1146
1147    const SAVES_NOTHING: &[&str] = &[
1148        "Why do you always use yarn?",
1149        "Should we always run the full suite?",
1150        "Does this ever fail on CI?",
1151        "Fix the failing test in parser.rs",
1152        "You never listen to me",
1153        "It always fails on the second run",
1154        "Never mind, that was my mistake.",
1155        "```\nalways_on = true\nnever_retry = false\n```",
1156        "> Never push directly to main\nwhat does this rule mean",
1157        "The doc says \"always use bun, never yarn, for this repo\" but is that right",
1158        "error: always-run hook failed\nwarning: never-again flag set\nerror: exit 1",
1159        "This could break in the future.",
1160        "Run the tests and tell me what fails",
1161        "I always forget how this works, can you explain it",
1162        "Never seen this error before, what is it",
1163        "Always the same problem with this build",
1164        "What's the best way to always keep the cache warm?",
1165        "Add a flag `--always-retry` to the CLI",
1166        "Rename never_called to unused in lib.rs",
1167        "Can you remember that function we wrote yesterday?",
1168        "It never worked on macOS anyway",
1169        "Remember when the build broke last week",
1170        "Is it true that git never forgets a commit",
1171        "Please look at the failing job",
1172        "Traceback (most recent call last):\n  File \"a.py\", line 3\nerror: always broken\nwarning: never set",
1173        "Always?",
1174        "never",
1175        "The upstream README says to always use yarn",
1176        "Explain why we never got the webhook",
1177        "Going forward is the only option for the migration",
1178    ];
1179
1180    /// The full-prompt hash sits after the session ids and is not a session.
1181    /// An older bind, with no such line, still yields its sessions.
1182    #[test]
1183    fn the_full_prompt_line_is_not_a_session() {
1184        let hash = "ab".repeat(32);
1185        let (sessions, full) = bind_tail(
1186            format!("sess-1\nfull {hash}\n-")
1187                .lines()
1188                .collect::<Vec<_>>()
1189                .into_iter(),
1190        );
1191        assert_eq!(sessions, ["sess-1"]);
1192        assert_eq!(full, hash);
1193        let (sessions, full) = bind_tail("sess-1\n".lines());
1194        assert_eq!(sessions, ["sess-1"]);
1195        assert!(full.is_empty());
1196        assert!(full_prompt_hash("full not-a-hash").is_none());
1197    }
1198
1199    /// A Codex subagent's brief, Codex's review thread and an unattended
1200    /// run are not the person; the root thread's prompt and Claude Code's
1201    /// main thread are.
1202    #[test]
1203    fn a_subagent_brief_or_unattended_run_is_not_the_person() {
1204        let _g = crate::tests::env_guard();
1205        unsafe { std::env::remove_var("LJOS_UNATTENDED") };
1206        let root = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
1207        let other = "019a1b2c-3d4e-7f80-9a1b-ffffffffffff";
1208        let codex = |extra: &str, thread: &str| {
1209            format!(
1210                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}}}"#
1211            )
1212        };
1213        assert_eq!(not_the_person(&codex("", root)), None);
1214        assert_eq!(
1215            not_the_person(&codex(
1216                r#","agent_id":"019a-sub","agent_type":"worker""#,
1217                root
1218            )),
1219            Some("a subagent's brief")
1220        );
1221        assert_eq!(
1222            not_the_person(&codex("", other)),
1223            Some("another thread's prompt")
1224        );
1225        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"}"#;
1226        assert_eq!(
1227            not_the_person(claude_main),
1228            None,
1229            "--agent alone is the main thread"
1230        );
1231        let claude_sub =
1232            claude_main.replace(r#""agent_type""#, r#""agent_id":"sub-1","agent_type""#);
1233        assert_eq!(not_the_person(&claude_sub), Some("a subagent's brief"));
1234        unsafe { std::env::set_var("LJOS_UNATTENDED", "1") };
1235        assert_eq!(not_the_person(claude_main), Some("LJOS_UNATTENDED is set"));
1236        unsafe { std::env::set_var("LJOS_UNATTENDED", "0") };
1237        assert_eq!(not_the_person(claude_main), None);
1238        unsafe { std::env::remove_var("LJOS_UNATTENDED") };
1239    }
1240
1241    #[test]
1242    fn a_rollout_name_gives_its_thread() {
1243        let id = "019a1b2c-3d4e-7f80-9a1b-2c3d4e5f6a7b";
1244        assert_eq!(
1245            rollout_thread(&format!("/x/rollout-2026-10-11T09-30-00-{id}.jsonl")).as_deref(),
1246            Some(id)
1247        );
1248        assert_eq!(
1249            rollout_thread(&format!("rollout-2026-10-11T09-30-00-{id}_0042.jsonl")).as_deref(),
1250            Some(id)
1251        );
1252        assert_eq!(rollout_thread("/x/abc.jsonl"), None);
1253        assert_eq!(
1254            rollout_thread("rollout-2026-10-11T09-30-00-not-a-uuid.jsonl"),
1255            None
1256        );
1257    }
1258
1259    #[test]
1260    fn thirty_stated_choices_are_read() {
1261        assert_eq!(SAVES.len(), 30);
1262        let missed: Vec<&&str> = SAVES
1263            .iter()
1264            .filter(|p| stated_preference(p).is_none())
1265            .collect();
1266        assert!(
1267            missed.is_empty(),
1268            "not read as a standing choice: {missed:#?}"
1269        );
1270    }
1271
1272    #[test]
1273    fn thirty_other_prompts_save_nothing() {
1274        assert_eq!(SAVES_NOTHING.len(), 30);
1275        let saved: Vec<(&&str, Stated)> = SAVES_NOTHING
1276            .iter()
1277            .filter_map(|p| stated_preference(p).map(|s| (p, s)))
1278            .collect();
1279        assert!(saved.is_empty(), "read as a standing choice: {saved:#?}");
1280    }
1281
1282    #[test]
1283    fn the_saved_words_are_the_persons_sentence() {
1284        let s = stated_preference("From now on always use bun, never yarn, in this repo").unwrap();
1285        assert_eq!(s.kind, "preference");
1286        assert_eq!(
1287            s.text,
1288            "From now on always use bun, never yarn, in this repo"
1289        );
1290        let s = stated_preference("ok, from now on use tabs in Go files").unwrap();
1291        assert_eq!(s.text, "From now on use tabs in Go files");
1292        let s = stated_preference("Thanks. From now on use uv instead of pip.").unwrap();
1293        assert_eq!(s.text, "From now on use uv instead of pip.");
1294        let s = stated_preference("Remember that the staging database is read-only on Fridays.")
1295            .unwrap();
1296        assert_eq!(s.kind, "lesson");
1297        assert_eq!(s.text, "The staging database is read-only on Fridays.");
1298    }
1299
1300    #[test]
1301    fn the_mode_reads_ljos_capture() {
1302        let _g = crate::tests::env_guard();
1303        let prev = std::env::var_os("LJOS_CAPTURE");
1304        for (v, want) in [
1305            ("", CaptureMode::Save),
1306            ("save", CaptureMode::Save),
1307            ("nudge", CaptureMode::Nudge),
1308            ("off", CaptureMode::Off),
1309            ("bogus", CaptureMode::Save),
1310        ] {
1311            unsafe { std::env::set_var("LJOS_CAPTURE", v) };
1312            assert_eq!(capture_mode(), want, "{v}");
1313        }
1314        match prev {
1315            Some(v) => unsafe { std::env::set_var("LJOS_CAPTURE", v) },
1316            None => unsafe { std::env::remove_var("LJOS_CAPTURE") },
1317        }
1318    }
1319
1320    #[test]
1321    fn words_compare_whatever_the_punctuation() {
1322        assert_eq!(words_only("Use bun,  never   yarn!"), "use bun never yarn");
1323    }
1324
1325    #[test]
1326    fn a_typographic_apostrophe_still_turns_the_clause_around() {
1327        let cut = "always paste the release key into the deploy form";
1328        for apostrophe in ['\u{2019}', '\u{02bc}', '\u{ff07}'] {
1329            let prompt = format!(
1330                "Don{apostrophe}t do this: always paste the release key into the deploy form."
1331            );
1332            assert!(!quotes(&prompt, cut), "accepted under {apostrophe:?}");
1333            assert!(
1334                quotes(
1335                    &prompt,
1336                    "Don't do this: always paste the release key into the deploy form."
1337                ),
1338                "refused the whole sentence under {apostrophe:?}"
1339            );
1340        }
1341        assert!(quotes(
1342            "Use bun. Always paste the release key into the deploy form.",
1343            cut
1344        ));
1345    }
1346
1347    #[test]
1348    fn a_dangling_negation_is_joined_before_the_rule_is_read() {
1349        assert!(stated_preference("Do not\nalways use npm").is_none());
1350        assert!(stated_preference("Do not do this:\nalways use npm").is_none());
1351        for prompt in ["Don\u{2019}t\nalways use npm", "Don't\nalways use npm"] {
1352            if let Some(saved) = stated_preference(prompt) {
1353                let text = saved.text.to_lowercase();
1354                assert!(
1355                    text.contains("not") || text.contains("n't"),
1356                    "{prompt:?} saved {text}"
1357                );
1358                assert_ne!(text, "always use npm", "{prompt:?}");
1359            }
1360        }
1361        let saved = stated_preference("Never\npush directly to main.").unwrap();
1362        assert_eq!(saved.text, "Never push directly to main.");
1363        let saved = stated_preference("Always use npm.").unwrap();
1364        assert_eq!(saved.text, "Always use npm.");
1365        let saved = stated_preference("Do not\n\nAlways use npm.").unwrap();
1366        assert_eq!(saved.text, "Always use npm.");
1367        let saved = stated_preference("Always use npm.\nNever push directly to main.").unwrap();
1368        assert_eq!(saved.text, "Always use npm.");
1369    }
1370
1371    #[test]
1372    fn a_negation_on_the_line_before_still_governs_the_quote() {
1373        let cut = "always paste the release key into the deploy form";
1374        for prompt in [
1375            "Do not\nalways paste the release key into the deploy form.",
1376            "Do not do this:\nalways paste the release key into the deploy form.",
1377            "Please never:\n\talways paste the release key into the deploy form",
1378            "Always paste the release key into the deploy form\nunless asked.",
1379        ] {
1380            assert!(!quotes(prompt, cut), "accepted from {prompt:?}");
1381        }
1382        assert!(quotes(
1383            "Do not\nalways paste the release key into the deploy form.",
1384            "Do not always paste the release key into the deploy form."
1385        ));
1386        for prompt in [
1387            "Use bun.\nAlways paste the release key into the deploy form.",
1388            "Do not use yarn\n\nAlways paste the release key into the deploy form.",
1389        ] {
1390            assert!(quotes(prompt, cut), "refused from {prompt:?}");
1391        }
1392    }
1393}