retro-core 2.1.5

Core library for retro, the active context curator for AI coding agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
use crate::models::{
    CompactPattern, CompactSession, CompactUserMessage, ContextSnapshot, KnowledgeNode, Pattern,
    Session,
};

const MAX_USER_MSG_LEN: usize = 500;
const MAX_USER_MSGS_PER_SESSION: usize = 300;
const MAX_PROMPT_CHARS: usize = 150_000;
const MAX_CONTEXT_SUMMARY_CHARS: usize = 5_000;

/// Build a compact summary of installed context for the analysis prompt.
/// Includes project skills, plugin skills, retro-managed CLAUDE.md rules, global agents,
/// and MEMORY.md notes (personal, informational only). Sections are omitted if empty.
/// Capped at 5K chars.
pub fn build_context_summary(snapshot: &ContextSnapshot) -> String {
    let mut sections: Vec<String> = Vec::new();

    // Project skills (name + description from frontmatter)
    let project_skills: Vec<(String, String)> = snapshot
        .skills
        .iter()
        .filter_map(|s| crate::ingest::context::parse_skill_frontmatter(&s.content))
        .collect();

    if !project_skills.is_empty() {
        let mut section = "### Project Skills\n".to_string();
        for (name, desc) in &project_skills {
            section.push_str(&format!("- {name}: {desc}\n"));
        }
        sections.push(section);
    }

    // Plugin skills
    if !snapshot.plugin_skills.is_empty() {
        let mut section = "### Plugin Skills\n".to_string();
        for ps in &snapshot.plugin_skills {
            section.push_str(&format!("- [{}] {}: {}\n", ps.plugin_name, ps.skill_name, ps.description));
        }
        sections.push(section);
    }

    // Existing retro-managed CLAUDE.md rules
    if let Some(ref claude_md) = snapshot.claude_md {
        if let Some(rules) = crate::projection::claude_md::read_managed_section(claude_md) {
            if !rules.is_empty() {
                let mut section = "### Existing CLAUDE.md Rules (retro-managed)\n".to_string();
                for rule in &rules {
                    section.push_str(&format!("- {rule}\n"));
                }
                sections.push(section);
            }
        }
    }

    // Global agents
    if !snapshot.global_agents.is_empty() {
        let mut section = "### Global Agents\n".to_string();
        for agent in &snapshot.global_agents {
            // Extract just the filename without extension
            let name = std::path::Path::new(&agent.path)
                .file_stem()
                .map(|s| s.to_string_lossy().to_string())
                .unwrap_or_else(|| agent.path.clone());
            section.push_str(&format!("- {name}\n"));
        }
        sections.push(section);
    }

    // MEMORY.md (personal notes Claude Code wrote for itself)
    if let Some(ref memory) = snapshot.memory_md {
        if !memory.trim().is_empty() {
            let mut section = "### MEMORY.md (personal, not shared with team)\n".to_string();
            section.push_str(memory);
            section.push('\n');
            sections.push(section);
        }
    }

    let mut result = sections.join("\n");

    // Cap at budget — truncate plugin skills section first if over
    if result.len() > MAX_CONTEXT_SUMMARY_CHARS {
        // Try without plugin skills
        sections.retain(|s| !s.starts_with("### Plugin Skills"));
        result = sections.join("\n");
    }

    if result.len() > MAX_CONTEXT_SUMMARY_CHARS {
        // Hard truncate at char boundary
        let mut i = MAX_CONTEXT_SUMMARY_CHARS;
        while i > 0 && !result.is_char_boundary(i) {
            i -= 1;
        }
        result.truncate(i);
    }

    result
}

