handoff-mcp-server 0.22.0 → 0.23.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 +2 -2
- package/README.md +4 -4
- package/package.json +1 -1
- package/skills/handoff/SKILL.md +11 -9
- package/skills/handoff-docs/SKILL.md +55 -1
- package/src/mcp/handlers/bulk_update.rs +2 -3
- package/src/mcp/handlers/docs.rs +449 -0
- package/src/mcp/handlers/mod.rs +4 -0
- package/src/mcp/handlers/task_checklist.rs +495 -0
- package/src/mcp/tools.rs +49 -8
- package/src/storage/docs/mod.rs +119 -0
- package/src/storage/tasks.rs +7 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "handoff-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.23.0"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
description = "MCP server that gives AI coding agents persistent memory across sessions"
|
|
6
6
|
license = "MIT"
|
|
@@ -10,7 +10,7 @@ readme = "README.md"
|
|
|
10
10
|
keywords = ["mcp", "ai", "claude", "handoff", "context"]
|
|
11
11
|
categories = ["command-line-utilities", "development-tools"]
|
|
12
12
|
rust-version = "1.85"
|
|
13
|
-
exclude = ["lefthook.yml", "tmp/", "wiki/", "
|
|
13
|
+
exclude = ["lefthook.yml", "tmp/", "wiki/", ".claude/", ".vscode/"]
|
|
14
14
|
|
|
15
15
|
[dependencies]
|
|
16
16
|
serde = { version = "1", features = ["derive"] }
|
package/README.md
CHANGED
|
@@ -556,10 +556,10 @@ handoff-mcp distinguishes the **raw human-effort estimate** you record on a task
|
|
|
556
556
|
from the **AI-effort hours** used in scheduling and metrics:
|
|
557
557
|
|
|
558
558
|
- **`require_estimate_hours`** (default `true`) — `handoff_update_task` rejects
|
|
559
|
-
creating or updating a *leaf* task
|
|
560
|
-
`
|
|
561
|
-
`blocked` / `skipped`
|
|
562
|
-
|
|
559
|
+
creating or updating a *leaf* task in `in_progress` / `review` / `done` without
|
|
560
|
+
`schedule.estimate_hours > 0`. Parent tasks (with children) and tasks in `todo` /
|
|
561
|
+
`blocked` / `skipped` are exempt, and an estimate already on the task satisfies
|
|
562
|
+
the requirement. Set to `false` to opt out.
|
|
563
563
|
- **`ai_estimate_multiplier`** (default `0.2`) — the factor applied to raw
|
|
564
564
|
estimates to model how long the work takes when an AI agent does it. Always
|
|
565
565
|
record the *raw human-effort* estimate; the multiplier is applied at
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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>",
|
package/skills/handoff/SKILL.md
CHANGED
|
@@ -54,10 +54,11 @@ 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
|
-
- **
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
- **Set `schedule.estimate_hours`** (raw human-effort hours, > 0) on leaf tasks
|
|
58
|
+
when moving them to `in_progress`/`review`/`done`. It is required by default —
|
|
59
|
+
`handoff_update_task` rejects leaf tasks in those statuses without it. Tasks in
|
|
60
|
+
`todo`/`blocked`/`skipped` and parent tasks (with children) are exempt. Enter the
|
|
61
|
+
raw human-effort estimate; the AI-effort multiplier
|
|
61
62
|
(`settings.ai_estimate_multiplier`, default 0.2) is applied automatically at
|
|
62
63
|
aggregation time by `handoff_get_metrics`/`handoff_get_capacity`. To turn the
|
|
63
64
|
requirement off, set `settings.require_estimate_hours = false`.
|
|
@@ -70,7 +71,7 @@ missing `estimate_hours` is refused, costing a round trip.
|
|
|
70
71
|
- [ ] `title` — present (required for any new task)
|
|
71
72
|
- [ ] `done_criteria` — verifiable items, not restatements of the title
|
|
72
73
|
- [ ] `schedule.estimate_hours` — **> 0, raw human-effort hours.** Skip only if
|
|
73
|
-
the task is a parent (has children) or its status is `blocked`/`skipped`
|
|
74
|
+
the task is a parent (has children) or its status is `todo`/`blocked`/`skipped`
|
|
74
75
|
- [ ] `priority` — `low` / `medium` / `high`
|
|
75
76
|
- [ ] `labels` — at least one, so the task is findable by filter
|
|
76
77
|
- [ ] `assignee` — matches a key in `config.toml [assignees.<key>]`
|
|
@@ -234,10 +235,10 @@ Use `handoff_bulk_update_tasks` for:
|
|
|
234
235
|
- Batch assignee changes (e.g., reassigning a team member's tasks).
|
|
235
236
|
- Each task update is independent — failures on one task don't roll back others.
|
|
236
237
|
- The `estimate_hours` rule applies here too: an update leaving a leaf task in
|
|
237
|
-
`
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
`blocked`/`skipped` are exempt.
|
|
238
|
+
`in_progress`/`review`/`done` without `schedule.estimate_hours` is rejected and
|
|
239
|
+
reported in `errors[]`. Supply the estimate in the same update when moving a
|
|
240
|
+
task out of `todo`/`blocked`/`skipped`. Parent tasks and the statuses
|
|
241
|
+
`todo`/`blocked`/`skipped` are exempt.
|
|
241
242
|
|
|
242
243
|
### Document Management
|
|
243
244
|
|
|
@@ -256,6 +257,7 @@ are too large for a single memory entry, use the doc tools instead — see the
|
|
|
256
257
|
| `handoff_doc_query` | Context injection (hook-driven) — staged `full`/`outline` results by fragment size |
|
|
257
258
|
| `handoff_doc_analyze` | Read-only heuristic scan of a file/directory — step 1 of importing existing docs |
|
|
258
259
|
| `handoff_doc_import` | Atomic bulk write of analyzed + AI-reviewed documents — step 3 of importing existing docs |
|
|
260
|
+
| `handoff_task_checklist` | Combined readiness view for a task — aggregates `done_criteria` with the verification matrices of its linked documents (`action="view"`, read-only); `action="generate"` builds `done_criteria` from a linked spec's sections |
|
|
259
261
|
|
|
260
262
|
`handoff_doc_save(task_ids: [...])` creates a **bidirectional** doc↔task
|
|
261
263
|
link: the document gains a `task_ids` entry and each linked task gains a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: handoff-docs
|
|
3
|
-
description: "Document management — save, read, search, import, and traverse structured project documents (specs, designs, ADRs, guides, notes). Triggers on 'ドキュメント保存', '仕様書を管理', '設計書をインポート', 'save this doc', 'import specs', 'document management', or when the user asks to persist/organize/search multi-section markdown that is too structured for a single memory entry."
|
|
3
|
+
description: "Document management — save, read, search, import, and traverse structured project documents (specs, designs, ADRs, guides, notes). Triggers on 'ドキュメント保存', '仕様書を管理', '設計書をインポート', 'save this doc', 'import specs', 'document management', 'タスク開始', 'spec registration', '仕様登録', 'verification check', or when the user asks to persist/organize/search multi-section markdown that is too structured for a single memory entry, or when the AI writes a spec/design document during development."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Handoff Docs Skill
|
|
@@ -17,6 +17,32 @@ If the knowledge is a short, standalone lesson/rule/convention/gotcha (< 1 page,
|
|
|
17
17
|
no internal sections), use `handoff-memory` instead — see "Memory vs Documents"
|
|
18
18
|
in `skills/handoff-memory/SKILL.md` for the boundary.
|
|
19
19
|
|
|
20
|
+
## Development Flow Integration
|
|
21
|
+
|
|
22
|
+
Document management is NOT just for explicit user requests. It activates
|
|
23
|
+
automatically during the standard development cycle:
|
|
24
|
+
|
|
25
|
+
### When writing a spec or design
|
|
26
|
+
After writing/updating a specification or finishing a `/design-review` session
|
|
27
|
+
(wiki/ or tmp/), immediately:
|
|
28
|
+
1. Start from a template (see "Templates" below) instead of a blank document.
|
|
29
|
+
2. `handoff_doc_save(title=..., body=..., doc_type="spec", task_ids=[...])`
|
|
30
|
+
3. `handoff_doc_verify(doc_id=..., action="generate")` to create the verification matrix
|
|
31
|
+
|
|
32
|
+
### When starting a task
|
|
33
|
+
Before implementation, fetch related specs:
|
|
34
|
+
- `handoff_doc_query(task_id="<task-id>")` — surfaces linked documents automatically
|
|
35
|
+
- Review the verification matrix: `handoff_doc_verify_status(doc_id=...)`
|
|
36
|
+
|
|
37
|
+
### When implementation is complete
|
|
38
|
+
Mark verified sections:
|
|
39
|
+
- `handoff_doc_verify(doc_id=..., action="check", fragment_seq=N)` for each completed section
|
|
40
|
+
- `handoff_doc_verify(doc_id=..., action="set_refs", fragment_seq=N, impl_refs=[...])` to record implementation locations
|
|
41
|
+
|
|
42
|
+
### When reviewing
|
|
43
|
+
Check readiness:
|
|
44
|
+
- `handoff_task_checklist(task_id=..., action="view")` — combined readiness view
|
|
45
|
+
|
|
20
46
|
## The 9 Doc Tools
|
|
21
47
|
|
|
22
48
|
| Tool | Purpose |
|
|
@@ -231,3 +257,31 @@ only be validated when every file is visible at once.
|
|
|
231
257
|
`spec` (requirements/behavior contracts) · `design` (architecture/design
|
|
232
258
|
docs) · `adr` (architecture decision records) · `guide` (how-to/operational
|
|
233
259
|
docs) · `note` (fallback — anything that doesn't fit the above).
|
|
260
|
+
|
|
261
|
+
## Templates
|
|
262
|
+
|
|
263
|
+
Three starter templates are registered as `doc_type="guide"` documents tagged
|
|
264
|
+
`template` (plus a type-specific tag: `spec`, `design`, or `adr`). Fetch one
|
|
265
|
+
with `handoff_doc_list(tags=["template"])` or `handoff_doc_get(doc_id=...)`
|
|
266
|
+
before writing a new spec/design/ADR from scratch — copy its section
|
|
267
|
+
structure rather than reinventing it:
|
|
268
|
+
|
|
269
|
+
| Template | Tags | Structure |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| Specification Template (`specification-template`) | `template`, `spec` | 課題 / ゴール / 設計 / 実装計画 / 検証チェックリスト / 未決事項 |
|
|
272
|
+
| Design Document Template (`design-doc-template`) | `template`, `design` | 概要 / 制約・前提 / 設計案(採用案・代替案)/ トレードオフ表 / 実装影響範囲 / リスク |
|
|
273
|
+
| ADR Template (`adr-template`) | `template`, `adr` | コンテキスト / 決定 / 理由 / 結果 |
|
|
274
|
+
|
|
275
|
+
Templates are registered with `auto_inject="none"` — they are reference
|
|
276
|
+
material fetched on demand, not injected into every prompt.
|
|
277
|
+
|
|
278
|
+
## Memory vs Documents
|
|
279
|
+
|
|
280
|
+
| Criterion | Use Memory | Use Documents |
|
|
281
|
+
|---|---|---|
|
|
282
|
+
| Size | < 1 page, no sections | Multi-section, structured |
|
|
283
|
+
| Lifecycle | Permanent lesson/rule | Versioned with the project |
|
|
284
|
+
| Granularity | Single fact/convention | Sections need independent tracking |
|
|
285
|
+
| Review tracking | Not needed | Verification matrix tracks per-section |
|
|
286
|
+
| Task linkage | Not applicable | Bidirectional task_ids |
|
|
287
|
+
| Example | "Always use SSH for git push" | "Authentication spec with 5 sections" |
|
|
@@ -131,9 +131,8 @@ fn apply_single_update(
|
|
|
131
131
|
|
|
132
132
|
// Guard the same invariant handoff_update_task enforces, or a bulk patch
|
|
133
133
|
// becomes a way around it. The check is on the task as it would be written —
|
|
134
|
-
// status and schedule already merged — not on the patch
|
|
135
|
-
//
|
|
136
|
-
// update_task refuses to write. Parent tasks (with children) are exempt.
|
|
134
|
+
// status and schedule already merged — not on the patch. Parent tasks (with
|
|
135
|
+
// children) and tasks in todo/blocked/skipped are exempt.
|
|
137
136
|
let has_children = task_has_children(&task_dir)?;
|
|
138
137
|
validate_estimate_required(
|
|
139
138
|
require_estimate_hours,
|
package/src/mcp/handlers/docs.rs
CHANGED
|
@@ -1002,6 +1002,323 @@ pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
|
|
|
1002
1002
|
Ok(to_json(&out))
|
|
1003
1003
|
}
|
|
1004
1004
|
|
|
1005
|
+
/// `handoff_doc_graph` — build a graph of every document in the project:
|
|
1006
|
+
/// `nodes[]` (one per document, with optional verification progress),
|
|
1007
|
+
/// `edges[]` (explicit parent_child/related links, plus implicit
|
|
1008
|
+
/// shared_task/shared_scope links when `include_implicit=true`), and
|
|
1009
|
+
/// `layers` (doc ids grouped by `doc_type`).
|
|
1010
|
+
pub fn handle_doc_graph(arguments: &Value) -> Result<String> {
|
|
1011
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
1012
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
1013
|
+
|
|
1014
|
+
let include_implicit = arguments
|
|
1015
|
+
.get("include_implicit")
|
|
1016
|
+
.and_then(|v| v.as_bool())
|
|
1017
|
+
.unwrap_or(true);
|
|
1018
|
+
let include_verification = arguments
|
|
1019
|
+
.get("include_verification")
|
|
1020
|
+
.and_then(|v| v.as_bool())
|
|
1021
|
+
.unwrap_or(false);
|
|
1022
|
+
|
|
1023
|
+
let docs = read_all_docs(&handoff)?;
|
|
1024
|
+
|
|
1025
|
+
let nodes: Vec<Value> = docs
|
|
1026
|
+
.iter()
|
|
1027
|
+
.map(|d| doc_graph_node_json(d, include_verification))
|
|
1028
|
+
.collect();
|
|
1029
|
+
|
|
1030
|
+
let mut edges = doc_graph_explicit_edges(&docs);
|
|
1031
|
+
if include_implicit {
|
|
1032
|
+
edges.extend(doc_graph_implicit_edges(&docs));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
let mut layers: std::collections::BTreeMap<String, Vec<String>> =
|
|
1036
|
+
std::collections::BTreeMap::new();
|
|
1037
|
+
for d in &docs {
|
|
1038
|
+
layers
|
|
1039
|
+
.entry(d.doc_type.clone())
|
|
1040
|
+
.or_default()
|
|
1041
|
+
.push(d.id.clone());
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
Ok(to_json(&json!({
|
|
1045
|
+
"nodes": nodes,
|
|
1046
|
+
"edges": edges,
|
|
1047
|
+
"layers": layers,
|
|
1048
|
+
})))
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/// Builds one `handoff_doc_graph` node: id/slug/title/doc_type/tags/task_ids
|
|
1052
|
+
/// /section_count/updated_at, plus `verification_progress` when requested
|
|
1053
|
+
/// (and the document has a verification matrix).
|
|
1054
|
+
fn doc_graph_node_json(doc: &DocMetadata, include_verification: bool) -> Value {
|
|
1055
|
+
let mut node = json!({
|
|
1056
|
+
"id": doc.id,
|
|
1057
|
+
"slug": doc.slug,
|
|
1058
|
+
"title": doc.title,
|
|
1059
|
+
"doc_type": doc.doc_type,
|
|
1060
|
+
"tags": doc.tags,
|
|
1061
|
+
"task_ids": doc.task_ids,
|
|
1062
|
+
"section_count": doc.sections.len(),
|
|
1063
|
+
"updated_at": doc.updated_at,
|
|
1064
|
+
});
|
|
1065
|
+
if include_verification {
|
|
1066
|
+
if let Some(v) = &doc.verification {
|
|
1067
|
+
let total = v.items.len();
|
|
1068
|
+
let verified = v.items.iter().filter(|i| i.status == "verified").count();
|
|
1069
|
+
node["verification_progress"] = json!({ "total": total, "verified": verified });
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
node
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/// Explicit edges: `parent_id` (`type="parent_child"`, `direction="down"`,
|
|
1076
|
+
/// from=parent to=child) and `related[]` (`type=<rel>`,
|
|
1077
|
+
/// `direction="forward"`, from=this doc to=related target). Related entries
|
|
1078
|
+
/// pointing at an id not present in `docs` are still emitted — the graph
|
|
1079
|
+
/// consumer is expected to render dangling links, not silently drop them.
|
|
1080
|
+
fn doc_graph_explicit_edges(docs: &[DocMetadata]) -> Vec<Value> {
|
|
1081
|
+
let mut edges = Vec::new();
|
|
1082
|
+
for d in docs {
|
|
1083
|
+
if let Some(parent_id) = &d.parent_id {
|
|
1084
|
+
edges.push(json!({
|
|
1085
|
+
"from": parent_id,
|
|
1086
|
+
"to": d.id,
|
|
1087
|
+
"type": "parent_child",
|
|
1088
|
+
"direction": "down",
|
|
1089
|
+
}));
|
|
1090
|
+
}
|
|
1091
|
+
for r in &d.related {
|
|
1092
|
+
edges.push(json!({
|
|
1093
|
+
"from": d.id,
|
|
1094
|
+
"to": r.id,
|
|
1095
|
+
"type": r.rel,
|
|
1096
|
+
"direction": "forward",
|
|
1097
|
+
}));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
edges
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/// Implicit edges: `shared_task` (two documents sharing at least one
|
|
1104
|
+
/// `task_ids` entry — `task_ids` on the edge lists every id shared, not just
|
|
1105
|
+
/// the first) and `shared_scope` (two documents sharing at least one
|
|
1106
|
+
/// `scope_paths` entry). Both are unordered/undirected pairs, emitted once
|
|
1107
|
+
/// per pair (i<j) to avoid duplicating the same relationship in both
|
|
1108
|
+
/// directions.
|
|
1109
|
+
fn doc_graph_implicit_edges(docs: &[DocMetadata]) -> Vec<Value> {
|
|
1110
|
+
let mut edges = Vec::new();
|
|
1111
|
+
for i in 0..docs.len() {
|
|
1112
|
+
for j in (i + 1)..docs.len() {
|
|
1113
|
+
let a = &docs[i];
|
|
1114
|
+
let b = &docs[j];
|
|
1115
|
+
|
|
1116
|
+
let shared_tasks: Vec<String> = a
|
|
1117
|
+
.task_ids
|
|
1118
|
+
.iter()
|
|
1119
|
+
.filter(|t| b.task_ids.contains(t))
|
|
1120
|
+
.cloned()
|
|
1121
|
+
.collect();
|
|
1122
|
+
if !shared_tasks.is_empty() {
|
|
1123
|
+
edges.push(json!({
|
|
1124
|
+
"from": a.id,
|
|
1125
|
+
"to": b.id,
|
|
1126
|
+
"type": "shared_task",
|
|
1127
|
+
"task_ids": shared_tasks,
|
|
1128
|
+
}));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
let shares_scope = a.scope_paths.iter().any(|p| b.scope_paths.contains(p));
|
|
1132
|
+
if shares_scope {
|
|
1133
|
+
edges.push(json!({
|
|
1134
|
+
"from": a.id,
|
|
1135
|
+
"to": b.id,
|
|
1136
|
+
"type": "shared_scope",
|
|
1137
|
+
}));
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
edges
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/// One entry in a `handoff_doc_trace` `chain[]`/`branches[].docs[]`:
|
|
1145
|
+
/// `{id, title, doc_type, rel}`. `rel` describes how this doc relates to the
|
|
1146
|
+
/// previous entry in the chain ("parent", "child", or the `related[].rel`
|
|
1147
|
+
/// value for a related-doc detour); `None` for the trace's starting doc.
|
|
1148
|
+
fn doc_trace_item_json(doc: &DocMetadata, rel: Option<&str>) -> Value {
|
|
1149
|
+
json!({
|
|
1150
|
+
"id": doc.id,
|
|
1151
|
+
"title": doc.title,
|
|
1152
|
+
"doc_type": doc.doc_type,
|
|
1153
|
+
"rel": rel,
|
|
1154
|
+
})
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/// Walks the child->parent chain starting at `doc` (exclusive — `doc` itself
|
|
1158
|
+
/// is not included), ordered from the immediate parent up to the root.
|
|
1159
|
+
/// `visited` prevents infinite loops on a cyclic `parent_id` graph; a doc
|
|
1160
|
+
/// already visited (including `doc` itself) stops the walk rather than
|
|
1161
|
+
/// erroring.
|
|
1162
|
+
fn doc_trace_walk_up(
|
|
1163
|
+
handoff: &Path,
|
|
1164
|
+
doc: &DocMetadata,
|
|
1165
|
+
visited: &mut std::collections::HashSet<String>,
|
|
1166
|
+
) -> Result<Vec<Value>> {
|
|
1167
|
+
let mut out = Vec::new();
|
|
1168
|
+
let mut current = doc.clone();
|
|
1169
|
+
while let Some(parent_id) = current.parent_id.clone() {
|
|
1170
|
+
if visited.contains(&parent_id) {
|
|
1171
|
+
break;
|
|
1172
|
+
}
|
|
1173
|
+
let Some(parent) = find_doc_by_id(handoff, &parent_id)? else {
|
|
1174
|
+
break;
|
|
1175
|
+
};
|
|
1176
|
+
visited.insert(parent.id.clone());
|
|
1177
|
+
out.push(doc_trace_item_json(&parent, Some("parent")));
|
|
1178
|
+
current = parent;
|
|
1179
|
+
}
|
|
1180
|
+
out.reverse();
|
|
1181
|
+
Ok(out)
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/// Recursively walks parent->children (DFS) starting at `doc` (exclusive).
|
|
1185
|
+
/// Returns the primary descendant chain (first child at each level) plus any
|
|
1186
|
+
/// `branches` recorded for multi-child forks. `visited` prevents infinite
|
|
1187
|
+
/// loops on a cyclic `children` graph.
|
|
1188
|
+
fn doc_trace_walk_down(
|
|
1189
|
+
handoff: &Path,
|
|
1190
|
+
doc: &DocMetadata,
|
|
1191
|
+
visited: &mut std::collections::HashSet<String>,
|
|
1192
|
+
branches: &mut Vec<Value>,
|
|
1193
|
+
) -> Result<Vec<Value>> {
|
|
1194
|
+
let mut children = Vec::new();
|
|
1195
|
+
for child_id in &doc.children {
|
|
1196
|
+
if visited.contains(child_id) {
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
if let Some(child) = find_doc_by_id(handoff, child_id)? {
|
|
1200
|
+
children.push(child);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
if children.is_empty() {
|
|
1205
|
+
return Ok(Vec::new());
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Fork detection: more than one live (non-visited, resolvable) child at
|
|
1209
|
+
// this level. Every child's own sub-chain is recorded under `branches`;
|
|
1210
|
+
// the first child's sub-chain also becomes the primary continuation of
|
|
1211
|
+
// the returned chain, so a single-child level still reads as a plain
|
|
1212
|
+
// linear chain.
|
|
1213
|
+
let is_fork = children.len() > 1;
|
|
1214
|
+
let mut primary_chain = Vec::new();
|
|
1215
|
+
|
|
1216
|
+
for (idx, child) in children.iter().enumerate() {
|
|
1217
|
+
if visited.contains(&child.id) {
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
visited.insert(child.id.clone());
|
|
1221
|
+
let mut sub_chain = vec![doc_trace_item_json(child, Some("child"))];
|
|
1222
|
+
sub_chain.extend(doc_trace_walk_down(handoff, child, visited, branches)?);
|
|
1223
|
+
|
|
1224
|
+
if is_fork {
|
|
1225
|
+
branches.push(json!({
|
|
1226
|
+
"fork_from": doc.id,
|
|
1227
|
+
"docs": sub_chain,
|
|
1228
|
+
}));
|
|
1229
|
+
}
|
|
1230
|
+
if idx == 0 {
|
|
1231
|
+
primary_chain = sub_chain;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
Ok(primary_chain)
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/// Appends `related` (implements/references/etc.) detours for every document
|
|
1239
|
+
/// already present in `chain` (by id), skipping any related id already
|
|
1240
|
+
/// visited. Related docs are appended once, immediately, as a flat list — a
|
|
1241
|
+
/// "detour" from the main chain rather than a further recursive expansion.
|
|
1242
|
+
fn doc_trace_related_detours(
|
|
1243
|
+
handoff: &Path,
|
|
1244
|
+
chain_doc_ids: &[String],
|
|
1245
|
+
visited: &mut std::collections::HashSet<String>,
|
|
1246
|
+
) -> Result<Vec<Value>> {
|
|
1247
|
+
let mut out = Vec::new();
|
|
1248
|
+
for doc_id in chain_doc_ids {
|
|
1249
|
+
let Some(doc) = find_doc_by_id(handoff, doc_id)? else {
|
|
1250
|
+
continue;
|
|
1251
|
+
};
|
|
1252
|
+
for r in &doc.related {
|
|
1253
|
+
if visited.contains(&r.id) {
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
let Some(target) = find_doc_by_id(handoff, &r.id)? else {
|
|
1257
|
+
continue;
|
|
1258
|
+
};
|
|
1259
|
+
visited.insert(target.id.clone());
|
|
1260
|
+
out.push(doc_trace_item_json(&target, Some(&r.rel)));
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
Ok(out)
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/// `handoff_doc_trace` — trace a document's family-tree lineage: `up` (walk
|
|
1267
|
+
/// child->parent), `down` (walk parent->children, DFS), or `both` (merge the
|
|
1268
|
+
/// up chain + the target + the down chain). `related` docs encountered along
|
|
1269
|
+
/// the primary chain are appended as detour entries. Multi-child forks in the
|
|
1270
|
+
/// `down` direction are additionally reported in `branches[]`.
|
|
1271
|
+
pub fn handle_doc_trace(arguments: &Value) -> Result<String> {
|
|
1272
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
1273
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
1274
|
+
|
|
1275
|
+
let doc_id = arguments
|
|
1276
|
+
.get("doc_id")
|
|
1277
|
+
.and_then(|v| v.as_str())
|
|
1278
|
+
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
1279
|
+
let direction = arguments
|
|
1280
|
+
.get("direction")
|
|
1281
|
+
.and_then(|v| v.as_str())
|
|
1282
|
+
.unwrap_or("both");
|
|
1283
|
+
|
|
1284
|
+
let doc = resolve_doc(&handoff, doc_id)?
|
|
1285
|
+
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
1286
|
+
|
|
1287
|
+
let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
1288
|
+
visited.insert(doc.id.clone());
|
|
1289
|
+
|
|
1290
|
+
let mut branches: Vec<Value> = Vec::new();
|
|
1291
|
+
let mut chain: Vec<Value> = Vec::new();
|
|
1292
|
+
|
|
1293
|
+
if direction == "up" || direction == "both" {
|
|
1294
|
+
chain.extend(doc_trace_walk_up(&handoff, &doc, &mut visited)?);
|
|
1295
|
+
}
|
|
1296
|
+
chain.push(doc_trace_item_json(&doc, None));
|
|
1297
|
+
if direction == "down" || direction == "both" {
|
|
1298
|
+
chain.extend(doc_trace_walk_down(
|
|
1299
|
+
&handoff,
|
|
1300
|
+
&doc,
|
|
1301
|
+
&mut visited,
|
|
1302
|
+
&mut branches,
|
|
1303
|
+
)?);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
let chain_doc_ids: Vec<String> = chain
|
|
1307
|
+
.iter()
|
|
1308
|
+
.filter_map(|v| v["id"].as_str().map(str::to_string))
|
|
1309
|
+
.collect();
|
|
1310
|
+
chain.extend(doc_trace_related_detours(
|
|
1311
|
+
&handoff,
|
|
1312
|
+
&chain_doc_ids,
|
|
1313
|
+
&mut visited,
|
|
1314
|
+
)?);
|
|
1315
|
+
|
|
1316
|
+
Ok(to_json(&json!({
|
|
1317
|
+
"chain": chain,
|
|
1318
|
+
"branches": branches,
|
|
1319
|
+
})))
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1005
1322
|
fn doc_metadata_json(doc: &DocMetadata) -> Value {
|
|
1006
1323
|
json!({
|
|
1007
1324
|
"id": doc.id,
|
|
@@ -1041,3 +1358,135 @@ fn string_array_value(v: &Value) -> Vec<String> {
|
|
|
1041
1358
|
fn to_json(v: &Value) -> String {
|
|
1042
1359
|
serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
|
|
1043
1360
|
}
|
|
1361
|
+
|
|
1362
|
+
#[cfg(test)]
|
|
1363
|
+
mod graph_tests {
|
|
1364
|
+
use super::*;
|
|
1365
|
+
|
|
1366
|
+
fn doc(id: &str, slug: &str, doc_type: &str) -> DocMetadata {
|
|
1367
|
+
DocMetadata::new(
|
|
1368
|
+
id.to_string(),
|
|
1369
|
+
slug.to_string(),
|
|
1370
|
+
format!("Title {id}"),
|
|
1371
|
+
doc_type.to_string(),
|
|
1372
|
+
"2026-07-12T00:00:00Z".to_string(),
|
|
1373
|
+
)
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
#[test]
|
|
1377
|
+
fn explicit_edges_include_parent_child_and_related() {
|
|
1378
|
+
let mut parent = doc("doc-1", "parent", "spec");
|
|
1379
|
+
let mut child = doc("doc-2", "child", "design");
|
|
1380
|
+
child.parent_id = Some("doc-1".to_string());
|
|
1381
|
+
parent.children = vec!["doc-2".to_string()];
|
|
1382
|
+
child.related.push(DocRelation {
|
|
1383
|
+
id: "doc-3".to_string(),
|
|
1384
|
+
rel: "implements".to_string(),
|
|
1385
|
+
});
|
|
1386
|
+
let other = doc("doc-3", "other", "note");
|
|
1387
|
+
|
|
1388
|
+
let docs = vec![parent, child, other];
|
|
1389
|
+
let edges = doc_graph_explicit_edges(&docs);
|
|
1390
|
+
|
|
1391
|
+
assert!(edges.iter().any(|e| e["type"] == "parent_child"
|
|
1392
|
+
&& e["from"] == "doc-1"
|
|
1393
|
+
&& e["to"] == "doc-2"
|
|
1394
|
+
&& e["direction"] == "down"));
|
|
1395
|
+
assert!(edges.iter().any(|e| e["type"] == "implements"
|
|
1396
|
+
&& e["from"] == "doc-2"
|
|
1397
|
+
&& e["to"] == "doc-3"
|
|
1398
|
+
&& e["direction"] == "forward"));
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
#[test]
|
|
1402
|
+
fn implicit_edges_detect_shared_task_ids() {
|
|
1403
|
+
let mut a = doc("doc-1", "a", "spec");
|
|
1404
|
+
let mut b = doc("doc-2", "b", "spec");
|
|
1405
|
+
a.task_ids = vec!["t-1".to_string(), "t-2".to_string()];
|
|
1406
|
+
b.task_ids = vec!["t-2".to_string(), "t-3".to_string()];
|
|
1407
|
+
let docs = vec![a, b];
|
|
1408
|
+
|
|
1409
|
+
let edges = doc_graph_implicit_edges(&docs);
|
|
1410
|
+
let shared_task_edge = edges
|
|
1411
|
+
.iter()
|
|
1412
|
+
.find(|e| e["type"] == "shared_task")
|
|
1413
|
+
.expect("shared_task edge must be generated");
|
|
1414
|
+
assert_eq!(shared_task_edge["from"], "doc-1");
|
|
1415
|
+
assert_eq!(shared_task_edge["to"], "doc-2");
|
|
1416
|
+
assert_eq!(shared_task_edge["task_ids"], json!(["t-2"]));
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
#[test]
|
|
1420
|
+
fn implicit_edges_detect_shared_scope_paths() {
|
|
1421
|
+
let mut a = doc("doc-1", "a", "spec");
|
|
1422
|
+
let mut b = doc("doc-2", "b", "spec");
|
|
1423
|
+
a.scope_paths = vec!["src/mcp/".to_string()];
|
|
1424
|
+
b.scope_paths = vec!["src/mcp/".to_string(), "src/storage/".to_string()];
|
|
1425
|
+
let docs = vec![a, b];
|
|
1426
|
+
|
|
1427
|
+
let edges = doc_graph_implicit_edges(&docs);
|
|
1428
|
+
assert!(edges
|
|
1429
|
+
.iter()
|
|
1430
|
+
.any(|e| e["type"] == "shared_scope" && e["from"] == "doc-1" && e["to"] == "doc-2"));
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
#[test]
|
|
1434
|
+
fn implicit_edges_absent_when_nothing_shared() {
|
|
1435
|
+
let a = doc("doc-1", "a", "spec");
|
|
1436
|
+
let b = doc("doc-2", "b", "spec");
|
|
1437
|
+
let docs = vec![a, b];
|
|
1438
|
+
|
|
1439
|
+
let edges = doc_graph_implicit_edges(&docs);
|
|
1440
|
+
assert!(edges.is_empty());
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
#[test]
|
|
1444
|
+
fn graph_node_json_includes_verification_progress_when_requested() {
|
|
1445
|
+
let mut d = doc("doc-1", "a", "spec");
|
|
1446
|
+
d.verification = Some(Verification {
|
|
1447
|
+
status: "in_review".to_string(),
|
|
1448
|
+
created_at: "2026-07-12T00:00:00Z".to_string(),
|
|
1449
|
+
updated_at: "2026-07-12T00:00:00Z".to_string(),
|
|
1450
|
+
items: vec![
|
|
1451
|
+
VerificationItem {
|
|
1452
|
+
fragment_seq: 0,
|
|
1453
|
+
heading: String::new(),
|
|
1454
|
+
status: "verified".to_string(),
|
|
1455
|
+
impl_refs: Vec::new(),
|
|
1456
|
+
test_refs: Vec::new(),
|
|
1457
|
+
reviewer: None,
|
|
1458
|
+
verified_at: None,
|
|
1459
|
+
notes: String::new(),
|
|
1460
|
+
content_hash_at_verify: None,
|
|
1461
|
+
},
|
|
1462
|
+
VerificationItem {
|
|
1463
|
+
fragment_seq: 1,
|
|
1464
|
+
heading: "H".to_string(),
|
|
1465
|
+
status: "pending".to_string(),
|
|
1466
|
+
impl_refs: Vec::new(),
|
|
1467
|
+
test_refs: Vec::new(),
|
|
1468
|
+
reviewer: None,
|
|
1469
|
+
verified_at: None,
|
|
1470
|
+
notes: String::new(),
|
|
1471
|
+
content_hash_at_verify: None,
|
|
1472
|
+
},
|
|
1473
|
+
],
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
let with_verification = doc_graph_node_json(&d, true);
|
|
1477
|
+
assert_eq!(
|
|
1478
|
+
with_verification["verification_progress"],
|
|
1479
|
+
json!({ "total": 2, "verified": 1 })
|
|
1480
|
+
);
|
|
1481
|
+
|
|
1482
|
+
let without_verification = doc_graph_node_json(&d, false);
|
|
1483
|
+
assert!(without_verification.get("verification_progress").is_none());
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
#[test]
|
|
1487
|
+
fn graph_node_json_omits_verification_progress_when_no_matrix() {
|
|
1488
|
+
let d = doc("doc-1", "a", "spec");
|
|
1489
|
+
let node = doc_graph_node_json(&d, true);
|
|
1490
|
+
assert!(node.get("verification_progress").is_none());
|
|
1491
|
+
}
|
|
1492
|
+
}
|
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -25,6 +25,7 @@ pub mod milestones;
|
|
|
25
25
|
pub mod refer;
|
|
26
26
|
pub mod referrals;
|
|
27
27
|
pub mod save_context;
|
|
28
|
+
pub mod task_checklist;
|
|
28
29
|
pub mod timer;
|
|
29
30
|
pub mod update_session;
|
|
30
31
|
pub mod update_task;
|
|
@@ -103,11 +104,14 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
|
|
|
103
104
|
"handoff_doc_delete" => docs::handle_doc_delete(arguments),
|
|
104
105
|
"handoff_doc_reassemble" => docs::handle_doc_reassemble(arguments),
|
|
105
106
|
"handoff_doc_tree" => docs::handle_doc_tree(arguments),
|
|
107
|
+
"handoff_doc_graph" => docs::handle_doc_graph(arguments),
|
|
108
|
+
"handoff_doc_trace" => docs::handle_doc_trace(arguments),
|
|
106
109
|
"handoff_doc_verify" => docs::handle_doc_verify(arguments),
|
|
107
110
|
"handoff_doc_verify_status" => docs::handle_doc_verify_status(arguments),
|
|
108
111
|
"handoff_doc_query" => docs_query::handle_doc_query(arguments),
|
|
109
112
|
"handoff_doc_analyze" => docs_query::handle_doc_analyze(arguments),
|
|
110
113
|
"handoff_doc_import" => docs_query::handle_doc_import(arguments),
|
|
114
|
+
"handoff_task_checklist" => task_checklist::handle(arguments),
|
|
111
115
|
_ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
|
|
112
116
|
};
|
|
113
117
|
|