handoff-mcp-server 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ use serde_json::Value;
4
4
  use super::resolve_project_dir;
5
5
  use crate::storage::ensure_handoff_exists;
6
6
  use crate::storage::referrals::{
7
- change_referral_status, is_valid_referral_status, read_referral_summaries,
7
+ change_referral_status, is_valid_referral_status, read_referral_by_id, read_referral_summaries,
8
8
  };
9
9
 
10
10
  pub fn handle_list(arguments: &Value) -> Result<String> {
@@ -32,6 +32,25 @@ pub fn handle_list(arguments: &Value) -> Result<String> {
32
32
  serde_json::to_string_pretty(&result).context("Failed to serialize referrals")
33
33
  }
34
34
 
35
+ pub fn handle_get(arguments: &Value) -> Result<String> {
36
+ let project_dir = resolve_project_dir(arguments)?;
37
+ let handoff = ensure_handoff_exists(&project_dir)?;
38
+ let referrals_dir = handoff.join("referrals");
39
+
40
+ let referral_id = arguments
41
+ .get("referral_id")
42
+ .and_then(|v| v.as_str())
43
+ .ok_or_else(|| anyhow::anyhow!("'referral_id' is required"))?;
44
+
45
+ let (data, status) = read_referral_by_id(&referrals_dir, referral_id)?
46
+ .ok_or_else(|| anyhow::anyhow!("Referral not found: {referral_id}"))?;
47
+
48
+ let mut result = serde_json::to_value(&data).context("Failed to serialize referral")?;
49
+ result["status"] = Value::String(status);
50
+
51
+ serde_json::to_string_pretty(&result).context("Failed to serialize referral")
52
+ }
53
+
35
54
  pub fn handle_update(arguments: &Value) -> Result<String> {
36
55
  let project_dir = resolve_project_dir(arguments)?;
37
56
  let handoff = ensure_handoff_exists(&project_dir)?;
@@ -179,7 +179,7 @@ fn write_active_session_data(
179
179
  }
180
180
 
181
181
  let updated = serde_json::to_string_pretty(data).context("Failed to serialize session")?;
182
- std::fs::write(entry.path(), updated)
182
+ crate::storage::atomic_write(entry.path(), updated.as_bytes())
183
183
  .with_context(|| format!("Failed to write session: {}", entry.path().display()))?;
184
184
  return Ok(());
185
185
  }
@@ -1,8 +1,11 @@
1
+ use std::collections::HashMap;
2
+
1
3
  use anyhow::{Context, Result};
2
4
  use chrono::Utc;
3
5
  use serde_json::Value;
4
6
 
5
7
  use super::resolve_project_dir;
8
+ use crate::storage::config::read_config;
6
9
  use crate::storage::ensure_handoff_exists;
7
10
  use crate::storage::tasks::*;
8
11
 
@@ -12,6 +15,10 @@ pub fn handle(arguments: &Value) -> Result<String> {
12
15
  let handoff = ensure_handoff_exists(&project_dir)?;
13
16
  let tasks_dir = handoff.join("tasks");
14
17
 
18
+ let require_estimate_hours = read_config(&handoff.join("config.toml"))
19
+ .map(|c| c.settings.require_estimate_hours)
20
+ .unwrap_or(true);
21
+
15
22
  let task_val = arguments
16
23
  .get("task")
17
24
  .ok_or_else(|| anyhow::anyhow!("'task' parameter is required"))?;
@@ -25,9 +32,15 @@ pub fn handle(arguments: &Value) -> Result<String> {
25
32
  }
26
33
  let task_exists = find_task_dir_by_id(&tasks_dir, existing_id)?.is_some();
27
34
  if task_exists {
28
- return handle_update(&tasks_dir, existing_id, task_val);
35
+ return handle_update(&tasks_dir, existing_id, task_val, require_estimate_hours);
29
36
  }
30
- return handle_upsert_create(&tasks_dir, existing_id, task_val, arguments);
37
+ return handle_upsert_create(
38
+ &tasks_dir,
39
+ existing_id,
40
+ task_val,
41
+ arguments,
42
+ require_estimate_hours,
43
+ );
31
44
  }
32
45
 
33
46
  let title = task_val
@@ -35,7 +48,13 @@ pub fn handle(arguments: &Value) -> Result<String> {
35
48
  .and_then(|v| v.as_str())
36
49
  .ok_or_else(|| anyhow::anyhow!("'task.title' is required for new tasks"))?;
37
50
 
38
- handle_create(&tasks_dir, title, task_val, arguments)
51
+ handle_create(
52
+ &tasks_dir,
53
+ title,
54
+ task_val,
55
+ arguments,
56
+ require_estimate_hours,
57
+ )
39
58
  }
40
59
 
41
60
  fn handle_create(
@@ -43,6 +62,7 @@ fn handle_create(
43
62
  title: &str,
44
63
  task_val: &Value,
45
64
  arguments: &Value,
65
+ require_estimate_hours: bool,
46
66
  ) -> Result<String> {
47
67
  let parent_id = arguments.get("parent_id").and_then(|v| v.as_str());
48
68
 
@@ -103,8 +123,21 @@ fn handle_create(
103
123
  .get("order")
104
124
  .and_then(|v| v.as_u64())
105
125
  .map(|v| v as u32),
126
+ assignee: task_val
127
+ .get("assignee")
128
+ .and_then(|v| v.as_str())
129
+ .map(String::from),
130
+ extra: HashMap::new(),
106
131
  };
107
132
 
133
+ // A newly created task is always a leaf (no children yet).
134
+ validate_estimate_required(
135
+ require_estimate_hours,
136
+ status,
137
+ false,
138
+ data.schedule.as_ref(),
139
+ )?;
140
+
108
141
  write_task(&task_dir, status, &data)?;
109
142
 
110
143
  Ok(format!("Created task {new_id}: {title} [{status}]"))
@@ -115,6 +148,7 @@ fn handle_upsert_create(
115
148
  task_id: &str,
116
149
  task_val: &Value,
117
150
  arguments: &Value,
151
+ require_estimate_hours: bool,
118
152
  ) -> Result<String> {
119
153
  let title = task_val
120
154
  .get("title")
@@ -181,14 +215,32 @@ fn handle_upsert_create(
181
215
  .get("order")
182
216
  .and_then(|v| v.as_u64())
183
217
  .map(|v| v as u32),
218
+ assignee: task_val
219
+ .get("assignee")
220
+ .and_then(|v| v.as_str())
221
+ .map(String::from),
222
+ extra: HashMap::new(),
184
223
  };
185
224
 
225
+ // Upsert-create: a brand-new task is a leaf.
226
+ validate_estimate_required(
227
+ require_estimate_hours,
228
+ status,
229
+ false,
230
+ data.schedule.as_ref(),
231
+ )?;
232
+
186
233
  write_task(&task_dir, status, &data)?;
187
234
 
188
235
  Ok(format!("Created task {task_id}: {title} [{status}]"))
189
236
  }
190
237
 
191
- fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -> Result<String> {
238
+ fn handle_update(
239
+ tasks_dir: &std::path::Path,
240
+ task_id: &str,
241
+ task_val: &Value,
242
+ require_estimate_hours: bool,
243
+ ) -> Result<String> {
192
244
  let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
193
245
  .ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
194
246
 
@@ -214,8 +266,32 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
214
266
  if task_val.get("done_criteria").is_some() {
215
267
  data.done_criteria = extract_done_criteria(task_val);
216
268
  }
217
- if task_val.get("schedule").is_some() {
218
- data.schedule = extract_schedule(task_val);
269
+ if let Some(sched_val) = task_val.get("schedule") {
270
+ // Field-level merge (not full replacement) so that fields not present in
271
+ // the patch — e.g. actual_hours/remaining_hours accrued by the VSCode timer —
272
+ // are preserved. Mirrors bulk_update_tasks. (referral ref-20260623-232823)
273
+ let schedule = data.schedule.get_or_insert_with(Default::default);
274
+ if let Some(sd) = sched_val.get("start_date").and_then(|v| v.as_str()) {
275
+ schedule.start_date = Some(sd.to_string());
276
+ }
277
+ if let Some(dd) = sched_val.get("due_date").and_then(|v| v.as_str()) {
278
+ schedule.due_date = Some(dd.to_string());
279
+ }
280
+ if let Some(eh) = sched_val.get("estimate_hours").and_then(|v| v.as_f64()) {
281
+ schedule.estimate_hours = Some(eh);
282
+ }
283
+ if let Some(ah) = sched_val.get("actual_hours").and_then(|v| v.as_f64()) {
284
+ schedule.actual_hours = Some(ah);
285
+ }
286
+ if let Some(rh) = sched_val.get("remaining_hours").and_then(|v| v.as_f64()) {
287
+ schedule.remaining_hours = Some(rh);
288
+ }
289
+ if let Some(ms) = sched_val.get("milestone").and_then(|v| v.as_str()) {
290
+ schedule.milestone = Some(ms.to_string());
291
+ }
292
+ if let Some(p) = sched_val.get("pinned").and_then(|v| v.as_bool()) {
293
+ schedule.pinned = Some(p);
294
+ }
219
295
  }
220
296
  if task_val.get("dependencies").is_some() {
221
297
  let new_deps = extract_string_array(task_val, "dependencies");
@@ -227,6 +303,12 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
227
303
  if let Some(order) = task_val.get("order").and_then(|v| v.as_u64()) {
228
304
  data.order = Some(order as u32);
229
305
  }
306
+ if task_val.get("assignee").is_some() {
307
+ data.assignee = task_val
308
+ .get("assignee")
309
+ .and_then(|v| v.as_str())
310
+ .map(String::from);
311
+ }
230
312
 
231
313
  let new_status = task_val
232
314
  .get("status")
@@ -246,6 +328,15 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
246
328
  validate_skipped_transition(&task_dir, &data)?;
247
329
  }
248
330
 
331
+ // Parent tasks (with children) are exempt; only leaf tasks need an estimate.
332
+ let has_children = task_has_children(&task_dir)?;
333
+ validate_estimate_required(
334
+ require_estimate_hours,
335
+ new_status,
336
+ has_children,
337
+ data.schedule.as_ref(),
338
+ )?;
339
+
249
340
  data.updated_at = Some(Utc::now().to_rfc3339());
250
341
 
251
342
  if let Some((old_path, _)) = find_task_file(&task_dir)? {
@@ -335,9 +426,11 @@ fn extract_schedule(val: &Value) -> Option<Schedule> {
335
426
  .map(String::from),
336
427
  estimate_hours: sched.get("estimate_hours").and_then(|v| v.as_f64()),
337
428
  actual_hours: sched.get("actual_hours").and_then(|v| v.as_f64()),
429
+ remaining_hours: sched.get("remaining_hours").and_then(|v| v.as_f64()),
338
430
  milestone: sched
339
431
  .get("milestone")
340
432
  .and_then(|v| v.as_str())
341
433
  .map(String::from),
434
+ pinned: sched.get("pinned").and_then(|v| v.as_bool()),
342
435
  })
343
436
  }
package/src/mcp/tools.rs CHANGED
@@ -175,7 +175,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
175
175
  },
176
176
  ToolDefinition {
177
177
  name: "handoff_list_tasks".to_string(),
178
- description: "List all tasks for the current project with optional status filter.".to_string(),
178
+ description: "List all tasks for the current project with optional filters.".to_string(),
179
179
  input_schema: json!({
180
180
  "type": "object",
181
181
  "properties": {
@@ -185,8 +185,25 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
185
185
  },
186
186
  "status_filter": {
187
187
  "type": "string",
188
- "description": "Filter by status",
188
+ "description": "Filter by status.",
189
189
  "enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"]
190
+ },
191
+ "assignee_filter": {
192
+ "type": "string",
193
+ "description": "Filter by assignee key."
194
+ },
195
+ "milestone_filter": {
196
+ "type": "string",
197
+ "description": "Filter by milestone name."
198
+ },
199
+ "priority_filter": {
200
+ "type": "string",
201
+ "description": "Filter by priority.",
202
+ "enum": ["low", "medium", "high"]
203
+ },
204
+ "label_filter": {
205
+ "type": "string",
206
+ "description": "Filter by label (task must contain this label)."
190
207
  }
191
208
  }
192
209
  }),
@@ -286,7 +303,9 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
286
303
  "due_date": { "type": "string", "description": "YYYY-MM-DD" },
287
304
  "estimate_hours": { "type": "number" },
288
305
  "actual_hours": { "type": "number" },
289
- "milestone": { "type": "string" }
306
+ "remaining_hours": { "type": "number", "description": "Hours remaining. Auto-decremented by handoff_log_time." },
307
+ "milestone": { "type": "string" },
308
+ "pinned": { "type": "boolean", "description": "If true, dates are locked and auto-scheduler skips this task." }
290
309
  }
291
310
  },
292
311
  "dependencies": {
@@ -297,6 +316,10 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
297
316
  "order": {
298
317
  "type": "integer",
299
318
  "description": "Display order among siblings. 0-based, lower = higher priority."
319
+ },
320
+ "assignee": {
321
+ "type": "string",
322
+ "description": "Assignee key (matches config.toml [assignees.<key>])."
300
323
  }
301
324
  },
302
325
  },
