handoff-mcp-server 0.10.0 → 0.11.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.
@@ -1,3 +1,5 @@
1
+ use std::collections::HashMap;
2
+
1
3
  use anyhow::{Context, Result};
2
4
  use chrono::Utc;
3
5
  use serde_json::Value;
@@ -103,6 +105,11 @@ fn handle_create(
103
105
  .get("order")
104
106
  .and_then(|v| v.as_u64())
105
107
  .map(|v| v as u32),
108
+ assignee: task_val
109
+ .get("assignee")
110
+ .and_then(|v| v.as_str())
111
+ .map(String::from),
112
+ extra: HashMap::new(),
106
113
  };
107
114
 
108
115
  write_task(&task_dir, status, &data)?;
@@ -181,6 +188,11 @@ fn handle_upsert_create(
181
188
  .get("order")
182
189
  .and_then(|v| v.as_u64())
183
190
  .map(|v| v as u32),
191
+ assignee: task_val
192
+ .get("assignee")
193
+ .and_then(|v| v.as_str())
194
+ .map(String::from),
195
+ extra: HashMap::new(),
184
196
  };
185
197
 
186
198
  write_task(&task_dir, status, &data)?;
@@ -214,8 +226,32 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
214
226
  if task_val.get("done_criteria").is_some() {
215
227
  data.done_criteria = extract_done_criteria(task_val);
216
228
  }
217
- if task_val.get("schedule").is_some() {
218
- data.schedule = extract_schedule(task_val);
229
+ if let Some(sched_val) = task_val.get("schedule") {
230
+ // Field-level merge (not full replacement) so that fields not present in
231
+ // the patch — e.g. actual_hours/remaining_hours accrued by the VSCode timer —
232
+ // are preserved. Mirrors bulk_update_tasks. (referral ref-20260623-232823)
233
+ let schedule = data.schedule.get_or_insert_with(Default::default);
234
+ if let Some(sd) = sched_val.get("start_date").and_then(|v| v.as_str()) {
235
+ schedule.start_date = Some(sd.to_string());
236
+ }
237
+ if let Some(dd) = sched_val.get("due_date").and_then(|v| v.as_str()) {
238
+ schedule.due_date = Some(dd.to_string());
239
+ }
240
+ if let Some(eh) = sched_val.get("estimate_hours").and_then(|v| v.as_f64()) {
241
+ schedule.estimate_hours = Some(eh);
242
+ }
243
+ if let Some(ah) = sched_val.get("actual_hours").and_then(|v| v.as_f64()) {
244
+ schedule.actual_hours = Some(ah);
245
+ }
246
+ if let Some(rh) = sched_val.get("remaining_hours").and_then(|v| v.as_f64()) {
247
+ schedule.remaining_hours = Some(rh);
248
+ }
249
+ if let Some(ms) = sched_val.get("milestone").and_then(|v| v.as_str()) {
250
+ schedule.milestone = Some(ms.to_string());
251
+ }
252
+ if let Some(p) = sched_val.get("pinned").and_then(|v| v.as_bool()) {
253
+ schedule.pinned = Some(p);
254
+ }
219
255
  }
220
256
  if task_val.get("dependencies").is_some() {
221
257
  let new_deps = extract_string_array(task_val, "dependencies");
@@ -227,6 +263,12 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
227
263
  if let Some(order) = task_val.get("order").and_then(|v| v.as_u64()) {
228
264
  data.order = Some(order as u32);
229
265
  }
266
+ if task_val.get("assignee").is_some() {
267
+ data.assignee = task_val
268
+ .get("assignee")
269
+ .and_then(|v| v.as_str())
270
+ .map(String::from);
271
+ }
230
272
 
231
273
  let new_status = task_val
232
274
  .get("status")
@@ -335,9 +377,11 @@ fn extract_schedule(val: &Value) -> Option<Schedule> {
335
377
  .map(String::from),
336
378
  estimate_hours: sched.get("estimate_hours").and_then(|v| v.as_f64()),
337
379
  actual_hours: sched.get("actual_hours").and_then(|v| v.as_f64()),
380
+ remaining_hours: sched.get("remaining_hours").and_then(|v| v.as_f64()),
338
381
  milestone: sched
339
382
  .get("milestone")
340
383
  .and_then(|v| v.as_str())
341
384
  .map(String::from),
385
+ pinned: sched.get("pinned").and_then(|v| v.as_bool()),
342
386
  })
343
387
  }
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
  },
