handoff-mcp-server 0.24.6 → 0.25.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.
@@ -1,9 +1,9 @@
1
1
  //! MCP handlers for document management (save / get / list) — P1-6a (t96.1),
2
- //! v5 rearchitecture (2-file slug-based storage,
2
+ //! frontmatter migration (t123.1-t123.3, single-file slug-based storage,
3
3
  //! wiki/130-document-management.md §3.1).
4
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
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
7
  //! task<->doc bidirectional link sync in
8
8
  //! `crate::storage::tasks::sync_doc_task_links`. See
9
9
  //! `wiki/130-document-management.md` §5.1-§5.3 for the spec.
@@ -20,7 +20,7 @@ use crate::storage::docs::split::{compute_sections, split};
20
20
  use crate::storage::docs::{
21
21
  delete_doc, delete_doc_body, ensure_docs_dir, find_doc_by_id, read_all_docs, read_doc,
22
22
  read_doc_body, validate_slug, write_doc, write_doc_body, CodeRef, DocMetadata, DocRelation,
23
- Verification, VerificationItem,
23
+ SubItem, Verification, VerificationItem,
24
24
  };
25
25
  use crate::storage::ensure_handoff_exists;
26
26
  use crate::storage::tasks::sync_doc_task_links;
@@ -85,10 +85,10 @@ pub fn handle_doc_save(arguments: &Value) -> Result<String> {
85
85
  };
86
86
 
87
87
  // `append_body`: join the appended text onto the existing document's
88
- // stripped body (read_doc_body — NOT read_full_body, whose
89
- // BOM/frontmatter restoration would otherwise get re-detected and
90
- // double-persisted by `split()` below). No separator is inserted when
91
- // the existing body is empty/missing (spec §3.1 edge case).
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
92
  let joined_body: String;
93
93
  let body: &str = if let Some(append_body) = append_body_arg {
94
94
  let existing_doc = existing
@@ -210,8 +210,7 @@ pub fn handle_doc_save(arguments: &Value) -> Result<String> {
210
210
 
211
211
  doc.has_bom = split_doc.has_bom;
212
212
  doc.line_ending = split_doc.line_ending.to_string();
213
- doc.source.frontmatter = split_doc.frontmatter.map(str::to_string);
214
- doc.source.frontmatter_trailing_eol = split_doc.frontmatter_trailing_eol;
213
+ doc.split_level = split_level;
215
214
  doc.updated_at = now.clone();
216
215
 
217
216
  // v5: the full body (after BOM/frontmatter stripping) is written verbatim
@@ -310,33 +309,130 @@ pub fn handle_doc_save(arguments: &Value) -> Result<String> {
310
309
  })))
311
310
  }
312
311
 
313
- /// Reads a document's full original body: `_doc.<slug>.md` (the
314
- /// post-BOM/frontmatter-stripped body) with the BOM and YAML frontmatter
315
- /// (if any) restored in front of it, exactly as originally authored.
316
- /// Returns `Ok(None)` when the `.md` file is missing (metadata exists but
317
- /// body was deleted out-of-band).
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.
318
427
  fn read_full_body(handoff: &Path, doc: &DocMetadata) -> Result<Option<String>> {
319
- let Some(stripped_body) = read_doc_body(handoff, &doc.slug)? else {
428
+ let Some(body) = read_doc_body(handoff, &doc.slug)? else {
320
429
  return Ok(None);
321
430
  };
322
- let mut body = stripped_body;
323
- if let Some(frontmatter) = &doc.source.frontmatter {
324
- let eol = if doc.line_ending == "crlf" {
325
- "\r\n"
326
- } else {
327
- "\n"
328
- };
329
- let trailing_eol = if doc.source.frontmatter_trailing_eol {
330
- eol
331
- } else {
332
- ""
333
- };
334
- body = format!("---{eol}{frontmatter}---{trailing_eol}{body}");
335
- }
336
- if doc.has_bom {
337
- body = format!("\u{FEFF}{body}");
338
- }
339
- Ok(Some(body))
431
+ Ok(Some(if doc.has_bom {
432
+ format!("\u{FEFF}{body}")
433
+ } else {
434
+ body
435
+ }))
340
436
  }
341
437
 
342
438
  /// `handoff_doc_get` — read a document (by `doc_id` or `slug`) as `full`
@@ -467,11 +563,12 @@ fn rank_docs_by_query(handoff: &Path, docs: &[DocMetadata], query: &str) -> Resu
467
563
  index_texts.push(text);
468
564
  }
469
565
 
