1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
//! Hook system for `apr code` — mirrors Claude Code's hook events.
//!
//! Hooks let a user intercept the agent loop at well-defined moments and
//! run an external command. The exit code of the command decides what
//! happens next:
//!
//! * `0` → allow (hook did nothing interesting, or approved the action)
//! * `1` → warn (hook emitted a message; agent continues)
//! * `2` → block (hook vetoed the action; agent aborts that step)
//!
//! This matches Claude Code's `settings.json` → `[[hooks]]` surface 1:1.
//!
//! PMAT-CODE-HOOKS-001. This file introduces the event enum, the config
//! struct that deserializes from `manifest.toml`'s `[[hooks]]` table,
//! and the registry + runner used by the agent loop. Runtime integration
//! ships incrementally:
//!
//! * SessionStart — wired from `cmd_code` (see `agent/code.rs`)
//! * PreToolUse / PostToolUse / UserPromptSubmit / Stop / SubagentStop —
//! surface ships now; call sites land in PMAT-CODE-HOOKS-RUNTIME-001.
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use serde::{Deserialize, Serialize};
/// Canonical hook events mirroring Claude Code.
///
/// Claude's official list (as of 2026-04) is exactly the six below. Any
/// additional events APR-specific work wants to surface should land as
/// a separate enum variant + status_history entry in the parity contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum HookEvent {
/// Fires once when the agent session starts.
SessionStart,
/// Fires before every tool call; exit code 2 vetoes the call.
PreToolUse,
/// Fires after every tool call regardless of success.
PostToolUse,
/// Fires whenever the user submits a prompt.
UserPromptSubmit,
/// Fires when the top-level agent is about to terminate.
Stop,
/// Fires when a sub-agent (spawned via the Task tool) terminates.
SubagentStop,
}
/// A single hook entry deserialized from `manifest.hooks[]`.
///
/// ```toml
/// [[hooks]]
/// event = "PreToolUse"
/// matcher = "shell" # optional — tool name glob/regex
/// command = "echo 'pre-tool' >> ~/.apr/hooks.log"
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookConfig {
/// Which event triggers this hook.
pub event: HookEvent,
/// Optional matcher (tool name, prompt text, etc.). When `None` the
/// hook always fires for its event.
#[serde(default)]
pub matcher: Option<String>,
/// Shell command to execute. Ran via `sh -c`.
pub command: String,
/// Optional timeout in seconds (default 30).
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
}
fn default_timeout_secs() -> u64 {
30
}
/// Decision returned by a hook, derived from the command's exit code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookDecision {
/// Exit 0 — agent continues as if no hook ran.
Allow,
/// Exit 1 — agent continues but surfaces the hook's stderr to the user.
Warn(String),
/// Exit 2 — agent aborts the current step; stderr becomes the reason.
Block(String),
}
impl HookDecision {
pub fn from_exit_code(code: i32, stderr: String) -> Self {
match code {
0 => Self::Allow,
1 => Self::Warn(stderr),
_ => Self::Block(stderr),
}
}
pub fn is_blocking(&self) -> bool {
matches!(self, Self::Block(_))
}
}
/// Indexed collection of hooks keyed by event.
#[derive(Debug, Default)]
pub struct HookRegistry {
by_event: HashMap<HookEvent, Vec<HookConfig>>,
}
impl HookRegistry {
pub fn new() -> Self {
Self::default()
}
/// Build a registry from a raw list (typically `manifest.hooks`).
pub fn from_configs(configs: impl IntoIterator<Item = HookConfig>) -> Self {
let mut reg = Self::new();
for cfg in configs {
reg.register(cfg);
}
reg
}
pub fn register(&mut self, cfg: HookConfig) {
self.by_event.entry(cfg.event).or_default().push(cfg);
}
pub fn hooks_for(&self, event: HookEvent) -> &[HookConfig] {
self.by_event.get(&event).map_or(&[], |v| v.as_slice())
}
pub fn len(&self) -> usize {
self.by_event.values().map(Vec::len).sum()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Fire every hook registered for `event` whose `matcher` (if any) is
/// contained in `context`. Returns the first blocking decision if
/// any, else the collected warnings, else Allow.
pub fn run(&self, event: HookEvent, context: &str, cwd: &Path) -> HookDecision {
let mut warnings = Vec::new();
for cfg in self.hooks_for(event) {
if let Some(m) = cfg.matcher.as_deref() {
if !context.contains(m) {
continue;
}
}
match run_single(cfg, cwd) {
HookDecision::Allow => {}
HookDecision::Warn(msg) => warnings.push(msg),
block @ HookDecision::Block(_) => return block,
}
}
if warnings.is_empty() {
HookDecision::Allow
} else {
HookDecision::Warn(warnings.join("\n"))
}
}
}
fn run_single(cfg: &HookConfig, cwd: &Path) -> HookDecision {
let output = match Command::new("sh").arg("-c").arg(&cfg.command).current_dir(cwd).output() {
Ok(o) => o,
Err(e) => {
return HookDecision::Warn(format!("hook '{}' failed to spawn: {e}", cfg.command));
}
};
let code = output.status.code().unwrap_or(0);
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
HookDecision::from_exit_code(code, stderr)
}
#[cfg(test)]
mod tests;