Skip to main content

ao_core/config/
agent.rs

1//! Agent-level config types: `AgentConfig`, `PermissionsMode`,
2//! default rules, and the `install_skills` helper.
3
4use crate::error::{AoError, Result};
5use serde::{Deserialize, Serialize};
6use std::path::Path;
7
8pub(super) fn default_permissions() -> PermissionsMode {
9    PermissionsMode::Permissionless
10}
11
12/// Permission mode for agent execution.
13///
14/// Strict serde deserialization — unknown values fail at load time (TS parity: M4).
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum PermissionsMode {
18    #[default]
19    Permissionless,
20    Default,
21    AutoEdit,
22    Suggest,
23}
24
25impl std::fmt::Display for PermissionsMode {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        let s = match self {
28            Self::Permissionless => "permissionless",
29            Self::Default => "default",
30            Self::AutoEdit => "auto-edit",
31            Self::Suggest => "suggest",
32        };
33        f.write_str(s)
34    }
35}
36
37/// Agent-level overrides per project.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct AgentConfig {
40    /// Permission mode: permissionless, default, auto-edit, suggest.
41    #[serde(default = "default_permissions")]
42    pub permissions: PermissionsMode,
43
44    /// System prompt rules appended via `--append-system-prompt`.
45    /// Structured workflow instructions (dev-lifecycle phases, testing
46    /// requirements, coding standards) that guide the agent's behavior.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub rules: Option<String>,
49
50    /// Path to an external rules file (relative to project path).
51    /// Takes precedence over inline `rules` if both are set.
52    #[serde(
53        default,
54        skip_serializing_if = "Option::is_none",
55        alias = "rules-file",
56        rename = "rules_file"
57    )]
58    pub rules_file: Option<String>,
59    /// Model override (TS: `agentConfig.model`).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub model: Option<String>,
62    /// Orchestrator model override (TS: `agentConfig.orchestratorModel`).
63    #[serde(
64        default,
65        skip_serializing_if = "Option::is_none",
66        rename = "orchestratorModel"
67    )]
68    pub orchestrator_model: Option<String>,
69    /// OpenCode session id (TS: `agentConfig.opencodeSessionId`).
70    #[serde(
71        default,
72        skip_serializing_if = "Option::is_none",
73        rename = "opencodeSessionId"
74    )]
75    pub opencode_session_id: Option<String>,
76}
77
78impl Default for AgentConfig {
79    fn default() -> Self {
80        Self {
81            permissions: PermissionsMode::Permissionless,
82            rules: Some(default_agent_rules().to_string()),
83            rules_file: None,
84            model: None,
85            orchestrator_model: None,
86            opencode_session_id: None,
87        }
88    }
89}
90
91/// Default dev-lifecycle rules for agents, inspired by ai-devkit.
92/// Structures the agent's workflow into phases for more effective output.
93pub fn default_agent_rules() -> &'static str {
94    r#"Follow this structured workflow for every task:
95
961. UNDERSTAND — Read the issue/task carefully. Check existing code, tests, and docs before changing anything.
972. PLAN — Design your approach. For non-trivial changes, outline what files you'll modify and why.
983. IMPLEMENT — Write the code. Follow existing patterns and conventions in the codebase.
994. VERIFY — Run tests, linter, and formatter. Fix any failures before proceeding.
1005. REVIEW — Re-read your changes. Check for security issues, missing edge cases, and unnecessary complexity.
1016. DELIVER — Commit your changes, push the branch, and create a PR with `gh pr create`. Include a clear title and description.
102
103Rules:
104- When spawned from an issue, use the dev-lifecycle workflow to turn the issue content into concrete requirements and a plan, then execute it.
105- Do not skip the verify step. Every change must pass tests and linting before you consider it done.
106- Always push your branch and open a PR when the task is complete.
107- Prefer editing existing files over creating new ones.
108- Keep changes focused — fix what was asked, don't refactor surrounding code.
109- If stuck for more than 5 minutes, explain what's blocking you.
110
111Testing rules:
112- Use `cargo t` (nextest alias) — NOT `cargo test`. Nextest is parallel/isolated and much faster.
113- Run `cargo test --doc` separately for doctests (nextest skips them).
114- Tests MUST be inline `#[cfg(test)] mod tests` at the bottom of the SAME file you changed.
115- Run only your module's tests during development: `cargo t -p <crate> <module_name>`
116- Before opening a PR: `cargo t --workspace && cargo test --doc --workspace && cargo clippy --workspace --all-targets -- -D warnings && cargo fmt --all -- --check`"#
117}
118
119/// Default orchestrator rules (read-only coordinator).
120pub fn default_orchestrator_rules() -> &'static str {
121    r#"After spawning a worker, do NOT stop. Run a monitoring loop:
1221. Immediately confirm spawn with: ao-rs status
1232. Every 5 minutes, check: ao-rs status --project <id>
1243. When worker reaches pr_open/review_pending/merged/ci_failed → act
1254. Only stop monitoring when all workers reach terminal state (merged/killed)
126
127NEVER call `ao-rs cleanup` — it permanently archives sessions off-disk, making them
128invisible in the dashboard. Merged/killed sessions must remain visible so the user can
129review them. Only the user decides when to archive.
130
131When sessions are merged/killed, remove their worktrees with `ao-rs prune`:
132  ao-rs prune --dry-run   # preview which worktrees would be removed
133  ao-rs prune             # remove worktrees (sessions stay visible in dashboard)
134
135When writing tests (and when instructing workers to write tests):
136- Tests MUST be inline `#[cfg(test)] mod tests` inside the SAME source file being changed.
137- Do NOT create separate integration test files unless testing cross-module behavior.
138- Run only the relevant module: `cargo t -p <crate> <module_name>`
139- Never write tests for compiler-provable things (type correctness, exhaustive match, etc.)."#
140}
141
142/// Default `.ai-devkit.json` content for Claude Code environment.
143fn ai_devkit_config_json() -> String {
144    // Simple ISO-8601 timestamp without pulling in chrono.
145    use std::time::SystemTime;
146    let now = SystemTime::now()
147        .duration_since(SystemTime::UNIX_EPOCH)
148        .unwrap_or_default()
149        .as_millis();
150    // ai-devkit uses JS-style ISO dates but only checks the field exists.
151    let ts = format!("{now}");
152    format!(
153        r#"{{
154  "version": "0.21.1",
155  "environments": ["claude"],
156  "phases": ["requirements","design","planning","implementation","testing","deployment","monitoring"],
157  "createdAt": "{ts}",
158  "updatedAt": "{ts}",
159  "skills": [
160    {{"registry":"codeaholicguy/ai-devkit","name":"dev-lifecycle"}},
161    {{"registry":"codeaholicguy/ai-devkit","name":"debug"}},
162    {{"registry":"codeaholicguy/ai-devkit","name":"memory"}},
163    {{"registry":"codeaholicguy/ai-devkit","name":"verify"}},
164    {{"registry":"codeaholicguy/ai-devkit","name":"tdd"}}
165  ]
166}}"#
167    )
168}
169
170/// Install ai-devkit skills into a project directory.
171///
172/// Writes `.ai-devkit.json` (Claude Code environment + default skills),
173/// then runs `npx ai-devkit@latest install` to download and symlink skills
174/// into `.claude/skills/`. Non-fatal: callers should treat errors as
175/// warnings (the config file is still valid without skills).
176pub fn install_skills(project_dir: &Path) -> Result<()> {
177    use std::process::Command;
178
179    // Write .ai-devkit.json so the install command is non-interactive.
180    let config_path = project_dir.join(".ai-devkit.json");
181    if !config_path.exists() {
182        std::fs::write(&config_path, ai_devkit_config_json()).map_err(AoError::Io)?;
183    }
184
185    let output = Command::new("npx")
186        .args(["ai-devkit@latest", "install"])
187        .current_dir(project_dir)
188        .output()
189        .map_err(|e| {
190            if e.kind() == std::io::ErrorKind::NotFound {
191                AoError::Other(
192                    "npx not found. Install Node.js and run: npx ai-devkit@latest init".into(),
193                )
194            } else {
195                AoError::Other(format!("failed to run npx ai-devkit install: {e}"))
196            }
197        })?;
198
199    if !output.status.success() {
200        let stderr = String::from_utf8_lossy(&output.stderr);
201        return Err(AoError::Other(format!(
202            "npx ai-devkit install failed: {stderr}"
203        )));
204    }
205
206    Ok(())
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn permissions_mode_valid_values_parse() {
215        for (yaml_val, expected) in [
216            ("permissionless", PermissionsMode::Permissionless),
217            ("default", PermissionsMode::Default),
218            ("auto-edit", PermissionsMode::AutoEdit),
219            ("suggest", PermissionsMode::Suggest),
220        ] {
221            let yaml = format!("permissions: {yaml_val}\n");
222            let ac: AgentConfig = serde_yaml::from_str(&yaml).unwrap();
223            assert_eq!(ac.permissions, expected, "failed for {yaml_val}");
224        }
225    }
226
227    #[test]
228    fn permissions_mode_display_roundtrip() {
229        assert_eq!(
230            PermissionsMode::Permissionless.to_string(),
231            "permissionless"
232        );
233        assert_eq!(PermissionsMode::Default.to_string(), "default");
234        assert_eq!(PermissionsMode::AutoEdit.to_string(), "auto-edit");
235        assert_eq!(PermissionsMode::Suggest.to_string(), "suggest");
236    }
237}