acp/commands/
primer.rs

1//! @acp:module "Primer Command"
2//! @acp:summary "Generate AI bootstrap primers with tiered content selection"
3//! @acp:domain cli
4//! @acp:layer handler
5//!
6//! RFC-0004: Tiered Interface Primers
7//! Generates token-efficient bootstrap text for AI agents.
8
9use std::cmp::Ordering;
10use std::path::PathBuf;
11
12use anyhow::Result;
13use console::style;
14use serde::{Deserialize, Serialize};
15
16use crate::cache::Cache;
17
18/// Options for the primer command
19#[derive(Debug, Clone)]
20pub struct PrimerOptions {
21    /// Token budget for the primer
22    pub budget: u32,
23    /// Required capabilities (e.g., "shell", "mcp")
24    pub capabilities: Vec<String>,
25    /// Output format
26    pub format: PrimerFormat,
27    /// Cache file path (for project warnings)
28    pub cache: Option<PathBuf>,
29    /// Output as JSON
30    pub json: bool,
31}
32
33/// Output format for primer
34#[derive(Debug, Clone, Copy, Default)]
35pub enum PrimerFormat {
36    #[default]
37    Text,
38    Json,
39}
40
41/// Tier level for content selection
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Tier {
44    Minimal,
45    Standard,
46    Full,
47}
48
49impl Tier {
50    /// Determine tier based on remaining budget
51    pub fn from_budget(remaining: u32) -> Self {
52        if remaining < 80 {
53            Tier::Minimal
54        } else if remaining < 300 {
55            Tier::Standard
56        } else {
57            Tier::Full
58        }
59    }
60}
61
62/// Bootstrap block content (~20 tokens)
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct Bootstrap {
65    pub awareness: String,
66    pub workflow: String,
67    pub expansion: String,
68    pub tokens: u32,
69}
70
71impl Default for Bootstrap {
72    fn default() -> Self {
73        Self {
74            awareness: "This project uses ACP. @acp:* comments are directives for you.".to_string(),
75            workflow: "Before editing: acp constraints <path>".to_string(),
76            expansion: "More: acp primer --budget N".to_string(),
77            tokens: 20,
78        }
79    }
80}
81
82/// Command documentation with tiered content
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct Command {
85    pub name: String,
86    pub critical: bool,
87    pub priority: u32,
88    pub capabilities: Vec<String>,
89    pub tiers: TierContent,
90}
91
92/// Tiered content for a command
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct TierContent {
95    pub minimal: TierLevel,
96    pub standard: Option<TierLevel>,
97    pub full: Option<TierLevel>,
98}
99
100/// Single tier level
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct TierLevel {
103    pub tokens: u32,
104    pub template: String,
105}
106
107/// Generated primer output
108#[derive(Debug, Clone, Serialize)]
109pub struct PrimerOutput {
110    pub total_tokens: u32,
111    pub tier: String,
112    pub commands_included: usize,
113    pub content: String,
114}
115
116/// Execute the primer command
117pub fn execute_primer(options: PrimerOptions) -> Result<()> {
118    let primer = generate_primer(&options)?;
119
120    if options.json || matches!(options.format, PrimerFormat::Json) {
121        println!("{}", serde_json::to_string_pretty(&primer)?);
122    } else {
123        println!("{}", primer.content);
124    }
125
126    Ok(())
127}
128
129/// Generate primer content based on budget and capabilities
130pub fn generate_primer(options: &PrimerOptions) -> Result<PrimerOutput> {
131    let bootstrap = Bootstrap::default();
132    let commands = get_default_commands();
133
134    // Filter commands by capabilities
135    let filtered_commands: Vec<&Command> = if options.capabilities.is_empty() {
136        commands.iter().collect()
137    } else {
138        commands
139            .iter()
140            .filter(|cmd| {
141                cmd.capabilities.is_empty()
142                    || cmd
143                        .capabilities
144                        .iter()
145                        .any(|cap| options.capabilities.contains(cap))
146            })
147            .collect()
148    };
149
150    // Sort by (critical desc, priority asc)
151    let mut sorted_commands = filtered_commands;
152    sorted_commands.sort_by(|a, b| match (a.critical, b.critical) {
153        (true, false) => Ordering::Less,
154        (false, true) => Ordering::Greater,
155        _ => a.priority.cmp(&b.priority),
156    });
157
158    // Calculate remaining budget after bootstrap
159    let remaining_budget = options.budget.saturating_sub(bootstrap.tokens);
160    let tier = Tier::from_budget(remaining_budget);
161
162    // Select commands within budget
163    let mut used_tokens = bootstrap.tokens;
164    let mut selected_commands: Vec<(&Command, &TierLevel)> = Vec::new();
165
166    for cmd in sorted_commands {
167        // Get the appropriate tier level
168        let tier_level = match tier {
169            Tier::Full => cmd
170                .tiers
171                .full
172                .as_ref()
173                .or(cmd.tiers.standard.as_ref())
174                .unwrap_or(&cmd.tiers.minimal),
175            Tier::Standard => cmd.tiers.standard.as_ref().unwrap_or(&cmd.tiers.minimal),
176            Tier::Minimal => &cmd.tiers.minimal,
177        };
178
179        let cmd_tokens = tier_level.tokens;
180
181        // Critical commands are always included
182        if cmd.critical || used_tokens + cmd_tokens <= options.budget {
183            used_tokens += cmd_tokens;
184            selected_commands.push((cmd, tier_level));
185        }
186    }
187
188    // Build output content
189    let mut content = String::new();
190
191    // Bootstrap block
192    content.push_str(&bootstrap.awareness);
193    content.push('\n');
194    content.push_str(&bootstrap.workflow);
195    content.push('\n');
196    content.push_str(&bootstrap.expansion);
197    content.push_str("\n\n");
198
199    // Commands
200    for (cmd, tier_level) in &selected_commands {
201        content.push_str(&format!("{}\n", style(&cmd.name).bold()));
202        content.push_str(&tier_level.template);
203        content.push_str("\n\n");
204    }
205
206    // Add project warnings if we have budget and cache
207    if let Some(cache_path) = &options.cache {
208        if cache_path.exists() && used_tokens + 30 < options.budget {
209            if let Ok(cache) = Cache::from_json(cache_path) {
210                let warnings = get_project_warnings(&cache);
211                if !warnings.is_empty() {
212                    content.push_str(&format!("{}\n", style("Project Warnings").bold()));
213                    for warning in warnings.iter().take(3) {
214                        content.push_str(&format!("  - {}\n", warning));
215                        used_tokens += 15;
216                        if used_tokens >= options.budget {
217                            break;
218                        }
219                    }
220                }
221            }
222        }
223    }
224
225    let tier_name = match tier {
226        Tier::Minimal => "minimal",
227        Tier::Standard => "standard",
228        Tier::Full => "full",
229    };
230
231    Ok(PrimerOutput {
232        total_tokens: used_tokens,
233        tier: tier_name.to_string(),
234        commands_included: selected_commands.len(),
235        content: content.trim().to_string(),
236    })
237}
238
239/// Get project-specific warnings from cache
240fn get_project_warnings(cache: &Cache) -> Vec<String> {
241    let mut warnings = Vec::new();
242
243    // Check for frozen/restricted symbols
244    for (name, symbol) in &cache.symbols {
245        if let Some(ref constraints) = symbol.constraints {
246            if constraints.level == "frozen" || constraints.level == "restricted" {
247                warnings.push(format!(
248                    "{}: {} ({})",
249                    name,
250                    constraints.level,
251                    constraints.directive.chars().take(50).collect::<String>()
252                ));
253            }
254        }
255    }
256
257    // Limit to most important
258    warnings.truncate(5);
259    warnings
260}
261
262/// Get the default command set for the primer
263fn get_default_commands() -> Vec<Command> {
264    vec![
265        Command {
266            name: "acp constraints <path>".to_string(),
267            critical: true,
268            priority: 1,
269            capabilities: vec!["shell".to_string()],
270            tiers: TierContent {
271                minimal: TierLevel {
272                    tokens: 8,
273                    template: "  Returns: lock level + directive".to_string(),
274                },
275                standard: Some(TierLevel {
276                    tokens: 25,
277                    template: "  Returns: lock level + directive
278  Levels: frozen (refuse), restricted (ask), normal (proceed)
279  Use: Check before ANY file modification"
280                        .to_string(),
281                }),
282                full: Some(TierLevel {
283                    tokens: 45,
284                    template: "  Returns: lock level + directive
285  Levels: frozen (refuse), restricted (ask), normal (proceed)
286  Use: Check before ANY file modification
287  Example:
288    $ acp constraints src/auth/session.ts
289    frozen - Core auth logic; security-critical"
290                        .to_string(),
291                }),
292            },
293        },
294        Command {
295            name: "acp query file <path>".to_string(),
296            critical: false,
297            priority: 2,
298            capabilities: vec!["shell".to_string()],
299            tiers: TierContent {
300                minimal: TierLevel {
301                    tokens: 6,
302                    template: "  Returns: purpose, constraints, symbols".to_string(),
303                },
304                standard: Some(TierLevel {
305                    tokens: 20,
306                    template: "  Returns: purpose, constraints, symbols, dependencies
307  Options: --json for machine-readable output
308  Use: Understand file context before working with it"
309                        .to_string(),
310                }),
311                full: Some(TierLevel {
312                    tokens: 35,
313                    template: "  Returns: purpose, constraints, symbols, dependencies
314  Options: --json for machine-readable output
315  Use: Understand file context before working with it
316  Example:
317    $ acp query file src/payments/processor.ts"
318                        .to_string(),
319                }),
320            },
321        },
322        Command {
323            name: "acp query symbol <name>".to_string(),
324            critical: false,
325            priority: 3,
326            capabilities: vec!["shell".to_string()],
327            tiers: TierContent {
328                minimal: TierLevel {
329                    tokens: 6,
330                    template: "  Returns: signature, purpose, constraints, callers".to_string(),
331                },
332                standard: Some(TierLevel {
333                    tokens: 18,
334                    template: "  Returns: signature, purpose, constraints, callers/callees
335  Options: --json for machine-readable output
336  Use: Understand function/method before modifying"
337                        .to_string(),
338                }),
339                full: None,
340            },
341        },
342        Command {
343            name: "acp query domain <name>".to_string(),
344            critical: false,
345            priority: 4,
346            capabilities: vec!["shell".to_string()],
347            tiers: TierContent {
348                minimal: TierLevel {
349                    tokens: 5,
350                    template: "  Returns: domain files, cross-cutting concerns".to_string(),
351                },
352                standard: Some(TierLevel {
353                    tokens: 15,
354                    template: "  Returns: domain files, cross-cutting concerns
355  Options: --json for machine-readable output
356  Use: Understand architectural boundaries"
357                        .to_string(),
358                }),
359                full: None,
360            },
361        },
362        Command {
363            name: "acp map [path]".to_string(),
364            critical: false,
365            priority: 5,
366            capabilities: vec!["shell".to_string()],
367            tiers: TierContent {
368                minimal: TierLevel {
369                    tokens: 5,
370                    template: "  Returns: directory tree with purposes".to_string(),
371                },
372                standard: Some(TierLevel {
373                    tokens: 15,
374                    template: "  Returns: directory tree with purposes and constraints
375  Options: --depth N, --inline (show todos/hacks)
376  Use: Navigate unfamiliar codebase"
377                        .to_string(),
378                }),
379                full: None,
380            },
381        },
382        Command {
383            name: "acp expand <text>".to_string(),
384            critical: false,
385            priority: 6,
386            capabilities: vec!["shell".to_string()],
387            tiers: TierContent {
388                minimal: TierLevel {
389                    tokens: 5,
390                    template: "  Expands $variable references to full paths".to_string(),
391                },
392                standard: Some(TierLevel {
393                    tokens: 12,
394                    template: "  Expands $variable references to full paths
395  Options: --mode inline|annotated
396  Use: Resolve variable shortcuts in instructions"
397                        .to_string(),
398                }),
399                full: None,
400            },
401        },
402        Command {
403            name: "acp attempt start <id>".to_string(),
404            critical: false,
405            priority: 7,
406            capabilities: vec!["shell".to_string()],
407            tiers: TierContent {
408                minimal: TierLevel {
409                    tokens: 5,
410                    template: "  Creates checkpoint for safe experimentation".to_string(),
411                },
412                standard: Some(TierLevel {
413                    tokens: 15,
414                    template: "  Creates checkpoint for safe experimentation
415  Related: acp attempt fail <id>, acp attempt verify <id>
416  Use: Track and revert failed approaches"
417                        .to_string(),
418                }),
419                full: None,
420            },
421        },
422        Command {
423            name: "acp primer --budget <N>".to_string(),
424            critical: false,
425            priority: 8,
426            capabilities: vec!["shell".to_string()],
427            tiers: TierContent {
428                minimal: TierLevel {
429                    tokens: 5,
430                    template: "  Get more context (this command)".to_string(),
431                },
432                standard: Some(TierLevel {
433                    tokens: 10,
434                    template: "  Get more context within token budget
435  Options: --capabilities shell,mcp
436  Use: Request more detailed primer"
437                        .to_string(),
438                }),
439                full: None,
440            },
441        },
442    ]
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn test_tier_from_budget() {
451        assert_eq!(Tier::from_budget(50), Tier::Minimal);
452        assert_eq!(Tier::from_budget(79), Tier::Minimal);
453        assert_eq!(Tier::from_budget(80), Tier::Standard);
454        assert_eq!(Tier::from_budget(200), Tier::Standard);
455        assert_eq!(Tier::from_budget(299), Tier::Standard);
456        assert_eq!(Tier::from_budget(300), Tier::Full);
457        assert_eq!(Tier::from_budget(500), Tier::Full);
458    }
459
460    #[test]
461    fn test_generate_minimal_primer() {
462        let options = PrimerOptions {
463            budget: 60,
464            capabilities: vec![],
465            format: PrimerFormat::Text,
466            cache: None,
467            json: false,
468        };
469
470        let result = generate_primer(&options).unwrap();
471        assert!(result.total_tokens <= 60 || result.commands_included == 1); // At least critical command
472        assert_eq!(result.tier, "minimal");
473        assert!(result.content.contains("constraints"));
474    }
475
476    #[test]
477    fn test_generate_standard_primer() {
478        let options = PrimerOptions {
479            budget: 200,
480            capabilities: vec![],
481            format: PrimerFormat::Text,
482            cache: None,
483            json: false,
484        };
485
486        let result = generate_primer(&options).unwrap();
487        assert_eq!(result.tier, "standard");
488        assert!(result.commands_included >= 3);
489    }
490
491    #[test]
492    fn test_critical_commands_always_included() {
493        let options = PrimerOptions {
494            budget: 30, // Very small budget
495            capabilities: vec![],
496            format: PrimerFormat::Text,
497            cache: None,
498            json: false,
499        };
500
501        let result = generate_primer(&options).unwrap();
502        // Critical command (constraints) should still be included
503        assert!(result.content.contains("constraints"));
504    }
505
506    #[test]
507    fn test_capability_filtering() {
508        let options = PrimerOptions {
509            budget: 200,
510            capabilities: vec!["mcp".to_string()], // No shell commands should match
511            format: PrimerFormat::Text,
512            cache: None,
513            json: false,
514        };
515
516        let result = generate_primer(&options).unwrap();
517        // With only MCP capability, fewer commands should be included
518        // (currently all commands require shell, so only bootstrap + critical)
519        assert!(result.content.contains("constraints")); // Critical still included
520    }
521}