handoff-mcp-server 0.26.0 → 0.27.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,489 +0,0 @@
1
- use std::collections::HashMap;
2
-
3
- use anyhow::{Context, Result};
4
- use chrono::{Datelike, Duration, NaiveDate, Utc};
5
- use serde_json::{json, Value};
6
- use toml_edit::DocumentMut;
7
-
8
- use super::resolve_project_dir;
9
- use crate::storage::config::weekday_to_num;
10
- use crate::storage::ensure_handoff_exists;
11
- use crate::storage::tasks::*;
12
-
13
- pub fn handle(arguments: &Value) -> Result<String> {
14
- let project_dir = resolve_project_dir(arguments)?;
15
- let handoff = ensure_handoff_exists(&project_dir)?;
16
- let config_path = handoff.join("config.toml");
17
- let tasks_dir = handoff.join("tasks");
18
-
19
- let dry_run = arguments
20
- .get("dry_run")
21
- .and_then(|v| v.as_bool())
22
- .unwrap_or(true);
23
- let assignee_filter = arguments.get("assignee_filter").and_then(|v| v.as_str());
24
-
25
- let calendar = parse_project_calendar(&config_path)?;
26
- let assignee_calendars = parse_assignee_calendars(&config_path)?;
27
-
28
- let (tree, _) = build_task_index(&tasks_dir, u32::MAX)?;
29
-
30
- // Collect schedulable tasks (non-terminal, not pinned)
31
- let mut schedulable: Vec<SchedulableTask> = Vec::new();
32
- collect_schedulable(&tree, &tasks_dir, assignee_filter, &mut schedulable)?;
33
-
34
- // Sort by dependencies then order
35
- sort_by_deps(&mut schedulable);
36
-
37
- // Schedule each task. The anchor defaults to today (UTC) but can be pinned
38
- // via `start_date` for planning a future start (and for deterministic tests).
39
- let start_date = match arguments.get("start_date").and_then(|v| v.as_str()) {
40
- Some(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")
41
- .map_err(|_| anyhow::anyhow!("Invalid start_date '{s}' (expected YYYY-MM-DD)"))?,
42
- None => {
43
- let today = Utc::now().format("%Y-%m-%d").to_string();
44
- NaiveDate::parse_from_str(&today, "%Y-%m-%d")?
45
- }
46
- };
47
-
48
- let mut assignee_next_date: HashMap<String, NaiveDate> = HashMap::new();
49
- let mut project_next_date = start_date;
50
- let mut changes: Vec<Value> = Vec::new();
51
-
52
- for task in &schedulable {
53
- let cal = task
54
- .assignee
55
- .as_ref()
56
- .and_then(|a| assignee_calendars.get(a))
57
- .unwrap_or(&calendar);
58
-
59
- let earliest = match &task.assignee {
60
- Some(a) => assignee_next_date.get(a).copied().unwrap_or(start_date),
61
- None => project_next_date,
62
- };
63
-
64
- // Also respect dependency completion dates
65
- let dep_earliest = task
66
- .dep_due_dates
67
- .iter()
68
- .filter_map(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
69
- .max()
70
- .map(|d| d + Duration::days(1))
71
- .unwrap_or(start_date);
72
-
73
- let task_start = earliest.max(dep_earliest);
74
-
75
- // Find next work day from task_start
76
- let actual_start = next_work_day(task_start, cal);
77
-
78
- // Calculate end date based on estimate_hours, drawing each day's capacity
79
- // from the calendar's per-day hours (day_hours overrides).
80
- let hours = task.estimate_hours.unwrap_or(8.0);
81
- let actual_end = advance_by_hours(actual_start, hours, cal);
82
-
83
- let new_start = actual_start.format("%Y-%m-%d").to_string();
84
- let new_due = actual_end.format("%Y-%m-%d").to_string();
85
-
86
- if task.old_start.as_deref() != Some(&new_start)
87
- || task.old_due.as_deref() != Some(&new_due)
88
- {
89
- changes.push(json!({
90
- "task_id": task.id,
91
- "old_start": task.old_start,
92
- "new_start": new_start,
93
- "old_due": task.old_due,
94
- "new_due": new_due,
95
- }));
96
- }
97
-
98
- // Advance the next available date for this assignee
99
- let next_available = actual_end + Duration::days(1);
100
- match &task.assignee {
101
- Some(a) => {
102
- assignee_next_date.insert(a.clone(), next_available);
103
- }
104
- None => {
105
- project_next_date = next_available;
106
- }
107
- }
108
-
109
- // Apply if not dry_run
110
- if !dry_run {
111
- if let Some(task_dir) = find_task_dir_by_id(&tasks_dir, &task.id)? {
112
- if let Some((mut data, status)) = read_task(&task_dir)? {
113
- let schedule = data.schedule.get_or_insert_with(Default::default);
114
- schedule.start_date = Some(new_start);
115
- schedule.due_date = Some(new_due);
116
- data.updated_at = Some(Utc::now().to_rfc3339());
117
-
118
- if let Some((old_path, _)) = find_task_file(&task_dir)? {
119
- std::fs::remove_file(&old_path)?;
120
- }
121
- write_task(&task_dir, &status, &data)?;
122
- }
123
- }
124
- }
125
- }
126
-
127
- // Summarize the assignee calendars that fed the computation (applied conditions).
128
- let assignee_capacity: serde_json::Map<String, Value> = assignee_calendars
129
- .iter()
130
- .map(|(k, c)| {
131
- (
132
- k.clone(),
133
- json!({
134
- "work_hours_per_day": c.work_hours_per_day,
135
- "closed_weekdays": c.closed_weekdays,
136
- }),
137
- )
138
- })
139
- .collect();
140
-
141
- // When changes are actually applied, record them on the active session(s) so
142
- // the decision is part of the audit trail, not just this response.
143
- let mut decision_recorded_in = 0usize;
144
- if !dry_run && !changes.is_empty() {
145
- let assignees_touched: std::collections::BTreeSet<&str> = schedulable
146
- .iter()
147
- .filter_map(|t| t.assignee.as_deref())
148
- .collect();
149
- let summary = format!(
150
- "Auto-scheduled {} task(s) across {} assignee(s) from {}",
151
- changes.len(),
152
- assignees_touched.len().max(1),
153
- start_date.format("%Y-%m-%d")
154
- );
155
- let decision = json!({
156
- "decision": summary,
157
- "reason": "handoff_auto_schedule applied computed start/due dates",
158
- "confidence": "confirmed",
159
- });
160
- let sessions_dir = handoff.join("sessions");
161
- decision_recorded_in = crate::storage::sessions::append_decision_to_active_sessions(
162
- &sessions_dir,
163
- decision,
164
- None,
165
- )?;
166
- }
167
-
168
- let result = json!({
169
- "dry_run": dry_run,
170
- "scheduled_count": schedulable.len(),
171
- "changed_count": changes.len(),
172
- "changes": changes,
173
- "decision_recorded_in_sessions": decision_recorded_in,
174
- "calendar_config": {
175
- "work_hours_per_day": calendar.work_hours_per_day,
176
- "closed_weekdays": calendar.closed_weekdays,
177
- "day_hours": calendar.day_hours,
178
- },
179
- "assignee_capacity": assignee_capacity,
180
- });
181
-
182
- serde_json::to_string_pretty(&result).map_err(Into::into)
183
- }
184
-
185
- struct SchedulableTask {
186
- id: String,
187
- assignee: Option<String>,
188
- estimate_hours: Option<f64>,
189
- old_start: Option<String>,
190
- old_due: Option<String>,
191
- dependencies: Vec<String>,
192
- dep_due_dates: Vec<String>,
193
- order: Option<u32>,
194
- }
195
-
196
- fn collect_schedulable(
197
- tree: &[TaskIndex],
198
- tasks_dir: &std::path::Path,
199
- assignee_filter: Option<&str>,
200
- result: &mut Vec<SchedulableTask>,
201
- ) -> Result<()> {
202
- for node in tree {
203
- if is_terminal_status(&node.status) {
204
- collect_schedulable(&node.children, tasks_dir, assignee_filter, result)?;
205
- continue;
206
- }
207
-
208
- // Check if pinned
209
- let pinned = node
210
- .schedule
211
- .as_ref()
212
- .and_then(|s| s.pinned)
213
- .unwrap_or(false);
214
- if pinned {
215
- collect_schedulable(&node.children, tasks_dir, assignee_filter, result)?;
216
- continue;
217
- }
218
-
219
- // Check assignee filter
220
- if let Some(filter) = assignee_filter {
221
- if node.assignee.as_deref() != Some(filter) {
222
- collect_schedulable(&node.children, tasks_dir, assignee_filter, result)?;
223
- continue;
224
- }
225
- }
226
-
227
- // Get dependency due dates
228
- let dep_due_dates: Vec<String> = node
229
- .dependencies
230
- .iter()
231
- .filter_map(|dep_id| {
232
- find_task_dir_by_id(tasks_dir, dep_id)
233
- .ok()
234
- .flatten()
235
- .and_then(|dir| read_task(&dir).ok().flatten())
236
- .and_then(|(data, _)| data.schedule.and_then(|s| s.due_date))
237
- })
238
- .collect();
239
-
240
- result.push(SchedulableTask {
241
- id: node.id.clone(),
242
- assignee: node.assignee.clone(),
243
- estimate_hours: node.schedule.as_ref().and_then(|s| s.estimate_hours),
244
- old_start: node.schedule.as_ref().and_then(|s| s.start_date.clone()),
245
- old_due: node.schedule.as_ref().and_then(|s| s.due_date.clone()),
246
- dependencies: node.dependencies.clone(),
247
- dep_due_dates,
248
- order: node.order,
249
- });
250
-
251
- collect_schedulable(&node.children, tasks_dir, assignee_filter, result)?;
252
- }
253
- Ok(())
254
- }
255
-
256
- fn sort_by_deps(tasks: &mut [SchedulableTask]) {
257
- // Simple topological sort: tasks with no deps first, then by order
258
- tasks.sort_by(|a, b| {
259
- let a_has_deps = !a.dependencies.is_empty();
260
- let b_has_deps = !b.dependencies.is_empty();
261
- a_has_deps
262
- .cmp(&b_has_deps)
263
- .then_with(|| {
264
- a.order
265
- .unwrap_or(u32::MAX)
266
- .cmp(&b.order.unwrap_or(u32::MAX))
267
- })
268
- .then_with(|| a.id.cmp(&b.id))
269
- });
270
- }
271
-
272
- struct Calendar {
273
- work_hours_per_day: f64,
274
- closed_weekdays: Vec<u32>,
275
- closed_dates: Vec<String>,
276
- open_dates: Vec<String>,
277
- /// Per-weekday-name or per-YYYY-MM-DD working-hour overrides.
278
- day_hours: HashMap<String, f64>,
279
- }
280
-
281
- const WEEKDAY_NAMES: [&str; 7] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
282
-
283
- impl Calendar {
284
- /// Effective working hours for a specific date. A date-specific override in
285
- /// `day_hours` takes precedence over a weekday-name override, which takes
286
- /// precedence over `work_hours_per_day`. Mirrors capacity.rs.
287
- fn hours_for(&self, date: &NaiveDate) -> f64 {
288
- let date_str = date.format("%Y-%m-%d").to_string();
289
- if let Some(h) = self.day_hours.get(&date_str) {
290
- return *h;
291
- }
292
- let weekday_num = weekday_index(date);
293
- let name = WEEKDAY_NAMES[weekday_num as usize];
294
- if let Some(h) = self.day_hours.get(name) {
295
- return *h;
296
- }
297
- self.work_hours_per_day
298
- }
299
-
300
- fn is_work_day(&self, date: &NaiveDate) -> bool {
301
- let date_str = date.format("%Y-%m-%d").to_string();
302
-
303
- if self.closed_dates.contains(&date_str) {
304
- return false;
305
- }
306
-
307
- if self.open_dates.contains(&date_str) {
308
- return true;
309
- }
310
-
311
- !self.closed_weekdays.contains(&weekday_index(date))
312
- }
313
- }
314
-
315
- fn weekday_index(date: &NaiveDate) -> u32 {
316
- match date.weekday() {
317
- chrono::Weekday::Sun => 0,
318
- chrono::Weekday::Mon => 1,
319
- chrono::Weekday::Tue => 2,
320
- chrono::Weekday::Wed => 3,
321
- chrono::Weekday::Thu => 4,
322
- chrono::Weekday::Fri => 5,
323
- chrono::Weekday::Sat => 6,
324
- }
325
- }
326
-
327
- fn next_work_day(from: NaiveDate, cal: &Calendar) -> NaiveDate {
328
- let mut date = from;
329
- while !cal.is_work_day(&date) {
330
- date += Duration::days(1);
331
- }
332
- date
333
- }
334
-
335
- /// Advance from `start` (assumed to be a work day) consuming `hours` of effort,
336
- /// drawing each day's capacity from `cal.hours_for(date)`. Returns the last work
337
- /// day the task occupies. Respects per-day hour overrides (day_hours), so a task
338
- /// spanning a half-capacity Friday takes an extra day. (referral ref-...004309 §5)
339
- fn advance_by_hours(start: NaiveDate, hours: f64, cal: &Calendar) -> NaiveDate {
340
- let mut date = start;
341
- // Consume the first day's capacity.
342
- let mut remaining = hours - cal.hours_for(&date).max(0.0);
343
- // Guard against a zero-capacity calendar (would otherwise loop forever).
344
- let mut guard = 0;
345
- while remaining > 1e-9 && guard < 10_000 {
346
- date = next_work_day(date + Duration::days(1), cal);
347
- remaining -= cal.hours_for(&date).max(0.0);
348
- guard += 1;
349
- }
350
- date
351
- }
352
-
353
- fn parse_project_calendar(config_path: &std::path::Path) -> Result<Calendar> {
354
- let mut cal = Calendar {
355
- work_hours_per_day: 8.0,
356
- closed_weekdays: vec![0, 6], // Sun, Sat
357
- closed_dates: Vec::new(),
358
- open_dates: Vec::new(),
359
- day_hours: HashMap::new(),
360
- };
361
-
362
- if !config_path.exists() {
363
- return Ok(cal);
364
- }
365
-
366
- let raw = std::fs::read_to_string(config_path).with_context(|| "Failed to read config")?;
367
- let doc: DocumentMut = raw.parse().with_context(|| "Failed to parse config")?;
368
-
369
- if let Some(calendar) = doc.get("calendar").and_then(|v| v.as_table()) {
370
- if let Some(h) = calendar
371
- .get("work_hours_per_day")
372
- .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
373
- {
374
- cal.work_hours_per_day = h;
375
- }
376
- if let Some(arr) = calendar.get("closed_weekdays").and_then(|v| v.as_array()) {
377
- cal.closed_weekdays = arr
378
- .iter()
379
- .filter_map(|v| {
380
- v.as_integer()
381
- .map(|i| i as u32)
382
- .or_else(|| v.as_str().and_then(weekday_to_num))
383
- })
384
- .collect();
385
- }
386
- if let Some(arr) = calendar.get("closed_dates").and_then(|v| v.as_array()) {
387
- cal.closed_dates = arr
388
- .iter()
389
- .filter_map(|v| v.as_str().map(String::from))
390
- .collect();
391
- }
392
- if let Some(arr) = calendar.get("open_dates").and_then(|v| v.as_array()) {
393
- cal.open_dates = arr
394
- .iter()
395
- .filter_map(|v| v.as_str().map(String::from))
396
- .collect();
397
- }
398
- if let Some(dh) = calendar.get("day_hours").and_then(|v| v.as_table()) {
399
- cal.day_hours = parse_day_hours(dh);
400
- }
401
- }
402
-
403
- Ok(cal)
404
- }
405
-
406
- /// Parse a `[*.day_hours]` table into a map of weekday-name/date -> hours.
407
- fn parse_day_hours(table: &toml_edit::Table) -> HashMap<String, f64> {
408
- let mut out = HashMap::new();
409
- for (key, item) in table.iter() {
410
- if let Some(h) = item
411
- .as_value()
412
- .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
413
- {
414
- out.insert(key.to_string(), h);
415
- }
416
- }
417
- out
418
- }
419
-
420
- fn parse_assignee_calendars(config_path: &std::path::Path) -> Result<HashMap<String, Calendar>> {
421
- let mut result = HashMap::new();
422
-
423
- if !config_path.exists() {
424
- return Ok(result);
425
- }
426
-
427
- let raw = std::fs::read_to_string(config_path).with_context(|| "Failed to read config")?;
428
- let doc: DocumentMut = raw.parse().with_context(|| "Failed to parse config")?;
429
-
430
- let base = parse_project_calendar(config_path)?;
431
-
432
- if let Some(assignees) = doc.get("assignees").and_then(|v| v.as_table()) {
433
- for (key, item) in assignees.iter() {
434
- let a = match item.as_table() {
435
- Some(t) => t,
436
- None => continue,
437
- };
438
-
439
- let mut cal = Calendar {
440
- work_hours_per_day: base.work_hours_per_day,
441
- closed_weekdays: base.closed_weekdays.clone(),
442
- closed_dates: base.closed_dates.clone(),
443
- open_dates: base.open_dates.clone(),
444
- day_hours: base.day_hours.clone(),
445
- };
446
-
447
- if let Some(h) = a
448
- .get("work_hours_per_day")
449
- .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
450
- {
451
- cal.work_hours_per_day = h;
452
- }
453
- if let Some(arr) = a.get("closed_weekdays").and_then(|v| v.as_array()) {
454
- cal.closed_weekdays = arr
455
- .iter()
456
- .filter_map(|v| {
457
- v.as_integer()
458
- .map(|i| i as u32)
459
- .or_else(|| v.as_str().and_then(weekday_to_num))
460
- })
461
- .collect();
462
- }
463
- if let Some(arr) = a.get("closed_dates").and_then(|v| v.as_array()) {
464
- for item in arr.iter() {
465
- if let Some(s) = item.as_str() {
466
- cal.closed_dates.push(s.to_string());
467
- }
468
- }
469
- }
470
- if let Some(arr) = a.get("open_dates").and_then(|v| v.as_array()) {
471
- for item in arr.iter() {
472
- if let Some(s) = item.as_str() {
473
- cal.open_dates.push(s.to_string());
474
- }
475
- }
476
- }
477
- // Per-assignee day_hours override the inherited project values key-by-key.
478
- if let Some(dh) = a.get("day_hours").and_then(|v| v.as_table()) {
479
- for (k, v) in parse_day_hours(dh) {
480
- cal.day_hours.insert(k, v);
481
- }
482
- }
483
-
484
- result.insert(key.to_string(), cal);
485
- }
486
- }
487
-
488
- Ok(result)
489
- }
@@ -1,155 +0,0 @@
1
- use anyhow::Result;
2
- use chrono::Utc;
3
- use serde_json::{json, Value};
4
-
5
- use super::resolve_project_dir;
6
- use crate::storage::config::read_config;
7
- use crate::storage::ensure_handoff_exists;
8
- use crate::storage::tasks::*;
9
-
10
- pub fn handle(arguments: &Value) -> Result<String> {
11
- let project_dir = resolve_project_dir(arguments)?;
12
- let handoff = ensure_handoff_exists(&project_dir)?;
13
- let tasks_dir = handoff.join("tasks");
14
-
15
- let require_estimate_hours = read_config(&handoff.join("config.toml"))
16
- .map(|c| c.settings.require_estimate_hours)
17
- .unwrap_or(true);
18
-
19
- let updates = arguments
20
- .get("updates")
21
- .and_then(|v| v.as_array())
22
- .ok_or_else(|| anyhow::anyhow!("'updates' parameter is required (array)"))?;
23
-
24
- let mut applied: u32 = 0;
25
- let mut errors: Vec<Value> = Vec::new();
26
-
27
- for update in updates {
28
- let task_id = match update.get("task_id").and_then(|v| v.as_str()) {
29
- Some(id) => id,
30
- None => {
31
- errors.push(json!({"task_id": null, "error": "missing task_id"}));
32
- continue;
33
- }
34
- };
35
-
36
- // A rejected update is reported per task and the rest still land: that is
37
- // this tool's established `applied` + `errors[]` contract.
38
- if let Err(e) = apply_single_update(&tasks_dir, task_id, update, require_estimate_hours) {
39
- errors.push(json!({"task_id": task_id, "error": e.to_string()}));
40
- } else {
41
- applied += 1;
42
- }
43
- }
44
-
45
- let result = json!({
46
- "applied": applied,
47
- "errors": errors,
48
- });
49
-
50
- serde_json::to_string_pretty(&result).map_err(Into::into)
51
- }
52
-
53
- fn apply_single_update(
54
- tasks_dir: &std::path::Path,
55
- task_id: &str,
56
- update: &Value,
57
- require_estimate_hours: bool,
58
- ) -> Result<()> {
59
- let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
60
- .ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, task_id)))?;
61
-
62
- let (mut data, current_status) = read_task(&task_dir)?
63
- .ok_or_else(|| anyhow::anyhow!("Task file not found for {task_id}"))?;
64
-
65
- if let Some(priority) = update.get("priority").and_then(|v| v.as_str()) {
66
- validate_priority(Some(priority))?;
67
- data.priority = Some(priority.to_string());
68
- }
69
-
70
- if let Some(notes) = update.get("notes").and_then(|v| v.as_str()) {
71
- data.notes = Some(notes.to_string());
72
- } else if let Some(append) = update.get("notes_append").and_then(|v| v.as_str()) {
73
- let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%S");
74
- let block = format!("--- {timestamp}\n{append}");
75
- match &mut data.notes {
76
- Some(existing) if !existing.is_empty() => {
77
- existing.push_str(&format!("\n\n{block}"));
78
- }
79
- _ => data.notes = Some(block),
80
- }
81
- }
82
-
83
- if update.get("assignee").is_some() {
84
- data.assignee = update
85
- .get("assignee")
86
- .and_then(|v| v.as_str())
87
- .map(String::from);
88
- }
89
-
90
- if let Some(sched_val) = update.get("schedule") {
91
- let schedule = data.schedule.get_or_insert_with(Default::default);
92
- if let Some(sd) = sched_val.get("start_date").and_then(|v| v.as_str()) {
93
- schedule.start_date = Some(sd.to_string());
94
- }
95
- if let Some(dd) = sched_val.get("due_date").and_then(|v| v.as_str()) {
96
- schedule.due_date = Some(dd.to_string());
97
- }
98
- if let Some(eh) = sched_val.get("estimate_hours").and_then(|v| v.as_f64()) {
99
- schedule.estimate_hours = Some(eh);
100
- }
101
- if let Some(ah) = sched_val.get("actual_hours").and_then(|v| v.as_f64()) {
102
- schedule.actual_hours = Some(ah);
103
- }
104
- if let Some(rh) = sched_val.get("remaining_hours").and_then(|v| v.as_f64()) {
105
- schedule.remaining_hours = Some(rh);
106
- }
107
- if let Some(ms) = sched_val.get("milestone").and_then(|v| v.as_str()) {
108
- schedule.milestone = Some(ms.to_string());
109
- }
110
- if let Some(p) = sched_val.get("pinned").and_then(|v| v.as_bool()) {
111
- schedule.pinned = Some(p);
112
- }
113
- }
114
-
115
- let new_status = update
116
- .get("status")
117
- .and_then(|v| v.as_str())
118
- .unwrap_or(&current_status);
119
-
120
- if !is_valid_status(new_status) {
121
- anyhow::bail!("Invalid status: {new_status}");
122
- }
123
-
124
- if new_status == "done" && current_status != "done" {
125
- validate_done_transition(&task_dir, &data)?;
126
- data.completed_at = Some(Utc::now().to_rfc3339());
127
- }
128
- if new_status == "skipped" && current_status != "skipped" {
129
- validate_skipped_transition(&task_dir, &data)?;
130
- }
131
-
132
- // Guard the same invariant handoff_update_task enforces, or a bulk patch
133
- // becomes a way around it. The check is on the task as it would be written —
134
- // status and schedule already merged — not on the patch. Parent tasks (with
135
- // children) and tasks in todo/blocked/skipped are exempt.
136
- let has_children = task_has_children(&task_dir)?;
137
- validate_estimate_required(
138
- require_estimate_hours,
139
- task_id,
140
- &data.title,
141
- new_status,
142
- has_children,
143
- false,
144
- data.schedule.as_ref(),
145
- )?;
146
-
147
- data.updated_at = Some(Utc::now().to_rfc3339());
148
-
149
- if let Some((old_path, _)) = find_task_file(&task_dir)? {
150
- std::fs::remove_file(&old_path)?;
151
- }
152
- write_task(&task_dir, new_status, &data)?;
153
-
154
- Ok(())
155
- }