@@ -621,6 +644,24 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
621
644
  }
622
645
  }),
623
646
  },
647
+ ToolDefinition {
648
+ name: "handoff_get_referral".to_string(),
649
+ description: "Get the full details of a single incoming referral by ID (summary, details, tasks with done_criteria, priority, context, status). Use this instead of reading .handoff/referrals/*.json directly.".to_string(),
650
+ input_schema: json!({
651
+ "type": "object",
652
+ "properties": {
653
+ "project_dir": {
654
+ "type": "string",
655
+ "description": "Project directory path. Defaults to current working directory."
656
+ },
657
+ "referral_id": {
658
+ "type": "string",
659
+ "description": "ID of the referral to retrieve (full id or a unique prefix)."
660
+ }
661
+ },
662
+ "required": ["referral_id"]
663
+ }),
664
+ },
624
665
  ToolDefinition {
625
666
  name: "handoff_update_referral".to_string(),
626
667
  description: "Update the status of an incoming referral (open -> acknowledged -> resolved).".to_string(),
@@ -703,9 +744,322 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
703
744
  }
704
745
  }),
705
746
  },
747
+ ToolDefinition {
748
+ name: "handoff_log_time".to_string(),
749
+ description: "Log hours worked on a task. Adds to actual_hours and deducts from remaining_hours atomically.".to_string(),
750
+ input_schema: json!({
751
+ "type": "object",
752
+ "properties": {
753
+ "project_dir": {
754
+ "type": "string",
755
+ "description": "Project directory path. Defaults to current working directory."
756
+ },
757
+ "task_id": {
758
+ "type": "string",
759
+ "description": "Task ID to log time against."
760
+ },
761
+ "hours": {
762
+ "type": "number",
763
+ "description": "Hours worked (e.g. 0.5 for 30 minutes)."
764
+ }
765
+ },
766
+ "required": ["task_id", "hours"]
767
+ }),
768
+ },
769
+ ToolDefinition {
770
+ name: "handoff_get_metrics".to_string(),
771
+ description: "Get project metrics: completion %, effort tracking, overdue tasks, budget status, and milestone breakdown.".to_string(),
772
+ input_schema: json!({
773
+ "type": "object",
774
+ "properties": {
775
+ "project_dir": {
776
+ "type": "string",
777
+ "description": "Project directory path. Defaults to current working directory."
778
+ },
779
+ "assignee": {
780
+ "type": "string",
781
+ "description": "Filter metrics to a specific assignee."
782
+ }
783
+ }
784
+ }),
785
+ },
786
+ ToolDefinition {
787
+ name: "handoff_list_sessions".to_string(),
788
+ description: "List all sessions (open, active, paused, closed) with summary info. Use handoff_get_session for full detail.".to_string(),
789
+ input_schema: json!({
790
+ "type": "object",
791
+ "properties": {
792
+ "project_dir": {
793
+ "type": "string",
794
+ "description": "Project directory path. Defaults to current working directory."
795
+ },
796
+ "status_filter": {
797
+ "type": "string",
798
+ "enum": ["open", "active", "paused", "closed"],
799
+ "description": "Filter sessions by status."
800
+ },
801
+ "limit": {
802
+ "type": "integer",
803
+ "description": "Max sessions to return (default 20)."
804
+ }
805
+ }
806
+ }),
807
+ },
808
+ ToolDefinition {
809
+ name: "handoff_list_assignees".to_string(),
810
+ description: "List all team members/assignees from config.toml with their task counts and effort stats.".to_string(),
811
+ input_schema: json!({
812
+ "type": "object",
813
+ "properties": {
814
+ "project_dir": {
815
+ "type": "string",
816
+ "description": "Project directory path. Defaults to current working directory."
817
+ }
818
+ }
819
+ }),
820
+ },
821
+ ToolDefinition {
822
+ name: "handoff_bulk_update_tasks".to_string(),
823
+ description: "Update multiple tasks in one call. Useful for applying auto-schedule results or bulk status/assignee changes.".to_string(),
824
+ input_schema: json!({
825
+ "type": "object",
826
+ "properties": {
827
+ "project_dir": {
828
+ "type": "string",
829
+ "description": "Project directory path. Defaults to current working directory."
830
+ },
831
+ "updates": {
832
+ "type": "array",
833
+ "description": "Array of task updates to apply.",
834
+ "items": {
835
+ "type": "object",
836
+ "properties": {
837
+ "task_id": { "type": "string", "description": "Task ID to update." },
838
+ "status": { "type": "string", "enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"] },
839
+ "priority": { "type": "string", "enum": ["low", "medium", "high"] },
840
+ "assignee": { "type": "string" },
841
+ "schedule": {
842
+ "type": "object",
843
+ "properties": {
844
+ "start_date": { "type": "string" },
845
+ "due_date": { "type": "string" },
846
+ "estimate_hours": { "type": "number" },
847
+ "actual_hours": { "type": "number" },
848
+ "remaining_hours": { "type": "number" },
849
+ "milestone": { "type": "string" },
850
+ "pinned": { "type": "boolean" }
851
+ }
852
+ }
853
+ },
854
+ "required": ["task_id"]
855
+ }
856
+ }
857
+ },
858
+ "required": ["updates"]
859
+ }),
860
+ },
861
+ ToolDefinition {
862
+ name: "handoff_get_session".to_string(),
863
+ description: "Get full detail of a specific session by ID. Returns decisions, checklist, handoff notes, context pointers, etc.".to_string(),
864
+ input_schema: json!({
865
+ "type": "object",
866
+ "properties": {
867
+ "project_dir": {
868
+ "type": "string",
869
+ "description": "Project directory path. Defaults to current working directory."
870
+ },
871
+ "session_id": {
872
+ "type": "string",
873
+ "description": "Session ID to retrieve."
874
+ }
875
+ },
876
+ "required": ["session_id"]
877
+ }),
878
+ },
879
+ ToolDefinition {
880
+ name: "handoff_get_capacity".to_string(),
881
+ description: "Get work capacity for a date range. Shows available hours per day based on calendar config, and allocated hours from scheduled tasks.".to_string(),
882
+ input_schema: json!({
883
+ "type": "object",
884
+ "properties": {
885
+ "project_dir": {
886
+ "type": "string",
887
+ "description": "Project directory path. Defaults to current working directory."
888
+ },
889
+ "start_date": {
890
+ "type": "string",
891
+ "description": "Start date (YYYY-MM-DD)."
892
+ },
893
+ "end_date": {
894
+ "type": "string",
895
+ "description": "End date (YYYY-MM-DD)."
896
+ },
897
+ "assignee": {
898
+ "type": "string",
899
+ "description": "Filter capacity to a specific assignee's calendar."
900
+ }
901
+ },
902
+ "required": ["start_date", "end_date"]
903
+ }),
904
+ },
905
+ ToolDefinition {
906
+ name: "handoff_auto_schedule".to_string(),
907
+ description: "Run auto-scheduler to compute optimal task dates based on dependencies, estimates, and calendar capacity. Returns change diff; applies changes unless dry_run=true.".to_string(),
908
+ input_schema: json!({
909
+ "type": "object",
910
+ "properties": {
911
+ "project_dir": {
912
+ "type": "string",
913
+ "description": "Project directory path. Defaults to current working directory."
914
+ },
915
+ "dry_run": {
916
+ "type": "boolean",
917
+ "description": "If true (default), return computed spans without writing. If false, apply changes to task files."
918
+ },
919
+ "assignee_filter": {
920
+ "type": "string",
921
+ "description": "Only schedule tasks assigned to this assignee."
922
+ },
923
+ "start_date": {
924
+ "type": "string",
925
+ "description": "Anchor date YYYY-MM-DD for the earliest task. Defaults to today (UTC)."
926
+ }
927
+ }
928
+ }),
929
+ },
930
+ ToolDefinition {
931
+ name: "handoff_add_assignee".to_string(),
932
+ description: "Add a team member to config.toml [assignees.<key>]. Fails if the key already exists.".to_string(),
933
+ input_schema: assignee_write_schema(true),
934
+ },
935
+ ToolDefinition {
936
+ name: "handoff_update_assignee".to_string(),
937
+ description: "Update an existing [assignees.<key>] entry. Only provided fields change; pass null to clear a field.".to_string(),
938
+ input_schema: assignee_write_schema(false),
939
+ },
940
+ ToolDefinition {
941
+ name: "handoff_remove_assignee".to_string(),
942
+ description: "Remove a team member from config.toml and unassign them from every task.".to_string(),
943
+ input_schema: json!({
944
+ "type": "object",
945
+ "properties": {
946
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
947
+ "key": { "type": "string", "description": "Assignee key to remove." }
948
+ },
949
+ "required": ["key"]
950
+ }),
951
+ },
952
+ ToolDefinition {
953
+ name: "handoff_list_milestones".to_string(),
954
+ description: "List all milestones defined in config.toml [milestones.*].".to_string(),
955
+ input_schema: json!({
956
+ "type": "object",
957
+ "properties": {
958
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." }
959
+ }
960
+ }),
961
+ },
962
+ ToolDefinition {
963
+ name: "handoff_add_milestone".to_string(),
964
+ description: "Add a milestone to config.toml [milestones.<name>]. Fails if it already exists.".to_string(),
965
+ input_schema: milestone_write_schema(),
966
+ },
967
+ ToolDefinition {
968
+ name: "handoff_update_milestone".to_string(),
969
+ description: "Update an existing [milestones.<name>] entry. Pass null to clear a field.".to_string(),
970
+ input_schema: milestone_write_schema(),
971
+ },
972
+ ToolDefinition {
973
+ name: "handoff_remove_milestone".to_string(),
974
+ description: "Remove a milestone from config.toml.".to_string(),
975
+ input_schema: json!({
976
+ "type": "object",
977
+ "properties": {
978
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
979
+ "name": { "type": "string", "description": "Milestone name to remove." }
980
+ },
981
+ "required": ["name"]
982
+ }),
983
+ },
984
+ ToolDefinition {
985
+ name: "handoff_update_calendar".to_string(),
986
+ description: "Patch the project [calendar] section (work hours, closed days, day_hours, schedule_mode). Only provided fields change.".to_string(),
987
+ input_schema: json!({
988
+ "type": "object",
989
+ "properties": {
990
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
991
+ "work_hours_per_day": { "type": "number", "description": "Default working hours per day." },
992
+ "closed_weekdays": { "type": "array", "description": "Non-working weekdays (0=Sun..6=Sat, or names like \"sat\").", "items": {} },
993
+ "closed_dates": { "type": "array", "description": "Non-working YYYY-MM-DD dates.", "items": { "type": "string" } },
994
+ "open_dates": { "type": "array", "description": "Working YYYY-MM-DD dates that override closed weekdays.", "items": { "type": "string" } },
995
+ "day_hours": { "type": "object", "description": "Per-weekday-name or per-date hour overrides, e.g. {\"fri\": 4, \"2026-07-01\": 0}.", "additionalProperties": { "type": "number" } },
996
+ "schedule_mode": { "type": "string", "description": "\"manual\" or \"auto\"." },
997
+ "overwork_limit_percent": { "type": "number" },
998
+ "max_utilization": { "type": "number" }
999
+ }
1000
+ }),
1001
+ },
1002
+ ToolDefinition {
1003
+ name: "handoff_update_labels".to_string(),
1004
+ description: "Set the project-level label vocabulary (top-level labels array in config.toml).".to_string(),
1005
+ input_schema: json!({
1006
+ "type": "object",
1007
+ "properties": {
1008
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1009
+ "labels": { "type": "array", "description": "Full replacement list of project labels.", "items": { "type": "string" } }
1010
+ },
1011
+ "required": ["labels"]
1012
+ }),
1013
+ },
1014
+ ToolDefinition {
1015
+ name: "handoff_start_project".to_string(),
1016
+ description: "Set the project started_at date and optionally shift all task dates so the earliest start aligns to it.".to_string(),
1017
+ input_schema: json!({
1018
+ "type": "object",
1019
+ "properties": {
1020
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1021
+ "start_date": { "type": "string", "description": "Project start date YYYY-MM-DD. Defaults to today (UTC)." },
1022
+ "shift_dates": { "type": "boolean", "description": "If true, shift every task's start/due dates so the earliest start lands on start_date." }
1023
+ }
1024
+ }),
1025
+ },
706
1026
  ]
