use crate::enums::named_enum;
use crate::model::{Author, Status, Task};
use crate::projects::{self, ProjectInfo};
use crate::store::{self, Store};
use serde_json::{Value, json};
use std::fs;
use std::path::{Component, Path, PathBuf};
named_enum! {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookEvent {
Prompt => "prompt",
Stop => "stop",
Edit => "edit",
Tool => "tool",
}
}
pub fn respond(event: HookEvent, input: &str, central: &Path, here: &Path) -> Option<String> {
let input: Value = serde_json::from_str(input).unwrap_or(Value::Null);
let cwd = input["cwd"].as_str().map_or_else(|| here.to_path_buf(), PathBuf::from);
match event {
HookEvent::Edit => refuse_brain_write(&written(input["tool_input"]["file_path"].as_str()?, &cwd), central),
HookEvent::Tool => tell_of_comments(&cwd),
HookEvent::Prompt | HookEvent::Stop => remind(event, &input, &cwd, central),
}
}
fn refuse_brain_write(file: &Path, central: &Path) -> Option<String> {
in_brain(file, central).then(|| {
json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "kanbrain: a brain's files are written only through `kb`, which takes \
the brain's lock (the board and other agents write there too), numbers new entries and keeps \
their format. Make this change with `kb` (`kb --help` lists the commands); if none makes it, \
tell the user what to change instead of editing the file.",
},
})
.to_string()
})
}
fn in_brain(file: &Path, central: &Path) -> bool {
let canonical = central.canonicalize().ok();
let centrals = [Some(central), canonical.as_deref()];
let owned = |path: &Path| {
path.ancestors().any(|dir| dir.file_name().is_some_and(|name| name == store::PROJECT_DIR))
|| centrals.into_iter().flatten().any(|c| path.strip_prefix(c).is_ok_and(|rel| central_owned(c, rel)))
};
owned(file) || real_path(file).is_some_and(|real| owned(&real))
}
fn central_owned(central: &Path, rel: &Path) -> bool {
let mut parts = rel.components();
let Some(first) = parts.next().map(Component::as_os_str) else { return false };
if parts.next().is_some() {
return first == projects::CARDS_DIR || first == store::REPORTS_DIR;
}
let kanbrains = [store::TAGS_FILE, projects::REGISTRY_FILE, store::LAST_ID_FILE, crate::migrate::FORMAT_FILE];
let name = first.to_string_lossy();
if kanbrains.contains(&&*name) {
return true;
}
if !name.ends_with(".md") {
return false;
}
if store::entry_named(&name) {
return true;
}
let path = central.join(rel);
let entry = |text: String| Task::from_markdown(&text).err().is_none_or(|error| error != crate::model::MISSING_ID);
fs::metadata(&path).ok().filter(fs::Metadata::is_file).is_some_and(|_| store::read_text(&path).map_or(true, entry))
}
fn written(path: &str, cwd: &Path) -> PathBuf {
let home = |rest: &str| dirs::home_dir().map(|home| home.join(rest));
let path = match path.strip_prefix("~/").or_else(|| (path == "~").then_some("")) {
Some(rest) => home(rest).unwrap_or_else(|| cwd.join(path)),
None => cwd.join(path),
};
lexical(&path)
}
fn lexical(path: &Path) -> PathBuf {
let mut resolved = PathBuf::new();
for part in path.components() {
if part == Component::ParentDir {
resolved.pop();
} else {
resolved.push(part);
}
}
resolved
}
fn real_path(file: &Path) -> Option<PathBuf> {
file.ancestors().find_map(|dir| Some(dir.canonicalize().ok()?.join(file.strip_prefix(dir).ok()?)))
}
fn remind(event: HookEvent, input: &Value, cwd: &Path, central: &Path) -> Option<String> {
let dir = store::find_project(cwd)?;
let project = projects::info(&dir, Some(central))?;
let missing = missing(&project).join("; and ");
if event == HookEvent::Prompt {
return (!missing.is_empty()).then(|| {
format!(
"kanbrain: this project isn't described yet. Before anything else in this turn, read the README \
and manifests and write {missing}. Then go on with the request."
)
});
}
if input["stop_hook_active"].as_bool() == Some(true) {
return None;
}
let mut reminders = Vec::new();
if !missing.is_empty() {
reminders.push(format!(
"kanbrain: the project still isn't described. Before ending this turn, read the README and \
manifests and write {missing}."
));
}
let tasks = Store::at(&dir).and_then(|s| s.load()).map(|(tasks, _)| tasks).unwrap_or_default();
reminders.extend(tasks.iter().filter(|t| uncommented(t)).map(|task| {
format!(
"kanbrain: #{0} {1} is in doing with no comment since an agent started it. If you started it, \
before ending this turn `kb comment {0}` what changed and what's next, or `kb done {0}` if \
it's finished; if you didn't, leave it alone.",
task.id, task.title
)
}));
(!reminders.is_empty()).then(|| {
json!({
"hookSpecificOutput": { "hookEventName": "Stop", "additionalContext": reminders.join("\n") },
})
.to_string()
})
}
fn tell_of_comments(cwd: &Path) -> Option<String> {
let dir = store::find_project(cwd)?;
let (tasks, _) = Store::at(&dir).and_then(|s| s.load()).ok()?;
let told: Vec<String> = tasks
.iter()
.filter(|task| unanswered(task))
.map(|task| {
format!(
"kanbrain: the user commented on #{0} {1}, which an agent is working on, after its last comment \
there. If it's yours, `kb show {0}` to read it, then `kb comment {0}` what you'll do about it, \
before going on; if it isn't, leave it alone.",
task.id, task.title
)
})
.collect();
(!told.is_empty()).then(|| {
json!({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "additionalContext": told.join("\n") } })
.to_string()
})
}
fn unanswered(task: &Task) -> bool {
let Some(started) = task.history.last().filter(|c| c.to == Status::Doing && c.by == Author::Agent) else {
return false;
};
let mut after_agents = task.comments.iter().rev().take_while(|c| c.author != Author::Agent);
task.is_running() && after_agents.any(|c| c.created >= started.at)
}
fn uncommented(task: &Task) -> bool {
let Some(started) = task.history.last().filter(|c| c.to == Status::Doing && c.by == Author::Agent) else {
return false;
};
task.is_running() && task.comments.iter().filter(|c| c.author == Author::Agent).all(|c| c.created < started.at)
}
fn missing(project: &ProjectInfo) -> Vec<&'static str> {
let mut missing = Vec::new();
if project.description.is_empty() {
missing.push(concat!(
"its description with `kb project describe -` (",
crate::projects::description_scope!(),
")"
));
}
if !project.siblings.is_empty() && project.notes.is_empty() {
missing.push("this repository's role with `kb project notes -` (what it owns, exposes and consumes)");
}
missing
}
#[cfg(test)]
#[path = "../../tests/cli/hook.rs"]
mod tests;