470
- let corpus = lexsim::Corpus::build(&index_texts);
471
- let query_tokens = lexsim::tokenize(query);
566
+ let corpus = lexsim::Corpus::build_weighted(&index_texts);
567
+ let query_tokens = lexsim::tokenize_weighted(query);
472
568
  let scope_paths: Vec<Vec<String>> = docs.iter().map(|d| d.scope_paths.clone()).collect();
473
569
  let config = RankConfig {
474
570
  min_score: DOC_QUERY_MIN_SCORE,
571
+ relative_threshold: 0.0,
475
572
  scope_path_bonus: SCOPE_PATH_BONUS,
476
573
  limit: docs.len(),
477
574
  };
@@ -563,9 +660,13 @@ pub fn handle_doc_reassemble(arguments: &Value) -> Result<String> {
563
660
  let doc = resolve_doc(&handoff, doc_id)?
564
661
  .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
565
662
 
566
- let stripped_body = read_doc_body(&handoff, &doc.slug)?
567
- .ok_or_else(|| anyhow::anyhow!("Document body file missing for slug '{}'", doc.slug))?;
568
- let drifted = lexsim::content_hash(&stripped_body) != doc.content_hash;
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());
569
670
 
570
671
  let body = read_full_body(&handoff, &doc)?.unwrap_or_default();
571
672
 
@@ -699,15 +800,38 @@ fn doc_tree_node_json(handoff: &Path, doc: &DocMetadata, include_related: bool)
699
800
 
700
801
  /// Recomputes `Verification.status` from its items (wiki/140-verification-matrix.md
701
802
  /// §3.3): all pending -> "pending"; all verified/skipped -> "verified";
