Skip to main content

max / goingson

Key projects by id so they can be renamed Every write that takes a project now takes `project_id` from list_projects. The name stops being a resolution key and becomes a label, which is what makes update_project's new `name` field safe: makeover-egui could not follow its crate to makeover-immediate before, because the rename would have stranded every caller holding the old name. Nothing creates a project as a side effect any more. ensure_project and the two copies of resolve_project_cached are gone; an id that names no project is refused rather than stored, so a stale id cannot leave a row that reads as deliberately unfiled. create_project stays the only way one appears. Reads keep their name filter and gain a project_id one. list_tasks and list_events still answer `project: "dellm"`, since a read that misses is a wrong answer on screen, not a silently misfiled row. Callers migrated: bugreport, dellm, audit (fuzz and deepexorcise inherit audit's field conventions), CLAUDE.md, the crate README, and the go-mcp-design wiki note.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-29 17:11 UTC
Signed with PGP, not checked
Commit: 7bcf9bc471a528a8834973c3e884a6c62ea523f8
Parent: d9e2628
8 files changed, +379 insertions, -218 deletions
@@ -33,13 +33,14 @@
33 33
34 34 Read:
35 35
36 - - `list_projects`: id, name, type, status.
37 - - `list_tasks(project?, status?, tag?, limit?, offset?)`: compact task rows,
36 + - `list_projects`: id, name, type, status. Every write that takes a project
37 + takes its `project_id`, so this is how a name becomes an id.
38 + - `list_tasks(project_id?, project?, status?, tag?, limit?, offset?)`: compact task rows,
38 39 paged (default 50, max 200). Descriptions over 240 chars are clipped and the
39 40 row marked `truncated`; `get_task` has the full text. The reply carries
40 41 `total` and, while pages remain, `next_offset`. Walk until it is absent.
41 42 - `get_task(id)`: one task with subtasks and annotations.
42 - - `list_events(from?, to?, project?, recurring_only?, expand?, limit?, offset?)`:
43 + - `list_events(from?, to?, project_id?, project?, recurring_only?, expand?, limit?, offset?)`:
43 44 calendar events in a window, paged on the same terms as `list_tasks`. Defaults
44 45 to the next 30 days. See "Events and recurrence" below for what `expand` does.
45 46 - `get_event(id)`: one event with its full description and recurrence rule.
@@ -52,22 +53,24 @@
52 53 | Tool | Capability |
53 54 |------|------------|
54 55 | `create_project(name, type?, description?)`, idempotent on name | `go.project.create` |
55 - | `update_project(project, {status?, type?, description?})`, resolved by name, overlays only the fields you pass | `go.project.update` |
56 - | `create_task({description, project?, tags?, due?, priority?})` | `go.task.create` |
56 + | `update_project(project_id, {name?, status?, type?, description?})`, overlays only the fields you pass, `name` renames | `go.project.update` |
57 + | `create_task({description, project_id?, tags?, due?, priority?})` | `go.task.create` |
57 58 | `bulk_import_tasks([...])`, the `/dellm` primitive | `go.task.bulk_import` |
58 59 | `update_task(id, fields)`, overlays only the fields you pass | `go.task.update` |
59 60 | `complete_task(id)` | `go.task.complete` |
60 61 | `report_problems([...], source?)`, the `/audit` and `/fuzz` primitive | `go.problem.report` |
61 62 | `promote_problem(id, description?, priority?)`, problem to task | `go.problem.promote` |
62 - | `update_problem(id, {status?, project?})`, triage state and attribution | `go.problem.update` |
63 - | `create_event({title, start, end?, all_day?, project?, location?, recurrence?, block_type?, reminders?})` | `go.event.create` |
63 + | `update_problem(id, {status?, project_id?})`, triage state and attribution | `go.problem.update` |
64 + | `create_event({title, start, end?, all_day?, project_id?, location?, recurrence?, block_type?, reminders?})` | `go.event.create` |
64 65 | `bulk_import_events([...], source?)`, deduped on `(source, source_ref)` | `go.event.bulk_import` |
65 66 | `update_event(id, fields)`, overlays only the fields you pass | `go.event.update` |
66 67 | `delete_event(id)` | `go.event.delete` |
67 68
68 69 `bulk_import_tasks` dedupes on a `source:` provenance tag (e.g.
69 70 `source:todo.md:42`), so re-running a migration wave does not double-insert.
70 - Projects referenced by name are resolved, and created if absent.
71 + A project is referenced by `project_id` only, and must already exist: an id that
72 + names nothing is refused rather than stored, and no write creates a project as a
73 + side effect. `create_project` is the only thing that makes one.
71 74
72 75 ## Events and recurrence
73 76
@@ -144,8 +147,8 @@
144 147 `update_project` is how a session retires a project: set `status` to `Archived`.
145 148 There is no `delete_project`; the underlying delete is a hard DELETE with no
146 149 soft-delete behind it, which is the wrong default to hand a session. Renaming is
147 - not offered either, since the name is how `create_task` and `bulk_import_tasks`
148 - resolve a project.
150 + safe and is what `name` does: a project is keyed by id everywhere, so its name is
151 + a label the user owns and can change without stranding anything filed under it.
149 152
150 153 Enum values are the same words in and out (`SideProject`, `OnHold`), not the
151 154 UI's display forms (`Side Project`, `On Hold`), so a row from `list_projects`
@@ -37,6 +37,22 @@
37 37 })
38 38 }
39 39
40 + /// Parse a project id string into a [`ProjectId`], or an `InvalidArgs`.
41 + ///
42 + /// Names are the user's label and change; the id is what the backend keys on.
43 + /// The error names `list_projects` because that is the only way to turn a name
44 + /// a caller knows into the id the write tools take.
45 + pub fn parse_project_id(tool: &str, s: &str) -> Result<ProjectId, Error> {
46 + Uuid::parse_str(s.trim())
47 + .map(ProjectId::from_uuid)
48 + .map_err(|_| Error::InvalidArgs {
49 + tool: tool.to_string(),
50 + message: format!(
51 + "`{s}` is not a valid project id (expected a UUID; call list_projects to resolve a name to its id)"
52 + ),
53 + })
54 + }
55 +
40 56 /// Parse a subtask id string into a [`SubtaskId`], or an `InvalidArgs`.
41 57 pub fn parse_subtask_id(tool: &str, s: &str) -> Result<SubtaskId, Error> {
42 58 Uuid::parse_str(s.trim())
@@ -11,6 +11,7 @@
11 11 use kberg::Error;
12 12 use serde_json::{Value, json};
13 13 use sqlx::SqlitePool;
14 + use uuid::Uuid;
14 15
15 16 async fn seed_db() -> SqlitePool {
16 17 let pool = goingson_db_sqlite::init_pool(Some(":memory:"))
@@ -82,11 +83,18 @@
82 83 async fn bulk_import_creates_dedupes_and_reconciles() {
83 84 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
84 85
86 + // The project has to exist first: an id is the only way to file a task, and
87 + // nothing creates a project as a side effect of an import any more.
88 + let dellm = call(&reg, "create_project", json!({ "name": "dellm" })).await["id"]
89 + .as_str()
90 + .unwrap()
91 + .to_string();
92 +
85 93 let payload = json!({
86 94 "tasks": [
87 - { "description": "migrate todos", "project": "dellm", "due": "2026-07-15",
95 + { "description": "migrate todos", "project_id": dellm, "due": "2026-07-15",
88 96 "priority": "High", "tags": ["migration"], "source": "todo.md:10" },
89 - { "description": "bridge rustdoc", "project": "dellm", "source": "todo.md:20" },
97 + { "description": "bridge rustdoc", "project_id": dellm, "source": "todo.md:20" },
90 98 { "description": "no source, always inserts" }
91 99 ]
92 100 });
@@ -100,7 +108,8 @@
100 108 assert_eq!(second["created"], 1, "only the source-less task re-inserts");
101 109 assert_eq!(second["skipped"], 2, "both sourced tasks are skipped");
102 110
103 - // Project was auto-created and reused (not duplicated).
111 + // The named project is the one that was created up front, not a second one
112 + // conjured by the import.
104 113 let projects = call(&reg, "list_projects", json!({})).await;
105 114 let names: Vec<&str> = projects["projects"]
106 115 .as_array()
@@ -113,8 +122,11 @@
113 122 // Reconciliation: 4 tasks total, filterable by project and tag.
114 123 let all = call(&reg, "list_tasks", json!({})).await;
115 124 assert_eq!(all["count"], 4);
116 - let in_project = call(&reg, "list_tasks", json!({ "project": "dellm" })).await;
125 + let in_project = call(&reg, "list_tasks", json!({ "project_id": dellm })).await;
117 126 assert_eq!(in_project["count"], 2);
127 + // The name filter still reads, and agrees with the id.
128 + let by_name = call(&reg, "list_tasks", json!({ "project": "dellm" })).await;
129 + assert_eq!(by_name["count"], 2);
118 130 let tagged = call(&reg, "list_tasks", json!({ "tag": "migration" })).await;
119 131 assert_eq!(tagged["count"], 1);
120 132
@@ -275,7 +287,7 @@
275 287
276 288 // 120 tasks: more than the default page, less than two max pages.
277 289 let items: Vec<Value> = (0..120)
278 - .map(|i| json!({ "description": format!("task {i}"), "project": "bulk" }))
290 + .map(|i| json!({ "description": format!("task {i}") }))
279 291 .collect();
280 292 call(&reg, "bulk_import_tasks", json!({ "tasks": items })).await;
281 293
@@ -360,28 +372,31 @@
360 372 async fn update_project_overlays_fields_and_rejects_a_bad_status() {
361 373 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
362 374
363 - call(
375 + let dawless = call(
364 376 &reg,
365 377 "create_project",
366 378 json!({
367 379 "name": "dawless", "type": "SideProject", "description": "a synth thing"
368 380 }),
369 381 )
370 - .await;
382 + .await["id"]
383 + .as_str()
384 + .unwrap()
385 + .to_string();
371 386
372 387 // The motivating case: retire a dormant project without touching the desktop UI.
373 388 let archived = call(
374 389 &reg,
375 390 "update_project",
376 391 json!({
377 - "project": "dawless", "status": "Archived"
392 + "project_id": dawless, "status": "Archived"
378 393 }),
379 394 )
380 395 .await;
381 396 assert_eq!(archived["status"], "Archived");
382 397 assert_eq!(
383 398 archived["name"], "dawless",
384 - "name is the resolution key, untouched"
399 + "an unpassed name is left alone"
385 400 );
386 401 assert_eq!(
387 402 archived["description"], "a synth thing",
@@ -399,7 +414,7 @@
399 414 let err = reg
400 415 .call(
401 416 "update_project",
402 - json!({ "project": "dawless", "status": "archivd" }),
417 + json!({ "project_id": dawless, "status": "archivd" }),
403 418 Some(&grants),
404 419 )
405 420 .await
@@ -411,16 +426,28 @@
411 426 "the failed call changed nothing"
412 427 );
413 428
414 - // Unknown project names are an error, never a silent create.
429 + // An id that names nothing is an error, never a silent create.
415 430 let err = reg
416 431 .call(
417 432 "update_project",
418 - json!({ "project": "ghost", "status": "Active" }),
433 + json!({ "project_id": Uuid::new_v4().to_string(), "status": "Active" }),
419 434 Some(&grants),
420 435 )
421 436 .await
422 437 .unwrap_err();
423 - assert!(matches!(err, Error::ToolFailed { .. }), "got {err:?}");
438 + assert!(matches!(err, Error::InvalidArgs { .. }), "got {err:?}");
439 +
440 + // So is a name where an id belongs: the old wire shape fails loudly rather
441 + // than resolving, which is what makes the rename below safe.
442 + let err = reg
443 + .call(
444 + "update_project",
445 + json!({ "project_id": "dawless", "status": "Active" }),
446 + Some(&grants),
447 + )
448 + .await
449 + .unwrap_err();
450 + assert!(matches!(err, Error::InvalidArgs { .. }), "got {err:?}");
424 451 assert_eq!(
425 452 call(&reg, "list_projects", json!({})).await["projects"]
426 453 .as_array()
@@ -430,13 +457,108 @@
430 457 );
431 458 }
432 459
460 + /// The case the id migration exists for: a project renamed under its work, with
461 + /// everything filed under it still filed under it afterwards. This is what a
462 + /// name-keyed backend could not do, since the rename stranded every caller
463 + /// holding the old name.
464 + #[tokio::test]
465 + async fn renaming_a_project_keeps_its_work() {
466 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
467 +
468 + let id = call(&reg, "create_project", json!({ "name": "makeover-egui" })).await["id"]
469 + .as_str()
470 + .unwrap()
471 + .to_string();
472 + call(
473 + &reg,
474 + "create_task",
475 + json!({ "description": "seed the crate", "project_id": id }),
476 + )
477 + .await;
478 + call(
479 + &reg,
480 + "report_problems",
481 + json!({
482 + "problems": [{ "title": "no renderer yet", "project_id": id }]
483 + }),
484 + )
485 + .await;
486 +
487 + let renamed = call(
488 + &reg,
489 + "update_project",
490 + json!({ "project_id": id, "name": "makeover-immediate" }),
491 + )
492 + .await;
493 + assert_eq!(renamed["name"], "makeover-immediate");
494 + assert_eq!(renamed["status"], "Active", "a rename touches nothing else");
495 +
496 + // The task followed the project rather than being stranded by the old name.
497 + let tasks = call(&reg, "list_tasks", json!({ "project_id": id })).await;
498 + assert_eq!(tasks["count"], 1);
499 + assert_eq!(tasks["tasks"][0]["project"], "makeover-immediate");
500 +
501 + // As did the problem.
502 + let problems = call(&reg, "list_problems", json!({ "project_id": id })).await;
503 + assert_eq!(problems["count"], 1);
504 +
505 + // And the id still writes, which is the whole point.
506 + call(
507 + &reg,
508 + "create_task",
509 + json!({ "description": "render to a terminal", "project_id": id }),
510 + )
511 + .await;
512 + assert_eq!(
513 + call(&reg, "list_tasks", json!({ "project_id": id })).await["count"],
514 + 2
515 + );
516 + }
517 +
518 + /// An id that no project answers to must fail loudly. Storing it would leave a
519 + /// row that reads as projectless with no way to tell it from a deliberate one.
520 + #[tokio::test]
521 + async fn writes_reject_a_project_id_that_names_nothing() {
522 + let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
523 + let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect();
524 + let ghost = Uuid::new_v4().to_string();
525 +
526 + for (tool, args) in [
527 + (
528 + "create_task",
529 + json!({ "description": "orphan", "project_id": ghost }),
530 + ),
531 + (
532 + "bulk_import_tasks",
533 + json!({ "tasks": [{ "description": "orphan", "project_id": ghost }] }),
534 + ),
535 + (
536 + "create_event",
537 + json!({ "title": "orphan", "start": "2026-08-03T09:00:00Z", "project_id": ghost }),
538 + ),
539 + (
540 + "report_problems",
541 + json!({ "problems": [{ "title": "orphan", "project_id": ghost }] }),
542 + ),
543 + ] {
544 + let err = reg.call(tool, args, Some(&grants)).await.unwrap_err();
545 + assert!(
546 + matches!(err, Error::InvalidArgs { .. }),
547 + "{tool}: expected InvalidArgs, got {err:?}"
548 + );
549 + }
550 +
551 + assert_eq!(call(&reg, "list_tasks", json!({})).await["count"], 0);
552 + assert_eq!(call(&reg, "list_problems", json!({})).await["count"], 0);
553 + }
554 +
433 555 #[tokio::test]
434 556 async fn update_project_is_capability_gated() {
435 557 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
436 558 let err = reg
437 559 .call(
438 560 "update_project",
439 - json!({ "project": "x", "status": "Archived" }),
561 + json!({ "project_id": Uuid::new_v4().to_string(), "status": "Archived" }),
440 562 Some(&HashSet::new()),
441 563 )
442 564 .await
@@ -454,16 +576,19 @@
454 576 async fn project_enum_values_round_trip_through_the_write_surface() {
455 577 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
456 578
457 - call(
579 + let id = call(
458 580 &reg,
459 581 "create_project",
460 582 json!({ "name": "roundtrip", "type": "SideProject" }),
461 583 )
462 - .await;
584 + .await["id"]
585 + .as_str()
586 + .unwrap()
587 + .to_string();
463 588 call(
464 589 &reg,
465 590 "update_project",
466 - json!({ "project": "roundtrip", "status": "OnHold" }),
591 + json!({ "project_id": id, "status": "OnHold" }),
467 592 )
468 593 .await;
469 594
@@ -479,7 +604,7 @@
479 604 &reg,
480 605 "update_project",
481 606 json!({
482 - "project": "roundtrip",
607 + "project_id": id,
483 608 "type": listed["type"].clone(),
484 609 "status": listed["status"].clone(),
485 610 }),
@@ -519,14 +644,19 @@
519 644 async fn report_problems_is_idempotent_and_creates_no_tasks() {
520 645 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
521 646
647 + let goingson = call(&reg, "create_project", json!({ "name": "goingson" })).await["id"]
648 + .as_str()
649 + .unwrap()
650 + .to_string();
651 +
522 652 let payload = json!({
523 653 "source": "audit",
524 654 "problems": [
525 655 { "title": "sync cascade orphans subtasks", "body": "repository/task.rs",
526 656 "pain": 4, "scale": 5, "source_ref": "task-repo:cascade",
527 - "project": "goingson", "tags": ["sync"] },
657 + "project_id": goingson, "tags": ["sync"] },
528 658 { "title": "lint scripts unwired", "pain": 1, "scale": 1,
529 - "source_ref": "scripts:lint", "project": "goingson" }
659 + "source_ref": "scripts:lint", "project_id": goingson }
530 660 ]
531 661 });
532 662
@@ -557,6 +687,11 @@
557 687 async fn promote_problem_creates_a_linked_task_once() {
558 688 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
559 689
690 + let goingson = call(&reg, "create_project", json!({ "name": "goingson" })).await["id"]
691 + .as_str()
692 + .unwrap()
693 + .to_string();
694 +
560 695 call(
561 696 &reg,
562 697 "report_problems",
@@ -567,7 +702,7 @@
567 702 "body": "blob_sync.rs streams nothing",
568 703 "pain": 5, "scale": 4,
569 704 "source_ref": "blob_sync:memory",
570 - "project": "goingson",
705 + "project_id": goingson,
571 706 "tags": ["perf"]
572 707 }]
573 708 }),
@@ -1006,6 +1141,11 @@
1006 1141 async fn create_event_round_trips_a_rich_rule() {
1007 1142 let reg = tools::registry(Arc::new(Ctx::new(seed_db().await)));
1008 1143
1144 + let mnw = call(&reg, "create_project", json!({ "name": "MNW" })).await["id"]
1145 + .as_str()
1146 + .unwrap()
1147 + .to_string();
1148 +
1009 1149 let created = call(
1010 1150 &reg,
1011 1151 "create_event",
@@ -1013,7 +1153,7 @@
1013 1153 "title": "standup",
1014 1154 "start": "2026-08-03T09:00:00Z",
1015 1155 "end": "2026-08-03T09:15:00Z",
1016 - "project": "MNW",
1156 + "project_id": mnw,
1017 1157 "location": "video",
1018 1158 "recurrence": { "pattern": "Weekly", "interval": 2, "weekdays": [0, 2] },
1019 1159 "reminders": [900, 0, 900]
@@ -29,7 +29,7 @@
29 29 //! Without that, a session would ask "do I already have a standup?", be told no,
30 30 //! and enter a second one.
31 31
32 - use std::collections::{HashMap, HashSet};
32 + use std::collections::HashSet;
33 33 use std::sync::Arc;
34 34
35 35 use async_trait::async_trait;
@@ -43,13 +43,13 @@
43 43 use kberg::{Error, Result, Tool, ToolCallResult, ToolKind};
44 44 use serde_json::{Value, json};
45 45
46 - use super::{ensure_project, resolve_project_cached};
46 + use super::{project_id_arg, project_id_field};
47 47 use crate::caps;
48 48 use crate::context::Ctx;
49 49 use crate::convert::{
50 50 MAX_LIMIT, TZ_KINDS, event_row, event_summary_row, parse_block_type, parse_enum,
51 - parse_event_id, parse_instant, parse_limit, parse_offset, parse_recurrence, parse_reminders,
52 - req_str,
51 + parse_event_id, parse_instant, parse_limit, parse_offset, parse_project_id, parse_recurrence,
52 + parse_reminders, req_str,
53 53 };
54 54
55 55 /// Default `external_source` a bulk import stamps on the rows it creates, so a
@@ -92,7 +92,7 @@
92 92 "list_events"
93 93 }
94 94 fn description(&self) -> &'static str {
95 - "List calendar events in a time window. `from`/`to` are RFC 3339 timestamps or bare YYYY-MM-DD dates (local midnight); they default to now and 30 days out. By default each recurring event appears once, as the stored series, with its rule under `recurrence`. Pass `expand: true` to get every occurrence in the window instead, each marked `is_recurring_instance` with a synthetic id that only `list_events` understands, never `get_event` or the write tools. Optional filters: `project` (name), `recurring_only`. Paged: `limit` (default 50, max 200) and `offset`. Long descriptions are clipped and the row marked `truncated`; use `get_event` for the full text."
95 + "List calendar events in a time window. `from`/`to` are RFC 3339 timestamps or bare YYYY-MM-DD dates (local midnight); they default to now and 30 days out. By default each recurring event appears once, as the stored series, with its rule under `recurrence`. Pass `expand: true` to get every occurrence in the window instead, each marked `is_recurring_instance` with a synthetic id that only `list_events` understands, never `get_event` or the write tools. Optional filters: `project_id`, `project` (name, a convenience for reading only), `recurring_only`. Paged: `limit` (default 50, max 200) and `offset`. Long descriptions are clipped and the row marked `truncated`; use `get_event` for the full text."
96 96 }
97 97 fn kind(&self) -> ToolKind {
98 98 ToolKind::Read
@@ -106,7 +106,8 @@
106 106 "properties": {
107 107 "from": { "type": "string", "description": "RFC 3339 timestamp or YYYY-MM-DD (local midnight). Defaults to now." },
108 108 "to": { "type": "string", "description": "RFC 3339 timestamp or YYYY-MM-DD (local midnight). Defaults to 30 days after `from`." },
109 - "project": { "type": "string" },
109 + "project_id": { "type": "string", "description": "Project id (UUID) from list_projects." },
110 + "project": { "type": "string", "description": "Project name. Reads only, and a rename moves it; prefer project_id." },
110 111 "recurring_only": { "type": "boolean" },
111 112 "expand": { "type": "boolean", "description": "Materialize recurring occurrences in the window instead of listing series." },
112 113 "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT },
@@ -178,8 +179,13 @@
178 179 events.sort_by_key(|e| e.start_time);
179 180
180 181 let project = args.get("project").and_then(Value::as_str);
182 + let project_id = match args.get("project_id").and_then(Value::as_str) {
183 + Some(raw) if !raw.trim().is_empty() => Some(parse_project_id(self.name(), raw)?),
184 + _ => None,
185 + };
181 186 let matched: Vec<&Event> = events
182 187 .iter()
188 + .filter(|e| project_id.is_none_or(|want| e.project_id == Some(want)))
183 189 .filter(|e| project.is_none_or(|p| e.project_name.as_deref() == Some(p)))
184 190 .filter(|e| !recurring_only || e.has_recurrence())
185 191 .collect();
@@ -353,7 +359,7 @@
353 359 ctx: &Ctx,
354 360 tool: &str,
355 361 item: &Value,
356 - project_cache: &mut HashMap<String, ProjectId>,
362 + seen_projects: &mut HashSet<ProjectId>,
357 363 ) -> Result<NewEvent> {
358 364 let title = req_str(tool, item, "title")?.to_string();
359 365 let (start_time, end_time) = resolve_span(tool, item)?;
@@ -361,14 +367,7 @@
361 367 let block_type = parse_block_type(tool, item.get("block_type"))?;
362 368 let (tz_kind, timezone) = parse_tz_kind(tool, item)?;
363 369
364 - let project_id = match item.get("project").and_then(Value::as_str) {
365 - Some(name) if !name.trim().is_empty() => Some(
366 - resolve_project_cached(ctx, name, project_cache)
367 - .await
368 - .map_err(|e| fail(tool, e))?,
369 - ),
370 - _ => None,
371 - };
370 + let project_id = project_id_arg(ctx, tool, item, seen_projects).await?;
372 371
373 372 let mut event = NewEvent {
374 373 user_id: Some(ctx.user_id),
@@ -424,7 +423,7 @@
424 423 "end": { "type": "string" },
425 424 "all_day": { "type": "boolean", "description": "Snap the span to whole local days." },
426 425 "description": { "type": "string" },
427 - "project": { "type": "string", "description": "Project name; created if absent." },
426 + "project_id": project_id_field(),
428 427 "location": { "type": "string" },
429 428 "recurrence": {
430 429 "description": "Either a word (None|Daily|Weekly|Monthly) or an object {pattern, interval?, weekdays?, monthly_spec?}. weekdays are 0=Mon..6=Sun. monthly_spec is {type: dayOfMonth, day} or {type: nthWeekday, week, weekday} (week -1 = last)."
@@ -451,7 +450,7 @@
451 450 "create_event"
452 451 }
453 452 fn description(&self) -> &'static str {
454 - "Create one calendar event. `title` and `start` are required; `start`/`end` take RFC 3339 or YYYY-MM-DD (local midnight). Pass `all_day: true` to snap the span to whole local days. `project` (name) is resolved to a project, creating it if absent. `recurrence` is a word (None|Daily|Weekly|Monthly) or a rich rule object; a recurring event is stored once as a series, not as one row per occurrence. Use `bulk_import_events` for more than a few."
453 + "Create one calendar event. `title` and `start` are required; `start`/`end` take RFC 3339 or YYYY-MM-DD (local midnight). Pass `all_day: true` to snap the span to whole local days. `project_id` (from list_projects) files it under a project; omit it for an unfiled event. `recurrence` is a word (None|Daily|Weekly|Monthly) or a rich rule object; a recurring event is stored once as a series, not as one row per occurrence. Use `bulk_import_events` for more than a few."
455 454 }
456 455 fn kind(&self) -> ToolKind {
457 456 ToolKind::Write(caps::event_create())
@@ -464,8 +463,8 @@
464 463 })
465 464 }
466 465 async fn call(&self, args: Value) -> Result<ToolCallResult> {
467 - let mut cache = HashMap::new();
468 - let event = new_event_from(&self.0, self.name(), &args, &mut cache).await?;
466 + let mut seen = HashSet::new();
467 + let event = new_event_from(&self.0, self.name(), &args, &mut seen).await?;
469 468 let created = self
470 469 .0
471 470 .events()
@@ -530,7 +529,7 @@
530 529 .unwrap_or(DEFAULT_IMPORT_SOURCE);
531 530
532 531 let repo = self.0.events();
533 - let mut cache: HashMap<String, ProjectId> = HashMap::new();
532 + let mut seen_projects: HashSet<ProjectId> = HashSet::new();
534 533 let mut seen: HashSet<String> = HashSet::new();
535 534 let mut created_ids = Vec::new();
536 535 let mut skipped = 0usize;
@@ -561,7 +560,7 @@
561 560 }
562 561 }
563 562
564 - let event = match new_event_from(&self.0, self.name(), item, &mut cache).await {
563 + let event = match new_event_from(&self.0, self.name(), item, &mut seen_projects).await {
565 564 Ok(event) => event,
566 565 // Report the offending item by index; a 200-item payload with
567 566 // one bad date is otherwise a guessing game.
@@ -724,14 +723,10 @@
724 723 if args.get("reminders").is_some() {
725 724 patch.reminder_offsets_seconds = parse_reminders(args.get("reminders"));
726 725 }
727 - if let Some(name) = args.get("project").and_then(Value::as_str)
728 - && !name.trim().is_empty()
729 - {
730 - patch.project_id = Some(
731 - ensure_project(&self.0, name)
732 - .await
733 - .map_err(|e| fail(self.name(), e))?,
734 - );
726 + // Present-but-empty (or null) clears the project, matching update_task.
727 + if args.get("project_id").is_some() {
728 + let mut seen = HashSet::new();
729 + patch.project_id = project_id_arg(&self.0, self.name(), &args, &mut seen).await?;
735 730 }
736 731
737 732 let (start_time, end_time) = overlay_span(self.name(), &args, &current)?;
@@ -2,14 +2,16 @@
2 2 //! wires them into a single `ToolRegistry`, and holds the few helpers more than
3 3 //! one tool module needs.
4 4
5 - use std::collections::HashMap;
5 + use std::collections::HashSet;
6 6 use std::sync::Arc;
7 7
8 + use goingson_core::ProjectId;
8 9 use goingson_core::repository::ProjectRepository;
9 - use goingson_core::{NewProject, ProjectId, ProjectStatus, ProjectType};
10 - use kberg::ToolRegistry;
10 + use kberg::{Error, ToolRegistry};
11 + use serde_json::{Value, json};
11 12
12 13 use crate::context::Ctx;
14 + use crate::convert::parse_project_id;
13 15
14 16 mod event;
15 17 mod problem;
@@ -57,44 +59,91 @@
57 59 r
58 60 }
59 61
60 - // Shared helpers. Tasks and events both take a project by name, and both must
61 - // resolve it the same way or the two surfaces would disagree about what
62 - // `project: "MNW"` means.
62 + // Shared helpers. Tasks, events and problems all attach to a project by id, and
63 + // all must resolve it the same way or the surfaces would disagree about what a
64 + // `project_id` means.
65 + //
66 + // A project is addressed by id, never by name: the name is the user's label and
67 + // is theirs to change, so resolving by it would strand every caller the moment
68 + // someone renamed a project in the app. Nothing here creates a project as a side
69 + // effect either. Name resolution used to do that, which turned a typo into a new
70 + // project rather than an error; `create_project` is now the only way one appears.
63 71
64 - /// Resolve a project by name, creating a default `SideProject` if absent.
65 - pub(super) async fn ensure_project(
72 + /// Check that a `project_id` names a project this user owns, returning it.
73 + ///
74 + /// The existence check is the whole point: an id that resolves to nothing would
75 + /// otherwise be stored verbatim and the row would read as projectless forever.
76 + pub(super) async fn verify_project(
66 77 ctx: &Ctx,
67 - name: &str,
68 - ) -> goingson_core::repository::Result<ProjectId> {
69 - let repo = ctx.projects();
70 - if let Some(existing) = repo.find_by_name(ctx.user_id, name).await? {
71 - return Ok(existing.id);
78 + tool: &str,
79 + id: ProjectId,
80 + ) -> Result<ProjectId, Error> {
81 + let found = ctx
82 + .projects()
83 + .get_by_id(id, ctx.user_id)
84 + .await
85 + .map_err(|e| Error::ToolFailed {
86 + tool: tool.to_string(),
87 + message: e.to_string(),
88 + })?;
89 + match found {
90 + Some(_) => Ok(id),
91 + None => Err(Error::InvalidArgs {
92 + tool: tool.to_string(),
93 + message: format!("no project with id `{id}` (call list_projects for the ids)"),
94 + }),
72 95 }
73 - let created = repo
74 - .create(
75 - ctx.user_id,
76 - NewProject {
77 - name: name.to_string(),
78 - description: String::new(),
79 - project_type: ProjectType::SideProject,
80 - status: ProjectStatus::default(),
81 - },
82 - )
83 - .await?;
84 - Ok(created.id)
85 96 }
86 97
87 - /// `ensure_project` with a within-call cache, so a bulk import that references
88 - /// the same project 200 times hits the database once.
89 - pub(super) async fn resolve_project_cached(
98 + /// [`verify_project`] with a within-call cache, so a bulk import referencing the
99 + /// same project 200 times checks it once.
100 + pub(super) async fn verify_project_cached(
90 101 ctx: &Ctx,
91 - name: &str,
92 - cache: &mut HashMap<String, ProjectId>,
93 - ) -> goingson_core::repository::Result<ProjectId> {
94 - if let Some(id) = cache.get(name) {
95 - return Ok(*id);
102 + tool: &str,
103 + id: ProjectId,
104 + seen: &mut HashSet<ProjectId>,
105 + ) -> Result<ProjectId, Error> {
106 + if seen.contains(&id) {
107 + return Ok(id);
96 108 }
97 - let id = ensure_project(ctx, name).await?;
98 - cache.insert(name.to_string(), id);
109 + verify_project(ctx, tool, id).await?;
110 + seen.insert(id);
99 111 Ok(id)
100 112 }
113 +
114 + /// Read an optional `project_id` field off a wire object.
115 + ///
116 + /// Absent, null or empty all mean "no project": absent because the caller said
117 + /// nothing, empty because that is how an update clears one. Anything else must
118 + /// parse as a UUID and name a project that exists.
119 + pub(super) async fn project_id_arg(
120 + ctx: &Ctx,
121 + tool: &str,
122 + item: &Value,
123 + seen: &mut HashSet<ProjectId>,
124 + ) -> Result<Option<ProjectId>, Error> {
125 + let Some(raw) = item.get("project_id") else {
126 + return Ok(None);
127 + };
128 + if raw.is_null() {
129 + return Ok(None);
130 + }
131 + let raw = raw.as_str().ok_or_else(|| Error::InvalidArgs {
132 + tool: tool.to_string(),
133 + message: format!("`project_id` must be a string (got `{raw}`)"),
134 + })?;
135 + if raw.trim().is_empty() {
136 + return Ok(None);
137 + }
138 + let id = parse_project_id(tool, raw)?;
139 + verify_project_cached(ctx, tool, id, seen).await.map(Some)
140 + }
141 +
142 + /// The `project_id` schema fragment, worded once so every tool says the same
143 + /// thing about where an id comes from.
144 + pub(super) fn project_id_field() -> Value {
145 + json!({
146 + "type": "string",
147 + "description": "Project id (UUID) from list_projects. Names are labels and can be renamed, so they are not accepted here."
148 + })
149 + }
@@ -16,13 +16,13 @@
16 16 //! refreshes its findings rather than duplicating them, and re-reporting
17 17 //! something already promoted or dismissed does not undo that decision.
18 18
19 - use std::collections::HashMap;
19 + use std::collections::HashSet;
20 20 use std::sync::Arc;
21 21
22 22 use async_trait::async_trait;
23 23 use chrono::Utc;
24 24 use goingson_core::models::DbValue;
25 - use goingson_core::repository::{ProblemRepository, ProjectRepository, TaskCrud};
25 + use goingson_core::repository::{ProblemRepository, TaskCrud};
26 26 use goingson_core::{
27 27 NewProblem, NewTask, Priority, ProblemFilter, ProblemId, ProblemStatus, ProjectId,
28 28 };
@@ -30,9 +30,10 @@
30 30 use serde_json::{Value, json};
31 31 use uuid::Uuid;
32 32
33 + use super::project_id_arg;
33 34 use crate::caps;
34 35 use crate::context::Ctx;
35 - use crate::convert::{parse_enum, parse_tags, req_str};
36 + use crate::convert::{parse_enum, parse_project_id, parse_tags, req_str};
36 37
37 38 /// The accepted wire words for a problem's triage state.
38 39 const PROBLEM_STATUSES: &[&str] = &["Open", "Promoted", "Dismissed", "Resolved"];
@@ -146,14 +147,7 @@
146 147 };
147 148
148 149 let project_id = match args.get("project_id").and_then(Value::as_str) {
149 - Some(s) => Some(
150 - Uuid::parse_str(s.trim())
151 - .map(ProjectId::from_uuid)
152 - .map_err(|_| Error::InvalidArgs {
153 - tool: self.name().to_string(),
154 - message: format!("`{s}` is not a valid project id (expected a UUID)"),
155 - })?,
156 - ),
150 + Some(s) => Some(parse_project_id(self.name(), s)?),
157 151 None => None,
158 152 };
159 153
@@ -188,7 +182,7 @@
188 182 "report_problems"
189 183 }
190 184 fn description(&self) -> &'static str {
191 - "Report findings as problems (the /audit and /fuzz primitive). Each item: {title, body?, pain?, scale?, source_ref?, project?, tags?, resolved?}. `pain` (how much it hurts a hit user) and `scale` (how broadly it hits) are 1-5, defaulting to 3, and together with age drive the painhours ranking. `source` names the lens (audit, fuzz, deepaudit) and `source_ref` identifies the finding within it; re-reporting the same pair updates that problem instead of duplicating it, and never undoes a promotion or dismissal. Reporting does NOT create tasks: use promote_problem for the ones worth working."
185 + "Report findings as problems (the /audit and /fuzz primitive). Each item: {title, body?, pain?, scale?, source_ref?, project_id?, tags?, resolved?}. `pain` (how much it hurts a hit user) and `scale` (how broadly it hits) are 1-5, defaulting to 3, and together with age drive the painhours ranking. `source` names the lens (audit, fuzz, deepaudit) and `source_ref` identifies the finding within it; re-reporting the same pair updates that problem instead of duplicating it, and never undoes a promotion or dismissal. Reporting does NOT create tasks: use promote_problem for the ones worth working."
192 186 }
193 187 fn kind(&self) -> ToolKind {
194 188 ToolKind::Write(caps::problem_report())
@@ -208,7 +202,7 @@
208 202 "pain": { "type": "integer", "description": "1-5, how much it hurts a hit user. Default 3." },
209 203 "scale": { "type": "integer", "description": "1-5, how broadly it hits. Default 3." },
210 204 "source_ref": { "type": "string", "description": "Stable id for this finding within the source (e.g. module:check). Defaults to the title, so a re-run with the same title updates in place." },
211 - "project": { "type": "string", "description": "Project name; created if absent." },
205 + "project_id": super::project_id_field(),
212 206 "tags": { "type": "array", "items": { "type": "string" } },
213 207 "resolved": { "type": "boolean", "description": "The source considers this fixed. Settles an untriaged problem; leaves a promoted or dismissed one alone." }
214 208 },
@@ -237,7 +231,7 @@
237 231 .to_string();
238 232
239 233 let repo = self.0.problems();
240 - let mut project_cache: HashMap<String, ProjectId> = HashMap::new();
234 + let mut seen_projects: HashSet<ProjectId> = HashSet::new();
241 235 let mut reported = Vec::new();
242 236
243 237 for (idx, item) in items.iter().enumerate() {
@@ -263,14 +257,15 @@
263 257 .unwrap_or(&title)
264 258 .to_string();
265 259
266 - let project_id = match item.get("project").and_then(Value::as_str) {
267 - Some(name) if !name.trim().is_empty() => Some(
268 - resolve_project_cached(&self.0, name, &mut project_cache)
269 - .await
270 - .map_err(|e| fail(self.name(), e))?,
271 - ),
272 - _ => None,
273 - };
260 + let project_id = project_id_arg(&self.0, self.name(), item, &mut seen_projects)
261 + .await
262 + .map_err(|e| match e {
263 + Error::InvalidArgs { message, .. } => Error::InvalidArgs {
264 + tool: self.name().to_string(),
265 + message: format!("problems[{idx}]: {message}"),
266 + },
267 + other => other,
268 + })?;
274 269
275 270 let now = Utc::now();
276 271 let problem = repo
@@ -443,7 +438,7 @@
443 438 "properties": {
444 439 "id": { "type": "string" },
445 440 "status": { "type": "string", "description": "Open | Promoted | Dismissed | Resolved" },
446 - "project": { "type": "string", "description": "Project name to attribute this problem to; created if absent. Pass an empty string to clear." }
441 + "project_id": { "type": "string", "description": "Project id (UUID) from list_projects, attributing this problem to it. Pass an empty string to clear." }
447 442 },
448 443 "required": ["id"]
449 444 })
@@ -461,17 +456,9 @@
461 456 message: format!("no problem with id `{id}`"),
462 457 })?;
463 458
464 - if let Some(raw) = args.get("project").and_then(Value::as_str) {
465 - let project_id = if raw.trim().is_empty() {
466 - None
467 - } else {
468 - let mut cache = HashMap::new();
469 - Some(
470 - resolve_project_cached(&self.0, raw, &mut cache)
471 - .await
472 - .map_err(|e| fail(self.name(), e))?,
473 - )
474 - };
459 + if args.get("project_id").is_some() {
460 + let mut seen = HashSet::new();
461 + let project_id = project_id_arg(&self.0, self.name(), &args, &mut seen).await?;
475 462 current = repo
476 463 .set_project(id, self.0.user_id, project_id)
477 464 .await
@@ -499,39 +486,3 @@
499 486 ))
500 487 }
501 488 }
502 -
503 - /// Resolve a project name to an id, creating it if absent, with a within-call
504 - /// cache so a batch referencing one project repeatedly hits the database once.
505 - ///
506 - /// Mirrors `task.rs`'s helper of the same shape. Kept separate rather than
507 - /// shared because the task module's version is private to it and a cross-module
508 - /// `pub(crate)` here would make two unrelated tool families share a seam neither
509 - /// needs.
510 - async fn resolve_project_cached(
511 - ctx: &Ctx,
512 - name: &str,
513 - cache: &mut HashMap<String, ProjectId>,
514 - ) -> goingson_core::repository::Result<ProjectId> {
515 - if let Some(id) = cache.get(name) {
516 - return Ok(*id);
517 - }
518 - let repo = ctx.projects();
519 - let id = match repo.find_by_name(ctx.user_id, name).await? {
520 - Some(existing) => existing.id,
521 - None => {
522 - repo.create(
523 - ctx.user_id,
524 - goingson_core::NewProject {
525 - name: name.to_string(),
526 - description: String::new(),
527 - project_type: goingson_core::ProjectType::SideProject,
528 - status: goingson_core::ProjectStatus::default(),
529 - },
530 - )
531 - .await?
532 - .id
533 - }
534 - };
535 - cache.insert(name.to_string(), id);
536 - Ok(id)
537 - }
@@ -21,7 +21,7 @@
21 21
22 22 use crate::caps;
23 23 use crate::context::Ctx;
24 - use crate::convert::{parse_enum, req_str};
24 + use crate::convert::{parse_enum, parse_project_id, req_str};
25 25
26 26 /// The accepted wire words, mirroring each enum's `strum` serialize form (which
27 27 /// is what `FromStr` parses). Listed back to a caller that gets one wrong.
@@ -51,7 +51,7 @@
51 51 "list_projects"
52 52 }
53 53 fn description(&self) -> &'static str {
54 - "List all projects (id, name, type, status). Use to resolve a project name to its id before creating tasks."
54 + "List all projects (id, name, type, status). Every write tool that takes a project takes its `project_id`, so this is how a name a human uses becomes the id the backend keys on."
55 55 }
56 56 fn kind(&self) -> ToolKind {
57 57 ToolKind::Read
@@ -172,7 +172,7 @@
172 172 "update_project"
173 173 }
174 174 fn description(&self) -> &'static str {
175 - "Update an existing project, resolved by `project` (its name). Only the fields you pass change. `status` is Active|OnHold|Completed|Archived. Archived is the non-destructive way to retire a project. `type` is Job|SideProject|Company|Essay|Article|Painting|Other. Renaming is not offered: the name is how create_task and bulk_import_tasks resolve a project, so a rename would strand callers."
175 + "Update an existing project, identified by `project_id` (from list_projects). Only the fields you pass change. `name` renames it: the name is a label, so renaming is safe and nothing else has to move. `status` is Active|OnHold|Completed|Archived. Archived is the non-destructive way to retire a project. `type` is Job|SideProject|Company|Essay|Article|Painting|Other."
176 176 }
177 177 fn kind(&self) -> ToolKind {
178 178 ToolKind::Write(caps::project_update())
@@ -181,36 +181,45 @@
181 181 json!({
182 182 "type": "object",
183 183 "properties": {
184 - "project": { "type": "string", "description": "The project's current name." },
184 + "project_id": super::project_id_field(),
185 + "name": { "type": "string", "description": "New name for the project. Renaming is safe: nothing resolves a project by name." },
185 186 "status": { "type": "string", "description": "Active | OnHold | Completed | Archived" },
186 187 "type": { "type": "string", "description": "Job | SideProject | Company | Essay | Article | Painting | Other" },
187 188 "description": { "type": "string" }
188 189 },
189 - "required": ["project"]
190 + "required": ["project_id"]
190 191 })
191 192 }
192 193 async fn call(&self, args: Value) -> Result<ToolCallResult> {
193 - let name = req_str(self.name(), &args, "project")?;
194 + let id = parse_project_id(self.name(), req_str(self.name(), &args, "project_id")?)?;
194 195 let repo = self.0.projects();
195 196
196 - // Resolve by name, like create_task does, so callers never handle ids.
197 197 let current = repo
198 - .find_by_name(self.0.user_id, name)
198 + .get_by_id(id, self.0.user_id)
199 199 .await
200 200 .map_err(|e| fail(self.name(), e))?
201 - .ok_or_else(|| Error::ToolFailed {
201 + .ok_or_else(|| Error::InvalidArgs {
202 202 tool: self.name().to_string(),
203 - message: format!("no project named `{name}`"),
203 + message: format!("no project with id `{id}` (call list_projects for the ids)"),
204 204 })?;
205 205
206 206 // Start from the current project, overlay only the provided fields.
207 - // The name rides through untouched: it is the resolution key.
208 207 let mut patch = UpdateProject {
209 208 name: current.name.clone(),
210 209 description: current.description.clone(),
211 210 project_type: current.project_type.clone(),
212 211 status: current.status.clone(),
213 212 };
213 + if let Some(name) = args.get("name").and_then(Value::as_str) {
214 + let name = name.trim();
215 + if name.is_empty() {
216 + return Err(Error::InvalidArgs {
217 + tool: self.name().to_string(),
218 + message: "`name` cannot be empty".to_string(),
219 + });
220 + }
221 + patch.name = name.to_string();
222 + }
214 223 if let Some(status) = args.get("status").and_then(Value::as_str) {
215 224 patch.status = parse_enum(self.name(), "status", status, PROJECT_STATUSES)?;
216 225 }
@@ -227,7 +236,7 @@
227 236 .map_err(|e| fail(self.name(), e))?
228 237 .ok_or_else(|| Error::ToolFailed {
229 238 tool: self.name().to_string(),
230 - message: format!("project `{name}` vanished during update"),
239 + message: format!("project `{id}` vanished during update"),
231 240 })?;
232 241
233 242 Ok(ToolCallResult::text(
@@ -7,7 +7,7 @@
7 7 //! backlog and creates tasks in one capability-gated call, deduping on a
8 8 //! `source:` provenance tag so a re-run does not double-insert.
9 9
10 - use std::collections::{HashMap, HashSet};
10 + use std::collections::HashSet;
11 11 use std::sync::Arc;
12 12
13 13 use async_trait::async_trait;
@@ -22,13 +22,13 @@
22 22 use kberg::{Error, Result, Tool, ToolCallResult, ToolKind};
23 23 use serde_json::{Value, json};
24 24
25 - use super::{ensure_project, resolve_project_cached};
25 + use super::{project_id_arg, project_id_field};
26 26 use crate::caps;
27 27 use crate::context::Ctx;
28 28 use crate::convert::{
29 - MAX_LIMIT, parse_due, parse_limit, parse_offset, parse_priority, parse_recurrence,
30 - parse_subtask_id, parse_tags, parse_task_id, req_str, source_tag, subtask_row, task_row,
31 - task_summary_row,
29 + MAX_LIMIT, parse_due, parse_limit, parse_offset, parse_priority, parse_project_id,
30 + parse_recurrence, parse_subtask_id, parse_tags, parse_task_id, req_str, source_tag,
31 + subtask_row, task_row, task_summary_row,
32 32 };
33 33
34 34 /// The `recurrence` schema fragment, identical across the task write tools and
@@ -56,7 +56,7 @@
56 56 "list_tasks"
57 57 }
58 58 fn description(&self) -> &'static str {
59 - "List tasks as compact rows. Optional filters: `project` (name), `status` (Pending|Started|Completed), `tag`. Paged: `limit` (default 50, max 200) and `offset`. Long descriptions are clipped and the row marked `truncated`; use `get_task` for the full text. The reply carries `total` (rows matching the filters) and, when more remain, `next_offset`."
59 + "List tasks as compact rows. Optional filters: `project_id`, `project` (name, a convenience for reading only), `status` (Pending|Started|Completed), `tag`. Paged: `limit` (default 50, max 200) and `offset`. Long descriptions are clipped and the row marked `truncated`; use `get_task` for the full text. The reply carries `total` (rows matching the filters) and, when more remain, `next_offset`."
60 60 }
61 61 fn kind(&self) -> ToolKind {
62 62 ToolKind::Read
@@ -68,7 +68,8 @@
68 68 json!({
69 69 "type": "object",
70 70 "properties": {
71 - "project": { "type": "string" },
71 + "project_id": { "type": "string", "description": "Project id (UUID) from list_projects." },
72 + "project": { "type": "string", "description": "Project name. Reads only, and a rename moves it; prefer project_id." },
72 73 "status": { "type": "string" },
73 74 "tag": { "type": "string" },
74 75 "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT },
@@ -88,6 +89,10 @@
88 89 .map_err(|e| fail(self.name(), e))?;
89 90
90 91 let project = args.get("project").and_then(Value::as_str);
92 + let project_id = match args.get("project_id").and_then(Value::as_str) {
93 + Some(raw) if !raw.trim().is_empty() => Some(parse_project_id(self.name(), raw)?),
94 + _ => None,
95 + };
91 96 let status = args
92 97 .get("status")
93 98 .and_then(Value::as_str)
@@ -96,6 +101,7 @@
96 101
97 102 let matched: Vec<&Task> = tasks
98 103 .iter()
104 + .filter(|t| project_id.is_none_or(|want| t.project_id == Some(want)))
99 105 .filter(|t| project.is_none_or(|p| t.project_name.as_deref() == Some(p)))
100 106 .filter(|t| status.as_ref().is_none_or(|s| &t.status == s))
101 107 .filter(|t| tag.is_none_or(|want| t.tags.iter().any(|have| have == want)))
@@ -218,7 +224,7 @@
218 224 "create_task"
219 225 }
220 226 fn description(&self) -> &'static str {
221 - "Create one task. A task has a short `title` and an optional longer `description`; the two are never concatenated. Passing only `description` splits it (first line becomes the title, the rest the body), so a single blob of text still lands correctly. `project` (name) is resolved to a project, creating it if absent. `due` accepts RFC 3339 or YYYY-MM-DD; `priority` is High|Medium|Low. `recurrence` is a word (None|Daily|Weekly|Monthly) or a rich rule object; without a `due` the chain anchors to whenever the task is first completed rather than to a fixed schedule."
227 + "Create one task. A task has a short `title` and an optional longer `description`; the two are never concatenated. Passing only `description` splits it (first line becomes the title, the rest the body), so a single blob of text still lands correctly. `project_id` (from list_projects) files it under a project; omit it for an unfiled task. `due` accepts RFC 3339 or YYYY-MM-DD; `priority` is High|Medium|Low. `recurrence` is a word (None|Daily|Weekly|Monthly) or a rich rule object; without a `due` the chain anchors to whenever the task is first completed rather than to a fixed schedule."
222 228 }
223 229 fn kind(&self) -> ToolKind {
224 230 ToolKind::Write(caps::task_create())
@@ -229,7 +235,7 @@
229 235 "properties": {
230 236 "title": { "type": "string" },
231 237 "description": { "type": "string" },
232 - "project": { "type": "string" },
238 + "project_id": project_id_field(),
233 239 "tags": { "type": "array", "items": { "type": "string" } },
234 240 "due": { "type": "string" },
235 241 "priority": { "type": "string" },
@@ -245,14 +251,8 @@
245 251 let tags = parse_tags(args.get("tags"));
246 252 let (recurrence, recurrence_rule) = parse_recurrence(self.name(), args.get("recurrence"))?;
247 253
248 - let project_id = match args.get("project").and_then(Value::as_str) {
249 - Some(name) if !name.trim().is_empty() => Some(
250 - ensure_project(&self.0, name)
251 - .await
252 - .map_err(|e| fail(self.name(), e))?,
253 - ),
254 - _ => None,
255 - };
254 + let mut seen = HashSet::new();
255 + let project_id = project_id_arg(&self.0, self.name(), &args, &mut seen).await?;
256 256
257 257 let mut builder = NewTask::builder(String::new())
258 258 .title(title)
@@ -291,7 +291,7 @@
291 291 "bulk_import_tasks"
292 292 }
293 293 fn description(&self) -> &'static str {
294 - "Create many tasks in one call (the /dellm migration primitive). Each item: {description, project?, tags?, due?, priority?, recurrence?, source?}. `source` is a provenance key (e.g. file:line); an item whose source tag already exists is skipped, so re-running is idempotent. Returns created/skipped counts and the new task ids."
294 + "Create many tasks in one call (the /dellm migration primitive). Each item: {description, project_id?, tags?, due?, priority?, recurrence?, source?}. `project_id` comes from list_projects. `source` is a provenance key (e.g. file:line); an item whose source tag already exists is skipped, so re-running is idempotent. Returns created/skipped counts and the new task ids."
295 295 }
296 296 fn kind(&self) -> ToolKind {
297 297 ToolKind::Write(caps::task_bulk_import())
@@ -306,7 +306,7 @@
306 306 "type": "object",
307 307 "properties": {
308 308 "description": { "type": "string" },
309 - "project": { "type": "string" },
309 + "project_id": project_id_field(),
310 310 "tags": { "type": "array", "items": { "type": "string" } },
311 311 "due": { "type": "string" },
312 312 "priority": { "type": "string" },
@@ -343,7 +343,7 @@
343 343 .cloned()
344 344 .collect();
345 345
346 - let mut project_cache: HashMap<String, ProjectId> = HashMap::new();
346 + let mut seen_projects: HashSet<ProjectId> = HashSet::new();
347 347 let mut created_ids = Vec::new();
348 348 let mut skipped = 0usize;
349 349
@@ -374,14 +374,17 @@
374 374 let (recurrence, recurrence_rule) =
375 375 parse_recurrence(self.name(), item.get("recurrence"))?;
376 376
377 - let project_id = match item.get("project").and_then(Value::as_str) {
378 - Some(name) if !name.trim().is_empty() => Some(
379 - resolve_project_cached(&self.0, name, &mut project_cache)
380 - .await
381 - .map_err(|e| fail(self.name(), e))?,
382 - ),
383 - _ => None,
384 - };
377 + let project_id = project_id_arg(&self.0, self.name(), item, &mut seen_projects)
378 + .await
379 + .map_err(|e| match e {
380 + // Report the offending item by index; a 200-item payload with
381 + // one stale project id is otherwise a guessing game.
382 + Error::InvalidArgs { message, .. } => Error::InvalidArgs {
383 + tool: self.name().to_string(),
384 + message: format!("tasks[{idx}]: {message}"),
385 + },
386 + other => other,
387 + })?;
385 388
386 389 let mut builder = NewTask::builder(String::new())
387 390 .title(title)
@@ -425,7 +428,7 @@
425 428 "update_task"
426 429 }
427 430 fn description(&self) -> &'static str {
428 - "Update fields of an existing task. Only the fields you pass change; the rest keep their current values. Accepts `title`, `description`, `priority`, `due`, `status` (Pending|Started|Completed), `tags`, `project`, `recurrence`, and `commit`. Passing `recurrence` rewrites the rule from this instance forward; instances already completed keep the rule they closed under. `title` is the short label and `description` the longer body; setting one leaves the other alone. `commit` appends an advancing commit to the task's commit list, repo-qualified as `<repo>@<shortsha>` (e.g. `deox@7c236fca8`); use `complete_task` to record the closing commit."
431 + "Update fields of an existing task. Only the fields you pass change; the rest keep their current values. Accepts `title`, `description`, `priority`, `due`, `status` (Pending|Started|Completed), `tags`, `project_id` (empty string unfiles the task), `recurrence`, and `commit`. Passing `recurrence` rewrites the rule from this instance forward; instances already completed keep the rule they closed under. `title` is the short label and `description` the longer body; setting one leaves the other alone. `commit` appends an advancing commit to the task's commit list, repo-qualified as `<repo>@<shortsha>` (e.g. `deox@7c236fca8`); use `complete_task` to record the closing commit."
429 432 }
430 433 fn kind(&self) -> ToolKind {
431 434 ToolKind::Write(caps::task_update())
@@ -441,7 +444,7 @@
441 444 "due": { "type": "string" },
442 445 "status": { "type": "string" },
443 446 "tags": { "type": "array", "items": { "type": "string" } },
444 - "project": { "type": "string" },
447 + "project_id": project_id_field(),
445 448 "recurrence": recurrence_field(),
446 449 "commit": { "type": "string" }
447 450 },
@@ -488,16 +491,11 @@
488 491 patch.recurrence = recurrence;
489 492 patch.recurrence_rule = rule;
490 493 }
491 - if let Some(name) = args.get("project").and_then(Value::as_str) {
492 - patch.project_id = if name.trim().is_empty() {
493 - None
494 - } else {
495 - Some(
496 - ensure_project(&self.0, name)
497 - .await
498 - .map_err(|e| fail(self.name(), e))?,
499 - )
500 - };
494 + // Present-but-empty (or null) clears the project, which is why this keys
495 + // off the field being there rather than off it parsing to an id.
496 + if args.get("project_id").is_some() {
497 + let mut seen = HashSet::new();
498 + patch.project_id = project_id_arg(&self.0, self.name(), &args, &mut seen).await?;
501 499 }
502 500
503 501 let updated = repo