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.
215///
216/// # Errors
217///
218/// The argument is missing or a placeholder.
219pub fn note_issue(raw: Option<&str>) -> Result<String, String> {
220    canonical_issue(raw, None).ok_or_else(|| "ljos_note: pass an issue id".into())
221}
222
223/// No claims to list.
224#[must_use]
225pub fn prompt_claims() -> String {
226    String::new()
227}
228
229/// A commit message does not book an issue.
230#[must_use]
231pub fn named_open_issue(_message: &str) -> Option<String> {
232    None
233}
234
235/// A guard ask is not written into a book.
236pub fn note_ask(_raw: &str, _call: &super::HookCall, _reason: &str) {}
237
238/// The SessionStart brief is not printed. The book does not keep one.
239pub fn print_brief_once() {}
240
241/// Nothing to recall onto a commit.
242#[must_use]
243pub fn commit_recall_line(_session: &str, _repo: &str, _subject: &str) -> Option<String> {
244    None
245}
246
247/// One line after `head` lands in `repo`.
248#[must_use]
249pub fn note_after_commit(repo: &str, head: &str, subject: &str) -> String {
250    let subject = subject.split_whitespace().collect::<Vec<_>>().join(" ");
251    let short = head.chars().take(12).collect::<String>();
252    let line = if subject.is_empty() {
253        format!("note: {short} in {repo}")
254    } else {
255        format!("note: {short} {subject}")
256    };
257    log_line(&line);
258    line
259}
260
261/// Whether the guard should stay out of this process.
262#[must_use]
263pub fn guard_suspended() -> bool {
264    ljos_off() || (temp_home().is_some() && !guard_forced_on())
265}
266
267/// `LJOS_GUARD` set to one of the on-switches.
268#[must_use]
269pub fn guard_forced_on() -> bool {
270    matches!(
271        std::env::var("LJOS_GUARD").ok().as_deref(),
272        Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
273    )
274}
275
276fn ljos_off() -> bool {
277    matches!(
278        std::env::var("LJOS_OFF").ok().as_deref(),
279        Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
280    )
281}
282
283/// Whether `LJOS_OFF` is set to one of its on-switches.
284#[must_use]
285pub fn ljos_is_off() -> bool {
286    ljos_off()
287}
288
289/// `HOME` when it is a temporary directory or sits inside one.
290#[must_use]
291pub fn temp_home() -> Option<PathBuf> {
292    std::env::var_os("HOME")
293        .map(PathBuf::from)
294        .filter(|home| path_is_temp(Some(home.as_path())))
295}
296
297/// Why [`guard_suspended`] holds. `None` when the guard is on.
298#[must_use]
299pub fn guard_off_reason() -> Option<String> {
300    if ljos_off() {
301        return Some("off: LJOS_OFF is set".to_string());
302    }
303    if guard_forced_on() {
304        return None;
305    }
306    temp_home().map(|home| {
307        format!(
308            "off: HOME ({}) is a temporary directory, and ljos stays out of test sandboxes. \
309             `LJOS_GUARD=on` keeps it on",
310            home.display()
311        )
312    })
313}
314
315/// Whether `path` is a temporary directory or sits inside one.
316#[must_use]
317pub fn path_is_temp(path: Option<&Path>) -> bool {
318    let Some(path) = path else {
319        return false;
320    };
321    if path.as_os_str().is_empty() {
322        return false;
323    }
324    let path = canonical_existing_prefix(path);
325    resolved_path_is_temp(&path)
326}
327
328/// `path` with the longest existing prefix resolved, and the missing
329/// tail put back. `canonicalize` of a path that does not exist yet
330/// returns the spelling unchanged, so `/tmp/scratch` would not match a
331/// temp root of `/private/tmp` when `/tmp` is a symlink.
332fn canonical_existing_prefix(path: &Path) -> PathBuf {
333    let mut missing = Vec::new();
334    let mut cur = path.to_path_buf();
335    loop {
336        if let Ok(mut real) = std::fs::canonicalize(&cur) {
337            for name in missing.iter().rev() {
338                real.push(name);
339            }
340            return real;
341        }
342        let Some(name) = cur.file_name().map(|name| name.to_os_string()) else {
343            return path.to_path_buf();
344        };
345        missing.push(name);
346        if !cur.pop() {
347            return path.to_path_buf();
348        }
349    }
350}
351
352/// [`path_is_temp`] for a path already resolved.
353#[must_use]
354pub fn resolved_path_is_temp(path: &Path) -> bool {
355    temp_roots().iter().any(|root| path.starts_with(root))
356}
357
358#[cfg(test)]
359thread_local! {
360    /// Temp roots that stand in for the machine's on one test thread.
361    pub(crate) static TEST_TEMP_ROOTS: std::cell::RefCell<Option<Vec<PathBuf>>> =
362        const { std::cell::RefCell::new(None) };
363}
364
365fn temp_roots() -> Vec<PathBuf> {
366    #[cfg(test)]
367    if let Some(roots) = TEST_TEMP_ROOTS.with(|r| r.borrow().clone()) {
368        return roots;
369    }
370    let mut roots = vec![
371        std::env::temp_dir(),
372        PathBuf::from("/tmp"),
373        PathBuf::from("/var/tmp"),
374        PathBuf::from("/private/tmp"),
375        PathBuf::from("/dev/shm"),
376    ];
377    if let Some(tmp) = std::env::var_os("TMPDIR").filter(|t| !t.is_empty()) {
378        roots.push(PathBuf::from(tmp));
379    }
380    roots
381        .into_iter()
382        .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
383        .collect()
384}
385
386/// Hook events are not booked.
387#[must_use]
388pub fn books(_event: &str) -> bool {
389    false
390}
391
392/// A hook call writes a note only when it is a commit. Nothing else is booked.
393#[must_use]
394pub fn observe(raw: &str, call: &super::HookCall) -> (String, Vec<String>) {
395    if call.event == "PostToolUse" && call.cue.contains("git commit") {
396        let subject = call
397            .cue
398            .split_once("-m ")
399            .map(|(_, rest)| rest.trim().trim_matches('"').to_string())
400            .unwrap_or_default();
401        let repo = serde_json::from_str::<Value>(raw)
402            .ok()
403            .and_then(|v| v.get("cwd").and_then(Value::as_str).map(str::to_string))
404            .unwrap_or_default();
405        let _ = note_after_commit(&repo, "commit", &subject);
406    }
407    (String::new(), Vec::new())
408}
409
410/// Nothing to recall.
411#[must_use]
412pub fn recall_for(_raw: &str, _call: &super::HookCall) -> String {
413    String::new()
414}
415
416pub(crate) type ActingFor = ((u32, u64), Option<String>);
417
418pub(crate) fn act_for(_owner: Option<ActingFor>) {}
419
420pub(crate) fn observe_queued(_raw: &str, _call: &super::HookCall) -> bool {
421    false
422}
423
424pub(crate) fn session_key(call: &super::HookCall) -> String {
425    call.session
426        .as_deref()
427        .map(str::trim)
428        .filter(|s| !s.is_empty())
429        .map(str::to_string)
430        .unwrap_or_else(|| std::process::id().to_string())
431}
432
433pub(crate) fn hook_owner() -> (u32, u64) {
434    (0, 0)
435}
436
437pub(crate) fn note_ask_within(_raw: &str, _call: &super::HookCall, _reason: &str) -> bool {
438    true
439}
440
441/// Write `bytes` beside `path` and rename them into place.
442pub(crate) fn replace_whole(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
443    use std::io::Write;
444    if let Some(dir) = path.parent() {
445        std::fs::create_dir_all(dir)?;
446    }
447    let dir = path.parent().unwrap_or_else(|| Path::new("."));
448    let stamp = std::time::SystemTime::now()
449        .duration_since(std::time::UNIX_EPOCH)
450        .unwrap_or_default()
451        .as_nanos();
452    let mut tmp_name = path
453        .file_name()
454        .map(|name| name.to_os_string())
455        .unwrap_or_else(|| "note".into());
456    tmp_name.push(format!(".tmp-{}-{stamp}", std::process::id()));
457    let tmp = dir.join(tmp_name);
458    #[cfg(unix)]
459    let mode = existing_mode(path);
460    {
461        let mut options = std::fs::OpenOptions::new();
462        options.create_new(true).write(true);
463        #[cfg(unix)]
464        {
465            use std::os::unix::fs::OpenOptionsExt;
466            options.mode(0o600);
467        }
468        let mut out = options.open(&tmp)?;
469        out.write_all(bytes)?;
470        out.sync_all()?;
471    }
472    #[cfg(unix)]
473    {
474        use std::os::unix::fs::PermissionsExt;
475        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
476    }
477    if let Err(err) = std::fs::rename(&tmp, path) {
478        let _ = std::fs::remove_file(&tmp);
479        return Err(err);
480    }
481    Ok(())
482}
483
484#[cfg(unix)]
485fn existing_mode(path: &Path) -> u32 {
486    use std::os::unix::fs::PermissionsExt;
487    std::fs::symlink_metadata(path)
488        .ok()
489        .filter(|meta| meta.file_type().is_file())
490        .map(|meta| meta.permissions().mode() & 0o777)
491        .unwrap_or(0o600)
492}
493
494/// Read a regular file, at most a megabyte.
495pub(crate) fn read_text_bounded(path: &Path) -> Option<String> {
496    let meta = std::fs::metadata(path).ok()?;
497    if !meta.is_file() || meta.len() > 1_048_576 {
498        return None;
499    }
500    std::fs::read_to_string(path).ok()
501}
502
503#[cfg(test)]
504mod temp_path_tests {
505    use super::*;
506
507    /// A path that is not created yet, under a symlink the temp root
508    /// resolves through, is still inside that root.
509    #[test]
510    fn a_missing_path_under_a_symlinked_temp_is_temporary() {
511        let root = tempfile::tempdir().unwrap();
512        let real = root.path().join("private/tmp");
513        std::fs::create_dir_all(&real).unwrap();
514        let link = root.path().join("tmp");
515        std::os::unix::fs::symlink(&real, &link).unwrap();
516        let canon = std::fs::canonicalize(&link).unwrap();
517        TEST_TEMP_ROOTS.with(|roots| roots.replace(Some(vec![canon.clone()])));
518        let missing = link.join("scratch");
519        assert!(
520            path_is_temp(Some(&missing)),
521            "{} against {}",
522            missing.display(),
523            canon.display()
524        );
525        TEST_TEMP_ROOTS.with(|roots| roots.replace(None));
526    }
527}