//! Problem tools: `list_problems` (read), `report_problems` (write, batch //! ingest), `promote_problem` (write, problem to task), and `update_problem` //! (write, triage state and project attribution). //! //! These are the surface `/audit`, `/fuzz`, and `deepaudit` write findings //! through. Before this existed, each run left a markdown file under //! `_private/docs//`, which is the scatter the 2026-07-24 cleanup //! deleted; a finding now lands as a row that ranks alongside every other //! problem instead of a file nobody re-reads. //! //! The split between reporting and promoting is the whole model: a problem is a //! candidate, and only [`PromoteProblem`] turns one into a task. A skill can //! report freely without filling the task list with work nobody chose. //! //! Ingest is idempotent on `(source, source_ref)`, so re-running an audit //! refreshes its findings rather than duplicating them, and re-reporting //! something already promoted or dismissed does not undo that decision. use std::collections::HashMap; use std::sync::Arc; use async_trait::async_trait; use chrono::Utc; use goingson_core::models::DbValue; use goingson_core::repository::{ProblemRepository, ProjectRepository, TaskCrud}; use goingson_core::{ NewProblem, NewTask, Priority, ProblemFilter, ProblemId, ProblemStatus, ProjectId, }; use kberg::{Error, Result, Tool, ToolCallResult, ToolKind}; use serde_json::{Value, json}; use uuid::Uuid; use crate::caps; use crate::context::Ctx; use crate::convert::{parse_enum, parse_tags, req_str}; /// The accepted wire words for a problem's triage state. const PROBLEM_STATUSES: &[&str] = &["Open", "Promoted", "Dismissed", "Resolved"]; /// The source name a tool reports under when it does not say. Skills should /// always pass their own (`audit`, `fuzz`, `deepaudit`) so findings can be /// filtered and re-pulled per lens. const DEFAULT_SOURCE: &str = "mcp"; fn fail(tool: &str, e: impl std::fmt::Display) -> Error { Error::ToolFailed { tool: tool.to_string(), message: e.to_string(), } } /// Parse a 1-5 painhours factor, defaulting to 3 when absent. /// /// Out-of-range values are an error rather than a clamp: a caller that wrote 8 /// meant something by it, and silently scoring it as 5 would misrank the /// finding without saying so. (The storage layer still clamps, because a row /// already on disk must never fail a read.) fn parse_factor(tool: &str, args: &Value, field: &str) -> std::result::Result { let Some(raw) = args.get(field) else { return Ok(3); }; if raw.is_null() { return Ok(3); } raw.as_u64() .filter(|n| (1..=5).contains(n)) .map(|n| n as u8) .ok_or_else(|| Error::InvalidArgs { tool: tool.to_string(), message: format!("`{field}` must be an integer 1-5 (got `{raw}`)"), }) } /// Parse a problem id string into a [`ProblemId`], or an `InvalidArgs`. fn parse_problem_id(tool: &str, s: &str) -> std::result::Result { Uuid::parse_str(s.trim()) .map(ProblemId::from_uuid) .map_err(|_| Error::InvalidArgs { tool: tool.to_string(), message: format!("`{s}` is not a valid problem id (expected a UUID)"), }) } /// Compact JSON projection of a problem for the read tools. /// /// `painhours` and `band` are computed at projection time, not stored, so a row /// read twice a month apart reports two different scores. That is the model /// working: an untouched problem gets more urgent. fn problem_row(p: &goingson_core::Problem) -> Value { json!({ "id": p.id.to_string(), "source": p.source, "source_ref": p.source_ref, "title": p.title, "body": p.body, "pain": p.pain, "scale": p.scale, "painhours": p.painhours(), "band": p.band().to_string(), "age": p.age(), "status": p.status.db_value(), "project_id": p.project_id.map(|id: ProjectId| id.to_string()), "tags": p.tags, "promoted_task_id": p.promoted_task_id.map(|id| id.to_string()), }) } pub struct ListProblems(pub Arc); #[async_trait] impl Tool for ListProblems { fn name(&self) -> &str { "list_problems" } fn description(&self) -> &str { "List problems, most urgent first. A problem is a candidate for work pulled from a source (wam tickets, audit and fuzz findings); it is not a task until promoted. Ranked by `painhours`, a 0-100 score computed from pain, scale, and age, so an untriaged problem climbs on its own. Filters: `source`, `status` (Open|Promoted|Dismissed|Resolved), `project_id`. Defaults to Open only; pass status explicitly to see settled ones." } fn kind(&self) -> ToolKind { ToolKind::Read } fn small_model_safe(&self) -> bool { true } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "source": { "type": "string", "description": "Restrict to one source, e.g. wam, audit, fuzz." }, "status": { "type": "string", "description": "Open | Promoted | Dismissed | Resolved. Omit for Open only; pass `all` for every status." }, "project_id": { "type": "string" } } }) } async fn call(&self, args: Value) -> Result { // Default to Open: the inbox question is "what is untriaged", and a // settled problem is answered work. `all` is the explicit escape. let status = match args.get("status").and_then(Value::as_str) { Some(s) if s.trim().eq_ignore_ascii_case("all") => None, Some(s) => Some(parse_enum::( self.name(), "status", s, PROBLEM_STATUSES, )?), None => Some(ProblemStatus::Open), }; let project_id = match args.get("project_id").and_then(Value::as_str) { Some(s) => Some( Uuid::parse_str(s.trim()) .map(ProjectId::from_uuid) .map_err(|_| Error::InvalidArgs { tool: self.name().to_string(), message: format!("`{s}` is not a valid project id (expected a UUID)"), })?, ), None => None, }; let filter = ProblemFilter { source: args .get("source") .and_then(Value::as_str) .map(str::to_string), status, project_id, }; let problems = self .0 .problems() .list(self.0.user_id, &filter) .await .map_err(|e| fail(self.name(), e))?; let rows: Vec = problems.iter().map(problem_row).collect(); Ok(ToolCallResult::text( serde_json::to_string(&json!({ "count": rows.len(), "problems": rows })).unwrap(), )) } } pub struct ReportProblems(pub Arc); #[async_trait] impl Tool for ReportProblems { fn name(&self) -> &str { "report_problems" } fn description(&self) -> &str { "Report findings as problems (the /audit and /fuzz primitive). Each item: {title, body?, pain?, scale?, source_ref?, project?, tags?, resolved?}. `pain` (how much it hurts a hit user) and `scale` (how broadly it hits) are 1-5, defaulting to 3, and together with age drive the painhours ranking. `source` names the lens (audit, fuzz, deepaudit) and `source_ref` identifies the finding within it; re-reporting the same pair updates that problem instead of duplicating it, and never undoes a promotion or dismissal. Reporting does NOT create tasks: use promote_problem for the ones worth working." } fn kind(&self) -> ToolKind { ToolKind::Write(caps::problem_report()) } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "source": { "type": "string", "description": "Which lens is reporting: audit, fuzz, deepaudit. Defaults to `mcp`." }, "problems": { "type": "array", "items": { "type": "object", "properties": { "title": { "type": "string", "description": "One line stating the problem." }, "body": { "type": "string", "description": "Detail: where it is, why it matters, how it fails." }, "pain": { "type": "integer", "description": "1-5, how much it hurts a hit user. Default 3." }, "scale": { "type": "integer", "description": "1-5, how broadly it hits. Default 3." }, "source_ref": { "type": "string", "description": "Stable id for this finding within the source (e.g. module:check). Defaults to the title, so a re-run with the same title updates in place." }, "project": { "type": "string", "description": "Project name; created if absent." }, "tags": { "type": "array", "items": { "type": "string" } }, "resolved": { "type": "boolean", "description": "The source considers this fixed. Settles an untriaged problem; leaves a promoted or dismissed one alone." } }, "required": ["title"] } } }, "required": ["problems"] }) } async fn call(&self, args: Value) -> Result { let items = args .get("problems") .and_then(Value::as_array) .ok_or_else(|| Error::InvalidArgs { tool: self.name().to_string(), message: "missing array field `problems`".into(), })?; let source = args .get("source") .and_then(Value::as_str) .map(str::trim) .filter(|s| !s.is_empty()) .unwrap_or(DEFAULT_SOURCE) .to_string(); let repo = self.0.problems(); let mut project_cache: HashMap = HashMap::new(); let mut reported = Vec::new(); for (idx, item) in items.iter().enumerate() { let title = item .get("title") .and_then(Value::as_str) .map(str::trim) .filter(|s| !s.is_empty()) .ok_or_else(|| Error::InvalidArgs { tool: self.name().to_string(), message: format!("problems[{idx}] is missing a non-empty `title`"), })? .to_string(); // Falling back to the title keeps a skill that did not think about // ids idempotent anyway: report the same finding twice and it // updates in place. let source_ref = item .get("source_ref") .and_then(Value::as_str) .map(str::trim) .filter(|s| !s.is_empty()) .unwrap_or(&title) .to_string(); let project_id = match item.get("project").and_then(Value::as_str) { Some(name) if !name.trim().is_empty() => Some( resolve_project_cached(&self.0, name, &mut project_cache) .await .map_err(|e| fail(self.name(), e))?, ), _ => None, }; let now = Utc::now(); let problem = repo .ingest( self.0.user_id, NewProblem { source: source.clone(), source_ref, title, body: item .get("body") .and_then(Value::as_str) .unwrap_or_default() .to_string(), pain: parse_factor(self.name(), item, "pain")?, scale: parse_factor(self.name(), item, "scale")?, project_id, tags: parse_tags(item.get("tags")), created_at: now, updated_at: now, resolved_upstream: item .get("resolved") .and_then(Value::as_bool) .unwrap_or(false), }, ) .await .map_err(|e| fail(self.name(), e))?; reported.push(problem); } // `ingest` upserts, so "new" is what the caller cares about: how much of // this run was actually new versus a refresh of what was already known. let new_count = reported .iter() .filter(|p| p.status == ProblemStatus::Open && p.promoted_task_id.is_none()) .count(); Ok(ToolCallResult::text( serde_json::to_string(&json!({ "reported": reported.len(), "open": new_count, "problems": reported.iter().map(problem_row).collect::>(), })) .unwrap(), )) } } pub struct PromoteProblem(pub Arc); #[async_trait] impl Tool for PromoteProblem { fn name(&self) -> &str { "promote_problem" } fn description(&self) -> &str { "Turn a problem into a task: creates the task, links it back to the problem, and marks the problem Promoted so it stops climbing the ranking. `description` defaults to the problem's title and body. The task inherits the problem's project and tags plus a `problem::` provenance tag. This is the only path from a problem to a task; reporting one never creates work on its own." } fn kind(&self) -> ToolKind { ToolKind::Write(caps::problem_promote()) } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "id": { "type": "string", "description": "The problem's id, from list_problems." }, "description": { "type": "string", "description": "Task description. Defaults to the problem's title, with its body appended." }, "priority": { "type": "string", "description": "High | Medium | Low. Defaults from the problem's painhours band." } }, "required": ["id"] }) } async fn call(&self, args: Value) -> Result { let id = parse_problem_id(self.name(), req_str(self.name(), &args, "id")?)?; let repo = self.0.problems(); let problem = repo .get_by_id(id, self.0.user_id) .await .map_err(|e| fail(self.name(), e))? .ok_or_else(|| Error::ToolFailed { tool: self.name().to_string(), message: format!("no problem with id `{id}`"), })?; if let Some(existing) = problem.promoted_task_id { // Idempotent rather than an error: a retried promote should report // the task it already made, not make a second one. return Ok(ToolCallResult::text( serde_json::to_string(&json!({ "task_id": existing.to_string(), "problem_id": problem.id.to_string(), "created": false, })) .unwrap(), )); } let description = match args.get("description").and_then(Value::as_str) { Some(d) if !d.trim().is_empty() => d.trim().to_string(), _ if problem.body.trim().is_empty() => problem.title.clone(), _ => format!("{}\n\n{}", problem.title, problem.body), }; // The band is the problem's own judgement of urgency, so it is the // sensible default priority. Critical and High both map to High: // GoingsOn tasks have no fourth level. let priority = match args.get("priority").and_then(Value::as_str) { Some(p) => Priority::from_str_or_default(p), None => match problem.band() { painhours::Priority::Critical | painhours::Priority::High => Priority::High, painhours::Priority::Medium => Priority::Medium, painhours::Priority::Low => Priority::Low, }, }; let mut tags = problem.tags.clone(); tags.push(format!("problem:{}:{}", problem.source, problem.source_ref)); let mut builder = NewTask::builder(description).priority(priority).tags(tags); if let Some(pid) = problem.project_id { builder = builder.project_id(pid); } let task = self .0 .tasks() .create(self.0.user_id, builder.build()) .await .map_err(|e| fail(self.name(), e))?; // Link second: if this fails the task still exists and the problem // stays Open, so a retry finds no backlink and is safe to run again. // The reverse order could mark a problem promoted with no task behind // it, which is the state nothing recovers from. repo.promote(problem.id, self.0.user_id, task.id) .await .map_err(|e| fail(self.name(), e))?; Ok(ToolCallResult::text( serde_json::to_string(&json!({ "task_id": task.id.to_string(), "problem_id": problem.id.to_string(), "created": true, })) .unwrap(), )) } } /// Named `...Tool` to match `UpdateProjectTool` and `UpdateTaskTool`. pub struct UpdateProblemTool(pub Arc); #[async_trait] impl Tool for UpdateProblemTool { fn name(&self) -> &str { "update_problem" } fn description(&self) -> &str { "Update a problem's triage state or project. `status` is Open|Promoted|Dismissed|Resolved: Dismissed is the 'seen it, not acting' verdict, and Open sends a settled problem back to the inbox (clearing any task backlink). Prefer dismissing over deleting, so a re-pull does not resurrect something already ruled on. Use promote_problem to create a task; setting status Promoted here only marks it, without one." } fn kind(&self) -> ToolKind { ToolKind::Write(caps::problem_update()) } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "id": { "type": "string" }, "status": { "type": "string", "description": "Open | Promoted | Dismissed | Resolved" }, "project": { "type": "string", "description": "Project name to attribute this problem to; created if absent. Pass an empty string to clear." } }, "required": ["id"] }) } async fn call(&self, args: Value) -> Result { let id = parse_problem_id(self.name(), req_str(self.name(), &args, "id")?)?; let repo = self.0.problems(); let mut current = repo .get_by_id(id, self.0.user_id) .await .map_err(|e| fail(self.name(), e))? .ok_or_else(|| Error::ToolFailed { tool: self.name().to_string(), message: format!("no problem with id `{id}`"), })?; if let Some(raw) = args.get("project").and_then(Value::as_str) { let project_id = if raw.trim().is_empty() { None } else { let mut cache = HashMap::new(); Some( resolve_project_cached(&self.0, raw, &mut cache) .await .map_err(|e| fail(self.name(), e))?, ) }; current = repo .set_project(id, self.0.user_id, project_id) .await .map_err(|e| fail(self.name(), e))? .ok_or_else(|| Error::ToolFailed { tool: self.name().to_string(), message: format!("problem `{id}` vanished during update"), })?; } if let Some(raw) = args.get("status").and_then(Value::as_str) { let status = parse_enum(self.name(), "status", raw, PROBLEM_STATUSES)?; current = repo .set_status(id, self.0.user_id, status) .await .map_err(|e| fail(self.name(), e))? .ok_or_else(|| Error::ToolFailed { tool: self.name().to_string(), message: format!("problem `{id}` vanished during update"), })?; } Ok(ToolCallResult::text( serde_json::to_string(&problem_row(¤t)).unwrap(), )) } } /// Resolve a project name to an id, creating it if absent, with a within-call /// cache so a batch referencing one project repeatedly hits the database once. /// /// Mirrors `task.rs`'s helper of the same shape. Kept separate rather than /// shared because the task module's version is private to it and a cross-module /// `pub(crate)` here would make two unrelated tool families share a seam neither /// needs. async fn resolve_project_cached( ctx: &Ctx, name: &str, cache: &mut HashMap, ) -> goingson_core::repository::Result { if let Some(id) = cache.get(name) { return Ok(*id); } let repo = ctx.projects(); let id = match repo.find_by_name(ctx.user_id, name).await? { Some(existing) => existing.id, None => { repo.create( ctx.user_id, goingson_core::NewProject { name: name.to_string(), description: String::new(), project_type: goingson_core::ProjectType::SideProject, status: goingson_core::ProjectStatus::default(), }, ) .await? .id } }; cache.insert(name.to_string(), id); Ok(id) }