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,957 +0,0 @@
1
- use std::cmp::Reverse;
2
- use std::collections::{HashMap, HashSet};
3
- use std::path::{Path, PathBuf};
4
-
5
- use anyhow::{Context, Result};
6
- use chrono::Utc;
7
- use serde::{Deserialize, Serialize};
8
- use serde_json::Value;
9
-
10
- fn is_empty_map(m: &HashMap<String, Value>) -> bool {
11
- m.is_empty()
12
- }
13
-
14
- #[derive(Debug, Clone, Serialize, Deserialize)]
15
- pub struct TaskData {
16
- pub id: String,
17
- pub title: String,
18
- #[serde(default, skip_serializing_if = "Option::is_none")]
19
- pub notes: Option<String>,
20
- #[serde(default, skip_serializing_if = "Option::is_none")]
21
- pub priority: Option<String>,
22
- #[serde(default, skip_serializing_if = "Option::is_none")]
23
- pub created_at: Option<String>,
24
- #[serde(default, skip_serializing_if = "Option::is_none")]
25
- pub updated_at: Option<String>,
26
- #[serde(default, skip_serializing_if = "Option::is_none")]
27
- pub completed_at: Option<String>,
28
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
29
- pub labels: Vec<String>,
30
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
31
- pub links: Vec<String>,
32
- /// Typed document/task/URL links (wiki/130-document-management.md §9.1).
33
- /// Additive alongside the legacy `links: Vec<String>` field, which is kept
34
- /// as-is for backward compatibility. Use the `links()` accessor to read a
35
- /// normalized, deduplicated view of both fields combined.
36
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
37
- pub task_links: Vec<TaskLink>,
38
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
39
- pub done_criteria: Vec<DoneCriterion>,
40
- #[serde(default, skip_serializing_if = "Option::is_none")]
41
- pub schedule: Option<Schedule>,
42
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
43
- pub dependencies: Vec<String>,
44
- #[serde(default, skip_serializing_if = "Option::is_none")]
45
- pub order: Option<u32>,
46
- #[serde(default, skip_serializing_if = "Option::is_none")]
47
- pub assignee: Option<String>,
48
- #[serde(flatten, default, skip_serializing_if = "is_empty_map")]
49
- pub extra: HashMap<String, Value>,
50
- }
51
-
52
- #[derive(Debug, Clone, Default, Serialize, Deserialize)]
53
- pub struct Schedule {
54
- #[serde(default, skip_serializing_if = "Option::is_none")]
55
- pub start_date: Option<String>,
56
- #[serde(default, skip_serializing_if = "Option::is_none")]
57
- pub due_date: Option<String>,
58
- #[serde(default, skip_serializing_if = "Option::is_none")]
59
- pub estimate_hours: Option<f64>,
60
- #[serde(default, skip_serializing_if = "Option::is_none")]
61
- pub actual_hours: Option<f64>,
62
- #[serde(default, skip_serializing_if = "Option::is_none")]
63
- pub remaining_hours: Option<f64>,
64
- #[serde(default, skip_serializing_if = "Option::is_none")]
65
- pub milestone: Option<String>,
66
- #[serde(default, skip_serializing_if = "Option::is_none")]
67
- pub pinned: Option<bool>,
68
- }
69
-
70
- #[derive(Debug, Clone, Serialize, Deserialize)]
71
- pub struct DoneCriterion {
72
- pub item: String,
73
- #[serde(default)]
74
- pub checked: bool,
75
- }
76
-
77
- /// A typed link from a task to a document, URL, file, or another task
78
- /// (wiki/130-document-management.md §9.1). `link_type` distinguishes the
79
- /// target kind: `"doc"` (document management fragment), `"url"`, `"file"`,
80
- /// or `"task"`.
81
- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82
- pub struct TaskLink {
83
- pub target: String,
84
- pub link_type: String,
85
- #[serde(default, skip_serializing_if = "Option::is_none")]
86
- pub label: Option<String>,
87
- }
88
-
89
- impl TaskData {
90
- /// Normalized, deduplicated view of every link on this task: the legacy
91
- /// `links: Vec<String>` field (each entry becomes `TaskLink { link_type:
92
- /// "file", label: None }`) merged with `task_links`. Dedupes by
93
- /// `(target, link_type)`, keeping the first occurrence — `task_links`
94
- /// entries are checked first so a richer (labeled) `task_links` entry
95
- /// wins over an equivalent bare legacy `links` entry.
96
- pub fn links(&self) -> Vec<TaskLink> {
97
- let mut seen: HashSet<(String, String)> = HashSet::new();
98
- let mut result = Vec::with_capacity(self.task_links.len() + self.links.len());
99
-
100
- for link in &self.task_links {
101
- let key = (link.target.clone(), link.link_type.clone());
102
- if seen.insert(key) {
103
- result.push(link.clone());
104
- }
105
- }
106
- for target in &self.links {
107
- let key = (target.clone(), "file".to_string());
108
- if seen.insert(key) {
109
- result.push(TaskLink {
110
- target: target.clone(),
111
- link_type: "file".to_string(),
112
- label: None,
113
- });
114
- }
115
- }
116
-
117
- result
118
- }
119
- }
120
-
121
- #[derive(Debug, Clone, Serialize, Deserialize)]
122
- pub struct TaskIndex {
123
- pub id: String,
124
- pub title: String,
125
- pub status: String,
126
- #[serde(default, skip_serializing_if = "Option::is_none")]
127
- pub schedule: Option<Schedule>,
128
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
129
- pub dependencies: Vec<String>,
130
- #[serde(default, skip_serializing_if = "Option::is_none")]
131
- pub order: Option<u32>,
132
- #[serde(default, skip_serializing_if = "Option::is_none")]
133
- pub assignee: Option<String>,
134
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
135
- pub children: Vec<TaskIndex>,
136
- }
137
-
138
- #[derive(Debug, Clone, Serialize, Deserialize)]
139
- pub struct TaskSummary {
140
- pub total: u32,
141
- pub by_status: std::collections::HashMap<String, u32>,
142
- #[serde(default, skip_serializing_if = "Option::is_none")]
143
- pub total_estimate_hours: Option<f64>,
144
- #[serde(default, skip_serializing_if = "Option::is_none")]
145
- pub total_actual_hours: Option<f64>,
146
- #[serde(default, skip_serializing_if = "Option::is_none")]
147
- pub completion_rate: Option<f64>,
148
- #[serde(default)]
149
- pub overdue_count: u32,
150
- }
151
-
152
- const VALID_STATUSES: &[&str] = &[
153
- "todo",
154
- "in_progress",
155
- "review",
156
- "done",
157
- "blocked",
158
- "skipped",
159
- ];
160
-
161
- const VALID_PRIORITIES: &[&str] = &["low", "medium", "high"];
162
-
163
- pub fn is_valid_status(status: &str) -> bool {
164
- VALID_STATUSES.contains(&status)
165
- }
166
-
167
- pub fn is_valid_priority(priority: &str) -> bool {
168
- VALID_PRIORITIES.contains(&priority)
169
- }
170
-
171
- pub fn validate_priority(priority: Option<&str>) -> Result<()> {
172
- if let Some(p) = priority {
173
- if !is_valid_priority(p) {
174
- anyhow::bail!(
175
- "Invalid priority: '{p}'. Must be one of: {}",
176
- VALID_PRIORITIES.join(", ")
177
- );
178
- }
179
- }
180
- Ok(())
181
- }
182
-
183
- pub fn is_terminal_status(status: &str) -> bool {
184
- status == "done" || status == "skipped"
185
- }
186
-
187
- pub fn title_to_slug(title: &str) -> String {
188
- let slug: String = title
189
- .chars()
190
- .map(|c| {
191
- if c.is_ascii_alphanumeric() {
192
- c.to_ascii_lowercase()
193
- } else if !c.is_ascii() {
194
- c
195
- } else {
196
- '-'
197
- }
198
- })
199
- .collect();
200
- let slug = slug.trim_matches('-').to_string();
201
- let mut result = String::new();
202
- let mut prev_dash = false;
203
- for c in slug.chars() {
204
- if c == '-' {
205
- if !prev_dash {
206
- result.push(c);
207
- }
208
- prev_dash = true;
209
- } else {
210
- result.push(c);
211
- prev_dash = false;
212
- }
213
- }
214
- if result.is_empty() {
215
- "task".to_string()
216
- } else {
217
- result
218
- }
219
- }
220
-
221
- pub fn find_task_file(task_dir: &Path) -> Result<Option<(PathBuf, String)>> {
222
- for entry in std::fs::read_dir(task_dir)
223
- .with_context(|| format!("Failed to read task dir: {}", task_dir.display()))?
224
- {
225
- let entry = entry?;
226
- let name = entry.file_name().to_string_lossy().to_string();
227
- if let Some(status) = parse_task_filename(&name) {
228
- return Ok(Some((entry.path(), status)));
229
- }
230
- }
231
- Ok(None)
232
- }
233
-
234
- fn parse_task_filename(name: &str) -> Option<String> {
235
- let name = name.strip_prefix("_task.")?;
236
- let status = name.strip_suffix(".json")?;
237
- if is_valid_status(status) {
238
- Some(status.to_string())
239
- } else {
240
- None
241
- }
242
- }
243
-
244
- pub fn read_task(task_dir: &Path) -> Result<Option<(TaskData, String)>> {
245
- let (file_path, status) = match find_task_file(task_dir)? {
246
- Some(v) => v,
247
- None => return Ok(None),
248
- };
249
- let content = std::fs::read_to_string(&file_path)
250
- .with_context(|| format!("Failed to read task: {}", file_path.display()))?;
251
- let data: TaskData = serde_json::from_str(&content)
252
- .with_context(|| format!("Failed to parse task: {}", file_path.display()))?;
253
- Ok(Some((data, status)))
254
- }
255
-
256
- pub fn write_task(task_dir: &Path, status: &str, data: &TaskData) -> Result<()> {
257
- let file_path = task_dir.join(format!("_task.{status}.json"));
258
- let content = serde_json::to_string_pretty(data).context("Failed to serialize task")?;
259
- crate::storage::atomic_write(&file_path, content.as_bytes())
260
- .with_context(|| format!("Failed to write task: {}", file_path.display()))?;
261
- Ok(())
262
- }
263
-
264
- /// Read-modify-write a task with optimistic concurrency control.
265
- ///
266
- /// Reads the current task, runs `mutate` on a copy, then re-reads just before
267
- /// writing: if the file's `updated_at` changed since the snapshot (another
268
- /// writer — e.g. the VSCode extension — won the race), the whole cycle retries
269
- /// up to `MAX_RETRIES` times. This matches the VSCode side's `updated_at`
270
- /// protocol (wiki/95-concurrency-safety.md) and prevents lost updates that
271
- /// atomic_write alone cannot (atomic_write stops *torn* reads, not *lost*
272
- /// updates).
273
- ///
274
- /// `mutate` receives the current `TaskData` and the resolved status, and returns
275
- /// the new status the task should have after the change (usually unchanged).
276
- pub fn read_modify_write_task<F>(task_dir: &Path, mut mutate: F) -> Result<()>
277
- where
278
- F: FnMut(&mut TaskData, &str) -> Result<String>,
279
- {
280
- const MAX_RETRIES: usize = 5;
281
-
282
- for attempt in 0..=MAX_RETRIES {
283
- let (mut data, status) = read_task(task_dir)?
284
- .ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
285
- let snapshot_updated_at = data.updated_at.clone();
286
-
287
- let new_status = mutate(&mut data, &status)?;
288
-
289
- // Re-read to detect a concurrent writer before committing.
290
- let current_updated_at = read_task(task_dir)?.and_then(|(d, _)| d.updated_at);
291
- if current_updated_at != snapshot_updated_at {
292
- // Someone else wrote between our read and write. Retry from scratch.
293
- if attempt == MAX_RETRIES {
294
- anyhow::bail!(
295
- "Concurrent modification of task in {} after {} retries; aborting to avoid \
296
- overwriting another writer's changes.",
297
- task_dir.display(),
298
- MAX_RETRIES
299
- );
300
- }
301
- continue;
302
- }
303
-
304
- if new_status != status {
305
- change_status(task_dir, &new_status)?;
306
- }
307
- write_task(task_dir, &new_status, &data)?;
308
- return Ok(());
309
- }
310
- unreachable!("loop returns or bails within MAX_RETRIES iterations")
311
- }
312
-
313
- pub fn change_status(task_dir: &Path, new_status: &str) -> Result<()> {
314
- if !is_valid_status(new_status) {
315
- anyhow::bail!("Invalid status: {new_status}");
316
- }
317
-
318
- let (old_path, old_status) = find_task_file(task_dir)?
319
- .ok_or_else(|| anyhow::anyhow!("No task file found in {}", task_dir.display()))?;
320
-
321
- if old_status == new_status {
322
- return Ok(());
323
- }
324
-
325
- let new_path = task_dir.join(format!("_task.{new_status}.json"));
326
- std::fs::rename(&old_path, &new_path).with_context(|| {
327
- format!(
328
- "Failed to rename {} -> {}",
329
- old_path.display(),
330
- new_path.display()
331
- )
332
- })?;
333
-
334
- Ok(())
335
- }
336
-
337
- pub fn next_child_id(parent_dir: &Path, parent_id: &str) -> Result<String> {
338
- let mut max_n: u32 = 0;
339
-
340
- if parent_dir.exists() {
341
- for entry in std::fs::read_dir(parent_dir)? {
342
- let entry = entry?;
343
- if !entry.file_type()?.is_dir() {
344
- continue;
345
- }
346
- let name = entry.file_name().to_string_lossy().to_string();
347
- if let Some(n) = extract_child_number(&name, parent_id) {
348
- max_n = max_n.max(n);
349
- }
350
- }
351
- }
352
-
353
- Ok(format!("{parent_id}.{}", max_n + 1))
354
- }
355
-
356
- pub fn next_top_level_id(tasks_dir: &Path) -> Result<String> {
357
- let mut max_n: u32 = 0;
358
-
359
- if tasks_dir.exists() {
360
- for entry in std::fs::read_dir(tasks_dir)? {
361
- let entry = entry?;
362
- if !entry.file_type()?.is_dir() {
363
- continue;
364
- }
365
- let name = entry.file_name().to_string_lossy().to_string();
366
- if let Some(n) = extract_top_level_number(&name) {
367
- max_n = max_n.max(n);
368
- }
369
- }
370
- }
371
-
372
- Ok(format!("t{}", max_n + 1))
373
- }
374
-
375
- fn extract_child_number(dir_name: &str, parent_id: &str) -> Option<u32> {
376
- let prefix = format!("{parent_id}.");
377
- let rest = dir_name.strip_prefix(&prefix)?;
378
- let num_part = rest.split('-').next()?;
379
- num_part.parse().ok()
380
- }
381
-
382
- fn extract_top_level_number(dir_name: &str) -> Option<u32> {
383
- let rest = dir_name.strip_prefix('t')?;
384
- let num_part = rest.split('-').next()?;
385
- if num_part.contains('.') {
386
- return None;
387
- }
388
- num_part.parse().ok()
389
- }
390
-
391
- pub fn find_task_dir_by_id(tasks_dir: &Path, task_id: &str) -> Result<Option<PathBuf>> {
392
- find_task_dir_recursive(tasks_dir, task_id)
393
- }
394
-
395
- fn dir_name_could_match(dir_name: &str, task_id: &str) -> bool {
396
- dir_name == task_id
397
- || (dir_name.starts_with(task_id) && dir_name.as_bytes().get(task_id.len()) == Some(&b'-'))
398
- }
399
-
400
- fn find_task_dir_recursive(dir: &Path, task_id: &str) -> Result<Option<PathBuf>> {
401
- if !dir.exists() {
402
- return Ok(None);
403
- }
404
- let mut candidates = Vec::new();
405
- let mut other_subdirs = Vec::new();
406
- for entry in std::fs::read_dir(dir)? {
407
- let entry = entry?;
408
- if !entry.file_type()?.is_dir() {
409
- continue;
410
- }
411
- let name = entry.file_name().to_string_lossy().to_string();
412
- if dir_name_could_match(&name, task_id) {
413
- candidates.push(entry.path());
414
- } else {
415
- other_subdirs.push(entry.path());
416
- }
417
- }
418
- // Verify candidates by reading the JSON id field.
419
- for candidate in &candidates {
420
- if let Some((data, _)) = read_task(candidate)? {
421
- if data.id == task_id {
422
- return Ok(Some(candidate.clone()));
423
- }
424
- }
425
- }
426
- // Recurse into all subdirectories (candidates that didn't match + others).
427
- for subdir in candidates.into_iter().chain(other_subdirs) {
428
- if let Some(found) = find_task_dir_recursive(&subdir, task_id)? {
429
- return Ok(Some(found));
430
- }
431
- }
432
- Ok(None)
433
- }
434
-
435
- fn collect_all_ids_recursive(dir: &Path, ids: &mut Vec<String>) -> Result<()> {
436
- if !dir.exists() {
437
- return Ok(());
438
- }
439
- for entry in std::fs::read_dir(dir)? {
440
- let entry = entry?;
441
- if !entry.file_type()?.is_dir() {
442
- continue;
443
- }
444
- if let Some((data, _)) = read_task(&entry.path())? {
445
- ids.push(data.id);
446
- }
447
- collect_all_ids_recursive(&entry.path(), ids)?;
448
- }
449
- Ok(())
450
- }
451
-
452
- pub fn suggest_task_id(tasks_dir: &Path, requested_id: &str) -> String {
453
- let mut all_ids = Vec::new();
454
- let _ = collect_all_ids_recursive(tasks_dir, &mut all_ids);
455
- if all_ids.is_empty() {
456
- return format!("Task not found: '{requested_id}'. No tasks exist yet.");
457
- }
458
- let mut scored: Vec<(&str, usize)> = all_ids
459
- .iter()
460
- .filter_map(|id| {
461
- let score = fuzzy_score(requested_id, id);
462
- if score > 0 {
463
- Some((id.as_str(), score))
464
- } else {
465
- None
466
- }
467
- })
468
- .collect();
469
- scored.sort_by_key(|&(_, s)| Reverse(s));
470
- scored.truncate(5);
471
-
472
- if scored.is_empty() {
473
- return format!(
474
- "Task not found: '{requested_id}'. Use handoff_list_tasks to see available task IDs."
475
- );
476
- }
477
- let suggestions: Vec<String> = scored.iter().map(|(id, _)| format!(" - {id}")).collect();
478
- format!(
479
- "Task not found: '{requested_id}'. Did you mean one of these?\n{}\n\
480
- Use handoff_list_tasks to see all task IDs.",
481
- suggestions.join("\n")
482
- )
483
- }
484
-
485
- fn fuzzy_score(query: &str, candidate: &str) -> usize {
486
- let q = query.to_lowercase();
487
- let c = candidate.to_lowercase();
488
- if c == q {
489
- return 100;
490
- }
491
- if c.starts_with(&q) || q.starts_with(&c) {
492
- return 80;
493
- }
494
- if c.contains(&q) || q.contains(&c) {
495
- return 60;
496
- }
497
- let q_parts: Vec<&str> = q.split(['-', '.', '_']).collect();
498
- let c_parts: Vec<&str> = c.split(['-', '.', '_']).collect();
499
- let matching = q_parts.iter().filter(|p| c_parts.contains(p)).count();
500
- if matching > 0 {
501
- return 20 * matching;
502
- }
503
- 0
504
- }
505
-
506
- pub fn build_task_index(
507
- tasks_dir: &Path,
508
- done_task_limit: u32,
509
- ) -> Result<(Vec<TaskIndex>, TaskSummary)> {
510
- let mut tree = Vec::new();
511
- let mut summary = TaskSummary {
512
- total: 0,
513
- by_status: HashMap::new(),
514
- total_estimate_hours: None,
515
- total_actual_hours: None,
516
- completion_rate: None,
517
- overdue_count: 0,
518
- };
519
- let mut done_count: u32 = 0;
520
- let mut estimate_sum: f64 = 0.0;
521
- let mut actual_sum: f64 = 0.0;
522
- let mut has_hours = false;
523
- let today = Utc::now().format("%Y-%m-%d").to_string();
524
-
525
- build_index_recursive(
526
- tasks_dir,
527
- &mut tree,
528
- &mut summary,
529
- &mut done_count,
530
- done_task_limit,
531
- &mut estimate_sum,
532
- &mut actual_sum,
533
- &mut has_hours,
534
- &today,
535
- )?;
536
-
537
- if has_hours {
538
- summary.total_estimate_hours = Some(estimate_sum);
539
- summary.total_actual_hours = Some(actual_sum);
540
- }
541
-
542
- if summary.total > 0 {
543
- let done = *summary.by_status.get("done").unwrap_or(&0) as f64;
544
- let skipped = *summary.by_status.get("skipped").unwrap_or(&0) as f64;
545
- summary.completion_rate = Some((done + skipped) / summary.total as f64);
546
- }
547
-
548
- Ok((tree, summary))
549
- }
550
-
551
- #[allow(clippy::too_many_arguments)]
552
- fn build_index_recursive(
553
- dir: &Path,
554
- tree: &mut Vec<TaskIndex>,
555
- summary: &mut TaskSummary,
556
- done_count: &mut u32,
557
- done_task_limit: u32,
558
- estimate_sum: &mut f64,
559
- actual_sum: &mut f64,
560
- has_hours: &mut bool,
561
- today: &str,
562
- ) -> Result<()> {
563
- if !dir.exists() {
564
- return Ok(());
565
- }
566
-
567
- let mut entries: Vec<_> = std::fs::read_dir(dir)?
568
- .filter_map(|e| e.ok())
569
- .filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
570
- .collect();
571
- entries.sort_by_key(|e| e.file_name());
572
-
573
- for entry in entries {
574
- let name = entry.file_name().to_string_lossy().to_string();
575
- if name.starts_with('.') {
576
- continue;
577
- }
578
-
579
- let task_dir = entry.path();
580
- let (data, status) = match read_task(&task_dir)? {
581
- Some(v) => v,
582
- None => continue,
583
- };
584
-
585
- summary.total += 1;
586
- *summary.by_status.entry(status.clone()).or_insert(0) += 1;
587
-
588
- if let Some(ref sched) = data.schedule {
589
- if let Some(est) = sched.estimate_hours {
590
- *estimate_sum += est;
591
- *has_hours = true;
592
- }
593
- if let Some(act) = sched.actual_hours {
594
- *actual_sum += act;
595
- *has_hours = true;
596
- }
597
- if let Some(ref due) = sched.due_date {
598
- if !is_terminal_status(&status) && due.as_str() < today {
599
- summary.overdue_count += 1;
600
- }
601
- }
602
- }
603
-
604
- if is_terminal_status(&status) {
605
- *done_count += 1;
606
- if *done_count > done_task_limit {
607
- continue;
608
- }
609
- }
610
-
611
- let mut children = Vec::new();
612
- build_index_recursive(
613
- &task_dir,
614
- &mut children,
615
- summary,
616
- done_count,
617
- done_task_limit,
618
- estimate_sum,
619
- actual_sum,
620
- has_hours,
621
- today,
622
- )?;
623
-
624
- tree.push(TaskIndex {
625
- id: data.id,
626
- title: data.title,
627
- status,
628
- schedule: data.schedule,
629
- dependencies: data.dependencies,
630
- order: data.order,
631
- assignee: data.assignee,
632
- children,
633
- });
634
- }
635
-
636
- Ok(())
637
- }
638
-
639
- pub fn validate_dependencies(tasks_dir: &Path, task_id: &str, new_deps: &[String]) -> Result<()> {
640
- let dep_graph = build_dependency_graph(tasks_dir)?;
641
-
642
- let mut graph = dep_graph;
643
- graph.insert(task_id.to_string(), new_deps.to_vec());
644
-
645
- let mut visited = HashSet::new();
646
- let mut stack = HashSet::new();
647
-
648
- if has_cycle(&graph, task_id, &mut visited, &mut stack) {
649
- anyhow::bail!(
650
- "Circular dependency detected: setting dependencies {:?} on task {task_id} would create a cycle",
651
- new_deps
652
- );
653
- }
654
-
655
- Ok(())
656
- }
657
-
658
- /// Validate dependencies for a whole batch of tasks that do not exist yet.
659
- ///
660
- /// `validate_dependencies` adds one node to the on-disk graph, which is all an
661
- /// update needs. An import creates a tree in one call, so its tasks may depend on
662
- /// each other: checking them one at a time would reject a legitimate dependency
663
- /// on a sibling (not yet written) and miss a cycle that lives entirely inside the
664
- /// batch (neither end written). Merge every pending node in first, then search.
665
- ///
666
- /// A dependency naming a task that exists nowhere is left alone — it contributes
667
- /// no edge, and `validate_dependencies` tolerates it too.
668
- pub fn validate_dependencies_batch(
669
- tasks_dir: &Path,
670
- pending: &[(String, Vec<String>)],
671
- ) -> Result<()> {
672
- let mut graph = build_dependency_graph(tasks_dir)?;
673
- for (id, deps) in pending {
674
- graph.insert(id.clone(), deps.clone());
675
- }
676
-
677
- for (id, deps) in pending {
678
- let mut visited = HashSet::new();
679
- let mut stack = HashSet::new();
680
- if has_cycle(&graph, id, &mut visited, &mut stack) {
681
- anyhow::bail!(
682
- "Circular dependency detected: setting dependencies {deps:?} on task {id} would create a cycle"
683
- );
684
- }
685
- }
686
-
687
- Ok(())
688
- }
689
-
690
- fn build_dependency_graph(tasks_dir: &Path) -> Result<HashMap<String, Vec<String>>> {
691
- let mut graph = HashMap::new();
692
- build_dep_graph_recursive(tasks_dir, &mut graph)?;
693
- Ok(graph)
694
- }
695
-
696
- fn build_dep_graph_recursive(dir: &Path, graph: &mut HashMap<String, Vec<String>>) -> Result<()> {
697
- if !dir.exists() {
698
- return Ok(());
699
- }
700
- for entry in std::fs::read_dir(dir)? {
701
- let entry = entry?;
702
- if !entry.file_type()?.is_dir() {
703
- continue;
704
- }
705
- let name = entry.file_name().to_string_lossy().to_string();
706
- if name.starts_with('.') || name.starts_with('_') {
707
- continue;
708
- }
709
- let task_dir = entry.path();
710
- if let Some((data, _)) = read_task(&task_dir)? {
711
- graph.insert(data.id.clone(), data.dependencies.clone());
712
- build_dep_graph_recursive(&task_dir, graph)?;
713
- }
714
- }
715
- Ok(())
716
- }
717
-
718
- fn has_cycle(
719
- graph: &HashMap<String, Vec<String>>,
720
- node: &str,
721
- visited: &mut HashSet<String>,
722
- stack: &mut HashSet<String>,
723
- ) -> bool {
724
- if stack.contains(node) {
725
- return true;
726
- }
727
- if visited.contains(node) {
728
- return false;
729
- }
730
- visited.insert(node.to_string());
731
- stack.insert(node.to_string());
732
-
733
- if let Some(deps) = graph.get(node) {
734
- for dep in deps {
735
- if has_cycle(graph, dep, visited, stack) {
736
- return true;
737
- }
738
- }
739
- }
740
-
741
- stack.remove(node);
742
- false
743
- }
744
-
745
- pub fn validate_done_transition(task_dir: &Path, data: &TaskData) -> Result<()> {
746
- for criterion in &data.done_criteria {
747
- if !criterion.checked {
748
- anyhow::bail!(
749
- "Cannot mark task {} as done: done_criteria item '{}' is not checked",
750
- data.id,
751
- criterion.item
752
- );
753
- }
754
- }
755
-
756
- check_children_terminal(task_dir, &data.id)?;
757
-
758
- Ok(())
759
- }
760
-
761
- pub fn validate_skipped_transition(task_dir: &Path, data: &TaskData) -> Result<()> {
762
- check_children_terminal(task_dir, &data.id)?;
763
- Ok(())
764
- }
765
-
766
- /// Returns true if the task directory contains at least one child task
767
- /// (a non-`_`/`.`-prefixed subdirectory holding a task file).
768
- pub fn task_has_children(task_dir: &Path) -> Result<bool> {
769
- if !task_dir.exists() {
770
- return Ok(false);
771
- }
772
- for entry in std::fs::read_dir(task_dir)? {
773
- let entry = entry?;
774
- if !entry.file_type()?.is_dir() {
775
- continue;
776
- }
777
- let name = entry.file_name().to_string_lossy().to_string();
778
- if name.starts_with('_') || name.starts_with('.') {
779
- continue;
780
- }
781
- if find_task_file(&entry.path())?.is_some() {
782
- return Ok(true);
783
- }
784
- }
785
- Ok(false)
786
- }
787
-
788
- /// Whether a task in the given status requires an effort estimate.
789
- /// Parent tasks (with children) and blocked/skipped/todo tasks are exempt;
790
- /// this only covers the status dimension. `todo` is exempt so that parent
791
- /// tasks can be created before their children exist (the children turn the
792
- /// parent from a leaf into a parent, removing the estimate requirement).
793
- /// The estimate is enforced when the task moves to `in_progress` or later.
794
- pub fn status_requires_estimate(status: &str) -> bool {
795
- matches!(status, "in_progress" | "review" | "done")
796
- }
797
-
798
- /// Validate that a leaf task carries an `estimate_hours` when the project
799
- /// requires it. `has_children` lets the caller skip parent tasks.
800
- ///
801
- /// The rejection names the offending task and shows a ready-to-send payload,
802
- /// so a caller that forgot the estimate can fix it in one retry instead of
803
- /// discovering the required shape by trial and error. `is_create` tailors that
804
- /// payload: creating a task also needs `title`, whereas an update must not
805
- /// imply the stored title should be overwritten.
806
- pub fn validate_estimate_required(
807
- require_estimate_hours: bool,
808
- id: &str,
809
- title: &str,
810
- status: &str,
811
- has_children: bool,
812
- is_create: bool,
813
- schedule: Option<&Schedule>,
814
- ) -> Result<()> {
815
- if !require_estimate_hours || has_children || !status_requires_estimate(status) {
816
- return Ok(());
817
- }
818
- let has_estimate = schedule
819
- .and_then(|s| s.estimate_hours)
820
- .is_some_and(|h| h > 0.0);
821
- if !has_estimate {
822
- // Mirror the rejected call, so the example can be resent as-is. Build it
823
- // with serde_json rather than string formatting: a title carrying a quote,
824
- // a backslash, or a control character must still serialize to valid JSON.
825
- // A create also needs `title`; an update must not imply overwriting it.
826
- let mut example = serde_json::Map::new();
827
- example.insert("id".into(), Value::String(id.to_string()));
828
- if is_create {
829
- example.insert("title".into(), Value::String(title.to_string()));
830
- }
831
- example.insert(
832
- "schedule".into(),
833
- serde_json::json!({ "estimate_hours": 2.0 }),
834
- );
835
- let example = Value::Object(example);
836
- anyhow::bail!(
837
- "Task '{id}' ({title}) requires an effort estimate: \
838
- schedule.estimate_hours (hours, > 0) is mandatory for a leaf task \
839
- in status '{status}'.\n\
840
- Estimate the raw human-effort hours — do not pre-multiply by the \
841
- AI-effort multiplier; that is applied at aggregation time.\n\
842
- Resend with, for example:\n \
843
- {example}\n\
844
- Exempt from this rule: parent tasks (any task with children), and \
845
- tasks in status 'todo', 'blocked', or 'skipped'.\n\
846
- To disable this requirement project-wide, set \
847
- settings.require_estimate_hours = false."
848
- );
849
- }
850
- Ok(())
851
- }
852
-
853
- fn check_children_terminal(task_dir: &Path, parent_id: &str) -> Result<()> {
854
- if !task_dir.exists() {
855
- return Ok(());
856
- }
857
- for entry in std::fs::read_dir(task_dir)? {
858
- let entry = entry?;
859
- if !entry.file_type()?.is_dir() {
860
- continue;
861
- }
862
- let name = entry.file_name().to_string_lossy().to_string();
863
- if name.starts_with('_') || name.starts_with('.') {
864
- continue;
865
- }
866
- if let Some((_, status)) = find_task_file(&entry.path())? {
867
- if !is_terminal_status(&status) {
868
- anyhow::bail!(
869
- "Cannot mark task {parent_id} as done/skipped: child task in directory '{}' has status '{status}'",
870
- name
871
- );
872
- }
873
- }
874
- }
875
- Ok(())
876
- }
877
-
878
- /// Result of [`sync_doc_task_links`]: reports which of the requested task ids
879
- /// could not be resolved to an existing task, so the caller (e.g. `doc_save`)
880
- /// can surface a warning to the user instead of the sync silently doing
881
- /// nothing for a mistyped id.
882
- #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
883
- pub struct SyncReport {
884
- /// Task ids passed in `link_task_ids` or `unlink_task_ids` that did not
885
- /// resolve to an existing task directory. Order-preserved, may contain
886
- /// ids from either list combined.
887
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
888
- pub unresolved: Vec<String>,
889
- }
890
-
891
- /// Synchronize the task side of a task<->document bidirectional link
892
- /// (wiki/130-document-management.md §9.2). Intended to be called by the
893
- /// `handoff_doc_save` handler (t96) after it has written its own doc-side
894
- /// `task_ids` field, so the two stay in agreement.
895
- ///
896
- /// For every task id in `link_task_ids`, push-if-absent a
897
- /// `TaskLink { target: doc_id, link_type: "doc", label: Some(doc_title) }`
898
- /// into that task's `task_links` (deduped by `(target, link_type)`).
899
- /// For every task id in `unlink_task_ids`, remove any `task_links` entry
900
- /// with `target == doc_id && link_type == "doc"`.
901
- ///
902
- /// A task id that does not resolve to an existing task directory is not
903
- /// treated as a hard error: the caller (doc_save) may be syncing a batch of
904
- /// task_ids where one was mistyped, and a document write should not be
905
- /// rolled back over an unrelated task lookup failure. Instead it is
906
- /// collected into the returned [`SyncReport::unresolved`] list so the caller
907
- /// can report it back to the user (partial success + report, not a silent
908
- /// skip).
909
- pub fn sync_doc_task_links(
910
- tasks_dir: &Path,
911
- doc_id: &str,
912
- doc_title: &str,
913
- link_task_ids: &[String],
914
- unlink_task_ids: &[String],
915
- ) -> Result<SyncReport> {
916
- let mut unresolved = Vec::new();
917
-
918
- for task_id in link_task_ids {
919
- let Some(task_dir) = find_task_dir_by_id(tasks_dir, task_id)? else {
920
- unresolved.push(task_id.clone());
921
- continue;
922
- };
923
- read_modify_write_task(&task_dir, |data, status| {
924
- let already_linked = data
925
- .task_links
926
- .iter()
927
- .any(|l| l.target == doc_id && l.link_type == "doc");
928
- if !already_linked {
929
- data.task_links.push(TaskLink {
930
- target: doc_id.to_string(),
931
- link_type: "doc".to_string(),
932
- label: Some(doc_title.to_string()),
933
- });
934
- data.updated_at = Some(Utc::now().to_rfc3339());
935
- }
936
- Ok(status.to_string())
937
- })?;
938
- }
939
-
940
- for task_id in unlink_task_ids {
941
- let Some(task_dir) = find_task_dir_by_id(tasks_dir, task_id)? else {
942
- unresolved.push(task_id.clone());
943
- continue;
944
- };
945
- read_modify_write_task(&task_dir, |data, status| {
946
- let before = data.task_links.len();
947
- data.task_links
948
- .retain(|l| !(l.target == doc_id && l.link_type == "doc"));
949
- if data.task_links.len() != before {
950
- data.updated_at = Some(Utc::now().to_rfc3339());
951
- }
952
- Ok(status.to_string())
953
- })?;
954
- }
955
-
956
- Ok(SyncReport { unresolved })
957
- }