handoff-mcp-server 0.11.0 → 0.13.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.
@@ -5,6 +5,7 @@ use chrono::Utc;
5
5
  use serde_json::{json, Value};
6
6
 
7
7
  use super::resolve_project_dir;
8
+ use crate::storage::config::read_config;
8
9
  use crate::storage::ensure_handoff_exists;
9
10
  use crate::storage::tasks::{build_task_index, is_terminal_status, TaskIndex};
10
11
 
@@ -50,6 +51,13 @@ pub fn handle(arguments: &Value) -> Result<String> {
50
51
 
51
52
  let budget = read_budget(&handoff);
52
53
 
54
+ // AI-effort multiplier: the raw estimate is the human-effort estimate;
55
+ // multiplying by ai_estimate_multiplier yields the expected AI-effort hours.
56
+ // Raw estimates are preserved; only the adjusted view is derived here.
57
+ let multiplier = read_config(&handoff.join("config.toml"))
58
+ .map(|c| c.settings.ai_estimate_multiplier)
59
+ .unwrap_or(0.2);
60
+
53
61
  let milestone_list: Vec<Value> = milestones
54
62
  .into_iter()
55
63
  .map(|(name, (done, ms_total, est, act))| {
@@ -58,6 +66,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
58
66
  "done": done,
59
67
  "total": ms_total,
60
68
  "estimate_hours": est,
69
+ "adjusted_estimate_hours": est * multiplier,
61
70
  "actual_hours": act,
62
71
  })
63
72
  })
@@ -68,6 +77,8 @@ pub fn handle(arguments: &Value) -> Result<String> {
68
77
  "by_status": by_status,
69
78
  "completion_percent": (completion_percent * 10.0).round() / 10.0,
70
79
  "total_estimate_hours": estimate_sum,
80
+ "ai_estimate_multiplier": multiplier,
81
+ "total_adjusted_estimate_hours": estimate_sum * multiplier,
71
82
  "total_actual_hours": actual_sum,
72
83
  "total_remaining_hours": remaining_sum,
73
84
  "overdue_count": overdue_tasks.len(),
@@ -15,6 +15,7 @@ pub mod list_sessions;
15
15
  pub mod list_tasks;
16
16
  pub mod load_context;
17
17
  pub mod log_time;
18
+ pub mod memory;
18
19
  pub mod metrics;
19
20
  pub mod milestones;
20
21
  pub mod refer;
@@ -53,6 +54,7 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
53
54
  "handoff_import_context" => import_context::handle(arguments),
54
55
  "handoff_refer" => refer::handle(arguments),
55
56
  "handoff_list_referrals" => referrals::handle_list(arguments),
57
+ "handoff_get_referral" => referrals::handle_get(arguments),
56
58
  "handoff_update_referral" => referrals::handle_update(arguments),
57
59
  "handoff_update_session" => update_session::handle(arguments),
58
60
  "handoff_log_time" => log_time::handle(arguments),
@@ -73,6 +75,10 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
73
75
  "handoff_update_calendar" => calendar::handle_update_calendar(arguments),
74
76
  "handoff_update_labels" => calendar::handle_update_labels(arguments),
75
77
  "handoff_start_project" => calendar::handle_start_project(arguments),
78
+ "memory_save" => memory::handle_save(arguments),
79
+ "memory_query" => memory::handle_query(arguments),
80
+ "memory_delete" => memory::handle_delete(arguments),
81
+ "memory_cleanup" => memory::handle_cleanup(arguments),
76
82
  _ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
77
83
  };
78
84
 
@@ -4,7 +4,7 @@ use serde_json::Value;
4
4
  use super::resolve_project_dir;
5
5
  use crate::storage::ensure_handoff_exists;
6
6
  use crate::storage::referrals::{
7
- change_referral_status, is_valid_referral_status, read_referral_summaries,
7
+ change_referral_status, is_valid_referral_status, read_referral_by_id, read_referral_summaries,
8
8
  };
9
9
 
