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 CHANGED
@@ -145,7 +145,7 @@ dependencies = [
145
145
 
146
146
  [[package]]
147
147
  name = "handoff-mcp"
148
- version = "0.22.0"
148
+ version = "0.23.0"
149
149
  dependencies = [
150
150
  "anyhow",
151
151
  "chrono",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.22.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/", "docs/", ".claude/", ".vscode/"]
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 (in `todo` / `in_progress` / `review` /
560
- `done`) without `schedule.estimate_hours > 0`. Parent tasks (with children) and
561
- `blocked` / `skipped` tasks are exempt, and an estimate already on the task
562
- satisfies the requirement. Set to `false` to opt out.
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.22.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>",
@@ -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
- - **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
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
- `todo`/`in_progress`/`review`/`done` without `schedule.estimate_hours` is
238
- rejected and reported in `errors[]`. Supply the estimate in the same update
239
- when moving a task out of `blocked`/`skipped`. Parent tasks and the statuses
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: a date-only patch
135
- // that leaves an estimateless leaf in `todo` is exactly the state
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,
@@ -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
+ }
@@ -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