@@ -703,9 +726,322 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
703
726
  }
704
727
  }),
705
728
  },
729
+ ToolDefinition {
730
+ name: "handoff_log_time".to_string(),
731
+ description: "Log hours worked on a task. Adds to actual_hours and deducts from remaining_hours atomically.".to_string(),
732
+ input_schema: json!({
733
+ "type": "object",
734
+ "properties": {
735
+ "project_dir": {
736
+ "type": "string",
737
+ "description": "Project directory path. Defaults to current working directory."
738
+ },
739
+ "task_id": {
740
+ "type": "string",
741
+ "description": "Task ID to log time against."
742
+ },
743
+ "hours": {
744
+ "type": "number",
745
+ "description": "Hours worked (e.g. 0.5 for 30 minutes)."
746
+ }
747
+ },
748
+ "required": ["task_id", "hours"]
749
+ }),
750
+ },
751
+ ToolDefinition {
752
+ name: "handoff_get_metrics".to_string(),
753
+ description: "Get project metrics: completion %, effort tracking, overdue tasks, budget status, and milestone breakdown.".to_string(),
754
+ input_schema: json!({
755
+ "type": "object",
756
+ "properties": {
757
+ "project_dir": {
758
+ "type": "string",
759
+ "description": "Project directory path. Defaults to current working directory."
760
+ },
761
+ "assignee": {
762
+ "type": "string",
763
+ "description": "Filter metrics to a specific assignee."
764
+ }
765
+ }
766
+ }),
767
+ },
768
+ ToolDefinition {
769
+ name: "handoff_list_sessions".to_string(),
770
+ description: "List all sessions (open, active, paused, closed) with summary info. Use handoff_get_session for full detail.".to_string(),
771
+ input_schema: json!({
772
+ "type": "object",
773
+ "properties": {
774
+ "project_dir": {
775
+ "type": "string",
776
+ "description": "Project directory path. Defaults to current working directory."
777
+ },
778
+ "status_filter": {
779
+ "type": "string",
780
+ "enum": ["open", "active", "paused", "closed"],
781
+ "description": "Filter sessions by status."
782
+ },
783
+ "limit": {
784
+ "type": "integer",
785
+ "description": "Max sessions to return (default 20)."
786
+ }
787
+ }
788
+ }),
789
+ },
790
+ ToolDefinition {
791
+ name: "handoff_list_assignees".to_string(),
792
+ description: "List all team members/assignees from config.toml with their task counts and effort stats.".to_string(),
793
+ input_schema: json!({
794
+ "type": "object",
795
+ "properties": {
796
+ "project_dir": {
797
+ "type": "string",
798
+ "description": "Project directory path. Defaults to current working directory."
799
+ }
800
+ }
801
+ }),
802
+ },
803
+ ToolDefinition {
804
+ name: "handoff_bulk_update_tasks".to_string(),
805
+ description: "Update multiple tasks in one call. Useful for applying auto-schedule results or bulk status/assignee changes.".to_string(),
806
+ input_schema: json!({
807
+ "type": "object",
808
+ "properties": {
809
+ "project_dir": {
810
+ "type": "string",
811
+ "description": "Project directory path. Defaults to current working directory."
812
+ },
813
+ "updates": {
814
+ "type": "array",
815
+ "description": "Array of task updates to apply.",
816
+ "items": {
817
+ "type": "object",
818
+ "properties": {
819
+ "task_id": { "type": "string", "description": "Task ID to update." },
820
+ "status": { "type": "string", "enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"] },
821
+ "priority": { "type": "string", "enum": ["low", "medium", "high"] },
822
+ "assignee": { "type": "string" },
823
+ "schedule": {
824
+ "type": "object",
825
+ "properties": {
826
+ "start_date": { "type": "string" },
827
+ "due_date": { "type": "string" },
828
+ "estimate_hours": { "type": "number" },
829
+ "actual_hours": { "type": "number" },
830
+ "remaining_hours": { "type": "number" },
831
+ "milestone": { "type": "string" },
832
+ "pinned": { "type": "boolean" }
833
+ }
834
+ }
835
+ },
836
+ "required": ["task_id"]
837
+ }
838
+ }
839
+ },
840
+ "required": ["updates"]
841
+ }),
842
+ },
843
+ ToolDefinition {
844
+ name: "handoff_get_session".to_string(),
845
+ description: "Get full detail of a specific session by ID. Returns decisions, checklist, handoff notes, context pointers, etc.".to_string(),
846
+ input_schema: json!({
847
+ "type": "object",
848
+ "properties": {
849
+ "project_dir": {
850
+ "type": "string",
851
+ "description": "Project directory path. Defaults to current working directory."
852
+ },
853
+ "session_id": {
854
+ "type": "string",
855
+ "description": "Session ID to retrieve."
856
+ }
857
+ },
858
+ "required": ["session_id"]
859
+ }),
860
+ },
861
+ ToolDefinition {
862
+ name: "handoff_get_capacity".to_string(),
863
+ 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(),
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
+ "start_date": {
872
+ "type": "string",
873
+ "description": "Start date (YYYY-MM-DD)."
874
+ },
875
+ "end_date": {
876
+ "type": "string",
877
+ "description": "End date (YYYY-MM-DD)."
878
+ },
879
+ "assignee": {
880
+ "type": "string",
881
+ "description": "Filter capacity to a specific assignee's calendar."
882
+ }
883
+ },
884
+ "required": ["start_date", "end_date"]
885
+ }),
886
+ },
887
+ ToolDefinition {
888
+ name: "handoff_auto_schedule".to_string(),
889
+ 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(),
890
+ input_schema: json!({
891
+ "type": "object",
892
+ "properties": {
893
+ "project_dir": {
894
+ "type": "string",
895
+ "description": "Project directory path. Defaults to current working directory."
896
+ },
897
+ "dry_run": {
898
+ "type": "boolean",
899
+ "description": "If true (default), return computed spans without writing. If false, apply changes to task files."
900
+ },
901
+ "assignee_filter": {
902
+ "type": "string",
903
+ "description": "Only schedule tasks assigned to this assignee."
904
+ },
905
+ "start_date": {
906
+ "type": "string",
907
+ "description": "Anchor date YYYY-MM-DD for the earliest task. Defaults to today (UTC)."
908
+ }
909
+ }
910
+ }),
911
+ },
912
+ ToolDefinition {
913
+ name: "handoff_add_assignee".to_string(),
914
+ description: "Add a team member to config.toml [assignees.<key>]. Fails if the key already exists.".to_string(),
915
+ input_schema: assignee_write_schema(true),
916
+ },
917
+ ToolDefinition {
918
+ name: "handoff_update_assignee".to_string(),
919
+ description: "Update an existing [assignees.<key>] entry. Only provided fields change; pass null to clear a field.".to_string(),
920
+ input_schema: assignee_write_schema(false),
921
+ },
922
+ ToolDefinition {
923
+ name: "handoff_remove_assignee".to_string(),
924
+ description: "Remove a team member from config.toml and unassign them from every task.".to_string(),
925
+ input_schema: json!({
926
+ "type": "object",
927
+ "properties": {
928
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
929
+ "key": { "type": "string", "description": "Assignee key to remove." }
930
+ },
931
+ "required": ["key"]
932
+ }),
933
+ },
934
+ ToolDefinition {
935
+ name: "handoff_list_milestones".to_string(),
936
+ description: "List all milestones defined in config.toml [milestones.*].".to_string(),
937
+ input_schema: json!({
938
+ "type": "object",
939
+ "properties": {
940
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." }
941
+ }
942
+ }),
943
+ },
944
+ ToolDefinition {
945
+ name: "handoff_add_milestone".to_string(),
946
+ description: "Add a milestone to config.toml [milestones.<name>]. Fails if it already exists.".to_string(),
947
+ input_schema: milestone_write_schema(),
948
+ },
949
+ ToolDefinition {
950
+ name: "handoff_update_milestone".to_string(),
951
+ description: "Update an existing [milestones.<name>] entry. Pass null to clear a field.".to_string(),
952
+ input_schema: milestone_write_schema(),
953
+ },
954
+ ToolDefinition {
955
+ name: "handoff_remove_milestone".to_string(),
956
+ description: "Remove a milestone from config.toml.".to_string(),
957
+ input_schema: json!({
958
+ "type": "object",
959
+ "properties": {
960
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
961
+ "name": { "type": "string", "description": "Milestone name to remove." }
962
+ },
963
+ "required": ["name"]
964
+ }),
965
+ },
966
+ ToolDefinition {
967
+ name: "handoff_update_calendar".to_string(),
968
+ description: "Patch the project [calendar] section (work hours, closed days, day_hours, schedule_mode). Only provided fields change.".to_string(),
969
+ input_schema: json!({
970
+ "type": "object",
971
+ "properties": {
972
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
973
+ "work_hours_per_day": { "type": "number", "description": "Default working hours per day." },
974
+ "closed_weekdays": { "type": "array", "description": "Non-working weekdays (0=Sun..6=Sat, or names like \"sat\").", "items": {} },
975
+ "closed_dates": { "type": "array", "description": "Non-working YYYY-MM-DD dates.", "items": { "type": "string" } },
976
+ "open_dates": { "type": "array", "description": "Working YYYY-MM-DD dates that override closed weekdays.", "items": { "type": "string" } },
977
+ "day_hours": { "type": "object", "description": "Per-weekday-name or per-date hour overrides, e.g. {\"fri\": 4, \"2026-07-01\": 0}.", "additionalProperties": { "type": "number" } },
978
+ "schedule_mode": { "type": "string", "description": "\"manual\" or \"auto\"." },
979
+ "overwork_limit_percent": { "type": "number" },
980
+ "max_utilization": { "type": "number" }
981
+ }
982
+ }),
983
+ },
984
+ ToolDefinition {
985
+ name: "handoff_update_labels".to_string(),
986
+ description: "Set the project-level label vocabulary (top-level labels array in config.toml).".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
+ "labels": { "type": "array", "description": "Full replacement list of project labels.", "items": { "type": "string" } }
992
+ },
993
+ "required": ["labels"]
994
+ }),
995
+ },
996
+ ToolDefinition {
997
+ name: "handoff_start_project".to_string(),
998
+ description: "Set the project started_at date and optionally shift all task dates so the earliest start aligns to it.".to_string(),
999
+ input_schema: json!({
1000
+ "type": "object",
1001
+ "properties": {
1002
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1003
+ "start_date": { "type": "string", "description": "Project start date YYYY-MM-DD. Defaults to today (UTC)." },
1004
+ "shift_dates": { "type": "boolean", "description": "If true, shift every task's start/due dates so the earliest start lands on start_date." }
1005
+ }
1006
+ }),
1007
+ },
706
1008
  ]