10
10
  pub fn handle_list(arguments: &Value) -> Result<String> {
@@ -32,6 +32,25 @@ pub fn handle_list(arguments: &Value) -> Result<String> {
32
32
  serde_json::to_string_pretty(&result).context("Failed to serialize referrals")
33
33
  }
34
34
 
35
+ pub fn handle_get(arguments: &Value) -> Result<String> {
36
+ let project_dir = resolve_project_dir(arguments)?;
37
+ let handoff = ensure_handoff_exists(&project_dir)?;
38
+ let referrals_dir = handoff.join("referrals");
39
+
40
+ let referral_id = arguments
41
+ .get("referral_id")
42
+ .and_then(|v| v.as_str())
43
+ .ok_or_else(|| anyhow::anyhow!("'referral_id' is required"))?;
44
+
45
+ let (data, status) = read_referral_by_id(&referrals_dir, referral_id)?
46
+ .ok_or_else(|| anyhow::anyhow!("Referral not found: {referral_id}"))?;
47
+
48
+ let mut result = serde_json::to_value(&data).context("Failed to serialize referral")?;
49
+ result["status"] = Value::String(status);
50
+
51
+ serde_json::to_string_pretty(&result).context("Failed to serialize referral")
52
+ }
53
+
35
54
  pub fn handle_update(arguments: &Value) -> Result<String> {
36
55
  let project_dir = resolve_project_dir(arguments)?;
37
56
  let handoff = ensure_handoff_exists(&project_dir)?;
@@ -5,6 +5,7 @@ use chrono::Utc;
5
5
  use serde_json::Value;
6
6
 
7
7
  use super::resolve_project_dir;
8
+ use crate::storage::config::read_config;
8
9
  use crate::storage::ensure_handoff_exists;
9
10
  use crate::storage::tasks::*;
10
11
 
@@ -14,6 +15,10 @@ pub fn handle(arguments: &Value) -> Result<String> {
14
15
  let handoff = ensure_handoff_exists(&project_dir)?;
15
16
  let tasks_dir = handoff.join("tasks");
16
17
 
18
+ let require_estimate_hours = read_config(&handoff.join("config.toml"))
19
+ .map(|c| c.settings.require_estimate_hours)
20
+ .unwrap_or(true);
21
+
17
22
  let task_val = arguments
18
23
  .get("task")
19
24
  .ok_or_else(|| anyhow::anyhow!("'task' parameter is required"))?;
@@ -27,9 +32,15 @@ pub fn handle(arguments: &Value) -> Result<String> {
27
32
  }
28
33
  let task_exists = find_task_dir_by_id(&tasks_dir, existing_id)?.is_some();
29
34
  if task_exists {
30
- return handle_update(&tasks_dir, existing_id, task_val);
35
+ return handle_update(&tasks_dir, existing_id, task_val, require_estimate_hours);
31
36
  }
32
- return handle_upsert_create(&tasks_dir, existing_id, task_val, arguments);
37
+ return handle_upsert_create(
38
+ &tasks_dir,
39
+ existing_id,
40
+ task_val,
41
+ arguments,
42
+ require_estimate_hours,
43
+ );
33
44
  }
34
45
 
35
46
  let title = task_val
@@ -37,7 +48,13 @@ pub fn handle(arguments: &Value) -> Result<String> {
37
48
  .and_then(|v| v.as_str())
38
49
  .ok_or_else(|| anyhow::anyhow!("'task.title' is required for new tasks"))?;
39
50
 
40
- handle_create(&tasks_dir, title, task_val, arguments)
51
+ handle_create(
52
+ &tasks_dir,
53
+ title,
54
+ task_val,
55
+ arguments,
56
+ require_estimate_hours,
57
+ )
41
58
  }
42
59
 
43
60
  fn handle_create(
@@ -45,6 +62,7 @@ fn handle_create(
45
62
  title: &str,
46
63
  task_val: &Value,
47
64
  arguments: &Value,
65
+ require_estimate_hours: bool,
48
66
  ) -> Result<String> {
49
67
  let parent_id = arguments.get("parent_id").and_then(|v| v.as_str());
50
68
 
@@ -112,6 +130,14 @@ fn handle_create(
112
130
  extra: HashMap::new(),
113
131
  };
114
132
 
133
+ // A newly created task is always a leaf (no children yet).
134
+ validate_estimate_required(
135
+ require_estimate_hours,
136
+ status,
137
+ false,
138
+ data.schedule.as_ref(),
139
+ )?;
140
+
115
141
  write_task(&task_dir, status, &data)?;
116
142
 
117
143
  Ok(format!("Created task {new_id}: {title} [{status}]"))
@@ -122,6 +148,7 @@ fn handle_upsert_create(
122
148
  task_id: &str,
123
149
  task_val: &Value,
124
150
  arguments: &Value,
151
+ require_estimate_hours: bool,
125
152
  ) -> Result<String> {
126
153
  let title = task_val
127
154
  .get("title")
@@ -195,12 +222,25 @@ fn handle_upsert_create(
195
222
  extra: HashMap::new(),
196
223
  };
197
224
 
225
+ // Upsert-create: a brand-new task is a leaf.
226
+ validate_estimate_required(
227
+ require_estimate_hours,
228
+ status,
229
+ false,
230
+ data.schedule.as_ref(),
231
+ )?;
232
+
198
233
  write_task(&task_dir, status, &data)?;
199
234
 
200
235
  Ok(format!("Created task {task_id}: {title} [{status}]"))
201
236
  }
202
237
 
203
- fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -> Result<String> {
238
+ fn handle_update(
239
+ tasks_dir: &std::path::Path,
240
+ task_id: &str,
241
+ task_val: &Value,
242
+ require_estimate_hours: bool,
243
+ ) -> Result<String> {
204
244
  let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
205
245
  .ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
206
246
 
@@ -288,6 +328,15 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
288
328
  validate_skipped_transition(&task_dir, &data)?;
289
329
  }
290
330
 
331
+ // Parent tasks (with children) are exempt; only leaf tasks need an estimate.
332
+ let has_children = task_has_children(&task_dir)?;
333
+ validate_estimate_required(
334
+ require_estimate_hours,
335
+ new_status,
336
+ has_children,
337
+ data.schedule.as_ref(),
338
+ )?;
339
+
291
340
  data.updated_at = Some(Utc::now().to_rfc3339());
292
341
 
293
342
  if let Some((old_path, _)) = find_task_file(&task_dir)? {
package/src/mcp/tools.rs CHANGED
@@ -644,6 +644,24 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
644
644
  }
645
645
  }),
646
646
  },
647
+ ToolDefinition {
648
+ name: "handoff_get_referral".to_string(),
649
+ description: "Get the full details of a single incoming referral by ID (summary, details, tasks with done_criteria, priority, context, status). Use this instead of reading .handoff/referrals/*.json directly.".to_string(),
650
+ input_schema: json!({
651
+ "type": "object",
652
+ "properties": {
653
+ "project_dir": {
654
+ "type": "string",
655
+ "description": "Project directory path. Defaults to current working directory."
656
+ },
657
+ "referral_id": {
658
+ "type": "string",
659
+ "description": "ID of the referral to retrieve (full id or a unique prefix)."
660
+ }
661
+ },
662
+ "required": ["referral_id"]
663
+ }),
664
+ },
647
665
  ToolDefinition {
648
666
  name: "handoff_update_referral".to_string(),
649
667
  description: "Update the status of an incoming referral (open -> acknowledged -> resolved).".to_string(),
@@ -1005,6 +1023,65 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1005
1023
  }
1006
1024
  }),
