From 2acbd432ddf888615fa9699e3a3fa07e1e05d8eb Mon Sep 17 00:00:00 2001 From: fagemx Date: Fri, 20 Mar 2026 08:47:39 +0800 Subject: [PATCH] feat(pack): session start decision pack injection (GH-334) Add DecisionPack type, build_decision_pack(), and render_decision_pack_md() to edda-pack for querying and rendering active decisions grouped by domain. Wire into dispatch_session_start in edda-bridge-claude to auto-inject active decisions into session context after project state, respecting context budget. Empty packs produce no injection. EDDA_DECISION_PACK_MAX env var controls max items (default 7). Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + .../src/dispatch/session.rs | 33 +++ crates/edda-pack/Cargo.toml | 1 + crates/edda-pack/src/lib.rs | 274 +++++++++++++++++- 4 files changed, 308 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 3782894..ea91ec2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -939,6 +939,7 @@ version = "0.1.1" dependencies = [ "anyhow", "edda-index", + "edda-ledger", "edda-store", "serde", "serde_json", diff --git a/crates/edda-bridge-claude/src/dispatch/session.rs b/crates/edda-bridge-claude/src/dispatch/session.rs index 49f0e35..7da8644 100644 --- a/crates/edda-bridge-claude/src/dispatch/session.rs +++ b/crates/edda-bridge-claude/src/dispatch/session.rs @@ -676,6 +676,39 @@ pub(super) fn dispatch_session_start( }); } + // Inject active decisions as context (Track F — Decision Deepening) + { + let decision_pack_md = { + let cwd_path = std::path::Path::new(cwd); + match edda_ledger::EddaPaths::find_root(cwd_path) { + Some(root) => { + let branch = edda_ledger::Ledger::open(&root) + .and_then(|l| l.head_branch()) + .unwrap_or_default(); + let max_items: usize = std::env::var("EDDA_DECISION_PACK_MAX") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(7); + let pack = edda_pack::build_decision_pack(&root, &branch, max_items); + let md = edda_pack::render_decision_pack_md(&pack); + if md.is_empty() { + None + } else { + Some(md) + } + } + None => None, + } + }; + + if let Some(dp) = decision_pack_md { + content = Some(match content { + Some(c) => format!("{c}\n\n{dp}"), + None => dp, + }); + } + } + // Previous session context is now rendered within the workspace section's // "## Session History" (tiered rendering). No separate injection needed. diff --git a/crates/edda-pack/Cargo.toml b/crates/edda-pack/Cargo.toml index 343dbcc..5d666e7 100644 --- a/crates/edda-pack/Cargo.toml +++ b/crates/edda-pack/Cargo.toml @@ -12,6 +12,7 @@ keywords.workspace = true [dependencies] edda-store = { path = "../edda-store", version = "0.1.1" } edda-index = { path = "../edda-index", version = "0.1.1" } +edda-ledger = { path = "../edda-ledger", version = "0.1.1" } anyhow.workspace = true thiserror.workspace = true serde.workspace = true diff --git a/crates/edda-pack/src/lib.rs b/crates/edda-pack/src/lib.rs index 3a86ec9..20d889b 100644 --- a/crates/edda-pack/src/lib.rs +++ b/crates/edda-pack/src/lib.rs @@ -1,6 +1,7 @@ use edda_index::{fetch_store_line, read_index_tail, IndexRecordV1}; +use edda_ledger::view::DecisionView; use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; const DEFAULT_INDEX_TAIL_LINES: usize = 5000; @@ -375,6 +376,139 @@ pub fn write_pack(project_dir: &Path, pack_md: &str, meta: &PackMetadata) -> any Ok(()) } +// ── Decision Pack ── + +/// A pack of active decisions grouped by domain, ready for session injection. +#[derive(Debug, Clone)] +pub struct DecisionPack { + /// Decisions grouped by domain (e.g., "db", "error", "auth") + pub groups: Vec, + /// Total number of decisions included + pub total: usize, + /// Branch these decisions are scoped to + pub branch: String, +} + +/// A group of decisions sharing the same domain prefix. +#[derive(Debug, Clone)] +pub struct DecisionGroup { + /// Domain name (e.g., "db", "error", "auth") + pub domain: String, + /// Decisions in this domain, sorted by key + pub decisions: Vec, +} + +/// Minimal decision summary for pack rendering (avoids carrying full DecisionView). +#[derive(Debug, Clone)] +pub struct DecisionSummary { + pub key: String, + pub value: String, + pub reason: String, + pub status: String, + pub authority: String, + pub reversibility: String, + pub affected_paths: Vec, +} + +impl From<&DecisionView> for DecisionSummary { + fn from(v: &DecisionView) -> Self { + Self { + key: v.key.clone(), + value: v.value.clone(), + reason: v.reason.clone(), + status: v.status.clone(), + authority: v.authority.clone(), + reversibility: v.reversibility.clone(), + affected_paths: v.affected_paths.clone(), + } + } +} + +/// Build a decision pack from active decisions in the ledger. +/// +/// Queries active decisions (status IN active, experimental) on the given +/// branch, groups by domain, and limits to `max_items` total decisions. +/// +/// Returns a pack with 0 groups if no active decisions exist. +pub fn build_decision_pack(repo_root: &Path, branch: &str, max_items: usize) -> DecisionPack { + let views: Vec = match edda_ledger::Ledger::open(repo_root) { + Ok(ledger) => ledger + .active_decisions_limited(None, None, None, None, max_items) + .unwrap_or_default() + .iter() + .map(edda_ledger::view::to_view) + .collect(), + Err(_) => Vec::new(), + }; + + if views.is_empty() { + return DecisionPack { + groups: Vec::new(), + total: 0, + branch: branch.to_string(), + }; + } + + // Group by domain, limit to max_items total + let mut by_domain: BTreeMap> = BTreeMap::new(); + let mut count = 0; + + for d in &views { + if count >= max_items { + break; + } + by_domain + .entry(d.domain.clone()) + .or_default() + .push(DecisionSummary::from(d)); + count += 1; + } + + let groups = by_domain + .into_iter() + .map(|(domain, mut decisions)| { + decisions.sort_by(|a, b| a.key.cmp(&b.key)); + DecisionGroup { domain, decisions } + }) + .collect(); + + DecisionPack { + groups, + total: count, + branch: branch.to_string(), + } +} + +/// Render a decision pack as a markdown section. +/// +/// Returns an empty string if the pack has 0 decisions. +pub fn render_decision_pack_md(pack: &DecisionPack) -> String { + if pack.total == 0 { + return String::new(); + } + + let mut lines = vec![format!( + "## Active Decisions ({} on `{}`)", + pack.total, pack.branch + )]; + + for group in &pack.groups { + lines.push(format!("\n### {}", group.domain)); + for d in &group.decisions { + let mut entry = format!("- **`{}={}`**", d.key, d.value); + if !d.reason.is_empty() { + entry.push_str(&format!(" — {}", d.reason)); + } + if !d.affected_paths.is_empty() { + entry.push_str(&format!("\n paths: `{}`", d.affected_paths.join("`, `"))); + } + lines.push(entry); + } + } + + lines.join("\n") +} + #[cfg(test)] mod tests { use super::*; @@ -457,4 +591,142 @@ mod tests { let meta_path = tmp.path().join("packs").join("hot.meta.json"); assert!(meta_path.exists()); } + + // ── Decision Pack tests ── + + fn make_summary(key: &str, value: &str, reason: &str, paths: Vec<&str>) -> DecisionSummary { + DecisionSummary { + key: key.to_string(), + value: value.to_string(), + reason: reason.to_string(), + status: "active".to_string(), + authority: "human".to_string(), + reversibility: "medium".to_string(), + affected_paths: paths.into_iter().map(|s| s.to_string()).collect(), + } + } + + fn make_pack(groups: Vec<(&str, Vec)>, branch: &str) -> DecisionPack { + let total: usize = groups.iter().map(|(_, ds)| ds.len()).sum(); + DecisionPack { + groups: groups + .into_iter() + .map(|(domain, decisions)| DecisionGroup { + domain: domain.to_string(), + decisions, + }) + .collect(), + total, + branch: branch.to_string(), + } + } + + #[test] + fn test_empty_pack() { + let pack = DecisionPack { + groups: Vec::new(), + total: 0, + branch: "main".to_string(), + }; + let md = render_decision_pack_md(&pack); + assert!(md.is_empty()); + } + + #[test] + fn test_full_pack_grouped_by_domain() { + let pack = make_pack( + vec![ + ( + "auth", + vec![make_summary("auth.strategy", "JWT", "stateless", vec![])], + ), + ( + "db", + vec![ + make_summary("db.engine", "sqlite", "embedded", vec![]), + make_summary("db.pool", "r2d2", "connection pooling", vec![]), + ], + ), + ( + "error", + vec![ + make_summary("error.lib", "thiserror", "typed errors", vec![]), + make_summary("error.pattern", "enum", "exhaustive", vec![]), + ], + ), + ], + "main", + ); + + assert_eq!(pack.groups.len(), 3); + assert_eq!(pack.total, 5); + + let md = render_decision_pack_md(&pack); + assert!(md.contains("## Active Decisions (5 on `main`)")); + assert!(md.contains("### auth")); + assert!(md.contains("### db")); + assert!(md.contains("### error")); + } + + #[test] + fn test_domain_grouping_order() { + let pack = make_pack( + vec![ + ("a_first", vec![make_summary("a_first.x", "1", "r", vec![])]), + ( + "m_middle", + vec![make_summary("m_middle.x", "2", "r", vec![])], + ), + ("z_test", vec![make_summary("z_test.x", "3", "r", vec![])]), + ], + "main", + ); + + let md = render_decision_pack_md(&pack); + let a_pos = md.find("### a_first").unwrap(); + let m_pos = md.find("### m_middle").unwrap(); + let z_pos = md.find("### z_test").unwrap(); + assert!(a_pos < m_pos); + assert!(m_pos < z_pos); + } + + #[test] + fn test_render_with_paths() { + let pack = make_pack( + vec![( + "db", + vec![make_summary( + "db.engine", + "sqlite", + "embedded", + vec!["crates/foo/**", "src/**"], + )], + )], + "main", + ); + + let md = render_decision_pack_md(&pack); + assert!(md.contains("paths: `crates/foo/**`, `src/**`")); + } + + #[test] + fn test_render_without_reason() { + let pack = make_pack( + vec![("db", vec![make_summary("db.engine", "sqlite", "", vec![])])], + "main", + ); + + let md = render_decision_pack_md(&pack); + assert!(md.contains("**`db.engine=sqlite`**")); + assert!(!md.contains(" — ")); + } + + #[test] + fn test_build_decision_pack_nonexistent_repo() { + // Non-existent path should return empty pack + let pack = build_decision_pack(Path::new("/nonexistent/path"), "main", 7); + assert_eq!(pack.total, 0); + assert!(pack.groups.is_empty()); + assert_eq!(render_decision_pack_md(&pack), ""); + } }