handoff-mcp-server 0.26.0 → 0.28.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.
Files changed (72) hide show
  1. package/README.md +31 -4
  2. package/bin/handoff-mcp.js +56 -13
  3. package/bin/resolve-binary.js +122 -0
  4. package/package.json +14 -9
  5. package/Cargo.lock +0 -686
  6. package/Cargo.toml +0 -30
  7. package/scripts/cargo-env.sh +0 -29
  8. package/scripts/handoff-memory-hook.py +0 -208
  9. package/scripts/install-local.sh +0 -109
  10. package/scripts/postinstall.js +0 -50
  11. package/scripts/sync-plugin-skills.sh +0 -35
  12. package/scripts/sync-plugin-version.sh +0 -85
  13. package/scripts/sync-workflow-inline.sh +0 -138
  14. package/src/cli.rs +0 -551
  15. package/src/context/injection.rs +0 -276
  16. package/src/context/mod.rs +0 -129
  17. package/src/lib.rs +0 -5
  18. package/src/main.rs +0 -157
  19. package/src/mcp/handlers/assignees.rs +0 -254
  20. package/src/mcp/handlers/auto_schedule.rs +0 -489
  21. package/src/mcp/handlers/bulk_update.rs +0 -155
  22. package/src/mcp/handlers/calendar.rs +0 -196
  23. package/src/mcp/handlers/capacity.rs +0 -318
  24. package/src/mcp/handlers/check_criterion.rs +0 -70
  25. package/src/mcp/handlers/config.rs +0 -402
  26. package/src/mcp/handlers/config_crud.rs +0 -183
  27. package/src/mcp/handlers/dashboard.rs +0 -214
  28. package/src/mcp/handlers/docs.rs +0 -2288
  29. package/src/mcp/handlers/docs_query.rs +0 -1335
  30. package/src/mcp/handlers/fork_session.rs +0 -91
  31. package/src/mcp/handlers/get_session.rs +0 -48
  32. package/src/mcp/handlers/get_task.rs +0 -53
  33. package/src/mcp/handlers/import_context.rs +0 -470
  34. package/src/mcp/handlers/init.rs +0 -28
  35. package/src/mcp/handlers/list_sessions.rs +0 -187
  36. package/src/mcp/handlers/list_tasks.rs +0 -308
  37. package/src/mcp/handlers/load_context.rs +0 -361
  38. package/src/mcp/handlers/log_time.rs +0 -67
  39. package/src/mcp/handlers/memory.rs +0 -961
  40. package/src/mcp/handlers/merge_sessions.rs +0 -103
  41. package/src/mcp/handlers/metrics.rs +0 -196
  42. package/src/mcp/handlers/milestones.rs +0 -102
  43. package/src/mcp/handlers/mod.rs +0 -140
  44. package/src/mcp/handlers/refer.rs +0 -307
  45. package/src/mcp/handlers/referrals.rs +0 -74
  46. package/src/mcp/handlers/save_context.rs +0 -354
  47. package/src/mcp/handlers/task_checklist.rs +0 -507
  48. package/src/mcp/handlers/timer.rs +0 -529
  49. package/src/mcp/handlers/update_session.rs +0 -197
  50. package/src/mcp/handlers/update_task.rs +0 -452
  51. package/src/mcp/mod.rs +0 -6
  52. package/src/mcp/protocol.rs +0 -41
  53. package/src/mcp/resources.rs +0 -57
  54. package/src/mcp/router.rs +0 -154
  55. package/src/mcp/tools.rs +0 -1522
  56. package/src/mcp/types.rs +0 -108
  57. package/src/setup.rs +0 -1212
  58. package/src/storage/config.rs +0 -578
  59. package/src/storage/docs/frontmatter.rs +0 -509
  60. package/src/storage/docs/mod.rs +0 -835
  61. package/src/storage/docs/model.rs +0 -708
  62. package/src/storage/docs/reassemble.rs +0 -167
  63. package/src/storage/docs/split.rs +0 -377
  64. package/src/storage/git.rs +0 -47
  65. package/src/storage/memory/injected.rs +0 -340
  66. package/src/storage/memory/mod.rs +0 -236
  67. package/src/storage/memory/model.rs +0 -127
  68. package/src/storage/mod.rs +0 -96
  69. package/src/storage/referrals.rs +0 -248
  70. package/src/storage/sessions.rs +0 -859
  71. package/src/storage/tasks.rs +0 -957
  72. package/templates/claude-md-section.md +0 -12
