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

+53
View File
@@ -1,6 +1,7 @@
use super::*;
use crate::client::{Message, MessageContent, MessageRole, Model};
use crate::hooks::HooksMap;
use anyhow::Result;
use fancy_regex::Regex;
@@ -99,6 +100,8 @@ pub struct Role {
skill_instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
memory: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
hooks: Option<HooksMap>,
#[serde(skip)]
model: Model,
@@ -161,6 +164,7 @@ impl Role {
role.skill_instructions = value.as_str().map(|v| v.to_string())
}
"memory" => role.memory = value.as_bool(),
"hooks" => role.hooks = parse_hooks_map(value),
_ => (),
}
}
@@ -248,6 +252,10 @@ impl Role {
if let Some(memory) = self.memory {
metadata.push(format!("memory: {memory}"));
}
if let Some(hooks) = &self.hooks {
let inline = serde_json::to_string(hooks).unwrap_or_else(|_| "{}".to_string());
metadata.push(format!("hooks: {inline}"));
}
if metadata.is_empty() {
format!("{}\n", self.prompt)
} else if self.prompt.is_empty() {
@@ -380,6 +388,11 @@ impl Role {
self.memory
}
#[allow(dead_code)]
pub fn hooks(&self) -> Option<&HooksMap> {
self.hooks.as_ref()
}
pub fn skills_enabled(&self) -> Option<bool> {
self.skills_enabled
}
@@ -544,6 +557,10 @@ fn parse_mcp_tools_map(value: &Value) -> Option<IndexMap<String, Vec<String>>> {
Some(mcp_tools)
}
fn parse_hooks_map(value: &Value) -> Option<HooksMap> {
serde_json::from_value(value.clone()).ok()
}
fn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) {
let mut text = prompt;
let mut search_input = true;
@@ -641,6 +658,42 @@ mod tests {
);
}
#[test]
fn role_hooks_round_trip_through_export() {
let content = "---\nhooks:\n turn.completed:\n - name: notify\n command: ./hooks/notify.sh\n---\nPrompt";
let role = Role::new("test", content);
let hooks = role.hooks().expect("hooks should be parsed");
assert_eq!(hooks["turn.completed"][0].name, "notify");
assert_eq!(hooks["turn.completed"][0].command, "./hooks/notify.sh");
let exported = role.export();
assert!(exported.contains("hooks:"));
let reparsed = Role::new("test", &exported);
assert_eq!(reparsed.hooks(), role.hooks());
assert_eq!(reparsed.prompt(), "Prompt");
}
#[test]
fn role_without_hooks_has_none_and_export_omits_key() {
let role = Role::new("test", "---\nmemory: true\n---\nPrompt");
assert!(role.hooks().is_none());
assert!(!role.export().contains("hooks:"));
}
#[test]
fn role_malformed_hooks_degrades_to_none() {
let content = "---\nhooks: [not, a, map]\n---\nPrompt";
let role = Role::new("test", content);
assert!(role.hooks().is_none());
assert_eq!(role.prompt(), "Prompt");
}
#[test]
fn role_new_no_metadata_has_none_fields() {
let role = Role::new("test", "Just a prompt");