From 3bd3147493b93cd30860c2c6993ac5b9c9d14dc6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 1 Jul 2026 19:22:29 +0000 Subject: [PATCH 1/5] fix: route daemon sync and automation through TraceDecay --- src/agent_cmd.rs | 78 +++++++++- src/agents/codex.rs | 240 ------------------------------ src/hooks.rs | 52 ++++++- src/mcp/hook_events.rs | 51 +++++++ src/mcp/server.rs | 11 ++ tests/cli_non_interactive_test.rs | 81 +++++----- tests/hooks_test.rs | 26 +++- 7 files changed, 245 insertions(+), 294 deletions(-) diff --git a/src/agent_cmd.rs b/src/agent_cmd.rs index fa6b558f..298c378a 100644 --- a/src/agent_cmd.rs +++ b/src/agent_cmd.rs @@ -121,6 +121,74 @@ fn validate_codex_automation_project_path() -> tracedecay::errors::Result tracedecay::errors::Result { + use tracedecay::automation::config::{ + effective_config, load_project_config, merge_project_config, project_config_path, + save_project_config, AutomationBackend, AutomationConfigPatch, AutomationHostMode, + AutomationTaskPatch, + }; + + let cg = open_or_init_codex_daemon_automation_project(project_path).await?; + let dashboard_root = cg.store_layout().dashboard_root.clone(); + let existing = load_project_config(&dashboard_root).await?; + let updated = merge_project_config( + existing, + AutomationConfigPatch { + enabled: Some(true), + backend: Some(AutomationBackend::CodexAppServer), + host_mode: Some(AutomationHostMode::Standalone), + model: Some(Some("gpt-5.5".to_string())), + require_dashboard_approval: Some(false), + auto_apply_memory_ops: Some(true), + auto_enable_skills: Some(false), + memory_curator: codex_daemon_interval_task(15 * 60), + session_reflector: codex_daemon_interval_task(15 * 60), + skill_writer: AutomationTaskPatch { + min_idle_secs: Some(Some(15 * 60)), + ..codex_daemon_interval_task(60 * 60) + }, + ..AutomationConfigPatch::default() + }, + ); + + let global = tracedecay::user_config::UserConfig::load().automation; + effective_config(&global, Some(&updated))?; + save_project_config(&dashboard_root, &updated).await?; + let path = project_config_path(&dashboard_root); + eprintln!( + "\x1b[32m✔\x1b[0m Enabled TraceDecay daemon automation loop at {}", + path.display() + ); + eprintln!( + " The daemon scheduler will run memory_curator, session_reflector, and skill_writer via the Codex app-server backend." + ); + Ok(path) +} + +async fn open_or_init_codex_daemon_automation_project( + project_path: &Path, +) -> tracedecay::errors::Result { + if tracedecay::tracedecay::TraceDecay::has_initialized_store(project_path).await { + tracedecay::tracedecay::TraceDecay::open(project_path).await + } else { + tracedecay::tracedecay::TraceDecay::init(project_path).await + } +} + +fn codex_daemon_interval_task( + interval_secs: u64, +) -> tracedecay::automation::config::AutomationTaskPatch { + tracedecay::automation::config::AutomationTaskPatch { + enabled: Some(true), + schedule: Some(Some("interval".to_string())), + interval_secs: Some(Some(interval_secs)), + cooldown_secs: Some(Some(5 * 60)), + ..tracedecay::automation::config::AutomationTaskPatch::default() + } +} + pub(crate) fn hermes_selected_profile_targets( home: &Path, profile: &Option, @@ -188,10 +256,7 @@ pub(crate) async fn handle_install_command( ag.post_install(Some(&project_path)).await; if automation && id == "codex" { let scoped_project_path = validate_codex_automation_project_path()?; - tracedecay::agents::codex::install_codex_native_automation( - &home, - &scoped_project_path, - )?; + install_codex_daemon_automation(&scoped_project_path).await?; } } installed_names.push(ag.name().to_string()); @@ -246,10 +311,7 @@ pub(crate) async fn handle_install_command( ag.post_install(project_path.as_deref()).await; if automation && id == "codex" { let scoped_project_path = validate_codex_automation_project_path()?; - tracedecay::agents::codex::install_codex_native_automation( - &home, - &scoped_project_path, - )?; + install_codex_daemon_automation(&scoped_project_path).await?; } } if !user_cfg.installed_agents.contains(&id) { diff --git a/src/agents/codex.rs b/src/agents/codex.rs index 070a0b71..cad4f888 100644 --- a/src/agents/codex.rs +++ b/src/agents/codex.rs @@ -13,7 +13,6 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::json; @@ -370,157 +369,6 @@ fn codex_update_project_path(ctx: &InstallContext) -> Option { .or_else(|| std::env::current_dir().ok()) } -const CODEX_TRACEDECAY_AUTOMATION_ID: &str = "watch-tracedecay-memory"; -const CODEX_TRACEDECAY_AUTOMATION_NAME: &str = "Watch TraceDecay Memory"; -const CODEX_TRACEDECAY_AUTOMATION_RRULE: &str = "FREQ=HOURLY;INTERVAL=1;BYMINUTE=0,15,30,45"; - -pub fn install_codex_native_automation(home: &Path, project_path: &Path) -> Result { - let automation_dir = home - .join(".codex/automations") - .join(CODEX_TRACEDECAY_AUTOMATION_ID); - let automation_path = automation_dir.join("automation.toml"); - let now_ms = unix_timestamp_millis(); - let created_at = existing_codex_automation_created_at(&automation_path).unwrap_or(now_ms); - - std::fs::create_dir_all(&automation_dir).map_err(|e| TraceDecayError::Config { - message: format!( - "cannot create Codex automation directory {}: {e}", - automation_dir.display() - ), - })?; - set_private_dir_permissions(&automation_dir); - - let mut table = toml::map::Map::new(); - table.insert("version".to_string(), toml::Value::Integer(1)); - table.insert( - "id".to_string(), - toml::Value::String(CODEX_TRACEDECAY_AUTOMATION_ID.to_string()), - ); - table.insert("kind".to_string(), toml::Value::String("cron".to_string())); - table.insert( - "name".to_string(), - toml::Value::String(CODEX_TRACEDECAY_AUTOMATION_NAME.to_string()), - ); - table.insert( - "prompt".to_string(), - toml::Value::String(codex_native_automation_prompt(project_path)), - ); - table.insert( - "status".to_string(), - toml::Value::String("ACTIVE".to_string()), - ); - table.insert( - "rrule".to_string(), - toml::Value::String(CODEX_TRACEDECAY_AUTOMATION_RRULE.to_string()), - ); - table.insert( - "model".to_string(), - toml::Value::String("gpt-5.5".to_string()), - ); - table.insert( - "reasoning_effort".to_string(), - toml::Value::String("medium".to_string()), - ); - table.insert( - "execution_environment".to_string(), - toml::Value::String("local".to_string()), - ); - table.insert( - "cwds".to_string(), - toml::Value::Array(vec![toml::Value::String( - project_path.display().to_string(), - )]), - ); - table.insert("created_at".to_string(), toml::Value::Integer(created_at)); - table.insert("updated_at".to_string(), toml::Value::Integer(now_ms)); - - let contents = - toml::to_string(&toml::Value::Table(table)).map_err(|e| TraceDecayError::Config { - message: format!("failed to serialize Codex automation TOML: {e}"), - })?; - safe_write_private_text_file(&automation_path, &contents)?; - eprintln!( - "\x1b[32m✔\x1b[0m Installed Codex automation {}", - automation_path.display() - ); - Ok(automation_path) -} - -fn existing_codex_automation_created_at(path: &Path) -> Option { - let contents = std::fs::read_to_string(path).ok()?; - let parsed = toml::from_str::(&contents).ok()?; - parsed.get("created_at")?.as_integer() -} - -fn safe_write_private_text_file(path: &Path, contents: &str) -> Result<()> { - let new_path = PathBuf::from(format!("{}.new", path.display())); - if let Err(e) = std::fs::write(&new_path, contents) { - std::fs::remove_file(&new_path).ok(); - return Err(TraceDecayError::Config { - message: format!( - "failed to write new Codex automation file {}: {e}", - new_path.display() - ), - }); - } - set_private_file_permissions(&new_path); - if let Err(e) = std::fs::rename(&new_path, path) { - std::fs::remove_file(&new_path).ok(); - return Err(TraceDecayError::Config { - message: format!( - "failed to rename {} → {}: {e}", - new_path.display(), - path.display() - ), - }); - } - set_private_file_permissions(path); - Ok(()) -} - -fn unix_timestamp_millis() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .min(i64::MAX as u128) as i64 -} - -#[cfg(unix)] -fn set_private_dir_permissions(path: &Path) { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)); -} - -#[cfg(not(unix))] -fn set_private_dir_permissions(_path: &Path) {} - -#[cfg(unix)] -fn set_private_file_permissions(path: &Path) { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); -} - -#[cfg(not(unix))] -fn set_private_file_permissions(_path: &Path) {} - -pub fn codex_native_automation_prompt(project_path: &Path) -> String { - format!( - "Watch the TraceDecay project at {}. Monitor TraceDecay project memory \ - and relevant session context for stale, duplicate, contradictory, or \ - useful durable facts. Use the installed TraceDecay plugin and the \ - user/profile-level TraceDecay store scoped to this project. Do not \ - create repo-local TraceDecay storage or automation files. Start \ - read-only: inspect memory health/status and search/list/probe/contradict \ - related facts before proposing changes. Do not delete or destructively \ - mutate facts without explicit user confirmation. If nothing important \ - changed, report briefly. If there is a useful maintenance action, \ - explain what changed or what needs review, including fact ids and \ - reasons when applicable.", - project_path.display() - ) -} - fn install_codex_plugin(home: &Path, tracedecay_bin: &str) -> Result<()> { let cached_dirs = codex_plugin_cached_install_dirs(home); if !cached_dirs.is_empty() { @@ -1690,92 +1538,4 @@ mod tests { "Codex skill bodies reference skills absent from the bundle: {dangling:?}" ); } - - #[test] - fn codex_native_automation_installs_global_project_scoped_record() { - let home = tempfile::TempDir::new().expect("temp home"); - let project = Path::new("/work/project"); - - let path = - install_codex_native_automation(home.path(), project).expect("automation install"); - assert_eq!( - path, - home.path() - .join(".codex/automations/watch-tracedecay-memory/automation.toml") - ); - assert!( - !project.join(".codex/automations").exists(), - "Codex automations must live in the global user profile, not the project" - ); - - let contents = std::fs::read_to_string(&path).expect("automation toml"); - let parsed = toml::from_str::(&contents).expect("valid toml"); - assert_eq!( - parsed.get("id").and_then(|value| value.as_str()), - Some("watch-tracedecay-memory") - ); - assert_eq!( - parsed.get("name").and_then(|value| value.as_str()), - Some("Watch TraceDecay Memory") - ); - assert_eq!( - parsed.get("rrule").and_then(|value| value.as_str()), - Some("FREQ=HOURLY;INTERVAL=1;BYMINUTE=0,15,30,45") - ); - assert_eq!( - parsed - .get("cwds") - .and_then(|value| value.as_array()) - .and_then(|values| values.first()) - .and_then(|value| value.as_str()), - Some("/work/project") - ); - assert_eq!( - parsed.get("model").and_then(|value| value.as_str()), - Some("gpt-5.5") - ); - assert_eq!( - parsed - .get("reasoning_effort") - .and_then(|value| value.as_str()), - Some("medium") - ); - assert!(parsed - .get("prompt") - .and_then(|value| value.as_str()) - .unwrap_or_default() - .contains("Do not create repo-local TraceDecay storage or automation files")); - - let created_at = parsed - .get("created_at") - .and_then(toml::Value::as_integer) - .expect("created_at"); - install_codex_native_automation(home.path(), project).expect("automation reinstall"); - let reparsed = std::fs::read_to_string(&path) - .expect("automation toml") - .parse::() - .expect("valid toml"); - assert_eq!( - reparsed.get("created_at").and_then(toml::Value::as_integer), - Some(created_at), - "reinstall should preserve created_at for the existing Codex automation" - ); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let dir_mode = std::fs::metadata(path.parent().unwrap()) - .expect("automation dir metadata") - .permissions() - .mode() - & 0o777; - let file_mode = std::fs::metadata(&path) - .expect("automation file metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(dir_mode, 0o700); - assert_eq!(file_mode, 0o600); - } - } } diff --git a/src/hooks.rs b/src/hooks.rs index 0b16c498..439e9e76 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -962,6 +962,11 @@ pub enum CursorShellSyncPlan { /// Bootstrap/maintain branch tracking for the given branch (supersedes a /// plain sync; the branch-add path copies the parent DB and syncs). BranchAdd(String), + /// Bootstrap/maintain branch tracking in a newly-created linked worktree. + WorktreeBranchAdd { + branch: String, + worktree_path: String, + }, /// Run a full incremental sync (same-branch change set). IncrementalSync, /// Ensure the current branch is tracked, then sync it if it already was. @@ -983,7 +988,14 @@ pub fn cursor_shell_sync_plan_with_current_branch( command: &str, current_branch: Option<&str>, ) -> CursorShellSyncPlan { - if let Some(branch) = cursor_branch_switch_target(command) { + let raw = shell_words(command); + if let Some((branch, worktree_path)) = cursor_worktree_add_branch_and_path(&raw) { + return CursorShellSyncPlan::WorktreeBranchAdd { + branch, + worktree_path, + }; + } + if let Some(branch) = cursor_branch_switch_target_from_tokens(&raw) { return CursorShellSyncPlan::BranchAdd(branch); } if is_git_state_changing_command(command) { @@ -1005,7 +1017,11 @@ pub fn cursor_shell_sync_plan_with_current_branch( /// `git` are considered. pub fn cursor_branch_switch_target(command: &str) -> Option { let raw = shell_words(command); - let sub_pos = git_subcommand_pos(&raw)?; + cursor_branch_switch_target_from_tokens(&raw) +} + +fn cursor_branch_switch_target_from_tokens(raw: &[String]) -> Option { + let sub_pos = git_subcommand_pos(raw)?; let sub = raw[sub_pos].to_ascii_lowercase(); match sub.as_str() { @@ -1052,9 +1068,30 @@ pub fn cursor_branch_switch_target(command: &str) -> Option { } fn cursor_worktree_add_target(after: &[String]) -> Option { + cursor_worktree_add_parts(after).map(|parts| parts.branch) +} + +fn cursor_worktree_add_branch_and_path(raw: &[String]) -> Option<(String, String)> { + let sub_pos = git_subcommand_pos(raw)?; + if raw.get(sub_pos)?.eq_ignore_ascii_case("worktree") + && raw.get(sub_pos + 1)?.eq_ignore_ascii_case("add") + { + let parts = cursor_worktree_add_parts(&raw[sub_pos + 2..])?; + return Some((parts.branch, parts.worktree_path)); + } + None +} + +struct WorktreeAddParts { + branch: String, + worktree_path: String, +} + +fn cursor_worktree_add_parts(after: &[String]) -> Option { let mut i = 0; let mut positional = Vec::new(); let mut detached = false; + let mut new_branch = None; while i < after.len() { let tok = &after[i]; if tok == "--" { @@ -1062,7 +1099,9 @@ fn cursor_worktree_add_target(after: &[String]) -> Option { break; } if matches!(tok.as_str(), "-b" | "-B") { - return after.get(i + 1).cloned(); + new_branch = after.get(i + 1).cloned(); + i += 2; + continue; } if tok == "-d" || tok == "--detach" { detached = true; @@ -1083,7 +1122,12 @@ fn cursor_worktree_add_target(after: &[String]) -> Option { if detached { return None; } - positional.get(1).cloned() + let worktree_path = positional.first()?.clone(); + let branch = new_branch.or_else(|| positional.get(1).cloned())?; + Some(WorktreeAddParts { + branch, + worktree_path, + }) } fn is_obvious_checkout_pathspec(token: &str) -> bool { diff --git a/src/mcp/hook_events.rs b/src/mcp/hook_events.rs index 74bfb3af..4d5c7e9d 100644 --- a/src/mcp/hook_events.rs +++ b/src/mcp/hook_events.rs @@ -65,6 +65,7 @@ pub(crate) struct HookEvent { pub(crate) enum HookEventPlan { SyncFiles(Vec), AddBranch(String), + AddBranchAt { root: PathBuf, branch: String }, SyncCurrentBranch { branch: String, agent: HookAgent }, DebouncedIncrementalSync(HookAgent), Noop, @@ -153,6 +154,13 @@ fn plan_shell_hook_event( } match crate::hooks::cursor_shell_sync_plan_with_current_branch(command, current_branch) { crate::hooks::CursorShellSyncPlan::BranchAdd(branch) => HookEventPlan::AddBranch(branch), + crate::hooks::CursorShellSyncPlan::WorktreeBranchAdd { + branch, + worktree_path, + } => HookEventPlan::AddBranchAt { + root: absolutize_hook_path(cwd, &worktree_path), + branch, + }, crate::hooks::CursorShellSyncPlan::IncrementalSync => { HookEventPlan::DebouncedIncrementalSync(event.agent) } @@ -166,6 +174,30 @@ fn plan_shell_hook_event( } } +fn absolutize_hook_path(cwd: &Path, path: &str) -> PathBuf { + let path = Path::new(path); + let joined = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + normalize_lexically(&joined) +} + +fn normalize_lexically(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + normalized +} + fn read_marker_secs(path: &Path) -> Option { std::fs::read_to_string(path) .ok()? @@ -299,6 +331,25 @@ mod tests { ); } + #[test] + fn plans_worktree_add_against_new_worktree_root() { + let params = json!({ + "agent": "codex", + "event": "postToolUseShell", + "command": "git worktree add ../wt feature/daemon-hooks", + "cwd": "/tmp/project" + }); + let event = parse_or_panic(¶ms); + + assert_eq!( + plan_hook_event(&event, Path::new("/tmp/project"), Some("main")), + HookEventPlan::AddBranchAt { + root: Path::new("/tmp/wt").to_path_buf(), + branch: "feature/daemon-hooks".to_string(), + } + ); + } + #[test] fn plans_workspace_open_as_current_branch_sync() { let params = json!({ diff --git a/src/mcp/server.rs b/src/mcp/server.rs index f57e4ee1..b39562aa 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -1405,6 +1405,17 @@ impl McpServer { Err(e) => eprintln!("[tracedecay] hook branch tracking failed: {e}"), } } + HookEventPlan::AddBranchAt { root, branch } => { + match self.add_hook_branch_tracking(&root, &branch, &cg).await { + Ok( + crate::branch::BranchAddOutcome::Added + | crate::branch::BranchAddOutcome::AlreadyTracked + | crate::branch::BranchAddOutcome::Deferred + | crate::branch::BranchAddOutcome::NotIndexed, + ) => {} + Err(e) => eprintln!("[tracedecay] hook worktree branch tracking failed: {e}"), + } + } HookEventPlan::SyncCurrentBranch { branch, agent } => { match self.add_hook_branch_tracking(root, &branch, &cg).await { Ok(crate::branch::BranchAddOutcome::Added) => { diff --git a/tests/cli_non_interactive_test.rs b/tests/cli_non_interactive_test.rs index b6b341d9..21711268 100644 --- a/tests/cli_non_interactive_test.rs +++ b/tests/cli_non_interactive_test.rs @@ -35,21 +35,6 @@ fn canonical_temp_path(path: &Path) -> PathBuf { } } -fn comparable_existing_path(path: &Path) -> String { - let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); - #[cfg(windows)] - { - let path = path.to_string_lossy().into_owned(); - path.strip_prefix(r"\\?\") - .unwrap_or(&path) - .to_ascii_lowercase() - } - #[cfg(not(windows))] - { - path.to_string_lossy().into_owned() - } -} - fn profile_root(home: &Path) -> PathBuf { canonical_temp_path(home).join(".tracedecay") } @@ -373,10 +358,12 @@ fn init_skips_gitignore_prompt_when_stdin_not_a_terminal() { } #[test] -fn install_codex_automation_writes_global_project_record_noninteractively() { +fn install_codex_automation_enables_tracedecay_daemon_loop_noninteractively() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); let project_root = canonical_temp_path(project.path()); + std::fs::create_dir_all(project_root.join("src")).unwrap(); + std::fs::write(project_root.join("src/lib.rs"), "pub fn marker() {}\n").unwrap(); let mut install = tracedecay_command(home.path(), &project_root); let _shim = add_tracedecay_path_shim(&mut install, home.path()); @@ -395,28 +382,54 @@ fn install_codex_automation_writes_global_project_record_noninteractively() { .is_file(), "install --agent codex should still install the Codex plugin bundle" ); - let automation_path = home - .path() - .join(".codex/automations/watch-tracedecay-memory/automation.toml"); - let automation = std::fs::read_to_string(&automation_path) - .unwrap_or_else(|e| panic!("failed to read {}: {e}", automation_path.display())); - let parsed = automation - .parse::() - .expect("automation.toml should be valid TOML"); - let configured_cwd = parsed - .get("cwds") - .and_then(|value| value.as_array()) - .and_then(|values| values.first()) - .and_then(|value| value.as_str()) - .expect("automation cwd should be written"); - assert_eq!( - comparable_existing_path(Path::new(configured_cwd)), - comparable_existing_path(&project_root) + assert!( + !home + .path() + .join(".codex/automations/watch-tracedecay-memory/automation.toml") + .exists(), + "Codex automation install must not create native Codex scheduled chats" ); assert!( !project_root.join(".codex/automations").exists(), - "codex automation install should write the automation under the user profile" + "Codex automation install must not create repo-local Codex automation files" ); + + let projects_dir = profile_root(home.path()).join("projects"); + let sidecars = std::fs::read_dir(&projects_dir) + .unwrap() + .map(|entry| { + entry + .unwrap() + .path() + .join("dashboard/automation_config.json") + }) + .filter(|path| path.is_file()) + .collect::>(); + assert_eq!( + sidecars.len(), + 1, + "codex automation install should write one TraceDecay project scheduler sidecar" + ); + + let sidecar: serde_json::Value = serde_json::from_slice( + &std::fs::read(&sidecars[0]).expect("automation sidecar should be readable"), + ) + .expect("automation sidecar should be valid JSON"); + assert_eq!(sidecar["enabled"], true); + assert_eq!(sidecar["backend"], "codex_app_server"); + assert_eq!(sidecar["host_mode"], "standalone"); + assert_eq!(sidecar["model"], "gpt-5.5"); + assert_eq!(sidecar["require_dashboard_approval"], false); + assert_eq!(sidecar["auto_apply_memory_ops"], true); + assert_eq!(sidecar["auto_enable_skills"], false); + assert_eq!(sidecar["memory_curator"]["enabled"], true); + assert_eq!(sidecar["memory_curator"]["schedule"], "interval"); + assert_eq!(sidecar["memory_curator"]["interval_secs"], 900); + assert_eq!(sidecar["session_reflector"]["enabled"], true); + assert_eq!(sidecar["session_reflector"]["interval_secs"], 900); + assert_eq!(sidecar["skill_writer"]["enabled"], true); + assert_eq!(sidecar["skill_writer"]["interval_secs"], 3600); + assert_eq!(sidecar["skill_writer"]["min_idle_secs"], 900); } #[test] diff --git a/tests/hooks_test.rs b/tests/hooks_test.rs index 44d81f03..44b74ad2 100644 --- a/tests/hooks_test.rs +++ b/tests/hooks_test.rs @@ -1087,6 +1087,24 @@ fn test_cursor_branch_switch_target_extracts_branch() { ); } +#[test] +fn test_cursor_shell_sync_plan_routes_worktree_add_to_worktree_branch_add() { + assert_eq!( + cursor_shell_sync_plan("git worktree add ../wt feature/z"), + CursorShellSyncPlan::WorktreeBranchAdd { + branch: "feature/z".to_string(), + worktree_path: "../wt".to_string(), + } + ); + assert_eq!( + cursor_shell_sync_plan("git worktree add -b feature/new ../wt main"), + CursorShellSyncPlan::WorktreeBranchAdd { + branch: "feature/new".to_string(), + worktree_path: "../wt".to_string(), + } + ); +} + #[test] fn test_cursor_branch_switch_target_ignores_path_checkouts_and_non_switches() { assert_eq!( @@ -1116,14 +1134,6 @@ fn test_cursor_shell_sync_plan_routes_branch_switch_to_branch_add() { cursor_shell_sync_plan("git switch -c feature/x"), CursorShellSyncPlan::BranchAdd("feature/x".to_string()) ); - assert_eq!( - cursor_shell_sync_plan("git worktree add ../wt feature/z"), - CursorShellSyncPlan::BranchAdd("feature/z".to_string()) - ); - assert_eq!( - cursor_shell_sync_plan("git worktree add -b feature/new ../wt main"), - CursorShellSyncPlan::BranchAdd("feature/new".to_string()) - ); } #[test] From fcc447fe9eb040842c1723bc21a732dc85a19842 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 1 Jul 2026 22:07:52 +0000 Subject: [PATCH 2/5] fix: auto-apply session reflection facts --- docs/SELF-IMPROVING-LOOPS-CONTRACTS.md | 14 +++ src/automation/runner.rs | 83 ++++++++++++- ...utomation_session_reflector_runner_test.rs | 117 ++++++++++++++++++ 3 files changed, 208 insertions(+), 6 deletions(-) diff --git a/docs/SELF-IMPROVING-LOOPS-CONTRACTS.md b/docs/SELF-IMPROVING-LOOPS-CONTRACTS.md index e082056a..def51e43 100644 --- a/docs/SELF-IMPROVING-LOOPS-CONTRACTS.md +++ b/docs/SELF-IMPROVING-LOOPS-CONTRACTS.md @@ -15,6 +15,20 @@ This is the durable contract for TraceDecay-owned self-improvement loops. Hermes | Kiro | Config, ledgers, managed-agent prompt-index content, MCP skill body serving | Managed-agent file ownership and host execution | Existing managed-agent path with prompt index plus `tracedecay_skill_view` | | Prompt-only agents | Config, ledgers, prompt-index generation, MCP skill body serving | Prompt ingestion and execution | Compact prompt index plus `tracedecay_skill_view` | +## Cadence And Automation Defaults + +Hermes is the reference behavior for self-improvement cadence, but TraceDecay owns its own scheduler in standalone mode. Hermes memory review and skill review are turn/iteration nudges: memory defaults to every 10 user turns when memory is enabled, skill review defaults to every 10 tool-calling iterations, and both run as a whitelisted background review fork after the foreground response. Hermes skill-library curator is separate: it runs after `curator.interval_hours` elapses, defaults to 168 hours, requires the idle gate (`curator.min_idle_hours`, default 2 hours), seeds the first run instead of mutating immediately, snapshots before real runs, and archives rather than deletes. + +TraceDecay standalone automation is time-scheduled by the daemon, not by Codex native automations or Hermes cron. The default scheduler tick is 60 seconds. `tracedecay install --agent codex --automation` enables the Codex app-server backend with `require_dashboard_approval=false`, `auto_apply_memory_ops=true`, `auto_enable_skills=false`, and these task cadences: + +| Task | Default cadence | Default mutation behavior | +| --- | --- | --- | +| `memory_curator` | Every 15 minutes, with a 5-minute cooldown | Validated accepted curation ops auto-apply when `auto_apply_memory_ops=true` and dashboard approval is disabled. | +| `session_reflector` | Every 15 minutes, with a 5-minute cooldown | Validated accepted session facts auto-apply under the same memory auto-apply policy; otherwise they stay as dashboard fact proposals. | +| `skill_writer` | Every 60 minutes, after a 15-minute idle window, with a 5-minute cooldown | Creates or updates managed skill drafts; skills are not auto-enabled while `auto_enable_skills=false`. | + +The daemon loop is the host for these jobs. It should not create Codex top-level chats for scheduler work, and it should not rely on Codex native recurring automations for liveness. Host backends provide the model call; TraceDecay owns evidence collection, validation, ledgers, apply policy, and scheduler state. + ## Standalone And Delegated Modes `standalone` means TraceDecay owns backend calls, evidence collection, validation, run ledger writes, approval staging, dashboard review payloads, and optional scheduler execution. Backend output can propose changes, but TraceDecay validates every proposed mutation before it can be applied. diff --git a/src/automation/runner.rs b/src/automation/runner.rs index 1ed540a3..af62bbdc 100644 --- a/src/automation/runner.rs +++ b/src/automation/runner.rs @@ -6,7 +6,9 @@ use serde_json::{json, Value}; use super::artifacts::sha256_json; use super::backend::{AgentTaskBackend, AgentTaskKind, AgentTaskRequest, AgentTaskResponse}; use super::config::AutomationConfig; -use super::fact_proposals::record_session_fact_proposals; +use super::fact_proposals::{ + apply_fact_proposal, record_session_fact_proposals, FactProposalRecord, FactProposalState, +}; use super::lifecycle::{ failed_backend_fallback_report, AgentTaskRunContext, BackendTaskRun, SchedulerGate, }; @@ -283,7 +285,7 @@ pub async fn run_session_reflector_with_backend( validate_fact_proposals(cg, &proposals, &evidence).await?; let accepted_count = accepted_facts.len(); let rejected_count = rejected_facts.len(); - let proposal_records = record_session_fact_proposals( + let mut proposal_records = record_session_fact_proposals( &run.dashboard_root, &run.run_id, evidence_hash.as_deref(), @@ -291,19 +293,59 @@ pub async fn run_session_reflector_with_backend( &rejected_facts, ) .await?; + let auto_apply_facts = config.auto_apply_memory_ops && !config.require_dashboard_approval; + let applied_fact_proposals = if auto_apply_facts { + auto_apply_session_fact_proposals( + cg, + &run.dashboard_root, + std::mem::take(&mut proposal_records), + ) + .await? + } else { + Vec::new() + }; + if auto_apply_facts { + proposal_records = applied_fact_proposals.clone(); + } let proposal_ids: Vec = proposal_records .iter() .map(|record| record.proposal_id.clone()) .collect(); + let applied_proposal_ids: Vec = applied_fact_proposals + .iter() + .filter(|record| record.state == FactProposalState::Applied) + .map(|record| record.proposal_id.clone()) + .collect(); + let applied_fact_ids: Vec = applied_fact_proposals + .iter() + .filter_map(|record| record.applied_fact_id) + .collect(); + let session_fact_apply_policy = json!({ + "decision": if auto_apply_facts && accepted_count > 0 { + "auto_apply_allowed" + } else if config.require_dashboard_approval && accepted_count > 0 { + "requires_dashboard_approval" + } else if accepted_count > 0 { + "proposal_only" + } else { + "no_valid_facts" + }, + "auto_apply_memory_ops": config.auto_apply_memory_ops, + "require_dashboard_approval": config.require_dashboard_approval, + "mutates_store": !applied_proposal_ids.is_empty(), + "applied_proposal_ids": applied_proposal_ids, + "applied_fact_ids": applied_fact_ids, + }); let report = json!({ - "status": "needs_approval", - "dry_run": true, + "status": if auto_apply_facts && accepted_count > 0 { "auto_applied" } else { "needs_approval" }, + "dry_run": !(auto_apply_facts && accepted_count > 0), "task": "session_reflector", "evidence_hash": evidence_hash, "accepted_facts": accepted_facts, "rejected_facts": rejected_facts, "proposal_ids": proposal_ids, "proposal_records": proposal_records, + "session_fact_apply_policy": session_fact_apply_policy, }); let mut record = finalizer.success_record( &response, @@ -320,13 +362,17 @@ pub async fn run_session_reflector_with_backend( accepted_count, rejected_count, ); - record.applied_ops = None; + record.applied_ops = report + .pointer("/session_fact_apply_policy/applied_proposal_ids") + .filter(|value| value.as_array().is_some_and(|items| !items.is_empty())) + .cloned(); record.rejected_ops = report.get("rejected_facts").cloned(); record.validation_report = Some(json!({ "status": report.get("status").cloned().unwrap_or_else(|| json!("needs_approval")), - "dry_run": true, + "dry_run": report.get("dry_run").cloned().unwrap_or_else(|| json!(true)), "accepted_count": accepted_count, "rejected_count": rejected_count, + "session_fact_apply_policy": report.get("session_fact_apply_policy").cloned().unwrap_or_else(|| json!({})), "pending_proposals": { "proposal_ids": report.get("proposal_ids").cloned().unwrap_or_else(|| json!([])), "accepted_facts": report.get("accepted_facts").cloned().unwrap_or_else(|| json!([])), @@ -344,6 +390,31 @@ pub async fn run_session_reflector_with_backend( }) } +async fn auto_apply_session_fact_proposals( + cg: &TraceDecay, + dashboard_root: &std::path::Path, + proposal_records: Vec, +) -> Result> { + let project_db = cg.open_project_store_db().await?; + let mut applied = Vec::with_capacity(proposal_records.len()); + for record in proposal_records { + if record.state != FactProposalState::PendingApproval { + applied.push(record); + continue; + } + applied.push( + apply_fact_proposal( + dashboard_root, + project_db.conn(), + &record.proposal_id, + Some("session_reflector:auto_apply".to_string()), + ) + .await?, + ); + } + Ok(applied) +} + pub async fn run_skill_writer_with_backend( cg: &TraceDecay, config: &AutomationConfig, diff --git a/tests/automation_session_reflector_runner_test.rs b/tests/automation_session_reflector_runner_test.rs index d6346c28..af837c74 100644 --- a/tests/automation_session_reflector_runner_test.rs +++ b/tests/automation_session_reflector_runner_test.rs @@ -333,6 +333,123 @@ async fn session_reflector_runner_validates_fact_proposals_without_applying() { assert!(records[0].applied_ops.is_none()); } +#[tokio::test] +async fn session_reflector_runner_auto_applies_facts_when_memory_auto_apply_is_enabled() { + let temp = tempdir().unwrap(); + let cg = init_project(temp.path()).await; + seed_session_evidence(&cg).await; + let backend = SessionJsonBackend::new(json!({ + "facts": [ + { + "content": "TraceDecay automation should make accepted session memories automatically", + "category": "project", + "tags": ["automation", "memory"], + "entities": ["TraceDecay"], + "trust": 0.76, + "source_span": {"session_id": "session-reflect-1", "message_id": "session-reflect-1-message-001"}, + "reason": "Repeated session evidence supports automatic durable memory capture" + } + ] + })); + let config = AutomationConfig { + enabled: true, + backend: AutomationBackend::CodexAppServer, + host_mode: AutomationHostMode::Standalone, + model: Some("configured-model".to_string()), + require_dashboard_approval: false, + auto_apply_memory_ops: true, + tasks: AutomationTaskSet { + session_reflector: AutomationTaskConfig { + enabled: true, + schedule: Some("manual".to_string()), + ..AutomationTaskConfig::default() + }, + ..AutomationTaskSet::default() + }, + ..AutomationConfig::default() + }; + + let run = run_session_reflector_with_backend( + &cg, + &config, + &backend, + SessionReflectorAutomationOptions { + trigger: AutomationTrigger::ManualCli, + provider: "cursor".to_string(), + query: "durable session reflection".to_string(), + evidence_limit: 5, + run_id: None, + ..SessionReflectorAutomationOptions::default() + }, + ) + .await + .unwrap(); + + assert_eq!(backend.calls(), 1); + assert_eq!(run.report["status"], json!("auto_applied")); + assert_eq!(run.report["dry_run"], json!(false)); + assert_eq!( + run.report["session_fact_apply_policy"]["decision"], + json!("auto_apply_allowed") + ); + assert_eq!( + run.report["session_fact_apply_policy"]["mutates_store"], + json!(true) + ); + assert_eq!( + run.ledger_record + .applied_ops + .as_ref() + .unwrap() + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!( + run.ledger_record.validation_report.as_ref().unwrap()["status"], + json!("auto_applied") + ); + + let pending = list_fact_proposals( + &cg.store_layout().dashboard_root, + Some(FactProposalState::PendingApproval), + 10, + ) + .await + .unwrap(); + assert!(pending.is_empty()); + let applied = list_fact_proposals( + &cg.store_layout().dashboard_root, + Some(FactProposalState::Applied), + 10, + ) + .await + .unwrap(); + assert_eq!(applied.len(), 1); + assert_eq!( + applied[0].reviewer.as_deref(), + Some("session_reflector:auto_apply") + ); + + let facts = cg + .search_facts(tracedecay::memory::types::SearchFactsRequest { + query: "automatic durable memory capture".to_string(), + category: Some(tracedecay::memory::types::MemoryCategory::Project), + limit: Some(10), + min_trust: Some(0.1), + include_why: false, + }) + .await + .unwrap(); + assert!( + facts + .iter() + .any(|hit| hit.fact.source.as_deref() == Some("session_reflector")), + "auto-applied accepted session facts should be written to the fact store" + ); +} + #[tokio::test] async fn session_fact_proposals_dedupe_repeated_pending_facts_across_runs() { let temp = tempdir().unwrap(); From 433323958dd8598d98aaf5084cf0f036d0c72b2c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 1 Jul 2026 22:16:43 +0000 Subject: [PATCH 3/5] fix: compile Windows worktree sync and resolve worktree adds via git -C The Windows-only daemon fallback was missing a match arm for the new WorktreeBranchAdd plan variant, breaking compilation on non-unix targets. Worktree roots are now resolved by a single shared helper that honors `git -C`/`--work-tree` overrides and canonicalizes through symlinks, and `git worktree add` parsing is consolidated into one classifier instead of two parallel parsers. --- src/daemon.rs | 7 +++++ src/hooks.rs | 54 +++++++++++++++++++++++++------------- src/mcp/hook_events.rs | 59 ++++++++++++++++++++++++------------------ src/mcp/server.rs | 3 +++ tests/hooks_test.rs | 36 ++++++++++++++------------ 5 files changed, 99 insertions(+), 60 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index d64f0b70..9d31bdc3 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -379,6 +379,13 @@ async fn notify_shell_hook_event_without_daemon(project_path: &Path, event: Daem crate::hooks::CursorShellSyncPlan::BranchAdd(branch) => { let _ = crate::tracedecay::TraceDecay::add_branch_tracking(project_path, &branch).await; } + crate::hooks::CursorShellSyncPlan::WorktreeBranchAdd { + branch, + worktree_path, + } => { + let root = crate::hooks::resolve_worktree_add_root(command, cwd, &worktree_path); + let _ = crate::tracedecay::TraceDecay::add_branch_tracking(&root, &branch).await; + } crate::hooks::CursorShellSyncPlan::CurrentBranchSync(branch) => { if !matches!( crate::tracedecay::TraceDecay::add_branch_tracking(project_path, &branch).await, diff --git a/src/hooks.rs b/src/hooks.rs index 439e9e76..0ea3dc01 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -989,10 +989,10 @@ pub fn cursor_shell_sync_plan_with_current_branch( current_branch: Option<&str>, ) -> CursorShellSyncPlan { let raw = shell_words(command); - if let Some((branch, worktree_path)) = cursor_worktree_add_branch_and_path(&raw) { + if let Some(parts) = cursor_worktree_add_parts_from_tokens(&raw) { return CursorShellSyncPlan::WorktreeBranchAdd { - branch, - worktree_path, + branch: parts.branch, + worktree_path: parts.worktree_path, }; } if let Some(branch) = cursor_branch_switch_target_from_tokens(&raw) { @@ -1009,7 +1009,8 @@ pub fn cursor_shell_sync_plan_with_current_branch( /// Returns the target branch for a branch-changing git command: /// `git checkout `, `git switch `, `git checkout -b `, -/// `git switch -c `, and `git worktree add `. +/// and `git switch -c `. Worktree creation is classified separately by +/// [`cursor_shell_sync_plan`], which owns `git worktree add` parsing. /// /// Path checkouts (`git checkout -- ` or obvious file pathspecs), remote /// tracking shortcuts such as `git switch --track origin/feature`, and @@ -1056,28 +1057,16 @@ fn cursor_branch_switch_target_from_tokens(raw: &[String]) -> Option { } None } - "worktree" => { - let action = raw.get(sub_pos + 1).map(|token| token.to_ascii_lowercase()); - if action.as_deref() != Some("add") { - return None; - } - cursor_worktree_add_target(&raw[sub_pos + 2..]) - } _ => None, } } -fn cursor_worktree_add_target(after: &[String]) -> Option { - cursor_worktree_add_parts(after).map(|parts| parts.branch) -} - -fn cursor_worktree_add_branch_and_path(raw: &[String]) -> Option<(String, String)> { +fn cursor_worktree_add_parts_from_tokens(raw: &[String]) -> Option { let sub_pos = git_subcommand_pos(raw)?; if raw.get(sub_pos)?.eq_ignore_ascii_case("worktree") && raw.get(sub_pos + 1)?.eq_ignore_ascii_case("add") { - let parts = cursor_worktree_add_parts(&raw[sub_pos + 2..])?; - return Some((parts.branch, parts.worktree_path)); + return cursor_worktree_add_parts(&raw[sub_pos + 2..]); } None } @@ -1286,6 +1275,35 @@ fn resolve_shell_path(cwd: &Path, value: &str) -> PathBuf { } } +/// Resolves the filesystem root of the worktree created by a +/// `git worktree add` command. git resolves the worktree path against +/// `-C `/`--work-tree` overrides rather than the shell cwd, so those are +/// honored first. The result is canonicalized when the worktree exists (it +/// does by the time a post-shell hook fires) so symlinked components resolve +/// the way git resolved them, falling back to lexical `..` normalization. +pub fn resolve_worktree_add_root(command: &str, cwd: &Path, worktree_path: &str) -> PathBuf { + let tokens = shell_words(command); + let base = git_explicit_work_dir(&tokens, cwd).unwrap_or_else(|| cwd.to_path_buf()); + let joined = resolve_shell_path(&base, worktree_path); + joined + .canonicalize() + .unwrap_or_else(|_| normalize_lexically(&joined)) +} + +fn normalize_lexically(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + normalized +} + fn paths_same(a: &Path, b: &Path) -> bool { match (a.canonicalize(), b.canonicalize()) { (Ok(a), Ok(b)) => a == b, diff --git a/src/mcp/hook_events.rs b/src/mcp/hook_events.rs index 4d5c7e9d..7561489f 100644 --- a/src/mcp/hook_events.rs +++ b/src/mcp/hook_events.rs @@ -158,7 +158,7 @@ fn plan_shell_hook_event( branch, worktree_path, } => HookEventPlan::AddBranchAt { - root: absolutize_hook_path(cwd, &worktree_path), + root: crate::hooks::resolve_worktree_add_root(command, cwd, &worktree_path), branch, }, crate::hooks::CursorShellSyncPlan::IncrementalSync => { @@ -174,30 +174,6 @@ fn plan_shell_hook_event( } } -fn absolutize_hook_path(cwd: &Path, path: &str) -> PathBuf { - let path = Path::new(path); - let joined = if path.is_absolute() { - path.to_path_buf() - } else { - cwd.join(path) - }; - normalize_lexically(&joined) -} - -fn normalize_lexically(path: &Path) -> PathBuf { - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::ParentDir => { - normalized.pop(); - } - other => normalized.push(other.as_os_str()), - } - } - normalized -} - fn read_marker_secs(path: &Path) -> Option { std::fs::read_to_string(path) .ok()? @@ -350,6 +326,39 @@ mod tests { ); } + #[test] + fn plans_worktree_add_resolving_path_against_git_dash_c_dir() { + // `git -C ` makes git resolve the worktree path against , + // not the shell cwd: from /project/src, `-C ..` targets the + // project root, so `../wt` lands beside the project at /wt. + let base = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir should create: {e}")); + let base_root = base + .path() + .canonicalize() + .unwrap_or_else(|e| panic!("tempdir should canonicalize: {e}")); + let project_root = base_root.join("project"); + std::fs::create_dir_all(project_root.join("src")) + .unwrap_or_else(|e| panic!("project dirs should create: {e}")); + std::fs::create_dir_all(base_root.join("wt")) + .unwrap_or_else(|e| panic!("worktree dir should create: {e}")); + + let params = json!({ + "agent": "codex", + "event": "postToolUseShell", + "command": "git -C .. worktree add ../wt feature/daemon-hooks", + "cwd": project_root.join("src") + }); + let event = parse_or_panic(¶ms); + + assert_eq!( + plan_hook_event(&event, &project_root, Some("main")), + HookEventPlan::AddBranchAt { + root: base_root.join("wt"), + branch: "feature/daemon-hooks".to_string(), + } + ); + } + #[test] fn plans_workspace_open_as_current_branch_sync() { let params = json!({ diff --git a/src/mcp/server.rs b/src/mcp/server.rs index b39562aa..9c57920e 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -1406,6 +1406,9 @@ impl McpServer { } } HookEventPlan::AddBranchAt { root, branch } => { + // The new worktree root is not this server's checkout, so no + // reopen or token-map refresh applies here (unlike AddBranch); + // branch tracking against the shared store is the whole job. match self.add_hook_branch_tracking(&root, &branch, &cg).await { Ok( crate::branch::BranchAddOutcome::Added diff --git a/tests/hooks_test.rs b/tests/hooks_test.rs index 44b74ad2..7ff436e3 100644 --- a/tests/hooks_test.rs +++ b/tests/hooks_test.rs @@ -1073,18 +1073,6 @@ fn test_cursor_branch_switch_target_extracts_branch() { cursor_branch_switch_target("git switch -c feature/y"), Some("feature/y".to_string()) ); - assert_eq!( - cursor_branch_switch_target("git worktree add ../wt feature/z"), - Some("feature/z".to_string()) - ); - assert_eq!( - cursor_branch_switch_target("git worktree add -b newbranch ../wt"), - Some("newbranch".to_string()) - ); - assert_eq!( - cursor_branch_switch_target("git worktree add -b feature/new ../wt main"), - Some("feature/new".to_string()) - ); } #[test] @@ -1096,6 +1084,13 @@ fn test_cursor_shell_sync_plan_routes_worktree_add_to_worktree_branch_add() { worktree_path: "../wt".to_string(), } ); + assert_eq!( + cursor_shell_sync_plan("git worktree add -b newbranch ../wt"), + CursorShellSyncPlan::WorktreeBranchAdd { + branch: "newbranch".to_string(), + worktree_path: "../wt".to_string(), + } + ); assert_eq!( cursor_shell_sync_plan("git worktree add -b feature/new ../wt main"), CursorShellSyncPlan::WorktreeBranchAdd { @@ -1105,6 +1100,18 @@ fn test_cursor_shell_sync_plan_routes_worktree_add_to_worktree_branch_add() { ); } +#[test] +fn test_cursor_shell_sync_plan_ignores_branchless_and_detached_worktree_adds() { + assert_eq!( + cursor_shell_sync_plan("git worktree add ../wt"), + CursorShellSyncPlan::Noop + ); + assert_eq!( + cursor_shell_sync_plan("git worktree add --detach ../wt main"), + CursorShellSyncPlan::Noop + ); +} + #[test] fn test_cursor_branch_switch_target_ignores_path_checkouts_and_non_switches() { assert_eq!( @@ -1115,11 +1122,6 @@ fn test_cursor_branch_switch_target_ignores_path_checkouts_and_non_switches() { assert_eq!(cursor_branch_switch_target("git checkout README.md"), None); assert_eq!(cursor_branch_switch_target("git pull --rebase"), None); assert_eq!(cursor_branch_switch_target("git merge origin/main"), None); - assert_eq!(cursor_branch_switch_target("git worktree add ../wt"), None); - assert_eq!( - cursor_branch_switch_target("git worktree add --detach ../wt main"), - None - ); assert_eq!(cursor_branch_switch_target("git status"), None); assert_eq!(cursor_branch_switch_target("echo git checkout main"), None); } From 78c570f0f4496ce1714b8de0db88b409dcfe253d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 1 Jul 2026 22:43:38 +0000 Subject: [PATCH 4/5] fix: keep codex automation install safe by default `install --agent codex --automation` no longer writes permissive require_dashboard_approval/auto_apply_memory_ops values; both stay unset so AutomationConfig defaults apply and re-runs never weaken settings a user chose. Unattended memory-op apply is now an explicit `--auto-apply` opt-in with a printed warning. The installer also removes the legacy Codex-native scheduled automation shipped in v0.0.10-v0.0.20 (which would otherwise run alongside the daemon loop), warns when no daemon service is running to execute the loop, announces implicit project init, and reuses the canonical load->merge->validate->save automation-config pipeline instead of duplicating it. --- src/agent_cmd.rs | 102 ++++++++++++++-------- src/agents/codex.rs | 54 ++++++++++++ src/automation/config.rs | 15 ++++ src/automation_cli.rs | 138 +++++++++++++----------------- src/cli.rs | 7 +- src/daemon.rs | 15 ++++ src/main.rs | 3 +- src/startup_tests.rs | 4 + tests/cli_non_interactive_test.rs | 126 ++++++++++++++++++++------- 9 files changed, 314 insertions(+), 150 deletions(-) diff --git a/src/agent_cmd.rs b/src/agent_cmd.rs index 298c378a..52c03491 100644 --- a/src/agent_cmd.rs +++ b/src/agent_cmd.rs @@ -1,6 +1,11 @@ use std::io; use std::path::{Path, PathBuf}; +use tracedecay::automation::config::{ + apply_project_config_patch, project_config_path, AutomationBackend, AutomationConfigPatch, + AutomationHostMode, AutomationTaskPatch, +}; + pub(crate) fn hermes_profile_targets( home: &Path, ) -> tracedecay::errors::Result>> { @@ -93,11 +98,19 @@ pub(crate) fn validate_hermes_project_root_flag( Ok(Some(path)) } +/// How `install --agent codex --automation` should configure the daemon loop. +#[derive(Debug, Clone, Copy)] +pub(crate) struct CodexAutomationInstall { + /// Apply accepted memory-curation ops without dashboard approval + /// (`--auto-apply`). + pub(crate) auto_apply: bool, +} + fn validate_codex_automation_flags( agent: Option<&str>, - automation: bool, + automation: Option, ) -> tracedecay::errors::Result<()> { - if !automation { + if automation.is_none() { return Ok(()); } if agent != Some("codex") { @@ -123,39 +136,40 @@ fn validate_codex_automation_project_path() -> tracedecay::errors::Result tracedecay::errors::Result { - use tracedecay::automation::config::{ - effective_config, load_project_config, merge_project_config, project_config_path, - save_project_config, AutomationBackend, AutomationConfigPatch, AutomationHostMode, - AutomationTaskPatch, - }; + let auto_apply = options.auto_apply; + if tracedecay::agents::codex::remove_legacy_codex_native_automation(home)? { + eprintln!( + "\x1b[32m✔\x1b[0m Removed the legacy Codex-native scheduled automation; the TraceDecay daemon loop replaces it." + ); + } let cg = open_or_init_codex_daemon_automation_project(project_path).await?; let dashboard_root = cg.store_layout().dashboard_root.clone(); - let existing = load_project_config(&dashboard_root).await?; - let updated = merge_project_config( - existing, - AutomationConfigPatch { - enabled: Some(true), - backend: Some(AutomationBackend::CodexAppServer), - host_mode: Some(AutomationHostMode::Standalone), - model: Some(Some("gpt-5.5".to_string())), - require_dashboard_approval: Some(false), - auto_apply_memory_ops: Some(true), - auto_enable_skills: Some(false), - memory_curator: codex_daemon_interval_task(15 * 60), - session_reflector: codex_daemon_interval_task(15 * 60), - skill_writer: AutomationTaskPatch { - min_idle_secs: Some(Some(15 * 60)), - ..codex_daemon_interval_task(60 * 60) - }, - ..AutomationConfigPatch::default() + let patch = AutomationConfigPatch { + enabled: Some(true), + backend: Some(AutomationBackend::CodexAppServer), + host_mode: Some(AutomationHostMode::Standalone), + model: Some(Some("gpt-5.5".to_string())), + // Unattended memory-op apply is opt-in: without --auto-apply these + // stay unset so AutomationConfig::default() keeps dashboard approval + // required, and re-running the installer never weakens stricter + // settings a user already chose. + require_dashboard_approval: auto_apply.then_some(false), + auto_apply_memory_ops: auto_apply.then_some(true), + memory_curator: codex_daemon_interval_task(15 * 60), + session_reflector: codex_daemon_interval_task(15 * 60), + skill_writer: AutomationTaskPatch { + min_idle_secs: Some(Some(15 * 60)), + ..codex_daemon_interval_task(60 * 60) }, - ); + ..AutomationConfigPatch::default() + }; let global = tracedecay::user_config::UserConfig::load().automation; - effective_config(&global, Some(&updated))?; - save_project_config(&dashboard_root, &updated).await?; + apply_project_config_patch(&dashboard_root, &global, patch).await?; let path = project_config_path(&dashboard_root); eprintln!( "\x1b[32m✔\x1b[0m Enabled TraceDecay daemon automation loop at {}", @@ -164,6 +178,16 @@ async fn install_codex_daemon_automation( eprintln!( " The daemon scheduler will run memory_curator, session_reflector, and skill_writer via the Codex app-server backend." ); + if auto_apply { + eprintln!( + "\x1b[33m⚠\x1b[0m --auto-apply: accepted memory-curation ops (deletes and merges) will be applied without dashboard approval. There is no archive; removals are permanent." + ); + } + if !tracedecay::daemon::daemon_reachable() { + eprintln!( + "\x1b[33m⚠\x1b[0m The TraceDecay daemon is not running, so the automation loop will stay idle. Enable it with `tracedecay daemon install-service`." + ); + } Ok(path) } @@ -173,19 +197,21 @@ async fn open_or_init_codex_daemon_automation_project( if tracedecay::tracedecay::TraceDecay::has_initialized_store(project_path).await { tracedecay::tracedecay::TraceDecay::open(project_path).await } else { + eprintln!( + "No TraceDecay store found for {}; initializing one (equivalent to `tracedecay init`).", + project_path.display() + ); tracedecay::tracedecay::TraceDecay::init(project_path).await } } -fn codex_daemon_interval_task( - interval_secs: u64, -) -> tracedecay::automation::config::AutomationTaskPatch { - tracedecay::automation::config::AutomationTaskPatch { +fn codex_daemon_interval_task(interval_secs: u64) -> AutomationTaskPatch { + AutomationTaskPatch { enabled: Some(true), schedule: Some(Some("interval".to_string())), interval_secs: Some(Some(interval_secs)), cooldown_secs: Some(Some(5 * 60)), - ..tracedecay::automation::config::AutomationTaskPatch::default() + ..AutomationTaskPatch::default() } } @@ -208,7 +234,7 @@ pub(crate) async fn handle_install_command( all_profiles: bool, project_root: Option, no_dashboard: bool, - automation: bool, + automation: Option, ) -> tracedecay::errors::Result<()> { validate_hermes_profile_flags(agent.as_deref(), &profile, all_profiles)?; let pinned_project_root = validate_hermes_project_root_flag(agent.as_deref(), &project_root)?; @@ -254,9 +280,9 @@ pub(crate) async fn handle_install_command( }; ag.install_local(&ctx, &project_path)?; ag.post_install(Some(&project_path)).await; - if automation && id == "codex" { + if let Some(options) = automation.filter(|_| id == "codex") { let scoped_project_path = validate_codex_automation_project_path()?; - install_codex_daemon_automation(&scoped_project_path).await?; + install_codex_daemon_automation(&scoped_project_path, &home, options).await?; } } installed_names.push(ag.name().to_string()); @@ -309,9 +335,9 @@ pub(crate) async fn handle_install_command( }; ag.install(&ctx)?; ag.post_install(project_path.as_deref()).await; - if automation && id == "codex" { + if let Some(options) = automation.filter(|_| id == "codex") { let scoped_project_path = validate_codex_automation_project_path()?; - install_codex_daemon_automation(&scoped_project_path).await?; + install_codex_daemon_automation(&scoped_project_path, &home, options).await?; } } if !user_cfg.installed_agents.contains(&id) { diff --git a/src/agents/codex.rs b/src/agents/codex.rs index cad4f888..6b432a14 100644 --- a/src/agents/codex.rs +++ b/src/agents/codex.rs @@ -446,6 +446,29 @@ fn sweep_legacy_project_codex_config(project_path: &Path) { uninstall_hooks(&codex_dir.join("hooks.json")); } +/// Directory of the Codex-native scheduled automation that tracedecay +/// v0.0.10 through v0.0.20 installed with `install --agent codex --automation`. +const LEGACY_CODEX_NATIVE_AUTOMATION_ID: &str = "watch-tracedecay-memory"; + +/// Removes the legacy Codex-native scheduled automation, returning whether one +/// was present. The `TraceDecay` daemon scheduler replaced it; leaving the +/// record in place would run both schedulers concurrently after an upgrade. +pub fn remove_legacy_codex_native_automation(home: &Path) -> Result { + let automation_dir = home + .join(".codex/automations") + .join(LEGACY_CODEX_NATIVE_AUTOMATION_ID); + match std::fs::remove_dir_all(&automation_dir) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(TraceDecayError::Config { + message: format!( + "failed to remove legacy Codex automation {}: {e}", + automation_dir.display() + ), + }), + } +} + fn uninstall_tracedecay_mcp_if_present(config_path: &Path) { let Ok(contents) = std::fs::read_to_string(config_path) else { return; @@ -1378,6 +1401,37 @@ mod tests { Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf() } + #[test] + fn remove_legacy_codex_native_automation_deletes_stale_record() { + let home = tempfile::tempdir().expect("tempdir should create"); + assert!( + !remove_legacy_codex_native_automation(home.path()) + .expect("removal without a record should succeed"), + "no legacy record should report nothing removed" + ); + + let automation_dir = home + .path() + .join(".codex/automations") + .join(LEGACY_CODEX_NATIVE_AUTOMATION_ID); + std::fs::create_dir_all(&automation_dir).expect("legacy dir should create"); + std::fs::write( + automation_dir.join("automation.toml"), + "status = \"ACTIVE\"\n", + ) + .expect("legacy automation should write"); + + assert!( + remove_legacy_codex_native_automation(home.path()) + .expect("removal of an existing record should succeed"), + "an existing legacy record should report removal" + ); + assert!( + !automation_dir.exists(), + "the legacy automation directory should be gone" + ); + } + fn relative_paths_under(root: &Path) -> Vec { let mut paths: Vec = collect_regular_files(root) .expect("plugin source bundle should be readable") diff --git a/src/automation/config.rs b/src/automation/config.rs index 16c3bf04..f878e8d2 100644 --- a/src/automation/config.rs +++ b/src/automation/config.rs @@ -253,6 +253,21 @@ pub fn merge_project_config( merged } +/// Canonical load -> merge -> validate -> save pipeline for the project +/// automation sidecar. Returns the merged project patch and the validated +/// effective config; nothing is saved when validation fails. +pub async fn apply_project_config_patch( + dashboard_root: &Path, + global: &AutomationConfig, + patch: AutomationConfigPatch, +) -> Result<(AutomationConfigPatch, AutomationConfig)> { + let current = load_project_config(dashboard_root).await?; + let project = merge_project_config(current, patch); + let effective = effective_config(global, Some(&project))?; + save_project_config(dashboard_root, &project).await?; + Ok((project, effective)) +} + pub fn validate_config(config: &AutomationConfig) -> Result<()> { if config.timeout_secs == 0 { return config_error("automation timeout_secs must be greater than zero"); diff --git a/src/automation_cli.rs b/src/automation_cli.rs index 539ad049..b2ea0625 100644 --- a/src/automation_cli.rs +++ b/src/automation_cli.rs @@ -567,8 +567,8 @@ async fn handle_automation_config_command( action: AutomationConfigAction, ) -> tracedecay::errors::Result<()> { use tracedecay::automation::config::{ - effective_config, load_project_config, merge_project_config, save_project_config, - AutomationBackend, AutomationConfigPatch, + apply_project_config_patch, effective_config, load_project_config, AutomationBackend, + AutomationConfigPatch, }; let path = match &action { @@ -599,7 +599,7 @@ async fn handle_automation_config_command( None }; - let updated = match action { + let patch = match action { AutomationConfigAction::Get { json, .. } => { let project = project_context .as_ref() @@ -616,25 +616,15 @@ async fn handle_automation_config_command( print_automation_config(&global, project, &effective, json, true)?; return Ok(()); } - AutomationConfigAction::Enable { .. } => merge_project_config( - project_context - .as_ref() - .and_then(|(_, project)| project.clone()), - AutomationConfigPatch { - enabled: Some(true), - backend: Some(AutomationBackend::CodexAppServer), - ..AutomationConfigPatch::default() - }, - ), - AutomationConfigAction::Disable { .. } => merge_project_config( - project_context - .as_ref() - .and_then(|(_, project)| project.clone()), - AutomationConfigPatch { - enabled: Some(false), - ..AutomationConfigPatch::default() - }, - ), + AutomationConfigAction::Enable { .. } => AutomationConfigPatch { + enabled: Some(true), + backend: Some(AutomationBackend::CodexAppServer), + ..AutomationConfigPatch::default() + }, + AutomationConfigAction::Disable { .. } => AutomationConfigPatch { + enabled: Some(false), + ..AutomationConfigPatch::default() + }, AutomationConfigAction::Set { backend, host_mode, @@ -665,61 +655,56 @@ async fn handle_automation_config_command( skill_writer_min_idle_secs, skill_writer_stale_lock_secs, .. - } => merge_project_config( - project_context - .as_ref() - .and_then(|(_, project)| project.clone()), - AutomationConfigPatch { - backend: backend - .as_deref() - .map(parse_automation_backend) - .transpose()?, - host_mode: host_mode - .as_deref() - .map(parse_automation_host_mode) - .transpose()?, - model: model.map(empty_string_or_none_clears), - timeout_secs, - scheduler_tick_secs, - max_tokens: parse_optional_u32(max_tokens, "max_tokens")?, - temperature: parse_optional_f32(temperature, "temperature")?, - require_dashboard_approval, - auto_apply_memory_ops, - auto_enable_skills, - memory_curator: automation_task_patch( - memory_curator, - memory_curator_schedule, - memory_curator_interval_secs, - memory_curator_cooldown_secs, - memory_curator_min_idle_secs, - memory_curator_stale_lock_secs, - "memory_curator", - )?, - session_reflector: automation_task_patch( - session_reflector, - session_reflector_schedule, - session_reflector_interval_secs, - session_reflector_cooldown_secs, - session_reflector_min_idle_secs, - session_reflector_stale_lock_secs, - "session_reflector", - )?, - skill_writer: automation_task_patch( - skill_writer, - skill_writer_schedule, - skill_writer_interval_secs, - skill_writer_cooldown_secs, - skill_writer_min_idle_secs, - skill_writer_stale_lock_secs, - "skill_writer", - )?, - ..AutomationConfigPatch::default() - }, - ), + } => AutomationConfigPatch { + backend: backend + .as_deref() + .map(parse_automation_backend) + .transpose()?, + host_mode: host_mode + .as_deref() + .map(parse_automation_host_mode) + .transpose()?, + model: model.map(empty_string_or_none_clears), + timeout_secs, + scheduler_tick_secs, + max_tokens: parse_optional_u32(max_tokens, "max_tokens")?, + temperature: parse_optional_f32(temperature, "temperature")?, + require_dashboard_approval, + auto_apply_memory_ops, + auto_enable_skills, + memory_curator: automation_task_patch( + memory_curator, + memory_curator_schedule, + memory_curator_interval_secs, + memory_curator_cooldown_secs, + memory_curator_min_idle_secs, + memory_curator_stale_lock_secs, + "memory_curator", + )?, + session_reflector: automation_task_patch( + session_reflector, + session_reflector_schedule, + session_reflector_interval_secs, + session_reflector_cooldown_secs, + session_reflector_min_idle_secs, + session_reflector_stale_lock_secs, + "session_reflector", + )?, + skill_writer: automation_task_patch( + skill_writer, + skill_writer_schedule, + skill_writer_interval_secs, + skill_writer_cooldown_secs, + skill_writer_min_idle_secs, + skill_writer_stale_lock_secs, + "skill_writer", + )?, + ..AutomationConfigPatch::default() + }, }; if scope == AutomationConfigScope::Global { - let effective = effective_config(&global, Some(&updated))?; + let effective = effective_config(&global, Some(&patch))?; user_config.automation = effective.clone(); if !user_config.save() { return Err(tracedecay::errors::TraceDecayError::Config { @@ -730,9 +715,8 @@ async fn handle_automation_config_command( } let (dashboard_root, _) = project_context.expect("project scope has project context"); - let effective = effective_config(&global, Some(&updated))?; - save_project_config(&dashboard_root, &updated).await?; - print_automation_config(&global, Some(&updated), &effective, true, false) + let (project, effective) = apply_project_config_patch(&dashboard_root, &global, patch).await?; + print_automation_config(&global, Some(&project), &effective, true, false) } fn automation_task_patch( diff --git a/src/cli.rs b/src/cli.rs index e5c99d9b..36b2a899 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -133,10 +133,15 @@ pub enum Commands { /// used with --agent hermes). #[arg(long)] no_dashboard: bool, - /// Install/update a Codex-native project automation in ~/.codex + /// Enable the TraceDecay daemon automation loop (memory curator, + /// session reflector, skill writer) for the current project /// (only used with --agent codex). #[arg(long)] automation: bool, + /// With --automation: opt in to applying accepted memory-curation ops + /// (permanent deletes/merges) without dashboard approval. + #[arg(long, requires = "automation")] + auto_apply: bool, }, /// Refresh settings for all already-installed agents Reinstall, diff --git a/src/daemon.rs b/src/daemon.rs index 9d31bdc3..d7048036 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -578,6 +578,21 @@ pub fn service_status(socket_path: &Path) -> String { ) } +/// Whether a daemon is accepting connections at the default socket path. +/// +/// Installers use this to warn when a daemon-scheduled feature is enabled but +/// no daemon service is running to execute it. +#[cfg(unix)] +pub fn daemon_reachable() -> bool { + default_socket_path().is_ok_and(|path| StdUnixStream::connect(path).is_ok()) +} + +/// The daemon (and its scheduler) is unix-only; see [`run_foreground`]. +#[cfg(not(unix))] +pub fn daemon_reachable() -> bool { + false +} + #[cfg(unix)] fn daemon_socket_state(socket_path: &Path) -> &'static str { if !socket_path.exists() { diff --git a/src/main.rs b/src/main.rs index 9761f941..2b30e007 100644 --- a/src/main.rs +++ b/src/main.rs @@ -605,6 +605,7 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { project_root, no_dashboard, automation, + auto_apply, } => { agent_cmd::handle_install_command( agent, @@ -613,7 +614,7 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { all_profiles, project_root, no_dashboard, - automation, + automation.then_some(agent_cmd::CodexAutomationInstall { auto_apply }), ) .await?; } diff --git a/src/startup_tests.rs b/src/startup_tests.rs index 278ae90b..06bc8e36 100644 --- a/src/startup_tests.rs +++ b/src/startup_tests.rs @@ -27,6 +27,7 @@ fn explicit_agent_config_commands_skip_startup_maintenance() { project_root: None, no_dashboard: false, automation: false, + auto_apply: false, })); assert!(should_skip_startup_maintenance(&Commands::Reinstall)); assert!(should_skip_startup_maintenance(&Commands::UpdatePlugin)); @@ -69,6 +70,7 @@ fn agent_install_maintenance_is_selective() { project_root: None, no_dashboard: false, automation: false, + auto_apply: false, })); assert!(should_skip_agent_install_maintenance(&Commands::Reinstall)); // `update-plugin` promises byte-identical configs; the implicit @@ -198,6 +200,7 @@ fn local_install_detection_tracks_dispatch_preamble_behavior() { project_root: None, no_dashboard: false, automation: false, + auto_apply: false, }; let global = Commands::Install { agent: Some("hermes".to_string()), @@ -207,6 +210,7 @@ fn local_install_detection_tracks_dispatch_preamble_behavior() { project_root: None, no_dashboard: false, automation: false, + auto_apply: false, }; assert!(is_local_install_command(&local)); diff --git a/tests/cli_non_interactive_test.rs b/tests/cli_non_interactive_test.rs index 21711268..fbb31d04 100644 --- a/tests/cli_non_interactive_test.rs +++ b/tests/cli_non_interactive_test.rs @@ -357,17 +357,15 @@ fn init_skips_gitignore_prompt_when_stdin_not_a_terminal() { ); } -#[test] -fn install_codex_automation_enables_tracedecay_daemon_loop_noninteractively() { - let home = TempDir::new().unwrap(); - let project = TempDir::new().unwrap(); - let project_root = canonical_temp_path(project.path()); - std::fs::create_dir_all(project_root.join("src")).unwrap(); - std::fs::write(project_root.join("src/lib.rs"), "pub fn marker() {}\n").unwrap(); - - let mut install = tracedecay_command(home.path(), &project_root); +fn run_codex_automation_install( + home: &TempDir, + project_root: &Path, + extra_args: &[&str], +) -> Output { + let mut install = tracedecay_command(home.path(), project_root); let _shim = add_tracedecay_path_shim(&mut install, home.path()); install.args(["install", "--agent", "codex", "--automation"]); + install.args(extra_args); let output = run_with_timeout(install, cli_timeout()); assert!( output.status.success(), @@ -375,25 +373,10 @@ fn install_codex_automation_enables_tracedecay_daemon_loop_noninteractively() { String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); + output +} - assert!( - home.path() - .join("plugins/tracedecay/.codex-plugin/plugin.json") - .is_file(), - "install --agent codex should still install the Codex plugin bundle" - ); - assert!( - !home - .path() - .join(".codex/automations/watch-tracedecay-memory/automation.toml") - .exists(), - "Codex automation install must not create native Codex scheduled chats" - ); - assert!( - !project_root.join(".codex/automations").exists(), - "Codex automation install must not create repo-local Codex automation files" - ); - +fn read_codex_automation_sidecar(home: &TempDir) -> serde_json::Value { let projects_dir = profile_root(home.path()).join("projects"); let sidecars = std::fs::read_dir(&projects_dir) .unwrap() @@ -410,18 +393,69 @@ fn install_codex_automation_enables_tracedecay_daemon_loop_noninteractively() { 1, "codex automation install should write one TraceDecay project scheduler sidecar" ); - - let sidecar: serde_json::Value = serde_json::from_slice( + serde_json::from_slice( &std::fs::read(&sidecars[0]).expect("automation sidecar should be readable"), ) - .expect("automation sidecar should be valid JSON"); + .expect("automation sidecar should be valid JSON") +} + +#[test] +fn install_codex_automation_enables_tracedecay_daemon_loop_noninteractively() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let project_root = canonical_temp_path(project.path()); + std::fs::create_dir_all(project_root.join("src")).unwrap(); + std::fs::write(project_root.join("src/lib.rs"), "pub fn marker() {}\n").unwrap(); + + let legacy_automation_dir = home + .path() + .join(".codex/automations/watch-tracedecay-memory"); + std::fs::create_dir_all(&legacy_automation_dir).unwrap(); + std::fs::write( + legacy_automation_dir.join("automation.toml"), + "status = \"ACTIVE\"\n", + ) + .unwrap(); + + let output = run_codex_automation_install(&home, &project_root, &[]); + + assert!( + home.path() + .join("plugins/tracedecay/.codex-plugin/plugin.json") + .is_file(), + "install --agent codex should still install the Codex plugin bundle" + ); + assert!( + !legacy_automation_dir.exists(), + "Codex automation install must remove the legacy native scheduled automation" + ); + assert!( + !project_root.join(".codex/automations").exists(), + "Codex automation install must not create repo-local Codex automation files" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("daemon is not running"), + "install should warn that the daemon service is missing\nstderr:\n{stderr}" + ); + + let sidecar = read_codex_automation_sidecar(&home); assert_eq!(sidecar["enabled"], true); assert_eq!(sidecar["backend"], "codex_app_server"); assert_eq!(sidecar["host_mode"], "standalone"); assert_eq!(sidecar["model"], "gpt-5.5"); - assert_eq!(sidecar["require_dashboard_approval"], false); - assert_eq!(sidecar["auto_apply_memory_ops"], true); - assert_eq!(sidecar["auto_enable_skills"], false); + assert!( + sidecar.get("require_dashboard_approval").is_none(), + "install must not weaken the approval default: {sidecar}" + ); + assert!( + sidecar.get("auto_apply_memory_ops").is_none(), + "install must not enable unattended memory ops by default: {sidecar}" + ); + assert!( + sidecar.get("auto_enable_skills").is_none(), + "install must leave skill auto-enablement at its default: {sidecar}" + ); assert_eq!(sidecar["memory_curator"]["enabled"], true); assert_eq!(sidecar["memory_curator"]["schedule"], "interval"); assert_eq!(sidecar["memory_curator"]["interval_secs"], 900); @@ -432,6 +466,32 @@ fn install_codex_automation_enables_tracedecay_daemon_loop_noninteractively() { assert_eq!(sidecar["skill_writer"]["min_idle_secs"], 900); } +#[test] +fn install_codex_automation_auto_apply_flag_opts_into_unattended_memory_ops() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let project_root = canonical_temp_path(project.path()); + std::fs::create_dir_all(project_root.join("src")).unwrap(); + std::fs::write(project_root.join("src/lib.rs"), "pub fn marker() {}\n").unwrap(); + + let output = run_codex_automation_install(&home, &project_root, &["--auto-apply"]); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("without dashboard approval"), + "opting into --auto-apply should print an explicit warning\nstderr:\n{stderr}" + ); + + let sidecar = read_codex_automation_sidecar(&home); + assert_eq!(sidecar["enabled"], true); + assert_eq!(sidecar["require_dashboard_approval"], false); + assert_eq!(sidecar["auto_apply_memory_ops"], true); + assert!( + sidecar.get("auto_enable_skills").is_none(), + "--auto-apply must not touch skill auto-enablement: {sidecar}" + ); +} + #[test] fn automation_config_enable_writes_project_sidecar_noninteractively() { let home = TempDir::new().unwrap(); From da76f3ff92be1eda8cd2c8673ae2cb58b7d6fde7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 1 Jul 2026 22:30:49 +0000 Subject: [PATCH 5/5] fix: satisfy clippy lazy-evaluation lint in session reflector report --- src/automation/runner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/automation/runner.rs b/src/automation/runner.rs index af62bbdc..2b818c44 100644 --- a/src/automation/runner.rs +++ b/src/automation/runner.rs @@ -369,7 +369,7 @@ pub async fn run_session_reflector_with_backend( record.rejected_ops = report.get("rejected_facts").cloned(); record.validation_report = Some(json!({ "status": report.get("status").cloned().unwrap_or_else(|| json!("needs_approval")), - "dry_run": report.get("dry_run").cloned().unwrap_or_else(|| json!(true)), + "dry_run": report.get("dry_run").cloned().unwrap_or(json!(true)), "accepted_count": accepted_count, "rejected_count": rejected_count, "session_fact_apply_policy": report.get("session_fact_apply_policy").cloned().unwrap_or_else(|| json!({})),