sara-tasks 2.7.4

Sara — folder-aware task manager
use rmcp::handler::server::wrapper::Parameters;
use rmcp::{tool, tool_router};

use crate::commands;
use crate::infrastructure::telemetry::Source;

use super::params::*;
use super::server::{SaraServer, mcp_err, ok_json};

#[tool_router(router = lifecycle_router, vis = "pub(crate)")]
impl SaraServer {
    #[tool(
        description = "Mark a task complete: stops its timer, renumbers display ids (keep using the UUID), and spawns the next occurrence of a recurring task. Errors if the task is blocked by unfinished dependencies, or if it is targeted by display id from a different git branch than it is tied to; force=true overrides both. Refuses a task whose acceptance criteria were not validated at the current HEAD: pass validate=true to run the gate first, or use `finish` (link PR + validate + done in one call). Call it only once the task's PR has merged, not when the PR is opened. Pass `used` with the memory labels that helped the task, to cite them; an unknown label fails the call before the task is closed.",
        annotations(
            read_only_hint = false,
            destructive_hint = true,
            idempotent_hint = false,
            open_world_hint = false
        )
    )]
    fn done(&self, Parameters(p): Parameters<DoneParams>) -> Result<String, String> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp done", |conn, cfg| {
                let used = p.used.as_deref().unwrap_or(&[]);
                if p.validate.unwrap_or(false) {
                    commands::guide::validate_value(
                        conn,
                        &p.id,
                        false,
                        commands::guide::GateOutput::Capture,
                        false,
                    )?;
                }
                let v = commands::shared::with_citation(conn, &p.id, used, || {
                    commands::done::done_value(conn, cfg, &p.id, p.force.unwrap_or(false))
                })
                .map_err(with_done_hint)?;
                commands::done::report_outcome(conn, cfg, Source::Mcp, &v);
                Ok(v)
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "Attach a URL, such as a PR or issue, to a task. `label` is optional display text. Linking a URL the task already has is a no-op and returns `already_linked: true`.",
        annotations(
            read_only_hint = false,
            destructive_hint = false,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    fn link(&self, Parameters(p): Parameters<LinkParams>) -> Result<String, String> {
        let v = self
            .with_task(
                p.project_path.as_deref(),
                "mcp link",
                &p.id,
                |conn, _cfg| {
                    commands::annotate::link_value(conn, &p.id, &p.url, p.label.as_deref())
                },
            )
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "Manage task dependencies. action=\"on\": `id` becomes blocked by `other`; \"off\": remove that edge; \"list\": show the task's blockers and what it blocks. `other` is required for on/off; edges that would form a cycle are refused.",
        annotations(
            read_only_hint = false,
            destructive_hint = true,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    fn dep(&self, Parameters(p): Parameters<DepParams>) -> Result<String, String> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp dep", |conn, cfg| {
                match p.action.as_str() {
                    "on" => {
                        let other = p
                            .other
                            .as_deref()
                            .ok_or_else(|| anyhow::anyhow!("dep action \"on\" requires `other`"))?;
                        commands::dep::dep_on_value(conn, cfg, &p.id, other)
                    }
                    "off" => {
                        let other = p.other.as_deref().ok_or_else(|| {
                            anyhow::anyhow!("dep action \"off\" requires `other`")
                        })?;
                        commands::dep::dep_off_value(conn, cfg, &p.id, other)
                    }
                    "list" => commands::dep::dep_list_value(conn, &p.id),
                    other => {
                        anyhow::bail!(
                            "unknown dep action `{other}` (expected \"on\", \"off\", or \"list\")"
                        )
                    }
                }
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "Prove a task green then stamp its guide as validated against the project's current git HEAD. Fail-closed: runs each acceptance criterion's verify command and refuses (errors) unless all are present and pass. Criteria already proven at the current commit with a clean working tree are reused from cache (skipped); identical verify commands run once. Errors if the project is not a git repo.",
        annotations(
            read_only_hint = false,
            destructive_hint = false,
            idempotent_hint = true,
            open_world_hint = true
        )
    )]
    fn validate(&self, Parameters(p): Parameters<IdParams>) -> Result<String, String> {
        let v = self
            .with_task(
                p.project_path.as_deref(),
                "mcp validate",
                &p.id,
                |conn, _cfg| {
                    commands::guide::validate_value(
                        conn,
                        &p.id,
                        false,
                        commands::guide::GateOutput::Capture,
                        false,
                    )
                },
            )
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "Set task fields without opening the TUI; pass at least one. `tags` REPLACES the whole tag set. `assignment` and `rationale` replace the guide's originating request and its why. Clear fields with `clear_due`, `clear_tags`, `clear_estimate` or `clear_recur`.",
        annotations(
            read_only_hint = false,
            destructive_hint = true,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    fn modify(&self, Parameters(p): Parameters<ModifyParams>) -> Result<String, String> {
        let tags = p.tags.clone().unwrap_or_default();
        let fields = p.description.is_some()
            || p.priority.is_some()
            || p.due.is_some()
            || p.clear_due.unwrap_or(false)
            || !tags.is_empty()
            || p.clear_tags.unwrap_or(false)
            || p.estimate.is_some()
            || p.clear_estimate.unwrap_or(false)
            || p.every.is_some()
            || p.clear_recur.unwrap_or(false);
        let v = self
            .with_project(p.project_path.as_deref(), "mcp modify", |conn, cfg| {
                let mut out = serde_json::json!({});
                if let Some(text) = p.assignment.as_deref() {
                    out["assignment"] = commands::guide::assignment_value(conn, &p.id, text)?;
                }
                if let Some(text) = p.rationale.as_deref() {
                    out["rationale"] = commands::guide::rationale_value(conn, &p.id, text)?;
                }
                if !fields && out.as_object().is_some_and(|o| !o.is_empty()) {
                    return Ok(out);
                }
                let mut v = commands::modify::modify_value(
                    conn,
                    cfg,
                    &p.id,
                    p.description.as_deref(),
                    p.priority.as_deref(),
                    p.due.as_deref(),
                    p.clear_due.unwrap_or(false),
                    &tags,
                    p.clear_tags.unwrap_or(false),
                    p.estimate.as_deref(),
                    p.clear_estimate.unwrap_or(false),
                    p.every.as_deref(),
                    p.clear_recur.unwrap_or(false),
                )?;
                if let (Some(obj), Some(extra)) = (v.as_object_mut(), out.as_object()) {
                    obj.extend(extra.clone());
                }
                Ok(v)
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "Close a task from its pull request in one call. Links `pr_url` (once; omit it to use the PR already linked), then checks whether the PR has merged (`merged`, or else `gh pr view`). Not merged yet: returns status=waiting_for_merge and changes nothing else, so it is safe to call right after opening the PR. Merged: runs the acceptance gate at the current HEAD (as `validate` does; check out and pull the base branch first) and marks the task done, citing `used` memories. Prefer it over link -> validate -> done.",
        annotations(
            read_only_hint = false,
            destructive_hint = true,
            idempotent_hint = false,
            open_world_hint = true
        )
    )]
    fn finish(&self, Parameters(p): Parameters<FinishParams>) -> Result<String, String> {
        let used = p.used.clone().unwrap_or_default();
        let v = self
            .with_project(p.project_path.as_deref(), "mcp finish", |conn, cfg| {
                let v = commands::agent::finish_value(
                    conn,
                    cfg,
                    &commands::agent::FinishRequest {
                        id: &p.id,
                        pr_url: p.pr_url.as_deref(),
                        merged: p.merged,
                        used: &used,
                    },
                )
                .map_err(with_done_hint)?;
                if v["status"] == "done" {
                    commands::done::report_outcome(conn, cfg, Source::Mcp, &v["done"]);
                }
                Ok(v)
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "Resolve a feedback item by its `feedback_id` (the annotation id from the `feedback`/`info` output — NOT a task id). Optionally cite the `run_id` (from `record_run`) that addressed it.",
        annotations(
            read_only_hint = false,
            destructive_hint = false,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    fn resolve(&self, Parameters(p): Parameters<ResolveParams>) -> Result<String, String> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp resolve", |conn, _cfg| {
                commands::guide::resolve_value(conn, p.feedback_id, p.run_id)
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "Record an AI/LLM interaction against a task as an audit-trail entry (shown in the task's AI activity). `kind` is required; model, provider, prompt, response and token counts are optional. Returns a `run_id` you can cite in `resolve`.",
        annotations(
            read_only_hint = false,
            destructive_hint = false,
            idempotent_hint = false,
            open_world_hint = false
        )
    )]
    fn record_run(&self, Parameters(p): Parameters<RecordRunParams>) -> Result<String, String> {
        let total = p
            .total_tokens
            .or_else(|| p.prompt_tokens.zip(p.completion_tokens).map(|(a, b)| a + b));
        let v = self
            .with_project(p.project_path.as_deref(), "mcp record_run", |conn, _cfg| {
                commands::guide::record_run_value(
                    conn,
                    &p.id,
                    &p.kind,
                    p.model.as_deref(),
                    p.provider.as_deref(),
                    p.prompt.as_deref(),
                    p.response.as_deref(),
                    p.prompt_tokens,
                    p.completion_tokens,
                    total,
                )
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "Remove a link from a task: by `link_id` (shown in `info`), or by the task `id` and the linked `url`.",
        annotations(
            read_only_hint = false,
            destructive_hint = true,
            idempotent_hint = false,
            open_world_hint = false
        )
    )]
    fn unlink(&self, Parameters(p): Parameters<UnlinkParams>) -> Result<String, String> {
        let v = match (p.link_id, p.id.as_deref(), p.url.as_deref()) {
            (Some(link_id), None, None) => {
                self.with_project(p.project_path.as_deref(), "mcp unlink", |conn, _cfg| {
                    commands::annotate::unlink_value(conn, link_id)
                })
            }
            (None, Some(id), Some(url)) => {
                self.with_task(p.project_path.as_deref(), "mcp unlink", id, |conn, _cfg| {
                    commands::annotate::unlink_url_value(conn, id, url)
                })
            }
            _ => Err(anyhow::anyhow!(
                "Pass either `link_id`, or both `id` and `url`"
            )),
        }
        .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "Remove an annotation from a task by its annotation id (shown in `info`).",
        annotations(
            read_only_hint = false,
            destructive_hint = true,
            idempotent_hint = false,
            open_world_hint = false
        )
    )]
    fn denotate(&self, Parameters(p): Parameters<DenotateParams>) -> Result<String, String> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp denotate", |conn, _cfg| {
                commands::annotate::denotate_value(conn, p.annotation_id)
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "Move a task to another project, given the target project's name.",
        annotations(
            read_only_hint = false,
            destructive_hint = false,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    fn move_task(&self, Parameters(p): Parameters<MoveTaskParams>) -> Result<String, String> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp move_task", |conn, cfg| {
                commands::move_task::move_value(conn, cfg, &p.id, &p.project)
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }
}

/// `done` refusals are phrased for the CLI; point MCP callers at the tool
/// arguments that get them past the gate.
fn with_done_hint(e: anyhow::Error) -> anyhow::Error {
    let msg = format!("{e:#}");
    if msg.contains("is not validated") {
        anyhow::anyhow!(
            "{msg} From MCP: call `done` with validate=true to run the acceptance gate first, \
             or `finish` with the PR url once it has merged; force=true closes without proof \
             (discouraged)."
        )
    } else {
        e
    }
}