@@ -1,196 +0,0 @@
1
- //! Project-level calendar, label, and project-start tools.
2
- //! - handoff_update_calendar: patch the `[calendar]` section.
3
- //! - handoff_update_labels: set the top-level `labels` array.
4
- //! - handoff_start_project: set `started_at` and (optionally) shift all task
5
- //! dates so the earliest start aligns to the project start date.
6
-
7
- use std::path::Path;
8
-
9
- use anyhow::Result;
10
- use chrono::{Duration, NaiveDate, Utc};
11
- use serde_json::Value;
12
-
13
- use super::config_crud::{
14
- config_path, ensure_table, load_doc, save_doc, set_f64_map, set_mixed_array, set_opt_f64,
15
- set_opt_str, set_string_array,
16
- };
17
-
18
- /// handoff_update_calendar — patch the `[calendar]` section. Only provided
19
- /// fields are changed; passing JSON null clears a field.
20
- pub fn handle_update_calendar(arguments: &Value) -> Result<String> {
21
- let path = config_path(arguments)?;
22
- let mut doc = load_doc(&path)?;
23
-
24
- let cal = ensure_table(&mut doc, "calendar")?;
25
- cal.set_implicit(false);
26
- set_opt_f64(
27
- cal,
28
- "work_hours_per_day",
29
- arguments.get("work_hours_per_day"),
30
- );
31
- set_mixed_array(cal, "closed_weekdays", arguments.get("closed_weekdays"));
32
- set_string_array(cal, "closed_dates", arguments.get("closed_dates"));
33
- set_string_array(cal, "open_dates", arguments.get("open_dates"));
34
- set_f64_map(cal, "day_hours", arguments.get("day_hours"));
35
- set_opt_str(cal, "schedule_mode", arguments.get("schedule_mode"));
36
- set_opt_f64(
37
- cal,
38
- "overwork_limit_percent",
39
- arguments.get("overwork_limit_percent"),
40
- );
41
- set_opt_f64(cal, "max_utilization", arguments.get("max_utilization"));
42
-
43
- // schedule_mode is also accepted at the top level for parity with VSCode.
44
- if let Some(mode) = arguments.get("schedule_mode").and_then(|v| v.as_str()) {
45
- doc["schedule_mode"] = toml_edit::Item::Value(toml_edit::Value::from(mode));
46
- }
47
-
48
- save_doc(&path, &doc)?;
49
- Ok("Updated calendar configuration".to_string())
50
- }
51
-
52
- /// handoff_update_labels — replace the top-level `labels` array.
53
- pub fn handle_update_labels(arguments: &Value) -> Result<String> {
54
- let path = config_path(arguments)?;
55
- let labels = arguments
56
- .get("labels")
57
- .and_then(|v| v.as_array())
58
- .ok_or_else(|| anyhow::anyhow!("'labels' (array of strings) is required"))?;
59
-
60
- let mut doc = load_doc(&path)?;
61
- let mut arr = toml_edit::Array::new();
62
- for it in labels {
63
- if let Some(s) = it.as_str() {
64
- arr.push(s);
65
- }
66
- }
67
- let count = arr.len();
68
- doc["labels"] = toml_edit::Item::Value(toml_edit::Value::Array(arr));
69
-
70
- save_doc(&path, &doc)?;
71
- Ok(format!("Set {count} project label(s)"))
72
- }
73
-
74
- /// handoff_start_project — record the project start timestamp and, when
75
- /// `shift_dates` is true, move every task's start/due dates so the earliest
76
- /// start lands on `start_date`. Mirrors the VSCode startProject command.
77
- pub fn handle_start_project(arguments: &Value) -> Result<String> {
78
- let path = config_path(arguments)?;
79
-
80
- // start_date defaults to today (UTC).
81
- let start_date_str = match arguments.get("start_date").and_then(|v| v.as_str()) {
82
- Some(s) => s.to_string(),
83
- None => Utc::now().format("%Y-%m-%d").to_string(),
84
- };
85
- let start_date = NaiveDate::parse_from_str(&start_date_str, "%Y-%m-%d").map_err(|_| {
86
- anyhow::anyhow!("Invalid start_date '{start_date_str}' (expected YYYY-MM-DD)")
87
- })?;
88
-
89
- let mut doc = load_doc(&path)?;
90
- doc["started_at"] = toml_edit::Item::Value(toml_edit::Value::from(start_date_str.as_str()));
91
- save_doc(&path, &doc)?;
92
-
93
- let shift = arguments
94
- .get("shift_dates")
95
- .and_then(|v| v.as_bool())
96
- .unwrap_or(false);
97
-
98
- if !shift {
99
- return Ok(format!("Set project start to {start_date_str}"));
100
- }
101
-
102
- let tasks_dir = path
103
- .parent()
104
- .map(|p| p.join("tasks"))
105
- .ok_or_else(|| anyhow::anyhow!("Cannot locate tasks dir"))?;
106
- let shifted = shift_all_dates(&tasks_dir, start_date)?;
107
-
108
- Ok(format!(
109
- "Set project start to {start_date_str} and shifted dates on {shifted} task(s)"
110
- ))
111
- }
112
-
113
- /// Find the earliest task start date and shift all task start/due dates so that
114
- /// earliest start aligns to `target`. Returns the number of tasks changed.
115
- fn shift_all_dates(tasks_dir: &Path, target: NaiveDate) -> Result<usize> {
116
- use crate::storage::tasks::{read_task, write_task};
117
-
118
- let dirs = collect_task_dirs(tasks_dir)?;
119
-
120
- // Pass 1: find earliest start date.
121
- let mut earliest: Option<NaiveDate> = None;
122
- for dir in &dirs {
123
- if let Some((data, _)) = read_task(dir)? {
124
- if let Some(sd) = data.schedule.as_ref().and_then(|s| s.start_date.as_deref()) {
125
- if let Ok(d) = NaiveDate::parse_from_str(sd, "%Y-%m-%d") {
126
- earliest = Some(earliest.map_or(d, |e| e.min(d)));
127
- }
128
- }
129
- }
130
- }
131
- let earliest = match earliest {
132
- Some(d) => d,
133
- None => return Ok(0), // no dated tasks → nothing to shift
134
- };
135
- let delta = (target - earliest).num_days();
136
- if delta == 0 {
137
- return Ok(0);
138
- }
139
-
140
- // Pass 2: apply the shift.
141
- let mut count = 0;
142
- for dir in &dirs {
143
- if let Some((mut data, status)) = read_task(dir)? {
144
- let mut changed = false;
145
- if let Some(sched) = data.schedule.as_mut() {
146
- if let Some(sd) = sched.start_date.as_deref() {
147
- if let Ok(d) = NaiveDate::parse_from_str(sd, "%Y-%m-%d") {
148
- sched.start_date = Some(shift(d, delta));
149
- changed = true;
150
- }
151
- }
152
- if let Some(dd) = sched.due_date.as_deref() {
153
- if let Ok(d) = NaiveDate::parse_from_str(dd, "%Y-%m-%d") {
154
- sched.due_date = Some(shift(d, delta));
155
- changed = true;
156
- }
157
- }
158
- }
159
- if changed {
160
- data.updated_at = Some(Utc::now().to_rfc3339());
161
- write_task(dir, &status, &data)?;
162
- count += 1;
163
- }
164
- }
165
- }
166
- Ok(count)
167
- }
168
-
169
- fn shift(date: NaiveDate, delta_days: i64) -> String {
170
- (date + Duration::days(delta_days))
171
- .format("%Y-%m-%d")
172
- .to_string()
173
- }
174
-
175
- fn collect_task_dirs(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
176
- let mut out = Vec::new();
177
- collect_into(dir, &mut out)?;
178
- Ok(out)
179
- }
180
-
181
- fn collect_into(dir: &Path, out: &mut Vec<std::path::PathBuf>) -> Result<()> {
182
- if !dir.exists() {
183
- return Ok(());
184
- }
185
- for entry in std::fs::read_dir(dir)? {
186
- let entry = entry?;
187
- let path = entry.path();
188
- if path.is_dir() {
189
- if crate::storage::tasks::find_task_file(&path)?.is_some() {
190
- out.push(path.clone());
191
- }
192
- collect_into(&path, out)?;
193
- }
194
- }
195
- Ok(())
196
- }
@@ -1,318 +0,0 @@
1
- use anyhow::{Context, Result};
2
- use chrono::{Datelike, NaiveDate};
3
- use serde_json::{json, Value};
4
- use toml_edit::DocumentMut;
5
-
6
- use super::resolve_project_dir;
7
- use crate::storage::config::{read_config, weekday_to_num};
8
- use crate::storage::ensure_handoff_exists;
9
- use crate::storage::tasks::{build_task_index, is_terminal_status, TaskIndex};
10
-
11
- pub fn handle(arguments: &Value) -> Result<String> {
12
- let project_dir = resolve_project_dir(arguments)?;
13
- let handoff = ensure_handoff_exists(&project_dir)?;
14
- let config_path = handoff.join("config.toml");
15
- let tasks_dir = handoff.join("tasks");
16
-
17
- let start_date_str = arguments
18
- .get("start_date")
19
- .and_then(|v| v.as_str())
20
- .ok_or_else(|| anyhow::anyhow!("'start_date' parameter is required (YYYY-MM-DD)"))?;
21
- let end_date_str = arguments
22
- .get("end_date")
23
- .and_then(|v| v.as_str())
24
- .ok_or_else(|| anyhow::anyhow!("'end_date' parameter is required (YYYY-MM-DD)"))?;
25
- let assignee_filter = arguments.get("assignee").and_then(|v| v.as_str());
26
-
27
- let start = NaiveDate::parse_from_str(start_date_str, "%Y-%m-%d")
28
- .with_context(|| format!("Invalid start_date: {start_date_str}"))?;
29
- let end = NaiveDate::parse_from_str(end_date_str, "%Y-%m-%d")
30
- .with_context(|| format!("Invalid end_date: {end_date_str}"))?;
31
-
32
- if end < start {
33
- anyhow::bail!("end_date must be >= start_date");
34
- }
35
-
36
- let calendar = parse_calendar(&config_path, assignee_filter)?;
37
-
38
- let mut days: Vec<Value> = Vec::new();
39
- let mut total_hours: f64 = 0.0;
40
- let mut work_days: u32 = 0;
41
-
42
- let mut date = start;
43
- while date <= end {
44
- let hours = calendar.hours_for_date(&date);
45
- if hours > 0.0 {
46
- work_days += 1;
47
- total_hours += hours;
48
- }
49
- days.push(json!({
50
- "date": date.format("%Y-%m-%d").to_string(),
51
- "capacity_hours": hours,
52
- "allocated_hours": 0.0,
53
- }));
54
- date += chrono::Duration::days(1);
55
- }
56
-
57
- // Calculate allocated hours from tasks
58
- let multiplier = read_config(&config_path)
59
- .map(|c| c.settings.ai_estimate_multiplier)
60
- .unwrap_or(0.2);
61
- if tasks_dir.exists() {
62
- let (tree, _) = build_task_index(&tasks_dir, u32::MAX)?;
63
- allocate_task_hours(&tree, assignee_filter, &start, &end, &mut days, multiplier);
64
- }
65
-
66
- let allocated_hours: f64 = days
67
- .iter()
68
- .filter_map(|d| d.get("allocated_hours").and_then(|v| v.as_f64()))
69
- .sum();
70
-
71
- let result = json!({
72
- "work_days": work_days,
73
- "total_hours": total_hours,
74
- "allocated_hours": allocated_hours,
75
- "available_hours": (total_hours - allocated_hours).max(0.0),
76
- "days": days,
77
- });
78
-
79
- serde_json::to_string_pretty(&result).map_err(Into::into)
80
- }
81
-
82
- struct CalendarConfig {
83
- work_hours_per_day: f64,
84
- closed_weekdays: Vec<u32>,
85
- closed_dates: Vec<String>,
86
- open_dates: Vec<String>,
87
- day_hours: Vec<(String, f64)>,
88
- }
89
-
90
- impl CalendarConfig {
91
- fn hours_for_date(&self, date: &NaiveDate) -> f64 {
92
- let date_str = date.format("%Y-%m-%d").to_string();
93
-
94
- // Check per-date hours first
95
- for (d, h) in &self.day_hours {
96
- if d == &date_str {
97
- return *h;
98
- }
99
- }
100
-
101
- // Check if explicitly open (overrides closed weekday)
102
- let is_open_override = self.open_dates.iter().any(|d| d == &date_str);
103
-
104
- // Check closed dates
105
- if self.closed_dates.iter().any(|d| d == &date_str) {
106
- return 0.0;
107
- }
108
-
109
- // Check closed weekdays (chrono: Mon=1 .. Sun=7, we use 0=Sun..6=Sat)
110
- let weekday_num = match date.weekday() {
111
- chrono::Weekday::Sun => 0,
112
- chrono::Weekday::Mon => 1,
113
- chrono::Weekday::Tue => 2,
114
- chrono::Weekday::Wed => 3,
115
- chrono::Weekday::Thu => 4,
116
- chrono::Weekday::Fri => 5,
117
- chrono::Weekday::Sat => 6,
118
- };
119
-
120
- if !is_open_override && self.closed_weekdays.contains(&weekday_num) {
121
- return 0.0;
122
- }
123
-
124
- // Check per-weekday hours
125
- let weekday_str = match weekday_num {
126
- 0 => "sun",
127
- 1 => "mon",
128
- 2 => "tue",
129
- 3 => "wed",
130
- 4 => "thu",
131
- 5 => "fri",
132
- 6 => "sat",
133
- _ => "",
134
- };
135
- for (d, h) in &self.day_hours {
136
- if d == weekday_str {
137
- return *h;
138
- }
139
- }
140
-
141
- self.work_hours_per_day
142
- }
143
- }
144
-
145
- fn parse_calendar(config_path: &std::path::Path, assignee: Option<&str>) -> Result<CalendarConfig> {
146
- let mut cal = CalendarConfig {
147
- work_hours_per_day: 8.0,
148
- closed_weekdays: Vec::new(),
149
- closed_dates: Vec::new(),
150
- open_dates: Vec::new(),
151
- day_hours: Vec::new(),
152
- };
153
-
154
- if !config_path.exists() {
155
- return Ok(cal);
156
- }
157
-
158
- let raw = std::fs::read_to_string(config_path).with_context(|| "Failed to read config.toml")?;
159
- let doc: DocumentMut = raw.parse().with_context(|| "Failed to parse config.toml")?;
160
-
161
- // Read project-level calendar
162
- if let Some(calendar) = doc.get("calendar").and_then(|v| v.as_table()) {
163
- if let Some(h) = calendar
164
- .get("work_hours_per_day")
165
- .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
166
- {
167
- cal.work_hours_per_day = h;
168
- }
169
- if let Some(arr) = calendar.get("closed_weekdays").and_then(|v| v.as_array()) {
170
- cal.closed_weekdays = arr
171
- .iter()
172
- .filter_map(|v| {
173
- v.as_integer()
174
- .map(|i| i as u32)
175
- .or_else(|| v.as_str().and_then(weekday_to_num))
176
- })
177
- .collect();
178
- }
179
- if let Some(arr) = calendar.get("closed_dates").and_then(|v| v.as_array()) {
180
- cal.closed_dates = arr
181
- .iter()
182
- .filter_map(|v| v.as_str().map(String::from))
183
- .collect();
184
- }
185
- if let Some(arr) = calendar.get("open_dates").and_then(|v| v.as_array()) {
186
- cal.open_dates = arr
187
- .iter()
188
- .filter_map(|v| v.as_str().map(String::from))
189
- .collect();
190
- }
191
- if let Some(dh) = calendar.get("day_hours").and_then(|v| v.as_table()) {
192
- for (k, v) in dh.iter() {
193
- if let Some(h) = v.as_float().or_else(|| v.as_integer().map(|i| i as f64)) {
194
- cal.day_hours.push((k.to_string(), h));
195
- }
196
- }
197
- }
198
- }
199
-
200
- // Override with assignee-specific calendar
201
- if let Some(assignee_key) = assignee {
202
- if let Some(assignees) = doc.get("assignees").and_then(|v| v.as_table()) {
203
- if let Some(a) = assignees.get(assignee_key).and_then(|v| v.as_table()) {
204
- if let Some(h) = a
205
- .get("work_hours_per_day")
206
- .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
207
- {
208
- cal.work_hours_per_day = h;
209
- }
210
- if let Some(arr) = a.get("closed_weekdays").and_then(|v| v.as_array()) {
211
- cal.closed_weekdays = arr
212
- .iter()
213
- .filter_map(|v| {
214
- v.as_integer()
215
- .map(|i| i as u32)
216
- .or_else(|| v.as_str().and_then(weekday_to_num))
217
- })
218
- .collect();
219
- }
220
- if let Some(arr) = a.get("closed_dates").and_then(|v| v.as_array()) {
221
- for item in arr.iter() {
222
- if let Some(s) = item.as_str() {
223
- cal.closed_dates.push(s.to_string());
224
- }
225
- }
226
- }
227
- if let Some(arr) = a.get("open_dates").and_then(|v| v.as_array()) {
228
- for item in arr.iter() {
229
- if let Some(s) = item.as_str() {
230
- cal.open_dates.push(s.to_string());
231
- }
232
- }
233
- }
234
- if let Some(dh) = a.get("day_hours").and_then(|v| v.as_table()) {
235
- for (k, v) in dh.iter() {
236
- if let Some(h) = v.as_float().or_else(|| v.as_integer().map(|i| i as f64)) {
237
- cal.day_hours.push((k.to_string(), h));
238
- }
239
- }
240
- }
241
- }
242
- }
243
- }
244
-
245
- Ok(cal)
246
- }
247
-
248
- fn allocate_task_hours(
249
- tree: &[TaskIndex],
250
- assignee_filter: Option<&str>,
251
- start: &NaiveDate,
252
- end: &NaiveDate,
253
- days: &mut [Value],
254
- multiplier: f64,
255
- ) {
256
- for node in tree {
257
- if is_terminal_status(&node.status) {
258
- continue;
259
- }
260
-
261
- let matches_assignee = match assignee_filter {
262
- Some(f) => node.assignee.as_deref() == Some(f),
263
- None => true,
264
- };
265
-
266
- if matches_assignee {
267
- if let Some(ref sched) = node.schedule {
268
- if let (Some(ref sd), Some(ref dd)) = (&sched.start_date, &sched.due_date) {
269
- if let (Ok(task_start), Ok(task_end)) = (
270
- NaiveDate::parse_from_str(sd, "%Y-%m-%d"),
271
- NaiveDate::parse_from_str(dd, "%Y-%m-%d"),
272
- ) {
273
- // remaining_hours is already actual AI-effort progress, so it
274
- // is used as-is; the raw estimate is human-effort and gets the
275
- // AI multiplier applied to derive AI-effort allocation.
276
- let est = match sched.remaining_hours {
277
- Some(rem) => rem,
278
- None => sched.estimate_hours.unwrap_or(0.0) * multiplier,
279
- };
280
- let overlap_start = (*start).max(task_start);
281
- let overlap_end = (*end).min(task_end);
282
- if overlap_start <= overlap_end {
283
- let task_days = (task_end - task_start).num_days().max(1) as f64;
284
- let hours_per_day = est / task_days;
285
-
286
- let mut date = overlap_start;
287
- while date <= overlap_end {
288
- let idx = (date - *start).num_days() as usize;
289
- if idx < days.len() {
290
- if let Some(obj) = days[idx].as_object_mut() {
291
- let cur = obj
292
- .get("allocated_hours")
293
- .and_then(|v| v.as_f64())
294
- .unwrap_or(0.0);
295
- obj.insert(
296
- "allocated_hours".to_string(),
297
- json!(cur + hours_per_day),
298
- );
299
- }
300
- }
301
- date += chrono::Duration::days(1);
302
- }
303
- }
304
- }
305
- }
306
- }
307
- }
308
-
309
- allocate_task_hours(
310
- &node.children,
311
- assignee_filter,
312
- start,
313
- end,
314
- days,
315
- multiplier,
316
- );
317
- }
318
- }
@@ -1,70 +0,0 @@
1
- use anyhow::Result;
2
- use chrono::Utc;
3
- use serde_json::Value;
4
-
5
- use super::resolve_project_dir;
6
- use crate::storage::ensure_handoff_exists;
7
- use crate::storage::tasks::{
8
- find_task_dir_by_id, find_task_file, read_task, suggest_task_id, write_task,
9
- };
10
-
11
- pub fn handle(arguments: &Value) -> Result<String> {
12
- let project_dir = resolve_project_dir(arguments)?;
13
-
14
- let handoff = ensure_handoff_exists(&project_dir)?;
15
- let tasks_dir = handoff.join("tasks");
16
-
17
- let task_id = arguments
18
- .get("task_id")
19
- .and_then(|v| v.as_str())
20
- .ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
21
-
22
- let criterion_index = arguments
23
- .get("criterion_index")
24
- .and_then(|v| v.as_u64())
25
- .ok_or_else(|| anyhow::anyhow!("'criterion_index' parameter is required"))?
26
- as usize;
27
-
28
- let checked = arguments
29
- .get("checked")
30
- .and_then(|v| v.as_bool())
31
- .ok_or_else(|| anyhow::anyhow!("'checked' parameter is required (boolean)"))?;
32
-
33
- let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
34
- .ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(&tasks_dir, task_id)))?;
35
-
36
- let (mut data, status) = read_task(&task_dir)?
37
- .ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
38
-
39
- if criterion_index >= data.done_criteria.len() {
40
- anyhow::bail!(
41
- "criterion_index {criterion_index} is out of range (task has {} criteria)",
42
- data.done_criteria.len()
43
- );
44
- }
45
-
46
- data.done_criteria[criterion_index].checked = checked;
47
- data.updated_at = Some(Utc::now().to_rfc3339());
48
-
49
- if let Some((old_path, _)) = find_task_file(&task_dir)? {
50
- std::fs::remove_file(&old_path)?;
51
- }
52
-
53
- write_task(&task_dir, &status, &data)?;
54
-
55
- let checked_count = data.done_criteria.iter().filter(|c| c.checked).count();
56
- let total = data.done_criteria.len();
57
-
58
- let result = serde_json::json!({
59
- "task_id": data.id,
60
- "criterion_index": criterion_index,
61
- "item": data.done_criteria[criterion_index].item,
62
- "checked": checked,
63
- "done_criteria_summary": {
64
- "total": total,
65
- "checked": checked_count,
66
- }
67
- });
68
-
69
- serde_json::to_string_pretty(&result).map_err(Into::into)
70
- }