kanbrain-core 0.4.1

The core of kanbrain (the `kb` command line and the kanban app): entries, markdown storage, tags and time tracking. Internal: its API follows kanbrain's versions and isn't meant for other crates.
Documentation
//! `kb hook prompt|stop`: what Claude Code's hooks run so that an agent describes the project (and,
//! with several repositories, this one's role) before anything else. Asked to in the section and in
//! `context`, agents still skipped it on their first turn; these have Claude Code remind them:
//!
//! - `prompt` (`UserPromptSubmit`): while something is missing, every prompt arrives with the request
//!   to write it first;
//! - `stop` (`Stop`): an agent ending its turn with it still missing is reminded to write it, as context
//!   Claude Code adds rather than a block (the turn ends all the same). Only once a turn (`stop_hook_active`).
//!
//! `stop` also catches the board falling behind: a task an agent started and hasn't commented on since
//! gets a reminder to say where it stands, as the section asks, so the person watching sees it.
//!
//! Once the project is described and the tasks the agent started commented on, they print nothing, so they cost
//! nothing.

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::path::{Path, PathBuf};

named_enum! {
    /// Which hook is asking, as `kb hook NAME` names it.
    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub enum HookEvent {
        /// `UserPromptSubmit`: a prompt is about to reach the agent.
        Prompt => "prompt",
        /// `Stop`: the agent is about to end its turn.
        Stop => "stop",
    }
}

/// What to print for `event`, given the JSON Claude Code sent (`input`) and the central brain: `None`
/// outside a project, or when there's nothing to remind. Malformed input reads as empty, so a hook never
/// breaks a session: it falls back to the folder it runs in, `here`, and lets the turn end.
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);
    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}."
                ));
            }
            // A brain that can't be read only loses the reminder, never the turn.
            let tasks = Store::at(&dir).and_then(|s| s.load()).map(|(tasks, _)| tasks).unwrap_or_default();
            // Each one: with subagents, an agent may have several running.
            reminders.extend(tasks.iter().filter(|t| uncommented(t)).map(|task| {
                // Which session started it isn't known, so the agent of another, or one that crashed, is told
                // apart by the agent reading this: never told to finish a task it didn't start.
                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
                )
            }));
            // As additional context rather than `decision: "block"`: the turn goes on all the same, but
            // Claude Code shows it as a reminder instead of a hook error.
            (!reminders.is_empty()).then(|| {
                json!({
                    "hookSpecificOutput": { "hookEventName": "Stop", "additionalContext": reminders.join("\n") },
                })
                .to_string()
            })
        }
    }
}

/// Whether `task` runs because an agent started it, and nobody has commented on it since. A task the
/// user started isn't the agent's to report on.
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)
}

/// What an agent still has to write about the project: its description and, in a project of several
/// repositories, this one's role.
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;