Skip to main content

forge_workspace/mcp/
tasks.rs

1//! Task MCP - the project's live task list (`mcp__forge__tasks__*`).
2//!
3//! forge owns the task list its sessions work from: a task is a declared
4//! piece of work in flight, stored in the machine-local redb store (see
5//! [`crate::store::tasks`]) and rendered by the Inspector's TASKS
6//! section. The CLI's own task tools are gated per model and do not
7//! survive a session, so forge does not use them.
8//!
9//! The tools (`tasks__create` / `tasks__update` / `tasks__list` /
10//! `tasks__delete` / `tasks__claim` / `tasks__wait`) are ANY-CALLER,
11//! scoped to the caller's own project - a worker creating its own
12//! subtasks is the normal case, and any session may move any task in its
13//! project; `tasks__approve` is the lead's own door and is not offered to
14//! a worker. Task-list mutations are direct `Workspace` methods (state
15//! writes), not Command-bus dispatches.
16//!
17//! - [`facade`] - the `TasksFacade` seam (prod over `Weak<Workspace>` +
18//!   a mock for tool tests).
19
20use std::sync::Arc;
21use std::time::SystemTime;
22
23use forge_sdk::mcp::server::McpServerBuilder;
24use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput};
25
26use forge_primitives::tasks::{LinkKind, Task, TaskId, TaskStatus, WaitingKind};
27
28use crate::SessionSlot;
29use crate::mcp::tasks::facade::{TaskDraft, TaskPatch, TasksError, TasksFacade};
30
31pub(crate) mod facade;
32
33/// Attach the task tools to an existing [`McpServerBuilder`]. Called for
34/// BOTH lead and worker sessions (tasks are any-caller), so
35/// `build_forge_server` invokes this unconditionally.
36pub(crate) fn add_tools(
37    builder: McpServerBuilder,
38    facade: Arc<dyn TasksFacade>,
39    slot: SessionSlot,
40    lead: bool,
41) -> McpServerBuilder {
42    let create = Create { facade: facade.clone(), slot: slot.clone() };
43    let update = Update { facade: facade.clone(), slot: slot.clone() };
44    let list = List { facade: facade.clone(), slot: slot.clone() };
45    let delete = Delete { facade: facade.clone(), slot: slot.clone() };
46    let claim = Claim { facade: facade.clone(), slot: slot.clone() };
47    let wait = Wait { facade: facade.clone(), slot: slot.clone() };
48    let builder = builder.tool(create).tool(update).tool(list).tool(delete).tool(claim).tool(wait);
49    // `tasks__approve` is the lead's door: only the lead stands behind the
50    // user's recorded approval, so a worker is not offered the verb at all.
51    if lead { builder.tool(Approve { facade, slot }) } else { builder }
52}
53
54fn tool_error(text: String) -> ToolOutput {
55    ToolOutput::error(text)
56}
57
58/// Format a `SystemTime` as a UTC RFC3339 string for tool output.
59fn fmt_rfc3339(t: SystemTime) -> String {
60    time::OffsetDateTime::from(t)
61        .format(&time::format_description::well_known::Rfc3339)
62        .unwrap_or_else(|_| "unknown".to_owned())
63}
64
65/// The status as the schema spells it.
66fn status_str(status: TaskStatus) -> &'static str {
67    match status {
68        TaskStatus::Pending => "pending",
69        TaskStatus::InProgress => "in_progress",
70        TaskStatus::Waiting => "waiting",
71        TaskStatus::Completed => "completed",
72        TaskStatus::Failed => "failed",
73        TaskStatus::Canceled => "canceled",
74    }
75}
76
77/// Readable JSON for one task (the tool-output shape the LLM sees).
78/// Absent optional fields are omitted rather than sent as null.
79fn task_to_json(task: &Task) -> serde_json::Value {
80    let mut map = serde_json::Map::new();
81    map.insert("id".to_owned(), serde_json::json!(task.id.as_str()));
82    map.insert("project".to_owned(), serde_json::json!(task.project_name));
83    map.insert("subject".to_owned(), serde_json::json!(task.subject));
84    map.insert("status".to_owned(), serde_json::json!(status_str(task.status)));
85    // An unclaimed task omits `owner` rather than writing a sentinel: a
86    // caller that reads the field back and filters on it would otherwise
87    // find none, and a session actually labelled `unclaimed` would read the
88    // same as nobody.
89    //
90    // The artifact a caller read before links existed: the first
91    // pull-request or path link, by its label when it has one.
92    let artifact = task
93        .links
94        .iter()
95        .find(|l| matches!(l.kind, LinkKind::Pr | LinkKind::Path))
96        .map(|l| l.label.clone().unwrap_or_else(|| l.target.clone()));
97    for (key, value) in [
98        ("active_form", task.active_form.as_deref()),
99        ("detail", task.detail.as_deref()),
100        ("artifact", artifact.as_deref()),
101        ("estimate", task.estimate.as_ref().map(|e| e.words.as_str())),
102        ("parent", task.parent.as_ref().map(TaskId::as_str)),
103        ("owner", task.owner.as_ref().map(SessionSlot::label)),
104    ] {
105        if let Some(value) = value {
106            map.insert(key.to_owned(), serde_json::json!(value));
107        }
108    }
109    for (key, value) in
110        [("verify", task.verify.map(verify_str)), ("reserved", task.reserved.map(|_| "user"))]
111    {
112        if let Some(value) = value {
113            map.insert(key.to_owned(), serde_json::json!(value));
114        }
115    }
116    if let Some(attested) = &task.attested {
117        map.insert(
118            "attested".to_owned(),
119            serde_json::json!({
120                "evidence": attested.evidence,
121                "at": fmt_rfc3339(attested.at),
122            }),
123        );
124    }
125    map.insert("created_at".to_owned(), serde_json::json!(fmt_rfc3339(task.created_at)));
126    map.insert("updated_at".to_owned(), serde_json::json!(fmt_rfc3339(task.updated_at)));
127    serde_json::Value::Object(map)
128}
129
130/// The gate as the tool schema spells it.
131fn verify_str(verify: forge_primitives::tasks::Verify) -> &'static str {
132    match verify {
133        forge_primitives::tasks::Verify::User => "user",
134        forge_primitives::tasks::Verify::None => "none",
135    }
136}
137
138fn format_tasks_error(err: &TasksError) -> String {
139    match err {
140        TasksError::UnknownCallerProject => {
141            "couldn't resolve your project; is this session attached to a forge.toml project?"
142                .to_owned()
143        }
144        TasksError::BadEstimate(words) => {
145            format!("\"{words}\" is not a duration; use 30m, 2h, 1d or 1w")
146        }
147        TasksError::Refused(why) => {
148            format!("the move was refused: {why}")
149        }
150        TasksError::NoSuchTask { id } => {
151            format!("no task with id {id} in your project")
152        }
153        TasksError::TaskInAnotherProject { id, project } => {
154            format!("task {id} exists in project {project}, not in your project")
155        }
156    }
157}
158
159struct Create {
160    facade: Arc<dyn TasksFacade>,
161    slot: SessionSlot,
162}
163
164#[async_trait::async_trait]
165impl Tool for Create {
166    fn name(&self) -> &'static str {
167        "tasks__create"
168    }
169
170    fn description(&self) -> &'static str {
171        "Declare a piece of live work in YOUR project's task list. `subject` is the row's text \
172         and the only required field; `active_form` is the in-progress wording (\"Adding tests\" \
173         for \"Add tests\"), shown while the task is running. `owner` is the label of the session \
174         holding it - a worker's label, or \"lead\" - and omitting it leaves the task unclaimed. \
175         A lead creating a row for a worker assigns it at creation: the worker's label in \
176         `owner`, and the kick says not to claim it. A row left in the lead's own hands \
177         refuses the worker's `tasks__claim` while the lead is live, and a row left in \
178         progress under a seat that goes is chased as having no live owner once the board \
179         marks it stale. \
180         `parent` names another task in the same project this one belongs under, `estimate` a \
181         duration, `rank` the queue order, `verify` whether completion waits on the user, \
182         `links` the references it carries (issues, PRs, specs, paths), `detail` free prose. \
183         `status` defaults to pending. A row whose deliverable IS the user's own read or \
184         decision - a report the user asked to read, a verdict the user asked to give - is \
185         minted `verify: \"user\"` explicitly: it ends parked on the user, and the board's \
186         approve is what completes it. Only the lead mints this gate; a worker hands the ask \
187         to its lead instead. The gate is minted on the row that owes the look; an \
188         epic's `verify: \"user\"` governs the epic's own completion, and a child never \
189         inherits it - a child that owes no look completes on its own terms. `reserved: \
190         \"user\"` reserves the pick for the user: the board sweep leaves it, it never reads \
191         ready, and no seat's claim takes it. Returns the stored task \
192         with its id (use it with tasks__update / tasks__delete). Any session in the project \
193         may call this."
194    }
195
196    fn input_schema(&self) -> serde_json::Value {
197        serde_json::json!({
198            "type": "object",
199            "properties": {
200                "subject": {
201                    "type": "string",
202                    "description": "The task's row text.",
203                },
204                "active_form": {
205                    "type": "string",
206                    "description": "The in-progress wording, shown while the task is running.",
207                },
208                "detail": {
209                    "type": "string",
210                    "description": "Free prose: the why.",
211                },
212                "status": {
213                    "type": "string",
214                    "enum": ["pending", "in_progress", "waiting", "completed", "failed", "canceled"],
215                    "description": "Where the task starts. Defaults to pending, the board's \
216                                    Backlog: work kept to look at later, not by itself an \
217                                    assignment. The sweep dispatches only unowned ready rows, \
218                                    so take ownership to set one aside.",
219                },
220                "owner": {
221                    "type": "string",
222                    "description": "The label of the session holding the task, in your project \
223                                    (\"lead\" for the lead). Omit for unclaimed.",
224                },
225                "parent": {
226                    "type": "string",
227                    "description": "The id of the task this one belongs under, in the same \
228                                    project; a full id or any unique prefix of one.",
229                },
230                "estimate": {
231                    "type": "string",
232                    "description": "A duration estimate (e.g. \"1d\").",
233                },
234                "rank": {
235                    "type": "integer",
236                    "description": "Queue order; lower reads first.",
237                },
238                "verify": {
239                    "type": "string",
240                    "enum": ["user", "none"],
241                    "description": "Whether the row's own completion waits on the user's look. \
242                                    The gate is minted on the row; a child never inherits \
243                                    its epic's. A row whose deliverable IS the user's own \
244                                    read or decision states `user`; anything else owes no \
245                                    look, even under a `user` epic. The gate is the lead's \
246                                    to mint; a worker asks its lead instead.",
247                },
248                "reserved": {
249                    "type": "string",
250                    "enum": ["user"],
251                    "description": "Set `user` to reserve this pick for the user: the board \
252                                    sweep leaves a reserved row for them instead of \
253                                    dispatching it.",
254                },
255                "links": {
256                    "type": "array",
257                    "description": "References this row carries: a spec, a plan, an issue, a PR, \
258                                    a branch, a path. The kind is derived from the target when \
259                                    omitted.",
260                    "items": {
261                        "type": "object",
262                        "properties": {
263                            "kind": {
264                                "type": "string",
265                                "enum": ["spec", "plan", "issue", "pr", "branch", "path", "other"],
266                            },
267                            "label": { "type": "string" },
268                            "target": { "type": "string" },
269                        },
270                        "required": ["target"],
271                    },
272                },
273            },
274            "required": ["subject"],
275            "additionalProperties": false,
276        })
277    }
278
279    async fn call(&self, input: ToolInput) -> ToolOutput {
280        let draft: TaskDraft = match serde_json::from_value(input.value) {
281            Ok(draft) => draft,
282            Err(err) => return tool_error(format!("invalid arguments: {err}")),
283        };
284        match self.facade.create_task(&self.slot, draft) {
285            Ok(task) => match serde_json::to_string_pretty(&task_to_json(&task)) {
286                Ok(json) => ToolOutput::text(json),
287                Err(err) => tool_error(format!("response serialization failed: {err}")),
288            },
289            Err(err) => tool_error(format_tasks_error(&err)),
290        }
291    }
292}
293
294struct Update {
295    facade: Arc<dyn TasksFacade>,
296    slot: SessionSlot,
297}
298
299#[derive(serde::Deserialize)]
300struct UpdateArgs {
301    id: String,
302    #[serde(flatten)]
303    patch: TaskPatch,
304}
305
306#[async_trait::async_trait]
307impl Tool for Update {
308    fn name(&self) -> &'static str {
309        "tasks__update"
310    }
311
312    fn description(&self) -> &'static str {
313        "Change a task in YOUR project by id, stating only the fields to move - everything else \
314         is left alone. `status` is the usual one (pending / in_progress / waiting / completed / \
315         failed / canceled), and `owner` takes a session label (\"lead\" for the lead) or the \
316         label of a worker. Pending is the board's Backlog: work kept to look at later, not by \
317         itself an assignment and not a promise to start. A pending row that reads ready and \
318         unowned is dispatchable; take ownership to set it aside. \
319         The board is for running work: a landed row with no children and \
320         no epic above it is DELETED in the same pass (tasks__delete), never left completed; \
321         a landed row under an epic - open or already terminal - is completed and left \
322         terminal, because the epic reads `to close` only once every child is terminal (a \
323         verify=user child parks in waiting on the user and counts only once the user \
324         approves); and completing the epic's ROOT row closes it and archives the whole \
325         tree. Any session in the \
326         project may move any of its tasks, so a lead can \
327         complete a worker's task and a worker can put a row it holds into waiting - and a row \
328         waiting on the user's own look cannot be completed here: the board's approve completes \
329         it, and a cancel of a row waiting on the user is the withdraw door once the ask has \
330         gone moot: it needs `detail`, the reason the ask is withdrawn, which lands on the row, \
331         and a cancel stating none - or only what the row already says - is refused. Any \
332         other move off `waiting` drops the wait the row held - except a completion on a row \
333         whose completion waits on the user: while it is waiting on something else the gate \
334         would trade that wait for a look park, so the completion is refused by name; move it \
335         off waiting first, then complete it. When the row you end is named by another row's \
336         dependency wait, move every waiter off that wait in the same pass - a wait left on an \
337         ended row reads that its target has ended indefinitely. `verify: \"user\"` \
338         may be set here by the lead, but not taken away: a row carrying `user` keeps that \
339         gate until the user's approve, so clearing it is refused rather than taken as a way \
340         around the look. Only the lead mints this gate; a worker hands the ask to its lead \
341         instead. \
342         The gate is minted on the row, never inherited from its epic, so reparenting changes \
343         no gate. A task is \
344         named by its full id or any unique prefix of one; an exact id wins over any longer id \
345         that starts with it, and a prefix matching more than one row is refused with the \
346         matching rows named. An unknown id is an error, not a no-op. Returns the task as the \
347         write left it."
348    }
349
350    fn input_schema(&self) -> serde_json::Value {
351        serde_json::json!({
352            "type": "object",
353            "properties": {
354                "id": {
355                    "type": "string",
356                    "description": "The task id to change, or any unique prefix of one.",
357                },
358                "subject": { "type": "string", "description": "New row text." },
359                "active_form": {
360                    "type": "string",
361                    "description": "New in-progress wording.",
362                },
363                "detail": { "type": "string", "description": "New detail prose." },
364                "status": {
365                    "type": "string",
366                    "enum": ["pending", "in_progress", "waiting", "completed", "failed", "canceled"],
367                    "description": "The task's new status. `completed` is not a resting state \
368                                    for a landed row with no children and no epic above it: \
369                                    it is deleted in the same pass. A landed row under an \
370                                    epic - open or already terminal - stays completed and \
371                                    terminal so the epic can read `to close`; a verify=user \
372                                    row parks in waiting on the user first.",
373                },
374                "owner": {
375                    "type": "string",
376                    "description": "The label of the session now holding the task.",
377                },
378                "parent": {
379                    "type": "string",
380                    "description": "The id of the task this one now belongs under, or any \
381                                    unique prefix of one.",
382                },
383                "rank": { "type": "integer", "description": "New queue order; lower reads first." },
384                "verify": {
385                    "type": "string",
386                    "enum": ["user", "none"],
387                    "description": "Whether the row's own completion waits on the user's look. \
388                                    Can be set here by the lead; a worker asks its lead \
389                                    instead. A `user` gate is the user's to clear: an update \
390                                    cannot clear it. The gate is minted on the row, so moving \
391                                    the row changes nothing.",
392                },
393                "parked": {
394                    "type": "boolean",
395                    "description": "Mark a waiting row's wait as parked by design - the user's \
396                                    own deferral - so the chase leaves it alone. Only a \
397                                    waiting row takes it.",
398                },
399                "links_add": {
400                    "type": "array",
401                    "description": "Links to attach; a target already on the row is left alone.",
402                    "items": {
403                        "type": "object",
404                        "properties": {
405                            "kind": {
406                                "type": "string",
407                                "enum": ["spec", "plan", "issue", "pr", "branch", "path", "other"],
408                            },
409                            "label": { "type": "string" },
410                            "target": { "type": "string" },
411                        },
412                        "required": ["target"],
413                    },
414                },
415                "links_remove": {
416                    "type": "array",
417                    "description": "Links to take off, by exact target.",
418                    "items": { "type": "string" },
419                },
420                "estimate": { "type": "string", "description": "A duration estimate." },
421            },
422            "required": ["id"],
423            "additionalProperties": false,
424        })
425    }
426
427    async fn call(&self, input: ToolInput) -> ToolOutput {
428        let args: UpdateArgs = match serde_json::from_value(input.value) {
429            Ok(args) => args,
430            Err(err) => return tool_error(format!("invalid arguments: {err}")),
431        };
432        match self.facade.update_task(&self.slot, &TaskId::from(args.id.as_str()), args.patch) {
433            Ok(task) => match serde_json::to_string_pretty(&task_to_json(&task)) {
434                Ok(json) => ToolOutput::text(json),
435                Err(err) => tool_error(format!("response serialization failed: {err}")),
436            },
437            Err(err) => tool_error(format_tasks_error(&err)),
438        }
439    }
440}
441
442struct List {
443    facade: Arc<dyn TasksFacade>,
444    slot: SessionSlot,
445}
446
447#[derive(serde::Deserialize)]
448struct ListArgs {
449    #[serde(default)]
450    owner: Option<String>,
451    #[serde(default)]
452    parent: Option<String>,
453    #[serde(default)]
454    status: Option<TaskStatus>,
455    #[serde(default)]
456    ready: Option<bool>,
457}
458
459fn waiting_kind_str(kind: WaitingKind) -> &'static str {
460    match kind {
461        WaitingKind::Decision => "decision",
462        WaitingKind::Dependency => "dependency",
463        WaitingKind::Resource => "resource",
464    }
465}
466
467/// One board row as the tool output reads it: the record, what the board
468/// derived, and the wait it is sitting on.
469fn board_row_to_json(row: &crate::board::BoardRow) -> serde_json::Value {
470    let mut map = match task_to_json(&row.task) {
471        serde_json::Value::Object(map) => map,
472        other => return other,
473    };
474    map.insert("worked".to_owned(), serde_json::json!(crate::board::fmt_secs(row.worked_secs)));
475    map.insert(
476        "updated_ago".to_owned(),
477        serde_json::json!(crate::board::fmt_secs(row.updated_secs_ago)),
478    );
479    let mut marks: Vec<&str> = Vec::new();
480    if row.marks.ready {
481        marks.push("ready");
482    }
483    if row.marks.in_review {
484        marks.push("in_review");
485    }
486    if row.marks.overdue {
487        marks.push("overdue");
488    }
489    if row.marks.no_movement {
490        marks.push("no_movement");
491    }
492    if row.marks.waiting_too_long {
493        marks.push("waiting_too_long");
494    }
495    if row.marks.stale {
496        marks.push("stale");
497    }
498    if row.marks.to_close {
499        marks.push("to_close");
500    }
501    map.insert("marks".to_owned(), serde_json::json!(marks));
502    if let Some(wait) = &row.task.waiting_on {
503        map.insert(
504            "waiting".to_owned(),
505            serde_json::json!({
506                "kind": wait.kind.map(waiting_kind_str),
507                "detail": wait.detail,
508                "on": wait.on.as_ref().map(TaskId::as_str),
509                "verification": wait.verification,
510                "parked": wait.parked,
511            }),
512        );
513    }
514    if let Some((done, total)) = row.rollup {
515        map.insert("rollup".to_owned(), serde_json::json!(format!("{done}/{total}")));
516    }
517    if let Some(parent_subject) = &row.parent_subject {
518        map.insert("parent_subject".to_owned(), serde_json::json!(parent_subject));
519    }
520    serde_json::Value::Object(map)
521}
522
523#[async_trait::async_trait]
524impl Tool for List {
525    fn name(&self) -> &'static str {
526        "tasks__list"
527    }
528
529    fn description(&self) -> &'static str {
530        "List the board of YOUR project: every live row as a whole record, plus what the board \
531         derives - `worked` time against the estimate, how long since any touch, and the marks \
532         (ready, in_review, overdue, no_movement, waiting_too_long, stale, to_close) - under a \
533         dated line, so no session does date arithmetic. Narrow with `owner` (a session label), \
534         `parent` (a task id), `status`, or `ready` (true = pending with nothing waiting on \
535         it and not reserved for the user; ownership is NOT part of the mark, so read `owner` \
536         too when the question is an unowned row). \
537         An empty array means your project has no tasks in flight, or that your project could \
538         not be resolved, or that this run could not read its stored tasks. Any session in the \
539         project may call this."
540    }
541
542    fn input_schema(&self) -> serde_json::Value {
543        serde_json::json!({
544            "type": "object",
545            "properties": {
546                "owner": {
547                    "type": "string",
548                    "description": "Only tasks held by this session label.",
549                },
550                "parent": {
551                    "type": "string",
552                    "description": "Only the tasks under this task id, or any unique prefix \
553                                    of one.",
554                },
555                "status": {
556                    "type": "string",
557                    "enum": ["pending", "in_progress", "waiting", "completed", "failed", "canceled"],
558                    "description": "Only tasks in this state.",
559                },
560                "ready": {
561                    "type": "boolean",
562                    "description": "True lists only rows something can start: pending, \
563                                    with nothing waiting on it, and not reserved for the \
564                                    user. Ownership is not part of the mark: read `owner` \
565                                    for an unowned row.",
566                },
567            },
568            "additionalProperties": false,
569        })
570    }
571
572    async fn call(&self, input: ToolInput) -> ToolOutput {
573        let args: ListArgs = match serde_json::from_value(input.value) {
574            Ok(args) => args,
575            Err(err) => return tool_error(format!("invalid arguments: {err}")),
576        };
577        let parent = args.parent.as_deref().map(TaskId::from);
578        let rows = self.facade.list_tasks(
579            &self.slot,
580            args.owner.as_deref(),
581            parent.as_ref(),
582            args.status,
583            args.ready,
584        );
585        let arr: Vec<serde_json::Value> = rows.iter().map(board_row_to_json).collect();
586        match serde_json::to_string_pretty(&serde_json::Value::Array(arr)) {
587            Ok(json) => ToolOutput::text(format!("now {}\n{json}", fmt_rfc3339(SystemTime::now()))),
588            Err(err) => tool_error(format!("task-list serialization failed: {err}")),
589        }
590    }
591}
592
593struct Delete {
594    facade: Arc<dyn TasksFacade>,
595    slot: SessionSlot,
596}
597
598#[derive(serde::Deserialize)]
599struct DeleteArgs {
600    id: String,
601}
602
603#[async_trait::async_trait]
604impl Tool for Delete {
605    fn name(&self) -> &'static str {
606        "tasks__delete"
607    }
608
609    fn description(&self) -> &'static str {
610        "Remove a task in YOUR project by id together with its children in the same call, so a \
611         parent never leaves subtasks pointing at a task that is gone. Use this to clear work \
612         that is over: the board is for running work, so a landed row - its work merged, \
613         delivered or concluded - with no children and no epic above it is deleted in the \
614         same pass, never left completed; a landed row under an epic - open or already \
615         terminal - is completed and left terminal instead, so the epic can read `to close` \
616         (a verify=user row parks in waiting on the user first). Nothing \
617         is archived. A row waiting on the user is not a delete's to \
618         take - the board's answer or approve settles it, and deleting it (or a tree holding \
619         it) is refused rather than taken as a way around the ask. A dependency wait is not \
620         such an ask: the row waits on the task it names, not on the user, even when the epic \
621         is `verify: user`. When the row you delete is named by another row's dependency wait, \
622         move every waiter off that wait in the same pass - a wait left on a deleted row reads \
623         that its target has ended indefinitely. \
624         An ask that has gone moot is withdrawn, not removed: `tasks__update` cancels it with \
625         the reason it goes away, and the row records why. The task is named by its \
626         full id or any unique \
627         prefix of one; an exact id wins over any longer id that starts with it, and a prefix \
628         matching more than one row is refused with the matching rows named. An unknown id is an \
629         error, not a no-op. Returns the removed task and how many descendants went with it. Any \
630         session in the project may call this."
631    }
632
633    fn input_schema(&self) -> serde_json::Value {
634        serde_json::json!({
635            "type": "object",
636            "properties": {
637                "id": {
638                    "type": "string",
639                    "description": "The task id to remove, or any unique prefix of one.",
640                },
641            },
642            "required": ["id"],
643            "additionalProperties": false,
644        })
645    }
646
647    async fn call(&self, input: ToolInput) -> ToolOutput {
648        let args: DeleteArgs = match serde_json::from_value(input.value) {
649            Ok(args) => args,
650            Err(err) => return tool_error(format!("invalid arguments: {err}")),
651        };
652        match self.facade.delete_task(&self.slot, &TaskId::from(args.id.as_str())) {
653            Ok(removed) => {
654                let mut envelope =
655                    crate::mcp::deleted::removed_record(&task_to_json(&removed.task));
656                envelope["descendants_removed"] = serde_json::json!(removed.descendants_removed);
657                match serde_json::to_string_pretty(&envelope) {
658                    Ok(json) => ToolOutput::text(json),
659                    Err(err) => tool_error(format!("response serialization failed: {err}")),
660                }
661            }
662            Err(err) => tool_error(format_tasks_error(&err)),
663        }
664    }
665}
666
667struct Claim {
668    facade: Arc<dyn TasksFacade>,
669    slot: SessionSlot,
670}
671
672#[derive(serde::Deserialize)]
673struct ClaimArgs {
674    #[serde(default)]
675    id: Option<String>,
676    #[serde(default)]
677    epic: Option<String>,
678}
679
680#[async_trait::async_trait]
681impl Tool for Claim {
682    fn name(&self) -> &'static str {
683        "tasks__claim"
684    }
685
686    fn description(&self) -> &'static str {
687        "Claim a task in YOUR project: one atomic write that makes you its owner and marks it \
688         in progress, ticking the attempt. Pass `id` for a named row, or `epic` to pull the top \
689         ready row of that epic by rank - exactly one of the two. `id` and `epic` each take a \
690         full task id or any unique prefix of one; an exact id wins over any longer id that \
691         starts with it, and a prefix matching more than one row is refused with the matching \
692         rows named. Refused when a live seat holds the row (named), when it is not pending, \
693         when it is reserved for the user (`reserved: \"user\"` - the sweep's pick and nobody \
694         else's), or when you already have a row in progress: one in progress per worker. \
695         Claiming is for unassigned rows: a row a lead created for you already names you as \
696         `owner`, so work it without claiming. Returns the claimed record."
697    }
698
699    fn input_schema(&self) -> serde_json::Value {
700        serde_json::json!({
701            "type": "object",
702            "properties": {
703                "id": {
704                    "type": "string",
705                    "description": "The task id to claim, or any unique prefix of one.",
706                },
707                "epic": {
708                    "type": "string",
709                    "description": "The epic whose top ready row to pull, by rank; a full id or \
710                                    any unique prefix of one.",
711                },
712            },
713            "additionalProperties": false,
714        })
715    }
716
717    async fn call(&self, input: ToolInput) -> ToolOutput {
718        let args: ClaimArgs = match serde_json::from_value(input.value) {
719            Ok(args) => args,
720            Err(err) => return tool_error(format!("invalid arguments: {err}")),
721        };
722        match (&args.id, &args.epic) {
723            (Some(_), None) | (None, Some(_)) => {}
724            _ => return tool_error("state exactly one of id or epic".to_owned()),
725        }
726        match self.facade.claim_task(&self.slot, args.id.as_deref(), args.epic.as_deref()) {
727            Ok(task) => match serde_json::to_string_pretty(&task_to_json(&task)) {
728                Ok(json) => ToolOutput::text(json),
729                Err(err) => tool_error(format!("response serialization failed: {err}")),
730            },
731            Err(err) => tool_error(format_tasks_error(&err)),
732        }
733    }
734}
735
736struct Wait {
737    facade: Arc<dyn TasksFacade>,
738    slot: SessionSlot,
739}
740
741#[derive(serde::Deserialize)]
742struct WaitArgs {
743    id: String,
744    kind: WaitingKind,
745    #[serde(default)]
746    detail: Option<String>,
747    #[serde(default)]
748    on: Option<String>,
749}
750
751#[async_trait::async_trait]
752impl Tool for Wait {
753    fn name(&self) -> &'static str {
754        "tasks__wait"
755    }
756
757    fn description(&self) -> &'static str {
758        "Put one of YOUR rows into waiting and state what it waits on. `kind` is decision \
759         (which routes to the user), dependency (another task: put its id in `on`), or resource \
760         (an account, a CI run, a machine). A decision wait is the at-convenience channel to \
761         the user: a lead who can continue but wants the person's look puts the ask here on a \
762         board row rather than saying it in prose, because prose is not a surface and a wait \
763         left in prose is invisible once your turn ends. Do not use it to park work the \
764         decision model, your peers or the review can settle. A dependency wait is on the \
765         task it names, not on the user. A blocker parked on the user's look has not ended, so the chain keeps \
766         waiting until the user approves it. The waiting row is not itself waiting on the \
767         user: a row's `verify: user` gates its own completion, not its dependency \
768         wait, so the row stays movable and deletable. Route a decision to the user when the user \
769         asked for a hold, when something is still genuinely ambiguous after the decision model, \
770         peer opinions and the team's own review, or when the person should look at the work when \
771         they come back. Only the lead mints a \
772         user-decision wait: a worker that believes the call is the user's hands it to its \
773         lead, because a decision a worker parks outlives its answer and the lead cannot close \
774         it afterward. `detail` is the words. \
775         A wait on the user that has gone moot is withdrawn, not stranded: a lead cancels the \
776         row with the reason it goes away (`tasks__update`, `detail`), and the row records why. \
777         The row \
778         and the `on` blocker are each named by a full id or any unique prefix of one; an exact \
779         id wins over any longer id that starts with it, and a prefix matching more than one row \
780         is refused with the matching rows named. The row must be yours. Refused for a row you \
781         do not hold, or one that is not there. Returns the record in waiting."
782    }
783
784    fn input_schema(&self) -> serde_json::Value {
785        serde_json::json!({
786            "type": "object",
787            "properties": {
788                "id": {
789                    "type": "string",
790                    "description": "The task id to put into waiting, or any unique prefix of one.",
791                },
792                "kind": {
793                    "type": "string",
794                    "enum": ["decision", "dependency", "resource"],
795                    "description": "What it waits on; the kind decides where the unblock lands.",
796                },
797                "detail": {
798                    "type": "string",
799                    "description": "The words: what exactly is being waited for. For a decision: \
800                                    why it needs the user specifically, what is already decided, \
801                                    what is open, what their answer changes, and the links to \
802                                    read - a bare question with no why is its own failure, and \
803                                    the flag exists to make the return cheap. A wait is a flag, \
804                                    not a stop: carry on with your next held row and let the \
805                                    answer resume this one, unless the user asked for a stop.",
806                },
807                "on": {
808                    "type": "string",
809                    "description": "For a dependency: the task id it waits on, or any unique \
810                                    prefix of one.",
811                },
812            },
813            "required": ["id", "kind"],
814            "additionalProperties": false,
815        })
816    }
817
818    async fn call(&self, input: ToolInput) -> ToolOutput {
819        let args: WaitArgs = match serde_json::from_value(input.value) {
820            Ok(args) => args,
821            Err(err) => return tool_error(format!("invalid arguments: {err}")),
822        };
823        if args.kind == WaitingKind::Dependency && args.on.is_none() {
824            return tool_error("a dependency wait needs `on`, the task it waits on".to_owned());
825        }
826        let on = args.on.as_deref().map(TaskId::from);
827        match self.facade.wait_task(
828            &self.slot,
829            &TaskId::from(args.id.as_str()),
830            args.kind,
831            args.detail,
832            on.as_ref(),
833        ) {
834            Ok(task) => match serde_json::to_string_pretty(&task_to_json(&task)) {
835                Ok(json) => ToolOutput::text(json),
836                Err(err) => tool_error(format!("response serialization failed: {err}")),
837            },
838            Err(err) => tool_error(format_tasks_error(&err)),
839        }
840    }
841}
842
843struct Approve {
844    facade: Arc<dyn TasksFacade>,
845    slot: SessionSlot,
846}
847
848#[derive(serde::Deserialize)]
849struct ApproveArgs {
850    id: String,
851    /// The approval as the user gave it, quoted.
852    approval: String,
853}
854
855#[async_trait::async_trait]
856impl Tool for Approve {
857    fn name(&self) -> &'static str {
858        "tasks__approve"
859    }
860
861    fn description(&self) -> &'static str {
862        "Lead-only. Complete a row waiting on the user's LOOK by recording the approval the \
863         user already gave, quoted from the chat or the look record. `approval` is that quote \
864         and must not be empty: it lands in the row's detail and is the evidence the board \
865         draws as lead-attested. This door is for a look only - never a decision or a \
866         question, which are the user's to answer - and only the project lead may call it. \
867         A verification wait on a row with no `verify: \"user\"` gate of its own is a stale \
868         park, not a look: never attest it, and clear it with a move off waiting, or a \
869         withdraw only when the ask is moot. \
870         The user's own approve, send-back and cancel doors stay; the user's reopen strikes \
871         the attestation, re-mints the look gate and parks the row on them again. A task is \
872         named by its full id or any unique prefix of one. Refused when the row is not \
873         waiting on a look, when the quote is empty, or when the caller is not the lead."
874    }
875
876    fn input_schema(&self) -> serde_json::Value {
877        serde_json::json!({
878            "type": "object",
879            "properties": {
880                "id": {
881                    "type": "string",
882                    "description": "The row waiting on the look, or any unique prefix of its id.",
883                },
884                "approval": {
885                    "type": "string",
886                    "description": "The user's approval, quoted as they gave it (e.g. \
887                                    \"Looks right - merge\"). Required and non-empty.",
888                },
889            },
890            "required": ["id", "approval"],
891            "additionalProperties": false,
892        })
893    }
894
895    async fn call(&self, input: ToolInput) -> ToolOutput {
896        let args: ApproveArgs = match serde_json::from_value(input.value) {
897            Ok(args) => args,
898            Err(err) => return tool_error(format!("invalid arguments: {err}")),
899        };
900        let approval = args.approval.trim();
901        if approval.is_empty() {
902            return tool_error(
903                "an attestation needs the user's approval quoted in `approval`; it must not be \
904                 empty"
905                    .to_owned(),
906            );
907        }
908        match self.facade.attest_task(&self.slot, &TaskId::from(args.id.as_str()), approval) {
909            Ok(task) => match serde_json::to_string_pretty(&task_to_json(&task)) {
910                Ok(json) => ToolOutput::text(json),
911                Err(err) => tool_error(format!("response serialization failed: {err}")),
912            },
913            Err(err) => tool_error(format_tasks_error(&err)),
914        }
915    }
916}
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921    use crate::mcp::tasks::facade::{MockTasksFacade, RemovedTaskTree};
922    use crate::mcp::test_support::text_of;
923    use forge_primitives::tasks::{Estimate, TaskLink};
924
925    fn lead_slot() -> SessionSlot {
926        SessionSlot::lead("TestOrg", "myproj")
927    }
928
929    fn input(value: serde_json::Value) -> ToolInput {
930        ToolInput { value }
931    }
932
933    #[tokio::test]
934    async fn create_persists_a_task_for_the_callers_project() {
935        let facade = Arc::new(MockTasksFacade::default());
936        let out = Create { facade: facade.clone(), slot: lead_slot() }
937            .call(input(serde_json::json!({ "subject": "Merge peers and workers into agents" })))
938            .await;
939        assert!(!out.is_error, "create succeeds: {out:?}");
940        let calls = facade.created.lock();
941        assert_eq!(calls.len(), 1, "one task created");
942        assert_eq!(calls[0].1.subject, "Merge peers and workers into agents");
943        assert_eq!(calls[0].2.project_name, "myproj", "stamped with the caller's project");
944    }
945
946    #[tokio::test]
947    async fn create_threads_every_optional_field_to_the_facade() {
948        let facade = Arc::new(MockTasksFacade::default());
949        let out = Create { facade: facade.clone(), slot: lead_slot() }
950            .call(input(serde_json::json!({
951                "subject": "Merge peers and workers",
952                "active_form": "Merging peers and workers",
953                "detail": "One agents family.",
954                "status": "in_progress",
955                "owner": "agents-merge",
956                "parent": "epic",
957                "rank": 10,
958                "verify": "user",
959                "reserved": "user",
960                "links": [
961                    { "kind": "issue", "label": "PR #1173", "target": "https://example.invalid/pull/1173" },
962                    { "target": "docs/plan.md" }
963                ],
964                "estimate": "1d",
965            })))
966            .await;
967        assert!(!out.is_error, "create succeeds: {out:?}");
968        let calls = facade.created.lock();
969        let draft = &calls[0].1;
970        assert_eq!(draft.active_form.as_deref(), Some("Merging peers and workers"));
971        assert_eq!(draft.detail.as_deref(), Some("One agents family."));
972        assert_eq!(draft.status, Some(TaskStatus::InProgress));
973        assert_eq!(draft.owner.as_deref(), Some("agents-merge"));
974        assert_eq!(draft.parent.as_deref(), Some("epic"));
975        assert_eq!(draft.rank, Some(10));
976        assert_eq!(draft.verify, Some(forge_primitives::tasks::Verify::User));
977        assert_eq!(
978            draft.reserved,
979            Some(forge_primitives::tasks::ReservedBy::User),
980            "the reserved pick reaches the facade",
981        );
982        assert_eq!(draft.links.len(), 2);
983        assert_eq!(draft.links[0].kind, Some(LinkKind::Issue));
984        assert_eq!(
985            draft.links[1].kind, None,
986            "an omitted kind is derived at the facade, not guessed here",
987        );
988        assert_eq!(draft.links[1].target, "docs/plan.md");
989        assert_eq!(draft.estimate.as_deref(), Some("1d"));
990    }
991
992    #[tokio::test]
993    async fn create_rejects_a_missing_subject_without_touching_the_facade() {
994        let facade = Arc::new(MockTasksFacade::default());
995        let out = Create { facade: facade.clone(), slot: lead_slot() }
996            .call(input(serde_json::json!({ "detail": "no subject" })))
997            .await;
998        assert!(out.is_error, "subject is required");
999        assert!(facade.created.lock().is_empty(), "invalid input never reaches the facade");
1000    }
1001
1002    #[tokio::test]
1003    async fn approve_threads_the_quote_and_the_caller_to_the_facade() {
1004        let facade = Arc::new(MockTasksFacade::default());
1005        let out = Approve { facade: facade.clone(), slot: lead_slot() }
1006            .call(input(serde_json::json!({ "id": "t-1", "approval": " looks good " })))
1007            .await;
1008        assert!(!out.is_error, "approve succeeds: {out:?}");
1009        let calls = facade.attested.lock();
1010        assert_eq!(calls.len(), 1, "one attestation reached the facade");
1011        assert_eq!(calls[0].1, TaskId::from("t-1"), "the row the caller named");
1012        assert_eq!(calls[0].2, "looks good", "the quote is trimmed, not dropped");
1013    }
1014
1015    #[tokio::test]
1016    async fn approve_refuses_an_empty_quote_without_touching_the_facade() {
1017        let facade = Arc::new(MockTasksFacade::default());
1018        let out = Approve { facade: facade.clone(), slot: lead_slot() }
1019            .call(input(serde_json::json!({ "id": "t-1", "approval": "   " })))
1020            .await;
1021        assert!(out.is_error, "an empty approval is nothing to stand behind");
1022        assert!(facade.attested.lock().is_empty(), "invalid input never reaches the facade");
1023    }
1024
1025    /// The lead door is shipped text (#2423/B): a caller learns it is
1026    /// lead-only and look-only, that the quote is required, and that the
1027    /// user's reopen is the way back.
1028    #[test]
1029    fn the_approve_description_states_the_lead_door() {
1030        let approve = Approve { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() };
1031        for clause in [
1032            "Lead-only",
1033            "never a decision or a question",
1034            "must not be empty",
1035            "the user's reopen strikes the attestation",
1036        ] {
1037            assert!(
1038                approve.description().contains(clause),
1039                "tasks__approve does not state the door ({clause}): {}",
1040                approve.description(),
1041            );
1042        }
1043        assert_eq!(
1044            approve.input_schema()["required"],
1045            serde_json::json!(["id", "approval"]),
1046            "the quote is required at the schema",
1047        );
1048    }
1049
1050    /// The gate is minted on the row, never inherited, so a verification
1051    /// wait left behind by an inherited gate is a stale park - not a look
1052    /// the lead can attest. Its door is a move or a withdraw, and the
1053    /// description says so where the lead reads the door's scope.
1054    #[test]
1055    fn the_approve_description_separates_a_stale_park_from_the_look() {
1056        let approve = Approve { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() };
1057        assert!(
1058            approve.description().contains(
1059                "A verification wait on a row with no `verify: \"user\"` gate of its own is a \
1060                 stale park, not a look"
1061            ),
1062            "the stale park is separated from the look: {}",
1063            approve.description(),
1064        );
1065        assert!(
1066            approve.description().contains(
1067                "never attest it, and clear it with a move off waiting, or a withdraw only \
1068                 when the ask is moot"
1069            ),
1070            "the stale park's own door and its moot condition are pinned: {}",
1071            approve.description(),
1072        );
1073    }
1074
1075    #[tokio::test]
1076    async fn update_reports_a_missing_id_rather_than_succeeding_silently() {
1077        let facade = Arc::new(MockTasksFacade::default());
1078        let out = Update { facade, slot: lead_slot() }
1079            .call(input(serde_json::json!({ "id": "nope", "status": "completed" })))
1080            .await;
1081        assert!(out.is_error, "an unknown id is an error, not a no-op");
1082        assert!(
1083            text_of(&out).contains("nope"),
1084            "the refusal names the id it was given, so the caller can see what it sent: {out:?}",
1085        );
1086    }
1087
1088    /// The update refusal says which case it is: an id another project
1089    /// owns reads differently from one no project has, so neither can be
1090    /// taken for the other.
1091    #[tokio::test]
1092    async fn update_refusals_name_the_case() {
1093        let facade = Arc::new(MockTasksFacade::default());
1094        let tool = Update { facade: facade.clone(), slot: lead_slot() };
1095
1096        *facade.update_result.lock() = Some(Err(TasksError::TaskInAnotherProject {
1097            id: "t-9".to_owned(),
1098            project: "otherproj".to_owned(),
1099        }));
1100        let elsewhere =
1101            tool.call(input(serde_json::json!({ "id": "t-9", "status": "pending" }))).await;
1102        assert!(elsewhere.is_error, "another project's row is refused");
1103        assert_eq!(
1104            text_of(&elsewhere),
1105            "task t-9 exists in project otherproj, not in your project",
1106            "the refusal names the project that owns the row",
1107        );
1108
1109        *facade.update_result.lock() = Some(Err(TasksError::NoSuchTask { id: "t-9".to_owned() }));
1110        let absent =
1111            tool.call(input(serde_json::json!({ "id": "t-9", "status": "pending" }))).await;
1112        assert!(absent.is_error, "an absent id is refused");
1113        assert_eq!(
1114            text_of(&absent),
1115            "no task with id t-9 in your project",
1116            "an id no project has must not read as another project's row",
1117        );
1118    }
1119
1120    /// The update result is the record it wrote, so the caller can name
1121    /// the task it moved and read the state it now holds - `updated task
1122    /// <id>` named neither.
1123    #[tokio::test]
1124    async fn update_echoes_the_updated_record() {
1125        let facade = Arc::new(MockTasksFacade::default());
1126        let mut task = sample_task();
1127        task.status = TaskStatus::InProgress;
1128        task.owner = Some(SessionSlot::lead("TestOrg", "myproj"));
1129        *facade.update_result.lock() = Some(Ok(task));
1130        let out = Update { facade, slot: lead_slot() }
1131            .call(input(serde_json::json!({ "id": "t-1", "status": "in_progress" })))
1132            .await;
1133        assert!(!out.is_error, "update succeeds: {out:?}");
1134        let json: serde_json::Value =
1135            serde_json::from_str(text_of(&out)).expect("the result is the task record, not prose");
1136        assert_eq!(json["id"], "t-1", "the record names the task it moved: {json}");
1137        assert_eq!(json["subject"], "Merge peers and workers");
1138        assert_eq!(json["status"], "in_progress", "and the state it now holds");
1139        assert_eq!(json["owner"], "lead");
1140    }
1141
1142    /// A delete echoes what went, in the adopted envelope: the record as
1143    /// it stood, plus the cascade's own count - the row has to say how
1144    /// much of the tree went with it. The record is compared to
1145    /// `task_to_json` whole, so an echo trimmed to a couple of fields
1146    /// cannot read as the record.
1147    #[tokio::test]
1148    async fn delete_echoes_the_removed_record_and_the_cascade_count() {
1149        let facade = Arc::new(MockTasksFacade::default());
1150        *facade.delete_result.lock() =
1151            Some(Ok(RemovedTaskTree { task: sample_task(), descendants_removed: 2 }));
1152        let out = Delete { facade, slot: lead_slot() }
1153            .call(input(serde_json::json!({ "id": "t-1" })))
1154            .await;
1155        assert!(!out.is_error, "delete succeeds: {out:?}");
1156        assert_eq!(out.blocks.len(), 1, "the envelope stays one text block: {out:?}");
1157        let json: serde_json::Value =
1158            serde_json::from_str(text_of(&out)).expect("the result is the adopted envelope");
1159        assert_eq!(json["status"], "deleted");
1160        assert_eq!(
1161            json["removed"],
1162            task_to_json(&sample_task()),
1163            "the echo is the whole record, not a hand-picked subset: {json}",
1164        );
1165        assert_eq!(json["descendants_removed"], 2);
1166    }
1167
1168    /// The count is always there: a leaf delete says zero rather than
1169    /// leaving the field out, so a reader never has to tell an omitted
1170    /// count from a cascade that took nothing.
1171    #[tokio::test]
1172    async fn a_delete_with_no_children_still_states_the_count() {
1173        let facade = Arc::new(MockTasksFacade::default());
1174        *facade.delete_result.lock() =
1175            Some(Ok(RemovedTaskTree { task: sample_task(), descendants_removed: 0 }));
1176        let out = Delete { facade, slot: lead_slot() }
1177            .call(input(serde_json::json!({ "id": "t-1" })))
1178            .await;
1179        assert!(!out.is_error, "delete succeeds: {out:?}");
1180        let json: serde_json::Value =
1181            serde_json::from_str(text_of(&out)).expect("the result is the adopted envelope");
1182        assert_eq!(json["descendants_removed"], 0, "a leaf states its count: {json}");
1183    }
1184
1185    #[tokio::test]
1186    async fn update_states_only_the_fields_it_was_given() {
1187        let facade = Arc::new(MockTasksFacade::default());
1188        *facade.update_result.lock() = Some(Ok(sample_task()));
1189        let out = Update { facade: facade.clone(), slot: lead_slot() }
1190            .call(input(serde_json::json!({
1191                "id": "t-1",
1192                "status": "waiting",
1193                "detail": "why",
1194                "links_add": [{ "target": "x" }]
1195            })))
1196            .await;
1197        assert!(!out.is_error, "update succeeds: {out:?}");
1198        let patch = &facade.updated.lock()[0].2;
1199        assert_eq!(patch.status, Some(TaskStatus::Waiting));
1200        assert_eq!(patch.detail.as_deref(), Some("why"), "the detail lands with the link");
1201        assert_eq!(patch.links_add.len(), 1, "the stated link reaches the facade");
1202        assert_eq!(patch.links_add[0].target, "x");
1203        assert_eq!(patch.subject, None, "an unstated field is left alone");
1204        assert_eq!(patch.owner, None, "an unstated field is left alone");
1205    }
1206
1207    fn sample_task() -> Task {
1208        Task {
1209            id: TaskId::from("t-1"),
1210            project_name: "myproj".to_owned(),
1211            subject: "Merge peers and workers".to_owned(),
1212            active_form: None,
1213            detail: None,
1214            status: TaskStatus::Pending,
1215            owner: None,
1216            parent: None,
1217            waiting_on: None,
1218            estimate: None,
1219            rank: None,
1220            verify: None,
1221            attested: None,
1222            reserved: None,
1223            links: Vec::new(),
1224            attempt: 0,
1225            archived_at: None,
1226            created_at: SystemTime::UNIX_EPOCH,
1227            updated_at: SystemTime::UNIX_EPOCH,
1228        }
1229    }
1230
1231    /// The spelling a caller reads in a result, the spelling it must send
1232    /// back, and the spelling the description names are the same six. Every
1233    /// one of those sites writes them out by hand rather than deriving them
1234    /// from the enum, so nothing else pins them to it.
1235    #[test]
1236    fn the_status_spellings_agree_across_the_output_and_both_schemas() {
1237        for (status, spelling) in [
1238            (TaskStatus::Pending, "pending"),
1239            (TaskStatus::InProgress, "in_progress"),
1240            (TaskStatus::Waiting, "waiting"),
1241            (TaskStatus::Completed, "completed"),
1242            (TaskStatus::Failed, "failed"),
1243            (TaskStatus::Canceled, "canceled"),
1244        ] {
1245            assert_eq!(status_str(status), spelling, "a result reads {status:?} as {spelling}");
1246        }
1247        for schema in [
1248            Create { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() }.input_schema(),
1249            Update { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() }.input_schema(),
1250        ] {
1251            let spelled: Vec<&str> = schema["properties"]["status"]["enum"]
1252                .as_array()
1253                .expect("the status property carries an enum")
1254                .iter()
1255                .filter_map(serde_json::Value::as_str)
1256                .collect();
1257            assert_eq!(
1258                spelled,
1259                ["pending", "in_progress", "waiting", "completed", "failed", "canceled"],
1260                "a caller must be able to send back what it read",
1261            );
1262        }
1263
1264        // The description is the site an agent reads BEFORE it writes anything,
1265        // and the one nothing checks: it named `blocked` for a release after
1266        // the enum dropped it, so a session following it took a hard
1267        // `invalid arguments` where the schema would have said the same thing
1268        // more cheaply.
1269        let description =
1270            Update { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() }.description();
1271        for spelling in ["pending", "in_progress", "waiting", "completed", "failed", "canceled"] {
1272            assert!(
1273                description.contains(spelling),
1274                "the description does not name {spelling}, which the schema accepts",
1275            );
1276        }
1277        assert!(
1278            !description.contains("blocked"),
1279            "the description names a status the enum no longer carries",
1280        );
1281    }
1282
1283    /// Every field the list description promises is in the block the model
1284    /// reads, spelled the way the description spells it, carrying the value
1285    /// its own field holds - the optional entries are one shared tuple, so
1286    /// a crossed pair of sources would otherwise read as correct.
1287    #[test]
1288    fn a_task_block_carries_the_named_fields() {
1289        let task = Task {
1290            active_form: Some("Merging".to_owned()),
1291            detail: Some("why".to_owned()),
1292            estimate: Some(Estimate { words: "1d".to_owned(), secs: 86_400 }),
1293            links: vec![TaskLink {
1294                kind: LinkKind::Pr,
1295                label: Some("PR #9".to_owned()),
1296                target: "https://example.invalid/pull/9".to_owned(),
1297                state: None,
1298                added_at: SystemTime::UNIX_EPOCH,
1299            }],
1300            parent: Some(TaskId::from("epic")),
1301            owner: Some(SessionSlot::lead("TestOrg", "myproj")),
1302            ..sample_task()
1303        };
1304        let json = task_to_json(&task);
1305        for key in [
1306            "id",
1307            "project",
1308            "subject",
1309            "status",
1310            "active_form",
1311            "detail",
1312            "artifact",
1313            "estimate",
1314            "parent",
1315            "owner",
1316            "created_at",
1317            "updated_at",
1318        ] {
1319            assert!(json.get(key).is_some(), "the task block carries {key}: {json}");
1320        }
1321        for (key, value) in [
1322            ("id", "t-1"),
1323            ("project", "myproj"),
1324            ("subject", "Merge peers and workers"),
1325            ("status", "pending"),
1326            ("active_form", "Merging"),
1327            ("detail", "why"),
1328            ("artifact", "PR #9"),
1329            ("estimate", "1d"),
1330            ("parent", "epic"),
1331            ("owner", "lead"),
1332        ] {
1333            assert_eq!(
1334                json.get(key).and_then(serde_json::Value::as_str),
1335                Some(value),
1336                "the block's {key} is the task's own {key}: {json}",
1337            );
1338        }
1339    }
1340
1341    /// An unclaimed task carries no `owner` key at all. Writing a sentinel
1342    /// makes the field unreadable: a caller filtering on what it read back
1343    /// finds none, and a session actually labelled `unclaimed` reads the
1344    /// same as nobody.
1345    #[test]
1346    fn an_unclaimed_task_omits_its_owner() {
1347        let json = task_to_json(&sample_task());
1348        assert!(json.get("owner").is_none(), "an unclaimed task carries no owner key: {json}");
1349    }
1350
1351    #[test]
1352    fn a_claimed_task_names_its_owner_by_label() {
1353        let task = Task {
1354            owner: Some(SessionSlot::worker("TestOrg", "myproj", "steward")),
1355            ..sample_task()
1356        };
1357        let json = task_to_json(&task);
1358        assert_eq!(json.get("owner").and_then(serde_json::Value::as_str), Some("steward"));
1359    }
1360
1361    /// An empty list has three causes, and the description names all of
1362    /// them. This is load-bearing rather than a prose lock: the caller is a
1363    /// model deciding whether to wait, to fix its project, or to give up,
1364    /// and an empty array that reads as "nothing in flight" when the store
1365    /// could not be read tells it to wait for work that will never appear.
1366    /// The cron group's list description carries the same clauses and the
1367    /// same test.
1368    #[test]
1369    fn the_list_description_owns_up_to_every_empty_result() {
1370        let desc =
1371            List { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() }.description();
1372        for clause in [
1373            "An empty array means your project has no tasks in flight",
1374            "or that your project could not be resolved",
1375            "or that this run could not read its stored tasks",
1376        ] {
1377            assert!(desc.contains(clause), "the list description owes {clause:?}: {desc}");
1378        }
1379    }
1380
1381    /// The description is the site a caller reads at the moment it picks
1382    /// `kind`, and `decision` is the one kind that parks a row on the user.
1383    /// Pinned so the parking test cannot drop out of the text that hands
1384    /// the caller that choice, and so the flag's context bar cannot drop
1385    /// out of the line the caller writes it on.
1386    #[test]
1387    fn the_wait_description_carries_the_attention_rule() {
1388        let wait = Wait { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() };
1389        let desc = wait.description();
1390        for clause in [
1391            "Route a decision to the user when the user asked for a hold",
1392            "something is still genuinely ambiguous after the decision model, peer opinions and \
1393             the team's own review",
1394            "when the person should look at the work when they come back",
1395        ] {
1396            assert!(desc.contains(clause), "the wait description owes {clause:?}: {desc}");
1397        }
1398        let detail = wait.input_schema()["properties"]["detail"]["description"]
1399            .as_str()
1400            .expect("the detail property carries a description")
1401            .to_owned();
1402        for clause in [
1403            "why it needs the user specifically, what is already decided, what is open, what \
1404             their answer changes, and the links to read",
1405            "a bare question with no why is its own failure",
1406            "A wait is a flag, not a stop",
1407            "unless the user asked for a stop",
1408        ] {
1409            assert!(detail.contains(clause), "the detail line owes {clause:?}: {detail}");
1410        }
1411    }
1412
1413    /// The description is the site a caller reads as it reaches for the
1414    /// tool, and the failure this fixes is a caller sending the short form
1415    /// it was told. Pinned per tool, so a later edit cannot drop the rule
1416    /// from one of the four verbs that resolve an id.
1417    #[test]
1418    fn every_task_tool_description_states_the_id_prefix_rule() {
1419        let facade = MockTasksFacade::new().into_arc();
1420        let slot = lead_slot();
1421        let claim = Claim { facade: facade.clone(), slot: slot.clone() };
1422        let update = Update { facade: facade.clone(), slot: slot.clone() };
1423        let wait = Wait { facade: facade.clone(), slot: slot.clone() };
1424        let delete = Delete { facade: facade.clone(), slot: slot.clone() };
1425        let create = Create { facade: facade.clone(), slot: slot.clone() };
1426        let list = List { facade: facade.clone(), slot: slot.clone() };
1427        for (name, description, schema, also_named) in [
1428            ("tasks__claim", claim.description(), claim.input_schema(), &["epic"] as &[&str]),
1429            ("tasks__update", update.description(), update.input_schema(), &[]),
1430            ("tasks__wait", wait.description(), wait.input_schema(), &["on"]),
1431            ("tasks__delete", delete.description(), delete.input_schema(), &[]),
1432        ] {
1433            for clause in [
1434                "unique prefix of one",
1435                // An exact id is a name, not a prefix of a longer one, and
1436                // no other site says so.
1437                "an exact id wins over any longer id that starts with it",
1438                "refused with the matching rows named",
1439            ] {
1440                assert!(
1441                    description.contains(clause),
1442                    "{name} does not state the rule ({clause}): {description}",
1443                );
1444            }
1445            for property_name in ["id"].into_iter().chain(also_named.iter().copied()) {
1446                let property =
1447                    schema["properties"][property_name]["description"].as_str().unwrap_or_else(
1448                        || panic!("{name} carries no {property_name} property description"),
1449                    );
1450                assert!(
1451                    property.contains("unique prefix"),
1452                    "{name}'s {property_name} property does not carry the rule: {property}",
1453                );
1454            }
1455        }
1456        // `parent` is an id a caller states too: the two writes store the
1457        // row it names, and the list narrows by it.
1458        for (name, schema) in [
1459            ("tasks__create", create.input_schema()),
1460            ("tasks__update", update.input_schema()),
1461            ("tasks__list", list.input_schema()),
1462        ] {
1463            let property = schema["properties"]["parent"]["description"]
1464                .as_str()
1465                .unwrap_or_else(|| panic!("{name} carries no parent property description"));
1466            assert!(
1467                property.contains("unique prefix"),
1468                "{name}'s parent property does not carry the rule: {property}",
1469            );
1470        }
1471    }
1472
1473    /// The delete states the ask gate the way the update does (#2251): a
1474    /// caller reaching for the verb learns a row waiting on the user - a
1475    /// look or a question - is not its to take, so the refusal is never
1476    /// the first word about it. The update's own look clause is the
1477    /// precedent - each description clause the verb depends on carries a
1478    /// test that fails when it goes missing.
1479    #[test]
1480    fn the_delete_description_states_the_ask_gate() {
1481        let facade = MockTasksFacade::new().into_arc();
1482        let slot = lead_slot();
1483        let delete = Delete { facade, slot };
1484        let description = delete.description();
1485        for clause in [
1486            "A row waiting on the user is not a delete's to take",
1487            "the board's answer or approve settles it",
1488            "refused rather than taken as a way around the ask",
1489        ] {
1490            assert!(
1491                description.contains(clause),
1492                "tasks__delete does not state the rule ({clause}): {description}",
1493            );
1494        }
1495    }
1496
1497    /// The dependency chain is shipped text (#2415): a dependency wait is
1498    /// on the task it names, not on the user, so `verify: user` on the row
1499    /// or its epic leaves the waiting row movable and deletable and the
1500    /// chain is released by the blocker's end.
1501    #[test]
1502    fn the_wait_and_delete_descriptions_state_the_dependency_chain() {
1503        let facade = MockTasksFacade::new().into_arc();
1504        let slot = lead_slot();
1505        let wait = Wait { facade: facade.clone(), slot: slot.clone() };
1506        let delete = Delete { facade, slot };
1507        for clause in [
1508            "A dependency wait is on the task it names, not on the user",
1509            "A blocker parked on the user's look has not ended, so the chain keeps waiting \
1510             until the user approves it",
1511            "a row's `verify: user` gates its own completion, not its dependency wait",
1512            "the row stays movable and deletable",
1513        ] {
1514            assert!(
1515                wait.description().contains(clause),
1516                "tasks__wait does not state the chain ({clause}): {}",
1517                wait.description(),
1518            );
1519        }
1520        assert!(
1521            delete.description().contains("A dependency wait is not such an ask"),
1522            "tasks__delete does not separate a dependency wait from the user's asks: {}",
1523            delete.description(),
1524        );
1525    }
1526
1527    /// **The wait is the at-convenience channel as well as the hold**:
1528    /// without it a seat writes its look request in prose, which reaches
1529    /// nobody once the turn ends.
1530    #[test]
1531    fn the_wait_description_states_the_at_convenience_channel() {
1532        let facade = MockTasksFacade::new().into_arc();
1533        let wait = Wait { facade, slot: lead_slot() };
1534        for clause in [
1535            "A decision wait is the at-convenience channel to the user",
1536            "puts the ask here on a board row rather than saying it in prose",
1537            "prose is not a surface",
1538        ] {
1539            assert!(
1540                wait.description().contains(clause),
1541                "tasks__wait does not state the channel ({clause}): {}",
1542                wait.description(),
1543            );
1544        }
1545    }
1546
1547    /// A wait left on an ended row reads that its target has ended
1548    /// forever, so the verb that ends the row owns moving every waiter
1549    /// off that wait in the same pass. Pinned on both doors, because
1550    /// either one can strand the chain.
1551    #[test]
1552    fn the_update_and_delete_descriptions_state_the_waiter_release() {
1553        let facade = MockTasksFacade::new().into_arc();
1554        let slot = lead_slot();
1555        let update = Update { facade: facade.clone(), slot: slot.clone() };
1556        let delete = Delete { facade, slot };
1557        for (name, description, remedy) in [
1558            (
1559                "tasks__update",
1560                update.description(),
1561                "When the row you end is named by another row's dependency wait, move every \
1562                 waiter off that wait in the same pass - a wait left on an ended row reads \
1563                 that its target has ended indefinitely",
1564            ),
1565            (
1566                "tasks__delete",
1567                delete.description(),
1568                "When the row you delete is named by another row's dependency wait, move every \
1569                 waiter off that wait in the same pass - a wait left on a deleted row reads \
1570                 that its target has ended indefinitely",
1571            ),
1572        ] {
1573            assert!(
1574                description.contains(remedy),
1575                "{name} does not state the waiter release: {description}",
1576            );
1577        }
1578    }
1579
1580    /// The withdraw door is shipped text (#2340): each verb that reaches a
1581    /// row waiting on the user states the door - the cancel carrying the
1582    /// reason, which lands on the row - so a lead reads the way out from
1583    /// the description it already has in hand, not from a refusal.
1584    #[test]
1585    fn the_task_descriptions_state_the_withdraw_door() {
1586        let facade = MockTasksFacade::new().into_arc();
1587        let slot = lead_slot();
1588        let update = Update { facade: facade.clone(), slot: slot.clone() };
1589        let delete = Delete { facade: facade.clone(), slot: slot.clone() };
1590        let wait = Wait { facade, slot };
1591        for (name, description, clauses) in [
1592            (
1593                "tasks__update",
1594                update.description(),
1595                &[
1596                    "a cancel of a row waiting on the user is the withdraw door once the ask has",
1597                    "it needs `detail`, the reason the ask is withdrawn, which lands on the row",
1598                    "and a cancel stating none - or only what the row already says - is refused",
1599                ] as &[&str],
1600            ),
1601            (
1602                "tasks__delete",
1603                delete.description(),
1604                &[
1605                    "An ask that has gone moot is withdrawn, not removed",
1606                    "the reason it goes away, and the row records why",
1607                ],
1608            ),
1609            (
1610                "tasks__wait",
1611                wait.description(),
1612                &[
1613                    "A wait on the user that has gone moot is withdrawn, not stranded",
1614                    "row with the reason it goes away (`tasks__update`, `detail`), and the row \
1615                     records why",
1616                ],
1617            ),
1618        ] {
1619            for clause in clauses {
1620                assert!(
1621                    description.contains(clause),
1622                    "{name} does not state the door ({clause}): {description}",
1623                );
1624            }
1625        }
1626    }
1627
1628    /// The board's Backlog is the `pending` status (#2831): the update is
1629    /// where an existing row is moved and the create's status property is
1630    /// where a new one is filed, so both say what pending means and name
1631    /// ownership as the way to set a row aside. Without it, pending reads as
1632    /// a parking space and the sweep then dispatches it.
1633    #[test]
1634    fn the_task_text_says_pending_is_the_backlog() {
1635        let facade = MockTasksFacade::new().into_arc();
1636        let slot = lead_slot();
1637        let update = Update { facade: facade.clone(), slot: slot.clone() };
1638        let create = Create { facade, slot };
1639        let description = update.description();
1640        for clause in [
1641            "Pending is the board's Backlog: work kept to look at later, not by itself an \
1642             assignment and not a promise to start",
1643            "A pending row that reads ready and unowned is dispatchable; take ownership to set \
1644             it aside",
1645        ] {
1646            assert!(
1647                description.contains(clause),
1648                "tasks__update does not state what pending means ({clause}): {description}",
1649            );
1650        }
1651        let status = create.input_schema()["properties"]["status"]["description"]
1652            .as_str()
1653            .expect("the status property carries a description")
1654            .to_owned();
1655        for clause in [
1656            "Defaults to pending, the board's Backlog: work kept to look at later, not by \
1657             itself an assignment",
1658            "The sweep dispatches only unowned ready rows, so take ownership to set one aside",
1659        ] {
1660            assert!(
1661                status.contains(clause),
1662                "tasks__create's status property owes the backlog where a row is filed \
1663                 ({clause}): {status}",
1664            );
1665        }
1666    }
1667
1668    /// Delete-on-landing is shipped text (#2377): the board is for running
1669    /// work, so a landed row with no children and no epic above it is
1670    /// deleted in the same pass rather than left completed. The carve-out
1671    /// is the board's own: an epic reads `to close` only while it holds
1672    /// terminal children, so a landed child of an epic - open or already
1673    /// terminal - is completed and left terminal instead, and a
1674    /// verify=user child parks in waiting on the user first. The update's
1675    /// status property is where the caller picks the terminal state, so it
1676    /// carries all of it.
1677    #[test]
1678    fn the_task_descriptions_state_delete_on_landing_and_the_epic_carve_out() {
1679        let facade = MockTasksFacade::new().into_arc();
1680        let slot = lead_slot();
1681        let update = Update { facade: facade.clone(), slot: slot.clone() };
1682        let delete = Delete { facade, slot };
1683        for (name, description, clause) in [
1684            (
1685                "tasks__update",
1686                update.description(),
1687                "The board is for running work: a landed row with no children and no epic \
1688                 above it is DELETED in the same pass (tasks__delete), never left completed; \
1689                 a landed row under an epic - open or already terminal - is completed and \
1690                 left terminal, because the epic reads `to close` only once every child is \
1691                 terminal (a verify=user child parks in waiting on the user and counts only \
1692                 once the user approves); and completing the epic's ROOT row closes it and \
1693                 archives the whole tree",
1694            ),
1695            (
1696                "tasks__delete",
1697                delete.description(),
1698                "a landed row - its work merged, delivered or concluded - with no children \
1699                 and no epic above it is deleted in the same pass, never left completed; a \
1700                 landed row under an epic - open or already terminal - is completed and left \
1701                 terminal instead, so the epic can read `to close` (a verify=user row parks \
1702                 in waiting on the user first)",
1703            ),
1704        ] {
1705            assert!(
1706                description.contains(clause),
1707                "{name} does not state the landing rule ({clause}): {description}",
1708            );
1709        }
1710        let status = update.input_schema()["properties"]["status"]["description"]
1711            .as_str()
1712            .expect("the status property carries a description")
1713            .to_owned();
1714        for clause in [
1715            "`completed` is not a resting state for a landed row with no children and no \
1716             epic above it",
1717            "A landed row under an epic - open or already terminal - stays completed and \
1718             terminal so the epic can read `to close`; a verify=user row parks in waiting on \
1719             the user first",
1720        ] {
1721            assert!(
1722                status.contains(clause),
1723                "the status property owes the rule where the caller picks it ({clause}): \
1724                 {status}",
1725            );
1726        }
1727    }
1728
1729    /// The waiting-completion refusal is shipped text (#2414): a caller
1730    /// reaching for `status: completed` learns from the description that
1731    /// a user-bound row already waiting on something else refuses the
1732    /// move by name, so the refusal is never the first word about it.
1733    #[test]
1734    fn the_update_description_states_the_waiting_completion_refusal() {
1735        let update = Update { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() };
1736        let description = update.description();
1737        for clause in [
1738            "except a completion on a row whose completion waits on the user",
1739            "the gate would trade that wait for a look park, so the completion is refused by \
1740             name",
1741            "move it off waiting first, then complete it",
1742        ] {
1743            assert!(
1744                description.contains(clause),
1745                "tasks__update does not state the refusal ({clause}): {description}",
1746            );
1747        }
1748    }
1749
1750    /// The verify gate is the lead's to mint on the update door too: a
1751    /// worker that reads only the update description must not find
1752    /// `verify: "user"` offered to it as an unconditional set.
1753    #[test]
1754    fn the_update_verify_door_keeps_the_mint_with_the_lead() {
1755        let update = Update { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() };
1756        assert!(
1757            update.description().contains(
1758                "Only the lead mints this gate; a worker hands the ask to its lead instead"
1759            ),
1760            "the update description keeps the gate with the lead: {}",
1761            update.description(),
1762        );
1763        let verify = update.input_schema()["properties"]["verify"]["description"]
1764            .as_str()
1765            .expect("the verify property carries a description")
1766            .to_owned();
1767        assert!(
1768            verify.contains("Can be set here by the lead; a worker asks its lead instead"),
1769            "the verify property keeps the gate with the lead: {verify}",
1770        );
1771    }
1772
1773    /// The user-read mint is shipped text (#2377), and the gate belongs to
1774    /// the row it is minted on (#2423): a row whose deliverable IS the
1775    /// user's own read or decision states `verify: "user"`, while an
1776    /// epic's gate governs the epic's own completion and a child never
1777    /// inherits it. The verify property is where the caller sets it.
1778    #[test]
1779    fn the_create_description_states_the_user_read_mint_never_inherited() {
1780        let create = Create { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() };
1781        let description = create.description();
1782        for clause in [
1783            "A row whose deliverable IS the user's own read or decision - a report the user \
1784             asked to read, a verdict the user asked to give - is minted `verify: \"user\"` \
1785             explicitly: it ends parked on the user, and the board's approve is what completes \
1786             it",
1787            "Only the lead mints this gate; a worker hands the ask to its lead instead",
1788            "an epic's `verify: \"user\"` governs the epic's own completion, and a child never \
1789             inherits it",
1790            "a child that owes no look completes on its own terms",
1791        ] {
1792            assert!(
1793                description.contains(clause),
1794                "tasks__create does not state the mint ({clause}): {description}",
1795            );
1796        }
1797        let verify = create.input_schema()["properties"]["verify"]["description"]
1798            .as_str()
1799            .expect("the verify property carries a description")
1800            .to_owned();
1801        for clause in [
1802            "The gate is minted on the row; a child never inherits its epic's",
1803            "anything else owes no look, even under a `user` epic",
1804            "The gate is the lead's to mint; a worker asks its lead instead",
1805        ] {
1806            assert!(
1807                verify.contains(clause),
1808                "the verify property owes the mint where the caller sets it ({clause}): {verify}",
1809            );
1810        }
1811    }
1812
1813    /// Only the lead mints a user-decision wait; a worker hands the ask
1814    /// over instead, because a decision a worker parks outlives its
1815    /// answer and the lead cannot close it afterward. Pinned on the wait
1816    /// verb, which is where the decision kind is chosen.
1817    #[test]
1818    fn the_wait_description_keeps_the_user_decision_mint_with_the_lead() {
1819        let wait = Wait { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() };
1820        for clause in [
1821            "Only the lead mints a user-decision wait",
1822            "a worker that believes the call is the user's hands it to its lead",
1823            "a decision a worker parks outlives its answer and the lead cannot close it afterward",
1824        ] {
1825            assert!(
1826                wait.description().contains(clause),
1827                "tasks__wait does not keep the ask with the lead ({clause}): {}",
1828                wait.description(),
1829            );
1830        }
1831    }
1832
1833    /// The assign-at-spawn path is shipped text (#2416): a lead creating
1834    /// a row for a worker assigns it at creation and says in the kick not
1835    /// to claim; claiming is for unassigned rows. Nothing else said so -
1836    /// a worker met the refusal before it met the rule.
1837    #[test]
1838    fn the_create_and_claim_descriptions_state_the_assign_at_spawn_pattern() {
1839        let facade = MockTasksFacade::new().into_arc();
1840        let slot = lead_slot();
1841        let create = Create { facade: facade.clone(), slot: slot.clone() };
1842        let claim = Claim { facade, slot };
1843        for clause in [
1844            "A lead creating a row for a worker assigns it at creation",
1845            "the kick says not to claim it",
1846            "refuses the worker's `tasks__claim` while the lead is live",
1847            "chased as having no live owner once the board marks it stale",
1848        ] {
1849            assert!(
1850                create.description().contains(clause),
1851                "tasks__create does not state the pattern ({clause}): {}",
1852                create.description(),
1853            );
1854        }
1855        for clause in [
1856            "Claiming is for unassigned rows",
1857            "a row a lead created for you already names you as `owner`",
1858            "work it without claiming",
1859        ] {
1860            assert!(
1861                claim.description().contains(clause),
1862                "tasks__claim does not state the pattern ({clause}): {}",
1863                claim.description(),
1864            );
1865        }
1866    }
1867
1868    #[tokio::test]
1869    async fn list_returns_project_tasks() {
1870        let facade = Arc::new(MockTasksFacade::default());
1871        let out = List { facade, slot: lead_slot() }.call(input(serde_json::json!({}))).await;
1872        assert!(!out.is_error, "list succeeds: {out:?}");
1873    }
1874
1875    fn board_row(task: Task) -> crate::board::BoardRow {
1876        crate::board::BoardRow {
1877            task,
1878            worked_secs: 0,
1879            updated_secs_ago: 0,
1880            marks: crate::board::Marks {
1881                ready: false,
1882                in_review: false,
1883                overdue: false,
1884                no_movement: false,
1885                waiting_too_long: false,
1886                stale: false,
1887                to_close: false,
1888            },
1889            rollup: None,
1890            parent_subject: None,
1891            history: Vec::new(),
1892            history_total: 0,
1893        }
1894    }
1895
1896    /// The list opens with the date, so a session reading it never does
1897    /// date arithmetic against bare timestamps.
1898    #[tokio::test]
1899    async fn the_list_leads_with_a_dated_line() {
1900        let facade = Arc::new(MockTasksFacade::default());
1901        let out = List { facade, slot: lead_slot() }.call(input(serde_json::json!({}))).await;
1902        assert!(!out.is_error, "list succeeds: {out:?}");
1903        let text = text_of(&out);
1904        let first = text.lines().next().expect("a first line");
1905        assert!(first.starts_with("now "), "the list opens with the date: {first}");
1906        assert!(first.contains('T'), "in RFC3339: {first}");
1907    }
1908
1909    #[tokio::test]
1910    async fn a_listed_row_carries_worked_time_and_its_marks() {
1911        let facade = Arc::new(MockTasksFacade::default());
1912        let mut row = board_row(sample_task());
1913        row.worked_secs = 3 * 3_600;
1914        row.updated_secs_ago = 12 * 60;
1915        row.marks.in_review = true;
1916        row.marks.overdue = true;
1917        facade.rows.lock().push(row);
1918        let out = List { facade, slot: lead_slot() }.call(input(serde_json::json!({}))).await;
1919        assert!(!out.is_error, "list succeeds: {out:?}");
1920        let text = text_of(&out);
1921        let body = text.split_once('\n').expect("a header line then the rows").1;
1922        let json: serde_json::Value =
1923            serde_json::from_str(body).expect("the body is the rows array");
1924        assert_eq!(json[0]["worked"], "3h", "worked time in the board's words: {json}");
1925        assert_eq!(json[0]["updated_ago"], "12m");
1926        let marks: Vec<&str> = json[0]["marks"]
1927            .as_array()
1928            .expect("marks array")
1929            .iter()
1930            .filter_map(serde_json::Value::as_str)
1931            .collect();
1932        assert!(marks.contains(&"overdue"), "marks name the facts: {marks:?}");
1933        assert!(marks.contains(&"in_review"), "marks name the facts: {marks:?}");
1934    }
1935
1936    #[tokio::test]
1937    async fn ready_narrows_to_rows_nothing_holds() {
1938        let facade = Arc::new(MockTasksFacade::default());
1939        let mut held = board_row(sample_task());
1940        held.task.owner = Some(SessionSlot::lead("TestOrg", "myproj"));
1941        let mut free = board_row(Task { id: TaskId::from("t-2"), ..sample_task() });
1942        free.marks.ready = true;
1943        facade.rows.lock().extend([held, free]);
1944        let out = List { facade: facade.clone(), slot: lead_slot() }
1945            .call(input(serde_json::json!({ "ready": true })))
1946            .await;
1947        assert!(!out.is_error, "list succeeds: {out:?}");
1948        let text = text_of(&out);
1949        assert!(text.contains("t-2"), "the ready row is listed: {text}");
1950        assert!(!text.contains("t-1"), "the held row is not: {text}");
1951        assert_eq!(facade.listed.lock()[0].3, Some(true), "and the filter was threaded");
1952    }
1953
1954    #[tokio::test]
1955    async fn delete_reports_a_missing_id_rather_than_succeeding_silently() {
1956        // The default mock finds nothing, so this is the not-found path.
1957        let facade = Arc::new(MockTasksFacade::default());
1958        let out = Delete { facade, slot: lead_slot() }
1959            .call(input(serde_json::json!({ "id": "nope" })))
1960            .await;
1961        assert!(out.is_error, "an unknown id is an error, not a no-op");
1962    }
1963
1964    /// The delete refusal says which case it is, the same split the
1965    /// update makes: a row another project owns is not an absent id.
1966    #[tokio::test]
1967    async fn delete_refusals_name_the_case() {
1968        let facade = Arc::new(MockTasksFacade::default());
1969        let tool = Delete { facade: facade.clone(), slot: lead_slot() };
1970
1971        *facade.delete_result.lock() = Some(Err(TasksError::TaskInAnotherProject {
1972            id: "t-9".to_owned(),
1973            project: "otherproj".to_owned(),
1974        }));
1975        let elsewhere = tool.call(input(serde_json::json!({ "id": "t-9" }))).await;
1976        assert!(elsewhere.is_error, "another project's row is refused");
1977        assert_eq!(
1978            text_of(&elsewhere),
1979            "task t-9 exists in project otherproj, not in your project",
1980            "the refusal names the project that owns the row",
1981        );
1982
1983        *facade.delete_result.lock() = Some(Err(TasksError::NoSuchTask { id: "t-9".to_owned() }));
1984        let absent = tool.call(input(serde_json::json!({ "id": "t-9" }))).await;
1985        assert!(absent.is_error, "an absent id is refused");
1986        assert_eq!(
1987            text_of(&absent),
1988            "no task with id t-9 in your project",
1989            "an id no project has must not read as another project's row",
1990        );
1991    }
1992
1993    #[test]
1994    fn tool_names_are_the_task_family() {
1995        // Assert the base tool names only. Combined with the `forge`
1996        // server name (proven in mcp::tests::build_forge_server_*) they
1997        // render as `mcp__forge__tasks__<x>` on the LLM side, which the SDK
1998        // auto-approve fast-path covers via the `mcp__forge__` prefix
1999        // (asserted in forge-sdk options.rs).
2000        let facade = MockTasksFacade::new().into_arc();
2001        let slot = lead_slot();
2002        let create = Create { facade: facade.clone(), slot: slot.clone() };
2003        let update = Update { facade: facade.clone(), slot: slot.clone() };
2004        let list = List { facade: facade.clone(), slot: slot.clone() };
2005        let delete = Delete { facade: facade.clone(), slot: slot.clone() };
2006        let claim = Claim { facade: facade.clone(), slot: slot.clone() };
2007        let wait = Wait { facade, slot };
2008        assert_eq!(create.name(), "tasks__create");
2009        assert_eq!(update.name(), "tasks__update");
2010        assert_eq!(list.name(), "tasks__list");
2011        assert_eq!(delete.name(), "tasks__delete");
2012        assert_eq!(claim.name(), "tasks__claim");
2013        assert_eq!(wait.name(), "tasks__wait");
2014    }
2015
2016    #[tokio::test]
2017    async fn claim_by_epic_threads_the_epic_and_echoes_the_row() {
2018        let facade = Arc::new(MockTasksFacade::default());
2019        let mut task = sample_task();
2020        task.status = TaskStatus::InProgress;
2021        task.owner = Some(SessionSlot::worker("TestOrg", "myproj", "w-1"));
2022        *facade.claim_result.lock() = Some(Ok(task));
2023        let slot = SessionSlot::worker("TestOrg", "myproj", "w-1");
2024        let out = Claim { facade: facade.clone(), slot }
2025            .call(input(serde_json::json!({
2026                "epic": "epic-1"
2027            })))
2028            .await;
2029        assert!(!out.is_error, "claim succeeds: {out:?}");
2030        let calls = facade.claimed.lock();
2031        assert_eq!(calls[0].1, None, "no id carried");
2032        assert_eq!(calls[0].2.as_deref(), Some("epic-1"));
2033        let json: serde_json::Value =
2034            serde_json::from_str(text_of(&out)).expect("the result is the claimed record");
2035        assert_eq!(json["status"], "in_progress");
2036        assert_eq!(json["owner"], "w-1");
2037    }
2038
2039    #[tokio::test]
2040    async fn claim_with_both_id_and_epic_is_refused_without_touching_the_facade() {
2041        let facade = Arc::new(MockTasksFacade::default());
2042        let out = Claim { facade: facade.clone(), slot: lead_slot() }
2043            .call(input(serde_json::json!({ "id": "t-1", "epic": "epic-1" })))
2044            .await;
2045        assert!(out.is_error, "exactly one of id or epic");
2046        let out = Claim { facade: facade.clone(), slot: lead_slot() }
2047            .call(input(serde_json::json!({})))
2048            .await;
2049        assert!(out.is_error, "neither is not one either");
2050        assert!(facade.claimed.lock().is_empty(), "invalid input never reaches the facade");
2051    }
2052
2053    /// The claim refusal says which case it is, the same split the
2054    /// update and delete make: a row another project owns is not an
2055    /// absent id.
2056    #[tokio::test]
2057    async fn claim_refusals_name_the_case() {
2058        let facade = Arc::new(MockTasksFacade::default());
2059        let tool = Claim { facade: facade.clone(), slot: lead_slot() };
2060
2061        *facade.claim_result.lock() = Some(Err(TasksError::TaskInAnotherProject {
2062            id: "t-9".to_owned(),
2063            project: "otherproj".to_owned(),
2064        }));
2065        let elsewhere = tool.call(input(serde_json::json!({ "id": "t-9" }))).await;
2066        assert!(elsewhere.is_error, "another project's row is refused");
2067        assert_eq!(
2068            text_of(&elsewhere),
2069            "task t-9 exists in project otherproj, not in your project",
2070            "the refusal names the project that owns the row",
2071        );
2072
2073        *facade.claim_result.lock() = Some(Err(TasksError::NoSuchTask { id: "t-9".to_owned() }));
2074        let absent = tool.call(input(serde_json::json!({ "id": "t-9" }))).await;
2075        assert!(absent.is_error, "an absent id is refused");
2076        assert_eq!(
2077            text_of(&absent),
2078            "no task with id t-9 in your project",
2079            "an id no project has must not read as another project's row",
2080        );
2081    }
2082
2083    /// The core's refusal is read by the caller: the seat that holds the
2084    /// row is named, not hidden behind a generic failure.
2085    #[tokio::test]
2086    async fn a_refused_claim_surfaces_the_cores_words() {
2087        let facade = Arc::new(MockTasksFacade::default());
2088        *facade.claim_result.lock() =
2089            Some(Err(TasksError::Refused("w-1 holds this row".to_owned())));
2090        let out = Claim { facade, slot: lead_slot() }
2091            .call(input(serde_json::json!({ "id": "t-1" })))
2092            .await;
2093        assert!(out.is_error, "the claim is refused");
2094        assert!(
2095            text_of(&out).contains("w-1 holds this row"),
2096            "and the refusal names the holder: {:?}",
2097            text_of(&out),
2098        );
2099    }
2100
2101    /// The wait refusal says which case it is: the row or its blocker
2102    /// naming another project's id is not an absent id.
2103    #[tokio::test]
2104    async fn wait_refusals_name_the_case() {
2105        let facade = Arc::new(MockTasksFacade::default());
2106        let tool = Wait { facade: facade.clone(), slot: lead_slot() };
2107
2108        *facade.wait_result.lock() = Some(Err(TasksError::TaskInAnotherProject {
2109            id: "t-9".to_owned(),
2110            project: "otherproj".to_owned(),
2111        }));
2112        let elsewhere =
2113            tool.call(input(serde_json::json!({ "id": "t-9", "kind": "resource" }))).await;
2114        assert!(elsewhere.is_error, "another project's row is refused");
2115        assert_eq!(
2116            text_of(&elsewhere),
2117            "task t-9 exists in project otherproj, not in your project",
2118            "the refusal names the project that owns the row",
2119        );
2120
2121        *facade.wait_result.lock() = Some(Err(TasksError::NoSuchTask { id: "t-9".to_owned() }));
2122        let absent = tool.call(input(serde_json::json!({ "id": "t-9", "kind": "resource" }))).await;
2123        assert!(absent.is_error, "an absent id is refused");
2124        assert_eq!(
2125            text_of(&absent),
2126            "no task with id t-9 in your project",
2127            "an id no project has must not read as another project's row",
2128        );
2129    }
2130
2131    #[tokio::test]
2132    async fn wait_threads_kind_detail_and_blocker() {
2133        let facade = Arc::new(MockTasksFacade::default());
2134        let out = Wait { facade: facade.clone(), slot: lead_slot() }
2135            .call(input(serde_json::json!({
2136                "id": "t-1",
2137                "kind": "dependency",
2138                "detail": "the board read has to land first",
2139                "on": "t-9",
2140            })))
2141            .await;
2142        assert!(!out.is_error, "wait succeeds: {out:?}");
2143        let calls = facade.waited.lock();
2144        assert_eq!(calls[0].2, WaitingKind::Dependency);
2145        assert_eq!(calls[0].3.as_deref(), Some("the board read has to land first"));
2146        assert_eq!(calls[0].4, Some(TaskId::from("t-9")));
2147    }
2148
2149    #[tokio::test]
2150    async fn a_dependency_wait_without_on_is_refused() {
2151        let facade = Arc::new(MockTasksFacade::default());
2152        let out = Wait { facade: facade.clone(), slot: lead_slot() }
2153            .call(input(serde_json::json!({ "id": "t-1", "kind": "dependency" })))
2154            .await;
2155        assert!(out.is_error, "a dependency wait names its blocker");
2156        assert!(facade.waited.lock().is_empty(), "invalid input never reaches the facade");
2157    }
2158}