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.
@@ -4,7 +4,10 @@
4
4
  //! in-memory (byte offsets into the body) rather than split into physical
5
5
  //! fragment files.
6
6
 
7
+ use std::collections::HashMap;
8
+
7
9
  use serde::{Deserialize, Serialize};
10
+ use serde_json::Value;
8
11
 
9
12
  /// Current document schema version. Bump when `DocMetadata` changes shape in
10
13
  /// a way that needs migration handling on read.
@@ -89,6 +92,15 @@ pub struct DocMetadata {
89
92
  #[serde(default = "default_line_ending")]
90
93
  pub line_ending: String,
91
94
 
95
+ /// ATX heading level (1-6) at which this document is split into
96
+ /// sections (frontmatter migration, t123.1/t123.2). Persisted per-doc so
97
+ /// a manually-edited `.md` file recomputes the same section boundaries
98
+ /// on every read. Defaults to
99
+ /// [`super::split::DEFAULT_SPLIT_LEVEL`] for documents saved before this
100
+ /// field existed.
101
+ #[serde(default = "default_split_level")]
102
+ pub split_level: u8,
103
+
92
104
  /// Section manifest, in `seq` order (v5: replaces the old `fragments`
93
105
  /// physical-file manifest — `sections` are in-memory byte-offset
94
106
  /// indexes into `_doc.<slug>.md`, not separate files). Old on-disk
@@ -112,6 +124,15 @@ pub struct DocMetadata {
112
124
  /// `#[serde(default)]`.
113
125
  #[serde(default, skip_serializing_if = "Option::is_none")]
114
126
  pub verification: Option<Verification>,
127
+
128
+ /// Unknown/unrecognized frontmatter keys, preserved for round-trip
129
+ /// fidelity (frontmatter migration spec: "extra fields"). Never written
130
+ /// by handoff-mcp itself; only ever populated by parsing a document
131
+ /// whose frontmatter has keys outside the known schema (e.g. hand-edited
132
+ /// or authored by another tool). Not present in the JSON-era on-disk
133
+ /// format, so `#[serde(default)]` keeps old fixtures deserializing.
134
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
135
+ pub extra: HashMap<String, Value>,
115
136
  }
116
137
 
117
138
  fn default_auto_inject() -> String {
@@ -122,6 +143,10 @@ fn default_line_ending() -> String {
122
143
  "lf".to_string()
123
144
  }
124
145
 
146
+ fn default_split_level() -> u8 {
147
+ super::split::DEFAULT_SPLIT_LEVEL
148
+ }
149
+
125
150
  /// Maximum allowed length of a `slug` (spec §3.1 v5 proposal).
126
151
  pub const MAX_SLUG_LEN: usize = 60;
127
152
 
@@ -147,11 +172,13 @@ impl DocMetadata {
147
172
  source: DocSource::default(),
148
173
  has_bom: false,
149
174
  line_ending: default_line_ending(),
175
+ split_level: default_split_level(),
150
176
  sections: Vec::new(),
151
177
  created_at: now.clone(),
152
178
  updated_at: now,
153
179
  content_hash: String::new(),
154
180
  verification: None,
181
+ extra: HashMap::new(),
155
182
  }
156
183
  }
157
184
  }
@@ -176,21 +203,27 @@ pub struct DocSource {
176
203
  /// Original file path when imported from `wiki/` or `tmp/`.
177
204
  #[serde(default, skip_serializing_if = "Option::is_none")]
178
205
  pub original_path: Option<String>,
179
- /// Canonical-form hash used to detect drift on reassembly.
206
+ /// Canonical-form hash used to detect drift on reassembly. In the
207
+ /// frontmatter format (t123.1+), this is the value persisted at the
208
+ /// *last save* (`source.canonical_hash` in frontmatter, untouched by
209
+ /// `read_doc`'s on-read `content_hash` recompute — t123.2), so comparing
210
+ /// it against the freshly-recomputed top-level `content_hash` is the
211
+ /// drift signal `doc_reassemble` uses.
180
212
  #[serde(default, skip_serializing_if = "Option::is_none")]
181
213
  pub canonical_hash: Option<String>,
182
- /// Raw YAML frontmatter block (spec §5.1 scope rule 6), extracted by
183
- /// [`super::split::split`] and excluded from section seq-0. `None` when
184
- /// the document has no frontmatter.
214
+ /// Legacy field (pre-frontmatter-migration, t96): raw YAML frontmatter
215
+ /// block stashed by the old 2-file format when a caller's authored
216
+ /// `body` started with its own `---`-fenced block, so it could be
217
+ /// restored losslessly on `doc_get`/`doc_reassemble`. **Dead in the
218
+ /// frontmatter format** — kept only so a legacy `_doc.<slug>.json`
219
+ /// sidecar still deserializes during migration
220
+ /// (`storage::docs::migrate_legacy_doc`); a document's own frontmatter
221
+ /// is now handoff-owned metadata, so a caller's leading frontmatter
222
+ /// block in `body` is absorbed rather than round-tripped (see
223
+ /// `handle_doc_get`'s `read_full_body`).
185
224
  #[serde(default, skip_serializing_if = "Option::is_none")]
186
225
  pub frontmatter: Option<String>,
187
- /// `true` if a line ending immediately followed the closing `---` fence
188
- /// in the originally authored body (see
189
- /// [`super::split::SplitDocument::frontmatter_trailing_eol`]).
190
- /// Meaningless when `frontmatter` is `None`. Defaults to `true` so
191
- /// documents persisted before this field existed keep the pre-fix
192
- /// reassembly behavior (always re-adding the eol) rather than silently
193
- /// dropping a byte they never reported not having.
226
+ /// Legacy field, paired with [`Self::frontmatter`] see its doc comment.
194
227
  #[serde(default = "default_frontmatter_trailing_eol")]
195
228
  pub frontmatter_trailing_eol: bool,
196
229
  }
