handoff-mcp-server 0.22.0 → 0.22.1

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.22.1"
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.22.1"
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
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>",
@@ -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
+ }
@@ -103,6 +103,8 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
103
103
  "handoff_doc_delete" => docs::handle_doc_delete(arguments),
104
104
  "handoff_doc_reassemble" => docs::handle_doc_reassemble(arguments),
105
105
  "handoff_doc_tree" => docs::handle_doc_tree(arguments),
106
+ "handoff_doc_graph" => docs::handle_doc_graph(arguments),
107
+ "handoff_doc_trace" => docs::handle_doc_trace(arguments),
106
108
  "handoff_doc_verify" => docs::handle_doc_verify(arguments),
107
109
  "handoff_doc_verify_status" => docs::handle_doc_verify_status(arguments),
108
110
  "handoff_doc_query" => docs_query::handle_doc_query(arguments),
package/src/mcp/tools.rs CHANGED
@@ -1358,6 +1358,31 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1358
1358
  "required": ["doc_id"]
1359
1359
  }),
1360
1360
  },
1361
+ ToolDefinition {
1362
+ name: "handoff_doc_graph".to_string(),
1363
+ description: "Build a graph of every document in the project: nodes (one per document, with id/slug/title/doc_type/tags/task_ids/section_count/updated_at, plus verification_progress {total,verified} when include_verification=true and a matrix exists), edges (explicit parent_id -> type='parent_child'/direction='down', explicit related[] -> type=<rel>/direction='forward', and — when include_implicit=true — implicit shared_task edges for documents sharing task_ids and shared_scope edges for documents sharing scope_paths), and layers (doc ids grouped by doc_type). Intended for graph-visualization UIs. Returns a JSON string {nodes:[…],edges:[…],layers:{…}}.".to_string(),
1364
+ input_schema: json!({
1365
+ "type": "object",
1366
+ "properties": {
1367
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1368
+ "include_implicit": { "type": "boolean", "description": "Also emit shared_task/shared_scope implicit edges.", "default": true },
1369
+ "include_verification": { "type": "boolean", "description": "Attach verification_progress {total,verified} to each node that has a verification matrix.", "default": false }
1370
+ }
1371
+ }),
1372
+ },
1373
+ ToolDefinition {
1374
+ name: "handoff_doc_trace".to_string(),
1375
+ description: "Trace a document's family-tree lineage from doc_id (id or slug): direction='up' walks the child->parent chain to the root; 'down' walks parent->children (DFS); 'both' (default) merges the up chain, the target doc, and the down chain into one ordered chain (root to leaf). related (implements/references/etc.) documents encountered along the chain are appended as detour entries. Multi-child forks encountered in the down direction are additionally reported in branches[] (one entry per fork, {fork_from,docs:[…]}). Cycle-safe: a visited set skips any document already seen in the traversal. Returns a JSON string {chain:[{id,title,doc_type,rel}…],branches:[{fork_from,docs:[…]}…]}.".to_string(),
1376
+ input_schema: json!({
1377
+ "type": "object",
1378
+ "properties": {
1379
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1380
+ "doc_id": { "type": "string", "description": "Document id or slug to trace from." },
1381
+ "direction": { "type": "string", "description": "Traversal direction.", "enum": ["up", "down", "both"], "default": "both" }
1382
+ },
1383
+ "required": ["doc_id"]
1384
+ }),
1385
+ },
1361
1386
  ToolDefinition {
1362
1387
  name: "handoff_doc_query".to_string(),
1363
1388
  description: "Inject document sections relevant to the current prompt/file/task (hook-driven context injection, mirrors memory_query at section granularity). Ranks by BM25 relevance + scope_paths match + task_id affinity, then stages each result as 'full' (whole section body, when its token estimate is within the inline threshold) or 'outline' (heading + sibling table of contents only, for larger sections — fetch the body via doc_get(format='section')). With session_id, already-injected sections (same content_hash) are skipped this session; mark_injected (default true) records survivors. suppress_doc_ids excludes given documents from this call's results; combined with suppress_until_changed=true (requires session_id), the suppression is recorded in the session's injected sidecar and persists across future calls until that document's content_hash changes. Returns a JSON string {documents:[…],injected_count}.".to_string(),