diff --git a/.github/workflows/hooks-ci.yml b/.github/workflows/hooks-ci.yml new file mode 100644 index 000000000..8f2fb46f3 --- /dev/null +++ b/.github/workflows/hooks-ci.yml @@ -0,0 +1,206 @@ +name: Hooks CI + +on: + push: + branches: [main, claude/*] + paths: + - 'crates/ruvector-cli/src/cli/hooks.rs' + - 'crates/ruvector-cli/tests/hooks_tests.rs' + - 'npm/packages/cli/**' + - '.github/workflows/hooks-ci.yml' + pull_request: + branches: [main] + paths: + - 'crates/ruvector-cli/**' + - 'npm/packages/cli/**' + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + rust-cli-tests: + name: Rust CLI Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Build CLI + run: cargo build -p ruvector-cli --release + + - name: Run hooks unit tests + run: cargo test -p ruvector-cli hooks --release + + - name: Test hooks commands + run: | + ./target/release/ruvector hooks --help + ./target/release/ruvector hooks stats + ./target/release/ruvector hooks session-start + ./target/release/ruvector hooks pre-edit src/main.rs + ./target/release/ruvector hooks post-edit --success src/main.rs + ./target/release/ruvector hooks remember --type test "CI test content" + ./target/release/ruvector hooks recall "CI test" + ./target/release/ruvector hooks learn test-state test-action --reward 0.5 + ./target/release/ruvector hooks suggest edit-rs --actions coder,reviewer + ./target/release/ruvector hooks route "test task" + ./target/release/ruvector hooks should-test src/lib.rs + ./target/release/ruvector hooks swarm-register ci-agent-1 rust-dev + ./target/release/ruvector hooks swarm-stats + ./target/release/ruvector hooks session-end + + npm-cli-tests: + name: npm CLI Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + working-directory: npm/packages/cli + run: npm install + + - name: Build CLI + working-directory: npm/packages/cli + run: npm run build + + - name: Test hooks commands + working-directory: npm/packages/cli + run: | + node dist/cli.js hooks --help + node dist/cli.js hooks stats + node dist/cli.js hooks session-start + node dist/cli.js hooks pre-edit src/test.ts + node dist/cli.js hooks post-edit --success src/test.ts + node dist/cli.js hooks remember --type test "CI test content" + node dist/cli.js hooks recall "CI test" + node dist/cli.js hooks learn test-state test-action --reward 0.5 + node dist/cli.js hooks suggest edit-ts --actions coder,reviewer + node dist/cli.js hooks route "test task" + node dist/cli.js hooks should-test src/lib.ts + node dist/cli.js hooks swarm-register ci-agent typescript-dev + node dist/cli.js hooks swarm-coordinate ci-agent other-agent --weight 0.8 + node dist/cli.js hooks swarm-optimize "task1,task2" + node dist/cli.js hooks swarm-recommend "typescript" + node dist/cli.js hooks swarm-stats + node dist/cli.js hooks session-end + + postgres-schema-validation: + name: PostgreSQL Schema Validation + runs-on: ubuntu-latest + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: ruvector_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Install PostgreSQL client + run: sudo apt-get install -y postgresql-client + + - name: Create ruvector type stub + run: | + psql "postgresql://test:test@localhost:5432/ruvector_test" < ( + LEFTARG = ruvector, + RIGHTARG = ruvector, + FUNCTION = ruvector_distance + ); + EOF + + - name: Validate hooks schema + run: | + psql "postgresql://test:test@localhost:5432/ruvector_test" -f crates/ruvector-cli/sql/hooks_schema.sql + + - name: Test schema functions + run: | + psql "postgresql://test:test@localhost:5432/ruvector_test" <, + pub dbname: String, +} + +impl PostgresConfig { + /// Create config from environment variables + pub fn from_env() -> Option { + // Try RUVECTOR_POSTGRES_URL first, then DATABASE_URL + if let Ok(url) = env::var("RUVECTOR_POSTGRES_URL").or_else(|_| env::var("DATABASE_URL")) { + return Self::from_url(&url); + } + + // Try individual environment variables + let host = env::var("RUVECTOR_PG_HOST").unwrap_or_else(|_| "localhost".to_string()); + let port = env::var("RUVECTOR_PG_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(5432); + let user = env::var("RUVECTOR_PG_USER").ok()?; + let password = env::var("RUVECTOR_PG_PASSWORD").ok(); + let dbname = env::var("RUVECTOR_PG_DATABASE").unwrap_or_else(|_| "ruvector".to_string()); + + Some(Self { + host, + port, + user, + password, + dbname, + }) + } + + /// Parse PostgreSQL connection URL + pub fn from_url(url: &str) -> Option { + // Parse postgres://user:password@host:port/dbname + let url = url.strip_prefix("postgres://").or_else(|| url.strip_prefix("postgresql://"))?; + + let (auth, rest) = url.split_once('@')?; + let (user, password) = if auth.contains(':') { + let (u, p) = auth.split_once(':')?; + (u.to_string(), Some(p.to_string())) + } else { + (auth.to_string(), None) + }; + + let (host_port, dbname) = rest.split_once('/')?; + let dbname = dbname.split('?').next()?.to_string(); + + let (host, port) = if host_port.contains(':') { + let (h, p) = host_port.split_once(':')?; + (h.to_string(), p.parse().ok()?) + } else { + (host_port.to_string(), 5432) + }; + + Some(Self { + host, + port, + user, + password, + dbname, + }) + } +} + +/// PostgreSQL storage backend for hooks +#[cfg(feature = "postgres")] +pub struct PostgresStorage { + pool: Pool, +} + +#[cfg(feature = "postgres")] +impl PostgresStorage { + /// Create a new PostgreSQL storage backend + pub async fn new(config: PostgresConfig) -> Result> { + let mut cfg = Config::new(); + cfg.host = Some(config.host); + cfg.port = Some(config.port); + cfg.user = Some(config.user); + cfg.password = config.password; + cfg.dbname = Some(config.dbname); + + let pool = cfg.create_pool(Some(Runtime::Tokio1), NoTls)?; + + Ok(Self { pool }) + } + + /// Update Q-value for state-action pair + pub async fn update_q( + &self, + state: &str, + action: &str, + reward: f32, + ) -> Result<(), Box> { + let client = self.pool.get().await?; + client + .execute( + "SELECT ruvector_hooks_update_q($1, $2, $3)", + &[&state, &action, &reward], + ) + .await?; + Ok(()) + } + + /// Get best action for state + pub async fn best_action( + &self, + state: &str, + actions: &[String], + ) -> Result, Box> { + let client = self.pool.get().await?; + let row = client + .query_opt( + "SELECT action, q_value, confidence FROM ruvector_hooks_best_action($1, $2)", + &[&state, &actions], + ) + .await?; + + Ok(row.map(|r| (r.get(0), r.get(1), r.get(2)))) + } + + /// Store content in semantic memory + pub async fn remember( + &self, + memory_type: &str, + content: &str, + embedding: Option<&[f32]>, + metadata: &serde_json::Value, + ) -> Result> { + let client = self.pool.get().await?; + let row = client + .query_one( + "SELECT ruvector_hooks_remember($1, $2, $3, $4)", + &[&memory_type, &content, &embedding, &metadata], + ) + .await?; + + Ok(row.get(0)) + } + + /// Search memory semantically + pub async fn recall( + &self, + query_embedding: &[f32], + limit: i32, + ) -> Result, Box> { + let client = self.pool.get().await?; + let rows = client + .query( + "SELECT id, memory_type, content, metadata, similarity + FROM ruvector_hooks_recall($1, $2)", + &[&query_embedding, &limit], + ) + .await?; + + Ok(rows + .iter() + .map(|r| MemoryResult { + id: r.get(0), + memory_type: r.get(1), + content: r.get(2), + metadata: r.get(3), + similarity: r.get(4), + }) + .collect()) + } + + /// Record file sequence + pub async fn record_sequence( + &self, + from_file: &str, + to_file: &str, + ) -> Result<(), Box> { + let client = self.pool.get().await?; + client + .execute( + "SELECT ruvector_hooks_record_sequence($1, $2)", + &[&from_file, &to_file], + ) + .await?; + Ok(()) + } + + /// Get suggested next files + pub async fn suggest_next( + &self, + file: &str, + limit: i32, + ) -> Result, Box> { + let client = self.pool.get().await?; + let rows = client + .query( + "SELECT to_file, count FROM ruvector_hooks_suggest_next($1, $2)", + &[&file, &limit], + ) + .await?; + + Ok(rows.iter().map(|r| (r.get(0), r.get(1))).collect()) + } + + /// Record error pattern + pub async fn record_error( + &self, + code: &str, + error_type: &str, + message: &str, + ) -> Result<(), Box> { + let client = self.pool.get().await?; + client + .execute( + "SELECT ruvector_hooks_record_error($1, $2, $3)", + &[&code, &error_type, &message], + ) + .await?; + Ok(()) + } + + /// Register agent in swarm + pub async fn swarm_register( + &self, + agent_id: &str, + agent_type: &str, + capabilities: &[String], + ) -> Result<(), Box> { + let client = self.pool.get().await?; + client + .execute( + "SELECT ruvector_hooks_swarm_register($1, $2, $3)", + &[&agent_id, &agent_type, &capabilities], + ) + .await?; + Ok(()) + } + + /// Record coordination between agents + pub async fn swarm_coordinate( + &self, + source: &str, + target: &str, + weight: f32, + ) -> Result<(), Box> { + let client = self.pool.get().await?; + client + .execute( + "SELECT ruvector_hooks_swarm_coordinate($1, $2, $3)", + &[&source, &target, &weight], + ) + .await?; + Ok(()) + } + + /// Get swarm statistics + pub async fn swarm_stats(&self) -> Result> { + let client = self.pool.get().await?; + let row = client + .query_one("SELECT * FROM ruvector_hooks_swarm_stats()", &[]) + .await?; + + Ok(SwarmStats { + total_agents: row.get(0), + active_agents: row.get(1), + total_edges: row.get(2), + avg_success_rate: row.get(3), + }) + } + + /// Get overall statistics + pub async fn get_stats(&self) -> Result> { + let client = self.pool.get().await?; + let row = client + .query_one("SELECT * FROM ruvector_hooks_get_stats()", &[]) + .await?; + + Ok(IntelligenceStats { + total_patterns: row.get(0), + total_memories: row.get(1), + total_trajectories: row.get(2), + total_errors: row.get(3), + session_count: row.get(4), + }) + } + + /// Start a new session + pub async fn session_start(&self) -> Result<(), Box> { + let client = self.pool.get().await?; + client + .execute("SELECT ruvector_hooks_session_start()", &[]) + .await?; + Ok(()) + } +} + +/// Memory search result +#[derive(Debug)] +pub struct MemoryResult { + pub id: i32, + pub memory_type: String, + pub content: String, + pub metadata: serde_json::Value, + pub similarity: f32, +} + +/// Swarm statistics +#[derive(Debug)] +pub struct SwarmStats { + pub total_agents: i64, + pub active_agents: i64, + pub total_edges: i64, + pub avg_success_rate: f32, +} + +/// Intelligence statistics +#[derive(Debug)] +pub struct IntelligenceStats { + pub total_patterns: i64, + pub total_memories: i64, + pub total_trajectories: i64, + pub total_errors: i64, + pub session_count: i64, +} + +/// Check if PostgreSQL is available +pub fn is_postgres_available() -> bool { + PostgresConfig::from_env().is_some() +} + +/// Storage backend selector +pub enum StorageBackend { + #[cfg(feature = "postgres")] + Postgres(PostgresStorage), + Json(super::Intelligence), +} + +impl StorageBackend { + /// Create storage backend from environment + #[cfg(feature = "postgres")] + pub async fn from_env() -> Result> { + if let Some(config) = PostgresConfig::from_env() { + match PostgresStorage::new(config).await { + Ok(pg) => return Ok(Self::Postgres(pg)), + Err(e) => { + eprintln!("Warning: PostgreSQL unavailable ({}), using JSON fallback", e); + } + } + } + Ok(Self::Json(super::Intelligence::new())) + } + + #[cfg(not(feature = "postgres"))] + pub fn from_env() -> Self { + Self::Json(super::Intelligence::new()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_from_url() { + let config = PostgresConfig::from_url("postgres://user:pass@localhost:5432/ruvector").unwrap(); + assert_eq!(config.host, "localhost"); + assert_eq!(config.port, 5432); + assert_eq!(config.user, "user"); + assert_eq!(config.password, Some("pass".to_string())); + assert_eq!(config.dbname, "ruvector"); + } + + #[test] + fn test_config_from_url_no_password() { + let config = PostgresConfig::from_url("postgres://user@localhost/ruvector").unwrap(); + assert_eq!(config.user, "user"); + assert_eq!(config.password, None); + } + + #[test] + fn test_config_from_url_with_query() { + let config = PostgresConfig::from_url("postgres://user:pass@localhost:5432/ruvector?sslmode=require").unwrap(); + assert_eq!(config.dbname, "ruvector"); + } +} diff --git a/crates/ruvector-cli/src/cli/mod.rs b/crates/ruvector-cli/src/cli/mod.rs index 5b48a6586..9d24c6e63 100644 --- a/crates/ruvector-cli/src/cli/mod.rs +++ b/crates/ruvector-cli/src/cli/mod.rs @@ -4,6 +4,8 @@ pub mod commands; pub mod format; pub mod graph; pub mod hooks; +#[cfg(feature = "postgres")] +pub mod hooks_postgres; pub mod progress; pub use commands::*; diff --git a/crates/ruvector-cli/tests/hooks_tests.rs b/crates/ruvector-cli/tests/hooks_tests.rs new file mode 100644 index 000000000..631205a79 --- /dev/null +++ b/crates/ruvector-cli/tests/hooks_tests.rs @@ -0,0 +1,302 @@ +//! Unit tests for the hooks CLI commands + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; +use std::fs; + +/// Helper to get the ruvector binary command +fn ruvector_cmd() -> Command { + Command::cargo_bin("ruvector").unwrap() +} + +#[test] +fn test_hooks_help() { + ruvector_cmd() + .arg("hooks") + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("Self-learning intelligence hooks")); +} + +#[test] +fn test_hooks_stats() { + ruvector_cmd() + .arg("hooks") + .arg("stats") + .assert() + .success() + .stdout(predicate::str::contains("Q-learning patterns")); +} + +#[test] +fn test_hooks_session_start() { + ruvector_cmd() + .arg("hooks") + .arg("session-start") + .assert() + .success() + .stdout(predicate::str::contains("Intelligence Layer Active")); +} + +#[test] +fn test_hooks_session_end() { + ruvector_cmd() + .arg("hooks") + .arg("session-end") + .assert() + .success() + .stdout(predicate::str::contains("Session ended")); +} + +#[test] +fn test_hooks_pre_edit() { + ruvector_cmd() + .arg("hooks") + .arg("pre-edit") + .arg("src/main.rs") + .assert() + .success() + .stdout(predicate::str::contains("Intelligence Analysis")); +} + +#[test] +fn test_hooks_post_edit_success() { + ruvector_cmd() + .arg("hooks") + .arg("post-edit") + .arg("--success") + .arg("src/lib.rs") + .assert() + .success() + .stdout(predicate::str::contains("Learning recorded")); +} + +#[test] +fn test_hooks_pre_command() { + ruvector_cmd() + .arg("hooks") + .arg("pre-command") + .arg("cargo build") + .assert() + .success() + .stdout(predicate::str::contains("Command")); +} + +#[test] +fn test_hooks_post_command() { + ruvector_cmd() + .arg("hooks") + .arg("post-command") + .arg("--success") + .arg("cargo") + .arg("test") + .assert() + .success() + .stdout(predicate::str::contains("recorded")); +} + +#[test] +fn test_hooks_remember() { + ruvector_cmd() + .arg("hooks") + .arg("remember") + .arg("--memory-type") + .arg("test") + .arg("test content for memory") + .assert() + .success() + .stdout(predicate::str::contains("success")); +} + +#[test] +fn test_hooks_recall() { + ruvector_cmd() + .arg("hooks") + .arg("recall") + .arg("test content") + .assert() + .success(); +} + +#[test] +fn test_hooks_learn() { + ruvector_cmd() + .arg("hooks") + .arg("learn") + .arg("test-state") + .arg("test-action") + .arg("--reward") + .arg("0.8") + .assert() + .success() + .stdout(predicate::str::contains("success")); +} + +#[test] +fn test_hooks_suggest() { + ruvector_cmd() + .arg("hooks") + .arg("suggest") + .arg("edit-rs") + .arg("--actions") + .arg("coder,reviewer,tester") + .assert() + .success() + .stdout(predicate::str::contains("action")); +} + +#[test] +fn test_hooks_route() { + ruvector_cmd() + .arg("hooks") + .arg("route") + .arg("implement feature") + .assert() + .success() + .stdout(predicate::str::contains("recommended")); +} + +#[test] +fn test_hooks_should_test() { + ruvector_cmd() + .arg("hooks") + .arg("should-test") + .arg("src/lib.rs") + .assert() + .success() + .stdout(predicate::str::contains("cargo test")); +} + +#[test] +fn test_hooks_suggest_next() { + ruvector_cmd() + .arg("hooks") + .arg("suggest-next") + .arg("src/main.rs") + .assert() + .success(); +} + +#[test] +fn test_hooks_record_error() { + ruvector_cmd() + .arg("hooks") + .arg("record-error") + .arg("cargo build") + .arg("error[E0308]: mismatched types") + .assert() + .success() + .stdout(predicate::str::contains("E0308")); +} + +#[test] +fn test_hooks_suggest_fix() { + ruvector_cmd() + .arg("hooks") + .arg("suggest-fix") + .arg("E0308") + .assert() + .success(); +} + +#[test] +fn test_hooks_swarm_register() { + ruvector_cmd() + .arg("hooks") + .arg("swarm-register") + .arg("test-agent-1") + .arg("rust-developer") + .arg("--capabilities") + .arg("rust,testing") + .assert() + .success() + .stdout(predicate::str::contains("success")); +} + +#[test] +fn test_hooks_swarm_coordinate() { + ruvector_cmd() + .arg("hooks") + .arg("swarm-coordinate") + .arg("agent-1") + .arg("agent-2") + .arg("--weight") + .arg("0.8") + .assert() + .success() + .stdout(predicate::str::contains("success")); +} + +#[test] +fn test_hooks_swarm_optimize() { + ruvector_cmd() + .arg("hooks") + .arg("swarm-optimize") + .arg("task1,task2,task3") + .assert() + .success() + .stdout(predicate::str::contains("assignments")); +} + +#[test] +fn test_hooks_swarm_recommend() { + ruvector_cmd() + .arg("hooks") + .arg("swarm-recommend") + .arg("rust development") + .assert() + .success(); +} + +#[test] +fn test_hooks_swarm_heal() { + ruvector_cmd() + .arg("hooks") + .arg("swarm-heal") + .arg("failed-agent") + .assert() + .success(); +} + +#[test] +fn test_hooks_swarm_stats() { + ruvector_cmd() + .arg("hooks") + .arg("swarm-stats") + .assert() + .success() + .stdout(predicate::str::contains("agents")); +} + +#[test] +fn test_hooks_pre_compact() { + ruvector_cmd() + .arg("hooks") + .arg("pre-compact") + .assert() + .success() + .stdout(predicate::str::contains("Pre-compact")); +} + +#[test] +fn test_hooks_init_creates_config() { + // Just test that init command runs successfully + // The actual config is created in ~/.ruvector/ not the current directory + ruvector_cmd() + .arg("hooks") + .arg("init") + .assert() + .success(); +} + +#[test] +fn test_hooks_install_runs() { + // Just test that install command runs successfully + ruvector_cmd() + .arg("hooks") + .arg("install") + .assert() + .success(); +} diff --git a/npm/packages/cli/src/cli.ts b/npm/packages/cli/src/cli.ts index e5fc09b00..f12d90985 100644 --- a/npm/packages/cli/src/cli.ts +++ b/npm/packages/cli/src/cli.ts @@ -12,7 +12,8 @@ import { program } from 'commander'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { createStorageSync, StorageBackend, JsonStorage } from './storage.js'; + +const INTEL_PATH = path.join(os.homedir(), '.ruvector', 'intelligence.json'); interface QPattern { state: string; @@ -64,6 +65,12 @@ interface SwarmEdge { coordination_count: number; } +interface FileSequence { + from_file: string; + to_file: string; + count: number; +} + interface IntelligenceStats { total_patterns: number; total_memories: number; @@ -78,7 +85,7 @@ interface IntelligenceData { memories: MemoryEntry[]; trajectories: Trajectory[]; errors: Record; - file_sequences: { from_file: string; to_file: string; count: number }[]; + file_sequences: FileSequence[]; agents: Record; edges: SwarmEdge[]; stats: IntelligenceStats; @@ -87,6 +94,7 @@ interface IntelligenceData { class Intelligence { private data: IntelligenceData; private alpha = 0.1; + private lastEditedFile: string | null = null; constructor() { this.data = this.load(); @@ -264,6 +272,100 @@ class Intelligence { } } + // Record file edit sequence for prediction + recordFileSequence(fromFile: string, toFile: string): void { + const existing = this.data.file_sequences.find( + s => s.from_file === fromFile && s.to_file === toFile + ); + if (existing) { + existing.count++; + } else { + this.data.file_sequences.push({ from_file: fromFile, to_file: toFile, count: 1 }); + } + this.lastEditedFile = toFile; + } + + // Suggest next files based on sequences + suggestNext(file: string, limit = 3): { file: string; score: number }[] { + return this.data.file_sequences + .filter(s => s.from_file === file) + .sort((a, b) => b.count - a.count) + .slice(0, limit) + .map(s => ({ file: s.to_file, score: s.count })); + } + + // Record error pattern + recordError(command: string, message: string): string[] { + const codeMatch = message.match(/error\[([A-Z]\d+)\]/i) || message.match(/([A-Z]\d{4})/); + const codes: string[] = []; + + if (codeMatch) { + const code = codeMatch[1]; + codes.push(code); + + if (!this.data.errors[code]) { + this.data.errors[code] = { + code, + error_type: this.classifyError(code), + message: message.slice(0, 500), + fixes: [], + occurrences: 0 + }; + } + this.data.errors[code].occurrences++; + this.data.errors[code].message = message.slice(0, 500); + this.data.stats.total_errors = Object.keys(this.data.errors).length; + } + + return codes; + } + + private classifyError(code: string): string { + if (code.startsWith('E0')) return 'type-error'; + if (code.startsWith('E1')) return 'borrow-error'; + if (code.startsWith('E2')) return 'lifetime-error'; + if (code.startsWith('E3')) return 'trait-error'; + if (code.startsWith('E4')) return 'macro-error'; + if (code.startsWith('E5')) return 'pattern-error'; + if (code.startsWith('E6')) return 'import-error'; + if (code.startsWith('E7')) return 'async-error'; + return 'unknown-error'; + } + + // Get fix suggestions for error code + suggestFix(code: string): { code: string; type: string; fixes: string[]; occurrences: number } | null { + const error = this.data.errors[code]; + if (!error) return null; + return { + code: error.code, + type: error.error_type, + fixes: error.fixes, + occurrences: error.occurrences + }; + } + + // Classify command type + classifyCommand(command: string): { category: string; subcategory: string; risk: string } { + const cmd = command.toLowerCase(); + + if (cmd.includes('cargo') || cmd.includes('rustc')) { + return { category: 'rust', subcategory: cmd.includes('test') ? 'test' : 'build', risk: 'low' }; + } + if (cmd.includes('npm') || cmd.includes('node') || cmd.includes('yarn')) { + return { category: 'javascript', subcategory: cmd.includes('test') ? 'test' : 'build', risk: 'low' }; + } + if (cmd.includes('git')) { + const risk = cmd.includes('push') || cmd.includes('force') ? 'medium' : 'low'; + return { category: 'git', subcategory: 'vcs', risk }; + } + if (cmd.includes('rm') || cmd.includes('delete')) { + return { category: 'filesystem', subcategory: 'destructive', risk: 'high' }; + } + + return { category: 'shell', subcategory: 'general', risk: 'low' }; + } + + // Swarm methods swarmRegister(id: string, agentType: string, capabilities: string[]): void { this.data.agents[id] = { id, @@ -285,11 +387,52 @@ class Intelligence { } } + swarmOptimize(tasks: string[]): { task: string; agents: number; edges: number }[] { + return tasks.map(task => ({ + task, + agents: Object.keys(this.data.agents).length, + edges: this.data.edges.length + })); + } + + swarmRecommend(taskType: string): { agent: string; type: string; score: number } | null { + const agents = Object.values(this.data.agents); + if (agents.length === 0) return null; + + // Find agent with matching capability or best success rate + const matching = agents.filter(a => + a.capabilities.some(c => taskType.toLowerCase().includes(c.toLowerCase())) + ); + + const best = matching.length > 0 + ? matching.sort((a, b) => b.success_rate - a.success_rate)[0] + : agents.sort((a, b) => b.success_rate - a.success_rate)[0]; + + return { agent: best.id, type: best.agent_type, score: best.success_rate }; + } + + swarmHeal(failedAgentId: string): { healed: boolean; replacement: string | null } { + const failed = this.data.agents[failedAgentId]; + if (!failed) return { healed: false, replacement: null }; + + // Mark as failed + failed.status = 'failed'; + failed.success_rate = 0; + + // Find replacement with same type + const replacement = Object.values(this.data.agents).find( + a => a.agent_type === failed.agent_type && a.status === 'active' && a.id !== failedAgentId + ); + + return { healed: true, replacement: replacement?.id ?? null }; + } + swarmStats(): { agents: number; edges: number; avgSuccess: number } { const agents = Object.keys(this.data.agents).length; const edges = this.data.edges.length; - const avgSuccess = agents > 0 - ? Object.values(this.data.agents).reduce((sum, a) => sum + a.success_rate, 0) / agents + const activeAgents = Object.values(this.data.agents).filter(a => a.status === 'active'); + const avgSuccess = activeAgents.length > 0 + ? activeAgents.reduce((sum, a) => sum + a.success_rate, 0) / activeAgents.length : 0; return { agents, edges, avgSuccess }; } @@ -302,6 +445,61 @@ class Intelligence { this.data.stats.session_count++; this.data.stats.last_session = this.now(); } + + sessionEnd(): { duration: number; actions: number } { + const duration = this.now() - this.data.stats.last_session; + const actions = this.data.trajectories.filter(t => t.timestamp >= this.data.stats.last_session).length; + return { duration, actions }; + } + + getLastEditedFile(): string | null { + return this.lastEditedFile; + } +} + +// Generate Claude hooks configuration +function generateClaudeHooksConfig(): object { + return { + hooks: { + PreToolUse: [ + { + matcher: "Edit|Write|MultiEdit", + hooks: [ + "npx @ruvector/cli hooks pre-edit \"$TOOL_INPUT_file_path\"" + ] + }, + { + matcher: "Bash", + hooks: [ + "npx @ruvector/cli hooks pre-command \"$TOOL_INPUT_command\"" + ] + } + ], + PostToolUse: [ + { + matcher: "Edit|Write|MultiEdit", + hooks: [ + "npx @ruvector/cli hooks post-edit --success \"$TOOL_INPUT_file_path\"" + ] + }, + { + matcher: "Bash", + hooks: [ + "npx @ruvector/cli hooks post-command --success \"$TOOL_INPUT_command\"" + ] + } + ], + SessionStart: [ + "npx @ruvector/cli hooks session-start" + ], + Stop: [ + "npx @ruvector/cli hooks session-end" + ], + PreCompact: [ + "npx @ruvector/cli hooks pre-compact" + ] + } + }; } // CLI setup @@ -312,6 +510,67 @@ program const hooks = program.command('hooks').description('Self-learning intelligence hooks for Claude Code'); +// ============================================================================ +// Core Commands +// ============================================================================ + +hooks.command('init') + .description('Initialize hooks in current project') + .option('--force', 'Force overwrite existing configuration') + .action((opts: { force?: boolean }) => { + const configPath = path.join(process.cwd(), '.ruvector', 'hooks.json'); + const configDir = path.dirname(configPath); + + if (fs.existsSync(configPath) && !opts.force) { + console.log('Hooks already initialized. Use --force to overwrite.'); + return; + } + + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + const config = { + version: '1.0.0', + enabled: true, + storage: 'json', + postgres_url: null, + learning: { alpha: 0.1, gamma: 0.95, epsilon: 0.1 } + }; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + console.log('✅ Hooks initialized at .ruvector/hooks.json'); + }); + +hooks.command('install') + .description('Install hooks into Claude settings') + .option('--settings-dir ', 'Claude settings directory', '.claude') + .action((opts: { settingsDir: string }) => { + const settingsPath = path.join(process.cwd(), opts.settingsDir, 'settings.json'); + const settingsDir = path.dirname(settingsPath); + + if (!fs.existsSync(settingsDir)) { + fs.mkdirSync(settingsDir, { recursive: true }); + } + + let settings: Record = {}; + if (fs.existsSync(settingsPath)) { + try { + settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + } catch {} + } + + const hooksConfig = generateClaudeHooksConfig(); + settings = { ...settings, ...hooksConfig }; + + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + console.log(`✅ Hooks installed to ${settingsPath}`); + console.log('\nInstalled hooks:'); + console.log(' - PreToolUse: Edit, Write, MultiEdit, Bash'); + console.log(' - PostToolUse: Edit, Write, MultiEdit, Bash'); + console.log(' - SessionStart, Stop, PreCompact'); + }); + hooks.command('stats') .description('Show intelligence statistics') .action(() => { @@ -331,6 +590,10 @@ hooks.command('stats') console.log(` \x1b[36m${rate}\x1b[0m average success rate`); }); +// ============================================================================ +// Session Hooks +// ============================================================================ + hooks.command('session-start') .description('Session start hook') .action(() => { @@ -341,6 +604,38 @@ hooks.command('session-start') console.log('⚡ Intelligence guides: agent routing, error fixes, file sequences'); }); +hooks.command('session-end') + .description('Session end hook') + .option('--export-metrics', 'Export session metrics') + .action((opts: { exportMetrics?: boolean }) => { + const intel = new Intelligence(); + const sessionInfo = intel.sessionEnd(); + intel.save(); + + console.log('📊 Session ended. Learning data saved.'); + if (opts.exportMetrics) { + console.log(JSON.stringify({ + duration_seconds: sessionInfo.duration, + actions_recorded: sessionInfo.actions, + saved: true + }, null, 2)); + } + }); + +hooks.command('pre-compact') + .description('Pre-compact hook - save state before context compaction') + .action(() => { + const intel = new Intelligence(); + const stats = intel.stats(); + intel.save(); + + console.log(`🗜️ Pre-compact: ${stats.total_trajectories} trajectories, ${stats.total_memories} memories saved`); + }); + +// ============================================================================ +// Edit Hooks +// ============================================================================ + hooks.command('pre-edit') .description('Pre-edit intelligence hook') .argument('', 'File path') @@ -355,6 +650,13 @@ hooks.command('pre-edit') console.log(` 📁 \x1b[36m${crate ?? 'project'}\x1b[0m/${fileName}`); console.log(` 🤖 Recommended: \x1b[32m\x1b[1m${agent}\x1b[0m (${(confidence * 100).toFixed(0)}% confidence)`); if (reason) console.log(` → \x1b[2m${reason}\x1b[0m`); + + // Show suggested next files + const nextFiles = intel.suggestNext(file, 3); + if (nextFiles.length > 0) { + console.log(' 📎 Likely next files:'); + nextFiles.forEach(n => console.log(` - ${n.file} (${n.score} edits)`)); + } }); hooks.command('post-edit') @@ -369,6 +671,12 @@ hooks.command('post-edit') const crate = crateMatch?.[1] ?? 'project'; const state = `edit_${ext}_in_${crate}`; + // Record file sequence + const lastFile = intel.getLastEditedFile(); + if (lastFile && lastFile !== file) { + intel.recordFileSequence(lastFile, file); + } + intel.learn(state, success ? 'successful-edit' : 'failed-edit', success ? 'completed' : 'failed', success ? 1.0 : -0.5); intel.remember('edit', `${success ? 'successful' : 'failed'} edit of ${ext} in ${crate}`); intel.save(); @@ -379,6 +687,112 @@ hooks.command('post-edit') if (test.suggest) console.log(` 🧪 Consider: \x1b[36m${test.command}\x1b[0m`); }); +// ============================================================================ +// Command Hooks +// ============================================================================ + +hooks.command('pre-command') + .description('Pre-command intelligence hook') + .argument('', 'Command to analyze') + .action((command: string[]) => { + const intel = new Intelligence(); + const cmd = command.join(' '); + const classification = intel.classifyCommand(cmd); + + console.log('\x1b[1m🧠 Command Analysis:\x1b[0m'); + console.log(` 📦 Category: \x1b[36m${classification.category}\x1b[0m`); + console.log(` 🏷️ Type: ${classification.subcategory}`); + + if (classification.risk === 'high') { + console.log(' ⚠️ Risk: \x1b[31mHIGH\x1b[0m - Review carefully'); + } else if (classification.risk === 'medium') { + console.log(' ⚡ Risk: \x1b[33mMEDIUM\x1b[0m'); + } else { + console.log(' ✅ Risk: \x1b[32mLOW\x1b[0m'); + } + }); + +hooks.command('post-command') + .description('Post-command learning hook') + .argument('', 'Command that ran') + .option('--success', 'Command succeeded') + .option('--stderr ', 'Stderr output for error learning') + .action((command: string[], opts: { success?: boolean; stderr?: string }) => { + const intel = new Intelligence(); + const cmd = command.join(' '); + const success = opts.success ?? true; + + // Learn from command outcome + const classification = intel.classifyCommand(cmd); + intel.learn( + `cmd_${classification.category}_${classification.subcategory}`, + success ? 'success' : 'failure', + success ? 'completed' : 'failed', + success ? 0.8 : -0.3 + ); + + // Learn from errors if stderr provided + if (opts.stderr) { + const errorCodes = intel.recordError(cmd, opts.stderr); + if (errorCodes.length > 0) { + console.log(`📊 Learned error patterns: ${errorCodes.join(', ')}`); + } + } + + intel.remember('command', `${cmd} ${success ? 'succeeded' : 'failed'}`); + intel.save(); + + console.log(`📊 Command ${success ? '✅' : '❌'} recorded`); + }); + +// ============================================================================ +// Error Learning +// ============================================================================ + +hooks.command('record-error') + .description('Record error pattern for learning') + .argument('', 'Command that produced error') + .argument('', 'Error message') + .action((command: string, message: string) => { + const intel = new Intelligence(); + const codes = intel.recordError(command, message); + intel.save(); + + console.log(JSON.stringify({ errors: codes, recorded: codes.length })); + }); + +hooks.command('suggest-fix') + .description('Get suggested fix for error code') + .argument('', 'Error code (e.g., E0308)') + .action((code: string) => { + const intel = new Intelligence(); + const fix = intel.suggestFix(code); + + if (fix) { + console.log(JSON.stringify(fix, null, 2)); + } else { + console.log(JSON.stringify({ code, fixes: [], occurrences: 0, type: 'unknown' })); + } + }); + +hooks.command('suggest-next') + .description('Suggest next files to edit based on patterns') + .argument('', 'Current file') + .option('-n, --limit ', 'Number of suggestions', '3') + .action((file: string, opts: { limit: string }) => { + const intel = new Intelligence(); + const suggestions = intel.suggestNext(file, parseInt(opts.limit)); + + console.log(JSON.stringify({ + current_file: file, + suggestions: suggestions.map(s => ({ file: s.file, frequency: s.score })) + }, null, 2)); + }); + +// ============================================================================ +// Memory Commands +// ============================================================================ + hooks.command('remember') .description('Store content in semantic memory') .requiredOption('-t, --type ', 'Memory type') @@ -407,6 +821,10 @@ hooks.command('recall') }, null, 2)); }); +// ============================================================================ +// Learning Commands +// ============================================================================ + hooks.command('learn') .description('Record a learning trajectory') .argument('', 'State identifier') @@ -442,9 +860,7 @@ hooks.command('route') task: task.join(' '), recommended: result.agent, confidence: result.confidence, - reasoning: result.reason, - file: opts.file, - crate: opts.crateName + reasoning: result.reason }, null, 2)); }); @@ -456,6 +872,10 @@ hooks.command('should-test') console.log(JSON.stringify(intel.shouldTest(file), null, 2)); }); +// ============================================================================ +// Swarm Commands +// ============================================================================ + hooks.command('swarm-register') .description('Register agent in swarm') .argument('', 'Agent ID') @@ -469,6 +889,51 @@ hooks.command('swarm-register') console.log(JSON.stringify({ success: true, agent_id: id, type })); }); +hooks.command('swarm-coordinate') + .description('Record agent coordination') + .argument('', 'Source agent ID') + .argument('', 'Target agent ID') + .option('-w, --weight ', 'Coordination weight', '1.0') + .action((source: string, target: string, opts: { weight: string }) => { + const intel = new Intelligence(); + intel.swarmCoordinate(source, target, parseFloat(opts.weight)); + intel.save(); + console.log(JSON.stringify({ success: true, source, target, weight: parseFloat(opts.weight) })); + }); + +hooks.command('swarm-optimize') + .description('Optimize task distribution') + .argument('', 'Tasks (comma-separated)') + .action((tasks: string) => { + const intel = new Intelligence(); + const taskList = tasks.split(',').map(s => s.trim()); + const result = intel.swarmOptimize(taskList); + console.log(JSON.stringify({ tasks: taskList.length, assignments: result }, null, 2)); + }); + +hooks.command('swarm-recommend') + .description('Recommend agent for task type') + .argument('', 'Type of task') + .action((taskType: string) => { + const intel = new Intelligence(); + const result = intel.swarmRecommend(taskType); + if (result) { + console.log(JSON.stringify({ task_type: taskType, recommended: result.agent, type: result.type, score: result.score })); + } else { + console.log(JSON.stringify({ task_type: taskType, recommended: null, message: 'No matching agent found' })); + } + }); + +hooks.command('swarm-heal') + .description('Handle agent failure') + .argument('', 'Failed agent ID') + .action((agentId: string) => { + const intel = new Intelligence(); + const result = intel.swarmHeal(agentId); + intel.save(); + console.log(JSON.stringify({ failed_agent: agentId, healed: result.healed, replacement: result.replacement })); + }); + hooks.command('swarm-stats') .description('Show swarm statistics') .action(() => {