const HOOK_TIMEOUT_SECS: u64 = 10;
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::{Subcommand, ValueEnum};
use cv_core::hooks::Event;
use cv_core::ir::{home_rel, Harness, SessionRef};
use cv_core::lanes::LaneEvent;
use cv_core::sanitize::sanitize_line;
use serde_json::{json, Map, Value};
use crate::util::short_id;
use crate::util::{fmt_int, shell_quote};
const SKILL: &str = include_str!("../../skill/SKILL.md");
const LIVE_MINUTES: i64 = 30;
const START_SESSIONS: usize = 6;
const HOOK_READ_BYTES: u64 = 8 * 1024 * 1024;
#[derive(Subcommand)]
pub(crate) enum HooksCmd {
Install {
#[arg(default_value = "claude")]
harness: String,
#[arg(long, value_enum, default_value_t = Scope::User)]
scope: Scope,
},
Remove {
#[arg(default_value = "claude")]
harness: String,
#[arg(long, value_enum, default_value_t = Scope::User)]
scope: Scope,
},
Show {
#[arg(default_value = "claude")]
harness: String,
#[arg(long, value_enum, default_value_t = Scope::User)]
scope: Scope,
#[arg(long)]
json: bool,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub(crate) enum Scope {
User,
Project,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, ValueEnum)]
pub(crate) enum HookName {
Compact,
Start,
Lane,
Decisions,
Edit,
}
impl HookName {
fn as_str(self) -> &'static str {
match self {
HookName::Compact => "compact",
HookName::Start => "start",
HookName::Lane => "lane",
HookName::Decisions => "decisions",
HookName::Edit => "edit",
}
}
fn parse(s: &str) -> Option<HookName> {
[
HookName::Compact,
HookName::Start,
HookName::Lane,
HookName::Decisions,
HookName::Edit,
]
.into_iter()
.find(|n| n.as_str() == s)
}
}
struct Spec {
event: &'static str,
matcher: Option<&'static str>,
name: HookName,
asynchronous: bool,
}
const SPECS: &[Spec] = &[
Spec {
event: "SessionStart",
matcher: Some("compact"),
name: HookName::Compact,
asynchronous: false,
},
Spec {
event: "SessionStart",
matcher: Some("startup|resume"),
name: HookName::Start,
asynchronous: false,
},
Spec {
event: "SubagentStart",
matcher: None,
name: HookName::Lane,
asynchronous: true,
},
Spec {
event: "SubagentStop",
matcher: None,
name: HookName::Lane,
asynchronous: true,
},
Spec {
event: "UserPromptSubmit",
matcher: None,
name: HookName::Decisions,
asynchronous: false,
},
Spec {
event: "PreToolUse",
matcher: Some("Edit|Write"),
name: HookName::Edit,
asynchronous: false,
},
];
const CODEX_EDIT: Spec = Spec {
event: "PreToolUse",
matcher: Some("apply_patch"),
name: HookName::Edit,
asynchronous: false,
};
fn specs(harness: Harness) -> impl Iterator<Item = &'static Spec> {
let edit = host(harness).and_then(|h| h.edit);
SPECS.iter().map(move |spec| match edit {
Some(edit) if spec.name == HookName::Edit => edit,
_ => spec,
})
}
struct Host {
harness: Harness,
config_var: &'static str,
dir: &'static str,
settings: &'static str,
edit: Option<&'static Spec>,
trust: bool,
policy: bool,
}
fn host(h: Harness) -> Option<Host> {
match h {
Harness::Claude => Some(Host {
harness: h,
config_var: "CLAUDE_CONFIG_DIR",
dir: ".claude",
settings: "settings.json",
edit: None,
trust: false,
policy: true,
}),
Harness::Codex => Some(Host {
harness: h,
config_var: "CODEX_HOME",
dir: ".codex",
settings: "hooks.json",
edit: Some(&CODEX_EDIT),
trust: true,
policy: false,
}),
Harness::Grok
| Harness::OpenCode
| Harness::Gemini
| Harness::Hermes
| Harness::OpenClaw
| Harness::Cursor
| Harness::Kimi
| Harness::KimiCode
| Harness::Qwen
| Harness::LmStudio
| Harness::Cline
| Harness::Roo
| Harness::Continue
| Harness::Goose
| Harness::Zed
| Harness::Devin
| Harness::ChatGptExport
| Harness::ClaudeExport
| Harness::OpenSession => None,
}
}
fn hosts() -> impl Iterator<Item = Host> {
Harness::ALL.iter().filter_map(|h| host(*h))
}
fn needs_trust(harness: Harness) -> bool {
host(harness).is_some_and(|h| h.trust)
}
impl Host {
fn user_dir(&self) -> Option<PathBuf> {
cv_core::env::var_os(self.config_var)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.or_else(|| crate::util::dirs_home().map(|h| h.join(self.dir)))
}
}
fn paths(harness: Harness, scope: Scope) -> Result<(PathBuf, PathBuf)> {
let host = host(harness).with_context(|| format!("{harness} has no verified hook contract"))?;
let dir = match scope {
Scope::User => host
.user_dir()
.with_context(|| format!("no home directory: cannot locate ~/{}", host.dir))?,
Scope::Project => std::env::current_dir()
.context("reading the current directory")?
.join(host.dir),
};
Ok((dir.join(host.settings), dir.join("skills").join("cv").join("SKILL.md")))
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Trust {
Trusted,
Untrusted,
Modified,
}
impl Trust {
fn as_str(self) -> &'static str {
match self {
Trust::Trusted => "trusted",
Trust::Untrusted => "untrusted",
Trust::Modified => "modified",
}
}
}
struct TrustRow {
name: HookName,
key: String,
hash: String,
trust: Trust,
}
fn codex_event_label(event: &str) -> &'static str {
match event {
"PreToolUse" => "pre_tool_use",
"SessionStart" => "session_start",
"UserPromptSubmit" => "user_prompt_submit",
"SubagentStart" => "subagent_start",
"SubagentStop" => "subagent_stop",
_ => "unknown",
}
}
fn codex_hook_hash(event: &str, matcher: Option<&str>, handler: &Value) -> String {
let mut hook = Map::new();
hook.insert("type".into(), json!("command"));
hook.insert("command".into(), handler.get("command").cloned().unwrap_or(Value::Null));
hook.insert(
"timeout".into(),
json!(handler.get("timeout").and_then(Value::as_u64).unwrap_or(600).max(1)),
);
hook.insert(
"async".into(),
json!(handler.get("async").and_then(Value::as_bool).unwrap_or(false)),
);
if let Some(s) = handler.get("statusMessage").filter(|v| v.is_string()) {
hook.insert("statusMessage".into(), s.clone());
}
if let Some(limit) = handler
.get("additionalContextLimit")
.and_then(Value::as_u64)
.filter(|l| *l != 2500)
{
hook.insert("additionalContextLimit".into(), json!(limit));
}
let mut identity = Map::new();
identity.insert("event_name".into(), json!(codex_event_label(event)));
if let Some(m) = matcher {
identity.insert("matcher".into(), json!(m));
}
identity.insert("hooks".into(), json!([Value::Object(hook)]));
let mut identity = Value::Object(identity);
identity.sort_all_objects();
let mut digest = cv_core::digest::Sha256::new();
digest.update(&serde_json::to_vec(&identity).unwrap_or_default());
format!("sha256:{}", digest.finish_hex())
}
fn codex_trust_store() -> HashMap<String, String> {
let Some(text) = hosts()
.find(|h| h.trust)
.and_then(|h| h.user_dir())
.and_then(|h| std::fs::read_to_string(h.join("config.toml")).ok())
else {
return HashMap::new();
};
let Ok(config) = text.parse::<toml::Table>() else {
return HashMap::new();
};
config
.get("hooks")
.and_then(|h| h.get("state"))
.and_then(toml::Value::as_table)
.into_iter()
.flatten()
.filter_map(|(key, state)| {
let hash = state.get("trusted_hash")?.as_str()?;
Some((key.clone(), hash.to_string()))
})
.collect()
}
fn codex_trust(path: &Path, settings: &Value) -> Vec<TrustRow> {
let store = codex_trust_store();
let Some(hooks) = settings.get("hooks").and_then(Value::as_object) else {
return Vec::new();
};
let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let mut rows = Vec::new();
for (event, groups) in hooks {
for (gi, group) in groups.as_array().into_iter().flatten().enumerate() {
let matcher = group.get("matcher").and_then(Value::as_str);
for (hi, handler) in group
.get("hooks")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
{
let Some(name) = ours(handler) else { continue };
let key = format!("{}:{}:{gi}:{hi}", path.display(), codex_event_label(event));
let hash = codex_hook_hash(event, matcher, handler);
let trust = match store.get(&key) {
None => Trust::Untrusted,
Some(h) if *h == hash => Trust::Trusted,
Some(_) => Trust::Modified,
};
rows.push(TrustRow { name, key, hash, trust });
}
}
}
rows
}
fn trust_summary(rows: &[TrustRow], scope: Scope) -> String {
let count = |t: Trust| rows.iter().filter(|r| r.trust == t).count();
let (untrusted, modified) = (count(Trust::Untrusted), count(Trust::Modified));
if untrusted + modified == 0 {
return "trusted".to_string();
}
let mut parts = Vec::new();
if untrusted > 0 {
parts.push(format!("{untrusted} untrusted"));
}
if modified > 0 {
parts.push(format!("{modified} modified since trusted"));
}
let project = match scope {
Scope::Project => " and this directory must be a trusted project",
Scope::User => "",
};
format!(
"{} — Codex skips them until you start `codex` and accept them in its startup hooks review{project} \
(automation: `codex exec --dangerously-bypass-hook-trust`)",
parts.join(", ")
)
}
fn read_settings(path: &Path) -> Result<Value> {
match std::fs::read_to_string(path) {
Ok(text) if text.trim().is_empty() => Ok(json!({})),
Ok(text) => serde_json::from_str(&text)
.with_context(|| format!("{} is not valid JSON — not touching it", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(json!({})),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
fn write_settings(path: &Path, v: &Value) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
let mut text = serde_json::to_string_pretty(v)?;
text.push('\n');
publish_hook_file(path, &text)
}
fn publish_hook_file(path: &Path, text: &str) -> Result<()> {
let target = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
cv_core::publish::publish_reader(&target, "hooks", text.as_bytes(), cv_core::publish::Claim::Replace)
.with_context(|| format!("writing {}", path.display()))
}
fn command_for(harness: Harness, exe: &str, name: HookName) -> String {
format!("{} hook {} --harness {harness}", shell_quote(exe), name.as_str())
}
fn ours(handler: &Value) -> Option<HookName> {
let cmd = handler.get("command")?.as_str()?.trim();
let (prog, rest) = cmd.rsplit_once(" hook ")?;
let mut words = rest.split_whitespace();
let name = HookName::parse(words.next()?)?;
match (words.next(), words.next(), words.next()) {
(None, ..) | (Some("--harness"), Some(_), None) => {}
_ => return None,
}
let prog = prog.trim().trim_matches(|c| c == '\'' || c == '"');
let base = Path::new(prog).file_name()?.to_str()?;
(base == "cv" || base == "cv.exe").then_some(name)
}
fn handler_for(harness: Harness, exe: &str, spec: &Spec) -> Value {
let mut h = Map::new();
h.insert("type".into(), json!("command"));
h.insert("command".into(), json!(command_for(harness, exe, spec.name)));
if spec.asynchronous {
h.insert("async".into(), json!(true));
} else {
h.insert("timeout".into(), json!(HOOK_TIMEOUT_SECS));
}
Value::Object(h)
}
fn strip_ours(settings: &mut Value) {
let Some(hooks) = settings.get_mut("hooks").and_then(Value::as_object_mut) else {
return;
};
for ev in SPECS.iter().map(|s| s.event).collect::<std::collections::BTreeSet<_>>() {
if let Some(groups) = hooks.get_mut(ev).and_then(Value::as_array_mut) {
for g in groups.iter_mut() {
if let Some(hs) = g.get_mut("hooks").and_then(Value::as_array_mut) {
hs.retain(|h| ours(h).is_none());
}
}
groups.retain(|g| {
g.get("hooks")
.and_then(Value::as_array)
.is_some_and(|hs| !hs.is_empty())
});
if groups.is_empty() {
hooks.remove(ev);
}
}
}
if hooks.is_empty() {
settings.as_object_mut().map(|o| o.remove("hooks"));
}
}
fn with_ours(harness: Harness, mut settings: Value, exe: &str) -> Value {
strip_ours(&mut settings);
let obj = settings.as_object_mut().expect("settings is an object");
let hooks = obj.entry("hooks").or_insert_with(|| json!({}));
if !hooks.is_object() {
*hooks = json!({});
}
let hooks = hooks.as_object_mut().expect("object");
for spec in specs(harness) {
let mut group = Map::new();
if let Some(m) = spec.matcher {
group.insert("matcher".into(), json!(m));
}
group.insert("hooks".into(), json!([handler_for(harness, exe, spec)]));
let groups = hooks.entry(spec.event).or_insert_with(|| json!([]));
if !groups.is_array() {
*groups = json!([]);
}
groups.as_array_mut().expect("array").push(Value::Object(group));
}
settings
}
fn present(harness: Harness, settings: &Value) -> HashMap<(&'static str, Option<&'static str>, HookName), String> {
let mut found = HashMap::new();
let Some(hooks) = settings.get("hooks").and_then(Value::as_object) else {
return found;
};
for spec in specs(harness) {
let Some(groups) = hooks.get(spec.event).and_then(Value::as_array) else {
continue;
};
for g in groups {
let matcher = g.get("matcher").and_then(Value::as_str);
if matcher != spec.matcher {
continue;
}
for h in g.get("hooks").and_then(Value::as_array).into_iter().flatten() {
if ours(h) == Some(spec.name) {
let cmd = h.get("command").and_then(Value::as_str).unwrap_or("").to_string();
found.insert((spec.event, spec.matcher, spec.name), cmd);
}
}
}
}
found
}
fn hook_harness(harness: &str) -> Result<Harness> {
match Harness::parse(harness).filter(|h| host(*h).is_some()) {
Some(h) => Ok(h),
None => bail!("`cv hooks` supports verified Claude Code (`claude`) and Codex (`codex`) hooks; {harness:?} has no verified hook contract"),
}
}
fn this_exe() -> Result<String> {
let exe = std::env::current_exe().context("locating the cv binary")?;
Ok(exe.to_string_lossy().into_owned())
}
pub(crate) fn cmd_hooks(cmd: HooksCmd) -> Result<()> {
match cmd {
HooksCmd::Install { harness, scope } => {
let harness = hook_harness(&harness)?;
let (settings_path, skill_path) = paths(harness, scope)?;
let exe = this_exe()?;
let before = read_settings(&settings_path)?;
let after = with_ours(harness, before.clone(), &exe);
let mut changed = Vec::new();
if after != before {
write_settings(&settings_path, &after)?;
changed.push(format!("{} entries → {}", SPECS.len(), home_rel(&settings_path)));
}
if std::fs::read_to_string(&skill_path).ok().as_deref() != Some(SKILL) {
if let Some(dir) = skill_path.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
publish_hook_file(&skill_path, SKILL)?;
changed.push(format!("skill → {}", home_rel(&skill_path)));
}
if changed.is_empty() {
println!(
"cv hooks: already installed ({} entries in {}); nothing written",
SPECS.len(),
home_rel(&settings_path)
);
} else {
println!("cv hooks: installed {}", changed.join(" · "));
println!(" (takes effect in new harness sessions; `cv hooks show` lists the entries, `cv hooks remove` deletes them)");
}
if needs_trust(harness) {
println!(
" Codex trust: {}",
trust_summary(&codex_trust(&settings_path, &after), scope)
);
}
Ok(())
}
HooksCmd::Remove { harness, scope } => {
let harness = hook_harness(&harness)?;
let (settings_path, skill_path) = paths(harness, scope)?;
let before = read_settings(&settings_path)?;
let n = present(harness, &before).len();
let mut after = before.clone();
strip_ours(&mut after);
let mut removed = Vec::new();
if after != before {
write_settings(&settings_path, &after)?;
removed.push(format!("{n} entries from {}", home_rel(&settings_path)));
}
if skill_path.exists() {
std::fs::remove_file(&skill_path).with_context(|| format!("removing {}", skill_path.display()))?;
if let Some(dir) = skill_path.parent() {
let _ = std::fs::remove_dir(dir); }
removed.push(format!("skill {}", home_rel(&skill_path)));
}
if removed.is_empty() {
println!(
"cv hooks: nothing of cv's in {}; nothing written",
home_rel(&settings_path)
);
} else {
println!("cv hooks: removed {}", removed.join(" · "));
}
Ok(())
}
HooksCmd::Show { harness, scope, json } => {
let harness = hook_harness(&harness)?;
let (settings_path, skill_path) = paths(harness, scope)?;
let exe = this_exe()?;
let before = read_settings(&settings_path)?;
let after = with_ours(harness, before.clone(), &exe);
let have = present(harness, &before);
let skill_present = std::fs::read_to_string(&skill_path).ok().as_deref() == Some(SKILL);
let trust = match needs_trust(harness) {
true => codex_trust(&settings_path, &before),
false => Vec::new(),
};
let trust_of = |s: &Spec| {
trust
.iter()
.find(|r| r.name == s.name && r.key.contains(&format!(":{}:", codex_event_label(s.event))))
};
if json {
let (mut installed, mut missing) = (Vec::new(), Vec::new());
for s in specs(harness) {
let mut row = json!({"event": s.event, "matcher": s.matcher, "hook": s.name.as_str(),
"command": command_for(harness, &exe, s.name)});
if let Some(t) = trust_of(s) {
row["trust"] = json!(t.trust.as_str());
row["trust_key"] = json!(t.key);
row["current_hash"] = json!(t.hash);
}
match have.get(&(s.event, s.matcher, s.name)) {
Some(cmd) if *cmd == command_for(harness, &exe, s.name) => installed.push(row),
_ => missing.push(row),
}
}
let mut out = json!({
"settings_path": settings_path, "installed": installed, "missing": missing,
"skill_path": skill_path, "skill_present": skill_present,
"before": before, "after": after, "would_change": after != before || !skill_present,
});
if needs_trust(harness) {
out["trust"] = json!(trust_summary(&trust, scope));
}
println!("{}", serde_json::to_string_pretty(&out)?);
return Ok(());
}
println!("# cv hooks — {} ({:?} scope)\n", home_rel(&settings_path), scope);
for s in specs(harness) {
let want = command_for(harness, &exe, s.name);
let mark = match have.get(&(s.event, s.matcher, s.name)) {
Some(cmd) if *cmd == want => "✓",
Some(_) => "~", None => "+",
};
let matcher = s.matcher.map(|m| format!(" [{m}]")).unwrap_or_default();
let trust = trust_of(s)
.map(|t| format!(" · {}", t.trust.as_str()))
.unwrap_or_default();
println!(" {mark} {:<17}{matcher:<18} {want}{trust}", s.event);
}
println!(
" {} skill {}",
if skill_present { "✓" } else { "+" },
home_rel(&skill_path)
);
println!(
"\n{}",
if after == before && skill_present {
format!("installed; `cv hooks install {harness}` would write nothing")
} else {
format!("✓ present · + `cv hooks install {harness}` adds · ~ replaces (another cv binary's path)")
}
);
if needs_trust(harness) && !trust.is_empty() {
println!("Codex trust: {}", trust_summary(&trust, scope));
}
Ok(())
}
}
}
pub(crate) struct Policy {
pub auto_compact_enabled: Option<bool>,
pub idle_compaction: Option<bool>,
pub env: Vec<(&'static str, Option<String>)>,
}
const POLICY_ENV: &[&str] = &[
"CLAUDE_AUTOCOMPACT_PCT_OVERRIDE",
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
"DISABLE_AUTO_COMPACT",
];
pub(crate) struct ScopeHooks {
pub scope: Scope,
pub path: PathBuf,
pub entries: std::result::Result<usize, String>,
trust: Vec<TrustRow>,
}
impl ScopeHooks {
fn read(harness: Harness) -> Vec<(ScopeHooks, Option<Value>)> {
[Scope::User, Scope::Project]
.into_iter()
.filter_map(|scope| {
let (path, _) = paths(harness, scope).ok()?;
let (entries, trust, settings) = match read_settings(&path) {
Ok(settings) => {
let trust = match needs_trust(harness) {
true => codex_trust(&path, &settings),
false => Vec::new(),
};
(Ok(present(harness, &settings).len()), trust, Some(settings))
}
Err(e) => (Err(e.root_cause().to_string()), Vec::new(), None),
};
Some((
ScopeHooks {
scope,
path,
entries,
trust,
},
settings,
))
})
.collect()
}
fn line(&self) -> String {
let (path, scope) = (home_rel(&self.path), self.scope);
match &self.entries {
Ok(n) => format!("{n}/{} in {path} ({scope:?})", SPECS.len()),
Err(why) => format!("{path} ({scope:?}) unreadable ({why})"),
}
}
fn reportable(&self) -> bool {
self.entries.as_ref().map_or(true, |n| *n > 0)
}
fn json(&self) -> Value {
let mut row = json!({
"scope": format!("{:?}", self.scope).to_lowercase(), "entries": self.entries.as_ref().ok(),
"of": SPECS.len(), "settings_path": self.path,
});
if let Err(why) = &self.entries {
row["error"] = json!(why);
}
row
}
}
pub(crate) fn status() -> (Vec<ScopeHooks>, Policy) {
let mut scopes = Vec::new();
let mut auto = None;
let mut idle = None;
let mut env_block: HashMap<String, String> = HashMap::new();
let policy_hosts = hosts().filter(|h| h.policy);
for (scope, settings) in policy_hosts.flat_map(|h| ScopeHooks::read(h.harness)) {
scopes.push(scope);
let Some(settings) = settings else { continue };
if let Some(b) = settings.get("autoCompactEnabled").and_then(Value::as_bool) {
auto = Some(b);
}
if let Some(b) = settings.get("idleCompaction").and_then(Value::as_bool) {
idle = Some(b);
}
if let Some(env) = settings.get("env").and_then(Value::as_object) {
for (k, v) in env {
if let Some(s) = v.as_str() {
env_block.insert(k.clone(), s.to_string());
}
}
}
}
let env = POLICY_ENV
.iter()
.map(|k| (*k, cv_core::env::var(k).ok().or_else(|| env_block.get(*k).cloned())))
.collect();
(
scopes,
Policy {
auto_compact_enabled: auto,
idle_compaction: idle,
env,
},
)
}
fn codex_status() -> Vec<ScopeHooks> {
hosts()
.filter(|h| h.trust)
.flat_map(|h| ScopeHooks::read(h.harness))
.map(|(scope, _)| scope)
.filter(ScopeHooks::reportable)
.collect()
}
pub(crate) fn doctor_lines() -> Vec<String> {
let (scopes, policy) = status();
let installed: Vec<String> = scopes.iter().filter(|s| s.reportable()).map(ScopeHooks::line).collect();
let hooks = if installed.is_empty() {
"not installed — `cv hooks install claude` wires compaction, session start, lanes, decisions and the edit guard"
.to_string()
} else {
installed.join(", ")
};
let codex: Vec<String> = codex_status()
.iter()
.map(|s| match s.entries {
Ok(_) => format!("{}, {}", s.line(), trust_summary(&s.trust, s.scope)),
Err(_) => s.line(),
})
.collect();
let codex = if codex.is_empty() {
"not installed — `cv hooks install codex` wires the same five loops for Codex".to_string()
} else {
codex.join("; ")
};
let onoff = |v: Option<bool>, default_on: bool| match v {
Some(true) => "true".to_string(),
Some(false) => "false".to_string(),
None => format!("unset (default {})", if default_on { "on" } else { "off" }),
};
let mut knobs = vec![
format!("autoCompactEnabled {}", onoff(policy.auto_compact_enabled, true)),
format!("idleCompaction {}", onoff(policy.idle_compaction, false)),
];
knobs.extend(policy.env.iter().map(|(k, v)| match v {
Some(v) => format!("{k}={}", sanitize_line(v)),
None => format!("{k} unset"),
}));
vec![
format!("Hooks: {hooks}"),
format!("Codex hooks: {codex}"),
format!("Policy: {}", knobs.join(" · ")),
]
}
pub(crate) fn doctor_json() -> Value {
let (scopes, policy) = status();
json!({
"installed": scopes.iter().map(ScopeHooks::json).collect::<Vec<_>>(),
"codex": codex_status().iter().map(|s| {
let mut row = s.json();
row["trust"] = s.trust.iter().map(|r| json!({"hook": r.name.as_str(), "key": r.key, "trust": r.trust.as_str()})).collect();
row
}).collect::<Vec<_>>(),
"policy": {
"auto_compact_enabled": policy.auto_compact_enabled,
"idle_compaction": policy.idle_compaction,
"env": policy.env.iter().map(|(k, v)| (k.to_string(), json!(v))).collect::<Map<String, Value>>(),
}
})
}
fn hook_call_harness(flag: Option<&str>) -> Result<Harness> {
if cv_core::env::var_os("CV_HOOK_HARNESS").is_some() {
return crate::util::usage(
"`$CV_HOOK_HARNESS` was removed in 0.18.0 — `cv hooks install codex` writes `--harness codex`",
);
}
hook_harness(flag.unwrap_or("claude"))
}
pub(crate) fn cmd_hook(name: HookName, harness: Option<String>) -> Result<()> {
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return crate::util::usage(format!(
"cv hook {} reads the harness's hook JSON on stdin, and stdin is a terminal; \
`cv hooks install <claude|codex>` writes the settings entries that pipe it",
name.as_str()
));
}
let harness = hook_call_harness(harness.as_deref())?;
let mut raw = String::new();
let _ = std::io::stdin().read_to_string(&mut raw);
let payload: Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
eprintln!(
"cv hook {}: stdin is not the hook JSON ({e}); nothing to do",
name.as_str()
);
return Ok(());
}
};
let decoded = cv_core::harness::rootless(harness)
.context("no adapter")
.and_then(|adapter| adapter.hook_event(&payload));
let out = match decoded {
Ok(None) => Ok(None),
Ok(Some(event)) => match name {
HookName::Compact => hook_compact(&event),
HookName::Start => hook_start(&event),
HookName::Lane => hook_lane(&event),
HookName::Decisions => hook_decisions(&event),
HookName::Edit => hook_edit(&event),
},
Err(e) => Err(e),
};
match out {
Ok(Some(v)) => println!("{v}"),
Ok(None) => {}
Err(e) => eprintln!("cv hook {}: {e:#}", name.as_str()),
}
Ok(())
}
fn selector(harness: Harness, id: &str) -> String {
match harness {
Harness::Claude => short_id(id),
Harness::Codex => format!("codex:{id}"),
Harness::Grok
| Harness::OpenCode
| Harness::Gemini
| Harness::Hermes
| Harness::OpenClaw
| Harness::Cursor
| Harness::Kimi
| Harness::KimiCode
| Harness::Qwen
| Harness::LmStudio
| Harness::Cline
| Harness::Roo
| Harness::Continue
| Harness::Goose
| Harness::Zed
| Harness::Devin
| Harness::ChatGptExport
| Harness::ClaudeExport
| Harness::OpenSession => format!("{harness}:{}", short_id(id)),
}
}
fn adapter_of(ev: &Event) -> Result<&'static dyn cv_core::harness::Adapter> {
cv_core::harness::rootless(ev.harness).with_context(|| format!("no {} adapter", ev.harness))
}
fn context(event: &str, text: String) -> Value {
json!({"hookSpecificOutput": {"hookEventName": event, "additionalContext": text}})
}
fn tail_text(path: &Path, bytes: u64) -> Option<String> {
use std::io::{Seek, SeekFrom};
let mut f = std::fs::File::open(path).ok()?;
let len = f.metadata().ok()?.len();
let start = len.saturating_sub(bytes);
f.seek(SeekFrom::Start(start)).ok()?;
let mut buf = Vec::with_capacity((len - start) as usize);
f.read_to_end(&mut buf).ok()?;
Some(String::from_utf8_lossy(&buf).into_owned())
}
fn not_recorded_yet(id: &str) -> Value {
context(
"SessionStart",
format!(
"context compacted; the transcript has not recorded the boundary yet — `cv show {id} --kind compaction_boundary,compaction_summary` \
lists every boundary once it has, `cv show {id} --pre-compaction N` reads the span the Nth discarded."
),
)
}
fn hook_compact(ev: &Event) -> Result<Option<Value>> {
let Some(path) = ev.transcript() else {
return Ok(None);
};
let adapter = adapter_of(ev)?;
let id = selector(ev.harness, &ev.session.id);
let rewinds = adapter.identity().is_some();
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
if size > HOOK_READ_BYTES {
let read = format!(
"the hook read the last {} of this {} transcript",
cv_core::ir::human_bytes(HOOK_READ_BYTES),
cv_core::ir::human_bytes(size)
);
let what = match adapter.last_compact_boundary(path, HOOK_READ_BYTES)? {
Some((trigger, pre_tokens)) => format!(
" ({}, {}; {read}, so the index is not given)",
trigger.as_deref().unwrap_or("compaction"),
pre_tokens
.map(|t| format!("{} tokens", fmt_int(t)))
.unwrap_or_else(|| "size unrecorded".into())
),
None => format!(" (no boundary recorded in what {read})"),
};
return Ok(Some(context(
"SessionStart",
format!(
"context compacted{what}. `cv show {id} --kind compaction_boundary,compaction_summary` lists this session's \
boundaries with their message indexes; `cv show {id} --pre-compaction N` reads what the Nth discarded (`cv show \
{id} --kind prompt,answer --pre-compaction N` just its prompts); {rewind} \
{AFFORDANCE_POINTER}",
rewind = if rewinds { format!("`cv rewind {id} --at <msg>` resumes the agent as of a message.") } else { String::new() },
AFFORDANCE_POINTER = affordance_pointer(&id)
),
)));
}
let comps = cv_core::compaction::detect(&ev.session, false)?;
let Some(last) = comps.last() else {
return Ok(Some(not_recorded_yet(&id)));
};
let k = comps.len();
let trigger = last.trigger.as_deref().unwrap_or("compaction");
let pre = last
.pre_tokens
.map(|t| format!("{} tokens", fmt_int(t)))
.unwrap_or_else(|| "size unrecorded".into());
let idx = last.boundary_msg_idx;
let mut text = format!(
"context compacted ({trigger}, {pre}) at message {idx} — compaction #{k} of this session. \
`cv show {id} --pre-compaction {k}` reads what was lost (`cv show {id} --kind prompt,answer --pre-compaction {k}` just its prompts)"
);
if idx > 0 && rewinds {
text.push_str(&format!(
"; `cv rewind {id} --at {}` resumes the agent as of before it.",
idx - 1
));
} else {
text.push('.');
}
text.push(' ');
text.push_str(&affordance_pointer(&id));
Ok(Some(context("SessionStart", text)))
}
fn affordance_pointer(id: &str) -> String {
format!("`cv show {id} --kind plan,schedule,background_job` lists the plan, schedules and background jobs this session holds.")
}
fn last_commit(path: &Path, bytes: u64) -> String {
static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let re = RE.get_or_init(|| {
regex::Regex::new(r#"\[([^\]\s\\"]+)(?: \([^)]*\))? ([0-9a-f]{7,40})\] "#).expect("static regex")
});
let Some(text) = tail_text(path, bytes) else {
return String::new();
};
match re.captures_iter(&text).last() {
Some(c) => format!(" · last commit {}", &c[2][..7.min(c[2].len())]),
None if std::fs::metadata(path).is_ok_and(|m| m.len() > bytes) => {
format!(" · no commit in its last {}", cv_core::ir::human_bytes(bytes))
}
None => String::new(),
}
}
fn ago(t: Option<chrono::DateTime<chrono::Utc>>) -> String {
t.map(|t| format!("{} ago", cv_core::task::age_short(t, chrono::Utc::now())))
.unwrap_or_else(|| "undated".into())
}
fn owed_decisions(hook: HookName) -> Option<(String, usize)> {
let who = cv_core::task::actor(None, "cv");
let outcome = match cv_core::task::fold() {
Ok(outcome) => outcome,
Err(e) => {
eprintln!("cv hook {}: {e:#}", hook.as_str());
return None;
}
};
let n = cv_core::task::inbox(&outcome.model, &who)
.iter()
.filter(|e| matches!(e.reason, cv_core::task::InboxReason::DecisionOwed))
.count();
(n > 0).then_some((who, n))
}
fn hook_start(ev: &Event) -> Result<Option<Value>> {
let cwd = match &ev.cwd {
Some(c) => c.clone(),
None => std::env::current_dir()?,
};
let canon = std::fs::canonicalize(&cwd).unwrap_or_else(|_| cwd.clone());
let mut rows: Vec<SessionRef> = cv_core::cataloged_sessions()
.into_iter()
.filter(|r| r.id != ev.session.id && !r.id.starts_with("agent-"))
.filter(|r| r.cwd.as_deref().is_some_and(|c| c == cwd || c == canon))
.collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.updated_at.or(r.created_at)));
rows.truncate(START_SESSIONS);
let owed = owed_decisions(HookName::Start);
if rows.is_empty() && owed.is_none() {
return Ok(None);
}
let mut lines = Vec::new();
let tail = HOOK_READ_BYTES / rows.len().max(1) as u64;
if !rows.is_empty() {
lines.push(format!(
"cv: {} earlier session(s) in this directory (catalog as of the last `cv ls`; newest first). \
`cv show <id>` reads one; `cv pack \"<task>\"` pulls the relevant parts of several into context.",
rows.len()
));
for r in &rows {
let title = r
.title
.as_deref()
.filter(|t| !t.trim_start().starts_with('<'))
.map(|t| cv_core::ir::truncate(&sanitize_line(t), 60))
.unwrap_or_else(|| "(untitled)".into());
let commit = last_commit(&r.path, tail);
let id = selector(r.harness, &r.id);
lines.push(format!(
"- {id} · {} · {} msgs · {title}{commit}",
ago(r.updated_at.or(r.created_at)),
r.message_count.map_or("–".to_string(), |n| n.to_string())
));
}
}
if let Some((who, owed)) = owed {
lines.push(format!(
"- {owed} decision(s) owed to {who}: `cv task inbox` lists them."
));
}
Ok(Some(context("SessionStart", lines.join("\n"))))
}
fn hook_lane(ev: &Event) -> Result<Option<Value>> {
let event = match ev.event.as_deref() {
Some("SubagentStart") => cv_core::lanes::LaneEventKind::Start,
Some("SubagentStop") => cv_core::lanes::LaneEventKind::Stop,
_ => return Ok(None),
};
let Some(agent) = ev.agent.as_ref() else {
return Ok(None);
};
let last_text = agent
.last_message
.as_deref()
.and_then(|t| t.lines().find(|l| !l.trim().is_empty()))
.map(|l| cv_core::ir::truncate(l.trim(), 200));
cv_core::lanes::append_lane_event(&LaneEvent {
ts: chrono::Utc::now(),
event,
session_id: ev.session.id.clone(),
agent_id: agent.id.clone(),
agent_type: agent.kind.clone(),
transcript_path: ev.transcript().map(Path::to_path_buf),
agent_transcript_path: agent.transcript.clone(),
cwd: ev.cwd.clone(),
last_text,
})
.context("appending to the lane ledger")?;
Ok(None)
}
fn hook_decisions(_ev: &Event) -> Result<Option<Value>> {
let Some((who, n)) = owed_decisions(HookName::Decisions) else {
return Ok(None);
};
Ok(Some(context(
"UserPromptSubmit",
format!("cv: {n} decision(s) owed to {who} — `cv task inbox` lists them; `cv task resolve <id> --accept-default` or `--choice <option>` answers one."),
)))
}
fn minutes_since_modified(path: &Path) -> Option<i64> {
let m = std::fs::metadata(path).ok()?.modified().ok()?;
Some(std::time::SystemTime::now().duration_since(m).ok()?.as_secs() as i64 / 60)
}
fn hook_edit(ev: &Event) -> Result<Option<Value>> {
let Some(guard) = cv_core::hooks::Guard::of(ev) else {
return Ok(None);
};
let mut conflicts = Vec::new();
for file in &ev.edits {
let abs = if Path::new(file).is_absolute() {
PathBuf::from(file)
} else {
ev.cwd
.clone()
.or_else(|| std::env::current_dir().ok())
.unwrap_or_default()
.join(file.trim_start_matches("./"))
};
let mut live: Vec<(String, i64)> = Vec::new();
let editing = cv_core::events::sessions_editing(&abs.to_string_lossy()).unwrap_or_else(|e| {
cv_core::env::warn_once("the event catalog", format_args!("{e:#}"));
Vec::new()
});
for t in editing {
let Some(path) = cv_core::hooks::transcript_of(&t) else {
continue;
};
if guard.exempts(&t, &path) {
continue;
}
if let Some(m) = minutes_since_modified(&path).filter(|m| *m <= LIVE_MINUTES) {
let shown = match Harness::parse(&t.harness) {
Some(h) => selector(h, &t.session_id),
None => short_id(&t.session_id),
};
live.push((shown, m));
}
}
if live.is_empty() {
continue;
}
live.sort_by_key(|(_, m)| *m);
let (first, mins) = &live[0];
let more = match live.len() {
1 => String::new(),
n => format!(" (and {} more live session{})", n - 1, if n == 2 { "" } else { "s" }),
};
let shown = home_rel(&abs);
conflicts.push(format!(
"cv: {shown} was edited by live session {first} ({mins} min ago){more}. `cv show {first} --last 10` shows its hand; proceed only if the edits do not collide."
));
}
if conflicts.is_empty() {
return Ok(None);
}
Ok(Some(json!({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": guard.decision.as_str(),
"permissionDecisionReason": conflicts.join("\n"),
}})))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codex_hook_hash_matches_codex_0_162_1() {
let exe = "/private/tmp/claude-501/-Users-ember-dev-cv/ece21c13-ee7c-4db3-b8a6-e83ade448e6a/scratchpad/target-hooks/debug/cv";
let mut settings = with_ours(Harness::Codex, json!({}), exe);
for groups in settings["hooks"].as_object_mut().unwrap().values_mut() {
for group in groups.as_array_mut().unwrap() {
let handler = &mut group["hooks"][0];
let name = ours(handler).unwrap();
handler["command"] = json!(format!("CV_HOOK_HARNESS=codex {exe} hook {}", name.as_str()));
}
}
let recorded = [
(
"SessionStart",
0,
"sha256:bab06d02d2b21b9460a5e75fe440527fb5133c32ba0bf29307f663ed3e45d1b1",
),
(
"SessionStart",
1,
"sha256:0648814e802b9e8ceaa87e88b9890ea7bfae7c35d03fe595bf8f0f3fdbdf956a",
),
(
"SubagentStart",
0,
"sha256:a374e342343e7317477ef79e00c96e9d54b586c80958b47a766d2e33a5cb48f2",
),
(
"SubagentStop",
0,
"sha256:2213dfa52226988d3a710d6fa6ffa12b83028a189d06968e8386ab9fd397a28a",
),
(
"UserPromptSubmit",
0,
"sha256:72e820dad14de1ced01a772173169e39c3e88bb7d9ac3265e8fb8c838fad1bc5",
),
(
"PreToolUse",
0,
"sha256:aec1ede91be48eb971d19eacb805a8a9b31038fedfcae6c2e460198c8a77b3e6",
),
];
for (event, group, hash) in recorded {
let g = &settings["hooks"][event][group];
assert_eq!(
codex_hook_hash(event, g["matcher"].as_str(), &g["hooks"][0]),
hash,
"{event}[{group}]"
);
}
let path = Path::new("/home/u/.codex/hooks.json");
let rows = codex_trust(path, &settings);
assert_eq!(rows.len(), 6);
assert!(rows.iter().all(|r| r.trust == Trust::Untrusted));
assert!(rows
.iter()
.any(|r| r.key == "/home/u/.codex/hooks.json:session_start:1:0" && r.name == HookName::Start));
assert!(rows
.iter()
.any(|r| r.key == "/home/u/.codex/hooks.json:pre_tool_use:0:0" && r.hash == recorded[5].2));
}
#[test]
fn hook_install_keeps_existing_readers_on_the_previous_files() {
use std::io::Read;
let _world = crate::test_world("hooks-atomic");
let (settings, skill) = paths(Harness::Claude, Scope::User).unwrap();
std::fs::create_dir_all(skill.parent().unwrap()).unwrap();
std::fs::write(&settings, "{}").unwrap();
std::fs::write(&skill, "previous skill").unwrap();
let mut previous_settings = std::fs::File::open(&settings).unwrap();
let mut previous_skill = std::fs::File::open(&skill).unwrap();
cmd_hooks(HooksCmd::Install {
harness: "claude".into(),
scope: Scope::User,
})
.unwrap();
let mut held = String::new();
previous_settings.read_to_string(&mut held).unwrap();
assert_eq!(held, "{}");
held.clear();
previous_skill.read_to_string(&mut held).unwrap();
assert_eq!(held, "previous skill");
assert_eq!(
read_settings(&settings).unwrap(),
with_ours(Harness::Claude, json!({}), &this_exe().unwrap())
);
assert_eq!(std::fs::read_to_string(skill).unwrap(), SKILL);
}
#[test]
fn install_is_idempotent_and_remove_leaves_the_rest_untouched() {
let theirs = json!({
"theme": "dark",
"hooks": {
"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "guard.sh"}]}],
"Stop": [{"hooks": [{"type": "command", "command": "/usr/bin/notify"}]}]
}
});
let once = with_ours(Harness::Claude, theirs.clone(), "/opt/cv");
let twice = with_ours(Harness::Claude, once.clone(), "/opt/cv");
assert_eq!(once, twice, "a second install changes nothing");
assert_eq!(present(Harness::Claude, &once).len(), SPECS.len());
assert_eq!(once["hooks"]["PreToolUse"][0]["matcher"], "Bash");
assert_eq!(once["hooks"]["PreToolUse"][1]["matcher"], "Edit|Write");
assert_eq!(once["hooks"]["SubagentStop"][0]["hooks"][0]["async"], true);
assert!(once["hooks"]["SessionStart"][0]["hooks"][0]["async"].is_null());
let moved = with_ours(Harness::Claude, once.clone(), "/usr/local/bin/cv");
assert_eq!(present(Harness::Claude, &moved).len(), SPECS.len());
assert_eq!(moved["hooks"]["SessionStart"].as_array().unwrap().len(), 2);
let mut back = moved;
strip_ours(&mut back);
assert_eq!(back, theirs, "remove restores the original exactly");
let mut empty = with_ours(Harness::Claude, json!({}), "/opt/cv");
strip_ours(&mut empty);
assert_eq!(empty, json!({}));
}
#[test]
fn ours_recognizes_the_command_shape_only() {
assert_eq!(
ours(&json!({"command": "/opt/cv hook compact"})),
Some(HookName::Compact)
);
assert_eq!(
ours(&json!({"command": "'/Applications/My Tools/cv' hook lane"})),
Some(HookName::Lane)
);
assert_eq!(ours(&json!({"command": "/opt/cv hook nope"})), None);
assert_eq!(ours(&json!({"command": "/opt/other hook compact"})), None);
assert_eq!(ours(&json!({"command": "cv-mcp"})), None);
assert_eq!(
ours(&json!({"command": "/opt/cv hook edit --harness codex"})),
Some(HookName::Edit)
);
assert_eq!(ours(&json!({"command": "/opt/cv hook edit --other x"})), None);
assert_eq!(
command_for(Harness::Codex, "/opt/cv", HookName::Lane),
"/opt/cv hook lane --harness codex"
);
assert_eq!(shell_quote("/a b/cv"), "'/a b/cv'");
assert_eq!(shell_quote("/usr/local/bin/cv"), "/usr/local/bin/cv");
assert_eq!(shell_quote("~/bin/cv"), "'~/bin/cv'", "a bare leading ~ would expand");
assert_eq!(shell_quote("/opt/cv@1/cv"), "/opt/cv@1/cv");
assert_eq!(shell_quote("it's"), "'it'\\''s'");
assert_eq!(shell_quote(""), "''");
}
fn claude_tail(p: &Path) -> Result<Option<(Option<String>, Option<u64>)>> {
cv_core::harness::rootless(Harness::Claude)
.unwrap()
.last_compact_boundary(p, HOOK_READ_BYTES)
}
#[test]
fn the_large_transcript_path_reads_the_newest_boundary_from_the_tail() {
let dir = std::env::temp_dir().join(format!("cv-hooks-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("t.jsonl");
let b = |trig: &str, pre: u64| {
json!({"type": "system", "subtype": "compact_boundary", "compactMetadata": {"trigger": trig, "preTokens": pre}})
.to_string()
};
let quote = json!({"type": "user", "message": {"content": "grep \"compact_boundary\" x"}}).to_string();
std::fs::write(&p, format!("{}\n{}\n{quote}\n", b("manual", 1), b("auto", 180_412))).unwrap();
assert_eq!(claude_tail(&p).unwrap(), Some((Some("auto".into()), Some(180_412))));
std::fs::write(&p, format!("{}\n{quote}\n", b("auto", 5))).unwrap();
assert_eq!(claude_tail(&p).unwrap(), Some((Some("auto".into()), Some(5))));
std::fs::write(&p, format!("{quote}\n")).unwrap();
assert_eq!(claude_tail(&p).unwrap(), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn last_commit_reads_the_tail_and_nothing_else() {
let dir = std::env::temp_dir().join(format!("cv-hooks-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("t.jsonl");
std::fs::write(
&p,
concat!(
"{\"type\":\"user\",\"message\":{\"content\":[{\"type\":\"tool_result\",\"content\":\"[main 3e6a269] docs\\n 1 file\"}]}}\n",
"{\"type\":\"user\",\"message\":{\"content\":[{\"type\":\"tool_result\",\"content\":\"[p3-hooks 0abec0fd1] later\\n\"}]}}\n",
"{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"see [1] and [2]\"}]}}\n",
),
)
.unwrap();
assert_eq!(last_commit(&p, HOOK_READ_BYTES), " · last commit 0abec0f");
assert_eq!(last_commit(&p, 64), " · no commit in its last 64 B");
std::fs::remove_dir_all(&dir).ok();
}
}