use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::core::knowledge::ProjectKnowledge;
use crate::core::protocol::ToolCallRecord;
use crate::core::session::SessionState;
use crate::core::workflow::WorkflowRun;
#[path = "handoff_ledger_authority.rs"]
mod authority;
#[path = "handoff_ledger_merge.rs"]
mod merge;
pub use authority::LedgerPart;
use authority::{BoundEntry, CURATED_TOOL, INCONSISTENT_CODE, LedgerAuthority, PartKind};
pub use merge::{MAX_MERGE_INPUTS, MergeOutcome, merge_ledgers};
pub(crate) const SCHEMA_VERSION: u32 = crate::core::contracts::HANDOFF_LEDGER_V1_SCHEMA_VERSION;
const MAX_KNOWLEDGE_FACTS: usize = 50;
const MAX_CURATED_REFS: usize = 20;
const MAX_LEDGER_FINDINGS: usize = 20;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HandoffLedgerV1 {
pub schema_version: u32,
pub created_at: String,
pub content_md5: String,
pub manifest_md5: String,
pub project_root: Option<String>,
pub agent_id: Option<String>,
pub client_name: Option<String>,
pub workflow: Option<WorkflowRun>,
pub session_snapshot: String,
pub session: SessionExcerpt,
pub tool_calls: ToolCallsSummary,
pub evidence_keys: Vec<String>,
pub knowledge: KnowledgeExcerpt,
pub curated_refs: Vec<CuratedRef>,
#[serde(default)]
pub active_overlays: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub merged_from: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_digest: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionExcerpt {
pub id: String,
pub task: Option<String>,
pub decisions: Vec<String>,
pub findings: Vec<String>,
pub next_steps: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolCallsSummary {
pub total: usize,
pub by_tool: BTreeMap<String, u64>,
pub by_ctx_read_mode: BTreeMap<String, u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct KnowledgeExcerpt {
pub project_hash: Option<String>,
pub facts: Vec<KnowledgeFactMini>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeFactMini {
pub category: String,
pub key: String,
pub value: String,
pub confidence: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CuratedRef {
pub path: String,
pub mode: String,
pub content_md5: String,
pub content: String,
}
#[derive(Debug, Clone)]
pub struct CreateLedgerInput {
pub agent_id: Option<String>,
pub client_name: Option<String>,
pub project_root: Option<String>,
pub session: SessionState,
pub tool_calls: Vec<ToolCallRecord>,
pub workflow: Option<WorkflowRun>,
pub curated_refs: Vec<(String, String)>, pub request_id: Option<String>,
}
const ORIGIN_UNCHECKED: &str = "handoff";
const ORIGIN_ALL_BOUND: &str = "handoff:all-bound";
const ORIGIN_ORIGINLESS_PART: &str = "handoff:originless-part";
struct CopiedParts {
enforced: bool,
project: Option<String>,
sources: Vec<crate::core::context_admission::stored::SourceRef>,
}
impl CopiedParts {
fn new(project_root: Option<&str>, enforced: bool) -> Self {
let project = project_root.filter(|root| !root.is_empty()).map(|root| {
crate::core::pathutil::canonicalize_secure(Path::new(root))
.ok()
.and_then(|path| path.to_str().map(str::to_owned))
.unwrap_or_else(|| root.to_owned())
});
Self {
enforced,
project,
sources: Vec::new(),
}
}
fn admit(
&mut self,
names: &[crate::core::archive::bindings::Item<'_>],
part: &LedgerPart<'_>,
reader: Option<&str>,
) -> Option<bool> {
if !self.enforced {
return Some(false);
}
let mut held = Vec::new();
for item in names {
match crate::core::archive::bindings::held(item, part.text(), reader) {
Ok(Some((project, sources))) if self.in_project(&project) => held.extend(sources),
Ok(None) => {}
Ok(Some(_)) | Err(_) => return None,
}
}
let bound = !held.is_empty();
self.sources.extend(held);
Some(bound)
}
fn in_project(&self, project: &str) -> bool {
self.project.as_deref() == Some(project)
}
}
pub fn create_ledger(input: CreateLedgerInput) -> Result<(HandoffLedgerV1, PathBuf), String> {
let CreateLedgerInput {
agent_id,
client_name,
project_root,
mut session,
tool_calls,
workflow,
curated_refs,
request_id,
} = input;
let (_keyed_create, request_digest) = match request_id.as_deref() {
Some(key) => {
crate::core::agents::check_request_id(key)?;
let Some(root) = project_root.as_deref() else {
return Err("request_id requires a project".to_string());
};
let lock = keyed_create_lock(root, agent_id.as_deref())?;
let paths: Vec<&str> = curated_refs
.iter()
.take(MAX_CURATED_REFS)
.map(|(path, _)| path.as_str())
.collect();
let digest = crate::core::agents::request_digest(
"leanctx/handoff-create/v1",
&serde_json::json!(paths),
);
if let Some(path) = keyed_ledger(root, agent_id.as_deref(), key, &digest)? {
return load_ledger(&path).map(|ledger| (ledger, path));
}
#[cfg(test)]
keyed_race::pause();
(Some(lock), Some(digest))
}
None => (None, None),
};
let manifest_md5 = manifest_md5();
let mut evidence_keys: BTreeSet<String> = BTreeSet::new();
for ev in &session.evidence {
evidence_keys.insert(ev.key.clone());
}
let mut by_tool: BTreeMap<String, u64> = BTreeMap::new();
let mut by_mode: BTreeMap<String, u64> = BTreeMap::new();
for call in &tool_calls {
*by_tool.entry(call.tool.clone()).or_insert(0) += 1;
if call.tool == "ctx_read"
&& let Some(m) = call.mode.as_deref()
{
*by_mode.entry(m.to_string()).or_insert(0) += 1;
}
}
let authority_required = crate::core::context_admission::stored::source_authority_required();
let mut parts = CopiedParts::new(project_root.as_deref(), authority_required);
let (session_id, session_root) = (session.id.clone(), session.project_root.clone());
let cut = session.findings.len().saturating_sub(MAX_LEDGER_FINDINGS);
session.findings.drain(..cut);
let mut finding_bound: Vec<bool> = Vec::new();
session.findings.retain(|finding| {
let item = crate::core::archive::bindings::Item::SessionFinding {
session: &session_id,
file: finding.file.as_deref(),
};
let part = LedgerPart::Finding(&finding.summary);
let copied = parts.admit(&[item], &part, session_root.as_deref());
finding_bound.extend(copied);
copied.is_some()
});
let session_excerpt = SessionExcerpt {
id: session.id.clone(),
task: session.task.as_ref().map(|t| t.description.clone()),
decisions: session
.decisions
.iter()
.rev()
.take(10)
.map(|d| d.summary.clone())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect(),
findings: session.findings.iter().map(|f| f.summary.clone()).collect(),
next_steps: session.next_steps.iter().take(20).cloned().collect(),
};
let (knowledge_excerpt, fact_bound) =
build_knowledge_excerpt(project_root.as_deref(), &mut parts);
let mut sources = Vec::new();
let mut curated = Vec::new();
for (p, text) in curated_refs.into_iter().take(MAX_CURATED_REFS) {
let bound = project_root.as_deref().and_then(|root| {
crate::core::context_admission::stored::SourceRef::read(&p, root, CURATED_TOOL).ok()
});
match bound {
Some((_, source)) => sources.push(source),
None if authority_required => continue,
None => {}
}
let md5 = crate::core::hasher::hash_hex(text.as_bytes());
curated.push(CuratedRef {
path: p,
mode: "signatures".to_string(),
content_md5: md5,
content: text,
});
}
sources.extend(parts.sources);
let mut ledger = HandoffLedgerV1 {
schema_version: SCHEMA_VERSION,
created_at: chrono::Local::now().to_rfc3339(),
content_md5: String::new(),
manifest_md5,
project_root,
agent_id,
client_name,
workflow,
session_snapshot: session.build_compaction_snapshot(),
session: session_excerpt,
tool_calls: ToolCallsSummary {
total: tool_calls.len(),
by_tool,
by_ctx_read_mode: by_mode,
},
evidence_keys: evidence_keys.into_iter().collect(),
knowledge: knowledge_excerpt,
curated_refs: curated,
active_overlays: {
let overlay_store = crate::core::context_overlay::OverlayStore::load_project(
&std::env::current_dir().unwrap_or_default(),
);
overlay_store
.all()
.iter()
.filter_map(|o| serde_json::to_value(o).ok())
.collect()
},
merged_from: Vec::new(),
request_id,
request_digest,
};
let md5 = ledger_content_md5(&ledger);
ledger.content_md5.clone_from(&md5);
let path = persist_ledger(&ledger, sources, |kind, index| {
let flags = match kind {
PartKind::Finding => &finding_bound,
PartKind::Fact => &fact_bound,
};
flags.get(index).copied().unwrap_or(false)
})?;
Ok((ledger, path))
}
fn persist_ledger(
ledger: &HandoffLedgerV1,
sources: Vec<crate::core::context_admission::stored::SourceRef>,
bound: impl Fn(PartKind, usize) -> bool,
) -> Result<PathBuf, String> {
let authority_required = crate::core::context_admission::stored::source_authority_required();
let path = ledger_path(&ledger.created_at, &ledger.content_md5)?;
let json = serde_json::to_string_pretty(ledger).map_err(|e| format!("serialize: {e}"))?;
let bound_parts = authority_required.then(|| ledger.bound_record(&bound));
let origin = bound_parts
.as_ref()
.map_or(ORIGIN_UNCHECKED, |record| ledger.checked_origin(record));
let text = ledger_text(&json)?;
let binding = admit_ledger(&text, ledger.project_root.as_deref(), sources, origin)?;
if authority_required {
serve_ledger(Some(&binding), &text, request_project().as_deref())?;
}
crate::config_io::write_atomic_with_backup(&path, &(json + "\n"))
.map_err(|e| format!("write {}: {e}", path.display()))?;
let authority = LedgerAuthority {
binding,
bound_parts,
};
let authority = serde_json::to_string(&authority).map_err(|e| format!("serialize: {e}"))?;
if let Err(error) =
crate::config_io::write_atomic_with_backup(&authority_path(&path), &authority)
{
let _ = std::fs::remove_file(&path);
return Err(format!("write {}: {error}", path.display()));
}
Ok(path)
}
fn authority_path(ledger: &Path) -> PathBuf {
ledger.with_extension("authority")
}
fn ledger_text(json: &str) -> Result<String, String> {
fn collect<'a>(value: &'a Value, leaves: &mut Vec<&'a str>) {
match value {
Value::String(text) if !text.is_empty() => leaves.push(text),
Value::Array(items) => items.iter().for_each(|item| collect(item, leaves)),
Value::Object(map) => map.values().for_each(|child| collect(child, leaves)),
_ => {}
}
}
let value: Value = serde_json::from_str(json).map_err(|e| format!("parse ledger: {e}"))?;
let mut leaves = Vec::new();
collect(&value, &mut leaves);
Ok(leaves.join("\n"))
}
fn admit_ledger(
json: &str,
project_root: Option<&str>,
sources: Vec<crate::core::context_admission::stored::SourceRef>,
origin_marker: &str,
) -> Result<crate::core::context_admission::stored::StoredBinding, String> {
use crate::core::context_admission::carrier::Withheld;
use crate::core::context_admission::stored::{Lineage, admit_and_bind};
let refused = |withheld: Withheld| withheld.message("handoff ledger");
let lineage = Lineage::of(sources).map_err(refused)?;
let (admitted, binding) =
admit_and_bind(json, origin_marker, project_root, lineage).map_err(refused)?;
if admitted != json {
return Err(refused(Withheld::new(&["policy.content_redacted"])));
}
Ok(binding)
}
fn request_project() -> Option<String> {
crate::core::policy::runtime::REQUEST_PROJECT
.try_with(|slot| slot.borrow().clone())
.ok()
.flatten()
.and_then(|project| project.to_str().map(str::to_owned))
}
fn serve_ledger(
binding: Option<&crate::core::context_admission::stored::StoredBinding>,
text: &str,
reader: Option<&str>,
) -> Result<(), String> {
use crate::core::context_admission::carrier::Withheld;
use crate::core::context_admission::stored::serve_stored;
let served = serve_stored(text, binding, reader)
.map_err(|withheld| withheld.message("handoff ledger"))?;
if served == text {
Ok(())
} else {
Err(Withheld::new(&["policy.content_redacted"]).message("handoff ledger"))
}
}
fn same_project(stored: Option<&str>, reader: Option<&str>) -> bool {
let canonical =
|root: &str| crate::core::pathutil::safe_canonicalize_bounded(Path::new(root), 2000);
match (stored, reader) {
(Some(stored), Some(reader)) => canonical(stored) == canonical(reader),
_ => false,
}
}
fn stored_project(path: &Path) -> Option<String> {
let json: Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
json.get("project_root")?.as_str().map(str::to_owned)
}
#[cfg(test)]
pub(crate) mod keyed_race {
use std::sync::{Condvar, Mutex, PoisonError};
use std::time::{Duration, Instant};
static STATE: Mutex<(usize, usize)> = Mutex::new((0, 0));
static ARRIVAL: Condvar = Condvar::new();
const BOUND: Duration = Duration::from_millis(500);
pub(crate) fn arm(expected: usize) -> impl Drop {
struct Disarm;
impl Drop for Disarm {
fn drop(&mut self) {
*STATE.lock().unwrap_or_else(PoisonError::into_inner) = (0, 0);
}
}
*STATE.lock().unwrap_or_else(PoisonError::into_inner) = (expected, 0);
Disarm
}
pub(super) fn pause() {
let mut state = STATE.lock().unwrap_or_else(PoisonError::into_inner);
if state.0 == 0 {
return;
}
state.1 += 1;
ARRIVAL.notify_all();
let deadline = Instant::now() + BOUND;
while state.1 < state.0 {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
break;
}
state = ARRIVAL
.wait_timeout(state, left)
.unwrap_or_else(PoisonError::into_inner)
.0;
}
}
}
fn keyed_create_lock(
project_root: &str,
agent_id: Option<&str>,
) -> Result<crate::core::agents::FileLock, String> {
const WAIT: std::time::Duration = std::time::Duration::from_secs(10);
let dir = handoffs_dir()?;
std::fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all {}: {e}", dir.display()))?;
let scope = serde_json::json!([project_root, agent_id]).to_string();
let name = crate::core::hasher::hash_hex(scope.as_bytes());
crate::core::agents::FileLock::acquire_with_timeout(
&dir.join(format!("keyed-{name}.lock")),
WAIT,
)
.map_err(|error| format!("handoff create is busy: {error}"))
}
fn keyed_ledger(
project_root: &str,
agent_id: Option<&str>,
key: &str,
digest: &str,
) -> Result<Option<PathBuf>, String> {
for path in list_ledgers(project_root) {
let Some(stored) = std::fs::read_to_string(&path)
.ok()
.and_then(|text| serde_json::from_str::<Value>(&text).ok())
else {
continue;
};
if stored["request_id"].as_str() != Some(key) || stored["agent_id"].as_str() != agent_id {
continue;
}
return if stored["request_digest"].as_str() == Some(digest) {
Ok(Some(path))
} else {
Err(crate::core::agents::request_id_conflict())
};
}
Ok(None)
}
pub fn list_ledgers(project_root: &str) -> Vec<PathBuf> {
let dir = handoffs_dir().ok();
let Some(dir) = dir else {
return Vec::new();
};
let Ok(rd) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut items: Vec<PathBuf> = rd
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json"))
.filter(|p| same_project(stored_project(p).as_deref(), Some(project_root)))
.collect();
items.sort();
items.reverse();
items
}
pub struct LedgerLineage {
binding: crate::core::context_admission::stored::StoredBinding,
bound: Vec<BoundEntry>,
text: String,
reader: Option<String>,
}
impl LedgerLineage {
pub fn carry(
&self,
part: &LedgerPart<'_>,
index: usize,
derived: &crate::core::archive::bindings::Item<'_>,
) -> Result<
Option<crate::core::archive::bindings::Carried>,
crate::core::context_admission::carrier::Withheld,
> {
let recorded = self
.bound
.iter()
.find(|entry| entry.kind == part.kind() && entry.index == index);
let Some(recorded) = recorded else {
return Ok(None);
};
if recorded.digest != part.digest() {
return Err(crate::core::context_admission::carrier::Withheld::new(&[
INCONSISTENT_CODE,
]));
}
crate::core::archive::bindings::carry_from(
derived,
part.text(),
&self.binding,
&self.text,
self.reader.as_deref(),
)
}
}
pub fn load_ledger(path: &Path) -> Result<HandoffLedgerV1, String> {
load_ledger_bound(path).map(|(ledger, _)| ledger)
}
pub fn format_show(path: &Path, ledger: &HandoffLedgerV1) -> String {
let mut out = serde_json::to_string_pretty(ledger).unwrap_or_else(|_| "{}".to_string());
out.push('\n');
format!("ctx_handoff show\n path: {}\n{}", path.display(), out)
}
pub fn load_ledger_bound(path: &Path) -> Result<(HandoffLedgerV1, Option<LedgerLineage>), String> {
load_served(path).map(|served| (served.ledger, served.lineage))
}
struct ServedLedger {
ledger: HandoffLedgerV1,
lineage: Option<LedgerLineage>,
files: Option<(String, Vec<(String, String, String)>)>,
}
fn load_served(path: &Path) -> Result<ServedLedger, String> {
use crate::core::context_admission::carrier::Withheld;
use crate::core::context_admission::stored;
let reader = request_project();
let not_found = || format!("handoff ledger {} not found", path.display());
let s = std::fs::read_to_string(path).map_err(|_| not_found())?;
let stored: Value = serde_json::from_str(&s).map_err(|_| not_found())?;
if !same_project(stored["project_root"].as_str(), reader.as_deref()) {
return Err(not_found());
}
let text = ledger_text(&s)?;
let authority: Option<LedgerAuthority> = std::fs::read_to_string(authority_path(path))
.ok()
.and_then(|authority| serde_json::from_str(&authority).ok());
let binding = authority.as_ref().map(|authority| &authority.binding);
let ledger: HandoffLedgerV1 = match serde_json::from_str(&s) {
Ok(ledger) => ledger,
Err(e) => {
serve_ledger(binding, &text, reader.as_deref())?;
return Err(format!("parse {}: {e}", path.display()));
}
};
let checked = authority
.as_ref()
.filter(|authority| checked_at_creation(authority.binding.origin()));
if checked.is_some_and(|authority| !ledger.agrees_with(authority)) {
return Err(Withheld::new(&[INCONSISTENT_CODE]).message("handoff ledger"));
}
serve_ledger(binding, &text, reader.as_deref())?;
let unchecked = binding.is_some_and(|binding| !checked_at_creation(binding.origin()));
if unchecked && ledger.has_copied_parts() && stored::source_authority_required() {
return Err(Withheld::no_source_authority().message("handoff ledger"));
}
stored::publish_served(binding.filter(|binding| ledger.has_file_lineage(binding.origin())));
let files = binding
.and_then(|binding| binding.file_sources())
.map(|(project, files)| {
let files = files
.into_iter()
.map(|(path, tool, digest)| (path.to_owned(), tool.to_owned(), digest.to_owned()))
.collect();
(project.to_owned(), files)
});
let lineage = authority
.filter(|authority| checked_at_creation(authority.binding.origin()))
.and_then(|authority| {
let record = authority.bound_parts.filter(|record| record.count > 0)?;
Some(LedgerLineage {
binding: authority.binding,
bound: record.entries,
text,
reader,
})
});
Ok(ServedLedger {
ledger,
lineage,
files,
})
}
fn checked_at_creation(origin: &str) -> bool {
origin == ORIGIN_ALL_BOUND || origin == ORIGIN_ORIGINLESS_PART
}
impl HandoffLedgerV1 {
fn has_copied_parts(&self) -> bool {
!self.session.findings.is_empty() || !self.knowledge.facts.is_empty()
}
fn has_file_lineage(&self, origin: &str) -> bool {
!self.has_unbound_text() && (origin == ORIGIN_ALL_BOUND || !self.has_copied_parts())
}
fn has_unbound_text(&self) -> bool {
!(self.session_snapshot.is_empty()
&& self.session.task.is_none()
&& self.session.decisions.is_empty()
&& self.session.next_steps.is_empty()
&& self.evidence_keys.is_empty()
&& self.workflow.is_none()
&& self.active_overlays.is_empty())
}
}
pub(crate) fn compute_content_md5_for_ledger(ledger: &HandoffLedgerV1) -> String {
ledger_content_md5(ledger)
}
pub fn projectless_ledgers() -> Vec<(PathBuf, HandoffLedgerV1)> {
let Ok(rd) = handoffs_dir().and_then(|dir| std::fs::read_dir(dir).map_err(|e| e.to_string()))
else {
return Vec::new();
};
let mut items: Vec<(PathBuf, HandoffLedgerV1)> = rd
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json"))
.filter_map(|p| {
let ledger: HandoffLedgerV1 =
serde_json::from_str(&std::fs::read_to_string(&p).ok()?).ok()?;
ledger.project_root.is_none().then_some((p, ledger))
})
.collect();
items.sort_by(|a, b| a.0.cmp(&b.0));
items
}
pub fn adopt_projectless_ledger(path: &Path, project_root: &str) -> Result<PathBuf, String> {
let mut ledger: HandoffLedgerV1 = serde_json::from_str(
&std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?,
)
.map_err(|e| format!("parse {}: {e}", path.display()))?;
if ledger.project_root.is_some() {
return Err("ledger already belongs to a project".into());
}
ledger.project_root = Some(project_root.to_string());
ledger.content_md5 = ledger_content_md5(&ledger);
persist_ledger(&ledger, Vec::new(), |_, _| false)
}
pub fn clear_ledgers(project_root: &str) -> Result<u32, String> {
let mut removed = 0u32;
for p in list_ledgers(project_root) {
if std::fs::remove_file(&p).is_ok() {
removed += 1;
}
let _ = std::fs::remove_file(authority_path(&p));
}
Ok(removed)
}
fn build_knowledge_excerpt(
project_root: Option<&str>,
parts: &mut CopiedParts,
) -> (KnowledgeExcerpt, Vec<bool>) {
use crate::core::archive::bindings::Item;
let Some(knowledge) = project_root.and_then(ProjectKnowledge::load) else {
return (KnowledgeExcerpt::default(), Vec::new());
};
let mut bound = Vec::new();
let request = crate::core::admitted_sources::request_root();
let reader = Some(request.as_str()).filter(|root| !root.is_empty());
let one_name = reader.is_none_or(|root| root == knowledge.project_root);
let mut facts = Vec::new();
for f in knowledge.facts.iter().filter(|f| f.is_current()) {
let names = [
Item::KnowledgeFact {
project: &knowledge.project_root,
category: &f.category,
key: &f.key,
},
Item::KnowledgeFact {
project: &request,
category: &f.category,
key: &f.key,
},
];
let names = if one_name { &names[..1] } else { &names[..] };
let part = LedgerPart::Fact {
category: &f.category,
key: &f.key,
value: &f.value,
};
let Some(part_bound) = parts.admit(names, &part, reader) else {
continue;
};
bound.push(part_bound);
facts.push(KnowledgeFactMini {
category: f.category.clone(),
key: f.key.clone(),
value: f.value.clone(),
confidence: f.confidence,
});
if facts.len() >= MAX_KNOWLEDGE_FACTS {
break;
}
}
let excerpt = KnowledgeExcerpt {
project_hash: Some(knowledge.project_hash.clone()),
facts,
};
(excerpt, bound)
}
fn ledger_path(created_at: &str, md5: &str) -> Result<PathBuf, String> {
let dir = handoffs_dir()?;
std::fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all {}: {e}", dir.display()))?;
let ts = created_at
.chars()
.filter(char::is_ascii_digit)
.take(14)
.collect::<String>();
let name = format!("{ts}-{md5}.json");
Ok(dir.join(name))
}
fn handoffs_dir() -> Result<PathBuf, String> {
let dir = crate::core::data_dir::lean_ctx_data_dir()
.map_err(|e| e.clone())?
.join("handoffs");
Ok(dir)
}
fn manifest_md5() -> String {
let v = crate::core::mcp_manifest::manifest_value();
let canon = crate::core::canonical::canonical_value(&v);
crate::core::hasher::hash_hex(canon.to_string().as_bytes())
}
fn ledger_content_md5(ledger: &HandoffLedgerV1) -> String {
let mut tmp = ledger.clone();
tmp.content_md5.clear();
let v = serde_json::to_value(&tmp).unwrap_or(Value::Null);
let canon = crate::core::canonical::canonical_value(&v);
crate::core::hasher::hash_hex(canon.to_string().as_bytes())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandoffPackage {
pub ledger: HandoffLedgerV1,
pub intent: Option<IntentSnapshot>,
pub context_snapshot: Option<ContextSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntentSnapshot {
pub task_type: String,
pub scope: String,
pub targets: Vec<String>,
pub keywords: Vec<String>,
pub language_hint: Option<String>,
pub urgency: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextSnapshot {
pub window_size: usize,
pub tokens_used: usize,
pub tokens_saved: usize,
pub files_loaded: Vec<LoadedFileInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoadedFileInfo {
pub path: String,
pub mode: String,
pub tokens: usize,
}
impl HandoffPackage {
pub fn build(
ledger: HandoffLedgerV1,
intent: Option<&super::intent_engine::StructuredIntent>,
context: Option<&crate::core::context_ledger::ContextLedger>,
) -> Self {
let intent_snap = intent.map(|i| IntentSnapshot {
task_type: i.task_type.as_str().to_string(),
scope: match i.scope {
super::intent_engine::IntentScope::SingleFile => "single_file",
super::intent_engine::IntentScope::MultiFile => "multi_file",
super::intent_engine::IntentScope::CrossModule => "cross_module",
super::intent_engine::IntentScope::ProjectWide => "project_wide",
}
.to_string(),
targets: i.targets.clone(),
keywords: i.keywords.clone(),
language_hint: i.language_hint.clone(),
urgency: i.urgency,
});
let ctx_snap = context.map(|c| ContextSnapshot {
window_size: c.window_size,
tokens_used: c.total_tokens_sent,
tokens_saved: c.total_tokens_saved,
files_loaded: c
.entries
.iter()
.map(|e| LoadedFileInfo {
path: e.path.clone(),
mode: e.mode.clone(),
tokens: e.sent_tokens,
})
.collect(),
});
HandoffPackage {
ledger,
intent: intent_snap,
context_snapshot: ctx_snap,
}
}
pub fn format_compact(&self) -> String {
let mut out = String::new();
out.push_str("--- HANDOFF ---\n");
if let Some(ref intent) = self.intent {
out.push_str(&format!(
"TASK: {} (scope: {}, conf: {})\n",
intent.task_type,
intent.scope,
if intent.urgency > 0.5 {
"URGENT"
} else {
"normal"
}
));
if !intent.targets.is_empty() {
out.push_str(&format!("TARGETS: {}\n", intent.targets.join(", ")));
}
if let Some(ref lang) = intent.language_hint {
out.push_str(&format!("LANG: {lang}\n"));
}
}
if let Some(ref ctx) = self.context_snapshot {
out.push_str(&format!(
"CTX: {}/{} tokens, {} files, saved {}\n",
ctx.tokens_used,
ctx.window_size,
ctx.files_loaded.len(),
ctx.tokens_saved,
));
}
if !self.ledger.session.decisions.is_empty() {
out.push_str("DECISIONS:\n");
for d in &self.ledger.session.decisions {
out.push_str(&format!(" - {d}\n"));
}
}
if !self.ledger.session.findings.is_empty() {
out.push_str("FINDINGS:\n");
for f in self.ledger.session.findings.iter().take(5) {
out.push_str(&format!(" - {f}\n"));
}
}
if !self.ledger.session.next_steps.is_empty() {
out.push_str("NEXT:\n");
for s in &self.ledger.session.next_steps {
out.push_str(&format!(" - {s}\n"));
}
}
out.push_str("---\n");
out
}
}
#[cfg(test)]
#[path = "handoff_ledger_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "handoff_ledger_lineage_tests.rs"]
mod lineage_tests;