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/agent_cmd.rs b/src/agent_cmd.rs index fa6b558f..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") { @@ -121,6 +134,87 @@ fn validate_codex_automation_project_path() -> tracedecay::errors::Result tracedecay::errors::Result { + 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 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; + 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 {}", + path.display() + ); + 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) +} + +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 { + 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) -> AutomationTaskPatch { + AutomationTaskPatch { + enabled: Some(true), + schedule: Some(Some("interval".to_string())), + interval_secs: Some(Some(interval_secs)), + cooldown_secs: Some(Some(5 * 60)), + ..AutomationTaskPatch::default() + } +} + pub(crate) fn hermes_selected_profile_targets( home: &Path, profile: &Option, @@ -140,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)?; @@ -186,12 +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()?; - tracedecay::agents::codex::install_codex_native_automation( - &home, - &scoped_project_path, - )?; + install_codex_daemon_automation(&scoped_project_path, &home, options).await?; } } installed_names.push(ag.name().to_string()); @@ -244,12 +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()?; - tracedecay::agents::codex::install_codex_native_automation( - &home, - &scoped_project_path, - )?; + 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 070a0b71..6b432a14 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() { @@ -598,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; @@ -1530,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") @@ -1690,92 +1592,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/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/runner.rs b/src/automation/runner.rs index 1ed540a3..2b818c44 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(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/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 d64f0b70..d7048036 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, @@ -571,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/hooks.rs b/src/hooks.rs index 0b16c498..0ea3dc01 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(parts) = cursor_worktree_add_parts_from_tokens(&raw) { + return CursorShellSyncPlan::WorktreeBranchAdd { + branch: parts.branch, + worktree_path: parts.worktree_path, + }; + } + if let Some(branch) = cursor_branch_switch_target_from_tokens(&raw) { return CursorShellSyncPlan::BranchAdd(branch); } if is_git_state_changing_command(command) { @@ -997,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 @@ -1005,7 +1018,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() { @@ -1040,21 +1057,30 @@ pub fn cursor_branch_switch_target(command: &str) -> 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 { +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") + { + return cursor_worktree_add_parts(&raw[sub_pos + 2..]); + } + 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 +1088,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 +1111,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 { @@ -1242,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/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/mcp/hook_events.rs b/src/mcp/hook_events.rs index 74bfb3af..7561489f 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: crate::hooks::resolve_worktree_add_root(command, cwd, &worktree_path), + branch, + }, crate::hooks::CursorShellSyncPlan::IncrementalSync => { HookEventPlan::DebouncedIncrementalSync(event.agent) } @@ -299,6 +307,58 @@ 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_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 f57e4ee1..9c57920e 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -1405,6 +1405,20 @@ impl McpServer { Err(e) => eprintln!("[tracedecay] hook branch tracking failed: {e}"), } } + 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 + | 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/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/automation_session_reflector_runner_test.rs b/tests/automation_session_reflector_runner_test.rs index bad2ae0f..027cb6f2 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(); diff --git a/tests/cli_non_interactive_test.rs b/tests/cli_non_interactive_test.rs index b6b341d9..fbb31d04 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") } @@ -372,15 +357,15 @@ fn init_skips_gitignore_prompt_when_stdin_not_a_terminal() { ); } -#[test] -fn install_codex_automation_writes_global_project_record_noninteractively() { - let home = TempDir::new().unwrap(); - let project = TempDir::new().unwrap(); - let project_root = canonical_temp_path(project.path()); - - 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(), @@ -388,6 +373,51 @@ fn install_codex_automation_writes_global_project_record_noninteractively() { String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); + output +} + +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() + .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" + ); + serde_json::from_slice( + &std::fs::read(&sidecars[0]).expect("automation sidecar should be readable"), + ) + .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() @@ -395,27 +425,70 @@ 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!( + !legacy_automation_dir.exists(), + "Codex automation install must remove the legacy native scheduled automation" ); 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 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!( + 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); + 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] +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}" ); } diff --git a/tests/hooks_test.rs b/tests/hooks_test.rs index 44d81f03..7ff436e3 100644 --- a/tests/hooks_test.rs +++ b/tests/hooks_test.rs @@ -1073,17 +1073,42 @@ fn test_cursor_branch_switch_target_extracts_branch() { cursor_branch_switch_target("git switch -c feature/y"), Some("feature/y".to_string()) ); +} + +#[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 newbranch ../wt"), + CursorShellSyncPlan::WorktreeBranchAdd { + branch: "newbranch".to_string(), + worktree_path: "../wt".to_string(), + } + ); assert_eq!( - cursor_branch_switch_target("git worktree add ../wt feature/z"), - Some("feature/z".to_string()) + 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_shell_sync_plan_ignores_branchless_and_detached_worktree_adds() { assert_eq!( - cursor_branch_switch_target("git worktree add -b newbranch ../wt"), - Some("newbranch".to_string()) + cursor_shell_sync_plan("git worktree add ../wt"), + CursorShellSyncPlan::Noop ); assert_eq!( - cursor_branch_switch_target("git worktree add -b feature/new ../wt main"), - Some("feature/new".to_string()) + cursor_shell_sync_plan("git worktree add --detach ../wt main"), + CursorShellSyncPlan::Noop ); } @@ -1097,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); } @@ -1116,14 +1136,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]