Skip to main content

edda_pack/
lib.rs

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; // 8MB
9const DEFAULT_PACK_TURNS: usize = 12;
10const DEFAULT_PACK_BUDGET_CHARS: usize = 12000;
11
12// ── Turn + ToolUse structs ──
13
14#[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
41// ── Turn alignment via uuid/parentUuid ──
42
43/// Build turns from index records by matching assistant.parentUuid → user.uuid.
44pub 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    // Build lookup by uuid
73    let by_uuid: HashMap<String, &IndexRecordV1> =
74        records.iter().map(|r| (r.uuid.clone(), r)).collect();
75
76    // Collect assistant records in order
77    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    // Process newest assistant first
90    for asst_rec in assistants.iter().rev() {
91        if turns.len() >= max_turns {
92            break;
93        }
94
95        // Walk UP the parentUuid chain to find the real user prompt.
96        // Claude Code transcript structure:
97        //   user(STRING) → assistant(tool_use) → user(tool_result) → assistant(tool_use) → ... → assistant(text)
98        // We start from the leaf assistant and walk up to find the root user with STRING content.
99        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                // Try to extract user text from this record
120                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; // Found the real user prompt
129                        }
130                    }
131                }
132                // Content is array (tool_result) or empty → keep walking up
133                current_parent = parent_rec.parent_uuid.as_deref();
134            } else if parent_rec.record_type == "assistant" {
135                // Intermediate assistant → collect its tool_uses
136                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; // unexpected record type
147            }
148        }
149
150        if real_user_text.is_empty() || real_user_uuid.is_empty() {
151            continue;
152        }
153
154        // Dedup: only one turn per real user prompt
155        if !seen_user_uuids.insert(real_user_uuid.clone()) {
156            continue;
157        }
158
159        // Parse final (leaf) assistant content
160        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        // Merge tool_uses: chain (reversed to chronological) + final assistant's
172        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
187/// Extract user text from a transcript user record.
188/// Returns non-empty string only for real user prompts (STRING content).
189/// Returns empty for tool_result arrays (these are tool execution results, not user input).
190fn 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    // String content → real user prompt
197    if let Some(s) = content.as_str() {
198        return s.to_string();
199    }
200
201    // Array content → check block types
202    if let Some(arr) = content.as_array() {
203        // If any block is tool_result, this is NOT a real user prompt
204        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        // Extract text from text blocks (handles ARRAY(text) format)
212        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// ── Pack rendering ──
285
286/// The stable section order for neutral memory-pack items.
287#[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/// A complete, neutral item in a memory pack.
315#[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
323/// Convert checkpoint events into neutral hot-pack items.
324pub 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
455/// Render neutral items in deterministic section and salience order.
456///
457/// Items are selected by salience, with stable tie-breakers, and are either
458/// included in full or omitted. The dropped count is part of the pack header.
459pub 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    // The count is in the header, so a digit-boundary change can affect fit.
469    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
510/// Render turns into a hot pack without truncating any turn item.
511pub 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
526/// Write hot.md and hot.meta.json to the packs directory.
527pub 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
539// ── Doctrine Pack (judgment layer) ──
540
541const DEFAULT_DOCTRINE_FILE: &str = ".havamal-pack.md";
542
543/// Read the project's doctrine hot pack (judgment layer).
544///
545/// Contract with havamal (github.com/fagemx/havamal): the project curates
546/// judgment — ideology, failure memory, taste — as doctrine files and
547/// generates a compressed pack via `havamal pack --out .havamal-pack.md`.
548/// edda transports that pack into the session. edda never generates judgment
549/// itself: machine-extracted judgment without curation is noise; facts flow
550/// automatically, judgment enters signed.
551///
552/// Resolution order:
553/// 1. `EDDA_DOCTRINE_PATH` env var (absolute, or relative to `repo_root`)
554/// 2. `<repo_root>/.havamal-pack.md`
555///
556/// Returns `None` when no doctrine source exists or the file is empty.
557/// Content is truncated at `budget` bytes on a char boundary.
558pub 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// ── Decision Pack ──
591
592/// A pack of active decisions grouped by domain, ready for session injection.
593#[derive(Debug, Clone)]
594pub struct DecisionPack {
595    /// Decisions grouped by domain (e.g., "db", "error", "auth")
596    pub groups: Vec<DecisionGroup>,
597    /// Total number of decisions included
598    pub total: usize,
599    /// Branch these decisions are scoped to
600    pub branch: String,
601}
602
603/// A group of decisions sharing the same domain prefix.
604#[derive(Debug, Clone)]
605pub struct DecisionGroup {
606    /// Domain name (e.g., "db", "error", "auth")
607    pub domain: String,
608    /// Decisions in this domain, sorted by key
609    pub decisions: Vec<DecisionSummary>,
610}
611
612/// Minimal decision summary for pack rendering (avoids carrying full DecisionView).
613#[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    /// Operator-ratified (GH-401). Derived from `decision_ratify` events at
623    /// build time — `From<&DecisionView>` alone cannot know it, so callers
624    /// that have ratified-state must set it explicitly.
625    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
643/// Build a decision pack from active decisions in the ledger.
644///
645/// Queries active decisions (status IN active, experimental) on the given
646/// branch, groups by domain, and limits to `max_items` total decisions.
647///
648/// Returns a pack with 0 groups if no active decisions exist.
649pub 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            // Fetch ALL active decisions (not SQL-limited): active_decisions is
653            // not branch-filtered, so a SQL LIMIT applied before the branch
654            // retain below could be entirely consumed by other branches and
655            // drop every decision for this branch. Filter by branch first,
656            // then cap at max_items.
657            Ok(ledger) => (
658                ledger
659                    .active_decisions(None, None, None, None)
660                    .unwrap_or_default(),
661                // GH-401: ratified decision event_ids, derived by rowid order.
662                ledger.ratified_decision_events().unwrap_or_default(),
663            ),
664            Err(_) => (Vec::new(), std::collections::BTreeSet::new()),
665        };
666    // Keep only this branch's decisions (see above), then cap at max_items so
667    // a decision — and its ratified-state — from another branch is never
668    // rendered under this branch's header.
669    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    // Group by domain, limit to max_items total
681    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
709/// Render a decision pack as a markdown section, split into an
710/// operator-ratified (binding) tier and an unratified tier (GH-401).
711///
712/// Binding status comes from `decision_ratify` events (carried on each
713/// summary as `ratified`), never from the authority string — so the pack
714/// can never launder agent inference into operator authority. Each domain
715/// group is preserved within its tier; unratified lines are annotated with
716/// their authorship. Returns an empty string if the pack has 0 decisions.
717pub 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
754/// Render one tier (ratified or not) of a decision pack, preserving domain
755/// grouping. Unratified lines carry an authorship tag; ratified lines do not.
756fn 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    // ── Doctrine Pack tests ──
906
907    #[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    // ── Decision Pack tests ──
944
945    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        // All make_summary decisions are unratified → Unratified section.
1022        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        // Ratified renders before unratified.
1045        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        // Ratified line has no authorship tag; unratified line is tagged.
1049        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        // The decision line itself carries no reason separator (the header may).
1115        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        // Non-existent path should return empty pack
1122        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        // End-to-end: a real ledger with two decisions and one ratify event
1131        // must split into ratified/unratified through the full
1132        // ledger → ratified_decision_events → is_decision_ratified → render
1133        // chain. The ratify is given an EARLIER timestamp than the decisions
1134        // but appended last (highest rowid) — so a green result proves rowid,
1135        // not the timestamp, is authoritative.
1136        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        // Ratify db.engine, appended LAST but with an EARLIER timestamp than
1162        // the decisions — a timestamp model would call it stale/unratified;
1163        // the rowid model correctly binds it.
1164        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}