diff --git a/docs/superpowers/plans/2026-07-07-auth-phase2-rotation.md b/docs/superpowers/plans/2026-07-07-auth-phase2-rotation.md deleted file mode 100644 index 19c0d0bf..00000000 --- a/docs/superpowers/plans/2026-07-07-auth-phase2-rotation.md +++ /dev/null @@ -1,1009 +0,0 @@ -# Auth Vault Phase 2 — Rotation, Cooldown & Health Scoring Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add smart multi-profile rotation, cooldown tracking, health scoring, aliases, and project associations to the auth profile vault. - -**Architecture:** New `src/auth_db.rs` handles SQLite persistence (reusing rusqlite, matching rollup.rs `open_or_rebuild()` + `migrate()` pattern). `src/auth.rs` extended with rotation algorithms, health scoring, cooldown management. `src/main.rs` refactors `AgentsAction::Auth` to top-level `Auth` command. - -**Tech Stack:** Rust, rusqlite (already in Cargo.toml), serde_json, sha2 (already in deps) - -## Global Constraints - -- No new dependencies — reuse rusqlite (already from PR #26) -- Generate `Cargo.lock` on first build -- 10-12 new tests, all hermetic via `with_temp_home` -- JSON output on all commands via `--json` flag -- Reuse `crate::paths::home()` for path resolution -- Reuse `crate::paths::test_support::with_temp_home` for test isolation - ---- - -### Task 1: Create auth_db.rs — SQLite schema and migrations - -**Files:** -- Create: `src/auth_db.rs` - -**Interfaces:** -- Consumes: `rusqlite::Connection`, `crate::paths::home` -- Produces: `pub fn open_or_rebuild() -> Connection`, `pub fn migrate(conn: &Connection)`, `pub fn record_error(conn: &Connection, agent: &str, profile: &str, error_msg: &str)`, `pub fn set_cooldown(conn: &Connection, agent: &str, profile: &str, minutes: u32, reason: &str)`, `pub fn list_cooldowns(conn: &Connection, agent: Option<&str>) -> Vec`, `pub fn clear_cooldown(conn: &Connection, agent: &str, profile: &str)`, `pub fn get_health(conn: &Connection, agent: &str, profile: &str) -> ProfileHealth`, `pub fn list_health(conn: &Connection, agent: &str) -> Vec`, `pub fn set_alias(conn: &Connection, agent: &str, alias: &str, profile: &str)`, `pub fn resolve_alias(conn: &Connection, agent: &str, name: &str) -> Option`, `pub fn set_project(conn: &Connection, path: &str, agent: &str, profile: &str)`, `pub fn get_project(conn: &Connection, path: &str, agent: &str) -> Option`, `pub fn unset_project(conn: &Connection, path: &str, agent: &str)`, `pub fn set_rotation_last(conn: &Connection, agent: &str, profile: &str, algorithm: &str)`, `pub fn get_rotation_last(conn: &Connection, agent: &str) -> Option<(String, String)>` -- Produces structs: `pub struct CooldownRow { pub agent: String, pub profile: String, pub until: String, pub reason: Option }`, `pub struct ProfileHealth { pub agent: String, pub profile: String, pub status: String, pub error_count_1h: i32, pub penalty: f64, pub last_used_at: Option }` - -- [ ] **Step 1: Write failing test for open_or_rebuild** - -```rust -// src/auth_db.rs -#[cfg(test)] -mod tests { - use super::*; - use crate::paths::test_support::with_temp_home; - - #[test] - fn open_or_rebuild_creates_tables() { - with_temp_home(|| { - let conn = open_or_rebuild(); - let mut stmt = conn.prepare( - "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" - ).unwrap(); - let tables: Vec = stmt.query_map([], |row| row.get(0)) - .unwrap() - .filter_map(|r| r.ok()) - .collect(); - assert!(tables.contains(&"profile_health".to_string())); - assert!(tables.contains(&"cooldowns".to_string())); - assert!(tables.contains(&"aliases".to_string())); - assert!(tables.contains(&"projects".to_string())); - }); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -cd C:\Users\shiva\workspace\leanstack && cargo test auth_db::tests::open_or_rebuild_creates_tables -``` - -Expected: FAIL — module not found / function not defined - -- [ ] **Step 3: Write auth_db.rs with schema, migrations, open_or_rebuild** - -```rust -use crate::paths::home; -use rusqlite::{params, Connection, Result as SqlResult}; -use std::path::PathBuf; - -const SCHEMA_VERSION: i32 = 1; - -pub struct CooldownRow { - pub agent: String, - pub profile: String, - pub until: String, - pub reason: Option, -} - -pub struct ProfileHealth { - pub agent: String, - pub profile: String, - pub status: String, - pub error_count_1h: i32, - pub penalty: f64, - pub last_used_at: Option, -} - -fn db_path() -> PathBuf { - home().join(".local").join("share").join("agentflare").join("auth.db") -} - -pub fn migrate(conn: &Connection) -> SqlResult<()> { - let version: i32 = conn - .query_row("PRAGMA user_version", [], |row| row.get(0)) - .unwrap_or(0); - if version >= SCHEMA_VERSION { - return Ok(()); - } - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS profile_health ( - agent TEXT NOT NULL, - profile TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'healthy', - error_count_1h INTEGER NOT NULL DEFAULT 0, - penalty REAL NOT NULL DEFAULT 0.0, - last_error_time TEXT, - last_used_at TEXT, - updated_at TEXT NOT NULL, - PRIMARY KEY (agent, profile) - ); - CREATE TABLE IF NOT EXISTS cooldowns ( - agent TEXT NOT NULL, - profile TEXT NOT NULL, - until TEXT NOT NULL, - reason TEXT, - PRIMARY KEY (agent, profile) - ); - CREATE TABLE IF NOT EXISTS aliases ( - agent TEXT NOT NULL, - alias TEXT NOT NULL, - profile TEXT NOT NULL, - PRIMARY KEY (agent, alias) - ); - CREATE TABLE IF NOT EXISTS projects ( - path TEXT NOT NULL, - agent TEXT NOT NULL, - profile TEXT NOT NULL, - PRIMARY KEY (path, agent) - );", - )?; - conn.pragma_update(None, "user_version", SCHEMA_VERSION)?; - Ok(()) -} - -pub fn open_or_rebuild() -> Connection { - let path = db_path(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).ok(); - } - let conn = Connection::open(&path).expect("open auth.db"); - migrate(&conn).expect("migrate auth.db"); - conn -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -```bash -cargo test auth_db::tests::open_or_rebuild_creates_tables -``` - -Expected: PASS - -- [ ] **Step 5: Write tests + impl for record_error, cooldown CRUD, health CRUD, aliases, projects** - -Write tests then impl in order. Each function = one test + impl cycle. - -```rust -// Test: record_error increments error count -#[test] -fn record_error_increments_count() { - with_temp_home(|| { - let conn = open_or_rebuild(); - record_error(&conn, "claude-code", "alice", "rate limit exceeded"); - let h = get_health(&conn, "claude-code", "alice"); - assert_eq!(h.error_count_1h, 1); - assert!(h.penalty > 0.0); - }); -} - -// Test: record_error resets count after 1 hour gap -#[test] -fn record_error_resets_after_hour() { - with_temp_home(|| { - let conn = open_or_rebuild(); - record_error(&conn, "claude-code", "alice", "timeout"); - // Simulate old timestamp - conn.execute( - "UPDATE profile_health SET last_error_time = datetime('now', '-2 hours') WHERE agent = ?1 AND profile = ?2", - params!["claude-code", "alice"], - ).unwrap(); - record_error(&conn, "claude-code", "alice", "timeout"); - let h = get_health(&conn, "claude-code", "alice"); - assert_eq!(h.error_count_1h, 1); // reset to 1 - }); -} - -// Test: set_cooldown creates entry -#[test] -fn set_cooldown_and_list() { - with_temp_home(|| { - let conn = open_or_rebuild(); - set_cooldown(&conn, "claude-code", "alice", 30, "manual"); - let list = list_cooldowns(&conn, Some("claude-code")); - assert_eq!(list.len(), 1); - assert_eq!(list[0].profile, "alice"); - }); -} - -// Test: clear_cooldown removes entry -#[test] -fn clear_cooldown_removes() { - with_temp_home(|| { - let conn = open_or_rebuild(); - set_cooldown(&conn, "claude-code", "alice", 30, "test"); - clear_cooldown(&conn, "claude-code", "alice"); - let list = list_cooldowns(&conn, Some("claude-code")); - assert!(list.is_empty()); - }); -} - -// Test: set_alias and resolve_alias -#[test] -fn alias_set_and_resolve() { - with_temp_home(|| { - let conn = open_or_rebuild(); - set_alias(&conn, "claude-code", "work", "work@company.com"); - assert_eq!( - resolve_alias(&conn, "claude-code", "work"), - Some("work@company.com".to_string()) - ); - assert_eq!(resolve_alias(&conn, "claude-code", "unknown"), None); - }); -} - -// Test: project set/get/unset with cascading -#[test] -fn project_association_cascading() { - with_temp_home(|| { - let conn = open_or_rebuild(); - set_project(&conn, "/home/user/projects", "claude-code", "work"); - assert_eq!( - get_project(&conn, "/home/user/projects/sub", "claude-code"), - Some("work".to_string()) - ); - unset_project(&conn, "/home/user/projects", "claude-code"); - assert_eq!( - get_project(&conn, "/home/user/projects/sub", "claude-code"), - None - ); - }); -} - -// Test: rotation state set/get -#[test] -fn rotation_last_tracks() { - with_temp_home(|| { - let conn = open_or_rebuild(); - set_rotation_last(&conn, "claude-code", "alice", "smart"); - let last = get_rotation_last(&conn, "claude-code"); - assert_eq!(last, Some(("alice".to_string(), "smart".to_string()))); - }); -} -``` - -Impls: - -```rust -fn now_iso() -> String { - chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string() -} - -pub fn record_error(conn: &Connection, agent: &str, profile: &str, error_msg: &str) { - let penalty = penalty_for_error(error_msg); - let now = now_iso(); - let existing = conn.query_row( - "SELECT error_count_1h, last_error_time FROM profile_health WHERE agent = ?1 AND profile = ?2", - params![agent, profile], - |row| Ok((row.get::<_, i32>(0)?, row.get::<_, Option>(1)?)), - ); - let new_count = match existing { - Ok((count, Some(last_time))) => { - // Reset if last error was > 1h ago - if is_older_than_1h(&last_time, &now) { 1 } else { count + 1 } - } - _ => 1, - }; - let decayed = decay_penalty(conn, agent, profile); - let new_penalty = decayed + penalty; - let status = if new_count >= 5 { "critical" } else if new_count >= 1 { "warning" } else { "healthy" }; - conn.execute( - "INSERT INTO profile_health (agent, profile, status, error_count_1h, penalty, last_error_time, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - ON CONFLICT(agent, profile) DO UPDATE SET - status = excluded.status, error_count_1h = excluded.error_count_1h, - penalty = excluded.penalty, last_error_time = excluded.last_error_time, - updated_at = excluded.updated_at", - params![agent, profile, status, new_count, new_penalty, now, now], - ).ok(); -} - -fn is_older_than_1h(timestamp: &str, now: &str) -> bool { - // Simple: parse ISO timestamps, compare. If parse fails, assume old. - if let (Ok(ts), Ok(n)) = ( - chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S"), - chrono::NaiveDateTime::parse_from_str(now, "%Y-%m-%dT%H:%M:%S"), - ) { - (n - ts).num_hours() >= 1 - } else { - true - } -} - -fn penalty_for_error(msg: &str) -> f64 { - let m = msg.to_lowercase(); - if m.contains("429") || m.contains("rate limit") || m.contains("too many requests") { 10.0 } - else if m.contains("401") || m.contains("403") || m.contains("unauthorized") { 100.0 } - else if m.contains("timeout") || m.contains("deadline exceeded") { 5.0 } - else if m.contains("500") || m.contains("502") || m.contains("503") || m.contains("504") { 5.0 } - else { 3.0 } -} - -fn decay_penalty(conn: &Connection, agent: &str, profile: &str) -> f64 { - let row = conn.query_row( - "SELECT penalty, last_error_time FROM profile_health WHERE agent = ?1 AND profile = ?2", - params![agent, profile], - |row| Ok((row.get::<_, f64>(0)?, row.get::<_, Option>(1)?)), - ); - match row { - Ok((penalty, Some(last_time))) => { - let now = now_iso(); - if let (Ok(ts), Ok(n)) = ( - chrono::NaiveDateTime::parse_from_str(&last_time, "%Y-%m-%dT%H:%M:%S"), - chrono::NaiveDateTime::parse_from_str(&now, "%Y-%m-%dT%H:%M:%S"), - ) { - let minutes = (n - ts).num_minutes().max(0) as f64; - let decay_intervals = (minutes / 5.0).floor(); - penalty * 0.8_f64.powf(decay_intervals) - } else { - penalty - } - } - _ => 0.0, - } -} - -pub fn set_cooldown(conn: &Connection, agent: &str, profile: &str, minutes: u32, reason: &str) { - let until = chrono::Utc::now() + chrono::Duration::minutes(minutes as i64); - conn.execute( - "INSERT INTO cooldowns (agent, profile, until, reason) VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(agent, profile) DO UPDATE SET until = excluded.until, reason = excluded.reason", - params![agent, profile, until.format("%Y-%m-%dT%H:%M:%S").to_string(), reason], - ).ok(); -} - -pub fn list_cooldowns(conn: &Connection, agent: Option<&str>) -> Vec { - let mut stmt = if let Some(a) = agent { - conn.prepare("SELECT agent, profile, until, reason FROM cooldowns WHERE agent = ?1 AND until > datetime('now')").unwrap() - } else { - conn.prepare("SELECT agent, profile, until, reason FROM cooldowns WHERE until > datetime('now')").unwrap() - }; - let rows = if agent.is_some() { - stmt.query_map(params![agent.unwrap()], |row| { - Ok(CooldownRow { - agent: row.get(0)?, profile: row.get(1)?, until: row.get(2)?, reason: row.get(3)?, - }) - }).unwrap() - } else { - stmt.query_map([], |row| { - Ok(CooldownRow { - agent: row.get(0)?, profile: row.get(1)?, until: row.get(2)?, reason: row.get(3)?, - }) - }).unwrap() - }; - rows.filter_map(|r| r.ok()).collect() -} - -pub fn clear_cooldown(conn: &Connection, agent: &str, profile: &str) { - conn.execute("DELETE FROM cooldowns WHERE agent = ?1 AND profile = ?2", params![agent, profile]).ok(); -} - -pub fn get_health(conn: &Connection, agent: &str, profile: &str) -> ProfileHealth { - conn.query_row( - "SELECT agent, profile, status, error_count_1h, penalty, last_used_at FROM profile_health WHERE agent = ?1 AND profile = ?2", - params![agent, profile], - |row| Ok(ProfileHealth { - agent: row.get(0)?, profile: row.get(1)?, status: row.get(2)?, - error_count_1h: row.get(3)?, penalty: row.get(4)?, last_used_at: row.get(5)?, - }), - ).unwrap_or_else(|_| ProfileHealth { - agent: agent.to_string(), profile: profile.to_string(), - status: "healthy".to_string(), error_count_1h: 0, penalty: 0.0, last_used_at: None, - }) -} - -pub fn list_health(conn: &Connection, agent: &str) -> Vec { - let mut stmt = conn.prepare( - "SELECT agent, profile, status, error_count_1h, penalty, last_used_at FROM profile_health WHERE agent = ?1" - ).unwrap(); - stmt.query_map(params![agent], |row| { - Ok(ProfileHealth { - agent: row.get(0)?, profile: row.get(1)?, status: row.get(2)?, - error_count_1h: row.get(3)?, penalty: row.get(4)?, last_used_at: row.get(5)?, - }) - }).unwrap().filter_map(|r| r.ok()).collect() -} - -pub fn set_alias(conn: &Connection, agent: &str, alias: &str, profile: &str) { - conn.execute( - "INSERT INTO aliases (agent, alias, profile) VALUES (?1, ?2, ?3) ON CONFLICT(agent, alias) DO UPDATE SET profile = excluded.profile", - params![agent, alias, profile], - ).ok(); -} - -pub fn resolve_alias(conn: &Connection, agent: &str, name: &str) -> Option { - conn.query_row( - "SELECT profile FROM aliases WHERE agent = ?1 AND alias = ?2", - params![agent, name], - |row| row.get(0), - ).ok() -} - -pub fn set_project(conn: &Connection, path: &str, agent: &str, profile: &str) { - conn.execute( - "INSERT INTO projects (path, agent, profile) VALUES (?1, ?2, ?3) ON CONFLICT(path, agent) DO UPDATE SET profile = excluded.profile", - params![path, agent, profile], - ).ok(); -} - -pub fn get_project(conn: &Connection, path: &str, agent: &str) -> Option { - // Cascading: find longest matching parent path - let mut stmt = conn.prepare( - "SELECT path, profile FROM projects WHERE agent = ?1 ORDER BY length(path) DESC" - ).unwrap(); - let candidates: Vec<(String, String)> = stmt.query_map(params![agent], |row| { - Ok((row.get::<_, String>(0)?, row.get(1)?)) - }).unwrap().filter_map(|r| r.ok()).collect(); - for (p, profile) in candidates { - if path.starts_with(&p) { - return Some(profile); - } - } - None -} - -pub fn unset_project(conn: &Connection, path: &str, agent: &str) { - conn.execute("DELETE FROM projects WHERE path = ?1 AND agent = ?2", params![path, agent]).ok(); -} - -pub fn set_rotation_last(conn: &Connection, agent: &str, profile: &str, algorithm: &str) { - conn.execute( - "INSERT OR REPLACE INTO rotation_state (agent, last_profile, algorithm) VALUES (?1, ?2, ?3)", - params![agent, profile, algorithm], - ).ok(); -} - -pub fn get_rotation_last(conn: &Connection, agent: &str) -> Option<(String, String)> { - conn.query_row( - "SELECT last_profile, algorithm FROM rotation_state WHERE agent = ?1", - params![agent], - |row| Ok((row.get(0)?, row.get(1)?)), - ).ok() -} -``` - -Note: also add `rotation_state` table to schema: -```sql -CREATE TABLE IF NOT EXISTS rotation_state ( - agent TEXT PRIMARY KEY, - last_profile TEXT, - algorithm TEXT NOT NULL DEFAULT 'smart' -); -``` - -- [ ] **Step 6: Run all auth_db tests** - -```bash -cargo test auth_db -``` - -Expected: all PASS (7 tests) - -- [ ] **Step 7: Commit** - -```bash -git add src/auth_db.rs Cargo.lock -git commit -m "feat: add auth_db SQLite layer for health, cooldown, rotation state" -``` - ---- - -### Task 2: Extend auth.rs — rotation, cooldown, health CLI - -**Files:** -- Modify: `src/auth.rs` -- Modify: `src/main.rs` - -**Interfaces:** -- Consumes: `crate::auth_db::*` from Task 1, existing auth functions (backup, activate, etc.) -- Produces: `pub fn rotate(agent: &str, algorithm: &str, json: bool)`, `pub fn next(agent: &str, algorithm: &str, json: bool)`, `pub fn pick(agent: &str)`, `pub fn cooldown_set(target: &str, minutes: Option, json: bool)`, `pub fn cooldown_list(agent: Option<&str>, json: bool)`, `pub fn cooldown_clear(target: &str, json: bool)`, `pub fn set_alias_cmd(agent: &str, profile: &str, alias: &str, json: bool)`, `pub fn project_set(agent: &str, profile: &str, json: bool)`, `pub fn project_unset(agent: &str, json: bool)` - -- [ ] **Step 1: Write rotate function** - -```rust -use crate::auth_db::{self, CooldownRow, ProfileHealth}; - -pub fn rotate(agent: &str, algorithm: &str, json: bool) { - let conn = auth_db::open_or_rebuild(); - let cooldowns = auth_db::list_cooldowns(&conn, Some(agent)); - let health = auth_db::list_health(&conn, agent); - let vault_profiles = list_profiles(agent); - - let active = vault_profiles.iter().filter(|p| { - !cooldowns.iter().any(|c| c.profile == **p) - }).cloned().collect::>(); - - if active.is_empty() { - if json { - println!("{}", serde_json::json!({"error": "no non-cooldown profiles available"})); - } else { - eprintln!("error: all profiles are in cooldown"); - } - return; - } - - let chosen = match algorithm { - "round-robin" => round_robin(&conn, agent, &active), - "random" => random_pick(&active), - _ => smart_pick(&health, &active, agent), - }; - - activate(agent, &chosen, json); - auth_db::set_rotation_last(&conn, agent, &chosen, algorithm); -} - -fn smart_pick(health: &[ProfileHealth], profiles: &[String], agent: &str) -> String { - let mut scored: Vec<(String, f64)> = profiles.iter().map(|p| { - let h = health.iter().find(|h| h.profile == *p); - let base = match h.map(|h| h.status.as_str()) { - Some("healthy") => 100.0, - Some("warning") => 50.0, - Some("critical") => 0.0, - _ => 100.0, - }; - let penalty = h.map(|h| h.penalty).unwrap_or(0.0); - let recency = if h.and_then(|h| h.last_used_at.as_ref()).is_some() { 0.0 } else { 10.0 }; - let jitter = (rand::random::() * 10.0) - 5.0; // ±5 - (p.clone(), base - penalty + recency + jitter) - }).collect(); - scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); - scored[0].0.clone() -} - -fn round_robin(conn: &Connection, agent: &str, profiles: &[String]) -> String { - if let Some((last, _)) = auth_db::get_rotation_last(conn, agent) { - if let Some(pos) = profiles.iter().position(|p| *p == last) { - let next = (pos + 1) % profiles.len(); - return profiles[next].clone(); - } - } - profiles[0].clone() -} - -fn random_pick(profiles: &[String]) -> String { - let idx = rand::random::() % profiles.len(); - profiles[idx].clone() -} -``` - -- [ ] **Step 2: Write cooldown CLI functions** - -```rust -pub fn cooldown_set(target: &str, minutes: Option, json: bool) { - let (agent, profile) = match parse_target(target) { - Some(p) => p, - None => { eprintln!("error: expected /"); return; } - }; - let mins = minutes.unwrap_or(60); - let conn = auth_db::open_or_rebuild(); - auth_db::set_cooldown(&conn, &agent, &profile, mins, "manual"); - if json { - println!("{}", serde_json::json!({"agent": agent, "profile": profile, "cooldown_minutes": mins})); - } else { - println!("cooldown set: {agent}/{profile} for {mins} minutes"); - } -} - -pub fn cooldown_list(agent: Option<&str>, json: bool) { - let conn = auth_db::open_or_rebuild(); - let list = auth_db::list_cooldowns(&conn, agent); - if json { - println!("{}", serde_json::to_string(&list.iter().map(|c| serde_json::json!({ - "agent": c.agent, "profile": c.profile, "until": c.until, "reason": c.reason, - })).collect::>()).unwrap()); - } else if list.is_empty() { - println!("no active cooldowns"); - } else { - for c in &list { - println!(" {}/{} until {} {}", c.agent, c.profile, c.until, c.reason.as_deref().unwrap_or("")); - } - } -} - -pub fn cooldown_clear(target: &str, json: bool) { - let (agent, profile) = match parse_target(target) { - Some(p) => p, - None => { eprintln!("error: expected /"); return; } - }; - let conn = auth_db::open_or_rebuild(); - auth_db::clear_cooldown(&conn, &agent, &profile); - if json { - println!("{}", serde_json::json!({"cleared": true, "agent": agent, "profile": profile})); - } else { - println!("cooldown cleared: {agent}/{profile}"); - } -} - -fn parse_target(target: &str) -> Option<(String, String)> { - let parts: Vec<&str> = target.splitn(2, '/').collect(); - if parts.len() == 2 { - Some((parts[0].to_string(), parts[1].to_string())) - } else { - None - } -} -``` - -- [ ] **Step 3: Write next, pick, alias, project functions** - -```rust -pub fn next(agent: &str, algorithm: &str, json: bool) { - let conn = auth_db::open_or_rebuild(); - let cooldowns = auth_db::list_cooldowns(&conn, Some(agent)); - let health = auth_db::list_health(&conn, agent); - let profiles = list_profiles(agent); - let active: Vec = profiles.iter() - .filter(|p| !cooldowns.iter().any(|c| c.profile == **p)) - .cloned().collect(); - let chosen = if active.is_empty() { - "(none — all cooldown'd)".to_string() - } else { - match algorithm { - "round-robin" => round_robin(&conn, agent, &active), - "random" => random_pick(&active), - _ => smart_pick(&health, &active, agent), - } - }; - if json { - println!("{}", serde_json::json!({"agent": agent, "next": chosen, "algorithm": algorithm})); - } else { - println!("next rotation for {agent} [{algorithm}]: {chosen}"); - } -} - -pub fn pick(agent: &str) { - let profiles = list_profiles(agent); - if profiles.is_empty() { - println!("no profiles for {agent}"); - return; - } - for (i, p) in profiles.iter().enumerate() { - println!(" [{}] {p}", i + 1); - } - print!("choose profile: "); - use std::io::Write; - std::io::stdout().flush().ok(); - let mut input = String::new(); - std::io::stdin().read_line(&mut input).ok(); - if let Ok(idx) = input.trim().parse::() { - if idx > 0 && idx <= profiles.len() { - activate(agent, &profiles[idx - 1], false); - return; - } - } - eprintln!("invalid selection"); -} - -pub fn set_alias_cmd(agent: &str, profile: &str, alias: &str, json: bool) { - let conn = auth_db::open_or_rebuild(); - auth_db::set_alias(&conn, agent, alias, profile); - if json { - println!("{}", serde_json::json!({"agent": agent, "alias": alias, "profile": profile})); - } else { - println!("alias set: {agent}/{alias} -> {profile}"); - } -} - -pub fn project_set(agent: &str, profile: &str, json: bool) { - let cwd = std::env::current_dir().unwrap_or_default(); - let path = cwd.to_string_lossy().to_string(); - let conn = auth_db::open_or_rebuild(); - auth_db::set_project(&conn, &path, agent, profile); - if json { - println!("{}", serde_json::json!({"path": path, "agent": agent, "profile": profile})); - } else { - println!("project set: {path} -> {agent}/{profile}"); - } -} - -pub fn project_unset(agent: &str, json: bool) { - let cwd = std::env::current_dir().unwrap_or_default(); - let path = cwd.to_string_lossy().to_string(); - let conn = auth_db::open_or_rebuild(); - auth_db::unset_project(&conn, &path, agent); - if json { - println!("{}", serde_json::json!({"path": path, "agent": agent, "unset": true})); - } else { - println!("project unset: {path}/{agent}"); - } -} -``` - -- [ ] **Step 4: Update activate to resolve aliases and projects** - -```rust -// At the top of existing activate function, add: -let profile = resolve_name(agent, profile); - -// Before catalog lookup, add: -fn resolve_name(agent: &str, name: &str) -> String { - let conn = auth_db::open_or_rebuild(); - // Check alias first - if let Some(real) = auth_db::resolve_alias(&conn, agent, name) { - return real; - } - // Check project association - let cwd = std::env::current_dir().unwrap_or_default(); - let path = cwd.to_string_lossy().to_string(); - if let Some(project_profile) = auth_db::get_project(&conn, &path, agent) { - return project_profile; - } - name.to_string() -} -``` - -- [ ] **Step 5: Write auth tests (rotation, cooldown, alias)** - -```rust -#[test] -fn cooldown_set_and_rotate_skips() { - with_temp_home(|| { - // Setup: backup two profiles - setup_vault_profile("claude-code", "alice", r#"{"token":"a"}"#); - setup_vault_profile("claude-code", "bob", r#"{"token":"b"}"#); - - // Set cooldown on alice - let conn = auth_db::open_or_rebuild(); - auth_db::set_cooldown(&conn, "claude-code", "alice", 60, "test"); - - // Activate using simple pick (not full rotate — test that cooldown'd is skipped) - let profiles = list_profiles("claude-code"); - let cooldowns = auth_db::list_cooldowns(&conn, Some("claude-code")); - let active: Vec<_> = profiles.iter().filter(|p| !cooldowns.iter().any(|c| c.profile == **p)).collect(); - assert_eq!(active.len(), 1); - assert_eq!(active[0], "bob"); - }); -} - -#[test] -fn alias_resolves_in_activate() { - with_temp_home(|| { - setup_vault_profile("claude-code", "work@company.com", r#"{"token":"x"}"#); - let conn = auth_db::open_or_rebuild(); - auth_db::set_alias(&conn, "claude-code", "w", "work@company.com"); - - let resolved = auth_db::resolve_alias(&conn, "claude-code", "w"); - assert_eq!(resolved, Some("work@company.com".to_string())); - }); -} - -fn setup_vault_profile(agent: &str, profile: &str, content: &str) { - let dir = profile_dir(agent, profile); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join("auth.json"), content).unwrap(); -} -``` - -- [ ] **Step 6: Run all auth tests** - -```bash -cargo test auth -``` - -Expected: all PASS (includes Phase 1 + Phase 2 tests) - -- [ ] **Step 7: Commit** - -```bash -git add src/auth.rs src/main.rs -git commit -m "feat: add rotation, cooldown, health scoring, aliases, project associations" -``` - ---- - -### Task 3: Update main.rs — refactor Auth to top-level command - -**Files:** -- Modify: `src/main.rs` - -**Interfaces:** -- Consumes: `crate::auth` functions from Task 2 -- Produces: Top-level `Auth` variant in Commands - -- [ ] **Step 1: Move AuthAction from AgentsAction to top-level Commands** - -Remove `Auth { action: AuthAction }` from `AgentsAction`. Add to `Commands`: - -```rust - /// Auth profile vault — backup, switch, rotate, and manage agent OAuth tokens. - Auth { - #[command(subcommand)] - action: AuthAction, - }, -``` - -- [ ] **Step 2: Add new AuthAction variants (rotate, cooldown, alias, project)** - -Add to `AuthAction` enum (keeping existing: Backup, Activate, Status, Catalog, Ls, Clear, Delete, Rename): - -```rust - /// Smart profile rotation (skips cooldown'd profiles). - Rotate { - agent: String, - /// Rotation algorithm (smart, round-robin, random). - #[arg(long, default_value = "smart")] - algorithm: String, - #[arg(long)] - json: bool, - }, - /// Preview what rotation would pick. - Next { - agent: String, - #[arg(long, default_value = "smart")] - algorithm: String, - #[arg(long)] - json: bool, - }, - /// Interactive profile selector. - Pick { - agent: String, - }, - /// Manage cooldowns. - Cooldown { - #[command(subcommand)] - action: CooldownAction, - }, - /// Create short alias for a profile. - Alias { - agent: String, - profile: String, - alias: String, - #[arg(long)] - json: bool, - }, - /// Manage project-profile associations. - Project { - #[command(subcommand)] - action: ProjectAction, - }, -} - -#[derive(Subcommand)] -enum CooldownAction { - /// Block a profile from rotation for N minutes. - Set { - /// / - target: String, - #[arg(long)] - minutes: Option, - #[arg(long)] - json: bool, - }, - /// List active cooldowns. - List { - agent: Option, - #[arg(long)] - json: bool, - }, - /// Clear a cooldown. - Clear { - /// / - target: String, - #[arg(long)] - json: bool, - }, -} - -#[derive(Subcommand)] -enum ProjectAction { - /// Link current directory to a profile. - Set { - agent: String, - profile: String, - #[arg(long)] - json: bool, - }, - /// Remove project association for current directory. - Unset { - agent: String, - #[arg(long)] - json: bool, - }, -} -``` - -- [ ] **Step 3: Wire match arms in main()** - -Move auth match from `AgentsAction::Auth { action }` to top-level: - -```rust - Commands::Auth { action } => match action { - AuthAction::Backup { agent, profile, json } => auth::backup(&agent, &profile, json), - AuthAction::Activate { agent, profile, json } => auth::activate(&agent, &profile, json), - AuthAction::Status { agent, json } => auth::status(agent.as_deref(), json), - AuthAction::Catalog { json } => auth::list_agents(json), - AuthAction::Ls { agent, json } => auth::ls(&agent, json), - AuthAction::Clear { agent, json } => auth::clear(&agent, json), - AuthAction::Delete { agent, profile, json } => auth::delete(&agent, &profile, json), - AuthAction::Rename { agent, old, new, json } => auth::rename(&agent, &old, &new, json), - AuthAction::Rotate { agent, algorithm, json } => auth::rotate(&agent, &algorithm, json), - AuthAction::Next { agent, algorithm, json } => auth::next(&agent, &algorithm, json), - AuthAction::Pick { agent } => auth::pick(&agent), - AuthAction::Cooldown { action } => match action { - CooldownAction::Set { target, minutes, json } => auth::cooldown_set(&target, minutes, json), - CooldownAction::List { agent, json } => auth::cooldown_list(agent.as_deref(), json), - CooldownAction::Clear { target, json } => auth::cooldown_clear(&target, json), - }, - AuthAction::Alias { agent, profile, alias, json } => auth::set_alias_cmd(&agent, &profile, &alias, json), - AuthAction::Project { action } => match action { - ProjectAction::Set { agent, profile, json } => auth::project_set(&agent, &profile, json), - ProjectAction::Unset { agent, json } => auth::project_unset(&agent, json), - }, - }, -``` - -Remove `AgentsAction::Auth` from the `Agents` match arm. - -- [ ] **Step 4: Add rand dependency for jitter + random rotation** - -```toml -# Cargo.toml -rand = "0.8" -``` - -- [ ] **Step 5: Build and test** - -```bash -cargo build && cargo test auth -``` - -- [ ] **Step 6: Commit** - -```bash -git add src/main.rs Cargo.toml Cargo.lock -git commit -m "refactor: move Auth to top-level command, add rotation/cooldown/alias/project" -``` - ---- - -### Task 4: Integration test — full rotate flow - -**Files:** -- Modify: `src/auth.rs` (add test) - -- [ ] **Step 1: Write integration test** - -```rust -#[test] -fn full_rotate_flow() { - with_temp_home(|| { - let conn = auth_db::open_or_rebuild(); - setup_vault_profile("claude-code", "alice", r#"{"token":"a"}"#); - setup_vault_profile("claude-code", "bob", r#"{"token":"b"}"#); - setup_vault_profile("claude-code", "carol", r#"{"token":"c"}"#); - - // Cooldown alice - auth_db::set_cooldown(&conn, "claude-code", "alice", 60, "manual"); - - // Record error on bob - auth_db::record_error(&conn, "claude-code", "bob", "502 Bad Gateway"); - - // Smart rotate should pick carol (alice cooldown'd, bob has penalty) - let profiles = list_profiles("claude-code"); - let health: Vec<_> = profiles.iter().map(|p| auth_db::get_health(&conn, "claude-code", p)).collect(); - let active: Vec<_> = profiles.iter().filter(|p| { - !auth_db::list_cooldowns(&conn, Some("claude-code")).iter().any(|c| c.profile == **p) - }).cloned().collect(); - let picked = smart_pick(&health, &active, "claude-code"); - assert_eq!(picked, "carol"); - }); -} -``` - -- [ ] **Step 2: Run test** - -```bash -cargo test auth::tests::full_rotate_flow -``` - -Expected: PASS - -- [ ] **Step 3: Commit** - -```bash -git add src/auth.rs -git commit -m "test: full rotation integration test with cooldown + health" -``` - -- [ ] **Final: Run full test suite** - -```bash -cargo test -``` diff --git a/docs/superpowers/plans/2026-07-07-cli-refactor-mise.md b/docs/superpowers/plans/2026-07-07-cli-refactor-mise.md deleted file mode 100644 index 65e6ea46..00000000 --- a/docs/superpowers/plans/2026-07-07-cli-refactor-mise.md +++ /dev/null @@ -1,113 +0,0 @@ -# Multi-Crate Workspace + CLI Refactor — Implementation Plan - -> **For agentic workers:** Use superpowers:subagent-driven-development. - -**Goal:** Refactor agentflare into mise-style multi-crate workspace. Independent modules become sub-crates under `crates/`. CLI split into `src/cli/` one file per subcommand with typed Args structs. - -**Reference:** https://github.com/jdx/mise — `crates/` workspace + `src/cli/` modular CLI - -**Issue:** [#44](https://github.com/getappz/agentflare/issues/44) -**Branch:** `feature/cli-refactor-mise` - ---- - -## Target workspace structure - -``` -agentflare/ (root crate — binary + CLI layer) - Cargo.toml [workspace] + [package] agentflare - src/ - main.rs thin entrypoint (~30 lines) - cli/ - mod.rs Cli struct, Commands enum, dispatch - init.rs InitArgs + run() - hook.rs HookArgs + run() - cost.rs CostArgs + run() - coaching.rs CoachingArgs + run() - agents.rs AgentsArgs + run() - alias.rs AliasArgs + run() - update.rs UpdateArgs + run() - uninstall.rs UninstallArgs + run() - auth.rs AuthArgs + run() - ponytail.rs PonytailArgs + run() - mcp.rs McpArgs + run() - (remaining modules stay in root: auth*, coaching, cost, init, ...) - -crates/ - ponytail/ (sub-crate — standalone skill engine) - Cargo.toml [package] ponytail - src/ - lib.rs pub mods, re-exports - config.rs mode resolution - state.rs flag file r/w - instructions.rs skill loading + filtering - switcher.rs mode switch detection - platform.rs agent + output formatting - sub_skills.rs embedded sub-skill content - detect.rs process-tree detection - skill*.md embedded skill files - - agent-registry/ (sub-crate — agent definitions) - Cargo.toml [package] agent-registry - src/ - lib.rs pub mods - registry.rs Agent enum, AgentSpec, Tier - detect.rs find_binary, extract_version, version cache -``` - ---- - -## Phase 1: CLI modularization (file-level) - -### Task 1: Foundation — `src/cli/mod.rs` + thin `main.rs` - -- Create `src/cli/` directory -- Move `Cli` struct, `Commands` enum, `AGENTFLARE_VERSION` to `mod.rs` -- Move all subcommand enums to their respective files -- Thin `main.rs` to: `Cli::parse().command.run(cli.yes)` -- Global `-y`/`--yes` and `-q`/`--quiet` flags on `Cli` - -### Tasks 2-12: Extract each subcommand - -One file per subcommand. Pattern: - -```rust -// src/cli/cost.rs -use clap::Args; - -#[derive(Args)] -pub struct CostArgs { - #[arg(long)] - pub days: Option, - #[arg(long)] - pub by_project: bool, -} - -impl CostArgs { - pub fn run(self) { - crate::cost::run(self.days, self.by_project); - } -} -``` - -Each task extracts one subcommand. All 11 follow identical mechanical pattern. - ---- - -## Phase 2: Workspace extraction (crate-level) - -### Task 13: Extract `crates/ponytail/` - -- Move `src/ponytail/` → `crates/ponytail/src/` -- Create `crates/ponytail/Cargo.toml` with deps (serde, serde_json, dirs, ureq, sysinfo-optional) -- Root Cargo.toml: add `ponytail = { path = "crates/ponytail" }` to workspace + deps -- Update imports: `crate::ponytail` → `ponytail` in root - -### Task 14: Extract `crates/agent-registry/` - -- Move `src/agent_registry.rs` + `src/agent_detect.rs` → `crates/agent-registry/src/` -- Create `crates/agent-registry/Cargo.toml` (clap, serde, dirs deps) -- Root: add to workspace + deps -- Update imports - -### Task 15: Build, test, clippy diff --git a/docs/superpowers/plans/2026-07-07-ponytail-l1-integration.md b/docs/superpowers/plans/2026-07-07-ponytail-l1-integration.md deleted file mode 100644 index 2ffbb951..00000000 --- a/docs/superpowers/plans/2026-07-07-ponytail-l1-integration.md +++ /dev/null @@ -1,994 +0,0 @@ -# Ponytail L1 Integration — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Port ponytail runtime logic (config, state, instructions, switcher, platform output) from Node.js hooks into agentflare Rust. Prompt content stays external — downloaded on demand. - -**Architecture:** New `src/ponytail/` module with 6 sub-modules + embedded fallback skill.md. One new top-level CLI command `agentflare ponytail` with subcommands for setup/status/set/default/off/update/hook. Follows existing `Commands` enum + `#[command(subcommand)]` pattern. - -**Tech Stack:** Rust, clap (derive), serde_json, dirs, ureq — all already in Cargo.toml. - -**Issue:** [#42](https://github.com/getappz/agentflare/issues/42) -**Spec:** `docs/superpowers/specs/2026-07-07-ponytail-l1-integration-design.md` -**Branch:** `feature/ponytail-l1-integration` - -## Global Constraints - -- Rust edition 2024, rust-version 1.91 -- unsafe_code = "warn", clippy all = "warn", pedantic = "warn" -- No new crate dependencies — reuse dirs, serde, serde_json, ureq -- Follow existing clap patterns: `#[command(subcommand)]` for nested commands -- Config/state/cache paths use agentflare paths (not ponytail's original paths) -- Embedded SKILL.md as `include_str!("skill.md")` — fallback only - ---- - -## File Structure - -| File | Purpose | -|------|---------| -| `src/ponytail/mod.rs` | Public API re-exports, `PonytailMode` struct | -| `src/ponytail/config.rs` | Mode resolution (env → config.json → "full"), validation, config I/O | -| `src/ponytail/state.rs` | Flag file `.ponytail-active` read/write | -| `src/ponytail/instructions.rs` | SKILL.md loading, intensity filtering, fallback generation | -| `src/ponytail/switcher.rs` | Mode switch detection in user input | -| `src/ponytail/platform.rs` | Agent platform detection, per-platform output formatting | -| `src/ponytail/skill.md` | Embedded default SKILL.md content (compiled into binary) | -| `src/main.rs` | Add `mod ponytail;`, `Ponytail` variant to `Commands`, dispatch | - ---- - -### Task 1: ponytail/config.rs - -**Files:** -- Create: `src/ponytail/config.rs` -- Create: `src/ponytail/mod.rs` - -**Interfaces:** -- Produces: - - `pub const DEFAULT_MODE: &str = "full"` - - `pub const VALID_MODES: &[&str] = &["off", "lite", "full", "ultra", "review"]` - - `pub const RUNTIME_MODES: &[&str] = &["off", "lite", "full", "ultra"]` - - `pub fn normalize_mode(mode: &str) -> Option<&'static str>` - - `pub fn normalize_config_mode(mode: &str) -> Option<&'static str>` - - `pub fn normalize_persisted_mode(mode: &str) -> Option<&'static str>` - - `pub fn is_deactivation(text: &str) -> bool` - - `pub fn default_mode() -> String` - - `pub fn set_default_mode(mode: &str) -> Result<(), String>` - - `pub fn config_dir() -> PathBuf` - - `pub fn config_path() -> PathBuf` - -- [ ] **Step 1: Create `src/ponytail/mod.rs` skeleton** - -```rust -pub mod config; -pub mod state; -pub mod instructions; -pub mod switcher; -pub mod platform; -``` - -- [ ] **Step 2: Create `src/ponytail/config.rs`** - -```rust -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -pub const DEFAULT_MODE: &str = "full"; -pub const VALID_MODES: &[&str] = &["off", "lite", "full", "ultra", "review"]; -pub const RUNTIME_MODES: &[&str] = &["off", "lite", "full", "ultra"]; - -pub fn normalize_mode(mode: &str) -> Option<&'static str> { - let m = mode.trim().to_lowercase(); - RUNTIME_MODES.iter().find(|&&v| v == m).copied() -} - -pub fn normalize_config_mode(mode: &str) -> Option<&'static str> { - let m = mode.trim().to_lowercase(); - VALID_MODES.iter().find(|&&v| v == m).copied() -} - -pub fn normalize_persisted_mode(mode: &str) -> Option<&'static str> { - normalize_mode(mode).or_else(|| normalize_config_mode(mode)) -} - -pub fn is_deactivation(text: &str) -> bool { - let t = text.trim().to_lowercase(); - let t = t.trim_end_matches(|c: char| c == '.' || c == '!' || c == '?' || c.is_whitespace()); - t == "stop ponytail" || t == "normal mode" -} - -pub fn config_dir() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("agentflare") - .join("ponytail") -} - -pub fn config_path() -> PathBuf { - config_dir().join("config.json") -} - -#[derive(Serialize, Deserialize, Default)] -struct ConfigFile { - default_mode: Option, -} - -pub fn default_mode() -> String { - if let Ok(val) = std::env::var("PONYTAIL_DEFAULT_MODE") { - if let Some(m) = normalize_config_mode(&val) { - return m.to_string(); - } - } - if let Ok(data) = std::fs::read_to_string(config_path()) { - if let Ok(cfg) = serde_json::from_str::(&data) { - if let Some(mode) = cfg.default_mode { - if let Some(m) = normalize_config_mode(&mode) { - return m.to_string(); - } - } - } - } - DEFAULT_MODE.to_string() -} - -pub fn set_default_mode(mode: &str) -> Result<(), String> { - let normalized = normalize_config_mode(mode).ok_or_else(|| format!("invalid mode: {mode}"))?; - let dir = config_dir(); - std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - let mut cfg: ConfigFile = std::fs::read_to_string(config_path()) - .ok() - .and_then(|d| serde_json::from_str(&d).ok()) - .unwrap_or_default(); - cfg.default_mode = Some(normalized.to_string()); - let json = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?; - std::fs::write(config_path(), json).map_err(|e| e.to_string())?; - Ok(()) -} -``` - -- [ ] **Step 3: Build check** - -```bash -cargo check -``` - -Expected: compiles. `config` type dead-code warnings OK (consumed later). - -- [ ] **Step 4: Commit** - -```bash -git add src/ponytail/ -git commit -m "feat(ponytail): add config module — mode resolution and validation" -``` - ---- - -### Task 2: ponytail/state.rs - -**Files:** -- Create: `src/ponytail/state.rs` -- Modify: `src/ponytail/mod.rs` - -**Interfaces:** -- Produces: - - `pub fn flag_path() -> PathBuf` - - `pub fn active_mode() -> Option` - - `pub fn set_active(mode: &str) -> io::Result<()>` - - `pub fn clear_active()` - -- [ ] **Step 1: Create `src/ponytail/state.rs`** - -```rust -use std::io; -use std::path::PathBuf; - -pub fn flag_path() -> PathBuf { - dirs::state_dir() - .unwrap_or_else(|| dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("."))) - .join("agentflare") - .join("ponytail") - .join("active") -} - -pub fn active_mode() -> Option { - std::fs::read_to_string(flag_path()) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) -} - -pub fn set_active(mode: &str) -> io::Result<()> { - let path = flag_path(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(path, mode) -} - -pub fn clear_active() { - let _ = std::fs::remove_file(flag_path()); -} -``` - -- [ ] **Step 2: Build check** - -```bash -cargo check -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/ponytail/state.rs -git commit -m "feat(ponytail): add state module — flag file read/write" -``` - ---- - -### Task 3: ponytail/skill.md (embedded fallback) - -**Files:** -- Create: `src/ponytail/skill.md` - -Embed the canonical SKILL.md as a fallback. Copy the content from the cloned ponytail repo at `C:\Users\shiva\workspace\refs\ponytail\skills\ponytail\SKILL.md`. - -- [ ] **Step 1: Copy skill.md** - -```bash -copy C:\Users\shiva\workspace\refs\ponytail\skills\ponytail\SKILL.md src\ponytail\skill.md -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/ponytail/skill.md -git commit -m "feat(ponytail): embed fallback SKILL.md" -``` - ---- - -### Task 4: ponytail/instructions.rs - -**Files:** -- Create: `src/ponytail/instructions.rs` - -**Interfaces:** -- Consumes: `ponytail::config::normalize_mode`, `ponytail::config::normalize_persisted_mode`, `ponytail::config::DEFAULT_MODE` -- Produces: - - `pub struct Instructions { pub mode: String, pub body: String }` - - `pub fn build(mode: &str, skill_path: Option<&std::path::Path>) -> Instructions` - - `pub fn filter_skill_body(body: &str, mode: &str) -> String` - - `pub fn fallback_instructions(mode: &str) -> String` - -- [ ] **Step 1: Create `src/ponytail/instructions.rs`** - -```rust -use crate::ponytail::config; -use std::path::Path; - -static EMBEDDED_SKILL: &str = include_str!("skill.md"); - -pub struct Instructions { - pub mode: String, - pub body: String, -} - -pub fn build(mode: &str, skill_path: Option<&Path>) -> Instructions { - let effective = config::normalize_persisted_mode(mode) - .unwrap_or(config::DEFAULT_MODE); - - let skill_body = if let Some(path) = skill_path { - std::fs::read_to_string(path).unwrap_or_else(|_| EMBEDDED_SKILL.to_string()) - } else { - let cache = crate::ponytail::state::flag_path() - .parent() - .unwrap_or(Path::new(".")) - .parent() - .unwrap_or(Path::new(".")) - .parent() - .unwrap_or(Path::new(".")) - .join("SKILL.md"); - std::fs::read_to_string(&cache).unwrap_or_else(|_| EMBEDDED_SKILL.to_string()) - }; - - let filtered = filter_skill_body(&skill_body, effective); - - Instructions { - mode: effective.to_string(), - body: filtered, - } -} - -pub fn filter_skill_body(body: &str, mode: &str) -> String { - let effective = config::normalize_mode(mode).unwrap_or(config::DEFAULT_MODE); - body.lines() - .filter(|line| { - if let Some(cap) = line.trim().strip_prefix("| **") { - if let Some(end) = cap.find("** |") { - let label_mode = config::normalize_mode(&cap[..end]); - if label_mode.is_some() { - return label_mode.unwrap() == effective; - } - } - } - if let Some(rest) = line.trim().strip_prefix("- ") { - if let Some(colon) = rest.find(':') { - let label_mode = config::normalize_mode(rest[..colon].trim()); - if label_mode.is_some() { - return label_mode.unwrap() == effective; - } - } - } - true - }) - .collect::>() - .join("\n") -} - -pub fn fallback_instructions(mode: &str) -> String { - let m = config::normalize_mode(mode).unwrap_or(config::DEFAULT_MODE); - format!( - "PONYTAIL MODE ACTIVE — level: {m}\n\n\ - You are a lazy senior developer. Lazy means efficient, not careless.\n\n\ - ## The ladder\n\n\ - 1. Does this need to exist at all? (YAGNI)\n\ - 2. Already in this codebase? Reuse it.\n\ - 3. Stdlib does it? Use it.\n\ - 4. Native platform feature covers it? Use it.\n\ - 5. Already-installed dependency solves it? Use it.\n\ - 6. Can it be one line? One line.\n\ - 7. Only then: the minimum code that works.\n\n\ - ## Rules\n\n\ - No unrequested abstractions. No boilerplate. Deletion over addition.\n\ - Code first, then at most three lines: what was skipped, when to add it.\n\ - Never simplify away: input validation, error handling, security, accessibility." - ) -} -``` - -- [ ] **Step 2: Build check** - -```bash -cargo check -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/ponytail/instructions.rs -git commit -m "feat(ponytail): add instructions module — skill loading and filtering" -``` - ---- - -### Task 5: ponytail/switcher.rs - -**Files:** -- Create: `src/ponytail/switcher.rs` - -**Interfaces:** -- Consumes: `ponytail::config::normalize_config_mode` -- Produces: - - `pub enum SwitchAction { SetMode(String), SetDefault(String), Off }` - - `pub fn detect(input: &str) -> Option` - -- [ ] **Step 1: Create `src/ponytail/switcher.rs`** - -```rust -use crate::ponytail::config; - -pub enum SwitchAction { - SetMode(String), - SetDefault(String), - Off, -} - -pub fn detect(input: &str) -> Option { - let prompt = input.trim().to_lowercase(); - - if config::is_deactivation(&prompt) { - return Some(SwitchAction::Off); - } - - let cmd = prompt - .strip_prefix("/ponytail") - .or_else(|| prompt.strip_prefix("@ponytail")) - .or_else(|| prompt.strip_prefix("$ponytail"))?; - - let parts: Vec<&str> = cmd.split_whitespace().collect(); - let sub = parts.first().copied().unwrap_or(""); - let arg = parts.get(1).copied().unwrap_or(""); - - if sub.is_empty() || sub == "lite" || sub == "full" || sub == "ultra" { - let mode = if sub.is_empty() { "full" } else { sub }; - config::normalize_config_mode(mode)?; - return Some(SwitchAction::SetMode(mode.to_string())); - } - - match sub { - "off" => Some(SwitchAction::Off), - "default" => { - let dmode = arg; - if dmode.is_empty() { - return None; - } - config::normalize_config_mode(dmode)?; - Some(SwitchAction::SetDefault(dmode.to_string())) - } - _ => None, - } -} -``` - -- [ ] **Step 2: Build check** - -```bash -cargo check -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/ponytail/switcher.rs -git commit -m "feat(ponytail): add switcher module — mode switch detection" -``` - ---- - -### Task 6: ponytail/platform.rs - -**Files:** -- Create: `src/ponytail/platform.rs` - -**Interfaces:** -- Produces: - - `pub enum AgentPlatform { Claude, Codex, Copilot, Fallback }` - - `pub fn detect() -> AgentPlatform` - - `pub fn format_hook_output(event: &str, ctx: &str, platform: &AgentPlatform) -> String` - -- [ ] **Step 1: Create `src/ponytail/platform.rs`** - -```rust -use serde_json::json; - -pub enum AgentPlatform { - Claude, - Codex, - Copilot, - Fallback, -} - -pub fn detect() -> AgentPlatform { - if std::env::var("CLAUDE_CONFIG_DIR").is_ok() { - AgentPlatform::Claude - } else if std::env::var("COPILOT_PLUGIN_DATA").is_ok() { - AgentPlatform::Copilot - } else if std::env::var("PLUGIN_DATA").is_ok() { - AgentPlatform::Codex - } else { - AgentPlatform::Fallback - } -} - -pub fn format_hook_output(event: &str, ctx: &str, platform: &AgentPlatform) -> String { - match platform { - AgentPlatform::Claude => { - if event == "SessionStart" && !ctx.is_empty() { - json!({ - "hookSpecificOutput": { - "hookEventName": event, - "additionalContext": ctx, - } - }) - .to_string() - } else { - let output: serde_json::Value = json!({ - "hookSpecificOutput": { - "hookEventName": event, - "additionalContext": ctx, - } - }); - output.to_string() - } - } - AgentPlatform::Codex => { - if event == "SessionStart" { - json!({ - "systemMessage": "PONYTAIL:FULL", - "hookSpecificOutput": { - "hookEventName": event, - "additionalContext": ctx, - } - }) - .to_string() - } else { - json!({ - "hookSpecificOutput": { - "hookEventName": event, - "additionalContext": ctx, - } - }) - .to_string() - } - } - AgentPlatform::Copilot => { - if event == "SessionStart" { - json!({ "additionalContext": ctx }).to_string() - } else { - String::new() - } - } - AgentPlatform::Fallback => ctx.to_string(), - } -} -``` - -- [ ] **Step 2: Build check** - -```bash -cargo check -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/ponytail/platform.rs -git commit -m "feat(ponytail): add platform module — detection and output formatting" -``` - ---- - -### Task 7: ponytail/mod.rs — public API - -**Files:** -- Modify: `src/ponytail/mod.rs` - -Replace skeleton with full public API. - -- [ ] **Step 1: Update `src/ponytail/mod.rs`** - -```rust -pub mod config; -pub mod instructions; -pub mod platform; -pub mod state; -pub mod switcher; - -pub use config::{ - default_mode, is_deactivation, normalize_config_mode, normalize_mode, - normalize_persisted_mode, set_default_mode, DEFAULT_MODE, RUNTIME_MODES, VALID_MODES, -}; -pub use instructions::{build as build_instructions, fallback_instructions, Instructions}; -pub use platform::{detect as detect_platform, format_hook_output, AgentPlatform}; -pub use state::{active_mode, clear_active, set_active}; -pub use switcher::{detect as detect_switch, SwitchAction}; -``` - -- [ ] **Step 2: Build check** - -```bash -cargo check -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/ponytail/mod.rs -git commit -m "feat(ponytail): finalize mod.rs public API" -``` - ---- - -### Task 8: CLI integration — Ponytail command - -**Files:** -- Modify: `src/main.rs` - -Add `mod ponytail;`, `Ponytail` variant to `Commands`, `PonytailAction` enum, and dispatch. - -- [ ] **Step 1: Add module declaration to `src/main.rs`** - -Add after existing `mod` declarations (after line 28 `mod update;`): - -```rust -mod ponytail; -``` - -- [ ] **Step 2: Add `Ponytail` variant to `Commands` enum** - -Add after `Auth` variant: - -```rust - /// Manage Ponytail — lazy senior dev mode for AI agents. - Ponytail { - #[command(subcommand)] - action: PonytailAction, - }, -``` - -- [ ] **Step 3: Add `PonytailAction` subcommand enum** - -Add after `AuthAction` enum definition: - -```rust -#[derive(Subcommand)] -enum PonytailAction { - /// Download SKILL.md and print per-platform hook config snippets. - Setup, - /// Show active ponytail mode (reads flag file + config default). - Status, - /// Set session-scoped mode (off|lite|full|ultra). Writes flag file. - Set { - mode: String, - }, - /// Persist default mode to config. Survives session restarts. - Default { - mode: String, - }, - /// Turn ponytail off for this session. - Off, - /// Re-download SKILL.md from ponytail repo to cache. - Update, - /// Hook entry point — called by agent hook systems. Not for manual use. - Hook { - #[command(subcommand)] - event: PonytailHookEvent, - }, -} - -#[derive(Subcommand)] -enum PonytailHookEvent { - /// Session start — emit rules as hook context, write flag file. - SessionStart, - /// Subagent start — emit rules for subagent context only. - SubagentStart, - /// Prompt submit — parse input for mode switch, update flag if found. - PromptSubmit, - /// Output ANSI mode badge for terminal statusline. - Statusline, -} -``` - -- [ ] **Step 4: Add dispatch in `main()` function** - -Add before the last closing brace of `main()`: - -```rust - Commands::Ponytail { action } => match action { - PonytailAction::Setup => { - println!("download SKILL.md to cache, print per-platform hook configs"); - } - PonytailAction::Status => { - let mode = ponytail::active_mode().unwrap_or_else(ponytail::default_mode); - println!("{mode}"); - } - PonytailAction::Set { mode } => { - let normalized = ponytail::normalize_config_mode(&mode) - .unwrap_or("full"); - ponytail::set_active(normalized).unwrap_or_else(|e| { - eprintln!("error: {e}"); - std::process::exit(1); - }); - println!("{normalized}"); - } - PonytailAction::Default { mode } => { - ponytail::set_default_mode(&mode).unwrap_or_else(|e| { - eprintln!("error: {e}"); - std::process::exit(1); - }); - ponytail::set_active(&mode).ok(); - println!("default: {mode}"); - } - PonytailAction::Off => { - ponytail::clear_active(); - println!("off"); - } - PonytailAction::Update => { - println!("re-download SKILL.md from ponytail repo"); - } - PonytailAction::Hook { event } => match event { - PonytailHookEvent::SessionStart => { - let mode = ponytail::active_mode() - .unwrap_or_else(ponytail::default_mode); - if mode == "off" { - ponytail::state::clear_active(); - println!("OK"); - return; - } - ponytail::set_active(&mode).ok(); - let instructions = ponytail::build_instructions(&mode, None); - let platform = ponytail::detect_platform(); - let output = ponytail::format_hook_output( - "SessionStart", - &instructions.body, - &platform, - ); - println!("{output}"); - } - PonytailHookEvent::SubagentStart => { - let mode = ponytail::active_mode() - .unwrap_or_else(ponytail::default_mode); - if mode == "off" { - println!("OK"); - return; - } - let instructions = ponytail::build_instructions(&mode, None); - let platform = ponytail::detect_platform(); - let output = ponytail::format_hook_output( - "SubagentStart", - &instructions.body, - &platform, - ); - println!("{output}"); - } - PonytailHookEvent::PromptSubmit => { - let mut input = String::new(); - std::io::stdin().read_line(&mut input).ok(); - if let Some(action) = ponytail::detect_switch(&input) { - match action { - ponytail::SwitchAction::SetMode(m) => { - ponytail::set_active(&m).ok(); - } - ponytail::SwitchAction::SetDefault(m) => { - ponytail::set_default_mode(&m).ok(); - ponytail::set_active(&m).ok(); - } - ponytail::SwitchAction::Off => { - ponytail::clear_active(); - } - } - } - println!("OK"); - } - PonytailHookEvent::Statusline => { - let mode = ponytail::active_mode() - .unwrap_or_else(ponytail::default_mode); - if mode == "off" || mode.is_empty() { - return; // no output = no badge - } - if mode == "full" { - print!("\x1b[38;5;108m[PONYTAIL]\x1b[0m"); - } else { - let upper = mode.to_uppercase(); - print!("\x1b[38;5;108m[PONYTAIL:{upper}]\x1b[0m"); - } - } - }, - } -``` - -- [ ] **Step 5: Build check** - -```bash -cargo check -``` - -- [ ] **Step 6: Commit** - -```bash -git add src/main.rs -git commit -m "feat(ponytail): add CLI commands — setup, status, set, hook" -``` - ---- - -### Task 9: Unit tests - -**Files:** -- Create: `src/ponytail/config.rs` (append tests) -- Create: `src/ponytail/state.rs` (append tests) -- Create: `src/ponytail/instructions.rs` (append tests) -- Create: `src/ponytail/switcher.rs` (append tests) - -- [ ] **Step 1: Add config tests to `src/ponytail/config.rs`** - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalizes_valid_modes() { - assert_eq!(normalize_mode("full"), Some("full")); - assert_eq!(normalize_mode("off"), Some("off")); - assert_eq!(normalize_mode("ULTRA"), Some("ultra")); - } - - #[test] - fn rejects_invalid_modes() { - assert_eq!(normalize_mode("extreme"), None); - assert_eq!(normalize_mode(""), None); - assert_eq!(normalize_config_mode("review"), Some("review")); - assert_eq!(normalize_mode("review"), None); // review not a runtime mode - } - - #[test] - fn detects_deactivation() { - assert!(is_deactivation("stop ponytail")); - assert!(is_deactivation("normal mode")); - assert!(is_deactivation("Normal Mode.")); - assert!(!is_deactivation("add a normal mode toggle")); - } - - #[test] - fn defaults_to_full() { - std::env::remove_var("PONYTAIL_DEFAULT_MODE"); - assert_eq!(default_mode(), "full"); - } - - #[test] - fn reads_env_var() { - std::env::set_var("PONYTAIL_DEFAULT_MODE", "lite"); - assert_eq!(default_mode(), "lite"); - std::env::remove_var("PONYTAIL_DEFAULT_MODE"); - } -} -``` - -- [ ] **Step 2: Run config tests** - -```bash -cargo test ponytail::config -``` - -Expected: 5 PASS - -- [ ] **Step 3: Add state tests** - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn roundtrip_active_mode() { - clear_active(); - assert_eq!(active_mode(), None); - set_active("full").unwrap(); - assert_eq!(active_mode(), Some("full".to_string())); - clear_active(); - assert_eq!(active_mode(), None); - } - - #[test] - fn clear_nonexistent_is_noop() { - clear_active(); // should not panic - } -} -``` - -- [ ] **Step 4: Run state tests** - -```bash -cargo test ponytail::state -``` - -Expected: 2 PASS - -- [ ] **Step 5: Add instructions tests** - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fallback_generates_for_mode() { - let f = fallback_instructions("full"); - assert!(f.contains("PONYTAIL MODE ACTIVE")); - assert!(f.contains("The ladder")); - } - - #[test] - fn build_uses_embedded_skill() { - let ins = build("full", None); - assert!(!ins.body.is_empty()); - assert_eq!(ins.mode, "full"); - } - - #[test] - fn filter_keeps_non_mode_lines() { - let input = "some rule\n| **lite** | lite only |\n| **full** | full only |\nother rule"; - let filtered = filter_skill_body(input, "full"); - assert!(filtered.contains("some rule")); - assert!(filtered.contains("full only")); - assert!(!filtered.contains("lite only")); - assert!(filtered.contains("other rule")); - } -} -``` - -- [ ] **Step 6: Run instructions tests** - -```bash -cargo test ponytail::instructions -``` - -Expected: 3 PASS - -- [ ] **Step 7: Add switcher tests** - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn detects_mode_switch() { - assert!(matches!(detect("/ponytail lite"), Some(SwitchAction::SetMode(m)) if m == "lite")); - assert!(matches!(detect("/ponytail full"), Some(SwitchAction::SetMode(m)) if m == "full")); - } - - #[test] - fn detects_off() { - assert!(matches!(detect("/ponytail off"), Some(SwitchAction::Off))); - } - - #[test] - fn detects_deactivation_phrase() { - assert!(matches!(detect("stop ponytail"), Some(SwitchAction::Off))); - } - - #[test] - fn detects_default() { - assert!(matches!(detect("/ponytail default ultra"), Some(SwitchAction::SetDefault(m)) if m == "ultra")); - } - - #[test] - fn ignores_false_positives() { - assert!(detect("let's talk about ponytail").is_none()); - assert!(detect("").is_none()); - } -} -``` - -- [ ] **Step 8: Run switcher tests** - -```bash -cargo test ponytail::switcher -``` - -Expected: 5 PASS - -- [ ] **Step 9: Commit** - -```bash -git add src/ponytail/config.rs src/ponytail/state.rs src/ponytail/instructions.rs src/ponytail/switcher.rs -git commit -m "test(ponytail): add unit tests for config, state, instructions, switcher" -``` - ---- - -### Task 10: Build and lint - -- [ ] **Step 1: Full build** - -```bash -cargo build -``` - -- [ ] **Step 2: Run all ponytail tests** - -```bash -cargo test ponytail -``` - -- [ ] **Step 3: Clippy** - -```bash -cargo clippy -- -D warnings -``` - -- [ ] **Step 4: Check for unsafe** - -```bash -cargo check -``` - -- [ ] **Step 5: Commit any lint fixes** - -```bash -git add -u -git commit -m "chore(ponytail): fix clippy warnings" -``` diff --git a/docs/superpowers/specs/2026-07-07-auth-phase2-rotation-design.md b/docs/superpowers/specs/2026-07-07-auth-phase2-rotation-design.md deleted file mode 100644 index 0e622268..00000000 --- a/docs/superpowers/specs/2026-07-07-auth-phase2-rotation-design.md +++ /dev/null @@ -1,181 +0,0 @@ -# Phase 2: Auth Rotation, Cooldown & Health Scoring - -**Status:** design -**Issue:** [#23](https://github.com/getappz/agentflare/issues/23) -**Depends on:** Phase 1 (vault + basic switching — in master) - -## Summary - -Add smart multi-profile rotation, cooldown tracking, and health scoring to the auth profile vault. Uses SQLite (rusqlite, already in deps) for state persistence, matching the `src/rollup.rs` pattern. - -## Architecture - -``` -agentflare auth rotate # smart rotation (default) -agentflare auth rotate --algorithm round-robin|random -agentflare auth next # preview rotation result -agentflare auth pick # interactive fzf-style selector -agentflare auth cooldown set / [--minutes N] -agentflare auth cooldown list [agent] -agentflare auth cooldown clear / -agentflare auth alias -agentflare auth project set -agentflare auth project unset -``` - -Commands use slug targets: `/` for cooldown/alias where both are needed. - -Top-level refactor: Phase 1 `agents auth *` commands move to `agentflare auth *` for consistency. - -## Files - -| File | Purpose | -|------|---------| -| `src/auth_db.rs` | SQLite schema, migrations, CRUD for health/cooldowns/aliases/projects | -| `src/auth.rs` | Extended CLI dispatch + rotation logic + health scoring | -| `src/main.rs` | Refactor AuthAction to top-level `Auth` command | - -## Database - -Reuses `rusqlite` from `src/rollup.rs`. DB path: `~/.local/share/agentflare/auth.db`. Migrations follow `rollup.rs` pattern: `SCHEMA_VERSION` tracking, `migrate()` function, `open_or_rebuild()`. - -### Schema v2 (extends v1 — vault tables already present) - -```sql -CREATE TABLE profile_health ( - agent TEXT NOT NULL, - profile TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'healthy', - error_count_1h INTEGER NOT NULL DEFAULT 0, - penalty REAL NOT NULL DEFAULT 0.0, - last_used_at TEXT, - updated_at TEXT NOT NULL, - PRIMARY KEY (agent, profile) -); - -CREATE TABLE cooldowns ( - agent TEXT NOT NULL, - profile TEXT NOT NULL, - until TEXT NOT NULL, - reason TEXT, - PRIMARY KEY (agent, profile) -); - -CREATE TABLE aliases ( - agent TEXT NOT NULL, - alias TEXT NOT NULL, - profile TEXT NOT NULL, - PRIMARY KEY (agent, alias) -); - -CREATE TABLE projects ( - path TEXT NOT NULL, - agent TEXT NOT NULL, - profile TEXT NOT NULL, - PRIMARY KEY (path, agent) -); -``` - -## Health Scoring - -Passive — health inferred from error history, not token introspection. - -### Status Tiers - -| Status | Condition | -|--------|-----------| -| `healthy` | 0 errors in last hour, no cooldown | -| `warning` | 1+ errors in last hour, or penalty > 5.0 | -| `critical` | 5+ errors in last hour, or explicitly cooldown'd | - -### Penalty System - -Error types and their penalty weights: - -| Error type | Penalty | Detection | -|-----------|---------|-----------| -| rate_limit | 10.0 | Output contains "429" / "rate limit" / "too many requests" | -| auth_error | 100.0 | Output contains "401" / "403" / "unauthorized" | -| timeout | 5.0 | Timeout or "deadline exceeded" | -| server_error | 5.0 | Output contains "500" / "502" / "503" / "504" | -| unknown | 3.0 | Any other error | - -Exponential decay: penalty × 0.8 every 5 minutes since `last_error` time. - -## Rotation Algorithms - -### Smart (default) - -Multi-factor scoring per profile: -1. Health base: healthy=100, warning=50, critical=0 -2. Minus penalty (if any) -3. Plus recency bonus: +10 if never used in last 30 min -4. Plus random jitter: ±5 -5. Highest score wins - -Skips: cooldown'd profiles, profiles with auth_error penalty >= 100. - -### Round-robin - -Sequential through profiles in alphabetical order. Skips cooldown'd profiles. Tracks position per agent in `rotation_state.last_profile`. - -### Random - -Uniform random among all non-cooldown profiles. - -## Cooldown - -- `cooldown set / --minutes 60` — blocks profile for N minutes -- Default: 60 minutes -- Setting cooldown auto-calculates: if no `--minutes`, uses default -- `cooldown list` — shows all active cooldowns with remaining time -- `cooldown clear` — removes cooldown entry -- Rotation algorithms automatically skip cooldown'd profiles -- Activating a cooldown'd profile warns + confirms (--force bypasses) - -## Aliases - -- `auth alias claude-code work work@company.com` — maps alias `work` to full profile name -- Resolved transparently in `activate`, `rotate`, etc. -- `auth ls` shows aliases inline: `work -> work@company.com` - -## Project Associations - -- `auth project set claude-code work@company.com` — binds profile to CWD -- Cascading: parent dir associations apply to subdirectories -- `auth activate claude-code` auto-resolves to project-associated profile -- Stored as absolute path → profile mapping - -## CLI Structure (in main.rs) - -```rust -Auth { - #[command(subcommand)] - action: AuthAction, -} -``` - -`AuthAction` moves from `AgentsAction` to become top-level, gaining new variants: Rotate, Next, Pick, Cooldown { Set/List/Clear }, Alias, Project { Set/Unset }. - -## JSON Output - -All commands support `--json`. Rotation output: -```json -{"agent":"claude-code","profile":"bob@gmail.com","algorithm":"smart","reason":"best health score (100)","skipped":["alice@gmail.com (cooldown)"]} -``` - -## Testing - -- All DB operations tested with temp SQLite in `with_temp_home` -- Rotation: test that cooldown'd profiles are skipped -- Penalty: test decay calculation, error categorization -- Health: test status transitions based on error counts -- Project associations: test cascading resolution -- 10-12 new tests - -## Out of Scope (Phase 3) - -- `auth run` wrapper with automatic failover -- Daemon detection + reload -- Profile isolation (isolated/shallow profiles) -- `auth exec` / `auth login` diff --git a/docs/superpowers/specs/2026-07-07-ponytail-l1-integration-design.md b/docs/superpowers/specs/2026-07-07-ponytail-l1-integration-design.md deleted file mode 100644 index d57afa0f..00000000 --- a/docs/superpowers/specs/2026-07-07-ponytail-l1-integration-design.md +++ /dev/null @@ -1,189 +0,0 @@ -# Ponytail L1 Integration — Design - -**Issue:** [#42](https://github.com/getappz/agentflare/issues/42) -**Date:** 2026-07-07 -**Branch:** `feature/ponytail-l1-integration` - -## Goal - -Port ponytail's runtime logic (config management, state tracking, instructions builder, mode switcher, platform output formatting) from Node.js hooks into agentflare's Rust binary. Prompt content (SKILL.md) stays external — fetched on demand from the ponytail repo. - -agentflare becomes the hook provider that every AI agent platform calls. The ponytail npm plugin becomes a thin manifest pointing at `agentflare ponytail hook`. - -## Architecture - -``` -┌─ CLI surface ──────────────────────────────────────────┐ -│ agentflare ponytail setup download SKILL.md │ -│ agentflare ponytail status show active mode │ -│ agentflare ponytail set session-scoped mode │ -│ agentflare ponytail default persist default mode │ -│ agentflare ponytail off shortcut off │ -│ agentflare ponytail update re-download skill │ -│ agentflare ponytail hook hook entrypoint │ -└─────────────────────────────────────────────────────────┘ - │ -┌─ Core lib (src/ponytail/) ─────────────────────────────┐ -│ mod.rs — pub API, re-exports │ -│ config.rs — Config struct, mode resolution │ -│ state.rs — flag file r/w (.ponytail-active) │ -│ instructions.rs — SkillDoc, filter_skill, fallback │ -│ switcher.rs — SwitchAction, detect_switch │ -│ platform.rs — AgentPlatform, format_output │ -│ skill.md — embedded default (fallback) │ -└─────────────────────────────────────────────────────────┘ - │ -┌─ Storage ──────────────────────────────────────────────┐ -│ ~/.config/agentflare/ponytail/config.json │ -│ ~/.local/state/agentflare/ponytail/active │ -│ ~/.cache/agentflare/ponytail/SKILL.md (downloaded) │ -└─────────────────────────────────────────────────────────┘ -``` - -State paths are agentflare-owned to avoid collision with existing ponytail plugin installs. - -## Module Details - -### config.rs - -```rust -const DEFAULT_MODE: &str = "full"; -const VALID_MODES: [&str; 5] = ["off", "lite", "full", "ultra", "review"]; -const RUNTIME_MODES: [&str; 4] = ["off", "lite", "full", "ultra"]; - -struct Config { - default_mode: String, -} -impl Config { - fn load() -> Self; // env -> config.json -> "full" - fn set_default(&mut self, mode: &str) -> bool; // persist to config.json - fn save(&self); -} -fn normalize_mode(mode: &str) -> Option<&str>; // validate against RUNTIME_MODES -fn normalize_config_mode(mode: &str) -> Option<&str>;// validate against VALID_MODES -fn is_deactivation(text: &str) -> bool; // "stop ponytail" / "normal mode" -``` - -Resolution order: `PONYTAIL_DEFAULT_MODE` env → `config.json` → `"full"`. -Config path: `~/.config/agentflare/ponytail/config.json`. - -### state.rs - -```rust -fn flag_path() -> PathBuf; // ~/.local/state/agentflare/ponytail/active -fn active_mode() -> Option; -fn set_active(mode: &str) -> io::Result<()>; -fn clear_active(); -``` - -Simple file-based flag. Write "full", "lite", etc. Delete on "off". Used by statusline and session-start to know active mode without re-parsing config. - -### instructions.rs - -```rust -struct Instructions { - mode: String, - body: String, // filtered SKILL.md content -} - -fn build(mode: &str, skill_path: Option<&Path>) -> Instructions; -fn filter_skill(body: &str, mode: &str) -> String; -fn fallback(mode: &str) -> String; -``` - -SKILL.md loading: -1. `skill_path` arg → custom path -2. `~/.cache/agentflare/ponytail/SKILL.md` → downloaded copy -3. Embedded `skill.md` → compiled-in fallback - -Filtering: intensity-specific rows in the table and example lines are kept only for the active mode. All other rules pass through unchanged. - -### switcher.rs - -```rust -enum SwitchAction { - SetMode(String), // session-scoped (off|lite|full|ultra) - SetDefault(String), // persist to config (off|lite|full|ultra) - Off, // shortcut for SetMode("off") -} - -fn detect(input: &str) -> Option; -``` - -Matches `/ponytail` command patterns in user prompt input. Used by the `prompt-submit` hook event. - -### platform.rs - -```rust -enum AgentPlatform { Claude, Codex, Copilot, Fallback } - -fn detect() -> AgentPlatform; -fn format(event: &str, ctx: &str, platform: AgentPlatform) -> String; -``` - -Platform detection via env vars: -- `CLAUDE_CONFIG_DIR` → Claude -- `PLUGIN_DATA` + not `COPILOT_PLUGIN_DATA` → Codex -- `COPILOT_PLUGIN_DATA` → Copilot -- none → Fallback (raw text) - -Output formats (exactly matching pony's current behavior): -- **Claude:** `{"hookSpecificOutput":{"hookEventName":"...","additionalContext":"..."}}` -- **Codex:** `{"systemMessage":"PONYTAIL:FULL","hookSpecificOutput":{"hookEventName":"...","additionalContext":"..."}}` -- **Copilot:** `{"additionalContext":"..."}` (SessionStart only, empty otherwise) -- **Fallback:** raw rules text on stdout - -## Hook Command - -``` -agentflare ponytail hook -``` - -Events: - -| Event | Action | -|-------|--------| -| `session-start` | Write flag file, emit rules as hook context | -| `subagent-start` | Emit rules for subagent context (no flag write) | -| `prompt-submit` | Parse input for mode switch, update flag if found | -| `statusline` | Output mode badge (ANSI colored) | - -Exit 0 on success, non-zero on error. Hook author handles failure gracefully (never blocks session start). - -## CLI Commands - -``` -agentflare ponytail setup download SKILL.md to cache, print per-platform hook configs -agentflare ponytail status print active mode (reads flag + config) -agentflare ponytail set write flag, session-scoped (off|lite|full|ultra) -agentflare ponytail default persist to config.json, write flag -agentflare ponytail off shortcut: ponytail set off -agentflare ponytail update re-download SKILL.md from ponytail repo -``` - -## Dependencies - -No new crate dependencies. Existing deps cover everything: -- `dirs` — config/state/cache paths -- `serde` / `serde_json` — config serialization, hook JSON output -- `ureq` — HTTP download of SKILL.md - -## Testing - -Unit tests per module: -- `config`: mode resolution order, validation, config r/w roundtrip -- `state`: flag file lifecycle, concurrent reads -- `instructions`: filter removes correct intensity rows, fallback generates -- `switcher`: detects all switch patterns, ignores false positives -- `platform`: detection from env vars, output format per platform - -Integration test: -- `agentflare ponytail hook session-start` → writes flag, emits Claude-format JSON - -## Out of Scope - -- Porting SKILL.md prompt content into Rust (stays external) -- Multi-platform plugin manifest files (`.claude-plugin/`, `.codex-plugin/`, etc.) -- Statusline scripts (`.ps1`, `.sh`) — these just call `agentflare ponytail hook statusline` -- ponytail-review, ponytail-audit, ponytail-debt, ponytail-gain skills (separate features) -- Benchmark suite