handoff-mcp-server 0.17.1 → 0.18.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
@@ -136,7 +136,7 @@ dependencies = [
136
136
 
137
137
  [[package]]
138
138
  name = "handoff-mcp"
139
- version = "0.17.1"
139
+ version = "0.18.0"
140
140
  dependencies = [
141
141
  "anyhow",
142
142
  "chrono",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.17.1"
3
+ version = "0.18.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.17.1",
3
+ "version": "0.18.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>",
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env bash
2
+ # Install handoff-mcp locally: binary, skills, and plugin caches.
3
+ # Usage: ./scripts/install-local.sh
4
+ set -euo pipefail
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7
+ ROOT="$(dirname "$SCRIPT_DIR")"
8
+ CLAUDE_DIR="${HOME}/.claude"
9
+ CACHE_DIR="${CLAUDE_DIR}/plugins/cache/handoff-mcp-marketplace"
10
+ INSTALLED_JSON="${CLAUDE_DIR}/plugins/installed_plugins.json"
11
+
12
+ cd "$ROOT"
13
+
14
+ # ---------- 1. Binary ----------
15
+ echo "==> Building release binary..."
16
+ cargo build --release
17
+
18
+ mkdir -p "${HOME}/.local/bin"
19
+ rm -f "${HOME}/.local/bin/handoff-mcp"
20
+ cp target/release/handoff-mcp "${HOME}/.local/bin/handoff-mcp"
21
+ echo " Installed: $(${HOME}/.local/bin/handoff-mcp --version)"
22
+
23
+ # ---------- 2. Bundled skills ----------
24
+ echo "==> Syncing skills..."
25
+ skill_count=0
26
+ for skill in skills/*/; do
27
+ name=$(basename "$skill")
28
+ mkdir -p "${CLAUDE_DIR}/skills/${name}"
29
+ cp "${skill}SKILL.md" "${CLAUDE_DIR}/skills/${name}/SKILL.md"
30
+ skill_count=$((skill_count + 1))
31
+ done
32
+ echo " ${skill_count} skills synced"
33
+
34
+ # ---------- 3. Sync plugin distribution skills ----------
35
+ echo "==> Syncing plugin distribution skills..."
36
+ "${SCRIPT_DIR}/sync-plugin-skills.sh"
37
+
38
+ # ---------- 4. Plugin caches ----------
39
+ echo "==> Syncing plugin caches..."
40
+
41
+ sync_plugin() {
42
+ local src_dir="$1"
43
+ local plugin_json="${src_dir}/.claude-plugin/plugin.json"
44
+
45
+ if [ ! -f "$plugin_json" ]; then
46
+ echo " SKIP: ${src_dir} (no .claude-plugin/plugin.json)"
47
+ return
48
+ fi
49
+
50
+ local name version cache_dest
51
+ name=$(python3 -c "import json,sys; print(json.load(sys.stdin)['name'])" < "$plugin_json")
52
+ version=$(python3 -c "import json,sys; print(json.load(sys.stdin)['version'])" < "$plugin_json")
53
+ cache_dest="${CACHE_DIR}/${name}/${version}"
54
+
55
+ rm -rf "$cache_dest"
56
+ mkdir -p "$cache_dest"
57
+
58
+ # Copy everything except .claude-plugin/ first, then copy .claude-plugin/
59
+ find "$src_dir" -mindepth 1 -maxdepth 1 -not -name '.claude-plugin' -exec cp -a {} "$cache_dest/" \;
60
+ cp -a "${src_dir}/.claude-plugin" "$cache_dest/.claude-plugin"
61
+
62
+ echo " ${name}@${version} -> ${cache_dest}"
63
+
64
+ # Update installed_plugins.json timestamp
65
+ if [ -f "$INSTALLED_JSON" ] && command -v python3 >/dev/null 2>&1; then
66
+ local git_sha
67
+ git_sha=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
68
+ local now
69
+ now=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
70
+ python3 -c "
71
+ import json, sys
72
+ key = '${name}@handoff-mcp-marketplace'
73
+ with open('${INSTALLED_JSON}', 'r') as f:
74
+ data = json.load(f)
75
+ if key in data.get('plugins', {}):
76
+ for entry in data['plugins'][key]:
77
+ entry['installPath'] = '${cache_dest}'
78
+ entry['version'] = '${version}'
79
+ entry['lastUpdated'] = '${now}'
80
+ entry['gitCommitSha'] = '${git_sha}'
81
+ with open('${INSTALLED_JSON}', 'w') as f:
82
+ json.dump(data, f, indent=4)
83
+ f.write('\n')
84
+ " 2>/dev/null || true
85
+ fi
86
+ }
87
+
88
+ sync_plugin "${ROOT}/plugin"
89
+ sync_plugin "${ROOT}/plugin-hooks"
90
+ sync_plugin "${ROOT}/plugin-task-loop"
91
+
92
+ # ---------- 5. Verify ----------
93
+ echo ""
94
+ echo "==> Done. Restart Claude Code to pick up changes."
95
+ echo " Binary: ${HOME}/.local/bin/handoff-mcp"
96
+ echo " Skills: ${CLAUDE_DIR}/skills/"
97
+ echo " Plugins: ${CACHE_DIR}/"
@@ -58,6 +58,7 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
58
58
  "settings.timer_idle_timeout_minutes",
59
59
  "dashboard.scan_dirs",
60
60
  "dashboard.exclude_patterns",
61
+ "dashboard.max_depth",
61
62
  "project.name",
62
63
  "project.description",
63
64
  ];
@@ -241,6 +242,16 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
241
242
  ));
242
243
  }
243
244
  }
245
+ "dashboard.max_depth" => {
246
+ let Some(n) = value.as_u64() else {
247
+ anyhow::bail!("dashboard.max_depth must be a non-negative integer");
248
+ };
249
+ if n == 0 {
250
+ anyhow::bail!("dashboard.max_depth must be >= 1");
251
+ }
252
+ config.dashboard.max_depth = n as usize;
253
+ applied.push(format!("dashboard.max_depth = {n}"));
254
+ }
244
255
  "project.name" => {
245
256
  if let Some(s) = value.as_str() {
246
257
  config.project.name = s.to_string();
@@ -1,9 +1,9 @@
1
- use std::path::Path;
1
+ use std::path::{Path, PathBuf};
2
2
 
3
3
  use anyhow::{Context, Result};
4
4
  use serde_json::Value;
5
5
 
6
- use crate::storage::config::read_config;
6
+ use crate::storage::config::{read_config, DashboardConfig};
7
7
  use crate::storage::expand_tilde;
8
8
  use crate::storage::referrals::read_referral_summaries;
9
9
  use crate::storage::sessions::{read_active_sessions, read_open_sessions, read_paused_sessions};
@@ -32,23 +32,18 @@ pub fn handle(arguments: &Value) -> Result<String> {
32
32
  continue;
33
33
  }
34
34
 
35
- let entries = match std::fs::read_dir(expanded_path) {
36
- Ok(e) => e,
37
- Err(_) => continue,
38
- };
35
+ let (max_depth, exclude_patterns) = resolve_scan_config(expanded_path, arguments);
39
36
 
40
- for entry in entries.flatten() {
41
- if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
42
- continue;
43
- }
44
-
45
- let project_path = entry.path();
46
- let handoff_dir = project_path.join(".handoff");
47
-
48
- if !handoff_dir.join("config.toml").exists() {
49
- continue;
50
- }
37
+ let mut discovered = Vec::new();
38
+ scan_recursive(
39
+ expanded_path,
40
+ 1,
41
+ max_depth,
42
+ &exclude_patterns,
43
+ &mut discovered,
44
+ );
51
45
 
46
+ for project_path in discovered {
52
47
  if let Ok(info) = collect_project_info(&project_path) {
53
48
  total_active += info["active_tasks"].as_u64().unwrap_or(0) as u32;
54
49
  total_blocked += info["blocked_tasks"].as_u64().unwrap_or(0) as u32;
@@ -66,6 +61,112 @@ pub fn handle(arguments: &Value) -> Result<String> {
66
61
  serde_json::to_string_pretty(&result).context("Failed to serialize dashboard")
67
62
  }
68
63
 
64
+ /// Resolves effective `max_depth` / `exclude_patterns` for a single scan_dir.
65
+ ///
66
+ /// Precedence: explicit tool `arguments` override (applies uniformly across
67
+ /// all scan_dirs, since it's an explicit user choice), then this scan_dir's
68
+ /// own `.handoff/config.toml` (if present), then — in the common umbrella-
69
+ /// workspace topology where the scan_dir itself is not a handoff project (e.g.
70
+ /// `~/pro/`) — the first *discovered child* project's config within this same
71
+ /// scan_dir's subtree, then built-in defaults.
72
+ ///
73
+ /// Scoped to a single `expanded_path` so config discovered under one scan_dir
74
+ /// never leaks into sibling scan_dirs in a multi-root dashboard call.
75
+ fn resolve_scan_config(expanded_path: &Path, arguments: &Value) -> (usize, Vec<String>) {
76
+ let mut defaults = DashboardConfig::default();
77
+
78
+ let own_config_path = expanded_path.join(".handoff").join("config.toml");
79
+ if let Ok(config) = read_config(&own_config_path) {
80
+ defaults = config.dashboard;
81
+ } else {
82
+ // scan_dir itself has no config of its own (typical umbrella-workspace
83
+ // case) — do a discovery pass scoped to this scan_dir's own subtree and
84
+ // look for a child project whose own dashboard config overrides the
85
+ // built-in default, so per-project settings still take effect without
86
+ // requiring an explicit tool argument. Discovery order is filesystem-
87
+ // dependent, so sort child paths for deterministic selection.
88
+ //
89
+ // Probe depth is capped at the caller's explicit max_depth argument
90
+ // (if given) so a shallow-depth request doesn't still pay for a full
91
+ // default-depth (5) filesystem walk just to look for fallback config.
92
+ let probe_depth = arguments
93
+ .get("max_depth")
94
+ .and_then(|v| v.as_u64())
95
+ .map(|n| n as usize)
96
+ .unwrap_or_else(|| DashboardConfig::default().max_depth)
97
+ .min(DashboardConfig::default().max_depth);
98
+ let mut discovered = Vec::new();
99
+ scan_recursive(expanded_path, 1, probe_depth, &[], &mut discovered);
100
+ discovered.sort();
101
+ for child_path in discovered {
102
+ let child_config_path = child_path.join(".handoff").join("config.toml");
103
+ if let Ok(config) = read_config(&child_config_path) {
104
+ if config.dashboard.max_depth != DashboardConfig::default().max_depth
105
+ || !config.dashboard.exclude_patterns.is_empty()
106
+ {
107
+ defaults = config.dashboard;
108
+ break;
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ let max_depth = arguments
115
+ .get("max_depth")
116
+ .and_then(|v| v.as_u64())
117
+ .map(|n| n as usize)
118
+ .unwrap_or(defaults.max_depth);
119
+
120
+ let exclude_patterns = arguments
121
+ .get("exclude_patterns")
122
+ .and_then(|v| v.as_array())
123
+ .map(|arr| {
124
+ arr.iter()
125
+ .filter_map(|v| v.as_str().map(String::from))
126
+ .collect()
127
+ })
128
+ .unwrap_or(defaults.exclude_patterns);
129
+
130
+ (max_depth, exclude_patterns)
131
+ }
132
+
133
+ /// Recursively scans `dir` up to `max_depth` levels for `.handoff/config.toml`
134
+ /// markers, skipping directories whose name exactly matches an entry in
135
+ /// `exclude_patterns`. Never descends into a directory literally named
136
+ /// `.handoff` — a project's own bookkeeping tree (tasks/sessions/memory/etc.)
137
+ /// can never contain a nested project marker, so walking it would only waste
138
+ /// I/O proportional to the project's task/session history.
139
+ fn scan_recursive(
140
+ dir: &Path,
141
+ depth: usize,
142
+ max_depth: usize,
143
+ exclude_patterns: &[String],
144
+ results: &mut Vec<PathBuf>,
145
+ ) {
146
+ if depth > max_depth {
147
+ return;
148
+ }
149
+ let entries = match std::fs::read_dir(dir) {
150
+ Ok(e) => e,
151
+ Err(_) => return,
152
+ };
153
+ for entry in entries.flatten() {
154
+ if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
155
+ continue;
156
+ }
157
+ let name = entry.file_name();
158
+ let name_str = name.to_string_lossy();
159
+ if name_str == ".handoff" || exclude_patterns.iter().any(|p| p == name_str.as_ref()) {
160
+ continue;
161
+ }
162
+ let path = entry.path();
163
+ if path.join(".handoff").join("config.toml").exists() {
164
+ results.push(path.clone());
165
+ }
166
+ scan_recursive(&path, depth + 1, max_depth, exclude_patterns, results);
167
+ }
168
+ }
169
+
69
170
  fn collect_project_info(project_path: &Path) -> Result<Value> {
70
171
  let handoff_dir = project_path.join(".handoff");
71
172
  let config = read_config(&handoff_dir.join("config.toml"))?;
@@ -1,10 +1,19 @@
1
+ use std::path::{Path, PathBuf};
2
+
1
3
  use anyhow::{Context, Result};
2
- use serde_json::Value;
4
+ use serde_json::{json, Value};
3
5
 
4
6
  use super::resolve_project_dir;
5
7
  use crate::storage::config::read_config;
6
8
  use crate::storage::ensure_handoff_exists;
7
- use crate::storage::tasks::{build_task_index, TaskIndex};
9
+ use crate::storage::tasks::{build_task_index, TaskIndex, TaskSummary};
10
+
11
+ /// Maximum depth (relative to the base project dir) scanned for nested
12
+ /// `.handoff/` child projects.
13
+ const MAX_CHILD_SCAN_DEPTH: usize = 5;
14
+
15
+ /// Directory names skipped while scanning for child projects.
16
+ const DEFAULT_SCAN_EXCLUDES: &[&str] = &["node_modules", ".git", "target", "dist", ".next"];
8
17
 
9
18
  pub fn handle(arguments: &Value) -> Result<String> {
10
19
  let project_dir = resolve_project_dir(arguments)?;
@@ -43,14 +52,183 @@ pub fn handle(arguments: &Value) -> Result<String> {
43
52
  tree
44
53
  };
45
54
 
55
+ let include_children = arguments
56
+ .get("include_children")
57
+ .and_then(|v| v.as_bool())
58
+ .unwrap_or(false);
59
+
60
+ if !include_children {
61
+ let result = serde_json::json!({
62
+ "task_tree": filtered_tree,
63
+ "task_summary": summary,
64
+ });
65
+ return serde_json::to_string_pretty(&result).context("Failed to serialize task list");
66
+ }
67
+
68
+ // Determine own project name for annotation (defaults to config, fallback to "root").
69
+ let own_project_name = if config_path.exists() {
70
+ read_config(&config_path)
71
+ .map(|c| c.project.name)
72
+ .unwrap_or_else(|_| "root".to_string())
73
+ } else {
74
+ "root".to_string()
75
+ };
76
+ let own_project_dir = project_dir.to_string_lossy().to_string();
77
+
78
+ let mut aggregated: Vec<Value> =
79
+ annotate_tasks_json(filtered_tree, &own_project_name, &own_project_dir);
80
+ let mut total_summary = summary;
81
+
82
+ for (child_name, child_dir) in discover_child_projects(&project_dir, MAX_CHILD_SCAN_DEPTH) {
83
+ let child_handoff = child_dir.join(".handoff");
84
+ let child_tasks_dir = child_handoff.join("tasks");
85
+ let child_config_path = child_handoff.join("config.toml");
86
+ let child_done_task_limit = read_config(&child_config_path)
87
+ .map(|c| c.settings.done_task_limit)
88
+ .unwrap_or(10);
89
+
90
+ let (child_tree, child_summary) =
91
+ match build_task_index(&child_tasks_dir, child_done_task_limit) {
92
+ Ok(v) => v,
93
+ Err(_) => continue,
94
+ };
95
+
96
+ let child_filtered = if filters.any_active() {
97
+ filter_tree(&child_tree, &filters)
98
+ } else {
99
+ child_tree
100
+ };
101
+
102
+ let child_dir_str = child_dir.to_string_lossy().to_string();
103
+ aggregated.extend(annotate_tasks_json(
104
+ child_filtered,
105
+ &child_name,
106
+ &child_dir_str,
107
+ ));
108
+ merge_summary(&mut total_summary, &child_summary);
109
+ }
110
+
46
111
  let result = serde_json::json!({
47
- "task_tree": filtered_tree,
48
- "task_summary": summary,
112
+ "task_tree": aggregated,
113
+ "task_summary": total_summary,
49
114
  });
50
115
 
51
116
  serde_json::to_string_pretty(&result).context("Failed to serialize task list")
52
117
  }
53
118
 
119
+ fn merge_summary(base: &mut TaskSummary, other: &TaskSummary) {
120
+ base.total += other.total;
121
+ for (status, count) in &other.by_status {
122
+ *base.by_status.entry(status.clone()).or_insert(0) += count;
123
+ }
124
+ base.overdue_count += other.overdue_count;
125
+ if let Some(other_est) = other.total_estimate_hours {
126
+ base.total_estimate_hours = Some(base.total_estimate_hours.unwrap_or(0.0) + other_est);
127
+ }
128
+ if let Some(other_act) = other.total_actual_hours {
129
+ base.total_actual_hours = Some(base.total_actual_hours.unwrap_or(0.0) + other_act);
130
+ }
131
+ if base.total > 0 {
132
+ let done = *base.by_status.get("done").unwrap_or(&0) as f64;
133
+ let skipped = *base.by_status.get("skipped").unwrap_or(&0) as f64;
134
+ base.completion_rate = Some((done + skipped) / base.total as f64);
135
+ }
136
+ }
137
+
138
+ /// Annotate a task tree with `project_name`/`project_dir` and a composite
139
+ /// `task_ref` disambiguator (`{project_name}-{project_dir_hash}:{id}`) for
140
+ /// cross-project display purposes.
141
+ ///
142
+ /// The original `id` field is deliberately left **unmodified**: it is the
143
+ /// value accepted by `handoff_get_task`/`handoff_update_task`/etc. (scoped by
144
+ /// the sibling `project_dir` field), and it is also what `dependencies`
145
+ /// entries reference. Rewriting `id` would make it unusable with every other
146
+ /// tool and would desync it from `dependencies`, which still contain raw ids.
147
+ ///
148
+ /// `project_name` alone is free-form user text with no cross-directory
149
+ /// uniqueness guarantee, so two child projects can share the same name. The
150
+ /// `task_ref` additionally embeds a short hash of `project_dir` (which is
151
+ /// unique per filesystem location) to guarantee it never collides even when
152
+ /// `project_name` is duplicated.
153
+ fn annotate_tasks_json(tasks: Vec<TaskIndex>, project_name: &str, project_dir: &str) -> Vec<Value> {
154
+ let dir_hash = short_hash(project_dir);
155
+ tasks
156
+ .into_iter()
157
+ .map(|t| {
158
+ let mut v = serde_json::to_value(&t).unwrap_or_default();
159
+ if let Some(obj) = v.as_object_mut() {
160
+ obj.insert("project_name".to_string(), json!(project_name));
161
+ obj.insert("project_dir".to_string(), json!(project_dir));
162
+ if let Some(id) = obj.get("id").and_then(|v| v.as_str()).map(String::from) {
163
+ obj.insert(
164
+ "task_ref".to_string(),
165
+ json!(format!("{project_name}-{dir_hash}:{id}")),
166
+ );
167
+ }
168
+ if let Some(children) = obj.remove("children") {
169
+ if let Ok(child_tasks) = serde_json::from_value::<Vec<TaskIndex>>(children) {
170
+ obj.insert(
171
+ "children".to_string(),
172
+ json!(annotate_tasks_json(child_tasks, project_name, project_dir)),
173
+ );
174
+ }
175
+ }
176
+ }
177
+ v
178
+ })
179
+ .collect()
180
+ }
181
+
182
+ /// Deterministic short hash (FNV-1a, hex-encoded) used to disambiguate
183
+ /// composite task IDs across child projects that share a `project_name`.
184
+ /// Not a dependency addition — plain FNV-1a is sufficient since this only
185
+ /// needs to be stable and collision-resistant for distinct directory paths,
186
+ /// not cryptographically secure.
187
+ fn short_hash(input: &str) -> String {
188
+ const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
189
+ const FNV_PRIME: u64 = 0x100000001b3;
190
+ let mut hash = FNV_OFFSET_BASIS;
191
+ for byte in input.as_bytes() {
192
+ hash ^= u64::from(*byte);
193
+ hash = hash.wrapping_mul(FNV_PRIME);
194
+ }
195
+ format!("{hash:08x}")
196
+ }
197
+
198
+ fn discover_child_projects(base: &Path, max_depth: usize) -> Vec<(String, PathBuf)> {
199
+ let mut results = Vec::new();
200
+ scan_children(base, 1, max_depth, &mut results);
201
+ results
202
+ }
203
+
204
+ fn scan_children(dir: &Path, depth: usize, max_depth: usize, results: &mut Vec<(String, PathBuf)>) {
205
+ if depth > max_depth {
206
+ return;
207
+ }
208
+ let entries = match std::fs::read_dir(dir) {
209
+ Ok(e) => e,
210
+ Err(_) => return,
211
+ };
212
+ for entry in entries.flatten() {
213
+ if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
214
+ continue;
215
+ }
216
+ let name = entry.file_name();
217
+ let name_str = name.to_string_lossy();
218
+ if name_str.starts_with('.') || DEFAULT_SCAN_EXCLUDES.contains(&name_str.as_ref()) {
219
+ continue;
220
+ }
221
+ let path = entry.path();
222
+ let config_path = path.join(".handoff").join("config.toml");
223
+ if config_path.exists() {
224
+ if let Ok(config) = read_config(&config_path) {
225
+ results.push((config.project.name.clone(), path.clone()));
226
+ }
227
+ }
228
+ scan_children(&path, depth + 1, max_depth, results);
229
+ }
230
+ }
231
+
54
232
  struct Filters<'a> {
55
233
  status: Option<&'a str>,
56
234
  assignee: Option<&'a str>,
@@ -1,3 +1,5 @@
1
+ use std::path::Path;
2
+
1
3
  use anyhow::{Context, Result};
2
4
  use serde_json::Value;
3
5
 
@@ -12,6 +14,13 @@ use crate::storage::sessions::{
12
14
  use crate::storage::tasks::{build_task_index, TaskIndex};
13
15
  use crate::storage::{ensure_handoff_exists, handoff_dir};
14
16
 
17
+ /// Maximum depth (relative to the base project dir) scanned for nested
18
+ /// `.handoff/` child projects.
19
+ const MAX_CHILD_SCAN_DEPTH: usize = 5;
20
+
21
+ /// Directory names skipped while scanning for child projects.
22
+ const DEFAULT_SCAN_EXCLUDES: &[&str] = &["node_modules", ".git", "target", "dist", ".next"];
23
+
15
24
  pub fn handle(arguments: &Value) -> Result<String> {
16
25
  let project_dir = resolve_project_dir(arguments)?;
17
26
 
@@ -261,6 +270,10 @@ pub fn handle(arguments: &Value) -> Result<String> {
261
270
  });
262
271
  }
263
272
 
273
+ // Always include child_projects (empty array if none).
274
+ let child_projects = discover_child_project_info(&project_dir);
275
+ result["child_projects"] = serde_json::json!(child_projects);
276
+
264
277
  serde_json::to_string_pretty(&result).context("Failed to serialize context")
265
278
  }
266
279
 
@@ -301,3 +314,48 @@ fn collect_active_ids_recursive(tasks: &[TaskIndex], ids: &mut Vec<String>) {
301
314
  collect_active_ids_recursive(&task.children, ids);
302
315
  }
303
316
  }
317
+
318
+ fn discover_child_project_info(base: &Path) -> Vec<Value> {
319
+ let mut results = Vec::new();
320
+ scan_for_children(base, 1, MAX_CHILD_SCAN_DEPTH, &mut results);
321
+ results
322
+ }
323
+
324
+ fn scan_for_children(dir: &Path, depth: usize, max_depth: usize, results: &mut Vec<Value>) {
325
+ if depth > max_depth {
326
+ return;
327
+ }
328
+ let entries = match std::fs::read_dir(dir) {
329
+ Ok(e) => e,
330
+ Err(_) => return,
331
+ };
332
+ for entry in entries.flatten() {
333
+ if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
334
+ continue;
335
+ }
336
+ let name = entry.file_name();
337
+ let name_str = name.to_string_lossy();
338
+ if name_str.starts_with('.') || DEFAULT_SCAN_EXCLUDES.contains(&name_str.as_ref()) {
339
+ continue;
340
+ }
341
+ let path = entry.path();
342
+ let handoff_dir = path.join(".handoff");
343
+ let config_path = handoff_dir.join("config.toml");
344
+ if config_path.exists() {
345
+ if let Ok(config) = read_config(&config_path) {
346
+ let tasks_dir = handoff_dir.join("tasks");
347
+ let (_, summary) = match build_task_index(&tasks_dir, 10) {
348
+ Ok(result) => result,
349
+ Err(_) => continue,
350
+ };
351
+ results.push(serde_json::json!({
352
+ "name": config.project.name,
353
+ "dir": path.to_string_lossy(),
354
+ "task_count": summary.total,
355
+ "status_summary": summary.by_status,
356
+ }));
357
+ }
358
+ }
359
+ scan_for_children(&path, depth + 1, max_depth, results);
360
+ }
361
+ }
@@ -35,11 +35,16 @@ use serde_json::Value;
35
35
  use crate::mcp::types::JsonRpcResponse;
36
36
 
37
37
  pub fn resolve_project_dir(arguments: &Value) -> Result<PathBuf> {
38
- let raw = match arguments.get("project_dir").and_then(|v| v.as_str()) {
38
+ let raw = match arguments
39
+ .get("project_dir")
40
+ .and_then(|v| v.as_str())
41
+ .filter(|s| !s.is_empty() && !s.starts_with("${"))
42
+ {
39
43
  Some(dir) => PathBuf::from(dir),
40
44
  None => std::env::current_dir().context("Failed to get current directory")?,
41
45
  };
42
- std::fs::canonicalize(&raw).with_context(|| format!("Invalid project path: {}", raw.display()))
46
+ std::fs::canonicalize(&raw)
47
+ .with_context(|| format!("Invalid project path: {raw}", raw = raw.display()))
43
48
  }
44
49
 
45
50
  pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
package/src/mcp/tools.rs CHANGED
@@ -221,6 +221,10 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
221
221
  "label_filter": {
222
222
  "type": "string",
223
223
  "description": "Filter by label (task must contain this label)."
224
+ },
225
+ "include_children": {
226
+ "type": "boolean",
227
+ "description": "If true, recursively scan project_dir for child .handoff/ projects and include their tasks. Each task gets project_name, project_dir, and task_ref fields (task_ref is a composite '{project_name}-{hash}:{id}' identifier unique across projects). The original 'id' field is left unchanged so it stays usable with handoff_get_task/handoff_update_task/dependencies when paired with the task's own project_dir. Default: false."
224
228
  }
225
229
  }
226
230
  }),
@@ -395,6 +399,15 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
395
399
  "items": { "type": "string" },
396
400
  "description": "Directories to scan. Defaults to config's dashboard.scan_dirs."
397
401
  },
402
+ "max_depth": {
403
+ "type": "integer",
404
+ "description": "Maximum directory depth for recursive scanning. Defaults to config's dashboard.max_depth (5)."
405
+ },
406
+ "exclude_patterns": {
407
+ "type": "array",
408
+ "items": { "type": "string" },
409
+ "description": "Directory names to skip during recursive scanning (exact match). Defaults to config's dashboard.exclude_patterns."
410
+ },
398
411
  "include_completed": {
399
412
  "type": "boolean",
400
413
  "description": "Include completed tasks in summary"
@@ -103,6 +103,9 @@ pub struct DashboardConfig {
103
103
  pub scan_dirs: Vec<String>,
104
104
  #[serde(default)]
105
105
  pub exclude_patterns: Vec<String>,
106
+ /// Maximum directory depth for recursive scanning. Default 5 (mirrors VSCode side).
107
+ #[serde(default = "default_max_depth")]
108
+ pub max_depth: usize,
106
109
  }
107
110
 
108
111
  /// Project-wide working calendar. Mirrors VSCode `CalendarConfig`.
@@ -280,6 +283,10 @@ fn default_scan_dirs() -> Vec<String> {
280
283
  vec!["~/pro/".to_string()]
281
284
  }
282
285
 
286
+ fn default_max_depth() -> usize {
287
+ 5
288
+ }
289
+
283
290
  impl Default for SettingsConfig {
284
291
  fn default() -> Self {
285
292
  Self {
@@ -309,6 +316,7 @@ impl Default for DashboardConfig {
309
316
  Self {
310
317
  scan_dirs: default_scan_dirs(),
311
318
  exclude_patterns: Vec::new(),
319
+ max_depth: default_max_depth(),
312
320
  }
313
321
  }
314
322
  }