handoff-mcp-server 0.11.0 → 0.12.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.
package/Cargo.lock CHANGED
@@ -144,7 +144,7 @@ dependencies = [
144
144
 
145
145
  [[package]]
146
146
  name = "handoff-mcp"
147
- version = "0.11.0"
147
+ version = "0.12.0"
148
148
  dependencies = [
149
149
  "anyhow",
150
150
  "chrono",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.11.0"
3
+ version = "0.12.0"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "MCP server that gives AI coding agents persistent memory across sessions",
5
5
  "license": "MIT",
6
6
  "author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
@@ -54,6 +54,13 @@ description: "Session handoff — load context at start, save at end, track task
54
54
  user that their input is needed before proceeding.
55
55
  - Create new tasks as work is discovered. Always include `done_criteria` with
56
56
  verifiable items so completion can be tracked.
57
+ - **Always set `schedule.estimate_hours`** (raw human-effort hours, > 0) on every
58
+ leaf task. It is required by default — `handoff_update_task` rejects creating or
59
+ updating a leaf task without it (parent tasks and `blocked`/`skipped` tasks are
60
+ exempt). Enter the raw human-effort estimate; the AI-effort multiplier
61
+ (`settings.ai_estimate_multiplier`, default 0.2) is applied automatically at
62
+ aggregation time by `handoff_get_metrics`/`handoff_get_capacity`. To turn the
63
+ requirement off, set `settings.require_estimate_hours = false`.
57
64
 
58
65
  ### Progressive done_criteria Checking
59
66
  - **Check off `done_criteria` immediately as each item is verified** — do not
@@ -91,7 +98,7 @@ description: "Session handoff — load context at start, save at end, track task
91
98
  Use `handoff_log_time` to record hours worked on a task:
92
99
  - Atomically adds to `schedule.actual_hours` and deducts from `schedule.remaining_hours`.
93
100
  - Never overwrite `actual_hours` directly — always use the additive `handoff_log_time` tool.
94
- - Set `schedule.estimate_hours` on task creation so metrics can show estimate vs actual.
101
+ - Set `schedule.estimate_hours` on task creation (required by default) so metrics can show estimate vs actual.
95
102
  - When the handoff-vscode time tracker is enabled, the extension also logs time
96
103
  automatically — the AI does not need to log time manually for extension-tracked tasks.
97
104
  - `handoff_update_task`'s `schedule` field **merges** (partial update): passing
@@ -4,6 +4,7 @@ use serde_json::{json, Value};
4
4
  use toml_edit::DocumentMut;
5
5
 
6
6
  use super::resolve_project_dir;
7
+ use crate::storage::config::read_config;
7
8
  use crate::storage::ensure_handoff_exists;
8
9
  use crate::storage::tasks::{build_task_index, is_terminal_status, TaskIndex};
9
10
 
@@ -54,9 +55,12 @@ pub fn handle(arguments: &Value) -> Result<String> {
54
55
  }
55
56
 
56
57
  // Calculate allocated hours from tasks
58
+ let multiplier = read_config(&config_path)
59
+ .map(|c| c.settings.ai_estimate_multiplier)
60
+ .unwrap_or(0.2);
57
61
  if tasks_dir.exists() {
58
62
  let (tree, _) = build_task_index(&tasks_dir, u32::MAX)?;
59
- allocate_task_hours(&tree, assignee_filter, &start, &end, &mut days);
63
+ allocate_task_hours(&tree, assignee_filter, &start, &end, &mut days, multiplier);
60
64
  }
61
65
 
62
66
  let allocated_hours: f64 = days
@@ -260,6 +264,7 @@ fn allocate_task_hours(
260
264
  start: &NaiveDate,
261
265
  end: &NaiveDate,
262
266
  days: &mut [Value],
267
+ multiplier: f64,
263
268
  ) {
264
269
  for node in tree {
265
270
  if is_terminal_status(&node.status) {
@@ -278,10 +283,13 @@ fn allocate_task_hours(
278
283
  NaiveDate::parse_from_str(sd, "%Y-%m-%d"),
279
284
  NaiveDate::parse_from_str(dd, "%Y-%m-%d"),
280
285
  ) {
281
- let est = sched
282
- .remaining_hours
283
- .or(sched.estimate_hours)
284
- .unwrap_or(0.0);
286
+ // remaining_hours is already actual AI-effort progress, so it
287
+ // is used as-is; the raw estimate is human-effort and gets the
288
+ // AI multiplier applied to derive AI-effort allocation.
289
+ let est = match sched.remaining_hours {
290
+ Some(rem) => rem,
291
+ None => sched.estimate_hours.unwrap_or(0.0) * multiplier,
292
+ };
285
293
  let overlap_start = (*start).max(task_start);
286
294
  let overlap_end = (*end).min(task_end);
287
295
  if overlap_start <= overlap_end {
@@ -311,6 +319,13 @@ fn allocate_task_hours(
311
319
  }
312
320
  }
313
321
 
314
- allocate_task_hours(&node.children, assignee_filter, start, end, days);
322
+ allocate_task_hours(
323
+ &node.children,
324
+ assignee_filter,
325
+ start,
326
+ end,
327
+ days,
328
+ multiplier,
329
+ );
315
330
  }
316
331
  }
@@ -44,6 +44,8 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
44
44
  "settings.history_limit",
45
45
  "settings.done_task_limit",
46
46
  "settings.auto_git_summary",
47
+ "settings.require_estimate_hours",
48
+ "settings.ai_estimate_multiplier",
47
49
  "settings.context_files",
48
50
  "dashboard.scan_dirs",
49
51
  "dashboard.exclude_patterns",
@@ -87,6 +89,21 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
87
89
  applied.push(format!("settings.auto_git_summary = {b}"));
88
90
  }
89
91
  }
92
+ "settings.require_estimate_hours" => {
93
+ if let Some(b) = value.as_bool() {
94
+ config.settings.require_estimate_hours = b;
95
+ applied.push(format!("settings.require_estimate_hours = {b}"));
96
+ }
97
+ }
98
+ "settings.ai_estimate_multiplier" => {
99
+ if let Some(n) = value.as_f64() {
100
+ if n < 0.0 {
101
+ anyhow::bail!("settings.ai_estimate_multiplier must be >= 0");
102
+ }
103
+ config.settings.ai_estimate_multiplier = n;
104
+ applied.push(format!("settings.ai_estimate_multiplier = {n}"));
105
+ }
106
+ }
90
107
  "settings.context_files" => {
91
108
  if let Some(arr) = value.as_array() {
92
109
  config.settings.context_files = arr
@@ -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(),
@@ -53,6 +53,7 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
53
53
  "handoff_import_context" => import_context::handle(arguments),
54
54
  "handoff_refer" => refer::handle(arguments),
55
55
  "handoff_list_referrals" => referrals::handle_list(arguments),
56
+ "handoff_get_referral" => referrals::handle_get(arguments),
56
57
  "handoff_update_referral" => referrals::handle_update(arguments),
57
58
  "handoff_update_session" => update_session::handle(arguments),
58
59
  "handoff_log_time" => log_time::handle(arguments),
@@ -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(),
@@ -49,6 +49,13 @@ 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>,
54
61
  #[serde(default)]
@@ -190,6 +197,14 @@ fn default_auto_git_summary() -> bool {
190
197
  true
191
198
  }
192
199
 
200
+ fn default_require_estimate_hours() -> bool {
201
+ true
202
+ }
203
+
204
+ fn default_ai_estimate_multiplier() -> f64 {
205
+ 0.2
206
+ }
207
+
193
208
  fn default_scan_dirs() -> Vec<String> {
194
209
  vec!["~/pro/".to_string()]
195
210
  }
@@ -200,6 +215,8 @@ impl Default for SettingsConfig {
200
215
  history_limit: default_history_limit(),
201
216
  done_task_limit: default_done_task_limit(),
202
217
  auto_git_summary: default_auto_git_summary(),
218
+ require_estimate_hours: default_require_estimate_hours(),
219
+ ai_estimate_multiplier: default_ai_estimate_multiplier(),
203
220
  context_files: Vec::new(),
204
221
  custom_fields: HashMap::new(),
205
222
  }
@@ -160,6 +160,44 @@ pub fn change_referral_status(
160
160
  Ok(())
161
161
  }
162
162
 
163
+ /// Read a single referral's full data by ID, returning the data and its status.
164
+ /// Searches every status (open / acknowledged / resolved). Matches by exact ID
165
+ /// first, then falls back to a unique prefix match for convenience.
166
+ pub fn read_referral_by_id(
167
+ referrals_dir: &Path,
168
+ referral_id: &str,
169
+ ) -> Result<Option<(ReferralData, String)>> {
170
+ if !referrals_dir.exists() {
171
+ return Ok(None);
172
+ }
173
+
174
+ let mut prefix_matches: Vec<(ReferralData, String)> = Vec::new();
175
+
176
+ for entry in std::fs::read_dir(referrals_dir)? {
177
+ let entry = entry?;
178
+ let name = entry.file_name().to_string_lossy().to_string();
179
+ if let Some(status) = parse_referral_status(&name) {
180
+ let content = std::fs::read_to_string(entry.path())?;
181
+ if let Ok(data) = serde_json::from_str::<ReferralData>(&content) {
182
+ if data.id == referral_id {
183
+ return Ok(Some((data, status)));
184
+ }
185
+ if data.id.starts_with(referral_id) {
186
+ prefix_matches.push((data, status));
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ match prefix_matches.len() {
193
+ 0 => Ok(None),
194
+ 1 => Ok(Some(prefix_matches.into_iter().next().unwrap())),
195
+ n => anyhow::bail!(
196
+ "Ambiguous referral id prefix '{referral_id}' matches {n} referrals; use the full id"
197
+ ),
198
+ }
199
+ }
200
+
163
201
  pub fn find_referral_file(
164
202
  referrals_dir: &Path,
165
203
  referral_id: &str,
@@ -590,6 +590,61 @@ pub fn validate_skipped_transition(task_dir: &Path, data: &TaskData) -> Result<(
590
590
  Ok(())
591
591
  }
592
592
 
593
+ /// Returns true if the task directory contains at least one child task
594
+ /// (a non-`_`/`.`-prefixed subdirectory holding a task file).
595
+ pub fn task_has_children(task_dir: &Path) -> Result<bool> {
596
+ if !task_dir.exists() {
597
+ return Ok(false);
598
+ }
599
+ for entry in std::fs::read_dir(task_dir)? {
600
+ let entry = entry?;
601
+ if !entry.file_type()?.is_dir() {
602
+ continue;
603
+ }
604
+ let name = entry.file_name().to_string_lossy().to_string();
605
+ if name.starts_with('_') || name.starts_with('.') {
606
+ continue;
607
+ }
608
+ if find_task_file(&entry.path())?.is_some() {
609
+ return Ok(true);
610
+ }
611
+ }
612
+ Ok(false)
613
+ }
614
+
615
+ /// Whether a task in the given status requires an effort estimate.
616
+ /// Parent tasks (with children) and blocked/skipped tasks are exempt;
617
+ /// this only covers the status dimension.
618
+ pub fn status_requires_estimate(status: &str) -> bool {
619
+ matches!(status, "todo" | "in_progress" | "review" | "done")
620
+ }
621
+
622
+ /// Validate that a leaf task carries an `estimate_hours` when the project
623
+ /// requires it. `has_children` lets the caller skip parent tasks.
624
+ /// Returns an error guiding the caller to add an estimate.
625
+ pub fn validate_estimate_required(
626
+ require_estimate_hours: bool,
627
+ status: &str,
628
+ has_children: bool,
629
+ schedule: Option<&Schedule>,
630
+ ) -> Result<()> {
631
+ if !require_estimate_hours || has_children || !status_requires_estimate(status) {
632
+ return Ok(());
633
+ }
634
+ let has_estimate = schedule
635
+ .and_then(|s| s.estimate_hours)
636
+ .is_some_and(|h| h > 0.0);
637
+ if !has_estimate {
638
+ anyhow::bail!(
639
+ "Task requires an effort estimate: set schedule.estimate_hours (hours, > 0) \
640
+ when creating or updating a leaf task in status '{status}'. \
641
+ Estimate the human-effort hours for this task. \
642
+ To disable this requirement project-wide, set settings.require_estimate_hours = false."
643
+ );
644
+ }
645
+ Ok(())
646
+ }
647
+
593
648
  fn check_children_terminal(task_dir: &Path, parent_id: &str) -> Result<()> {
594
649
  if !task_dir.exists() {
595
650
  return Ok(());