1use 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct AgentConfig {
40 #[serde(default = "default_permissions")]
42 pub permissions: PermissionsMode,
43
44 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub rules: Option<String>,
49
50 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub model: Option<String>,
62 #[serde(
64 default,
65 skip_serializing_if = "Option::is_none",
66 rename = "orchestratorModel"
67 )]
68 pub orchestrator_model: Option<String>,
69 #[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
91pub 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
119pub 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
142fn ai_devkit_config_json() -> String {
144 use std::time::SystemTime;
146 let now = SystemTime::now()
147 .duration_since(SystemTime::UNIX_EPOCH)
148 .unwrap_or_default()
149 .as_millis();
150 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
170pub fn install_skills(project_dir: &Path) -> Result<()> {
177 use std::process::Command;
178
179 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}