From 78bdfbdb32f154e99a6ca68d84ca8208c932a358 Mon Sep 17 00:00:00 2001 From: Karn Date: Thu, 16 Jul 2026 17:08:29 +0530 Subject: [PATCH 1/3] feat: add incremental Claude Code adapter --- Cargo.lock | 16 + Cargo.toml | 1 + README.md | 17 +- adapters/claude-code/Cargo.toml | 25 + adapters/claude-code/src/discovery.rs | 149 ++++++ adapters/claude-code/src/importer.rs | 427 ++++++++++++++++ adapters/claude-code/src/lib.rs | 17 + adapters/claude-code/src/normalize.rs | 469 ++++++++++++++++++ ...11111111-1111-4111-8111-111111111111.jsonl | 7 + .../subagents/agent-a.jsonl | 1 + adapters/claude-code/tests/import.rs | 134 +++++ crates/autophagy-cli/Cargo.toml | 1 + crates/autophagy-cli/src/main.rs | 162 +++++- crates/autophagy-cli/tests/cli.rs | 38 ++ .../migrations/0002_source_cursors.sql | 11 + crates/autophagy-store/src/error.rs | 17 + crates/autophagy-store/src/lib.rs | 4 +- crates/autophagy-store/src/migration.rs | 21 +- crates/autophagy-store/src/model.rs | 13 + crates/autophagy-store/src/store.rs | 111 ++++- crates/autophagy-store/tests/store.rs | 52 +- docs/architecture/database-schema.md | 25 +- docs/architecture/repository-structure.md | 7 +- docs/guides/claude-code.md | 68 +++ 24 files changed, 1743 insertions(+), 50 deletions(-) create mode 100644 adapters/claude-code/Cargo.toml create mode 100644 adapters/claude-code/src/discovery.rs create mode 100644 adapters/claude-code/src/importer.rs create mode 100644 adapters/claude-code/src/lib.rs create mode 100644 adapters/claude-code/src/normalize.rs create mode 100644 adapters/claude-code/tests/fixtures/projects/-workspace-demo/11111111-1111-4111-8111-111111111111.jsonl create mode 100644 adapters/claude-code/tests/fixtures/projects/-workspace-demo/11111111-1111-4111-8111-111111111111/subagents/agent-a.jsonl create mode 100644 adapters/claude-code/tests/import.rs create mode 100644 crates/autophagy-store/migrations/0002_source_cursors.sql create mode 100644 docs/guides/claude-code.md diff --git a/Cargo.lock b/Cargo.lock index fc124c1..91b5c13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,10 +87,26 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "autophagy-adapter-claude-code" +version = "0.1.0-alpha.1" +dependencies = [ + "autophagy-events", + "autophagy-store", + "directories", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", + "time", +] + [[package]] name = "autophagy-cli" version = "0.1.0-alpha.1" dependencies = [ + "autophagy-adapter-claude-code", "autophagy-core", "autophagy-store", "clap", diff --git a/Cargo.toml b/Cargo.toml index 38b6a6a..86d30cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "3" members = [ + "adapters/claude-code", "crates/autophagy-cli", "crates/autophagy-core", "crates/autophagy-events", diff --git a/README.md b/README.md index c42d546..d1eeb46 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ change because of what happened?” ## Status Autophagy is in foundation development. Agent Event Protocol (AEP) v0.1, the -transactional local SQLite event store, and a generic JSONL CLI vertical slice -are implemented. No daemon, native-agent adapter, or background capture ships -yet. +transactional local SQLite event store, generic JSONL CLI vertical slice, and +incremental Claude Code history adapter are implemented. No daemon or +background capture ships yet. ## Principles @@ -27,6 +27,7 @@ yet. ## Repository map ```text +adapters/claude-code/ Native transcript discovery and AEP normalization crates/autophagy-cli/ User-facing import, sessions, and search commands crates/autophagy-core/ Reusable streaming import application services crates/autophagy-events/ AEP Rust types, parsing, and validation @@ -65,6 +66,16 @@ See the [generic JSONL guide](docs/guides/generic-jsonl.md) for dry-run, project selection, standard input, JSON output, privacy controls, and exit-code semantics. +Preview the exact Claude Code transcripts selected without writing a database: + +```sh +mise exec -- cargo run -p autophagy-cli -- --output json \ + import --adapter claude-code --dry-run +``` + +See the [Claude Code adapter guide](docs/guides/claude-code.md) for incremental +cursoring, subagents, content policy, and the normalization capability matrix. + ## Try the contract Install [mise](https://mise.jdx.dev/), then run: diff --git a/adapters/claude-code/Cargo.toml b/adapters/claude-code/Cargo.toml new file mode 100644 index 0000000..52d7f20 --- /dev/null +++ b/adapters/claude-code/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "autophagy-adapter-claude-code" +description = "Incremental Claude Code transcript adapter for Autophagy" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +autophagy-events = { path = "../../crates/autophagy-events" } +autophagy-store = { path = "../../crates/autophagy-store" } +directories.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true +time.workspace = true + +[dev-dependencies] +tempfile = "3.27" + +[lints] +workspace = true diff --git a/adapters/claude-code/src/discovery.rs b/adapters/claude-code/src/discovery.rs new file mode 100644 index 0000000..64f5a15 --- /dev/null +++ b/adapters/claude-code/src/discovery.rs @@ -0,0 +1,149 @@ +use std::{ + env, fs, io, + path::{Path, PathBuf}, +}; + +use directories::BaseDirs; +use serde::Serialize; + +/// Whether a transcript belongs to a primary Claude Code session or subagent. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionKind { + /// Top-level session transcript. + Main, + /// Nested `agent-*.jsonl` subagent transcript. + Subagent, +} + +/// Metadata-only description of one transcript selected for import. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct DiscoveredSession { + /// Absolute source path. + pub path: PathBuf, + /// Stable slash-separated path relative to the discovery root. + pub relative_path: String, + /// File size observed during discovery. + pub size_bytes: u64, + /// Transcript category. + pub kind: SessionKind, +} + +/// Discovery controls. Discovery never opens transcript contents. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DiscoveryOptions { + /// Claude Code projects directory, or one explicit JSONL file. + pub input: PathBuf, + /// Include nested subagent transcripts. + pub include_subagents: bool, +} + +/// Exact set of source files an import will consider. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct DiscoveryPlan { + /// Canonical input root or explicit file. + pub root: PathBuf, + /// Sorted, deterministic transcript list. + pub files: Vec, +} + +/// Resolve `${CLAUDE_CONFIG_DIR:-~/.claude}/projects`. +/// +/// # Errors +/// Returns an error if no home directory can be determined. +pub fn default_projects_root() -> Result { + if let Some(config) = env::var_os("CLAUDE_CONFIG_DIR").filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(config).join("projects")); + } + let base = BaseDirs::new().ok_or(DiscoveryError::HomeUnavailable)?; + Ok(base.home_dir().join(".claude/projects")) +} + +/// Discover selected Claude Code transcript files without reading their contents. +/// +/// # Errors +/// Returns an error for an inaccessible input or filesystem traversal failure. +pub fn discover(options: &DiscoveryOptions) -> Result { + let root = fs::canonicalize(&options.input)?; + let metadata = fs::metadata(&root)?; + let mut files = Vec::new(); + if metadata.is_file() { + add_file(&root, &root, options.include_subagents, &mut files)?; + } else if metadata.is_dir() { + walk(&root, &root, options.include_subagents, &mut files)?; + } else { + return Err(DiscoveryError::UnsupportedInput(root)); + } + files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + Ok(DiscoveryPlan { root, files }) +} + +fn walk( + root: &Path, + directory: &Path, + include_subagents: bool, + files: &mut Vec, +) -> Result<(), DiscoveryError> { + let mut entries = fs::read_dir(directory)?.collect::, _>>()?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let file_type = entry.file_type()?; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + walk(root, &entry.path(), include_subagents, files)?; + } else if file_type.is_file() { + add_file(root, &entry.path(), include_subagents, files)?; + } + } + Ok(()) +} + +fn add_file( + root: &Path, + path: &Path, + include_subagents: bool, + files: &mut Vec, +) -> Result<(), DiscoveryError> { + if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { + return Ok(()); + } + let is_subagent = path + .file_stem() + .and_then(|value| value.to_str()) + .is_some_and(|name| name.starts_with("agent-")); + if is_subagent && !include_subagents { + return Ok(()); + } + let relative = if root.is_file() { + path.file_name().map(PathBuf::from).unwrap_or_default() + } else { + path.strip_prefix(root).unwrap_or(path).to_path_buf() + }; + files.push(DiscoveredSession { + path: path.to_path_buf(), + relative_path: relative.to_string_lossy().replace('\\', "/"), + size_bytes: fs::metadata(path)?.len(), + kind: if is_subagent { + SessionKind::Subagent + } else { + SessionKind::Main + }, + }); + Ok(()) +} + +/// Failure while resolving or enumerating Claude Code history. +#[derive(Debug, thiserror::Error)] +pub enum DiscoveryError { + /// Filesystem operation failed. + #[error("could not discover Claude Code transcripts: {0}")] + Io(#[from] io::Error), + /// Platform home directory was unavailable. + #[error("could not determine the home directory for Claude Code history")] + HomeUnavailable, + /// Input was neither a regular file nor directory. + #[error("Claude Code input is not a regular file or directory: {}", .0.display())] + UnsupportedInput(PathBuf), +} diff --git a/adapters/claude-code/src/importer.rs b/adapters/claude-code/src/importer.rs new file mode 100644 index 0000000..7404028 --- /dev/null +++ b/adapters/claude-code/src/importer.rs @@ -0,0 +1,427 @@ +use std::{ + fmt::Write as _, + fs::File, + io::{BufRead, BufReader, Read, Seek, SeekFrom}, + path::PathBuf, +}; + +use autophagy_events::Event; +use autophagy_store::{ + EventStore, InsertOutcome, SearchProjection, SourceCursor, SourceIdentity, StoreError, +}; +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::{ + ADAPTER_NAME, + discovery::{DiscoveryError, DiscoveryOptions, DiscoveryPlan, discover}, + normalize::{FileState, NormalizeContext, normalize_record}, +}; + +/// Controls for one Claude Code history import. +#[allow(clippy::struct_excessive_bools)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClaudeImportOptions { + /// Claude Code projects root, or one explicit transcript file. + pub input: PathBuf, + /// Stable identity for this Claude Code history installation. + pub instance_key: String, + /// Optional source label. + pub display_name: Option, + /// Exact working directories to include; empty includes all. + pub projects: Vec, + /// Include nested `agent-*.jsonl` transcripts. + pub include_subagents: bool, + /// Persist prompt, assistant, and result text in namespaced metadata. + pub include_content: bool, + /// Add tool input to the explicit search projection. + pub index_tool_input: bool, + /// Metadata keys approved for search indexing. + pub index_metadata: Vec, + /// Preview discovery and normalization without database writes. + pub dry_run: bool, + /// Maximum retained diagnostics. + pub max_diagnostics: usize, +} + +impl ClaudeImportOptions { + /// Create conservative defaults for a history root. + #[must_use] + pub fn new(input: PathBuf, instance_key: impl Into) -> Self { + Self { + input, + instance_key: instance_key.into(), + display_name: None, + projects: Vec::new(), + include_subagents: false, + include_content: false, + index_tool_input: false, + index_metadata: Vec::new(), + dry_run: false, + max_diagnostics: 100, + } + } +} + +/// One bounded adapter diagnostic with exact source provenance. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct ClaudeImportDiagnostic { + /// Relative transcript path. + pub file: String, + /// One-based physical line. + pub line: u64, + /// Stable issue category. + pub code: String, + /// Human-readable detail. + pub message: String, +} + +/// Aggregate result and exact discovery plan for a Claude Code import. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct ClaudeImportSummary { + /// Exact metadata-only discovery result. + pub discovery: DiscoveryPlan, + /// Files opened after discovery. + pub files_read: u64, + /// Complete physical JSONL lines read. + pub lines_read: u64, + /// JSON records presented to normalization. + pub records_seen: u64, + /// Normalized AEP events. + pub events_emitted: u64, + /// Newly persisted events. + pub inserted: u64, + /// Identical existing events. + pub duplicates: u64, + /// Same-ID/different-content conflicts quarantined. + pub conflicts: u64, + /// Events excluded by project selection. + pub skipped: u64, + /// Structurally valid but unsupported Claude records. + pub unsupported: u64, + /// Invalid records or normalization failures. + pub rejected: u64, + /// Cursors reset after truncation or source replacement. + pub cursor_resets: u64, + /// Cursors saved after successful incremental reads. + pub cursors_advanced: u64, + /// Files whose incomplete trailing record was deferred. + pub partial_tails: u64, + /// Retained source-addressed diagnostics. + pub diagnostics: Vec, + /// Diagnostics omitted after the configured bound. + pub diagnostics_suppressed: u64, + /// Whether no database writes were performed. + pub dry_run: bool, +} + +impl ClaudeImportSummary { + /// Return whether operator attention is required. + #[must_use] + pub const fn has_issues(&self) -> bool { + self.rejected > 0 || self.conflicts > 0 + } +} + +/// Discover, normalize, and incrementally import Claude Code transcripts. +/// +/// # Errors +/// Returns an error for invalid options, discovery or I/O failures, a missing +/// writable store, or an unrecoverable database failure. +#[allow(clippy::too_many_lines)] +pub fn import_claude_code( + mut store: Option<&mut EventStore>, + options: &ClaudeImportOptions, +) -> Result { + validate_options(options)?; + if !options.dry_run && store.is_none() { + return Err(ClaudeImportError::MissingStore); + } + let discovery = discover(&DiscoveryOptions { + input: options.input.clone(), + include_subagents: options.include_subagents, + })?; + let source = SourceIdentity { + adapter: ADAPTER_NAME.to_owned(), + instance_key: options.instance_key.clone(), + display_name: options.display_name.clone(), + }; + let mut summary = ClaudeImportSummary { + discovery: discovery.clone(), + files_read: 0, + lines_read: 0, + records_seen: 0, + events_emitted: 0, + inserted: 0, + duplicates: 0, + conflicts: 0, + skipped: 0, + unsupported: 0, + rejected: 0, + cursor_resets: 0, + cursors_advanced: 0, + partial_tails: 0, + diagnostics: Vec::new(), + diagnostics_suppressed: 0, + dry_run: options.dry_run, + }; + let scope = project_scope(&options.projects); + + for discovered in &discovery.files { + summary.files_read += 1; + let origin = format!("{scope}:{}", discovered.relative_path); + let mut state = FileState::default(); + let mut offset = 0_u64; + let mut line_number = 0_u64; + if !options.dry_run { + if let Some(cursor) = store + .as_deref() + .ok_or(ClaudeImportError::MissingStore)? + .get_source_cursor(&source, &origin)? + { + if cursor.byte_offset <= discovered.size_bytes + && prefix_hash(&discovered.path, cursor.byte_offset)? == cursor.head_hash + { + offset = cursor.byte_offset; + line_number = cursor.line_number; + state = serde_json::from_value(cursor.state).map_err(|error| { + ClaudeImportError::CursorState { + file: discovered.relative_path.clone(), + source: error, + } + })?; + } else { + summary.cursor_resets += 1; + } + } + } + + let file = File::open(&discovered.path)?; + let mut reader = BufReader::new(file); + reader.seek(SeekFrom::Start(offset))?; + let mut buffer = Vec::new(); + loop { + buffer.clear(); + let bytes = reader.read_until(b'\n', &mut buffer)?; + if bytes == 0 { + break; + } + if buffer.last() != Some(&b'\n') { + summary.partial_tails += 1; + break; + } + offset += u64::try_from(bytes).map_err(|_| ClaudeImportError::PositionOverflow)?; + line_number += 1; + summary.lines_read += 1; + let record_bytes = buffer.strip_suffix(b"\n").unwrap_or(&buffer); + let record_bytes = record_bytes.strip_suffix(b"\r").unwrap_or(record_bytes); + if record_bytes.iter().all(u8::is_ascii_whitespace) { + continue; + } + summary.records_seen += 1; + let record: Value = match serde_json::from_slice(record_bytes) { + Ok(value) => value, + Err(error) => { + reject( + &mut summary, + options, + &discovered.relative_path, + line_number, + "invalid_json", + error.to_string(), + ); + continue; + } + }; + let context = NormalizeContext { + relative_path: &discovered.relative_path, + line: line_number, + include_content: options.include_content, + }; + let outcome = match normalize_record(&record, &mut state, &context) { + Ok(value) => value, + Err(message) => { + reject( + &mut summary, + options, + &discovered.relative_path, + line_number, + "unsupported_shape", + message, + ); + continue; + } + }; + if !outcome.supported { + summary.unsupported += 1; + } + summary.events_emitted += u64::try_from(outcome.events.len()) + .map_err(|_| ClaudeImportError::PositionOverflow)?; + for event in outcome.events { + if !project_selected(&event, &options.projects) { + summary.skipped += 1; + continue; + } + if options.dry_run { + continue; + } + let projection = search_projection(&event, options); + match store + .as_deref_mut() + .ok_or(ClaudeImportError::MissingStore)? + .insert_event(&source, &event, &projection)? + { + InsertOutcome::Inserted { .. } => summary.inserted += 1, + InsertOutcome::Duplicate { .. } => summary.duplicates += 1, + InsertOutcome::ConflictQuarantined { .. } => summary.conflicts += 1, + } + } + } + if !options.dry_run { + let cursor = SourceCursor { + byte_offset: offset, + line_number, + head_hash: prefix_hash(&discovered.path, offset)?, + state: serde_json::to_value(&state)?, + }; + store + .as_deref() + .ok_or(ClaudeImportError::MissingStore)? + .save_source_cursor(&source, &origin, &cursor)?; + summary.cursors_advanced += 1; + } + } + Ok(summary) +} + +fn validate_options(options: &ClaudeImportOptions) -> Result<(), ClaudeImportError> { + if options.instance_key.trim().is_empty() { + return Err(ClaudeImportError::InvalidOptions( + "instance_key must not be blank".to_owned(), + )); + } + if options + .display_name + .as_ref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(ClaudeImportError::InvalidOptions( + "display_name must not be blank".to_owned(), + )); + } + if options.projects.iter().any(|value| value.trim().is_empty()) { + return Err(ClaudeImportError::InvalidOptions( + "project selections must not be blank".to_owned(), + )); + } + Ok(()) +} + +fn project_scope(projects: &[String]) -> String { + let mut selected = projects.to_vec(); + selected.sort(); + let digest = Sha256::digest(selected.join("\0").as_bytes()); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + encoded +} + +fn prefix_hash(path: &PathBuf, consumed: u64) -> Result<[u8; 32], ClaudeImportError> { + let mut file = File::open(path)?; + let limit = consumed.min(4096); + let mut bytes = + vec![0; usize::try_from(limit).map_err(|_| ClaudeImportError::PositionOverflow)?]; + file.read_exact(&mut bytes)?; + Ok(Sha256::digest(bytes).into()) +} + +fn project_selected(event: &Event, projects: &[String]) -> bool { + projects.is_empty() + || event + .project + .as_ref() + .is_some_and(|project| projects.contains(project)) +} + +fn search_projection(event: &Event, options: &ClaudeImportOptions) -> SearchProjection { + let tool_input_text = options + .index_tool_input + .then(|| event.tool.as_ref()?.input.as_ref().map(value_as_text)) + .flatten(); + let searchable_text = options + .index_metadata + .iter() + .filter_map(|key| event.metadata.get(key)) + .map(value_as_text) + .filter(|text| !text.is_empty()) + .collect::>() + .join("\n"); + SearchProjection { + tool_input_text, + searchable_text: (!searchable_text.is_empty()).then_some(searchable_text), + } +} + +fn value_as_text(value: &Value) -> String { + value + .as_str() + .map_or_else(|| value.to_string(), str::to_owned) +} + +fn reject( + summary: &mut ClaudeImportSummary, + options: &ClaudeImportOptions, + file: &str, + line: u64, + code: &str, + message: String, +) { + summary.rejected += 1; + if summary.diagnostics.len() < options.max_diagnostics { + summary.diagnostics.push(ClaudeImportDiagnostic { + file: file.to_owned(), + line, + code: code.to_owned(), + message, + }); + } else { + summary.diagnostics_suppressed += 1; + } +} + +/// Fatal Claude Code adapter failure. +#[derive(Debug, thiserror::Error)] +pub enum ClaudeImportError { + /// Option validation failed. + #[error("invalid Claude Code import option: {0}")] + InvalidOptions(String), + /// Metadata-only discovery failed. + #[error(transparent)] + Discovery(#[from] DiscoveryError), + /// Transcript I/O failed. + #[error("could not read Claude Code transcript: {0}")] + Io(#[from] std::io::Error), + /// Database operation failed. + #[error(transparent)] + Store(#[from] StoreError), + /// Cursor JSON could not be encoded. + #[error("could not serialize Claude Code cursor state: {0}")] + Json(#[from] serde_json::Error), + /// Persisted cursor state is invalid. + #[error("invalid cursor state for {file}: {source}")] + CursorState { + /// Relative transcript path containing the cursor. + file: String, + /// JSON shape error. + source: serde_json::Error, + }, + /// Non-preview imports need a store. + #[error("a writable event store is required unless dry_run is enabled")] + MissingStore, + /// Source position exceeded supported integer ranges. + #[error("Claude Code source position exceeds supported integer range")] + PositionOverflow, +} diff --git a/adapters/claude-code/src/lib.rs b/adapters/claude-code/src/lib.rs new file mode 100644 index 0000000..f8faa86 --- /dev/null +++ b/adapters/claude-code/src/lib.rs @@ -0,0 +1,17 @@ +//! Incremental, privacy-conscious import of Claude Code session transcripts. + +mod discovery; +mod importer; +mod normalize; + +pub use discovery::{ + DiscoveredSession, DiscoveryError, DiscoveryOptions, DiscoveryPlan, SessionKind, + default_projects_root, discover, +}; +pub use importer::{ + ClaudeImportDiagnostic, ClaudeImportError, ClaudeImportOptions, ClaudeImportSummary, + import_claude_code, +}; + +/// Stable adapter identifier written to AEP envelopes and source provenance. +pub const ADAPTER_NAME: &str = "claude-code"; diff --git a/adapters/claude-code/src/normalize.rs b/adapters/claude-code/src/normalize.rs new file mode 100644 index 0000000..11edfa4 --- /dev/null +++ b/adapters/claude-code/src/normalize.rs @@ -0,0 +1,469 @@ +use std::{ + collections::{BTreeMap, HashMap}, + fmt::Write as _, +}; + +use autophagy_events::{ + Artifact, ArtifactKind, Event, EventId, EventKind, SessionId, SpecVersion, ToolCall, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +use crate::ADAPTER_NAME; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(crate) struct FileState { + pub next_sequence: u64, + pub started: bool, + pub pending_tools: HashMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct PendingTool { + pub name: String, + pub input: Option, + pub event_id: String, +} + +pub(crate) struct NormalizeContext<'a> { + pub relative_path: &'a str, + pub line: u64, + pub include_content: bool, +} + +pub(crate) struct RecordOutcome { + pub events: Vec, + pub supported: bool, +} + +pub(crate) fn normalize_record( + record: &Value, + state: &mut FileState, + context: &NormalizeContext<'_>, +) -> Result { + let object = record.as_object().ok_or("record must be a JSON object")?; + let record_type = string(object, "type").unwrap_or_default(); + let timestamp = object + .get("timestamp") + .and_then(Value::as_str) + .map(|value| OffsetDateTime::parse(value, &Rfc3339)) + .transpose() + .map_err(|error| format!("invalid timestamp: {error}"))?; + let session = session_id(object, context.relative_path); + let project = string(object, "cwd").map(str::to_owned); + let record_key = string(object, "uuid") + .or_else(|| string(object, "id")) + .map_or_else(|| format!("line-{}", context.line), str::to_owned); + let mut events = Vec::new(); + + let potentially_supported = matches!(record_type, "user" | "assistant" | "summary") + || (record_type == "system" + && string(object, "subtype").is_some_and(|value| value.contains("compact"))); + if potentially_supported && !state.started { + let timestamp = timestamp.ok_or("supported record is missing timestamp")?; + events.push(base_event( + state, + &session, + timestamp, + project.clone(), + EventKind::SessionStarted, + event_id(context.relative_path, &record_key, "session-start", 0), + provenance(object, context, None), + )); + state.started = true; + } + + match record_type { + "user" => normalize_user( + object, + timestamp.ok_or("user record is missing timestamp")?, + &session, + project, + &record_key, + state, + context, + &mut events, + )?, + "assistant" => normalize_assistant( + object, + timestamp.ok_or("assistant record is missing timestamp")?, + &session, + project.as_ref(), + &record_key, + state, + context, + &mut events, + )?, + "summary" => { + let mut metadata = provenance(object, context, None); + if context.include_content { + if let Some(summary) = object.get("summary") { + metadata.insert("claude.content".to_owned(), summary.clone()); + } + } + events.push(base_event( + state, + &session, + timestamp.ok_or("summary record is missing timestamp")?, + project, + EventKind::ContextCompacted, + event_id(context.relative_path, &record_key, "compacted", 0), + metadata, + )); + } + "system" if string(object, "subtype").is_some_and(|value| value.contains("compact")) => { + events.push(base_event( + state, + &session, + timestamp.ok_or("compaction record is missing timestamp")?, + project, + EventKind::ContextCompacted, + event_id(context.relative_path, &record_key, "compacted", 0), + provenance(object, context, None), + )); + } + _ => { + return Ok(RecordOutcome { + events, + supported: false, + }); + } + } + + Ok(RecordOutcome { + events, + supported: true, + }) +} + +#[allow(clippy::too_many_arguments)] +fn normalize_user( + object: &Map, + timestamp: OffsetDateTime, + session: &SessionId, + project: Option, + record_key: &str, + state: &mut FileState, + context: &NormalizeContext<'_>, + events: &mut Vec, +) -> Result<(), String> { + let message_content = object + .get("message") + .and_then(|message| message.get("content")); + match message_content { + Some(Value::String(text)) => { + let mut metadata = provenance(object, context, None); + if context.include_content { + metadata.insert("claude.content".to_owned(), Value::String(text.clone())); + } + events.push(base_event( + state, + session, + timestamp, + project, + EventKind::PromptSubmitted, + event_id(context.relative_path, record_key, "prompt", 0), + metadata, + )); + } + Some(Value::Array(blocks)) => { + let text = blocks + .iter() + .filter(|block| block.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|block| block.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + if !text.is_empty() { + let mut metadata = provenance(object, context, None); + if context.include_content { + metadata.insert("claude.content".to_owned(), Value::String(text)); + } + events.push(base_event( + state, + session, + timestamp, + project.clone(), + EventKind::PromptSubmitted, + event_id(context.relative_path, record_key, "prompt", 0), + metadata, + )); + } + for (index, block) in blocks.iter().enumerate().filter(|(_, block)| { + block.get("type").and_then(Value::as_str) == Some("tool_result") + }) { + normalize_tool_result( + object, + block, + index, + timestamp, + session, + project.clone(), + record_key, + state, + context, + events, + )?; + } + } + _ => return Err("user record has no supported message content".to_owned()), + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn normalize_assistant( + object: &Map, + timestamp: OffsetDateTime, + session: &SessionId, + project: Option<&String>, + record_key: &str, + state: &mut FileState, + context: &NormalizeContext<'_>, + events: &mut Vec, +) -> Result<(), String> { + let blocks = object + .get("message") + .and_then(|message| message.get("content")) + .and_then(Value::as_array) + .ok_or("assistant record has no content blocks")?; + let text = blocks + .iter() + .filter(|block| block.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|block| block.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + if !text.is_empty() { + let mut metadata = provenance(object, context, None); + if context.include_content { + metadata.insert("claude.content".to_owned(), Value::String(text)); + } + events.push(base_event( + state, + session, + timestamp, + project.cloned(), + EventKind::DecisionRecorded, + event_id(context.relative_path, record_key, "decision", 0), + metadata, + )); + } + for (index, block) in blocks + .iter() + .enumerate() + .filter(|(_, block)| block.get("type").and_then(Value::as_str) == Some("tool_use")) + { + let native_id = block + .get("id") + .and_then(Value::as_str) + .ok_or("tool_use block is missing id")?; + let name = block + .get("name") + .and_then(Value::as_str) + .ok_or("tool_use block is missing name")? + .to_owned(); + let input = block.get("input").cloned(); + let id = event_id(context.relative_path, record_key, "tool-called", index); + let mut event = base_event( + state, + session, + timestamp, + project.cloned(), + EventKind::ToolCalled, + id.clone(), + provenance(object, context, Some(index)), + ); + event.artifacts = file_artifacts(input.as_ref()); + event.tool = Some(ToolCall { + name: name.clone(), + input: input.clone(), + exit_code: None, + duration_ms: None, + metadata: BTreeMap::new(), + }); + state.pending_tools.insert( + native_id.to_owned(), + PendingTool { + name, + input, + event_id: id.as_str().to_owned(), + }, + ); + events.push(event); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn normalize_tool_result( + object: &Map, + block: &Value, + index: usize, + timestamp: OffsetDateTime, + session: &SessionId, + project: Option, + record_key: &str, + state: &mut FileState, + context: &NormalizeContext<'_>, + events: &mut Vec, +) -> Result<(), String> { + let native_id = block + .get("tool_use_id") + .and_then(Value::as_str) + .ok_or("tool_result block is missing tool_use_id")?; + let Some(pending) = state.pending_tools.remove(native_id) else { + // Compacted transcripts can retain a result after its call disappeared. + // Skipping preserves evidence integrity instead of inventing tool data. + return Ok(()); + }; + let failed = block + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false); + let result_content = block.get("content"); + let exit_code = if failed { + Some(parse_exit_code(result_content).unwrap_or(1)) + } else { + Some(0) + }; + let kind = if failed { + EventKind::ToolFailed + } else { + EventKind::ToolCompleted + }; + let mut metadata = provenance(object, context, Some(index)); + metadata.insert( + "claude.tool_use_id".to_owned(), + Value::String(native_id.to_owned()), + ); + if context.include_content { + if let Some(content) = result_content { + metadata.insert("claude.content".to_owned(), content.clone()); + } + } + let mut event = base_event( + state, + session, + timestamp, + project, + kind, + event_id(context.relative_path, record_key, "tool-result", index), + metadata, + ); + event.parent_event_id = Some(EventId::new(pending.event_id)); + event.artifacts = file_artifacts(pending.input.as_ref()); + event.tool = Some(ToolCall { + name: pending.name, + input: pending.input, + exit_code, + duration_ms: None, + metadata: BTreeMap::new(), + }); + events.push(event); + Ok(()) +} + +fn base_event( + state: &mut FileState, + session_id: &SessionId, + timestamp: OffsetDateTime, + project: Option, + kind: EventKind, + event_id: EventId, + metadata: BTreeMap, +) -> Event { + let sequence = state.next_sequence; + state.next_sequence += 1; + Event { + spec_version: SpecVersion::V0_1, + event_id, + session_id: session_id.clone(), + timestamp, + sequence: Some(sequence), + source: ADAPTER_NAME.to_owned(), + kind, + project, + parent_event_id: None, + tool: None, + artifacts: Vec::new(), + metadata, + } +} + +fn provenance( + object: &Map, + context: &NormalizeContext<'_>, + block_index: Option, +) -> BTreeMap { + let mut metadata = BTreeMap::from([ + ( + "claude.source_file".to_owned(), + Value::String(context.relative_path.to_owned()), + ), + ("claude.line".to_owned(), json!(context.line)), + ]); + if let Some(uuid) = string(object, "uuid") { + metadata.insert( + "claude.record_uuid".to_owned(), + Value::String(uuid.to_owned()), + ); + } + if let Some(index) = block_index { + metadata.insert("claude.block_index".to_owned(), json!(index)); + } + metadata +} + +fn session_id(_object: &Map, relative_path: &str) -> SessionId { + SessionId::new(format!("ses_claude_{}", digest(relative_path))) +} + +fn event_id(relative_path: &str, record_key: &str, role: &str, index: usize) -> EventId { + EventId::new(format!( + "evt_claude_{}", + digest(&format!("{relative_path}\0{record_key}\0{role}\0{index}")) + )) +} + +fn digest(value: &str) -> String { + let digest = Sha256::digest(value.as_bytes()); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + encoded +} + +fn string<'a>(object: &'a Map, key: &str) -> Option<&'a str> { + object.get(key).and_then(Value::as_str) +} + +fn parse_exit_code(content: Option<&Value>) -> Option { + let text = match content? { + Value::String(value) => value.as_str(), + Value::Array(values) => values + .iter() + .find_map(|value| value.get("text").and_then(Value::as_str))?, + _ => return None, + }; + text.lines() + .find_map(|line| line.trim().strip_prefix("Exit code ")?.trim().parse().ok()) +} + +fn file_artifacts(input: Option<&Value>) -> Vec { + let Some(path) = input + .and_then(|value| value.get("file_path")) + .and_then(Value::as_str) + else { + return Vec::new(); + }; + vec![Artifact { + kind: ArtifactKind::File, + path: Some(path.to_owned()), + uri: None, + digest: None, + metadata: BTreeMap::new(), + }] +} diff --git a/adapters/claude-code/tests/fixtures/projects/-workspace-demo/11111111-1111-4111-8111-111111111111.jsonl b/adapters/claude-code/tests/fixtures/projects/-workspace-demo/11111111-1111-4111-8111-111111111111.jsonl new file mode 100644 index 0000000..939b998 --- /dev/null +++ b/adapters/claude-code/tests/fixtures/projects/-workspace-demo/11111111-1111-4111-8111-111111111111.jsonl @@ -0,0 +1,7 @@ +{"type":"user","uuid":"r1","sessionId":"11111111-1111-4111-8111-111111111111","timestamp":"2026-07-16T08:00:00Z","cwd":"/workspace/demo","message":{"role":"user","content":"Please inspect the build."}} +{"type":"assistant","uuid":"r2","sessionId":"11111111-1111-4111-8111-111111111111","timestamp":"2026-07-16T08:00:01Z","cwd":"/workspace/demo","message":{"role":"assistant","content":[{"type":"text","text":"I will run the build."},{"type":"tool_use","id":"tool-1","name":"Bash","input":{"command":"mise run check"}}]}} +{"type":"user","uuid":"r3","sessionId":"11111111-1111-4111-8111-111111111111","timestamp":"2026-07-16T08:00:02Z","cwd":"/workspace/demo","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-1","is_error":true,"content":"Exit code 1\nfixture failure"}]}} +{"type":"assistant","uuid":"r4","sessionId":"11111111-1111-4111-8111-111111111111","timestamp":"2026-07-16T08:00:03Z","cwd":"/workspace/demo","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool-2","name":"Read","input":{"file_path":"/workspace/demo/Cargo.toml"}}]}} +{"type":"user","uuid":"r5","sessionId":"11111111-1111-4111-8111-111111111111","timestamp":"2026-07-16T08:00:04Z","cwd":"/workspace/demo","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-2","content":"fixture file contents"}]}} +{"type":"summary","uuid":"r6","sessionId":"11111111-1111-4111-8111-111111111111","timestamp":"2026-07-16T08:00:05Z","cwd":"/workspace/demo","summary":"fixture compacted context"} +{"type":"queue-operation","uuid":"r7","timestamp":"2026-07-16T08:00:06Z"} diff --git a/adapters/claude-code/tests/fixtures/projects/-workspace-demo/11111111-1111-4111-8111-111111111111/subagents/agent-a.jsonl b/adapters/claude-code/tests/fixtures/projects/-workspace-demo/11111111-1111-4111-8111-111111111111/subagents/agent-a.jsonl new file mode 100644 index 0000000..307b4c2 --- /dev/null +++ b/adapters/claude-code/tests/fixtures/projects/-workspace-demo/11111111-1111-4111-8111-111111111111/subagents/agent-a.jsonl @@ -0,0 +1 @@ +{"type":"user","uuid":"s1","sessionId":"11111111-1111-4111-8111-111111111111","timestamp":"2026-07-16T08:01:00Z","cwd":"/workspace/demo","message":{"role":"user","content":"Subagent fixture prompt."}} diff --git a/adapters/claude-code/tests/import.rs b/adapters/claude-code/tests/import.rs new file mode 100644 index 0000000..eaef762 --- /dev/null +++ b/adapters/claude-code/tests/import.rs @@ -0,0 +1,134 @@ +//! Contract tests over anonymized Claude Code-shaped transcript fixtures. + +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use autophagy_adapter_claude_code::{ + ClaudeImportOptions, DiscoveryOptions, SessionKind, discover, import_claude_code, +}; +use autophagy_store::EventStore; + +fn fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/projects") +} + +#[test] +fn discovery_is_sorted_and_subagents_are_opt_in() { + let root = fixture_root(); + let main = discover(&DiscoveryOptions { + input: root.clone(), + include_subagents: false, + }) + .expect("discover main"); + assert_eq!(main.files.len(), 1); + assert_eq!(main.files[0].kind, SessionKind::Main); + + let all = discover(&DiscoveryOptions { + input: root, + include_subagents: true, + }) + .expect("discover all"); + assert_eq!(all.files.len(), 2); + assert_eq!(all.files[1].kind, SessionKind::Subagent); + assert!(all.files[0].relative_path < all.files[1].relative_path); +} + +#[test] +fn import_is_structural_incremental_and_defers_partial_tails() { + let directory = tempfile::tempdir().expect("temp directory"); + copy_tree(&fixture_root(), directory.path()); + let mut store = EventStore::open_in_memory().expect("store"); + let mut options = ClaudeImportOptions::new(directory.path().to_path_buf(), "fixture:claude"); + + let first = import_claude_code(Some(&mut store), &options).expect("first import"); + assert_eq!(first.records_seen, 7); + assert_eq!(first.events_emitted, 8); + assert_eq!(first.inserted, 8); + assert_eq!(first.unsupported, 1); + assert_eq!(first.rejected, 0); + assert_eq!(store.stats().expect("stats").events, 8); + + let second = import_claude_code(Some(&mut store), &options).expect("second import"); + assert_eq!(second.records_seen, 0); + assert_eq!(second.inserted, 0); + + let transcript = directory + .path() + .join("-workspace-demo/11111111-1111-4111-8111-111111111111.jsonl"); + append( + &transcript, + "{\"type\":\"queue-operation\",\"uuid\":\"partial\"}", + ); + let partial = import_claude_code(Some(&mut store), &options).expect("partial import"); + assert_eq!(partial.partial_tails, 1); + assert_eq!(partial.records_seen, 0); + + append(&transcript, "\n"); + let completed = import_claude_code(Some(&mut store), &options).expect("completed import"); + assert_eq!(completed.records_seen, 1); + assert_eq!(completed.unsupported, 1); + + options.dry_run = true; + let preview = import_claude_code(None, &options).expect("preview"); + assert_eq!(preview.discovery.files.len(), 1); + assert_eq!(preview.inserted, 0); + assert!(!preview.discovery.files[0].relative_path.is_empty()); +} + +#[test] +fn pending_tool_state_survives_between_appends() { + let directory = tempfile::tempdir().expect("temp directory"); + let transcript = directory.path().join("session.jsonl"); + fs::write(&transcript, "{\"type\":\"assistant\",\"uuid\":\"call\",\"sessionId\":\"session\",\"timestamp\":\"2026-07-16T09:00:00Z\",\"cwd\":\"/repo\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"Bash\",\"input\":{\"command\":\"false\"}}]}}\n").expect("write call"); + let mut store = EventStore::open_in_memory().expect("store"); + let options = ClaudeImportOptions::new(transcript.clone(), "fixture:pending"); + let first = import_claude_code(Some(&mut store), &options).expect("call import"); + assert_eq!(first.inserted, 2); + + append( + &transcript, + "{\"type\":\"user\",\"uuid\":\"result\",\"sessionId\":\"session\",\"timestamp\":\"2026-07-16T09:00:01Z\",\"cwd\":\"/repo\",\"message\":{\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"t1\",\"is_error\":true,\"content\":\"Exit code 7\"}]}}\n", + ); + let second = import_claude_code(Some(&mut store), &options).expect("result import"); + assert_eq!(second.inserted, 1); + assert_eq!(second.rejected, 0); + assert_eq!(store.stats().expect("stats").events, 3); +} + +#[test] +fn orphaned_tool_results_are_skipped_without_guessing() { + let directory = tempfile::tempdir().expect("temp directory"); + let transcript = directory.path().join("session.jsonl"); + fs::write(&transcript, "{\"type\":\"user\",\"uuid\":\"orphan\",\"timestamp\":\"2026-07-16T09:00:01Z\",\"cwd\":\"/repo\",\"message\":{\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"missing\",\"content\":\"result\"}]}}\n").expect("write orphan"); + let mut store = EventStore::open_in_memory().expect("store"); + let options = ClaudeImportOptions::new(transcript, "fixture:orphan"); + let summary = import_claude_code(Some(&mut store), &options).expect("orphan import"); + assert_eq!(summary.rejected, 0); + assert_eq!(summary.inserted, 1); +} + +fn append(path: &Path, value: &str) { + use std::io::Write; + let mut file = fs::OpenOptions::new() + .append(true) + .open(path) + .expect("open append"); + file.write_all(value.as_bytes()).expect("append fixture"); +} + +fn copy_tree(source: &Path, destination: &Path) { + for entry in fs::read_dir(source) + .expect("read fixture") + .map(Result::unwrap) + { + let target = destination.join(entry.file_name()); + if entry.file_type().expect("file type").is_dir() { + fs::create_dir_all(&target).expect("create fixture directory"); + copy_tree(&entry.path(), &target); + } else { + fs::copy(entry.path(), target).expect("copy fixture"); + } + } +} diff --git a/crates/autophagy-cli/Cargo.toml b/crates/autophagy-cli/Cargo.toml index b2f2279..8277199 100644 --- a/crates/autophagy-cli/Cargo.toml +++ b/crates/autophagy-cli/Cargo.toml @@ -13,6 +13,7 @@ name = "autophagy" path = "src/main.rs" [dependencies] +autophagy-adapter-claude-code = { path = "../../adapters/claude-code" } autophagy-core = { path = "../autophagy-core" } autophagy-store = { path = "../autophagy-store" } clap.workspace = true diff --git a/crates/autophagy-cli/src/main.rs b/crates/autophagy-cli/src/main.rs index 2bfc65f..76cec03 100644 --- a/crates/autophagy-cli/src/main.rs +++ b/crates/autophagy-cli/src/main.rs @@ -7,6 +7,9 @@ use std::{ process::ExitCode, }; +use autophagy_adapter_claude_code::{ + ClaudeImportOptions, ClaudeImportSummary, default_projects_root, import_claude_code, +}; use autophagy_core::{ImportOptions, ImportSummary, import_jsonl}; use autophagy_store::{EventStore, SearchHit, SessionSummary, StoreError}; use clap::{Parser, Subcommand, ValueEnum}; @@ -39,14 +42,24 @@ enum OutputFormat { Json, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum ImportAdapter { + GenericJsonl, + ClaudeCode, +} + #[derive(Debug, Subcommand)] enum Commands { - /// Import normalized AEP JSONL from a file or standard input. + /// Import normalized AEP JSONL or native agent history. Import { - /// JSONL file, or `-` for standard input. - #[arg(default_value = "-", value_name = "FILE")] + /// Input file/root. `-` means stdin for generic JSONL or Claude's default history root. + #[arg(default_value = "-", value_name = "PATH")] input: PathBuf, + /// Source format to discover and normalize. + #[arg(long, value_enum, default_value_t = ImportAdapter::GenericJsonl)] + adapter: ImportAdapter, + /// Stable source identity. Defaults to the canonical input path or `stdin`. #[arg(long, value_name = "KEY")] instance_key: Option, @@ -59,6 +72,14 @@ enum Commands { #[arg(long = "project", value_name = "PATH")] projects: Vec, + /// Include Claude Code `agent-*.jsonl` subagent transcripts. + #[arg(long)] + include_subagents: bool, + + /// Persist Claude prompt, response, and tool-result text in event metadata. + #[arg(long)] + include_content: bool, + /// Index tool input after confirming the source has already been redacted. #[arg(long)] index_tool_input: bool, @@ -97,11 +118,27 @@ enum Commands { #[derive(Debug, Serialize)] #[serde(tag = "command", content = "result", rename_all = "snake_case")] enum CommandReport { - Import(ImportSummary), + Import(ImportReport), Sessions(Vec), Search(Vec), } +#[derive(Debug, Serialize)] +#[serde(untagged)] +enum ImportReport { + Generic(ImportSummary), + ClaudeCode(ClaudeImportSummary), +} + +impl ImportReport { + const fn has_issues(&self) -> bool { + match self { + Self::Generic(summary) => summary.has_issues(), + Self::ClaudeCode(summary) => summary.has_issues(), + } + } +} + impl CommandReport { const fn has_issues(&self) -> bool { match self { @@ -119,6 +156,10 @@ enum CliError { Store(#[from] StoreError), #[error(transparent)] Import(#[from] autophagy_core::ImportError), + #[error(transparent)] + ClaudeImport(#[from] autophagy_adapter_claude_code::ClaudeImportError), + #[error(transparent)] + ClaudeDiscovery(#[from] autophagy_adapter_claude_code::DiscoveryError), #[error("could not serialize command output: {0}")] Json(#[from] serde_json::Error), #[error("could not determine the platform-local application data directory")] @@ -146,33 +187,62 @@ fn execute(cli: Cli) -> Result { match cli.command { Commands::Import { input, + adapter, instance_key, display_name, projects, + include_subagents, + include_content, index_tool_input, index_metadata, dry_run, max_diagnostics, - } => { - let instance_key = instance_key.unwrap_or(derive_instance_key(&input)?); - let mut options = ImportOptions::new(instance_key); - options.display_name = display_name; - options.projects = projects; - options.index_tool_input = index_tool_input; - options.index_metadata = index_metadata; - options.dry_run = dry_run; - options.max_diagnostics = max_diagnostics; - let reader = open_input(&input)?; - - let summary = if dry_run { - import_jsonl(reader, None, &options)? - } else { - let database = resolve_database_path(cli.database)?; - let mut store = open_store(&database)?; - import_jsonl(reader, Some(&mut store), &options)? - }; - Ok(CommandReport::Import(summary)) - } + } => match adapter { + ImportAdapter::GenericJsonl => { + let instance_key = instance_key.unwrap_or(derive_instance_key(&input)?); + let mut options = ImportOptions::new(instance_key); + options.display_name = display_name; + options.projects = projects; + options.index_tool_input = index_tool_input; + options.index_metadata = index_metadata; + options.dry_run = dry_run; + options.max_diagnostics = max_diagnostics; + let reader = open_input(&input)?; + let summary = if dry_run { + import_jsonl(reader, None, &options)? + } else { + let database = resolve_database_path(cli.database)?; + let mut store = open_store(&database)?; + import_jsonl(reader, Some(&mut store), &options)? + }; + Ok(CommandReport::Import(ImportReport::Generic(summary))) + } + ImportAdapter::ClaudeCode => { + let input = if input == Path::new("-") { + default_projects_root()? + } else { + input + }; + let instance_key = instance_key.unwrap_or(derive_instance_key(&input)?); + let mut options = ClaudeImportOptions::new(input, instance_key); + options.display_name = display_name; + options.projects = projects; + options.include_subagents = include_subagents; + options.include_content = include_content; + options.index_tool_input = index_tool_input; + options.index_metadata = index_metadata; + options.dry_run = dry_run; + options.max_diagnostics = max_diagnostics; + let summary = if dry_run { + import_claude_code(None, &options)? + } else { + let database = resolve_database_path(cli.database)?; + let mut store = open_store(&database)?; + import_claude_code(Some(&mut store), &options)? + }; + Ok(CommandReport::Import(ImportReport::ClaudeCode(summary))) + } + }, Commands::Sessions { limit } => { let database = resolve_database_path(cli.database)?; let store = open_store(&database)?; @@ -232,7 +302,12 @@ fn write_report( writeln!(writer)?; } OutputFormat::Text => match report { - CommandReport::Import(summary) => write_import_summary(&mut writer, summary)?, + CommandReport::Import(summary) => match summary { + ImportReport::Generic(summary) => write_import_summary(&mut writer, summary)?, + ImportReport::ClaudeCode(summary) => { + write_claude_import_summary(&mut writer, summary)?; + } + }, CommandReport::Sessions(sessions) => { writeln!(writer, "SESSION\tSOURCE\tEVENTS\tLAST EVENT\tPROJECT")?; for session in sessions { @@ -257,6 +332,43 @@ fn write_report( Ok(()) } +fn write_claude_import_summary( + writer: &mut impl Write, + summary: &ClaudeImportSummary, +) -> io::Result<()> { + writeln!( + writer, + "{} files · {} records · {} events · {} inserted · {} duplicates · {} conflicts · {} unsupported · {} rejected{}", + summary.discovery.files.len(), + summary.records_seen, + summary.events_emitted, + summary.inserted, + summary.duplicates, + summary.conflicts, + summary.unsupported, + summary.rejected, + if summary.dry_run { " · dry run" } else { "" } + )?; + for file in &summary.discovery.files { + writeln!(writer, "{}\t{} bytes", file.relative_path, file.size_bytes)?; + } + for diagnostic in &summary.diagnostics { + writeln!( + writer, + "{}:{} [{}] {}", + diagnostic.file, diagnostic.line, diagnostic.code, diagnostic.message + )?; + } + if summary.diagnostics_suppressed > 0 { + writeln!( + writer, + "{} additional diagnostics suppressed", + summary.diagnostics_suppressed + )?; + } + Ok(()) +} + fn write_import_summary(writer: &mut impl Write, summary: &ImportSummary) -> io::Result<()> { writeln!( writer, diff --git a/crates/autophagy-cli/tests/cli.rs b/crates/autophagy-cli/tests/cli.rs index a5d8fac..c81a443 100644 --- a/crates/autophagy-cli/tests/cli.rs +++ b/crates/autophagy-cli/tests/cli.rs @@ -83,6 +83,44 @@ fn dry_run_with_bad_records_returns_attention_exit_without_creating_database() { assert!(!database.exists()); } +#[test] +fn claude_code_history_imports_and_reimports_incrementally() { + let directory = tempfile::tempdir().expect("temporary directory"); + let database = directory.path().join("autophagy.db"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../adapters/claude-code/tests/fixtures/projects"); + + let imported = run_json( + &database, + [ + "import", + fixture.to_str().expect("UTF-8 path"), + "--adapter", + "claude-code", + ], + ); + assert_eq!(imported["result"]["inserted"], 8); + assert_eq!( + imported["result"]["discovery"]["files"] + .as_array() + .expect("files") + .len(), + 1 + ); + + let repeated = run_json( + &database, + [ + "import", + fixture.to_str().expect("UTF-8 path"), + "--adapter", + "claude-code", + ], + ); + assert_eq!(repeated["result"]["records_seen"], 0); + assert_eq!(repeated["result"]["inserted"], 0); +} + fn run_json(database: &Path, args: [&str; N]) -> Value { let output = command(database) .args(["--output", "json"]) diff --git a/crates/autophagy-store/migrations/0002_source_cursors.sql b/crates/autophagy-store/migrations/0002_source_cursors.sql new file mode 100644 index 0000000..d3be8cb --- /dev/null +++ b/crates/autophagy-store/migrations/0002_source_cursors.sql @@ -0,0 +1,11 @@ +CREATE TABLE source_cursors ( + adapter TEXT NOT NULL CHECK (length(trim(adapter)) BETWEEN 1 AND 128), + instance_key TEXT NOT NULL CHECK (length(trim(instance_key)) > 0), + origin TEXT NOT NULL CHECK (length(trim(origin)) > 0), + byte_offset INTEGER NOT NULL CHECK (byte_offset >= 0), + line_number INTEGER NOT NULL CHECK (line_number >= 0), + head_hash BLOB NOT NULL CHECK (length(head_hash) = 32), + state_json TEXT NOT NULL CHECK (json_valid(state_json)), + updated_at TEXT NOT NULL, + PRIMARY KEY (adapter, instance_key, origin) +) STRICT; diff --git a/crates/autophagy-store/src/error.rs b/crates/autophagy-store/src/error.rs index 1009f53..7d96a38 100644 --- a/crates/autophagy-store/src/error.rs +++ b/crates/autophagy-store/src/error.rs @@ -62,6 +62,23 @@ pub enum StoreError { /// Rejected zero-based artifact position. ordinal: usize, }, + /// An incremental cursor cannot fit `SQLite`'s signed integer representation. + #[error("cursor {field} value {value} exceeds SQLite's integer range")] + CursorOutOfRange { + /// Rejected cursor field. + field: &'static str, + /// Rejected unsigned value. + value: u64, + }, + /// An incremental cursor origin was blank. + #[error("cursor origin must not be empty or whitespace")] + InvalidCursorOrigin, + /// Persisted cursor state violated a database invariant. + #[error("persisted cursor contains an invalid {field} value")] + CorruptCursor { + /// Invalid cursor field. + field: &'static str, + }, /// An applied migration's SQL no longer matches the compiled migration. #[error("migration {version} checksum does not match the compiled migration")] MigrationDrift { diff --git a/crates/autophagy-store/src/lib.rs b/crates/autophagy-store/src/lib.rs index 6f237fa..4b2b99f 100644 --- a/crates/autophagy-store/src/lib.rs +++ b/crates/autophagy-store/src/lib.rs @@ -12,7 +12,7 @@ mod util; pub use error::StoreError; pub use model::{ - DeleteSummary, InsertOutcome, SearchHit, SearchProjection, SessionSummary, SourceIdentity, - StoreStats, + DeleteSummary, InsertOutcome, SearchHit, SearchProjection, SessionSummary, SourceCursor, + SourceIdentity, StoreStats, }; pub use store::EventStore; diff --git a/crates/autophagy-store/src/migration.rs b/crates/autophagy-store/src/migration.rs index 5b0b444..0670ff7 100644 --- a/crates/autophagy-store/src/migration.rs +++ b/crates/autophagy-store/src/migration.rs @@ -19,11 +19,18 @@ struct Migration { sql: &'static str, } -const MIGRATIONS: &[Migration] = &[Migration { - version: 1, - description: "initial event store", - sql: include_str!("../migrations/0001_initial.sql"), -}]; +const MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + description: "initial event store", + sql: include_str!("../migrations/0001_initial.sql"), + }, + Migration { + version: 2, + description: "incremental source cursors", + sql: include_str!("../migrations/0002_source_cursors.sql"), + }, +]; pub(crate) fn apply(connection: &mut Connection) -> Result<(), StoreError> { connection.execute_batch(BOOTSTRAP_SQL)?; @@ -109,14 +116,14 @@ mod tests { connection .execute( "INSERT INTO schema_migrations(version, description, checksum, applied_at) - VALUES (2, 'future', ?1, '2026-07-16T00:00:00Z')", + VALUES (3, 'future', ?1, '2026-07-16T00:00:00Z')", params![[7_u8; 32].as_slice()], ) .expect("future migration"); assert!(matches!( apply(&mut connection), - Err(StoreError::DatabaseTooNew { version: 2 }) + Err(StoreError::DatabaseTooNew { version: 3 }) )); } } diff --git a/crates/autophagy-store/src/model.rs b/crates/autophagy-store/src/model.rs index 293bde4..311c828 100644 --- a/crates/autophagy-store/src/model.rs +++ b/crates/autophagy-store/src/model.rs @@ -11,6 +11,19 @@ pub struct SourceIdentity { pub display_name: Option, } +/// Durable position and adapter state for an append-only source file. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct SourceCursor { + /// Bytes safely consumed from the beginning of the source. + pub byte_offset: u64, + /// Complete physical lines safely consumed. + pub line_number: u64, + /// SHA-256 of the source's first bounded block for rotation detection. + pub head_hash: [u8; 32], + /// Adapter-defined JSON required to resume normalization correctly. + pub state: serde_json::Value, +} + impl SourceIdentity { /// Create a source identity without a display label. #[must_use] diff --git a/crates/autophagy-store/src/store.rs b/crates/autophagy-store/src/store.rs index ce66f43..5ed6b96 100644 --- a/crates/autophagy-store/src/store.rs +++ b/crates/autophagy-store/src/store.rs @@ -4,8 +4,8 @@ use autophagy_events::{Event, EventKind}; use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params}; use crate::{ - DeleteSummary, InsertOutcome, SearchHit, SearchProjection, SessionSummary, SourceIdentity, - StoreError, StoreStats, migration, util, + DeleteSummary, InsertOutcome, SearchHit, SearchProjection, SessionSummary, SourceCursor, + SourceIdentity, StoreError, StoreStats, migration, util, }; /// Transactional owner of one local Autophagy `SQLite` database. @@ -47,6 +47,105 @@ impl EventStore { )?) } + /// Load an adapter's durable cursor for one source origin. + /// + /// # Errors + /// + /// Returns [`StoreError`] for invalid source identity, blank origin, + /// corrupt persisted state, or a database failure. + pub fn get_source_cursor( + &self, + source: &SourceIdentity, + origin: &str, + ) -> Result, StoreError> { + validate_source(source)?; + validate_cursor_origin(origin)?; + let stored = self + .connection + .query_row( + "SELECT byte_offset, line_number, head_hash, state_json + FROM source_cursors + WHERE adapter = ?1 AND instance_key = ?2 AND origin = ?3", + params![source.adapter, source.instance_key, origin], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Vec>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .optional()?; + let Some((byte_offset, line_number, head_hash, state_json)) = stored else { + return Ok(None); + }; + let head_hash: [u8; 32] = head_hash + .try_into() + .map_err(|_| StoreError::CorruptCursor { field: "head_hash" })?; + Ok(Some(SourceCursor { + byte_offset: u64::try_from(byte_offset).map_err(|_| StoreError::CorruptCursor { + field: "byte_offset", + })?, + line_number: u64::try_from(line_number).map_err(|_| StoreError::CorruptCursor { + field: "line_number", + })?, + head_hash, + state: serde_json::from_str(&state_json)?, + })) + } + + /// Atomically create or replace an adapter's durable cursor. + /// + /// # Errors + /// + /// Returns [`StoreError`] for invalid identity or cursor values, + /// serialization failure, or a database failure. + pub fn save_source_cursor( + &self, + source: &SourceIdentity, + origin: &str, + cursor: &SourceCursor, + ) -> Result<(), StoreError> { + validate_source(source)?; + validate_cursor_origin(origin)?; + let byte_offset = + i64::try_from(cursor.byte_offset).map_err(|_| StoreError::CursorOutOfRange { + field: "byte_offset", + value: cursor.byte_offset, + })?; + let line_number = + i64::try_from(cursor.line_number).map_err(|_| StoreError::CursorOutOfRange { + field: "line_number", + value: cursor.line_number, + })?; + let state_json = serde_json::to_string(&cursor.state)?; + let updated_at = util::now_timestamp()?; + self.connection.execute( + "INSERT INTO source_cursors( + adapter, instance_key, origin, byte_offset, line_number, + head_hash, state_json, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(adapter, instance_key, origin) DO UPDATE SET + byte_offset = excluded.byte_offset, + line_number = excluded.line_number, + head_hash = excluded.head_hash, + state_json = excluded.state_json, + updated_at = excluded.updated_at", + params![ + source.adapter, + source.instance_key, + origin, + byte_offset, + line_number, + cursor.head_hash.as_slice(), + state_json, + updated_at, + ], + )?; + Ok(()) + } + /// Atomically validate and persist one normalized event. /// /// Identical event IDs and content hashes are no-ops. Reusing an event ID @@ -414,6 +513,14 @@ fn validate_source(source: &SourceIdentity) -> Result<(), StoreError> { Ok(()) } +fn validate_cursor_origin(origin: &str) -> Result<(), StoreError> { + if origin.trim().is_empty() { + Err(StoreError::InvalidCursorOrigin) + } else { + Ok(()) + } +} + fn persisted_tool_input(event: &Event) -> Result, serde_json::Error> { event .tool diff --git a/crates/autophagy-store/tests/store.rs b/crates/autophagy-store/tests/store.rs index caa9c73..9dc6397 100644 --- a/crates/autophagy-store/tests/store.rs +++ b/crates/autophagy-store/tests/store.rs @@ -6,8 +6,8 @@ use autophagy_events::{ Artifact, ArtifactKind, Event, EventId, EventKind, SessionId, SpecVersion, ToolCall, }; use autophagy_store::{ - DeleteSummary, EventStore, InsertOutcome, SearchProjection, SourceIdentity, StoreError, - StoreStats, + DeleteSummary, EventStore, InsertOutcome, SearchProjection, SourceCursor, SourceIdentity, + StoreError, StoreStats, }; use serde_json::{Value, json}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; @@ -27,7 +27,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"), 1); + assert_eq!(store.schema_version().expect("schema version"), 2); assert!(matches!( store .insert_event(&source, &event, &SearchProjection::default()) @@ -37,7 +37,7 @@ fn migrations_persist_and_reopen_cleanly() { } let reopened = EventStore::open(&database).expect("store should reopen"); - assert_eq!(reopened.schema_version().expect("schema version"), 1); + assert_eq!(reopened.schema_version().expect("schema version"), 2); assert_eq!( reopened .get_event(event.event_id.as_str()) @@ -46,6 +46,50 @@ fn migrations_persist_and_reopen_cleanly() { ); } +#[test] +fn source_cursors_round_trip_and_update() { + let store = EventStore::open_in_memory().expect("store"); + let source = source("instance-cursor"); + assert_eq!( + store + .get_source_cursor(&source, "project/session.jsonl") + .expect("missing cursor"), + None + ); + + let mut cursor = SourceCursor { + byte_offset: 120, + line_number: 3, + head_hash: [7; 32], + state: json!({"pending": {"tool-1": "bash"}}), + }; + store + .save_source_cursor(&source, "project/session.jsonl", &cursor) + .expect("save cursor"); + assert_eq!( + store + .get_source_cursor(&source, "project/session.jsonl") + .expect("load cursor"), + Some(cursor.clone()) + ); + + cursor.byte_offset = 240; + cursor.line_number = 6; + store + .save_source_cursor(&source, "project/session.jsonl", &cursor) + .expect("update cursor"); + assert_eq!( + store + .get_source_cursor(&source, "project/session.jsonl") + .expect("load updated cursor"), + Some(cursor) + ); + assert!(matches!( + store.get_source_cursor(&source, " "), + Err(StoreError::InvalidCursorOrigin) + )); +} + #[test] fn insertion_rolls_up_sessions_and_indexes_only_approved_text() { let mut store = EventStore::open_in_memory().expect("store should open"); diff --git a/docs/architecture/database-schema.md b/docs/architecture/database-schema.md index 0ab3732..016d92f 100644 --- a/docs/architecture/database-schema.md +++ b/docs/architecture/database-schema.md @@ -1,14 +1,14 @@ # Milestone 1 database schema -Status: implemented in PR 2 (2026-07-16) +Status: implemented through PR 4 (2026-07-16) SQLite is the single-user source of truth. Foreign keys and WAL mode are enabled per connection. Timestamps are canonical RFC 3339 UTC strings so exports remain readable; sortable integer sequence fields break same-timestamp ties. -The authoritative DDL is the immutable -[`0001_initial.sql`](../../crates/autophagy-store/migrations/0001_initial.sql) -migration. The logical schema and its trust boundaries are summarized here. +The authoritative DDL lives in the ordered, immutable files under +[`crates/autophagy-store/migrations`](../../crates/autophagy-store/migrations). +The logical schema and its trust boundaries are summarized here. ```sql CREATE TABLE schema_migrations ( @@ -132,6 +132,18 @@ CREATE TABLE imports ( UNIQUE (source_id, origin, fingerprint) ) STRICT; +CREATE TABLE source_cursors ( + adapter TEXT NOT NULL, + instance_key TEXT NOT NULL, + origin TEXT NOT NULL, + byte_offset INTEGER NOT NULL CHECK (byte_offset >= 0), + line_number INTEGER NOT NULL CHECK (line_number >= 0), + head_hash BLOB NOT NULL CHECK (length(head_hash) = 32), + state_json TEXT NOT NULL CHECK (json_valid(state_json)), + updated_at TEXT NOT NULL, + PRIMARY KEY (adapter, instance_key, origin) +) STRICT; + CREATE VIRTUAL TABLE events_fts USING fts5( project_path, tool_name, @@ -163,6 +175,11 @@ are never indexed blindly. 5. Source-file fingerprints and cursors avoid rescanning unchanged inputs, but correctness does not depend on that optimization. +`source_cursors` stores the last complete byte and physical-line boundary plus +adapter-defined state. The Claude Code adapter includes pending tool calls in +that state so a result appended in a later run can still link to its call. A +bounded prefix hash detects replacement or truncation and resets safely. + ## Deletion Deleting a session cascades through events, conflict records, event-to-artifact diff --git a/docs/architecture/repository-structure.md b/docs/architecture/repository-structure.md index 43ffac6..c970c82 100644 --- a/docs/architecture/repository-structure.md +++ b/docs/architecture/repository-structure.md @@ -43,9 +43,10 @@ autophagy/ └── website/ ``` -`autophagy-events`, `autophagy-store`, `autophagy-core`, and `autophagy-cli` -exist through PR 3. A crate or package is added when its PR contains an -executable vertical slice; empty placeholder crates are avoided. +`autophagy-events`, `autophagy-store`, `autophagy-core`, `autophagy-cli`, and +the native Claude Code adapter exist through PR 4. A crate or package is added +when its PR contains an executable vertical slice; empty placeholder crates are +avoided. ## Dependency direction diff --git a/docs/guides/claude-code.md b/docs/guides/claude-code.md new file mode 100644 index 0000000..3c5ac52 --- /dev/null +++ b/docs/guides/claude-code.md @@ -0,0 +1,68 @@ +# Claude Code adapter + +The adapter discovers Claude Code session transcripts, normalizes supported +records into AEP v0.1, and resumes at the last complete JSONL boundary. Claude +documents transcripts under `~/.claude/projects//.jsonl`; +`CLAUDE_CONFIG_DIR` changes the configuration root. See Claude's official +[session documentation](https://code.claude.com/docs/en/sessions) and +[environment-variable reference](https://code.claude.com/docs/en/env-vars). + +## Preview and import + +Preview the exact metadata-only discovery plan. This opens no database and the +JSON result lists every selected path and observed byte size: + +```sh +mise exec -- cargo run -p autophagy-cli -- --output json \ + import --adapter claude-code --dry-run +``` + +Import the default history root: + +```sh +mise exec -- cargo run -p autophagy-cli -- \ + import --adapter claude-code +``` + +Pass a directory or one transcript explicitly after `import`. Primary sessions +are selected by default; add `--include-subagents` for nested +`agent-*.jsonl` transcripts. Repeat `--project /exact/working/directory` to +limit stored events. Project-filtered imports use independent cursor scopes. + +## Content and search policy + +By default, prompt text, assistant text, and tool-result output are not copied +into normalized event metadata. `--include-content` opts into local persistence +under `claude.content`. Tool inputs remain part of the structural AEP tool call, +but they are excluded from FTS unless `--index-tool-input` is supplied. + +Use `--index-metadata claude.content` only with `--include-content` and only when +that local content is approved for search. Changing `--include-content` after a +history has already been imported changes canonical event content; use a fresh +database when intentionally changing that evidence policy. + +## Incremental behavior + +Only newline-terminated records advance a cursor. An actively written partial +tail is deferred until the next run. Each cursor stores a byte offset, physical +line number, bounded prefix hash, event sequence, and unmatched tool calls. +Truncated or replaced files reset automatically; stable event IDs make the +rescan idempotent. + +## Capability matrix + +| Claude Code shape | AEP v0.1 event | Notes | +|---|---|---| +| First supported record | `session.started` | Deterministic synthetic boundary | +| User text message | `prompt.submitted` | Text omitted unless opted in | +| Assistant text block(s) | `decision.recorded` | Text omitted unless opted in | +| Assistant `tool_use` | `tool.called` | Input and file artifact preserved | +| User `tool_result` | `tool.completed` / `tool.failed` | Linked when its call is present; orphaned results are skipped rather than guessed | +| Summary or compaction system record | `context.compacted` | Summary omitted unless opted in | +| Subagent transcript | Same mappings | Opt-in discovery; distinct session ID | +| Queue, title, attachment, snapshot, and other metadata | Unsupported/skipped | Counted, never guessed | +| User correction or rejection | Not inferred | Requires explicit future source evidence | + +Every emitted event carries `claude.source_file` and `claude.line`; native record +UUID and content-block index are retained when present. Those fields allow an +operator to trace normalized evidence back to the exact local source record. From 81f2f863547a1fb59762d75ef7450cdf283adf81 Mon Sep 17 00:00:00 2001 From: Karn Date: Thu, 16 Jul 2026 17:12:28 +0530 Subject: [PATCH 2/3] maint: prevent concurrent Rust setup in CI --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40e0d4b..4387d10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,5 +31,8 @@ jobs: version: 2026.6.1 cache: true + - name: Warm Rust toolchain + run: mise exec -- rustc --version + - name: Run quality gate run: mise run check From 65ceefad806375cb461be91043e67cfac4676f12 Mon Sep 17 00:00:00 2001 From: Karn Date: Thu, 16 Jul 2026 17:13:49 +0530 Subject: [PATCH 3/3] fix: install Rust quality components in CI --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4387d10..41608a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,8 +31,8 @@ jobs: version: 2026.6.1 cache: true - - name: Warm Rust toolchain - run: mise exec -- rustc --version + - name: Install Rust quality components + run: mise exec -- rustup component add clippy rustfmt - name: Run quality gate run: mise run check