handoff-mcp-server 0.26.0 → 0.27.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.
Files changed (72) hide show
  1. package/README.md +31 -4
  2. package/bin/handoff-mcp.js +56 -13
  3. package/bin/resolve-binary.js +122 -0
  4. package/package.json +14 -9
  5. package/Cargo.lock +0 -686
  6. package/Cargo.toml +0 -30
  7. package/scripts/cargo-env.sh +0 -29
  8. package/scripts/handoff-memory-hook.py +0 -208
  9. package/scripts/install-local.sh +0 -109
  10. package/scripts/postinstall.js +0 -50
  11. package/scripts/sync-plugin-skills.sh +0 -35
  12. package/scripts/sync-plugin-version.sh +0 -85
  13. package/scripts/sync-workflow-inline.sh +0 -138
  14. package/src/cli.rs +0 -551
  15. package/src/context/injection.rs +0 -276
  16. package/src/context/mod.rs +0 -129
  17. package/src/lib.rs +0 -5
  18. package/src/main.rs +0 -157
  19. package/src/mcp/handlers/assignees.rs +0 -254
  20. package/src/mcp/handlers/auto_schedule.rs +0 -489
  21. package/src/mcp/handlers/bulk_update.rs +0 -155
  22. package/src/mcp/handlers/calendar.rs +0 -196
  23. package/src/mcp/handlers/capacity.rs +0 -318
  24. package/src/mcp/handlers/check_criterion.rs +0 -70
  25. package/src/mcp/handlers/config.rs +0 -402
  26. package/src/mcp/handlers/config_crud.rs +0 -183
  27. package/src/mcp/handlers/dashboard.rs +0 -214
  28. package/src/mcp/handlers/docs.rs +0 -2288
  29. package/src/mcp/handlers/docs_query.rs +0 -1335
  30. package/src/mcp/handlers/fork_session.rs +0 -91
  31. package/src/mcp/handlers/get_session.rs +0 -48
  32. package/src/mcp/handlers/get_task.rs +0 -53
  33. package/src/mcp/handlers/import_context.rs +0 -470
  34. package/src/mcp/handlers/init.rs +0 -28
  35. package/src/mcp/handlers/list_sessions.rs +0 -187
  36. package/src/mcp/handlers/list_tasks.rs +0 -308
  37. package/src/mcp/handlers/load_context.rs +0 -361
  38. package/src/mcp/handlers/log_time.rs +0 -67
  39. package/src/mcp/handlers/memory.rs +0 -961
  40. package/src/mcp/handlers/merge_sessions.rs +0 -103
  41. package/src/mcp/handlers/metrics.rs +0 -196
  42. package/src/mcp/handlers/milestones.rs +0 -102
  43. package/src/mcp/handlers/mod.rs +0 -140
  44. package/src/mcp/handlers/refer.rs +0 -307
  45. package/src/mcp/handlers/referrals.rs +0 -74
  46. package/src/mcp/handlers/save_context.rs +0 -354
  47. package/src/mcp/handlers/task_checklist.rs +0 -507
  48. package/src/mcp/handlers/timer.rs +0 -529
  49. package/src/mcp/handlers/update_session.rs +0 -197
  50. package/src/mcp/handlers/update_task.rs +0 -452
  51. package/src/mcp/mod.rs +0 -6
  52. package/src/mcp/protocol.rs +0 -41
  53. package/src/mcp/resources.rs +0 -57
  54. package/src/mcp/router.rs +0 -154
  55. package/src/mcp/tools.rs +0 -1522
  56. package/src/mcp/types.rs +0 -108
  57. package/src/setup.rs +0 -1212
  58. package/src/storage/config.rs +0 -578
  59. package/src/storage/docs/frontmatter.rs +0 -509
  60. package/src/storage/docs/mod.rs +0 -835
  61. package/src/storage/docs/model.rs +0 -708
  62. package/src/storage/docs/reassemble.rs +0 -167
  63. package/src/storage/docs/split.rs +0 -377
  64. package/src/storage/git.rs +0 -47
  65. package/src/storage/memory/injected.rs +0 -340
  66. package/src/storage/memory/mod.rs +0 -236
  67. package/src/storage/memory/model.rs +0 -127
  68. package/src/storage/mod.rs +0 -96
  69. package/src/storage/referrals.rs +0 -248
  70. package/src/storage/sessions.rs +0 -859
  71. package/src/storage/tasks.rs +0 -957
  72. package/templates/claude-md-section.md +0 -12