702
- /// otherwise -> "in_review".
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`.
703
806
  fn recompute_verification_status(items: &[VerificationItem]) -> String {
704
- if items.iter().all(|i| i.status == "pending") {
807
+ let statuses: Vec<String> = items.iter().map(item_effective_status).collect();
808
+ if statuses.iter().all(|s| s == "pending") {
705
809
  "pending".to_string()
706
- } else if items
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
707
829
  .iter()
708
- .all(|i| i.status == "verified" || i.status == "skipped")
830
+ .all(|s| s.status == "verified" || s.status == "skipped")
709
831
  {
710
832
  "verified".to_string()
833
+ } else if item.sub_items.iter().all(|s| s.status == "pending") {
834
+ "pending".to_string()
711
835
  } else {
712
836
  "in_review".to_string()
713
837
  }
@@ -732,6 +856,264 @@ fn code_refs_from_value(v: &Value) -> Vec<CodeRef> {
732
856
  .unwrap_or_default()
733
857
  }
734
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
+
735
1117
  /// Summary counts used by both `doc_verify`'s mutation response and
736
1118
  /// `doc_verify_status`'s `progress` block.
737
1119
  struct VerificationCounts {
@@ -742,30 +1124,62 @@ struct VerificationCounts {
742
1124
  stale: usize,
743
1125
  }
744
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 の合計").
745
1132
  fn count_verification(doc: &DocMetadata, v: &Verification) -> VerificationCounts {
746
- let checked = v.items.iter().filter(|i| i.status == "verified").count();
747
- let skipped = v.items.iter().filter(|i| i.status == "skipped").count();
748
- let pending = v.items.iter().filter(|i| i.status == "pending").count();
749
- let stale = v.items.iter().filter(|i| item_is_stale(doc, i)).count();
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
+
750
1159
  VerificationCounts {
751
1160
  checked,
752
1161
  skipped,
753
1162
  pending,
754
- total: v.items.len(),
1163
+ total: checked + skipped + pending,
755
1164
  stale,
756
1165
  }
757
1166
  }
758
1167
 
759
1168
  /// An item is stale when it was verified at a content_hash that no longer
760
1169
  /// matches its section's current content_hash (spec §3.5) — items never
761
- /// verified (`content_hash_at_verify: None`) are never stale, and items whose
1170
+ /// verified (`content_hash_at_verify: None`) are never stale, items whose
762
1171
  /// section has been removed (sync should have dropped them, but be
763
- /// defensive) are treated as stale so drift is never silently hidden.
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.
764
1175
  fn item_is_stale(doc: &DocMetadata, item: &VerificationItem) -> bool {
765
1176
  let Some(hash_at_verify) = &item.content_hash_at_verify else {
766
1177
  return false;
767
1178
  };
768
- match doc.sections.iter().find(|s| s.seq == item.fragment_seq) {
1179
+ let Some(fragment_seq) = item.fragment_seq else {
1180
+ return false;
1181
+ };
1182
+ match doc.sections.iter().find(|s| s.seq == fragment_seq) {
769
1183
  Some(section) => &section.content_hash != hash_at_verify,
770
1184
  None => true,
771
1185
  }
@@ -789,6 +1203,24 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
789
1203
  let mut doc = resolve_doc(&handoff, doc_id)?
790
1204
  .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
791
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
+
792
1224
  let now = chrono::Utc::now().to_rfc3339();
793
1225
 
794
1226
  match action {
@@ -813,7 +1245,7 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
813
1245
  .sections
814
1246
  .iter()
815
1247
  .map(|s| VerificationItem {
816
- fragment_seq: s.seq,
1248
+ fragment_seq: Some(s.seq),
817
1249
  heading: s.heading.clone(),
818
1250
  status: if skip_seqs.contains(&s.seq) {
819
1251
  "skipped".to_string()
@@ -826,6 +1258,9 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
826
1258
  verified_at: None,
827
1259
  notes: String::new(),
828
1260
  content_hash_at_verify: None,
1261
+ category: "section".to_string(),
1262
+ sub_items: Vec::new(),
1263
+ label: None,
829
1264
  })
830
1265
  .collect();
831
1266
 
@@ -837,7 +1272,7 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
837
1272
  });
838
1273
  }
839
1274
  "check" => {
840
- let fragment_seq = required_fragment_seq(arguments)?;
1275
+ let fragment_seqs = required_fragment_seqs(arguments)?;
841
1276
  let reviewer = arguments
842
1277
  .get("reviewer")
843
1278
  .and_then(|v| v.as_str())
@@ -846,31 +1281,172 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
846
1281
  .get("notes")
847
1282
  .and_then(|v| v.as_str())
848
1283
  .map(str::to_string);
849
- let section_hash = doc
850
- .sections
851
- .iter()
852
- .find(|s| s.seq == fragment_seq)
853
- .map(|s| s.content_hash.clone());
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();
854
1334
 
855
1335
  let v = verification_mut(&mut doc, doc_id)?;
856
- let item = find_item_mut(v, fragment_seq, doc_id)?;
857
- item.status = "verified".to_string();
858
- item.verified_at = Some(now.clone());
859
- if reviewer.is_some() {
860
- item.reviewer = reviewer;
861
- }
862
- if let Some(notes) = notes {
863
- item.notes = notes;
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
+ }
864
1361
  }
865
- item.content_hash_at_verify = section_hash;
866
1362
  v.updated_at = now.clone();
867
1363
  v.status = recompute_verification_status(&v.items);
868
1364
  }
869
1365
  "skip" => {
870
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
+
871
1372
  let v = verification_mut(&mut doc, doc_id)?;
872
1373
  let item = find_item_mut(v, fragment_seq, doc_id)?;
873
- item.status = "skipped".to_string();
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
+ }
874
1450
  v.updated_at = now.clone();
875
1451
  v.status = recompute_verification_status(&v.items);
876
1452
  }
@@ -879,13 +1455,19 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
879
1455
  let v = verification_mut(&mut doc, doc_id)?;
880
1456
  let current_seqs: std::collections::HashSet<usize> =
881
1457
  sections.iter().map(|s| s.seq).collect();
882
- v.items.retain(|i| current_seqs.contains(&i.fragment_seq));
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
+ });
883
1465
  let existing_seqs: std::collections::HashSet<usize> =
884
- v.items.iter().map(|i| i.fragment_seq).collect();
1466
+ v.items.iter().filter_map(|i| i.fragment_seq).collect();
885
1467
  for s in &sections {
886
1468
  if !existing_seqs.contains(&s.seq) {
887
1469
  v.items.push(VerificationItem {
888
- fragment_seq: s.seq,
1470
+ fragment_seq: Some(s.seq),
889
1471
  heading: s.heading.clone(),
890
1472
  status: "pending".to_string(),
891
1473
  impl_refs: Vec::new(),
@@ -894,10 +1476,15 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
894
1476
  verified_at: None,
895
1477
  notes: String::new(),
896
1478
  content_hash_at_verify: None,
1479
+ category: "section".to_string(),
1480
+ sub_items: Vec::new(),
1481
+ label: None,
897
1482
  });
898
1483
  }
899
1484
  }
900
- v.items.sort_by_key(|i| i.fragment_seq);
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));
901
1488
  v.updated_at = now.clone();
902
1489
  v.status = recompute_verification_status(&v.items);
903
1490
  }
@@ -918,7 +1505,7 @@ pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
918
1505
  v.status = recompute_verification_status(&v.items);
919
1506
  }
920
1507
  other => anyhow::bail!(
921
- "Unknown action '{other}'; expected one of generate, check, skip, sync, set_refs"
1508
+ "Unknown action '{other}'; expected one of generate, check, check_all, skip, sync, set_refs, add_item, suggest_refs"
922
1509
  ),
923
1510
  }
924
1511
 
@@ -949,6 +1536,25 @@ fn required_fragment_seq(arguments: &Value) -> Result<usize> {
949
1536
  .ok_or_else(|| anyhow::anyhow!("'fragment_seq' is required for this action"))
950
1537
  }
951
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
+
952
1558
  fn verification_mut<'a>(doc: &'a mut DocMetadata, doc_id: &str) -> Result<&'a mut Verification> {
953
1559
  doc.verification.as_mut().ok_or_else(|| {
954
1560
  anyhow::anyhow!(
@@ -964,7 +1570,7 @@ fn find_item_mut<'a>(
964
1570
  ) -> Result<&'a mut VerificationItem> {
965
1571
  v.items
966
1572
  .iter_mut()
967
- .find(|i| i.fragment_seq == fragment_seq)
1573
+ .find(|i| i.fragment_seq == Some(fragment_seq))
968
1574
  .ok_or_else(|| {
969
1575
  anyhow::anyhow!(
970
1576
  "No verification item at fragment_seq={fragment_seq} for document {doc_id}"
@@ -972,6 +1578,21 @@ fn find_item_mut<'a>(
972
1578
  })
973
1579
  }
974
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
+
975
1596
  /// `handoff_doc_verify_status` — verification matrix summary + optional
976
1597
  /// per-item detail with stale detection (wiki/140-verification-matrix.md
977
1598
  /// §4.2).
@@ -987,6 +1608,10 @@ pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
987
1608
  .get("include_items")
988
1609
  .and_then(|v| v.as_bool())
989
1610
  .unwrap_or(false);
1611
+ let format = arguments
1612
+ .get("format")
1613
+ .and_then(|v| v.as_str())
1614
+ .unwrap_or("json");
990
1615
 
991
1616
  let doc = resolve_doc(&handoff, doc_id)?
992
1617
  .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
@@ -1004,6 +1629,10 @@ pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
1004
1629
  (counts.checked + counts.skipped) as f64 / counts.total as f64 * 100.0
1005
1630
  };
1006
1631
 
1632
+ if format == "checklist" {
1633
+ return Ok(render_verification_checklist(&doc, v, &counts, percentage));
1634
+ }
1635
+
1007
1636
  let mut out = json!({
1008
1637
  "doc_id": doc.id,
1009
1638
  "title": doc.title,
@@ -1023,6 +1652,21 @@ pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
1023
1652
  .items
1024
1653
  .iter()
1025
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();
1026
1670
  json!({
1027
1671
  "fragment_seq": i.fragment_seq,
1028
1672
  "heading": i.heading,
@@ -1033,6 +1677,9 @@ pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
1033
1677
  "reviewer": i.reviewer,
1034
1678
  "verified_at": i.verified_at,
1035
1679
  "notes": i.notes,
1680
+ "category": i.category,
1681
+ "sub_items": sub_items,
1682
+ "label": i.label,
1036
1683
  })
1037
1684
  })
1038
1685
  .collect();
@@ -1042,6 +1689,109 @@ pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
1042
1689
  Ok(to_json(&out))
1043
1690
  }
1044
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
+
1045
1795
  /// `handoff_doc_graph` — build a graph of every document in the project:
1046
1796
  /// `nodes[]` (one per document, with optional verification progress),
1047
1797
  /// `edges[]` (explicit parent_child/related links, plus implicit
@@ -1489,7 +2239,7 @@ mod graph_tests {
1489
2239
  updated_at: "2026-07-12T00:00:00Z".to_string(),
1490
2240
  items: vec![
1491
2241
  VerificationItem {
1492
- fragment_seq: 0,
2242
+ fragment_seq: Some(0),
1493
2243
  heading: String::new(),
1494
2244
  status: "verified".to_string(),
1495
2245
  impl_refs: Vec::new(),
@@ -1498,9 +2248,12 @@ mod graph_tests {
1498
2248
  verified_at: None,
1499
2249
  notes: String::new(),
1500
2250
  content_hash_at_verify: None,
2251
+ category: "section".to_string(),
2252
+ sub_items: Vec::new(),
2253
+ label: None,
1501
2254
  },
1502
2255
  VerificationItem {
1503
- fragment_seq: 1,
2256
+ fragment_seq: Some(1),
1504
2257
  heading: "H".to_string(),
1505
2258
  status: "pending".to_string(),
1506
2259
  impl_refs: Vec::new(),
@@ -1509,6 +2262,9 @@ mod graph_tests {
1509
2262
  verified_at: None,
1510
2263
  notes: String::new(),
1511
2264
  content_hash_at_verify: None,
2265
+ category: "section".to_string(),
2266
+ sub_items: Vec::new(),
2267
+ label: None,
1512
2268
  },
1513
2269
  ],
1514
2270
  });