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

+2
View File
@@ -928,6 +928,8 @@ mod tests {
max_concurrent_agents: None,
max_agent_depth: None,
global_tools: Vec::new(),
hooks: Default::default(),
global_hooks: Vec::new(),
mcp_servers: Vec::new(),
mcp_tools: None,
skills_enabled: None,
+2
View File
@@ -497,6 +497,8 @@ mod tests {
max_concurrent_agents: None,
max_agent_depth: None,
global_tools: Vec::new(),
hooks: Default::default(),
global_hooks: Vec::new(),
mcp_servers: Vec::new(),
mcp_tools: None,
skills_enabled: None,
+53
View File
@@ -1,4 +1,5 @@
use crate::config::AgentVariable;
use crate::hooks::HooksMap;
use anyhow::Result;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
@@ -43,6 +44,12 @@ pub struct Graph {
#[serde(default)]
pub global_tools: Vec<String>,
#[serde(default)]
pub hooks: HooksMap,
#[serde(default)]
pub global_hooks: Vec<String>,
#[serde(default)]
pub mcp_servers: Vec<String>,
@@ -611,6 +618,52 @@ nodes:
}
}
#[test]
fn graph_hooks_and_global_hooks_parse_and_round_trip() {
let yaml = r#"
name: g
start: e
hooks:
tool.started:
- name: notify
command: ./hooks/notify.sh
global_hooks:
- tool.started.notify
nodes:
e:
id: e
type: end
output: done
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
assert_eq!(graph.hooks["tool.started"][0].name, "notify");
assert_eq!(graph.hooks["tool.started"][0].command, "./hooks/notify.sh");
assert_eq!(graph.global_hooks, vec!["tool.started.notify"]);
let reparsed: Graph =
serde_yaml::from_str(&serde_yaml::to_string(&graph).unwrap()).unwrap();
assert_eq!(reparsed.hooks, graph.hooks);
assert_eq!(reparsed.global_hooks, graph.global_hooks);
}
#[test]
fn graph_without_hooks_keys_parses_empty() {
let yaml = r#"
name: g
start: e
nodes:
e:
id: e
type: end
output: done
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
assert!(graph.hooks.is_empty());
assert!(graph.global_hooks.is_empty());
}
#[test]
fn agent_node_teammates_parses_and_defaults_false() {
let yaml = r#"
+2
View File
@@ -1459,6 +1459,8 @@ mod tests {
max_concurrent_agents: None,
max_agent_depth: None,
global_tools: Vec::new(),
hooks: Default::default(),
global_hooks: Vec::new(),
mcp_servers: Vec::new(),
mcp_tools: None,
skills_enabled: None,