707
1027
  }
708
1028
 
1029
+ /// Shared input schema for add/update assignee. `key` is required either way.
1030
+ fn assignee_write_schema(_is_add: bool) -> Value {
1031
+ json!({
1032
+ "type": "object",
1033
+ "properties": {
1034
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1035
+ "key": { "type": "string", "description": "Stable assignee key (used as [assignees.<key>])." },
1036
+ "display_name": { "type": "string", "description": "Human-readable name." },
1037
+ "color": { "type": "string", "description": "Display color (hex or name)." },
1038
+ "work_hours_per_day": { "type": "number", "description": "This member's daily working hours." },
1039
+ "closed_weekdays": { "type": "array", "description": "Non-working weekdays (0=Sun..6=Sat or names).", "items": {} },
1040
+ "closed_dates": { "type": "array", "description": "Non-working YYYY-MM-DD dates.", "items": { "type": "string" } },
1041
+ "open_dates": { "type": "array", "description": "Working YYYY-MM-DD override dates.", "items": { "type": "string" } },
1042
+ "day_hours": { "type": "object", "description": "Per-weekday/date hour overrides.", "additionalProperties": { "type": "number" } }
1043
+ },
1044
+ "required": ["key"]
1045
+ })
1046
+ }
1047
+
1048
+ /// Shared input schema for add/update milestone. `name` is required.
1049
+ fn milestone_write_schema() -> Value {
1050
+ json!({
1051
+ "type": "object",
1052
+ "properties": {
1053
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1054
+ "name": { "type": "string", "description": "Milestone name (used as [milestones.<name>])." },
1055
+ "date": { "type": "string", "description": "Target date YYYY-MM-DD." },
1056
+ "color": { "type": "string", "description": "Display color." },
1057
+ "description": { "type": "string", "description": "Free-form description." }
1058
+ },
1059
+ "required": ["name"]
1060
+ })
1061
+ }
1062
+
709
1063
  pub fn all_resource_definitions() -> Vec<Value> {
710
1064
  vec![
711
1065
  json!({