@@ -251,10 +284,14 @@ pub struct Verification {
251
284
  }
252
285
 
253
286
  /// One row in the verification matrix — tracks review state of a single
254
- /// spec fragment.
287
+ /// spec fragment (v1) or, since v2 (wiki/140-verification-matrix.md §7), a
288
+ /// freeform top-level item not tied to any section (`fragment_seq: None`).
255
289
  #[derive(Debug, Clone, Serialize, Deserialize)]
256
290
  pub struct VerificationItem {
257
- pub fragment_seq: usize,
291
+ /// The section this item tracks. `None` (v2) = a freeform item, not
292
+ /// tied to any document section — see `label`.
293
+ #[serde(default)]
294
+ pub fragment_seq: Option<usize>,
258
295
  pub heading: String,
259
296
  /// "pending" | "skipped" | "verified".
260
297
  pub status: String,
@@ -273,6 +310,47 @@ pub struct VerificationItem {
273
310
  /// flagged (`doc_verify_status`).
274
311
  #[serde(default, skip_serializing_if = "Option::is_none")]
275
312
  pub content_hash_at_verify: Option<String>,
313
+
314
+ /// v2: item category — `"section"` (default, existing heading-level
315
+ /// items), `"requirement"`, `"visual"`, `"regression"`, `"manual"`
316
+ /// (free-extensible, not an enforced enum).
317
+ #[serde(default = "default_category")]
318
+ pub category: String,
319
+ /// v2: individual requirements tracked within a `category="section"`
320
+ /// item.
321
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
322
+ pub sub_items: Vec<SubItem>,
323
+ /// v2: label for a freeform item (`fragment_seq: None`).
324
+ #[serde(default, skip_serializing_if = "Option::is_none")]
325
+ pub label: Option<String>,
326
+ }
327
+
328
+ fn default_category() -> String {
329
+ "section".to_string()
330
+ }
331
+
332
+ /// v2 (wiki/140-verification-matrix.md §7.1): one individual requirement
333
+ /// tracked within a section-level `VerificationItem::sub_items`.
334
+ #[derive(Debug, Clone, Serialize, Deserialize)]
335
+ pub struct SubItem {
336
+ /// 0-based position within the parent item's `sub_items`.
337
+ pub index: usize,
338
+ pub description: String,
339
+ /// "pending" | "skipped" | "verified".
340
+ pub status: String,
341
+ #[serde(default, skip_serializing_if = "Option::is_none")]
342
+ pub reviewer: Option<String>,
343
+ #[serde(default, skip_serializing_if = "Option::is_none")]
344
+ pub verified_at: Option<String>,
345
+ #[serde(default)]
346
+ pub notes: String,
347
+ /// "requirement" (default) | "visual" | "manual" | ... (free-extensible).
348
+ #[serde(default = "default_sub_category")]
349
+ pub category: String,
350
+ }
351
+
352
+ fn default_sub_category() -> String {
353
+ "requirement".to_string()
276
354
  }
277
355
 
278
356
  /// A reference to a source code location.
@@ -494,7 +572,7 @@ mod tests {
494
572
  created_at: "2026-07-11T10:00:00Z".to_string(),
495
573
  updated_at: "2026-07-11T14:30:00Z".to_string(),
496
574
  items: vec![VerificationItem {
497
- fragment_seq: 2,
575
+ fragment_seq: Some(2),
498
576
  heading: "1. 課題".to_string(),
499
577
  status: "verified".to_string(),
500
578
  impl_refs: vec![CodeRef {
@@ -511,6 +589,9 @@ mod tests {
511
589
  verified_at: Some("2026-07-11T14:30:00Z".to_string()),
512
590
  notes: String::new(),
513
591
  content_hash_at_verify: Some("abc123".to_string()),
592
+ category: "section".to_string(),
593
+ sub_items: Vec::new(),
594
+ label: None,
514
595
  }],
515
596
  });
516
597
 
@@ -519,7 +600,7 @@ mod tests {
519
600
  let v = back.verification.expect("verification must round-trip");
520
601
  assert_eq!(v.status, "in_review");
521
602
  assert_eq!(v.items.len(), 1);
522
- assert_eq!(v.items[0].fragment_seq, 2);
603
+ assert_eq!(v.items[0].fragment_seq, Some(2));
523
604
  assert_eq!(v.items[0].impl_refs[0].path, "src/storage/docs/mod.rs");
524
605
  assert_eq!(
525
606
  v.items[0].test_refs[0].label.as_deref(),
@@ -527,6 +608,96 @@ mod tests {
527
608
  );
528
609
  }
529
610
 
611
+ /// wiki/140-verification-matrix.md §7.1 (v2 extension): a v1
612
+ /// `VerificationItem` (plain-number `fragment_seq`, no `category` /
613
+ /// `sub_items` / `label`) must still deserialize, defaulting
614
+ /// `category` to `"section"`, `sub_items` to empty, and `label` to
615
+ /// `None` — v1 behavior is fully preserved.
616
+ #[test]
617
+ fn verification_item_v1_json_deserializes_with_v2_defaults() {
618
+ let v1_item = serde_json::json!({
619
+ "fragment_seq": 2,
620
+ "heading": "1. 課題",
621
+ "status": "verified",
622
+ "reviewer": "ai",
623
+ "verified_at": "2026-07-11T14:30:00Z",
624
+ });
625
+ let item: VerificationItem = serde_json::from_value(v1_item).unwrap();
626
+ assert_eq!(item.fragment_seq, Some(2));
627
+ assert_eq!(item.category, "section");
628
+ assert!(item.sub_items.is_empty());
629
+ assert!(item.label.is_none());
630
+ }
631
+
632
+ #[test]
633
+ fn sub_item_defaults_category_to_requirement() {
634
+ let json = serde_json::json!({
635
+ "index": 0,
636
+ "description": "形状=八面体であること",
637
+ "status": "pending",
638
+ });
639
+ let sub: SubItem = serde_json::from_value(json).unwrap();
640
+ assert_eq!(sub.category, "requirement");
641
+ assert!(sub.notes.is_empty());
642
+ assert!(sub.reviewer.is_none());
643
+ }
644
+
645
+ #[test]
646
+ fn verification_item_supports_freeform_fragment_seq_none() {
647
+ let item = VerificationItem {
648
+ fragment_seq: None,
649
+ heading: "ドラッグ操作の目視確認".to_string(),
650
+ status: "pending".to_string(),
651
+ impl_refs: Vec::new(),
652
+ test_refs: Vec::new(),
653
+ reviewer: None,
654
+ verified_at: None,
655
+ notes: String::new(),
656
+ content_hash_at_verify: None,
657
+ category: "visual".to_string(),
658
+ sub_items: Vec::new(),
659
+ label: Some("ドラッグ操作の目視確認".to_string()),
660
+ };
661
+ let json = serde_json::to_string(&item).unwrap();
662
+ let back: VerificationItem = serde_json::from_str(&json).unwrap();
663
+ assert!(back.fragment_seq.is_none());
664
+ assert_eq!(back.label.as_deref(), Some("ドラッグ操作の目視確認"));
665
+ assert_eq!(back.category, "visual");
666
+ }
667
+
668
+ #[test]
669
+ fn verification_item_sub_items_round_trip() {
670
+ let mut item = VerificationItem {
671
+ fragment_seq: Some(2),
672
+ heading: "1. 課題".to_string(),
673
+ status: "in_review".to_string(),
674
+ impl_refs: Vec::new(),
675
+ test_refs: Vec::new(),
676
+ reviewer: None,
677
+ verified_at: None,
678
+ notes: String::new(),
679
+ content_hash_at_verify: None,
680
+ category: "section".to_string(),
681
+ sub_items: Vec::new(),
682
+ label: None,
683
+ };
684
+ item.sub_items.push(SubItem {
685
+ index: 0,
686
+ description: "形状=八面体であること".to_string(),
687
+ status: "verified".to_string(),
688
+ reviewer: Some("ai".to_string()),
689
+ verified_at: Some("2026-07-11T14:30:00Z".to_string()),
690
+ notes: String::new(),
691
+ category: "requirement".to_string(),
692
+ });
693
+
694
+ let json = serde_json::to_string(&item).unwrap();
695
+ let back: VerificationItem = serde_json::from_str(&json).unwrap();
696
+ assert_eq!(back.sub_items.len(), 1);
697
+ assert_eq!(back.sub_items[0].description, "形状=八面体であること");
698
+ assert_eq!(back.sub_items[0].status, "verified");
699
+ }
700
+
530
701
  #[test]
531
702
  fn valid_constants_contain_spec_values() {
532
703
  assert!(VALID_DOC_TYPES.contains(&"spec"));
@@ -152,6 +152,7 @@ mod tests {
152
152
  "lesson".to_string(),
153
153
  vec![],
154
154
  vec![],
155
+ vec![],
155
156
  now_rfc3339(),
156
157
  )
157
158
  }
@@ -2,7 +2,9 @@ use serde::{Deserialize, Serialize};
2
2
 
3
3
  /// Current memory schema version. Bump when `MemoryEntry` changes shape in a way
4
4
  /// that needs migration handling on read.
5
- pub const MEMORY_SCHEMA_VERSION: u32 = 1;
5
+ ///
6
+ /// v1 → v2: added `keywords` field (defaults to empty on read).
7
+ pub const MEMORY_SCHEMA_VERSION: u32 = 2;
6
8
 
7
9
  /// Valid `kind` values for a memory. Free-form text is rejected so the field
8
10
  /// stays a small, queryable enumeration.
@@ -32,6 +34,13 @@ pub struct MemoryEntry {
32
34
  /// Free-form tags; also fed into the similarity index.
33
35
  #[serde(default)]
34
36
  pub tags: Vec<String>,
37
+ /// Subject keywords — nouns, technical terms, proper nouns that identify what
38
+ /// this memory is *about*. Used for BM25 matching with boosted weight.
39
+ /// Distinct from `tags` (classification labels) and `scope_paths` (file
40
+ /// prefixes). Populated by the AI at save time; defaults to empty for v1
41
+ /// memories.
42
+ #[serde(default)]
43
+ pub keywords: Vec<String>,
35
44
  /// Path prefixes this memory applies to (e.g. `src/storage/`). A query whose
36
45
  /// file paths start with one of these gets a relevance boost.
37
46
  #[serde(default)]
@@ -64,6 +73,7 @@ impl MemoryEntry {
64
73
  text: String,
65
74
  kind: String,
66
75
  tags: Vec<String>,
76
+ keywords: Vec<String>,
67
77
  scope_paths: Vec<String>,
68
78
  now: String,
69
79
  ) -> Self {
@@ -74,6 +84,7 @@ impl MemoryEntry {
74
84
  text,
75
85
  kind,
76
86
  tags,
87
+ keywords,
77
88
  scope_paths,
78
89
  content_hash,
79
90
  created_at: now.clone(),
@@ -84,13 +95,33 @@ impl MemoryEntry {
84
95
  }
85
96
  }
86
97
 
87
- /// The text used for similarity: body plus tags (tags carry intent that may
88
- /// not appear verbatim in the body).
98
+ /// Plain-text index for Jaccard similarity: body + tags + keywords.
89
99
  pub fn index_text(&self) -> String {
90
- if self.tags.is_empty() {
91
- self.text.clone()
92
- } else {
93
- format!("{} {}", self.text, self.tags.join(" "))
100
+ let mut parts = vec![self.text.clone()];
101
+ if !self.tags.is_empty() {
102
+ parts.push(self.tags.join(" "));
103
+ }
104
+ if !self.keywords.is_empty() {
105
+ parts.push(self.keywords.join(" "));
106
+ }
107
+ parts.join(" ")
108
+ }
109
+
110
+ /// Weighted tokens for `Corpus::build_weighted_tokens`. Body + tags are
111
+ /// tokenized at default weight; keywords are added with weight 2.0
112
+ /// (doc-side TF boost), replacing the old 2x-concatenation hack.
113
+ pub fn index_weighted_tokens(&self) -> Vec<lexsim::WeightedToken> {
114
+ let mut base = vec![self.text.clone()];
115
+ if !self.tags.is_empty() {
116
+ base.push(self.tags.join(" "));
117
+ }
118
+ let mut tokens = lexsim::tokenize_weighted(&base.join(" "));
119
+ for kw in &self.keywords {
120
+ for mut wt in lexsim::tokenize_weighted(kw) {
121
+ wt.weight *= 2.0;
122
+ tokens.push(wt);
123
+ }
94
124
  }
125
+ tokens
95
126
  }
96
127
  }
@@ -384,18 +384,37 @@ pub fn read_latest_closed_session(sessions_dir: &Path) -> Result<Option<SessionD
384
384
  Ok(sessions.into_iter().last())
385
385
  }
386
386
 
387
- fn apply_session_updates(data: &mut SessionData, updates: &SessionData) {
387
+ fn apply_session_updates(
388
+ data: &mut SessionData,
389
+ updates: &SessionData,
390
+ provided_keys: Option<&Value>,
391
+ ) {
388
392
  data.summary = updates.summary.clone();
389
393
  data.ended_at = updates.ended_at.clone();
390
394
  data.branch = updates.branch.clone();
391
395
  data.commit = updates.commit.clone();
392
396
  data.dirty_files = updates.dirty_files.clone();
393
- data.decisions = updates.decisions.clone();
394
- data.blockers = updates.blockers.clone();
395
- data.checklist = updates.checklist.clone();
396
- data.handoff_notes = updates.handoff_notes.clone();
397
- data.references = updates.references.clone();
398
- data.context_pointers = updates.context_pointers.clone();
397
+
398
+ let was_provided = |key: &str| provided_keys.and_then(|v| v.get(key)).is_some();
399
+
400
+ if was_provided("decisions") {
401
+ data.decisions = updates.decisions.clone();
402
+ }
403
+ if was_provided("blockers") {
404
+ data.blockers = updates.blockers.clone();
405
+ }
406
+ if was_provided("checklist") {
407
+ data.checklist = updates.checklist.clone();
408
+ }
409
+ if was_provided("handoff_notes") {
410
+ data.handoff_notes = updates.handoff_notes.clone();
411
+ }
412
+ if was_provided("references") {
413
+ data.references = updates.references.clone();
414
+ }
415
+ if was_provided("context_pointers") {
416
+ data.context_pointers = updates.context_pointers.clone();
417
+ }
399
418
  if updates.environment.is_some() {
400
419
  data.environment = updates.environment.clone();
401
420
  }
@@ -405,7 +424,7 @@ fn apply_session_updates(data: &mut SessionData, updates: &SessionData) {
405
424
  if updates.label.is_some() {
406
425
  data.label = updates.label.clone();
407
426
  }
408
- if !updates.related_task_ids.is_empty() {
427
+ if was_provided("related_task_ids") && !updates.related_task_ids.is_empty() {
409
428
  data.related_task_ids = updates.related_task_ids.clone();
410
429
  }
411
430
  }
@@ -415,6 +434,7 @@ fn find_and_update_active_session(
415
434
  session_id: &str,
416
435
  updates: &SessionData,
417
436
  transition_to: Option<&str>,
437
+ provided_keys: Option<&Value>,
418
438
  ) -> Result<Option<PathBuf>> {
419
439
  let suffix = ".active.json";
420
440
 
@@ -464,7 +484,7 @@ fn find_and_update_active_session(
464
484
  let mut data: SessionData = serde_json::from_str(&content)
465
485
  .with_context(|| format!("Failed to parse session: {}", path.display()))?;
466
486
 
467
- apply_session_updates(&mut data, updates);
487
+ apply_session_updates(&mut data, updates, provided_keys);
468
488
 
469
489
  let updated_content =
470
490
  serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
@@ -495,16 +515,24 @@ pub fn update_and_close_active_session(
495
515
  sessions_dir: &Path,
496
516
  session_id: &str,
497
517
  updates: &SessionData,
518
+ provided_keys: Option<&Value>,
498
519
  ) -> Result<Option<PathBuf>> {
499
- find_and_update_active_session(sessions_dir, session_id, updates, Some("closed"))
520
+ find_and_update_active_session(
521
+ sessions_dir,
522
+ session_id,
523
+ updates,
524
+ Some("closed"),
525
+ provided_keys,
526
+ )
500
527
  }
501
528
 
502
529
  pub fn update_active_session(
503
530
  sessions_dir: &Path,
504
531
  session_id: &str,
505
532
  updates: &SessionData,
533
+ provided_keys: Option<&Value>,
506
534
  ) -> Result<Option<PathBuf>> {
507
- find_and_update_active_session(sessions_dir, session_id, updates, None)
535
+ find_and_update_active_session(sessions_dir, session_id, updates, None, provided_keys)
508
536
  }
509
537
 
510
538
  pub fn read_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<SessionData>> {