1use 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
16pub struct DurableWrite(());
18
19impl Drop for DurableWrite {
20 fn drop(&mut self) {
21 DURABLE_WRITES.fetch_sub(1, Ordering::AcqRel);
22 }
23}
24
25#[must_use]
27pub fn durable_write() -> DurableWrite {
28 DURABLE_WRITES.fetch_add(1, Ordering::AcqRel);
29 DurableWrite(())
30}
31
32#[must_use]
34pub fn durable_pending() -> bool {
35 DURABLE_WRITES.load(Ordering::Acquire) > 0
36}
37
38const 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#[must_use]
58pub fn mcp_tool_writes(name: &str) -> bool {
59 MCP_WRITES.contains(&name)
60}
61
62pub fn before_mcp_write(_name: &str, _args: Option<&serde_json::Map<String, Value>>) {}
64
65pub fn mcp_shutdown() {}
67
68pub fn touch_activity() {
70 LAST_ACTIVITY.store(now_secs(), Ordering::Relaxed);
71}
72
73#[must_use]
75pub fn last_activity() -> u64 {
76 LAST_ACTIVITY.load(Ordering::Relaxed)
77}
78
79#[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
88pub fn note_mcp_process_call() {}
90
91pub fn resume_seat() {}
93
94#[must_use]
96pub fn idle_limit() -> Option<Duration> {
97 idle_limit_from(std::env::var("LJOS_MCP_IDLE").ok().as_deref())
98}
99
100#[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#[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
123pub fn spawn_idle_finish() {}
125
126#[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#[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#[must_use]
155pub fn session_issue(_session: Option<&str>) -> Option<String> {
156 None
157}
158
159#[must_use]
161pub fn open_issue() -> Option<String> {
162 None
163}
164
165pub fn note_finished(_issue: &str) {}
167
168pub 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
182pub struct ShutdownFinish;
184
185impl Drop for ShutdownFinish {
186 fn drop(&mut self) {}
187}
188
189pub 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#[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
214pub 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#[must_use]
225pub fn prompt_claims() -> String {
226 String::new()
227}
228
229#[must_use]
231pub fn named_open_issue(_message: &str) -> Option<String> {
232 None
233}
234
235pub fn note_ask(_raw: &str, _call: &super::HookCall, _reason: &str) {}
237
238pub fn print_brief_once() {}
240
241#[must_use]
243pub fn commit_recall_line(_session: &str, _repo: &str, _subject: &str) -> Option<String> {
244 None
245}
246
247#[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#[must_use]
263pub fn guard_suspended() -> bool {
264 ljos_off() || (temp_home().is_some() && !guard_forced_on())
265}
266
267#[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#[must_use]
285pub fn ljos_is_off() -> bool {
286 ljos_off()
287}
288
289#[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#[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#[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 = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
325 resolved_path_is_temp(&path)
326}
327
328#[must_use]
330pub fn resolved_path_is_temp(path: &Path) -> bool {
331 temp_roots().iter().any(|root| path.starts_with(root))
332}
333
334#[cfg(test)]
335thread_local! {
336 pub(crate) static TEST_TEMP_ROOTS: std::cell::RefCell<Option<Vec<PathBuf>>> =
338 const { std::cell::RefCell::new(None) };
339}
340
341fn temp_roots() -> Vec<PathBuf> {
342 #[cfg(test)]
343 if let Some(roots) = TEST_TEMP_ROOTS.with(|r| r.borrow().clone()) {
344 return roots;
345 }
346 let mut roots = vec![
347 std::env::temp_dir(),
348 PathBuf::from("/tmp"),
349 PathBuf::from("/var/tmp"),
350 PathBuf::from("/private/tmp"),
351 PathBuf::from("/dev/shm"),
352 ];
353 if let Some(tmp) = std::env::var_os("TMPDIR").filter(|t| !t.is_empty()) {
354 roots.push(PathBuf::from(tmp));
355 }
356 roots
357 .into_iter()
358 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
359 .collect()
360}
361
362#[must_use]
364pub fn books(_event: &str) -> bool {
365 false
366}
367
368#[must_use]
370pub fn observe(raw: &str, call: &super::HookCall) -> (String, Vec<String>) {
371 if call.event == "PostToolUse" && call.cue.contains("git commit") {
372 let subject = call
373 .cue
374 .split_once("-m ")
375 .map(|(_, rest)| rest.trim().trim_matches('"').to_string())
376 .unwrap_or_default();
377 let repo = serde_json::from_str::<Value>(raw)
378 .ok()
379 .and_then(|v| v.get("cwd").and_then(Value::as_str).map(str::to_string))
380 .unwrap_or_default();
381 let _ = note_after_commit(&repo, "commit", &subject);
382 }
383 (String::new(), Vec::new())
384}
385
386#[must_use]
388pub fn recall_for(_raw: &str, _call: &super::HookCall) -> String {
389 String::new()
390}
391
392pub(crate) type ActingFor = ((u32, u64), Option<String>);
393
394pub(crate) fn act_for(_owner: Option<ActingFor>) {}
395
396pub(crate) fn observe_queued(_raw: &str, _call: &super::HookCall) -> bool {
397 false
398}
399
400pub(crate) fn session_key(call: &super::HookCall) -> String {
401 call.session
402 .as_deref()
403 .map(str::trim)
404 .filter(|s| !s.is_empty())
405 .map(str::to_string)
406 .unwrap_or_else(|| std::process::id().to_string())
407}
408
409pub(crate) fn hook_owner() -> (u32, u64) {
410 (0, 0)
411}
412
413pub(crate) fn note_ask_within(_raw: &str, _call: &super::HookCall, _reason: &str) -> bool {
414 true
415}
416
417pub(crate) fn replace_whole(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
419 use std::io::Write;
420 if let Some(dir) = path.parent() {
421 std::fs::create_dir_all(dir)?;
422 }
423 let dir = path.parent().unwrap_or_else(|| Path::new("."));
424 let stamp = std::time::SystemTime::now()
425 .duration_since(std::time::UNIX_EPOCH)
426 .unwrap_or_default()
427 .as_nanos();
428 let mut tmp_name = path
429 .file_name()
430 .map(|name| name.to_os_string())
431 .unwrap_or_else(|| "note".into());
432 tmp_name.push(format!(".tmp-{}-{stamp}", std::process::id()));
433 let tmp = dir.join(tmp_name);
434 #[cfg(unix)]
435 let mode = existing_mode(path);
436 {
437 let mut options = std::fs::OpenOptions::new();
438 options.create_new(true).write(true);
439 #[cfg(unix)]
440 {
441 use std::os::unix::fs::OpenOptionsExt;
442 options.mode(0o600);
443 }
444 let mut out = options.open(&tmp)?;
445 out.write_all(bytes)?;
446 out.sync_all()?;
447 }
448 #[cfg(unix)]
449 {
450 use std::os::unix::fs::PermissionsExt;
451 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
452 }
453 if let Err(err) = std::fs::rename(&tmp, path) {
454 let _ = std::fs::remove_file(&tmp);
455 return Err(err);
456 }
457 Ok(())
458}
459
460#[cfg(unix)]
461fn existing_mode(path: &Path) -> u32 {
462 use std::os::unix::fs::PermissionsExt;
463 std::fs::symlink_metadata(path)
464 .ok()
465 .filter(|meta| meta.file_type().is_file())
466 .map(|meta| meta.permissions().mode() & 0o777)
467 .unwrap_or(0o600)
468}
469
470pub(crate) fn read_text_bounded(path: &Path) -> Option<String> {
472 let meta = std::fs::metadata(path).ok()?;
473 if !meta.is_file() || meta.len() > 1_048_576 {
474 return None;
475 }
476 std::fs::read_to_string(path).ok()
477}