Skip to main content

max / goingson

Add the go-mcp problem tools The MCP surface /audit, /fuzz, and deepaudit will report findings through, so a run leaves rows that rank against every other problem instead of a markdown file under _private/docs that nobody re-reads. report_problems is the counterpart to bulk_import_tasks for the problem side: batch, and idempotent on (source, source_ref), so re-running an audit refreshes its findings and a dismissed one stays dismissed. Reporting deliberately creates no tasks; promote_problem is the only path from a problem to work, which is what lets a lens report everything it finds without filling the task list. promote_problem creates the task before writing the backlink. The reverse order could mark a problem promoted with no task behind it, which nothing recovers from; this way a failure leaves a stray task and an Open problem, and a retry is safe. Out-of-range pain/scale is rejected at the tool boundary rather than clamped: a caller that wrote 8 meant something, and scoring it 5 in silence would misrank the finding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 01:27 UTC
Commit: d80be8da93e4d8debf4486d792adc80fabc0b657
Parent: 86cc60d
9 files changed, +729 insertions, -3 deletions
M Cargo.lock +1
@@ -2197,6 +2197,7 @@ dependencies = [
2197 2197 "goingson-core",
2198 2198 "goingson-db-sqlite",
2199 2199 "kberg",
2200 + "painhours",
2200 2201 "serde",
2201 2202 "serde_json",
2202 2203 "sqlx",
@@ -13,6 +13,7 @@ path = "src/main.rs"
13 13
14 14 [dependencies]
15 15 goingson-core = { workspace = true, features = ["sqlx-sqlite"] }
16 + painhours = { workspace = true }
16 17 goingson-db-sqlite = { workspace = true }
17 18 kberg = { workspace = true }
18 19
@@ -38,6 +38,9 @@ Read:
38 38 row marked `truncated`; `get_task` has the full text. The reply carries
39 39 `total` and, while pages remain, `next_offset` — walk until it is absent.
40 40 - `get_task(id)` — one task with subtasks and annotations.
41 + - `list_problems(source?, status?, project_id?)` — problems ranked by
42 + `painhours`, most urgent first. Defaults to `Open`; pass `status: "all"` for
43 + settled ones too.
41 44
42 45 Write (each gated on a capability id):
43 46
@@ -49,11 +52,35 @@ Write (each gated on a capability id):
49 52 | `bulk_import_tasks([...])` — the `/dellm` primitive | `go.task.bulk_import` |
50 53 | `update_task(id, fields)` — overlays only the fields you pass | `go.task.update` |
51 54 | `complete_task(id)` | `go.task.complete` |
55 + | `report_problems([...], source?)` — the `/audit` and `/fuzz` primitive | `go.problem.report` |
56 + | `promote_problem(id, description?, priority?)` — problem to task | `go.problem.promote` |
57 + | `update_problem(id, {status?, project?})` — triage state and attribution | `go.problem.update` |
52 58
53 59 `bulk_import_tasks` dedupes on a `source:` provenance tag (e.g.
54 60 `source:todo.md:42`), so re-running a migration wave does not double-insert.
55 61 Projects referenced by name are resolved, and created if absent.
56 62
63 + ## Problems
64 +
65 + GoingsOn is the list of solutions. A *problem* is a candidate for work that came
66 + from somewhere else — a wam ticket, an `/audit` or `/fuzz` finding — and it is
67 + not a task until someone promotes it. That split is the point: a lens can report
68 + everything it finds without filling the task list with work nobody chose.
69 +
70 + Urgency is not stored. Each problem carries `pain` (how much it hurts a hit
71 + user) and `scale` (how broadly it hits), both 1-5, which combine with its age
72 + into a 0-100 `painhours` score from the shared `painhours` crate — the same
73 + model wam ranks tickets by, so one list can span both. An untriaged problem
74 + climbs on its own; promoting or dismissing it freezes the score.
75 +
76 + `report_problems` upserts on `(source, source_ref)`. Re-running an audit
77 + refreshes its findings rather than duplicating them, and never undoes a
78 + promotion or dismissal, so a dismissed finding stays dismissed across runs.
79 + `source_ref` defaults to the title when a caller does not supply one.
80 +
81 + Prefer `update_problem(status: "Dismissed")` over deleting: a deleted problem
82 + comes straight back on the next report, while a dismissed one stays down.
83 +
57 84 `update_project` is how a session retires a project: set `status` to `Archived`.
58 85 There is no `delete_project` — the underlying delete is a hard DELETE with no
59 86 soft-delete behind it, which is the wrong default to hand a session. Renaming is
@@ -7,6 +7,9 @@ use kberg::WriteCapability;
7 7
8 8 pub const PROJECT_CREATE: &str = "go.project.create";
9 9 pub const PROJECT_UPDATE: &str = "go.project.update";
10 + pub const PROBLEM_REPORT: &str = "go.problem.report";
11 + pub const PROBLEM_PROMOTE: &str = "go.problem.promote";
12 + pub const PROBLEM_UPDATE: &str = "go.problem.update";
10 13 pub const TASK_CREATE: &str = "go.task.create";
11 14 pub const TASK_BULK_IMPORT: &str = "go.task.bulk_import";
12 15 pub const TASK_UPDATE: &str = "go.task.update";
@@ -18,6 +21,15 @@ pub fn project_create() -> WriteCapability {
18 21 pub fn project_update() -> WriteCapability {
19 22 WriteCapability::new(PROJECT_UPDATE, "update an existing project")
20 23 }
24 + pub fn problem_report() -> WriteCapability {
25 + WriteCapability::new(PROBLEM_REPORT, "report findings as problems")
26 + }
27 + pub fn problem_promote() -> WriteCapability {
28 + WriteCapability::new(PROBLEM_PROMOTE, "promote a problem into a task")
29 + }
30 + pub fn problem_update() -> WriteCapability {
31 + WriteCapability::new(PROBLEM_UPDATE, "update a problem's triage state")
32 + }
21 33 pub fn task_create() -> WriteCapability {
22 34 WriteCapability::new(TASK_CREATE, "create one task")
23 35 }
@@ -8,7 +8,7 @@
8 8 use std::path::PathBuf;
9 9
10 10 use goingson_core::UserId;
11 - use goingson_db_sqlite::{SqliteProjectRepository, SqliteTaskRepository};
11 + use goingson_db_sqlite::{SqliteProblemRepository, SqliteProjectRepository, SqliteTaskRepository};
12 12 use sqlx::SqlitePool;
13 13 use uuid::Uuid;
14 14
@@ -36,6 +36,10 @@ impl Ctx {
36 36 SqliteTaskRepository::new(self.pool.clone())
37 37 }
38 38
39 + pub fn problems(&self) -> SqliteProblemRepository {
40 + SqliteProblemRepository::new(self.pool.clone())
41 + }
42 +
39 43 pub fn projects(&self) -> SqliteProjectRepository {
40 44 SqliteProjectRepository::new(self.pool.clone())
41 45 }
@@ -91,7 +91,8 @@ fn print_help() {
91 91 \x20 JSON-RPC over stdin/stdout (for `claude mcp add go-mcp -- go-mcp --stdio`).\n\n\
92 92 Write capabilities (grant to enable the matching tool):\n\
93 93 \x20 go.project.create go.task.create go.task.bulk_import\n\
94 - \x20 go.task.update go.task.complete\n"
94 + \x20 go.task.update go.task.complete\n\
95 + \x20 go.problem.report go.problem.promote go.problem.update\n"
95 96 );
96 97 }
97 98
@@ -7,9 +7,11 @@ use kberg::ToolRegistry;
7 7
8 8 use crate::context::Ctx;
9 9
10 + mod problem;
10 11 mod project;
11 12 mod task;
12 13
14 + pub use problem::{ListProblems, PromoteProblem, ReportProblems, UpdateProblemTool};
13 15 pub use project::{CreateProject, ListProjects, UpdateProjectTool};
14 16 pub use task::{BulkImportTasks, CompleteTask, CreateTask, GetTask, ListTasks, UpdateTaskTool};
15 17
@@ -20,12 +22,16 @@ pub fn registry(ctx: Arc<Ctx>) -> ToolRegistry {
20 22 r.register(ListProjects(ctx.clone()));
21 23 r.register(ListTasks(ctx.clone()));
22 24 r.register(GetTask(ctx.clone()));
25 + r.register(ListProblems(ctx.clone()));
23 26 // Writes (capability-gated).
24 27 r.register(CreateProject(ctx.clone()));
25 28 r.register(UpdateProjectTool(ctx.clone()));
26 29 r.register(CreateTask(ctx.clone()));
27 30 r.register(BulkImportTasks(ctx.clone()));
28 31 r.register(UpdateTaskTool(ctx.clone()));
29 - r.register(CompleteTask(ctx));
32 + r.register(CompleteTask(ctx.clone()));
33 + r.register(ReportProblems(ctx.clone()));
34 + r.register(PromoteProblem(ctx.clone()));
35 + r.register(UpdateProblemTool(ctx));
30 36 r
31 37 }
@@ -0,0 +1,537 @@
1 + //! Problem tools: `list_problems` (read), `report_problems` (write, batch
2 + //! ingest), `promote_problem` (write, problem to task), and `update_problem`
3 + //! (write, triage state and project attribution).
4 + //!
5 + //! These are the surface `/audit`, `/fuzz`, and `deepaudit` write findings
6 + //! through. Before this existed, each run left a markdown file under
7 + //! `_private/docs/<project>/`, which is the scatter the 2026-07-24 cleanup
8 + //! deleted; a finding now lands as a row that ranks alongside every other
9 + //! problem instead of a file nobody re-reads.
10 + //!
11 + //! The split between reporting and promoting is the whole model: a problem is a
12 + //! candidate, and only [`PromoteProblem`] turns one into a task. A skill can
13 + //! report freely without filling the task list with work nobody chose.
14 + //!
15 + //! Ingest is idempotent on `(source, source_ref)`, so re-running an audit
16 + //! refreshes its findings rather than duplicating them, and re-reporting
17 + //! something already promoted or dismissed does not undo that decision.
18 +
19 + use std::collections::HashMap;
20 + use std::sync::Arc;
21 +
22 + use async_trait::async_trait;
23 + use chrono::Utc;
24 + use goingson_core::models::DbValue;
25 + use goingson_core::repository::{ProblemRepository, ProjectRepository, TaskCrud};
26 + use goingson_core::{
27 + NewProblem, NewTask, Priority, ProblemFilter, ProblemId, ProblemStatus, ProjectId,
28 + };
29 + use kberg::{Error, Result, Tool, ToolCallResult, ToolKind};
30 + use serde_json::{Value, json};
31 + use uuid::Uuid;
32 +
33 + use crate::caps;
34 + use crate::context::Ctx;
35 + use crate::convert::{parse_enum, parse_tags, req_str};
36 +
37 + /// The accepted wire words for a problem's triage state.
38 + const PROBLEM_STATUSES: &[&str] = &["Open", "Promoted", "Dismissed", "Resolved"];
39 +
40 + /// The source name a tool reports under when it does not say. Skills should
41 + /// always pass their own (`audit`, `fuzz`, `deepaudit`) so findings can be
42 + /// filtered and re-pulled per lens.
43 + const DEFAULT_SOURCE: &str = "mcp";
44 +
45 + fn fail(tool: &str, e: impl std::fmt::Display) -> Error {
46 + Error::ToolFailed {
47 + tool: tool.to_string(),
48 + message: e.to_string(),
49 + }
50 + }
51 +
52 + /// Parse a 1-5 painhours factor, defaulting to 3 when absent.
53 + ///
54 + /// Out-of-range values are an error rather than a clamp: a caller that wrote 8
55 + /// meant something by it, and silently scoring it as 5 would misrank the
56 + /// finding without saying so. (The storage layer still clamps, because a row
57 + /// already on disk must never fail a read.)
58 + fn parse_factor(tool: &str, args: &Value, field: &str) -> std::result::Result<u8, Error> {
59 + let Some(raw) = args.get(field) else {
60 + return Ok(3);
61 + };
62 + if raw.is_null() {
63 + return Ok(3);
64 + }
65 + raw.as_u64()
66 + .filter(|n| (1..=5).contains(n))
67 + .map(|n| n as u8)
68 + .ok_or_else(|| Error::InvalidArgs {
69 + tool: tool.to_string(),
70 + message: format!("`{field}` must be an integer 1-5 (got `{raw}`)"),
71 + })
72 + }
73 +
74 + /// Parse a problem id string into a [`ProblemId`], or an `InvalidArgs`.
75 + fn parse_problem_id(tool: &str, s: &str) -> std::result::Result<ProblemId, Error> {
76 + Uuid::parse_str(s.trim())
77 + .map(ProblemId::from_uuid)
78 + .map_err(|_| Error::InvalidArgs {
79 + tool: tool.to_string(),
80 + message: format!("`{s}` is not a valid problem id (expected a UUID)"),
81 + })
82 + }
83 +
84 + /// Compact JSON projection of a problem for the read tools.
85 + ///
86 + /// `painhours` and `band` are computed at projection time, not stored, so a row
87 + /// read twice a month apart reports two different scores. That is the model
88 + /// working: an untouched problem gets more urgent.
89 + fn problem_row(p: &goingson_core::Problem) -> Value {
90 + json!({
91 + "id": p.id.to_string(),
92 + "source": p.source,
93 + "source_ref": p.source_ref,
94 + "title": p.title,
95 + "body": p.body,
96 + "pain": p.pain,
97 + "scale": p.scale,
98 + "painhours": p.painhours(),
99 + "band": p.band().to_string(),
100 + "age": p.age(),
101 + "status": p.status.db_value(),
102 + "project_id": p.project_id.map(|id: ProjectId| id.to_string()),
103 + "tags": p.tags,
104 + "promoted_task_id": p.promoted_task_id.map(|id| id.to_string()),
105 + })
106 + }
107 +
108 + pub struct ListProblems(pub Arc<Ctx>);
109 +
110 + #[async_trait]
111 + impl Tool for ListProblems {
112 + fn name(&self) -> &str {
113 + "list_problems"
114 + }
115 + fn description(&self) -> &str {
116 + "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."
117 + }
118 + fn kind(&self) -> ToolKind {
119 + ToolKind::Read
120 + }
121 + fn small_model_safe(&self) -> bool {
122 + true
123 + }
124 + fn input_schema(&self) -> Value {
125 + json!({
126 + "type": "object",
127 + "properties": {
128 + "source": { "type": "string", "description": "Restrict to one source, e.g. wam, audit, fuzz." },
129 + "status": { "type": "string", "description": "Open | Promoted | Dismissed | Resolved. Omit for Open only; pass `all` for every status." },
130 + "project_id": { "type": "string" }
131 + }
132 + })
133 + }
134 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
135 + // Default to Open: the inbox question is "what is untriaged", and a
136 + // settled problem is answered work. `all` is the explicit escape.
137 + let status = match args.get("status").and_then(Value::as_str) {
138 + Some(s) if s.trim().eq_ignore_ascii_case("all") => None,
139 + Some(s) => Some(parse_enum::<ProblemStatus>(
140 + self.name(),
141 + "status",
142 + s,
143 + PROBLEM_STATUSES,
144 + )?),
145 + None => Some(ProblemStatus::Open),
146 + };
147 +
148 + let project_id = match args.get("project_id").and_then(Value::as_str) {
149 + Some(s) => Some(
150 + Uuid::parse_str(s.trim())
151 + .map(ProjectId::from_uuid)
152 + .map_err(|_| Error::InvalidArgs {
153 + tool: self.name().to_string(),
154 + message: format!("`{s}` is not a valid project id (expected a UUID)"),
155 + })?,
156 + ),
157 + None => None,
158 + };
159 +
160 + let filter = ProblemFilter {
161 + source: args
162 + .get("source")
163 + .and_then(Value::as_str)
164 + .map(str::to_string),
165 + status,
166 + project_id,
167 + };
168 +
169 + let problems = self
170 + .0
171 + .problems()
172 + .list(self.0.user_id, &filter)
173 + .await
174 + .map_err(|e| fail(self.name(), e))?;
175 +
176 + let rows: Vec<Value> = problems.iter().map(problem_row).collect();
177 + Ok(ToolCallResult::text(
178 + serde_json::to_string(&json!({ "count": rows.len(), "problems": rows })).unwrap(),
179 + ))
180 + }
181 + }
182 +
183 + pub struct ReportProblems(pub Arc<Ctx>);
184 +
185 + #[async_trait]
186 + impl Tool for ReportProblems {
187 + fn name(&self) -> &str {
188 + "report_problems"
189 + }
190 + fn description(&self) -> &str {
191 + "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."
192 + }
193 + fn kind(&self) -> ToolKind {
194 + ToolKind::Write(caps::problem_report())
195 + }
196 + fn input_schema(&self) -> Value {
197 + json!({
198 + "type": "object",
199 + "properties": {
200 + "source": { "type": "string", "description": "Which lens is reporting: audit, fuzz, deepaudit. Defaults to `mcp`." },
201 + "problems": {
202 + "type": "array",
203 + "items": {
204 + "type": "object",
205 + "properties": {
206 + "title": { "type": "string", "description": "One line stating the problem." },
207 + "body": { "type": "string", "description": "Detail: where it is, why it matters, how it fails." },
208 + "pain": { "type": "integer", "description": "1-5, how much it hurts a hit user. Default 3." },
209 + "scale": { "type": "integer", "description": "1-5, how broadly it hits. Default 3." },
210 + "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." },
211 + "project": { "type": "string", "description": "Project name; created if absent." },
212 + "tags": { "type": "array", "items": { "type": "string" } },
213 + "resolved": { "type": "boolean", "description": "The source considers this fixed. Settles an untriaged problem; leaves a promoted or dismissed one alone." }
214 + },
215 + "required": ["title"]
216 + }
217 + }
218 + },
219 + "required": ["problems"]
220 + })
221 + }
222 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
223 + let items = args
224 + .get("problems")
225 + .and_then(Value::as_array)
226 + .ok_or_else(|| Error::InvalidArgs {
227 + tool: self.name().to_string(),
228 + message: "missing array field `problems`".into(),
229 + })?;
230 +
231 + let source = args
232 + .get("source")
233 + .and_then(Value::as_str)
234 + .map(str::trim)
235 + .filter(|s| !s.is_empty())
236 + .unwrap_or(DEFAULT_SOURCE)
237 + .to_string();
238 +
239 + let repo = self.0.problems();
240 + let mut project_cache: HashMap<String, ProjectId> = HashMap::new();
241 + let mut reported = Vec::new();
242 +
243 + for (idx, item) in items.iter().enumerate() {
244 + let title = item
245 + .get("title")
246 + .and_then(Value::as_str)
247 + .map(str::trim)
248 + .filter(|s| !s.is_empty())
249 + .ok_or_else(|| Error::InvalidArgs {
250 + tool: self.name().to_string(),
251 + message: format!("problems[{idx}] is missing a non-empty `title`"),
252 + })?
253 + .to_string();
254 +
255 + // Falling back to the title keeps a skill that did not think about
256 + // ids idempotent anyway: report the same finding twice and it
257 + // updates in place.
258 + let source_ref = item
259 + .get("source_ref")
260 + .and_then(Value::as_str)
261 + .map(str::trim)
262 + .filter(|s| !s.is_empty())
263 + .unwrap_or(&title)
264 + .to_string();
265 +
266 + let project_id = match item.get("project").and_then(Value::as_str) {
267 + Some(name) if !name.trim().is_empty() => Some(
268 + resolve_project_cached(&self.0, name, &mut project_cache)
269 + .await
270 + .map_err(|e| fail(self.name(), e))?,
271 + ),
272 + _ => None,
273 + };
274 +
275 + let now = Utc::now();
276 + let problem = repo
277 + .ingest(
278 + self.0.user_id,
279 + NewProblem {
280 + source: source.clone(),
281 + source_ref,
282 + title,
283 + body: item
284 + .get("body")
285 + .and_then(Value::as_str)
286 + .unwrap_or_default()
287 + .to_string(),
288 + pain: parse_factor(self.name(), item, "pain")?,
289 + scale: parse_factor(self.name(), item, "scale")?,
290 + project_id,
291 + tags: parse_tags(item.get("tags")),
292 + created_at: now,
293 + updated_at: now,
294 + resolved_upstream: item
295 + .get("resolved")
296 + .and_then(Value::as_bool)
297 + .unwrap_or(false),
298 + },
299 + )
300 + .await
301 + .map_err(|e| fail(self.name(), e))?;
302 +
303 + reported.push(problem);
304 + }
305 +
306 + // `ingest` upserts, so "new" is what the caller cares about: how much of
307 + // this run was actually new versus a refresh of what was already known.
308 + let new_count = reported
309 + .iter()
310 + .filter(|p| p.status == ProblemStatus::Open && p.promoted_task_id.is_none())
311 + .count();
312 +
313 + Ok(ToolCallResult::text(
314 + serde_json::to_string(&json!({
315 + "reported": reported.len(),
316 + "open": new_count,
317 + "problems": reported.iter().map(problem_row).collect::<Vec<_>>(),
318 + }))
319 + .unwrap(),
320 + ))
321 + }
322 + }
323 +
324 + pub struct PromoteProblem(pub Arc<Ctx>);
325 +
326 + #[async_trait]
327 + impl Tool for PromoteProblem {
328 + fn name(&self) -> &str {
329 + "promote_problem"
330 + }
331 + fn description(&self) -> &str {
332 + "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:<source>:<ref>` provenance tag. This is the only path from a problem to a task; reporting one never creates work on its own."
333 + }
334 + fn kind(&self) -> ToolKind {
335 + ToolKind::Write(caps::problem_promote())
336 + }
337 + fn input_schema(&self) -> Value {
338 + json!({
339 + "type": "object",
340 + "properties": {
341 + "id": { "type": "string", "description": "The problem's id, from list_problems." },
342 + "description": { "type": "string", "description": "Task description. Defaults to the problem's title, with its body appended." },
343 + "priority": { "type": "string", "description": "High | Medium | Low. Defaults from the problem's painhours band." }
344 + },
345 + "required": ["id"]
346 + })
347 + }
348 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
349 + let id = parse_problem_id(self.name(), req_str(self.name(), &args, "id")?)?;
350 + let repo = self.0.problems();
351 +
352 + let problem = repo
353 + .get_by_id(id, self.0.user_id)
354 + .await
355 + .map_err(|e| fail(self.name(), e))?
356 + .ok_or_else(|| Error::ToolFailed {
357 + tool: self.name().to_string(),
358 + message: format!("no problem with id `{id}`"),
359 + })?;
360 +
361 + if let Some(existing) = problem.promoted_task_id {
362 + // Idempotent rather than an error: a retried promote should report
363 + // the task it already made, not make a second one.
364 + return Ok(ToolCallResult::text(
365 + serde_json::to_string(&json!({
366 + "task_id": existing.to_string(),
367 + "problem_id": problem.id.to_string(),
368 + "created": false,
369 + }))
370 + .unwrap(),
371 + ));
372 + }
373 +
374 + let description = match args.get("description").and_then(Value::as_str) {
375 + Some(d) if !d.trim().is_empty() => d.trim().to_string(),
376 + _ if problem.body.trim().is_empty() => problem.title.clone(),
377 + _ => format!("{}\n\n{}", problem.title, problem.body),
378 + };
379 +
380 + // The band is the problem's own judgement of urgency, so it is the
381 + // sensible default priority. Critical and High both map to High:
382 + // GoingsOn tasks have no fourth level.
383 + let priority = match args.get("priority").and_then(Value::as_str) {
384 + Some(p) => Priority::from_str_or_default(p),
385 + None => match problem.band() {
386 + painhours::Priority::Critical | painhours::Priority::High => Priority::High,
387 + painhours::Priority::Medium => Priority::Medium,
388 + painhours::Priority::Low => Priority::Low,
389 + },
390 + };
391 +
392 + let mut tags = problem.tags.clone();
393 + tags.push(format!("problem:{}:{}", problem.source, problem.source_ref));
394 +
395 + let mut builder = NewTask::builder(description).priority(priority).tags(tags);
396 + if let Some(pid) = problem.project_id {
397 + builder = builder.project_id(pid);
398 + }
399 +
400 + let task = self
401 + .0
402 + .tasks()
403 + .create(self.0.user_id, builder.build())
404 + .await
405 + .map_err(|e| fail(self.name(), e))?;
406 +
407 + // Link second: if this fails the task still exists and the problem
408 + // stays Open, so a retry finds no backlink and is safe to run again.
409 + // The reverse order could mark a problem promoted with no task behind
410 + // it, which is the state nothing recovers from.
411 + repo.promote(problem.id, self.0.user_id, task.id)
412 + .await
413 + .map_err(|e| fail(self.name(), e))?;
414 +
415 + Ok(ToolCallResult::text(
416 + serde_json::to_string(&json!({
417 + "task_id": task.id.to_string(),
418 + "problem_id": problem.id.to_string(),
419 + "created": true,
420 + }))
421 + .unwrap(),
422 + ))
423 + }
424 + }
425 +
426 + /// Named `...Tool` to match `UpdateProjectTool` and `UpdateTaskTool`.
427 + pub struct UpdateProblemTool(pub Arc<Ctx>);
428 +
429 + #[async_trait]
430 + impl Tool for UpdateProblemTool {
431 + fn name(&self) -> &str {
432 + "update_problem"
433 + }
434 + fn description(&self) -> &str {
435 + "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."
436 + }
437 + fn kind(&self) -> ToolKind {
438 + ToolKind::Write(caps::problem_update())
439 + }
440 + fn input_schema(&self) -> Value {
441 + json!({
442 + "type": "object",
443 + "properties": {
444 + "id": { "type": "string" },
445 + "status": { "type": "string", "description": "Open | Promoted | Dismissed | Resolved" },
446 + "project": { "type": "string", "description": "Project name to attribute this problem to; created if absent. Pass an empty string to clear." }
447 + },
448 + "required": ["id"]
449 + })
450 + }
451 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
452 + let id = parse_problem_id(self.name(), req_str(self.name(), &args, "id")?)?;
453 + let repo = self.0.problems();
454 +
455 + let mut current = repo
456 + .get_by_id(id, self.0.user_id)
457 + .await
458 + .map_err(|e| fail(self.name(), e))?
459 + .ok_or_else(|| Error::ToolFailed {
460 + tool: self.name().to_string(),
461 + message: format!("no problem with id `{id}`"),
462 + })?;
463 +
464 + if let Some(raw) = args.get("project").and_then(Value::as_str) {
465 + let project_id = if raw.trim().is_empty() {
466 + None
467 + } else {
468 + let mut cache = HashMap::new();
469 + Some(
470 + resolve_project_cached(&self.0, raw, &mut cache)
471 + .await
472 + .map_err(|e| fail(self.name(), e))?,
473 + )
474 + };
475 + current = repo
476 + .set_project(id, self.0.user_id, project_id)
477 + .await
478 + .map_err(|e| fail(self.name(), e))?
479 + .ok_or_else(|| Error::ToolFailed {
480 + tool: self.name().to_string(),
481 + message: format!("problem `{id}` vanished during update"),
482 + })?;
483 + }
484 +
485 + if let Some(raw) = args.get("status").and_then(Value::as_str) {
486 + let status = parse_enum(self.name(), "status", raw, PROBLEM_STATUSES)?;
487 + current = repo
488 + .set_status(id, self.0.user_id, status)
489 + .await
490 + .map_err(|e| fail(self.name(), e))?
491 + .ok_or_else(|| Error::ToolFailed {
492 + tool: self.name().to_string(),
493 + message: format!("problem `{id}` vanished during update"),
494 + })?;
495 + }
496 +
497 + Ok(ToolCallResult::text(
498 + serde_json::to_string(&problem_row(&current)).unwrap(),
499 + ))
500 + }
Lines truncated
@@ -402,3 +402,177 @@ async fn create_project_rejects_an_unknown_type_instead_of_defaulting() {
402 402 let listed = call(&reg, "list_projects", json!({})).await;
403 403 assert!(listed["projects"].as_array().unwrap().is_empty());
404 404 }
405 +
406 + // -- problems -----------------------------------------------------------------
407 +
408 + /// The `/audit` shape: a lens reports findings, which land as candidates rather
409 + /// than as work, and a re-run refreshes them in place.
410 + #[tokio::test]
411 + async fn report_problems_is_idempotent_and_creates_no_tasks() {
412 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
413 +
414 + let payload = json!({
415 + "source": "audit",
416 + "problems": [
417 + { "title": "sync cascade orphans subtasks", "body": "repository/task.rs",
418 + "pain": 4, "scale": 5, "source_ref": "task-repo:cascade",
419 + "project": "goingson", "tags": ["sync"] },
420 + { "title": "lint scripts unwired", "pain": 1, "scale": 1,
421 + "source_ref": "scripts:lint", "project": "goingson" }
422 + ]
423 + });
424 +
425 + let first = call(&reg, "report_problems", payload.clone()).await;
426 + assert_eq!(first["reported"], 2);
427 +
428 + // Reporting is not planning: no tasks exist yet.
429 + let tasks = call(&reg, "list_tasks", json!({})).await;
430 + assert_eq!(tasks["count"], 0, "reporting must not create work");
431 +
432 + // A second run of the same audit updates in place.
433 + let second = call(&reg, "report_problems", payload).await;
434 + assert_eq!(second["reported"], 2);
435 + let listed = call(&reg, "list_problems", json!({})).await;
436 + assert_eq!(listed["count"], 2, "re-running an audit must not duplicate");
437 +
438 + // Ranked by painhours: the widespread finding outranks the narrow one.
439 + let rows = listed["problems"].as_array().unwrap();
440 + assert_eq!(rows[0]["source_ref"], "task-repo:cascade");
441 + assert!(
442 + rows[0]["painhours"].as_u64().unwrap() > rows[1]["painhours"].as_u64().unwrap(),
443 + "scale must drive the ranking"
444 + );
445 + assert_eq!(rows[0]["source"], "audit");
446 + }
447 +
448 + #[tokio::test]
449 + async fn promote_problem_creates_a_linked_task_once() {
450 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
451 +
452 + call(
453 + &reg,
454 + "report_problems",
455 + json!({
456 + "source": "fuzz",
457 + "problems": [{
458 + "title": "blob upload loads whole file into memory",
459 + "body": "blob_sync.rs streams nothing",
460 + "pain": 5, "scale": 4,
461 + "source_ref": "blob_sync:memory",
462 + "project": "goingson",
463 + "tags": ["perf"]
464 + }]
465 + }),
466 + )
467 + .await;
468 +
469 + let listed = call(&reg, "list_problems", json!({})).await;
470 + let problem_id = listed["problems"][0]["id"].as_str().unwrap().to_string();
471 +
472 + let promoted = call(&reg, "promote_problem", json!({ "id": problem_id })).await;
473 + assert_eq!(promoted["created"], true);
474 + let task_id = promoted["task_id"].as_str().unwrap().to_string();
475 +
476 + // The task carries the problem's project, tags, and a provenance tag, and
477 + // takes its priority from the painhours band.
478 + let task = call(&reg, "get_task", json!({ "id": task_id })).await;
479 + assert_eq!(task["project"], "goingson");
480 + let tags: Vec<&str> = task["tags"]
481 + .as_array()
482 + .unwrap()
483 + .iter()
484 + .map(|t| t.as_str().unwrap())
485 + .collect();
486 + assert!(tags.contains(&"perf"));
487 + assert!(tags.contains(&"problem:fuzz:blob_sync:memory"));
488 + assert!(
489 + task["description"]
490 + .as_str()
491 + .unwrap()
492 + .starts_with("blob upload loads whole file into memory")
493 + );
494 +
495 + // Promoting is idempotent: a retry reports the same task, not a second one.
496 + let again = call(&reg, "promote_problem", json!({ "id": problem_id })).await;
497 + assert_eq!(again["created"], false);
498 + assert_eq!(again["task_id"], task_id);
499 + let tasks = call(&reg, "list_tasks", json!({})).await;
500 + assert_eq!(tasks["count"], 1);
501 +
502 + // And the problem has left the inbox, with the backlink recorded.
503 + let open = call(&reg, "list_problems", json!({})).await;
504 + assert_eq!(open["count"], 0, "a promoted problem leaves the inbox");
505 + let all = call(&reg, "list_problems", json!({ "status": "all" })).await;
506 + assert_eq!(all["problems"][0]["status"], "Promoted");
507 + assert_eq!(all["problems"][0]["promoted_task_id"], task_id);
508 + }
509 +
510 + #[tokio::test]
511 + async fn re_reporting_does_not_undo_a_dismissal() {
512 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
513 +
514 + let payload = json!({
515 + "source": "audit",
516 + "problems": [{ "title": "cosmetic naming nit", "source_ref": "style:naming" }]
517 + });
518 + call(&reg, "report_problems", payload.clone()).await;
519 +
520 + let listed = call(&reg, "list_problems", json!({})).await;
521 + let id = listed["problems"][0]["id"].as_str().unwrap().to_string();
522 +
523 + let dismissed = call(
524 + &reg,
525 + "update_problem",
526 + json!({ "id": id, "status": "Dismissed" }),
527 + )
528 + .await;
529 + assert_eq!(dismissed["status"], "Dismissed");
530 +
531 + // The next audit run reports it again. It must not come back to the inbox.
532 + call(&reg, "report_problems", payload).await;
533 + let open = call(&reg, "list_problems", json!({})).await;
534 + assert_eq!(open["count"], 0, "a dismissal must survive a re-run");
535 + }
536 +
537 + #[tokio::test]
538 + async fn problem_writes_are_capability_gated() {
539 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
540 +
541 + for (tool, capability) in [
542 + ("report_problems", "go.problem.report"),
543 + ("promote_problem", "go.problem.promote"),
544 + ("update_problem", "go.problem.update"),
545 + ] {
546 + let err = reg
547 + .call(tool, json!({}), Some(&HashSet::new()))
548 + .await
549 + .unwrap_err();
550 + match err {
551 + Error::CapabilityDenied { capability: c, .. } => assert_eq!(c, capability),
552 + other => panic!("{tool}: expected CapabilityDenied, got {other:?}"),
553 + }
554 + }
555 + }
556 +
557 + #[tokio::test]
558 + async fn report_problems_rejects_an_out_of_range_factor() {
559 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
560 + let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect();
561 +
562 + let err = reg
563 + .call(
564 + "report_problems",
565 + json!({ "problems": [{ "title": "x", "pain": 9 }] }),
566 + Some(&grants),
567 + )
568 + .await
569 + .unwrap_err();
570 +
571 + match err {
572 + Error::InvalidArgs { message, .. } => assert!(
573 + message.contains("`pain` must be an integer 1-5"),
574 + "unhelpful message: {message}"
575 + ),
576 + other => panic!("expected InvalidArgs, got {other:?}"),
577 + }
578 + }