707
1009
  }
708
1010
 
1011
+ /// Shared input schema for add/update assignee. `key` is required either way.
1012
+ fn assignee_write_schema(_is_add: bool) -> Value {
1013
+ json!({
1014
+ "type": "object",
1015
+ "properties": {
1016
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1017
+ "key": { "type": "string", "description": "Stable assignee key (used as [assignees.<key>])." },
1018
+ "display_name": { "type": "string", "description": "Human-readable name." },
1019
+ "color": { "type": "string", "description": "Display color (hex or name)." },
1020
+ "work_hours_per_day": { "type": "number", "description": "This member's daily working hours." },
1021
+ "closed_weekdays": { "type": "array", "description": "Non-working weekdays (0=Sun..6=Sat or names).", "items": {} },
1022
+ "closed_dates": { "type": "array", "description": "Non-working YYYY-MM-DD dates.", "items": { "type": "string" } },
1023
+ "open_dates": { "type": "array", "description": "Working YYYY-MM-DD override dates.", "items": { "type": "string" } },
1024
+ "day_hours": { "type": "object", "description": "Per-weekday/date hour overrides.", "additionalProperties": { "type": "number" } }
1025
+ },
1026
+ "required": ["key"]
1027
+ })
1028
+ }
1029
+
1030
+ /// Shared input schema for add/update milestone. `name` is required.
1031
+ fn milestone_write_schema() -> Value {
1032
+ json!({
1033
+ "type": "object",
1034
+ "properties": {
1035
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1036
+ "name": { "type": "string", "description": "Milestone name (used as [milestones.<name>])." },
1037
+ "date": { "type": "string", "description": "Target date YYYY-MM-DD." },
1038
+ "color": { "type": "string", "description": "Display color." },
1039
+ "description": { "type": "string", "description": "Free-form description." }
1040
+ },
1041
+ "required": ["name"]
1042
+ })
1043
+ }
1044
+
709
1045
  pub fn all_resource_definitions() -> Vec<Value> {
710
1046
  vec![
711
1047
  json!({
@@ -11,6 +11,27 @@ pub struct Config {
11
11
  pub settings: SettingsConfig,
12
12
  #[serde(default)]
13
13
  pub dashboard: DashboardConfig,
14
+ /// Project-level start timestamp (pre-start mode). RFC3339 string.
15
+ #[serde(default, skip_serializing_if = "Option::is_none")]
16
+ pub started_at: Option<String>,
17
+ /// Scheduling mode: "manual" or "auto".
18
+ #[serde(default, skip_serializing_if = "Option::is_none")]
19
+ pub schedule_mode: Option<String>,
20
+ /// Project-level label vocabulary.
21
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
22
+ pub labels: Vec<String>,
23
+ #[serde(default, skip_serializing_if = "CalendarConfig::is_empty")]
24
+ pub calendar: CalendarConfig,
25
+ /// Team members keyed by stable assignee key.
26
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
27
+ pub assignees: HashMap<String, AssigneeConfig>,
28
+ /// Milestones keyed by milestone name.
29
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
30
+ pub milestones: HashMap<String, MilestoneConfig>,
31
+ #[serde(default, skip_serializing_if = "GanttViewConfig::is_empty")]
32
+ pub gantt_view: GanttViewConfig,
33
+ #[serde(default, skip_serializing_if = "EffortBudgetConfig::is_empty")]
34
+ pub effort_budget: EffortBudgetConfig,
14
35
  }
15
36
 
16
37
  #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -42,6 +63,121 @@ pub struct DashboardConfig {
42
63
  pub exclude_patterns: Vec<String>,
43
64
  }
44
65
 
66
+ /// Project-wide working calendar. Mirrors VSCode `CalendarConfig`.
67
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
68
+ pub struct CalendarConfig {
69
+ #[serde(default, skip_serializing_if = "Option::is_none")]
70
+ pub work_hours_per_day: Option<f64>,
71
+ /// Weekday numbers (0=Sun..6=Sat) that are non-working.
72
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
73
+ pub closed_weekdays: Vec<u32>,
74
+ /// Specific YYYY-MM-DD dates that are non-working (override weekdays).
75
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
76
+ pub closed_dates: Vec<String>,
77
+ /// Specific YYYY-MM-DD dates that are working even if normally closed.
78
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
79
+ pub open_dates: Vec<String>,
80
+ /// Per-weekday / per-date working-hour overrides. Key = weekday name or YYYY-MM-DD.
81
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
82
+ pub day_hours: HashMap<String, f64>,
83
+ #[serde(default, skip_serializing_if = "Option::is_none")]
84
+ pub schedule_mode: Option<String>,
85
+ #[serde(default, skip_serializing_if = "Option::is_none")]
86
+ pub overwork_limit_percent: Option<f64>,
87
+ #[serde(default, skip_serializing_if = "Option::is_none")]
88
+ pub max_utilization: Option<f64>,
89
+ }
90
+
91
+ impl CalendarConfig {
92
+ pub fn is_empty(&self) -> bool {
93
+ self.work_hours_per_day.is_none()
94
+ && self.closed_weekdays.is_empty()
95
+ && self.closed_dates.is_empty()
96
+ && self.open_dates.is_empty()
97
+ && self.day_hours.is_empty()
98
+ && self.schedule_mode.is_none()
99
+ && self.overwork_limit_percent.is_none()
100
+ && self.max_utilization.is_none()
101
+ }
102
+ }
103
+
104
+ /// A single team member's configuration. Mirrors VSCode `AssigneeConfig`.
105
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
106
+ pub struct AssigneeConfig {
107
+ #[serde(default, skip_serializing_if = "Option::is_none")]
108
+ pub display_name: Option<String>,
109
+ #[serde(default, skip_serializing_if = "Option::is_none")]
110
+ pub color: Option<String>,
111
+ #[serde(default, skip_serializing_if = "Option::is_none")]
112
+ pub work_hours_per_day: Option<f64>,
113
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
114
+ pub closed_weekdays: Vec<u32>,
115
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
116
+ pub closed_dates: Vec<String>,
117
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
118
+ pub open_dates: Vec<String>,
119
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
120
+ pub day_hours: HashMap<String, f64>,
121
+ }
122
+
123
+ /// A milestone definition. Mirrors VSCode `MilestoneConfig`.
124
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
125
+ pub struct MilestoneConfig {
126
+ #[serde(default, skip_serializing_if = "Option::is_none")]
127
+ pub date: Option<String>,
128
+ #[serde(default, skip_serializing_if = "Option::is_none")]
129
+ pub color: Option<String>,
130
+ #[serde(default, skip_serializing_if = "Option::is_none")]
131
+ pub description: Option<String>,
132
+ }
133
+
134
+ /// Gantt view UI settings. Mirrors VSCode `GanttViewSettings`.
135
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
136
+ pub struct GanttViewConfig {
137
+ #[serde(default, skip_serializing_if = "Option::is_none")]
138
+ pub sort: Option<String>,
139
+ #[serde(default, skip_serializing_if = "Option::is_none")]
140
+ pub zoom: Option<String>,
141
+ #[serde(default, skip_serializing_if = "Option::is_none")]
142
+ pub mode: Option<String>,
143
+ #[serde(default, skip_serializing_if = "Option::is_none")]
144
+ pub group_by_milestone: Option<bool>,
145
+ #[serde(default, skip_serializing_if = "Option::is_none")]
146
+ pub group_by_assignee: Option<bool>,
147
+ #[serde(default, skip_serializing_if = "Option::is_none")]
148
+ pub show_workload: Option<bool>,
149
+ #[serde(default, skip_serializing_if = "Option::is_none")]
150
+ pub filter_assignee: Option<String>,
151
+ #[serde(default, skip_serializing_if = "Option::is_none")]
152
+ pub workload_view: Option<String>,
153
+ }
154
+
155
+ impl GanttViewConfig {
156
+ pub fn is_empty(&self) -> bool {
157
+ self.sort.is_none()
158
+ && self.zoom.is_none()
159
+ && self.mode.is_none()
160
+ && self.group_by_milestone.is_none()
161
+ && self.group_by_assignee.is_none()
162
+ && self.show_workload.is_none()
163
+ && self.filter_assignee.is_none()
164
+ && self.workload_view.is_none()
165
+ }
166
+ }
167
+
168
+ /// Effort budget. Mirrors VSCode `budgetTotalHours`.
169
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
170
+ pub struct EffortBudgetConfig {
171
+ #[serde(default, skip_serializing_if = "Option::is_none")]
172
+ pub total_hours: Option<f64>,
173
+ }
174
+
175
+ impl EffortBudgetConfig {
176
+ pub fn is_empty(&self) -> bool {
177
+ self.total_hours.is_none()
178
+ }
179
+ }
180
+
45
181
  fn default_history_limit() -> u32 {
46
182
  20
47
183
  }
@@ -92,6 +228,14 @@ impl Config {
92
228
  },
93
229
  settings: SettingsConfig::default(),
94
230
  dashboard: DashboardConfig::default(),
231
+ started_at: None,
232
+ schedule_mode: None,
233
+ labels: Vec::new(),
234
+ calendar: CalendarConfig::default(),
235
+ assignees: HashMap::new(),
236
+ milestones: HashMap::new(),
237
+ gantt_view: GanttViewConfig::default(),
238
+ effort_budget: EffortBudgetConfig::default(),
95
239
  }
96
240
  }
97
241
  }
@@ -106,7 +250,7 @@ pub fn read_config(path: &Path) -> Result<Config> {
106
250
 
107
251
  pub fn write_config(path: &Path, config: &Config) -> Result<()> {
108
252
  let content = toml::to_string_pretty(config).context("Failed to serialize config")?;
109
- std::fs::write(path, content)
253
+ crate::storage::atomic_write(path, content.as_bytes())
110
254
  .with_context(|| format!("Failed to write config: {}", path.display()))?;
111
255
  Ok(())
112
256
  }