zagens-cli 0.8.0

Zagens headless CLI + HTTP/SSE runtime sidecar (`zagens`, `zagens-runtime` binaries)
Documentation
//! Audit scratchpad tools (`scratchpad_status`, `scratchpad_append`, …).

use crate::tools::scratchpad_inputs::{
    scratchpad_append_input_schema, scratchpad_init_input_schema,
    scratchpad_list_notes_input_schema, scratchpad_set_area_input_schema,
    scratchpad_status_input_schema, scratchpad_verify_note_input_schema,
};
use async_trait::async_trait;
use serde_json::{Value, json};

use crate::scratchpad::AreaStatus;
use crate::scratchpad::{
    ScratchpadStore, default_init_areas, display_run_path, parse_init_areas, resolve_run_id,
    resolve_run_id_for_init, verify_note, workspace_audit_inventory,
};

fn persist_scratchpad_run(ctx: &ToolContext, run_id: &str) {
    if let Ok(mut guard) = ctx.runtime.wire.scratchpad_run_id.lock() {
        *guard = Some(run_id.to_string());
    }
    if let Some(persist) = &ctx.runtime.wire.persist_scratchpad_run_id {
        persist(run_id.to_string());
    }
}
use crate::tools::spec::{
    ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
    optional_str, required_str,
};
#[derive(Debug, Default)]
pub struct ScratchpadInitTool;

#[async_trait]
impl ToolSpec for ScratchpadInitTool {
    fn name(&self) -> &'static str {
        "scratchpad_init"
    }

    fn description(&self) -> &'static str {
        "Bootstrap an audit scratchpad run under .zagens/scratchpad/{run_id}/ (inventory.json + notes.jsonl). Idempotent when inventory already exists."
    }

    fn input_schema(&self) -> Value {
        scratchpad_init_input_schema()
    }

    fn capabilities(&self) -> Vec<ToolCapability> {
        vec![ToolCapability::WritesFiles]
    }

    fn approval_requirement(&self) -> ApprovalRequirement {
        ApprovalRequirement::Auto
    }

    async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
        let run_id = resolve_run_id_for_init(context, optional_str(&input, "run_id"))?;
        let scope = optional_str(&input, "scope");
        let areas = if optional_str(&input, "template") == Some("workspace_audit") {
            workspace_audit_inventory(&context.workspace)?
        } else {
            match input.get("areas").and_then(|v| v.as_array()) {
                Some(raw) => parse_init_areas(raw)?,
                None => default_init_areas(),
            }
        };
        let store = ScratchpadStore::init(context, &run_id, areas, scope)?;
        persist_scratchpad_run(context, &run_id);
        let status = store.build_status()?;
        Ok(ToolResult::success(
            serde_json::to_string_pretty(&json!({
                "run_id": run_id,
                "path": display_run_path(&run_id),
                "status": status,
            }))
            .unwrap_or_default(),
        ))
    }
}

#[derive(Debug, Default)]
pub struct ScratchpadStatusTool;

#[async_trait]
impl ToolSpec for ScratchpadStatusTool {
    fn name(&self) -> &'static str {
        "scratchpad_status"
    }

    fn description(&self) -> &'static str {
        "Return audit scratchpad progress: inventory completion, note counts, resume_area_id, and findings tallies."
    }

    fn input_schema(&self) -> Value {
        scratchpad_status_input_schema()
    }

    fn capabilities(&self) -> Vec<ToolCapability> {
        vec![ToolCapability::ReadOnly]
    }

    fn approval_requirement(&self) -> ApprovalRequirement {
        ApprovalRequirement::Auto
    }

    async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
        let run_id = resolve_run_id(context, optional_str(&input, "run_id"))?;
        let store = ScratchpadStore::open(context, &run_id)?;
        persist_scratchpad_run(context, &run_id);
        let status = store.build_status()?;
        Ok(ToolResult::success(
            serde_json::to_string_pretty(&status).unwrap_or_default(),
        ))
    }
}

#[derive(Debug, Default)]
pub struct ScratchpadAppendTool;

#[async_trait]
impl ToolSpec for ScratchpadAppendTool {
    fn name(&self) -> &'static str {
        "scratchpad_append"
    }

    fn description(&self) -> &'static str {
        "Append one validated line to notes.jsonl (auto id, ts). area_id must exist in inventory.json (except kind=meta with area_id=_global)."
    }

    fn input_schema(&self) -> Value {
        scratchpad_append_input_schema()
    }

    fn capabilities(&self) -> Vec<ToolCapability> {
        vec![ToolCapability::WritesFiles]
    }

    fn approval_requirement(&self) -> ApprovalRequirement {
        ApprovalRequirement::Auto
    }

    async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
        let run_id = resolve_run_id(context, optional_str(&input, "run_id"))?;
        let line = input
            .get("line")
            .cloned()
            .ok_or_else(|| ToolError::missing_field("line"))?;
        let store = ScratchpadStore::open(context, &run_id)?;
        let note = store.append_note(line)?;
        persist_scratchpad_run(context, &run_id);
        let out = json!({
            "id": note.id,
            "path": format!("{}/notes.jsonl", display_run_path(&run_id))
        });
        Ok(ToolResult::success(
            serde_json::to_string_pretty(&out).unwrap_or_default(),
        ))
    }
}