1007
1025
  },
1026
+ ToolDefinition {
1027
+ name: "memory_save".to_string(),
1028
+ description: "Save a long-lived project memory (lesson/rule/convention/gotcha) that future sessions should respect. Detects exact and near-duplicate memories: an exact match is reported (not rewritten), a near-duplicate is returned as a 'conflict' with both bodies for you to merge (call again with merge_into=<id> and absorb_ids=[…]) or save separately with force=true. Returns a JSON string.".to_string(),
1029
+ input_schema: json!({
1030
+ "type": "object",
1031
+ "properties": {
1032
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1033
+ "text": { "type": "string", "description": "The memory body (any language). Required, non-empty." },
1034
+ "kind": { "type": "string", "description": "Memory kind.", "enum": ["lesson", "rule", "convention", "gotcha"], "default": "lesson" },
1035
+ "tags": { "type": "array", "items": { "type": "string" }, "description": "Optional tags; also indexed for similarity." },
1036
+ "scope_paths": { "type": "array", "items": { "type": "string" }, "description": "Path prefixes this memory applies to (e.g. 'src/storage/'). Boosts relevance when a query touches a matching file." },
1037
+ "merge_into": { "type": "string", "description": "Commit an AI merge: overwrite this memory id with `text` and absorb `absorb_ids`." },
1038
+ "absorb_ids": { "type": "array", "items": { "type": "string" }, "description": "Memory ids to delete and record as superseded when merging." },
1039
+ "force": { "type": "boolean", "description": "Save even if a near-duplicate exists (skip the conflict response).", "default": false }
1040
+ },
1041
+ "required": ["text"]
1042
+ }),
1043
+ },
1044
+ ToolDefinition {
1045
+ name: "memory_query".to_string(),
1046
+ description: "Return the project memories most relevant to the given text/file (BM25 + scope-path boosting). Intended for automatic injection via hooks, but callable directly. Returns a JSON string {\"memories\":[{id,text,kind,score}],\"injected_count\"}.".to_string(),
1047
+ input_schema: json!({
1048
+ "type": "object",
1049
+ "properties": {
1050
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1051
+ "session_id": { "type": "string", "description": "Hook session id. When given, memories already injected this session (same content hash) are filtered out; an edited memory is re-injected." },
1052
+ "text": { "type": "string", "description": "The current prompt or context text to match against." },
1053
+ "tool_name": { "type": "string", "description": "Name of the tool about to run (e.g. 'Edit'); added to the query." },
1054
+ "file_paths": { "type": "array", "items": { "type": "string" }, "description": "File paths in play; basenames are added to the query and scope_paths are matched against these." },
1055
+ "limit": { "type": "integer", "description": "Maximum memories to return.", "default": 5 },
1056
+ "mark_injected": { "type": "boolean", "description": "Record returned memories in the session sidecar and bump their hit_count/last_referenced_at. Requires session_id.", "default": true }
1057
+ },
1058
+ "required": ["text"]
1059
+ }),
1060
+ },
1061
+ ToolDefinition {
1062
+ name: "memory_delete".to_string(),
1063
+ description: "Delete a project memory by id (full id or unique prefix). Use for AI-driven cleanup of stale memories. Returns a JSON string {\"status\":\"deleted\",\"id\"}.".to_string(),
1064
+ input_schema: json!({
1065
+ "type": "object",
1066
+ "properties": {
1067
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1068
+ "id": { "type": "string", "description": "Memory id to delete (full id or unique prefix)." }
1069
+ },
1070
+ "required": ["id"]
1071
+ }),
1072
+ },
1073
+ ToolDefinition {
1074
+ name: "memory_cleanup".to_string(),
1075
+ description: "Housekeep the project memory store (intended for SessionStart). Silently merges exact duplicates (lossless), then returns recommendations the AI should act on: near-duplicate clusters (merge with memory_save merge_into=…) and stale memories (consider memory_delete). Also garbage-collects old per-session injection sidecars. Returns a JSON string {\"auto_merged_exact\":n,\"cleanup_recommendations\":{\"similar_clusters\":[…],\"stale\":[…]},\"injected_sidecars_removed\":k}.".to_string(),
1076
+ input_schema: json!({
1077
+ "type": "object",
1078
+ "properties": {
1079
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1080
+ "apply_exact_merges": { "type": "boolean", "description": "Auto-merge exact-duplicate memories (same content hash). Lossless and safe.", "default": true },
1081
+ "stale_days": { "type": "integer", "description": "Flag memories not referenced for this many days as stale recommendations.", "default": 60 }
1082
+ }
1083
+ }),
1084
+ },
1008
1085
  ]
