feat(TASK-033): add hook config schema across all scopes

Introduce src/hooks.rs as a types-only module (HookDef, HooksMap) and
plumb hooks:/global_hooks: config fields through every scope:

- Config + AppConfig hooks field, both Default impls, the manual
  from_config copy, and a COYOTE_HOOKS env override
- Role frontmatter: hand-parsed hooks arm, private field, getter, and
  JSON-inline re-serialization in Role::export (malformed maps degrade
  to None)
- AgentConfig + Graph: hooks + global_hooks on both structs, copied in
  AgentConfig::from_graph, with a per-agent global_hooks env override
- paths::hooks_dir() = <config_dir>/hooks with COYOTE_HOOKS_DIR override
- Commented hooks: block in the global config template (global scope
  only; no global_hooks key)

No engine or dispatch logic yet; existing configs parse unchanged.
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 13:44:34 -06:00
1 parent e4dfbe906c
commit 9693cdd4ac
12 files changed
+344 -1

No files matched your search

+48
View File
@@ -1,4 +1,5 @@
use crate::client::{ClientConfig, Model, ModelType, list_models};
use crate::hooks::HooksMap;
use crate::render::{MarkdownRender, RenderOptions};
use crate::utils::{IS_STDOUT_TERMINAL, NO_COLOR, decode_bin, drain_stale_tty_input, get_env_name};
@@ -52,6 +53,9 @@ pub struct AppConfig {
pub enabled_mcp_servers: Option<Vec<String>>,
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[serde(default)]
pub hooks: HooksMap,
pub auto_continue: bool,
pub max_auto_continues: usize,
pub inject_todo_instructions: bool,
@@ -143,6 +147,8 @@ impl Default for AppConfig {
enabled_mcp_servers: None,
mcp_tools: None,
hooks: Default::default(),
auto_continue: false,
max_auto_continues: 10,
inject_todo_instructions: true,
@@ -235,6 +241,8 @@ impl AppConfig {
enabled_mcp_servers: config.enabled_mcp_servers,
mcp_tools: config.mcp_tools,
hooks: config.hooks,
auto_continue: config.auto_continue,
max_auto_continues: config.max_auto_continues,
inject_todo_instructions: config.inject_todo_instructions,
@@ -572,6 +580,12 @@ impl AppConfig {
self.enabled_mcp_servers = v.map(|raw| super::csv_to_vec(&raw));
}
if let Ok(v) = env::var(get_env_name("hooks"))
&& let Ok(v) = serde_json::from_str(&v)
{
self.hooks = v;
}
if let Some(v) = super::read_env_value::<String>(&get_env_name("repl_prelude")) {
self.repl_prelude = v;
}
@@ -788,6 +802,40 @@ mod tests {
assert_eq!(app.clients.len(), 1);
}
#[test]
fn from_config_copies_hooks() {
let mut hooks = crate::hooks::HooksMap::new();
hooks.insert(
"turn.completed".to_string(),
vec![crate::hooks::HookDef {
name: "notify".to_string(),
command: "./hooks/notify.sh".to_string(),
}],
);
let cfg = Config {
model_id: "test-model".to_string(),
clients: vec![ClientConfig::default()],
hooks: hooks.clone(),
..Config::default()
};
let app = AppConfig::from_config(cfg).unwrap();
assert_eq!(app.hooks, hooks);
}
#[test]
fn from_config_defaults_hooks_to_empty() {
let cfg = Config {
model_id: "test-model".to_string(),
clients: vec![ClientConfig::default()],
..Config::default()
};
let app = AppConfig::from_config(cfg).unwrap();
assert!(app.hooks.is_empty());
}
#[test]
fn from_config_copies_mapping_fields() {
let mut cfg = Config {