use std::path::{Path, PathBuf};
use anyhow::Result;
use objects::{
object::{AnnotationKind, AnnotationScope, AnnotationStatus, ContextTarget},
store::{AgentRegistry, AgentStatus},
};
use repo::{Repository, SessionManager};
use serde_json::{Value, json};
use tracing::debug;
use crate::{
cli::commands::snapshot::{SnapshotAgentOverrides, create_snapshot},
config::UserConfig,
};
pub(crate) fn handle_pre_tool_use(repo: &Repository, payload: &Value) -> Result<()> {
let tool_name = payload
.get("tool_name")
.and_then(Value::as_str)
.unwrap_or("");
if !is_file_tool(tool_name) {
return Ok(());
}
let Some(path_str) = payload
.get("tool_input")
.and_then(|v| v.get("file_path"))
.and_then(Value::as_str)
else {
return Ok(());
};
let Some(rel_path) = relative_to_repo(repo.root(), path_str) else {
return Ok(());
};
let annotations = match load_active_annotations(repo, &rel_path) {
Ok(list) => list,
Err(err) => {
debug!(?err, "heddle context lookup failed in PreToolUse hook");
return Ok(());
}
};
if annotations.is_empty() {
return Ok(());
}
let body = format_annotations(&rel_path, &annotations);
emit_hook_specific_output("PreToolUse", &body);
Ok(())
}
pub(crate) fn handle_stop_capture(
repo: &Repository,
user_config: &UserConfig,
payload: &Value,
intent_hint: &str,
) -> Result<()> {
if !worktree_dirty(repo)? {
return Ok(());
}
let overrides = SnapshotAgentOverrides {
provider: Some("anthropic".to_string()),
model: resolve_model(payload),
session: payload
.get("session_id")
.and_then(Value::as_str)
.map(|s| s.to_string()),
segment: None,
policy: None,
no_policy: false,
no_agent: false,
};
let intent = payload
.get("message")
.and_then(Value::as_str)
.or_else(|| payload.get("stop_reason").and_then(Value::as_str))
.map(|s| s.to_string())
.unwrap_or_else(|| intent_hint.to_string());
let output = create_snapshot(repo, user_config, Some(intent), None, overrides)?;
debug!(change_id = %output.change_id, "heddle stop-hook captured state");
Ok(())
}
fn is_file_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"Read" | "Edit" | "Write" | "NotebookEdit" | "MultiEdit"
)
}
fn relative_to_repo(root: &Path, raw: &str) -> Option<PathBuf> {
let raw_path = PathBuf::from(raw);
if raw_path.is_absolute() {
raw_path.strip_prefix(root).ok().map(Path::to_path_buf)
} else {
Some(raw_path)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ActiveAnnotation {
kind: AnnotationKind,
scope: AnnotationScope,
content: String,
attribution: String,
}
fn load_active_annotations(repo: &Repository, rel_path: &Path) -> Result<Vec<ActiveAnnotation>> {
let Some(head_id) = repo.head()? else {
return Ok(Vec::new());
};
let Some(state) = repo.store().get_state(&head_id)? else {
return Ok(Vec::new());
};
let Some(ctx_root) = state.context else {
return Ok(Vec::new());
};
let path_str = rel_path.to_string_lossy().to_string();
let Ok(target) = ContextTarget::file(&path_str) else {
return Ok(Vec::new());
};
let Some(blob) = repo.get_context_blob(&ctx_root, &target)? else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for annotation in &blob.annotations {
if annotation.status != AnnotationStatus::Active {
continue;
}
let Some(rev) = annotation.current_revision() else {
continue;
};
out.push(ActiveAnnotation {
kind: rev.kind,
scope: annotation.scope.clone(),
content: rev.content.clone(),
attribution: rev.attribution.clone(),
});
}
Ok(out)
}
fn format_annotations(rel_path: &Path, annotations: &[ActiveAnnotation]) -> String {
let count = annotations.len();
let mut out = format!(
"Heddle carries {count} active annotation{plural} for `{path}` from prior work:\n",
plural = if count == 1 { "" } else { "s" },
path = rel_path.display()
);
for a in annotations {
let kind_tag = match a.kind {
AnnotationKind::Constraint => "constraint",
AnnotationKind::Invariant => "invariant",
AnnotationKind::Rationale => "rationale",
};
let scope_tag = match &a.scope {
AnnotationScope::File => "file".to_string(),
AnnotationScope::Symbol { name, .. } => format!("symbol:{name}"),
AnnotationScope::Lines(a, b) => format!("lines:{a}-{b}"),
};
out.push_str(&format!(
"- [{kind_tag} · {scope_tag}] {} (via {})\n",
a.content.trim(),
a.attribution
));
}
out.push_str(
"\nThese annotations encode rules, invariants, and design rationale captured alongside the code. \
Respect them, or supersede them with `heddle context supersede` before capturing a change that invalidates them.",
);
out
}
fn worktree_dirty(repo: &Repository) -> Result<bool> {
let Some(head_id) = repo.head()? else {
return Ok(true);
};
let Some(head_state) = repo.store().get_state(&head_id)? else {
return Ok(true);
};
let tree = repo.build_tree(repo.root())?;
let tree_hash = repo.store().put_tree(&tree)?;
Ok(tree_hash != head_state.tree)
}
fn resolve_model(payload: &Value) -> Option<String> {
if let Some(display) = payload
.get("model")
.and_then(|m| m.get("display_name"))
.and_then(Value::as_str)
{
return Some(display.to_string());
}
if let Some(id) = payload
.get("model")
.and_then(|m| m.get("id"))
.and_then(Value::as_str)
{
return Some(id.to_string());
}
payload
.get("model")
.and_then(Value::as_str)
.map(|s| s.to_string())
}
fn emit_hook_specific_output(event: &str, context_body: &str) {
let value = json!({
"hookSpecificOutput": {
"hookEventName": event,
"additionalContext": context_body,
}
});
if serde_json::to_writer(std::io::stdout(), &value).is_ok() {
println!();
}
}
pub(crate) fn handle_user_prompt_segment_rotate(
repo: &Repository,
heddle_session_id: &str,
payload: &Value,
) -> Result<()> {
if heddle_session_id.is_empty() {
return Ok(());
}
let provider = "anthropic".to_string();
let model = resolve_model(payload).unwrap_or_else(|| "unknown".to_string());
let mut sessions = SessionManager::new(repo.root());
match sessions.add_segment(heddle_session_id, provider, model, None) {
Ok(segment) => {
debug!(segment_id = %segment.id, heddle_session_id, "rotated segment on UserPromptSubmit");
Ok(())
}
Err(err) => {
debug!(?err, "segment rotation skipped (session may have ended)");
Ok(())
}
}
}
pub(crate) fn mark_subagent_complete(repo: &Repository, payload: &Value) -> Result<()> {
let Some(agent_id) = payload.get("agent_id").and_then(Value::as_str) else {
return Ok(());
};
let native_actor_key = format!("claude-code:agent:{agent_id}");
let registry = AgentRegistry::new(repo.heddle_dir());
let Some(entry) = registry.find_active_by_native_actor_key(&native_actor_key)? else {
debug!(%native_actor_key, "no active subagent entry to mark complete");
return Ok(());
};
registry.update_status(&entry.session_id, AgentStatus::Complete)?;
debug!(session_id = %entry.session_id, %native_actor_key, "marked subagent AgentEntry Complete");
Ok(())
}
#[cfg(test)]
mod tests {
use objects::object::{
Annotation, AnnotationKind, AnnotationRevision, AnnotationScope, AnnotationStatus,
ContextBlob, ContextTarget,
};
use super::*;
fn annotation(kind: AnnotationKind, scope: AnnotationScope, content: &str) -> Annotation {
Annotation {
annotation_id: "ann-1".to_string(),
scope,
status: AnnotationStatus::Active,
revisions: vec![AnnotationRevision {
revision_id: "rev-1".to_string(),
kind,
content: content.to_string(),
tags: vec![],
attribution: "alice <alice@example.com>".to_string(),
created_at: 0,
source_hash: None,
created_at_state: None,
}],
supersedes_annotation_id: None,
supersedes_rewrite_pct: None,
visibility: objects::object::AnnotationVisibility::default(),
resolved_from_discussion: None,
}
}
#[test]
fn is_file_tool_recognizes_edit_family() {
assert!(is_file_tool("Read"));
assert!(is_file_tool("Edit"));
assert!(is_file_tool("Write"));
assert!(is_file_tool("MultiEdit"));
assert!(is_file_tool("NotebookEdit"));
assert!(!is_file_tool("Bash"));
assert!(!is_file_tool("Task"));
assert!(!is_file_tool(""));
}
#[test]
fn relative_to_repo_strips_absolute_prefix() {
let root = Path::new("/home/me/proj");
assert_eq!(
relative_to_repo(root, "/home/me/proj/src/lib.rs"),
Some(PathBuf::from("src/lib.rs")),
);
assert_eq!(
relative_to_repo(root, "src/lib.rs"),
Some(PathBuf::from("src/lib.rs")),
);
assert_eq!(relative_to_repo(root, "/other/path"), None);
}
#[test]
fn resolve_model_prefers_display_then_id_then_flat() {
let display = serde_json::json!({"model": {"display_name": "Claude Opus 4.7", "id": "claude-opus-4-7"}});
assert_eq!(resolve_model(&display).as_deref(), Some("Claude Opus 4.7"));
let id = serde_json::json!({"model": {"id": "claude-opus-4-7"}});
assert_eq!(resolve_model(&id).as_deref(), Some("claude-opus-4-7"));
let flat = serde_json::json!({"model": "claude-sonnet-4-6"});
assert_eq!(resolve_model(&flat).as_deref(), Some("claude-sonnet-4-6"));
let none = serde_json::json!({});
assert_eq!(resolve_model(&none), None);
}
#[test]
fn format_annotations_includes_all_three_kinds() {
let rel = PathBuf::from("src/lib.rs");
let input = vec![
ActiveAnnotation {
kind: AnnotationKind::Constraint,
scope: AnnotationScope::File,
content: "must be idempotent".to_string(),
attribution: "alice".to_string(),
},
ActiveAnnotation {
kind: AnnotationKind::Invariant,
scope: AnnotationScope::Lines(10, 20),
content: "never calls baz()".to_string(),
attribution: "bob".to_string(),
},
ActiveAnnotation {
kind: AnnotationKind::Rationale,
scope: AnnotationScope::Symbol {
name: "foo".to_string(),
resolved_lines: None,
},
content: "inlined for hot path".to_string(),
attribution: "claude-sonnet-4-6".to_string(),
},
];
let rendered = format_annotations(&rel, &input);
assert!(rendered.contains("3 active annotations"));
assert!(rendered.contains("[constraint · file]"));
assert!(rendered.contains("[invariant · lines:10-20]"));
assert!(rendered.contains("[rationale · symbol:foo]"));
assert!(rendered.contains("via alice"));
assert!(rendered.contains("supersede"));
}
#[test]
fn context_blob_round_trips_active_annotations() {
let mut active = annotation(
AnnotationKind::Constraint,
AnnotationScope::File,
"x must be non-negative",
);
active.status = AnnotationStatus::Active;
let mut superseded = annotation(
AnnotationKind::Rationale,
AnnotationScope::File,
"old reason",
);
superseded.status = AnnotationStatus::Superseded;
let blob = ContextBlob::new(vec![active, superseded]);
let encoded = blob.encode().unwrap();
let decoded = ContextBlob::decode(&encoded).unwrap();
let live: Vec<_> = decoded
.annotations
.iter()
.filter(|a| a.status == AnnotationStatus::Active)
.collect();
assert_eq!(live.len(), 1);
assert_eq!(
live[0].current_revision().unwrap().content,
"x must be non-negative"
);
let t = ContextTarget::file("src/lib.rs").unwrap();
assert_eq!(t.path(), Some("src/lib.rs"));
}
}