1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::core::knowledge::ProjectKnowledge;
8use crate::core::protocol::ToolCallRecord;
9use crate::core::session::SessionState;
10use crate::core::workflow::WorkflowRun;
11
12#[path = "handoff_ledger_authority.rs"]
13mod authority;
14#[path = "handoff_ledger_merge.rs"]
15mod merge;
16
17pub use authority::LedgerPart;
18use authority::{BoundEntry, CURATED_TOOL, INCONSISTENT_CODE, LedgerAuthority, PartKind};
19pub use merge::{MAX_MERGE_INPUTS, MergeOutcome, merge_ledgers};
20
21pub(crate) const SCHEMA_VERSION: u32 = crate::core::contracts::HANDOFF_LEDGER_V1_SCHEMA_VERSION;
22const MAX_KNOWLEDGE_FACTS: usize = 50;
23const MAX_CURATED_REFS: usize = 20;
24const MAX_LEDGER_FINDINGS: usize = 20;
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27pub struct HandoffLedgerV1 {
28 pub schema_version: u32,
29 pub created_at: String,
30 pub content_md5: String,
31 pub manifest_md5: String,
32 pub project_root: Option<String>,
33 pub agent_id: Option<String>,
34 pub client_name: Option<String>,
35 pub workflow: Option<WorkflowRun>,
36 pub session_snapshot: String,
37 pub session: SessionExcerpt,
38 pub tool_calls: ToolCallsSummary,
39 pub evidence_keys: Vec<String>,
40 pub knowledge: KnowledgeExcerpt,
41 pub curated_refs: Vec<CuratedRef>,
42 #[serde(default)]
43 pub active_overlays: Vec<serde_json::Value>,
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub merged_from: Vec<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub request_id: Option<String>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub request_digest: Option<String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59pub struct SessionExcerpt {
60 pub id: String,
61 pub task: Option<String>,
62 pub decisions: Vec<String>,
63 pub findings: Vec<String>,
64 pub next_steps: Vec<String>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, Default)]
68pub struct ToolCallsSummary {
69 pub total: usize,
70 pub by_tool: BTreeMap<String, u64>,
71 pub by_ctx_read_mode: BTreeMap<String, u64>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, Default)]
75pub struct KnowledgeExcerpt {
76 pub project_hash: Option<String>,
77 pub facts: Vec<KnowledgeFactMini>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct KnowledgeFactMini {
82 pub category: String,
83 pub key: String,
84 pub value: String,
85 pub confidence: f32,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct CuratedRef {
90 pub path: String,
91 pub mode: String,
92 pub content_md5: String,
93 pub content: String,
94}
95
96#[derive(Debug, Clone)]
97pub struct CreateLedgerInput {
98 pub agent_id: Option<String>,
99 pub client_name: Option<String>,
100 pub project_root: Option<String>,
101 pub session: SessionState,
102 pub tool_calls: Vec<ToolCallRecord>,
103 pub workflow: Option<WorkflowRun>,
104 pub curated_refs: Vec<(String, String)>, pub request_id: Option<String>,
107}
108
109const ORIGIN_UNCHECKED: &str = "handoff";
129const ORIGIN_ALL_BOUND: &str = "handoff:all-bound";
130const ORIGIN_ORIGINLESS_PART: &str = "handoff:originless-part";
131
132struct CopiedParts {
134 enforced: bool,
136 project: Option<String>,
138 sources: Vec<crate::core::context_admission::stored::SourceRef>,
140}
141
142impl CopiedParts {
143 fn new(project_root: Option<&str>, enforced: bool) -> Self {
144 let project = project_root.filter(|root| !root.is_empty()).map(|root| {
145 crate::core::pathutil::canonicalize_secure(Path::new(root))
146 .ok()
147 .and_then(|path| path.to_str().map(str::to_owned))
148 .unwrap_or_else(|| root.to_owned())
149 });
150 Self {
151 enforced,
152 project,
153 sources: Vec::new(),
154 }
155 }
156
157 fn admit(
175 &mut self,
176 names: &[crate::core::archive::bindings::Item<'_>],
177 part: &LedgerPart<'_>,
178 reader: Option<&str>,
179 ) -> Option<bool> {
180 if !self.enforced {
181 return Some(false);
182 }
183 let mut held = Vec::new();
184 for item in names {
185 match crate::core::archive::bindings::held(item, part.text(), reader) {
186 Ok(Some((project, sources))) if self.in_project(&project) => held.extend(sources),
187 Ok(None) => {}
188 Ok(Some(_)) | Err(_) => return None,
189 }
190 }
191 let bound = !held.is_empty();
192 self.sources.extend(held);
193 Some(bound)
194 }
195
196 fn in_project(&self, project: &str) -> bool {
197 self.project.as_deref() == Some(project)
198 }
199}
200
201pub fn create_ledger(input: CreateLedgerInput) -> Result<(HandoffLedgerV1, PathBuf), String> {
211 let CreateLedgerInput {
212 agent_id,
213 client_name,
214 project_root,
215 mut session,
216 tool_calls,
217 workflow,
218 curated_refs,
219 request_id,
220 } = input;
221 let (_keyed_create, request_digest) = match request_id.as_deref() {
225 Some(key) => {
226 crate::core::agents::check_request_id(key)?;
227 let Some(root) = project_root.as_deref() else {
228 return Err("request_id requires a project".to_string());
229 };
230 let lock = keyed_create_lock(root, agent_id.as_deref())?;
231 let paths: Vec<&str> = curated_refs
232 .iter()
233 .take(MAX_CURATED_REFS)
234 .map(|(path, _)| path.as_str())
235 .collect();
236 let digest = crate::core::agents::request_digest(
237 "leanctx/handoff-create/v1",
238 &serde_json::json!(paths),
239 );
240 if let Some(path) = keyed_ledger(root, agent_id.as_deref(), key, &digest)? {
241 return load_ledger(&path).map(|ledger| (ledger, path));
242 }
243 #[cfg(test)]
244 keyed_race::pause();
245 (Some(lock), Some(digest))
246 }
247 None => (None, None),
248 };
249 let manifest_md5 = manifest_md5();
250
251 let mut evidence_keys: BTreeSet<String> = BTreeSet::new();
252 for ev in &session.evidence {
253 evidence_keys.insert(ev.key.clone());
254 }
255
256 let mut by_tool: BTreeMap<String, u64> = BTreeMap::new();
257 let mut by_mode: BTreeMap<String, u64> = BTreeMap::new();
258 for call in &tool_calls {
259 *by_tool.entry(call.tool.clone()).or_insert(0) += 1;
260 if call.tool == "ctx_read"
261 && let Some(m) = call.mode.as_deref()
262 {
263 *by_mode.entry(m.to_string()).or_insert(0) += 1;
264 }
265 }
266
267 let authority_required = crate::core::context_admission::stored::source_authority_required();
278 let mut parts = CopiedParts::new(project_root.as_deref(), authority_required);
279 let (session_id, session_root) = (session.id.clone(), session.project_root.clone());
280 let cut = session.findings.len().saturating_sub(MAX_LEDGER_FINDINGS);
281 session.findings.drain(..cut);
282 let mut finding_bound: Vec<bool> = Vec::new();
283 session.findings.retain(|finding| {
284 let item = crate::core::archive::bindings::Item::SessionFinding {
285 session: &session_id,
286 file: finding.file.as_deref(),
287 };
288 let part = LedgerPart::Finding(&finding.summary);
289 let copied = parts.admit(&[item], &part, session_root.as_deref());
290 finding_bound.extend(copied);
291 copied.is_some()
292 });
293
294 let session_excerpt = SessionExcerpt {
295 id: session.id.clone(),
296 task: session.task.as_ref().map(|t| t.description.clone()),
297 decisions: session
298 .decisions
299 .iter()
300 .rev()
301 .take(10)
302 .map(|d| d.summary.clone())
303 .collect::<Vec<_>>()
304 .into_iter()
305 .rev()
306 .collect(),
307 findings: session.findings.iter().map(|f| f.summary.clone()).collect(),
308 next_steps: session.next_steps.iter().take(20).cloned().collect(),
309 };
310
311 let (knowledge_excerpt, fact_bound) =
312 build_knowledge_excerpt(project_root.as_deref(), &mut parts);
313
314 let mut sources = Vec::new();
321 let mut curated = Vec::new();
322 for (p, text) in curated_refs.into_iter().take(MAX_CURATED_REFS) {
323 let bound = project_root.as_deref().and_then(|root| {
324 crate::core::context_admission::stored::SourceRef::read(&p, root, CURATED_TOOL).ok()
325 });
326 match bound {
327 Some((_, source)) => sources.push(source),
328 None if authority_required => continue,
329 None => {}
330 }
331 let md5 = crate::core::hasher::hash_hex(text.as_bytes());
332 curated.push(CuratedRef {
333 path: p,
334 mode: "signatures".to_string(),
335 content_md5: md5,
336 content: text,
337 });
338 }
339 sources.extend(parts.sources);
340
341 let mut ledger = HandoffLedgerV1 {
342 schema_version: SCHEMA_VERSION,
343 created_at: chrono::Local::now().to_rfc3339(),
344 content_md5: String::new(),
345 manifest_md5,
346 project_root,
347 agent_id,
348 client_name,
349 workflow,
350 session_snapshot: session.build_compaction_snapshot(),
351 session: session_excerpt,
352 tool_calls: ToolCallsSummary {
353 total: tool_calls.len(),
354 by_tool,
355 by_ctx_read_mode: by_mode,
356 },
357 evidence_keys: evidence_keys.into_iter().collect(),
358 knowledge: knowledge_excerpt,
359 curated_refs: curated,
360 active_overlays: {
361 let overlay_store = crate::core::context_overlay::OverlayStore::load_project(
362 &std::env::current_dir().unwrap_or_default(),
363 );
364 overlay_store
365 .all()
366 .iter()
367 .filter_map(|o| serde_json::to_value(o).ok())
368 .collect()
369 },
370 merged_from: Vec::new(),
371 request_id,
372 request_digest,
373 };
374
375 let md5 = ledger_content_md5(&ledger);
376 ledger.content_md5.clone_from(&md5);
377
378 let path = persist_ledger(&ledger, sources, |kind, index| {
379 let flags = match kind {
380 PartKind::Finding => &finding_bound,
381 PartKind::Fact => &fact_bound,
382 };
383 flags.get(index).copied().unwrap_or(false)
384 })?;
385 Ok((ledger, path))
386}
387
388fn persist_ledger(
392 ledger: &HandoffLedgerV1,
393 sources: Vec<crate::core::context_admission::stored::SourceRef>,
394 bound: impl Fn(PartKind, usize) -> bool,
395) -> Result<PathBuf, String> {
396 let authority_required = crate::core::context_admission::stored::source_authority_required();
397 let path = ledger_path(&ledger.created_at, &ledger.content_md5)?;
398 let json = serde_json::to_string_pretty(ledger).map_err(|e| format!("serialize: {e}"))?;
399 let bound_parts = authority_required.then(|| ledger.bound_record(&bound));
406 let origin = bound_parts
407 .as_ref()
408 .map_or(ORIGIN_UNCHECKED, |record| ledger.checked_origin(record));
409 let text = ledger_text(&json)?;
410 let binding = admit_ledger(&text, ledger.project_root.as_deref(), sources, origin)?;
411 if authority_required {
416 serve_ledger(Some(&binding), &text, request_project().as_deref())?;
417 }
418 crate::config_io::write_atomic_with_backup(&path, &(json + "\n"))
419 .map_err(|e| format!("write {}: {e}", path.display()))?;
420 let authority = LedgerAuthority {
421 binding,
422 bound_parts,
423 };
424 let authority = serde_json::to_string(&authority).map_err(|e| format!("serialize: {e}"))?;
425 if let Err(error) =
426 crate::config_io::write_atomic_with_backup(&authority_path(&path), &authority)
427 {
428 let _ = std::fs::remove_file(&path);
431 return Err(format!("write {}: {error}", path.display()));
432 }
433 Ok(path)
434}
435
436fn authority_path(ledger: &Path) -> PathBuf {
440 ledger.with_extension("authority")
441}
442
443fn ledger_text(json: &str) -> Result<String, String> {
447 fn collect<'a>(value: &'a Value, leaves: &mut Vec<&'a str>) {
448 match value {
449 Value::String(text) if !text.is_empty() => leaves.push(text),
450 Value::Array(items) => items.iter().for_each(|item| collect(item, leaves)),
451 Value::Object(map) => map.values().for_each(|child| collect(child, leaves)),
452 _ => {}
453 }
454 }
455 let value: Value = serde_json::from_str(json).map_err(|e| format!("parse ledger: {e}"))?;
456 let mut leaves = Vec::new();
457 collect(&value, &mut leaves);
458 Ok(leaves.join("\n"))
459}
460
461fn admit_ledger(
462 json: &str,
463 project_root: Option<&str>,
464 sources: Vec<crate::core::context_admission::stored::SourceRef>,
465 origin_marker: &str,
466) -> Result<crate::core::context_admission::stored::StoredBinding, String> {
467 use crate::core::context_admission::carrier::Withheld;
468 use crate::core::context_admission::stored::{Lineage, admit_and_bind};
469 let refused = |withheld: Withheld| withheld.message("handoff ledger");
470 let lineage = Lineage::of(sources).map_err(refused)?;
471 let (admitted, binding) =
472 admit_and_bind(json, origin_marker, project_root, lineage).map_err(refused)?;
473 if admitted != json {
474 return Err(refused(Withheld::new(&["policy.content_redacted"])));
475 }
476 Ok(binding)
477}
478
479fn request_project() -> Option<String> {
481 crate::core::policy::runtime::REQUEST_PROJECT
482 .try_with(|slot| slot.borrow().clone())
483 .ok()
484 .flatten()
485 .and_then(|project| project.to_str().map(str::to_owned))
486}
487
488fn serve_ledger(
500 binding: Option<&crate::core::context_admission::stored::StoredBinding>,
501 text: &str,
502 reader: Option<&str>,
503) -> Result<(), String> {
504 use crate::core::context_admission::carrier::Withheld;
505 use crate::core::context_admission::stored::serve_stored;
506 let served = serve_stored(text, binding, reader)
507 .map_err(|withheld| withheld.message("handoff ledger"))?;
508 if served == text {
509 Ok(())
510 } else {
511 Err(Withheld::new(&["policy.content_redacted"]).message("handoff ledger"))
512 }
513}
514
515fn same_project(stored: Option<&str>, reader: Option<&str>) -> bool {
518 let canonical =
519 |root: &str| crate::core::pathutil::safe_canonicalize_bounded(Path::new(root), 2000);
520 match (stored, reader) {
521 (Some(stored), Some(reader)) => canonical(stored) == canonical(reader),
522 _ => false,
523 }
524}
525
526fn stored_project(path: &Path) -> Option<String> {
528 let json: Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
529 json.get("project_root")?.as_str().map(str::to_owned)
530}
531
532#[cfg(test)]
537pub(crate) mod keyed_race {
538 use std::sync::{Condvar, Mutex, PoisonError};
539 use std::time::{Duration, Instant};
540
541 static STATE: Mutex<(usize, usize)> = Mutex::new((0, 0));
543 static ARRIVAL: Condvar = Condvar::new();
544 const BOUND: Duration = Duration::from_millis(500);
545
546 pub(crate) fn arm(expected: usize) -> impl Drop {
548 struct Disarm;
549 impl Drop for Disarm {
550 fn drop(&mut self) {
551 *STATE.lock().unwrap_or_else(PoisonError::into_inner) = (0, 0);
552 }
553 }
554 *STATE.lock().unwrap_or_else(PoisonError::into_inner) = (expected, 0);
555 Disarm
556 }
557
558 pub(super) fn pause() {
559 let mut state = STATE.lock().unwrap_or_else(PoisonError::into_inner);
560 if state.0 == 0 {
561 return;
562 }
563 state.1 += 1;
564 ARRIVAL.notify_all();
565 let deadline = Instant::now() + BOUND;
566 while state.1 < state.0 {
567 let left = deadline.saturating_duration_since(Instant::now());
568 if left.is_zero() {
569 break;
570 }
571 state = ARRIVAL
572 .wait_timeout(state, left)
573 .unwrap_or_else(PoisonError::into_inner)
574 .0;
575 }
576 }
577}
578
579fn keyed_create_lock(
583 project_root: &str,
584 agent_id: Option<&str>,
585) -> Result<crate::core::agents::FileLock, String> {
586 const WAIT: std::time::Duration = std::time::Duration::from_secs(10);
587 let dir = handoffs_dir()?;
588 std::fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all {}: {e}", dir.display()))?;
589 let scope = serde_json::json!([project_root, agent_id]).to_string();
590 let name = crate::core::hasher::hash_hex(scope.as_bytes());
591 crate::core::agents::FileLock::acquire_with_timeout(
592 &dir.join(format!("keyed-{name}.lock")),
593 WAIT,
594 )
595 .map_err(|error| format!("handoff create is busy: {error}"))
596}
597
598fn keyed_ledger(
602 project_root: &str,
603 agent_id: Option<&str>,
604 key: &str,
605 digest: &str,
606) -> Result<Option<PathBuf>, String> {
607 for path in list_ledgers(project_root) {
608 let Some(stored) = std::fs::read_to_string(&path)
609 .ok()
610 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
611 else {
612 continue;
613 };
614 if stored["request_id"].as_str() != Some(key) || stored["agent_id"].as_str() != agent_id {
615 continue;
616 }
617 return if stored["request_digest"].as_str() == Some(digest) {
618 Ok(Some(path))
619 } else {
620 Err(crate::core::agents::request_id_conflict())
621 };
622 }
623 Ok(None)
624}
625
626pub fn list_ledgers(project_root: &str) -> Vec<PathBuf> {
629 let dir = handoffs_dir().ok();
630 let Some(dir) = dir else {
631 return Vec::new();
632 };
633 let Ok(rd) = std::fs::read_dir(&dir) else {
634 return Vec::new();
635 };
636 let mut items: Vec<PathBuf> = rd
637 .flatten()
638 .map(|e| e.path())
639 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json"))
640 .filter(|p| same_project(stored_project(p).as_deref(), Some(project_root)))
641 .collect();
642 items.sort();
643 items.reverse();
644 items
645}
646
647pub struct LedgerLineage {
653 binding: crate::core::context_admission::stored::StoredBinding,
654 bound: Vec<BoundEntry>,
657 text: String,
659 reader: Option<String>,
660}
661
662impl LedgerLineage {
663 pub fn carry(
683 &self,
684 part: &LedgerPart<'_>,
685 index: usize,
686 derived: &crate::core::archive::bindings::Item<'_>,
687 ) -> Result<
688 Option<crate::core::archive::bindings::Carried>,
689 crate::core::context_admission::carrier::Withheld,
690 > {
691 let recorded = self
692 .bound
693 .iter()
694 .find(|entry| entry.kind == part.kind() && entry.index == index);
695 let Some(recorded) = recorded else {
696 return Ok(None);
697 };
698 if recorded.digest != part.digest() {
699 return Err(crate::core::context_admission::carrier::Withheld::new(&[
700 INCONSISTENT_CODE,
701 ]));
702 }
703 crate::core::archive::bindings::carry_from(
704 derived,
705 part.text(),
706 &self.binding,
707 &self.text,
708 self.reader.as_deref(),
709 )
710 }
711}
712
713pub fn load_ledger(path: &Path) -> Result<HandoffLedgerV1, String> {
715 load_ledger_bound(path).map(|(ledger, _)| ledger)
716}
717
718pub fn format_show(path: &Path, ledger: &HandoffLedgerV1) -> String {
720 let mut out = serde_json::to_string_pretty(ledger).unwrap_or_else(|_| "{}".to_string());
721 out.push('\n');
722 format!("ctx_handoff show\n path: {}\n{}", path.display(), out)
723}
724
725pub fn load_ledger_bound(path: &Path) -> Result<(HandoffLedgerV1, Option<LedgerLineage>), String> {
734 load_served(path).map(|served| (served.ledger, served.lineage))
735}
736
737struct ServedLedger {
741 ledger: HandoffLedgerV1,
742 lineage: Option<LedgerLineage>,
743 files: Option<(String, Vec<(String, String, String)>)>,
744}
745
746fn load_served(path: &Path) -> Result<ServedLedger, String> {
747 use crate::core::context_admission::carrier::Withheld;
748 use crate::core::context_admission::stored;
749 let reader = request_project();
753 let not_found = || format!("handoff ledger {} not found", path.display());
754 let s = std::fs::read_to_string(path).map_err(|_| not_found())?;
755 let stored: Value = serde_json::from_str(&s).map_err(|_| not_found())?;
756 if !same_project(stored["project_root"].as_str(), reader.as_deref()) {
757 return Err(not_found());
758 }
759 let text = ledger_text(&s)?;
760 let authority: Option<LedgerAuthority> = std::fs::read_to_string(authority_path(path))
763 .ok()
764 .and_then(|authority| serde_json::from_str(&authority).ok());
765 let binding = authority.as_ref().map(|authority| &authority.binding);
766 let ledger: HandoffLedgerV1 = match serde_json::from_str(&s) {
767 Ok(ledger) => ledger,
768 Err(e) => {
769 serve_ledger(binding, &text, reader.as_deref())?;
772 return Err(format!("parse {}: {e}", path.display()));
773 }
774 };
775 let checked = authority
781 .as_ref()
782 .filter(|authority| checked_at_creation(authority.binding.origin()));
783 if checked.is_some_and(|authority| !ledger.agrees_with(authority)) {
784 return Err(Withheld::new(&[INCONSISTENT_CODE]).message("handoff ledger"));
785 }
786 serve_ledger(binding, &text, reader.as_deref())?;
787 let unchecked = binding.is_some_and(|binding| !checked_at_creation(binding.origin()));
795 if unchecked && ledger.has_copied_parts() && stored::source_authority_required() {
796 return Err(Withheld::no_source_authority().message("handoff ledger"));
797 }
798 stored::publish_served(binding.filter(|binding| ledger.has_file_lineage(binding.origin())));
803 let files = binding
804 .and_then(|binding| binding.file_sources())
805 .map(|(project, files)| {
806 let files = files
807 .into_iter()
808 .map(|(path, tool, digest)| (path.to_owned(), tool.to_owned(), digest.to_owned()))
809 .collect();
810 (project.to_owned(), files)
811 });
812 let lineage = authority
815 .filter(|authority| checked_at_creation(authority.binding.origin()))
816 .and_then(|authority| {
817 let record = authority.bound_parts.filter(|record| record.count > 0)?;
818 Some(LedgerLineage {
819 binding: authority.binding,
820 bound: record.entries,
821 text,
822 reader,
823 })
824 });
825 Ok(ServedLedger {
826 ledger,
827 lineage,
828 files,
829 })
830}
831
832fn checked_at_creation(origin: &str) -> bool {
836 origin == ORIGIN_ALL_BOUND || origin == ORIGIN_ORIGINLESS_PART
837}
838
839impl HandoffLedgerV1 {
840 fn has_copied_parts(&self) -> bool {
842 !self.session.findings.is_empty() || !self.knowledge.facts.is_empty()
843 }
844
845 fn has_file_lineage(&self, origin: &str) -> bool {
854 !self.has_unbound_text() && (origin == ORIGIN_ALL_BOUND || !self.has_copied_parts())
855 }
856
857 fn has_unbound_text(&self) -> bool {
862 !(self.session_snapshot.is_empty()
863 && self.session.task.is_none()
864 && self.session.decisions.is_empty()
865 && self.session.next_steps.is_empty()
866 && self.evidence_keys.is_empty()
867 && self.workflow.is_none()
868 && self.active_overlays.is_empty())
869 }
870}
871
872pub(crate) fn compute_content_md5_for_ledger(ledger: &HandoffLedgerV1) -> String {
873 ledger_content_md5(ledger)
874}
875
876pub fn projectless_ledgers() -> Vec<(PathBuf, HandoffLedgerV1)> {
879 let Ok(rd) = handoffs_dir().and_then(|dir| std::fs::read_dir(dir).map_err(|e| e.to_string()))
880 else {
881 return Vec::new();
882 };
883 let mut items: Vec<(PathBuf, HandoffLedgerV1)> = rd
884 .flatten()
885 .map(|e| e.path())
886 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json"))
887 .filter_map(|p| {
888 let ledger: HandoffLedgerV1 =
889 serde_json::from_str(&std::fs::read_to_string(&p).ok()?).ok()?;
890 ledger.project_root.is_none().then_some((p, ledger))
891 })
892 .collect();
893 items.sort_by(|a, b| a.0.cmp(&b.0));
894 items
895}
896
897pub fn adopt_projectless_ledger(path: &Path, project_root: &str) -> Result<PathBuf, String> {
901 let mut ledger: HandoffLedgerV1 = serde_json::from_str(
902 &std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?,
903 )
904 .map_err(|e| format!("parse {}: {e}", path.display()))?;
905 if ledger.project_root.is_some() {
906 return Err("ledger already belongs to a project".into());
907 }
908 ledger.project_root = Some(project_root.to_string());
909 ledger.content_md5 = ledger_content_md5(&ledger);
910 persist_ledger(&ledger, Vec::new(), |_, _| false)
911}
912
913pub fn clear_ledgers(project_root: &str) -> Result<u32, String> {
915 let mut removed = 0u32;
916 for p in list_ledgers(project_root) {
917 if std::fs::remove_file(&p).is_ok() {
918 removed += 1;
919 }
920 let _ = std::fs::remove_file(authority_path(&p));
921 }
922 Ok(removed)
923}
924
925fn build_knowledge_excerpt(
926 project_root: Option<&str>,
927 parts: &mut CopiedParts,
928) -> (KnowledgeExcerpt, Vec<bool>) {
929 use crate::core::archive::bindings::Item;
930 let Some(knowledge) = project_root.and_then(ProjectKnowledge::load) else {
931 return (KnowledgeExcerpt::default(), Vec::new());
932 };
933 let mut bound = Vec::new();
935
936 let request = crate::core::admitted_sources::request_root();
941 let reader = Some(request.as_str()).filter(|root| !root.is_empty());
942 let one_name = reader.is_none_or(|root| root == knowledge.project_root);
943 let mut facts = Vec::new();
944 for f in knowledge.facts.iter().filter(|f| f.is_current()) {
945 let names = [
946 Item::KnowledgeFact {
947 project: &knowledge.project_root,
948 category: &f.category,
949 key: &f.key,
950 },
951 Item::KnowledgeFact {
952 project: &request,
953 category: &f.category,
954 key: &f.key,
955 },
956 ];
957 let names = if one_name { &names[..1] } else { &names[..] };
958 let part = LedgerPart::Fact {
959 category: &f.category,
960 key: &f.key,
961 value: &f.value,
962 };
963 let Some(part_bound) = parts.admit(names, &part, reader) else {
964 continue;
965 };
966 bound.push(part_bound);
967 facts.push(KnowledgeFactMini {
968 category: f.category.clone(),
969 key: f.key.clone(),
970 value: f.value.clone(),
971 confidence: f.confidence,
972 });
973 if facts.len() >= MAX_KNOWLEDGE_FACTS {
974 break;
975 }
976 }
977
978 let excerpt = KnowledgeExcerpt {
979 project_hash: Some(knowledge.project_hash.clone()),
980 facts,
981 };
982 (excerpt, bound)
983}
984
985fn ledger_path(created_at: &str, md5: &str) -> Result<PathBuf, String> {
986 let dir = handoffs_dir()?;
987 std::fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all {}: {e}", dir.display()))?;
988 let ts = created_at
989 .chars()
990 .filter(char::is_ascii_digit)
991 .take(14)
992 .collect::<String>();
993 let name = format!("{ts}-{md5}.json");
994 Ok(dir.join(name))
995}
996
997fn handoffs_dir() -> Result<PathBuf, String> {
998 let dir = crate::core::data_dir::lean_ctx_data_dir()
999 .map_err(|e| e.clone())?
1000 .join("handoffs");
1001 Ok(dir)
1002}
1003
1004fn manifest_md5() -> String {
1005 let v = crate::core::mcp_manifest::manifest_value();
1006 let canon = crate::core::canonical::canonical_value(&v);
1007 crate::core::hasher::hash_hex(canon.to_string().as_bytes())
1008}
1009
1010fn ledger_content_md5(ledger: &HandoffLedgerV1) -> String {
1011 let mut tmp = ledger.clone();
1012 tmp.content_md5.clear();
1013 let v = serde_json::to_value(&tmp).unwrap_or(Value::Null);
1014 let canon = crate::core::canonical::canonical_value(&v);
1015 crate::core::hasher::hash_hex(canon.to_string().as_bytes())
1016}
1017
1018#[derive(Debug, Clone, Serialize, Deserialize)]
1019pub struct HandoffPackage {
1020 pub ledger: HandoffLedgerV1,
1021 pub intent: Option<IntentSnapshot>,
1022 pub context_snapshot: Option<ContextSnapshot>,
1023}
1024
1025#[derive(Debug, Clone, Serialize, Deserialize)]
1026pub struct IntentSnapshot {
1027 pub task_type: String,
1028 pub scope: String,
1029 pub targets: Vec<String>,
1030 pub keywords: Vec<String>,
1031 pub language_hint: Option<String>,
1032 pub urgency: f64,
1033}
1034
1035#[derive(Debug, Clone, Serialize, Deserialize)]
1036pub struct ContextSnapshot {
1037 pub window_size: usize,
1038 pub tokens_used: usize,
1039 pub tokens_saved: usize,
1040 pub files_loaded: Vec<LoadedFileInfo>,
1041}
1042
1043#[derive(Debug, Clone, Serialize, Deserialize)]
1044pub struct LoadedFileInfo {
1045 pub path: String,
1046 pub mode: String,
1047 pub tokens: usize,
1048}
1049
1050impl HandoffPackage {
1051 pub fn build(
1052 ledger: HandoffLedgerV1,
1053 intent: Option<&super::intent_engine::StructuredIntent>,
1054 context: Option<&crate::core::context_ledger::ContextLedger>,
1055 ) -> Self {
1056 let intent_snap = intent.map(|i| IntentSnapshot {
1057 task_type: i.task_type.as_str().to_string(),
1058 scope: match i.scope {
1059 super::intent_engine::IntentScope::SingleFile => "single_file",
1060 super::intent_engine::IntentScope::MultiFile => "multi_file",
1061 super::intent_engine::IntentScope::CrossModule => "cross_module",
1062 super::intent_engine::IntentScope::ProjectWide => "project_wide",
1063 }
1064 .to_string(),
1065 targets: i.targets.clone(),
1066 keywords: i.keywords.clone(),
1067 language_hint: i.language_hint.clone(),
1068 urgency: i.urgency,
1069 });
1070
1071 let ctx_snap = context.map(|c| ContextSnapshot {
1072 window_size: c.window_size,
1073 tokens_used: c.total_tokens_sent,
1074 tokens_saved: c.total_tokens_saved,
1075 files_loaded: c
1076 .entries
1077 .iter()
1078 .map(|e| LoadedFileInfo {
1079 path: e.path.clone(),
1080 mode: e.mode.clone(),
1081 tokens: e.sent_tokens,
1082 })
1083 .collect(),
1084 });
1085
1086 HandoffPackage {
1087 ledger,
1088 intent: intent_snap,
1089 context_snapshot: ctx_snap,
1090 }
1091 }
1092
1093 pub fn format_compact(&self) -> String {
1094 let mut out = String::new();
1095
1096 out.push_str("--- HANDOFF ---\n");
1097 if let Some(ref intent) = self.intent {
1098 out.push_str(&format!(
1099 "TASK: {} (scope: {}, conf: {})\n",
1100 intent.task_type,
1101 intent.scope,
1102 if intent.urgency > 0.5 {
1103 "URGENT"
1104 } else {
1105 "normal"
1106 }
1107 ));
1108 if !intent.targets.is_empty() {
1109 out.push_str(&format!("TARGETS: {}\n", intent.targets.join(", ")));
1110 }
1111 if let Some(ref lang) = intent.language_hint {
1112 out.push_str(&format!("LANG: {lang}\n"));
1113 }
1114 }
1115
1116 if let Some(ref ctx) = self.context_snapshot {
1117 out.push_str(&format!(
1118 "CTX: {}/{} tokens, {} files, saved {}\n",
1119 ctx.tokens_used,
1120 ctx.window_size,
1121 ctx.files_loaded.len(),
1122 ctx.tokens_saved,
1123 ));
1124 }
1125
1126 if !self.ledger.session.decisions.is_empty() {
1127 out.push_str("DECISIONS:\n");
1128 for d in &self.ledger.session.decisions {
1129 out.push_str(&format!(" - {d}\n"));
1130 }
1131 }
1132
1133 if !self.ledger.session.findings.is_empty() {
1134 out.push_str("FINDINGS:\n");
1135 for f in self.ledger.session.findings.iter().take(5) {
1136 out.push_str(&format!(" - {f}\n"));
1137 }
1138 }
1139
1140 if !self.ledger.session.next_steps.is_empty() {
1141 out.push_str("NEXT:\n");
1142 for s in &self.ledger.session.next_steps {
1143 out.push_str(&format!(" - {s}\n"));
1144 }
1145 }
1146
1147 out.push_str("---\n");
1148 out
1149 }
1150}
1151
1152#[cfg(test)]
1153#[path = "handoff_ledger_tests.rs"]
1154mod tests;
1155
1156#[cfg(test)]
1157#[path = "handoff_ledger_lineage_tests.rs"]
1158mod lineage_tests;