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 +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/scripts/install-local.sh +97 -0
- package/src/mcp/handlers/config.rs +11 -0
- package/src/mcp/handlers/dashboard.rs +118 -17
- package/src/mcp/handlers/list_tasks.rs +182 -4
- package/src/mcp/handlers/load_context.rs +58 -0
- package/src/mcp/handlers/mod.rs +7 -2
- package/src/mcp/tools.rs +13 -0
- package/src/storage/config.rs +8 -0
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.
|
|
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
|
|
36
|
-
Ok(e) => e,
|
|
37
|
-
Err(_) => continue,
|
|
38
|
-
};
|
|
35
|
+
let (max_depth, exclude_patterns) = resolve_scan_config(expanded_path, arguments);
|
|
39
36
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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":
|
|
48
|
-
"task_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
|
+
}
|
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -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
|
|
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)
|
|
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"
|
package/src/storage/config.rs
CHANGED
|
@@ -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
|
}
|