handoff-mcp-server 0.8.1 → 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.
package/src/mcp/tools.rs CHANGED
@@ -59,9 +59,9 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
59
59
  },
60
60
  "session_status": {
61
61
  "type": "string",
62
- "description": "Status of the new session: 'open' (default) = saved but not activated, allows multiple open sessions; 'active' = immediately active for this session.",
63
- "enum": ["open", "active"],
64
- "default": "open"
62
+ "description": "Session status after save. 'closed' (default) = close the active session as history. 'active' = keep or create an active session (use at session start to establish a persistent session that survives interruptions).",
63
+ "enum": ["closed", "active"],
64
+ "default": "closed"
65
65
  },
66
66
  "close_session_id": {
67
67
  "type": "string",
@@ -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
  },
@@ -644,9 +667,381 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
644
667
  "required": ["referral_id", "status"]
645
668
  }),
646
669
  },
670
+ ToolDefinition {
671
+ name: "handoff_update_session".to_string(),
672
+ description: "Incrementally update the active session. Toggle checklist items, add decisions, notes, or context pointers without resending everything. Use during work for progressive updates.".to_string(),
673
+ input_schema: json!({
674
+ "type": "object",
675
+ "properties": {
676
+ "project_dir": {
677
+ "type": "string",
678
+ "description": "Project directory path. Defaults to current working directory."
679
+ },
680
+ "checklist_index": {
681
+ "type": "integer",
682
+ "description": "0-based index of a checklist item to toggle."
683
+ },
684
+ "checklist_checked": {
685
+ "type": "boolean",
686
+ "description": "Set the checklist item to checked (true) or unchecked (false). Defaults to true."
687
+ },
688
+ "add_checklist_item": {
689
+ "type": "string",
690
+ "description": "Text of a new checklist item to add (unchecked)."
691
+ },
692
+ "checklist_owner": {
693
+ "type": "string",
694
+ "description": "Owner for the new checklist item: 'user' or 'ai'. Defaults to 'ai'.",
695
+ "enum": ["user", "ai"]
696
+ },
697
+ "add_decision": {
698
+ "type": "object",
699
+ "description": "A decision to append to the session.",
700
+ "properties": {
701
+ "decision": { "type": "string" },
702
+ "reason": { "type": "string" },
703
+ "confidence": { "type": "string", "enum": ["confirmed", "estimated", "unverified"] }
704
+ },
705
+ "required": ["decision"]
706
+ },
707
+ "add_handoff_note": {
708
+ "type": "object",
709
+ "description": "A handoff note to append to the session.",
710
+ "properties": {
711
+ "note": { "type": "string" },
712
+ "category": { "type": "string", "enum": ["caution", "context", "suggestion"] }
713
+ },
714
+ "required": ["note"]
715
+ },
716
+ "add_context_pointer": {
717
+ "type": "object",
718
+ "description": "A context pointer to append to the session.",
719
+ "properties": {
720
+ "path": { "type": "string" },
721
+ "reason": { "type": "string" },
722
+ "lines": { "type": "string" }
723
+ },
724
+ "required": ["path"]
725
+ }
726
+ }
727
+ }),
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
+ },
647
1008
  ]
648
1009
  }
649
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
+
650
1045
  pub fn all_resource_definitions() -> Vec<Value> {
651
1046
  vec![
652
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
  }