/// Build the pattern discovery prompt for a batch of sessions.
///
/// When `full_management` is `true`, appends additional instructions asking the AI
/// to examine the full CLAUDE.md and propose `claude_md_edits` (add/remove/reword/move).
pub fn build_analysis_prompt(
    sessions: &[Session],
    existing_patterns: &[Pattern],
    context_summary: Option<&str>,
    full_management: bool,
) -> String {
    let mut compact_sessions: Vec<CompactSession> = sessions.iter().map(to_compact_session).collect();
    let compact_patterns = existing_patterns.iter().map(to_compact_pattern).collect::<Vec<_>>();

    let patterns_json =
        serde_json::to_string_pretty(&compact_patterns).unwrap_or_else(|_| "[]".to_string());

    let context_section = match context_summary {
        Some(summary) if !summary.is_empty() => format!(
            r#"

## Installed Context

The following context is already installed for this project.

**Important:** MEMORY.md contains personal notes that Claude Code wrote for itself — these are NOT shared with the team. If a pattern overlaps with MEMORY.md content but would benefit the team as a shared rule or skill, **still create it** (do not mark as `db_only`). MEMORY.md overlap only justifies `db_only` for patterns targeting `global_agent`. For all other installed context (skills, CLAUDE.md rules, agents), overlap means the pattern is already covered — skip it or mark `db_only`.

{summary}
"#
        ),
        _ => String::new(),
    };

    // Estimate base prompt size (template + patterns + context), then fit as many sessions as possible
    let base_size = 3000 + patterns_json.len() + context_section.len();
    let budget = MAX_PROMPT_CHARS.saturating_sub(base_size);

    // Progressively drop sessions from the end until we fit
    let mut sessions_json = serde_json::to_string_pretty(&compact_sessions).unwrap_or_else(|_| "[]".to_string());
    while sessions_json.len() > budget && compact_sessions.len() > 1 {
        compact_sessions.pop();
        sessions_json = serde_json::to_string_pretty(&compact_sessions).unwrap_or_else(|_| "[]".to_string());
    }

    let mut prompt = format!(
        r#"You are an expert at analyzing AI coding agent session histories to discover **real, recurring patterns**.

A pattern is a behavior, preference, or workflow that appears in **2 or more sessions**. A single occurrence is just an observation — not a pattern. Your job is to find things worth automating because they keep happening.

Analyze the following session data from Claude Code conversations. Look for:

1. **Repetitive Instructions** — Things the user tells the agent across **multiple sessions** (e.g., "always use uv not pip", "run clippy before committing"). The same instruction given once is not a pattern — it becomes one when it recurs.

2. **Recurring Mistakes** — The same **class** of error the agent makes in **multiple sessions** (e.g., using the wrong API, forgetting edge cases, picking the wrong tool). A bug encountered and fixed once is not a recurring mistake.

3. **Workflow Patterns** — Specific multi-step procedures the user guides the agent through in **multiple sessions** (e.g., "first run lint, then test, then commit with this message format"). A workflow followed once for a particular task is not a pattern.

4. **Explicit Directives** — When the user uses strong directive language like **"always"**, **"never"**, **"must"**, or **"don't ever"**, they are explicitly stating a project rule or convention. These are high-confidence signals even from a single session. Examples:
   - "Always create API routes using the router factory pattern"
   - "Never import directly from internal modules, use the public API"
   - "You must run migrations before testing"
   These are typically project-specific conventions about how code should be written, not workflow preferences. They belong in `claude_md`.

## What is NOT a pattern

Do NOT report any of the following:
- **One-time bug fixes** — A bug that was encountered and resolved in a single session
- **Task-specific instructions** — Directions that only applied to one particular task and are not general preferences

## Confidence calibration

Confidence reflects how certain you are this is a real, recurring pattern:
- **Explicit directive (single session)**: When the user uses "always", "never", "must", or similar imperative language to state a rule, report with confidence **0.7-0.85** even from a single session. The directive language itself is strong evidence this is a standing rule, not a one-time instruction. Target: `claude_md`.
- **Seen in 1 session only (no directive language)**: Report with confidence 0.4-0.5 if the signal is clear and specific. These are stored as candidate observations and will be confirmed when the behavior recurs in a future session. Do NOT report vague or ambiguous single-session observations.
- **Seen in 2 sessions**: Confidence 0.6-0.75 depending on how clear and specific the pattern is.
- **Seen in 3+ sessions**: Confidence 0.7-1.0.

**suggested_target** — where should this pattern be projected?
- `claude_md` — Simple rules, project conventions, or explicit directives ("always do X", "never do Y"). Explicit directives qualify from a single session. Other rules require 2+ sessions.
- `skill` — Multi-step procedures or complex workflows. Requires evidence from 2+ sessions.
- `global_agent` — Cross-project personal preferences. Requires evidence from 2+ sessions.
- `db_only` — Already covered by installed context (skill, plugin, CLAUDE.md rule, or agent). Use this when a real pattern exists but is already handled.

## Existing Patterns

These patterns have already been discovered. **Before creating any "new" pattern, carefully check each existing pattern below.** If a new finding is about the same topic, behavior, or user preference as an existing pattern — even if the wording is completely different — you MUST use "update" with the existing pattern's ID rather than creating a new pattern.

Examples of patterns that should be merged (same topic, different wording):
- "User repeatedly asks to update docs after completing each phase" ↔ "After each phase completion, user expects documentation updates"
- "Always run tests before committing" ↔ "User insists on running the test suite prior to any git commit"
- "Use uv instead of pip" ↔ "User prefers uv as the Python package manager, not pip"

When in doubt, prefer "update" over "new" — duplicate patterns are worse than missed ones.

```json
{patterns_json}
```
{context_section}
## Session Data

```json
{sessions_json}
```

## Response Format

Return a JSON object with a "reasoning" string and a "patterns" array. Begin with a "reasoning" field: a 1-2 sentence summary of what you observed across the sessions and why you did or didn't find patterns. Each element of the "patterns" array is either a new pattern or an update to an existing one:

```json
{{
  "reasoning": "Sessions contained mostly one-off bug fixes with no recurring themes. One explicit directive about testing was found.",
  "patterns": [
    {{
      "action": "new",
      "pattern_type": "repetitive_instruction",
      "description": "Clear description of what was observed across sessions",
      "confidence": 0.85,
      "source_sessions": ["session-id-1", "session-id-2"],
      "related_files": ["path/to/relevant/file"],
      "suggested_content": "The rule or instruction to add (e.g., 'Always run cargo clippy -- -D warnings before committing')",
      "suggested_target": "claude_md"
    }},
    {{
      "action": "update",
      "existing_id": "existing-pattern-uuid",
      "new_sessions": ["session-id-3"],
      "new_confidence": 0.92
    }}
  ]
}}
```

Important:
- **Quality over quantity** — fewer strong patterns are better than many weak ones. When in doubt, skip it.
- Strong patterns require evidence from **2+ sessions**. Single-session observations may be reported only if the signal is clear and specific (confidence 0.4-0.5).
- Only return patterns with confidence >= 0.4
- Be specific in descriptions — vague patterns like "user prefers clean code" are useless. State the concrete behavior.
- For `suggested_content`, write the actual rule or instruction as it should appear in the target
- CRITICAL: Do not create duplicate patterns. Two patterns about the same underlying behavior are duplicates even if described differently. Always check existing patterns for semantic overlap, not just textual similarity.
- Do not suggest skills or rules that duplicate installed plugin functionality
- CRITICAL: Return ONLY the raw JSON object. No prose, no explanation, no markdown formatting, no commentary before or after. Your entire response must be parseable as a single JSON object starting with {{ and ending with }}. If no patterns found, return {{"reasoning": "your observation summary", "patterns": []}}"#
    );

    if full_management {
        prompt.push_str(r#"

## CLAUDE.md Edits (full_management mode)

In addition to discovering patterns, examine the FULL CLAUDE.md content provided in the Installed Context section above. Propose edits to improve clarity, accuracy, and organization. Return these as a `claude_md_edits` array in your JSON response (alongside `reasoning` and `patterns`).

Each edit is an object with:
- `edit_type`: one of `add`, `remove`, `reword`, `move`
  - `add` — add new content (provide `suggested_content` and `target_section`)
  - `remove` — remove stale, redundant, or incorrect content (provide `original_text`)
  - `reword` — improve existing content (provide `original_text` and `suggested_content`)
  - `move` — relocate content to a better section (provide `original_text` and `target_section`)
- `original_text`: the existing text being edited (required for remove/reword/move)
- `suggested_content`: the new or replacement text (required for add/reword)
- `target_section`: which section the content belongs in (required for add/move)
- `reasoning`: why this edit improves the CLAUDE.md

Only propose edits for genuine improvements — not cosmetic or stylistic changes. Focus on:
- Removing stale or outdated information
- Fixing contradictions or inaccuracies
- Adding missing information discovered from session patterns
- Improving organization (moving content to more logical sections)
- Rewording unclear or ambiguous instructions

If no edits are needed, omit `claude_md_edits` or return an empty array."#);
    }

    prompt
}

/// Build the context audit prompt for redundancy/contradiction detection.
pub fn build_audit_prompt(
    claude_md: Option<&str>,
    skills: &[(String, String)],
    memory_md: Option<&str>,
    agents: &[(String, String)],
) -> String {
    let claude_md_section = match claude_md {
        Some(content) => format!("### CLAUDE.md\n```\n{content}\n```"),
        None => "### CLAUDE.md\n(not present)".to_string(),
    };

    let skills_section = if skills.is_empty() {
        "### Skills\n(none)".to_string()
    } else {
        let mut s = "### Skills\n".to_string();
        for (path, content) in skills {
            s.push_str(&format!("**{path}**:\n```\n{content}\n```\n\n"));
        }
        s
    };

    let memory_section = match memory_md {
        Some(content) => format!("### MEMORY.md\n```\n{content}\n```"),
        None => "### MEMORY.md\n(not present)".to_string(),
    };

    let agents_section = if agents.is_empty() {
        "### Global Agents\n(none)".to_string()
    } else {
        let mut s = "### Global Agents\n".to_string();
        for (path, content) in agents {
            s.push_str(&format!("**{path}**:\n```\n{content}\n```\n\n"));
        }
        s
    };

    format!(
        r#"You are an expert at reviewing AI coding agent context for quality and consistency.

Review the following context files used by Claude Code. Look for:

1. **Redundant** — Same information appears in multiple places (e.g., a rule in CLAUDE.md and a skill that says the same thing). Suggest consolidation.

2. **Contradictory** — Conflicting instructions across files (e.g., one says "use pip" and another says "use uv"). Flag for review.

3. **Oversized** — CLAUDE.md or skills that are excessively long and should be broken up or consolidated.

4. **Stale** — Rules or skills that reference outdated tools, deprecated patterns, or things that no longer apply.

## Context Files

{claude_md_section}

{skills_section}

{memory_section}

{agents_section}

## Response Format

Return a JSON object with a "findings" array:

```json
{{
  "findings": [
    {{
      "finding_type": "redundant",
      "description": "Clear description of what's redundant/contradictory/etc",
      "affected_items": ["CLAUDE.md", ".claude/skills/some-skill/SKILL.md"],
      "suggestion": "Specific suggestion for how to fix this"
    }}
  ]
}}
```

Important:
- Only report genuine issues, not minor style differences
- Be specific about which files and which content is affected
- Return ONLY the JSON object, no other text
- If no issues found, return {{"findings": []}}"#
    )
}

/// Build the curate prompt for agentic CLAUDE.md rewrite.
///
/// The AI receives the current CLAUDE.md, discovered patterns, MEMORY.md (if available),
/// and a project file tree. It is instructed to explore the codebase and produce a
/// complete improved CLAUDE.md.
pub fn build_curate_prompt(
    claude_md: &str,
    patterns: &[Pattern],
    memory_md: Option<&str>,
    project_tree: &str,
) -> String {
    let patterns_section = if patterns.is_empty() {
        "(no patterns discovered yet)".to_string()
    } else {
        let mut s = String::new();
        for p in patterns {
            s.push_str(&format!(
                "- [confidence={:.2}] [{}] {}\n",
                p.confidence,
                p.suggested_target,
                p.description
            ));
            if !p.suggested_content.is_empty() {
                s.push_str(&format!("  suggested: {}\n", p.suggested_content));
            }
        }
        s
    };

    let memory_section = match memory_md {
        Some(content) if !content.trim().is_empty() => format!(
            r#"
## MEMORY.md

MEMORY.md contains personal notes that Claude Code wrote for itself. This is read-only
context — do NOT copy it verbatim into CLAUDE.md. Use it to understand the developer's
preferences and project conventions.

```
{content}
```
"#
        ),
        _ => String::new(),
    };

    let claude_md_section = if claude_md.is_empty() {
        "(CLAUDE.md does not exist yet — create one from scratch)".to_string()
    } else {
        format!("```markdown\n{claude_md}\n```")
    };

    format!(
        r#"You are an expert at writing CLAUDE.md files — the project-level instruction files for Claude Code, an AI coding agent.

Your task: produce a complete, improved CLAUDE.md for this project. You have access to tools to explore the codebase. Use them to understand the project structure, conventions, build system, test patterns, and any other relevant details.

## Current CLAUDE.md

{claude_md_section}

## Discovered Patterns

These patterns were discovered by analyzing the developer's Claude Code session history. They represent real, recurring behaviors, preferences, and conventions. Incorporate the important ones into the new CLAUDE.md.

{patterns_section}
{memory_section}
## Project File Tree

```
{project_tree}
```

## Instructions

1. **Explore the codebase** using your tools. Read key files: build configs (Cargo.toml, package.json, pyproject.toml, etc.), test files, CI configs, source code structure. Understand the project deeply.

2. **Produce a complete CLAUDE.md** that includes:
   - Project overview and purpose
   - Architecture and structure
   - Build/test/run commands
   - Key design decisions and conventions
   - Coding standards and patterns specific to this project
   - Dependency information
   - Any other information that would help an AI coding agent work effectively on this project

3. **Incorporate discovered patterns** where they add value. Not all patterns need to go in CLAUDE.md — only those that represent project conventions, rules, or important context.

4. **Improve on the existing CLAUDE.md** if one exists:
   - Fix inaccuracies by checking the actual codebase
   - Remove stale or outdated information
   - Add missing sections that would be useful
   - Improve organization and clarity
   - Keep valuable content that is accurate

5. **Output format**: Return ONLY the new CLAUDE.md content — raw markdown, no wrapping code fences, no explanation before or after. Your entire response should be the new CLAUDE.md file content, ready to write to disk."#
    )
}

/// Build the v2 analysis prompt with graph context and scope classification instructions.
pub fn build_graph_analysis_prompt(
    sessions: &[CompactSession],
    existing_nodes: &[KnowledgeNode],
    project: Option<&str>,
) -> String {
    let mut prompt = String::new();

    prompt.push_str("You are analyzing coding session transcripts to discover patterns, rules, preferences, and skills.\n\n");

    prompt.push_str("## Scope Classification\n\n");
    prompt.push_str("For each piece of knowledge, classify its scope:\n");
    prompt.push_str("- **global**: Personal style, communication preferences, general coding habits (e.g., 'always use snake_case', 'prefer concise responses')\n");
    prompt.push_str("- **project**: Code-specific conventions, architecture decisions, project tooling (e.g., 'this project uses SQLite WAL mode', 'run cargo test before committing')\n");
    prompt.push_str("- When ambiguous, default to **project**\n\n");

    prompt.push_str("## Node Types\n\n");
    prompt.push_str("- **preference**: How the user likes things done\n");
    prompt.push_str("- **pattern**: Observed recurring behavior\n");
    prompt.push_str("- **rule**: An explicit directive from the user\n");
    prompt.push_str("- **skill**: A reusable capability or workflow\n");
    prompt.push_str("- **memory**: Factual context about the project or user\n");
    prompt.push_str("- **directive**: Strong instruction ('always'/'never'/'must')\n\n");

    // Include existing knowledge for dedup and relationship detection
    if !existing_nodes.is_empty() {
        prompt.push_str("## Existing Knowledge\n\n");
        for node in existing_nodes.iter().take(50) {
            prompt.push_str(&format!(
                "- [{}] {} ({}) conf={:.2}: {}\n",
                node.id,
                node.node_type,
                node.scope,
                node.confidence,
                crate::util::truncate_str(&node.content, 200),
            ));
        }
        prompt.push_str("\n");
        prompt.push_str("If a session reinforces existing knowledge, emit an update_node with higher confidence.\n");
        prompt.push_str("If new knowledge contradicts existing, note it but still create the new node.\n");
        prompt.push_str("If new knowledge is semantically identical to existing, emit merge_nodes.\n\n");
    }

    // Include sessions
    prompt.push_str("## Sessions to Analyze\n\n");
    let sessions_json = serde_json::to_string_pretty(&sessions).unwrap_or_default();
    prompt.push_str(&sessions_json);
    prompt.push_str("\n\n");

    if let Some(proj) = project {
        prompt.push_str(&format!("Current project: {proj}\n\n"));
    }

    prompt.push_str("## Instructions\n\n");
    prompt.push_str("Analyze these sessions and emit graph operations:\n");
    prompt.push_str("- create_node: New knowledge discovered\n");
    prompt.push_str("- update_node: Existing knowledge reinforced (bump confidence)\n");
    prompt.push_str("- create_edge: Relationship between nodes (supports, derived_from)\n");
    prompt.push_str("- merge_nodes: Duplicate knowledge detected\n\n");
    prompt.push_str("Be selective. Only emit operations for clear, actionable knowledge. Prefer fewer high-quality nodes over many weak ones.\n");
    prompt.push_str("Explicit user directives ('always', 'never', 'must') get confidence 0.7-0.85.\n");
    prompt.push_str("Single-session observations get confidence 0.4-0.5.\n");

    prompt
}

pub fn to_compact_session(session: &Session) -> CompactSession {
    let user_messages: Vec<CompactUserMessage> = session
        .user_messages
        .iter()
        .take(MAX_USER_MSGS_PER_SESSION)
        .map(|m| CompactUserMessage {
            text: truncate_str(&m.text, MAX_USER_MSG_LEN),
            timestamp: m.timestamp.clone(),
        })
        .collect();

    let thinking_highlights: Vec<String> = session
        .assistant_messages
        .iter()
        .filter_map(|m| m.thinking_summary.clone())
        .collect();

    CompactSession {
        session_id: session.session_id.clone(),
        project: session.project.clone(),
        user_messages,
        tools_used: session.tools_used.clone(),
        errors: session.errors.clone(),
        thinking_highlights,
        summaries: session.summaries.clone(),
    }
}

fn to_compact_pattern(pattern: &Pattern) -> CompactPattern {
    CompactPattern {
        id: pattern.id.clone(),
        pattern_type: pattern.pattern_type.to_string(),
        description: pattern.description.clone(),
        confidence: pattern.confidence,
        times_seen: pattern.times_seen,
        suggested_target: pattern.suggested_target.to_string(),
    }
}

fn truncate_str(s: &str, max: usize) -> String {
    if s.len() <= max {
        return s.to_string();
    }
    // Find valid UTF-8 boundary
    let mut i = max;
    while i > 0 && !s.is_char_boundary(i) {
        i -= 1;
    }
    format!("{}...", &s[..i])
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::{AgentFile, PluginSkillSummary, SkillFile};

    #[test]
    fn test_build_audit_prompt_all_present() {
        let skills = vec![
            ("skills/lint/SKILL.md".to_string(), "lint skill content".to_string()),
        ];
        let agents = vec![
            ("agents/helper.md".to_string(), "helper agent content".to_string()),
        ];
        let prompt = build_audit_prompt(
            Some("# CLAUDE.md content"),
            &skills,
            Some("# MEMORY.md content"),
            &agents,
        );
        assert!(prompt.contains("# CLAUDE.md content"));
        assert!(prompt.contains("lint skill content"));
        assert!(prompt.contains("# MEMORY.md content"));
        assert!(prompt.contains("helper agent content"));
        assert!(prompt.contains("\"findings\""));
    }

    #[test]
    fn test_build_audit_prompt_none_present() {
        let prompt = build_audit_prompt(None, &[], None, &[]);
        assert!(prompt.contains("(not present)"));
        assert!(prompt.contains("(none)"));
    }

    #[test]
    fn test_build_audit_prompt_partial() {
        let prompt = build_audit_prompt(Some("rules here"), &[], None, &[]);
        assert!(prompt.contains("rules here"));
        assert!(prompt.contains("### MEMORY.md\n(not present)"));
        assert!(prompt.contains("### Skills\n(none)"));
    }

    fn empty_snapshot() -> ContextSnapshot {
        ContextSnapshot {
            claude_md: None,
            skills: Vec::new(),
            memory_md: None,
            global_agents: Vec::new(),
            plugin_skills: Vec::new(),
        }
    }

    #[test]
    fn test_build_context_summary_empty() {
        let snapshot = empty_snapshot();
        let summary = build_context_summary(&snapshot);
        assert!(summary.is_empty());
    }

    #[test]
    fn test_build_context_summary_full() {
        let snapshot = ContextSnapshot {
            claude_md: Some("before\n<!-- retro:managed:start -->\n- Always use uv\n- Run cargo test\n<!-- retro:managed:end -->\nafter".to_string()),
            skills: vec![SkillFile {
                path: "skills/tdd/SKILL.md".to_string(),
                content: "---\nname: tdd\ndescription: Test-driven development workflow\n---\nbody".to_string(),
            }],
            memory_md: None,
            global_agents: vec![AgentFile {
                path: "/home/user/.claude/agents/code-reviewer.md".to_string(),
                content: "reviewer content".to_string(),
            }],
            plugin_skills: vec![PluginSkillSummary {
                plugin_name: "superpowers".to_string(),
                skill_name: "brainstorming".to_string(),
                description: "Explores user intent".to_string(),
            }],
        };
        let summary = build_context_summary(&snapshot);
        assert!(summary.contains("### Project Skills"));
        assert!(summary.contains("- tdd: Test-driven development workflow"));
        assert!(summary.contains("### Plugin Skills"));
        assert!(summary.contains("[superpowers] brainstorming: Explores user intent"));
        assert!(summary.contains("### Existing CLAUDE.md Rules (retro-managed)"));
        assert!(summary.contains("- Always use uv"));
        assert!(summary.contains("- Run cargo test"));
        assert!(summary.contains("### Global Agents"));
        assert!(summary.contains("- code-reviewer"));
    }

    #[test]
    fn test_build_context_summary_no_managed_section() {
        let snapshot = ContextSnapshot {
            claude_md: Some("# My CLAUDE.md\nNo managed section here.".to_string()),
            skills: Vec::new(),
            memory_md: None,
            global_agents: Vec::new(),
            plugin_skills: Vec::new(),
        };
        let summary = build_context_summary(&snapshot);
        // No sections should appear
        assert!(summary.is_empty());
    }

    #[test]
    fn test_build_context_summary_budget_cap() {
        // Create a snapshot with many plugin skills to exceed the 5K cap
        let mut plugin_skills = Vec::new();
        for i in 0..200 {
            plugin_skills.push(PluginSkillSummary {
                plugin_name: format!("plugin-{i}"),
                skill_name: format!("skill-with-a-long-name-{i}"),
                description: format!("A fairly long description for skill number {i} that takes up space"),
            });
        }
        let snapshot = ContextSnapshot {
            claude_md: None,
            skills: vec![SkillFile {
                path: "skills/my-skill/SKILL.md".to_string(),
                content: "---\nname: my-skill\ndescription: A project skill\n---\nbody".to_string(),
            }],
            memory_md: None,
            global_agents: Vec::new(),
            plugin_skills,
        };
        let summary = build_context_summary(&snapshot);
        assert!(summary.len() <= 5000);
        // Plugin skills should have been dropped, but project skills retained
        assert!(summary.contains("### Project Skills"));
        assert!(!summary.contains("### Plugin Skills"));
    }

    #[test]
    fn test_build_analysis_prompt_with_context() {
        let sessions = vec![Session {
            session_id: "sess-1".to_string(),
            project: "/test".to_string(),
            session_path: "/test/session.jsonl".to_string(),
            user_messages: vec![],
            assistant_messages: vec![],
            summaries: vec![],
            tools_used: vec![],
            errors: vec![],
            metadata: crate::models::SessionMetadata {
                cwd: None,
                version: None,
                git_branch: None,
                model: None,
            },
        }];
        let context = "### Plugin Skills\n- [superpowers] brainstorming: Explores intent\n";
        let prompt = build_analysis_prompt(&sessions, &[], Some(context), false);
        assert!(prompt.contains("## Installed Context"));
        assert!(prompt.contains("[superpowers] brainstorming"));
        assert!(prompt.contains("Already covered by installed context"));
        assert!(prompt.contains("Do not suggest skills or rules that duplicate installed plugin functionality"));
    }

    #[test]
    fn test_build_analysis_prompt_without_context() {
        let sessions = vec![Session {
            session_id: "sess-1".to_string(),
            project: "/test".to_string(),
            session_path: "/test/session.jsonl".to_string(),
            user_messages: vec![],
            assistant_messages: vec![],
            summaries: vec![],
            tools_used: vec![],
            errors: vec![],
            metadata: crate::models::SessionMetadata {
                cwd: None,
                version: None,
                git_branch: None,
                model: None,
            },
        }];
        let prompt = build_analysis_prompt(&sessions, &[], None, false);
        assert!(!prompt.contains("## Installed Context"));
        // Core prompt structure should still be there
        assert!(prompt.contains("## Existing Patterns"));
        assert!(prompt.contains("## Session Data"));
    }

    fn make_test_session() -> Session {
        Session {
            session_id: "sess-1".to_string(),
            project: "/test".to_string(),
            session_path: "/test/session.jsonl".to_string(),
            user_messages: vec![],
            assistant_messages: vec![],
            summaries: vec![],
            tools_used: vec![],
            errors: vec![],
            metadata: crate::models::SessionMetadata {
                cwd: None,
                version: None,
                git_branch: None,
                model: None,
            },
        }
    }

    #[test]
    fn test_build_analysis_prompt_full_management() {
        let sessions = vec![make_test_session()];
        let context = "### Existing CLAUDE.md Rules\n- Always use uv\n";
        let prompt = build_analysis_prompt(&sessions, &[], Some(context), true);
        assert!(
            prompt.contains("claude_md_edits"),
            "full_management=true should include claude_md_edits instructions"
        );
        assert!(prompt.contains("## CLAUDE.md Edits (full_management mode)"));
        assert!(prompt.contains("edit_type"));
        assert!(prompt.contains("original_text"));
        assert!(prompt.contains("suggested_content"));
        assert!(prompt.contains("target_section"));
    }

    #[test]
    fn test_build_analysis_prompt_no_full_management() {
        let sessions = vec![make_test_session()];
        let context = "### Existing CLAUDE.md Rules\n- Always use uv\n";
        let prompt = build_analysis_prompt(&sessions, &[], Some(context), false);
        assert!(
            !prompt.contains("claude_md_edits"),
            "full_management=false should NOT include claude_md_edits instructions"
        );
        assert!(!prompt.contains("## CLAUDE.md Edits (full_management mode)"));
    }

    #[test]
    fn test_build_curate_prompt() {
        use crate::models::{PatternStatus, PatternType, SuggestedTarget};
        use chrono::Utc;

        let claude_md = "# My Project\n\nSome existing content.";
        let now = Utc::now();
        let patterns = vec![Pattern {
            id: "pat-1".to_string(),
            pattern_type: PatternType::RepetitiveInstruction,
            description: "Always run cargo test before committing".to_string(),
            confidence: 0.85,
            times_seen: 3,
            first_seen: now,
            last_seen: now,
            last_projected: None,
            status: PatternStatus::Discovered,
            source_sessions: vec!["sess-1".to_string()],
            related_files: vec![],
            suggested_content: "Run cargo test before committing".to_string(),
            suggested_target: SuggestedTarget::ClaudeMd,
            project: Some("/test".to_string()),
            generation_failed: false,
        }];
        let memory = "User prefers concise commit messages.";
        let tree = "src/main.rs\nsrc/lib.rs\nCargo.toml";

        let prompt = build_curate_prompt(claude_md, &patterns, Some(memory), tree);

        // Check key sections are present
        assert!(prompt.contains("## Current CLAUDE.md"), "should have current CLAUDE.md section");
        assert!(prompt.contains("Some existing content"), "should include CLAUDE.md content");
        assert!(prompt.contains("## Discovered Patterns"), "should have patterns section");
        assert!(prompt.contains("Always run cargo test before committing"), "should include pattern description");
        assert!(prompt.contains("confidence=0.85"), "should include confidence");
        assert!(prompt.contains("## MEMORY.md"), "should have MEMORY.md section");
        assert!(prompt.contains("concise commit messages"), "should include MEMORY.md content");
        assert!(prompt.contains("## Project File Tree"), "should have file tree section");
        assert!(prompt.contains("src/main.rs"), "should include file tree entries");
        assert!(prompt.contains("Explore the codebase"), "should instruct AI to explore");
        assert!(prompt.contains("Produce a complete CLAUDE.md"), "should instruct AI to produce output");
    }

    #[test]
    fn test_build_curate_prompt_empty_claude_md() {
        let prompt = build_curate_prompt("", &[], None, "src/main.rs");
        assert!(prompt.contains("does not exist yet"), "should note missing CLAUDE.md");
        assert!(!prompt.contains("## MEMORY.md"), "should omit MEMORY.md section when not available");
        assert!(prompt.contains("no patterns discovered"), "should note no patterns");
    }
}