dirge-agent 0.18.5

Minimalistic coding agent written in Rust, optimized for memory footprint and performance
//! `write_todo_list` — the model's bulk planning surface over the persistent
//! issue board.
//!
//! Historically this was a throwaway in-memory checklist separate from the
//! `issue` tool. The two have been consolidated: a todo IS an issue. Each
//! `write_todo_list` call upserts its items into the project's `issues` table
//! (see [`crate::extras::issue_db`]), scoped to the current session, so bulk
//! planning gains the full issue lifecycle (open / in_progress / blocked /
//! done / cancelled) and the model can rely on which items are still open.
//!
//! [`TODO_LIST`] is no longer the source of truth — it's a fast in-memory
//! mirror of this session's live board that the right-pane panel and the
//! end-of-turn nudge read without touching SQLite on every frame. Both tools
//! ([`WriteTodoList`] and [`super::issue::IssueTool`]) refresh it after a write,
//! and `session::rehydrate` refreshes it on resume.
//!
//! One of four similarly-named work-tracking surfaces — NOT the phased `/plan`
//! workflow, plan-**mode**, or background `task`s. See the canonical map in
//! [`crate::agent::plan`].

use std::path::{Path, PathBuf};

#[allow(unused_imports)]
use crate::sync_util::LockExt;
use rig::completion::ToolDefinition;
use rig::tool::Tool;
use serde::{Deserialize, Serialize};

use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm};
use crate::extras::issue_db::{
    IssueStore, is_terminal_status, normalize_priority, normalize_status,
};

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TodoItem {
    pub content: String,
    pub status: String,
    pub priority: String,
}

#[derive(Deserialize)]
pub struct TodoWriteArgs {
    pub todos: Vec<TodoItem>,
}

/// In-memory mirror of the current session's live board (open / in_progress /
/// blocked), refreshed from the `issues` table after every write. The
/// right-pane TODOS panel and the end-of-turn nudge read this so neither has to
/// hit SQLite on every redraw. The DB is authoritative; this is a cache.
pub static TODO_LIST: std::sync::Mutex<Vec<TodoItem>> = std::sync::Mutex::new(Vec::new());

/// Re-query the session's live board from the issue DB and replace the
/// [`TODO_LIST`] mirror. Best-effort: a DB open/read failure leaves the mirror
/// as-is (transient lock) rather than blanking the panel. `session_id = None`
/// matches issues with a NULL session.
pub fn refresh_board(db_path: &Path, session_id: Option<&str>) {
    if let Ok(store) = IssueStore::open_at(db_path) {
        refresh_board_from(&store, session_id);
    }
}

/// Refresh the mirror from an already-open store. Callers that just wrote to
/// the board (the `write_todo_list` and `issue` tools) use this to avoid
/// re-opening the DB — a second `open_at` would re-run `CREATE TABLE IF NOT
/// EXISTS` and the WAL pragma and add lock contention on the shared file.
pub fn refresh_board_from(store: &IssueStore, session_id: Option<&str>) {
    if let Ok(board) = store.board_for_session(session_id, None) {
        let items: Vec<TodoItem> = board
            .into_iter()
            .map(|i| TodoItem {
                content: i.title,
                status: i.status,
                priority: i.priority,
            })
            .collect();
        *TODO_LIST.lock_ignore_poison() = items;
    }
}

/// Snapshot the current mirror. `save_session` persists this with the session
/// so the TODOS panel shows immediately on resume, before the first tool call
/// re-runs `refresh_board`.
pub fn snapshot() -> Vec<TodoItem> {
    TODO_LIST.lock_ignore_poison().clone()
}

/// Number of unfinished items on the mirrored board — anything still open or
/// in progress (blocked items are deliberately parked, terminal items are
/// gone). Used by the agent loop to nudge the model not to stop with unfinished
/// planned work.
pub fn unfinished_count() -> usize {
    // The mirror only ever holds normalized DB statuses (open / in_progress /
    // blocked), so this need only match the two that count as unfinished.
    // Blocked is deliberately parked and doesn't nudge.
    TODO_LIST
        .lock()
        .map(|list| {
            list.iter()
                .filter(|t| matches!(t.status.as_str(), "open" | "in_progress"))
                .count()
        })
        .unwrap_or(0)
}

pub struct WriteTodoList {
    db_path: PathBuf,
    session_id: Option<String>,
    permission: Option<PermCheck>,
    ask_tx: Option<AskSender>,
}

impl WriteTodoList {
    pub fn new(
        db_path: PathBuf,
        session_id: Option<String>,
        permission: Option<PermCheck>,
        ask_tx: Option<AskSender>,
    ) -> Self {
        WriteTodoList {
            db_path,
            session_id,
            permission,
            ask_tx,
        }
    }
}

impl Tool for WriteTodoList {
    const NAME: &'static str = "write_todo_list";

    type Error = ToolError;
    type Args = TodoWriteArgs;
    type Output = String;

