handoff-mcp-server 0.20.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.
@@ -22,6 +22,14 @@ pub fn handle(arguments: &Value) -> Result<String> {
22
22
  let (data, status) = read_task(&task_dir)?
23
23
  .ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
24
24
 
25
+ // `links` stays the legacy `Vec<String>` for backward compatibility with
26
+ // existing clients (skills / VSCode extension). `task_links` is an
27
+ // additive field carrying the normalized, deduplicated view from the
28
+ // `links()` accessor (wiki/130-document-management.md §9.1), so callers
29
+ // that understand typed links (doc/url/file/task) can read them without
30
+ // re-deriving the merge themselves.
31
+ let normalized_links = data.links();
32
+
25
33
  let result = serde_json::json!({
26
34
  "id": data.id,
27
35
  "title": data.title,
@@ -33,6 +41,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
33
41
  "completed_at": data.completed_at,
34
42
  "labels": data.labels,
35
43
  "links": data.links,
44
+ "task_links": normalized_links,
36
45
  "done_criteria": data.done_criteria,
37
46
  "schedule": data.schedule,
38
47
  "dependencies": data.dependencies,
@@ -360,6 +360,7 @@ fn create_task_recursive(
360
360
  completed_at,
361
361
  labels: extract_string_array_from(task_val, "labels"),
362
362
  links: extract_string_array_from(task_val, "links"),
363
+ task_links: Vec::new(),
363
364
  done_criteria: extract_done_criteria(task_val),
364
365
  schedule: extract_schedule(task_val),
365
366
  dependencies: extract_string_array_from(task_val, "dependencies"),
@@ -11,6 +11,7 @@ use serde_json::{json, Value};
11
11
  use std::path::Path;
12
12
 
13
13
  use super::resolve_project_dir;
14
+ use crate::context::injection::{filter_already_injected, rank_by_bm25_and_scope, RankConfig};
14
15
  use crate::storage::config::{read_config, SettingsConfig};
15
16
  use crate::storage::ensure_handoff_exists;
16
17
  use crate::storage::memory::{
@@ -277,49 +278,47 @@ pub fn handle_query(arguments: &Value) -> Result<String> {
277
278
  for p in &file_paths {
278
279
  query_tokens.extend(lexsim::tokenize(&basename(p)));
279
280
  }
280
- let scores = corpus.bm25_scores_tokens(&query_tokens);
281
281
 
282
- // Score + scope-path bonus, then threshold and rank.
283
- let mut ranked: Vec<(usize, f64)> = memories
284
- .iter()
285
- .enumerate()
286
- .map(|(i, m)| {
287
- let mut s = scores[i];
288
- if scope_matches(&m.scope_paths, &file_paths) {
289
- s += SCOPE_PATH_BONUS;
290
- }
291
- (i, s)
292
- })
293
- .filter(|(_, s)| *s >= settings.memory_query_min_score)
294
- .collect();
295
- ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
282
+ // Score + scope-path bonus, then threshold and rank (shared with doc_query).
283
+ let scope_paths: Vec<Vec<String>> = memories.iter().map(|m| m.scope_paths.clone()).collect();
284
+ let rank_config = RankConfig {
285
+ min_score: settings.memory_query_min_score,
286
+ scope_path_bonus: SCOPE_PATH_BONUS,
287
+ // Rank without truncating yet — the session diff below needs the full
288
+ // ranked order so it can backfill past already-injected memories.
289
+ limit: memories.len(),
290
+ };
291
+ let ranked = rank_by_bm25_and_scope(
292
+ &corpus,
293
+ &query_tokens,
294
+ &scope_paths,
295
+ &file_paths,
296
+ &rank_config,
297
+ );
296
298
 
297
299
  // Per-session diff: drop memories already injected this session at the same
298
300
  // hash. The `limit` is applied to the *fresh* set so the caller still gets up
299
301
  // to `limit` new memories even when earlier prompts already consumed some.
300
302
  let now = now_rfc3339();
301
303
  let injected_set = session_id.map(|sid| read_injected_set(&handoff, sid, &now));
302
- let fresh: Vec<(usize, f64)> = ranked
303
- .into_iter()
304
- .filter(|(i, _)| match &injected_set {
305
- Some(set) => {
306
- let m = &memories[*i];
307
- !set.already_injected(&m.id, &m.content_hash)
308
- }
309
- None => true,
310
- })
311
- .take(limit)
312
- .collect();
304
+ let already_injected = |i: usize| match &injected_set {
305
+ Some(set) => {
306
+ let m = &memories[i];
307
+ set.already_injected(&m.id, &m.content_hash)
308
+ }
309
+ None => false,
310
+ };
311
+ let fresh = filter_already_injected(ranked, already_injected, limit);
313
312
 
314
313
  let out: Vec<Value> = fresh
315
314
  .iter()
316
- .map(|(i, s)| {
317
- let m = &memories[*i];
315
+ .map(|item| {
316
+ let m = &memories[item.index];
318
317
  json!({
319
318
  "id": m.id,
320
319
  "text": m.text,
321
320
  "kind": m.kind,
322
- "score": round2(*s),
321
+ "score": round2(item.score),
323
322
  })
324
323
  })
325
324
  .collect();
@@ -352,13 +351,13 @@ fn mark_injected_memories(
352
351
  handoff: &std::path::Path,
353
352
  session_id: &str,
354
353
  memories: &[MemoryEntry],
355
- fresh: &[(usize, f64)],
354
+ fresh: &[crate::context::injection::RankItem],
356
355
  now: &str,
357
356
  ) -> Result<()> {
358
357
  let mut set = read_injected_set(handoff, session_id, now);
359
358
  set.updated_at = now.to_string();
360
- for (i, _) in fresh {
361
- let m = &memories[*i];
359
+ for item in fresh {
360
+ let m = &memories[item.index];
362
361
  set.mark(&m.id, &m.content_hash);
363
362
  }
364
363
  // (1) Persist the suppression record first — this is the correctness-critical
@@ -369,8 +368,8 @@ fn mark_injected_memories(
369
368
  // concurrent edit's other fields. A failure here is non-fatal: the sidecar
370
369
  // already recorded the injection, so the worst case is a slightly stale
371
370
  // hit_count — never a re-spam or a double count.
372
- for (i, _) in fresh {
373
- let m = &memories[*i];
371
+ for item in fresh {
372
+ let m = &memories[item.index];
374
373
  if let Ok(Some(mut entry)) = read_memory_by_id(handoff, &m.id) {
375
374
  entry.hit_count = entry.hit_count.saturating_add(1);
376
375
  entry.last_referenced_at = Some(now.to_string());
@@ -742,16 +741,6 @@ impl UnionFind {
742
741
  }
743
742
  }
744
743
 
745
- /// True if any `scope` prefix matches any `file` path.
746
- fn scope_matches(scopes: &[String], files: &[String]) -> bool {
747
- if scopes.is_empty() || files.is_empty() {
748
- return false;
749
- }
750
- scopes
751
- .iter()
752
- .any(|scope| files.iter().any(|f| f.contains(scope.as_str())))
753
- }
754
-
755
744
  /// Last path component of `p` (handles both `/` and `\` separators).
756
745
  fn basename(p: &str) -> String {
757
746
  p.rsplit(['/', '\\']).next().unwrap_or(p).to_string()
@@ -796,15 +785,6 @@ mod tests {
796
785
  assert_eq!(basename("plain.rs"), "plain.rs");
797
786
  }
798
787
 
799
- #[test]
800
- fn scope_matches_prefix() {
801
- let scopes = vec!["src/storage/".to_string()];
802
- let files = vec!["/repo/src/storage/mod.rs".to_string()];
803
- assert!(scope_matches(&scopes, &files));
804
- let files2 = vec!["/repo/src/mcp/mod.rs".to_string()];
805
- assert!(!scope_matches(&scopes, &files2));
806
- }
807
-
808
788
  #[test]
809
789
  fn string_array_parsing() {
810
790
  let v = json!({ "tags": ["a", "b", 3, "c"] });
@@ -7,6 +7,8 @@ pub mod check_criterion;
7
7
  pub mod config;
8
8
  pub mod config_crud;
9
9
  pub mod dashboard;
10
+ pub mod docs;
11
+ pub mod docs_query;
10
12
  pub mod fork_session;
11
13
  pub mod get_session;
12
14
  pub mod get_task;
@@ -95,6 +97,19 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
95
97
  "handoff_timer_start" => timer::handle_start(arguments),
96
98
  "handoff_timer_stop" => timer::handle_stop(arguments),
97
99
  "handoff_timer_get_time" => timer::handle_get_time(arguments),
100
+ "handoff_doc_save" => docs::handle_doc_save(arguments),
101
+ "handoff_doc_get" => docs::handle_doc_get(arguments),
102
+ "handoff_doc_list" => docs::handle_doc_list(arguments),
103
+ "handoff_doc_delete" => docs::handle_doc_delete(arguments),
104
+ "handoff_doc_reassemble" => docs::handle_doc_reassemble(arguments),
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),
108
+ "handoff_doc_verify" => docs::handle_doc_verify(arguments),
109
+ "handoff_doc_verify_status" => docs::handle_doc_verify_status(arguments),
110
+ "handoff_doc_query" => docs_query::handle_doc_query(arguments),
111
+ "handoff_doc_analyze" => docs_query::handle_doc_analyze(arguments),
112
+ "handoff_doc_import" => docs_query::handle_doc_import(arguments),
98
113
  _ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
99
114
  };
100
115
 
@@ -114,6 +114,7 @@ fn handle_create(
114
114
  completed_at: None,
115
115
  labels: extract_string_array(task_val, "labels"),
116
116
  links: extract_string_array(task_val, "links"),
117
+ task_links: Vec::new(),
117
118
  done_criteria: extract_done_criteria(task_val),
118
119
  schedule: extract_schedule(task_val),
119
120
  dependencies,
@@ -208,6 +209,7 @@ fn handle_upsert_create(
208
209
  completed_at: None,
209
210
  labels: extract_string_array(task_val, "labels"),
210
211
  links: extract_string_array(task_val, "links"),
212
+ task_links: Vec::new(),
211
213
  done_criteria: extract_done_criteria(task_val),
212
214
  schedule: extract_schedule(task_val),
213
215
  dependencies,
package/src/mcp/tools.rs CHANGED
@@ -1234,6 +1234,201 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1234
1234
  "required": ["task_id"]
1235
1235
  }),
1236
1236
  },
1237
+ // ---- Document management tools (P1-6a, v5 rearchitecture: wiki/130-document-management.md §3.1) ----
1238
+ ToolDefinition {
1239
+ name: "handoff_doc_save".to_string(),
1240
+ description: "Create or update a document from a full Markdown body. The body is stored verbatim at _doc.<slug>.md and split in-memory into a `sections` byte-offset index (no per-section files), syncing the bidirectional task<->doc link when task_ids is given. Omit doc_id to create a new document (slug is then required and must be unique); pass an existing doc_id to update it (slug is taken from the existing document — it cannot be renamed via doc_save). Returns a JSON string {doc_id,slug,title,doc_type,section_count,content_hash,warnings:[…]} — warnings lists any task_ids that could not be resolved.".to_string(),
1241
+ input_schema: json!({
1242
+ "type": "object",
1243
+ "properties": {
1244
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1245
+ "doc_id": { "type": "string", "description": "Existing document id to update. Omit to create a new document." },
1246
+ "slug": { "type": "string", "description": "Human-readable file-naming slug ([a-z0-9-], max 60 chars), used to name _doc.<slug>.json/.md. Required when creating; ignored on update (the existing document's slug is kept)." },
1247
+ "title": { "type": "string", "description": "Document title. Required when creating; optional on update (defaults to the existing title)." },
1248
+ "body": { "type": "string", "description": "Full Markdown body. Required." },
1249
+ "doc_type": { "type": "string", "description": "Document type.", "enum": ["spec", "design", "adr", "guide", "note"], "default": "note" },
1250
+ "tags": { "type": "array", "items": { "type": "string" }, "description": "Tags for filtering/search." },
1251
+ "scope_paths": { "type": "array", "items": { "type": "string" }, "description": "Path prefixes this document applies to; boosts relevance in doc_list(query=...) when a file path matches." },
1252
+ "parent_id": { "type": "string", "description": "Parent document id (family tree)." },
1253
+ "task_ids": { "type": "array", "items": { "type": "string" }, "description": "Task ids to link bidirectionally. On update, ids removed from this list are unlinked; ids added are linked." },
1254
+ "related": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "rel": { "type": "string", "enum": ["supersedes", "references", "implements", "extends", "conflicts"] } }, "required": ["id", "rel"] }, "description": "Sibling/relative relationships to other documents." },
1255
+ "split_level": { "type": "integer", "description": "ATX heading level at/above which the body is split into sections.", "default": 2 },
1256
+ "auto_inject": { "type": "string", "description": "Auto-injection control.", "enum": ["auto", "full", "outline", "none"], "default": "auto" }
1257
+ },
1258
+ "required": ["body"]
1259
+ }),
1260
+ },
1261
+ ToolDefinition {
1262
+ name: "handoff_doc_get".to_string(),
1263
+ description: "Read a document by doc_id or slug. format='full' returns the original Markdown body (read directly from _doc.<slug>.md) plus metadata; 'meta' returns metadata only (no body, cheap for graph traversal); 'section' returns one section's body (byte-sliced from the document body, requires seq). Returns a JSON string.".to_string(),
1264
+ input_schema: json!({
1265
+ "type": "object",
1266
+ "properties": {
1267
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1268
+ "doc_id": { "type": "string", "description": "Document id or slug to read." },
1269
+ "format": { "type": "string", "description": "Read mode.", "enum": ["full", "meta", "section"], "default": "full" },
1270
+ "seq": { "type": "integer", "description": "Section sequence number. Required when format='section'." }
1271
+ },
1272
+ "required": ["doc_id"]
1273
+ }),
1274
+ },
1275
+ ToolDefinition {
1276
+ name: "handoff_doc_list".to_string(),
1277
+ description: "List/search documents. Filters (doc_type, tags [AND — every tag must be present], task_id) are applied first; an optional query BM25-ranks the survivors by title + tags + body text. include_body includes each matching document's full body, read from _doc.<slug>.md (default false — metadata only). Returns a JSON string {documents:[…]}.".to_string(),
1278
+ input_schema: json!({
1279
+ "type": "object",
1280
+ "properties": {
1281
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1282
+ "doc_type": { "type": "string", "description": "Filter by document type." },
1283
+ "tags": { "type": "array", "items": { "type": "string" }, "description": "Filter: document must have every listed tag (AND)." },
1284
+ "task_id": { "type": "string", "description": "Filter: only documents linked to this task." },
1285
+ "include_body": { "type": "boolean", "description": "Include each document's full body.", "default": false },
1286
+ "query": { "type": "string", "description": "BM25 text search over title + tags + body." }
1287
+ }
1288
+ }),
1289
+ },
1290
+ ToolDefinition {
1291
+ name: "handoff_doc_delete".to_string(),
1292
+ description: "Delete a document (by doc_id or slug) and its body file. Unlinks the document from any linked tasks' task_links, removes it from its parent's children list, and clears parent_id on any of its own children (orphaning them — delete does not cascade to descendants). Returns a JSON string {deleted,doc_id,section_count,warnings:[…]}.".to_string(),
1293
+ input_schema: json!({
1294
+ "type": "object",
1295
+ "properties": {
1296
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1297
+ "doc_id": { "type": "string", "description": "Document id or slug to delete." }
1298
+ },
1299
+ "required": ["doc_id"]
1300
+ }),
1301
+ },
1302
+ ToolDefinition {
1303
+ name: "handoff_doc_reassemble".to_string(),
1304
+ description: "Read a document's (by doc_id or slug) original Markdown body directly from _doc.<slug>.md, restoring BOM/frontmatter, and detect drift (the body's current content hash no longer matches its recorded content_hash — e.g. edited directly outside doc_save). Optionally writes the body to output_path. Returns a JSON string {doc_id,body,drifted,output_path?}.".to_string(),
1305
+ input_schema: json!({
1306
+ "type": "object",
1307
+ "properties": {
1308
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1309
+ "doc_id": { "type": "string", "description": "Document id or slug to reassemble." },
1310
+ "output_path": { "type": "string", "description": "Optional filesystem path to write the reassembled body to." }
1311
+ },
1312
+ "required": ["doc_id"]
1313
+ }),
1314
+ },
1315
+ ToolDefinition {
1316
+ name: "handoff_doc_tree".to_string(),
1317
+ description: "Traverse a document's family tree (parent/children) starting from doc_id (id or slug), up to depth levels of descendants, plus the immediate parent (if any). include_related additionally attaches the document's related (semantic) links. Returns a JSON string tree {id,title,doc_type,parent,children:[…],related:[…]}.".to_string(),
1318
+ input_schema: json!({
1319
+ "type": "object",
1320
+ "properties": {
1321
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1322
+ "doc_id": { "type": "string", "description": "Root document id or slug to traverse from." },
1323
+ "depth": { "type": "integer", "description": "How many levels of children to descend.", "default": 2 },
1324
+ "include_related": { "type": "boolean", "description": "Also include the root document's `related` entries.", "default": false }
1325
+ },
1326
+ "required": ["doc_id"]
1327
+ }),
1328
+ },
1329
+ ToolDefinition {
1330
+ name: "handoff_doc_verify".to_string(),
1331
+ description: "Operate on a document's verification matrix (wiki/140-verification-matrix.md): generate (create a matrix from the document's current sections, error if one already exists), check (mark fragment_seq verified, recording verified_at/reviewer/notes/content_hash_at_verify), skip (mark fragment_seq skipped), sync (re-sync the matrix with the document's current sections — adds new sections as pending, removes deleted ones, preserves existing item status), or set_refs (update impl_refs/test_refs for fragment_seq). Overall verification_status is recomputed after every mutation: 'pending' if all items pending, 'verified' if all verified/skipped, else 'in_review'. Returns a JSON string {doc_id,verification_status,checked,skipped,pending,total,stale}.".to_string(),
1332
+ input_schema: json!({
1333
+ "type": "object",
1334
+ "properties": {
1335
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1336
+ "doc_id": { "type": "string", "description": "Document id or slug to operate on." },
1337
+ "action": { "type": "string", "description": "Verification matrix action.", "enum": ["generate", "check", "skip", "sync", "set_refs"] },
1338
+ "skip_seqs": { "type": "array", "items": { "type": "integer" }, "description": "generate only: section seqs to create as 'skipped' instead of 'pending'." },
1339
+ "fragment_seq": { "type": "integer", "description": "check/skip/set_refs: the section seq (VerificationItem.fragment_seq) to operate on." },
1340
+ "reviewer": { "type": "string", "description": "check: who verified it.", "enum": ["ai", "user"] },
1341
+ "notes": { "type": "string", "description": "check: optional free-text note." },
1342
+ "impl_refs": { "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "lines": { "type": "string" }, "label": { "type": "string" } }, "required": ["path"] }, "description": "set_refs: implementation code references to attach to fragment_seq." },
1343
+ "test_refs": { "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "lines": { "type": "string" }, "label": { "type": "string" } }, "required": ["path"] }, "description": "set_refs: test code references to attach to fragment_seq." }
1344
+ },
1345
+ "required": ["doc_id", "action"]
1346
+ }),
1347
+ },
1348
+ ToolDefinition {
1349
+ name: "handoff_doc_verify_status".to_string(),
1350
+ description: "Get a document's verification matrix status: overall verification_status, progress counts (checked/skipped/pending/total/stale/percentage), and (when include_items=true) every item with a computed stale flag (its content_hash_at_verify no longer matches the section's current content_hash — spec §3.5). Errors if the document has no verification matrix yet (use handoff_doc_verify(action='generate') first). Returns a JSON string {doc_id,title,verification_status,progress:{…},items?:[…]}.".to_string(),
1351
+ input_schema: json!({
1352
+ "type": "object",
1353
+ "properties": {
1354
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1355
+ "doc_id": { "type": "string", "description": "Document id or slug to read verification status for." },
1356
+ "include_items": { "type": "boolean", "description": "Include the full per-item list (with stale detection).", "default": false }
1357
+ },
1358
+ "required": ["doc_id"]
1359
+ }),
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
+ },
1386
+ ToolDefinition {
1387
+ name: "handoff_doc_query".to_string(),
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(),
1389
+ input_schema: json!({
1390
+ "type": "object",
1391
+ "properties": {
1392
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1393
+ "text": { "type": "string", "description": "Prompt/query text to rank sections against." },
1394
+ "file_paths": { "type": "array", "items": { "type": "string" }, "description": "File paths in play; boosts documents whose scope_paths match." },
1395
+ "task_id": { "type": "string", "description": "Boost sections belonging to documents linked to this task (highest-weight ranking signal)." },
1396
+ "session_id": { "type": "string", "description": "Session id for per-session diff injection (skips sections already injected at their current content_hash)." },
1397
+ "limit": { "type": "integer", "description": "Max number of sections to return.", "default": 5 },
1398
+ "mark_injected": { "type": "boolean", "description": "Record returned sections in the session's injected sidecar.", "default": true },
1399
+ "suppress_doc_ids": { "type": "array", "items": { "type": "string" }, "description": "Document ids to exclude entirely from this call's results." },
1400
+ "suppress_until_changed": { "type": "boolean", "description": "With suppress_doc_ids and session_id: persist the suppression in the session's injected sidecar so those documents stay excluded from future doc_query calls until their content_hash changes.", "default": false }
1401
+ }
1402
+ }),
1403
+ },
1404
+ ToolDefinition {
1405
+ name: "handoff_doc_analyze".to_string(),
1406
+ description: "Read-only scan of a Markdown file or directory (never writes). Auto-detects doc_type (keyword scan), tags (frontmatter + heading tokens), scope_paths (code/inline file paths), and a suggested_slug (derived from title) per file; extracts and classifies Markdown links (internal/external/broken); proposes a parent/children tree from directory structure (skip with flatten=true). Returns a JSON conditioning report {files_scanned,auto_resolved:[…],needs_review:[…],proposed_tree:{…}} for AI review before handoff_doc_import.".to_string(),
1407
+ input_schema: json!({
1408
+ "type": "object",
1409
+ "properties": {
1410
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1411
+ "path": { "type": "string", "description": "File or directory path (relative to project_dir) to scan." },
1412
+ "recursive": { "type": "boolean", "description": "Recurse into subdirectories when path is a directory.", "default": true },
1413
+ "flatten": { "type": "boolean", "description": "Skip parent/children tree inference; every file is a standalone document.", "default": false }
1414
+ },
1415
+ "required": ["path"]
1416
+ }),
1417
+ },
1418
+ ToolDefinition {
1419
+ name: "handoff_doc_import".to_string(),
1420
+ description: "Bulk-write an analyzed payload (from handoff_doc_analyze, with the AI's overrides applied) as new documents. Each analyzed.auto_resolved entry must carry its file's full Markdown 'body' (doc_import writes from the payload, it does not re-read the filesystem). Each document's slug is taken from its override's 'slug' if given, else its suggested_slug, disambiguated with a numeric suffix on collision. Persists every file as a document, applies proposed_tree parent/children relationships, links task_ids to every imported document (bidirectionally), and invalidates the doc corpus cache. Returns a JSON string {imported_count,documents:[{doc_id,slug,title,section_count}],warnings:[…]}.".to_string(),
1421
+ input_schema: json!({
1422
+ "type": "object",
1423
+ "properties": {
1424
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1425
+ "analyzed": { "type": "object", "description": "The handoff_doc_analyze report, with each auto_resolved entry additionally carrying its file's 'body'." },
1426
+ "overrides": { "type": "array", "items": { "type": "object", "properties": { "file": { "type": "string" }, "slug": { "type": "string" }, "title": { "type": "string" }, "doc_type": { "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } }, "scope_paths": { "type": "array", "items": { "type": "string" } } }, "required": ["file"] }, "description": "Per-file AI overrides applied on top of analyzed.auto_resolved before writing." },
1427
+ "task_ids": { "type": "array", "items": { "type": "string" }, "description": "Link every imported document to these tasks (bidirectionally)." }
1428
+ },
1429
+ "required": ["analyzed"]
1430
+ }),
1431
+ },
1237
1432
  ]
1238
1433
  }
1239
1434