Skip to main content

ljos_cli/
bookkeep.rs

1//! A note written after a commit.
2//!
3//! The claim book is gone. What stays is the guard's notion of a temporary
4//! home, a durable file replace, and one line in the runtime log when a
5//! commit lands.
6
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
9use std::time::Duration;
10
11use serde_json::Value;
12
13static LAST_ACTIVITY: AtomicU64 = AtomicU64::new(0);
14static DURABLE_WRITES: AtomicUsize = AtomicUsize::new(0);
15
16/// Held for the body of a write that replaces a file.
17pub struct DurableWrite(());
18
19impl Drop for DurableWrite {
20    fn drop(&mut self) {
21        DURABLE_WRITES.fetch_sub(1, Ordering::AcqRel);
22    }
23}
24
25/// Mark one write in progress. Drop the guard when the write returns.
26#[must_use]
27pub fn durable_write() -> DurableWrite {
28    DURABLE_WRITES.fetch_add(1, Ordering::AcqRel);
29    DurableWrite(())
30}
31
32/// A [`durable_write`] guard is still held.
33#[must_use]
34pub fn durable_pending() -> bool {
35    DURABLE_WRITES.load(Ordering::Acquire) > 0
36}
37
38/// MCP tools that write a habitat. The book no longer joins them to a claim.
39const MCP_WRITES: &[&str] = &[
40    "ljos_remember",
41    "ljos_prefer",
42    "ljos_forget",
43    "ljos_deed",
44    "ljos_rule",
45    "ljos_vote",
46    "ljos_learn",
47    "ljos_trust",
48    "ljos_persona",
49    "ljos_playbook",
50    "ljos_graded",
51    "ljos_consolidate",
52    "ljos_calibrate",
53    "ljos_habit",
54];
55
56/// Whether this MCP tool writes a habitat.
57#[must_use]
58pub fn mcp_tool_writes(name: &str) -> bool {
59    MCP_WRITES.contains(&name)
60}
61
62/// A write tool runs on its own. The book does not attach it to a claim.
63pub fn before_mcp_write(_name: &str, _args: Option<&serde_json::Map<String, Value>>) {}
64
65/// The server exit no longer finishes claims.
66pub fn mcp_shutdown() {}
67
68/// Note that a tool ran, so the idle watcher waits another window.
69pub fn touch_activity() {
70    LAST_ACTIVITY.store(now_secs(), Ordering::Relaxed);
71}
72
73/// Unix seconds of the last [`touch_activity`], or zero.
74#[must_use]
75pub fn last_activity() -> u64 {
76    LAST_ACTIVITY.load(Ordering::Relaxed)
77}
78
79/// Unix seconds now.
80#[must_use]
81pub fn now_secs() -> u64 {
82    std::time::SystemTime::now()
83        .duration_since(std::time::UNIX_EPOCH)
84        .map(|elapsed| elapsed.as_secs())
85        .unwrap_or(0)
86}
87
88/// Count one tool call in this process.
89pub fn note_mcp_process_call() {}
90
91/// The next process does not finish a claim book.
92pub fn resume_seat() {}
93
94/// How long a silent MCP server sits. Unset is thirty minutes.
95#[must_use]
96pub fn idle_limit() -> Option<Duration> {
97    idle_limit_from(std::env::var("LJOS_MCP_IDLE").ok().as_deref())
98}
99
100/// [`idle_limit`] from an already-read value.
101#[must_use]
102pub fn idle_limit_from(raw: Option<&str>) -> Option<Duration> {
103    let Some(raw) = raw.map(str::trim).filter(|text| !text.is_empty()) else {
104        return Some(Duration::from_secs(30 * 60));
105    };
106    let lowered = raw.to_ascii_lowercase();
107    if matches!(lowered.as_str(), "0" | "off" | "false" | "no") {
108        return None;
109    }
110    match super::reap::parse_idle(raw) {
111        Ok(window) if window.is_zero() => None,
112        Ok(window) => Some(window),
113        Err(_) => Some(Duration::from_secs(30 * 60)),
114    }
115}
116
117/// Whether `now` is at least `limit` past `last`.
118#[must_use]
119pub fn idle_due(now: u64, last: u64, limit: Duration) -> bool {
120    last > 0 && now.saturating_sub(last) >= limit.as_secs()
121}
122
123/// The idle watcher does not finish a claim book.
124pub fn spawn_idle_finish() {}
125
126/// Words a model passes when it does not know the issue id.
127#[must_use]
128pub fn blank_issue(id: &str) -> bool {
129    matches!(
130        id.trim().to_ascii_lowercase().as_str(),
131        "" | "none"
132            | "null"
133            | "nil"
134            | "n/a"
135            | "na"
136            | "undefined"
137            | "unknown"
138            | "-"
139            | "n.a."
140            | "n.a"
141    )
142}
143
144/// The issue `raw` names. A placeholder is not an issue. There is no book
145/// to inherit one from.
146#[must_use]
147pub fn canonical_issue(raw: Option<&str>, _session: Option<&str>) -> Option<String> {
148    raw.map(str::trim)
149        .filter(|id| !blank_issue(id))
150        .map(str::to_string)
151}
152
153/// No claim book, so a session holds no issue.
154#[must_use]
155pub fn session_issue(_session: Option<&str>) -> Option<String> {
156    None
157}
158
159/// No claim book, so nothing is open.
160#[must_use]
161pub fn open_issue() -> Option<String> {
162    None
163}
164
165/// Nothing to finish.
166pub fn note_finished(_issue: &str) {}
167
168/// The book does not file a claim. The task is the note.
169///
170/// # Errors
171///
172/// `task` is empty.
173pub fn start_work(_issue: Option<&str>, _repo: Option<&str>, task: &str) -> Result<String, String> {
174    let task = task.split_whitespace().collect::<Vec<_>>().join(" ");
175    if task.is_empty() {
176        return Err("ljos_start: pass the task in one sentence".into());
177    }
178    log_line(&format!("note: {task}"));
179    Ok(format!("noted: {task}"))
180}
181
182/// Finishes nothing. Bind it so a drop is still a drop.
183pub struct ShutdownFinish;
184
185impl Drop for ShutdownFinish {
186    fn drop(&mut self) {}
187}
188
189/// Append one line to the seat's `book.log`.
190pub fn log_line(line: &str) {
191    let dir = super::runtime_dir();
192    let _ = std::fs::create_dir_all(&dir);
193    let Ok(mut file) = std::fs::OpenOptions::new()
194        .create(true)
195        .append(true)
196        .open(dir.join("book.log"))
197    else {
198        return;
199    };
200    use std::io::Write;
201    let _ = writeln!(file, "{line}");
202}
203
204/// The seat name, or this process, for a caller that still asks.
205#[must_use]
206pub fn seat_session() -> String {
207    std::env::var("LJOS_SEAT")
208        .ok()
209        .map(|raw| raw.trim().to_string())
210        .filter(|raw| !raw.is_empty())
211        .unwrap_or_else(|| std::process::id().to_string())
212}
213
214/// The issue argument when it is not a placeholder. A missing or blank
215/// argument is the issue this seat holds.
216///
217/// # Errors
218///
219/// The argument is a placeholder, or it is blank and this seat holds no issue.
220pub fn note_issue(raw: Option<&str>) -> Result<String, String> {
221    if let Some(id) = canonical_issue(raw, None) {
222        return Ok(id);
223    }
224    super::held_issue().ok_or_else(|| "ljos_note: pass an issue id".into())
225}
226
227/// No claims to list.
228#[must_use]
229pub fn prompt_claims() -> String {
230    String::new()
231}
232
233/// A commit message does not book an issue.
234#[must_use]
235pub fn named_open_issue(_message: &str) -> Option<String> {
236    None
237}
238
239/// The SessionStart brief is not printed. The book does not keep one.
240pub fn print_brief_once() {}
241
242/// Nothing to recall onto a commit.
243#[must_use]
244pub fn commit_recall_line(_session: &str, _repo: &str, _subject: &str) -> Option<String> {
245    None
246}
247
248/// One line after `head` lands in `repo`.
249#[must_use]
250pub fn note_after_commit(repo: &str, head: &str, subject: &str) -> String {
251    let subject = subject.split_whitespace().collect::<Vec<_>>().join(" ");
252    let short = head.chars().take(12).collect::<String>();
253    let line = if subject.is_empty() {
254        format!("note: {short} in {repo}")
255    } else {
256        format!("note: {short} {subject}")
257    };
258    log_line(&line);
259    line
260}
261
262/// Whether the guard should stay out of this process.
263#[must_use]
264pub fn guard_suspended() -> bool {
265    ljos_off() || (temp_home().is_some() && !guard_forced_on())
266}
267
268/// `LJOS_GUARD` set to one of the on-switches.
269#[must_use]
270pub fn guard_forced_on() -> bool {
271    matches!(
272        std::env::var("LJOS_GUARD").ok().as_deref(),
273        Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
274    )
275}
276
277fn ljos_off() -> bool {
278    matches!(
279        std::env::var("LJOS_OFF").ok().as_deref(),
280        Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
281    )
282}
283
284/// Whether `LJOS_OFF` is set to one of its on-switches.
285#[must_use]
286pub fn ljos_is_off() -> bool {
287    ljos_off()
288}
289
290/// `HOME` when it is a temporary directory or sits inside one.
291#[must_use]
292pub fn temp_home() -> Option<PathBuf> {
293    std::env::var_os("HOME")
294        .map(PathBuf::from)
295        .filter(|home| path_is_temp(Some(home.as_path())))
296}
297
298/// Why [`guard_suspended`] holds. `None` when the guard is on.
299#[must_use]
300pub fn guard_off_reason() -> Option<String> {
301    if ljos_off() {
302        return Some("off: LJOS_OFF is set".to_string());
303    }
304    if guard_forced_on() {
305        return None;
306    }
307    temp_home().map(|home| {
308        format!(
309            "off: HOME ({}) is a temporary directory, and ljos stays out of test sandboxes. \
310             `LJOS_GUARD=on` keeps it on",
311            home.display()
312        )
313    })
314}
315
316/// Whether `path` is a temporary directory or sits inside one.
317#[must_use]
318pub fn path_is_temp(path: Option<&Path>) -> bool {
319    let Some(path) = path else {
320        return false;
321    };
322    if path.as_os_str().is_empty() {
323        return false;
324    }
325    let path = canonical_existing_prefix(path);
326    resolved_path_is_temp(&path)
327}
328
329/// `path` with the longest existing prefix resolved, and the missing
330/// tail put back. `canonicalize` of a path that does not exist yet
331/// returns the spelling unchanged, so `/tmp/scratch` would not match a
332/// temp root of `/private/tmp` when `/tmp` is a symlink.
333fn canonical_existing_prefix(path: &Path) -> PathBuf {
334    let mut missing = Vec::new();
335    let mut cur = path.to_path_buf();
336    loop {
337        if let Ok(mut real) = std::fs::canonicalize(&cur) {
338            for name in missing.iter().rev() {
339                real.push(name);
340            }
341            return real;
342        }
343        let Some(name) = cur.file_name().map(|name| name.to_os_string()) else {
344            return path.to_path_buf();
345        };
346        missing.push(name);
347        if !cur.pop() {
348            return path.to_path_buf();
349        }
350    }
351}
352
353/// [`path_is_temp`] for a path already resolved.
354#[must_use]
355pub fn resolved_path_is_temp(path: &Path) -> bool {
356    temp_roots().iter().any(|root| path.starts_with(root))
357}
358
359#[cfg(test)]
360thread_local! {
361    /// Temp roots that stand in for the machine's on one test thread.
362    pub(crate) static TEST_TEMP_ROOTS: std::cell::RefCell<Option<Vec<PathBuf>>> =
363        const { std::cell::RefCell::new(None) };
364}
365
366fn temp_roots() -> Vec<PathBuf> {
367    #[cfg(test)]
368    if let Some(roots) = TEST_TEMP_ROOTS.with(|r| r.borrow().clone()) {
369        return roots;
370    }
371    let mut roots = vec![
372        std::env::temp_dir(),
373        PathBuf::from("/tmp"),
374        PathBuf::from("/var/tmp"),
375        PathBuf::from("/private/tmp"),
376        PathBuf::from("/dev/shm"),
377    ];
378    if let Some(tmp) = std::env::var_os("TMPDIR").filter(|t| !t.is_empty()) {
379        roots.push(PathBuf::from(tmp));
380    }
381    roots
382        .into_iter()
383        .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
384        .collect()
385}
386
387/// Hook events are not booked.
388#[must_use]
389pub fn books(_event: &str) -> bool {
390    false
391}
392
393/// A hook call writes a note only when it is a commit. Nothing else is booked.
394#[must_use]
395pub fn observe(raw: &str, call: &super::HookCall) -> (String, Vec<String>) {
396    if call.event == "PostToolUse" && call.cue.contains("git commit") {
397        let subject = call
398            .cue
399            .split_once("-m ")
400            .map(|(_, rest)| rest.trim().trim_matches('"').to_string())
401            .unwrap_or_default();
402        let repo = serde_json::from_str::<Value>(raw)
403            .ok()
404            .and_then(|v| v.get("cwd").and_then(Value::as_str).map(str::to_string))
405            .unwrap_or_default();
406        let _ = note_after_commit(&repo, "commit", &subject);
407    }
408    (String::new(), Vec::new())
409}
410
411/// Nothing to recall.
412#[must_use]
413pub fn recall_for(_raw: &str, _call: &super::HookCall) -> String {
414    String::new()
415}
416
417pub(crate) type ActingFor = ((u32, u64), Option<String>);
418
419pub(crate) fn act_for(_owner: Option<ActingFor>) {}
420
421pub(crate) fn observe_queued(_raw: &str, _call: &super::HookCall) -> bool {
422    false
423}
424
425pub(crate) fn session_key(call: &super::HookCall) -> String {
426    call.session
427        .as_deref()
428        .map(str::trim)
429        .filter(|s| !s.is_empty())
430        .map(str::to_string)
431        .unwrap_or_else(|| std::process::id().to_string())
432}
433
434pub(crate) fn hook_owner() -> (u32, u64) {
435    (0, 0)
436}
437
438/// Write `bytes` beside `path` and rename them into place.
439pub(crate) fn replace_whole(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
440    use std::io::Write;
441    if let Some(dir) = path.parent() {
442        std::fs::create_dir_all(dir)?;
443    }
444    let dir = path.parent().unwrap_or_else(|| Path::new("."));
445    let stamp = std::time::SystemTime::now()
446        .duration_since(std::time::UNIX_EPOCH)
447        .unwrap_or_default()
448        .as_nanos();
449    let mut tmp_name = path
450        .file_name()
451        .map(|name| name.to_os_string())
452        .unwrap_or_else(|| "note".into());
453    tmp_name.push(format!(".tmp-{}-{stamp}", std::process::id()));
454    let tmp = dir.join(tmp_name);
455    #[cfg(unix)]
456    let mode = existing_mode(path);
457    {
458        let mut options = std::fs::OpenOptions::new();
459        options.create_new(true).write(true);
460        #[cfg(unix)]
461        {
462            use std::os::unix::fs::OpenOptionsExt;
463            options.mode(0o600);
464        }
465        let mut out = options.open(&tmp)?;
466        out.write_all(bytes)?;
467        out.sync_all()?;
468    }
469    #[cfg(unix)]
470    {
471        use std::os::unix::fs::PermissionsExt;
472        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
473    }
474    if let Err(err) = std::fs::rename(&tmp, path) {
475        let _ = std::fs::remove_file(&tmp);
476        return Err(err);
477    }
478    Ok(())
479}
480
481#[cfg(unix)]
482fn existing_mode(path: &Path) -> u32 {
483    use std::os::unix::fs::PermissionsExt;
484    std::fs::symlink_metadata(path)
485        .ok()
486        .filter(|meta| meta.file_type().is_file())
487        .map(|meta| meta.permissions().mode() & 0o777)
488        .unwrap_or(0o600)
489}
490
491/// Read a regular file, at most a megabyte.
492pub(crate) fn read_text_bounded(path: &Path) -> Option<String> {
493    let meta = std::fs::metadata(path).ok()?;
494    if !meta.is_file() || meta.len() > 1_048_576 {
495        return None;
496    }
497    std::fs::read_to_string(path).ok()
498}
499
500#[cfg(test)]
501mod temp_path_tests {
502    use super::*;
503
504    /// A path that is not created yet, under a symlink the temp root
505    /// resolves through, is still inside that root.
506    #[test]
507    fn a_missing_path_under_a_symlinked_temp_is_temporary() {
508        let root = tempfile::tempdir().unwrap();
509        let real = root.path().join("private/tmp");
510        std::fs::create_dir_all(&real).unwrap();
511        let link = root.path().join("tmp");
512        std::os::unix::fs::symlink(&real, &link).unwrap();
513        let canon = std::fs::canonicalize(&link).unwrap();
514        TEST_TEMP_ROOTS.with(|roots| roots.replace(Some(vec![canon.clone()])));
515        let missing = link.join("scratch");
516        assert!(
517            path_is_temp(Some(&missing)),
518            "{} against {}",
519            missing.display(),
520            canon.display()
521        );
522        TEST_TEMP_ROOTS.with(|roots| roots.replace(None));
523    }
524}