@@ -1,2288 +0,0 @@
1
- //! MCP handlers for document management (save / get / list) — P1-6a (t96.1),
2
- //! frontmatter migration (t123.1-t123.3, single-file slug-based storage,
3
- //! wiki/130-document-management.md §3.1).
4
- //!
5
- //! Builds on the storage layer in `crate::storage::docs` (on-demand section
6
- //! computation + slug-named `_doc.<slug>.md` frontmatter+body 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
- SubItem, 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_arg = arguments.get("body").and_then(|v| v.as_str());
65
- let append_body_arg = arguments.get("append_body").and_then(|v| v.as_str());
66
- if body_arg.is_some() && append_body_arg.is_some() {
67
- anyhow::bail!("'body' and 'append_body' are mutually exclusive");
68
- }
69
- if body_arg.is_none() && append_body_arg.is_none() {
70
- anyhow::bail!("either 'body' or 'append_body' is required");
71
- }
72
-
73
- let doc_id = arguments.get("doc_id").and_then(|v| v.as_str());
74
- if append_body_arg.is_some() && doc_id.is_none() {
75
- anyhow::bail!(
76
- "'append_body' requires 'doc_id' (appending to a new document is not meaningful)"
77
- );
78
- }
79
- let existing = match doc_id {
80
- Some(id) => Some(
81
- find_doc_by_id(&handoff, id)?
82
- .ok_or_else(|| anyhow::anyhow!("Document not found: {id}"))?,
83
- ),
84
- None => None,
85
- };
86
-
87
- // `append_body`: join the appended text onto the existing document's
88
- // stripped body (read_doc_body — NOT read_full_body, whose BOM
89
- // restoration would otherwise get re-detected and double-persisted by
90
- // `split()` below). No separator is inserted when the existing body is
91
- // empty/missing (spec §3.1 edge case).
92
- let joined_body: String;
93
- let body: &str = if let Some(append_body) = append_body_arg {
94
- let existing_doc = existing
95
- .as_ref()
96
- .expect("append_body requires doc_id, checked above, so existing is Some");
97
- let existing_body = read_doc_body(&handoff, &existing_doc.slug)?.unwrap_or_default();
98
- let separator = arguments
99
- .get("separator")
100
- .and_then(|v| v.as_str())
101
- .unwrap_or("\n\n");
102
- joined_body = if existing_body.is_empty() {
103
- append_body.to_string()
104
- } else {
105
- format!("{existing_body}{separator}{append_body}")
106
- };
107
- &joined_body
108
- } else {
109
- body_arg.expect("body_arg is Some in this branch, checked above")
110
- };
111
-
112
- // slug: required for new documents, taken from the existing document on
113
- // update (the `slug` argument is ignored on update — renaming a
114
- // document's file-naming slug is out of scope for `doc_save`).
115
- let slug = match &existing {
116
- Some(d) => d.slug.clone(),
117
- None => {
118
- let slug = arguments
119
- .get("slug")
120
- .and_then(|v| v.as_str())
121
- .ok_or_else(|| anyhow::anyhow!("'slug' is required for new documents"))?
122
- .to_string();
123
- validate_slug(&slug)?;
124
- if read_doc(&handoff, &slug)?.is_some() {
125
- anyhow::bail!("slug '{slug}' is already in use by another document");
126
- }
127
- slug
128
- }
129
- };
130
-
131
- let title = arguments
132
- .get("title")
133
- .and_then(|v| v.as_str())
134
- .or(existing.as_ref().map(|d| d.title.as_str()))
135
- .ok_or_else(|| anyhow::anyhow!("'title' is required for new documents"))?
136
- .to_string();
137
-
138
- let split_level = arguments
139
- .get("split_level")
140
- .and_then(|v| v.as_u64())
141
- .map(|n| n as u8)
142
- .unwrap_or(crate::storage::docs::split::DEFAULT_SPLIT_LEVEL);
143
-
144
- let split_doc = split(body, split_level)?;
145
-
146
- let now = chrono::Utc::now().to_rfc3339();
147
- let id = doc_id.map(str::to_string).unwrap_or_else(new_doc_id);
148
-
149
- let mut doc = match existing {
150
- Some(mut d) => {
151
- d.title = title.clone();
152
- d
153
- }
154
- None => {
155
- let mut d = DocMetadata::new(
156
- id.clone(),
157
- slug.clone(),
158
- title.clone(),
159
- "note".to_string(),
160
- now.clone(),
161
- );
162
- d.source.origin = "authored".to_string();
163
- d
164
- }
165
- };
166
-
167
- if let Some(doc_type) = arguments.get("doc_type").and_then(|v| v.as_str()) {
168
- doc.doc_type = doc_type.to_string();
169
- }
170
- if let Some(tags) = arguments.get("tags") {
171
- doc.tags = string_array_value(tags);
172
- }
173
- if let Some(scope_paths) = arguments.get("scope_paths") {
174
- doc.scope_paths = string_array_value(scope_paths);
175
- }
176
- let previous_parent_id = doc.parent_id.clone();
177
- if let Some(parent_id) = arguments.get("parent_id") {
178
- doc.parent_id = parent_id.as_str().map(str::to_string);
179
- }
180
- let mut warnings: Vec<String> = Vec::new();
181
- if let Some(related) = arguments.get("related").and_then(|v| v.as_array()) {
182
- let mut malformed_count = 0usize;
183
- doc.related = related
184
- .iter()
185
- .filter_map(|r| {
186
- let rid = r.get("id").and_then(|v| v.as_str());
187
- let rel = r.get("rel").and_then(|v| v.as_str());
188
- match (rid, rel) {
189
- (Some(rid), Some(rel)) => Some(DocRelation {
190
- id: rid.to_string(),
191
- rel: rel.to_string(),
192
- }),
193
- _ => {
194
- malformed_count += 1;
195
- None
196
- }
197
- }
198
- })
199
- .collect();
200
- if malformed_count > 0 {
201
- warnings.push(format!(
202
- "Ignored {malformed_count} malformed 'related' entr{} (each entry requires string 'id' and 'rel')",
203
- if malformed_count == 1 { "y" } else { "ies" }
204
- ));
205
- }
206
- }
207
- if let Some(auto_inject) = arguments.get("auto_inject").and_then(|v| v.as_str()) {
208
- doc.auto_inject = auto_inject.to_string();
209
- }
210
-
211
- doc.has_bom = split_doc.has_bom;
212
- doc.line_ending = split_doc.line_ending.to_string();
213
- doc.split_level = split_level;
214
- doc.updated_at = now.clone();
215
-
216
- // v5: the full body (after BOM/frontmatter stripping) is written verbatim
217
- // to `_doc.<slug>.md`; sections are an in-memory byte-offset index into
218
- // it, computed fresh on every save (no stale-fragment cleanup needed —
219
- // there is nothing left on disk to clean up per section).
220
- let body_after_strip: String = split_doc.fragments.iter().map(|f| f.body).collect();
221
- if !body_after_strip.starts_with("# ") {
222
- warnings.push(
223
- "body does not start with a level-1 heading — consider adding one for readability"
224
- .to_string(),
225
- );
226
- }
227
- write_doc_body(&handoff, &slug, &body_after_strip)?;
228
- doc.sections = compute_sections(&split_doc);
229
-
230
- let content_hash = lexsim::content_hash(&body_after_strip);
231
- doc.content_hash = content_hash.clone();
232
- doc.source.canonical_hash = Some(content_hash);
233
-
234
- let new_task_ids = arguments
235
- .get("task_ids")
236
- .map(string_array_value)
237
- .unwrap_or_else(|| doc.task_ids.clone());
238
-
239
- if arguments.get("task_ids").is_some() {
240
- let (link_ids, unlink_ids) = if doc_id.is_some() {
241
- let previous: Vec<String> = doc.task_ids.clone();
242
- let link: Vec<String> = new_task_ids
243
- .iter()
244
- .filter(|t| !previous.contains(t))
245
- .cloned()
246
- .collect();
247
- let unlink: Vec<String> = previous
248
- .iter()
249
- .filter(|t| !new_task_ids.contains(t))
250
- .cloned()
251
- .collect();
252
- (link, unlink)
253
- } else {
254
- (new_task_ids.clone(), Vec::new())
255
- };
256
-
257
- let tasks_dir = handoff.join("tasks");
258
- let report = sync_doc_task_links(&tasks_dir, &id, &title, &link_ids, &unlink_ids)?;
259
- if !report.unresolved.is_empty() {
260
- warnings.push(format!(
261
- "Could not resolve task id(s) for linking: {}",
262
- report.unresolved.join(", ")
263
- ));
264
- }
265
- doc.task_ids = new_task_ids;
266
- }
267
-
268
- write_doc(&handoff, &doc)?;
269
-
270
- // Keep the family tree's `children` list in sync with `parent_id`: if the
271
- // parent changed (including unset -> set on first save), push this doc's
272
- // id into the new parent's `children` and drop it from the old parent's,
273
- // mirroring the same "sync the other side" pattern as
274
- // sync_doc_task_links. A parent id that doesn't resolve is a non-fatal
275
- // warning, not a rollback — same policy as unresolved task_ids above.
276
- // `parent_id` references a document's stable `id`, not its `slug`, so
277
- // resolution goes through `find_doc_by_id`.
278
- if doc.parent_id != previous_parent_id {
279
- if let Some(old_parent_id) = &previous_parent_id {
280
- if let Some(mut old_parent) = find_doc_by_id(&handoff, old_parent_id)? {
281
- let before = old_parent.children.len();
282
- old_parent.children.retain(|c| c != &id);
283
- if old_parent.children.len() != before {
284
- write_doc(&handoff, &old_parent)?;
285
- }
286
- }
287
- }
288
- if let Some(new_parent_id) = &doc.parent_id {
289
- match find_doc_by_id(&handoff, new_parent_id)? {
290
- Some(mut new_parent) => {
291
- if !new_parent.children.iter().any(|c| c == &id) {
292
- new_parent.children.push(id.clone());
293
- write_doc(&handoff, &new_parent)?;
294
- }
295
- }
296
- None => warnings.push(format!("Parent document not found: {new_parent_id}")),
297
- }
298
- }
299
- }
300
-
301
- Ok(to_json(&json!({
302
- "doc_id": id,
303
- "slug": doc.slug,
304
- "title": doc.title,
305
- "doc_type": doc.doc_type,
306
- "section_count": doc.sections.len(),
307
- "content_hash": doc.content_hash,
308
- "warnings": warnings,
309
- })))
310
- }
311
-
312
- /// `handoff_doc_update_section` — replace a single section's body by `seq`
313
- /// without requiring the caller to re-send the whole document (partial
314
- /// update API, t123.4). Computes sections on-demand from the current body
315
- /// (mirrors `read_doc`'s recompute — sections are never trusted from
316
- /// frontmatter), byte-slices out the target section's range, splices in
317
- /// `new_content`, and writes the result back. `expected_hash` is an optional
318
- /// optimistic lock against the section's current `content_hash`.
319
- pub fn handle_doc_update_section(arguments: &Value) -> Result<String> {
320
- let project_dir = resolve_project_dir(arguments)?;
321
- let handoff = ensure_handoff_exists(&project_dir)?;
322
-
323
- let doc_id = arguments
324
- .get("doc_id")
325
- .and_then(|v| v.as_str())
326
- .ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
327
- let seq = arguments
328
- .get("seq")
329
- .and_then(|v| v.as_u64())
330
- .ok_or_else(|| anyhow::anyhow!("'seq' is required"))? as usize;
331
- let new_content = arguments
332
- .get("new_content")
333
- .and_then(|v| v.as_str())
334
- .ok_or_else(|| anyhow::anyhow!("'new_content' is required"))?;
335
- let expected_hash = arguments.get("expected_hash").and_then(|v| v.as_str());
336
-
337
- let mut doc = resolve_doc(&handoff, doc_id)?
338
- .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
339
-
340
- let body = read_doc_body(&handoff, &doc.slug)?
341
- .ok_or_else(|| anyhow::anyhow!("Document body file missing for slug '{}'", doc.slug))?;
342
- let split_doc = split(&body, doc.split_level)?;
343
- let sections = compute_sections(&split_doc);
344
-
345
- let section = sections
346
- .iter()
347
- .find(|s| s.seq == seq)
348
- .ok_or_else(|| anyhow::anyhow!("Section not found: doc_id={doc_id} seq={seq}"))?;
349
-
350
- if let Some(expected) = expected_hash {
351
- if expected != section.content_hash {
352
- anyhow::bail!(
353
- "expected_hash mismatch for doc_id={doc_id} seq={seq}: expected {expected}, \
354
- current content_hash is {} — retry with the current hash if this overwrite is \
355
- still intended",
356
- section.content_hash
357
- );
358
- }
359
- }
360
-
361
- // Splice `new_content` into the section's byte range. `extract_section`
362
- // is not used here (it would re-validate the just-computed hash, which
363
- // is redundant since `section` was computed from this exact `body`
364
- // moments ago) — the byte range is sliced directly instead.
365
- let start = section.byte_offset;
366
- let end = section.byte_offset + section.byte_length;
367
- let mut new_body = String::with_capacity(body.len() - (end - start) + new_content.len());
368
- new_body.push_str(&body[..start]);
369
- new_body.push_str(new_content);
370
- new_body.push_str(&body[end..]);
371
-
372
- write_doc_body(&handoff, &doc.slug, &new_body)?;
373
-
374
- let new_split_doc = split(&new_body, doc.split_level)?;
375
- let new_sections = compute_sections(&new_split_doc);
376
- doc.sections = new_sections.clone();
377
-
378
- let now = chrono::Utc::now().to_rfc3339();
379
- doc.updated_at = now;
380
-
381
- let content_hash = lexsim::content_hash(&new_body);
382
- doc.content_hash = content_hash.clone();
383
- doc.source.canonical_hash = Some(content_hash);
384
-
385
- write_doc(&handoff, &doc)?;
386
-
387
- crate::context::doc_corpus_cache()
388
- .lock()
389
- .expect("cache")
390
- .increment_generation();
391
-
392
- let updated_section = new_sections.iter().find(|s| s.seq == seq);
393
- let verification_stale = doc.verification.as_ref().is_some_and(|v| {
394
- v.items
395
- .iter()
396
- .any(|i| i.fragment_seq == Some(seq) && item_is_stale(&doc, i))
397
- });
398
-
399
- let mut out = json!({
400
- "doc_id": doc.id,
401
- "seq": seq,
402
- "heading": updated_section.map(|s| s.heading.clone()),
403
- "content_hash": updated_section.map(|s| s.content_hash.clone()),
404
- "updated_at": doc.updated_at,
405
- "section_count": doc.sections.len(),
406
- });
407
- if verification_stale {
408
- out["warnings"] = json!([format!(
409
- "Verification item at fragment_seq={seq} is now stale (content changed since it was verified)"
410
- )]);
411
- }
412
-
413
- Ok(to_json(&out))
414
- }
415
-
416
- /// Reads a document's authored content body: the part of `_doc.<slug>.md`
417
- /// *after* handoff's own YAML frontmatter block, with the original UTF-8 BOM
418
- /// (if any) restored in front of it. Returns `Ok(None)` when the `.md` file
419
- /// is missing.
420
- ///
421
- /// Frontmatter migration (t123.1): the `.md` file's frontmatter now *is* the
422
- /// document's metadata (handoff-owned), not a user-authored block being
423
- /// losslessly stashed — so unlike the pre-migration 2-file format, a leading
424
- /// YAML block the caller originally passed into `doc_save`'s `body` argument
425
- /// is absorbed into (and superseded by) handoff's own frontmatter, not
426
- /// preserved verbatim. Only the BOM is still restored losslessly.
427
- fn read_full_body(handoff: &Path, doc: &DocMetadata) -> Result<Option<String>> {
428
- let Some(body) = read_doc_body(handoff, &doc.slug)? else {
429
- return Ok(None);
430
- };
431
- Ok(Some(if doc.has_bom {
432
- format!("\u{FEFF}{body}")
433
- } else {
434
- body
435
- }))
436
- }
437
-
438
- /// `handoff_doc_get` — read a document (by `doc_id` or `slug`) as `full`
439
- /// (the original Markdown body + metadata), `meta` (metadata only), or
440
- /// `section` (one section's body, byte-sliced from `_doc.<slug>.md`).
441
- pub fn handle_doc_get(arguments: &Value) -> Result<String> {
442
- let project_dir = resolve_project_dir(arguments)?;
443
- let handoff = ensure_handoff_exists(&project_dir)?;
444
-
445
- let doc_id = arguments
446
- .get("doc_id")
447
- .and_then(|v| v.as_str())
448
- .ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
449
-
450
- let format = arguments
451
- .get("format")
452
- .and_then(|v| v.as_str())
453
- .unwrap_or("full");
454
-
455
- match format {
456
- "meta" => {
457
- let doc = resolve_doc(&handoff, doc_id)?
458
- .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
459
- Ok(to_json(&doc_metadata_json(&doc)))
460
- }
461
- "section" | "fragment" => {
462
- let seq = arguments
463
- .get("seq")
464
- .and_then(|v| v.as_u64())
465
- .ok_or_else(|| anyhow::anyhow!("'seq' is required when format='section'"))?
466
- as usize;
467
- let doc = resolve_doc(&handoff, doc_id)?
468
- .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
469
- let section =
470
- doc.sections.iter().find(|s| s.seq == seq).ok_or_else(|| {
471
- anyhow::anyhow!("Section not found: doc_id={doc_id} seq={seq}")
472
- })?;
473
- let body = read_doc_body(&handoff, &doc.slug)?.ok_or_else(|| {
474
- anyhow::anyhow!("Document body file missing for slug '{}'", doc.slug)
475
- })?;
476
- let section_body = extract_section(&body, section)?;
477
- Ok(to_json(&json!({
478
- "doc_id": doc.id,
479
- "seq": section.seq,
480
- "heading": section.heading,
481
- "level": section.level,
482
- "content_hash": section.content_hash,
483
- "body": section_body,
484
- })))
485
- }
486
- _ => {
487
- let doc = resolve_doc(&handoff, doc_id)?
488
- .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
489
- let body = read_full_body(&handoff, &doc)?.unwrap_or_default();
490
- let mut out = doc_metadata_json(&doc);
491
- out["body"] = json!(body);
492
- Ok(to_json(&out))
493
- }
494
- }
495
- }
496
-
497
- /// `handoff_doc_list` — list/search documents with optional `doc_type` /
498
- /// `tags` (AND) / `task_id` filters, BM25 `query` ranking, and optional
499
- /// reassembled `body` inclusion.
500
- pub fn handle_doc_list(arguments: &Value) -> Result<String> {
501
- let project_dir = resolve_project_dir(arguments)?;
502
- let handoff = ensure_handoff_exists(&project_dir)?;
503
-
504
- let doc_type = arguments.get("doc_type").and_then(|v| v.as_str());
505
- let tags = arguments.get("tags").map(string_array_value);
506
- let task_id = arguments.get("task_id").and_then(|v| v.as_str());
507
- let include_body = arguments
508
- .get("include_body")
509
- .and_then(|v| v.as_bool())
510
- .unwrap_or(false);
511
- let query = arguments
512
- .get("query")
513
- .and_then(|v| v.as_str())
514
- .map(str::trim)
515
- .filter(|s| !s.is_empty());
516
-
517
- let mut docs = read_all_docs(&handoff)?;
518
- if let Some(dt) = doc_type {
519
- docs.retain(|d| d.doc_type == dt);
520
- }
521
- if let Some(tags) = &tags {
522
- if !tags.is_empty() {
523
- docs.retain(|d| tags.iter().all(|t| d.tags.contains(t)));
524
- }
525
- }
526
- if let Some(tid) = task_id {
527
- docs.retain(|d| d.task_ids.iter().any(|t| t == tid));
528
- }
529
-
530
- let ordered_indices: Vec<usize> = if let Some(q) = query {
531
- rank_docs_by_query(&handoff, &docs, q)?
532
- } else {
533
- (0..docs.len()).collect()
534
- };
535
-
536
- let mut out_docs = Vec::with_capacity(ordered_indices.len());
537
- for idx in ordered_indices {
538
- let d = &docs[idx];
539
- let mut entry = doc_metadata_json(d);
540
- if include_body {
541
- let body = read_full_body(&handoff, d)?.unwrap_or_default();
542
- entry["body"] = json!(body);
543
- }
544
- out_docs.push(entry);
545
- }
546
-
547
- Ok(to_json(&json!({ "documents": out_docs })))
548
- }
549
-
550
- /// Ranks `docs` against `query` via BM25 over each document's index text
551
- /// (title + tags + body), returning original-order indices sorted by
552
- /// descending relevance. Corpus is built fresh every call (no cache — the
553
- /// cache is reserved for `doc_query`, t96.3, per the task's own note).
554
- fn rank_docs_by_query(handoff: &Path, docs: &[DocMetadata], query: &str) -> Result<Vec<usize>> {
555
- let mut index_texts = Vec::with_capacity(docs.len());
556
- for d in docs {
557
- let body = read_doc_body(handoff, &d.slug)?.unwrap_or_default();
558
- let mut text = d.title.clone();
559
- text.push(' ');
560
- text.push_str(&d.tags.join(" "));
561
- text.push(' ');
562
- text.push_str(&body);
563
- index_texts.push(text);
564
- }
565
-
566
- let corpus = lexsim::Corpus::build_weighted(&index_texts);
567
- let query_tokens = lexsim::tokenize_weighted(query);
568
- let scope_paths: Vec<Vec<String>> = docs.iter().map(|d| d.scope_paths.clone()).collect();
569
- let config = RankConfig {
570
- min_score: DOC_QUERY_MIN_SCORE,
571
- relative_threshold: 0.0,
572
- scope_path_bonus: SCOPE_PATH_BONUS,
573
- limit: docs.len(),
574
- };
575
- let ranked = rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &config);
576
- Ok(ranked.into_iter().map(|item| item.index).collect())
577
- }
578
-
579
- /// `handoff_doc_delete` — delete a document (by `doc_id` or `slug`) and its
580
- /// body file, unlink it from any linked tasks, remove it from its parent's
581
- /// `children`, and orphan (clear `parent_id` on) any of its own children.
582
- /// See `wiki/130-document-management.md` §5.4.
583
- pub fn handle_doc_delete(arguments: &Value) -> Result<String> {
584
- let project_dir = resolve_project_dir(arguments)?;
585
- let handoff = ensure_handoff_exists(&project_dir)?;
586
-
587
- let doc_id = arguments
588
- .get("doc_id")
589
- .and_then(|v| v.as_str())
590
- .ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
591
-
592
- let doc = resolve_doc(&handoff, doc_id)?
593
- .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
594
-
595
- let mut warnings: Vec<String> = Vec::new();
596
-
597
- delete_doc_body(&handoff, &doc.slug)?;
598
- delete_doc(&handoff, &doc.slug)?;
599
-
600
- if !doc.task_ids.is_empty() {
601
- let tasks_dir = handoff.join("tasks");
602
- let report = sync_doc_task_links(&tasks_dir, &doc.id, &doc.title, &[], &doc.task_ids)?;
603
- if !report.unresolved.is_empty() {
604
- warnings.push(format!(
605
- "Could not resolve task id(s) for unlinking: {}",
606
- report.unresolved.join(", ")
607
- ));
608
- }
609
- }
610
-
611
- if let Some(parent_id) = &doc.parent_id {
612
- if let Some(mut parent) = find_doc_by_id(&handoff, parent_id)? {
613
- let before = parent.children.len();
614
- parent.children.retain(|c| c != &doc.id);
615
- if parent.children.len() != before {
616
- write_doc(&handoff, &parent)?;
617
- }
618
- } else {
619
- warnings.push(format!("Parent document not found: {parent_id}"));
620
- }
621
- }
622
-
623
- for child_id in &doc.children {
624
- if let Some(mut child) = find_doc_by_id(&handoff, child_id)? {
625
- child.parent_id = None;
626
- write_doc(&handoff, &child)?;
627
- } else {
628
- warnings.push(format!("Child document not found: {child_id}"));
629
- }
630
- }
631
-
632
- crate::context::doc_corpus_cache()
633
- .lock()
634
- .expect("cache")
635
- .increment_generation();
636
-
637
- Ok(to_json(&json!({
638
- "deleted": true,
639
- "doc_id": doc.id,
640
- "section_count": doc.sections.len(),
641
- "warnings": warnings,
642
- })))
643
- }
644
-
645
- /// `handoff_doc_reassemble` — read a document's (by `doc_id` or `slug`)
646
- /// original Markdown body directly from `_doc.<slug>.md` (v5: the `.md` file
647
- /// already *is* the original document, restoring BOM/frontmatter is the only
648
- /// reassembly step left), and detect drift (the body's current content hash
649
- /// no longer matches the recorded `content_hash` — e.g. edited directly
650
- /// outside `doc_save`). See `wiki/130-document-management.md` §5.5.
651
- pub fn handle_doc_reassemble(arguments: &Value) -> Result<String> {
652
- let project_dir = resolve_project_dir(arguments)?;
653
- let handoff = ensure_handoff_exists(&project_dir)?;
654
-
655
- let doc_id = arguments
656
- .get("doc_id")
657
- .and_then(|v| v.as_str())
658
- .ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
659
-
660
- let doc = resolve_doc(&handoff, doc_id)?
661
- .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
662
-
663
- // `doc.content_hash` is recomputed fresh from the body on every read
664
- // (t123.2), so comparing it to itself would never detect drift.
665
- // `doc.source.canonical_hash` is the hash persisted at the *last
666
- // `doc_save`* (untouched by the on-read recompute — see
667
- // `storage::docs::read_doc`), so that's the correct "was this edited
668
- // out-of-band since the last save" baseline.
669
- let drifted = doc.source.canonical_hash.as_deref() != Some(doc.content_hash.as_str());
670
-
671
- let body = read_full_body(&handoff, &doc)?.unwrap_or_default();
672
-
673
- let output_path = arguments.get("output_path").and_then(|v| v.as_str());
674
- let mut out = json!({
675
- "doc_id": doc.id,
676
- "body": body,
677
- "drifted": drifted,
678
- });
679
- if let Some(path) = output_path {
680
- std::fs::write(path, &body)
681
- .with_context(|| format!("Failed to write reassembled document to {path}"))?;
682
- out["output_path"] = json!(path);
683
- }
684
-
685
- Ok(to_json(&out))
686
- }
687
-
688
- /// `handoff_doc_tree` — traverse a document's family tree starting from
689
- /// `doc_id`: its immediate parent (if any) plus `depth` levels of children,
690
- /// optionally including its `related` (semantic) links. See
691
- /// `wiki/130-document-management.md` §5.6.
692
- pub fn handle_doc_tree(arguments: &Value) -> Result<String> {
693
- let project_dir = resolve_project_dir(arguments)?;
694
- let handoff = ensure_handoff_exists(&project_dir)?;
695
-
696
- let doc_id = arguments
697
- .get("doc_id")
698
- .and_then(|v| v.as_str())
699
- .ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
700
-
701
- let depth = arguments
702
- .get("depth")
703
- .and_then(|v| v.as_u64())
704
- .unwrap_or(DEFAULT_TREE_DEPTH);
705
-
706
- let include_related = arguments
707
- .get("include_related")
708
- .and_then(|v| v.as_bool())
709
- .unwrap_or(false);
710
-
711
- let doc = resolve_doc(&handoff, doc_id)?
712
- .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
713
-
714
- let mut tree = doc_tree_node_json(&handoff, &doc, include_related)?;
715
-
716
- let parent = match &doc.parent_id {
717
- Some(parent_id) => find_doc_by_id(&handoff, parent_id)?.map(|p| doc_tree_summary_json(&p)),
718
- None => None,
719
- };
720
- tree["parent"] = parent.unwrap_or(Value::Null);
721
-
722
- tree["children"] = json!(doc_tree_children(
723
- &handoff,
724
- &doc.children,
725
- depth,
726
- include_related
727
- )?);
728
-
729
- Ok(to_json(&tree))
730
- }
731
-
732
- /// Default depth for `handoff_doc_tree` when `depth` is omitted (spec §5.6).
733
- const DEFAULT_TREE_DEPTH: u64 = 2;
734
-
735
- /// Recursively builds the `children` array for [`handle_doc_tree`], descending
736
- /// up to `depth` levels. Missing child documents (broken link) are skipped
737
- /// rather than erroring the whole traversal.
738
- fn doc_tree_children(
739
- handoff: &Path,
740
- child_ids: &[String],
741
- depth: u64,
742
- include_related: bool,
743
- ) -> Result<Vec<Value>> {
744
- if depth == 0 {
745
- return Ok(Vec::new());
746
- }
747
- let mut out = Vec::with_capacity(child_ids.len());
748
- for child_id in child_ids {
749
- let Some(child) = find_doc_by_id(handoff, child_id)? else {
750
- continue;
751
- };
752
- let mut node = doc_tree_node_json(handoff, &child, include_related)?;
753
- node["children"] = json!(doc_tree_children(
754
- handoff,
755
- &child.children,
756
- depth - 1,
757
- include_related
758
- )?);
759
- out.push(node);
760
- }
761
- Ok(out)
762
- }
763
-
764
- /// Compact `{id, title, doc_type}` summary used for `parent` and family-tree
765
- /// list entries (`related`) in `doc_tree`'s output.
766
- fn doc_tree_summary_json(doc: &DocMetadata) -> Value {
767
- json!({
768
- "id": doc.id,
769
- "title": doc.title,
770
- "doc_type": doc.doc_type,
771
- })
772
- }
773
-
774
- /// One node in the `doc_tree` output: id/title/doc_type plus (optionally)
775
- /// `related` summaries (each resolved to `{id, rel, title}`). `children` is
776
- /// populated by the caller afterward.
777
- fn doc_tree_node_json(handoff: &Path, doc: &DocMetadata, include_related: bool) -> Result<Value> {
778
- let mut related: Vec<Value> = Vec::new();
779
- if include_related {
780
- for r in &doc.related {
781
- // related entries may point cross-tree/cross-project ids that
782
- // don't resolve locally; that lookup is deferred to a future
783
- // resolver (spec §10.3) — for now a related id that can't be
784
- // read from this project's docs/ is a no-op skip, matching the
785
- // same lenient policy as read_all_docs.
786
- let Some(target) = find_doc_by_id(handoff, &r.id)? else {
787
- continue;
788
- };
789
- related.push(json!({ "id": r.id, "rel": r.rel, "title": target.title }));
790
- }
791
- }
792
- Ok(json!({
793
- "id": doc.id,
794
- "title": doc.title,
795
- "doc_type": doc.doc_type,
796
- "children": [],
797
- "related": related,
798
- }))
799
- }
800
-
801
- /// Recomputes `Verification.status` from its items (wiki/140-verification-matrix.md
802
- /// §3.3): all pending -> "pending"; all verified/skipped -> "verified";
803
- /// otherwise -> "in_review". v2 (§7.4): an item with `sub_items` is judged by
804
- /// its sub_items' aggregate effective status, not its own `status` field —
805
- /// see `item_effective_status`.
806
- fn recompute_verification_status(items: &[VerificationItem]) -> String {
807
- let statuses: Vec<String> = items.iter().map(item_effective_status).collect();
808
- if statuses.iter().all(|s| s == "pending") {
809
- "pending".to_string()
810
- } else if statuses.iter().all(|s| s == "verified" || s == "skipped") {
811
- "verified".to_string()
812
- } else {
813
- "in_review".to_string()
814
- }
815
- }
816
-
817
- /// v2 (§7.4): the effective status of a `VerificationItem` for the purposes
818
- /// of the parent `Verification.status` rollup. An item with no `sub_items`
819
- /// uses its own `status` unchanged (v1 behavior). An item with `sub_items`
820
- /// is judged by their aggregate: all verified/skipped -> "verified", all
821
- /// pending -> "pending", otherwise -> "in_review" (a partial mix, distinct
822
- /// from "pending").
823
- fn item_effective_status(item: &VerificationItem) -> String {
824
- if item.sub_items.is_empty() {
825
- return item.status.clone();
826
- }
827
- if item
828
- .sub_items
829
- .iter()
830
- .all(|s| s.status == "verified" || s.status == "skipped")
831
- {
832
- "verified".to_string()
833
- } else if item.sub_items.iter().all(|s| s.status == "pending") {
834
- "pending".to_string()
835
- } else {
836
- "in_review".to_string()
837
- }
838
- }
839
-
840
- /// Parses a JSON array of `{path, lines?, label?}` objects into `CodeRef`s.
841
- /// Entries missing the required `path` are skipped.
842
- fn code_refs_from_value(v: &Value) -> Vec<CodeRef> {
843
- v.as_array()
844
- .map(|arr| {
845
- arr.iter()
846
- .filter_map(|r| {
847
- let path = r.get("path").and_then(|v| v.as_str())?.to_string();
848
- Some(CodeRef {
849
- path,
850
- lines: r.get("lines").and_then(|v| v.as_str()).map(str::to_string),
851
- label: r.get("label").and_then(|v| v.as_str()).map(str::to_string),
852
- })
853
- })
854
- .collect()
855
- })
856
- .unwrap_or_default()
857
- }
858
-
859
- /// Source file extensions `suggest_refs` scans for impl/test definitions
860
- /// (t124.6). Kept as a small fixed list rather than "every file" so a scan
861
- /// stays fast and doesn't surface binary/asset noise; extend here if a
862
- /// project needs another language.
863
- const SUGGEST_REFS_EXTENSIONS: &[&str] = &["rs", "ts", "tsx", "py", "go", "js", "jsx"];
864
-
865
- /// Maximum number of impl_ref/test_ref candidates returned per verification
866
- /// item by `suggest_refs` (spec: "Return at most ~20 suggestions per item to
867
- /// avoid overwhelming output"), applied independently to each of the two
868
- /// lists.
869
- const SUGGEST_REFS_MAX_PER_ITEM: usize = 20;
870
-
871
- /// A single scanned definition (function/struct/impl/mod, or a test
872
- /// function) found while walking `scope_paths` — the raw material
873
- /// `suggest_refs` matches against verification item headings.
874
- struct ScannedDefinition {
875
- /// Path to the source file, relative to the project root (matches the
876
- /// `path` shape already used by `CodeRef`/`set_refs`).
877
- rel_path: String,
878
- /// The identifier name found after the defining keyword (e.g. the `foo`
879
- /// in `fn foo(...)`), used for the heading fuzzy-match.
880
- name: String,
881
- /// 1-based line number the definition starts on, used to build the
882
- /// `lines` hint on the suggested `CodeRef`.
883
- line: usize,
884
- }
885
-
886
- /// Walks `doc.scope_paths` under `project_dir` and, for every verification
887
- /// item, returns impl/test ref candidates whose definition name fuzzy-
888
- /// matches the item's heading (t124.6). Read-only — never touches the
889
- /// document or the filesystem beyond reading source files.
890
- fn suggest_refs(project_dir: &Path, doc: &DocMetadata, v: &Verification) -> Vec<Value> {
891
- let files = scan_scope_files(project_dir, &doc.scope_paths);
892
- let (impl_defs, test_defs) = scan_definitions(project_dir, &files);
893
-
894
- v.items
895
- .iter()
896
- .map(|item| {
897
- let heading = item.label.clone().unwrap_or_else(|| item.heading.clone());
898
- let keywords = heading_keywords(&heading);
899
-
900
- let suggested_impl_refs = match_definitions(&impl_defs, &keywords);
901
- let suggested_test_refs = match_definitions(&test_defs, &keywords);
902
-
903
- json!({
904
- "fragment_seq": item.fragment_seq,
905
- "heading": item.heading,
906
- "suggested_impl_refs": suggested_impl_refs,
907
- "suggested_test_refs": suggested_test_refs,
908
- })
909
- })
910
- .collect()
911
- }
912
-
913
- /// Recursively collects every file under `project_dir` whose relative path
914
- /// starts with one of `scope_paths` (prefix match, spec: "Look for files
915
- /// matching scope_paths patterns") and whose extension is in
916
- /// [`SUGGEST_REFS_EXTENSIONS`]. `scope_paths` entries are relative to
917
- /// `project_dir` (e.g. `src/mcp/handlers/`), matching how `scope_paths` is
918
- /// documented and used elsewhere (BM25 scope bonus, shared-scope graph
919
- /// edges).
920
- fn scan_scope_files(project_dir: &Path, scope_paths: &[String]) -> Vec<std::path::PathBuf> {
921
- let mut out = Vec::new();
922
- for scope in scope_paths {
923
- let root = project_dir.join(scope);
924
- walk_dir(&root, &mut out);
925
- }
926
- out
927
- }
928
-
929
- fn walk_dir(path: &Path, out: &mut Vec<std::path::PathBuf>) {
930
- if path.is_file() {
931
- if has_suggest_refs_extension(path) {
932
- out.push(path.to_path_buf());
933
- }
934
- return;
935
- }
936
- let Ok(entries) = std::fs::read_dir(path) else {
937
- return;
938
- };
939
- for entry in entries.flatten() {
940
- let p = entry.path();
941
- if p.is_dir() {
942
- walk_dir(&p, out);
943
- } else if has_suggest_refs_extension(&p) {
944
- out.push(p);
945
- }
946
- }
947
- }
948
-
949
- fn has_suggest_refs_extension(path: &Path) -> bool {
950
- path.extension()
951
- .and_then(|e| e.to_str())
952
- .is_some_and(|ext| SUGGEST_REFS_EXTENSIONS.contains(&ext))
953
- }
954
-
955
- /// Splits `files` into impl definitions (fn/struct/impl/mod) and test
956
- /// definitions (files under a `tests/` dir, named `*_test.*`/`test_*`, or
957
- /// containing `#[test]`/`#[cfg(test)]`-marked functions), per-file, by
958
- /// scanning each line with the heuristics from the task spec.
959
- fn scan_definitions(
960
- project_dir: &Path,
961
- files: &[std::path::PathBuf],
962
- ) -> (Vec<ScannedDefinition>, Vec<ScannedDefinition>) {
963
- let mut impl_defs = Vec::new();
964
- let mut test_defs = Vec::new();
965
-
966
- for file in files {
967
- let Ok(content) = std::fs::read_to_string(file) else {
968
- continue;
969
- };
970
- let rel_path = file
971
- .strip_prefix(project_dir)
972
- .unwrap_or(file)
973
- .to_string_lossy()
974
- .replace('\\', "/");
975
- let is_test_file = is_test_path(&rel_path);
976
-
977
- let mut next_is_test_fn = false;
978
- for (idx, line) in content.lines().enumerate() {
979
- let trimmed = line.trim_start();
980
- let line_no = idx + 1;
981
-
982
- if trimmed.starts_with("#[test]") || trimmed.starts_with("#[cfg(test)]") {
983
- next_is_test_fn = true;
984
- continue;
985
- }
986
-
987
- if let Some(name) = extract_test_fn_name(trimmed) {
988
- if is_test_file || next_is_test_fn {
989
- test_defs.push(ScannedDefinition {
990
- rel_path: rel_path.clone(),
991
- name,
992
- line: line_no,
993
- });
994
- }
995
- next_is_test_fn = false;
996
- continue;
997
- }
998
- next_is_test_fn = false;
999
-
1000
- if let Some(name) = extract_impl_def_name(trimmed) {
1001
- let bucket = if is_test_file {
1002
- &mut test_defs
1003
- } else {
1004
- &mut impl_defs
1005
- };
1006
- bucket.push(ScannedDefinition {
1007
- rel_path: rel_path.clone(),
1008
- name,
1009
- line: line_no,
1010
- });
1011
- }
1012
- }
1013
- }
1014
-
1015
- (impl_defs, test_defs)
1016
- }
1017
-
1018
- fn is_test_path(rel_path: &str) -> bool {
1019
- let lower = rel_path.to_ascii_lowercase();
1020
- lower.split('/').any(|seg| seg == "tests" || seg == "test")
1021
- || lower.contains("_test.")
1022
- || lower.contains("/test_")
1023
- || lower.starts_with("test_")
1024
- }
1025
-
1026
- /// Recognizes `fn test_*` / `def test_*` test-function definitions
1027
- /// (spec: "`fn test_`") regardless of visibility/async modifiers.
1028
- fn extract_test_fn_name(trimmed: &str) -> Option<String> {
1029
- for prefix in ["pub async fn ", "pub fn ", "async fn ", "fn ", "def "] {
1030
- if let Some(rest) = trimmed.strip_prefix(prefix) {
1031
- if rest.trim_start().starts_with("test_") {
1032
- return extract_identifier(rest);
1033
- }
1034
- }
1035
- }
1036
- None
1037
- }
1038
-
1039
- /// Recognizes impl-style definitions the spec calls out: `fn `, `pub fn `,
1040
- /// `struct `, `impl `, `mod `.
1041
- fn extract_impl_def_name(trimmed: &str) -> Option<String> {
1042
- for prefix in [
1043
- "pub async fn ",
1044
- "pub fn ",
1045
- "async fn ",
1046
- "fn ",
1047
- "pub struct ",
1048
- "struct ",
1049
- "pub mod ",
1050
- "mod ",
1051
- "impl ",
1052
- ] {
1053
- if let Some(rest) = trimmed.strip_prefix(prefix) {
1054
- return extract_identifier(rest);
1055
- }
1056
- }
1057
- None
1058
- }
1059
-
1060
- /// Pulls the leading identifier (`[A-Za-z0-9_]+`) off the start of `rest`,
1061
- /// e.g. `"foo(bar: &str) {"` -> `"foo"`, `"Foo<T> for Bar"` -> `"Foo"`.
1062
- fn extract_identifier(rest: &str) -> Option<String> {
1063
- let ident: String = rest
1064
- .chars()
1065
- .take_while(|c| c.is_alphanumeric() || *c == '_')
1066
- .collect();
1067
- if ident.is_empty() {
1068
- None
1069
- } else {
1070
- Some(ident)
1071
- }
1072
- }
1073
-
1074
- /// Splits a heading into lowercase keyword tokens for the fuzzy match
1075
- /// (spec: "case-insensitive substring match"), dropping short/common words
1076
- /// that would otherwise match almost every identifier.
1077
- fn heading_keywords(heading: &str) -> Vec<String> {
1078
- const STOPWORDS: &[&str] = &["the", "a", "an", "of", "to", "and", "or", "for", "in", "on"];
1079
- heading
1080
- .split(|c: char| !c.is_alphanumeric())
1081
- .map(|w| w.to_ascii_lowercase())
1082
- .filter(|w| w.len() > 2 && !STOPWORDS.contains(&w.as_str()))
1083
- .collect()
1084
- }
1085
-
1086
- /// Matches `defs` whose `name` (case-insensitive) contains any of
1087
- /// `keywords` as a substring, deduplicates by `(path, name)`, and caps the
1088
- /// result at [`SUGGEST_REFS_MAX_PER_ITEM`].
1089
- fn match_definitions(defs: &[ScannedDefinition], keywords: &[String]) -> Vec<Value> {
1090
- if keywords.is_empty() {
1091
- return Vec::new();
1092
- }
1093
- let mut seen = std::collections::HashSet::new();
1094
- let mut out = Vec::new();
1095
- for def in defs {
1096
- let name_lower = def.name.to_ascii_lowercase();
1097
- let matches = keywords.iter().any(|kw| name_lower.contains(kw.as_str()));
1098
- if !matches {
1099
- continue;
1100
- }
1101
- let key = (def.rel_path.clone(), def.name.clone());
1102
- if !seen.insert(key) {
1103
- continue;
1104
- }
1105
- out.push(json!({
1106
- "path": def.rel_path,
1107
- "lines": def.line.to_string(),
1108
- "label": def.name,
1109
- }));
1110
- if out.len() >= SUGGEST_REFS_MAX_PER_ITEM {
1111
- break;
1112
- }
1113
- }
1114
- out
1115
- }
1116
-
1117
- /// Summary counts used by both `doc_verify`'s mutation response and
1118
- /// `doc_verify_status`'s `progress` block.
1119
- struct VerificationCounts {
1120
- checked: usize,
1121
- skipped: usize,
1122
- pending: usize,
1123
- total: usize,
1124
- stale: usize,
1125
- }
1126
-
1127
- /// v2 (§7.4): counts every leaf verification unit — a top-level item that
1128
- /// has no `sub_items` counts itself directly (v1 behavior, includes freeform
1129
- /// items), while an item that *does* have `sub_items` counts each sub_item
1130
- /// instead of itself (so `total` is "top-level item count (leaf-only) + all
1131
- /// sub_items count", matching the spec's "トップレベル + 全 sub_items の合計").
1132
- fn count_verification(doc: &DocMetadata, v: &Verification) -> VerificationCounts {
1133
- let mut checked = 0;
1134
- let mut skipped = 0;
1135
- let mut pending = 0;
1136
- let mut stale = 0;
1137
-
1138
- for item in &v.items {
1139
- if item.sub_items.is_empty() {
1140
- match item.status.as_str() {
1141
- "verified" => checked += 1,
1142
- "skipped" => skipped += 1,
1143
- _ => pending += 1,
1144
- }
1145
- } else {
1146
- for sub in &item.sub_items {
1147
- match sub.status.as_str() {
1148
- "verified" => checked += 1,
1149
- "skipped" => skipped += 1,
1150
- _ => pending += 1,
1151
- }
1152
- }
1153
- }
1154
- if item_is_stale(doc, item) {
1155
- stale += 1;
1156
- }
1157
- }
1158
-
1159
- VerificationCounts {
1160
- checked,
1161
- skipped,
1162
- pending,
1163
- total: checked + skipped + pending,
1164
- stale,
1165
- }
1166
- }
1167
-
1168
- /// An item is stale when it was verified at a content_hash that no longer
1169
- /// matches its section's current content_hash (spec §3.5) — items never
1170
- /// verified (`content_hash_at_verify: None`) are never stale, items whose
1171
- /// section has been removed (sync should have dropped them, but be
1172
- /// defensive) are treated as stale so drift is never silently hidden, and
1173
- /// freeform items (v2, `fragment_seq: None`) are never stale since they are
1174
- /// not tied to any section's content_hash.
1175
- fn item_is_stale(doc: &DocMetadata, item: &VerificationItem) -> bool {
1176
- let Some(hash_at_verify) = &item.content_hash_at_verify else {
1177
- return false;
1178
- };
1179
- let Some(fragment_seq) = item.fragment_seq else {
1180
- return false;
1181
- };
1182
- match doc.sections.iter().find(|s| s.seq == fragment_seq) {
1183
- Some(section) => &section.content_hash != hash_at_verify,
1184
- None => true,
1185
- }
1186
- }
1187
-
1188
- /// `handoff_doc_verify` — generate/check/skip/sync/set_refs a document's
1189
- /// verification matrix (wiki/140-verification-matrix.md §4.1).
1190
- pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
1191
- let project_dir = resolve_project_dir(arguments)?;
1192
- let handoff = ensure_handoff_exists(&project_dir)?;
1193
-
1194
- let doc_id = arguments
1195
- .get("doc_id")
1196
- .and_then(|v| v.as_str())
1197
- .ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
1198
- let action = arguments
1199
- .get("action")
1200
- .and_then(|v| v.as_str())
1201
- .ok_or_else(|| anyhow::anyhow!("'action' is required"))?;
1202
-
1203
- let mut doc = resolve_doc(&handoff, doc_id)?
1204
- .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
1205
-
1206
- // `suggest_refs` is read-only (it never mutates the verification matrix,
1207
- // only proposes candidates for the caller to feed into `set_refs`), and
1208
- // its response shape (a `suggestions` list) differs from every other
1209
- // action's mutation-count summary — handled separately, before the
1210
- // shared mutate-then-write flow below.
1211
- if action == "suggest_refs" {
1212
- let v = doc.verification.as_ref().ok_or_else(|| {
1213
- anyhow::anyhow!(
1214
- "No verification matrix exists for document {doc_id}; use action='generate' first"
1215
- )
1216
- })?;
1217
- let suggestions = suggest_refs(&project_dir, &doc, v);
1218
- return Ok(to_json(&json!({
1219
- "doc_id": doc.id,
1220
- "suggestions": suggestions,
1221
- })));
1222
- }
1223
-
1224
- let now = chrono::Utc::now().to_rfc3339();
1225
-
1226
- match action {
1227
- "generate" => {
1228
- if doc.verification.is_some() {
1229
- anyhow::bail!(
1230
- "Verification matrix already exists for document {doc_id}; use action='sync' to re-sync it instead"
1231
- );
1232
- }
1233
- let skip_seqs: Vec<usize> = arguments
1234
- .get("skip_seqs")
1235
- .and_then(|v| v.as_array())
1236
- .map(|arr| {
1237
- arr.iter()
1238
- .filter_map(|v| v.as_u64())
1239
- .map(|n| n as usize)
1240
- .collect()
1241
- })
1242
- .unwrap_or_default();
1243
-
1244
- let items: Vec<VerificationItem> = doc
1245
- .sections
1246
- .iter()
1247
- .map(|s| VerificationItem {
1248
- fragment_seq: Some(s.seq),
1249
- heading: s.heading.clone(),
1250
- status: if skip_seqs.contains(&s.seq) {
1251
- "skipped".to_string()
1252
- } else {
1253
- "pending".to_string()
1254
- },
1255
- impl_refs: Vec::new(),
1256
- test_refs: Vec::new(),
1257
- reviewer: None,
1258
- verified_at: None,
1259
- notes: String::new(),
1260
- content_hash_at_verify: None,
1261
- category: "section".to_string(),
1262
- sub_items: Vec::new(),
1263
- label: None,
1264
- })
1265
- .collect();
1266
-
1267
- doc.verification = Some(Verification {
1268
- status: recompute_verification_status(&items),
1269
- created_at: now.clone(),
1270
- updated_at: now,
1271
- items,
1272
- });
1273
- }
1274
- "check" => {
1275
- let fragment_seqs = required_fragment_seqs(arguments)?;
1276
- let reviewer = arguments
1277
- .get("reviewer")
1278
- .and_then(|v| v.as_str())
1279
- .map(str::to_string);
1280
- let notes = arguments
1281
- .get("notes")
1282
- .and_then(|v| v.as_str())
1283
- .map(str::to_string);
1284
- let sub_item_index = arguments
1285
- .get("sub_item_index")
1286
- .and_then(|v| v.as_u64())
1287
- .map(|n| n as usize);
1288
-
1289
- for fragment_seq in fragment_seqs {
1290
- let section_hash = doc
1291
- .sections
1292
- .iter()
1293
- .find(|s| s.seq == fragment_seq)
1294
- .map(|s| s.content_hash.clone());
1295
-
1296
- let v = verification_mut(&mut doc, doc_id)?;
1297
- let item = find_item_mut(v, fragment_seq, doc_id)?;
1298
-
1299
- if let Some(sub_index) = sub_item_index {
1300
- let sub = find_sub_item_mut(item, sub_index, fragment_seq, doc_id)?;
1301
- sub.status = "verified".to_string();
1302
- sub.verified_at = Some(now.clone());
1303
- if reviewer.is_some() {
1304
- sub.reviewer = reviewer.clone();
1305
- }
1306
- if let Some(notes) = &notes {
1307
- sub.notes = notes.clone();
1308
- }
1309
- } else {
1310
- item.status = "verified".to_string();
1311
- item.verified_at = Some(now.clone());
1312
- if reviewer.is_some() {
1313
- item.reviewer = reviewer.clone();
1314
- }
1315
- if let Some(notes) = &notes {
1316
- item.notes = notes.clone();
1317
- }
1318
- item.content_hash_at_verify = section_hash;
1319
- }
1320
- v.updated_at = now.clone();
1321
- v.status = recompute_verification_status(&v.items);
1322
- }
1323
- }
1324
- "check_all" => {
1325
- let reviewer = arguments
1326
- .get("reviewer")
1327
- .and_then(|v| v.as_str())
1328
- .map(str::to_string);
1329
- let notes = arguments
1330
- .get("notes")
1331
- .and_then(|v| v.as_str())
1332
- .map(str::to_string);
1333
- let sections = doc.sections.clone();
1334
-
1335
- let v = verification_mut(&mut doc, doc_id)?;
1336
- for item in v.items.iter_mut() {
1337
- let section_hash = item.fragment_seq.and_then(|seq| {
1338
- sections
1339
- .iter()
1340
- .find(|s| s.seq == seq)
1341
- .map(|s| s.content_hash.clone())
1342
- });
1343
- item.status = "verified".to_string();
1344
- item.verified_at = Some(now.clone());
1345
- if reviewer.is_some() {
1346
- item.reviewer = reviewer.clone();
1347
- }
1348
- if let Some(notes) = &notes {
1349
- item.notes = notes.clone();
1350
- }
1351
- item.content_hash_at_verify = section_hash;
1352
-
1353
- // v2: check_all also verifies every sub_item (spec §7.2).
1354
- for sub in item.sub_items.iter_mut() {
1355
- sub.status = "verified".to_string();
1356
- sub.verified_at = Some(now.clone());
1357
- if reviewer.is_some() {
1358
- sub.reviewer = reviewer.clone();
1359
- }
1360
- }
1361
- }
1362
- v.updated_at = now.clone();
1363
- v.status = recompute_verification_status(&v.items);
1364
- }
1365
- "skip" => {
1366
- let fragment_seq = required_fragment_seq(arguments)?;
1367
- let sub_item_index = arguments
1368
- .get("sub_item_index")
1369
- .and_then(|v| v.as_u64())
1370
- .map(|n| n as usize);
1371
-
1372
- let v = verification_mut(&mut doc, doc_id)?;
1373
- let item = find_item_mut(v, fragment_seq, doc_id)?;
1374
- if let Some(sub_index) = sub_item_index {
1375
- let sub = find_sub_item_mut(item, sub_index, fragment_seq, doc_id)?;
1376
- sub.status = "skipped".to_string();
1377
- } else {
1378
- item.status = "skipped".to_string();
1379
- }
1380
- v.updated_at = now.clone();
1381
- v.status = recompute_verification_status(&v.items);
1382
- }
1383
- "add_item" => {
1384
- let v = verification_mut(&mut doc, doc_id)?;
1385
- match arguments.get("fragment_seq").and_then(|v| v.as_u64()) {
1386
- None => {
1387
- // Freeform top-level item (spec §7.2): fragment_seq
1388
- // omitted/null, label required.
1389
- let label = arguments
1390
- .get("label")
1391
- .and_then(|v| v.as_str())
1392
- .ok_or_else(|| {
1393
- anyhow::anyhow!(
1394
- "'label' is required for add_item when 'fragment_seq' is omitted"
1395
- )
1396
- })?
1397
- .to_string();
1398
- let category = arguments
1399
- .get("category")
1400
- .and_then(|v| v.as_str())
1401
- .unwrap_or("visual")
1402
- .to_string();
1403
- v.items.push(VerificationItem {
1404
- fragment_seq: None,
1405
- heading: label.clone(),
1406
- status: "pending".to_string(),
1407
- impl_refs: Vec::new(),
1408
- test_refs: Vec::new(),
1409
- reviewer: None,
1410
- verified_at: None,
1411
- notes: String::new(),
1412
- content_hash_at_verify: None,
1413
- category,
1414
- sub_items: Vec::new(),
1415
- label: Some(label),
1416
- });
1417
- }
1418
- Some(seq) => {
1419
- // Sub-item on an existing section item (spec §7.2):
1420
- // fragment_seq given, description required.
1421
- let fragment_seq = seq as usize;
1422
- let description = arguments
1423
- .get("description")
1424
- .and_then(|v| v.as_str())
1425
- .ok_or_else(|| {
1426
- anyhow::anyhow!(
1427
- "'description' is required for add_item when 'fragment_seq' is given"
1428
- )
1429
- })?
1430
- .to_string();
1431
- let category = arguments
1432
- .get("category")
1433
- .and_then(|v| v.as_str())
1434
- .unwrap_or("requirement")
1435
- .to_string();
1436
-
1437
- let item = find_item_mut(v, fragment_seq, doc_id)?;
1438
- let index = item.sub_items.len();
1439
- item.sub_items.push(SubItem {
1440
- index,
1441
- description,
1442
- status: "pending".to_string(),
1443
- reviewer: None,
1444
- verified_at: None,
1445
- notes: String::new(),
1446
- category,
1447
- });
1448
- }
1449
- }
1450
- v.updated_at = now.clone();
1451
- v.status = recompute_verification_status(&v.items);
1452
- }
1453
- "sync" => {
1454
- let sections = doc.sections.clone();
1455
- let v = verification_mut(&mut doc, doc_id)?;
1456
- let current_seqs: std::collections::HashSet<usize> =
1457
- sections.iter().map(|s| s.seq).collect();
1458
- // Freeform items (fragment_seq=None, v2) are never section-tied,
1459
- // so `sync` always keeps them — only section-tied items whose
1460
- // seq no longer exists are dropped.
1461
- v.items.retain(|i| match i.fragment_seq {
1462
- Some(seq) => current_seqs.contains(&seq),
1463
- None => true,
1464
- });
1465
- let existing_seqs: std::collections::HashSet<usize> =
1466
- v.items.iter().filter_map(|i| i.fragment_seq).collect();
1467
- for s in &sections {
1468
- if !existing_seqs.contains(&s.seq) {
1469
- v.items.push(VerificationItem {
1470
- fragment_seq: Some(s.seq),
1471
- heading: s.heading.clone(),
1472
- status: "pending".to_string(),
1473
- impl_refs: Vec::new(),
1474
- test_refs: Vec::new(),
1475
- reviewer: None,
1476
- verified_at: None,
1477
- notes: String::new(),
1478
- content_hash_at_verify: None,
1479
- category: "section".to_string(),
1480
- sub_items: Vec::new(),
1481
- label: None,
1482
- });
1483
- }
1484
- }
1485
- // Sort section-tied items by seq; freeform items (None) sort
1486
- // last, in their prior relative order (stable sort).
1487
- v.items.sort_by_key(|i| (i.fragment_seq.is_none(), i.fragment_seq));
1488
- v.updated_at = now.clone();
1489
- v.status = recompute_verification_status(&v.items);
1490
- }
1491
- "set_refs" => {
1492
- let fragment_seq = required_fragment_seq(arguments)?;
1493
- let impl_refs = arguments.get("impl_refs").map(code_refs_from_value);
1494
- let test_refs = arguments.get("test_refs").map(code_refs_from_value);
1495
-
1496
- let v = verification_mut(&mut doc, doc_id)?;
1497
- let item = find_item_mut(v, fragment_seq, doc_id)?;
1498
- if let Some(impl_refs) = impl_refs {
1499
- item.impl_refs = impl_refs;
1500
- }
1501
- if let Some(test_refs) = test_refs {
1502
- item.test_refs = test_refs;
1503
- }
1504
- v.updated_at = now.clone();
1505
- v.status = recompute_verification_status(&v.items);
1506
- }
1507
- other => anyhow::bail!(
1508
- "Unknown action '{other}'; expected one of generate, check, check_all, skip, sync, set_refs, add_item, suggest_refs"
1509
- ),
1510
- }
1511
-
1512
- write_doc(&handoff, &doc)?;
1513
-
1514
- let v = doc
1515
- .verification
1516
- .as_ref()
1517
- .expect("verification was just set/mutated above");
1518
- let counts = count_verification(&doc, v);
1519
-
1520
- Ok(to_json(&json!({
1521
- "doc_id": doc.id,
1522
- "verification_status": v.status,
1523
- "checked": counts.checked,
1524
- "skipped": counts.skipped,
1525
- "pending": counts.pending,
1526
- "total": counts.total,
1527
- "stale": counts.stale,
1528
- })))
1529
- }
1530
-
1531
- fn required_fragment_seq(arguments: &Value) -> Result<usize> {
1532
- arguments
1533
- .get("fragment_seq")
1534
- .and_then(|v| v.as_u64())
1535
- .map(|n| n as usize)
1536
- .ok_or_else(|| anyhow::anyhow!("'fragment_seq' is required for this action"))
1537
- }
1538
-
1539
- /// Like `required_fragment_seq`, but accepts `fragment_seq` as either a
1540
- /// single number (backward compat) or an array of numbers (batch `check`).
1541
- fn required_fragment_seqs(arguments: &Value) -> Result<Vec<usize>> {
1542
- match arguments.get("fragment_seq") {
1543
- Some(Value::Array(arr)) => {
1544
- let seqs: Vec<usize> = arr
1545
- .iter()
1546
- .filter_map(|v| v.as_u64())
1547
- .map(|n| n as usize)
1548
- .collect();
1549
- if seqs.is_empty() {
1550
- anyhow::bail!("'fragment_seq' array must contain at least one section seq");
1551
- }
1552
- Ok(seqs)
1553
- }
1554
- _ => required_fragment_seq(arguments).map(|seq| vec![seq]),
1555
- }
1556
- }
1557
-
1558
- fn verification_mut<'a>(doc: &'a mut DocMetadata, doc_id: &str) -> Result<&'a mut Verification> {
1559
- doc.verification.as_mut().ok_or_else(|| {
1560
- anyhow::anyhow!(
1561
- "No verification matrix exists for document {doc_id}; use action='generate' first"
1562
- )
1563
- })
1564
- }
1565
-
1566
- fn find_item_mut<'a>(
1567
- v: &'a mut Verification,
1568
- fragment_seq: usize,
1569
- doc_id: &str,
1570
- ) -> Result<&'a mut VerificationItem> {
1571
- v.items
1572
- .iter_mut()
1573
- .find(|i| i.fragment_seq == Some(fragment_seq))
1574
- .ok_or_else(|| {
1575
- anyhow::anyhow!(
1576
- "No verification item at fragment_seq={fragment_seq} for document {doc_id}"
1577
- )
1578
- })
1579
- }
1580
-
1581
- /// v2: finds a `SubItem` by `index` within `item.sub_items` (used by
1582
- /// `check`/`skip` when `sub_item_index` is given).
1583
- fn find_sub_item_mut<'a>(
1584
- item: &'a mut VerificationItem,
1585
- sub_index: usize,
1586
- fragment_seq: usize,
1587
- doc_id: &str,
1588
- ) -> Result<&'a mut SubItem> {
1589
- item.sub_items.get_mut(sub_index).ok_or_else(|| {
1590
- anyhow::anyhow!(
1591
- "No sub_item at index={sub_index} for fragment_seq={fragment_seq} on document {doc_id}"
1592
- )
1593
- })
1594
- }
1595
-
1596
- /// `handoff_doc_verify_status` — verification matrix summary + optional
1597
- /// per-item detail with stale detection (wiki/140-verification-matrix.md
1598
- /// §4.2).
1599
- pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
1600
- let project_dir = resolve_project_dir(arguments)?;
1601
- let handoff = ensure_handoff_exists(&project_dir)?;
1602
-
1603
- let doc_id = arguments
1604
- .get("doc_id")
1605
- .and_then(|v| v.as_str())
1606
- .ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
1607
- let include_items = arguments
1608
- .get("include_items")
1609
- .and_then(|v| v.as_bool())
1610
- .unwrap_or(false);
1611
- let format = arguments
1612
- .get("format")
1613
- .and_then(|v| v.as_str())
1614
- .unwrap_or("json");
1615
-
1616
- let doc = resolve_doc(&handoff, doc_id)?
1617
- .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
1618
-
1619
- let v = doc.verification.as_ref().ok_or_else(|| {
1620
- anyhow::anyhow!(
1621
- "No verification matrix exists for document {doc_id}; use handoff_doc_verify(action='generate') first"
1622
- )
1623
- })?;
1624
-
1625
- let counts = count_verification(&doc, v);
1626
- let percentage = if counts.total == 0 {
1627
- 0.0
1628
- } else {
1629
- (counts.checked + counts.skipped) as f64 / counts.total as f64 * 100.0
1630
- };
1631
-
1632
- if format == "checklist" {
1633
- return Ok(render_verification_checklist(&doc, v, &counts, percentage));
1634
- }
1635
-
1636
- let mut out = json!({
1637
- "doc_id": doc.id,
1638
- "title": doc.title,
1639
- "verification_status": v.status,
1640
- "progress": {
1641
- "checked": counts.checked,
1642
- "skipped": counts.skipped,
1643
- "pending": counts.pending,
1644
- "total": counts.total,
1645
- "stale": counts.stale,
1646
- "percentage": percentage,
1647
- },
1648
- });
1649
-
1650
- if include_items {
1651
- let items: Vec<Value> = v
1652
- .items
1653
- .iter()
1654
- .map(|i| {
1655
- let sub_items: Vec<Value> = i
1656
- .sub_items
1657
- .iter()
1658
- .map(|s| {
1659
- json!({
1660
- "index": s.index,
1661
- "description": s.description,
1662
- "status": s.status,
1663
- "reviewer": s.reviewer,
1664
- "verified_at": s.verified_at,
1665
- "notes": s.notes,
1666
- "category": s.category,
1667
- })
1668
- })
1669
- .collect();
1670
- json!({
1671
- "fragment_seq": i.fragment_seq,
1672
- "heading": i.heading,
1673
- "status": i.status,
1674
- "stale": item_is_stale(&doc, i),
1675
- "impl_refs": i.impl_refs,
1676
- "test_refs": i.test_refs,
1677
- "reviewer": i.reviewer,
1678
- "verified_at": i.verified_at,
1679
- "notes": i.notes,
1680
- "category": i.category,
1681
- "sub_items": sub_items,
1682
- "label": i.label,
1683
- })
1684
- })
1685
- .collect();
1686
- out["items"] = json!(items);
1687
- }
1688
-
1689
- Ok(to_json(&out))
1690
- }
1691
-
1692
- /// Status icon + label used by the `format="checklist"` Markdown rendering
1693
- /// (spec §7.3): `✓ verified`, `⊘ skipped`, `○ pending`.
1694
- fn status_icon(status: &str) -> String {
1695
- match status {
1696
- "verified" => "✓ verified".to_string(),
1697
- "skipped" => "⊘ skipped".to_string(),
1698
- other => format!("○ {other}"),
1699
- }
1700
- }
1701
-
1702
- /// Renders a document's verification matrix as a Markdown checklist (v2,
1703
- /// wiki/140-verification-matrix.md §7.3): one `##` block per top-level item
1704
- /// (`§{seq} {heading}` for section-tied items, `— {label}` for freeform
1705
- /// items), with impl/test refs and a `- [x]`/`- [ ]` checkbox line per
1706
- /// sub_item.
1707
- fn render_verification_checklist(
1708
- doc: &DocMetadata,
1709
- v: &Verification,
1710
- counts: &VerificationCounts,
1711
- percentage: f64,
1712
- ) -> String {
1713
- use std::fmt::Write;
1714
-
1715
- let mut out = String::new();
1716
- let _ = writeln!(out, "# Verification: {}", doc.title);
1717
- let _ = writeln!(
1718
- out,
1719
- "Status: {} ({}/{}, {:.0}%)",
1720
- v.status,
1721
- counts.checked + counts.skipped,
1722
- counts.total,
1723
- percentage
1724
- );
1725
- out.push('\n');
1726
-
1727
- for item in &v.items {
1728
- let icon = status_icon(&item.status);
1729
- let stale_warning = if item_is_stale(doc, item) {
1730
- " ⚠ stale"
1731
- } else {
1732
- ""
1733
- };
1734
-
1735
- match item.fragment_seq {
1736
- Some(seq) => {
1737
- let _ = writeln!(out, "## §{seq} {} {icon}{stale_warning}", item.heading);
1738
- if !item.impl_refs.is_empty() {
1739
- let refs: Vec<String> = item.impl_refs.iter().map(code_ref_display).collect();
1740
- let _ = writeln!(out, "- impl: {}", refs.join(", "));
1741
- }
1742
- if !item.test_refs.is_empty() {
1743
- let refs: Vec<String> = item.test_refs.iter().map(code_ref_display).collect();
1744
- let _ = writeln!(out, "- test: {}", refs.join(", "));
1745
- }
1746
- }
1747
- None => {
1748
- let label = item.label.as_deref().unwrap_or(&item.heading);
1749
- let _ = writeln!(
1750
- out,
1751
- "## — {label} {icon}{stale_warning} [{}]",
1752
- item.category
1753
- );
1754
- }
1755
- }
1756
-
1757
- for sub in &item.sub_items {
1758
- let checkbox = if sub.status == "verified" { "x" } else { " " };
1759
- match (&sub.reviewer, &sub.verified_at) {
1760
- (Some(reviewer), Some(verified_at)) => {
1761
- let date = verified_at.split('T').next().unwrap_or(verified_at);
1762
- let _ = writeln!(
1763
- out,
1764
- "- [{checkbox}] {} (@{reviewer}, {date}) [{}]",
1765
- sub.description, sub.category
1766
- );
1767
- }
1768
- _ => {
1769
- let _ = writeln!(out, "- [{checkbox}] {} [{}]", sub.description, sub.category);
1770
- }
1771
- }
1772
- }
1773
- out.push('\n');
1774
- }
1775
-
1776
- out
1777
- }
1778
-
1779
- /// Renders a `CodeRef` for the checklist format: `path` optionally suffixed
1780
- /// with `:lines` and/or ` (label)`.
1781
- fn code_ref_display(r: &CodeRef) -> String {
1782
- let mut s = r.path.clone();
1783
- if let Some(lines) = &r.lines {
1784
- s.push(':');
1785
- s.push_str(lines);
1786
- }
1787
- if let Some(label) = &r.label {
1788
- s.push_str(" (");
1789
- s.push_str(label);
1790
- s.push(')');
1791
- }
1792
- s
1793
- }
1794
-
1795
- /// `handoff_doc_graph` — build a graph of every document in the project:
1796
- /// `nodes[]` (one per document, with optional verification progress),
1797
- /// `edges[]` (explicit parent_child/related links, plus implicit
1798
- /// shared_task/shared_scope links when `include_implicit=true`), and
1799
- /// `layers` (doc ids grouped by `doc_type`).
1800
- pub fn handle_doc_graph(arguments: &Value) -> Result<String> {
1801
- let project_dir = resolve_project_dir(arguments)?;
1802
- let handoff = ensure_handoff_exists(&project_dir)?;
1803
-
1804
- let include_implicit = arguments
1805
- .get("include_implicit")
1806
- .and_then(|v| v.as_bool())
1807
- .unwrap_or(true);
1808
- let include_verification = arguments
1809
- .get("include_verification")
1810
- .and_then(|v| v.as_bool())
1811
- .unwrap_or(false);
1812
-
1813
- let docs = read_all_docs(&handoff)?;
1814
-
1815
- let nodes: Vec<Value> = docs
1816
- .iter()
1817
- .map(|d| doc_graph_node_json(d, include_verification))
1818
- .collect();
1819
-
1820
- let mut edges = doc_graph_explicit_edges(&docs);
1821
- if include_implicit {
1822
- edges.extend(doc_graph_implicit_edges(&docs));
1823
- }
1824
-
1825
- let mut layers: std::collections::BTreeMap<String, Vec<String>> =
1826
- std::collections::BTreeMap::new();
1827
- for d in &docs {
1828
- layers
1829
- .entry(d.doc_type.clone())
1830
- .or_default()
1831
- .push(d.id.clone());
1832
- }
1833
-
1834
- Ok(to_json(&json!({
1835
- "nodes": nodes,
1836
- "edges": edges,
1837
- "layers": layers,
1838
- })))
1839
- }
1840
-
1841
- /// Builds one `handoff_doc_graph` node: id/slug/title/doc_type/tags/task_ids
1842
- /// /section_count/updated_at, plus `verification_progress` when requested
1843
- /// (and the document has a verification matrix).
1844
- fn doc_graph_node_json(doc: &DocMetadata, include_verification: bool) -> Value {
1845
- let mut node = json!({
1846
- "id": doc.id,
1847
- "slug": doc.slug,
1848
- "title": doc.title,
1849
- "doc_type": doc.doc_type,
1850
- "tags": doc.tags,
1851
- "task_ids": doc.task_ids,
1852
- "section_count": doc.sections.len(),
1853
- "updated_at": doc.updated_at,
1854
- });
1855
- if include_verification {
1856
- if let Some(v) = &doc.verification {
1857
- let total = v.items.len();
1858
- let verified = v.items.iter().filter(|i| i.status == "verified").count();
1859
- node["verification_progress"] = json!({ "total": total, "verified": verified });
1860
- }
1861
- }
1862
- node
1863
- }
1864
-
1865
- /// Explicit edges: `parent_id` (`type="parent_child"`, `direction="down"`,
1866
- /// from=parent to=child) and `related[]` (`type=<rel>`,
1867
- /// `direction="forward"`, from=this doc to=related target). Related entries
1868
- /// pointing at an id not present in `docs` are still emitted — the graph
1869
- /// consumer is expected to render dangling links, not silently drop them.
1870
- fn doc_graph_explicit_edges(docs: &[DocMetadata]) -> Vec<Value> {
1871
- let mut edges = Vec::new();
1872
- for d in docs {
1873
- if let Some(parent_id) = &d.parent_id {
1874
- edges.push(json!({
1875
- "from": parent_id,
1876
- "to": d.id,
1877
- "type": "parent_child",
1878
- "direction": "down",
1879
- }));
1880
- }
1881
- for r in &d.related {
1882
- edges.push(json!({
1883
- "from": d.id,
1884
- "to": r.id,
1885
- "type": r.rel,
1886
- "direction": "forward",
1887
- }));
1888
- }
1889
- }
1890
- edges
1891
- }
1892
-
1893
- /// Implicit edges: `shared_task` (two documents sharing at least one
1894
- /// `task_ids` entry — `task_ids` on the edge lists every id shared, not just
1895
- /// the first) and `shared_scope` (two documents sharing at least one
1896
- /// `scope_paths` entry). Both are unordered/undirected pairs, emitted once
1897
- /// per pair (i<j) to avoid duplicating the same relationship in both
1898
- /// directions.
1899
- fn doc_graph_implicit_edges(docs: &[DocMetadata]) -> Vec<Value> {
1900
- let mut edges = Vec::new();
1901
- for i in 0..docs.len() {
1902
- for j in (i + 1)..docs.len() {
1903
- let a = &docs[i];
1904
- let b = &docs[j];
1905
-
1906
- let shared_tasks: Vec<String> = a
1907
- .task_ids
1908
- .iter()
1909
- .filter(|t| b.task_ids.contains(t))
1910
- .cloned()
1911
- .collect();
1912
- if !shared_tasks.is_empty() {
1913
- edges.push(json!({
1914
- "from": a.id,
1915
- "to": b.id,
1916
- "type": "shared_task",
1917
- "task_ids": shared_tasks,
1918
- }));
1919
- }
1920
-
1921
- let shares_scope = a.scope_paths.iter().any(|p| b.scope_paths.contains(p));
1922
- if shares_scope {
1923
- edges.push(json!({
1924
- "from": a.id,
1925
- "to": b.id,
1926
- "type": "shared_scope",
1927
- }));
1928
- }
1929
- }
1930
- }
1931
- edges
1932
- }
1933
-
1934
- /// One entry in a `handoff_doc_trace` `chain[]`/`branches[].docs[]`:
1935
- /// `{id, title, doc_type, rel}`. `rel` describes how this doc relates to the
1936
- /// previous entry in the chain ("parent", "child", or the `related[].rel`
1937
- /// value for a related-doc detour); `None` for the trace's starting doc.
1938
- fn doc_trace_item_json(doc: &DocMetadata, rel: Option<&str>) -> Value {
1939
- json!({
1940
- "id": doc.id,
1941
- "title": doc.title,
1942
- "doc_type": doc.doc_type,
1943
- "rel": rel,
1944
- })
1945
- }
1946
-
1947
- /// Walks the child->parent chain starting at `doc` (exclusive — `doc` itself
1948
- /// is not included), ordered from the immediate parent up to the root.
1949
- /// `visited` prevents infinite loops on a cyclic `parent_id` graph; a doc
1950
- /// already visited (including `doc` itself) stops the walk rather than
1951
- /// erroring.
1952
- fn doc_trace_walk_up(
1953
- handoff: &Path,
1954
- doc: &DocMetadata,
1955
- visited: &mut std::collections::HashSet<String>,
1956
- ) -> Result<Vec<Value>> {
1957
- let mut out = Vec::new();
1958
- let mut current = doc.clone();
1959
- while let Some(parent_id) = current.parent_id.clone() {
1960
- if visited.contains(&parent_id) {
1961
- break;
1962
- }
1963
- let Some(parent) = find_doc_by_id(handoff, &parent_id)? else {
1964
- break;
1965
- };
1966
- visited.insert(parent.id.clone());
1967
- out.push(doc_trace_item_json(&parent, Some("parent")));
1968
- current = parent;
1969
- }
1970
- out.reverse();
1971
- Ok(out)
1972
- }
1973
-
1974
- /// Recursively walks parent->children (DFS) starting at `doc` (exclusive).
1975
- /// Returns the primary descendant chain (first child at each level) plus any
1976
- /// `branches` recorded for multi-child forks. `visited` prevents infinite
1977
- /// loops on a cyclic `children` graph.
1978
- fn doc_trace_walk_down(
1979
- handoff: &Path,
1980
- doc: &DocMetadata,
1981
- visited: &mut std::collections::HashSet<String>,
1982
- branches: &mut Vec<Value>,
1983
- ) -> Result<Vec<Value>> {
1984
- let mut children = Vec::new();
1985
- for child_id in &doc.children {
1986
- if visited.contains(child_id) {
1987
- continue;
1988
- }
1989
- if let Some(child) = find_doc_by_id(handoff, child_id)? {
1990
- children.push(child);
1991
- }
1992
- }
1993
-
1994
- if children.is_empty() {
1995
- return Ok(Vec::new());
1996
- }
1997
-
1998
- // Fork detection: more than one live (non-visited, resolvable) child at
1999
- // this level. Every child's own sub-chain is recorded under `branches`;
2000
- // the first child's sub-chain also becomes the primary continuation of
2001
- // the returned chain, so a single-child level still reads as a plain
2002
- // linear chain.
2003
- let is_fork = children.len() > 1;
2004
- let mut primary_chain = Vec::new();
2005
-
2006
- for (idx, child) in children.iter().enumerate() {
2007
- if visited.contains(&child.id) {
2008
- continue;
2009
- }
2010
- visited.insert(child.id.clone());
2011
- let mut sub_chain = vec![doc_trace_item_json(child, Some("child"))];
2012
- sub_chain.extend(doc_trace_walk_down(handoff, child, visited, branches)?);
2013
-
2014
- if is_fork {
2015
- branches.push(json!({
2016
- "fork_from": doc.id,
2017
- "docs": sub_chain,
2018
- }));
2019
- }
2020
- if idx == 0 {
2021
- primary_chain = sub_chain;
2022
- }
2023
- }
2024
-
2025
- Ok(primary_chain)
2026
- }
2027
-
2028
- /// Appends `related` (implements/references/etc.) detours for every document
2029
- /// already present in `chain` (by id), skipping any related id already
2030
- /// visited. Related docs are appended once, immediately, as a flat list — a
2031
- /// "detour" from the main chain rather than a further recursive expansion.
2032
- fn doc_trace_related_detours(
2033
- handoff: &Path,
2034
- chain_doc_ids: &[String],
2035
- visited: &mut std::collections::HashSet<String>,
2036
- ) -> Result<Vec<Value>> {
2037
- let mut out = Vec::new();
2038
- for doc_id in chain_doc_ids {
2039
- let Some(doc) = find_doc_by_id(handoff, doc_id)? else {
2040
- continue;
2041
- };
2042
- for r in &doc.related {
2043
- if visited.contains(&r.id) {
2044
- continue;
2045
- }
2046
- let Some(target) = find_doc_by_id(handoff, &r.id)? else {
2047
- continue;
2048
- };
2049
- visited.insert(target.id.clone());
2050
- out.push(doc_trace_item_json(&target, Some(&r.rel)));
2051
- }
2052
- }
2053
- Ok(out)
2054
- }
2055
-
2056
- /// `handoff_doc_trace` — trace a document's family-tree lineage: `up` (walk
2057
- /// child->parent), `down` (walk parent->children, DFS), or `both` (merge the
2058
- /// up chain + the target + the down chain). `related` docs encountered along
2059
- /// the primary chain are appended as detour entries. Multi-child forks in the
2060
- /// `down` direction are additionally reported in `branches[]`.
2061
- pub fn handle_doc_trace(arguments: &Value) -> Result<String> {
2062
- let project_dir = resolve_project_dir(arguments)?;
2063
- let handoff = ensure_handoff_exists(&project_dir)?;
2064
-
2065
- let doc_id = arguments
2066
- .get("doc_id")
2067
- .and_then(|v| v.as_str())
2068
- .ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
2069
- let direction = arguments
2070
- .get("direction")
2071
- .and_then(|v| v.as_str())
2072
- .unwrap_or("both");
2073
-
2074
- let doc = resolve_doc(&handoff, doc_id)?
2075
- .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
2076
-
2077
- let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
2078
- visited.insert(doc.id.clone());
2079
-
2080
- let mut branches: Vec<Value> = Vec::new();
2081
- let mut chain: Vec<Value> = Vec::new();
2082
-
2083
- if direction == "up" || direction == "both" {
2084
- chain.extend(doc_trace_walk_up(&handoff, &doc, &mut visited)?);
2085
- }
2086
- chain.push(doc_trace_item_json(&doc, None));
2087
- if direction == "down" || direction == "both" {
2088
- chain.extend(doc_trace_walk_down(
2089
- &handoff,
2090
- &doc,
2091
- &mut visited,
2092
- &mut branches,
2093
- )?);
2094
- }
2095
-
2096
- let chain_doc_ids: Vec<String> = chain
2097
- .iter()
2098
- .filter_map(|v| v["id"].as_str().map(str::to_string))
2099
- .collect();
2100
- chain.extend(doc_trace_related_detours(
2101
- &handoff,
2102
- &chain_doc_ids,
2103
- &mut visited,
2104
- )?);
2105
-
2106
- Ok(to_json(&json!({
2107
- "chain": chain,
2108
- "branches": branches,
2109
- })))
2110
- }
2111
-
2112
- fn doc_metadata_json(doc: &DocMetadata) -> Value {
2113
- json!({
2114
- "id": doc.id,
2115
- "slug": doc.slug,
2116
- "title": doc.title,
2117
- "doc_type": doc.doc_type,
2118
- "tags": doc.tags,
2119
- "scope_paths": doc.scope_paths,
2120
- "parent_id": doc.parent_id,
2121
- "children": doc.children,
2122
- "related": doc.related,
2123
- "auto_inject": doc.auto_inject,
2124
- "task_ids": doc.task_ids,
2125
- "has_bom": doc.has_bom,
2126
- "line_ending": doc.line_ending,
2127
- "sections": doc.sections,
2128
- "section_count": doc.sections.len(),
2129
- "created_at": doc.created_at,
2130
- "updated_at": doc.updated_at,
2131
- "content_hash": doc.content_hash,
2132
- })
2133
- }
2134
-
2135
- /// Read a `&[String]` from a JSON string-array value (missing/non-array →
2136
- /// empty).
2137
- fn string_array_value(v: &Value) -> Vec<String> {
2138
- v.as_array()
2139
- .map(|arr| {
2140
- arr.iter()
2141
- .filter_map(|v| v.as_str())
2142
- .map(str::to_string)
2143
- .collect()
2144
- })
2145
- .unwrap_or_default()
2146
- }
2147
-
2148
- fn to_json(v: &Value) -> String {
2149
- serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
2150
- }
2151
-
2152
- #[cfg(test)]
2153
- mod graph_tests {
2154
- use super::*;
2155
-
2156
- fn doc(id: &str, slug: &str, doc_type: &str) -> DocMetadata {
2157
- DocMetadata::new(
2158
- id.to_string(),
2159
- slug.to_string(),
2160
- format!("Title {id}"),
2161
- doc_type.to_string(),
2162
- "2026-07-12T00:00:00Z".to_string(),
2163
- )
2164
- }
2165
-
2166
- #[test]
2167
- fn explicit_edges_include_parent_child_and_related() {
2168
- let mut parent = doc("doc-1", "parent", "spec");
2169
- let mut child = doc("doc-2", "child", "design");
2170
- child.parent_id = Some("doc-1".to_string());
2171
- parent.children = vec!["doc-2".to_string()];
2172
- child.related.push(DocRelation {
2173
- id: "doc-3".to_string(),
2174
- rel: "implements".to_string(),
2175
- });
2176
- let other = doc("doc-3", "other", "note");
2177
-
2178
- let docs = vec![parent, child, other];
2179
- let edges = doc_graph_explicit_edges(&docs);
2180
-
2181
- assert!(edges.iter().any(|e| e["type"] == "parent_child"
2182
- && e["from"] == "doc-1"
2183
- && e["to"] == "doc-2"
2184
- && e["direction"] == "down"));
2185
- assert!(edges.iter().any(|e| e["type"] == "implements"
2186
- && e["from"] == "doc-2"
2187
- && e["to"] == "doc-3"
2188
- && e["direction"] == "forward"));
2189
- }
2190
-
2191
- #[test]
2192
- fn implicit_edges_detect_shared_task_ids() {
2193
- let mut a = doc("doc-1", "a", "spec");
2194
- let mut b = doc("doc-2", "b", "spec");
2195
- a.task_ids = vec!["t-1".to_string(), "t-2".to_string()];
2196
- b.task_ids = vec!["t-2".to_string(), "t-3".to_string()];
2197
- let docs = vec![a, b];
2198
-
2199
- let edges = doc_graph_implicit_edges(&docs);
2200
- let shared_task_edge = edges
2201
- .iter()
2202
- .find(|e| e["type"] == "shared_task")
2203
- .expect("shared_task edge must be generated");
2204
- assert_eq!(shared_task_edge["from"], "doc-1");
2205
- assert_eq!(shared_task_edge["to"], "doc-2");
2206
- assert_eq!(shared_task_edge["task_ids"], json!(["t-2"]));
2207
- }
2208
-
2209
- #[test]
2210
- fn implicit_edges_detect_shared_scope_paths() {
2211
- let mut a = doc("doc-1", "a", "spec");
2212
- let mut b = doc("doc-2", "b", "spec");
2213
- a.scope_paths = vec!["src/mcp/".to_string()];
2214
- b.scope_paths = vec!["src/mcp/".to_string(), "src/storage/".to_string()];
2215
- let docs = vec![a, b];
2216
-
2217
- let edges = doc_graph_implicit_edges(&docs);
2218
- assert!(edges
2219
- .iter()
2220
- .any(|e| e["type"] == "shared_scope" && e["from"] == "doc-1" && e["to"] == "doc-2"));
2221
- }
2222
-
2223
- #[test]
2224
- fn implicit_edges_absent_when_nothing_shared() {
2225
- let a = doc("doc-1", "a", "spec");
2226
- let b = doc("doc-2", "b", "spec");
2227
- let docs = vec![a, b];
2228
-
2229
- let edges = doc_graph_implicit_edges(&docs);
2230
- assert!(edges.is_empty());
2231
- }
2232
-
2233
- #[test]
2234
- fn graph_node_json_includes_verification_progress_when_requested() {
2235
- let mut d = doc("doc-1", "a", "spec");
2236
- d.verification = Some(Verification {
2237
- status: "in_review".to_string(),
2238
- created_at: "2026-07-12T00:00:00Z".to_string(),
2239
- updated_at: "2026-07-12T00:00:00Z".to_string(),
2240
- items: vec![
2241
- VerificationItem {
2242
- fragment_seq: Some(0),
2243
- heading: String::new(),
2244
- status: "verified".to_string(),
2245
- impl_refs: Vec::new(),
2246
- test_refs: Vec::new(),
2247
- reviewer: None,
2248
- verified_at: None,
2249
- notes: String::new(),
2250
- content_hash_at_verify: None,
2251
- category: "section".to_string(),
2252
- sub_items: Vec::new(),
2253
- label: None,
2254
- },
2255
- VerificationItem {
2256
- fragment_seq: Some(1),
2257
- heading: "H".to_string(),
2258
- status: "pending".to_string(),
2259
- impl_refs: Vec::new(),
2260
- test_refs: Vec::new(),
2261
- reviewer: None,
2262
- verified_at: None,
2263
- notes: String::new(),
2264
- content_hash_at_verify: None,
2265
- category: "section".to_string(),
2266
- sub_items: Vec::new(),
2267
- label: None,
2268
- },
2269
- ],
2270
- });
2271
-
2272
- let with_verification = doc_graph_node_json(&d, true);
2273
- assert_eq!(
2274
- with_verification["verification_progress"],
2275
- json!({ "total": 2, "verified": 1 })
2276
- );
2277
-
2278
- let without_verification = doc_graph_node_json(&d, false);
2279
- assert!(without_verification.get("verification_progress").is_none());
2280
- }
2281
-
2282
- #[test]
2283
- fn graph_node_json_omits_verification_progress_when_no_matrix() {
2284
- let d = doc("doc-1", "a", "spec");
2285
- let node = doc_graph_node_json(&d, true);
2286
- assert!(node.get("verification_progress").is_none());
2287
- }
2288
- }