From 7daaaaaaa128ef101eb556a0fd5873ee3fe9c7dd Mon Sep 17 00:00:00 2001 From: Karn Date: Fri, 17 Jul 2026 02:01:38 +0530 Subject: [PATCH 1/2] feat: add Claude Code installation target Materialize shadow-passed mutations as repo-scoped Claude Code skills at .claude/skills/autophagy-/SKILL.md alongside the existing Codex target, sharing one InstallTarget-parameterized code path for planning, non-overwrite materialization, symlink-escape refusal, and drift-checked rollback. The Claude Code body reproduces the reviewed instruction, versioned trigger selectors, exclusions, and an evidence footer citing exact supporting and counterexample event IDs plus mutation ID and version. Migration 0007 relaxes the installation registry CHECK constraints to accept the new target and its .claude/skills path; uninstall reconstructs the materializer from the stored target. The CLI gains --target codex|claude-code (defaulting to codex). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015LY7xtercMc3NtLeX2z4YV --- apps/macos/Sources/AutophagyKit/Models.swift | 2 +- .../DatabaseReaderTests.swift | 2 +- .../AutophagyKitTests/FixtureDatabase.swift | 5 +- crates/autophagy-cli/src/main.rs | 48 ++++-- crates/autophagy-cli/tests/cli.rs | 28 +++ crates/autophagy-install/src/lib.rs | 160 +++++++++++++++--- crates/autophagy-install/tests/codex_skill.rs | 112 +++++++++++- .../0007_claude_code_install_target.sql | 29 ++++ crates/autophagy-store/src/migration.rs | 14 +- crates/autophagy-store/src/store.rs | 17 +- crates/autophagy-store/tests/store.rs | 108 +++++++++++- docs/architecture/database-schema.md | 6 +- docs/decisions/0005-macos-read-only-app.md | 2 +- .../0006-claude-code-install-target.md | 71 ++++++++ docs/guides/shadow-and-installation.md | 56 ++++-- 15 files changed, 596 insertions(+), 64 deletions(-) create mode 100644 crates/autophagy-store/migrations/0007_claude_code_install_target.sql create mode 100644 docs/decisions/0006-claude-code-install-target.md 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..4bc4591 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,8 +1438,9 @@ fn write_report( )?, CommandReport::MutationInstall(report) => writeln!( writer, - "{}\t{}\t{}\t{}", + "{}\t{}\t{}\t{}\t{}", report.installation_id, + report.target, report.relative_path, if report.dry_run { "dry-run" diff --git a/crates/autophagy-cli/tests/cli.rs b/crates/autophagy-cli/tests/cli.rs index 876e022..1beaaa2 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, [ 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..1e7c650 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 { @@ -1916,11 +1919,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..8467e5c 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,98 @@ 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"); +} + fn source(instance_key: &str) -> SourceIdentity { SourceIdentity::new("codex", instance_key).with_display_name("Codex") } @@ -806,6 +898,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..86a0ef7 --- /dev/null +++ b/docs/decisions/0006-claude-code-install-target.md @@ -0,0 +1,71 @@ +# 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. +- **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..034234b 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,41 @@ 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. + +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 +93,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. From 236f31737b744301326a094ce390473c2efaacdf Mon Sep 17 00:00:00 2001 From: Karn Date: Fri, 17 Jul 2026 02:11:44 +0530 Subject: [PATCH 2/2] fix: address PR #21 review nits for Claude Code install target - record_uninstall derives the transition reason (and metadata) from the stored installation target instead of hardcoding the Codex reason, so a Claude Code uninstall now records "Claude Code repo skill uninstalled". - CLI plain-text install output appends target as the last TSV column rather than inserting it mid-row, preserving positional parsers. - Document the evidence-footer asymmetry (Claude Code only, for Codex byte-stability; the DB audit retains evidence IDs for both) in ADR 0006 and the installation guide. - Add a CLI-level real (non-dry-run) install --target claude-code + uninstall round-trip test exercising audit-driven target reconstruction, and assert the claude-code uninstall reason in the store test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015LY7xtercMc3NtLeX2z4YV --- crates/autophagy-cli/src/main.rs | 4 +- crates/autophagy-cli/tests/cli.rs | 116 ++++++++++++++++++ crates/autophagy-store/src/store.rs | 21 +++- crates/autophagy-store/tests/store.rs | 8 ++ .../0006-claude-code-install-target.md | 9 +- docs/guides/shadow-and-installation.md | 7 ++ 6 files changed, 158 insertions(+), 7 deletions(-) diff --git a/crates/autophagy-cli/src/main.rs b/crates/autophagy-cli/src/main.rs index 4bc4591..eee4c96 100644 --- a/crates/autophagy-cli/src/main.rs +++ b/crates/autophagy-cli/src/main.rs @@ -1440,14 +1440,14 @@ fn write_report( writer, "{}\t{}\t{}\t{}\t{}", report.installation_id, - report.target, report.relative_path, if report.dry_run { "dry-run" } 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 1beaaa2..32fef67 100644 --- a/crates/autophagy-cli/tests/cli.rs +++ b/crates/autophagy-cli/tests/cli.rs @@ -780,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-store/src/store.rs b/crates/autophagy-store/src/store.rs index 1e7c650..cd22f70 100644 --- a/crates/autophagy-store/src/store.rs +++ b/crates/autophagy-store/src/store.rs @@ -1251,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 { @@ -1290,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 ], diff --git a/crates/autophagy-store/tests/store.rs b/crates/autophagy-store/tests/store.rs index 8467e5c..d518d7b 100644 --- a/crates/autophagy-store/tests/store.rs +++ b/crates/autophagy-store/tests/store.rs @@ -817,6 +817,14 @@ fn claude_code_installation_registers_audits_and_reverses() { 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 { diff --git a/docs/decisions/0006-claude-code-install-target.md b/docs/decisions/0006-claude-code-install-target.md index 86a0ef7..41a3544 100644 --- a/docs/decisions/0006-claude-code-install-target.md +++ b/docs/decisions/0006-claude-code-install-target.md @@ -40,7 +40,14 @@ and relax the registry constraints with a new migration. 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. + 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 diff --git a/docs/guides/shadow-and-installation.md b/docs/guides/shadow-and-installation.md index 034234b..6f76b41 100644 --- a/docs/guides/shadow-and-installation.md +++ b/docs/guides/shadow-and-installation.md @@ -72,6 +72,13 @@ 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