1use edda_index::{fetch_store_line, read_index_tail, IndexRecordV1};
2use edda_ledger::view::DecisionView;
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, HashMap, HashSet};
5use std::path::Path;
6
7const DEFAULT_INDEX_TAIL_LINES: usize = 5000;
8const DEFAULT_INDEX_TAIL_MAX_BYTES: u64 = 8 * 1024 * 1024; const DEFAULT_PACK_TURNS: usize = 12;
10const DEFAULT_PACK_BUDGET_CHARS: usize = 12000;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Turn {
16 pub user_uuid: String,
17 pub assistant_uuid: String,
18 pub user_text: String,
19 pub assistant_texts: Vec<String>,
20 pub tool_uses: Vec<ToolUse>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ToolUse {
25 pub id: Option<String>,
26 pub name: String,
27 pub command: Option<String>,
28 pub description: Option<String>,
29 pub file_path: Option<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PackMetadata {
34 pub project_id: String,
35 pub session_id: String,
36 pub git_branch: String,
37 pub turn_count: usize,
38 pub budget_chars: usize,
39}
40
41pub fn build_turns(
45 project_dir: &Path,
46 session_id: &str,
47 max_turns: usize,
48) -> anyhow::Result<Vec<Turn>> {
49 let tail_lines: usize = std::env::var("EDDA_INDEX_TAIL_LINES")
50 .ok()
51 .and_then(|v| v.parse().ok())
52 .unwrap_or(DEFAULT_INDEX_TAIL_LINES);
53 let tail_bytes: u64 = std::env::var("EDDA_INDEX_TAIL_MAX_BYTES")
54 .ok()
55 .and_then(|v| v.parse().ok())
56 .unwrap_or(DEFAULT_INDEX_TAIL_MAX_BYTES);
57 let pack_turns: usize = std::env::var("EDDA_PACK_TURNS")
58 .ok()
59 .and_then(|v| v.parse().ok())
60 .unwrap_or(DEFAULT_PACK_TURNS);
61 let max_turns = max_turns.min(pack_turns);
62
63 let index_path = project_dir
64 .join("index")
65 .join(format!("{session_id}.jsonl"));
66 let records = read_index_tail(&index_path, tail_lines, tail_bytes)?;
67
68 if records.is_empty() {
69 return Ok(vec![]);
70 }
71
72 let by_uuid: HashMap<String, &IndexRecordV1> =
74 records.iter().map(|r| (r.uuid.clone(), r)).collect();
75
76 let assistants: Vec<&IndexRecordV1> = records
78 .iter()
79 .filter(|r| r.record_type == "assistant")
80 .collect();
81
82 let store_path = project_dir
83 .join("transcripts")
84 .join(format!("{session_id}.jsonl"));
85
86 let mut turns = Vec::new();
87 let mut seen_user_uuids = HashSet::new();
88
89 for asst_rec in assistants.iter().rev() {
91 if turns.len() >= max_turns {
92 break;
93 }
94
95 let mut current_parent = asst_rec.parent_uuid.as_deref();
100 let mut chain_tool_uses: Vec<ToolUse> = Vec::new();
101 let mut real_user_uuid = String::new();
102 let mut real_user_text = String::new();
103
104 let mut depth = 0;
105 const MAX_CHAIN_DEPTH: usize = 50;
106
107 while let Some(parent_id) = current_parent {
108 if depth >= MAX_CHAIN_DEPTH {
109 break;
110 }
111 depth += 1;
112
113 let parent_rec = match by_uuid.get(parent_id) {
114 Some(r) => r,
115 None => break,
116 };
117
118 if parent_rec.record_type == "user" {
119 if let Ok(raw) =
121 fetch_store_line(&store_path, parent_rec.store_offset, parent_rec.store_len)
122 {
123 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&raw) {
124 let text = extract_user_text(&json);
125 if !text.is_empty() {
126 real_user_uuid = parent_rec.uuid.clone();
127 real_user_text = text;
128 break; }
130 }
131 }
132 current_parent = parent_rec.parent_uuid.as_deref();
134 } else if parent_rec.record_type == "assistant" {
135 if let Ok(raw) =
137 fetch_store_line(&store_path, parent_rec.store_offset, parent_rec.store_len)
138 {
139 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&raw) {
140 let (_, tus) = parse_assistant_content(&json);
141 chain_tool_uses.extend(tus);
142 }
143 }
144 current_parent = parent_rec.parent_uuid.as_deref();
145 } else {
146 break; }
148 }
149
150 if real_user_text.is_empty() || real_user_uuid.is_empty() {
151 continue;
152 }
153
154 if !seen_user_uuids.insert(real_user_uuid.clone()) {
156 continue;
157 }
158
159 let asst_raw =
161 match fetch_store_line(&store_path, asst_rec.store_offset, asst_rec.store_len) {
162 Ok(r) => r,
163 Err(_) => continue,
164 };
165 let asst_json: serde_json::Value = match serde_json::from_slice(&asst_raw) {
166 Ok(v) => v,
167 Err(_) => continue,
168 };
169 let (assistant_texts, final_tool_uses) = parse_assistant_content(&asst_json);
170
171 chain_tool_uses.reverse();
173 chain_tool_uses.extend(final_tool_uses);
174
175 turns.push(Turn {
176 user_uuid: real_user_uuid,
177 assistant_uuid: asst_rec.uuid.clone(),
178 user_text: real_user_text,
179 assistant_texts,
180 tool_uses: chain_tool_uses,
181 });
182 }
183
184 Ok(turns)
185}
186
187fn extract_user_text(user_json: &serde_json::Value) -> String {
191 let content = match user_json.get("message").and_then(|m| m.get("content")) {
192 Some(c) => c,
193 None => return String::new(),
194 };
195
196 if let Some(s) = content.as_str() {
198 return s.to_string();
199 }
200
201 if let Some(arr) = content.as_array() {
203 let has_tool_result = arr
205 .iter()
206 .any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_result"));
207 if has_tool_result {
208 return String::new();
209 }
210
211 let texts: Vec<&str> = arr
213 .iter()
214 .filter_map(|b| {
215 if b.get("type").and_then(|t| t.as_str()) == Some("text") {
216 b.get("text").and_then(|t| t.as_str())
217 } else {
218 None
219 }
220 })
221 .collect();
222 if !texts.is_empty() {
223 return texts.join(" ");
224 }
225 }
226
227 String::new()
228}
229
230fn parse_assistant_content(asst_json: &serde_json::Value) -> (Vec<String>, Vec<ToolUse>) {
231 let mut texts = Vec::new();
232 let mut tool_uses = Vec::new();
233
234 let content = asst_json.get("message").and_then(|m| m.get("content"));
235
236 if let Some(arr) = content.and_then(|c| c.as_array()) {
237 for block in arr {
238 let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
239 match block_type {
240 "text" => {
241 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
242 texts.push(text.to_string());
243 }
244 }
245 "tool_use" => {
246 let name = block
247 .get("name")
248 .and_then(|v| v.as_str())
249 .unwrap_or("")
250 .to_string();
251 let id = block.get("id").and_then(|v| v.as_str()).map(|s| s.into());
252 let input = block.get("input");
253 let command = input
254 .and_then(|i| i.get("command"))
255 .and_then(|c| c.as_str())
256 .map(|s| s.into());
257 let description = input
258 .and_then(|i| i.get("description"))
259 .and_then(|d| d.as_str())
260 .map(|s| s.into());
261 let file_path = input
262 .and_then(|i| i.get("file_path"))
263 .and_then(|f| f.as_str())
264 .map(|s| s.into());
265
266 tool_uses.push(ToolUse {
267 id,
268 name,
269 command,
270 description,
271 file_path,
272 });
273 }
274 _ => {}
275 }
276 }
277 } else if let Some(text) = content.and_then(|c| c.as_str()) {
278 texts.push(text.to_string());
279 }
280
281 (texts, tool_uses)
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
288pub enum PackSection {
289 Goals,
290 BindingDecisions,
291 UnratifiedDecisions,
292 OpenCheckpoints,
293 Constraints,
294 Coordination,
295 FileStateDeltas,
296 RecentTurns,
297}
298
299impl PackSection {
300 fn title(self) -> &'static str {
301 match self {
302 Self::Goals => "Goals",
303 Self::BindingDecisions => "Binding Decisions",
304 Self::UnratifiedDecisions => "Unratified Decisions",
305 Self::OpenCheckpoints => "Open Checkpoints",
306 Self::Constraints => "Constraints",
307 Self::Coordination => "Coordination",
308 Self::FileStateDeltas => "File-State Deltas",
309 Self::RecentTurns => "Recent Turns (deterministic)",
310 }
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct PackItem {
317 pub section: PackSection,
318 pub key: String,
319 pub body: String,
320 pub salience: u64,
321}
322
323pub fn checkpoint_items(events: &[edda_core::Event]) -> Vec<PackItem> {
325 events
326 .iter()
327 .rev()
328 .enumerate()
329 .filter_map(|(index, event)| {
330 let payload: edda_core::event::CheckpointPayload =
331 serde_json::from_value(event.payload.clone()).ok()?;
332 let rejected = payload
333 .rejected
334 .iter()
335 .map(|item| format!("{} — {}", item.hypothesis, item.reason))
336 .collect::<Vec<_>>();
337 let body = format!(
338 "- hypotheses: {}\n- rejected: {}\n- open: {}\n- next: {}\n",
339 payload.hypotheses.join(" | "),
340 rejected.join(" | "),
341 payload.open.join(" | "),
342 payload.next,
343 );
344 Some(PackItem {
345 section: PackSection::OpenCheckpoints,
346 key: format!("Checkpoint {}", event.event_id),
347 body,
348 salience: 90 + (events.len() - index) as u64,
349 })
350 })
351 .collect()
352}
353
354fn latest_registered_checkpoint_items(project_id: &str) -> Vec<PackItem> {
355 let Some(project) = edda_store::registry::get_project(project_id) else {
356 return Vec::new();
357 };
358 let Ok(ledger) = edda_ledger::Ledger::open(project.path) else {
359 return Vec::new();
360 };
361 let Ok(branch) = ledger.head_branch() else {
362 return Vec::new();
363 };
364 let Ok(events) = ledger.iter_events_by_type("checkpoint") else {
365 return Vec::new();
366 };
367 let events: Vec<_> = events
368 .into_iter()
369 .filter(|event| event.branch == branch)
370 .collect();
371 checkpoint_items(&events).into_iter().take(1).collect()
372}
373
374fn pack_budget(budget_chars: usize) -> usize {
375 if budget_chars == 0 {
376 std::env::var("EDDA_PACK_BUDGET_CHARS")
377 .ok()
378 .and_then(|v| v.parse().ok())
379 .unwrap_or(DEFAULT_PACK_BUDGET_CHARS)
380 } else {
381 budget_chars
382 }
383}
384
385fn render_pack_header(metadata: &PackMetadata, dropped_items: usize) -> String {
386 format!(
387 "# edda memory pack (hot)\n\n- project_id: {}\n- session_id: {}\n- git_branch: {}\n- turns: {}\n- dropped_items: {}\n\n",
388 metadata.project_id,
389 metadata.session_id,
390 metadata.git_branch,
391 metadata.turn_count,
392 dropped_items,
393 )
394}
395
396fn render_selected_items(
397 metadata: &PackMetadata,
398 items: &[PackItem],
399 selected: &[usize],
400 dropped_items: usize,
401) -> String {
402 let mut ordered = selected.to_vec();
403 ordered.sort_by(|a, b| {
404 items[*a]
405 .section
406 .cmp(&items[*b].section)
407 .then_with(|| items[*a].key.cmp(&items[*b].key))
408 .then_with(|| a.cmp(b))
409 });
410
411 let mut out = render_pack_header(metadata, dropped_items);
412 let mut section = None;
413 for index in ordered {
414 let item = &items[index];
415 if section != Some(item.section) {
416 out.push_str(&format!("## {}\n\n", item.section.title()));
417 section = Some(item.section);
418 }
419 out.push_str(&format!("### {}\n", item.key));
420 out.push_str(&item.body);
421 if !item.body.ends_with('\n') {
422 out.push('\n');
423 }
424 out.push('\n');
425 }
426 out
427}
428
429fn select_items(
430 metadata: &PackMetadata,
431 items: &[PackItem],
432 budget: usize,
433 dropped_items: usize,
434) -> Vec<usize> {
435 let mut ranked: Vec<usize> = (0..items.len()).collect();
436 ranked.sort_by(|a, b| {
437 items[*b]
438 .salience
439 .cmp(&items[*a].salience)
440 .then_with(|| items[*a].section.cmp(&items[*b].section))
441 .then_with(|| items[*a].key.cmp(&items[*b].key))
442 .then_with(|| a.cmp(b))
443 });
444
445 let mut selected = Vec::new();
446 for index in ranked {
447 selected.push(index);
448 if render_selected_items(metadata, items, &selected, dropped_items).len() > budget {
449 selected.pop();
450 }
451 }
452 selected
453}
454
455pub fn render_ordered_pack(
460 items: &[PackItem],
461 metadata: &PackMetadata,
462 budget_chars: usize,
463) -> String {
464 let budget = pack_budget(budget_chars);
465 let mut dropped_items = items.len();
466 let mut selected = Vec::new();
467
468 for _ in 0..4 {
470 selected = select_items(metadata, items, budget, dropped_items);
471 let next_dropped = items.len() - selected.len();
472 if next_dropped == dropped_items {
473 break;
474 }
475 dropped_items = next_dropped;
476 }
477
478 render_selected_items(metadata, items, &selected, items.len() - selected.len())
479}
480
481fn render_turn_body(turn: &Turn) -> String {
482 let mut body = format!("- User: {}\n", turn.user_text);
483 for tu in &turn.tool_uses {
484 let cmd_str = tu
485 .command
486 .as_deref()
487 .map(|c| format!(" `{c}`"))
488 .unwrap_or_default();
489 let desc_str = tu
490 .description
491 .as_deref()
492 .map(|d| format!(" ({d})"))
493 .unwrap_or_default();
494 let file_str = tu
495 .file_path
496 .as_deref()
497 .map(|f| format!(" file={f}"))
498 .unwrap_or_default();
499 body.push_str(&format!(
500 " - ToolUse: {}{}{}{}\n",
501 tu.name, cmd_str, desc_str, file_str
502 ));
503 }
504 for text in &turn.assistant_texts {
505 body.push_str(&format!(" - Assistant: {text}\n"));
506 }
507 body
508}
509
510pub fn render_pack(turns: &[Turn], metadata: &PackMetadata, budget_chars: usize) -> String {
512 let mut items: Vec<PackItem> = turns
513 .iter()
514 .enumerate()
515 .map(|(index, turn)| PackItem {
516 section: PackSection::RecentTurns,
517 key: format!("Turn {} (newest first)", index + 1),
518 body: render_turn_body(turn),
519 salience: (turns.len() - index) as u64,
520 })
521 .collect();
522 items.extend(latest_registered_checkpoint_items(&metadata.project_id));
523 render_ordered_pack(&items, metadata, budget_chars)
524}
525
526pub fn write_pack(project_dir: &Path, pack_md: &str, meta: &PackMetadata) -> anyhow::Result<()> {
528 let packs_dir = project_dir.join("packs");
529 std::fs::create_dir_all(&packs_dir)?;
530
531 edda_store::write_atomic(&packs_dir.join("hot.md"), pack_md.as_bytes())?;
532
533 let meta_json = serde_json::to_string_pretty(meta)?;
534 edda_store::write_atomic(&packs_dir.join("hot.meta.json"), meta_json.as_bytes())?;
535
536 Ok(())
537}
538
539const DEFAULT_DOCTRINE_FILE: &str = ".havamal-pack.md";
542
543pub fn read_doctrine_pack(repo_root: &Path, budget: usize) -> Option<String> {
559 let path = match std::env::var("EDDA_DOCTRINE_PATH") {
560 Ok(p) if !p.trim().is_empty() => {
561 let pb = std::path::PathBuf::from(p.trim());
562 if pb.is_absolute() {
563 pb
564 } else {
565 repo_root.join(pb)
566 }
567 }
568 _ => repo_root.join(DEFAULT_DOCTRINE_FILE),
569 };
570
571 let raw = std::fs::read_to_string(&path).ok()?;
572 let trimmed = raw.trim();
573 if trimmed.is_empty() {
574 return None;
575 }
576
577 let mut body = trimmed.to_string();
578 if body.len() > budget {
579 let mut end = budget;
580 while end > 0 && !body.is_char_boundary(end) {
581 end -= 1;
582 }
583 body.truncate(end);
584 body.push_str("\n<!-- doctrine truncated by EDDA_DOCTRINE_BUDGET_CHARS -->");
585 }
586
587 Some(format!("## Doctrine (judgment layer)\n\n{body}"))
588}
589
590#[derive(Debug, Clone)]
594pub struct DecisionPack {
595 pub groups: Vec<DecisionGroup>,
597 pub total: usize,
599 pub branch: String,
601}
602
603#[derive(Debug, Clone)]
605pub struct DecisionGroup {
606 pub domain: String,
608 pub decisions: Vec<DecisionSummary>,
610}
611
612#[derive(Debug, Clone)]
614pub struct DecisionSummary {
615 pub key: String,
616 pub value: String,
617 pub reason: String,
618 pub status: String,
619 pub authority: String,
620 pub reversibility: String,
621 pub affected_paths: Vec<String>,
622 pub ratified: bool,
626}
627
628impl From<&DecisionView> for DecisionSummary {
629 fn from(v: &DecisionView) -> Self {
630 Self {
631 key: v.key.clone(),
632 value: v.value.clone(),
633 reason: v.reason.clone(),
634 status: v.status.clone(),
635 authority: v.authority.clone(),
636 reversibility: v.reversibility.clone(),
637 affected_paths: v.affected_paths.clone(),
638 ratified: false,
639 }
640 }
641}
642
643pub fn build_decision_pack(repo_root: &Path, branch: &str, max_items: usize) -> DecisionPack {
650 let (mut views, ratified): (Vec<DecisionView>, std::collections::BTreeSet<String>) =
651 match edda_ledger::Ledger::open(repo_root) {
652 Ok(ledger) => (
658 ledger
659 .active_decisions(None, None, None, None)
660 .unwrap_or_default(),
661 ledger.ratified_decision_events().unwrap_or_default(),
663 ),
664 Err(_) => (Vec::new(), std::collections::BTreeSet::new()),
665 };
666 views.retain(|v| v.branch == branch);
670 views.truncate(max_items);
671
672 if views.is_empty() {
673 return DecisionPack {
674 groups: Vec::new(),
675 total: 0,
676 branch: branch.to_string(),
677 };
678 }
679
680 let mut by_domain: BTreeMap<String, Vec<DecisionSummary>> = BTreeMap::new();
682 let mut count = 0;
683
684 for d in &views {
685 if count >= max_items {
686 break;
687 }
688 let mut summary = DecisionSummary::from(d);
689 summary.ratified = edda_ledger::view::is_decision_ratified(d, &ratified);
690 by_domain.entry(d.domain.clone()).or_default().push(summary);
691 count += 1;
692 }
693
694 let groups = by_domain
695 .into_iter()
696 .map(|(domain, mut decisions)| {
697 decisions.sort_by(|a, b| a.key.cmp(&b.key));
698 DecisionGroup { domain, decisions }
699 })
700 .collect();
701
702 DecisionPack {
703 groups,
704 total: count,
705 branch: branch.to_string(),
706 }
707}
708
709pub fn render_decision_pack_md(pack: &DecisionPack) -> String {
718 if pack.total == 0 {
719 return String::new();
720 }
721
722 let ratified_count = pack
723 .groups
724 .iter()
725 .flat_map(|g| &g.decisions)
726 .filter(|d| d.ratified)
727 .count();
728 let unratified_count = pack.total.saturating_sub(ratified_count);
729
730 let mut sections: Vec<String> = Vec::new();
731 if ratified_count > 0 {
732 sections.push(render_decision_tier(
733 pack,
734 true,
735 &format!(
736 "## Ratified Decisions ({} on `{}`)",
737 ratified_count, pack.branch
738 ),
739 ));
740 }
741 if unratified_count > 0 {
742 sections.push(render_decision_tier(
743 pack,
744 false,
745 &format!(
746 "## Unratified Decisions ({} on `{}`) — recorded, not binding until `edda ratify`",
747 unratified_count, pack.branch
748 ),
749 ));
750 }
751 sections.join("\n\n")
752}
753
754fn render_decision_tier(pack: &DecisionPack, want_ratified: bool, header: &str) -> String {
757 let mut lines = vec![header.to_string()];
758 for group in &pack.groups {
759 let decs: Vec<&DecisionSummary> = group
760 .decisions
761 .iter()
762 .filter(|d| d.ratified == want_ratified)
763 .collect();
764 if decs.is_empty() {
765 continue;
766 }
767 lines.push(format!("\n### {}", group.domain));
768 for d in decs {
769 let mut entry = if want_ratified {
770 format!("- **`{}={}`**", d.key, d.value)
771 } else {
772 format!(
773 "- [{}] **`{}={}`**",
774 edda_core::types::authorship_tag(&d.authority),
775 d.key,
776 d.value
777 )
778 };
779 if !d.reason.is_empty() {
780 entry.push_str(&format!(" — {}", d.reason));
781 }
782 if !d.affected_paths.is_empty() {
783 entry.push_str(&format!("\n paths: `{}`", d.affected_paths.join("`, `")));
784 }
785 lines.push(entry);
786 }
787 }
788 lines.join("\n")
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794
795 #[test]
796 fn render_pack_basic() {
797 let turns = vec![Turn {
798 user_uuid: "u1".into(),
799 assistant_uuid: "a1".into(),
800 user_text: "How do I sort a list?".into(),
801 assistant_texts: vec!["Use the sort() method.".into()],
802 tool_uses: vec![ToolUse {
803 id: Some("tu1".into()),
804 name: "Bash".into(),
805 command: Some("ls -la".into()),
806 description: Some("List files".into()),
807 file_path: None,
808 }],
809 }];
810
811 let meta = PackMetadata {
812 project_id: "abc123".into(),
813 session_id: "s1".into(),
814 git_branch: "main".into(),
815 turn_count: 1,
816 budget_chars: 12000,
817 };
818
819 let md = render_pack(&turns, &meta, 12000);
820 assert!(md.contains("# edda memory pack (hot)"));
821 assert!(md.contains("How do I sort a list?"));
822 assert!(md.contains("ToolUse: Bash"));
823 assert!(md.contains("Use the sort() method."));
824 }
825
826 #[test]
827 fn checkpoint_items_join_the_hot_pack_schema() {
828 let checkpoint = edda_core::event::CheckpointPayload {
829 hypotheses: vec!["cache invalidation is the cause".to_string()],
830 rejected: vec![edda_core::event::RejectedHypothesis {
831 hypothesis: "database corruption".to_string(),
832 reason: "integrity check passes".to_string(),
833 }],
834 open: vec!["confirm the next rebuild".to_string()],
835 next: "run the rebuild check".to_string(),
836 };
837 let event =
838 edda_core::event::new_checkpoint_event("main", None, "agent", &checkpoint).unwrap();
839 let items = checkpoint_items(&[event]);
840 let meta = PackMetadata {
841 project_id: "abc".into(),
842 session_id: "s1".into(),
843 git_branch: "main".into(),
844 turn_count: 0,
845 budget_chars: 12000,
846 };
847
848 let md = render_ordered_pack(&items, &meta, 12000);
849
850 assert!(md.contains("## Open Checkpoints"));
851 assert!(md.contains("cache invalidation is the cause"));
852 assert!(md.contains("integrity check passes"));
853 assert!(md.contains("run the rebuild check"));
854 }
855
856 #[test]
857 fn render_pack_drops_whole_items_at_budget() {
858 let turns: Vec<Turn> = (0..20)
859 .map(|i| Turn {
860 user_uuid: format!("u{i}"),
861 assistant_uuid: format!("a{i}"),
862 user_text: format!("Question {i} with some extra text padding to fill space"),
863 assistant_texts: vec![format!(
864 "Answer {i} with a reasonably long response text to consume budget"
865 )],
866 tool_uses: vec![],
867 })
868 .collect();
869
870 let meta = PackMetadata {
871 project_id: "abc".into(),
872 session_id: "s1".into(),
873 git_branch: "main".into(),
874 turn_count: 20,
875 budget_chars: 500,
876 };
877
878 let md = render_pack(&turns, &meta, 500);
879 assert!(md.contains("- dropped_items: "));
880 assert!(!md.contains("truncated by budget"));
881 assert!(md.len() <= 500);
882 }
883
884 #[test]
885 fn write_pack_creates_files() {
886 let tmp = tempfile::tempdir().unwrap();
887 let meta = PackMetadata {
888 project_id: "test".into(),
889 session_id: "s1".into(),
890 git_branch: "main".into(),
891 turn_count: 0,
892 budget_chars: 12000,
893 };
894
895 write_pack(tmp.path(), "# pack content", &meta).unwrap();
896
897 let hot = tmp.path().join("packs").join("hot.md");
898 assert!(hot.exists());
899 assert_eq!(std::fs::read_to_string(&hot).unwrap(), "# pack content");
900
901 let meta_path = tmp.path().join("packs").join("hot.meta.json");
902 assert!(meta_path.exists());
903 }
904
905 #[test]
908 fn doctrine_pack_missing_returns_none() {
909 let tmp = tempfile::tempdir().unwrap();
910 assert!(read_doctrine_pack(tmp.path(), 4000).is_none());
911 }
912
913 #[test]
914 fn doctrine_pack_reads_default_file() {
915 let tmp = tempfile::tempdir().unwrap();
916 std::fs::write(
917 tmp.path().join(".havamal-pack.md"),
918 "## L1 — MUST STAY TRUE\nClaims never close work.",
919 )
920 .unwrap();
921 let md = read_doctrine_pack(tmp.path(), 4000).unwrap();
922 assert!(md.starts_with("## Doctrine (judgment layer)"));
923 assert!(md.contains("Claims never close work."));
924 }
925
926 #[test]
927 fn doctrine_pack_empty_file_returns_none() {
928 let tmp = tempfile::tempdir().unwrap();
929 std::fs::write(tmp.path().join(".havamal-pack.md"), " \n\n").unwrap();
930 assert!(read_doctrine_pack(tmp.path(), 4000).is_none());
931 }
932
933 #[test]
934 fn doctrine_pack_truncates_at_budget() {
935 let tmp = tempfile::tempdir().unwrap();
936 let long = "x".repeat(500);
937 std::fs::write(tmp.path().join(".havamal-pack.md"), &long).unwrap();
938 let md = read_doctrine_pack(tmp.path(), 100).unwrap();
939 assert!(md.contains("doctrine truncated"));
940 assert!(md.len() < 300);
941 }
942
943 fn make_summary(key: &str, value: &str, reason: &str, paths: Vec<&str>) -> DecisionSummary {
946 DecisionSummary {
947 key: key.to_string(),
948 value: value.to_string(),
949 reason: reason.to_string(),
950 status: "active".to_string(),
951 authority: "agent".to_string(),
952 reversibility: "medium".to_string(),
953 affected_paths: paths.into_iter().map(|s| s.to_string()).collect(),
954 ratified: false,
955 }
956 }
957
958 fn make_ratified(key: &str, value: &str, reason: &str) -> DecisionSummary {
959 let mut s = make_summary(key, value, reason, vec![]);
960 s.authority = "operator".to_string();
961 s.ratified = true;
962 s
963 }
964
965 fn make_pack(groups: Vec<(&str, Vec<DecisionSummary>)>, branch: &str) -> DecisionPack {
966 let total: usize = groups.iter().map(|(_, ds)| ds.len()).sum();
967 DecisionPack {
968 groups: groups
969 .into_iter()
970 .map(|(domain, decisions)| DecisionGroup {
971 domain: domain.to_string(),
972 decisions,
973 })
974 .collect(),
975 total,
976 branch: branch.to_string(),
977 }
978 }
979
980 #[test]
981 fn test_empty_pack() {
982 let pack = DecisionPack {
983 groups: Vec::new(),
984 total: 0,
985 branch: "main".to_string(),
986 };
987 let md = render_decision_pack_md(&pack);
988 assert!(md.is_empty());
989 }
990
991 #[test]
992 fn test_full_pack_grouped_by_domain() {
993 let pack = make_pack(
994 vec![
995 (
996 "auth",
997 vec![make_summary("auth.strategy", "JWT", "stateless", vec![])],
998 ),
999 (
1000 "db",
1001 vec![
1002 make_summary("db.engine", "sqlite", "embedded", vec![]),
1003 make_summary("db.pool", "r2d2", "connection pooling", vec![]),
1004 ],
1005 ),
1006 (
1007 "error",
1008 vec![
1009 make_summary("error.lib", "thiserror", "typed errors", vec![]),
1010 make_summary("error.pattern", "enum", "exhaustive", vec![]),
1011 ],
1012 ),
1013 ],
1014 "main",
1015 );
1016
1017 assert_eq!(pack.groups.len(), 3);
1018 assert_eq!(pack.total, 5);
1019
1020 let md = render_decision_pack_md(&pack);
1021 assert!(md.contains("## Unratified Decisions (5 on `main`)"));
1023 assert!(!md.contains("## Ratified Decisions"));
1024 assert!(md.contains("### auth"));
1025 assert!(md.contains("### db"));
1026 assert!(md.contains("### error"));
1027 }
1028
1029 #[test]
1030 fn two_tier_splits_ratified_and_unratified() {
1031 let pack = make_pack(
1032 vec![
1033 ("db", vec![make_ratified("db.engine", "postgres", "JSONB")]),
1034 (
1035 "api",
1036 vec![make_summary("api.style", "REST", "compat", vec![])],
1037 ),
1038 ],
1039 "main",
1040 );
1041 let md = render_decision_pack_md(&pack);
1042 assert!(md.contains("## Ratified Decisions (1 on `main`)"));
1043 assert!(md.contains("## Unratified Decisions (1 on `main`)"));
1044 let r = md.find("## Ratified Decisions").unwrap();
1046 let u = md.find("## Unratified Decisions").unwrap();
1047 assert!(r < u, "ratified tier must render first");
1048 assert!(md.contains("**`db.engine=postgres`**"));
1050 assert!(md.contains("[agent] **`api.style=REST`**"));
1051 }
1052
1053 #[test]
1054 fn all_ratified_omits_unratified_section() {
1055 let pack = make_pack(
1056 vec![("db", vec![make_ratified("db.engine", "pg", "r")])],
1057 "main",
1058 );
1059 let md = render_decision_pack_md(&pack);
1060 assert!(md.contains("## Ratified Decisions (1 on `main`)"));
1061 assert!(!md.contains("## Unratified Decisions"));
1062 }
1063
1064 #[test]
1065 fn test_domain_grouping_order() {
1066 let pack = make_pack(
1067 vec![
1068 ("a_first", vec![make_summary("a_first.x", "1", "r", vec![])]),
1069 (
1070 "m_middle",
1071 vec![make_summary("m_middle.x", "2", "r", vec![])],
1072 ),
1073 ("z_test", vec![make_summary("z_test.x", "3", "r", vec![])]),
1074 ],
1075 "main",
1076 );
1077
1078 let md = render_decision_pack_md(&pack);
1079 let a_pos = md.find("### a_first").unwrap();
1080 let m_pos = md.find("### m_middle").unwrap();
1081 let z_pos = md.find("### z_test").unwrap();
1082 assert!(a_pos < m_pos);
1083 assert!(m_pos < z_pos);
1084 }
1085
1086 #[test]
1087 fn test_render_with_paths() {
1088 let pack = make_pack(
1089 vec![(
1090 "db",
1091 vec![make_summary(
1092 "db.engine",
1093 "sqlite",
1094 "embedded",
1095 vec!["crates/foo/**", "src/**"],
1096 )],
1097 )],
1098 "main",
1099 );
1100
1101 let md = render_decision_pack_md(&pack);
1102 assert!(md.contains("paths: `crates/foo/**`, `src/**`"));
1103 }
1104
1105 #[test]
1106 fn test_render_without_reason() {
1107 let pack = make_pack(
1108 vec![("db", vec![make_summary("db.engine", "sqlite", "", vec![])])],
1109 "main",
1110 );
1111
1112 let md = render_decision_pack_md(&pack);
1113 assert!(md.contains("**`db.engine=sqlite`**"));
1114 let decision_line = md.lines().find(|l| l.contains("db.engine=sqlite")).unwrap();
1116 assert!(!decision_line.contains(" — "));
1117 }
1118
1119 #[test]
1120 fn test_build_decision_pack_nonexistent_repo() {
1121 let pack = build_decision_pack(Path::new("/nonexistent/path"), "main", 7);
1123 assert_eq!(pack.total, 0);
1124 assert!(pack.groups.is_empty());
1125 assert_eq!(render_decision_pack_md(&pack), "");
1126 }
1127
1128 #[test]
1129 fn build_decision_pack_derives_ratified_from_real_ledger() {
1130 let tmp = tempfile::tempdir().unwrap();
1137 let root = tmp.path();
1138 let ledger = edda_ledger::Ledger::open_or_init(root).unwrap();
1139
1140 let decide = |key: &str, value: &str| {
1141 let parent = ledger.last_event_hash().unwrap();
1142 let dp = edda_core::types::DecisionPayload {
1143 key: key.into(),
1144 value: value.into(),
1145 reason: None,
1146 scope: None,
1147 authority: Some("agent".into()),
1148 affected_paths: None,
1149 tags: None,
1150 review_after: None,
1151 reversibility: None,
1152 village_id: None,
1153 };
1154 let ev = edda_core::event::new_decision_event("main", parent.as_deref(), "worker", &dp)
1155 .unwrap();
1156 ledger.append_event(&ev).unwrap();
1157 };
1158 decide("db.engine", "postgres");
1159 decide("api.style", "REST");
1160
1161 let parent = ledger.last_event_hash().unwrap();
1165 let mut rat = edda_core::event::new_decision_ratify_event(
1166 "main",
1167 parent.as_deref(),
1168 "db.engine",
1169 "operator",
1170 None,
1171 )
1172 .unwrap();
1173 rat.ts = "2000-01-01T00:00:00Z".into();
1174 edda_core::event::finalize_event(&mut rat).unwrap();
1175 ledger.append_event(&rat).unwrap();
1176
1177 let pack = build_decision_pack(root, "main", 10);
1178 let md = render_decision_pack_md(&pack);
1179
1180 assert!(md.contains("## Ratified Decisions (1 on `main`)"));
1181 assert!(md.contains("**`db.engine=postgres`**"));
1182 assert!(md.contains("## Unratified Decisions (1 on `main`)"));
1183 assert!(md.contains("[agent] **`api.style=REST`**"));
1184 }
1185}