diff --git a/CLAUDE.md b/CLAUDE.md index 8ca7f87..6056033 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,3 +34,9 @@ - `cargo fmt` and `cargo clippy` must pass (CI enforced) - Prefer explicit imports over glob imports - Keep functions focused — if it's doing two things, split it + +## Dogfooding +- This project uses itself (`tt`) to orchestrate development — tutti developing tutti +- When a tutti feature doesn't work during a dogfood run, **fix the framework** (the `tt` command / runtime / session code), don't work around it by doing the task manually +- Always validate the fix by re-running the original orchestration flow through `tt` +- The purpose of dogfood runs is to surface real bugs in tutti's agent orchestration, not to ship the target feature by any means necessary diff --git a/src/automation/mod.rs b/src/automation/mod.rs index 05a8624..f367ca2 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -3195,6 +3195,7 @@ mod tests { fresh_worktree: None, branch: None, persistent: false, + memory: None, env: HashMap::new(), }], tool_packs: vec![], @@ -4410,6 +4411,7 @@ mod tests { fresh_worktree: None, branch: None, persistent: false, + memory: None, env: HashMap::new(), }, AgentConfig { @@ -4422,6 +4424,7 @@ mod tests { fresh_worktree: None, branch: None, persistent: false, + memory: None, env: HashMap::new(), }, ], diff --git a/src/budget/mod.rs b/src/budget/mod.rs index 06565cf..2ce35ed 100644 --- a/src/budget/mod.rs +++ b/src/budget/mod.rs @@ -256,6 +256,7 @@ mod tests { fresh_worktree: None, branch: None, persistent: false, + memory: None, env: HashMap::new(), }], tool_packs: vec![], diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index 9172787..a0e1406 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -590,6 +590,7 @@ mod tests { fresh_worktree: None, branch: None, persistent: false, + memory: None, env: HashMap::new(), }], tool_packs: vec![], diff --git a/src/cli/handoff.rs b/src/cli/handoff.rs index 9774abe..97a7d92 100644 --- a/src/cli/handoff.rs +++ b/src/cli/handoff.rs @@ -572,6 +572,7 @@ mod tests { fresh_worktree: None, branch: None, persistent: false, + memory: None, env: HashMap::new(), }], tool_packs: vec![], diff --git a/src/cli/send.rs b/src/cli/send.rs index 101ffd3..ddef5cd 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -5,6 +5,7 @@ use crate::health::{WaitCompletionSource, WaitFailureReason}; use crate::runtime::{self, AgentStatus}; use crate::session::TmuxSession; use crate::{budget, budget::BudgetGuardOutcome}; +use colored::Colorize; use serde::Serialize; use std::path::PathBuf; use std::time::{Duration, Instant}; @@ -72,6 +73,29 @@ pub fn run( wait_for_runtime_ready(&runtime_name, &session, &agent_name)?; } + // Check that the runtime is actually running (not exited to shell) + if let Some(adapter) = runtime::get_adapter(&runtime_name, None) { + let output = TmuxSession::capture_pane(&session, 200).unwrap_or_default(); + let status = adapter.detect_status(&output); + if !launched_via_auto_up { + match &status { + AgentStatus::Unknown => { + eprintln!( + " {} {agent_name} runtime status is unknown (may have exited); prompt may hit the shell", + "warn".yellow() + ); + } + AgentStatus::AuthFailed(err) => { + eprintln!( + " {} {agent_name} runtime reports auth failure: {err}; re-authenticate or check credentials", + "warn".yellow() + ); + } + _ => {} + } + } + } + let capture_lines = options.output_lines.max(20); let before_output = if options.output { Some(TmuxSession::capture_pane(&session, capture_lines).unwrap_or_default()) diff --git a/src/cli/up.rs b/src/cli/up.rs index 020ec35..f2dad01 100644 --- a/src/cli/up.rs +++ b/src/cli/up.rs @@ -170,6 +170,19 @@ pub fn run( let (working_dir, worktree_path, branch) = prepare_agent_working_dir(project_root, &config.defaults, agent, fresh_worktree); + // Inject persistent memory into agent working directory + let file_injected = inject_agent_memory(project_root, &working_dir, agent, &runtime_name)?; + + // Build effective prompt (with memory prepended for non-Claude runtimes, + // or as fallback for claude-code when file injection was skipped) + let effective_prompt = prepend_memory_to_prompt( + project_root, + agent, + &runtime_name, + agent.prompt.as_deref(), + file_injected, + )?; + // Merge workspace env with agent-level env (agent overrides workspace) let mut env = workspace_env.clone(); for (k, v) in &agent.env { @@ -205,7 +218,7 @@ pub fn run( permissions_policy, project_root, &agent.name, - agent.prompt.as_deref(), + effective_prompt.as_deref(), ) { Ok(v) => v, Err(e) => { @@ -433,6 +446,183 @@ fn prepare_agent_working_dir( } } +const MEMORY_SECTION_START: &str = ""; +const MEMORY_SECTION_END: &str = ""; + +/// Validate that a path is not a symlink and resolves within an allowed root. +fn validate_no_symlink(path: &Path, label: &str, allowed_root: &Path) -> Result<()> { + if let Ok(meta) = path.symlink_metadata() + && meta.file_type().is_symlink() + { + return Err(TuttiError::ConfigValidation(format!( + "{label} is a symlink, which is not allowed: {}. \ + Replace it with a regular file under {}", + path.display(), + allowed_root.display() + ))); + } + if let Ok(root) = allowed_root.canonicalize() { + // When the path exists, verify its canonical form lives under the root. + if path.exists() { + if let Ok(canonical) = path.canonicalize() + && !canonical.starts_with(&root) + { + return Err(TuttiError::ConfigValidation(format!( + "{label} resolves outside the allowed directory ({}): {}. \ + Move the file into the workspace or update the memory path in your config", + root.display(), + path.display() + ))); + } + } else if let Some(parent) = path.parent() { + // Path doesn't exist yet – validate the parent directory so that + // a later `fs::write` cannot be tricked into creating a file + // outside the allowed root via a symlinked parent. + if let Ok(canonical_parent) = parent.canonicalize() + && !canonical_parent.starts_with(&root) + { + return Err(TuttiError::ConfigValidation(format!( + "{label} would be created outside the allowed directory ({}): {}. \ + Move the file into the workspace or update the memory path in your config", + root.display(), + path.display() + ))); + } + } + } + Ok(()) +} + +/// Inject agent memory file into the working directory. +/// +/// For claude-code runtimes, writes a managed memory section into CLAUDE.md +/// in the working dir. The section is bounded by markers so it can be +/// replaced idempotently on relaunch (no duplicates). Skips injection +/// when the working dir is the project root (no worktree) to avoid +/// mutating the workspace's own CLAUDE.md. +/// +/// Returns `true` if memory was successfully injected via file, `false` if +/// skipped (caller should fall back to prompt prepending). +fn inject_agent_memory( + project_root: &Path, + working_dir: &str, + agent: &AgentConfig, + runtime_name: &str, +) -> Result { + let memory_path = match &agent.memory { + Some(p) => p.trim(), + None => return Ok(false), + }; + + // Only inject into CLAUDE.md for claude-code runtime + if runtime_name != "claude-code" { + return Ok(false); + } + + let working = Path::new(working_dir); + + // Don't mutate the workspace's own CLAUDE.md when worktrees are disabled + if working.canonicalize().ok() == project_root.canonicalize().ok() { + return Ok(false); + } + + let resolved = project_root.join(memory_path); + if !resolved.exists() { + return Ok(false); + } + + // Reject symlinked memory source + validate_no_symlink(&resolved, "memory file", project_root)?; + + let memory_contents = fs::read_to_string(&resolved)?; + if memory_contents.trim().is_empty() { + return Ok(false); + } + + let claude_md_path = working.join("CLAUDE.md"); + + // Reject symlinked CLAUDE.md destination (check existing file and parent dir) + validate_no_symlink(&claude_md_path, "CLAUDE.md", working)?; + if let Some(parent) = claude_md_path.parent() { + validate_no_symlink(parent, "CLAUDE.md parent directory", working)?; + } + + let existing = if claude_md_path.exists() { + fs::read_to_string(&claude_md_path)? + } else { + String::new() + }; + + // Strip any previous managed memory section + let base = if let Some(start) = existing.find(MEMORY_SECTION_START) { + if let Some(rel_end) = existing[start..].find(MEMORY_SECTION_END) { + let end = start + rel_end; + let before = &existing[..start]; + let after = &existing[end + MEMORY_SECTION_END.len()..]; + format!("{}{}", before.trim_end(), after) + } else { + existing[..start].trim_end().to_string() + } + } else { + existing + }; + + let memory_section = format!( + "\n\n{MEMORY_SECTION_START}\n# Agent Memory\n\n{}\n{MEMORY_SECTION_END}", + memory_contents.trim() + ); + let combined = format!("{}{memory_section}\n", base.trim_end()); + fs::write(&claude_md_path, combined)?; + + Ok(true) +} + +/// Prepend memory file contents to the agent prompt. +/// +/// Used for non-Claude runtimes, and as a fallback for claude-code when +/// file injection was skipped (e.g. no worktree). +fn prepend_memory_to_prompt( + project_root: &Path, + agent: &AgentConfig, + runtime_name: &str, + base_prompt: Option<&str>, + file_injected: bool, +) -> Result> { + let memory_path = match &agent.memory { + Some(p) => p.trim().to_string(), + None => return Ok(base_prompt.map(String::from)), + }; + + // If CLAUDE.md injection already succeeded, skip prompt prepending + if runtime_name == "claude-code" && file_injected { + return Ok(base_prompt.map(String::from)); + } + + let resolved = project_root.join(&memory_path); + if !resolved.exists() { + return Ok(base_prompt.map(String::from)); + } + + // Reject symlinked memory source + validate_no_symlink(&resolved, "memory file", project_root)?; + + let memory_contents = fs::read_to_string(&resolved)?; + if memory_contents.trim().is_empty() { + return Ok(base_prompt.map(String::from)); + } + + let prompt = match base_prompt { + Some(p) => format!( + "## Agent Memory\n\n{}\n\n---\n\n{}", + memory_contents.trim(), + p + ), + None => format!("## Agent Memory\n\n{}", memory_contents.trim()), + }; + + Ok(Some(prompt)) +} + fn resolve_launch_settings( config: &TuttiConfig, mode_override: Option, @@ -579,8 +769,8 @@ fn build_launch_command( "codex constrained launch requires configured [permissions] policy".to_string(), ) })?; - let shim_path = write_shell_policy_shims(project_root, agent_name, policy)?; - let policy_appendix = runtime_policy_appendix("Codex", policy); + let shim_path = write_shell_policy_shims(project_root, agent_name, "codex", policy)?; + let policy_appendix = runtime_policy_appendix("Codex", "codex", policy); let prompt = append_policy_prompt(base_prompt, &policy_appendix); let pre_args = vec![ "-a".to_string(), @@ -605,8 +795,8 @@ fn build_launch_command( .to_string(), ) })?; - let shim_path = write_shell_policy_shims(project_root, agent_name, policy)?; - let policy_appendix = runtime_policy_appendix("OpenClaw", policy); + let shim_path = write_shell_policy_shims(project_root, agent_name, "openclaw", policy)?; + let policy_appendix = runtime_policy_appendix("OpenClaw", "openclaw", policy); let prompt = append_policy_prompt(base_prompt, &policy_appendix); let cmd = adapter.build_spawn_command(prompt.as_deref()); Ok(( @@ -624,8 +814,8 @@ fn build_launch_command( "aider constrained launch requires configured [permissions] policy".to_string(), ) })?; - let shim_path = write_shell_policy_shims(project_root, agent_name, policy)?; - let policy_appendix = runtime_policy_appendix("Aider", policy); + let shim_path = write_shell_policy_shims(project_root, agent_name, "aider", policy)?; + let policy_appendix = runtime_policy_appendix("Aider", "aider", policy); let prompt = append_policy_prompt(base_prompt, &policy_appendix); let cmd = adapter.build_spawn_command(prompt.as_deref()); Ok(( @@ -664,12 +854,25 @@ fn write_claude_settings_file( Ok(settings_path) } +fn runtime_shell_allow_rules(runtime_name: &str, policy: &PermissionsConfig) -> Vec { + let mut rules = shell_command_allow_rules(policy); + if runtime_name.eq_ignore_ascii_case("codex") + && !rules + .iter() + .any(|rule| normalize(rule).starts_with("apply_patch")) + { + rules.push("apply_patch *".to_string()); + } + rules +} + fn write_shell_policy_shims( project_root: &Path, agent_name: &str, + runtime_name: &str, policy: &PermissionsConfig, ) -> Result { - let rules = shell_command_allow_rules(policy); + let rules = runtime_shell_allow_rules(runtime_name, policy); if rules.is_empty() { return Err(TuttiError::ConfigValidation(format!( "{} constrained launch requires shell command allow rules in [permissions].allow", @@ -778,13 +981,12 @@ fn wrap_launch_command_with_shim_path(command: &str, shim_dir: &Path) -> String ) } -fn runtime_policy_appendix(runtime_label: &str, policy: &PermissionsConfig) -> String { - let rules: Vec = policy - .allow - .iter() - .map(normalize) - .filter(|entry| !entry.is_empty()) - .collect(); +fn runtime_policy_appendix( + runtime_label: &str, + runtime_name: &str, + policy: &PermissionsConfig, +) -> String { + let rules = runtime_shell_allow_rules(runtime_name, policy); let mut out = format!( "Tutti policy constraints for {runtime_label}:\n\ @@ -1308,6 +1510,18 @@ fn run_all( agent, fresh_worktree, ); + + // Inject persistent memory (same as run()) + let file_injected = + inject_agent_memory(project_root, &working_dir, agent, &runtime_name)?; + let effective_prompt = prepend_memory_to_prompt( + project_root, + agent, + &runtime_name, + agent.prompt.as_deref(), + file_injected, + )?; + let attempts = resolve_runtime_launch_attempts( &config, Some(&global), @@ -1343,7 +1557,7 @@ fn run_all( permissions_policy, project_root, &agent.name, - agent.prompt.as_deref(), + effective_prompt.as_deref(), ) { Ok(v) => v, Err(e) => { @@ -1489,6 +1703,7 @@ mod tests { fresh_worktree: None, branch: None, persistent: false, + memory: None, env: HashMap::new(), } } @@ -2124,10 +2339,331 @@ mod tests { allow: vec!["Read".to_string(), "Edit".to_string()], }; - let err = write_shell_policy_shims(&dir, "backend", &policy) + let err = write_shell_policy_shims(&dir, "backend", "openclaw", &policy) .expect_err("tool-only policy should fail"); assert!(err.to_string().contains("shell command allow rules")); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn runtime_shell_allow_rules_adds_apply_patch_for_codex() { + let policy = PermissionsConfig { + allow: vec!["git *".to_string(), "cargo *".to_string()], + }; + + let codex_rules = runtime_shell_allow_rules("codex", &policy); + assert!(codex_rules.iter().any(|rule| rule == "apply_patch *")); + + let aider_rules = runtime_shell_allow_rules("aider", &policy); + assert!(!aider_rules.iter().any(|rule| rule == "apply_patch *")); + } + + #[test] + fn inject_agent_memory_writes_managed_section() { + let dir = + std::env::temp_dir().join(format!("tutti-test-memory-inject-{}", std::process::id())); + let working = dir.join("worktree"); + std::fs::create_dir_all(&working).unwrap(); + + let memory_dir = dir.join(".tutti/state/memory"); + std::fs::create_dir_all(&memory_dir).unwrap(); + std::fs::write( + memory_dir.join("backend.md"), + "ClickHouse LEFT JOIN returns empty string not NULL", + ) + .unwrap(); + + // Create existing CLAUDE.md + std::fs::write(working.join("CLAUDE.md"), "# Project\nExisting content").unwrap(); + + let agent = AgentConfig { + name: "backend".to_string(), + runtime: Some("claude-code".to_string()), + scope: None, + prompt: None, + depends_on: vec![], + worktree: None, + fresh_worktree: None, + branch: None, + persistent: false, + memory: Some(".tutti/state/memory/backend.md".to_string()), + env: HashMap::new(), + }; + + let injected = + inject_agent_memory(&dir, &working.to_string_lossy(), &agent, "claude-code").unwrap(); + assert!(injected); + + let contents = std::fs::read_to_string(working.join("CLAUDE.md")).unwrap(); + assert!(contents.contains("Existing content")); + assert!(contents.contains("# Agent Memory")); + assert!(contents.contains("ClickHouse LEFT JOIN")); + assert!(contents.contains(MEMORY_SECTION_START)); + assert!(contents.contains(MEMORY_SECTION_END)); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn inject_agent_memory_is_idempotent() { + let dir = std::env::temp_dir().join(format!( + "tutti-test-memory-idempotent-{}", + std::process::id() + )); + let working = dir.join("worktree"); + std::fs::create_dir_all(&working).unwrap(); + + let memory_dir = dir.join(".tutti/state/memory"); + std::fs::create_dir_all(&memory_dir).unwrap(); + std::fs::write(memory_dir.join("backend.md"), "Learning v1").unwrap(); + + std::fs::write(working.join("CLAUDE.md"), "# Project").unwrap(); + + let agent = AgentConfig { + name: "backend".to_string(), + runtime: Some("claude-code".to_string()), + scope: None, + prompt: None, + depends_on: vec![], + worktree: None, + fresh_worktree: None, + branch: None, + persistent: false, + memory: Some(".tutti/state/memory/backend.md".to_string()), + env: HashMap::new(), + }; + + // Inject twice + inject_agent_memory(&dir, &working.to_string_lossy(), &agent, "claude-code").unwrap(); + // Update memory and re-inject + std::fs::write(memory_dir.join("backend.md"), "Learning v2").unwrap(); + inject_agent_memory(&dir, &working.to_string_lossy(), &agent, "claude-code").unwrap(); + + let contents = std::fs::read_to_string(working.join("CLAUDE.md")).unwrap(); + // Should contain v2 but NOT v1 + assert!(contents.contains("Learning v2")); + assert!(!contents.contains("Learning v1")); + // Markers should appear exactly once + assert_eq!( + contents.matches(MEMORY_SECTION_START).count(), + 1, + "start marker should appear once" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn inject_agent_memory_creates_claude_md_when_missing() { + let dir = + std::env::temp_dir().join(format!("tutti-test-memory-create-{}", std::process::id())); + let working = dir.join("worktree"); + std::fs::create_dir_all(&working).unwrap(); + + let memory_dir = dir.join(".tutti/state/memory"); + std::fs::create_dir_all(&memory_dir).unwrap(); + std::fs::write(memory_dir.join("backend.md"), "Some memory").unwrap(); + + let agent = AgentConfig { + name: "backend".to_string(), + runtime: Some("claude-code".to_string()), + scope: None, + prompt: None, + depends_on: vec![], + worktree: None, + fresh_worktree: None, + branch: None, + persistent: false, + memory: Some(".tutti/state/memory/backend.md".to_string()), + env: HashMap::new(), + }; + + inject_agent_memory(&dir, &working.to_string_lossy(), &agent, "claude-code").unwrap(); + + let contents = std::fs::read_to_string(working.join("CLAUDE.md")).unwrap(); + assert!(contents.contains("# Agent Memory")); + assert!(contents.contains("Some memory")); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn inject_agent_memory_noop_when_no_memory_config() { + let dir = + std::env::temp_dir().join(format!("tutti-test-memory-noop-{}", std::process::id())); + let working = dir.join("worktree"); + std::fs::create_dir_all(&working).unwrap(); + + let agent = AgentConfig { + name: "backend".to_string(), + runtime: Some("claude-code".to_string()), + scope: None, + prompt: None, + depends_on: vec![], + worktree: None, + fresh_worktree: None, + branch: None, + persistent: false, + memory: None, + env: HashMap::new(), + }; + + inject_agent_memory(&dir, &working.to_string_lossy(), &agent, "claude-code").unwrap(); + + assert!(!working.join("CLAUDE.md").exists()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn inject_agent_memory_noop_when_memory_file_missing() { + let dir = + std::env::temp_dir().join(format!("tutti-test-memory-missing-{}", std::process::id())); + let working = dir.join("worktree"); + std::fs::create_dir_all(&working).unwrap(); + + let agent = AgentConfig { + name: "backend".to_string(), + runtime: Some("claude-code".to_string()), + scope: None, + prompt: None, + depends_on: vec![], + worktree: None, + fresh_worktree: None, + branch: None, + persistent: false, + memory: Some(".tutti/state/memory/backend.md".to_string()), + env: HashMap::new(), + }; + + inject_agent_memory(&dir, &working.to_string_lossy(), &agent, "claude-code").unwrap(); + + assert!(!working.join("CLAUDE.md").exists()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn inject_agent_memory_skips_project_root() { + let dir = + std::env::temp_dir().join(format!("tutti-test-memory-skiproot-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + let memory_dir = dir.join(".tutti/state/memory"); + std::fs::create_dir_all(&memory_dir).unwrap(); + std::fs::write(memory_dir.join("backend.md"), "Some memory").unwrap(); + std::fs::write(dir.join("CLAUDE.md"), "# Project").unwrap(); + + let agent = AgentConfig { + name: "backend".to_string(), + runtime: Some("claude-code".to_string()), + scope: None, + prompt: None, + depends_on: vec![], + worktree: None, + fresh_worktree: None, + branch: None, + persistent: false, + memory: Some(".tutti/state/memory/backend.md".to_string()), + env: HashMap::new(), + }; + + // working_dir == project_root → should not mutate CLAUDE.md, returns false + let injected = + inject_agent_memory(&dir, &dir.to_string_lossy(), &agent, "claude-code").unwrap(); + assert!(!injected); + + let contents = std::fs::read_to_string(dir.join("CLAUDE.md")).unwrap(); + assert_eq!(contents, "# Project"); + + // Fallback: prepend_memory_to_prompt should inject into prompt for + // claude-code when file_injected=false + let result = + prepend_memory_to_prompt(&dir, &agent, "claude-code", Some("Original prompt"), false) + .unwrap() + .unwrap(); + assert!(result.contains("Some memory")); + assert!(result.contains("Original prompt")); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn prepend_memory_to_prompt_adds_context() { + let dir = + std::env::temp_dir().join(format!("tutti-test-memory-prompt-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + let memory_dir = dir.join(".tutti/state/memory"); + std::fs::create_dir_all(&memory_dir).unwrap(); + std::fs::write(memory_dir.join("backend.md"), "Important learning").unwrap(); + + let agent = AgentConfig { + name: "backend".to_string(), + runtime: Some("codex".to_string()), + scope: None, + prompt: Some("You are a backend dev".to_string()), + depends_on: vec![], + worktree: None, + fresh_worktree: None, + branch: None, + persistent: false, + memory: Some(".tutti/state/memory/backend.md".to_string()), + env: HashMap::new(), + }; + + let result = + prepend_memory_to_prompt(&dir, &agent, "codex", Some("You are a backend dev"), false) + .unwrap() + .unwrap(); + + assert!(result.contains("Important learning")); + assert!(result.contains("You are a backend dev")); + assert!(result.contains("## Agent Memory")); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn prepend_memory_to_prompt_noop_for_claude_code() { + let dir = + std::env::temp_dir().join(format!("tutti-test-memory-cc-noop-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + let memory_dir = dir.join(".tutti/state/memory"); + std::fs::create_dir_all(&memory_dir).unwrap(); + std::fs::write(memory_dir.join("backend.md"), "Some memory").unwrap(); + + let agent = AgentConfig { + name: "backend".to_string(), + runtime: Some("claude-code".to_string()), + scope: None, + prompt: Some("Original prompt".to_string()), + depends_on: vec![], + worktree: None, + fresh_worktree: None, + branch: None, + persistent: false, + memory: Some(".tutti/state/memory/backend.md".to_string()), + env: HashMap::new(), + }; + + // file_injected=true → claude-code skips prompt prepending + let result = + prepend_memory_to_prompt(&dir, &agent, "claude-code", Some("Original prompt"), true) + .unwrap() + .unwrap(); + assert_eq!(result, "Original prompt"); + + // file_injected=false → claude-code falls back to prompt prepending + let result = + prepend_memory_to_prompt(&dir, &agent, "claude-code", Some("Original prompt"), false) + .unwrap() + .unwrap(); + assert!(result.contains("Some memory")); + assert!(result.contains("Original prompt")); + + std::fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/src/cli/watch.rs b/src/cli/watch.rs index 89edbc9..9e7a864 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -825,6 +825,7 @@ mod tests { fresh_worktree: None, branch: None, persistent: false, + memory: None, env: HashMap::new(), }) .collect(), diff --git a/src/config/defaults.rs b/src/config/defaults.rs index 20ecbf0..f9fc436 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -71,6 +71,7 @@ prompt = "You own the UI. Follow existing component patterns." # prompt = "Monitor open PRs. Check CI status." # persistent = true # keeps running, doesn't "finish" # fresh_worktree = true # reset this agent worktree to current HEAD on each tt up +# memory = ".tutti/state/memory/pr-monitor.md" # persistent memory file injected on startup # Reusable automation workflows (opt-in) # [[workflow]] diff --git a/src/config/mod.rs b/src/config/mod.rs index 5c6f72d..6ab9241 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,7 +1,7 @@ use crate::error::{Result, TuttiError}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; pub mod defaults; @@ -108,6 +108,9 @@ pub struct AgentConfig { pub branch: Option, #[serde(default)] pub persistent: bool, + /// Path to a persistent memory file (relative to project root). + #[serde(default)] + pub memory: Option, /// Agent-level environment variables (override workspace env). #[serde(default)] pub env: HashMap, @@ -565,6 +568,34 @@ impl TuttiConfig { } } + // Validate memory paths are relative and don't escape project root + for agent in &self.agents { + if let Some(ref memory) = agent.memory { + let trimmed = memory.trim(); + if trimmed.is_empty() { + return Err(TuttiError::ConfigValidation(format!( + "agent '{}' has empty memory path", + agent.name + ))); + } + if Path::new(trimmed).is_absolute() { + return Err(TuttiError::ConfigValidation(format!( + "agent '{}' memory path must be relative: '{trimmed}'", + agent.name + ))); + } + if Path::new(trimmed) + .components() + .any(|c| matches!(c, Component::ParentDir)) + { + return Err(TuttiError::ConfigValidation(format!( + "agent '{}' memory path must not contain '..': '{trimmed}'", + agent.name + ))); + } + } + } + self.validate_automation()?; self.validate_tool_packs()?; self.validate_budget()?; @@ -1660,6 +1691,86 @@ inject_files = ["/tmp/snapshot.json"] assert!(err.to_string().contains("workspace-relative")); } + #[test] + fn memory_field_parses_from_toml() { + let toml_str = r#" +[workspace] +name = "test" + +[[agent]] +name = "backend" +runtime = "claude-code" +memory = ".tutti/state/memory/backend.md" +"#; + let config: TuttiConfig = toml::from_str(toml_str).unwrap(); + assert_eq!( + config.agents[0].memory.as_deref(), + Some(".tutti/state/memory/backend.md") + ); + config.validate().unwrap(); + } + + #[test] + fn memory_field_defaults_to_none() { + let toml_str = r#" +[workspace] +name = "test" + +[[agent]] +name = "backend" +runtime = "claude-code" +"#; + let config: TuttiConfig = toml::from_str(toml_str).unwrap(); + assert!(config.agents[0].memory.is_none()); + } + + #[test] + fn memory_path_must_be_relative() { + let toml_str = r#" +[workspace] +name = "test" + +[[agent]] +name = "backend" +runtime = "claude-code" +memory = "/tmp/memory.md" +"#; + let config: TuttiConfig = toml::from_str(toml_str).unwrap(); + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("must be relative")); + } + + #[test] + fn memory_path_rejects_traversal() { + let toml_str = r#" +[workspace] +name = "test" + +[[agent]] +name = "backend" +runtime = "claude-code" +memory = "../../etc/passwd" +"#; + let config: TuttiConfig = toml::from_str(toml_str).unwrap(); + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("must not contain '..'")); + } + + #[test] + fn memory_path_allows_double_dot_in_filename() { + let toml_str = r#" +[workspace] +name = "test" + +[[agent]] +name = "backend" +runtime = "claude-code" +memory = ".tutti/state/memory/notes..md" +"#; + let config: TuttiConfig = toml::from_str(toml_str).unwrap(); + config.validate().unwrap(); + } + #[test] fn parse_tool_packs() { let toml_str = r#" @@ -1952,6 +2063,7 @@ workflow_source = "run" fresh_worktree: None, branch: None, persistent: false, + memory: None, env: HashMap::new(), }; assert_eq!( @@ -1972,6 +2084,7 @@ workflow_source = "run" fresh_worktree: None, branch: None, persistent: false, + memory: None, env: HashMap::new(), }; assert_eq!(agent.resolved_branch(), "tutti/backend"); @@ -1989,6 +2102,7 @@ workflow_source = "run" fresh_worktree: None, branch: None, persistent: false, + memory: None, env: HashMap::new(), }; assert!(!agent.resolved_fresh_worktree()); diff --git a/src/session/tmux.rs b/src/session/tmux.rs index 672af30..abe2a6f 100644 --- a/src/session/tmux.rs +++ b/src/session/tmux.rs @@ -109,6 +109,13 @@ impl TmuxSession { } /// Send text to a running session and press Enter. + /// + /// The entire text is pasted as a single tmux buffer then submitted. + /// Multi-line text uses bracketed paste mode (`-p`) so it arrives + /// atomically; a second Enter is sent because some TUI applications + /// (e.g. codex) consume the first Enter as "finalize paste" rather + /// than "submit". Single-line text skips bracketed paste to avoid + /// that extra-Enter issue with simple shell commands. pub fn send_text(session: &str, text: &str) -> Result<()> { if !Self::session_exists(session) { return Err(TuttiError::TmuxError(format!( @@ -117,27 +124,19 @@ impl TmuxSession { ))); } - // Preserve line boundaries: each line is sent literally, then Enter. - let lines: Vec<&str> = if text.is_empty() { - vec![""] - } else { - text.lines().collect() - }; + let is_multiline = text.contains('\n'); - for line in lines { - if !line.is_empty() { - send_line_via_tmux_buffer(session, line)?; - } + if !text.is_empty() { + send_text_via_tmux_buffer(session, text, is_multiline)?; + } - let out = Command::new("tmux") - .args(["send-keys", "-t", session, "Enter"]) - .output()?; - if !out.status.success() { - let stderr = String::from_utf8_lossy(&out.stderr); - return Err(TuttiError::TmuxError(format!( - "failed to send Enter to '{session}': {stderr}" - ))); - } + // First Enter: for multi-line bracketed pastes, some TUIs treat this + // as "close the paste bracket" rather than "submit". + send_enter(session)?; + + // Second Enter when bracketed paste was used — actually submits. + if is_multiline { + send_enter(session)?; } Ok(()) @@ -190,7 +189,25 @@ fn shell_escape_value(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } -fn send_line_via_tmux_buffer(session: &str, line: &str) -> Result<()> { +fn send_enter(session: &str) -> Result<()> { + let out = Command::new("tmux") + .args(["send-keys", "-t", session, "Enter"]) + .output()?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + return Err(TuttiError::TmuxError(format!( + "failed to send Enter to '{session}': {stderr}" + ))); + } + Ok(()) +} + +/// Load text into a tmux buffer and paste it into the target session. +/// +/// When `bracketed` is true, uses `-p` flag for bracketed paste mode so the +/// receiving application treats embedded newlines as part of the paste rather +/// than as individual Enter keypresses. +fn send_text_via_tmux_buffer(session: &str, text: &str, bracketed: bool) -> Result<()> { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) @@ -205,7 +222,7 @@ fn send_line_via_tmux_buffer(session: &str, line: &str) -> Result<()> { .spawn()?; if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(line.as_bytes())?; + stdin.write_all(text.as_bytes())?; } let load_output = child.wait_with_output()?; if !load_output.status.success() { @@ -215,9 +232,12 @@ fn send_line_via_tmux_buffer(session: &str, line: &str) -> Result<()> { ))); } - let paste_output = Command::new("tmux") - .args(["paste-buffer", "-d", "-b", &buffer_name, "-t", session]) - .output()?; + let mut paste_args = vec!["paste-buffer", "-d"]; + if bracketed { + paste_args.push("-p"); + } + paste_args.extend(["-b", &buffer_name, "-t", session]); + let paste_output = Command::new("tmux").args(&paste_args).output()?; if !paste_output.status.success() { let stderr = String::from_utf8_lossy(&paste_output.stderr); return Err(TuttiError::TmuxError(format!( diff --git a/src/state/mod.rs b/src/state/mod.rs index 9e70f18..247fb99 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -321,6 +321,7 @@ pub fn ensure_tutti_dir(project_root: &Path) -> Result { "worktrees", "handoffs", "logs", + "state/memory", ]; for subdir in &subdirs { @@ -851,6 +852,7 @@ mod tests { assert!(dir.join(".tutti/worktrees").exists()); assert!(dir.join(".tutti/handoffs").exists()); assert!(dir.join(".tutti/logs").exists()); + assert!(dir.join(".tutti/state/memory").exists()); std::fs::remove_dir_all(&dir).unwrap(); } diff --git a/src/worktree/mod.rs b/src/worktree/mod.rs index 4125ac8..21d20af 100644 --- a/src/worktree/mod.rs +++ b/src/worktree/mod.rs @@ -192,3 +192,177 @@ fn git_rev_parse(path: &Path) -> Result { } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } + +#[cfg(test)] +mod tests { + use super::{ + WorktreeSnapshot, ensure_fresh_worktree, ensure_worktree, inspect_worktree, + remove_worktree, worktree_path, + }; + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct TestRepo { + root: PathBuf, + } + + impl TestRepo { + fn new() -> Self { + let unique = format!( + "tutti-worktree-tests-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("current time should be after epoch") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + fs::create_dir_all(&root).expect("temp repo dir should be created"); + + run_git(&root, ["init"]); + run_git(&root, ["config", "user.name", "Tutti Tests"]); + run_git(&root, ["config", "user.email", "tutti-tests@example.com"]); + + fs::write(root.join("README.md"), "initial\n").expect("seed file should be written"); + run_git(&root, ["add", "README.md"]); + run_git(&root, ["commit", "-m", "initial"]); + + Self { root } + } + + fn path(&self) -> &Path { + &self.root + } + } + + impl Drop for TestRepo { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + fn run_git(path: &Path, args: I) -> String + where + I: IntoIterator, + S: AsRef, + { + let args_vec: Vec = args + .into_iter() + .map(|arg| arg.as_ref().to_string()) + .collect(); + let output = Command::new("git") + .args(args_vec.iter().map(String::as_str)) + .current_dir(path) + .output() + .expect("git command should run"); + + assert!( + output.status.success(), + "git {:?} failed in {}: {}", + args_vec, + path.display(), + String::from_utf8_lossy(&output.stderr) + ); + + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + + #[test] + fn ensure_worktree_creates_new_branch_and_reports_clean_snapshot() { + let repo = TestRepo::new(); + + let path = ensure_worktree(repo.path(), "tester", "tutti/tester") + .expect("worktree should be created"); + + assert_eq!(path, worktree_path(repo.path(), "tester")); + assert!(path.exists()); + assert_eq!( + run_git(&path, ["rev-parse", "--abbrev-ref", "HEAD"]), + "tutti/tester" + ); + assert_eq!( + inspect_worktree(repo.path(), "tester").expect("snapshot should succeed"), + WorktreeSnapshot { + exists: true, + dirty: false, + at_project_head: true, + } + ); + } + + #[test] + fn inspect_worktree_detects_dirty_state_and_head_divergence() { + let repo = TestRepo::new(); + let path = ensure_worktree(repo.path(), "tester", "tutti/tester") + .expect("worktree should be created"); + + fs::write(path.join("dirty.txt"), "pending\n").expect("dirty file should be written"); + + assert_eq!( + inspect_worktree(repo.path(), "tester").expect("snapshot should succeed"), + WorktreeSnapshot { + exists: true, + dirty: true, + at_project_head: true, + } + ); + + run_git(&path, ["add", "dirty.txt"]); + run_git(&path, ["commit", "-m", "diverge"]); + + assert_eq!( + inspect_worktree(repo.path(), "tester").expect("snapshot should succeed"), + WorktreeSnapshot { + exists: true, + dirty: false, + at_project_head: false, + } + ); + } + + #[test] + fn ensure_fresh_worktree_resets_diverged_branch_to_project_head() { + let repo = TestRepo::new(); + let path = ensure_worktree(repo.path(), "tester", "tutti/tester") + .expect("worktree should be created"); + + fs::write(path.join("feature.txt"), "change\n").expect("feature file should be written"); + run_git(&path, ["add", "feature.txt"]); + run_git(&path, ["commit", "-m", "feature"]); + + let refreshed = ensure_fresh_worktree(repo.path(), "tester", "tutti/tester") + .expect("fresh worktree should be recreated"); + + assert_eq!(refreshed, worktree_path(repo.path(), "tester")); + assert_eq!( + inspect_worktree(repo.path(), "tester").expect("snapshot should succeed"), + WorktreeSnapshot { + exists: true, + dirty: false, + at_project_head: true, + } + ); + assert!(!refreshed.join("feature.txt").exists()); + } + + #[test] + fn remove_worktree_is_idempotent_and_removes_existing_checkout() { + let repo = TestRepo::new(); + + remove_worktree(repo.path(), "tester").expect("missing worktree removal should succeed"); + + let path = ensure_worktree(repo.path(), "tester", "tutti/tester") + .expect("worktree should be created"); + assert!(path.exists()); + + remove_worktree(repo.path(), "tester").expect("existing worktree removal should succeed"); + + assert!(!path.exists()); + assert_eq!( + inspect_worktree(repo.path(), "tester").expect("missing snapshot should succeed"), + WorktreeSnapshot::default() + ); + } +} diff --git a/tests/send_auto_up_integration.rs b/tests/send_auto_up_integration.rs index d1a5891..63ef0aa 100644 --- a/tests/send_auto_up_integration.rs +++ b/tests/send_auto_up_integration.rs @@ -36,10 +36,32 @@ fn normalize_no_ws(s: &str) -> String { s.chars().filter(|c| !c.is_whitespace()).collect() } +fn tmux_is_usable() -> bool { + if Command::new("tmux").arg("-V").output().is_err() { + return false; + } + + let session = format!("tutti-itest-preflight-{}", std::process::id()); + let create = Command::new("tmux") + .args(["new-session", "-d", "-s", &session]) + .output(); + let Ok(create) = create else { + return false; + }; + if !create.status.success() { + return false; + } + + let _ = Command::new("tmux") + .args(["kill-session", "-t", &session]) + .output(); + true +} + #[test] fn send_auto_up_wait_output_preserves_long_prompt() { - if Command::new("tmux").arg("-V").output().is_err() { - // Skip if tmux is unavailable in this environment. + if !tmux_is_usable() { + // Skip if tmux is unavailable or unusable in this environment. return; }