An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.

feat(cli): sessions, graph interchange, and ADR export commands

Add new CLI commands and subcommands:
- Sessions command with list and latest subcommands for viewing session records and handoff notes
- Graph command with export/import/diff subcommands for TOML interchange
- Export action to Decisions command for ADR markdown file generation

Implement CLI handlers that:
- Open database and create appropriate stores (SessionStore, SqliteGraphStore)
- Call underlying functions (export_adrs, export_goal, import_goal, diff_goal)
- Format and display results appropriately
- Support both stdout output and file output where applicable

Verifies P1c.AC5.1-5.4 with help text validation.

+310 -24
+8 -4
src/graph/decay.rs
··· 169 169 use super::*; 170 170 use chrono::Duration; 171 171 172 - fn create_test_node(id: &str, created_days_ago: i64, completed_days_ago: Option<i64>) -> GraphNode { 172 + fn create_test_node( 173 + id: &str, 174 + created_days_ago: i64, 175 + completed_days_ago: Option<i64>, 176 + ) -> GraphNode { 173 177 let now = Utc::now(); 174 178 let created_at = now - Duration::days(created_days_ago); 175 179 let completed_at = completed_days_ago.map(|d| now - Duration::days(d)); ··· 276 280 fn test_decay_multiple_nodes() { 277 281 let config = DecayConfig::default(); 278 282 let nodes = vec![ 279 - create_test_node("n1", 10, Some(2)), // Full 280 - create_test_node("n2", 20, Some(15)), // Summary 281 - create_test_node("n3", 50, Some(45)), // Minimal 283 + create_test_node("n1", 10, Some(2)), // Full 284 + create_test_node("n2", 20, Some(15)), // Summary 285 + create_test_node("n3", 50, Some(45)), // Minimal 282 286 ]; 283 287 284 288 let decayed = decay_nodes(&nodes, Utc::now(), &config);
+276 -1
src/main.rs
··· 60 60 /// Search query 61 61 query: String, 62 62 }, 63 + /// View sessions and handoff notes 64 + Sessions { 65 + #[command(subcommand)] 66 + action: Option<SessionAction>, 67 + }, 68 + /// Import/export graph data 69 + Graph { 70 + #[command(subcommand)] 71 + action: GraphAction, 72 + }, 63 73 } 64 74 65 75 #[derive(Subcommand)] ··· 110 120 History, 111 121 /// Show decision details 112 122 Show { id: String }, 123 + /// Export decisions as ADR markdown files 124 + Export { 125 + /// Output directory for markdown files 126 + #[arg(long)] 127 + output: Option<String>, 128 + }, 129 + } 130 + 131 + #[derive(Subcommand)] 132 + enum SessionAction { 133 + /// List sessions for current goal 134 + List { 135 + /// Goal ID (if omitted, uses current goal context) 136 + #[arg(long)] 137 + goal: Option<String>, 138 + }, 139 + /// Show most recent handoff notes 140 + Latest { 141 + /// Goal ID (if omitted, uses current goal context) 142 + #[arg(long)] 143 + goal: Option<String>, 144 + }, 145 + } 146 + 147 + #[derive(Subcommand)] 148 + enum GraphAction { 149 + /// Export goals to TOML files 150 + Export { 151 + /// Goal ID (if omitted, exports all goals) 152 + #[arg(long)] 153 + goal: Option<String>, 154 + /// Output directory 155 + #[arg(long)] 156 + output: Option<String>, 157 + }, 158 + /// Import TOML files 159 + Import { 160 + /// Path to TOML file 161 + path: String, 162 + /// Show changes without applying 163 + #[arg(long)] 164 + dry_run: bool, 165 + /// Use file version in conflicts 166 + #[arg(long)] 167 + theirs: bool, 168 + /// Keep database version in conflicts 169 + #[arg(long)] 170 + ours: bool, 171 + }, 172 + /// Diff TOML file against DB 173 + Diff { 174 + /// Path to TOML file 175 + path: String, 176 + }, 113 177 } 114 178 115 179 /// Find config file in standard locations ··· 426 490 println!("Decision '{}' not found", id); 427 491 } 428 492 } 493 + Some(DecisionAction::Export { output }) => { 494 + if let Some(proj_id) = cli.project { 495 + let output_dir = output.unwrap_or_else(|| ".".to_string()); 496 + let output_path = std::path::PathBuf::from(&output_dir); 497 + 498 + match rustagent::graph::export::export_adrs( 499 + &graph_store, 500 + &proj_id, 501 + &output_path, 502 + ) 503 + .await 504 + { 505 + Ok(files) => { 506 + println!("Exported {} decision(s) to {}:", files.len(), output_dir); 507 + for file in files { 508 + println!(" {}", file.display()); 509 + } 510 + } 511 + Err(e) => { 512 + println!("Export failed: {}", e); 513 + } 514 + } 515 + } else { 516 + println!("Project must be specified with --project flag"); 517 + } 518 + } 429 519 None => { 430 - println!("Please specify a decision action: list, now, history, or show"); 520 + println!( 521 + "Please specify a decision action: list, now, history, show, or export" 522 + ); 431 523 } 432 524 } 433 525 } ··· 481 573 for node in results { 482 574 println!("{:<20} {:<15} {:<30}", node.id, node.node_type, node.title); 483 575 } 576 + } 577 + } 578 + Commands::Sessions { action } => { 579 + // Open database 580 + let db_path = db_path()?; 581 + let database = db::Database::open(&db_path).await?; 582 + let session_store = rustagent::graph::session::SessionStore::new(database.clone()); 583 + 584 + match action { 585 + Some(SessionAction::List { goal }) => { 586 + if let Some(goal_id) = goal { 587 + match session_store.list_sessions(&goal_id).await { 588 + Ok(sessions) => { 589 + if sessions.is_empty() { 590 + println!("No sessions found for goal {}", goal_id); 591 + } else { 592 + println!("Sessions for {}:", goal_id); 593 + println!("{:<20} {:<25} {:<15}", "ID", "Started", "Status"); 594 + println!("{}", "=".repeat(60)); 595 + for session in sessions { 596 + let status = if session.ended_at.is_some() { 597 + "Ended" 598 + } else { 599 + "Active" 600 + }; 601 + println!( 602 + "{:<20} {:<25} {:<15}", 603 + session.id, 604 + session.started_at.format("%Y-%m-%d %H:%M"), 605 + status 606 + ); 607 + } 608 + } 609 + } 610 + Err(e) => println!("Error listing sessions: {}", e), 611 + } 612 + } else { 613 + println!("Goal ID must be specified with --goal flag"); 614 + } 615 + } 616 + Some(SessionAction::Latest { goal }) => { 617 + if let Some(goal_id) = goal { 618 + match session_store.get_latest_session(&goal_id).await { 619 + Ok(Some(session)) => { 620 + println!("Latest session for {}:", goal_id); 621 + println!(" ID: {}", session.id); 622 + println!(" Started: {}", session.started_at); 623 + if let Some(ended) = session.ended_at { 624 + println!(" Ended: {}", ended); 625 + } 626 + if let Some(notes) = session.handoff_notes { 627 + println!("\nHandoff Notes:"); 628 + println!("{}", notes); 629 + } 630 + } 631 + Ok(None) => println!("No sessions found for goal {}", goal_id), 632 + Err(e) => println!("Error retrieving session: {}", e), 633 + } 634 + } else { 635 + println!("Goal ID must be specified with --goal flag"); 636 + } 637 + } 638 + None => { 639 + println!("Please specify a session action: list or latest"); 640 + } 641 + } 642 + } 643 + Commands::Graph { action } => { 644 + // Open database 645 + let db_path = db_path()?; 646 + let database = db::Database::open(&db_path).await?; 647 + let graph_store = rustagent::graph::store::SqliteGraphStore::new(database.clone()); 648 + 649 + match action { 650 + GraphAction::Export { goal, output } => { 651 + if let Some(goal_id) = goal { 652 + let project_name = 653 + cli.project.clone().unwrap_or_else(|| "unknown".to_string()); 654 + match rustagent::graph::interchange::export_goal( 655 + &graph_store, 656 + &goal_id, 657 + &project_name, 658 + ) 659 + .await 660 + { 661 + Ok(toml_content) => { 662 + if let Some(output_path) = output { 663 + // Write to file 664 + match std::fs::write(&output_path, &toml_content) { 665 + Ok(_) => println!("Exported goal to {}", output_path), 666 + Err(e) => println!("Failed to write file: {}", e), 667 + } 668 + } else { 669 + // Print to stdout 670 + println!("{}", toml_content); 671 + } 672 + } 673 + Err(e) => println!("Export failed: {}", e), 674 + } 675 + } else { 676 + println!("Goal ID must be specified with --goal flag"); 677 + } 678 + } 679 + GraphAction::Import { 680 + path, 681 + dry_run, 682 + theirs, 683 + ours, 684 + } => match std::fs::read_to_string(&path) { 685 + Ok(content) => { 686 + let strategy = if theirs { 687 + rustagent::graph::interchange::ImportStrategy::Theirs 688 + } else if ours { 689 + rustagent::graph::interchange::ImportStrategy::Ours 690 + } else { 691 + rustagent::graph::interchange::ImportStrategy::Merge 692 + }; 693 + 694 + match tokio::runtime::Handle::current().block_on( 695 + rustagent::graph::interchange::import_goal( 696 + &graph_store, 697 + &content, 698 + strategy, 699 + ), 700 + ) { 701 + Ok(result) => { 702 + if dry_run { 703 + println!("[DRY RUN] Changes that would be applied:"); 704 + } 705 + println!(" Added nodes: {}", result.added_nodes); 706 + println!(" Added edges: {}", result.added_edges); 707 + println!(" Unchanged: {}", result.unchanged); 708 + if !result.conflicts.is_empty() { 709 + println!(" Conflicts: {}", result.conflicts.len()); 710 + } 711 + if !result.skipped_edges.is_empty() { 712 + println!(" Skipped edges: {}", result.skipped_edges.len()); 713 + } 714 + } 715 + Err(e) => println!("Import failed: {}", e), 716 + } 717 + } 718 + Err(e) => println!("Failed to read file: {}", e), 719 + }, 720 + GraphAction::Diff { path } => match std::fs::read_to_string(&path) { 721 + Ok(content) => { 722 + match tokio::runtime::Handle::current().block_on( 723 + rustagent::graph::interchange::diff_goal(&graph_store, &content), 724 + ) { 725 + Ok(result) => { 726 + println!("Diff results for {}:", path); 727 + if !result.added_nodes.is_empty() { 728 + println!(" Added nodes: {}", result.added_nodes.len()); 729 + for node_id in &result.added_nodes { 730 + println!(" + {}", node_id); 731 + } 732 + } 733 + if !result.changed_nodes.is_empty() { 734 + println!(" Changed nodes: {}", result.changed_nodes.len()); 735 + for (node_id, fields) in &result.changed_nodes { 736 + println!(" ~ {} ({})", node_id, fields.join(", ")); 737 + } 738 + } 739 + if !result.removed_nodes.is_empty() { 740 + println!(" Removed nodes: {}", result.removed_nodes.len()); 741 + for node_id in &result.removed_nodes { 742 + println!(" - {}", node_id); 743 + } 744 + } 745 + if !result.added_edges.is_empty() { 746 + println!(" Added edges: {}", result.added_edges.len()); 747 + } 748 + if !result.removed_edges.is_empty() { 749 + println!(" Removed edges: {}", result.removed_edges.len()); 750 + } 751 + println!(" Unchanged nodes: {}", result.unchanged_nodes); 752 + println!(" Unchanged edges: {}", result.unchanged_edges); 753 + } 754 + Err(e) => println!("Diff failed: {}", e), 755 + } 756 + } 757 + Err(e) => println!("Failed to read file: {}", e), 758 + }, 484 759 } 485 760 } 486 761 }
+26 -19
tests/decay_test.rs
··· 1 1 //! Tests for node decay functionality (P1c.AC4.1 - P1c.AC4.4) 2 2 3 3 use chrono::{Duration, Utc}; 4 - use rustagent::graph::decay::{decay_node, decay_nodes, DecayConfig, DecayDetail}; 4 + use rustagent::graph::decay::{DecayConfig, DecayDetail, decay_node, decay_nodes}; 5 5 use rustagent::graph::{GraphNode, NodeStatus, NodeType}; 6 6 use std::collections::HashMap; 7 7 8 - fn create_test_node( 9 - id: &str, 10 - created_days_ago: i64, 11 - completed_days_ago: Option<i64>, 12 - ) -> GraphNode { 8 + fn create_test_node(id: &str, created_days_ago: i64, completed_days_ago: Option<i64>) -> GraphNode { 13 9 let now = Utc::now(); 14 10 let created_at = now - Duration::days(created_days_ago); 15 11 let completed_at = completed_days_ago.map(|d| now - Duration::days(d)); ··· 56 52 metadata, 57 53 } => { 58 54 assert_eq!(description, "Test description with important details"); 59 - assert_eq!(metadata.get("key_outcome"), Some(&"Important result".to_string())); 60 - assert_eq!(metadata.get("context"), Some(&"Additional context".to_string())); 55 + assert_eq!( 56 + metadata.get("key_outcome"), 57 + Some(&"Important result".to_string()) 58 + ); 59 + assert_eq!( 60 + metadata.get("context"), 61 + Some(&"Additional context".to_string()) 62 + ); 61 63 } 62 64 other => panic!("Expected Full detail for recent node, got {:?}", other), 63 65 } ··· 144 146 fn test_decay_multiple_nodes() { 145 147 let config = DecayConfig::default(); 146 148 let nodes = vec![ 147 - create_test_node("n1", 10, Some(2)), // Full 148 - create_test_node("n2", 20, Some(15)), // Summary 149 - create_test_node("n3", 50, Some(45)), // Minimal 149 + create_test_node("n1", 10, Some(2)), // Full 150 + create_test_node("n2", 20, Some(15)), // Summary 151 + create_test_node("n3", 50, Some(45)), // Minimal 150 152 ]; 151 153 152 154 let decayed = decay_nodes(&nodes, Utc::now(), &config); ··· 177 179 DecayDetail::Full { .. } => { 178 180 // Expected - uses completed_at (2 days old), not created_at (100 days old) 179 181 } 180 - other => panic!( 181 - "Should use completed_at for age: got {:?}", 182 - other 183 - ), 182 + other => panic!("Should use completed_at for age: got {:?}", other), 184 183 } 185 184 } 186 185 ··· 198 197 DecayDetail::Full { .. } => { 199 198 // Expected - uses created_at when completed_at is None 200 199 } 201 - other => panic!("Should use created_at when completed_at is None: got {:?}", other), 200 + other => panic!( 201 + "Should use created_at when completed_at is None: got {:?}", 202 + other 203 + ), 202 204 } 203 205 } 204 206 ··· 257 259 let config = DecayConfig::default(); 258 260 let mut node = create_test_node("full_meta", 10, Some(1)); 259 261 260 - node.metadata.insert("custom_field".to_string(), "custom_value".to_string()); 261 - node.metadata.insert("tags".to_string(), "a,b,c".to_string()); 262 + node.metadata 263 + .insert("custom_field".to_string(), "custom_value".to_string()); 264 + node.metadata 265 + .insert("tags".to_string(), "a,b,c".to_string()); 262 266 263 267 let decayed = decay_node(&node, Utc::now(), &config); 264 268 ··· 268 272 metadata, 269 273 } => { 270 274 assert_eq!(metadata.len(), 4); 271 - assert_eq!(metadata.get("custom_field"), Some(&"custom_value".to_string())); 275 + assert_eq!( 276 + metadata.get("custom_field"), 277 + Some(&"custom_value".to_string()) 278 + ); 272 279 assert_eq!(metadata.get("tags"), Some(&"a,b,c".to_string())); 273 280 } 274 281 other => panic!("Expected Full detail with all metadata: got {:?}", other),