1009
1086
  }
1010
1087
 
@@ -49,8 +49,37 @@ pub struct SettingsConfig {
49
49
  pub done_task_limit: u32,
50
50
  #[serde(default = "default_auto_git_summary")]
51
51
  pub auto_git_summary: bool,
52
+ /// Require `estimate_hours` when creating/updating leaf tasks. Default true.
53
+ #[serde(default = "default_require_estimate_hours")]
54
+ pub require_estimate_hours: bool,
55
+ /// Multiplier applied to AI-entered `estimate_hours` to derive the
56
+ /// adjusted (AI-effort) estimate at aggregation time. Default 0.2.
57
+ #[serde(default = "default_ai_estimate_multiplier")]
58
+ pub ai_estimate_multiplier: f64,
52
59
  #[serde(default)]
53
60
  pub context_files: Vec<String>,
61
+ /// Master switch for the memory feature (save/query/cleanup). Default true.
62
+ #[serde(default = "default_memory_enabled")]
63
+ pub memory_enabled: bool,
64
+ /// Jaccard threshold above which `memory_save` treats a save as a
65
+ /// near-duplicate `conflict` for the AI to merge. Default 0.72.
66
+ #[serde(default = "default_memory_dup_threshold")]
67
+ pub memory_dup_threshold: f64,
68
+ /// BM25 relevance floor for `memory_query`; scores below are not returned.
69
+ /// Default 0.5.
70
+ #[serde(default = "default_memory_query_min_score")]
71
+ pub memory_query_min_score: f64,
72
+ /// Maximum number of memories `memory_query` returns per call. Default 5.
73
+ #[serde(default = "default_memory_query_limit")]
74
+ pub memory_query_limit: u32,
75
+ /// Days after which an un(re)referenced memory is flagged `stale` by
76
+ /// `memory_cleanup`. Default 60.
77
+ #[serde(default = "default_memory_stale_days")]
78
+ pub memory_stale_days: i64,
79
+ /// Age (days) past which `memory_cleanup` garbage-collects a per-session
80
+ /// `injected/` sidecar. Default 14.
81
+ #[serde(default = "default_memory_injected_gc_days")]
82
+ pub memory_injected_gc_days: i64,
54
83
  #[serde(default)]
55
84
  pub custom_fields: HashMap<String, toml::Value>,
56
85
  }