    async fn definition(&self, _prompt: String) -> ToolDefinition {
        ToolDefinition {
            name: "write_todo_list".to_string(),
            description: "Lay out or update a structured plan for a COMPLEX, MULTI-STEP task. Each item is a tracked issue on your persistent board (the same board the `issue` tool and /issues use), scoped to this session. Listing an item creates it or updates a matching one by title; statuses follow the issue lifecycle (pending|in_progress|completed|cancelled, plus blocked). Items you omit are NOT auto-closed — to finish one, restate it with status completed (or cancelled); the loop nudges you to finish or close open items before ending a turn. Use the `issue` tool for incremental single-item edits or cross-session work. Skip this for trivial single-step work; use `task` to delegate independent work to a background subagent.".to_string(),
            parameters: serde_json::json!({
                "type": "object",
                "properties": {
                    "todos": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "content": { "type": "string", "description": "Task description (matched by title on later calls)" },
                                "status": { "type": "string", "description": "pending, in_progress, blocked, completed, or cancelled" },
                                "priority": { "type": "string", "description": "high, normal, or low" }
                            },
                            "required": ["content", "status", "priority"]
                        },
                        "description": "Full list of tasks to track"
                    }
                },
                "required": ["todos"]
            }),
        }
    }

    async fn call(&self, args: TodoWriteArgs) -> Result<String, ToolError> {
        check_perm(&self.permission, &self.ask_tx, "write_todo_list", "").await?;

        // Cap the plan so a pathological agent can't bloat the board (and every
        // subsequent prompt's reminder) by spamming hundreds of items. 50 is
        // generous for any reasonable plan; longer lists usually mean the work
        // should be split across turns.
        const MAX_TODOS: usize = 50;
        if args.todos.len() > MAX_TODOS {
            return Err(ToolError::Msg(format!(
                "todo list too long ({} items); cap is {}. Trim the list or split the work across multiple turns.",
                args.todos.len(),
                MAX_TODOS,
            )));
        }

        // No active session (e.g. `--no-session`): the user opted out of
        // persistence, so keep the plan ephemeral in the mirror only rather
        // than writing durable rows that would leak into the project-wide
        // turn-start board reminder of every future session. Mirrors the old
        // in-memory `write_todo_list` for this mode: replace the whole list,
        // normalized and minus terminal items (which leave the board).
        let Some(session_id) = self.session_id.as_deref() else {
            let items: Vec<TodoItem> = args
                .todos
                .iter()
                .filter_map(|t| {
                    let content = t.content.trim();
                    let status = normalize_status(&t.status).unwrap_or("open");
                    if content.is_empty() || is_terminal_status(status) {
                        return None;
                    }
                    Some(TodoItem {
                        content: content.to_string(),
                        status: status.to_string(),
                        priority: normalize_priority(&t.priority)
                            .unwrap_or("normal")
                            .to_string(),
                    })
                })
                .collect();
            let live = items.len();
            *TODO_LIST.lock_ignore_poison() = items;
            return Ok(format!(
                "Tracked {live} live item(s) (not persisted — no active session)."
            ));
        };

        let triples: Vec<(&str, &str, &str)> = args
            .todos
            .iter()
            .map(|t| (t.content.as_str(), t.status.as_str(), t.priority.as_str()))
            .collect();

        let store = IssueStore::open_at(&self.db_path).map_err(ToolError::Msg)?;
        let applied = store
            .sync_todos(Some(session_id), &triples)
            .map_err(ToolError::Msg)?;

        // Refresh the panel/nudge mirror from the store we already hold open.
        refresh_board_from(&store, Some(session_id));
        let live = TODO_LIST.lock_ignore_poison().len();
        Ok(format!(
            "Synced {applied} item(s) to the board; {live} live item(s) now on this session's board."
        ))
    }
}

#[cfg(test)]
mod tool_tests {
    use super::*;

    fn tmp_db() -> PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "dirge-todotool-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        std::fs::create_dir_all(&dir).unwrap();
        dir.join("state.db")
    }

    /// `write_todo_list` writes its plan through to the session's issue board,
    /// upserting by title across calls (no duplicate rows, omitted items kept).
    #[tokio::test]
    async fn write_todo_list_persists_to_the_issue_board() {
        let db = tmp_db();
        let tool = WriteTodoList::new(db.clone(), Some("sess-1".into()), None, None);

        tool.call(TodoWriteArgs {
            todos: vec![
                TodoItem {
                    content: "Design API".into(),
                    status: "in_progress".into(),
                    priority: "high".into(),
                },
                TodoItem {
                    content: "Write tests".into(),
                    status: "pending".into(),
                    priority: "low".into(),
                },
            ],
        })
        .await
        .unwrap();

        let store = IssueStore::open_at(&db).unwrap();
        let board = store.board_for_session(Some("sess-1"), None).unwrap();
        assert_eq!(board.len(), 2);
        // in_progress sorts ahead of open.
        assert_eq!(board[0].title, "Design API");
        assert_eq!(board[0].status, "in_progress");

        // Second call completes one item; the other is omitted but must stay.
        tool.call(TodoWriteArgs {
            todos: vec![TodoItem {
                content: "Design API".into(),
                status: "completed".into(),
                priority: "high".into(),
            }],
        })
        .await
        .unwrap();

        let board = store.board_for_session(Some("sess-1"), None).unwrap();
        let titles: Vec<&str> = board.iter().map(|i| i.title.as_str()).collect();
        assert_eq!(titles, vec!["Write tests"], "omitted item stays live");
        // No duplicate "Design API" — it was upserted, then closed.
        assert_eq!(store.search("Design API", 10).unwrap().len(), 1);
    }
}

#[cfg(test)]
mod nudge_tests {
    use super::*;

    /// `unfinished_count` counts open/pending + in_progress, ignoring
    /// blocked/done/cancelled. (Mutates the global TODO_LIST mirror directly.)
    #[test]
    fn unfinished_count_counts_open_and_in_progress() {
        let item = |status: &str| TodoItem {
            content: "x".into(),
            status: status.into(),
            priority: "normal".into(),
        };
        {
            let mut list = TODO_LIST.lock_ignore_poison();
            *list = vec![
                item("done"),
                item("open"),
                item("in_progress"),
                item("blocked"),
            ];
        }
        assert_eq!(unfinished_count(), 2);
        {
            let mut list = TODO_LIST.lock_ignore_poison();
            *list = vec![item("done"), item("blocked")];
        }
        assert_eq!(unfinished_count(), 0);
        // Leave the global clean for any other consumer.
        TODO_LIST.lock_ignore_poison().clear();
    }
}