Skip to main content

caboodle/
harness_hooks.rs

1//! The query-first hooks for Claude Code and Codex (aegis-wwl).
2//!
3//! A shantytown crew gets its query-first directive from the role settings st
4//! launches it with. Anyone else who installs the stack runs plain `claude` or
5//! `codex`, which read only the user's own settings, so until now they got no
6//! query-first block at all. `tambo hooks install` adds two tambo-owned hooks
7//! (SessionStart and UserPromptSubmit) to `~/.claude/settings.json` and
8//! `~/.codex/config.toml`. Each one runs `tambo hook query-first`, which prints
9//! the directive for the configured Quipu and appends one line to a local usage
10//! counter.
11//!
12//! Install keeps every existing hook and is idempotent: a second run writes
13//! nothing. Before the first change it saves the file's exact bytes (or that it
14//! was absent). Uninstall puts those bytes back when the file still holds only
15//! what install wrote. If the file was edited since then, uninstall removes only
16//! the tambo entries and says so.
17
18use std::{
19    env, fs,
20    io::{Read, Write},
21    os::unix::fs::{OpenOptionsExt, PermissionsExt},
22    path::{Path, PathBuf},
23    time::{SystemTime, UNIX_EPOCH},
24};
25
26use anyhow::{bail, Context, Result};
27use serde_json::{json, Value};
28use sha2::{Digest, Sha256};
29
30use crate::doctor::{Finding, Level};
31
32/// The two events that carry the directive.
33pub const EVENTS: [&str; 2] = ["SessionStart", "UserPromptSubmit"];
34/// Every tambo-owned hook command contains this, and no other hook does.
35const MARK: &str = " hook query-first --harness ";
36const TIMEOUT_SECONDS: i64 = 5;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Harness {
40    Claude,
41    Codex,
42}
43
44impl Harness {
45    pub const ALL: [Harness; 2] = [Harness::Claude, Harness::Codex];
46
47    pub fn name(self) -> &'static str {
48        match self {
49            Harness::Claude => "claude",
50            Harness::Codex => "codex",
51        }
52    }
53
54    pub fn parse(name: &str) -> Result<Self> {
55        match name {
56            "claude" => Ok(Harness::Claude),
57            "codex" => Ok(Harness::Codex),
58            other => bail!("unknown harness {other:?}; expected claude or codex"),
59        }
60    }
61}
62
63/// Where the harness settings and tambo's hook state live.
64#[derive(Debug, Clone)]
65pub struct Paths {
66    pub claude_settings: PathBuf,
67    pub codex_config: PathBuf,
68    pub state: PathBuf,
69}
70
71impl Paths {
72    /// `$CLAUDE_CONFIG_DIR` (default `~/.claude`), `$CODEX_HOME` (default
73    /// `~/.codex`) and `$XDG_STATE_HOME/tambo/agent-hooks` (default
74    /// `~/.local/state/...`), the same places the harnesses themselves read.
75    pub fn from_env() -> Result<Self> {
76        let home = env::var_os("HOME")
77            .map(PathBuf::from)
78            .context("HOME is not set")?;
79        let dir = |var: &str, default: PathBuf| {
80            env::var_os(var)
81                .filter(|v| !v.is_empty())
82                .map(PathBuf::from)
83                .unwrap_or(default)
84        };
85        Ok(Self {
86            claude_settings: dir("CLAUDE_CONFIG_DIR", home.join(".claude")).join("settings.json"),
87            codex_config: dir("CODEX_HOME", home.join(".codex")).join("config.toml"),
88            state: dir("XDG_STATE_HOME", home.join(".local/state"))
89                .join("tambo")
90                .join("agent-hooks"),
91        })
92    }
93
94    fn settings(&self, harness: Harness) -> &Path {
95        match harness {
96            Harness::Claude => &self.claude_settings,
97            Harness::Codex => &self.codex_config,
98        }
99    }
100
101    fn before(&self, harness: Harness) -> PathBuf {
102        self.state.join(format!("{}.before", harness.name()))
103    }
104
105    fn absent_marker(&self, harness: Harness) -> PathBuf {
106        self.state.join(format!("{}.before-absent", harness.name()))
107    }
108
109    fn after_digest(&self, harness: Harness) -> PathBuf {
110        self.state.join(format!("{}.after.sha256", harness.name()))
111    }
112
113    fn config(&self) -> PathBuf {
114        self.state.join("config.json")
115    }
116
117    pub fn usage(&self) -> PathBuf {
118        self.state.join("usage.jsonl")
119    }
120}
121
122/// Which Quipu the directive names. Saved at install so the hook needs no plan.
123#[derive(Debug, Clone, Default, PartialEq, Eq)]
124pub struct Target {
125    /// Absolute path of the plan's local store.
126    pub db: Option<PathBuf>,
127}
128
129fn quote(value: &str) -> String {
130    format!("'{}'", value.replace('\'', "'\"'\"'"))
131}
132
133/// The exact hook command for one harness and event.
134pub fn command(tambo: &Path, harness: Harness, event: &str) -> String {
135    format!(
136        "{}{MARK}{} --event {event}",
137        quote(&tambo.display().to_string()),
138        harness.name()
139    )
140}
141
142fn is_ours(command: &str) -> bool {
143    command.contains(MARK)
144}
145
146fn digest(bytes: &[u8]) -> String {
147    format!("{:x}", Sha256::digest(bytes))
148}
149
150fn read_optional(path: &Path) -> Result<Option<Vec<u8>>> {
151    match fs::read(path) {
152        Ok(bytes) => Ok(Some(bytes)),
153        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
154        Err(e) => Err(e).with_context(|| format!("read {}", path.display())),
155    }
156}
157
158/// Replace `path` atomically, keeping an existing file's mode (new files 0600).
159fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
160    let parent = path.parent().context("settings path has no parent")?;
161    fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
162    let mode = fs::metadata(path)
163        .map(|m| m.permissions().mode() & 0o7777)
164        .unwrap_or(0o600);
165    let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
166    tmp.write_all(bytes)?;
167    tmp.as_file().sync_all()?;
168    fs::set_permissions(tmp.path(), fs::Permissions::from_mode(mode))?;
169    tmp.persist(path)
170        .with_context(|| format!("replace {}", path.display()))?;
171    Ok(())
172}
173
174fn private_write(path: &Path, bytes: &[u8]) -> Result<()> {
175    fs::create_dir_all(path.parent().context("state path has no parent")?)?;
176    let mut tmp = tempfile::NamedTempFile::new_in(path.parent().unwrap())?;
177    tmp.write_all(bytes)?;
178    fs::set_permissions(tmp.path(), fs::Permissions::from_mode(0o600))?;
179    tmp.persist(path)?;
180    Ok(())
181}
182
183// --- Claude Code: settings.json -----------------------------------------------
184
185/// JSON that keeps the user's key order. serde_json's `Value` sorts object keys
186/// (this crate does not enable `preserve_order`, and must not: other modules
187/// hash serialized JSON), so editing through it would reorder the whole file.
188#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
189#[serde(untagged)]
190enum J {
191    Null,
192    Bool(bool),
193    Number(serde_json::Number),
194    String(String),
195    Array(Vec<J>),
196    Object(indexmap::IndexMap<String, J>),
197}
198
199impl J {
200    fn get(&self, key: &str) -> Option<&J> {
201        match self {
202            J::Object(map) => map.get(key),
203            _ => None,
204        }
205    }
206    fn get_mut(&mut self, key: &str) -> Option<&mut J> {
207        match self {
208            J::Object(map) => map.get_mut(key),
209            _ => None,
210        }
211    }
212    fn array(&self) -> Option<&Vec<J>> {
213        match self {
214            J::Array(items) => Some(items),
215            _ => None,
216        }
217    }
218    fn array_mut(&mut self) -> Option<&mut Vec<J>> {
219        match self {
220            J::Array(items) => Some(items),
221            _ => None,
222        }
223    }
224    fn object_mut(&mut self) -> Option<&mut indexmap::IndexMap<String, J>> {
225        match self {
226            J::Object(map) => Some(map),
227            _ => None,
228        }
229    }
230    fn str(&self) -> Option<&str> {
231        match self {
232            J::String(s) => Some(s),
233            _ => None,
234        }
235    }
236}
237
238fn claude_parse(bytes: Option<&[u8]>) -> Result<J> {
239    let empty = || J::Object(indexmap::IndexMap::new());
240    match bytes {
241        None => Ok(empty()),
242        Some(b) if b.iter().all(u8::is_ascii_whitespace) => Ok(empty()),
243        Some(b) => {
244            let v: J = serde_json::from_slice(b).context(
245                "Claude settings.json is not valid JSON; fix it first, nothing was written",
246            )?;
247            if !matches!(v, J::Object(_)) {
248                bail!("Claude settings.json is not a JSON object; nothing was written");
249            }
250            Ok(v)
251        }
252    }
253}
254
255fn claude_serialize(settings: &J) -> Result<Vec<u8>> {
256    let mut out = serde_json::to_vec_pretty(settings)?;
257    out.push(b'\n');
258    Ok(out)
259}
260
261/// `(group, hook, command)` for every hook under one event.
262fn claude_hooks(settings: &J, event: &str) -> Vec<(usize, usize, String)> {
263    let mut found = Vec::new();
264    let groups = settings
265        .get("hooks")
266        .and_then(|h| h.get(event))
267        .and_then(J::array);
268    for (g, group) in groups.into_iter().flatten().enumerate() {
269        for (h, hook) in group
270            .get("hooks")
271            .and_then(J::array)
272            .into_iter()
273            .flatten()
274            .enumerate()
275        {
276            if let Some(cmd) = hook.get("command").and_then(J::str) {
277                found.push((g, h, cmd.to_string()));
278            }
279        }
280    }
281    found
282}
283
284fn claude_commands(settings: &J, event: &str) -> Vec<String> {
285    claude_hooks(settings, event)
286        .into_iter()
287        .map(|(_, _, c)| c)
288        .collect()
289}
290
291/// Drop tambo hooks (all of them, or all but the first per event when
292/// `keep_first`), then any group or event left empty by that.
293fn claude_remove(settings: &mut J, keep_first: bool) {
294    let Some(hooks) = settings.get_mut("hooks").and_then(J::object_mut) else {
295        return;
296    };
297    for event in EVENTS {
298        let Some(groups) = hooks.get_mut(event).and_then(J::array_mut) else {
299            continue;
300        };
301        let mut kept = !keep_first;
302        for group in groups.iter_mut() {
303            if let Some(list) = group.get_mut("hooks").and_then(J::array_mut) {
304                list.retain(|h| {
305                    if !h.get("command").and_then(J::str).is_some_and(is_ours) {
306                        return true;
307                    }
308                    if kept {
309                        return false;
310                    }
311                    kept = true;
312                    true
313                });
314            }
315        }
316        groups.retain(|g| !g.get("hooks").and_then(J::array).is_some_and(Vec::is_empty));
317        if groups.is_empty() {
318            hooks.shift_remove(event);
319        }
320    }
321    if hooks.is_empty() {
322        settings.object_mut().unwrap().shift_remove("hooks");
323    }
324}
325
326fn claude_render(current: Option<&[u8]>, wanted: &[(String, String)]) -> Result<Option<Vec<u8>>> {
327    let mut settings = claude_parse(current)?;
328    let original = settings.clone();
329    claude_remove(&mut settings, true);
330    for (event, cmd) in wanted {
331        let ours = claude_hooks(&settings, event)
332            .into_iter()
333            .find(|(_, _, c)| is_ours(c));
334        if let Some((g, h, existing)) = ours {
335            // Replace in place: the hook keeps its position among the user's.
336            if existing != *cmd {
337                let hook = &mut settings
338                    .get_mut("hooks")
339                    .unwrap()
340                    .get_mut(event)
341                    .unwrap()
342                    .array_mut()
343                    .unwrap()[g]
344                    .get_mut("hooks")
345                    .unwrap()
346                    .array_mut()
347                    .unwrap()[h];
348                *hook.get_mut("command").unwrap() = J::String(cmd.clone());
349            }
350            continue;
351        }
352        let object = settings.object_mut().unwrap();
353        let hooks = object
354            .entry("hooks".to_string())
355            .or_insert_with(|| J::Object(indexmap::IndexMap::new()))
356            .object_mut()
357            .context("Claude settings.json `hooks` is not an object; nothing was written")?;
358        let group: J = serde_json::from_value(
359            json!({"hooks":[{"type":"command","command":cmd,"timeout":TIMEOUT_SECONDS}]}),
360        )?;
361        hooks
362            .entry(event.clone())
363            .or_insert_with(|| J::Array(Vec::new()))
364            .array_mut()
365            .with_context(|| format!("Claude settings.json hooks.{event} is not a list"))?
366            .push(group);
367    }
368    if settings == original {
369        return Ok(None);
370    }
371    Ok(Some(claude_serialize(&settings)?))
372}
373
374fn claude_strip(current: &[u8]) -> Result<Vec<u8>> {
375    let mut settings = claude_parse(Some(current))?;
376    claude_remove(&mut settings, false);
377    claude_serialize(&settings)
378}
379
380// --- Codex: config.toml -------------------------------------------------------
381
382fn codex_parse(bytes: Option<&[u8]>) -> Result<toml_edit::DocumentMut> {
383    let text = match bytes {
384        None => String::new(),
385        Some(b) => String::from_utf8(b.to_vec()).context("Codex config.toml is not UTF-8")?,
386    };
387    text.parse::<toml_edit::DocumentMut>()
388        .context("Codex config.toml is not valid TOML; fix it first, nothing was written")
389}
390
391fn codex_commands(doc: &toml_edit::DocumentMut, event: &str) -> Vec<String> {
392    let Some(groups) = doc
393        .get("hooks")
394        .and_then(|h| h.get(event))
395        .and_then(toml_edit::Item::as_array_of_tables)
396    else {
397        return Vec::new();
398    };
399    groups
400        .iter()
401        .filter_map(|g| g.get("hooks").and_then(toml_edit::Item::as_array_of_tables))
402        .flat_map(|list| list.iter())
403        .filter_map(|h| {
404            h.get("command")
405                .and_then(|c| c.as_str())
406                .map(str::to_string)
407        })
408        .collect()
409}
410
411fn snake(event: &str) -> &'static str {
412    match event {
413        "SessionStart" => "session_start",
414        _ => "user_prompt_submit",
415    }
416}
417
418/// The names Codex may have recorded this config under: the path as given and,
419/// when different, resolved (macOS /var -> /private/var).
420fn trust_names(config: &Path) -> Vec<String> {
421    let mut names = vec![config.display().to_string()];
422    if let Ok(real) = fs::canonicalize(config) {
423        let real = real.display().to_string();
424        if !names.contains(&real) {
425            names.push(real);
426        }
427    }
428    names
429}
430
431/// `(group, hook, is_ours)` for every hook under one event, in file order.
432/// Codex counts every hook entry, so positions here include the user's.
433fn codex_positions(doc: &toml_edit::DocumentMut, event: &str) -> Vec<(usize, usize, bool)> {
434    let mut found = Vec::new();
435    let Some(groups) = doc
436        .get("hooks")
437        .and_then(|h| h.get(event))
438        .and_then(toml_edit::Item::as_array_of_tables)
439    else {
440        return found;
441    };
442    for (g, group) in groups.iter().enumerate() {
443        let Some(list) = group
444            .get("hooks")
445            .and_then(toml_edit::Item::as_array_of_tables)
446        else {
447            continue;
448        };
449        for (h, hook) in list.iter().enumerate() {
450            let ours = hook
451                .get("command")
452                .and_then(|c| c.as_str())
453                .is_some_and(is_ours);
454            found.push((g, h, ours));
455        }
456    }
457    found
458}
459
460/// Codex keys its hook-trust ledger (`[hooks.state]`) by
461/// `<config path>:<event>:<group>:<hook>`. These are the keys of our hooks.
462fn codex_trust_keys(doc: &toml_edit::DocumentMut, config: &Path) -> Vec<String> {
463    let names = trust_names(config);
464    let mut keys = Vec::new();
465    for event in EVENTS {
466        for (g, h, ours) in codex_positions(doc, event) {
467            if ours {
468                for name in &names {
469                    keys.push(format!("{name}:{}:{g}:{h}", snake(event)));
470                }
471            }
472        }
473    }
474    keys
475}
476
477/// Whether Codex has recorded trust for every one of our hooks.
478fn codex_trusted(doc: &toml_edit::DocumentMut, config: &Path) -> bool {
479    let state = doc.get("hooks").and_then(|h| h.get("state"));
480    let keys = codex_trust_keys(doc, config);
481    EVENTS.iter().all(|e| {
482        keys.iter()
483            .filter(|k| k.contains(&format!(":{}:", snake(e))))
484            .any(|k| state.and_then(|s| s.get(k)).is_some())
485    })
486}
487
488/// Drop tambo hooks (all, or all but the first per event when `keep_first`).
489///
490/// Codex's trust keys are positional, so removing a hook shifts every later
491/// hook of that event. Each shifted hook's trust entry moves to its new key, so
492/// the user's own hooks stay trusted; only the removed hooks' entries go.
493fn codex_remove(doc: &mut toml_edit::DocumentMut, config: &Path, keep_first: bool) {
494    let names = trust_names(config);
495    let mut moves: Vec<(String, Option<String>)> = Vec::new(); // old key -> new key (None = drop)
496    for event in EVENTS {
497        let positions = codex_positions(doc, event);
498        let mut kept_first = !keep_first;
499        let keep: Vec<bool> = positions
500            .iter()
501            .map(|&(_, _, ours)| {
502                if !ours {
503                    return true;
504                }
505                if kept_first {
506                    return false;
507                }
508                kept_first = true;
509                true
510            })
511            .collect();
512        // A group is dropped only when it had hooks and all of them go (the same
513        // rule as the retain below); its new index counts the groups kept before it.
514        let groups = doc
515            .get("hooks")
516            .and_then(|h| h.get(event))
517            .and_then(toml_edit::Item::as_array_of_tables)
518            .map_or(0, toml_edit::ArrayOfTables::len);
519        let mut new_group = Vec::with_capacity(groups);
520        let mut next = 0;
521        for g in 0..groups {
522            let mine: Vec<bool> = positions
523                .iter()
524                .zip(&keep)
525                .filter(|(p, _)| p.0 == g)
526                .map(|(_, k)| *k)
527                .collect();
528            if !mine.is_empty() && mine.iter().all(|k| !k) {
529                new_group.push(None);
530            } else {
531                new_group.push(Some(next));
532                next += 1;
533            }
534        }
535        let mut next_hook: std::collections::HashMap<usize, usize> = Default::default();
536        for (&(g, h, _), &k) in positions.iter().zip(&keep) {
537            let new = if k {
538                let ng = new_group[g].expect("a group with a kept hook is kept");
539                let slot = next_hook.entry(ng).or_insert(0);
540                let nh = *slot;
541                *slot += 1;
542                Some((ng, nh))
543            } else {
544                None
545            };
546            if new == Some((g, h)) {
547                continue;
548            }
549            for name in &names {
550                let old = format!("{name}:{}:{g}:{h}", snake(event));
551                let target = new.map(|(ng, nh)| format!("{name}:{}:{ng}:{nh}", snake(event)));
552                moves.push((old, target));
553            }
554        }
555    }
556    let Some(hooks) = doc
557        .get_mut("hooks")
558        .and_then(toml_edit::Item::as_table_like_mut)
559    else {
560        return;
561    };
562    if let Some(state) = hooks
563        .get_mut("state")
564        .and_then(toml_edit::Item::as_table_like_mut)
565    {
566        // Take every affected entry out first, so a move never lands on a key
567        // that another move is about to vacate.
568        let mut taken = Vec::new();
569        for (old, target) in &moves {
570            if let Some(item) = state.remove(old) {
571                if let Some(target) = target {
572                    taken.push((target.clone(), item));
573                }
574            }
575        }
576        for (target, item) in taken {
577            state.insert(&target, item);
578        }
579        if state.is_empty() {
580            hooks.remove("state");
581        }
582    }
583    for event in EVENTS {
584        let Some(groups) = hooks
585            .get_mut(event)
586            .and_then(toml_edit::Item::as_array_of_tables_mut)
587        else {
588            continue;
589        };
590        let mut kept_first = !keep_first;
591        for group in groups.iter_mut() {
592            if let Some(list) = group
593                .get_mut("hooks")
594                .and_then(toml_edit::Item::as_array_of_tables_mut)
595            {
596                list.retain(|h| {
597                    if !h
598                        .get("command")
599                        .and_then(|c| c.as_str())
600                        .is_some_and(is_ours)
601                    {
602                        return true;
603                    }
604                    if kept_first {
605                        return false;
606                    }
607                    kept_first = true;
608                    true
609                });
610            }
611        }
612        groups.retain(|g| {
613            !g.get("hooks")
614                .and_then(toml_edit::Item::as_array_of_tables)
615                .is_some_and(|l| l.is_empty())
616        });
617        if groups.is_empty() {
618            hooks.remove(event);
619        }
620    }
621    if hooks.is_empty() {
622        doc.remove("hooks");
623    }
624}
625
626fn codex_render(
627    current: Option<&[u8]>,
628    wanted: &[(String, String)],
629    config: &Path,
630) -> Result<Option<Vec<u8>>> {
631    let mut doc = codex_parse(current)?;
632    let original = doc.to_string();
633    codex_remove(&mut doc, config, true);
634    for (event, cmd) in wanted {
635        let ours = codex_positions(&doc, event).into_iter().find(|p| p.2);
636        if let Some((g, h, _)) = ours {
637            // Replace in place, so no other hook's position (or trust key) moves.
638            let hook = &mut doc["hooks"][event.as_str()]
639                .as_array_of_tables_mut()
640                .unwrap()
641                .get_mut(g)
642                .unwrap()["hooks"]
643                .as_array_of_tables_mut()
644                .unwrap()
645                .get_mut(h)
646                .unwrap();
647            if hook.get("command").and_then(|c| c.as_str()) != Some(cmd.as_str()) {
648                hook.insert("command", toml_edit::value(cmd.as_str()));
649            }
650            continue;
651        }
652        if doc.get("hooks").is_none() {
653            let mut table = toml_edit::Table::new();
654            table.set_implicit(true);
655            doc.insert("hooks", toml_edit::Item::Table(table));
656        }
657        let hooks = doc["hooks"]
658            .as_table_mut()
659            .context("Codex config.toml `hooks` is not a table; nothing was written")?;
660        let mut hook = toml_edit::Table::new();
661        hook.insert("type", toml_edit::value("command"));
662        hook.insert("command", toml_edit::value(cmd.as_str()));
663        hook.insert("timeout", toml_edit::value(TIMEOUT_SECONDS));
664        let mut list = toml_edit::ArrayOfTables::new();
665        list.push(hook);
666        let mut group = toml_edit::Table::new();
667        group.insert("hooks", toml_edit::Item::ArrayOfTables(list));
668        // Appended last, so no existing hook's position changes.
669        hooks
670            .entry(event)
671            .or_insert(toml_edit::Item::ArrayOfTables(
672                toml_edit::ArrayOfTables::new(),
673            ))
674            .as_array_of_tables_mut()
675            .with_context(|| format!("Codex config.toml hooks.{event} is not an array of tables"))?
676            .push(group);
677    }
678    let rendered = doc.to_string();
679    if rendered == original {
680        return Ok(None);
681    }
682    Ok(Some(rendered.into_bytes()))
683}
684
685fn codex_strip(current: &[u8], config: &Path) -> Result<Vec<u8>> {
686    let mut doc = codex_parse(Some(current))?;
687    codex_remove(&mut doc, config, false);
688    Ok(doc.to_string().into_bytes())
689}
690
691fn render(
692    harness: Harness,
693    current: Option<&[u8]>,
694    wanted: &[(String, String)],
695    path: &Path,
696) -> Result<Option<Vec<u8>>> {
697    match harness {
698        Harness::Claude => claude_render(current, wanted),
699        Harness::Codex => codex_render(current, wanted, path),
700    }
701}
702
703fn strip(harness: Harness, current: &[u8], path: &Path) -> Result<Vec<u8>> {
704    match harness {
705        Harness::Claude => claude_strip(current),
706        Harness::Codex => codex_strip(current, path),
707    }
708}
709
710fn configured(harness: Harness, current: Option<&[u8]>) -> Result<Vec<(String, String)>> {
711    let mut found = Vec::new();
712    match harness {
713        Harness::Claude => {
714            let settings = claude_parse(current)?;
715            for e in EVENTS {
716                for c in claude_commands(&settings, e)
717                    .into_iter()
718                    .filter(|c| is_ours(c))
719                {
720                    found.push((e.to_string(), c));
721                }
722            }
723        }
724        Harness::Codex => {
725            let doc = codex_parse(current)?;
726            for e in EVENTS {
727                for c in codex_commands(&doc, e).into_iter().filter(|c| is_ours(c)) {
728                    found.push((e.to_string(), c));
729                }
730            }
731        }
732    }
733    Ok(found)
734}
735
736/// Why this host must not get user-level hooks, if it is a shantytown crew host.
737///
738/// Crew sessions are launched with st's role settings, which already carry the
739/// query-first directive. Claude Code merges those with `~/.claude/settings.json`,
740/// so user-level hooks would put a second copy in every crew session.
741pub fn crew_host_reason(shanty_root: Option<&std::ffi::OsStr>, plan_crew: bool) -> Option<String> {
742    if shanty_root.is_some_and(|r| !r.is_empty()) {
743        return Some("SHANTY_ROOT is set".into());
744    }
745    plan_crew.then(|| "the plan selects a shantytown crew".into())
746}
747
748/// A settings file that is a symlink (a dotfile manager, say) would silently
749/// become a regular file when replaced, so refuse instead.
750fn refuse_symlink(path: &Path) -> Result<()> {
751    if fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) {
752        bail!(
753            "{} is a symlink; tambo will not replace it with a regular file, and nothing was written. \
754             Add the hooks to the file it points to, or replace the link with a regular file and rerun",
755            path.display()
756        );
757    }
758    Ok(())
759}
760
761// --- install / uninstall / verify ---------------------------------------------
762
763/// What happened to one harness's settings file.
764#[derive(Debug, Clone, PartialEq, Eq)]
765pub enum Outcome {
766    Written,
767    Unchanged,
768    Restored,
769    Removed,
770    NothingToRemove,
771}
772
773impl Outcome {
774    pub fn describe(&self) -> &'static str {
775        match self {
776            Outcome::Written => "query-first hooks installed",
777            Outcome::Unchanged => "query-first hooks already installed; nothing written",
778            Outcome::Restored => "restored the exact settings saved before install",
779            Outcome::Removed => {
780                "removed the tambo hooks; the file changed after install, so other edits were kept"
781            }
782            Outcome::NothingToRemove => "no tambo hooks found; nothing written",
783        }
784    }
785}
786
787/// Add the query-first hooks for every harness, recording `target` for the hook.
788pub fn install(paths: &Paths, tambo: &Path, target: &Target) -> Result<Vec<(Harness, Outcome)>> {
789    if !tambo.is_absolute() {
790        bail!(
791            "the hook command needs tambo's absolute path, got {}",
792            tambo.display()
793        );
794    }
795    // Render every file first, so a malformed one stops the install before any write.
796    let mut staged = Vec::new();
797    for harness in Harness::ALL {
798        let path = paths.settings(harness);
799        refuse_symlink(path)?;
800        let current = read_optional(path)?;
801        let wanted: Vec<(String, String)> = EVENTS
802            .iter()
803            .map(|e| (e.to_string(), command(tambo, harness, e)))
804            .collect();
805        let next = render(harness, current.as_deref(), &wanted, path)?;
806        staged.push((harness, current, next));
807    }
808    fs::create_dir_all(&paths.state)?;
809    fs::set_permissions(&paths.state, fs::Permissions::from_mode(0o700))?;
810    let config = json!({"schema":1,"db":target.db.as_ref().map(|d| d.display().to_string())});
811    private_write(&paths.config(), &serde_json::to_vec_pretty(&config)?)?;
812    let mut outcomes = Vec::new();
813    for (harness, current, next) in staged {
814        let Some(next) = next else {
815            outcomes.push((harness, Outcome::Unchanged));
816            continue;
817        };
818        // Only the first install saves the prior file: later runs must not
819        // replace the user's original with a file that already holds our hooks.
820        if !paths.before(harness).exists() && !paths.absent_marker(harness).exists() {
821            match &current {
822                Some(bytes) => private_write(&paths.before(harness), bytes)?,
823                None => private_write(&paths.absent_marker(harness), b"")?,
824            }
825        }
826        write_atomic(paths.settings(harness), &next)?;
827        private_write(&paths.after_digest(harness), digest(&next).as_bytes())?;
828        outcomes.push((harness, Outcome::Written));
829    }
830    Ok(outcomes)
831}
832
833/// Remove the hooks; restore the saved bytes when nothing else changed since.
834pub fn uninstall(paths: &Paths) -> Result<Vec<(Harness, Outcome)>> {
835    for harness in Harness::ALL {
836        refuse_symlink(paths.settings(harness))?;
837    }
838    let mut outcomes = Vec::new();
839    for harness in Harness::ALL {
840        let path = paths.settings(harness);
841        let current = read_optional(path)?;
842        let after = read_optional(&paths.after_digest(harness))?
843            .map(|b| String::from_utf8_lossy(&b).trim().to_string());
844        let before = read_optional(&paths.before(harness))?;
845        let was_absent = paths.absent_marker(harness).exists();
846        let untouched = matches!((&current, &after), (Some(c), Some(a)) if digest(c) == *a);
847        let outcome = match &current {
848            Some(_) if untouched && was_absent => {
849                fs::remove_file(path).with_context(|| format!("remove {}", path.display()))?;
850                Outcome::Restored
851            }
852            Some(_) if untouched && before.is_some() => {
853                write_atomic(path, before.as_deref().unwrap())?;
854                Outcome::Restored
855            }
856            Some(bytes) if !configured(harness, Some(bytes))?.is_empty() => {
857                // Something else wrote the file since install (Codex records hook
858                // trust and its own state in it). Remove only our hooks and their
859                // trust entries; if that gives back the saved file, say so.
860                let stripped = strip(harness, bytes, path)?;
861                if before.as_deref() == Some(stripped.as_slice()) {
862                    write_atomic(path, &stripped)?;
863                    Outcome::Restored
864                } else if was_absent && is_empty_settings(harness, &stripped)? {
865                    fs::remove_file(path)?;
866                    Outcome::Restored
867                } else {
868                    write_atomic(path, &stripped)?;
869                    Outcome::Removed
870                }
871            }
872            _ => Outcome::NothingToRemove,
873        };
874        for leftover in [
875            paths.before(harness),
876            paths.absent_marker(harness),
877            paths.after_digest(harness),
878        ] {
879            if leftover.exists() {
880                fs::remove_file(&leftover)?;
881            }
882        }
883        outcomes.push((harness, outcome));
884    }
885    Ok(outcomes)
886}
887
888fn is_empty_settings(harness: Harness, bytes: &[u8]) -> Result<bool> {
889    Ok(match harness {
890        Harness::Claude => matches!(claude_parse(Some(bytes))?, J::Object(m) if m.is_empty()),
891        Harness::Codex => codex_parse(Some(bytes))?.as_table().is_empty(),
892    })
893}
894
895fn program(command: &str) -> Option<PathBuf> {
896    let rest = command.strip_prefix('\'')?;
897    let end = rest.find('\'')?;
898    Some(PathBuf::from(&rest[..end]))
899}
900
901fn executable(path: &Path) -> bool {
902    fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
903}
904
905/// One finding per harness: installed, missing or broken, with the fix.
906pub fn verify(paths: &Paths) -> Vec<Finding> {
907    let mut findings = Vec::new();
908    for harness in Harness::ALL {
909        let subject = format!("{} query-first hooks", harness.name());
910        let path = paths.settings(harness);
911        let found = match read_optional(path).and_then(|b| configured(harness, b.as_deref())) {
912            Ok(found) => found,
913            Err(e) => {
914                findings.push(Finding {
915                    level: Level::Fail,
916                    subject,
917                    detail: format!("cannot read {}: {e:#}", path.display()),
918                });
919                continue;
920            }
921        };
922        let missing: Vec<&str> = EVENTS
923            .iter()
924            .copied()
925            .filter(|e| !found.iter().any(|(event, _)| event == e))
926            .collect();
927        let broken: Vec<String> = found
928            .iter()
929            .filter(|(_, c)| !program(c).is_some_and(|p| executable(&p)))
930            .map(|(e, c)| {
931                format!(
932                    "{e} runs {}",
933                    program(c)
934                        .map(|p| p.display().to_string())
935                        .unwrap_or_else(|| c.clone())
936                )
937            })
938            .collect();
939        let duplicated = EVENTS
940            .iter()
941            .any(|e| found.iter().filter(|(event, _)| event == e).count() > 1);
942        let (level, detail) = if !broken.is_empty() {
943            (
944                Level::Fail,
945                format!(
946                    "broken ({}, not executable); fix: tambo hooks install",
947                    broken.join("; ")
948                ),
949            )
950        } else if missing.len() == EVENTS.len() {
951            (
952                Level::Warn,
953                format!(
954                    "not installed in {}; fix: tambo hooks install",
955                    path.display()
956                ),
957            )
958        } else if !missing.is_empty() || duplicated {
959            (
960                Level::Fail,
961                format!(
962                    "incomplete or duplicated in {} (missing: {}); fix: tambo hooks install",
963                    path.display(),
964                    missing.join(", ")
965                ),
966            )
967        } else if harness == Harness::Codex
968            && !read_optional(path)
969                .ok()
970                .flatten()
971                .and_then(|b| codex_parse(Some(&b)).ok())
972                .is_some_and(|doc| codex_trusted(&doc, path))
973        {
974            (
975                Level::Warn,
976                "installed but not trusted yet, so Codex will not run them; fix: start `codex` and choose \"Trust all and continue\" (or \"Review hooks\" first)".to_string(),
977            )
978        } else {
979            (
980                Level::Ok,
981                format!(
982                    "installed in {}{}",
983                    path.display(),
984                    last_firing(paths, harness)
985                ),
986            )
987        };
988        findings.push(Finding {
989            level,
990            subject,
991            detail,
992        });
993    }
994    findings
995}
996
997fn last_firing(paths: &Paths, harness: Harness) -> String {
998    let Ok(text) = fs::read_to_string(paths.usage()) else {
999        return "; not fired yet".into();
1000    };
1001    let count = text
1002        .lines()
1003        .filter_map(|l| serde_json::from_str::<Value>(l).ok())
1004        .filter(|v| v["harness"] == harness.name())
1005        .count();
1006    if count == 0 {
1007        "; not fired yet".into()
1008    } else {
1009        format!("; fired {count} times")
1010    }
1011}
1012
1013// --- the hook itself ----------------------------------------------------------
1014
1015fn load_target(paths: &Paths) -> Target {
1016    let db = fs::read(paths.config())
1017        .ok()
1018        .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
1019        .and_then(|v| v["db"].as_str().map(PathBuf::from));
1020    Target { db }
1021}
1022
1023/// The text a session sees. `None` means print nothing.
1024pub fn directive(
1025    event: &str,
1026    server: Option<&str>,
1027    target: &Target,
1028    crew: bool,
1029) -> (Option<String>, &'static str, Option<bool>) {
1030    if crew {
1031        // st launched this session with role settings that carry their own
1032        // query-first directive; a second copy is noise.
1033        return (None, "crew-managed", None);
1034    }
1035    let (mode, how, reachable) = match (server, &target.db) {
1036        (Some(url), _) => (
1037            "remote",
1038            format!(
1039                "Use the quipu MCP tool quipu_search {{\"query\":\"<subject>\"}} if it is registered, or:\n  curl -s {url}/search -H 'content-type: application/json' -d '{{\"query\":\"<subject>\"}}'"
1040            ),
1041            None,
1042        ),
1043        (None, Some(db)) => (
1044            "local",
1045            format!(
1046                "Run: quipu search \"<subject>\" --db {}",
1047                quote(&db.display().to_string())
1048            ),
1049            Some(db.exists()),
1050        ),
1051        (None, None) => (
1052            "unconfigured",
1053            "No Quipu store is configured for these hooks; run `tambo hooks install` from your tambo work directory.".to_string(),
1054            Some(false),
1055        ),
1056    };
1057    let text = if event == "SessionStart" {
1058        let status = match reachable {
1059            Some(false) if target.db.is_some() => format!(
1060                "\nUNAVAILABLE: the store {} does not exist, so nothing can be looked up. That is not the same as \"nothing is known\".",
1061                target.db.as_ref().unwrap().display()
1062            ),
1063            _ => String::new(),
1064        };
1065        format!(
1066            "QUERY-FIRST (knowledge graph): before you start a task, ask Quipu what is already known about what you are about to touch (earlier incidents, decisions, where facts came from) instead of re-deriving it.\n  {how}\nQuery first, grep second.{status}"
1067        )
1068    } else {
1069        format!(
1070            "Query-first reminder: check Quipu for what this prompt touches before acting. {how}"
1071        )
1072    };
1073    (Some(text), mode, reachable)
1074}
1075
1076/// `tambo hook query-first`: print the directive and count the firing.
1077/// Always exits 0; a hook must never block a session.
1078pub fn run_hook(harness: Harness, event: &str) -> Result<()> {
1079    if !EVENTS.contains(&event) {
1080        bail!("unsupported query-first event {event:?}");
1081    }
1082    let mut input = Vec::new();
1083    let _ = std::io::stdin().take(1 << 20).read_to_end(&mut input);
1084    let session = serde_json::from_slice::<Value>(&input)
1085        .ok()
1086        .and_then(|v| v["session_id"].as_str().map(str::to_string));
1087    let paths = Paths::from_env()?;
1088    let target = load_target(&paths);
1089    let server = env::var("QUIPU_SERVER")
1090        .ok()
1091        .filter(|s| !s.trim().is_empty());
1092    let crew = env::var_os("SHANTY_AGENT").is_some() && env::var_os("SHANTY_ROOT").is_some();
1093    let (text, mode, reachable) = directive(event, server.as_deref(), &target, crew);
1094    if let Some(text) = text {
1095        println!("{text}");
1096    }
1097    let at = SystemTime::now()
1098        .duration_since(UNIX_EPOCH)
1099        .map(|d| d.as_secs_f64())
1100        .unwrap_or(0.0);
1101    let line = json!({"at":at,"harness":harness.name(),"event":event,"mode":mode,
1102        "reachable":reachable,"session":session,"printed":mode != "crew-managed"});
1103    if fs::create_dir_all(&paths.state).is_ok() {
1104        if let Ok(mut f) = fs::OpenOptions::new()
1105            .create(true)
1106            .append(true)
1107            .mode(0o600)
1108            .open(paths.usage())
1109        {
1110            let _ = writeln!(f, "{line}");
1111        }
1112    }
1113    Ok(())
1114}
1115
1116#[cfg(test)]
1117#[path = "harness_hooks_tests.rs"]
1118mod tests;