@@ -190,6 +219,38 @@ fn default_auto_git_summary() -> bool {
190
219
  true
191
220
  }
192
221
 
222
+ fn default_require_estimate_hours() -> bool {
223
+ true
224
+ }
225
+
226
+ fn default_ai_estimate_multiplier() -> f64 {
227
+ 0.2
228
+ }
229
+
230
+ fn default_memory_enabled() -> bool {
231
+ true
232
+ }
233
+
234
+ fn default_memory_dup_threshold() -> f64 {
235
+ 0.72
236
+ }
237
+
238
+ fn default_memory_query_min_score() -> f64 {
239
+ 0.5
240
+ }
241
+
242
+ fn default_memory_query_limit() -> u32 {
243
+ 5
244
+ }
245
+
246
+ fn default_memory_stale_days() -> i64 {
247
+ 60
248
+ }
249
+
250
+ fn default_memory_injected_gc_days() -> i64 {
251
+ 14
252
+ }
253
+
193
254
  fn default_scan_dirs() -> Vec<String> {
194
255
  vec!["~/pro/".to_string()]
195
256
  }
@@ -200,7 +261,15 @@ impl Default for SettingsConfig {
200
261
  history_limit: default_history_limit(),
201
262
  done_task_limit: default_done_task_limit(),
202
263
  auto_git_summary: default_auto_git_summary(),
264
+ require_estimate_hours: default_require_estimate_hours(),
265
+ ai_estimate_multiplier: default_ai_estimate_multiplier(),
203
266
  context_files: Vec::new(),
267
+ memory_enabled: default_memory_enabled(),
268
+ memory_dup_threshold: default_memory_dup_threshold(),
269
+ memory_query_min_score: default_memory_query_min_score(),
270
+ memory_query_limit: default_memory_query_limit(),
271
+ memory_stale_days: default_memory_stale_days(),
272
+ memory_injected_gc_days: default_memory_injected_gc_days(),
204
273
  custom_fields: HashMap::new(),
205
274
  }
206
275
  }