handoff-mcp-server 0.24.5 → 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.
@@ -333,9 +333,9 @@ pub fn handle_doc_query(arguments: &Value) -> Result<String> {
333
333
  .map_err(|_| anyhow::anyhow!("doc corpus cache mutex poisoned"))?;
334
334
  let corpus = cache.get_or_build_corpus(&doc_texts);
335
335
 
336
- let mut query_tokens = lexsim::tokenize(&text);
336
+ let mut query_tokens = lexsim::tokenize_weighted(&text);
337
337
  for p in &file_paths {
338
- query_tokens.extend(lexsim::tokenize(&basename(p)));
338
+ query_tokens.extend(lexsim::tokenize_weighted(&basename(p)));
339
339
  }
340
340
 
341
341
  let scope_paths: Vec<Vec<String>> = candidates
@@ -344,6 +344,7 @@ pub fn handle_doc_query(arguments: &Value) -> Result<String> {
344
344
  .collect();
345
345
  let rank_config = RankConfig {
346
346
  min_score: DOC_QUERY_MIN_SCORE,
347
+ relative_threshold: 0.0,
347
348
  scope_path_bonus: SCOPE_PATH_BONUS,
348
349
  limit: candidates.len(),
349
350
  };
@@ -957,8 +958,7 @@ pub fn handle_doc_import(arguments: &Value) -> Result<String> {
957
958
  doc.source.original_path = Some(file.clone());
958
959
  doc.has_bom = split_doc.has_bom;
959
960
  doc.line_ending = split_doc.line_ending.to_string();
960
- doc.source.frontmatter = split_doc.frontmatter.map(str::to_string);
961
- doc.source.frontmatter_trailing_eol = split_doc.frontmatter_trailing_eol;
961
+ doc.split_level = DEFAULT_SPLIT_LEVEL;
962
962
 
963
963
  let body_after_strip: String = split_doc.fragments.iter().map(|f| f.body).collect();
964
964
  write_doc_body(&handoff, &slug, &body_after_strip)?;
@@ -96,6 +96,7 @@ pub fn handle_save(arguments: &Value) -> Result<String> {
96
96
  }
97
97
 
98
98
  let tags = string_array(arguments, "tags");
99
+ let keywords = string_array(arguments, "keywords");
99
100
  let scope_paths = string_array(arguments, "scope_paths");
100
101
  let force = arguments
101
102
  .get("force")
@@ -112,6 +113,7 @@ pub fn handle_save(arguments: &Value) -> Result<String> {
112
113
  text,
113
114
  kind,
114
115
  tags,
116
+ keywords,
115
117
  scope_paths,
116
118
  );
117
119
  }
@@ -130,7 +132,18 @@ pub fn handle_save(arguments: &Value) -> Result<String> {
130
132
  // (3) Near-duplicate: hand both bodies back for AI-driven merge.
131
133
  if !force {
132
134
  let dup_threshold = settings.memory_dup_threshold;
133
- let new_set = lexsim::token_set(&text);
135
+ // Build the new memory's index text (body + tags + keywords),
136
+ // matching MemoryEntry::index_text() for symmetric Jaccard comparison.
137
+ let mut new_index = text.clone();
138
+ if !tags.is_empty() {
139
+ new_index.push(' ');
140
+ new_index.push_str(&tags.join(" "));
141
+ }
142
+ if !keywords.is_empty() {
143
+ new_index.push(' ');
144
+ new_index.push_str(&keywords.join(" "));
145
+ }
146
+ let new_set = lexsim::token_set(&new_index);
134
147
  let mut similar: Vec<Value> = Vec::new();
135
148
  for m in &existing {
136
149
  let score = lexsim::jaccard_sets(&new_set, &lexsim::token_set(&m.index_text()));
@@ -163,13 +176,22 @@ pub fn handle_save(arguments: &Value) -> Result<String> {
163
176
 
164
177
  // (4) New memory.
165
178
  let id = new_memory_id();
166
- let entry = MemoryEntry::new(id.clone(), text, kind, tags, scope_paths, now_rfc3339());
179
+ let entry = MemoryEntry::new(
180
+ id.clone(),
181
+ text,
182
+ kind,
183
+ tags,
184
+ keywords,
185
+ scope_paths,
186
+ now_rfc3339(),
187
+ );
167
188
  write_memory(&handoff, &entry)?;
168
189
  Ok(to_json(&json!({ "status": "saved", "id": id })))
169
190
  }
170
191
 
171
192
  /// Commit an AI-driven merge: overwrite `merge_into` with the merged text and
172
193
  /// delete the absorbed memories, recording them in `superseded_ids`.
194
+ #[allow(clippy::too_many_arguments)]
173
195
  fn commit_merge(
174
196
  handoff: &std::path::Path,
175
197
  merge_into: &str,
@@ -177,6 +199,7 @@ fn commit_merge(
177
199
  text: String,
178
200
  kind: String,
179
201
  tags: Vec<String>,
202
+ keywords: Vec<String>,
180
203
  scope_paths: Vec<String>,
181
204
  ) -> Result<String> {
182
205
  let mut target = read_memory_by_id(handoff, merge_into)?
@@ -188,6 +211,9 @@ fn commit_merge(
188
211
  if !tags.is_empty() {
189
212
  target.tags = tags;
190
213
  }
214
+ if !keywords.is_empty() {
215
+ target.keywords = keywords;
216
+ }
191
217
  if !scope_paths.is_empty() {
192
218
  target.scope_paths = scope_paths;
193
219
  }
@@ -200,8 +226,24 @@ fn commit_merge(
200
226
  if raw == &target_id {
201
227
  continue; // never absorb the target into itself
202
228
  }
203
- // Resolve to a concrete id (supports prefixes), then delete it.
204
229
  if let Some(m) = read_memory_by_id(handoff, raw)? {
230
+ // Fold the absorbed memory's metadata into the target before
231
+ // deleting, so keywords/tags/scope_paths are not lost.
232
+ for kw in &m.keywords {
233
+ if !target.keywords.contains(kw) {
234
+ target.keywords.push(kw.clone());
235
+ }
236
+ }
237
+ for t in &m.tags {
238
+ if !target.tags.contains(t) {
239
+ target.tags.push(t.clone());
240
+ }
241
+ }
242
+ for s in &m.scope_paths {
243
+ if !target.scope_paths.contains(s) {
244
+ target.scope_paths.push(s.clone());
245
+ }
246
+ }
205
247
  if delete_memory(handoff, &m.id)? {
206
248
  absorbed.push(m.id);
207
249
  }
@@ -265,24 +307,28 @@ pub fn handle_query(arguments: &Value) -> Result<String> {
265
307
  return Ok(to_json(&json!({ "memories": [], "injected_count": 0 })));
266
308
  }
267
309
 
268
- // Build the BM25 corpus over each memory's index text (body + tags).
269
- let docs: Vec<String> = memories.iter().map(|m| m.index_text()).collect();
270
- let corpus = lexsim::Corpus::build(&docs);
310
+ // Build the weighted BM25 corpus from per-memory weighted tokens.
311
+ // Keywords get doc-side TF boost (weight 2.0) via build_weighted_tokens;
312
+ // stopwords and CL-CnG trigrams from stopword-only context are excluded.
313
+ let doc_tokens: Vec<Vec<lexsim::WeightedToken>> =
314
+ memories.iter().map(|m| m.index_weighted_tokens()).collect();
315
+ let corpus = lexsim::Corpus::build_weighted_tokens(&doc_tokens);
271
316
 
272
317
  // Query = prompt text + tool name + file basenames (so a PreToolUse hook
273
318
  // that only passes a file path still matches name-related memories).
274
- let mut query_tokens = lexsim::tokenize(&text);
319
+ let mut query_tokens = lexsim::tokenize_weighted(&text);
275
320
  if let Some(tn) = tool_name {
276
- query_tokens.extend(lexsim::tokenize(tn));
321
+ query_tokens.extend(lexsim::tokenize_weighted(tn));
277
322
  }
278
323
  for p in &file_paths {
279
- query_tokens.extend(lexsim::tokenize(&basename(p)));
324
+ query_tokens.extend(lexsim::tokenize_weighted(&basename(p)));
280
325
  }
281
326
 
282
327
  // Score + scope-path bonus, then threshold and rank (shared with doc_query).
283
328
  let scope_paths: Vec<Vec<String>> = memories.iter().map(|m| m.scope_paths.clone()).collect();
284
329
  let rank_config = RankConfig {
285
330
  min_score: settings.memory_query_min_score,
331
+ relative_threshold: settings.memory_query_relative_threshold,
286
332
  scope_path_bonus: SCOPE_PATH_BONUS,
287
333
  // Rank without truncating yet — the session diff below needs the full
288
334
  // ranked order so it can backfill past already-injected memories.
@@ -314,15 +360,27 @@ pub fn handle_query(arguments: &Value) -> Result<String> {
314
360
  .iter()
315
361
  .map(|item| {
316
362
  let m = &memories[item.index];
317
- json!({
363
+ let mut entry = json!({
318
364
  "id": m.id,
319
365
  "text": m.text,
320
366
  "kind": m.kind,
321
367
  "score": round2(item.score),
322
- })
368
+ });
369
+ if !m.keywords.is_empty() {
370
+ entry["keywords"] = json!(m.keywords);
371
+ }
372
+ entry
323
373
  })
324
374
  .collect();
325
375
 
376
+ // Warn about returned memories that have no keywords — these get weaker
377
+ // BM25 signal and would benefit from `memory_save` with explicit keywords.
378
+ let missing_kw: Vec<&str> = fresh
379
+ .iter()
380
+ .filter(|item| memories[item.index].keywords.is_empty())
381
+ .map(|item| memories[item.index].id.as_str())
382
+ .collect();
383
+
326
384
  // Bookkeeping: record survivors in the sidecar and bump usage stats. Only
327
385
  // when we have a session id and marking is enabled (the hook's normal path).
328
386
  if mark_injected && !fresh.is_empty() {
@@ -331,10 +389,21 @@ pub fn handle_query(arguments: &Value) -> Result<String> {
331
389
  }
332
390
  }
333
391
 
334
- Ok(to_json(&json!({
392
+ let mut result = json!({
335
393
  "memories": out,
336
394
  "injected_count": out.len(),
337
- })))
395
+ });
396
+ if !missing_kw.is_empty() {
397
+ result["warnings"] = json!([
398
+ format!(
399
+ "{} injected memories have no keywords — add keywords via memory_save(merge_into=<id>) to improve match precision: {}",
400
+ missing_kw.len(),
401
+ missing_kw.join(", ")
402
+ )
403
+ ]);
404
+ }
405
+
406
+ Ok(to_json(&result))
338
407
  }
339
408
 
340
409
  /// Append the freshly-injected memories to the session sidecar and bump each
@@ -530,6 +599,11 @@ fn merge_exact_duplicates(handoff: &std::path::Path, now: &str) -> Result<usize>
530
599
  keeper.tags.push(t.clone());
531
600
  }
532
601
  }
602
+ for kw in &dup.keywords {
603
+ if !keeper.keywords.contains(kw) {
604
+ keeper.keywords.push(kw.clone());
605
+ }
606
+ }
533
607
  for s in &dup.scope_paths {
534
608
  if !keeper.scope_paths.contains(s) {
535
609
  keeper.scope_paths.push(s.clone());
@@ -804,6 +878,7 @@ mod tests {
804
878
  "lesson".to_string(),
805
879
  vec![],
806
880
  vec![],
881
+ vec![],
807
882
  created.to_string(),
808
883
  );
809
884
  m.last_referenced_at = last_ref.map(str::to_string);
@@ -99,6 +99,7 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
99
99
  "handoff_timer_stop" => timer::handle_stop(arguments),
100
100
  "handoff_timer_get_time" => timer::handle_get_time(arguments),
101
101
  "handoff_doc_save" => docs::handle_doc_save(arguments),
102
+ "handoff_doc_update_section" => docs::handle_doc_update_section(arguments),
102
103
  "handoff_doc_get" => docs::handle_doc_get(arguments),
103
104
  "handoff_doc_list" => docs::handle_doc_list(arguments),
104
105
  "handoff_doc_delete" => docs::handle_doc_delete(arguments),
@@ -130,11 +130,16 @@ pub fn handle(arguments: &Value) -> Result<String> {
130
130
  if let Some(active_session) = target {
131
131
  let sid = active_session.id.clone().unwrap_or_default();
132
132
  if keep_active {
133
- let updated_path = update_active_session(&sessions_dir, &sid, &handoff_updates)?;
133
+ let updated_path =
134
+ update_active_session(&sessions_dir, &sid, &handoff_updates, Some(arguments))?;
134
135
  (0, updated_path, Some(sid))
135
136
  } else {
136
- let closed_path =
137
- update_and_close_active_session(&sessions_dir, &sid, &handoff_updates)?;
137
+ let closed_path = update_and_close_active_session(
138
+ &sessions_dir,
139
+ &sid,
140
+ &handoff_updates,
141
+ Some(arguments),
142
+ )?;
138
143
  (
139
144
  if closed_path.is_some() { 1 } else { 0 },
140
145
  closed_path,
@@ -314,7 +314,12 @@ fn item_is_stale(doc: &DocMetadata, item: &VerificationItem) -> bool {
314
314
  let Some(hash_at_verify) = &item.content_hash_at_verify else {
315
315
  return false;
316
316
  };
317
- match doc.sections.iter().find(|s| s.seq == item.fragment_seq) {
317
+ let Some(fragment_seq) = item.fragment_seq else {
318
+ // Freeform items (v2, fragment_seq=None) are never stale: they are
319
+ // not tied to any section's content_hash.
320
+ return false;
321
+ };
322
+ match doc.sections.iter().find(|s| s.seq == fragment_seq) {
318
323
  Some(section) => &section.content_hash != hash_at_verify,
319
324
  None => true,
320
325
  }
@@ -470,10 +475,17 @@ fn suggested_actions_json(data: &TaskData, docs: &[DocMetadata]) -> Vec<String>
470
475
  for item in &v.items {
471
476
  let resolved = item.status == "verified" || item.status == "skipped";
472
477
  if !resolved || item_is_stale(doc, item) {
473
- actions.push(format!(
474
- "handoff_doc_verify(doc_id=\"{}\", action=\"check\", fragment_seq={}) \"{}\" のレビュー完了時",
475
- doc.id, item.fragment_seq, item.heading
476
- ));
478
+ let action = match item.fragment_seq {
479
+ Some(seq) => format!(
480
+ "handoff_doc_verify(doc_id=\"{}\", action=\"check\", fragment_seq={}) — \"{}\" のレビュー完了時",
481
+ doc.id, seq, item.heading
482
+ ),
483
+ None => format!(
484
+ "handoff_doc_verify(doc_id=\"{}\", action=\"check\", fragment_seq=null, label=\"{}\") — \"{}\" のレビュー完了時 (フリーフォーム項目)",
485
+ doc.id, item.label.as_deref().unwrap_or(&item.heading), item.heading
486
+ ),
487
+ };
488
+ actions.push(action);
477
489
  }
478
490
  }
479
491
  }
@@ -153,11 +153,13 @@ pub fn handle(arguments: &Value) -> Result<String> {
153
153
  Ok(msg)
154
154
  }
155
155
 
156
- fn truncate(s: &str, max: usize) -> String {
157
- if s.len() <= max {
158
- s.to_string()
156
+ fn truncate(s: &str, max_chars: usize) -> String {
157
+ let mut chars = s.chars();
158
+ let truncated: String = chars.by_ref().take(max_chars).collect();
159
+ if chars.next().is_some() {
160
+ format!("{truncated}...")
159
161
  } else {
160
- format!("{}...", &s[..max])
162
+ truncated
161
163
  }
162
164
  }
163
165
 
package/src/mcp/tools.rs CHANGED
@@ -1080,6 +1080,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1080
1080
  "text": { "type": "string", "description": "The memory body (any language). Required, non-empty." },
1081
1081
  "kind": { "type": "string", "description": "Memory kind.", "enum": ["lesson", "rule", "convention", "gotcha"], "default": "lesson" },
1082
1082
  "tags": { "type": "array", "items": { "type": "string" }, "description": "Optional tags; also indexed for similarity." },
1083
+ "keywords": { "type": "array", "items": { "type": "string" }, "description": "Subject keywords — nouns, technical terms, proper nouns that identify what this memory is about. These are weighted higher than body text in BM25 relevance scoring. Distinct from tags (classification labels)." },
1083
1084
  "scope_paths": { "type": "array", "items": { "type": "string" }, "description": "Path prefixes this memory applies to (e.g. 'src/storage/'). Boosts relevance when a query touches a matching file." },
1084
1085
  "merge_into": { "type": "string", "description": "Commit an AI merge: overwrite this memory id with `text` and absorb `absorb_ids`." },
1085
1086
  "absorb_ids": { "type": "array", "items": { "type": "string" }, "description": "Memory ids to delete and record as superseded when merging." },
@@ -1259,6 +1260,21 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1259
1260
  }
1260
1261
  }),
1261
1262
  },
1263
+ ToolDefinition {
1264
+ name: "handoff_doc_update_section".to_string(),
1265
+ description: "Replace a single section of a document by its seq number, without re-sending the whole document body. new_content is the replacement Markdown (including the section's own heading line); an empty string deletes the section. Sections are computed on-demand from the current body (same as doc_get/doc_save), so seq must match the document's current section numbering. expected_hash is an optional optimistic lock: when given, it must match the section's current content_hash or the call errors with the actual current hash (for retry) and makes no change. Updates the document's updated_at and content_hash, and — if a verification matrix item exists at this fragment_seq — surfaces a warning that it is now stale (its content_hash_at_verify no longer matches). Returns a JSON string {doc_id,seq,heading,content_hash,updated_at,section_count,warnings?:[…]}.".to_string(),
1266
+ input_schema: json!({
1267
+ "type": "object",
1268
+ "properties": {
1269
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1270
+ "doc_id": { "type": "string", "description": "Document id or slug to update." },
1271
+ "seq": { "type": "integer", "description": "Section sequence number to replace (0 = preamble)." },
1272
+ "new_content": { "type": "string", "description": "Replacement Markdown text for this section, including its own heading line. An empty string deletes the section." },
1273
+ "expected_hash": { "type": "string", "description": "Optimistic lock: the section's expected current content_hash. If it does not match, the update is rejected with the actual current hash." }
1274
+ },
1275
+ "required": ["doc_id", "seq", "new_content"]
1276
+ }),
1277
+ },
1262
1278
  ToolDefinition {
1263
1279
  name: "handoff_doc_get".to_string(),
1264
1280
  description: "Read a document by doc_id or slug. format='full' returns the original Markdown body (read directly from _doc.<slug>.md) plus metadata; 'meta' returns metadata only (no body, cheap for graph traversal); 'section' returns one section's body (byte-sliced from the document body, requires seq). Returns a JSON string.".to_string(),
@@ -1329,17 +1345,21 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1329
1345
  },
1330
1346
  ToolDefinition {
1331
1347
  name: "handoff_doc_verify".to_string(),
1332
- description: "Operate on a document's verification matrix (wiki/140-verification-matrix.md): generate (create a matrix from the document's current sections, error if one already exists), check (mark fragment_seq verified, recording verified_at/reviewer/notes/content_hash_at_verify), skip (mark fragment_seq skipped), sync (re-sync the matrix with the document's current sections — adds new sections as pending, removes deleted ones, preserves existing item status), or set_refs (update impl_refs/test_refs for fragment_seq). Overall verification_status is recomputed after every mutation: 'pending' if all items pending, 'verified' if all verified/skipped, else 'in_review'. Returns a JSON string {doc_id,verification_status,checked,skipped,pending,total,stale}.".to_string(),
1348
+ description: "Operate on a document's verification matrix (wiki/140-verification-matrix.md): generate (create a matrix from the document's current sections, error if one already exists), check (mark fragment_seq — or its sub_item_index — verified, recording verified_at/reviewer/notes/content_hash_at_verify — fragment_seq may be a single section seq or an array of seqs to verify in one call), check_all (verify every section and sub_item in the matrix in one call, error if no matrix exists yet), skip (mark fragment_seq — or its sub_item_index — skipped), sync (re-sync the matrix with the document's current sections — adds new sections as pending, removes deleted ones, preserves existing item status), set_refs (update impl_refs/test_refs for fragment_seq), add_item (v2, spec §7.2: with fragment_seq given, append a SubItem — description required — to that section's sub_items; with fragment_seq omitted, append a new freeform top-level item not tied to any section — label required), or suggest_refs (t124.6: read-only — scans the document's scope_paths for source/test files whose fn/struct/impl/mod definitions or test functions fuzzy-match each verification item's heading, and returns candidate impl_refs/test_refs per item for the caller to review and apply via set_refs; errors if no matrix exists yet). Overall verification_status is recomputed after every mutation: 'pending' if all items pending, 'verified' if all verified/skipped, else 'in_review'. Returns a JSON string {doc_id,verification_status,checked,skipped,pending,total,stale} for mutating actions, or {doc_id,suggestions:[{fragment_seq,heading,suggested_impl_refs,suggested_test_refs}]} for suggest_refs.".to_string(),
1333
1349
  input_schema: json!({
1334
1350
  "type": "object",
1335
1351
  "properties": {
1336
1352
  "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1337
1353
  "doc_id": { "type": "string", "description": "Document id or slug to operate on." },
1338
- "action": { "type": "string", "description": "Verification matrix action.", "enum": ["generate", "check", "skip", "sync", "set_refs"] },
1354
+ "action": { "type": "string", "description": "Verification matrix action.", "enum": ["generate", "check", "check_all", "skip", "sync", "set_refs", "add_item", "suggest_refs"] },
1339
1355
  "skip_seqs": { "type": "array", "items": { "type": "integer" }, "description": "generate only: section seqs to create as 'skipped' instead of 'pending'." },
1340
- "fragment_seq": { "type": "integer", "description": "check/skip/set_refs: the section seq (VerificationItem.fragment_seq) to operate on." },
1341
- "reviewer": { "type": "string", "description": "check: who verified it.", "enum": ["ai", "user"] },
1342
- "notes": { "type": "string", "description": "check: optional free-text note." },
1356
+ "fragment_seq": { "description": "skip/set_refs: the section seq (VerificationItem.fragment_seq) to operate on. check: a single section seq, or an array of section seqs to verify in one call. add_item: the section seq to attach a new sub_item to; omit to add a freeform top-level item instead.", "oneOf": [ { "type": "integer" }, { "type": "array", "items": { "type": "integer" } } ] },
1357
+ "sub_item_index": { "type": "integer", "description": "check/skip: the 0-based SubItem.index within fragment_seq's sub_items to operate on, instead of the parent item itself." },
1358
+ "description": { "type": "string", "description": "add_item (fragment_seq given): the new sub_item's description. Required in this form." },
1359
+ "label": { "type": "string", "description": "add_item (fragment_seq omitted): the new freeform top-level item's label. Required in this form." },
1360
+ "category": { "type": "string", "description": "add_item: item/sub_item category (free-extensible, e.g. 'requirement', 'visual', 'regression', 'manual'). Defaults to 'requirement' for sub_items, 'visual' for freeform items." },
1361
+ "reviewer": { "type": "string", "description": "check/check_all: who verified it.", "enum": ["ai", "user"] },
1362
+ "notes": { "type": "string", "description": "check/check_all: optional free-text note." },
1343
1363
  "impl_refs": { "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "lines": { "type": "string" }, "label": { "type": "string" } }, "required": ["path"] }, "description": "set_refs: implementation code references to attach to fragment_seq." },
1344
1364
  "test_refs": { "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "lines": { "type": "string" }, "label": { "type": "string" } }, "required": ["path"] }, "description": "set_refs: test code references to attach to fragment_seq." }
1345
1365
  },
@@ -1348,13 +1368,14 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1348
1368
  },
1349
1369
  ToolDefinition {
1350
1370
  name: "handoff_doc_verify_status".to_string(),
1351
- description: "Get a document's verification matrix status: overall verification_status, progress counts (checked/skipped/pending/total/stale/percentage), and (when include_items=true) every item with a computed stale flag (its content_hash_at_verify no longer matches the section's current content_hash — spec §3.5). Errors if the document has no verification matrix yet (use handoff_doc_verify(action='generate') first). Returns a JSON string {doc_id,title,verification_status,progress:{…},items?:[…]}.".to_string(),
1371
+ description: "Get a document's verification matrix status: overall verification_status, progress counts (checked/skipped/pending/total/stale/percentage — v2: counts leaf items, i.e. sub_items and freeform items, spec §7.4), and (when include_items=true) every item with a computed stale flag (its content_hash_at_verify no longer matches the section's current content_hash — spec §3.5). Errors if the document has no verification matrix yet (use handoff_doc_verify(action='generate') first). Returns a JSON string {doc_id,title,verification_status,progress:{…},items?:[…]} by default, or (format='checklist') a Markdown checklist rendering (spec §7.3) instead.".to_string(),
1352
1372
  input_schema: json!({
1353
1373
  "type": "object",
1354
1374
  "properties": {
1355
1375
  "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1356
1376
  "doc_id": { "type": "string", "description": "Document id or slug to read verification status for." },
1357
- "include_items": { "type": "boolean", "description": "Include the full per-item list (with stale detection).", "default": false }
1377
+ "include_items": { "type": "boolean", "description": "Include the full per-item list (with stale detection).", "default": false },
1378
+ "format": { "type": "string", "description": "Output format: 'json' (default, structured payload) or 'checklist' (Markdown checklist rendering with headings, sub_item checkboxes, refs, and categories).", "enum": ["json", "checklist"], "default": "json" }
1358
1379
  },
1359
1380
  "required": ["doc_id"]
1360
1381
  }),