use crate::model::{Author, Status, Task};
use crate::projects::{self, ProjectInfo};
use crate::store::{self, Store};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookEvent {
Prompt,
Stop,
}
impl HookEvent {
pub fn parse(name: &str) -> Option<Self> {
match name {
"prompt" => Some(Self::Prompt),
"stop" => Some(Self::Stop),
_ => None,
}
}
}
pub fn respond(event: HookEvent, input: &str, central: &Path) -> Option<String> {
let input: Value = serde_json::from_str(input).unwrap_or(Value::Null);
let cwd = input["cwd"].as_str().map(PathBuf::from).or_else(|| std::env::current_dir().ok())?;
let dir = store::find_project(&cwd)?;
let project = projects::info(&dir, Some(central))?;
let missing = missing(&project).join("; and ");
match event {
HookEvent::Prompt if missing.is_empty() => None,
HookEvent::Prompt => Some(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."
)),
HookEvent::Stop if input["stop_hook_active"].as_bool() == Some(true) => None,
HookEvent::Stop => {
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 you started it. Before ending this turn, \
`kb comment {0}` what changed and what's next, or `kb done {0}` if it's finished.",
task.id, task.title
)
}));
(!reminders.is_empty()).then(|| {
json!({
"hookSpecificOutput": { "hookEventName": "Stop", "additionalContext": reminders.join("\n") },
})
.to_string()
})
}
}
}
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().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;