handoff-mcp-server 0.19.1 → 0.22.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.
@@ -0,0 +1,1043 @@
1
+ //! MCP handlers for document management (save / get / list) — P1-6a (t96.1),
2
+ //! v5 rearchitecture (2-file slug-based storage,
3
+ //! wiki/130-document-management.md §3.1).
4
+ //!
5
+ //! Builds on the storage layer in `crate::storage::docs` (split into
6
+ //! in-memory sections + slug-named `.json`/`.md` pair I/O) and the
7
+ //! task<->doc bidirectional link sync in
8
+ //! `crate::storage::tasks::sync_doc_task_links`. See
9
+ //! `wiki/130-document-management.md` §5.1-§5.3 for the spec.
10
+
11
+ use std::path::Path;
12
+
13
+ use anyhow::{Context, Result};
14
+ use serde_json::{json, Value};
15
+
16
+ use super::resolve_project_dir;
17
+ use crate::context::injection::{rank_by_bm25_and_scope, RankConfig};
18
+ use crate::storage::docs::reassemble::extract_section;
19
+ use crate::storage::docs::split::{compute_sections, split};
20
+ use crate::storage::docs::{
21
+ delete_doc, delete_doc_body, ensure_docs_dir, find_doc_by_id, read_all_docs, read_doc,
22
+ read_doc_body, validate_slug, write_doc, write_doc_body, CodeRef, DocMetadata, DocRelation,
23
+ Verification, VerificationItem,
24
+ };
25
+ use crate::storage::ensure_handoff_exists;
26
+ use crate::storage::tasks::sync_doc_task_links;
27
+
28
+ /// Bonus added to a document's BM25 score when one of its `scope_paths` is a
29
+ /// prefix of one of the query's `file_paths`. Mirrors `memory.rs`'s
30
+ /// `SCOPE_PATH_BONUS` — kept as a separate constant since the two features
31
+ /// tune independently even though the value happens to match today.
32
+ const SCOPE_PATH_BONUS: f64 = 2.0;
33
+
34
+ /// Default relevance floor for `doc_list(query=...)`. Kept at 0.0 (no floor)
35
+ /// since `doc_list` is an explicit search the caller controls via `query`
36
+ /// presence/absence, unlike `memory_query`'s hook-driven auto-injection which
37
+ /// needs a floor to avoid noise.
38
+ const DOC_QUERY_MIN_SCORE: f64 = 0.0;
39
+
40
+ fn new_doc_id() -> String {
41
+ format!("doc-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S-%6f"))
42
+ }
43
+
44
+ /// Resolve a document by either its file-naming `slug` or its stable `id`
45
+ /// (spec instructs `doc_get`/`doc_delete`/etc. to accept either). Tries the
46
+ /// direct slug-keyed file lookup first (cheap, no scan), falling back to a
47
+ /// full `id` scan so callers that only recorded a document's `id` (e.g. from
48
+ /// a `related`/`parent_id` reference) can still resolve it.
49
+ fn resolve_doc(handoff: &Path, slug_or_id: &str) -> Result<Option<DocMetadata>> {
50
+ if let Some(doc) = read_doc(handoff, slug_or_id)? {
51
+ return Ok(Some(doc));
52
+ }
53
+ find_doc_by_id(handoff, slug_or_id)
54
+ }
55
+
56
+ /// `handoff_doc_save` — create or update a document from a full Markdown
57
+ /// body: split into in-memory sections, persist the body + metadata as a
58
+ /// slug-named pair, and sync the task<->doc bidirectional link.
59
+ pub fn handle_doc_save(arguments: &Value) -> Result<String> {
60
+ let project_dir = resolve_project_dir(arguments)?;
61
+ let handoff = ensure_handoff_exists(&project_dir)?;
62
+ ensure_docs_dir(&handoff)?;
63
+
64
+ let body = arguments
65
+ .get("body")
66
+ .and_then(|v| v.as_str())
67
+ .ok_or_else(|| anyhow::anyhow!("'body' is required"))?;
68
+
69
+ let doc_id = arguments.get("doc_id").and_then(|v| v.as_str());
70
+ let existing = match doc_id {
71
+ Some(id) => Some(
72
+ find_doc_by_id(&handoff, id)?
73
+ .ok_or_else(|| anyhow::anyhow!("Document not found: {id}"))?,
74
+ ),
75
+ None => None,
76
+ };
77
+
78
+ // slug: required for new documents, taken from the existing document on
79
+ // update (the `slug` argument is ignored on update — renaming a
80
+ // document's file-naming slug is out of scope for `doc_save`).
81
+ let slug = match &existing {
82
+ Some(d) => d.slug.clone(),
83
+ None => {
84
+ let slug = arguments
85
+ .get("slug")
86
+ .and_then(|v| v.as_str())
87
+ .ok_or_else(|| anyhow::anyhow!("'slug' is required for new documents"))?
88
+ .to_string();
89
+ validate_slug(&slug)?;
90
+ if read_doc(&handoff, &slug)?.is_some() {
91
+ anyhow::bail!("slug '{slug}' is already in use by another document");
92
+ }
93
+ slug
94
+ }
95
+ };
96
+
97
+ let title = arguments
98
+ .get("title")
99
+ .and_then(|v| v.as_str())
100
+ .or(existing.as_ref().map(|d| d.title.as_str()))
101
+ .ok_or_else(|| anyhow::anyhow!("'title' is required for new documents"))?
102
+ .to_string();
103
+
104
+ let split_level = arguments
105
+ .get("split_level")
106
+ .and_then(|v| v.as_u64())
107
+ .map(|n| n as u8)
108
+ .unwrap_or(crate::storage::docs::split::DEFAULT_SPLIT_LEVEL);
109
+
110
+ let split_doc = split(body, split_level)?;
111
+
112
+ let now = chrono::Utc::now().to_rfc3339();
113
+ let id = doc_id.map(str::to_string).unwrap_or_else(new_doc_id);
114
+
115
+ let mut doc = match existing {
116
+ Some(mut d) => {
117
+ d.title = title.clone();
118
+ d
119
+ }
120
+ None => {
121
+ let mut d = DocMetadata::new(
122
+ id.clone(),
123
+ slug.clone(),
124
+ title.clone(),
125
+ "note".to_string(),
126
+ now.clone(),
127
+ );
128
+ d.source.origin = "authored".to_string();
129
+ d
130
+ }
131
+ };
132
+
133
+ if let Some(doc_type) = arguments.get("doc_type").and_then(|v| v.as_str()) {
134
+ doc.doc_type = doc_type.to_string();
135
+ }
136
+ if let Some(tags) = arguments.get("tags") {
137
+ doc.tags = string_array_value(tags);
138
+ }
139
+ if let Some(scope_paths) = arguments.get("scope_paths") {
140
+ doc.scope_paths = string_array_value(scope_paths);
141
+ }
142
+ let previous_parent_id = doc.parent_id.clone();
143
+ if let Some(parent_id) = arguments.get("parent_id") {
144
+ doc.parent_id = parent_id.as_str().map(str::to_string);
145
+ }
146
+ let mut warnings: Vec<String> = Vec::new();
147
+ if let Some(related) = arguments.get("related").and_then(|v| v.as_array()) {
148
+ let mut malformed_count = 0usize;
149
+ doc.related = related
150
+ .iter()
151
+ .filter_map(|r| {
152
+ let rid = r.get("id").and_then(|v| v.as_str());
153
+ let rel = r.get("rel").and_then(|v| v.as_str());
154
+ match (rid, rel) {
155
+ (Some(rid), Some(rel)) => Some(DocRelation {
156
+ id: rid.to_string(),
157
+ rel: rel.to_string(),
158
+ }),
159
+ _ => {
160
+ malformed_count += 1;
161
+ None
162
+ }
163
+ }
164
+ })
165
+ .collect();
166
+ if malformed_count > 0 {
167
+ warnings.push(format!(
168
+ "Ignored {malformed_count} malformed 'related' entr{} (each entry requires string 'id' and 'rel')",
169
+ if malformed_count == 1 { "y" } else { "ies" }
170
+ ));
171
+ }
172
+ }
173
+ if let Some(auto_inject) = arguments.get("auto_inject").and_then(|v| v.as_str()) {
174
+ doc.auto_inject = auto_inject.to_string();
175
+ }
176
+
177
+ doc.has_bom = split_doc.has_bom;
178
+ doc.line_ending = split_doc.line_ending.to_string();
179
+ doc.source.frontmatter = split_doc.frontmatter.map(str::to_string);
180
+ doc.source.frontmatter_trailing_eol = split_doc.frontmatter_trailing_eol;
181
+ doc.updated_at = now.clone();
182
+
183
+ // v5: the full body (after BOM/frontmatter stripping) is written verbatim
184
+ // to `_doc.<slug>.md`; sections are an in-memory byte-offset index into
185
+ // it, computed fresh on every save (no stale-fragment cleanup needed —
186
+ // there is nothing left on disk to clean up per section).
187
+ let body_after_strip: String = split_doc.fragments.iter().map(|f| f.body).collect();
188
+ write_doc_body(&handoff, &slug, &body_after_strip)?;
189
+ doc.sections = compute_sections(&split_doc);
190
+
191
+ let content_hash = lexsim::content_hash(&body_after_strip);
192
+ doc.content_hash = content_hash.clone();
193
+ doc.source.canonical_hash = Some(content_hash);
194
+
195
+ let new_task_ids = arguments
196
+ .get("task_ids")
197
+ .map(string_array_value)
198
+ .unwrap_or_else(|| doc.task_ids.clone());
199
+
200
+ if arguments.get("task_ids").is_some() {
201
+ let (link_ids, unlink_ids) = if doc_id.is_some() {
202
+ let previous: Vec<String> = doc.task_ids.clone();
203
+ let link: Vec<String> = new_task_ids
204
+ .iter()
205
+ .filter(|t| !previous.contains(t))
206
+ .cloned()
207
+ .collect();
208
+ let unlink: Vec<String> = previous
209
+ .iter()
210
+ .filter(|t| !new_task_ids.contains(t))
211
+ .cloned()
212
+ .collect();
213
+ (link, unlink)
214
+ } else {
215
+ (new_task_ids.clone(), Vec::new())
216
+ };
217
+
218
+ let tasks_dir = handoff.join("tasks");
219
+ let report = sync_doc_task_links(&tasks_dir, &id, &title, &link_ids, &unlink_ids)?;
220
+ if !report.unresolved.is_empty() {
221
+ warnings.push(format!(
222
+ "Could not resolve task id(s) for linking: {}",
223
+ report.unresolved.join(", ")
224
+ ));
225
+ }
226
+ doc.task_ids = new_task_ids;
227
+ }
228
+
229
+ write_doc(&handoff, &doc)?;
230
+
231
+ // Keep the family tree's `children` list in sync with `parent_id`: if the
232
+ // parent changed (including unset -> set on first save), push this doc's
233
+ // id into the new parent's `children` and drop it from the old parent's,
234
+ // mirroring the same "sync the other side" pattern as
235
+ // sync_doc_task_links. A parent id that doesn't resolve is a non-fatal
236
+ // warning, not a rollback — same policy as unresolved task_ids above.
237
+ // `parent_id` references a document's stable `id`, not its `slug`, so
238
+ // resolution goes through `find_doc_by_id`.
239
+ if doc.parent_id != previous_parent_id {
240
+ if let Some(old_parent_id) = &previous_parent_id {
241
+ if let Some(mut old_parent) = find_doc_by_id(&handoff, old_parent_id)? {
242
+ let before = old_parent.children.len();
243
+ old_parent.children.retain(|c| c != &id);
244
+ if old_parent.children.len() != before {
245
+ write_doc(&handoff, &old_parent)?;
246
+ }
247
+ }
248
+ }
249
+ if let Some(new_parent_id) = &doc.parent_id {
250
+ match find_doc_by_id(&handoff, new_parent_id)? {
251
+ Some(mut new_parent) => {
252
+ if !new_parent.children.iter().any(|c| c == &id) {
253
+ new_parent.children.push(id.clone());
254
+ write_doc(&handoff, &new_parent)?;
255
+ }
256
+ }
257
+ None => warnings.push(format!("Parent document not found: {new_parent_id}")),
258
+ }
259
+ }
260
+ }
261
+
262
+ Ok(to_json(&json!({
263
+ "doc_id": id,
264
+ "slug": doc.slug,
265
+ "title": doc.title,
266
+ "doc_type": doc.doc_type,
267
+ "section_count": doc.sections.len(),
268
+ "content_hash": doc.content_hash,
269
+ "warnings": warnings,
270
+ })))
271
+ }
272
+
273
+ /// Reads a document's full original body: `_doc.<slug>.md` (the
274
+ /// post-BOM/frontmatter-stripped body) with the BOM and YAML frontmatter
275
+ /// (if any) restored in front of it, exactly as originally authored.
276
+ /// Returns `Ok(None)` when the `.md` file is missing (metadata exists but
277
+ /// body was deleted out-of-band).
278
+ fn read_full_body(handoff: &Path, doc: &DocMetadata) -> Result<Option<String>> {
279
+ let Some(stripped_body) = read_doc_body(handoff, &doc.slug)? else {
280
+ return Ok(None);
281
+ };
282
+ let mut body = stripped_body;
283
+ if let Some(frontmatter) = &doc.source.frontmatter {
284
+ let eol = if doc.line_ending == "crlf" {
285
+ "\r\n"
286
+ } else {
287
+ "\n"
288
+ };
289
+ let trailing_eol = if doc.source.frontmatter_trailing_eol {
290
+ eol
291
+ } else {
292
+ ""
293
+ };
294
+ body = format!("---{eol}{frontmatter}---{trailing_eol}{body}");
295
+ }
296
+ if doc.has_bom {
297
+ body = format!("\u{FEFF}{body}");
298
+ }
299
+ Ok(Some(body))
300
+ }
301
+
302
+ /// `handoff_doc_get` — read a document (by `doc_id` or `slug`) as `full`
303
+ /// (the original Markdown body + metadata), `meta` (metadata only), or
304
+ /// `section` (one section's body, byte-sliced from `_doc.<slug>.md`).
305
+ pub fn handle_doc_get(arguments: &Value) -> Result<String> {
306
+ let project_dir = resolve_project_dir(arguments)?;
307
+ let handoff = ensure_handoff_exists(&project_dir)?;
308
+
309
+ let doc_id = arguments
310
+ .get("doc_id")
311
+ .and_then(|v| v.as_str())
312
+ .ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
313
+
314
+ let format = arguments
315
+ .get("format")
316
+ .and_then(|v| v.as_str())
317
+ .unwrap_or("full");
318
+
319
+ match format {
320
+ "meta" => {
321
+ let doc = resolve_doc(&handoff, doc_id)?
322
+ .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
323
+ Ok(to_json(&doc_metadata_json(&doc)))
324
+ }
325
+ "section" | "fragment" => {
326
+ let seq = arguments
327
+ .get("seq")
328
+ .and_then(|v| v.as_u64())
329
+ .ok_or_else(|| anyhow::anyhow!("'seq' is required when format='section'"))?
330
+ as usize;
331
+ let doc = resolve_doc(&handoff, doc_id)?
332
+ .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
333
+ let section =
334
+ doc.sections.iter().find(|s| s.seq == seq).ok_or_else(|| {
335
+ anyhow::anyhow!("Section not found: doc_id={doc_id} seq={seq}")
336
+ })?;
337
+ let body = read_doc_body(&handoff, &doc.slug)?.ok_or_else(|| {
338
+ anyhow::anyhow!("Document body file missing for slug '{}'", doc.slug)
339
+ })?;
340
+ let section_body = extract_section(&body, section)?;
341
+ Ok(to_json(&json!({
342
+ "doc_id": doc.id,
343
+ "seq": section.seq,
344
+ "heading": section.heading,
345
+ "level": section.level,
346
+ "content_hash": section.content_hash,
347
+ "body": section_body,
348
+ })))
349
+ }
350
+ _ => {
351
+ let doc = resolve_doc(&handoff, doc_id)?
352
+ .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
353
+ let body = read_full_body(&handoff, &doc)?.unwrap_or_default();
354
+ let mut out = doc_metadata_json(&doc);
355
+ out["body"] = json!(body);
356
+ Ok(to_json(&out))
357
+ }
358
+ }
359
+ }
360
+
361
+ /// `handoff_doc_list` — list/search documents with optional `doc_type` /
362
+ /// `tags` (AND) / `task_id` filters, BM25 `query` ranking, and optional
363
+ /// reassembled `body` inclusion.
364
+ pub fn handle_doc_list(arguments: &Value) -> Result<String> {
365
+ let project_dir = resolve_project_dir(arguments)?;
366
+ let handoff = ensure_handoff_exists(&project_dir)?;
367
+
368
+ let doc_type = arguments.get("doc_type").and_then(|v| v.as_str());
369
+ let tags = arguments.get("tags").map(string_array_value);
370
+ let task_id = arguments.get("task_id").and_then(|v| v.as_str());
371
+ let include_body = arguments
372
+ .get("include_body")
373
+ .and_then(|v| v.as_bool())
374
+ .unwrap_or(false);
375
+ let query = arguments
376
+ .get("query")
377
+ .and_then(|v| v.as_str())
378
+ .map(str::trim)
379
+ .filter(|s| !s.is_empty());
380
+
381
+ let mut docs = read_all_docs(&handoff)?;
382
+ if let Some(dt) = doc_type {
383
+ docs.retain(|d| d.doc_type == dt);
384
+ }
385
+ if let Some(tags) = &tags {
386
+ if !tags.is_empty() {
387
+ docs.retain(|d| tags.iter().all(|t| d.tags.contains(t)));
388
+ }
389
+ }
390
+ if let Some(tid) = task_id {
391
+ docs.retain(|d| d.task_ids.iter().any(|t| t == tid));
392
+ }
393
+
394
+ let ordered_indices: Vec<usize> = if let Some(q) = query {
395
+ rank_docs_by_query(&handoff, &docs, q)?
396
+ } else {
397
+ (0..docs.len()).collect()
398
+ };
399
+
400
+ let mut out_docs = Vec::with_capacity(ordered_indices.len());
401
+ for idx in ordered_indices {
402
+ let d = &docs[idx];
403
+ let mut entry = doc_metadata_json(d);
404
+ if include_body {
405
+ let body = read_full_body(&handoff, d)?.unwrap_or_default();
406
+ entry["body"] = json!(body);
407
+ }
408
+ out_docs.push(entry);
409
+ }
410
+
411
+ Ok(to_json(&json!({ "documents": out_docs })))
412
+ }
413
+
414
+ /// Ranks `docs` against `query` via BM25 over each document's index text
415
+ /// (title + tags + body), returning original-order indices sorted by
416
+ /// descending relevance. Corpus is built fresh every call (no cache — the
417
+ /// cache is reserved for `doc_query`, t96.3, per the task's own note).
418
+ fn rank_docs_by_query(handoff: &Path, docs: &[DocMetadata], query: &str) -> Result<Vec<usize>> {
419
+ let mut index_texts = Vec::with_capacity(docs.len());
420
+ for d in docs {
421
+ let body = read_doc_body(handoff, &d.slug)?.unwrap_or_default();
422
+ let mut text = d.title.clone();
423
+ text.push(' ');
424
+ text.push_str(&d.tags.join(" "));
425
+ text.push(' ');
426
+ text.push_str(&body);
427
+ index_texts.push(text);
428
+ }
429
+
430
+ let corpus = lexsim::Corpus::build(&index_texts);
431
+ let query_tokens = lexsim::tokenize(query);
432
+ let scope_paths: Vec<Vec<String>> = docs.iter().map(|d| d.scope_paths.clone()).collect();
433
+ let config = RankConfig {
434
+ min_score: DOC_QUERY_MIN_SCORE,
435
+ scope_path_bonus: SCOPE_PATH_BONUS,
436
+ limit: docs.len(),
437
+ };
438
+ let ranked = rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &config);
439
+ Ok(ranked.into_iter().map(|item| item.index).collect())
440
+ }
441
+
442
+ /// `handoff_doc_delete` — delete a document (by `doc_id` or `slug`) and its
443
+ /// body file, unlink it from any linked tasks, remove it from its parent's
444
+ /// `children`, and orphan (clear `parent_id` on) any of its own children.
445
+ /// See `wiki/130-document-management.md` §5.4.
446
+ pub fn handle_doc_delete(arguments: &Value) -> Result<String> {
447
+ let project_dir = resolve_project_dir(arguments)?;
448
+ let handoff = ensure_handoff_exists(&project_dir)?;
449
+
450
+ let doc_id = arguments
451
+ .get("doc_id")
452
+ .and_then(|v| v.as_str())
453
+ .ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
454
+
455
+ let doc = resolve_doc(&handoff, doc_id)?
456
+ .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
457
+
458
+ let mut warnings: Vec<String> = Vec::new();
459
+
460
+ delete_doc_body(&handoff, &doc.slug)?;
461
+ delete_doc(&handoff, &doc.slug)?;
462
+
463
+ if !doc.task_ids.is_empty() {
464
+ let tasks_dir = handoff.join("tasks");
465
+ let report = sync_doc_task_links(&tasks_dir, &doc.id, &doc.title, &[], &doc.task_ids)?;
466
+ if !report.unresolved.is_empty() {
467
+ warnings.push(format!(
468
+ "Could not resolve task id(s) for unlinking: {}",
469
+ report.unresolved.join(", ")
470
+ ));
471
+ }
472
+ }
473
+
474
+ if let Some(parent_id) = &doc.parent_id {
475
+ if let Some(mut parent) = find_doc_by_id(&handoff, parent_id)? {
476
+ let before = parent.children.len();
477
+ parent.children.retain(|c| c != &doc.id);
478
+ if parent.children.len() != before {
479
+ write_doc(&handoff, &parent)?;
480
+ }
481
+ } else {
482
+ warnings.push(format!("Parent document not found: {parent_id}"));
483
+ }
484
+ }
485
+
486
+ for child_id in &doc.children {
487
+ if let Some(mut child) = find_doc_by_id(&handoff, child_id)? {
488
+ child.parent_id = None;
489
+ write_doc(&handoff, &child)?;
490
+ } else {
491
+ warnings.push(format!("Child document not found: {child_id}"));
492
+ }
493
+ }
494
+
495
+ crate::context::doc_corpus_cache()
496
+ .lock()
497
+ .expect("cache")
498
+ .increment_generation();
499
+
500
+ Ok(to_json(&json!({
501
+ "deleted": true,
502
+ "doc_id": doc.id,
503
+ "section_count": doc.sections.len(),
504
+ "warnings": warnings,
505
+ })))
506
+ }
507
+
508
+ /// `handoff_doc_reassemble` — read a document's (by `doc_id` or `slug`)
509
+ /// original Markdown body directly from `_doc.<slug>.md` (v5: the `.md` file
510
+ /// already *is* the original document, restoring BOM/frontmatter is the only
511
+ /// reassembly step left), and detect drift (the body's current content hash
512
+ /// no longer matches the recorded `content_hash` — e.g. edited directly
513
+ /// outside `doc_save`). See `wiki/130-document-management.md` §5.5.
514
+ pub fn handle_doc_reassemble(arguments: &Value) -> Result<String> {
515
+ let project_dir = resolve_project_dir(arguments)?;
516
+ let handoff = ensure_handoff_exists(&project_dir)?;
517
+
518
+ let doc_id = arguments
519
+ .get("doc_id")
520
+ .and_then(|v| v.as_str())
521
+ .ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
522
+
523
+ let doc = resolve_doc(&handoff, doc_id)?
524
+ .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
525
+
526
+ let stripped_body = read_doc_body(&handoff, &doc.slug)?
527
+ .ok_or_else(|| anyhow::anyhow!("Document body file missing for slug '{}'", doc.slug))?;
528
+ let drifted = lexsim::content_hash(&stripped_body) != doc.content_hash;
529
+
530
+ let body = read_full_body(&handoff, &doc)?.unwrap_or_default();
531
+
532
+ let output_path = arguments.get("output_path").and_then(|v| v.as_str());
533
+ let mut out = json!({
534
+ "doc_id": doc.id,
535
+ "body": body,
536
+ "drifted": drifted,
537
+ });
538
+ if let Some(path) = output_path {
539
+ std::fs::write(path, &body)
540
+ .with_context(|| format!("Failed to write reassembled document to {path}"))?;
541
+ out["output_path"] = json!(path);
542
+ }
543
+
544
+ Ok(to_json(&out))
545
+ }
546
+
547
+ /// `handoff_doc_tree` — traverse a document's family tree starting from
548
+ /// `doc_id`: its immediate parent (if any) plus `depth` levels of children,
549
+ /// optionally including its `related` (semantic) links. See
550
+ /// `wiki/130-document-management.md` §5.6.
551
+ pub fn handle_doc_tree(arguments: &Value) -> Result<String> {
552
+ let project_dir = resolve_project_dir(arguments)?;
553
+ let handoff = ensure_handoff_exists(&project_dir)?;
554
+
555
+ let doc_id = arguments
556
+ .get("doc_id")
557
+ .and_then(|v| v.as_str())
558
+ .ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
559
+
560
+ let depth = arguments
561
+ .get("depth")
562
+ .and_then(|v| v.as_u64())
563
+ .unwrap_or(DEFAULT_TREE_DEPTH);
564
+
565
+ let include_related = arguments
566
+ .get("include_related")
567
+ .and_then(|v| v.as_bool())
568
+ .unwrap_or(false);
569
+
570
+ let doc = resolve_doc(&handoff, doc_id)?
571
+ .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
572
+
573
+ let mut tree = doc_tree_node_json(&handoff, &doc, include_related)?;
574
+
575
+ let parent = match &doc.parent_id {
576
+ Some(parent_id) => find_doc_by_id(&handoff, parent_id)?.map(|p| doc_tree_summary_json(&p)),
577
+ None => None,
578
+ };
579
+ tree["parent"] = parent.unwrap_or(Value::Null);
580
+
581
+ tree["children"] = json!(doc_tree_children(
582
+ &handoff,
583
+ &doc.children,
584
+ depth,
585
+ include_related
586
+ )?);
587
+
588
+ Ok(to_json(&tree))
589
+ }
590
+
591
+ /// Default depth for `handoff_doc_tree` when `depth` is omitted (spec §5.6).
592
+ const DEFAULT_TREE_DEPTH: u64 = 2;
593
+
594
+ /// Recursively builds the `children` array for [`handle_doc_tree`], descending
595
+ /// up to `depth` levels. Missing child documents (broken link) are skipped
596
+ /// rather than erroring the whole traversal.
597
+ fn doc_tree_children(
598
+ handoff: &Path,
599
+ child_ids: &[String],
600
+ depth: u64,
601
+ include_related: bool,
602
+ ) -> Result<Vec<Value>> {
603
+ if depth == 0 {
604
+ return Ok(Vec::new());
605
+ }
606
+ let mut out = Vec::with_capacity(child_ids.len());
607
+ for child_id in child_ids {
608
+ let Some(child) = find_doc_by_id(handoff, child_id)? else {
609
+ continue;
610
+ };
611
+ let mut node = doc_tree_node_json(handoff, &child, include_related)?;
612
+ node["children"] = json!(doc_tree_children(
613
+ handoff,
614
+ &child.children,
615
+ depth - 1,
616
+ include_related
617
+ )?);
618
+ out.push(node);
619
+ }
620
+ Ok(out)
621
+ }
622
+
623
+ /// Compact `{id, title, doc_type}` summary used for `parent` and family-tree
624
+ /// list entries (`related`) in `doc_tree`'s output.
625
+ fn doc_tree_summary_json(doc: &DocMetadata) -> Value {
626
+ json!({
627
+ "id": doc.id,
628
+ "title": doc.title,
629
+ "doc_type": doc.doc_type,
630
+ })
631
+ }
632
+
633
+ /// One node in the `doc_tree` output: id/title/doc_type plus (optionally)
634
+ /// `related` summaries (each resolved to `{id, rel, title}`). `children` is
635
+ /// populated by the caller afterward.
636
+ fn doc_tree_node_json(handoff: &Path, doc: &DocMetadata, include_related: bool) -> Result<Value> {
637
+ let mut related: Vec<Value> = Vec::new();
638
+ if include_related {
639
+ for r in &doc.related {
640
+ // related entries may point cross-tree/cross-project ids that
641
+ // don't resolve locally; that lookup is deferred to a future
642
+ // resolver (spec §10.3) — for now a related id that can't be
643
+ // read from this project's docs/ is a no-op skip, matching the
644
+ // same lenient policy as read_all_docs.
645
+ let Some(target) = find_doc_by_id(handoff, &r.id)? else {
646
+ continue;
647
+ };
648
+ related.push(json!({ "id": r.id, "rel": r.rel, "title": target.title }));
649
+ }
650
+ }
651
+ Ok(json!({
652
+ "id": doc.id,
653
+ "title": doc.title,
654
+ "doc_type": doc.doc_type,
655
+ "children": [],
656
+ "related": related,
657
+ }))
658
+ }
659
+
660
+ /// Recomputes `Verification.status` from its items (wiki/140-verification-matrix.md
661
+ /// §3.3): all pending -> "pending"; all verified/skipped -> "verified";
662
+ /// otherwise -> "in_review".
663
+ fn recompute_verification_status(items: &[VerificationItem]) -> String {
664
+ if items.iter().all(|i| i.status == "pending") {
665
+ "pending".to_string()
666
+ } else if items
667
+ .iter()
668
+ .all(|i| i.status == "verified" || i.status == "skipped")
669
+ {
670
+ "verified".to_string()
671
+ } else {
672
+ "in_review".to_string()
673
+ }
674
+ }
675
+
676
+ /// Parses a JSON array of `{path, lines?, label?}` objects into `CodeRef`s.
677
+ /// Entries missing the required `path` are skipped.
678
+ fn code_refs_from_value(v: &Value) -> Vec<CodeRef> {
679
+ v.as_array()
680
+ .map(|arr| {
681
+ arr.iter()
682
+ .filter_map(|r| {
683
+ let path = r.get("path").and_then(|v| v.as_str())?.to_string();
684
+ Some(CodeRef {
685
+ path,
686
+ lines: r.get("lines").and_then(|v| v.as_str()).map(str::to_string),
687
+ label: r.get("label").and_then(|v| v.as_str()).map(str::to_string),
688
+ })
689
+ })
690
+ .collect()
691
+ })
692
+ .unwrap_or_default()
693
+ }
694
+
695
+ /// Summary counts used by both `doc_verify`'s mutation response and
696
+ /// `doc_verify_status`'s `progress` block.
697
+ struct VerificationCounts {
698
+ checked: usize,
699
+ skipped: usize,
700
+ pending: usize,
701
+ total: usize,
702
+ stale: usize,
703
+ }
704
+
705
+ fn count_verification(doc: &DocMetadata, v: &Verification) -> VerificationCounts {
706
+ let checked = v.items.iter().filter(|i| i.status == "verified").count();
707
+ let skipped = v.items.iter().filter(|i| i.status == "skipped").count();
708
+ let pending = v.items.iter().filter(|i| i.status == "pending").count();
709
+ let stale = v.items.iter().filter(|i| item_is_stale(doc, i)).count();
710
+ VerificationCounts {
711
+ checked,
712
+ skipped,
713
+ pending,
714
+ total: v.items.len(),
715
+ stale,
716
+ }
717
+ }
718
+
719
+ /// An item is stale when it was verified at a content_hash that no longer
720
+ /// matches its section's current content_hash (spec §3.5) — items never
721
+ /// verified (`content_hash_at_verify: None`) are never stale, and items whose
722
+ /// section has been removed (sync should have dropped them, but be
723
+ /// defensive) are treated as stale so drift is never silently hidden.
724
+ fn item_is_stale(doc: &DocMetadata, item: &VerificationItem) -> bool {
725
+ let Some(hash_at_verify) = &item.content_hash_at_verify else {
726
+ return false;
727
+ };
728
+ match doc.sections.iter().find(|s| s.seq == item.fragment_seq) {
729
+ Some(section) => &section.content_hash != hash_at_verify,
730
+ None => true,
731
+ }
732
+ }
733
+
734
+ /// `handoff_doc_verify` — generate/check/skip/sync/set_refs a document's
735
+ /// verification matrix (wiki/140-verification-matrix.md §4.1).
736
+ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
737
+ let project_dir = resolve_project_dir(arguments)?;
738
+ let handoff = ensure_handoff_exists(&project_dir)?;
739
+
740
+ let doc_id = arguments
741
+ .get("doc_id")
742
+ .and_then(|v| v.as_str())
743
+ .ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
744
+ let action = arguments
745
+ .get("action")
746
+ .and_then(|v| v.as_str())
747
+ .ok_or_else(|| anyhow::anyhow!("'action' is required"))?;
748
+
749
+ let mut doc = resolve_doc(&handoff, doc_id)?
750
+ .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
751
+
752
+ let now = chrono::Utc::now().to_rfc3339();
753
+
754
+ match action {
755
+ "generate" => {
756
+ if doc.verification.is_some() {
757
+ anyhow::bail!(
758
+ "Verification matrix already exists for document {doc_id}; use action='sync' to re-sync it instead"
759
+ );
760
+ }
761
+ let skip_seqs: Vec<usize> = arguments
762
+ .get("skip_seqs")
763
+ .and_then(|v| v.as_array())
764
+ .map(|arr| {
765
+ arr.iter()
766
+ .filter_map(|v| v.as_u64())
767
+ .map(|n| n as usize)
768
+ .collect()
769
+ })
770
+ .unwrap_or_default();
771
+
772
+ let items: Vec<VerificationItem> = doc
773
+ .sections
774
+ .iter()
775
+ .map(|s| VerificationItem {
776
+ fragment_seq: s.seq,
777
+ heading: s.heading.clone(),
778
+ status: if skip_seqs.contains(&s.seq) {
779
+ "skipped".to_string()
780
+ } else {
781
+ "pending".to_string()
782
+ },
783
+ impl_refs: Vec::new(),
784
+ test_refs: Vec::new(),
785
+ reviewer: None,
786
+ verified_at: None,
787
+ notes: String::new(),
788
+ content_hash_at_verify: None,
789
+ })
790
+ .collect();
791
+
792
+ doc.verification = Some(Verification {
793
+ status: recompute_verification_status(&items),
794
+ created_at: now.clone(),
795
+ updated_at: now,
796
+ items,
797
+ });
798
+ }
799
+ "check" => {
800
+ let fragment_seq = required_fragment_seq(arguments)?;
801
+ let reviewer = arguments
802
+ .get("reviewer")
803
+ .and_then(|v| v.as_str())
804
+ .map(str::to_string);
805
+ let notes = arguments
806
+ .get("notes")
807
+ .and_then(|v| v.as_str())
808
+ .map(str::to_string);
809
+ let section_hash = doc
810
+ .sections
811
+ .iter()
812
+ .find(|s| s.seq == fragment_seq)
813
+ .map(|s| s.content_hash.clone());
814
+
815
+ let v = verification_mut(&mut doc, doc_id)?;
816
+ let item = find_item_mut(v, fragment_seq, doc_id)?;
817
+ item.status = "verified".to_string();
818
+ item.verified_at = Some(now.clone());
819
+ if reviewer.is_some() {
820
+ item.reviewer = reviewer;
821
+ }
822
+ if let Some(notes) = notes {
823
+ item.notes = notes;
824
+ }
825
+ item.content_hash_at_verify = section_hash;
826
+ v.updated_at = now.clone();
827
+ v.status = recompute_verification_status(&v.items);
828
+ }
829
+ "skip" => {
830
+ let fragment_seq = required_fragment_seq(arguments)?;
831
+ let v = verification_mut(&mut doc, doc_id)?;
832
+ let item = find_item_mut(v, fragment_seq, doc_id)?;
833
+ item.status = "skipped".to_string();
834
+ v.updated_at = now.clone();
835
+ v.status = recompute_verification_status(&v.items);
836
+ }
837
+ "sync" => {
838
+ let sections = doc.sections.clone();
839
+ let v = verification_mut(&mut doc, doc_id)?;
840
+ let current_seqs: std::collections::HashSet<usize> =
841
+ sections.iter().map(|s| s.seq).collect();
842
+ v.items.retain(|i| current_seqs.contains(&i.fragment_seq));
843
+ let existing_seqs: std::collections::HashSet<usize> =
844
+ v.items.iter().map(|i| i.fragment_seq).collect();
845
+ for s in &sections {
846
+ if !existing_seqs.contains(&s.seq) {
847
+ v.items.push(VerificationItem {
848
+ fragment_seq: s.seq,
849
+ heading: s.heading.clone(),
850
+ status: "pending".to_string(),
851
+ impl_refs: Vec::new(),
852
+ test_refs: Vec::new(),
853
+ reviewer: None,
854
+ verified_at: None,
855
+ notes: String::new(),
856
+ content_hash_at_verify: None,
857
+ });
858
+ }
859
+ }
860
+ v.items.sort_by_key(|i| i.fragment_seq);
861
+ v.updated_at = now.clone();
862
+ v.status = recompute_verification_status(&v.items);
863
+ }
864
+ "set_refs" => {
865
+ let fragment_seq = required_fragment_seq(arguments)?;
866
+ let impl_refs = arguments.get("impl_refs").map(code_refs_from_value);
867
+ let test_refs = arguments.get("test_refs").map(code_refs_from_value);
868
+
869
+ let v = verification_mut(&mut doc, doc_id)?;
870
+ let item = find_item_mut(v, fragment_seq, doc_id)?;
871
+ if let Some(impl_refs) = impl_refs {
872
+ item.impl_refs = impl_refs;
873
+ }
874
+ if let Some(test_refs) = test_refs {
875
+ item.test_refs = test_refs;
876
+ }
877
+ v.updated_at = now.clone();
878
+ v.status = recompute_verification_status(&v.items);
879
+ }
880
+ other => anyhow::bail!(
881
+ "Unknown action '{other}'; expected one of generate, check, skip, sync, set_refs"
882
+ ),
883
+ }
884
+
885
+ write_doc(&handoff, &doc)?;
886
+
887
+ let v = doc
888
+ .verification
889
+ .as_ref()
890
+ .expect("verification was just set/mutated above");
891
+ let counts = count_verification(&doc, v);
892
+
893
+ Ok(to_json(&json!({
894
+ "doc_id": doc.id,
895
+ "verification_status": v.status,
896
+ "checked": counts.checked,
897
+ "skipped": counts.skipped,
898
+ "pending": counts.pending,
899
+ "total": counts.total,
900
+ "stale": counts.stale,
901
+ })))
902
+ }
903
+
904
+ fn required_fragment_seq(arguments: &Value) -> Result<usize> {
905
+ arguments
906
+ .get("fragment_seq")
907
+ .and_then(|v| v.as_u64())
908
+ .map(|n| n as usize)
909
+ .ok_or_else(|| anyhow::anyhow!("'fragment_seq' is required for this action"))
910
+ }
911
+
912
+ fn verification_mut<'a>(doc: &'a mut DocMetadata, doc_id: &str) -> Result<&'a mut Verification> {
913
+ doc.verification.as_mut().ok_or_else(|| {
914
+ anyhow::anyhow!(
915
+ "No verification matrix exists for document {doc_id}; use action='generate' first"
916
+ )
917
+ })
918
+ }
919
+
920
+ fn find_item_mut<'a>(
921
+ v: &'a mut Verification,
922
+ fragment_seq: usize,
923
+ doc_id: &str,
924
+ ) -> Result<&'a mut VerificationItem> {
925
+ v.items
926
+ .iter_mut()
927
+ .find(|i| i.fragment_seq == fragment_seq)
928
+ .ok_or_else(|| {
929
+ anyhow::anyhow!(
930
+ "No verification item at fragment_seq={fragment_seq} for document {doc_id}"
931
+ )
932
+ })
933
+ }
934
+
935
+ /// `handoff_doc_verify_status` — verification matrix summary + optional
936
+ /// per-item detail with stale detection (wiki/140-verification-matrix.md
937
+ /// §4.2).
938
+ pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
939
+ let project_dir = resolve_project_dir(arguments)?;
940
+ let handoff = ensure_handoff_exists(&project_dir)?;
941
+
942
+ let doc_id = arguments
943
+ .get("doc_id")
944
+ .and_then(|v| v.as_str())
945
+ .ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
946
+ let include_items = arguments
947
+ .get("include_items")
948
+ .and_then(|v| v.as_bool())
949
+ .unwrap_or(false);
950
+
951
+ let doc = resolve_doc(&handoff, doc_id)?
952
+ .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
953
+
954
+ let v = doc.verification.as_ref().ok_or_else(|| {
955
+ anyhow::anyhow!(
956
+ "No verification matrix exists for document {doc_id}; use handoff_doc_verify(action='generate') first"
957
+ )
958
+ })?;
959
+
960
+ let counts = count_verification(&doc, v);
961
+ let percentage = if counts.total == 0 {
962
+ 0.0
963
+ } else {
964
+ (counts.checked + counts.skipped) as f64 / counts.total as f64 * 100.0
965
+ };
966
+
967
+ let mut out = json!({
968
+ "doc_id": doc.id,
969
+ "title": doc.title,
970
+ "verification_status": v.status,
971
+ "progress": {
972
+ "checked": counts.checked,
973
+ "skipped": counts.skipped,
974
+ "pending": counts.pending,
975
+ "total": counts.total,
976
+ "stale": counts.stale,
977
+ "percentage": percentage,
978
+ },
979
+ });
980
+
981
+ if include_items {
982
+ let items: Vec<Value> = v
983
+ .items
984
+ .iter()
985
+ .map(|i| {
986
+ json!({
987
+ "fragment_seq": i.fragment_seq,
988
+ "heading": i.heading,
989
+ "status": i.status,
990
+ "stale": item_is_stale(&doc, i),
991
+ "impl_refs": i.impl_refs,
992
+ "test_refs": i.test_refs,
993
+ "reviewer": i.reviewer,
994
+ "verified_at": i.verified_at,
995
+ "notes": i.notes,
996
+ })
997
+ })
998
+ .collect();
999
+ out["items"] = json!(items);
1000
+ }
1001
+
1002
+ Ok(to_json(&out))
1003
+ }
1004
+
1005
+ fn doc_metadata_json(doc: &DocMetadata) -> Value {
1006
+ json!({
1007
+ "id": doc.id,
1008
+ "slug": doc.slug,
1009
+ "title": doc.title,
1010
+ "doc_type": doc.doc_type,
1011
+ "tags": doc.tags,
1012
+ "scope_paths": doc.scope_paths,
1013
+ "parent_id": doc.parent_id,
1014
+ "children": doc.children,
1015
+ "related": doc.related,
1016
+ "auto_inject": doc.auto_inject,
1017
+ "task_ids": doc.task_ids,
1018
+ "has_bom": doc.has_bom,
1019
+ "line_ending": doc.line_ending,
1020
+ "sections": doc.sections,
1021
+ "section_count": doc.sections.len(),
1022
+ "created_at": doc.created_at,
1023
+ "updated_at": doc.updated_at,
1024
+ "content_hash": doc.content_hash,
1025
+ })
1026
+ }
1027
+
1028
+ /// Read a `&[String]` from a JSON string-array value (missing/non-array →
1029
+ /// empty).
1030
+ fn string_array_value(v: &Value) -> Vec<String> {
1031
+ v.as_array()
1032
+ .map(|arr| {
1033
+ arr.iter()
1034
+ .filter_map(|v| v.as_str())
1035
+ .map(str::to_string)
1036
+ .collect()
1037
+ })
1038
+ .unwrap_or_default()
1039
+ }
1040
+
1041
+ fn to_json(v: &Value) -> String {
1042
+ serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
1043
+ }