#[derive(Debug, Default)]
pub struct ScratchpadListNotesTool;

#[async_trait]
impl ToolSpec for ScratchpadListNotesTool {
    fn name(&self) -> &'static str {
        "scratchpad_list_notes"
    }

    fn description(&self) -> &'static str {
        "List recent notes.jsonl entries for one area_id (full JSON objects, not summaries)."
    }

    fn input_schema(&self) -> Value {
        scratchpad_list_notes_input_schema()
    }

    fn capabilities(&self) -> Vec<ToolCapability> {
        vec![ToolCapability::ReadOnly]
    }

    fn approval_requirement(&self) -> ApprovalRequirement {
        ApprovalRequirement::Auto
    }

    async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
        let run_id = resolve_run_id(context, optional_str(&input, "run_id"))?;
        persist_scratchpad_run(context, &run_id);
        let area_id = required_str(&input, "area_id")?;
        let limit = input
            .get("limit")
            .and_then(|v| v.as_u64())
            .unwrap_or(20)
            .clamp(1, 100) as usize;
        let store = ScratchpadStore::open(context, &run_id)?;
        let notes = store.list_notes(area_id, limit)?;
        let out = json!({ "area_id": area_id, "notes": notes });
        Ok(ToolResult::success(
            serde_json::to_string_pretty(&out).unwrap_or_default(),
        ))
    }
}

#[derive(Debug, Default)]
pub struct ScratchpadSetAreaTool;

#[async_trait]
impl ToolSpec for ScratchpadSetAreaTool {
    fn name(&self) -> &'static str {
        "scratchpad_set_area"
    }

    fn description(&self) -> &'static str {
        "Update one inventory area status. status=done defaults require_min_notes=1; status=deferred defaults require_min_notes=0 (still needs kind=meta when require_deferred_meta is enabled). Non-empty `notes` satisfies require_min_notes via an implicit meta append when notes.jsonl is empty."
    }

    fn input_schema(&self) -> Value {
        scratchpad_set_area_input_schema()
    }

    fn capabilities(&self) -> Vec<ToolCapability> {
        vec![ToolCapability::WritesFiles]
    }

    fn approval_requirement(&self) -> ApprovalRequirement {
        ApprovalRequirement::Auto
    }

    async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
        let run_id = resolve_run_id(context, optional_str(&input, "run_id"))?;
        let area_id = required_str(&input, "area_id")?;
        let status_str = required_str(&input, "status")?;
        let status = AreaStatus::from_str(status_str).ok_or_else(|| {
            ToolError::invalid_input(format!(
                "invalid status '{status_str}'; use pending|in_progress|done|deferred"
            ))
        })?;
        let remark = optional_str(&input, "notes");
        let require_min = input
            .get("require_min_notes")
            .and_then(|v| v.as_u64())
            .map(|v| v as usize)
            .unwrap_or_else(|| match status {
                AreaStatus::Done => 1,
                _ => 0,
            });
        let store = ScratchpadStore::open(context, &run_id)?;
        let scratchpad_cfg = context
            .runtime
            .wire
            .scratchpad_config
            .clone()
            .unwrap_or_default();
        let inventory =
            store.set_area_status(area_id, status, remark, require_min, &scratchpad_cfg)?;
        persist_scratchpad_run(context, &run_id);
        let areas_done = inventory
            .areas
            .iter()
            .filter(|a| a.status == AreaStatus::Done)
            .count();
        Ok(ToolResult::success(
            serde_json::to_string_pretty(&json!({
                "run_id": run_id,
                "area_id": area_id,
                "status": status.as_str(),
                "areas_done": areas_done,
            }))
            .unwrap_or_default(),
        ))
    }
}

#[derive(Debug, Default)]
pub struct ScratchpadVerifyNoteTool;

#[async_trait]
impl ToolSpec for ScratchpadVerifyNoteTool {
    fn name(&self) -> &'static str {
        "scratchpad_verify_note"
    }

    fn description(&self) -> &'static str {
        "Promote an open scratchpad note to status=verified (append-only supersede). \
         Call only after read_file/grep_files confirms the claim. Required before scratchpad_set_area(done) when open HIGH/BLOCKER findings exist."
    }

    fn input_schema(&self) -> Value {
        scratchpad_verify_note_input_schema()
    }

    fn capabilities(&self) -> Vec<ToolCapability> {
        vec![ToolCapability::WritesFiles]
    }

    fn approval_requirement(&self) -> ApprovalRequirement {
        ApprovalRequirement::Auto
    }

    async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
        let run_id = resolve_run_id(context, optional_str(&input, "run_id"))?;
        let note_id = required_str(&input, "note_id")?;
        let store = ScratchpadStore::open(context, &run_id)?;
        let note = verify_note(&store, note_id)?;
        persist_scratchpad_run(context, &run_id);
        Ok(ToolResult::success(
            serde_json::to_string_pretty(&json!({
                "verified_id": note.id,
                "supersedes": note_id,
                "status": note.status,
            }))
            .unwrap_or_default(),
        ))
    }
}