handoff-mcp-server 0.19.1 → 0.22.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.
@@ -22,6 +22,14 @@ pub fn handle(arguments: &Value) -> Result<String> {
22
22
  let (data, status) = read_task(&task_dir)?
23
23
  .ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
24
24
 
25
+ // `links` stays the legacy `Vec<String>` for backward compatibility with
26
+ // existing clients (skills / VSCode extension). `task_links` is an
27
+ // additive field carrying the normalized, deduplicated view from the
28
+ // `links()` accessor (wiki/130-document-management.md §9.1), so callers
29
+ // that understand typed links (doc/url/file/task) can read them without
30
+ // re-deriving the merge themselves.
31
+ let normalized_links = data.links();
32
+
25
33
  let result = serde_json::json!({
26
34
  "id": data.id,
27
35
  "title": data.title,
@@ -33,6 +41,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
33
41
  "completed_at": data.completed_at,
34
42
  "labels": data.labels,
35
43
  "links": data.links,
44
+ "task_links": normalized_links,
36
45
  "done_criteria": data.done_criteria,
37
46
  "schedule": data.schedule,
38
47
  "dependencies": data.dependencies,
@@ -30,16 +30,46 @@ pub fn handle(arguments: &Value) -> Result<String> {
30
30
  .and_then(|v| v.as_str())
31
31
  .unwrap_or("other");
32
32
 
33
+ let require_estimate_hours = read_config(&config_path)
34
+ .map(|c| c.settings.require_estimate_hours)
35
+ .unwrap_or(true);
36
+
33
37
  let mut tasks_created: u32 = 0;
34
38
  let mut top_level_count: u32 = 0;
35
39
  let mut nested_count: u32 = 0;
36
40
 
37
41
  if let Some(tasks) = arguments.get("tasks").and_then(|v| v.as_array()) {
42
+ // Validate the whole payload before creating anything. Import writes many
43
+ // tasks in one call, so a rejection discovered mid-tree would otherwise
44
+ // leave a half-written forest behind and burn task IDs —
45
+ // `next_top_level_id` counts directories, not task files.
46
+ //
47
+ // Nothing is created yet, so `next_top_level_id` keeps returning the same
48
+ // value; project the IDs the writing pass below will assign, purely so the
49
+ // rejection names the task the caller sent.
50
+ let first_id = next_top_level_id(&tasks_dir)?;
51
+ let first_n: u32 = first_id
52
+ .strip_prefix('t')
53
+ .and_then(|n| n.parse().ok())
54
+ .with_context(|| format!("Unexpected task id form: {first_id}"))?;
55
+ let mut pending_deps: Vec<(String, Vec<String>)> = Vec::new();
56
+ for (i, task_val) in tasks.iter().enumerate() {
57
+ let projected_id = format!("t{}", first_n + i as u32);
58
+ validate_task_recursive(
59
+ &projected_id,
60
+ task_val,
61
+ require_estimate_hours,
62
+ &mut pending_deps,
63
+ )?;
64
+ }
65
+
66
+ // Check the dependency graph once, with every task in this payload already
67
+ // in it. Task-at-a-time checking would both reject a valid dependency on a
68
+ // sibling still unwritten and miss a cycle confined to the payload.
69
+ validate_dependencies_batch(&tasks_dir, &pending_deps)?;
70
+
38
71
  for task_val in tasks {
39
- let title = task_val
40
- .get("title")
41
- .and_then(|v| v.as_str())
42
- .ok_or_else(|| anyhow::anyhow!("Each task requires a 'title'"))?;
72
+ let title = task_title(task_val)?;
43
73
 
44
74
  let task_id = next_top_level_id(&tasks_dir)?;
45
75
  let count = create_task_recursive(&tasks_dir, &task_id, None, title, task_val)?;
@@ -204,6 +234,84 @@ pub fn handle(arguments: &Value) -> Result<String> {
204
234
  Ok(msg)
205
235
  }
206
236
 
237
+ // The validating pass and the writing pass must read the payload identically, or
238
+ // a task could pass validation and then be written as something else. These three
239
+ // accessors are the single source of truth for the fields the estimate rule keys
240
+ // on; both passes go through them rather than reaching into `task_val` directly.
241
+
242
+ fn task_title(task_val: &Value) -> Result<&str> {
243
+ task_val
244
+ .get("title")
245
+ .and_then(|v| v.as_str())
246
+ .ok_or_else(|| anyhow::anyhow!("Each task requires a 'title'"))
247
+ }
248
+
249
+ fn task_status(task_val: &Value) -> &str {
250
+ task_val
251
+ .get("status")
252
+ .and_then(|v| v.as_str())
253
+ .unwrap_or("todo")
254
+ }
255
+
256
+ fn task_children(task_val: &Value) -> Option<&Vec<Value>> {
257
+ task_val.get("children").and_then(|v| v.as_array())
258
+ }
259
+
260
+ /// Check one task subtree against the rules `create_task_recursive` would
261
+ /// otherwise only discover after writing earlier siblings to disk.
262
+ ///
263
+ /// `has_children` is read from the payload, not the filesystem: at this point the
264
+ /// child directories do not exist, so a filesystem probe would call every parent
265
+ /// a leaf and demand an estimate it is exempt from.
266
+ fn validate_task_recursive(
267
+ task_id: &str,
268
+ task_val: &Value,
269
+ require_estimate_hours: bool,
270
+ pending_deps: &mut Vec<(String, Vec<String>)>,
271
+ ) -> Result<()> {
272
+ let title = task_title(task_val)?;
273
+
274
+ let status = task_status(task_val);
275
+ if !is_valid_status(status) {
276
+ anyhow::bail!("Invalid status: {status}");
277
+ }
278
+
279
+ validate_priority(task_val.get("priority").and_then(|v| v.as_str()))?;
280
+
281
+ let children = task_children(task_val);
282
+ // An empty `children` array creates no child tasks, so such a task is a leaf
283
+ // and owes an estimate — matching what the writing pass will produce.
284
+ let has_children = children.is_some_and(|c| !c.is_empty());
285
+
286
+ // import only ever creates, so `is_create` is true and the resend example
287
+ // carries `title` — the caller has no stored task to preserve it.
288
+ validate_estimate_required(
289
+ require_estimate_hours,
290
+ task_id,
291
+ title,
292
+ status,
293
+ has_children,
294
+ true,
295
+ extract_schedule(task_val).as_ref(),
296
+ )?;
297
+
298
+ // Record this task's edges under the ID the writing pass will give it, so the
299
+ // batch cycle check below sees the graph as it will exist after the import.
300
+ pending_deps.push((
301
+ task_id.to_string(),
302
+ extract_string_array_from(task_val, "dependencies"),
303
+ ));
304
+
305
+ if let Some(children) = children {
306
+ for (i, child_val) in children.iter().enumerate() {
307
+ let child_id = format!("{task_id}.{}", i + 1);
308
+ validate_task_recursive(&child_id, child_val, require_estimate_hours, pending_deps)?;
309
+ }
310
+ }
311
+
312
+ Ok(())
313
+ }
314
+
207
315
  fn create_task_recursive(
208
316
  tasks_dir: &std::path::Path,
209
317
  task_id: &str,
@@ -220,11 +328,12 @@ fn create_task_recursive(
220
328
  .with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
221
329
 
222
330
  let now = Utc::now().to_rfc3339();
223
- let status = task_val
224
- .get("status")
225
- .and_then(|v| v.as_str())
226
- .unwrap_or("todo");
331
+ let status = task_status(task_val);
227
332
 
333
+ // `validate_task_recursive` has already passed on this payload, so these two
334
+ // cannot fail here. They stay as defense-in-depth: this function writes to
335
+ // disk, and a future caller that forgets the pre-pass must not slip an
336
+ // invalid status or priority through unchecked.
228
337
  if !is_valid_status(status) {
229
338
  anyhow::bail!("Invalid status: {status}");
230
339
  }
@@ -251,6 +360,7 @@ fn create_task_recursive(
251
360
  completed_at,
252
361
  labels: extract_string_array_from(task_val, "labels"),
253
362
  links: extract_string_array_from(task_val, "links"),
363
+ task_links: Vec::new(),
254
364
  done_criteria: extract_done_criteria(task_val),
255
365
  schedule: extract_schedule(task_val),
256
366
  dependencies: extract_string_array_from(task_val, "dependencies"),
@@ -269,12 +379,9 @@ fn create_task_recursive(
269
379
 
270
380
  let mut count: u32 = 1;
271
381
 
272
- if let Some(children) = task_val.get("children").and_then(|v| v.as_array()) {
382
+ if let Some(children) = task_children(task_val) {
273
383
  for (i, child_val) in children.iter().enumerate() {
274
- let child_title = child_val
275
- .get("title")
276
- .and_then(|v| v.as_str())
277
- .ok_or_else(|| anyhow::anyhow!("Each child task requires a 'title'"))?;
384
+ let child_title = task_title(child_val)?;
278
385
 
279
386
  let child_id = format!("{task_id}.{}", i + 1);
280
387
  count += create_task_recursive(
@@ -11,6 +11,7 @@ use serde_json::{json, Value};
11
11
  use std::path::Path;
12
12
 
13
13
  use super::resolve_project_dir;
14
+ use crate::context::injection::{filter_already_injected, rank_by_bm25_and_scope, RankConfig};
14
15
  use crate::storage::config::{read_config, SettingsConfig};
15
16
  use crate::storage::ensure_handoff_exists;
16
17
  use crate::storage::memory::{
@@ -277,49 +278,47 @@ pub fn handle_query(arguments: &Value) -> Result<String> {
277
278
  for p in &file_paths {
278
279
  query_tokens.extend(lexsim::tokenize(&basename(p)));
279
280
  }
280
- let scores = corpus.bm25_scores_tokens(&query_tokens);
281
281
 
282
- // Score + scope-path bonus, then threshold and rank.
283
- let mut ranked: Vec<(usize, f64)> = memories
284
- .iter()
285
- .enumerate()
286
- .map(|(i, m)| {
287
- let mut s = scores[i];
288
- if scope_matches(&m.scope_paths, &file_paths) {
289
- s += SCOPE_PATH_BONUS;
290
- }
291
- (i, s)
292
- })
293
- .filter(|(_, s)| *s >= settings.memory_query_min_score)
294
- .collect();
295
- ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
282
+ // Score + scope-path bonus, then threshold and rank (shared with doc_query).
283
+ let scope_paths: Vec<Vec<String>> = memories.iter().map(|m| m.scope_paths.clone()).collect();
284
+ let rank_config = RankConfig {
285
+ min_score: settings.memory_query_min_score,
286
+ scope_path_bonus: SCOPE_PATH_BONUS,
287
+ // Rank without truncating yet — the session diff below needs the full
288
+ // ranked order so it can backfill past already-injected memories.
289
+ limit: memories.len(),
290
+ };
291
+ let ranked = rank_by_bm25_and_scope(
292
+ &corpus,
293
+ &query_tokens,
294
+ &scope_paths,
295
+ &file_paths,
296
+ &rank_config,
297
+ );
296
298
 
297
299
  // Per-session diff: drop memories already injected this session at the same
298
300
  // hash. The `limit` is applied to the *fresh* set so the caller still gets up
299
301
  // to `limit` new memories even when earlier prompts already consumed some.
300
302
  let now = now_rfc3339();
301
303
  let injected_set = session_id.map(|sid| read_injected_set(&handoff, sid, &now));
302
- let fresh: Vec<(usize, f64)> = ranked
303
- .into_iter()
304
- .filter(|(i, _)| match &injected_set {
305
- Some(set) => {
306
- let m = &memories[*i];
307
- !set.already_injected(&m.id, &m.content_hash)
308
- }
309
- None => true,
310
- })
311
- .take(limit)
312
- .collect();
304
+ let already_injected = |i: usize| match &injected_set {
305
+ Some(set) => {
306
+ let m = &memories[i];
307
+ set.already_injected(&m.id, &m.content_hash)
308
+ }
309
+ None => false,
310
+ };
311
+ let fresh = filter_already_injected(ranked, already_injected, limit);
313
312
 
314
313
  let out: Vec<Value> = fresh
315
314
  .iter()
316
- .map(|(i, s)| {
317
- let m = &memories[*i];
315
+ .map(|item| {
316
+ let m = &memories[item.index];
318
317
  json!({
319
318
  "id": m.id,
320
319
  "text": m.text,
321
320
  "kind": m.kind,
322
- "score": round2(*s),
321
+ "score": round2(item.score),
323
322
  })
324
323
  })
325
324
  .collect();
@@ -352,13 +351,13 @@ fn mark_injected_memories(
352
351
  handoff: &std::path::Path,
353
352
  session_id: &str,
354
353
  memories: &[MemoryEntry],
355
- fresh: &[(usize, f64)],
354
+ fresh: &[crate::context::injection::RankItem],
356
355
  now: &str,
357
356
  ) -> Result<()> {
358
357
  let mut set = read_injected_set(handoff, session_id, now);
359
358
  set.updated_at = now.to_string();
360
- for (i, _) in fresh {
361
- let m = &memories[*i];
359
+ for item in fresh {
360
+ let m = &memories[item.index];
362
361
  set.mark(&m.id, &m.content_hash);
363
362
  }
364
363
  // (1) Persist the suppression record first — this is the correctness-critical
@@ -369,8 +368,8 @@ fn mark_injected_memories(
369
368
  // concurrent edit's other fields. A failure here is non-fatal: the sidecar
370
369
  // already recorded the injection, so the worst case is a slightly stale
371
370
  // hit_count — never a re-spam or a double count.
372
- for (i, _) in fresh {
373
- let m = &memories[*i];
371
+ for item in fresh {
372
+ let m = &memories[item.index];
374
373
  if let Ok(Some(mut entry)) = read_memory_by_id(handoff, &m.id) {
375
374
  entry.hit_count = entry.hit_count.saturating_add(1);
376
375
  entry.last_referenced_at = Some(now.to_string());
@@ -742,16 +741,6 @@ impl UnionFind {
742
741
  }
743
742
  }
744
743
 
745
- /// True if any `scope` prefix matches any `file` path.
746
- fn scope_matches(scopes: &[String], files: &[String]) -> bool {
747
- if scopes.is_empty() || files.is_empty() {
748
- return false;
749
- }
750
- scopes
751
- .iter()
752
- .any(|scope| files.iter().any(|f| f.contains(scope.as_str())))
753
- }
754
-
755
744
  /// Last path component of `p` (handles both `/` and `\` separators).
756
745
  fn basename(p: &str) -> String {
757
746
  p.rsplit(['/', '\\']).next().unwrap_or(p).to_string()
@@ -796,15 +785,6 @@ mod tests {
796
785
  assert_eq!(basename("plain.rs"), "plain.rs");
797
786
  }
798
787
 
799
- #[test]
800
- fn scope_matches_prefix() {
801
- let scopes = vec!["src/storage/".to_string()];
802
- let files = vec!["/repo/src/storage/mod.rs".to_string()];
803
- assert!(scope_matches(&scopes, &files));
804
- let files2 = vec!["/repo/src/mcp/mod.rs".to_string()];
805
- assert!(!scope_matches(&scopes, &files2));
806
- }
807
-
808
788
  #[test]
809
789
  fn string_array_parsing() {
810
790
  let v = json!({ "tags": ["a", "b", 3, "c"] });
@@ -7,6 +7,8 @@ pub mod check_criterion;
7
7
  pub mod config;
8
8
  pub mod config_crud;
9
9
  pub mod dashboard;
10
+ pub mod docs;
11
+ pub mod docs_query;
10
12
  pub mod fork_session;
11
13
  pub mod get_session;
12
14
  pub mod get_task;
@@ -95,6 +97,17 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
95
97
  "handoff_timer_start" => timer::handle_start(arguments),
96
98
  "handoff_timer_stop" => timer::handle_stop(arguments),
97
99
  "handoff_timer_get_time" => timer::handle_get_time(arguments),
100
+ "handoff_doc_save" => docs::handle_doc_save(arguments),
101
+ "handoff_doc_get" => docs::handle_doc_get(arguments),
102
+ "handoff_doc_list" => docs::handle_doc_list(arguments),
103
+ "handoff_doc_delete" => docs::handle_doc_delete(arguments),
104
+ "handoff_doc_reassemble" => docs::handle_doc_reassemble(arguments),
105
+ "handoff_doc_tree" => docs::handle_doc_tree(arguments),
106
+ "handoff_doc_verify" => docs::handle_doc_verify(arguments),
107
+ "handoff_doc_verify_status" => docs::handle_doc_verify_status(arguments),
108
+ "handoff_doc_query" => docs_query::handle_doc_query(arguments),
109
+ "handoff_doc_analyze" => docs_query::handle_doc_analyze(arguments),
110
+ "handoff_doc_import" => docs_query::handle_doc_import(arguments),
98
111
  _ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
99
112
  };
100
113
 
@@ -82,8 +82,6 @@ fn handle_create(
82
82
  let slug = title_to_slug(title);
83
83
  let dir_name = format!("{new_id}-{slug}");
84
84
  let task_dir = parent_dir.join(&dir_name);
85
- std::fs::create_dir_all(&task_dir)
86
- .with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
87
85
 
88
86
  let now = Utc::now().to_rfc3339();
89
87
  let status = task_val
@@ -116,6 +114,7 @@ fn handle_create(
116
114
  completed_at: None,
117
115
  labels: extract_string_array(task_val, "labels"),
118
116
  links: extract_string_array(task_val, "links"),
117
+ task_links: Vec::new(),
119
118
  done_criteria: extract_done_criteria(task_val),
120
119
  schedule: extract_schedule(task_val),
121
120
  dependencies,
@@ -133,11 +132,20 @@ fn handle_create(
133
132
  // A newly created task is always a leaf (no children yet).
134
133
  validate_estimate_required(
135
134
  require_estimate_hours,
135
+ &new_id,
136
+ title,
136
137
  status,
137
138
  false,
139
+ true,
138
140
  data.schedule.as_ref(),
139
141
  )?;
140
142
 
143
+ // Create the directory only once every validation has passed. A rejected
144
+ // create must leave nothing behind: an orphan dir would burn the task ID,
145
+ // because `next_top_level_id` counts directories, not task files.
146
+ std::fs::create_dir_all(&task_dir)
147
+ .with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
148
+
141
149
  write_task(&task_dir, status, &data)?;
142
150
 
143
151
  Ok(format!("Created task {new_id}: {title} [{status}]"))
@@ -169,8 +177,6 @@ fn handle_upsert_create(
169
177
  let slug = title_to_slug(title);
170
178
  let dir_name = format!("{task_id}-{slug}");
171
179
  let task_dir = parent_dir.join(&dir_name);
172
- std::fs::create_dir_all(&task_dir)
173
- .with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
174
180
 
175
181
  let now = Utc::now().to_rfc3339();
176
182
  let status = task_val
@@ -203,6 +209,7 @@ fn handle_upsert_create(
203
209
  completed_at: None,
204
210
  labels: extract_string_array(task_val, "labels"),
205
211
  links: extract_string_array(task_val, "links"),
212
+ task_links: Vec::new(),
206
213
  done_criteria: extract_done_criteria(task_val),
207
214
  schedule: extract_schedule(task_val),
208
215
  dependencies,
@@ -220,11 +227,19 @@ fn handle_upsert_create(
220
227
  // Upsert-create: a brand-new task is a leaf.
221
228
  validate_estimate_required(
222
229
  require_estimate_hours,
230
+ task_id,
231
+ title,
223
232
  status,
224
233
  false,
234
+ true,
225
235
  data.schedule.as_ref(),
226
236
  )?;
227
237
 
238
+ // Create the directory only once every validation has passed, so a rejected
239
+ // upsert-create leaves no orphan dir shadowing the requested ID.
240
+ std::fs::create_dir_all(&task_dir)
241
+ .with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
242
+
228
243
  write_task(&task_dir, status, &data)?;
229
244
 
230
245
  Ok(format!("Created task {task_id}: {title} [{status}]"))
@@ -336,8 +351,11 @@ fn handle_update(
336
351
  let has_children = task_has_children(&task_dir)?;
337
352
  validate_estimate_required(
338
353
  require_estimate_hours,
354
+ task_id,
355
+ &data.title,
339
356
  new_status,
340
357
  has_children,
358
+ false,
341
359
  data.schedule.as_ref(),
342
360
  )?;
343
361