diff --git a/apps/macos/Sources/AutophagyKit/Models.swift b/apps/macos/Sources/AutophagyKit/Models.swift index 9443f60..7fa1862 100644 --- a/apps/macos/Sources/AutophagyKit/Models.swift +++ b/apps/macos/Sources/AutophagyKit/Models.swift @@ -4,7 +4,7 @@ import Foundation /// /// Corresponds to the highest immutable migration in /// `crates/autophagy-store/migrations` at the time of writing. -public let knownSchemaVersion = 6 +public let knownSchemaVersion = 7 /// How the opened database's schema relates to what this app understands. public enum SchemaCompatibility: Equatable { diff --git a/apps/macos/Tests/AutophagyKitTests/DatabaseReaderTests.swift b/apps/macos/Tests/AutophagyKitTests/DatabaseReaderTests.swift index 0ebfe3b..0e1d086 100644 --- a/apps/macos/Tests/AutophagyKitTests/DatabaseReaderTests.swift +++ b/apps/macos/Tests/AutophagyKitTests/DatabaseReaderTests.swift @@ -9,7 +9,7 @@ struct DatabaseReaderTests { let temp = TempPath(FixtureDatabase.populated()) let reader = try DatabaseReader(path: temp.path) #expect(reader.isAutophagyDatabase()) - #expect(reader.schemaInfo().compatibility == .supported(version: 6)) + #expect(reader.schemaInfo().compatibility == .supported(version: 7)) } @Test func readsSessionsWithSourceMetadata() throws { diff --git a/apps/macos/Tests/AutophagyKitTests/FixtureDatabase.swift b/apps/macos/Tests/AutophagyKitTests/FixtureDatabase.swift index afe1e61..f866375 100644 --- a/apps/macos/Tests/AutophagyKitTests/FixtureDatabase.swift +++ b/apps/macos/Tests/AutophagyKitTests/FixtureDatabase.swift @@ -23,12 +23,13 @@ enum FixtureDatabase { /// source, several sessions/events, and one challenged + one rejected /// mutation candidate (with evidence links and transitions). static func populated() -> String { - make(schemaVersion: 6) { db in + make(schemaVersion: 7) { db in createSchema(db) exec(db, """ INSERT INTO schema_migrations(version, description, checksum, applied_at) VALUES (1,'initial',zeroblob(32),'2026-07-16T00:00:00Z'), - (6,'retrieval',zeroblob(32),'2026-07-16T00:00:00Z'); + (6,'retrieval',zeroblob(32),'2026-07-16T00:00:00Z'), + (7,'claude_code_install',zeroblob(32),'2026-07-16T00:00:00Z'); """) exec(db, """ INSERT INTO sources(source_id, adapter, instance_key, display_name, diff --git a/crates/autophagy-cli/src/main.rs b/crates/autophagy-cli/src/main.rs index d6f6e4c..eee4c96 100644 --- a/crates/autophagy-cli/src/main.rs +++ b/crates/autophagy-cli/src/main.rs @@ -18,7 +18,8 @@ use autophagy_adapter_codex::{ use autophagy_core::{ImportOptions, ImportSummary, import_jsonl}; use autophagy_events::Event; use autophagy_install::{ - CodexSkillPlan, InstallError, InstalledArtifact, materialize, plan_codex_skill, unmaterialize, + InstallError, InstallTarget, InstalledArtifact, SkillPlan, materialize, plan_skill, + unmaterialize, }; use autophagy_mutations::{GenerationOutcome, equivalence_key, generate_candidates}; use autophagy_patterns::{DetectorConfig, EvidencePacket, detect}; @@ -382,7 +383,7 @@ enum MutationAction { #[arg(long, value_name = "PATH")] observations: PathBuf, }, - /// Install one shadow-passed mutation as a repo-scoped Codex skill. + /// Install one shadow-passed mutation as a repo-scoped coding-agent skill. Install { /// Stable mutation identity. mutation_id: String, @@ -391,6 +392,10 @@ enum MutationAction { #[arg(long, value_name = "PATH")] repository: PathBuf, + /// Coding agent to materialize the skill for. + #[arg(long, value_enum, default_value_t = InstallTargetChoice::Codex)] + target: InstallTargetChoice, + /// Required phrase acknowledging the scoped filesystem write: `repo-skill-write`. #[arg(long, value_name = "PHRASE")] confirm_permissions: String, @@ -399,13 +404,32 @@ enum MutationAction { #[arg(long)] dry_run: bool, }, - /// Remove an audited Codex skill and retire its mutation. + /// Remove an audited repo-scoped skill and retire its mutation. Uninstall { /// Stable mutation identity. mutation_id: String, }, } +/// Coding-agent installation target selectable on the command line. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)] +#[serde(rename_all = "kebab-case")] +enum InstallTargetChoice { + /// Codex repo-scoped skill under `.agents/skills`. + Codex, + /// Claude Code repo-scoped skill under `.claude/skills`. + ClaudeCode, +} + +impl From for InstallTarget { + fn from(choice: InstallTargetChoice) -> Self { + match choice { + InstallTargetChoice::Codex => Self::Codex, + InstallTargetChoice::ClaudeCode => Self::ClaudeCode, + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)] #[serde(rename_all = "snake_case")] enum SynthesisProviderChoice { @@ -1090,6 +1114,7 @@ fn execute_mutation_action( MutationAction::Install { mutation_id, repository, + target, confirm_permissions, dry_run, } => { @@ -1106,7 +1131,7 @@ fn execute_mutation_action( .into()); } let package = serde_json::from_value(details.mutation.package)?; - let plan = plan_codex_skill(&package, &repository)?; + let plan = plan_skill(&package, &repository, target.into())?; if dry_run { return Ok(CommandReport::MutationInstall(install_report( &plan, true, false, None, @@ -1116,7 +1141,7 @@ fn execute_mutation_action( let registration = InstallationRegistration { installation_id: plan.installation_id.clone(), mutation_id: plan.mutation_id.clone(), - target: "codex_repo_skill".to_owned(), + target: plan.target.registry_id().to_owned(), repository_root: plan.repository_root.to_string_lossy().into_owned(), relative_path: portable_relative_path(&plan.relative_path), content_hash: plan.content_hash.clone(), @@ -1146,11 +1171,13 @@ fn execute_mutation_action( let audit = store.get_installation(&mutation_id)?; let details = store.get_mutation(&mutation_id)?; let package = serde_json::from_value(details.mutation.package)?; - let plan = plan_codex_skill(&package, Path::new(&audit.repository_root))?; + let target = InstallTarget::from_registry_id(&audit.target) + .ok_or(CliError::InstallationAuditMismatch)?; + let plan = plan_skill(&package, Path::new(&audit.repository_root), target)?; if plan.installation_id != audit.installation_id || portable_relative_path(&plan.relative_path) != audit.relative_path || plan.content_hash != audit.content_hash - || audit.target != "codex_repo_skill" + || plan.target.registry_id() != audit.target { return Err(CliError::InstallationAuditMismatch); } @@ -1176,14 +1203,14 @@ fn execute_mutation_action( } fn install_report( - plan: &CodexSkillPlan, + plan: &SkillPlan, dry_run: bool, materialized: bool, transition: Option, ) -> MutationInstallReport { MutationInstallReport { installation_id: plan.installation_id.clone(), - target: "codex_repo_skill", + target: plan.target.registry_id(), repository_root: plan.repository_root.to_string_lossy().into_owned(), relative_path: portable_relative_path(&plan.relative_path), content_hash: plan.content_hash.clone(), @@ -1411,7 +1438,7 @@ fn write_report( )?, CommandReport::MutationInstall(report) => writeln!( writer, - "{}\t{}\t{}\t{}", + "{}\t{}\t{}\t{}\t{}", report.installation_id, report.relative_path, if report.dry_run { @@ -1419,7 +1446,8 @@ fn write_report( } else { "installed" }, - report.content_hash + report.content_hash, + report.target )?, CommandReport::MutationUninstall(outcome) => writeln!( writer, diff --git a/crates/autophagy-cli/tests/cli.rs b/crates/autophagy-cli/tests/cli.rs index 876e022..32fef67 100644 --- a/crates/autophagy-cli/tests/cli.rs +++ b/crates/autophagy-cli/tests/cli.rs @@ -531,8 +531,36 @@ fn milestone_demo_digests_exports_deletes_and_prunes_offline() { ); assert_eq!(preview_install["result"]["dry_run"], true); assert_eq!(preview_install["result"]["materialized"], false); + assert_eq!(preview_install["result"]["target"], "codex_repo_skill"); assert!(!install_repository.join(".agents").exists()); + // The `--target claude-code` selector plans a `.claude/skills` skill and + // reports the Claude Code target without writing anything on a dry run. + let claude_preview = run_json( + &database, + [ + "mutations", + "install", + &failure_id, + "--repository", + install_repository.to_str().expect("UTF-8 path"), + "--target", + "claude-code", + "--confirm-permissions", + "repo-skill-write", + "--dry-run", + ], + ); + assert_eq!(claude_preview["result"]["target"], "claude_code_repo_skill"); + assert!( + claude_preview["result"]["relative_path"] + .as_str() + .expect("relative path") + .starts_with(".claude/skills/") + ); + assert_eq!(claude_preview["result"]["materialized"], false); + assert!(!install_repository.join(".claude").exists()); + let installed = run_json( &database, [ @@ -752,6 +780,122 @@ fn import_redacts_secrets_excludes_paths_and_requires_delete_confirmation() { assert_eq!(deleted["result"]["sessions_deleted"], 1); } +#[test] +#[allow(clippy::too_many_lines)] +fn claude_code_install_and_uninstall_round_trip_through_cli() { + let directory = tempfile::tempdir().expect("temporary directory"); + let database = directory.path().join("autophagy.db"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../evals/fixtures/findings/deterministic.jsonl"); + run_json(&database, ["import", fixture.to_str().expect("UTF-8 path")]); + run_json(&database, ["mutations", "propose"]); + let registry = run_json(&database, ["mutations", "list"]); + let failure_id = registry["result"] + .as_array() + .expect("registry") + .iter() + .find(|mutation| mutation["source_detector"] == "repeated_command_failure") + .expect("failure mutation")["mutation_id"] + .as_str() + .expect("mutation ID") + .to_owned(); + + run_json( + &database, + [ + "mutations", + "challenge", + &failure_id, + "--check", + "coincidence-considered", + "--check", + "sessions-comparable", + "--check", + "trigger-observable", + "--check", + "legitimate-uses-bounded", + "--check", + "equivalent-searched", + "--check", + "counterexamples-reviewed", + ], + ); + let passing_replay = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../evals/fixtures/replay/command-preflight-pass.json"); + run_json( + &database, + [ + "mutations", + "replay", + &failure_id, + "--scenarios", + passing_replay.to_str().expect("UTF-8 path"), + ], + ); + let passing_shadow = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../evals/fixtures/shadow/command-preflight-pass.json"); + run_json( + &database, + [ + "mutations", + "shadow", + &failure_id, + "--observations", + passing_shadow.to_str().expect("UTF-8 path"), + ], + ); + + let repository = directory.path().join("claude-target"); + fs::create_dir(&repository).expect("repository"); + fs::create_dir(repository.join(".git")).expect("git marker"); + + // Real (non-dry-run) install materializes the Claude Code skill. + let installed = run_json( + &database, + [ + "mutations", + "install", + &failure_id, + "--repository", + repository.to_str().expect("UTF-8 path"), + "--target", + "claude-code", + "--confirm-permissions", + "repo-skill-write", + ], + ); + assert_eq!(installed["result"]["target"], "claude_code_repo_skill"); + assert_eq!(installed["result"]["materialized"], true); + assert_eq!( + installed["result"]["transition"]["mutation_state"], + "active" + ); + let installed_path = repository.join( + installed["result"]["relative_path"] + .as_str() + .expect("relative path"), + ); + assert!(installed_path.is_file()); + let body = fs::read_to_string(&installed_path).expect("installed skill"); + assert!(body.contains("## Evidence")); + assert!(body.contains(&failure_id)); + + // Uninstall (no --target flag) reconstructs the materializer from the + // stored audit target and reverses cleanly. + let uninstalled = run_json(&database, ["mutations", "uninstall", &failure_id]); + assert_eq!(uninstalled["result"]["mutation_state"], "retired"); + assert_eq!(uninstalled["result"]["installation_state"], "uninstalled"); + assert!(!installed_path.exists()); + + // The retired installation audit retains the Claude Code target. + let shown = run_json(&database, ["mutations", "show", &failure_id]); + assert_eq!( + shown["result"]["installations"][0]["target"], + "claude_code_repo_skill" + ); + assert_eq!(shown["result"]["installations"][0]["state"], "uninstalled"); +} + fn run_json(database: &Path, args: [&str; N]) -> Value { let output = command(database) .args(["--output", "json"]) diff --git a/crates/autophagy-install/src/lib.rs b/crates/autophagy-install/src/lib.rs index 7c654d2..70efab2 100644 --- a/crates/autophagy-install/src/lib.rs +++ b/crates/autophagy-install/src/lib.rs @@ -1,8 +1,9 @@ //! Explicit, reversible mutation installation targets. //! -//! The initial materializer writes one repo-scoped Codex skill under -//! `.agents/skills`. It never overwrites an existing file and uninstall refuses -//! content drift. +//! Materializers write one repo-scoped skill for a supported coding agent: +//! Codex under `.agents/skills` or Claude Code under `.claude/skills`. Every +//! target follows the same lifecycle discipline: it never overwrites an +//! existing file and uninstall refuses content drift. use std::{ fmt::Write as _, @@ -14,14 +15,66 @@ use std::{ use autophagy_mutations::MutationPackage; use sha2::{Digest, Sha256}; -/// Exact filesystem plan for one repo-scoped Codex skill. +/// Supported repo-scoped skill installation targets. +/// +/// Both variants share the same planning, materialization, and rollback logic; +/// they differ only in the repository-relative skill directory, the persisted +/// registry identifier, and the coding agent named in the rendered guidance. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InstallTarget { + /// Codex repo-scoped skill under `.agents/skills`. + Codex, + /// Claude Code repo-scoped skill under `.claude/skills`. + ClaudeCode, +} + +impl InstallTarget { + /// Stable registry identifier persisted in the installation audit. + #[must_use] + pub fn registry_id(self) -> &'static str { + match self { + Self::Codex => "codex_repo_skill", + Self::ClaudeCode => "claude_code_repo_skill", + } + } + + /// Recover a target from its persisted registry identifier. + #[must_use] + pub fn from_registry_id(registry_id: &str) -> Option { + match registry_id { + "codex_repo_skill" => Some(Self::Codex), + "claude_code_repo_skill" => Some(Self::ClaudeCode), + _ => None, + } + } + + /// Repository-relative directory components holding installed skills. + fn skill_root(self) -> [&'static str; 2] { + match self { + Self::Codex => [".agents", "skills"], + Self::ClaudeCode => [".claude", "skills"], + } + } + + /// Human-facing coding agent name used in rendered guidance. + fn agent_name(self) -> &'static str { + match self { + Self::Codex => "Codex", + Self::ClaudeCode => "Claude Code", + } + } +} + +/// Exact filesystem plan for one repo-scoped skill. #[derive(Clone, Debug, Eq, PartialEq)] -pub struct CodexSkillPlan { +pub struct SkillPlan { /// Stable installation identity for this mutation and repository. pub installation_id: String, /// Installed mutation identity. pub mutation_id: String, - /// Stable Codex skill name. + /// Coding agent this skill targets. + pub target: InstallTarget, + /// Stable skill name. pub skill_name: String, /// Canonical repository root. pub repository_root: PathBuf, @@ -33,7 +86,10 @@ pub struct CodexSkillPlan { pub content_hash: String, } -impl CodexSkillPlan { +/// Backwards-compatible alias for the original Codex-only plan name. +pub type CodexSkillPlan = SkillPlan; + +impl SkillPlan { /// Absolute installation path. #[must_use] pub fn absolute_path(&self) -> PathBuf { @@ -54,15 +110,16 @@ pub struct InstalledArtifact { pub content_hash: String, } -/// Build a deterministic repo-scoped Codex skill plan without writing files. +/// Build a deterministic repo-scoped skill plan without writing files. /// /// # Errors /// Returns [`InstallError`] when the package is invalid or the target is not an /// existing directory. -pub fn plan_codex_skill( +pub fn plan_skill( package: &MutationPackage, repository_root: &Path, -) -> Result { + target: InstallTarget, +) -> Result { package .validate() .map_err(|error| InstallError::InvalidPackage(error.to_string()))?; @@ -80,11 +137,13 @@ pub fn plan_codex_skill( .take(12) .collect::(); let skill_name = format!("autophagy-{suffix}"); - let relative_path = PathBuf::from(".agents") - .join("skills") - .join(&skill_name) - .join("SKILL.md"); - let content = render_skill(package, &skill_name); + let mut relative_path = PathBuf::new(); + for component in target.skill_root() { + relative_path.push(component); + } + relative_path.push(&skill_name); + relative_path.push("SKILL.md"); + let content = render_skill(package, &skill_name, target); let content_hash = sha256_hex(content.as_bytes()); let installation_id = format!( "ins_{}", @@ -98,9 +157,10 @@ pub fn plan_codex_skill( .as_bytes() ) ); - Ok(CodexSkillPlan { + Ok(SkillPlan { installation_id, mutation_id: package.mutation_id.clone(), + target, skill_name, repository_root, relative_path, @@ -109,13 +169,37 @@ pub fn plan_codex_skill( }) } +/// Build a deterministic repo-scoped Codex skill plan without writing files. +/// +/// # Errors +/// Returns [`InstallError`] when the package is invalid or the target is not an +/// existing directory. +pub fn plan_codex_skill( + package: &MutationPackage, + repository_root: &Path, +) -> Result { + plan_skill(package, repository_root, InstallTarget::Codex) +} + +/// Build a deterministic repo-scoped Claude Code skill plan without writing files. +/// +/// # Errors +/// Returns [`InstallError`] when the package is invalid or the target is not an +/// existing directory. +pub fn plan_claude_code_skill( + package: &MutationPackage, + repository_root: &Path, +) -> Result { + plan_skill(package, repository_root, InstallTarget::ClaudeCode) +} + /// Create exactly one planned `SKILL.md` without overwriting existing content. /// /// # Errors /// Returns [`InstallError`] for an existing target or filesystem failure. -pub fn materialize(plan: &CodexSkillPlan) -> Result { +pub fn materialize(plan: &SkillPlan) -> Result { let root = fs::canonicalize(&plan.repository_root)?; - let skill_directory = create_scoped_skill_directory(&root, &plan.skill_name)?; + let skill_directory = create_scoped_skill_directory(&root, plan.target, &plan.skill_name)?; let path = skill_directory.join("SKILL.md"); let mut file = match OpenOptions::new().write(true).create_new(true).open(&path) { Ok(file) => file, @@ -140,9 +224,14 @@ pub fn materialize(plan: &CodexSkillPlan) -> Result Result { +fn create_scoped_skill_directory( + root: &Path, + target: InstallTarget, + skill_name: &str, +) -> Result { let mut current = root.to_path_buf(); - for component in [".agents", "skills", skill_name] { + let [first, second] = target.skill_root(); + for component in [first, second, skill_name] { let next = current.join(component); match fs::create_dir(&next) { Ok(()) => {} @@ -191,7 +280,7 @@ pub fn unmaterialize(artifact: &InstalledArtifact) -> Result<(), InstallError> { Ok(()) } -fn render_skill(package: &MutationPackage, skill_name: &str) -> String { +fn render_skill(package: &MutationPackage, skill_name: &str, target: InstallTarget) -> String { let title = package .title .split_whitespace() @@ -215,9 +304,36 @@ fn render_skill(package: &MutationPackage, skill_name: &str) -> String { writeln!(rendered, "- {exclusion}").expect("String write"); } rendered.push_str("\nThis skill was installed only after challenge, replay, shadow evaluation, and explicit user approval.\n"); + if target == InstallTarget::ClaudeCode { + rendered.push_str(&render_evidence_footer(package, target)); + } rendered } +fn render_evidence_footer(package: &MutationPackage, target: InstallTarget) -> String { + let mut footer = String::from("\n## Evidence\n\n"); + writeln!( + footer, + "Installed for {} from Autophagy mutation `{}` (version `{}`), finding `{}`.", + target.agent_name(), + package.mutation_id, + package.version, + package.source_finding_id + ) + .expect("String write"); + footer.push_str("\nSupporting events:\n\n"); + for event_id in &package.hypothesis.supporting_event_ids { + writeln!(footer, "- `{event_id}`").expect("String write"); + } + if !package.hypothesis.counterexample_event_ids.is_empty() { + footer.push_str("\nCounterexample events:\n\n"); + for event_id in &package.hypothesis.counterexample_event_ids { + writeln!(footer, "- `{event_id}`").expect("String write"); + } + } + footer +} + fn yaml_double_quoted(value: &str) -> String { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) } @@ -231,7 +347,7 @@ fn sha256_hex(bytes: &[u8]) -> String { encoded } -/// Error produced by Codex skill planning or materialization. +/// Error produced by repo-scoped skill planning or materialization. #[derive(Debug, thiserror::Error)] pub enum InstallError { /// The mutation package failed its semantic contract. diff --git a/crates/autophagy-install/tests/codex_skill.rs b/crates/autophagy-install/tests/codex_skill.rs index 1c095e1..5071e3a 100644 --- a/crates/autophagy-install/tests/codex_skill.rs +++ b/crates/autophagy-install/tests/codex_skill.rs @@ -1,9 +1,12 @@ -//! Reversible repo-scoped Codex skill materialization tests. +//! Reversible repo-scoped skill materialization tests (Codex and Claude Code). use std::{fs, io::Cursor}; use autophagy_core::{ImportOptions, import_jsonl}; -use autophagy_install::{InstallError, materialize, plan_codex_skill, unmaterialize}; +use autophagy_install::{ + InstallError, InstallTarget, materialize, plan_claude_code_skill, plan_codex_skill, + unmaterialize, +}; use autophagy_mutations::{GenerationOutcome, MutationPackage, generate_candidates}; use autophagy_patterns::{DetectorConfig, detect}; use autophagy_store::EventStore; @@ -66,6 +69,111 @@ fn materializer_refuses_symlink_escape_before_creating_external_content() { assert!(!outside.path().join("skills").exists()); } +#[test] +fn claude_code_skill_targets_claude_directory_with_evidence_footer() { + let repository = tempfile::tempdir().expect("repository"); + fs::create_dir(repository.path().join(".git")).expect("git marker"); + let package = command_failure_package(); + + let plan = plan_claude_code_skill(&package, repository.path()).expect("plan"); + assert_eq!(plan.target, InstallTarget::ClaudeCode); + assert_eq!(plan.target.registry_id(), "claude_code_repo_skill"); + assert!(plan.relative_path.starts_with(".claude/skills")); + assert!(plan.relative_path.ends_with("SKILL.md")); + assert!(plan.content.contains("name: autophagy-")); + assert!(plan.content.contains(&package.intervention.instruction)); + // Evidence footer cites exact event IDs, mutation ID, and version. + assert!(plan.content.contains("## Evidence")); + assert!(plan.content.contains(&package.mutation_id)); + for event_id in &package.hypothesis.supporting_event_ids { + assert!( + plan.content.contains(event_id), + "footer must cite supporting event {event_id}" + ); + } + assert_eq!( + plan, + plan_claude_code_skill(&package, repository.path()).expect("stable plan") + ); + + // The Claude Code plan is a distinct target from the Codex plan for the + // same package: different directory, distinct installation identity. + let codex = plan_codex_skill(&package, repository.path()).expect("codex plan"); + assert_ne!(plan.relative_path, codex.relative_path); + assert_ne!(plan.installation_id, codex.installation_id); + assert_ne!(plan.content_hash, codex.content_hash); + assert!(!codex.content.contains("## Evidence")); + + let artifact = materialize(&plan).expect("materialize"); + assert!(plan.absolute_path().is_file()); + assert!( + repository + .path() + .join(".claude/skills") + .join(&plan.skill_name) + .join("SKILL.md") + .is_file() + ); + assert!(matches!( + materialize(&plan), + Err(InstallError::TargetExists(_)) + )); + unmaterialize(&artifact).expect("uninstall"); + assert!(!plan.absolute_path().exists()); + + // Reversible and idempotent: the identical skill can be reinstalled after + // a clean uninstall, reproducing the exact deterministic bytes. + let reinstalled = materialize(&plan).expect("reinstall"); + assert_eq!(reinstalled.content_hash, plan.content_hash); + unmaterialize(&reinstalled).expect("second uninstall"); + assert!(!plan.absolute_path().exists()); +} + +#[test] +fn claude_code_uninstall_refuses_content_drift() { + let repository = tempfile::tempdir().expect("repository"); + fs::create_dir(repository.path().join(".git")).expect("git marker"); + let package = command_failure_package(); + let plan = plan_claude_code_skill(&package, repository.path()).expect("plan"); + let artifact = materialize(&plan).expect("materialize"); + fs::write(plan.absolute_path(), "user changed this skill").expect("drift"); + assert!(matches!( + unmaterialize(&artifact), + Err(InstallError::ContentDrift { .. }) + )); +} + +#[cfg(unix)] +#[test] +fn claude_code_materializer_refuses_symlink_escape() { + use std::os::unix::fs::symlink; + + let repository = tempfile::tempdir().expect("repository"); + let outside = tempfile::tempdir().expect("outside"); + fs::create_dir(repository.path().join(".git")).expect("git marker"); + symlink(outside.path(), repository.path().join(".claude")).expect("symlink"); + let package = command_failure_package(); + let plan = plan_claude_code_skill(&package, repository.path()).expect("plan"); + assert!(matches!( + materialize(&plan), + Err(InstallError::TargetEscapesRepository(_)) + )); + assert!(!outside.path().join("skills").exists()); +} + +#[test] +fn install_targets_round_trip_registry_identifiers() { + assert_eq!( + InstallTarget::from_registry_id("codex_repo_skill"), + Some(InstallTarget::Codex) + ); + assert_eq!( + InstallTarget::from_registry_id("claude_code_repo_skill"), + Some(InstallTarget::ClaudeCode) + ); + assert_eq!(InstallTarget::from_registry_id("vscode_repo_skill"), None); +} + fn command_failure_package() -> MutationPackage { let mut store = EventStore::open_in_memory().expect("store"); import_jsonl( diff --git a/crates/autophagy-store/migrations/0007_claude_code_install_target.sql b/crates/autophagy-store/migrations/0007_claude_code_install_target.sql new file mode 100644 index 0000000..a8522ba --- /dev/null +++ b/crates/autophagy-store/migrations/0007_claude_code_install_target.sql @@ -0,0 +1,29 @@ +-- Broaden the installation registry to record additional repo-scoped skill +-- targets. The original 0005 table constrained `target` to Codex and the +-- relative path to `.agents/skills/...`. Claude Code repo-scoped skills live +-- under `.claude/skills/...`, so both CHECK constraints are relaxed here. +-- +-- SQLite cannot alter a CHECK constraint in place, so the table is recreated +-- and its rows copied, mirroring the pattern used by migration 0005. + +CREATE TABLE mutation_installations_v7 ( + installation_id TEXT PRIMARY KEY CHECK (installation_id LIKE 'ins_%'), + mutation_id TEXT NOT NULL UNIQUE REFERENCES mutation_candidates(mutation_id) ON DELETE CASCADE, + target TEXT NOT NULL CHECK (target IN ('codex_repo_skill', 'claude_code_repo_skill')), + repository_root TEXT NOT NULL, + relative_path TEXT NOT NULL CHECK ( + relative_path LIKE '.agents/skills/%/SKILL.md' + OR relative_path LIKE '.claude/skills/%/SKILL.md' + ), + content_hash TEXT NOT NULL CHECK (length(content_hash) = 64), + permission_review_json TEXT NOT NULL CHECK (json_valid(permission_review_json)), + state TEXT NOT NULL CHECK (state IN ('installed', 'uninstalled')), + installed_at TEXT NOT NULL, + uninstalled_at TEXT +) STRICT; + +INSERT INTO mutation_installations_v7 SELECT * FROM mutation_installations; + +DROP TABLE mutation_installations; + +ALTER TABLE mutation_installations_v7 RENAME TO mutation_installations; diff --git a/crates/autophagy-store/src/migration.rs b/crates/autophagy-store/src/migration.rs index d4d50b0..9ac2ab1 100644 --- a/crates/autophagy-store/src/migration.rs +++ b/crates/autophagy-store/src/migration.rs @@ -50,6 +50,11 @@ const MIGRATIONS: &[Migration] = &[ description: "exact normalized-signature retrieval index", sql: include_str!("../migrations/0006_retrieval_signature.sql"), }, + Migration { + version: 7, + description: "claude code repo-skill installation target", + sql: include_str!("../migrations/0007_claude_code_install_target.sql"), + }, ]; pub(crate) fn apply(connection: &mut Connection) -> Result<(), StoreError> { @@ -133,17 +138,18 @@ mod tests { fn newer_database_is_rejected() { let mut connection = Connection::open_in_memory().expect("database"); apply(&mut connection).expect("initial migration"); + let future = MIGRATIONS.last().expect("migration").version + 1; connection .execute( "INSERT INTO schema_migrations(version, description, checksum, applied_at) - VALUES (7, 'future', ?1, '2026-07-16T00:00:00Z')", - params![[7_u8; 32].as_slice()], + VALUES (?1, 'future', ?2, '2026-07-16T00:00:00Z')", + params![future, [7_u8; 32].as_slice()], ) .expect("future migration"); assert!(matches!( apply(&mut connection), - Err(StoreError::DatabaseTooNew { version: 7 }) + Err(StoreError::DatabaseTooNew { version }) if version == future )); } @@ -243,7 +249,7 @@ mod tests { connection .pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) .expect("schema version"), - 6 + MIGRATIONS.last().expect("migration").version ); } } diff --git a/crates/autophagy-store/src/store.rs b/crates/autophagy-store/src/store.rs index b5d1a90..cd22f70 100644 --- a/crates/autophagy-store/src/store.rs +++ b/crates/autophagy-store/src/store.rs @@ -1198,12 +1198,15 @@ impl EventStore { "relative_path": registration.relative_path, "permission_review": registration.permission_review, }))?; + let reason = match registration.target.as_str() { + "claude_code_repo_skill" => "user approved Claude Code repo-skill installation", + _ => "user approved Codex repo-skill installation", + }; transaction.execute( "INSERT INTO mutation_transitions( mutation_id, from_state, to_state, reason, metadata_json, occurred_at - ) VALUES (?1, 'shadow_passed', 'active', - 'user approved Codex repo-skill installation', ?2, ?3)", - params![registration.mutation_id, metadata, now], + ) VALUES (?1, 'shadow_passed', 'active', ?2, ?3, ?4)", + params![registration.mutation_id, reason, metadata, now], )?; transaction.commit()?; Ok(InstallationTransitionOutcome { @@ -1248,11 +1251,18 @@ impl EventStore { let transaction = self .connection .transaction_with_behavior(TransactionBehavior::Immediate)?; - let (installation_id, installation_state) = transaction + let (installation_id, installation_state, target) = transaction .query_row( - "SELECT installation_id, state FROM mutation_installations WHERE mutation_id = ?1", + "SELECT installation_id, state, target FROM mutation_installations + WHERE mutation_id = ?1", [mutation_id], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, ) .optional()? .ok_or_else(|| StoreError::InstallationNotFound { @@ -1287,14 +1297,20 @@ impl EventStore { WHERE mutation_id = ?1", params![mutation_id, now], )?; + let reason = match target.as_str() { + "claude_code_repo_skill" => "Claude Code repo skill uninstalled", + _ => "Codex repo skill uninstalled", + }; transaction.execute( "INSERT INTO mutation_transitions( mutation_id, from_state, to_state, reason, metadata_json, occurred_at - ) VALUES (?1, 'active', 'retired', 'Codex repo skill uninstalled', ?2, ?3)", + ) VALUES (?1, 'active', 'retired', ?2, ?3, ?4)", params![ mutation_id, + reason, serde_json::to_string(&serde_json::json!({ "installation_id": installation_id, + "target": target, }))?, now ], @@ -1916,11 +1932,15 @@ fn shadow_report_matches_registration(registration: &ShadowRegistration) -> bool } fn valid_installation_registration(registration: &InstallationRegistration) -> bool { + let target_path_prefix = match registration.target.as_str() { + "codex_repo_skill" => ".agents/skills/", + "claude_code_repo_skill" => ".claude/skills/", + _ => return false, + }; registration.installation_id.starts_with("ins_") && registration.mutation_id.starts_with("mut_") - && registration.target == "codex_repo_skill" && !registration.repository_root.trim().is_empty() - && registration.relative_path.starts_with(".agents/skills/") + && registration.relative_path.starts_with(target_path_prefix) && registration.relative_path.ends_with("/SKILL.md") && registration.content_hash.len() == 64 && registration diff --git a/crates/autophagy-store/tests/store.rs b/crates/autophagy-store/tests/store.rs index 309eab3..d518d7b 100644 --- a/crates/autophagy-store/tests/store.rs +++ b/crates/autophagy-store/tests/store.rs @@ -30,7 +30,7 @@ fn migrations_persist_and_reopen_cleanly() { { let mut store = EventStore::open(&database).expect("store should open"); - assert_eq!(store.schema_version().expect("schema version"), 6); + assert_eq!(store.schema_version().expect("schema version"), 7); assert!(matches!( store .insert_event(&source, &event, &SearchProjection::default()) @@ -40,7 +40,7 @@ fn migrations_persist_and_reopen_cleanly() { } let reopened = EventStore::open(&database).expect("store should reopen"); - assert_eq!(reopened.schema_version().expect("schema version"), 6); + assert_eq!(reopened.schema_version().expect("schema version"), 7); assert_eq!( reopened .get_event(event.event_id.as_str()) @@ -727,6 +727,106 @@ fn mutation_registry_is_idempotent_audited_and_evidence_bound() { )); } +#[test] +fn claude_code_installation_registers_audits_and_reverses() { + let mut store = EventStore::open_in_memory().expect("store"); + let source = source("instance-claude-code"); + for (event_id, session_id, timestamp) in [ + ( + "evt_mutation-support-a", + "ses_mutation-support-a", + "2026-07-16T06:00:00Z", + ), + ( + "evt_mutation-support-b", + "ses_mutation-support-b", + "2026-07-16T06:01:00Z", + ), + ( + "evt_mutation-counter", + "ses_mutation-counter", + "2026-07-16T06:02:00Z", + ), + ("evt_replay-only", "ses_replay-only", "2026-07-16T06:03:00Z"), + ] { + store + .insert_event( + &source, + &session_event( + event_id, + session_id, + EventKind::DecisionRecorded, + timestamp, + 0, + ), + &SearchProjection::default(), + ) + .expect("evidence event"); + } + store + .register_mutation(&mutation_registration( + "mut_registry", + "fnd_registry", + "eqv_registry", + )) + .expect("register"); + store + .challenge_mutation("mut_registry", &json!({"checks":["sessions_comparable"]})) + .expect("challenge"); + store + .register_replay(&replay_registration("rpl_passing", "rsh_passing", true)) + .expect("replay"); + store + .register_shadow(&shadow_registration("shr_passing", "shh_passing", true)) + .expect("shadow"); + + // Unknown targets are refused by validation before any state change. + let mut unknown = claude_code_installation_registration(); + unknown.target = "vscode_repo_skill".to_owned(); + assert!(matches!( + store.register_installation(&unknown), + Err(StoreError::InvalidInstallationRegistration) + )); + + let installation = claude_code_installation_registration(); + assert_eq!( + store + .register_installation(&installation) + .expect("claude code install"), + InstallationTransitionOutcome { + installation_id: "ins_claude".to_owned(), + mutation_state: "active".to_owned(), + installation_state: "installed".to_owned(), + } + ); + let audit = store.get_installation("mut_registry").expect("audit"); + assert_eq!(audit.target, "claude_code_repo_skill"); + assert_eq!( + audit.relative_path, + ".claude/skills/autophagy-registry/SKILL.md" + ); + + assert_eq!( + store.record_uninstall("mut_registry").expect("uninstall"), + InstallationTransitionOutcome { + installation_id: "ins_claude".to_owned(), + mutation_state: "retired".to_owned(), + installation_state: "uninstalled".to_owned(), + } + ); + let details = store.get_mutation("mut_registry").expect("retired details"); + assert_eq!(details.mutation.state, "retired"); + assert_eq!(details.installations[0].state, "uninstalled"); + // The retirement transition reason is derived from the stored target, not + // hardcoded to Codex. + let retire = details + .transitions + .iter() + .find(|transition| transition.to_state == "retired") + .expect("retire transition"); + assert_eq!(retire.reason, "Claude Code repo skill uninstalled"); +} + fn source(instance_key: &str) -> SourceIdentity { SourceIdentity::new("codex", instance_key).with_display_name("Codex") } @@ -806,6 +906,18 @@ fn installation_registration() -> InstallationRegistration { } } +fn claude_code_installation_registration() -> InstallationRegistration { + InstallationRegistration { + installation_id: "ins_claude".to_owned(), + mutation_id: "mut_registry".to_owned(), + target: "claude_code_repo_skill".to_owned(), + repository_root: "/workspace/project".to_owned(), + relative_path: ".claude/skills/autophagy-registry/SKILL.md".to_owned(), + content_hash: "a".repeat(64), + permission_review: json!({"confirmed":"repo-skill-write"}), + } +} + fn session_event( event_id: &str, session_id: &str, diff --git a/docs/architecture/database-schema.md b/docs/architecture/database-schema.md index e55fc3a..fc5e901 100644 --- a/docs/architecture/database-schema.md +++ b/docs/architecture/database-schema.md @@ -297,7 +297,11 @@ Shadow reports follow the same immutable, evidence-linked design. Passing advances only `replay_passed -> shadow_passed`. Installation records retain the canonical target, exact relative path, installed content hash, permission review, and uninstall timestamp; install and uninstall lifecycle transitions -commit with their audit updates. +commit with their audit updates. `target` is one of `codex_repo_skill` +(`.agents/skills//SKILL.md`) or `claude_code_repo_skill` +(`.claude/skills//SKILL.md`), and `relative_path` is constrained to match +the recorded target. Uninstall derives the materializer from the stored target, +so rollback always reconstructs the exact deterministic bytes it installed. `source_cursors` stores the last complete byte and physical-line boundary plus adapter-defined state. The Claude Code adapter includes pending tool calls in diff --git a/docs/decisions/0005-macos-read-only-app.md b/docs/decisions/0005-macos-read-only-app.md index 1515f1e..809519b 100644 --- a/docs/decisions/0005-macos-read-only-app.md +++ b/docs/decisions/0005-macos-read-only-app.md @@ -48,7 +48,7 @@ testable `AutophagyKit` library, opened strictly read-only. `max(version)` from `schema_migrations` and classifies the database as supported, older-but-readable, newer-than-known, or not-an-Autophagy-database. Every query checks for table existence first, so a schema that predates or - postdates the app's known version (6) degrades to empty results and a clear + postdates the app's known version (currently 7) degrades to empty results and a clear message rather than a crash or a misread. - **Same default path as the CLI.** The default database location is resolved to `~/Library/Application Support/sh.autophagy.Autophagy/autophagy.db`, matching diff --git a/docs/decisions/0006-claude-code-install-target.md b/docs/decisions/0006-claude-code-install-target.md new file mode 100644 index 0000000..41a3544 --- /dev/null +++ b/docs/decisions/0006-claude-code-install-target.md @@ -0,0 +1,78 @@ +# ADR 0006: Claude Code installation target + +- Status: accepted +- Date: 2026-07-17 + +## Context + +`autophagy-install` could materialize exactly one reversible artifact: a +repo-scoped Codex skill under `.agents/skills`. Most users run Claude Code, +which loads repo-scoped skills from a different location. Shipping 0.1.0 with a +Codex-only installer leaves the common case unserved. + +Claude Code's repo-scoped skill format is a `SKILL.md` under +`.claude/skills//` with `name`/`description` YAML frontmatter +followed by Markdown instructions, loaded automatically for sessions in that +repository. It needs no hooks, no `settings.json` edits, and no global +(`~/.claude`) writes — the same repo-scoped, reversible stance the Codex +materializer already takes. + +The installation registry (migration 0005) hard-coded the Codex target: a +`CHECK (target = 'codex_repo_skill')` constraint and a +`CHECK (relative_path LIKE '.agents/skills/%/SKILL.md')` constraint. Recording a +Claude Code installation therefore requires a stored-schema change, which under +the project's rules requires an ordered migration and a decision record. + +## Decision + +Add a `ClaudeCode` installation target alongside `Codex`, sharing one code path, +and relax the registry constraints with a new migration. + +- **Shared target abstraction.** `autophagy-install` gains an `InstallTarget` + enum (`Codex`, `ClaudeCode`). Planning, materialization, non-overwrite, + symlink-escape refusal, and drift-checked rollback are all target-agnostic; + the target only selects the repository-relative skill directory + (`.agents/skills` vs `.claude/skills`), the persisted registry identifier + (`codex_repo_skill` vs `claude_code_repo_skill`), and the agent named in the + rendered guidance. The former `plan_codex_skill` remains as a thin wrapper and + `CodexSkillPlan` remains as a type alias, so existing callers are unaffected. +- **Deterministic, evidence-linked body.** The Claude Code `SKILL.md` reproduces + the reviewed instruction, the exact versioned trigger selectors, the + exclusions, and an evidence footer citing the exact supporting and + counterexample AEP event IDs plus the mutation ID and version. The Codex body + is byte-for-byte unchanged. The evidence footer with event IDs is therefore + deliberately asymmetric — it is emitted only in the Claude Code body, because + changing the Codex body would alter the content hash of the skill Autophagy + materializes and break drift detection for skills installed before this + change; the constraint "every derived finding retains exact evidence + identifiers" is already satisfied for both targets by the installation audit + in `mutation_installations`, which links the mutation (and thus its evidence) + to the on-disk file regardless of target. +- **Migration 0007.** `mutation_installations` is recreated (SQLite cannot alter + a `CHECK` in place) with `target IN ('codex_repo_skill', + 'claude_code_repo_skill')` and a `relative_path` check that accepts either + `.agents/skills/%/SKILL.md` or `.claude/skills/%/SKILL.md`. Existing rows copy + forward unchanged. The store's registration validator accepts both targets and + their matching path prefix. +- **Target-driven uninstall.** The install audit already records the target, so + uninstall reconstructs the correct materializer from the stored identifier and + keeps its byte-exact, drift-refusing rollback. A mutation still has at most one + active installation. +- **CLI selector with a safe default.** `mutations install` gains + `--target codex|claude-code`, defaulting to `codex` so existing invocations + and their `--output json` shape are unchanged (the report now also carries the + target). `mutations uninstall` needs no flag — it reads the target from the + audit. + +## Privacy + +The change preserves the local-first, offline, reversible guarantees. A Claude +Code install writes exactly one file — `.claude/skills/autophagy-/SKILL.md` +— inside the user-selected Git repository, only after the mutation has passed +challenge, replay, and shadow evaluation and the user supplies the exact +`repo-skill-write` confirmation phrase. Nothing is written to `~/.claude`, to +global configuration, to hooks, or off the machine. The file contains only +deterministic, already-redacted, reviewed content plus the exact evidence +identifiers the project requires derived findings to retain. Uninstall removes +the file and directory and refuses to touch content that no longer matches the +recorded hash. diff --git a/docs/guides/shadow-and-installation.md b/docs/guides/shadow-and-installation.md index 2bc0d68..6f76b41 100644 --- a/docs/guides/shadow-and-installation.md +++ b/docs/guides/shadow-and-installation.md @@ -1,4 +1,4 @@ -# Shadow and reversible Codex installation +# Shadow and reversible installation Shadow is the final measurement gate before a user may install an instruction mutation. It observes where the immutable trigger would fire but never changes @@ -21,22 +21,26 @@ always contain `mutation_applied: false` and `model_used: false`. The candidate itself remains zero-permission. Installation is a separate user operation requesting one scoped filesystem effect: create one `SKILL.md` under -the selected repository's `.agents/skills` directory. +the selected repository's skill directory for the chosen coding agent. ```sh autophagy mutations install mut_example \ --repository /workspace/project \ + --target claude-code \ --confirm-permissions repo-skill-write \ --dry-run ``` -The target must be an existing Git repository root. Dry-run reports the -canonical repository, exact relative path, content hash, -target, and required permission without writing or activating anything. +The `--target` selector chooses the coding agent. It defaults to `codex`, so +existing invocations keep their behavior; pass `--target claude-code` for a +Claude Code skill. The target must be an existing Git repository root. Dry-run +reports the canonical repository, exact relative path, content hash, target, and +required permission without writing or activating anything. ## Codex repo skill target -After removing `--dry-run`, the materializer creates: +With `--target codex` (the default), after removing `--dry-run` the materializer +creates: ```text /.agents/skills/autophagy-/SKILL.md @@ -47,16 +51,48 @@ This follows Codex's documented repo-scoped skill location and required Autophagy does not write `$HOME/.agents/skills`, Codex config, hooks, commands, or network settings. -Codex selects skills from their descriptions and task context. The installed -skill repeats the reviewed selectors as instructions, but it is not a -mechanically enforced pre-tool hook; shadow precision therefore remains -evidence for user judgment rather than a guarantee of identical activation. +## Claude Code repo skill target + +With `--target claude-code` the materializer creates: + +```text +/.claude/skills/autophagy-/SKILL.md +``` + +This is Claude Code's repo-scoped skill location: a `SKILL.md` with `name` and +`description` YAML frontmatter followed by Markdown instructions, which Claude +Code loads automatically for sessions in that repository. Autophagy writes only +that one file inside the selected repository. It does not write `~/.claude`, +`settings.json`, hooks, slash commands, subagents, or any global configuration — +the same repo-scoped stance as the Codex materializer. + +The Claude Code skill body carries the reviewed instruction, the exact versioned +trigger selectors, the exclusions, and an evidence footer citing the exact +supporting (and any counterexample) AEP event IDs alongside the mutation ID and +version. Every identifier is reproduced deterministically from the reviewed, +shadow-passed package; nothing is model-generated. + +The event-ID evidence footer appears only in the Claude Code body: the Codex +body is kept byte-for-byte identical to earlier releases so its content hash — +and therefore drift detection for Codex skills installed before this change — +stays stable. This is only a difference in the on-disk body; the installation +audit in SQLite retains the mutation link (and thus its exact evidence +identifiers) for both targets. + +Both agents select skills from their descriptions and task context. The +installed skill repeats the reviewed selectors as instructions, but it is not a +mechanically enforced pre-tool hook; shadow precision therefore remains evidence +for user judgment rather than a guarantee of identical activation. + +## Installation audit Installation requires registry state `shadow_passed` and the exact confirmation phrase `repo-skill-write`. The materializer refuses existing files and symlink escapes. After writing, SQLite records the canonical root, relative path, -content SHA-256, target, and permission review while transitioning the mutation -to `active`. If that audit fails, the new file is removed. +content SHA-256, target (`codex_repo_skill` or `claude_code_repo_skill`), and +permission review while transitioning the mutation to `active`. A mutation can +have at most one active installation. If that audit fails, the new file is +removed. ## Uninstall @@ -64,7 +100,8 @@ to `active`. If that audit fails, the new file is removed. autophagy mutations uninstall mut_example ``` -Uninstall loads the audited target, verifies that its bytes still match the +Uninstall loads the audited target, reconstructs the materializer from the +stored target identifier, verifies that the file's bytes still match the installation hash, removes `SKILL.md`, and records `active -> retired`. If the file changed, rollback refuses to delete user edits. If the database update fails after removal, Autophagy recreates the exact deterministic skill.