diff --git a/README.md b/README.md index 40645cc5..9c7aec8b 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ tracedecay install --agent antigravity # Google Antigravity (formerly Windsu tracedecay install --agent claude # Claude Code tracedecay install --agent cline # Cline tracedecay install --agent codex # OpenAI Codex CLI +tracedecay install --agent codex --automation tracedecay install --agent copilot # GitHub Copilot tracedecay install --agent cursor # Cursor tracedecay install --agent gemini # Gemini CLI @@ -151,7 +152,7 @@ For project-scoped setup, run from the repository root: tracedecay install --local --agent cursor ``` -Local install writes only workspace files such as `.mcp.json`, `.codex/config.toml`, `.vscode/mcp.json`, `.hermes/plugins/tracedecay/`, or the equivalent project config for Claude, Codex, Gemini, Hermes, Kiro, OpenCode, Copilot/VS Code, Zed, Roo Code, Kimi, Kilo, and Vibe. Generated MCP configs and plugin wrappers use the resolved absolute `tracedecay` executable path. Hermes installs into `~/.hermes/plugins/tracedecay/` by default, or into `~/.hermes/profiles//plugins/tracedecay/` with `--profile `; profile names are normalized to lowercase and must match `[a-z0-9][a-z0-9_-]{0,63}`. The generated context engine resolves its project from explicit host kwargs first, then a `project_root` key on the Hermes config object, then the install-time pin, and finally the session cwd. Pin a profile to one project with `tracedecay install --agent hermes [--profile ] --project-root /abs/path` — the pin is written into the generated plugin, applies to every plugin tool call regardless of the Hermes working directory, and survives later unpinned reinstalls. Use `tracedecay uninstall --agent hermes --profile ` to remove a named profile install; `reinstall` and `doctor --agent hermes` currently operate on the default profile. To refresh generated plugin code after an upgrade without any config writes, run `tracedecay update-plugin` (alias `update-plugins`): it rewrites only tracedecay-generated artifacts — the Hermes plugin files and dashboard page for every detected profile (pin re-read from `config.yaml`, never written), the Cursor plugin bundle, the Codex plugin bundle, and the Kiro managed agent — and leaves `config.yaml`, `config.toml`, `mcp.json`, settings, hooks, and prompt rules byte-for-byte intact. Config-managed integrations (Claude, Gemini, …) have no generated artifacts and are reported as untouched; `tracedecay reinstall` remains the command that reconciles their config entries. Hermes wrappers run from Hermes' current working directory, use a 600-second timeout, and include truncated stdout/stderr in error JSON. Hermes local install without `--profile` writes only project plugin files and `.hermes/config.yaml`; `tracedecay install --local --agent hermes --profile ` is a deliberate mixed-scope mode that targets the named profile instead. Project-local Hermes plugins are loaded by launching Hermes with `HERMES_HOME=/.hermes`. For Codex, global install writes a Codex plugin source bundle to `~/plugins/tracedecay`, registers it in `~/.agents/plugins/marketplace.json`, and prints `codex plugin add tracedecay@personal`; hooks and AGENTS.md stay config-managed because current Codex plugin manifests accept MCP and skills but not hook declarations. For Cursor, both global and `--local` install put the plugin in `~/.cursor/plugins/local/tracedecay` and require a Cursor reload. The plugin MCP config runs `tracedecay serve --path ${workspaceFolder}`, so it resolves the active workspace instead of the plugin directory and uses that workspace's active project store rather than the legacy global Cursor MCP registration. Cursor install no longer writes `.cursor/mcp.json`, `.cursor/hooks.json`, `.cursor/rules/tracedecay.mdc` (legacy artifact name), or `.cursor/permissions.json`; approvals are left to Cursor approval/run-mode behavior. The plugin hooks are: +Local install writes only workspace files such as `.mcp.json`, `.codex/config.toml`, `.vscode/mcp.json`, `.hermes/plugins/tracedecay/`, or the equivalent project config for Claude, Codex, Gemini, Hermes, Kiro, OpenCode, Copilot/VS Code, Zed, Roo Code, Kimi, Kilo, and Vibe. Generated MCP configs and plugin wrappers use the resolved absolute `tracedecay` executable path. Hermes installs into `~/.hermes/plugins/tracedecay/` by default, or into `~/.hermes/profiles//plugins/tracedecay/` with `--profile `; profile names are normalized to lowercase and must match `[a-z0-9][a-z0-9_-]{0,63}`. The generated context engine resolves its project from explicit host kwargs first, then a `project_root` key on the Hermes config object, then the install-time pin, and finally the session cwd. Pin a profile to one project with `tracedecay install --agent hermes [--profile ] --project-root /abs/path` — the pin is written into the generated plugin, applies to every plugin tool call regardless of the Hermes working directory, and survives later unpinned reinstalls. Use `tracedecay uninstall --agent hermes --profile ` to remove a named profile install; `reinstall` and `doctor --agent hermes` currently operate on the default profile. To refresh generated plugin code after an upgrade without any config writes, run `tracedecay update-plugin` (alias `update-plugins`): it rewrites only tracedecay-generated artifacts — the Hermes plugin files and dashboard page for every detected profile (pin re-read from `config.yaml`, never written), the Cursor plugin bundle, the Codex plugin bundle, and the Kiro managed agent — and leaves `config.yaml`, `config.toml`, `mcp.json`, settings, hooks, and prompt rules byte-for-byte intact. Config-managed integrations (Claude, Gemini, …) have no generated artifacts and are reported as untouched; `tracedecay reinstall` remains the command that reconciles their config entries. Hermes wrappers run from Hermes' current working directory, use a 600-second timeout, and include truncated stdout/stderr in error JSON. Hermes local install without `--profile` writes only project plugin files and `.hermes/config.yaml`; `tracedecay install --local --agent hermes --profile ` is a deliberate mixed-scope mode that targets the named profile instead. Project-local Hermes plugins are loaded by launching Hermes with `HERMES_HOME=/.hermes`. For Codex, global install writes a Codex plugin source bundle to `~/plugins/tracedecay`, registers it in `~/.agents/plugins/marketplace.json`, and prints `codex plugin add tracedecay@personal`; hooks and AGENTS.md stay config-managed because current Codex plugin manifests accept MCP and skills but not hook declarations. Passing `--automation` with `--agent codex` also installs or updates the Codex-native `Watch TraceDecay Memory` automation in the user profile store at `~/.codex/automations/watch-tracedecay-memory/automation.toml`; project scope is encoded in that global record's `cwds` field, so no project-local automation files are written. For Cursor, both global and `--local` install put the plugin in `~/.cursor/plugins/local/tracedecay` and require a Cursor reload. The plugin MCP config runs `tracedecay serve --path ${workspaceFolder}`, so it resolves the active workspace instead of the plugin directory and uses that workspace's active project store rather than the legacy global Cursor MCP registration. Cursor install no longer writes `.cursor/mcp.json`, `.cursor/hooks.json`, `.cursor/rules/tracedecay.mdc` (legacy artifact name), or `.cursor/permissions.json`; approvals are left to Cursor approval/run-mode behavior. The plugin hooks are: - `sessionStart` — fire-and-forget; injects context steering the Agent toward tracedecay MCP tools and reports index freshness (suggests `tracedecay init` when no initialized project store is found). - `subagentStart` — blocks research/explore subagents until tracedecay MCP tools have been tried; the plugin's own `code-explorer`/`code-health-auditor`/`session-historian` agents are allow-listed. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index c4a87c0e..23a2628d 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -332,6 +332,12 @@ hooks, using the resolved absolute `tracedecay` path). The local hooks are identical to the global Codex install described under "Codex lifecycle hooks" below. +Pass `--automation` with `--agent codex` to install or update the Codex-native +`Watch TraceDecay Memory` automation. TraceDecay writes the native global record +at `~/.codex/automations/watch-tracedecay-memory/automation.toml` and scopes it +to the current project with that record's `cwds` field; it does not write +project-local automation files. + #### Codex lifecycle hooks Codex supports a Claude-style lifecycle hook system (enabled by default; verified against Codex 0.136.0). Both global (`~/.codex/hooks.json`) and project-local (`/.codex/hooks.json`) installs register tracedecay hooks using Codex's nested config shape — `hooks[event] -> [ { matcher?, hooks: [ { type: "command", command, timeout } ] } ]` — and reconcile them idempotently while preserving any foreign hooks. Each hook reads Codex's single stdin JSON event (`session_id`, `cwd`, `hook_event_name`, plus event-specific fields) and writes Codex-shaped stdout. The project root is resolved from the event `cwd`, and every hook is fail-open and only acts when it finds an initialized project store. diff --git a/src/agents/codex.rs b/src/agents/codex.rs index 3fb1373e..06c7f496 100644 --- a/src/agents/codex.rs +++ b/src/agents/codex.rs @@ -12,6 +12,7 @@ //! until trusted. The installer prints that guidance after writing `hooks.json`. use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::json; @@ -344,6 +345,157 @@ 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() { @@ -1393,4 +1545,94 @@ 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(|value| 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(|value| 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/cli.rs b/src/cli.rs index e7e05f10..e07c3331 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -108,6 +108,10 @@ pub enum Commands { /// used with --agent hermes). #[arg(long)] no_dashboard: bool, + /// Install/update a Codex-native project automation in ~/.codex + /// (only used with --agent codex). + #[arg(long)] + automation: bool, }, /// Refresh settings for all already-installed agents Reinstall, @@ -605,6 +609,7 @@ mod cli_parse_tests { all_profiles, project_root, no_dashboard, + .. }) if agent.as_deref() == Some("hermes") && !local && profile.as_deref() == Some("dev") @@ -622,6 +627,22 @@ mod cli_parse_tests { assert!(matches!(cli.command, Some(Commands::UpdatePlugin))); } + #[test] + fn codex_install_automation_flag_parses_without_extra_knobs() { + let cli = + Cli::try_parse_from(["tracedecay", "install", "--agent", "codex", "--automation"]) + .expect("Codex automation install should parse"); + + assert!(matches!( + cli.command, + Some(Commands::Install { + agent, + automation, + .. + }) if agent.as_deref() == Some("codex") && automation + )); + } + #[test] fn status_and_branch_add_commands_dispatch_to_expected_variants() { let status = Cli::try_parse_from([ diff --git a/src/main.rs b/src/main.rs index 4818ffe8..3da18bc4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -195,6 +195,34 @@ fn validate_hermes_project_root_flag( Ok(Some(path)) } +fn validate_codex_automation_flags( + agent: Option<&str>, + automation: bool, +) -> tracedecay::errors::Result<()> { + if !automation { + return Ok(()); + } + if agent != Some("codex") { + return Err(tracedecay::errors::TraceDecayError::Config { + message: "`--automation` is only supported with `--agent codex`".to_string(), + }); + } + Ok(()) +} + +fn validate_codex_automation_project_path() -> tracedecay::errors::Result { + let project_path = + std::env::current_dir().map_err(|e| tracedecay::errors::TraceDecayError::Config { + message: format!("could not determine current project directory: {e}"), + })?; + std::fs::canonicalize(&project_path).map_err(|e| tracedecay::errors::TraceDecayError::Config { + message: format!( + "could not canonicalize project directory {}: {e}", + project_path.display() + ), + }) +} + fn hermes_selected_profile_targets( home: &std::path::Path, profile: &Option, @@ -639,10 +667,12 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { all_profiles, project_root, no_dashboard, + automation, } => { validate_hermes_profile_flags(agent.as_deref(), &profile, all_profiles)?; let pinned_project_root = validate_hermes_project_root_flag(agent.as_deref(), &project_root)?; + validate_codex_automation_flags(agent.as_deref(), automation)?; let home = tracedecay::agents::home_dir().ok_or_else(|| { tracedecay::errors::TraceDecayError::Config { message: "could not determine home directory".to_string(), @@ -687,6 +717,13 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { }; ag.install_local(&ctx, &project_path)?; 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, + )?; + } } installed_names.push(ag.name().to_string()); } else { @@ -741,6 +778,13 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { }; ag.install(&ctx)?; 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, + )?; + } } if !user_cfg.installed_agents.contains(&id) { user_cfg.installed_agents.push(id); @@ -1629,6 +1673,7 @@ mod startup_tests { all_profiles: false, project_root: None, no_dashboard: false, + automation: false, })); assert!(should_skip_startup_maintenance(&Commands::Reinstall)); assert!(should_skip_startup_maintenance(&Commands::UpdatePlugin)); @@ -1666,6 +1711,7 @@ mod startup_tests { all_profiles: false, project_root: None, no_dashboard: false, + automation: false, })); assert!(should_skip_agent_install_maintenance(&Commands::Reinstall)); // `update-plugin` promises byte-identical configs; the implicit @@ -1749,6 +1795,7 @@ mod startup_tests { all_profiles: false, project_root: None, no_dashboard: false, + automation: false, }; let global = Commands::Install { agent: Some("hermes".to_string()), @@ -1757,6 +1804,7 @@ mod startup_tests { all_profiles: false, project_root: None, no_dashboard: false, + automation: false, }; assert!(is_local_install_command(&local));