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.
- package/Cargo.lock +29 -3
- package/Cargo.toml +4 -2
- package/README.md +23 -9
- package/package.json +1 -1
- package/skills/handoff-docs/SKILL.md +181 -9
- package/src/context/injection.rs +55 -18
- package/src/context/mod.rs +1 -1
- package/src/mcp/handlers/auto_schedule.rs +1 -13
- package/src/mcp/handlers/capacity.rs +1 -14
- package/src/mcp/handlers/config.rs +13 -0
- package/src/mcp/handlers/docs.rs +832 -76
- package/src/mcp/handlers/docs_query.rs +4 -4
- package/src/mcp/handlers/memory.rs +88 -13
- package/src/mcp/handlers/mod.rs +1 -0
- package/src/mcp/handlers/save_context.rs +8 -3
- package/src/mcp/handlers/task_checklist.rs +17 -5
- package/src/mcp/handlers/update_session.rs +6 -4
- package/src/mcp/tools.rs +28 -7
- package/src/storage/config.rs +217 -5
- package/src/storage/docs/frontmatter.rs +509 -0
- package/src/storage/docs/mod.rs +346 -108
- package/src/storage/docs/model.rs +186 -15
- package/src/storage/memory/mod.rs +1 -0
- package/src/storage/memory/model.rs +38 -7
- package/src/storage/sessions.rs +39 -11
|
@@ -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
|
-
///
|
|
183
|
-
///
|
|
184
|
-
///
|
|
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
|
-
///
|
|
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
|
-
|
|
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"));
|
|
@@ -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
|
-
|
|
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
|
-
///
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
}
|
package/src/storage/sessions.rs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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(
|
|
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>> {
|