diff --git a/.claude/settings.json b/.claude/settings.json index 87ee9495..b025507b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,6 +1,16 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", "hooks": { + "PostModelSwitch": [ + { + "hooks": [ + { + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/run-sce-or-show-install-guidance.sh\" sce hooks claude-model-state", + "type": "command" + } + ] + } + ], "PostToolUse": [ { "hooks": [ @@ -31,6 +41,16 @@ "matcher": "Bash" } ], + "SessionStart": [ + { + "hooks": [ + { + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/run-sce-or-show-install-guidance.sh\" sce hooks claude-model-state", + "type": "command" + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/.codex/hooks.json b/.codex/hooks.json index b318bd09..70543a09 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -1,46 +1,46 @@ { "hooks": { - "UserPromptSubmit": [ + "PostToolUse": [ { "hooks": [ { - "type": "command", - "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex" + "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex", + "type": "command" } - ] + ], + "matcher": "apply_patch" } ], - "Stop": [ + "PreToolUse": [ { "hooks": [ { - "type": "command", - "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex" + "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex", + "type": "command" } - ] + ], + "matcher": "Bash" } ], - "PreToolUse": [ + "Stop": [ { - "matcher": "Bash", "hooks": [ { - "type": "command", - "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex" + "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex", + "type": "command" } ] } ], - "PostToolUse": [ + "UserPromptSubmit": [ { - "matcher": "apply_patch", "hooks": [ { - "type": "command", - "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex" + "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex", + "type": "command" } ] } ] } -} \ No newline at end of file +} diff --git a/cli/migrations/agent-trace-repository/003_claude_model_state.sql b/cli/migrations/agent-trace-repository/003_claude_model_state.sql new file mode 100644 index 00000000..1015b6b3 --- /dev/null +++ b/cli/migrations/agent-trace-repository/003_claude_model_state.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS claude_model_state ( + session_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + model_id TEXT NOT NULL, + observation_kind TEXT NOT NULL CHECK (observation_kind IN ('session_start', 'post_model_switch')), + source TEXT NOT NULL, + observed_at_ms INTEGER NOT NULL CHECK (observed_at_ms >= 0), + PRIMARY KEY (session_id, agent_id) +); diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index a7dc33bc..8c79db2e 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -323,6 +323,9 @@ pub enum HooksSubcommand { #[command(about = "Run Codex hook (reads JSON payload from STDIN)")] Codex, + + #[command(about = "Run Claude model-state hook (reads JSON payload from STDIN)")] + ClaudeModelState, } #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] diff --git a/cli/src/services/agent_trace_db/mod.rs b/cli/src/services/agent_trace_db/mod.rs index 1c5ae8b7..bfdd4a81 100644 --- a/cli/src/services/agent_trace_db/mod.rs +++ b/cli/src/services/agent_trace_db/mod.rs @@ -66,6 +66,49 @@ VALUES (?1, ?2, ?3, ?4, ?5)"; const SELECT_MESSAGE_EXISTS_SQL: &str = "SELECT 1 FROM messages WHERE session_id = ?1 AND message_id = ?2 LIMIT 1"; +pub const UPSERT_CLAUDE_MODEL_STATE_SQL: &str = "INSERT INTO claude_model_state ( + session_id, + agent_id, + model_id, + observation_kind, + source, + observed_at_ms +) VALUES (?1, ?2, ?3, ?4, ?5, ?6) +ON CONFLICT (session_id, agent_id) DO UPDATE SET + model_id = excluded.model_id, + observation_kind = excluded.observation_kind, + source = excluded.source, + observed_at_ms = excluded.observed_at_ms +WHERE excluded.observed_at_ms > claude_model_state.observed_at_ms + OR ( + excluded.observed_at_ms = claude_model_state.observed_at_ms + AND ( + (excluded.observation_kind = 'post_model_switch' + AND claude_model_state.observation_kind = 'session_start') + OR ( + excluded.observation_kind = claude_model_state.observation_kind + AND ( + excluded.model_id > claude_model_state.model_id + OR ( + excluded.model_id = claude_model_state.model_id + AND excluded.source > claude_model_state.source + ) + ) + ) + ) + )"; + +pub const SELECT_CLAUDE_MODEL_STATE_SQL: &str = "SELECT + session_id, + agent_id, + model_id, + observation_kind, + source, + observed_at_ms +FROM claude_model_state +WHERE session_id = ?1 AND agent_id = ?2 +LIMIT 1"; + const CONVERSATION_TEXT_EVENT_OPERATION_NAME: &str = "insert conversation text event"; const CONVERSATION_TEXT_EVENT_RETRY_HINT: &str = "retry after the database lock clears; if the issue persists, stop other SCE processes using this database and rerun the command"; @@ -81,6 +124,39 @@ pub struct DiffTraceInsert<'a> { pub payload_type: &'a str, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ObservationKind { + SessionStart, + PostModelSwitch, +} + +impl ObservationKind { + fn as_str(self) -> &'static str { + match self { + Self::SessionStart => "session_start", + Self::PostModelSwitch => "post_model_switch", + } + } + + fn from_str(value: &str) -> Result { + match value { + "session_start" => Ok(Self::SessionStart), + "post_model_switch" => Ok(Self::PostModelSwitch), + other => anyhow::bail!("invalid Claude model-state observation kind: {other}"), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClaudeModelStateObservation { + pub session_id: String, + pub agent_id: String, + pub model_id: String, + pub observation_kind: ObservationKind, + pub source: String, + pub observed_at_ms: i64, +} + /// Raw diff trace row read from the agent trace database. #[derive(Clone, Debug, Eq, PartialEq)] pub struct DiffTracePatchRow { @@ -258,6 +334,64 @@ fn insert_agent_trace_with(db: &TursoDb, input: AgentTraceInsert<' ) } +fn upsert_claude_model_state_with( + db: &TursoDb, + input: ClaudeModelStateObservation, +) -> Result { + db.execute( + UPSERT_CLAUDE_MODEL_STATE_SQL, + ( + input.session_id, + input.agent_id, + input.model_id, + input.observation_kind.as_str(), + input.source, + input.observed_at_ms, + ), + ) +} + +fn claude_model_state_by_session_and_agent_with( + db: &TursoDb, + session_id: &str, + agent_id: &str, +) -> Result> { + let rows = db.query_map( + SELECT_CLAUDE_MODEL_STATE_SQL, + (session_id, agent_id), + claude_model_state_observation_from_turso, + )?; + + Ok(rows.into_iter().next()) +} + +fn claude_model_state_observation_from_turso( + row: &turso::Row, +) -> Result { + Ok(ClaudeModelStateObservation { + session_id: row + .get(0) + .context("failed to read claude_model_state.session_id")?, + agent_id: row + .get(1) + .context("failed to read claude_model_state.agent_id")?, + model_id: row + .get(2) + .context("failed to read claude_model_state.model_id")?, + observation_kind: ObservationKind::from_str( + row.get::(3) + .context("failed to read claude_model_state.observation_kind")? + .as_str(), + )?, + source: row + .get(4) + .context("failed to read claude_model_state.source")?, + observed_at_ms: row + .get(5) + .context("failed to read claude_model_state.observed_at_ms")?, + }) +} + #[allow(dead_code)] fn insert_message_with(db: &TursoDb, input: InsertMessageInsert) -> Result { db.execute( diff --git a/cli/src/services/agent_trace_db/repository.rs b/cli/src/services/agent_trace_db/repository.rs index 9cd63d2e..d567c651 100644 --- a/cli/src/services/agent_trace_db/repository.rs +++ b/cli/src/services/agent_trace_db/repository.rs @@ -25,8 +25,9 @@ use crate::{ use super::{ insert_agent_trace_with, insert_conversation_text_event_with, insert_diff_trace_with, insert_message_with, insert_messages_with, insert_part_with, insert_parts_with, - insert_post_commit_patch_intersection_with, recent_diff_trace_patches_with, AgentTraceInsert, - DiffTraceInsert, InsertMessageInsert, InsertPartInsert, PostCommitPatchIntersectionInsert, + insert_post_commit_patch_intersection_with, recent_diff_trace_patches_with, + upsert_claude_model_state_with, AgentTraceInsert, ClaudeModelStateObservation, DiffTraceInsert, + InsertMessageInsert, InsertPartInsert, PostCommitPatchIntersectionInsert, RecentDiffTracePatches, }; @@ -244,6 +245,18 @@ impl RepositoryAgentTraceDb { insert_agent_trace_with(self, input) } + pub fn upsert_claude_model_state(&self, input: ClaudeModelStateObservation) -> Result { + upsert_claude_model_state_with(self, input) + } + + pub fn claude_model_state_by_session_and_agent( + &self, + session_id: &str, + agent_id: &str, + ) -> Result> { + super::claude_model_state_by_session_and_agent_with(self, session_id, agent_id) + } + /// Query and parse recent diff trace patches within the inclusive time /// window for this repository-scoped database. Rows remain repository-level; /// no checkout filter or checkout provenance is applied. @@ -316,7 +329,9 @@ mod tests { }; use super::*; - use crate::services::agent_trace_db::{MessageRole, PartType, PAYLOAD_TYPE_PATCH}; + use crate::services::agent_trace_db::{ + MessageRole, ObservationKind, PartType, PAYLOAD_TYPE_PATCH, + }; fn valid_patch(path: &str, content: &str) -> String { format!( @@ -376,6 +391,22 @@ mod tests { .unwrap_or_else(|| panic!("table '{name}' should exist")) } + fn claude_observation( + model_id: &str, + observation_kind: ObservationKind, + source: &str, + observed_at_ms: i64, + ) -> ClaudeModelStateObservation { + ClaudeModelStateObservation { + session_id: String::from("cc_session-1"), + agent_id: String::new(), + model_id: String::from(model_id), + observation_kind, + source: String::from(source), + observed_at_ms, + } + } + #[test] fn open_at_initializes_the_full_schema_from_one_migration() { let db_path = unique_test_db_path("baseline"); @@ -388,6 +419,7 @@ mod tests { "agent_traces", "messages", "parts", + "claude_model_state", ] { assert!( sqlite_object_exists(&db, "table", table), @@ -426,9 +458,10 @@ mod tests { vec![ String::from("001_repository_schema"), String::from("002_repository_source_instance_id"), + String::from("003_claude_model_state"), ], "repository DBs should be initialized from the baseline schema plus \ - its additive source-instance-id migration" + its additive source-instance-id and Claude model-state migrations" ); db.ensure_schema_ready_for_hooks() @@ -437,6 +470,163 @@ mod tests { remove_test_db(&db_path); } + #[test] + fn pre_claude_model_state_database_is_upgraded_by_the_additive_migration() { + let db_path = unique_test_db_path("claude-model-state-upgrade"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + db.execute("DROP TABLE claude_model_state", ()) + .expect("test should remove the post-003 table"); + db.execute( + "DELETE FROM __sce_migrations WHERE id = '003_claude_model_state'", + (), + ) + .expect("test should remove the post-003 migration record"); + drop(db); + + let upgraded = + RepositoryAgentTraceDb::new_at(&db_path).expect("pre-003 database should upgrade"); + assert!(sqlite_object_exists( + &upgraded, + "table", + "claude_model_state" + )); + let applied_ids = upgraded + .query_map( + "SELECT id FROM __sce_migrations ORDER BY id ASC", + (), + |row| row.get::(0).map_err(Into::into), + ) + .expect("migration metadata query should succeed"); + assert_eq!( + applied_ids, + vec![ + String::from("001_repository_schema"), + String::from("002_repository_source_instance_id"), + String::from("003_claude_model_state"), + ] + ); + + remove_test_db(&db_path); + } + + #[test] + fn claude_model_state_has_exact_scope_and_guarded_deterministic_updates() { + let db_path = unique_test_db_path("claude-model-state"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + + let initial = claude_observation("claude/A", ObservationKind::SessionStart, "startup", 100); + assert_eq!( + db.upsert_claude_model_state(initial.clone()) + .expect("initial write"), + 1 + ); + assert_eq!( + db.claude_model_state_by_session_and_agent("cc_session-1", "") + .expect("state lookup") + .expect("state should exist"), + initial + ); + assert!(db + .claude_model_state_by_session_and_agent("cc_session-1", "subagent") + .expect("subagent lookup") + .is_none()); + assert!(db + .claude_model_state_by_session_and_agent("cc_other", "") + .expect("other session lookup") + .is_none()); + + assert_eq!( + db.upsert_claude_model_state(claude_observation( + "claude/older", + ObservationKind::PostModelSwitch, + "picker", + 99, + )) + .expect("older write should be guarded"), + 0 + ); + assert_eq!( + db.claude_model_state_by_session_and_agent("cc_session-1", "") + .expect("state lookup") + .expect("state should remain") + .model_id, + "claude/A" + ); + + let switched = + claude_observation("claude/B", ObservationKind::PostModelSwitch, "picker", 101); + assert_eq!( + db.upsert_claude_model_state(switched.clone()) + .expect("newer write"), + 1 + ); + assert_eq!( + db.upsert_claude_model_state(switched.clone()) + .expect("identical replay should be harmless"), + 0 + ); + + assert_eq!( + db.upsert_claude_model_state(claude_observation( + "claude/C", + ObservationKind::SessionStart, + "resume", + 101, + )) + .expect("equal-time lower-priority write should be guarded"), + 0 + ); + assert_eq!( + db.claude_model_state_by_session_and_agent("cc_session-1", "") + .expect("state lookup") + .expect("state should remain") + .model_id, + "claude/B" + ); + + remove_test_db(&db_path); + } + + #[test] + fn equal_time_same_kind_observations_use_a_stable_tie_break_and_concurrent_writes_converge() { + let db_path = unique_test_db_path("claude-model-state-concurrent"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + drop(db); + + let db_path = std::sync::Arc::new(db_path); + let handles: Vec<_> = (0..8) + .map(|index| { + let db_path = std::sync::Arc::clone(&db_path); + std::thread::spawn(move || { + let db = RepositoryAgentTraceDb::open_without_migrations_at(&*db_path) + .expect("repository DB should reopen for concurrent state write"); + db.upsert_claude_model_state(claude_observation( + &format!("claude/model-{index}"), + ObservationKind::PostModelSwitch, + "picker", + 500, + )) + .expect("concurrent state write should succeed") + }) + }) + .collect(); + for handle in handles { + handle.join().expect("state writer should not panic"); + } + + let db = RepositoryAgentTraceDb::open_without_migrations_at(&*db_path) + .expect("repository DB should reopen for verification"); + let state = db + .claude_model_state_by_session_and_agent("cc_session-1", "") + .expect("state lookup") + .expect("concurrent writes should leave one state row"); + assert_eq!(state.model_id, "claude/model-7"); + assert_eq!(state.observation_kind, ObservationKind::PostModelSwitch); + assert_eq!(state.observed_at_ms, 500); + + remove_test_db(&db_path); + } + #[test] fn trace_tables_have_no_checkout_id_columns() { let db_path = unique_test_db_path("no-checkout-id"); diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index c6067a29..92bb015e 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -2511,6 +2511,16 @@ mod tests { .bytes } + fn legacy_claude_agent_trace_hook(event: &str) -> serde_json::Value { + serde_json::json!({ + "hooks": [{ + "type": "command", + "command": "bun", + "args": [".claude/plugins/sce-agent-trace.ts", event] + }] + }) + } + fn embedded_opencode_config_bytes() -> &'static [u8] { crate::services::setup::iter_embedded_assets_for_setup_target_with_selection( crate::services::setup::SetupTarget::OpenCode, @@ -2853,6 +2863,148 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + #[test] + fn claude_settings_doctor_repairs_historical_bun_hooks_through_merge_path() { + let root = unique_temp_repository_root("claude-legacy-bun-fix"); + let claude_dir = root.join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + + let existing = serde_json::json!({ + "$schema": "https://old.example/schema.json", + "permissions": {"allow": ["Bash(git *)"]}, + "hooks": { + "SessionStart": [ + { + "hooks": [{ + "type": "command", + "command": "bun", + "args": [".claude/plugins/my-company-hook.ts"] + }] + }, + legacy_claude_agent_trace_hook("SessionStart") + ], + "UserPromptSubmit": [legacy_claude_agent_trace_hook("UserPromptSubmit")], + "PostToolUse": [legacy_claude_agent_trace_hook("PostToolUse")], + "Stop": [legacy_claude_agent_trace_hook("Stop")] + } + }); + let settings_path = claude_dir.join("settings.json"); + std::fs::write( + &settings_path, + serde_json::to_vec_pretty(&existing).unwrap(), + ) + .unwrap(); + + let before = collect_claude_integration_groups(&root, &[]); + let before_child = before + .iter() + .flat_map(|group| &group.children) + .find(|child| child.relative_path == "settings.json") + .expect("settings.json child present before repair"); + assert_eq!( + before_child.content_state, + IntegrationContentState::Mismatch + ); + + let fix_results = super::repair_merge_target_configs(&root, &allowed_policy()); + assert!( + fix_results + .iter() + .any(|result| matches!(result.outcome, super::FixResult::Fixed)), + "doctor --fix should repair historical SCE hooks: {fix_results:?}" + ); + + let repaired: serde_json::Value = + serde_json::from_slice(&std::fs::read(&settings_path).unwrap()).unwrap(); + assert_eq!(repaired["permissions"]["allow"][0], "Bash(git *)"); + assert_eq!( + repaired["hooks"]["SessionStart"][0]["hooks"][0]["args"][0], + ".claude/plugins/my-company-hook.ts" + ); + assert!(!repaired + .to_string() + .contains(".claude/plugins/sce-agent-trace.ts")); + assert_eq!( + repaired["hooks"]["SessionStart"] + .as_array() + .unwrap() + .iter() + .filter(|entry| { + entry["hooks"][0]["command"] + .as_str() + .is_some_and(|command| command.contains("sce hooks claude-model-state")) + }) + .count(), + 1 + ); + + let after = collect_claude_integration_groups(&root, &[]); + let after_child = after + .iter() + .flat_map(|group| &group.children) + .find(|child| child.relative_path == "settings.json") + .expect("settings.json child present after repair"); + assert_eq!(after_child.content_state, IntegrationContentState::Match); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn claude_settings_reports_missing_lifecycle_hook_individually_then_fix_repairs_it() { + for missing_event in ["SessionStart", "PostModelSwitch"] { + let root = unique_temp_repository_root(&format!("claude-lifecycle-{missing_event}")); + let claude_dir = root.join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + + let generated_bytes = embedded_claude_settings_bytes(); + let installed_bytes = + crate::services::setup::config_merge::merge_or_create_claude_settings( + None, + generated_bytes, + "settings.json", + ) + .unwrap(); + let mut drifted: serde_json::Value = serde_json::from_slice(&installed_bytes).unwrap(); + drifted["hooks"][missing_event] = serde_json::json!([]); + let settings_path = claude_dir.join("settings.json"); + std::fs::write(&settings_path, serde_json::to_vec_pretty(&drifted).unwrap()).unwrap(); + + let groups = collect_claude_integration_groups(&root, &[]); + let settings_child = groups + .iter() + .flat_map(|group| &group.children) + .find(|child| child.relative_path == "settings.json") + .expect("settings.json child present"); + assert_eq!( + settings_child.content_state, + IntegrationContentState::Mismatch, + "doctor should report missing {missing_event} as drift" + ); + + let fix_results = super::repair_merge_target_configs(&root, &allowed_policy()); + assert!( + fix_results + .iter() + .any(|result| matches!(result.outcome, super::FixResult::Fixed)), + "doctor --fix should repair missing {missing_event}: {fix_results:?}" + ); + + let repaired: serde_json::Value = + serde_json::from_slice(&std::fs::read(&settings_path).unwrap()).unwrap(); + assert_eq!( + repaired["hooks"][missing_event][0]["hooks"][0]["command"], + r#"bash "$CLAUDE_PROJECT_DIR/.claude/hooks/run-sce-or-show-install-guidance.sh" sce hooks claude-model-state"# + ); + assert!(collect_claude_integration_groups(&root, &[]) + .iter() + .flat_map(|group| &group.children) + .find(|child| child.relative_path == "settings.json") + .is_some_and(|child| { child.content_state == IntegrationContentState::Match })); + + std::fs::remove_dir_all(&root).ok(); + } + } + #[test] fn opencode_config_reports_match_despite_extra_user_plugin_then_drift_and_fix() { let root = unique_temp_repository_root("opencode-fix"); diff --git a/cli/src/services/hooks/claude_model_state.rs b/cli/src/services/hooks/claude_model_state.rs new file mode 100644 index 00000000..dc446240 --- /dev/null +++ b/cli/src/services/hooks/claude_model_state.rs @@ -0,0 +1,778 @@ +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; +use serde_json::Value; + +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::agent_trace_db::{ClaudeModelStateObservation, ObservationKind}; +use crate::services::observability::traits::Logger; + +use super::{ + current_unix_time_ms, normalize_claude_model_id, prefixed_diff_trace_session_id, + read_hook_stdin, CLAUDE_TOOL_NAME, +}; + +const SESSION_START_EVENT: &str = "SessionStart"; +const POST_MODEL_SWITCH_EVENT: &str = "PostModelSwitch"; +const ERROR_EVENT: &str = "sce.hooks.claude_model_state.error"; +const DB_OPEN_FAILED_EVENT: &str = "sce.hooks.claude_model_state.agent_trace_db_open_failed"; +const DB_WRITE_FAILED_EVENT: &str = "sce.hooks.claude_model_state.agent_trace_db_write_failed"; + +pub(super) fn run_claude_model_state_subcommand( + repository_root: &Path, + logger: Option<&dyn Logger>, +) -> String { + let stdin_payload = match read_hook_stdin() { + Ok(payload) => payload, + Err(error) => { + log_fail_open(logger, ERROR_EVENT, &error, None); + return String::new(); + } + }; + let session_id = fail_open_session_id(&stdin_payload); + + let observed_at_ms = match current_unix_time_ms() { + Ok(observed_at_ms) => observed_at_ms, + Err(error) => { + log_fail_open(logger, ERROR_EVENT, &error, session_id.as_deref()); + return String::new(); + } + }; + + run_claude_model_state_from_payload(repository_root, &stdin_payload, logger, || { + Ok(observed_at_ms) + }) +} + +#[cfg(test)] +pub(super) fn run_claude_model_state_from_payload_at_state_root( + repository_root: &Path, + state_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, + observed_at_ms: F, +) -> String +where + F: FnOnce() -> Result, +{ + run_claude_model_state_from_payload_with( + repository_root, + stdin_payload, + logger, + observed_at_ms, + |repository_root, context_message| { + super::open_agent_trace_db_for_hook_runtime_at_state_root( + repository_root, + state_root, + context_message, + ) + }, + ) +} + +fn run_claude_model_state_from_payload( + repository_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, + observed_at_ms: F, +) -> String +where + F: FnOnce() -> Result, +{ + run_claude_model_state_from_payload_with( + repository_root, + stdin_payload, + logger, + observed_at_ms, + super::open_agent_trace_db_for_hook_runtime, + ) +} + +fn run_claude_model_state_from_payload_with( + repository_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, + observed_at_ms: F, + open_db: O, +) -> String +where + F: FnOnce() -> Result, + O: FnOnce(&Path, &'static str) -> Result, +{ + let session_id = fail_open_session_id(stdin_payload); + let observed_at_ms = match observed_at_ms() { + Ok(observed_at_ms) if observed_at_ms >= 0 => observed_at_ms, + Ok(observed_at_ms) => { + let error = anyhow!( + "Invalid Claude model-state observation time: expected a non-negative millisecond value, got {observed_at_ms}." + ); + log_fail_open(logger, ERROR_EVENT, &error, session_id.as_deref()); + return String::new(); + } + Err(error) => { + log_fail_open(logger, ERROR_EVENT, &error, session_id.as_deref()); + return String::new(); + } + }; + + let observation = match parse_claude_model_state_payload(stdin_payload, observed_at_ms) { + Ok(observation) => observation, + Err(error) => { + log_fail_open(logger, ERROR_EVENT, &error, session_id.as_deref()); + return String::new(); + } + }; + let Some(observation) = observation else { + return String::new(); + }; + + let db = match open_db( + repository_root, + "Failed to open Agent Trace DB for Claude model-state persistence.", + ) { + Ok(db) => db, + Err(error) => { + log_fail_open( + logger, + DB_OPEN_FAILED_EVENT, + &error, + Some(&observation.session_id), + ); + return String::new(); + } + }; + + if let Err(error) = persist_claude_model_state(&db, observation) { + log_fail_open(logger, DB_WRITE_FAILED_EVENT, &error, session_id.as_deref()); + } + + String::new() +} + +fn persist_claude_model_state( + db: &RepositoryAgentTraceDb, + observation: ClaudeModelStateObservation, +) -> Result<()> { + db.upsert_claude_model_state(observation) + .context("Failed to persist Claude model-state observation.")?; + Ok(()) +} + +fn parse_claude_model_state_payload( + stdin_payload: &str, + observed_at_ms: i64, +) -> Result> { + let parsed: Value = serde_json::from_str(stdin_payload) + .context("Invalid Claude model-state payload from STDIN: expected valid JSON.")?; + let payload = parsed.as_object().ok_or_else(|| { + anyhow!("Invalid Claude model-state payload from STDIN: expected a JSON object.") + })?; + + let event_name = required_non_empty_string(payload, "hook_event_name")?; + let observation_kind = match event_name.as_str() { + SESSION_START_EVENT => ObservationKind::SessionStart, + POST_MODEL_SWITCH_EVENT => ObservationKind::PostModelSwitch, + _ => return Ok(None), + }; + + let session_id = prefixed_diff_trace_session_id( + CLAUDE_TOOL_NAME, + required_non_empty_string(payload, "session_id")?.as_str(), + ); + let agent_id = optional_agent_id(payload)?; + + match observation_kind { + ObservationKind::SessionStart => { + let Some(model_id) = optional_model_id(payload, "model")? else { + return Ok(None); + }; + let source = required_non_empty_string(payload, "source")?; + + Ok(Some(ClaudeModelStateObservation { + session_id, + agent_id, + model_id, + observation_kind, + source, + observed_at_ms, + })) + } + ObservationKind::PostModelSwitch => { + let _from_model = required_model_id(payload, "from_model")?; + let to_model = required_model_id(payload, "to_model")?; + let source = required_non_empty_string(payload, "source")?; + + Ok(Some(ClaudeModelStateObservation { + session_id, + agent_id, + model_id: to_model, + observation_kind, + source, + observed_at_ms, + })) + } + } +} + +fn required_model_id(payload: &serde_json::Map, field_name: &str) -> Result { + let value = required_non_empty_string(payload, field_name)?; + normalize_claude_model_id(&value).ok_or_else(|| { + anyhow!( + "Invalid Claude model-state payload from STDIN: field '{field_name}' must be a non-empty model identifier." + ) + }) +} + +fn optional_model_id( + payload: &serde_json::Map, + field_name: &str, +) -> Result> { + let Some(value) = payload.get(field_name) else { + return Ok(None); + }; + + if value.is_null() { + return Ok(None); + } + + let value = value.as_str().ok_or_else(|| { + anyhow!( + "Invalid Claude model-state payload from STDIN: field '{field_name}' must be null or a string." + ) + })?; + Ok(normalize_claude_model_id(value)) +} + +fn optional_agent_id(payload: &serde_json::Map) -> Result { + let Some(value) = payload.get("agent_id") else { + return Ok(String::new()); + }; + if value.is_null() { + return Ok(String::new()); + } + + let value = value.as_str().ok_or_else(|| { + anyhow!( + "Invalid Claude model-state payload from STDIN: field 'agent_id' must be null or a non-empty string." + ) + })?; + let value = value.trim(); + if value.is_empty() { + return Err(anyhow!( + "Invalid Claude model-state payload from STDIN: field 'agent_id' must be non-empty when present." + )); + } + Ok(value.to_string()) +} + +fn required_non_empty_string( + payload: &serde_json::Map, + field_name: &str, +) -> Result { + let value = payload.get(field_name).ok_or_else(|| { + anyhow!( + "Invalid Claude model-state payload from STDIN: missing required field '{field_name}'." + ) + })?; + let value = value.as_str().ok_or_else(|| { + anyhow!( + "Invalid Claude model-state payload from STDIN: field '{field_name}' must be a non-empty string." + ) + })?; + let value = value.trim(); + if value.is_empty() { + return Err(anyhow!( + "Invalid Claude model-state payload from STDIN: field '{field_name}' must be a non-empty string." + )); + } + Ok(value.to_string()) +} + +fn fail_open_session_id(stdin_payload: &str) -> Option { + let payload: Value = serde_json::from_str(stdin_payload).ok()?; + let payload = payload.as_object()?; + payload + .get("session_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn log_fail_open( + logger: Option<&dyn Logger>, + event_id: &str, + error: &anyhow::Error, + session_id: Option<&str>, +) { + if let Some(log) = logger { + log.error(event_id, &format!("{error:#}"), &[], session_id); + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::{Path, PathBuf}, + process::Command, + sync::{Arc, Mutex}, + time::{SystemTime, UNIX_EPOCH}, + }; + + use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, + }; + + use super::*; + use crate::services::agent_trace_db::ObservationKind; + use serde_json::json; + + fn parse(payload: &Value, observed_at_ms: i64) -> Option { + parse_claude_model_state_payload(&payload.to_string(), observed_at_ms) + .expect("model-state payload should parse") + } + + #[derive(Clone, Default)] + struct RecordingLogger { + errors: Arc>>, + } + + impl Logger for RecordingLogger { + fn info(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + fn debug(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + fn warn(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + + fn error(&self, event_id: &str, message: &str, _: &[(&str, &str)], _: Option<&str>) { + self.errors + .lock() + .expect("recording logger mutex must not be poisoned") + .push((event_id.to_string(), message.to_string())); + } + + fn log_cli_error(&self, _: &crate::services::error::CliError, _: Option<&str>) {} + } + + fn unique_temp_dir(label: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("sce-claude-model-state-{label}-{suffix}")); + fs::create_dir_all(&path).expect("temporary directory should be created"); + path + } + + fn run_git(repo_root: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(repo_root) + .output() + .expect("git should start"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn init_git_repo(label: &str) -> PathBuf { + let repo_root = unique_temp_dir(label); + run_git(&repo_root, &["init", "-q"]); + run_git( + &repo_root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + repo_root + } + + #[test] + fn session_start_normalizes_main_session_and_model() { + let observation = parse( + &json!({ + "hook_event_name": "SessionStart", + "session_id": "session-1", + "model": "claude-opus-4-1", + "source": "startup" + }), + 42, + ) + .expect("SessionStart should persist"); + + assert_eq!(observation.session_id, "cc_session-1"); + assert_eq!(observation.agent_id, ""); + assert_eq!(observation.model_id, "claude/claude-opus-4-1"); + assert_eq!(observation.observation_kind, ObservationKind::SessionStart); + assert_eq!(observation.source, "startup"); + assert_eq!(observation.observed_at_ms, 42); + } + + #[test] + fn model_less_session_start_is_a_no_op() { + assert_eq!( + parse( + &json!({ + "hook_event_name": "SessionStart", + "session_id": "session-1", + "source": "resume" + }), + 42, + ), + None + ); + assert_eq!( + parse( + &json!({ + "hook_event_name": "SessionStart", + "session_id": "session-1", + "model": null, + "source": "resume" + }), + 42, + ), + None + ); + assert_eq!( + parse( + &json!({ + "hook_event_name": "SessionStart", + "session_id": "session-1", + "model": " ", + "source": "resume" + }), + 42, + ), + None + ); + } + + #[test] + fn post_model_switch_uses_to_model_and_exact_agent_scope() { + let observation = parse( + &json!({ + "hook_event_name": "PostModelSwitch", + "session_id": "cc_session-1", + "agent_id": "agent-1", + "from_model": "old-model", + "to_model": "claude/new-model", + "source": "picker" + }), + 43, + ) + .expect("PostModelSwitch should persist"); + + assert_eq!(observation.session_id, "cc_session-1"); + assert_eq!(observation.agent_id, "agent-1"); + assert_eq!(observation.model_id, "claude/new-model"); + assert_eq!( + observation.observation_kind, + ObservationKind::PostModelSwitch + ); + assert_eq!(observation.source, "picker"); + } + + #[test] + fn post_model_switch_accepts_current_and_future_sources_unchanged() { + for (source, expected) in [ + ("command", "command"), + ("picker", "picker"), + ("sdk", "sdk"), + ("auto", "auto"), + ("resume", "resume"), + ("future-source", "future-source"), + (" future-source ", "future-source"), + ] { + let observation = parse( + &json!({ + "hook_event_name": "PostModelSwitch", + "session_id": "session-1", + "from_model": "model-a", + "to_model": "model-b", + "source": source + }), + 43, + ) + .expect("source should produce an observation"); + assert_eq!(observation.source, expected); + } + } + + #[test] + fn malformed_switch_rejects_missing_or_invalid_fields() { + for payload in [ + json!({ + "hook_event_name": "PostModelSwitch", + "session_id": "session-1", + "from_model": "model-a", + "to_model": "model-b" + }), + json!({ + "hook_event_name": "PostModelSwitch", + "session_id": "session-1", + "from_model": "model-a", + "to_model": "model-b", + "source": null + }), + json!({ + "hook_event_name": "PostModelSwitch", + "session_id": "session-1", + "from_model": "model-a", + "to_model": "model-b", + "source": "" + }), + json!({ + "hook_event_name": "PostModelSwitch", + "session_id": "session-1", + "from_model": "model-a", + "to_model": "model-b", + "source": " " + }), + json!({ + "hook_event_name": "PostModelSwitch", + "session_id": "session-1", + "from_model": "model-a", + "to_model": "model-b", + "source": 42 + }), + json!({ + "hook_event_name": "PostModelSwitch", + "session_id": "session-1", + "from_model": "", + "to_model": "model-b", + "source": "command" + }), + ] { + assert!(parse_claude_model_state_payload(&payload.to_string(), 43).is_err()); + } + } + + #[test] + fn agent_id_uses_main_scope_only_when_missing_or_null() { + let base = json!({ + "hook_event_name": "SessionStart", + "session_id": "session-1", + "model": "model-a", + "source": "startup" + }); + + for (agent_id, expected) in [ + (None, ""), + (Some(Value::Null), ""), + (Some(json!("agent-1")), "agent-1"), + (Some(json!(" agent-1 ")), "agent-1"), + ] { + let mut payload = base.clone(); + if let Some(agent_id) = agent_id { + payload["agent_id"] = agent_id; + } + assert_eq!( + parse(&payload, 42).expect("agent ID should parse").agent_id, + expected + ); + } + } + + #[test] + fn agent_id_rejects_present_empty_whitespace_and_non_string_values() { + let base = json!({ + "hook_event_name": "SessionStart", + "session_id": "session-1", + "model": "model-a", + "source": "startup" + }); + + for agent_id in [json!(""), json!(" "), json!(42)] { + let mut payload = base.clone(); + payload["agent_id"] = agent_id; + assert!( + parse_claude_model_state_payload(&payload.to_string(), 42).is_err(), + "present malformed agent_id must be rejected" + ); + } + } + + #[test] + fn present_empty_agent_id_fails_open_without_opening_or_writing_a_database() { + let repo_root = std::env::temp_dir().join(format!( + "sce-claude-model-state-invalid-agent-{}", + std::process::id() + )); + let logger = RecordingLogger::default(); + let output = run_claude_model_state_from_payload( + &repo_root, + &json!({ + "hook_event_name": "SessionStart", + "session_id": "session-1", + "agent_id": " ", + "model": "model-a", + "source": "startup" + }) + .to_string(), + Some(&logger), + || Ok(42), + ); + + assert_eq!(output, ""); + assert!(!repo_root.exists(), "validation failure must not open a DB"); + let errors = logger + .errors + .lock() + .expect("logger mutex must not be poisoned"); + assert!(errors + .iter() + .any(|(_, message)| message.contains("agent_id"))); + } + + #[test] + fn unsupported_event_is_a_no_op() { + assert_eq!( + parse( + &json!({ + "hook_event_name": "Stop", + "session_id": "session-1" + }), + 42, + ), + None + ); + } + + #[test] + fn lifecycle_handler_returns_empty_output_for_intake_failures_and_no_ops() { + assert_eq!( + run_claude_model_state_from_payload(Path::new("/unused"), "not-json", None, || Ok(42),), + "" + ); + assert_eq!( + run_claude_model_state_from_payload( + Path::new("/unused"), + &json!({ + "hook_event_name": "SessionStart", + "session_id": "session-1", + "model": null, + "source": "resume" + }) + .to_string(), + None, + || Ok(42), + ), + "" + ); + } + + #[test] + fn pre_003_hook_runtime_fails_open_without_migrating_or_writing_state() { + let repo_root = init_git_repo("pre-003-repo"); + let state_root = unique_temp_dir("pre-003-state"); + let storage = resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &repo_root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("setup path should create the pre-003 fixture"); + let db_path = storage.db_path.clone(); + storage + .db + .execute("DROP TABLE claude_model_state", ()) + .expect("fixture should remove migration 003 table"); + storage + .db + .execute( + "DELETE FROM __sce_migrations WHERE id = '003_claude_model_state'", + (), + ) + .expect("fixture should remove migration 003 metadata"); + drop(storage); + + let logger = RecordingLogger::default(); + let output = run_claude_model_state_from_payload_at_state_root( + &repo_root, + &state_root, + &json!({ + "hook_event_name": "SessionStart", + "session_id": "session-1", + "model": "model-a", + "source": "startup" + }) + .to_string(), + Some(&logger), + || Ok(42), + ); + + assert_eq!(output, "", "hook stdout must remain empty"); + let errors = logger + .errors + .lock() + .expect("logger mutex must not be poisoned"); + assert!( + errors.iter().any(|(event_id, message)| { + event_id == DB_OPEN_FAILED_EVENT && message.contains("Run 'sce setup'.") + }), + "unexpected diagnostics: {errors:?}" + ); + drop(errors); + + let db = RepositoryAgentTraceDb::open_for_hooks_without_migrations_at(&db_path) + .expect("pre-003 DB should remain openable without migrations"); + let table_exists = db + .query_map( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'claude_model_state'", + (), + |row| row.get::(0).map_err(Into::into), + ) + .expect("table existence query should succeed"); + assert!( + table_exists.is_empty(), + "hook must not create migration 003 table" + ); + let applied_ids = db + .query_map( + "SELECT id FROM __sce_migrations ORDER BY id ASC", + (), + |row| row.get::(0).map_err(Into::into), + ) + .expect("migration metadata query should succeed"); + assert!(!applied_ids.iter().any(|id| id == "003_claude_model_state")); + + drop(db); + fs::remove_dir_all(repo_root).expect("repo fixture should be removed"); + fs::remove_dir_all(state_root).expect("state fixture should be removed"); + } + + #[test] + fn lifecycle_observation_is_written_to_the_repository_state_register() { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let db_path = std::env::temp_dir().join(format!("sce-claude-model-state-{suffix}.db")); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("repository DB should open"); + let observation = parse( + &json!({ + "hook_event_name": "PostModelSwitch", + "session_id": "session-1", + "from_model": "model-a", + "to_model": "model-b", + "source": "command" + }), + 42, + ) + .expect("switch should produce an observation"); + + persist_claude_model_state(&db, observation).expect("state write should succeed"); + + let state = db + .claude_model_state_by_session_and_agent("cc_session-1", "") + .expect("state lookup should succeed") + .expect("state should be present"); + assert_eq!(state.model_id, "claude/model-b"); + assert_eq!(state.observation_kind, ObservationKind::PostModelSwitch); + assert_eq!(state.observed_at_ms, 42); + + drop(db); + let _ = fs::remove_file(db_path); + } +} diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 22780922..c8c48337 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -19,6 +19,11 @@ use crate::services::agent_trace_db::{ PartType, PostCommitPatchIntersectionInsert, RecentDiffTracePatches, PAYLOAD_TYPE_PATCH, PAYLOAD_TYPE_STRUCTURED, }; +#[cfg(test)] +use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, + resolve_agent_trace_storage_for_hook_runtime_at_state_root, +}; use crate::services::agent_trace_storage::{ resolve_agent_trace_storage_for_hook_runtime, AgentTraceStorageContext, }; @@ -33,6 +38,7 @@ use crate::services::structured_patch::{ ClaudeStructuredPatchDerivationResult, PatchBuildResult, }; use crate::services::sync::auto_sync; +pub mod claude_model_state; pub mod claude_transcript; pub mod codex; pub mod command; @@ -92,6 +98,7 @@ pub enum HookSubcommand { DiffTrace, ConversationTrace, Codex, + ClaudeModelState, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -101,6 +108,8 @@ struct DiffTracePayload { diff: String, time: u64, model_id: Option, + #[serde(skip)] + agent_id: Option, tool_name: String, tool_version: Option, payload_type: String, @@ -228,6 +237,9 @@ fn run_hooks_subcommand_in_repo( Ok(run_conversation_trace_subcommand(repository_root, logger)) } HookSubcommand::Codex => Ok(codex::run_codex_subcommand(repository_root, logger)), + HookSubcommand::ClaudeModelState => Ok( + claude_model_state::run_claude_model_state_subcommand(repository_root, logger), + ), } } @@ -366,6 +378,25 @@ fn open_agent_trace_db_for_hook_runtime( .context(context_message) } +#[cfg(test)] +pub(crate) fn open_agent_trace_db_for_hook_runtime_at_state_root( + repository_root: &Path, + state_root: &Path, + context_message: &'static str, +) -> Result { + let storage_config = config::resolve_agent_trace_storage_runtime_config(repository_root) + .context("Failed to resolve Agent Trace repository storage config.")?; + let storage_context = AgentTraceStorageContext { + repository_root, + explicit_repository_id: storage_config.repository_id.as_deref(), + repository_remote: &storage_config.repository_remote, + }; + + resolve_agent_trace_storage_for_hook_runtime_at_state_root(&storage_context, state_root) + .map(|storage| storage.db) + .context(context_message) +} + #[derive(Clone, Debug, Eq, PartialEq)] struct ConversationTraceEventPersistenceSummary { persisted: usize, @@ -870,26 +901,21 @@ fn run_diff_trace_subcommand_from_payload_with( ); } } - let agent_trace_db_persisted = match persist_diff_trace_payload_to_agent_trace_db( - repository_root, - payload, - payload.model_id.as_deref(), - payload.tool_version.as_deref(), - logger, - ) { - Ok(persisted) => persisted, - Err(error) => { - if let Some(log) = logger { - log.warn( - "sce.hooks.diff_trace.agent_trace_db_write_failed", - &error.to_string(), - &[], - Some(&payload.session_id), - ); + let agent_trace_db_persisted = + match persist_diff_trace_payload_to_agent_trace_db(repository_root, payload, logger) { + Ok(persisted) => persisted, + Err(error) => { + if let Some(log) = logger { + log.warn( + "sce.hooks.diff_trace.agent_trace_db_write_failed", + &error.to_string(), + &[], + Some(&payload.session_id), + ); + } + false } - false - } - }; + }; if agent_trace_db_persisted { String::from("diff-trace hook intake persisted payload to AgentTraceDb.") @@ -930,6 +956,7 @@ fn parse_diff_trace_payload(stdin_payload: &str) -> Result diff, time, model_id, + agent_id: None, tool_name, tool_version, payload_type: PAYLOAD_TYPE_PATCH.to_string(), @@ -964,6 +991,7 @@ fn parse_claude_diff_trace_payload( diff: stdin_payload.to_string(), time: patch.time, model_id: resolve_claude_model_id(payload), + agent_id: extract_claude_agent_id(payload)?, tool_name: patch.tool_name, tool_version: patch.tool_version, payload_type: PAYLOAD_TYPE_STRUCTURED.to_string(), @@ -977,6 +1005,27 @@ fn parse_claude_diff_trace_payload( } } +fn extract_claude_agent_id(payload: &serde_json::Map) -> Result> { + let Some(value) = payload.get("agent_id") else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + + let value = value.as_str().ok_or_else(|| { + anyhow!(StdinPayloadKind::DiffTrace + .validation_error("field 'agent_id' must be null or a non-empty string")) + })?; + let value = value.trim(); + if value.is_empty() { + bail!(StdinPayloadKind::DiffTrace + .validation_error("field 'agent_id' must be null or a non-empty string")); + } + + Ok(Some(value.to_string())) +} + fn resolve_claude_model_id(payload: &serde_json::Map) -> Option { resolve_claude_model_id_with(payload, claude_transcript::extract_claude_transcript_model) } @@ -1265,36 +1314,69 @@ fn required_field<'a>( fn persist_diff_trace_payload_to_agent_trace_db( repository_root: &Path, payload: &DiffTracePayload, - model_id: Option<&str>, - tool_version: Option<&str>, logger: Option<&dyn Logger>, ) -> Result { - persist_diff_trace_payload_to_agent_trace_db_with(payload, model_id, tool_version, |input| { - let db = match open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for diff-trace persistence.", - ) { - Ok(db) => db, - Err(error) => { - if let Some(log) = logger { - log.error( - "sce.hooks.diff_trace.agent_trace_db_open_failed", - &error.to_string(), - &[], - Some(&payload.session_id), - ); - } - - return Ok(false); + let db = match open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for diff-trace persistence.", + ) { + Ok(db) => db, + Err(error) => { + if let Some(log) = logger { + log.error( + "sce.hooks.diff_trace.agent_trace_db_open_failed", + &error.to_string(), + &[], + Some(&payload.session_id), + ); } - }; - db.insert_diff_trace(input) - .context("Failed to persist diff-trace payload to Agent Trace DB.")?; - Ok(true) + return Ok(false); + } + }; + + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, payload)?; + Ok(true) +} + +fn persist_diff_trace_payload_to_agent_trace_db_with_db( + db: &RepositoryAgentTraceDb, + payload: &DiffTracePayload, +) -> Result<()> { + let model_id = resolve_diff_trace_model_id(db, payload)?; + db.insert_diff_trace(DiffTraceInsert { + time_ms: diff_trace_db_time_ms(payload.time)?, + session_id: &prefixed_diff_trace_session_id(&payload.tool_name, &payload.session_id), + patch: &payload.diff, + model_id: model_id.as_deref(), + tool_name: &payload.tool_name, + tool_version: payload.tool_version.as_deref(), + payload_type: &payload.payload_type, }) + .context("Failed to persist diff-trace payload to Agent Trace DB.")?; + + Ok(()) +} + +fn resolve_diff_trace_model_id( + db: &RepositoryAgentTraceDb, + payload: &DiffTracePayload, +) -> Result> { + if payload.model_id.is_some() + || payload.tool_name != CLAUDE_TOOL_NAME + || payload.payload_type != PAYLOAD_TYPE_STRUCTURED + { + return Ok(payload.model_id.clone()); + } + + let session_id = prefixed_diff_trace_session_id(CLAUDE_TOOL_NAME, &payload.session_id); + let agent_id = payload.agent_id.as_deref().unwrap_or(""); + Ok(db + .claude_model_state_by_session_and_agent(&session_id, agent_id)? + .map(|state| state.model_id)) } +#[cfg(test)] fn persist_diff_trace_payload_to_agent_trace_db_with( payload: &DiffTracePayload, model_id: Option<&str>, @@ -1847,6 +1929,7 @@ fn hook_runtime_invocation_name(subcommand: &HookSubcommand) -> &'static str { HookSubcommand::DiffTrace => "diff-trace runtime invocation", HookSubcommand::ConversationTrace => "conversation-trace runtime invocation", HookSubcommand::Codex => "codex runtime invocation", + HookSubcommand::ClaudeModelState => "Claude model-state runtime invocation", } } @@ -2289,10 +2372,18 @@ where #[cfg(test)] mod tests { - use std::{cell::RefCell, path::Path}; + use std::{ + cell::RefCell, + fs, + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, + }; use super::*; - use crate::services::agent_trace_db::{ParsedDiffTracePatch, SkippedDiffTracePatch}; + use crate::services::agent_trace_db::{ + ClaudeModelStateObservation, ObservationKind, ParsedDiffTracePatch, SkippedDiffTracePatch, + }; #[derive(Debug, Eq, PartialEq)] struct CapturedPostCommitIntersectionInsert { @@ -2625,6 +2716,7 @@ mod tests { diff: String::from("diff text"), time: 1_800_000_000_000_u64, model_id: model_id.map(String::from), + agent_id: None, tool_name: String::from(tool_name), tool_version: tool_version.map(String::from), payload_type: String::from(payload_type), @@ -2673,6 +2765,27 @@ mod tests { } } + fn parsed_claude_diff_trace(event: &Value) -> DiffTracePayload { + match parse_diff_trace_payload(&event.to_string()) + .expect("Claude PostToolUse diff-trace payload should parse") + { + DiffTraceParseResult::Persist(payload) => payload, + DiffTraceParseResult::NoOp(message) => { + panic!("Claude Write payload should persist, got no-op: {message}") + } + } + } + + fn unique_attribution_db_path(label: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + std::env::temp_dir() + .join(format!("sce-claude-model-attribution-{label}-{suffix}")) + .join("agent-trace.db") + } + fn resolved_claude_model_id_with(event: &Value, transcript_lookup: F) -> Option where F: FnOnce(&Path, &str) -> Option, @@ -2683,6 +2796,60 @@ mod tests { ) } + fn run_attribution_git(repo_root: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(repo_root) + .output() + .expect("git should start"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn init_attribution_git_repo(label: &str) -> PathBuf { + let repo_root = unique_attribution_db_path(label) + .parent() + .expect("test repository should have a parent") + .to_path_buf(); + fs::create_dir_all(&repo_root).expect("test repository directory should be created"); + run_attribution_git(&repo_root, &["init", "-q"]); + run_attribution_git( + &repo_root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + repo_root + } + + fn model_less_claude_diff_event( + session_id: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> Value { + let mut event = claude_model_test_event(Path::new("/virtual/missing.jsonl"), tool_use_id); + let object = event + .as_object_mut() + .expect("Claude test event should be an object"); + object.insert("session_id".to_string(), json!(session_id)); + object.remove("transcript_path"); + object.remove("tool_use_id"); + if let Some(agent_id) = agent_id { + object.insert("agent_id".to_string(), json!(agent_id)); + } + event + } + + fn persisted_model_ids(db: &RepositoryAgentTraceDb) -> Vec> { + db.query_map( + "SELECT model_id FROM diff_traces ORDER BY id ASC", + (), + |row| row.get::>(0).map_err(Into::into), + ) + .expect("persisted model IDs should be readable") + } + #[test] fn claude_model_direct_nested_metadata_wins_over_transcript_without_double_prefixing() { let transcript_path = Path::new("/unused/direct-precedence.jsonl"); @@ -2733,6 +2900,327 @@ mod tests { ); } + #[test] + fn claude_diff_trace_parser_keeps_agent_id_ephemeral_and_storage_free() { + let mut event = claude_model_test_event(Path::new("/virtual/missing.jsonl"), "tool-123"); + event + .as_object_mut() + .expect("test event should be an object") + .insert("agent_id".to_string(), json!(" agent-1 ")); + + let payload = parsed_claude_diff_trace(&event); + + assert_eq!(payload.agent_id.as_deref(), Some("agent-1")); + assert!(serde_json::to_value(&payload) + .expect("internal payload should serialize") + .get("agent_id") + .is_none()); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn claude_model_attribution_end_to_end_persists_lifecycle_fallback_precedence_and_scope() { + let repo_root = init_attribution_git_repo("end-to-end"); + let state_root = unique_attribution_db_path("end-to-end-state") + .parent() + .expect("test state should have a parent") + .to_path_buf(); + let storage = resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &repo_root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("setup path should initialize the test repository DB"); + drop(storage); + + let session_start = json!({ + "hook_event_name": "SessionStart", + "session_id": "session-123", + "model": "model-a", + "source": "startup" + }); + assert_eq!( + claude_model_state::run_claude_model_state_from_payload_at_state_root( + &repo_root, + &state_root, + &session_start.to_string(), + None, + || Ok(10), + ), + "" + ); + + let db = open_agent_trace_db_for_hook_runtime_at_state_root( + &repo_root, + &state_root, + "test DB should open after SessionStart", + ) + .expect("test DB should open after SessionStart"); + assert_eq!( + db.claude_model_state_by_session_and_agent("cc_session-123", "") + .expect("SessionStart state should be readable") + .expect("SessionStart should seed state") + .model_id, + "claude/model-a" + ); + let session_start_event = model_less_claude_diff_event("session-123", "tool-a", None); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&session_start_event), + ) + .expect("SessionStart state should attribute the next diff trace"); + drop(db); + + let post_model_switch = json!({ + "hook_event_name": "PostModelSwitch", + "session_id": "session-123", + "from_model": "model-a", + "to_model": "model-b", + "source": "picker" + }); + assert_eq!( + claude_model_state::run_claude_model_state_from_payload_at_state_root( + &repo_root, + &state_root, + &post_model_switch.to_string(), + None, + || Ok(20), + ), + "" + ); + + let db = open_agent_trace_db_for_hook_runtime_at_state_root( + &repo_root, + &state_root, + "test DB should open after PostModelSwitch", + ) + .expect("test DB should open after PostModelSwitch"); + assert_eq!( + db.claude_model_state_by_session_and_agent("cc_session-123", "") + .expect("PostModelSwitch state should be readable") + .expect("PostModelSwitch should update state") + .model_id, + "claude/model-b" + ); + let switched_event = model_less_claude_diff_event("session-123", "tool-b", None); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&switched_event), + ) + .expect("PostModelSwitch state should attribute the next diff trace"); + + let mut direct_event = model_less_claude_diff_event("session-123", "tool-direct", None); + direct_event + .as_object_mut() + .expect("Claude test event should be an object") + .insert("model".to_string(), json!("model-c")); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&direct_event), + ) + .expect("direct model attribution should persist"); + + let transcript_path = state_root.join("transcript.jsonl"); + fs::write( + &transcript_path, + concat!( + r#"{"type":"assistant","message":{"role":"assistant","model":"model-c","content":[{"type":"tool_use","id":"tool-transcript"}]}}"#, + "\n" + ), + ) + .expect("transcript fixture should be written"); + let transcript_event = claude_model_test_event(&transcript_path, "tool-transcript"); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&transcript_event), + ) + .expect("transcript model attribution should persist"); + + let no_state_event = + model_less_claude_diff_event("session-without-state", "tool-none", None); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&no_state_event), + ) + .expect("an attribution-less diff trace should still persist"); + + let subagent_event = + model_less_claude_diff_event("session-123", "tool-subagent", Some("subagent-1")); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&subagent_event), + ) + .expect("a subagent diff trace should persist"); + + assert_eq!( + persisted_model_ids(&db), + vec![ + Some(String::from("claude/model-a")), + Some(String::from("claude/model-b")), + Some(String::from("claude/model-c")), + Some(String::from("claude/model-c")), + None, + None, + ] + ); + + drop(db); + fs::remove_file(transcript_path).expect("transcript fixture should be removed"); + fs::remove_dir_all(repo_root).expect("test repository should be removed"); + fs::remove_dir_all(state_root).expect("test state should be removed"); + } + + #[test] + fn claude_diff_trace_persistence_uses_state_only_after_direct_and_transcript() { + let db_path = unique_attribution_db_path("precedence"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-123"), + agent_id: String::new(), + model_id: String::from("claude/state-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 1, + }) + .expect("state should be seeded"); + + let mut state_event = claude_model_test_event(Path::new("/virtual/missing.jsonl"), "state"); + let state_object = state_event + .as_object_mut() + .expect("test event should be an object"); + state_object.remove("transcript_path"); + state_object.remove("tool_use_id"); + let state_payload = parsed_claude_diff_trace(&state_event); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &state_payload) + .expect("state fallback should persist"); + + let mut direct_event = state_event.clone(); + direct_event + .as_object_mut() + .expect("test event should be an object") + .insert("model".to_string(), json!("direct-model")); + let direct_payload = parsed_claude_diff_trace(&direct_event); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &direct_payload) + .expect("direct attribution should persist"); + + let transcript_path = db_path.with_extension("jsonl"); + fs::write( + &transcript_path, + concat!( + r#"{"type":"assistant","message":{"role":"assistant","model":"transcript-model","content":[{"type":"tool_use","id":"transcript"}]}}"#, + "\n" + ), + ) + .expect("transcript fixture should be written"); + let transcript_event = claude_model_test_event(&transcript_path, "transcript"); + let transcript_payload = parsed_claude_diff_trace(&transcript_event); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &transcript_payload) + .expect("transcript attribution should persist"); + + let models = db + .query_map( + "SELECT model_id FROM diff_traces ORDER BY id ASC", + (), + |row| row.get::>(0).map_err(Into::into), + ) + .expect("persisted models should be readable"); + assert_eq!( + models, + vec![ + Some(String::from("claude/state-model")), + Some(String::from("claude/direct-model")), + Some(String::from("claude/transcript-model")), + ] + ); + + drop(db); + fs::remove_file(transcript_path).expect("transcript fixture should be removed"); + fs::remove_dir_all(db_path.parent().expect("test DB should have a parent")) + .expect("test DB directory should be removed"); + } + + #[test] + fn normalized_claude_tool_name_does_not_use_claude_state_fallback() { + let db_path = unique_attribution_db_path("normalized-claude"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-123"), + agent_id: String::new(), + model_id: String::from("claude/parent-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 1, + }) + .expect("parent state should be seeded"); + + let payload = diff_trace_payload_with( + CLAUDE_TOOL_NAME, + "session-123", + PAYLOAD_TYPE_PATCH, + None, + None, + ); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) + .expect("normalized Claude payload should persist"); + + let model = db + .query_map("SELECT model_id FROM diff_traces LIMIT 1", (), |row| { + row.get::>(0).map_err(Into::into) + }) + .expect("persisted model should be readable") + .into_iter() + .next() + .expect("diff trace row should exist"); + assert_eq!(model, None); + + drop(db); + fs::remove_dir_all(db_path.parent().expect("test DB should have a parent")) + .expect("test DB directory should be removed"); + } + + #[test] + fn claude_diff_trace_state_lookup_isolated_to_exact_subagent_scope() { + let db_path = unique_attribution_db_path("subagent"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-123"), + agent_id: String::new(), + model_id: String::from("claude/parent-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 1, + }) + .expect("parent state should be seeded"); + + let mut event = claude_model_test_event(Path::new("/virtual/missing.jsonl"), "subagent"); + let event_object = event + .as_object_mut() + .expect("test event should be an object"); + event_object.remove("transcript_path"); + event_object.remove("tool_use_id"); + event_object.insert("agent_id".to_string(), json!("subagent-1")); + let payload = parsed_claude_diff_trace(&event); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) + .expect("subagent diff trace should persist"); + + let model = db + .query_map("SELECT model_id FROM diff_traces LIMIT 1", (), |row| { + row.get::>(0).map_err(Into::into) + }) + .expect("persisted model should be readable") + .into_iter() + .next() + .expect("diff trace row should exist"); + assert_eq!(model, None); + + drop(db); + fs::remove_dir_all(db_path.parent().expect("test DB should have a parent")) + .expect("test DB directory should be removed"); + } + #[test] fn prefixed_diff_trace_session_id_prefixes_fresh_pi_session_id() { assert_eq!( diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index 31d56473..e11d26d7 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -434,6 +434,9 @@ fn convert_hooks_subcommand_request( Ok(services::hooks::HookSubcommand::ConversationTrace) } cli_schema::HooksSubcommand::Codex => Ok(services::hooks::HookSubcommand::Codex), + cli_schema::HooksSubcommand::ClaudeModelState => { + Ok(services::hooks::HookSubcommand::ClaudeModelState) + } } } @@ -491,6 +494,20 @@ mod tests { ); } + #[test] + fn claude_model_state_hook_parses_to_silent_hook_request() { + let command = parse(&["sce", "hooks", "claude-model-state"]); + + let RuntimeCommand::Hooks(command) = command else { + panic!("expected hooks command"); + }; + + assert_eq!( + command.subcommand, + services::hooks::HookSubcommand::ClaudeModelState + ); + } + #[test] fn sync_json_format_parses_to_sync_request() { let command = parse(&["sce", "sync", "--format", "json"]); diff --git a/cli/src/services/setup/config_merge.rs b/cli/src/services/setup/config_merge.rs index b2340342..1395e195 100644 --- a/cli/src/services/setup/config_merge.rs +++ b/cli/src/services/setup/config_merge.rs @@ -10,6 +10,7 @@ use serde_json::Value; /// Substring identifying an SCE-authored Claude hook command /// (`config/pkl/renderers/claude-content.pkl`). const CLAUDE_SCE_HOOK_MARKER: &str = "run-sce-or-show-install-guidance.sh"; +const LEGACY_CLAUDE_AGENT_TRACE_PLUGIN: &str = ".claude/plugins/sce-agent-trace.ts"; /// Path prefix identifying an SCE-authored `OpenCode` plugin registration /// (`config/pkl/base/opencode.pkl`), matched structurally so a plugin path an @@ -106,20 +107,29 @@ fn merge_claude_settings(existing: &Value, generated: &Value, source_path: &str) Ok(Value::Object(existing_obj)) } -/// True when a Claude hook-matcher entry (`{"matcher": ..., "hooks": [{"type", -/// "command"}, ...]}`) carries at least one command routed through the SCE -/// hook script. fn hook_entry_is_sce_owned(entry: &Value) -> bool { entry .get("hooks") .and_then(Value::as_array) - .is_some_and(|hooks| { - hooks.iter().any(|hook| { - hook.get("command") - .and_then(Value::as_str) - .is_some_and(|command| command.contains(CLAUDE_SCE_HOOK_MARKER)) - }) - }) + .is_some_and(|hooks| hooks.iter().any(hook_is_sce_owned)) +} + +fn hook_is_sce_owned(hook: &Value) -> bool { + hook.get("command") + .and_then(Value::as_str) + .is_some_and(|command| command.contains(CLAUDE_SCE_HOOK_MARKER)) + || hook_is_legacy_claude_agent_trace(hook) +} + +fn hook_is_legacy_claude_agent_trace(hook: &Value) -> bool { + hook.get("type").and_then(Value::as_str) == Some("command") + && hook.get("command").and_then(Value::as_str) == Some("bun") + && hook + .get("args") + .and_then(Value::as_array) + .and_then(|args| args.first()) + .and_then(Value::as_str) + == Some(LEGACY_CLAUDE_AGENT_TRACE_PLUGIN) } /// Merges `generated` (the freshly rendered SCE `OpenCode` config) into @@ -250,6 +260,16 @@ mod tests { }) } + fn legacy_sce_hook_entry(event: &str) -> Value { + json!({ + "hooks": [{ + "type": "command", + "command": "bun", + "args": [LEGACY_CLAUDE_AGENT_TRACE_PLUGIN, event] + }] + }) + } + fn user_hook_entry() -> Value { json!({ "matcher": "Bash", @@ -259,11 +279,28 @@ mod tests { }) } + fn user_bun_hook_entry() -> Value { + json!({ + "hooks": [{ + "type": "command", + "command": "bun", + "args": [".claude/plugins/my-company-hook.ts"] + }] + }) + } + fn generated_settings() -> Value { json!({ "$schema": "https://json.schemastore.org/claude-code-settings.json", "hooks": { + "SessionStart": [sce_hook_entry("sce hooks claude-model-state")], + "PostModelSwitch": [sce_hook_entry("sce hooks claude-model-state")], "PreToolUse": [sce_hook_entry("sce policy bash")], + "PostToolUse": [ + sce_hook_entry("sce hooks diff-trace"), + sce_hook_entry("sce hooks conversation-trace") + ], + "UserPromptSubmit": [sce_hook_entry("sce hooks conversation-trace")], "Stop": [sce_hook_entry("sce hooks conversation-trace")] } }) @@ -300,10 +337,135 @@ mod tests { let twice = merge_claude_settings(&once, &generated_settings(), "settings.json").unwrap(); assert_eq!(once, twice); + assert_eq!(twice["hooks"]["SessionStart"].as_array().unwrap().len(), 1); + assert_eq!( + twice["hooks"]["PostModelSwitch"].as_array().unwrap().len(), + 1 + ); assert_eq!(twice["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); + assert_eq!(twice["hooks"]["PostToolUse"].as_array().unwrap().len(), 2); + assert_eq!( + twice["hooks"]["UserPromptSubmit"].as_array().unwrap().len(), + 1 + ); assert_eq!(twice["hooks"]["Stop"].as_array().unwrap().len(), 1); } + #[test] + fn recognizes_only_the_exact_legacy_claude_agent_trace_shape() { + assert!(hook_entry_is_sce_owned(&legacy_sce_hook_entry( + "SessionStart" + ))); + assert!(!hook_entry_is_sce_owned(&user_bun_hook_entry())); + assert!(!hook_entry_is_sce_owned(&json!({ + "hooks": [{ + "type": "command", + "command": "bun", + "args": [".claude/plugins/other-hook.ts", "SessionStart"] + }] + }))); + assert!(!hook_entry_is_sce_owned(&json!({ + "hooks": [{ + "type": "command", + "command": "bash", + "args": [LEGACY_CLAUDE_AGENT_TRACE_PLUGIN, "SessionStart"] + }] + }))); + } + + #[test] + fn preserves_user_bun_and_command_hooks_during_merge() { + let existing = json!({ + "hooks": { + "SessionStart": [user_bun_hook_entry(), user_hook_entry()] + } + }); + + let merged = + merge_claude_settings(&existing, &generated_settings(), "settings.json").unwrap(); + let session_start = merged["hooks"]["SessionStart"].as_array().unwrap(); + + assert_eq!(session_start[0], user_bun_hook_entry()); + assert_eq!(session_start[1], user_hook_entry()); + } + + #[test] + fn replaces_historical_claude_agent_trace_hooks_and_is_idempotent() { + let existing = json!({ + "permissions": {"allow": ["Bash(git *)"]}, + "hooks": { + "SessionStart": [user_hook_entry(), legacy_sce_hook_entry("SessionStart")], + "UserPromptSubmit": [legacy_sce_hook_entry("UserPromptSubmit")], + "PostToolUse": [legacy_sce_hook_entry("PostToolUse")], + "Stop": [legacy_sce_hook_entry("Stop")] + } + }); + + let once = + merge_claude_settings(&existing, &generated_settings(), "settings.json").unwrap(); + let twice = merge_claude_settings(&once, &generated_settings(), "settings.json").unwrap(); + + assert_eq!(once, twice); + assert_eq!(once["permissions"]["allow"][0], "Bash(git *)"); + for event in ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"] { + let entries = once["hooks"][event].as_array().unwrap(); + assert!(entries + .iter() + .all(|entry| { !entry.to_string().contains(LEGACY_CLAUDE_AGENT_TRACE_PLUGIN) })); + assert!( + entries.iter().any(|entry| entry == &user_hook_entry()) || event != "SessionStart" + ); + } + assert_eq!( + once["hooks"]["SessionStart"] + .as_array() + .unwrap() + .iter() + .filter(|entry| hook_entry_is_sce_owned(entry)) + .count(), + 1 + ); + assert_eq!( + once["hooks"]["PostModelSwitch"].as_array().unwrap().len(), + 1 + ); + assert_eq!(once["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); + assert_eq!(once["hooks"]["PostToolUse"].as_array().unwrap().len(), 2); + assert_eq!( + once["hooks"]["UserPromptSubmit"].as_array().unwrap().len(), + 1 + ); + assert_eq!(once["hooks"]["Stop"].as_array().unwrap().len(), 1); + } + + #[test] + fn replaces_stale_claude_model_state_commands_without_touching_user_hooks() { + let existing = json!({ + "hooks": { + "SessionStart": [ + user_hook_entry(), + sce_hook_entry("sce hooks session-model") + ], + "PostModelSwitch": [sce_hook_entry("sce hooks old-model-state")] + } + }); + + let merged = + merge_claude_settings(&existing, &generated_settings(), "settings.json").unwrap(); + + assert_eq!( + merged["hooks"]["SessionStart"].as_array().unwrap(), + &[ + user_hook_entry(), + sce_hook_entry("sce hooks claude-model-state") + ] + ); + assert_eq!( + merged["hooks"]["PostModelSwitch"].as_array().unwrap(), + &[sce_hook_entry("sce hooks claude-model-state")] + ); + } + #[test] fn drops_sce_entry_the_generated_document_no_longer_declares() { let existing = json!({ diff --git a/config/pkl/renderers/claude-content.pkl b/config/pkl/renderers/claude-content.pkl index 2abbce93..51ab840d 100644 --- a/config/pkl/renderers/claude-content.pkl +++ b/config/pkl/renderers/claude-content.pkl @@ -15,6 +15,26 @@ settings = new common.RenderedTextFile { { "$schema": "https://json.schemastore.org/claude-code-settings.json", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "\(sceHookCommand("sce hooks claude-model-state"))" + } + ] + } + ], + "PostModelSwitch": [ + { + "hooks": [ + { + "type": "command", + "command": "\(sceHookCommand("sce hooks claude-model-state"))" + } + ] + } + ], "PreToolUse": [ { "matcher": "Bash", diff --git a/context/architecture.md b/context/architecture.md index 9c7ed7fa..feda173d 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -47,7 +47,7 @@ The scaffold provides stable canonical content-unit identifiers and reusable tar Renderer modules apply target-specific metadata/frontmatter rules while reusing canonical content bodies: - All four renderers consume the six canonical workflow packages as behavior sources. OpenCode, Claude, and Pi emit the same six command-routed workflow packages: `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`; Codex emits the same six as skill packages only, with no command or prompt layer. Each renderer also emits the standalone internal `sce-decision` package with `SKILL.md` plus `references/adr-template.md`; it stays outside workflow composition and has no command or prompt. For the phase-based workflows, `workflow-composite.pkl` renders one `SKILL.md` that owns input parsing, phase order, branching, waits, and same-session resume, plus package-local references for each applicable phase and persisted-document format. The applicable reference must be read before its phase runs. `references/output.md` remains the sole owner of human-visible gates and terminal layouts. Phase statuses remain internal, commands and prompts still invoke exactly one workflow skill, and SCE sibling handoffs remain limited to the successful task-synchronization gate's bounded `sce-decision` invocation; `/validate` reports validation directly without a plan-synchronization handoff. Relevant non-SCE skills may help within the active step and must return control without weakening its invariants. `sce-handover` and `sce-brownfield` are phase-free; handover has a package-local persisted-format template in addition to `SKILL.md` and `references/output.md`, while brownfield retains its two-file shape. OpenCode, Claude, Pi, and Codex render identical package-relative inventories and document bodies for each workflow, apart from supported target frontmatter. -- Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs. The Pi renderer adds no frontmatter to either prompts or skills. The Codex renderer likewise adds no frontmatter and, unlike Pi, emits no `commands` mapping at all — `config/.agents` has no command/prompt directory. Codex also differs from all three other targets in the two arguments it passes `skillDocuments` beyond frontmatter: the arguments-reference token (OpenCode, Claude, and Pi pass the literal `$ARGUMENTS` their harnesses substitute, while Codex passes the plain-prose token `invocation input`, since its skill loading provides no such substitution) and a per-workflow-slug invocation-example function (empty for OpenCode, Claude, and Pi; for Codex, one authored, runnable `$sce-{slug} ...` example per catalog workflow). So Codex's `## Input` prose and its `sce-handover`/`sce-brownfield` `references/output.md` diverge from Pi's by the arguments-reference token, and every Codex skill's `## Input` section additionally carries a trailing "For example: `$sce-{slug} ...`." paragraph that Pi/Claude/OpenCode do not render. +- Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs, with `SessionStart` and `PostModelSwitch` lifecycle registrations routed to the local model-state hook. The Pi renderer adds no frontmatter to either prompts or skills. The Codex renderer likewise adds no frontmatter and, unlike Pi, emits no `commands` mapping at all — `config/.agents` has no command/prompt directory. Codex also differs from all three other targets in the two arguments it passes `skillDocuments` beyond frontmatter: the arguments-reference token (OpenCode, Claude, and Pi pass the literal `$ARGUMENTS` their harnesses substitute, while Codex passes the plain-prose token `invocation input`, since its skill loading provides no such substitution) and a per-workflow-slug invocation-example function (empty for OpenCode, Claude, and Pi; for Codex, one authored, runnable `$sce-{slug} ...` example per catalog workflow). So Codex's `## Input` prose and its `sce-handover`/`sce-brownfield` `references/output.md` diverge from Pi's by the arguments-reference token, and every Codex skill's `## Input` section additionally carries a trailing "For example: `$sce-{slug} ...`." paragraph that Pi/Claude/OpenCode do not render. - Pi renderer consumes the same shared workflow composition as OpenCode and Claude. It emits exactly six thin prompts to `config/.pi/prompts/{slug}.md`, each routing to exactly one workflow skill, four phase-based workflow packages with package-local phase and supporting references plus the phase-free handover and brownfield packages under `config/.pi/skills/{slug}/` (handover also has its persisted-format template), and the standalone `sce-decision` package beside them. Pi prompts and skills carry no target-specific frontmatter beyond the shared description and argument hint, so Pi passes the empty extra-frontmatter string to both package render paths. It emits no Pi agent-role prompts. Pi has no settings/plugin manifest; runtime integration remains the project-local extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts` to `config/.pi/extensions/sce/index.ts` (auto-discovered by Pi, no registration manifest; see `context/sce/pi-extension-runtime.md`). - Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi; its `skillDocuments` output matches Pi's byte-for-byte for every shared document except where the arguments-reference token appears (`invocation input` in place of Pi's substituted `$ARGUMENTS`, in every skill's `## Input` prose and in `sce-handover`/`sce-brownfield`'s `references/output.md`) and where every skill's `## Input` section carries Codex's own trailing concrete `$sce-{slug}` invocation-example paragraph, which Pi's `skillDocuments` call passes as empty and so never renders. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. Codex alone additionally carries `{skillSlug}/agents/openai.yaml` for each of the six catalog workflow skills (not `sce-decision`), rendered by `codex-metadata.pkl` from the same catalog `title`/`description` plus an authored `default_prompt`, with `policy.allow_implicit_invocation: false` so these stateful lifecycle workflows activate only from explicit `$sce-` invocation or `/skills` discovery, never from conversational relevance alone; no other target has an implicit-invocation policy concept. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash` only, and `PostToolUse` for `apply_patch` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the generated command resolves the Git root at invocation time and invokes that helper with quoted paths, so it works from nested event directories and spaced repository paths while exiting successfully when Git-root resolution fails. No Codex analog to `$CLAUDE_PROJECT_DIR` is required. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, and `PostToolUse(apply_patch)` to distinct dispatch arms, all four now real behavior. `UserPromptSubmit` and `Stop` each persist one `messages`/`parts` row atomically through the shared `insert_conversation_text_event` transactional primitive (a replayed or concurrent duplicate delivery leaves exactly one row pair); `PreToolUse(Bash)` delegates to the existing Bash policy engine (`evaluate_bash_command_policy`), returning Codex's own native `PreToolUse` deny response or silent allow; and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves source and move-destination paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row via the existing `insert_diff_trace` when non-empty — invalid cwd/path resolution, invalid/missing sessions, Delete-File operations, and a `Move to` with no changed lines produce no evidence; reported model IDs remain unqualified unless Codex supplied a qualifier (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)) — with every other combination, and any malformed STDIN, failing open silently. Bash-triggered filesystem mutations remain untracked for Codex. - Workflow composition itself is shared rather than per target. `config/pkl/renderers/workflow-composite.pkl` owns the six composite workflow definitions and assembles their references, while each composite looks up its typed identity in `config/pkl/base/workflow-catalog.pkl` and migrated workflow modules supply canonical phase, persisted-document, and output documents. Every workflow supplies a required `StructuredCompositeSource`, so commands, phase documents, persisted-document formats, and output references render in package or composite mode before Markdown assembly. The renderer has no nullable legacy adapter, frontmatter stripping, or prose-wide replacement chain. Its `renderSkill`, `renderCommand`, and `skillDocuments` entrypoints take a newline-terminated `extraFrontmatterLines` string carrying only the frontmatter a target supports; a target that adds none passes the empty string. `renderSkill` and `skillDocuments` additionally take an `argumentsReference` string naming the invocation input in skill-mode prose — `$ARGUMENTS` for OpenCode, Claude, and Pi, whose harnesses substitute it, or a plain-prose token for a target whose skill loading does not (Codex passes `invocation input`) — and an `invocationExample` function from skill slug to a concrete `$sce-{slug}` example string, appended by `model.invocationExampleParagraph` immediately before each workflow's `## Workflow` heading when non-empty; OpenCode, Claude, and Pi pass `(_) -> ""` so their `## Input` sections are unaffected, while Codex supplies one authored example per catalog workflow. `renderCommand`'s thin wrapper text is unaffected by either parameter and always states the literal `$ARGUMENTS` its harness substitutes. `renderSkill` assembles the document as an ordered section list — preamble (purpose, user-visible output, and the composite control-flow rules, all stated before the workflow's `## Input`), then the workflow body, then the phase appendix and any persisted-document formats, each emitted only when its listing is non-empty. Claude passes `compatibility: claude` for skills and a catalog-derived `allowed-tools` line for commands. The `renderSkill` preamble also carries the no-improvisation rule that every generated workflow `SKILL.md` states on every target: the executing agent follows the canonical workflow's steps, gates, and stops exactly as written and never invents, skips, reorders, or merges a step, and its user-visible output is limited to the `references/output.md` layouts with no invented layout and no added preamble, commentary, summary, or extra section. Its generic control-flow wording says that any workflow-defined user wait resumes the same skill in the same session; workflow-specific wait semantics remain in the workflow that owns them. The rule is prose instruction only; the generation contract checks assert paths and metadata, not agent behavior. @@ -120,7 +120,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/config/types.rs` is the canonical owner for the shared runtime/config primitive seam used by the CLI: `LogLevel`, `LogFormat`, `SCE_LOG_LEVEL`, `SCE_LOG_FORMAT`, `SCE_LOG_DIR`, `DEFAULT_LOG_FILE_RETENTION_LIMIT`, and the shared bool parsing helpers used by both config resolution and observability bootstrap; `cli/src/services/config/mod.rs` re-exports those primitives through the facade. - `cli/src/services/capabilities.rs` defines the current broad CLI capability traits consumed by the borrowed, compile-time-typed `AppContext`: `FsOps` with `StdFsOps` for filesystem operations and `GitOps` with `ProcessGitOps` for git command execution plus repository-root/hooks-directory resolution. Existing service internals do not consume these traits directly yet; command execution uses narrow accessors and repo-root-scoped context derivation. - `cli/src/services/lifecycle.rs` defines the current compile-safe lifecycle seam. `ServiceLifecycle` has default no-op generic `diagnose`, `fix`, and `setup` methods over `C: HasRepoRoot`, with lifecycle-owned health, fix, and setup result types so the trait contract is not publicly anchored to doctor/setup module types or the full `AppContext` shape. The same module owns the static `LifecycleProvider` enum and shared `lifecycle_providers(include_hooks)` catalog/factory, returning providers in deterministic order (config → local_db → auth_db → agent_trace_db → hooks when requested); enum dispatch calls each concrete provider through generic context methods without boxed lifecycle-provider allocation or repo-root trait-object context erasure. Hooks exposes a `HooksLifecycle` provider in `cli/src/services/hooks/lifecycle.rs` for hook rollout diagnosis/fix/setup using lifecycle-owned health records plus the canonical required-hook installer. Config exposes a `ConfigLifecycle` provider in `cli/src/services/config/lifecycle.rs` for global/repo-local config validation and repo-local `.sce/config.json` bootstrap. local_db exposes a `LocalDbLifecycle` provider in `cli/src/services/local_db/lifecycle.rs` for canonical local DB path health, parent-directory readiness/bootstrap, and `LocalDb::new()` setup. auth_db exposes an `AuthDbLifecycle` provider in `cli/src/services/auth_db/lifecycle.rs` for canonical auth DB path health, parent-directory readiness/bootstrap, and `AuthDb::new()` setup. agent_trace_db exposes an `AgentTraceDbLifecycle` provider in `cli/src/services/agent_trace_db/lifecycle.rs` for setup-time repository-scoped Agent Trace storage initialization when a repo root is available and repository Agent Trace DB path health/fix from resolved repository identity, returning an actionable "requires a Git repository" diagnostic outside repository context (no global/checkout fallback path; the former fallback was removed by the `retire-legacy-agent-trace-db` plan). Doctor runtime aggregates the full provider catalog for `diagnose` and `fix` and adapts lifecycle records into doctor report/fix records at the orchestration boundary; setup command aggregates the shared catalog for `setup` with hooks included only when requested and adapts hook setup outcomes before rendering setup-owned messages. -- Agent Trace lifecycle setup resolves `agent_trace.repository_id` / `agent_trace.repository_remote`, creates/reuses checkout identity for diagnostics, and initializes the repository-scoped DB through `agent_trace_storage::resolve_agent_trace_storage(...)`; hook runtime uses the same storage resolver and `RepositoryAgentTraceDb` lazy fast-path-then-migrate open with bounded retry plus narrow migration-metadata repair for concurrent first-open races. +- Agent Trace lifecycle setup resolves `agent_trace.repository_id` / `agent_trace.repository_remote`, creates/reuses checkout identity for diagnostics, and creates or migrates the repository-scoped DB through `agent_trace_storage::resolve_agent_trace_storage(...)`; hook runtime uses the same storage identity/path resolution and no-migration open path, with missing or stale schema failing open through the existing `Run 'sce setup'.` guidance. - `cli/src/services/auth_command/mod.rs` defines the implemented auth command surface for `sce auth login|logout|whoami`, including device-flow login, stored-credential validation/renewal through login with device-flow fallback, logout, and Control Plane `/me`-backed whoami rendering in text/JSON formats; text mode uses flat `Email`, `First Name`, `Last Name`, `Role`, `Permissions`, and `Organization Name` labels, with optional names and missing role/permissions/workspace values handled deterministically. Logged-out text returns exact login guidance and renewal reports retain the `login` operation label. `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. There is no public renewal or status subcommand. - `cli/src/services/db/mod.rs` provides the shared generic Turso infrastructure seam: `DbSpec` supplies a service-specific name, path, ordered embedded migrations, and config-file lookup key (`db_config_key()`), while `TursoDb` owns parent-directory creation, `Builder::new_local(...)` initialization (with `experimental_multiprocess_wal(true)` for safe concurrent access), Turso connection setup, tokio current-thread runtime bridging, retry-backed blocking `execute`/`query`/`query_values`/`query_map` wrappers, and generic migration execution with per-database `__sce_migrations` metadata. `TursoDb::new()` and `EncryptedTursoDb::new()` wrap only their local open/connect block in `run_with_retry_sync` using a config-driven connection-open policy resolved from the `DATABASE_RETRY_CONFIG` `OnceLock` with fallback to hardcoded defaults, while operation methods use a config-driven operation policy from the same source. `query_values()` returns fully fetched column names plus raw `turso::Value` rows for deterministic operator-facing rendering; `query_map()` retries the initial query and row-fetch loop, then applies caller row mapping after retry completion. Migration execution is not retried and uses batch execution so one migration file may contain multiple SQL statements while still recording one migration ID. The same module also provides `EncryptedTursoDb`, a structurally parallel encrypted adapter that resolves the encryption key through `encryption_key::get_or_create_encryption_key()`, enables Turso local encryption with strict `aegis256` cipher selection, and exposes retry-backed synchronous wrappers plus migration execution. `cli/src/services/db/encryption_key.rs` first derives a Turso-compatible 64-character hex key from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text when present, otherwise falls back to keyring-backed credential-store get-or-create behavior; no plaintext auth DB fallback exists. - `cli/src/services/local_db/mod.rs` provides the concrete local DB spec and `LocalDb` type alias over the shared generic `TursoDb` adapter. `LocalDbSpec` resolves the deterministic persistent runtime DB target through the shared default-path seam and declares no local migrations; `TursoDb` supplies retry-backed blocking `execute`/`query`, parent-directory creation, Turso connection setup, tokio current-thread runtime bridging, and generic migration execution. @@ -132,8 +132,8 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns the `DoctorCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Runtime doctor execution resolves a repository root, derives a scoped context, requests the shared static lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Agent Trace database inspection is no longer a doctor-adjacent command surface; doctor owns repository-scoped DB health and checkout identity facts, while `sce sync` owns control-plane synchronization. Report fact collection preserves environment/repository/hook/integration display data and adds a non-launching `post_commit_auto_sync` fact based on canonical post-commit managed-block currency plus resolved `agent_trace.auto_sync` source/value; this fact does not launch `sce sync`, and existing hook problem/remediation/readiness semantics remain authoritative. Service-owned lifecycle providers own config validation, local DB and repository-scoped Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. Integration inspection in `doctor/inspect.rs` is scoped twice over: `resolve_doctor_integration_targets` picks which targets to inspect, and `persisted_optional_workflows` (reused from setup) resolves which optional workflows the repository selected, which the OpenCode/Claude/Pi/Codex child collectors apply through `iter_embedded_assets_for_setup_target_with_selection`. An unselected optional workflow therefore contributes no expected children at all, so no row and no missing/mismatch problem can be produced for it, while a selected one keeps the unchanged presence and content-hash checks. Codex's collector (`collect_codex_integration_groups`) is the one exception to the single-target-directory shape the other three share: since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix, it resolves its integration root through `InstallTargetPaths::codex_target_dir()` (the repository root itself) rather than a `RepoPaths` per-target subdirectory, and splits assets into `Skills` (`.agents/skills/` prefix) and `Hooks` (`.codex/` prefix) groups instead of Pi's `Extensions`/`Prompts`/`Skills` split. Its `.codex/hooks.json` reporting is per-registration rather than one whole-file child: `codex_hook_config::diagnose_document` classifies each of the four required registrations as `PresentAndCurrent`/`Missing`/`Stale` (or the whole document as `Malformed` when it cannot be structurally validated) without writing anything, so unrelated user handlers never create a false whole-document mismatch. For a structurally current registration, `codex_hook_trust` separately reads (never writes) Codex's own durable `$CODEX_HOME/config.toml` hook-trust state — reproducing upstream's `hook_hash`/`hook_key`/`hook_trust_status` exactly — and reports `Trusted`/`Untrusted`/`Modified`/`Disabled`/`Unknown`; only `Trusted` renders healthy. `sce doctor --fix` repairs a structurally unhealthy `.codex/hooks.json` through the existing merge-install path, but a registration that is current yet not-yet-trusted is never "fixed", since SCE cannot grant Codex hook trust. - `cli/src/services/version/mod.rs` defines the version command parser/rendering contract (`parse_version_request`, `render_version`) with deterministic text output and stable JSON runtime-identification fields; `cli/src/services/version/command.rs` owns the `VersionCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/services/completion/mod.rs` defines completion parser/rendering contract (`parse_completion_request`, `render_completion`) with deterministic Bash/Zsh/Fish script output aligned to current parser-valid command/flag surfaces; `cli/src/services/completion/command.rs` owns the `CompletionCommand` payload used by the static `RuntimeCommand` enum. -- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the enabled-by-default attribution-hooks config/env control is not opted out, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); the preflight is wired into `run_commit_msg_subcommand_in_repo` and logs `sce.hooks.commit_msg.ai_overlap_error` on error paths; `cli/src/services/hooks/command.rs` owns the `HooksCommand` payload used by the static `RuntimeCommand` enum. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent repository-level `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists result to `post_commit_patch_intersections`, then persists built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in the repository-scoped Agent Trace DB without post-commit file artifacts); after successful validation and persistence, the default-enabled config-file-only `agent_trace.auto_sync` gate launches one detached sync-owned `sync --format json` child unless explicitly disabled in config, with launcher failures ignored and no high-frequency hook trigger; `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`), required nullable/non-empty `tool_version` plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit signed `time_ms` storage, prefixes the stored `diff_traces.session_id` before insert construction (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi, same-tool idempotent), and inserts the parsed payload fields into `RepositoryAgentTraceDb` without creating a parsed-payload `context/tmp` artifact; Claude structured `PostToolUse` diff-trace intake resolves model attribution event-locally: direct top-level or nested metadata wins, otherwise the event's `transcript_path` is scanned for the assistant envelope whose `tool_use.id` matches `tool_use_id`; either source is normalized once with the `claude/` prefix and lookup failures remain nullable. `session-model` is no longer a supported hook route. -- Generated Claude settings no longer register `SessionStart` for Agent Trace model attribution, and `sce hooks session-model` is no longer a supported hook command. The `session_models` table/API and session-level fallback lookup were removed in T02 of the `remove-session-models-direct-claude-model-id` plan; `diff-trace` now uses direct-first/event-transcript-second Claude `model_id` resolution and direct `tool_version` values, without restoring session-level state. +- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`), including the silent `claude-model-state` lifecycle intake delegated to `cli/src/services/hooks/claude_model_state.rs`, plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the enabled-by-default attribution-hooks config/env control is not opted out, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); the preflight is wired into `run_commit_msg_subcommand_in_repo` and logs `sce.hooks.commit_msg.ai_overlap_error` on error paths; `cli/src/services/hooks/command.rs` owns the `HooksCommand` payload used by the static `RuntimeCommand` enum. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent repository-level `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists result to `post_commit_patch_intersections`, then persists built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in the repository-scoped Agent Trace DB without post-commit file artifacts); after successful validation and persistence, the default-enabled config-file-only `agent_trace.auto_sync` gate launches one detached sync-owned `sync --format json` child unless explicitly disabled in config, with launcher failures ignored and no high-frequency hook trigger; `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`), required nullable/non-empty `tool_version` plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit signed `time_ms` storage, prefixes the stored `diff_traces.session_id` before insert construction (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi, same-tool idempotent), and inserts the parsed payload fields into `RepositoryAgentTraceDb` without creating a parsed-payload `context/tmp` artifact; Claude structured `PostToolUse` diff-trace intake resolves model attribution with `direct > exact transcript > exact session/agent state > NULL`: direct top-level or nested metadata wins, otherwise the event's `transcript_path` is scanned for the assistant envelope whose `tool_use.id` matches `tool_use_id`, then persistence performs one exact local `claude_model_state` lookup using canonical session and ephemeral agent scope; model values normalize once through `claude/`, subagents do not inherit main-session state, and unresolved values remain nullable. `session-model` is no longer a supported hook route. +- Generated Claude settings register `SessionStart` and `PostModelSwitch` only for the local model-state hook; `sce hooks session-model` is no longer a supported hook command. Claude Code 2.1.250 and 2.1.251 compatibility smoke passed with the unknown `PostModelSwitch` registration, so SCE installs it unconditionally without a raised minimum or capability gate. The `session_models` table/API and generic session-level fallback lookup were removed in T02 of the `remove-session-models-direct-claude-model-id` plan; the separate `sce hooks claude-model-state` command writes Claude lifecycle observations into the non-exported exact-scope register through the no-migration hook-runtime DB path, without restoring that generic abstraction. `diff-trace` uses direct-first/event-transcript-second Claude `model_id` resolution and consults the exact local lifecycle state only as its final fallback, with direct `tool_version` values. - `cli/src/services/resilience.rs` defines bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) for transient operation hardening with deterministic failure messaging and retry observability. - `context/cli/agent-trace-auto-sync.md` documents the sync-owned, one-shot post-commit launcher boundary: it reuses `sce sync`, has no daemon or local retry machinery, and fails open when child startup cannot be completed; doctor reports this capability without invoking the launcher. - `cli/src/services/sync/progress.rs` owns the sync-local, consumer-typed progress seam: generic `ProgressReporter` supports event delivery plus explicit successful finalization, closure-based collectors, and a no-op implementation alongside the fixed `indicatif` stderr presentation adapter. `cli/src/services/sync/sync.rs` owns `SyncProgressEvent` and its four-stream payload semantics, while `sync/command.rs` selects the terminal adapter for text and the no-op reporter for JSON. There is no top-level `cli/src/services/progress/` module; sync orchestration depends only on its sync-owned contract, so terminal-library details stay at the sync presentation boundary. diff --git a/context/context-map.md b/context/context-map.md index b1985622..db8fc502 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -63,13 +63,13 @@ Feature/domain context: - `context/sce/local-db.md` (implemented `cli/src/services/local_db/mod.rs` local database spec with `LocalDb = TursoDb`, canonical local DB path resolution, zero local migrations, and inherited retry-backed blocking `execute`/`query`/`query_map` methods using the shared Turso adapter) - `context/sce/shared-turso-db.md` (current shared `cli/src/services/db/mod.rs` Turso database infrastructure seam, including `DbSpec`, generic `TursoDb`, encrypted `EncryptedTursoDb`, build-time generated migration constants from `cli/build.rs`/Cargo `OUT_DIR`, config-driven constructor/open-connect retry via `run_with_retry_sync`, no-migration `TursoDb::open_without_migrations()` / explicit-path `open_without_migrations_at(path)` for hot runtime paths, migration-running `new()` / explicit-path `new_at(path)` / `run_migrations()` with per-database `__sce_migrations` tracking, config-driven operation retry for `execute`/`query`/`query_values`/`query_map` with a `<= 2_000ms` default query failure budget, raw-value row fetching for deterministic operator-facing rendering, row-mapping excluded from retry, generic embedded migration execution, non-mutating `migration_metadata_problems()` and `ensure_schema_ready(setup_guidance)` readiness methods on `TursoDb`, non-mutating-data `passive_checkpoint()` PASSIVE WAL checkpoint method on `TursoDb` (not on `EncryptedTursoDb`, fail-open, no truncation guarantee, called once by `sce hooks post-commit` after successful Agent Trace persistence — see `context/sce/agent-trace-hooks-command-routing.md`), and concrete wrappers for `LocalDb`, `AuthDb`, plus `RepositoryAgentTraceDb`) - `context/sce/auth-db.md` (encrypted `AuthDb = EncryptedTursoDb` adapter, canonical `/sce/auth.db` path, build-time generated `AUTH_MIGRATIONS` from `cli/migrations/auth/`, auth credential schema and updated-at trigger baseline, lifecycle setup/doctor integration, encrypted token-storage persistence, and `SCE_AUTH_DB_ENCRYPTION_KEY`/OS credential-store key handling) -- `context/sce/agent-trace-db.md` (implemented Agent Trace database adapter: the sole repository-scoped `RepositoryAgentTraceDb` backed by the fresh multi-statement baseline schema plus the additive `source_instance_id` migration, with `repository_metadata` carrying both `repository_id` and a concurrency-safe atomic-claim `source_instance_id` (physical database identity, independent of `repository_id`), narrow concurrent-first-open repair for missing one-file baseline migration metadata after all required schema tables exist, no trace-table `checkout_id` columns, repository-level typed insert helpers for diff traces, post-commit intersections, Agent Trace rows, messages, and parts, repository-level recent diff-trace reads without checkout filtering including persisted hunk-model plus canonical touched-line-session enrichment for structured rows, on-demand command/hook initialization with no daemon/background service, and the never-touch on-disk boundary for any pre-migration checkout-scoped/global DB files; the checkout-scoped `AgentTraceDb` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, and the 15-file `cli/migrations/agent-trace/` chain were removed by the `retire-legacy-agent-trace-db` plan; active hook writers/readers and Agent Trace setup/lifecycle resolve repository storage through `agent_trace_storage`) +- `context/sce/agent-trace-db.md` (implemented Agent Trace database adapter: the sole repository-scoped `RepositoryAgentTraceDb` backed by the fresh multi-statement baseline schema plus additive `source_instance_id` and `claude_model_state` migrations, with `repository_metadata` carrying both `repository_id` and a concurrency-safe atomic-claim `source_instance_id` (physical database identity, independent of `repository_id`), narrow concurrent-first-open repair for missing one-file baseline migration metadata after all required schema tables exist, no trace-table `checkout_id` columns, repository-level typed insert helpers for diff traces, post-commit intersections, Agent Trace rows, messages, parts, and the non-exported exact-scope Claude model-state register, repository-level recent diff-trace reads without checkout filtering including persisted hunk-model plus canonical touched-line-session enrichment for structured rows, on-demand command/hook initialization with no daemon/background service, and the never-touch on-disk boundary for any pre-migration checkout-scoped/global DB files; the checkout-scoped `AgentTraceDb` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, and the 15-file `cli/migrations/agent-trace/` chain were removed by the `retire-legacy-agent-trace-db` plan; active hook writers/readers and Agent Trace setup/lifecycle resolve repository storage through `agent_trace_storage`) - `context/sce/agent-trace-export-readers.md` (implemented `AgentTraceExportReader<'a>` in `cli/src/services/agent_trace_export/mod.rs`: the read-only local export boundary over `RepositoryAgentTraceDb` — `read_messages_after`/`read_parts_after`/`read_diff_traces_after`/`read_agent_traces_after`, each cursor/limit/JS-safe-integer validated, materialized into owned camelCase `serde::Serialize` DTOs; composes directly with `ResolvedAgentTraceStorage` without owning `source_instance_id`; no local sync cursor, no `agent-trace-sync.db`, no Turso Sync, no ETL, no DWH) - `context/sce/agent-trace-core-schema-migrations.md` (historical reference for removed local DB schema bootstrap behavior; T03 now implements the actual local DB with migrations) - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) - `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` (T01 Local Hooks MVP production contract freeze and deterministic gap matrix for `agent-trace-local-hooks-production-mvp`) - `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, and always-emitted `metadata.sce.line_changes` (`{ai,mixed,unknown}` each `{added,removed}` `u64` counters, `#[serde(default)]` for backward-compatible deserialization) carrying exact touched-line attribution counts from canonical `post_commit_patch` hunks reusing the same per-hunk classification, plus per-file trace data from patch inputs via `intersect_patches(constructed_patch, post_commit_patch)` then `post_commit_patch`-anchored hunk classification into `ai`/`mixed`/`unknown` contributor categories, serialized per conversation with a required lookup `url` derived from top-level `AgentTrace.id`, nested `contributor.type` with optional `contributor.model_id` omitted when provenance is missing, optional canonical session links derived from matched touched-line provenance, one derived `ranges[{start_line,end_line,content_hash}]` entry per post-commit or embedded-patch hunk, and range `content_hash` values that hash touched-line kind/content independent of positions and metadata) -- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection and Agent Trace DB persistence, DB-only `diff-trace` STDIN intake with OpenCode normalized payloads and Claude structured `PostToolUse` payload classification, tool-prefixed stored `diff_traces.session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) while hook diagnostics route with unprefixed producer-native sessions when available, nullable event-local `model_id`/direct `tool_version` persistence without session fallback, Claude direct-first model metadata extraction from top-level or nested fields followed by fail-open `transcript_path` + `tool_use_id` JSONL lookup when direct metadata is absent, `claude/` prefix normalization, no parsed-payload artifact persistence under `context/tmp`, `session-model` removed from the supported hook surface, and `conversation-trace` STDIN intake for normalized batches plus supported raw Claude events with per-item and first-valid-insert batch diagnostic routing; this document also owns the current `diff-trace` and `conversation-trace` fail-open intake contracts.) +- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection and Agent Trace DB persistence, DB-only `diff-trace` STDIN intake with OpenCode normalized payloads and Claude structured `PostToolUse` payload classification, tool-prefixed stored `diff_traces.session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) while hook diagnostics route with unprefixed producer-native sessions when available, nullable `model_id`/direct `tool_version` persistence with Claude `direct > exact transcript > exact session/agent state > NULL` attribution, Claude direct-first model metadata extraction from top-level or nested fields followed by fail-open `transcript_path` + `tool_use_id` JSONL lookup and exact local state lookup when model is absent, `claude/` prefix normalization, no parsed-payload artifact persistence under `context/tmp`, the silent local-only `sce hooks claude-model-state` lifecycle intake for synchronous `SessionStart` and asynchronous `PostModelSwitch` writes, `session-model` removed from the supported hook surface, and `conversation-trace` STDIN intake for normalized batches plus supported raw Claude events with per-item and first-valid-insert batch diagnostic routing; this document also owns the current `diff-trace`, `conversation-trace`, and Claude model-state fail-open intake contracts.) - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus current Rust evaluator seam and OpenCode/Claude delegation references, including config schema, argv-prefix matching, shell/nix unwrapping, custom-policy `satisfied_by` wrapper exemption, fixed preset catalog/messages, and precedence rules) - `context/sce/bash-policy-satisfied-by-wrapper-exemption.md` (custom-policy `satisfied_by` field: optional list of wrapper argv prefixes that exempt a policy from firing when the matched command was unwrapped from one of them; `NormalizedSegment` wrapper-chain tracking, matching model, examples, and scope) @@ -103,6 +103,8 @@ Recent decision records: - `context/decisions/2026-08-23-codex-event-scoped-apply-patch-evidence-identities.md` (uses bounded, deterministic `tool_use_id`-derived synthetic line identities for Codex apply_patch evidence; positions are evidence identities rather than source line numbers, with existing patch combination/intersection semantics unchanged) - `context/decisions/2026-08-23-codex-truthful-model-provenance.md` (preserves non-empty Codex model IDs unchanged, leaves blank/missing values nullable, and forbids inferred provider prefixes or a fabricated provider field) - `context/decisions/2026-08-23-codex-root-aware-hook-invocation.md` (requires generated Codex hook commands to resolve the Git root at invocation time, quote the helper path, preserve STDIN, and fail open when root resolution fails) +- `context/decisions/2026-09-01-claude-model-attribution-state.md` (accepts a Claude-specific local latest-model-state register as a bounded exception to the prior no-session-level-cache attribution constraint, with best-effort local observation-time ordering and no export/sync scope) +- `context/decisions/2026-09-01-claude-post-model-switch-compatibility.md` (accepts unconditional installation of the Claude `PostModelSwitch` registration after Claude Code 2.1.250/2.1.251 compatibility smoke showed unknown-event tolerance and settings preservation) - `context/decisions/2026-08-26-setup-storage-fail-closed-on-invalid-config.md` (keeps general startup degradation for invalid discovered config while making setup and Agent Trace storage fail closed before side effects or fallback identity selection) - `context/decisions/2026-08-14-compact-task-record-supersedes-handoff.md` (the completed task record — `Completed`/`Files changed`/`Result`/`Verify`/`Context impact`/`Context synchronization`, identified only by plan path and task ID — is the sole durable input for immediate and cross-session task synchronization, with no separate persisted `Context synchronization handoff` structure; supersedes only the handoff-shape portion of `2026-08-12-persist-workflow-sync-lifecycle-in-plans.md`, whose `pending`/`synced`/`blocked` lifecycle-state invariant remains in force) - `context/decisions/2026-08-12-decision-gate-semantics.md` (nonqualifying/skipped decision gates are non-blocking; ADRs are immutable, active-only reuse is allowed, changed decisions create new dated records, and `Deprecated`/`Superseded` are creation-time-only statuses) diff --git a/context/decisions/2026-09-01-claude-model-attribution-state.md b/context/decisions/2026-09-01-claude-model-attribution-state.md new file mode 100644 index 00000000..7c921570 --- /dev/null +++ b/context/decisions/2026-09-01-claude-model-attribution-state.md @@ -0,0 +1,155 @@ +# Decision: Add Claude-specific latest model state for diff-trace attribution + +Date: 2026-09-01 +Status: Accepted +Plan: `context/plans/claude-model-attribution-state.md` +Task: T01 +Supersedes: The no-session-level-cache constraint for Claude model attribution in `context/plans/remove-session-models-direct-claude-model-id.md` (only that constraint; the historical plan remains unchanged) + +## Context + +Production data shows that most Claude `diff_traces` rows have a `NULL` +`model_id`. Claude `PostToolUse` events do not normally expose model identity, +and the existing event-local transcript fallback is often unable to help because +Claude writes the transcript asynchronously and it may not yet contain the +matching assistant record when the hook runs. Direct metadata and exact +transcript lookup therefore remain useful, but they cannot close the attribution +gap at hook time. + +Claude now provides two lifecycle signals that can supply local context: +`SessionStart.model` and `PostModelSwitch` with `from_model`, `to_model`, and a +source such as `command`, `picker`, `sdk`, `auto`, or `resume`. The hook contract +does not provide an authoritative upstream sequence number or event timestamp, +and it provides no synchronization barrier between a lifecycle hook and later +`PostToolUse` hooks. + +The former generic `session_models` abstraction was deliberately removed. Its +removal simplified the cross-editor data model, but restoring it would recreate +a broad session-level architecture for a problem specific to Claude's lifecycle +signals. + +## Decision + +Add a small Claude-specific latest-model-state register to each existing +repository-scoped Agent Trace DB. The `claude_model_state` register is keyed by +canonical `(session_id, agent_id)`, with the empty agent ID representing the +main conversation. A model-bearing `SessionStart` records normalized +`claude/` state, and `PostModelSwitch` records normalized `to_model` +state after the local hook write completes. `from_model` is validated and may +inform diagnostics, but it is not a compare-and-swap precondition. + +Diff-trace attribution uses this state only as a final local fallback, preserving +this precedence: + +1. direct Claude event metadata; +2. an exact `tool_use_id` match in the event's transcript; +3. current Claude state for the exact canonical session and agent scope; +4. `NULL`. + +The state is local-only. It is not exported, synchronized, exposed through a +control-plane endpoint, or added to the Agent Trace export streams. The durable +exported attribution remains only `diff_traces.model_id`; ephemeral Claude +`agent_id` context is used for exact local lookup and is not persisted in that +schema or sent to the control plane. There is no historical backfill. + +`observed_at_ms` means the local SCE time at which the hook observed the event, +not Claude's authoritative causal event time. The register is therefore a +best-effort latest-locally-observed register, not an event log and not a proof +of switch order. Strictly older local observations cannot overwrite newer ones. +PostModelSwitch wins equal-timestamp conflicts against SessionStart, and equal +timestamps within one observation kind use a stable deterministic tie-breaker +rather than arrival order. Replayed identical observations are idempotent. + +## Rationale + +A Claude-specific register addresses the observed timing gap without changing +the shared Agent Trace schema or reintroducing a generic cross-editor session +model. Seeding at `SessionStart` covers initial attribution, while +`PostModelSwitch.to_model` updates local state after an in-session switch. Exact +agent scoping prevents a subagent without its own state from inheriting the +parent conversation's model. + +The local observation-time guard is the strongest deterministic protection +available from this hook contract. It intentionally does not claim to recover +Claude's causal ordering, because no upstream sequence/timestamp metadata or +synchronization barrier is available to SCE. + +## Alternatives considered + +- **Restore the generic `session_models` table and command** — rejected: it + would revive a retired cross-editor abstraction for a Claude-specific + lifecycle problem and broaden the persistence/export surface unnecessarily. +- **Keep direct and transcript attribution only** — rejected: production `NULL` + rates and asynchronous transcript visibility show that event-local lookup + alone is insufficient. +- **Treat `observed_at_ms` as Claude event time or require strict causal ordering** + — rejected: SCE observes hook delivery locally and Claude exposes neither an + authoritative sequence/timestamp nor a synchronization barrier through this + contract. +- **Poll, sleep, or wait for state convergence before persisting a diff trace** + — rejected: this would make a high-frequency hook depend on another process, + violate the fail-open/minimal-work boundary, and still would not establish + causal ordering. + +## Compatibility and risks + +- Claude invokes `SessionStart` synchronously relative to Claude's execution + and `PostModelSwitch` asynchronously relative to Claude's execution. In both + SCE handlers, the local database write is performed directly before the hook + process exits. A post-switch visibility race remains: Claude may continue + before the asynchronous PostModelSwitch hook process has completed its local + write. That trace can observe stale state or `NULL`; later traces can use the + new state. SCE does not spawn, detach, background, or defer the + `claude_model_state` write, and does not poll, retry, or delay persistence to + eliminate this race. +- Rapid consecutive switches can launch overlapping lifecycle hooks. The + local-observation guard rejects older observations and resolves equal-time + conflicts deterministically, but cannot prove Claude's causal order. Only an + upstream sequence number or authoritative event timestamp could provide that + guarantee. +- The state fallback is best-effort and cannot detect a one-turn fallback-chain + model substitution when Claude exposes that substitution neither through + lifecycle state nor through the exact transcript lookup. +- Clients that do not emit `PostModelSwitch` remain seeded by `SessionStart` and + can become stale after an in-session model switch; compatibility policy is + handled by the lifecycle-hook installation task. + +## Guardrails + +- Do not restore `session_models` or add a generic session-level attribution + abstraction. +- Do not export, sync, or add control-plane schema for `claude_model_state`. +- Do not add `agent_id` to `diff_traces`, Agent Trace payloads, or related + external interfaces. +- Do not claim that local observation time proves Claude's event order. +- Keep direct and exact-transcript attribution ahead of state, and keep + subagent lookups isolated to their exact `(session_id, agent_id)` pair. + +## Consequences + +Claude diff traces gain a local state fallback for model attribution while +unresolved cases remain nullable. Repository setup gains one additive local +migration. Claude invokes SessionStart synchronously relative to Claude's +execution and PostModelSwitch asynchronously relative to Claude's execution. In +both SCE handlers, the local database write is performed directly before the +hook process exits. The existing export boundary, sync streams, Control Plane +protocol, and historical plans remain unchanged. + +The design accepts two upstream timing limitations: a post-switch trace can +arrive before the state write is visible, and overlapping switches cannot be +ordered beyond the deterministic local-observation guard. These are explicit +best-effort semantics rather than hidden correctness claims. + +## Follow-up + +T02–T06 implement and verify the register, silent hook intake, persistence +fallback, lifecycle-hook registration, end-to-end regressions, and current-state +context synchronization. The compatibility smoke in T05 must record whether +unsupported older Claude Code builds require a raised minimum version or a +capability-gated `PostModelSwitch` registration. + +## References + +- Plan: [`claude-model-attribution-state`](../plans/claude-model-attribution-state.md) +- Historical constraint: [`remove-session-models-direct-claude-model-id`](../plans/remove-session-models-direct-claude-model-id.md) +- Related Claude attribution behavior: [`fix-claude-model-attribution`](../plans/fix-claude-model-attribution.md) diff --git a/context/decisions/2026-09-01-claude-post-model-switch-compatibility.md b/context/decisions/2026-09-01-claude-post-model-switch-compatibility.md new file mode 100644 index 00000000..540cf2b7 --- /dev/null +++ b/context/decisions/2026-09-01-claude-post-model-switch-compatibility.md @@ -0,0 +1,76 @@ +# Decision: Install Claude PostModelSwitch registration unconditionally + +Date: 2026-09-01 +Status: Accepted +Plan: `context/plans/claude-model-attribution-state.md` +Task: `T05` + +## Context + +SCE needs to register Claude's `PostModelSwitch` lifecycle event so the local +Claude model-state register can observe in-session model changes. The +immediately older Claude Code build must not reject the unknown event in a way +that loses existing SCE hooks or the user's settings file. + +A compatibility smoke used a temporary settings file containing an existing +`PreToolUse` registration and an unknown `PostModelSwitch` registration. Claude +Code 2.1.251 (the event-supporting build) and 2.1.250 (the immediately older +build) both reached the expected invalid-API-key termination rather than a +settings/configuration failure, and the settings file remained byte-unchanged. + +## Decision + +Install the `PostModelSwitch` SCE registration unconditionally alongside +`SessionStart`; do not raise SCE's Claude Code minimum version or capability-gate +the registration. + +## Rationale + +The pre-event-supporting 2.1.250 build safely tolerates the unknown registration +and preserves the settings file and existing hook configuration. Unconditional +installation keeps the generated configuration deterministic while allowing +older clients to operate in a degraded mode without post-switch updates. + +## Alternatives considered + +- **Raise the minimum Claude Code version to the first `PostModelSwitch` build** + — not selected because the immediately older supported build passed the + compatibility smoke without losing configuration. +- **Capability-gate `PostModelSwitch` installation** — not selected because the + tested older client safely ignores the unknown event and a capability probe + would add install-time complexity without improving this compatibility path. + +## Compatibility and risks + +- The tested Claude Code 2.1.250 and 2.1.251 builds retain the existing SCE + registrations and user settings when this registration is present; clients + that do not emit the event remain dependent on `SessionStart` and may have + stale in-session state. +- The smoke used an invalid API key to stop before network work; it proves + configuration acceptance and preservation, not model-switch execution. + +## Guardrails + +- The registration remains an SCE-owned command hook routed through the existing + helper and `sce hooks claude-model-state` command. +- This decision does not change the lifecycle hook's local-only, fail-open, + zero-stdout behavior or the model-state synchronization boundaries. + +## Consequences + +The canonical Claude settings renderer always emits both lifecycle registrations +and setup/doctor treat either missing registration as SCE settings drift. Older +clients without `PostModelSwitch` support continue to work but receive no +post-switch state updates. + +## Follow-up + +T06 covers end-to-end attribution regression and the remaining upgrade note. + +## References + +- Plan: [`claude-model-attribution-state`](../plans/claude-model-attribution-state.md) +- Task: `T05` +- Current-state context: [`Agent Trace hook routing`](../sce/agent-trace-hooks-command-routing.md) +- Evidence: [`Claude settings renderer`](../../config/pkl/renderers/claude-content.pkl) +- Related decision: [`Claude-specific latest model state`](2026-09-01-claude-model-attribution-state.md) diff --git a/context/glossary.md b/context/glossary.md index e1b44628..33ab4cce 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -78,12 +78,12 @@ - `encrypted Turso adapter`: Generic adapter seam in `cli/src/services/db/mod.rs` exposed as `EncryptedTursoDb`, structurally parallel to `TursoDb` (connection, tokio runtime bridge, spec typing). Its constructor resolves the encryption key via `encryption_key::get_or_create_encryption_key(&db_path, db_name)`, which derives a Turso-compatible 64-character hex key from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text before falling back to OS credential-store keyring get-or-create behavior; credential-store default registration is guarded by stable `OnceLock` plus an atomic in-progress flag so errors or panics leave initialization retryable without mutex poisoning. The adapter enables Turso local encryption with strict `aegis256` cipher selection through `turso::EncryptionOpts`, wraps encrypted local open/connect in the default DB connection-open retry policy, and runs embedded migrations after retry has produced a connection; the adapter also exposes retry-backed synchronous `execute`, `query`, `query_map`, and `run_migrations` helpers with `__sce_migrations` tracking parity. - `auth DB adapter`: Module in `cli/src/services/auth_db/mod.rs` that defines `AuthDbSpec` and exposes `AuthDb` as an `EncryptedTursoDb` alias. It resolves the canonical `/sce/auth.db` path with `auth_db_path()`, keeps encryption mandatory with `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret precedence before OS keyring fallback and no plaintext mode, and embeds ordered auth migrations where baseline SQL creates `auth_credentials` without `user_id`, with `updated_at`, and a trigger that auto-refreshes `updated_at` on row updates. Auth runtime token-storage is now wired through `cli/src/services/token_storage.rs`, which persists tokens via the `auth_credentials` table in the encrypted auth DB instead of a JSON file. - `AuthDbLifecycle`: Lifecycle provider in `cli/src/services/auth_db/lifecycle.rs` that implements `ServiceLifecycle` for encrypted auth DB setup/doctor integration. `diagnose` collects auth DB path health problems, `fix` bootstraps missing auth DB parent directory, and `setup` calls `AuthDb::new()`. Registered as `LifecycleProviderId::AuthDb` in the shared lifecycle catalog. -- `agent trace DB adapter`: Modules under `cli/src/services/agent_trace_db/` that define the sole repository-scoped `RepositoryAgentTraceDb = TursoDb` adapter (the checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter and its 15-file migration chain were removed by the `retire-legacy-agent-trace-db` plan). The repository adapter uses the `agent-trace-repository` migration set (fresh baseline schema plus the additive `source_instance_id` migration) with `repository_metadata`, repository-level `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, and `parts` tables, no row-level `checkout_id`, typed parameterized insert helpers, and chronological recent `diff_traces` query/parse support. `AgentTraceDbLifecycle` initializes/checks repository-scoped storage through `agent_trace_storage`. +- `agent trace DB adapter`: Modules under `cli/src/services/agent_trace_db/` that define the sole repository-scoped `RepositoryAgentTraceDb = TursoDb` adapter (the checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter and its 15-file migration chain were removed by the `retire-legacy-agent-trace-db` plan). The repository adapter uses the `agent-trace-repository` migration set (fresh baseline schema plus additive `source_instance_id` and `claude_model_state` migrations) with `repository_metadata`, repository-level `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, and `parts` tables, no row-level `checkout_id`, typed parameterized insert helpers, the non-exported `claude_model_state` latest-locally-observed register with exact `(session_id, agent_id)` reads and guarded writes, and chronological recent `diff_traces` query/parse support. `AgentTraceDbLifecycle` initializes/checks repository-scoped storage through `agent_trace_storage`. - `post-commit Agent Trace auto-sync readiness`: The doctor report fact that explains whether the enabled post-commit trigger is ready without invoking it. Doctor compares the installed `post-commit` hook's SCE managed block using the same currency semantics as setup and resolves config-file-only `agent_trace.auto_sync` with source metadata. JSON states are `ready`, `disabled`, `not_ready`, and `not_applicable`; explicit disable is healthy, while existing hook problems continue to own overall readiness and remediation. See [automatic Agent Trace synchronization](cli/agent-trace-auto-sync.md) and [doctor human text](sce/doctor-human-text-contract.md). - `structured patch service`: Pure synchronous Rust service in `cli/src/services/structured_patch.rs` that derives supported structured editor hook payloads into canonical `ParsedPatch` values. The current implemented source is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured patches; wired into `sce hooks diff-trace` for Claude payload classification at intake and into `RepositoryAgentTraceDb::recent_diff_trace_patches` for post-commit structured payload parsing, where persisted row `model_id` is assigned to every hunk and persisted canonical row `session_id` to every touched line before downstream reconstruction. - `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace(...)` under `metadata.sce`, carrying `version` (sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`) and `line_changes` (exact `{ai,mixed,unknown}` × `{added,removed}` `u64` touched-line attribution counts derived from canonical `post_commit_patch` hunks, reusing each hunk's existing `Conversation.contributor.type` classification with no independent second classification pass, `#[serde(default)]` for backward-compatible deserialization of pre-existing payloads); the whole object is schema-validated with the rest of the payload and persisted in AgentTraceDb `agent_traces.trace_json` without changing the top-level Agent Trace payload/schema `version`. - `Agent Trace range content_hash`: Per-range `content_hash` emitted by `build_agent_trace(...)` inside every `ranges[]` entry as `murmur3:`, computed from the touched-line kind/content of the `post_commit_patch` or embedded-patch hunk used to emit that range while excluding positions, paths, metadata, and database IDs. -- `event-local Claude model attribution`: Diff-trace enrichment rule where one Claude `PostToolUse` event resolves its model from direct top-level/nested metadata first, then only from that event's `transcript_path` by matching `tool_use_id` to an assistant envelope's `tool_use.id`; either source receives one `claude/` normalization step, failures remain `NULL`, and no `session_models` table or session-level cache participates. +- `Claude diff-trace attribution`: Diff-trace enrichment rule where one Claude `PostToolUse` event resolves its model with `direct > exact transcript > exact session/agent state > NULL`: direct top-level/nested metadata first, then that event's `transcript_path` matched by `tool_use_id` to an assistant envelope's `tool_use.id`, then one exact `(cc_, agent_id)` lookup in local `claude_model_state`; model sources receive one `claude/` normalization step, ephemeral agent context is never exported, and subagents do not inherit main-session state. - `DiffTraceInsert`: Insert payload in `cli/src/services/agent_trace_db/mod.rs` carrying `time_ms`, tool-prefixed `session_id`, `patch`, `model_id`, `tool_name`, nullable `tool_version`, and `payload_type` for parameterized writes to the `diff_traces` table; `payload_type` uses `PAYLOAD_TYPE_PATCH` (`"patch"`) for `OpenCode` unified-diff payloads and `PAYLOAD_TYPE_STRUCTURED` (`"structured"`) for `Claude` `PostToolUse` structured payloads. - `diff_traces payload_type discriminator`: `TEXT NOT NULL DEFAULT 'patch'` column in `diff_traces` added by migration `015_add_diff_traces_payload_type`; values are `PAYLOAD_TYPE_PATCH` (`"patch"`) for `OpenCode` unified-diff source payloads and `PAYLOAD_TYPE_STRUCTURED` (`"structured"`) for `Claude` `PostToolUse` structured source payloads; existing rows default to `"patch"` for backward compatibility. - `bash policy satisfied_by`: Optional field on a custom `policies.bash` entry listing wrapper argv prefixes that already satisfy the policy. When the matched command was unwrapped from one of these wrappers (outermost first, tracked by `NormalizedSegment.wrappers` in `cli/src/services/bash_policy.rs`), the policy does not fire, so a policy steering `rg` toward nix stays quiet for `nix shell nixpkgs#ripgrep -c rg ...` while still blocking a bare `rg`. Custom-policy-only; presets cannot declare satisfying wrappers. Exact argv-prefix matching only. See `context/sce/bash-tool-policy-enforcement-contract.md`. @@ -168,7 +168,7 @@ - `setup catalog-derived pruning`: `prune_stale_assets_for_concrete_target` in `cli/src/services/setup/mod.rs`, run after per-asset install, deletes every path the full embedded catalog for a concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, walking upward until it reaches the target root or a directory that still holds something (a user file inside an SCE-owned skill directory keeps that directory from being removed). Pruning is stateless and derives only from the compiled-in catalog; no install manifest is persisted, so an orphaned file installed by an older `sce` binary under a name the current catalog no longer knows is left alone. - `setup config-merge seam`: Pure JSON merge services covering `.claude/settings.json`, `.opencode/opencode.json`, and Codex's `.codex/hooks.json`; the latter is owned by shared `cli/src/services/codex_hook_config.rs`, which validates structure and requires both the generated helper path and the `sce hooks codex` command contract before replacing stale or duplicate registrations. Both `merge_or_create_claude_settings` and `merge_or_create_opencode_config` share the shape `(existing_bytes: Option<&[u8]>, generated_bytes: &[u8], source_path: &str) -> Result>`: each returns `generated_bytes` verbatim when no existing file is present; otherwise each parses both as JSON (an existing-file parse failure is a hard error naming `source_path`, with no write) and delegates to a pure merge function that copies the SCE-owned `$schema` key from the generated document and preserves every other top-level key from the existing document untouched. `merge_claude_settings` additionally replaces, per hook event key the generated `hooks` object declares, only the entries whose `hooks[].command` contains the SCE ownership marker `run-sce-or-show-install-guidance.sh`, appended after the surviving non-SCE entries; event keys the generated document does not declare are left untouched. `merge_opencode_config` additionally merges the `plugin` array as a set: existing entries whose path starts with the SCE ownership marker `./plugins/sce-` are dropped structurally (so a stale plugin path an older or renamed catalog installed is recognized and pruned even after the current generated document stops declaring it), then the generated document's `plugin` entries are appended. `install_single_asset_with_rename` (`mod install` in `cli/src/services/setup/mod.rs`) detects each merge target via `is_claude_settings_merge_target` / `is_opencode_config_merge_target` / `is_codex_hooks_merge_target` and stages the merged bytes instead of the embedded asset's bytes before the shared stage/swap step. The shared Codex service also exposes `diagnose_document`, which classifies each of the four required registrations as `PresentAndCurrent`/`Missing`/`Stale` (or the whole document as `Malformed` when it fails structural validation) without writing anything; a `PresentAndCurrent` result always implies a no-op merge. `cli/src/services/doctor/inspect.rs` uses the Claude/OpenCode fragment functions to inspect those merge targets and the Codex diagnosis (instead of byte-exact `sha256` or whole-document comparison) to inspect `.codex/hooks.json` per registration, further gating a structurally current registration on `codex_hook_trust::trust_readiness` (reads Codex's own `$CODEX_HOME`/`~/.codex/config.toml` hook-trust state read-only; see `context/architecture.md`), and `crate::services::setup::repair_merge_target_asset` lets `sce doctor --fix` repair all three repairable merge targets, including Codex's, by reinstalling just that one asset through the same merge-install path — never to grant trust. - `setup atomic-swap`: Per-file replacement choreography in `cli/src/services/setup/mod.rs` where staged content is renamed directly over an existing destination file, without ever unlinking that destination first — `fs::rename` replaces it atomically on both Unix and Windows, so a rename failure leaves the prior destination content and permissions untouched; on swap failure, the engine cleans that file's staging artifact and returns deterministic recovery guidance naming its path (recover from version control). No backup artifacts are created. Config install (`install_embedded_setup_assets`) applies this at individual-asset granularity and never removes an integration target directory as a whole; required-hook install applies it per hook file. Formerly called "setup remove-and-replace"; that name predates the removal of the pre-swap unlink step. -- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, `conversation-trace`, and `codex` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, event-local model attribution (Claude direct-first then event-transcript fallback), and direct `tool_version`, without session-level state; `codex` is Codex's own single dispatcher subcommand (`cli/src/services/hooks/codex/`), classifying its raw hook JSON internally by `(hook_event_name, tool_name)` rather than routing through `diff-trace`/`conversation-trace` like the other three tools — all four of its supported arms now have real behavior: `UserPromptSubmit` and `Stop` capture real conversation evidence into `messages`/`parts`, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` outer-normalizes supported raw/heredoc input before parsing, resolves paths from event `cwd` against the real Git root into safe repository-relative paths, then parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence using event-scoped synthetic line identities derived from `tool_use_id` (see `context/sce/codex-integration-runtime.md`). Invalid cwd/path mappings or identity/range failures fail open before persistence. +- `hooks command routing contract`: Current hook command parser/dispatcher plus runtime wiring in `cli/src/services/hooks/mod.rs` (`HookSubcommand`, `run_hooks_subcommand`) supports `pre-commit`, `commit-msg `, `post-commit`, `post-rewrite `, `diff-trace`, `conversation-trace`, and `codex` with deterministic invocation validation/usage errors; `session-model` is no longer supported. `commit-msg` is the only active attribution path behind the attribution hooks gate and staged-diff AI-overlap preflight; `pre-commit`/`post-rewrite` are no-ops; `post-commit` persists intersections and built Agent Trace payloads; `diff-trace` persists DB-only AgentTraceDb rows using tool-prefixed `session_id`, Claude `direct > exact transcript > exact session/agent state > NULL` attribution, and direct `tool_version`, while keeping agent context ephemeral and state local-only; `codex` is Codex's own single dispatcher subcommand (`cli/src/services/hooks/codex/`), classifying its raw hook JSON internally by `(hook_event_name, tool_name)` rather than routing through `diff-trace`/`conversation-trace` like the other three tools — all four of its supported arms now have real behavior: `UserPromptSubmit` and `Stop` capture real conversation evidence into `messages`/`parts`, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` outer-normalizes supported raw/heredoc input before parsing, resolves paths from event `cwd` against the real Git root into safe repository-relative paths, then parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence using event-scoped synthetic line identities derived from `tool_use_id` (see `context/sce/codex-integration-runtime.md`). Invalid cwd/path mappings or identity/range failures fail open before persistence. - `Claude raw hook capture (removed)`: Former hidden/internal `sce hooks claude-capture ` intake path removed in T05 of the `claude-typescript-model-cache-remove-rust-capture` plan. Rust now exposes `diff-trace` and `conversation-trace` intakes for active Claude/OpenCode editor runtimes; `session-model` is also removed from the supported hook command surface. The removed route previously wrote pretty-printed JSON artifacts under `context/tmp/claude/` without AgentTraceDb writes. See `context/sce/claude-raw-hook-capture.md`. - `deferred sync command` (historical): An earlier implementation note deferred a user-invocable sync command; it was superseded first by nested `sce trace sync` and now by the top-level `sce sync` command (see `context/cli/sync-command.md` and `context/cli/agent-trace-sync-command.md`). Local DB initialization and health ownership remain split between setup and doctor. - `sce CLI onboarding guide`: Crate-local documentation at `cli/README.md` that defines runnable placeholder commands, non-goals/safety limits, and roadmap mapping to service modules. @@ -184,7 +184,7 @@ - `agent trace historical reference docs`: Retained `context/sce/agent-trace-*.md` artifacts that describe the removed pre-v0.3 Agent Trace design and task slices; they are reference-only and do not describe the active local-hook runtime. - `agent trace commit-msg co-author policy`: Current contract in `cli/src/services/hooks/mod.rs` (`apply_commit_msg_coauthor_policy`) that applies exactly one canonical trailer (`Co-authored-by: SCE `) only when attribution hooks are enabled, SCE is not disabled, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); `NoOverlap` and `Error` both suppress the trailer, with `Error` logged via `sce.hooks.commit_msg.ai_overlap_error`; duplicate canonical trailers are deduped idempotently. - `local DB migration contract`: `cli/src/services/local_db/mod.rs` delegates migration execution to `TursoDb` through the `DbSpec::migrations()` contract. The current `LocalDbSpec` migration list is empty, so `LocalDb::new()` opens/creates the canonical local DB without creating local tables. -- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, resolves Claude `model_id` event-locally with direct metadata before fail-open transcript lookup, persists direct `tool_version`, applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, `conversation-trace` is the active message/part intake path, and `codex` (`cli/src/services/hooks/codex/`) classifies its own raw hook JSON into four supported dispatch arms — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row using event-scoped synthetic line identities — with every other event/tool combination (including `PreToolUse(apply_patch)`) and malformed STDIN failing open as a no-op. `session-model` is no longer a supported hook route. +- `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, resolves Claude `model_id` with `direct > exact transcript > exact session/agent state > NULL` precedence, persists direct `tool_version`, applies stored `session_id` prefixes (`oc_`/`cc_`/`pi_`), and inserts DB-only AgentTraceDb rows, `conversation-trace` is the active message/part intake path, and `codex` (`cli/src/services/hooks/codex/`) classifies its own raw hook JSON into four supported dispatch arms — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row using event-scoped synthetic line identities — with every other event/tool combination (including `PreToolUse(apply_patch)`) and malformed STDIN failing open as a no-op. `session-model` is no longer a supported hook route. - `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, Agent Trace DB discovery is repository-scoped only (the checkout-scoped `sce trace --legacy` surface was removed by the `retire-legacy-agent-trace-db` plan), and output exposes deterministic doctor mode, readiness, stable problem taxonomy/fixability fields, checkout/database records, and fix-result records. The runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and repository-scoped Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode, Claude, Pi, plus Codex integration content health. Human text mode uses the approved sectioned layout (`Environment`, `Configuration` with checkout identity plus repository-scoped Agent Trace DB rows when available, `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows where missing files surface as `[MISS]`, mismatches/read failures as `[FAIL]`, and affected parent groups as `[FAIL]`. Agent Trace DB rows include repository ID, identity source, safe canonical identity, configured remote name, and never raw remote URLs. Current integration groups include `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, `OpenCode skills`, `ClaudeCode plugins`, `ClaudeCode commands`, `ClaudeCode skills`, `Pi prompts`, `Pi skills`, `Pi extensions`, `Codex skills`, and `Codex hooks`; Claude `settings.json` plus `hooks/**` belong to `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, Pi `prompts/**` and `skills/**` map to the Pi groups, and Codex's `.agents/skills/**` plus `.codex/hooks.json`/`.codex/hooks/**` map to the Codex groups (the latter also carrying a Codex hook trust/review reminder when unhealthy). Fix mode reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. - `compact doctor text report`: Current human-readable `sce doctor` rendering collapses healthy state, repository, and typed integration-area checks into the `Environment`/`Repository`/`Integrations` hierarchy, suppresses healthy paths and identity metadata, and uses `--format json` as the full-detail route. The canonical current contract is [doctor human text](sce/doctor-human-text-contract.md). - `cli warnings-denied lint policy`: `cli/Cargo.toml` sets `warnings = "deny"`, so plain `cargo clippy --manifest-path cli/Cargo.toml` already fails on warnings without needing an extra `-- -D warnings` tail. @@ -240,7 +240,7 @@ - `conversation-trace mixed batch`: Rust `sce hooks conversation-trace` STDIN contract accepting `{ payloads: [{ type: "message" | "message.part", ... }] }` with top-level `type` ignored and malformed-item skipping. See `context/sce/agent-trace-hooks-command-routing.md` and `context/sce/agent-trace-db.md`. - `conversation-trace raw Claude event path`: Claude hook event classification via `hook_event_name` routing (`UserPromptSubmit`/`Stop`/`PostToolUse`) that produces normalized `message` + `message.part` items. See `context/sce/agent-trace-hooks-command-routing.md`. - `agent-trace plugin conversation-trace handoff seam`: OpenCode plugin (`config/lib/agent-trace-plugin/`) mixed-batch envelope construction for `sce hooks conversation-trace`. See `context/sce/opencode-agent-trace-plugin-runtime.md`. -- `agent-trace plugin diff-trace hook handoff seam`: Internal helper `runDiffTraceHook` in `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts` that invokes `sce hooks diff-trace`, streams `{ sessionID, diff, time, model_id, tool_name, tool_version }` to STDIN JSON (`tool_name` fixed to `opencode`, `tool_version` session-derived when available), and surfaces deterministic invocation failures. +- `sce hooks claude-model-state`: Silent Claude lifecycle hook command that accepts raw `SessionStart` and `PostModelSwitch` JSON, records normalized model observations in the local exact-scope `claude_model_state` register through the no-migration repository hook path, and returns empty stdout on success, no-op, malformed-input, clock, DB-open, or DB-write branches while logging failures. `SessionStart` is synchronous relative to Claude execution; `PostModelSwitch` is asynchronous, so local write completion—not Claude causal event order—determines state visibility. See [Agent Trace hook routing](sce/agent-trace-hooks-command-routing.md). - `agent-trace plugin secondary diff persistence ownership`: Current runtime contract where `buildTrace` no longer writes diff-trace artifacts or database rows directly; extracted diff payloads are forwarded to CLI `diff-trace` intake and the Rust hook runtime owns AgentTraceDb insertion without any `context/tmp` artifact fallback. - `messages table (Agent Trace DB)`: Agent Trace DB table created by migration `008_create_messages.sql`; stores session-scoped parent messages with columns `session_id`, `message_id`, `role` (`user`/`assistant` via CHECK constraint), `generated_at_unix_ms`, `created_at`, and `updated_at`. Message body text belongs to `parts.text`, not the parent `messages` row. Has a unique index on `(session_id, message_id)` for duplicate-ignore parent message inserts and a compound index on `(session_id, generated_at_unix_ms, id)` for chronological session message retrieval. No foreign keys to any other table. - `musl static Linux release`: The Linux binary release targets (`x86_64-unknown-linux-musl` and `aarch64-unknown-linux-musl`) compile against musl libc and link fully statically. The resulting binary has no runtime libc dependency and zero `/nix/store/` references in ELF metadata, strings, or dynamic-linker fields, satisfying the native portability audit. The musl targets replace the previous glibc-linked `*-unknown-linux-gnu` targets; macOS (`aarch64-apple-darwin`) is unchanged. Introduced in the `musl-static-linux-release` plan. diff --git a/context/overview.md b/context/overview.md index 80959703..c6a2001f 100644 --- a/context/overview.md +++ b/context/overview.md @@ -41,9 +41,9 @@ Setup repository preflight: every `sce setup` mode, including `--bootstrap-conte Sync owns the complete progress boundary in `cli/src/services/sync/progress.rs`: the consumer-typed `ProgressReporter` contract, no-op reporter, focused contract tests, and fixed `indicatif` terminal adapter. `SyncProgressEvent` remains owned by `cli/src/services/sync/sync.rs`; `sync/command.rs` selects the adapter or no-op implementation by output format, there is no top-level `cli/src/services/progress/` module, and JSON callers use the sync-owned no-op reporter. The same config resolver now also owns the attribution-hooks gate used by local hook runtime: opt-out env `SCE_ATTRIBUTION_HOOKS_DISABLED` overrides `policies.attribution_hooks.enabled` with inverted semantics, and the gate defaults to enabled unless explicitly disabled. The config service split now includes `cli/src/services/config/resolver.rs` as the focused owner for config-file discovery, file-layer merging, env/flag/default precedence, auth-key resolution, observability resolution, attribution-hooks resolution, and default-discovered invalid-file degradation; `cli/src/services/config/mod.rs` remains the facade/rendering orchestration surface while preserving existing `services::config` imports. -Generated config now includes repo-local OpenCode plugin assets: `sce-bash-policy.ts` plus `sce-agent-trace.ts` are emitted under `config/.opencode/plugins/`; the OpenCode agent-trace plugin extracts `{ sessionID, diff, time, model_id }` from user `message.updated` events with diffs, tracks per-session OpenCode client version from `session.created`/`session.updated`, and sends payloads to `sce hooks diff-trace` with `tool_name="opencode"` plus optional `tool_version`. Claude generated config now routes supported `PostToolUse Write|Edit|MultiEdit|NotebookEdit` events directly to `sce hooks diff-trace`; it no longer registers a `SessionStart` hook or calls `sce hooks session-model`. Rust handles extraction, validation, and persistence without a TypeScript intermediary; the former `config/.claude/plugins/sce-agent-trace.ts` Bun runtime was removed in T07 of the `claude-rust-diff-trace` plan. The Rust hook validates required fields, resolves Claude `model_id` event-locally with direct metadata first and matching `transcript_path`/`tool_use_id` JSONL fallback while keeping `tool_version` direct (with no `session_models` runtime), and persists tool-prefixed `session_id` values (`oc*`for OpenCode,`cc*`for Claude,`pi*`for Pi),`model_id`, `tool_name`, and nullable `tool_version`into`diff_traces`through AgentTraceDb. Bash-policy now delegates OpenCode enforcement to the Rust`sce policy bash`command: the generated OpenCode plugin at`config/.opencode/plugins/sce-bash-policy.ts`is a thin wrapper that calls`sce policy bash --input normalized --output json`via`spawnSync`and throws on deny decisions; it no longer contains independent TypeScript policy logic. The former`bash-policy/runtime.ts`TypeScript runtime has been removed. Preset... +Generated config now includes repo-local OpenCode plugin assets: `sce-bash-policy.ts` plus `sce-agent-trace.ts` are emitted under `config/.opencode/plugins/`; the OpenCode agent-trace plugin extracts `{ sessionID, diff, time, model_id }` from user `message.updated` events with diffs, tracks per-session OpenCode client version from `session.created`/`session.updated`, and sends payloads to `sce hooks diff-trace` with `tool_name="opencode"` plus optional `tool_version`. Claude generated config registers `SessionStart` and `PostModelSwitch` for local model-state intake through `sce hooks claude-model-state`, while supported `PostToolUse Write|Edit|MultiEdit|NotebookEdit` events remain routed directly to `sce hooks diff-trace`; it does not call the retired `sce hooks session-model` route. Rust handles extraction, validation, and persistence without a TypeScript intermediary; the former `config/.claude/plugins/sce-agent-trace.ts` Bun runtime was removed in T07 of the `claude-rust-diff-trace` plan. The Rust hook validates required fields, resolves Claude `model_id` with `direct > exact transcript > exact session/agent state > NULL` precedence using direct metadata, matching `transcript_path`/`tool_use_id` JSONL fallback, then one exact local state lookup while keeping `tool_version` direct (without restoring `session_models`), and persists tool-prefixed `session_id` values (`oc*`for OpenCode,`cc*`for Claude,`pi*`for Pi),`model_id`, `tool_name`, and nullable `tool_version`into`diff_traces`through AgentTraceDb. Bash-policy now delegates OpenCode enforcement to the Rust`sce policy bash`command: the generated OpenCode plugin at`config/.opencode/plugins/sce-bash-policy.ts`is a thin wrapper that calls`sce policy bash --input normalized --output json`via`spawnSync`and throws on deny decisions; it no longer contains independent TypeScript policy logic. The former`bash-policy/runtime.ts`TypeScript runtime has been removed. Preset... Claude bash-policy enforcement is also generated through`.claude/settings.json`as a`PreToolUse` `Bash`command hook running`sce policy bash`, so Claude and OpenCode both delegate to the Rust policy evaluator without a Claude TypeScript runtime. Pi bash-policy enforcement is delegated the same way through a project-local Pi extension (`config/lib/pi-plugin/sce-pi-extension.ts`, emitted to `config/.pi/extensions/sce/index.ts`) whose `tool_call`handler blocks denied bash commands via`sce policy bash`and fails open when the policy check cannot run (see`context/sce/pi-extension-runtime.md`). -Local database bootstrap is now owned by `LocalDbLifecycle::setup`and`AgentTraceDbLifecycle::setup`aggregated by the setup command. Agent Trace setup creates/reuses the current checkout ID for diagnostics and initializes the repository-scoped`/sce/repos//agent-trace.db`with the repository schema; hook runtime lazily creates or upgrades that repository DB when setup has not run or schema metadata is incomplete. Doctor validates the repository-scoped DB path/health and can bootstrap missing parent directories; outside a Git repository it reports an actionable "requires a Git repository" diagnostic instead of probing a sentinel path.`sce sync`is fully implemented: it resolves repository-scoped storage, authenticates against the control plane with stored WorkOS credentials, fetches authoritative cursors once, synchronizes the four Agent Trace capture streams concurrently while preserving sequential batches within each stream, and renders the documented concise text/JSON output (see`context/cli/sync-command.md`). The former `sce trace` command group and its database inspection surfaces are unavailable. +Local database bootstrap is now owned by `LocalDbLifecycle::setup`and`AgentTraceDbLifecycle::setup`aggregated by the setup command. Agent Trace lifecycle setup creates/reuses the current checkout ID for diagnostics and creates or migrates the repository-scoped`/sce/repos//agent-trace.db`; hook runtime uses the same repository-storage identity/path resolution without running migrations, and missing or stale schema causes hook paths to fail open with the existing`Run 'sce setup'.`guidance. Doctor validates the repository-scoped DB path/health and can bootstrap missing parent directories; outside a Git repository it reports an actionable "requires a Git repository" diagnostic instead of probing a sentinel path.`sce sync`is fully implemented: it resolves repository-scoped storage, authenticates against the control plane with stored WorkOS credentials, fetches authoritative cursors once, synchronizes the four Agent Trace capture streams concurrently while preserving sequential batches within each stream, and renders the documented concise text/JSON output (see`context/cli/sync-command.md`). The former `sce trace` command group and its database inspection surfaces are unavailable. The repository-root flake (`flake.nix`) applies a Rust overlay-backed stable toolchain pinned to `1.95.0`(with`rustfmt`and`clippy`), reads package/check version from the repo-root `.version`file, and builds`packages.sce`through a Crane`buildDepsOnly`+`buildPackage`pipeline. One deterministic pre-Cargo Nix derivation invokes the shared generated-input producer and supplies its validated`SCE_CLI_GENERATED_INPUT_DIR`store path to native, release, test, and Clippy Cargo derivations. Pkl is absent from those Cargo environments; dependency-only and format derivations do not receive the handoff, so canonical generation changes invalidate compiling outputs without invalidating dependency artifacts or formatting.`cli-tests`, `cli-clippy`, and `cli-fmt`remain Crane-backed check derivations. The root flake splits native and release outputs:`packages.sce`and`packages.default` build the **native** development binary (`scePackage`), while `packages.sce-release` builds the release binary (`sceReleasePackage`: static musl on Linux, native on Darwin). So `nix build .#sce`/`.#default`, `nix run . -- --help`, `nix run .#sce -- --help`, and `nix profile install github:crocoder-dev/shared-context-engineering`target the native binary, and`nix build .#sce-release`/`nix run .#sce-release -- ...`(plus`nix run .#release-artifacts`, which builds `.#sce-release`) target the release binary. On Linux the native and release outputs are distinct store paths, and the release output passes the native portability audit. `packages..ci-checks`is the explicit long-running validation tier:`nix build .#ci-checks`builds the`.#sce-release`package and, on Linux, audits the real release binary for forbidden`/nix/store/`references, so the expensive work stays out of`nix flake check`(which never builds`.#sce-release`). Git-commit embedding is **release-only**: `SCE_GIT_COMMIT`is injected via a`releaseCommitArgs` fragment applied only to the release derivations (`scePackageMusl`on Linux,`sceReleasePackageNative`on Darwin), not to`commonCargoArgs`. So native `.#sce`/`.#default`and every`nix flake check` derivation (`cli-tests`, `cli-clippy`, `cli-fmt`) build without the commit in their inputs and stay cache-reusable across commits (native `sce version`reports`unknown`), while `.#sce-release`still reports the real commit via`sce version`. `cli/build.rs` `emit_git_commit`emits`SCE_GIT_COMMIT`only when the env var is explicitly set — no`git rev-parse`fallback and no`.git/HEAD`/`.git/packed-refs`rerun watches. On Darwin the release now uses a distinct native-toolchain derivation (native toolchain + commit), so it diverges from`.#sce`to carry the commit while native stays commit-independent. @@ -68,10 +68,10 @@ The current supported automated release target matrix is `x86_64-unknown-linux-m Context sync uses an important-change gate: cross-cutting/policy/architecture/terminology changes require root shared-file edits, while localized tasks run verify-only root checks without default churn. OpenCode and Claude no longer generate legacy bootstrap or context-sync skills; `/commit` and `/handover` are generated only as catalog-registered composite workflow packages. OpenCode retains only thin routing agents, while Claude emits no agents. The superseded grouped Markdown catalog and automated OpenCode profile have been removed from Pkl ownership and generated outputs. The prior no-git-wrapper Agent Trace design artifacts under `context/sce/agent-trace-*.md` are retained only as historical reference; the current CLI runtime no longer wires the removed Agent Trace schema adaptation, payload building, retry replay, or rewrite handling paths into local hook execution. - The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); when the preflight returns `NoOverlap` or `Error` (including DB open failure, schema not ready, query error, staged diff read failure, or zero overlap), the trailer is not appended and errors are logged via `sce.hooks.commit_msg.ai_overlap_error`; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days (dispatching `patch` rows through existing unified-diff parsing and `structured` rows through `structured_patch::derive_claude_structured_patch` at read time, then assigning each structured hunk the persisted row model and every structured touched line the persisted canonical `cc_...` row session), combines/intersects patches, persists intersection metadata to `post_commit_patch_intersections`, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows, top-level `metadata.sce.version` from the compiled `sce` CLI package version, always-emitted `metadata.sce.line_changes` touched-line attribution counts, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent or `null` → `None`, present+non-empty → `Some`, present+empty → error), required nullable/non-empty `tool_version`, plus required `u64` millisecond `time`, uses direct-first/event-transcript-second Claude `model_id` resolution plus direct `tool_version` without any `session_models` runtime, and continues with `None` when event-local lookup cannot resolve attribution, with same-tool-idempotent stored `session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and no parsed-payload artifact fallback. Claude structured `PostToolUse` diff-trace intake prefers direct top-level or nested model metadata, then matches the event's `tool_use_id` in its JSONL `transcript_path`, normalizes either source once with the `claude/` prefix, and fails open to `None`. + The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); when the preflight returns `NoOverlap` or `Error` (including DB open failure, schema not ready, query error, staged diff read failure, or zero overlap), the trailer is not appended and errors are logged via `sce.hooks.commit_msg.ai_overlap_error`; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days (dispatching `patch` rows through existing unified-diff parsing and `structured` rows through `structured_patch::derive_claude_structured_patch` at read time, then assigning each structured hunk the persisted row model and every structured touched line the persisted canonical `cc_...` row session), combines/intersects patches, persists intersection metadata to `post_commit_patch_intersections`, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows, top-level `metadata.sce.version` from the compiled `sce` CLI package version, always-emitted `metadata.sce.line_changes` touched-line attribution counts, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent or `null` → `None`, present+non-empty → `Some`, present+empty → error), required nullable/non-empty `tool_version`, plus required `u64` millisecond `time`, uses `direct > exact transcript > exact session/agent state > NULL` Claude `model_id` resolution plus direct `tool_version`, with ephemeral agent scope and no generic `session_models` runtime, and continues with `None` when all sources cannot resolve attribution, with same-tool-idempotent stored `session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and no parsed-payload artifact fallback. Claude structured `PostToolUse` diff-trace intake prefers direct top-level or nested model metadata, then matches the event's `tool_use_id` in its JSONL `transcript_path`, consults exact local lifecycle state only when both event-local sources fail, normalizes model values once with the `claude/` prefix, and fails open to `None` when no source resolves. The CLI now also includes an approved operator-environment doctor contract documented in `context/sce/agent-trace-hook-doctor.md`; the runtime includes `sce doctor --fix` parsing/help, stable problem/fix-result reporting, canonical hook-repair reuse, bounded doctor-owned local-DB directory bootstrap for the missing SCE-owned DB parent path, and target-scoped integration inventory: Claude reports `Plugins`, `Commands`, and `Skills`; OpenCode reports `Plugins`, `Agents`, `Commands`, and `Skills`; Pi reports `Extensions`, `Prompts`, and `Skills`; and Codex reports `Skills` and `Hooks` (see `context/sce/doctor-human-text-contract.md`). Its non-launching post-commit Agent Trace auto-sync fact reports enabled/current, explicit disabled, not-ready, and not-applicable states using canonical managed-block currency and resolved configuration; existing hook remediation and readiness semantics remain unchanged. The local DB service now provides `LocalDb` as a thin `TursoDb` alias in `cli/src/services/local_db/mod.rs`; `LocalDbSpec` resolves the canonical local DB path from the shared default-path catalog and currently declares zero migrations. Shared Turso infrastructure lives in `cli/src/services/db/mod.rs`, where `DbSpec` and generic `TursoDb` support local or remote sync-mode opens, parent-directory creation, connection setup, synchronous query helpers, embedded migration execution, and shared DB lifecycle helpers. Auth DB persistence uses encrypted `AuthDb = EncryptedTursoDb` and token storage persists credentials through the `auth_credentials` table. Agent Trace persistence uses the sole `RepositoryAgentTraceDb = TursoDb` adapter at `/sce/repos//agent-trace.db`, with a one-file repository schema for `repository_metadata`, `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes/triggers, and no `checkout_id` columns on trace rows. The checkout-scoped `AgentTraceDb = TursoDb` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, and the 15-file `cli/migrations/agent-trace/` chain were removed by the `retire-legacy-agent-trace-db` plan; active hook runtime writes nullable event-local diff-trace attribution without a `session_models` API/table dependency. - The hooks command surface now also supports concrete runtime subcommand routing (`pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `conversation-trace`) with deterministic argument/STDIN validation; `session-model` is no longer supported. Current runtime behavior keeps commit-msg attribution enabled by default unless explicitly opted out: the attribution gate enables canonical trailer insertion in `commit-msg` only when the staged-diff AI-overlap preflight confirms AI/editor evidence (no trailer is appended when the preflight finds no overlap or encounters any error); `pre-commit`/`post-rewrite` remain deterministic no-ops, `post-commit` requires validated `--remote-url`, threads that URL into the Agent Trace flow, prints it to stderr, remains the active bounded recent-diff-trace intersection path, and after successful Agent Trace persistence optionally launches the detached sync-owned `sync --format json` child when config-file-only `agent_trace.auto_sync` is true; `diff-trace` is the active intake path for parsed STDIN `{ sessionID, diff, time, model_id?, tool_name, tool_version }` payload persistence with optional `model_id`, required non-empty `tool_name`, required nullable/non-empty `tool_version`, direct-first/event-transcript-second Claude `model_id` plus direct `tool_version` values (no session-model fallback or cache), required `u64` millisecond `time`, same-tool-idempotent stored `session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and no parsed-payload artifact fallback. This behavior is documented in `context/sce/agent-trace-hooks-command-routing.md`. The removed `sce hooks claude-capture` raw capture route is documented in `context/sce/claude-raw-hook-capture.md` as a removed feature. + The hooks command surface now also supports concrete runtime subcommand routing (`pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `claude-model-state`) with deterministic argument/STDIN validation; `session-model` is no longer supported. Claude settings install the model-state command for both `SessionStart` and `PostModelSwitch`; compatibility smoke against Claude Code 2.1.251 and immediately older 2.1.250 showed that the older client safely ignores the unknown event, so installation is unconditional without a raised minimum or capability gate. Current runtime behavior keeps commit-msg attribution enabled by default unless explicitly opted out: the attribution gate enables canonical trailer insertion in `commit-msg` only when the staged-diff AI-overlap preflight confirms AI/editor evidence (no trailer is appended when the preflight finds no overlap or encounters any error); `pre-commit`/`post-rewrite` remain deterministic no-ops, `post-commit` requires validated `--remote-url`, threads that URL into the Agent Trace flow, prints it to stderr, remains the active bounded recent-diff-trace intersection path, and after successful Agent Trace persistence optionally launches the detached sync-owned `sync --format json` child when config-file-only `agent_trace.auto_sync` is true; `diff-trace` is the active intake path for parsed STDIN `{ sessionID, diff, time, model_id?, tool_name, tool_version }` payload persistence with optional `model_id`, required non-empty `tool_name`, required nullable/non-empty `tool_version`, `direct > exact transcript > exact session/agent state > NULL` Claude `model_id` plus direct `tool_version` values (exact local state only; no generic session-model fallback or cache), required `u64` millisecond `time`, same-tool-idempotent stored `session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and no parsed-payload artifact fallback. This behavior is documented in `context/sce/agent-trace-hooks-command-routing.md`. The removed `sce hooks claude-capture` raw capture route is documented in `context/sce/claude-raw-hook-capture.md` as a removed feature. The setup service now also exposes deterministic required-hook embedded asset accessors (`iter_required_hook_assets`, `get_required_hook_asset`) backed by canonical templates in `cli/assets/hooks/` for `pre-commit`, `commit-msg`, and `post-commit`; this behavior is documented in `context/sce/setup-githooks-hook-asset-packaging.md`. The setup service now also includes required-hook install orchestration (`install_required_git_hooks`) that resolves repository root and effective hooks path from git truth, computes the bytes to stage by merging the canonical hook template with any existing hook (preserving a foreign hook's content as an exact prefix with the SCE managed block appended, or bringing an SCE-owned block current in place) rather than writing canonical bytes verbatim, enforces deterministic per-hook outcomes (`Installed`/`Updated`/`Skipped`) against that merged content, surfaces a deterministic advisory when an appended block would be unreachable, and uses a unified atomic-swap policy that renames staged content directly over existing hooks without unlinking them first, with deterministic recovery guidance on swap failures; this behavior is documented in `context/sce/setup-githooks-install-flow.md`. The setup command parser/dispatch now also supports composable setup+hooks runs (`sce setup --opencode|--claude|--pi|--codex|--all --hooks`) plus hooks-only mode (`sce setup --hooks` with optional `--repo `), enforces deterministic compatibility validation (`--repo` requires `--hooks`; target flags remain mutually exclusive), and emits deterministic setup/hook outcome messaging (`installed`/`updated`/`skipped`); this behavior is documented in `context/sce/setup-githooks-cli-ux.md`. @@ -137,7 +137,7 @@ Lightweight post-task verification baseline (required after each completed task) - Use `context/sce/agent-trace-core-schema-migrations.md` for the current neutral local DB empty-file baseline and removed schema-bootstrap behavior. - Use `context/sce/agent-trace-retry-queue-observability.md` for the current inactive retry-replay status and retained historical notes about the removed local-hook retry path. - Use `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` for the frozen T01 Local Hooks MVP production contract and deterministic gap matrix that maps current seam-level code truth to the remaining implementation stack (`T02`..`T10`). -- Use `context/sce/agent-trace-hooks-command-routing.md` for the implemented T02 `sce hooks` command routing contract (subcommand parsing, deterministic invocation errors, and initial runtime entrypoint behavior). +- Use `context/sce/agent-trace-hooks-command-routing.md` for the implemented `sce hooks` command routing contract (subcommand parsing, deterministic invocation errors, active trace intake behavior, and the silent Claude model-state lifecycle intake). - Use `context/sce/claude-raw-hook-capture.md` (removed feature) for the former hidden/internal Claude raw hook JSON capture intake. The `sce hooks claude-capture` CLI route, `ClaudeCaptureEvent`, `claude_transcript.rs`, and `RepoPaths::claude_capture_tmp_dir()` were removed in T05. Rust now exposes `diff-trace` and `conversation-trace` intakes for active Agent Trace hook data; `session-model` is also removed from the supported hook command surface. - Use `context/sce/setup-githooks-hook-asset-packaging.md` for the implemented `sce-setup-githooks-any-repo` T02 compile-time hook-template packaging contract and setup-service required-hook embedded accessor surface. - Use `context/sce/setup-githooks-install-flow.md` for the implemented `sce-setup-githooks-any-repo` T03 required-hook install orchestration contract (git-truth hooks-path resolution, per-hook installed/updated/skipped outcomes, and atomic-swap replacement behavior). diff --git a/context/patterns.md b/context/patterns.md index 9ad112fd..998e6190 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -163,7 +163,8 @@ - For `diff-trace` hook intake, keep producer-facing failure behavior fail-open: STDIN read and parse/validation failures use `sce.hooks.diff_trace.error`; AgentTraceDb open failures use the error-level `sce.hooks.diff_trace.agent_trace_db_open_failed`; later conversion/insert failures retain `sce.hooks.diff_trace.agent_trace_db_write_failed`. Preserve existing output text and emit only the most specific persistence diagnostic for one failure. - For `conversation-trace` hook intake, keep producer-facing failure behavior fail-open: STDIN read, top-level parse/validation, and unsupported raw Claude hook events use `sce.hooks.conversation_trace.error`; AgentTraceDb open failures use the error-level `sce.hooks.conversation_trace.agent_trace_db_open_failed`; later batch inserts retain their existing warning event. Preserve valid-payload mixed-batch accounting, skipped-item logging, output text, and one diagnostic per DB-open failure. - For generated Codex hook invocation, resolve the Git repository root at runtime and invoke the installed helper with quoted expansions; exit successfully and silently when Git-root resolution fails, and preserve the helper's existing missing-CLI stderr guidance and STDIN forwarding. For `codex` hook intake, route every Codex hook event through the single `sce hooks codex` subcommand and classify it internally (`(hook_event_name, tool_name)` → `UserPromptSubmit`/`Stop`/`PreToolUse(Bash)`/`PostToolUse(apply_patch)`/`NoOp`) rather than adding per-event subcommands like Claude's native hook script does; keep producer-facing failure behavior fail-open, using `sce.hooks.codex.error` for STDIN read and JSON parse failures, and route every unrecognized `hook_event_name`/`tool_name` combination (including `PreToolUse(apply_patch)`) to the same deterministic silent `NoOp` success rather than an error. -- For diff-trace attribution persistence, keep Claude model resolution event-local: prefer direct payload metadata, then use only that event's `transcript_path` and `tool_use_id` for fail-open JSONL lookup, normalize either source through the `claude/` convention, and store unresolved attribution as `NULL` in `diff_traces`. Persist `tool_version` directly. Do not restore the former `session_models` fallback or any session-level cache. +- For raw structured Claude diff-trace attribution persistence, keep model resolution ordered `direct > exact transcript > exact session/agent state > NULL`: prefer direct payload metadata, then use only that event's `transcript_path` and `tool_use_id` for fail-open JSONL lookup, and only after both fail perform one exact lookup in the local `claude_model_state` register using canonical `cc_` plus the event's exact agent scope. Normalized payloads, even with `tool_name="claude"`, are not eligible for the state fallback. Normalize model values through the `claude/` convention and store unresolved attribution as `NULL` in `diff_traces`; persist `tool_version` directly. Do not restore the former generic `session_models` abstraction, broaden subagent scope to the main session, poll/wait for lifecycle state, or reparse stored raw Claude JSON; the parser remains storage-free and unsupported events remain DB-free. +- For `sce hooks claude-model-state`, parse raw Claude `SessionStart` and `PostModelSwitch` events without database access, normalize `cc_`/`claude/`, map absent or null `agent_id` to the exact main-session scope `""`, and trim present agent IDs while rejecting empty or non-string values before any DB access. Write directly through the no-migration repository hook path before the process exits. A model-less SessionStart is a silent no-op; PostModelSwitch validates both model fields but persists `to_model`. Current Claude sources include `command`, `picker`, `sdk`, `auto`, and `resume`, but SCE accepts any non-empty source string and stores it opaquely. Keep the command local-only, logger-diagnostic-only, fail-open, empty-stdout, and free of migration, sync, polling, or detached/background work; SessionStart is synchronous relative to Claude execution while PostModelSwitch is asynchronous and may overlap. - For recent structured diff-trace reconstruction, treat persisted row attribution as canonical: assign the row `model_id` to every reconstructed hunk and the tool-prefixed row `session_id` to every reconstructed touched line before combination/intersection. Never reuse the raw unprefixed Claude payload session as touched-line provenance. - For commit-msg co-author policy seams, gate canonical trailer insertion on runtime controls (`SCE_DISABLED` plus the shared attribution-hooks enablement gate) plus the staged-diff AI-overlap evidence gate (`StagedDiffAiOverlapResult::Overlap` maps to `ai_contribution_present = true`; `NoOverlap` and `Error` both map to `false`), and enforce idempotent dedupe so allowed cases end with exactly one `Co-authored-by: SCE ` trailer. - For local hook attribution flows, resolve the top-level enablement gate through the shared config precedence model (`SCE_ATTRIBUTION_HOOKS_DISABLED` opt-out env over `policies.attribution_hooks.enabled`, default `true`) so commit-msg attribution is enabled by default while explicit config `enabled = false` and truthy env opt-out still suppress it without adding hook-specific config parsing. diff --git a/context/plans/claude-model-attribution-state.md b/context/plans/claude-model-attribution-state.md new file mode 100644 index 00000000..66844b7a --- /dev/null +++ b/context/plans/claude-model-attribution-state.md @@ -0,0 +1,305 @@ +# Plan: claude-model-attribution-state + +## Change summary + +Claude diff-trace attribution currently resolves `diff_traces.model_id` from direct +`PostToolUse` metadata, then from an exact `tool_use_id` lookup in the Claude JSONL +transcript, then `NULL`. Production data shows most Claude `diff_traces` rows are +`NULL` because Claude `PostToolUse` does not normally expose model identity and the +transcript is written asynchronously, so it is frequently unavailable at hook time. +The `remove-session-models-direct-claude-model-id` plan deliberately removed +session-level model state, and the `fix-claude-model-attribution` plan's +transcript fallback has not closed the gap. + +This plan adds a small, Claude-specific latest-model-state register in the existing +repository-scoped Agent Trace DB (`/sce/repos//agent-trace.db`), +seeded from two Claude lifecycle signals — `SessionStart.model` and +`PostModelSwitch.from_model`/`to_model` — through a new silent hook command +`sce hooks claude-model-state`. Diff-trace persistence consults that state only when +direct and transcript attribution both fail, keeping the precedence +`direct > exact transcript > exact session/agent state > NULL`. The register is a +best-effort register in which the latest locally observed lifecycle information +wins: `observed_at_ms` is the local SCE time at which the hook observed the event, +not Claude's authoritative causal event order. +`diff_traces.model_id` stays the durable attribution result and the only model value +exported to the control plane. Claude invokes `SessionStart` synchronously relative +to Claude's execution and `PostModelSwitch` asynchronously relative to Claude's +execution. In both SCE handlers, the local database write is performed directly +before the hook process exits; SCE does not spawn, detach, background, or defer the +`claude_model_state` write. + +This is a deliberate, narrower reintroduction of local model state. It does **not** +restore the generic cross-editor `session_models` table or the `sce hooks session-model` +command; the new state is Claude-specific because both the missing-attribution +problem and the lifecycle API are Claude-specific. It adds no sync stream, no +control-plane endpoint, no ClickHouse schema change, and no historical backfill. An +accompanying decision record supersedes only the earlier "no session-level cache" +constraint for Claude model attribution, leaving the historical plans unchanged. + +### Claude attribution flow and ephemeral context + +For Claude structured diff-trace events, the internal parsed representation carries +`agent_id: Option` (or an equivalent typed ephemeral attribution context). +The value is extracted from the raw hook event when present and is used only for the +local state lookup; it is not part of the `diff_traces` schema, export payload, +Control Plane data, or persisted raw-event reparsing path. Main-session events use +`None` internally and map it to `agent_id = ""` only for state lookup/storage. + +```text +Claude PostToolUse + | + v +parse Claude event + | + +-- model_id: direct -> transcript -> None + | + +-- agent_id: optional ephemeral context + | + v +open RepositoryAgentTraceDb once + | + +-- model already resolved? use it + | + +-- otherwise lookup: + (canonical cc_session_id, exact agent_id scope) + | + v +DiffTraceInsert +``` + +## Acceptance criteria + +How this plan is proven complete. Each criterion is observable and names the +check that proves it. `/validate` runs these checks; no task in the stack +performs final validation. + +- [x] AC1: A decision record `context/decisions/2026-09-01-claude-model-attribution-state.md` exists, records the production evidence, the transcript-timing limitation, the new `PostModelSwitch` capability, the rejected generic `session_models` restoration, that `observed_at_ms` is local SCE observation time rather than Claude's causal event order, the best-effort latest-locally-observed register semantics, the distinct rapid-consecutive-switch and post-switch visibility races, the absence of an upstream sequence/timestamp or synchronization barrier in this hook contract, the local-only/no-export scope, and the specific one-turn fallback-chain model substitution limitation, and explicitly supersedes the earlier "no session-level cache" constraint for Claude model attribution without editing the historical plans. + - Validate: inspect the file for each listed element and a `Supersedes` reference to the prior constraint; verify the historical plans are unchanged relative to the PR base: + ```sh + BASE="$(git merge-base HEAD origin/main)" + + git diff --exit-code "$BASE"..HEAD -- \ + context/plans/remove-session-models-direct-claude-model-id.md \ + context/plans/fix-claude-model-attribution.md + ``` +- [x] AC2: Additive migration `003_claude_model_state.sql` creates the `claude_model_state` table with primary key `(session_id, agent_id)`, `observation_kind` constrained to `session_start | post_model_switch`, and `observed_at_ms >= 0`; `001_repository_schema.sql` and `002_repository_source_instance_id.sql` are byte-unchanged; `001 -> 002 -> 003` applies cleanly on a fresh DB and on a pre-`003` DB. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_state`; `git diff --stat` shows no change to `001`/`002`. +- [x] AC3: The repository adapter exposes typed guarded read/write helpers for `claude_model_state`, and no export/sync module (`agent_trace_export`, `sync`) references the table or a model-state cursor. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_state`; `rg -n "claude_model_state" cli/src/services/agent_trace_export cli/src/services/sync` returns nothing. +- [x] AC4: `SessionStart` with a model persists normalized `(cc_, "", claude/, session_start, , )`; `SessionStart` without a model (or empty/null) is a silent no-op that never deletes, nulls, or replaces existing state; canonical `cc_` identity is idempotent; the main conversation uses `agent_id = ""`. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_state`. +- [x] AC5: `PostModelSwitch` persists `to_model` (normalized) as current state with `observation_kind = post_model_switch`; `from_model`/`to_model` are validated/normalized, with `from_model` used only as validation/diagnostic information and never as a compare-and-swap precondition; sources `command`, `picker`, `sdk`, `auto`, `resume` are all accepted; `observed_at_ms` is local SCE observation time, not Claude's causal event timestamp; a strictly older local observation never overwrites a newer local observation, making this a best-effort latest-locally-observed register rather than a correctness guarantee about switch order; replayed identical observations are idempotent; equal-timestamp conflicts resolve deterministically (PostModelSwitch beats SessionStart, with a stable non-arrival-order tie-break for same-kind observations); the ordering/clock helper is injectable in tests. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_state`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model`. +- [x] AC6: `sce hooks claude-model-state` writes zero bytes to stdout on every branch (successful write, no-op, malformed input, DB-open failure, DB-write failure), fails open, never returns exit code 2 or denies Claude activity, performs only local DB work with no network access or auto-sync, and routes all diagnostics through the existing logger. Claude invokes `SessionStart` synchronously relative to Claude's execution and `PostModelSwitch` asynchronously relative to Claude's execution; in both SCE handlers, the local database write is performed directly before the hook process exits. `SessionStart` remains minimal and fast so initial state is persisted before subsequent tool activity where practical; `PostModelSwitch` handling respects Claude's lifecycle semantics, including possible overlapping hook executions, without requiring SCE to serialize them or prove their causal order. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_state` (stdout-byte assertions per branch); inspect the handler for any `println!`/stdout writer and any exit-code-2 path. +- [x] AC7: `parse_claude_diff_trace_payload` performs no database access and keeps its `direct -> transcript -> None` result; for Claude structured events, the internal parsed diff-trace representation carries `agent_id: Option` (or an equivalent ephemeral typed context) extracted from the raw event when present, without adding it to the external payload or persisted schema; unsupported/no-op Claude events reach no DB access; a valid Claude diff trace whose parser model is unresolved performs exactly one `claude_model_state` lookup after the repository DB is already open, and the resolved value flows into `DiffTraceInsert.model_id`; direct and transcript attribution still win over state. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_attribution`; inspect `parse_claude_diff_trace_payload` for storage calls; confirm the diff-trace path opens the repository DB once. +- [x] AC8: State lookup for a diff trace uses the canonical `(cc_, exact agent_id scope)` from the event; `agent_id` is used only for resolving `claude_model_state`, with main-session `None` mapped to `""` for lookup/storage. A subagent (`agent_id != ""`) with no state for that exact pair resolves to `NULL` and never falls back to `(cc_, "")`. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_attribution`. +- [x] AC9: Canonical Pkl generates `SessionStart` and `PostModelSwitch` registrations for `sce hooks claude-model-state` in `.claude/settings.json` while preserving the existing `PreToolUse Bash`, `PostToolUse` diff-trace/conversation-trace, `UserPromptSubmit`, and `Stop` registrations; `sce setup` merge adds both SCE lifecycle hooks, preserves user-owned hooks on those same events and unrelated settings, replaces stale SCE-owned model-state commands, and does not duplicate identical SCE registrations; `sce doctor` reports an installed Claude config missing either SCE lifecycle hook as drift and `sce doctor --fix` repairs it through the existing config-merge path. + - Validate: `nix run .#pkl-check-generated`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml config_merge`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor`. +- [x] AC10: Migration `003` is applied only through `sce setup` / lifecycle setup; no hook runtime path (including `sce hooks claude-model-state`) runs any migration; a pre-`003` repository DB surfaces the existing schema-not-ready `Run 'sce setup'.` guidance from hook paths rather than migrating; `sce doctor` diagnoses the incomplete schema; an upgrade note tells Claude-attribution repositories to rerun `sce setup`. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_state`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor`; inspect the hook handler and `resolve_agent_trace_storage_for_hook_runtime` usage for no migration call; confirm the upgrade note lands in `context/`. +- [x] AC11: End-to-end, with direct and transcript attribution unavailable: `SessionStart(A)` then a model-less Claude `PostToolUse` persists `diff_traces.model_id = claude/A`; after the `PostModelSwitch(A->B)` local DB write has completed and state `B` is persisted, a model-less `PostToolUse` persists `claude/B`; a direct model `C` persists `claude/C` regardless of state `B`; a transcript model `C` persists `claude/C` regardless of state `B`; no direct, no transcript, no state persists `NULL`; a subagent `PostToolUse` with no subagent state does not receive the parent's `B`; OpenCode, Pi, and Codex diff-trace attribution behavior is unchanged. The test/design notes must not claim to prove Claude's real asynchronous scheduling order or that the first post-switch `PostToolUse` sees `B` before the state write completes. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_attribution`; existing OpenCode/Pi/Codex hook and Agent Trace regression tests pass under `nix flake check`. +- [x] AC12: `AgentTraceExportReader` still exposes exactly the four existing streams (messages, parts, diff_traces, agent_traces); sync state/API types gain no model-state cursor; `diff_traces.model_id` export carries the resolved value with no control-plane protocol change. + - Validate: `nix flake check`; `rg -n "claude_model_state|model.?state.?cursor" cli/src/services/agent_trace_export cli/src/services/sync` returns nothing. + +### Full validation + +Repository-wide checks `/validate` runs after the last task, regardless of +which criterion they map to. + +- `nix run .#pkl-check-generated` +- `nix flake check` + +Claude Code compatibility smoke (release-level, in addition to repository tests): +a Claude Code build that supports `PostModelSwitch` captures state on a real model +switch, and one immediately older supported Claude Code build does not lose the +existing SCE hooks or the whole `.claude/settings.json` when it encounters the +unknown `PostModelSwitch` registration. + +### Context sync + +- `context/sce/agent-trace-db.md` — add `claude_model_state` table, migration `003`, and the typed guarded read/write helpers; state it is not exported. +- `context/sce/agent-trace-hooks-command-routing.md` — add `sce hooks claude-model-state`, Claude's synchronous `SessionStart` and asynchronous `PostModelSwitch` invocation semantics, the direct local database write before either SCE hook process exits, the zero-stdout / fail-open / no-exit-2 / local-only contract, the post-switch visibility race and no-convergence-wait behavior, and the diff-trace `direct > transcript > state > NULL` precedence with ephemeral agent context and subagent isolation. +- `context/sce/claude-raw-hook-capture.md` — note that `SessionStart` is registered again (for model state only, not raw capture) and `PostModelSwitch` is newly registered. +- `context/sce/agent-trace-hook-doctor.md` — add the missing SCE lifecycle-hook drift check and its `--fix` path. +- `context/sce/agent-trace-export-readers.md` — state `claude_model_state` is outside the export boundary. +- `context/architecture.md`, `context/overview.md`, `context/patterns.md`, `context/glossary.md`, `context/context-map.md` — update the Claude model-attribution summary from direct/transcript-only to include the local state fallback; add glossary entries for `claude_model_state` and `sce hooks claude-model-state`; add the migration-`003` upgrade note (rerun `sce setup`). +- Add `context/decisions/2026-09-01-claude-model-attribution-state.md`. + +## Task context synchronization lifecycle + +Persist this field in every plan; this is durable plan state, not chat state: + +- **Task context synchronization:** every task carries `pending | synced | blocked`. + A completed task must be `synced` before another task can start or the plan can + finish. +- For `blocked`, record **Blocker**, **Required action**, and **Retry condition** + beside the status. Never infer `synced` from conversation history; write every + lifecycle transition to the plan file. + +## Constraints and non-goals + +- **In scope:** `cli/migrations/agent-trace-repository/003_claude_model_state.sql`; `cli/src/services/agent_trace_db/` (state structs, guarded update/query helpers, repository adapter delegation); `cli/src/services/hooks/` (new `claude-model-state` subcommand, CLI schema, `HookSubcommand`, parse/runtime conversion, injectable clock seam, diff-trace persistence-boundary lookup); `cli/src/cli_schema.rs` / `cli/src/services/parse/command_runtime.rs`; `config/pkl/renderers/claude-content.pkl` and regenerated Claude settings; `cli/src/services/setup/config_merge.rs` and doctor inspect/lifecycle only where the new events need coverage; focused Rust tests; the listed current-state context files and the new decision record. +- **Out of scope:** control-plane / ClickHouse schema, sync worker or cursor changes, `AgentTraceExportReader` streams, historical `model_id` backfill, OpenCode / Pi / Codex attribution behavior, the retired generic `session_models` table and `sce hooks session-model` command, any TypeScript Claude translation runtime. +- **Constraints:** hooks never run migrations (`agent_trace_hook_no_hot_path_migrations`); Claude parsing must not depend on database access; `sce hooks claude-model-state` must write zero stdout bytes, fail open, never exit 2 or deny Claude activity, perform only local DB work, and perform no network access or auto-sync; Claude invokes `SessionStart` synchronously relative to Claude's execution and `PostModelSwitch` asynchronously relative to Claude's execution, and both SCE handlers perform the local database write directly before the hook process exits; SCE must not spawn, detach, background, or defer the `claude_model_state` write; state is a per-`(session_id, agent_id)` latest-value register guarded by `observed_at_ms`, not an event log, where `observed_at_ms` is local SCE observation time and the register is only a best-effort latest-locally-observed result, never a proof of Claude causal event order; strictly older local observations are rejected and equal-time outcomes use the PostModelSwitch-over-SessionStart rule plus a stable non-arrival-order tie-break for same-kind observations; `model_id` normalizes through the existing `claude/` convention; no fabricated `unknown` model; transcript lookup stays ahead of state and is not removed; a model-less `SessionStart` never clears state; Claude structured diff traces carry `agent_id: Option` only as ephemeral context for exact state lookup, mapping main-session `None` to stored `""`; do not add `agent_id` to `diff_traces`, exports, or Control Plane, and do not reparse stored raw Claude JSON during persistence; OpenCode / Pi / Codex payload behavior remains unchanged; new deps pinned exactly, newest Node runtime for any new JS/TS work (`feedback_deps.md`) — though this plan expects no JS/TS changes. +- **Non-goal:** claiming the state fallback cannot detect a one-turn fallback-chain model substitution when Claude exposes that substitution neither through lifecycle state nor through the exact transcript lookup; generalizing the register into a cross-editor session-attribution architecture; using parent-session state as subagent state. + +## Assumptions + +- Claude Code emits `SessionStart` with an optional `model` field and `PostModelSwitch` with `from_model`, `to_model`, and `source` fields, and includes `agent_id` when a hook fires inside a subagent. `PostModelSwitch` is available from Claude Code 2.1.251. These are taken from the change request; the exact minimum-version policy is settled by the compatibility smoke in T05. Claude invokes `SessionStart` synchronously relative to Claude's execution and `PostModelSwitch` asynchronously relative to Claude's execution, so hook executions can overlap; in both SCE handlers, the local database write is performed directly before the hook process exits. Claude does not currently expose an authoritative sequence number or event timestamp through this hook contract. +- The decision record is authored as T01 because it is the explicit premise of this change (superseding a recorded constraint); if the `/next-task` synchronization decision gate is the preferred mechanism, it reuses the active record rather than writing a second one. +- `agent_id = ""` is the canonical scope for the main Claude conversation; the empty string (not `NULL`) is stored so the primary key stays total. +- The existing `config_merge` per-event SCE-owned filtering covers `SessionStart`/`PostModelSwitch` and the other generated Claude events using either the current `run-sce-or-show-install-guidance.sh` command marker or the exact historical `bun` plus `.claude/plugins/sce-agent-trace.ts` shape; T05 extends ownership recognition and coverage without adding a separate migration path. +- `observed_at_ms` is captured in Rust immediately after hook STDIN is read, via an injectable clock seam, and means local SCE observation time. Tests control the guarded local-observation comparison; it cannot establish Claude's causal switch order. + +## Task stack + +- [x] T01: `Record the Claude attribution-state decision` (status:done) + - Task ID: T01 + - Scope: In — write `context/decisions/2026-09-01-claude-model-attribution-state.md` capturing production evidence, transcript-timing limitation, the `PostModelSwitch` capability, rejected generic `session_models` restoration, local SCE observation-time semantics (not Claude causal ordering), best-effort latest-locally-observed behavior, the distinct rapid-consecutive-switch and post-switch visibility races, the absence of a synchronization barrier or upstream ordering metadata, local-only/no-export scope, and the specific one-turn fallback-chain model substitution limitation; state that it supersedes only the earlier "no session-level cache" constraint for Claude model attribution. Out — any runtime code, migration, or edits to the historical plan files. + - Dependencies: none + - Done when: the decision file exists in the ADR format, distinguishes Claude-specific current state from the retired generic `session_models` abstraction, names its `Supersedes` target, and the two historical plans are untouched. + - Verify: inspect the file against AC1; `git status --short` allows only `context/decisions/2026-09-01-claude-model-attribution-state.md` and `context/plans/claude-model-attribution-state.md` (the active plan may record the task/context-sync transition), and confirms `context/plans/remove-session-models-direct-claude-model-id.md` and `context/plans/fix-claude-model-attribution.md` are untouched. + - Completed: 2026-09-01 + - Files changed: `context/decisions/2026-09-01-claude-model-attribution-state.md` + - Result: Added the accepted Claude-specific attribution-state decision, preserving direct and transcript precedence while defining local observation-time, best-effort register semantics and its timing limitations. + - Verify: ADR inspection passed against AC1; the historical plans were verified unchanged relative to the PR base with: + ```sh + BASE="$(git merge-base HEAD origin/main)" + + git diff --exit-code "$BASE"..HEAD -- \ + context/plans/remove-session-models-direct-claude-model-id.md \ + context/plans/fix-claude-model-attribution.md + ``` + Baseline-relative comparison found only the new ADR changed before this plan record. + - Done checks: Decision file exists in repository ADR format; distinguishes Claude-specific state from retired generic `session_models`; names the superseded no-session-level-cache constraint; records all required production evidence, lifecycle, race, ordering, scope, and fallback limitations; historical plans remain untouched. + - Context impact: cross-cutting decision — establishes the bounded Claude-specific local-state exception to the prior attribution constraint; synchronization must reconcile the decision record and inspect the mandatory root context files before another task starts. + - Context synchronization: synced + +- [x] T02: `Add claude_model_state table and typed guarded helpers` (status:done) + - Task ID: T02 + - Scope: In — `003_claude_model_state.sql` additive migration; `RepositoryAgentTraceDbSpec` migration list picks it up; state structs (`ClaudeModelStateObservation`, `ObservationKind`); guarded upsert using local `observed_at_ms` (best-effort latest-locally-observed guard, PostModelSwitch-over-SessionStart plus a stable same-kind equal-time tie-break, replay idempotence) and exact-`(session_id, agent_id)` query helpers on `RepositoryAgentTraceDb`; migration/concurrency tests including `001 -> 002 -> 003` on fresh and pre-`003` DBs. Out — hook routing, diff-trace lookup, generated settings, export/sync wiring. + - Dependencies: T01 + - Done when: setup-created and upgraded repository DBs persist and read Claude latest-model state deterministically; `001`/`002` are byte-unchanged; no export/sync module references the table. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_state`; `git diff --stat cli/migrations/agent-trace-repository/`. + - Completed: 2026-09-01 + - Files changed: `cli/migrations/agent-trace-repository/003_claude_model_state.sql`, `cli/src/services/agent_trace_db/mod.rs`, `cli/src/services/agent_trace_db/repository.rs` + - Result: Added the additive Claude model-state table, typed observation model, exact-scope lookup, and guarded deterministic upsert with timestamp, lifecycle-kind, and same-kind lexical ordering rules; added fresh/upgrade, replay, exact-scope, and concurrent-write coverage. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_state` — pass (2 tests); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml equal_time_same_kind_observations` — pass (1 test); `git diff --exit-code -- cli/migrations/agent-trace-repository/001_repository_schema.sql cli/migrations/agent-trace-repository/002_repository_source_instance_id.sql` — pass; `git diff --stat cli/migrations/agent-trace-repository/` — pass (exit 0; new migration recorded as untracked in the baseline-relative snapshot). + - Done checks: All satisfied — fresh and pre-003 repository databases apply the migration; typed guarded state persistence and exact-scope reads are covered, including deterministic equal-time and concurrent writes; migrations `001` and `002` are byte-unchanged; no export/sync code was changed or references the table. + - Context impact: localized Agent Trace DB schema and repository-adapter contract — context synchronization must document the new non-exported Claude state table and helpers, then inspect the mandatory root context files. + - Context synchronization: synced + +- [x] T03: `Add silent sce hooks claude-model-state intake` (status:done) + - Task ID: T03 + - Scope: In — `claude-model-state` in `cli_schema.rs`, `command_runtime.rs`, `HookSubcommand`, and `run_hooks_subcommand`; raw `SessionStart`/`PostModelSwitch` parsing, validation, `cc_`/`claude/` normalization, optional ephemeral `agent_id` handling; injectable clock seam for local `observed_at_ms`; fail-open logging through the existing logger; zero stdout bytes, no exit 2, no denial, local-only/no-network/no-sync behavior on every branch; Claude invokes `SessionStart` synchronously relative to Claude's execution and `PostModelSwitch` asynchronously relative to Claude's execution, and both SCE handlers perform the local database write directly before the hook process exits; keep the work minimal/fast and respect Claude's `PostModelSwitch` lifecycle semantics, including overlapping hook executions; state becomes visible when the PostModelSwitch local DB write completes, not when Claude's model switch occurs, and no causal ordering is asserted; SCE must not spawn, detach, background, or defer the write; focused tests for SessionStart (model / missing model / cleared-state protection), PostModelSwitch (to_model wins, `from_model` validation/diagnostics but no strict CAS, source variants, local-observation guard, replay idempotence, equal-time), and stdout-byte assertions. Out — diff-trace persistence lookup, generated settings, doctor/setup merge. + - Dependencies: T02 + - Done when: Claude invokes `SessionStart` synchronously and `PostModelSwitch` asynchronously relative to Claude's execution, both lifecycle events perform direct guarded state writes before their SCE hook processes exit with no stdout bytes, PostModelSwitch respects Claude's lifecycle semantics, and a pre-`003` DB surfaces `Run 'sce setup'.` guidance without migrating. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_state`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model`. + - Completed: 2026-09-01 + - Files changed: `cli/src/cli_schema.rs`, `cli/src/services/parse/command_runtime.rs`, `cli/src/services/hooks/mod.rs`, `cli/src/services/hooks/claude_model_state.rs`, `cli/src/services/agent_trace_db/mod.rs`, `cli/src/services/agent_trace_db/repository.rs` + - Result: Added the silent `sce hooks claude-model-state` route and direct local Claude lifecycle-state intake. SessionStart model observations and PostModelSwitch transitions are validated, normalized, scoped by optional agent ID, guarded by local observation time through the existing repository adapter, and fail open with logger-only diagnostics and empty stdout. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_state` — pass (15 tests, including missing/null/trimmed and rejected empty/non-string agent IDs, documented and future opaque sources, and pre-003 hook-runtime coverage); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model` — pass (19 tests, including existing Claude attribution coverage); the pre-003 hook test confirms empty stdout, logger-only `Run 'sce setup'.` guidance, no `claude_model_state` table creation, and no `003_claude_model_state` migration record. + - Done checks: All satisfied — CLI/runtime routing recognizes `claude-model-state`; present `agent_id` values are trimmed and must be non-empty strings while missing/null values map to main scope `""`; PostModelSwitch accepts current Claude source values and arbitrary non-empty opaque future values, trimming before persistence; lifecycle parsing preserves to-model precedence and malformed-field rejection; direct guarded writes use the no-migration hook DB path; a pre-003 hook fails open without applying migration 003; failures remain logger-only, empty-stdout, fail-open behavior. + - Context impact: localized Claude hook command/runtime contract and lifecycle intake, with compile-time retention of the T02 typed state query API until T04 consumes it; synchronization must document the new hook route, synchronous/asynchronous lifecycle write contract, fail-open boundary, and inspect the mandatory root context files. + - Context synchronization: synced + +- [x] T04: `Consult claude_model_state at diff-trace persistence` (status:done) + - Task ID: T04 + - Scope: In — after `parse_claude_diff_trace_payload` returns a valid Claude diff trace with unresolved model and after the repository DB is already open, perform exactly one exact `(canonical cc_, agent_id)` `claude_model_state` lookup and pass any result into `DiffTraceInsert.model_id`; extend the internal Claude parsed representation with optional ephemeral `agent_id`, extracting it from the raw event, mapping main-session `None` to `""`, and never serializing/storing/exporting it; preserve `direct -> transcript -> None` in the parser with no DB access; keep unsupported/no-op Claude events DB-free; do not poll, sleep, retry waiting for model state, or wait for another hook process; do not reparse stored raw Claude JSON; preserve subagent isolation (no fallback to `agent_id = ""`); persisted-row and precedence tests, plus OpenCode/Pi/Codex regression coverage. Out — schema/export changes, backfill, changes to direct/transcript resolution. + - Dependencies: T02, T03 + - Done when: `direct > transcript > state > NULL` precedence and subagent isolation are proven on persisted rows, the parser still queries no storage, and one diff trace opens the repository DB once. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_attribution`. + - Completed: 2026-09-01 + - Files changed: `cli/src/services/hooks/mod.rs` + - Result: Added ephemeral Claude agent-scope capture and a single exact-scope model-state lookup at the already-open diff-trace persistence boundary, restricted to raw structured Claude payloads and preserving direct/transcript precedence and non-Claude behavior. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_attribution` — pass (1 test); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_diff_trace` — pass (3 tests); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml 'services::hooks::tests'` — pass (35 tests, including normalized `tool_name="claude"` provenance isolation); focused `services::hooks::tests::normalized_claude_tool_name_does_not_use_claude_state_fallback` — pass (1 test). + - Done checks: All satisfied — persisted-row tests prove `direct > transcript > state > NULL` only for eligible raw structured Claude events, normalized `tool_name="claude"` cannot inherit lifecycle state, and exact subagent isolation; parser agent context is ephemeral and storage-free; the production persistence path opens the repository DB once and performs the fallback lookup only for eligible unresolved Claude traces. + - Context impact: localized Claude diff-trace persistence boundary — context synchronization must document the state fallback, ephemeral agent scope, and exact subagent isolation, then inspect the mandatory root context files. + - Context synchronization: synced + +- [x] T05: `Register and merge Claude lifecycle hooks safely` (status:done) + - Task ID: T05 + - Scope: In — `config/pkl/renderers/claude-content.pkl` adds `SessionStart` and `PostModelSwitch` command hooks routing to `sce hooks claude-model-state`; regenerate canonical outputs; config-merge tests (adds both, preserves user hooks/settings, replaces stale SCE model-state commands, no duplicate SCE registrations); doctor drift + `--fix` tests for a config missing either SCE lifecycle hook; run the pre/post-`PostModelSwitch` Claude Code compatibility smoke and, per its result, either document a raised minimum Claude Code version or capability-gate `PostModelSwitch` installation. Out — OpenCode/Pi/Codex generation, diff-trace lookup, migration/state code. + - Dependencies: T03 + - Done when: `nix run .#pkl-check-generated` passes with the two new registrations and the existing five preserved, setup merge is non-destructive, doctor flags and fixes the drift, and the compatibility policy is explicitly recorded (not assumed). + - Verify: `nix run .#pkl-check-generated`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml config_merge`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor`. + - Completed: 2026-09-01 + - Files changed: `config/pkl/renderers/claude-content.pkl`, `cli/src/services/setup/config_merge.rs`, `cli/src/services/doctor/inspect.rs` + - Result: Registered the Claude `SessionStart` and `PostModelSwitch` lifecycle events to the silent `sce hooks claude-model-state` command while preserving the existing five registrations; covered non-destructive/idempotent setup merging, stale SCE model-state replacement, and individual doctor drift repair for both lifecycle events. Setup/doctor ownership detection recognizes both the current wrapper-script SCE hook shape and the historical `.claude/plugins/sce-agent-trace.ts` Bun hook shape. Upgrading removes obsolete SCE-generated Bun registrations while preserving user-owned Bun and command hooks. + - Verify: `nix run .#pkl-check-generated` — pass (ephemeral generation: 141 files; inventory `93228c3eaa96c0cf4c9eb4f6c59d17a828bff73fb9be8235694dfe5e48b9efbf`); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml config_merge` — pass (21 tests); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` — pass (25 tests); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup` — pass (69 tests). Compatibility smoke passed on Claude Code 2.1.251 and immediately older 2.1.250: both accepted a settings file containing unknown `PostModelSwitch`, returned the expected invalid-API-key termination rather than a settings/configuration failure, and left existing settings/hooks unchanged. Policy: keep unconditional `PostModelSwitch` registration; Claude Code 2.1.250 safely ignores the unknown event registration, so no raised minimum or capability gate is required. + - Done checks: All satisfied — canonical Pkl output includes the two lifecycle registrations and existing hooks; setup merge preserves user keys/hooks, replaces stale current and historical SCE-owned entries, and is idempotent; arbitrary user-owned Bun and command hooks remain untouched; doctor reports missing, stale, or historical Claude settings fragments as drift and repairs them through the existing merge path; compatibility smoke establishes unconditional installation compatibility for Claude Code 2.1.250 and newer. + - Context impact: localized Claude setup/doctor integration contract and generated hook registration, including historical SCE Bun-hook cleanup — context synchronization remains synced and the existing lifecycle/compatibility context is authoritative. + - Context synchronization: synced + +- [x] T06: `Add end-to-end attribution regression and upgrade note` (status:done) + - Task ID: T06 + - Scope: In — real-shaped fixtures and a lifecycle-then-`PostToolUse` regression covering every AC11 scenario (SessionStart-seeded, PostModelSwitch-updated after its local write completes, direct-wins, transcript-wins, all-absent NULL, subagent isolation) asserting persisted `diff_traces.model_id`; test the stable state-after-persistence contract without simulating an unsupported global ordering guarantee; confirm existing Agent Trace / OpenCode / Pi / Codex attribution regressions still pass; add the migration-`003` upgrade note ("rerun `sce setup`") to a durable context file. Out — new product behavior, export/sync changes. + - Dependencies: T04, T05 + - Done when: all AC11 scenarios pass as persisted-row assertions, unrelated producer behavior is unchanged, and the upgrade note is in `context/`. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_attribution` — pass (2 tests, including the new lifecycle/persistence regression); `nix flake check` — pass (all checks passed). + - Completed: 2026-09-01 + - Files changed: `cli/src/services/hooks/claude_model_state.rs`, `cli/src/services/hooks/mod.rs`, `context/sce/agent-trace-db.md` + - Result: Added real-shaped Claude SessionStart/PostModelSwitch and PostToolUse persisted-row coverage for lifecycle fallback, post-switch visibility, direct/transcript precedence, NULL attribution, and exact subagent isolation; added the explicit migration-003 setup upgrade note. + - Done checks: All satisfied — the focused regression records `claude/model-a`, `claude/model-b`, direct/transcript `claude/model-c`, and NULL rows in the expected order; state is asserted after the local PostModelSwitch write and before the subsequent diff trace; the full flake checks retain existing Agent Trace, OpenCode, Pi, and Codex regressions. + - Context impact: localized Claude attribution verification and operator-upgrade guidance — synchronization must retain the end-to-end persisted-row contract and migration-003 rerun-setup requirement while inspecting the mandatory root context files. + - Context synchronization: synced + +## Residual risks + +- **Post-switch visibility race:** Claude invokes `PostModelSwitch` asynchronously relative to Claude's execution, while SCE performs the local database write directly before the hook process exits. Claude may continue before the asynchronous PostModelSwitch hook process has completed its local write, because Claude does not wait for that process. A `PostToolUse` may therefore arrive while the corresponding lifecycle hook is still running. If direct and transcript attribution are unavailable, that event may use stale previous state or `NULL`; subsequent events use the new state after the lifecycle hook finishes. SCE will not spawn, detach, background, or defer the `claude_model_state` write, poll, sleep, retry waiting for model-state convergence, or delay diff-trace persistence waiting for another hook process. This is an upstream lifecycle-timing limitation; `observed_at_ms` does not solve it and the current hook contract cannot eliminate it. +- **Rapid consecutive switch ordering:** Rapid consecutive switches such as `A -> B` followed by `B -> C` can launch overlapping `PostModelSwitch` hook executions. `observed_at_ms` records when SCE locally observes each event (before persistence), so the guarded register can reject an obviously older local observation and resolve equal times deterministically, but it cannot establish Claude's causal switch order. Perfect ordering requires an upstream sequence number or authoritative event timestamp, which Claude does not currently expose through this hook contract. +- A supported Claude Code build that does not emit `PostModelSwitch` leaves the register dependent on `SessionStart` and therefore stale after an in-session switch; the compatibility policy must state this degraded mode. +- The state fallback is best-effort and cannot detect a one-turn fallback-chain model substitution when Claude exposes that substitution neither through lifecycle state nor through the exact transcript lookup. + +## Open questions + +- Claude Code forward-compatibility beyond the tested compatibility floor remains unverified. T05 smoke-tested Claude Code 2.1.251 and immediately older 2.1.250; both accepted the unknown `PostModelSwitch` registration without settings/configuration failure or mutation, so the settled policy is unconditional installation with no raised minimum or capability gate. Future clients that reject unknown lifecycle events would require a new compatibility decision. +- This is the third plan to churn Claude diff-trace model attribution in roughly two months (`remove-session-models-direct-claude-model-id`, then `fix-claude-model-attribution`, now this). The premise is genuinely different — production `NULL` evidence plus a lifecycle event that did not exist before — and the plan deliberately avoids restoring the generic abstraction. Flagged rather than doubted: if `PostModelSwitch` adoption is slow, main-session state is seeded only by `SessionStart` and goes stale after an in-session `/model` switch on older clients, which is the degraded mode the compatibility note must state plainly. + +## Validation Report + +**Status:** validated +**Date:** 2026-09-02 + +### Commands run + +- `nix run .#pkl-check-generated` -> exit 0 (ephemeral generation passed: 141 files; inventory `93228c3eaa96c0cf4c9eb4f6c59d17a828bff73fb9be8235694dfe5e48b9efbf`) +- `nix flake check` -> exit 0 (all checks passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_state` -> exit 0 (16 tests passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model` -> exit 0 (21 tests passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_attribution` -> exit 0 (2 tests passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml config_merge` -> exit 0 (21 tests passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` -> exit 0 (25 tests passed) +- Historical-plan baseline diff -> exit 0 (both historical plans unchanged relative to the merge base) +- Migration `001`/`002` baseline diff -> exit 0 (both migrations unchanged relative to the merge base) +- Export/sync absence inspections -> passed (no `claude_model_state` or model-state cursor references) +- Handler and parser inspections -> passed (silent fail-open handler, no hook migration, storage-free parser, single persistence-boundary lookup) +- Decision/context/settings inspections -> passed (required ADR, context updates, lifecycle registrations, and preserved existing Claude hooks confirmed) + +### Success-criteria verification + +- [x] AC1: Decision record and historical-plan preservation -> ADR contains all required decision, evidence, race, ordering, scope, and limitation statements; baseline diff is clean. +- [x] AC2: Additive migration and guarded schema setup -> migration tests pass; `001` and `002` are unchanged. +- [x] AC3: Typed state adapter outside export/sync -> adapter tests pass; absence inspections found no export/sync references. +- [x] AC4: SessionStart normalization and no-op behavior -> focused state tests pass. +- [x] AC5: PostModelSwitch guarded register semantics -> state and Claude-model tests pass, including source, clock, ordering, replay, and scope behavior. +- [x] AC6: Silent fail-open lifecycle hook -> focused tests pass; handler inspection confirms logger-only diagnostics and no production stdout or exit-2 path. +- [x] AC7: Storage-free parsing and persistence fallback boundary -> attribution tests and parser/persistence inspection pass. +- [x] AC8: Exact canonical agent scope and subagent isolation -> attribution tests and exact-scope inspection pass. +- [x] AC9: Generated lifecycle registration, setup merge, and doctor repair -> generated-output, config-merge, and doctor checks pass. +- [x] AC10: Setup-only migration and upgrade guidance -> state/doctor checks and hook/storage inspection pass; context documents rerunning `sce setup`. +- [x] AC11: End-to-end precedence and producer regressions -> attribution tests and full flake validation pass. +- [x] AC12: Four-stream export boundary and unchanged protocol -> full flake validation passes; export/sync absence inspections pass. + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- Post-switch visibility and rapid-consecutive-switch ordering remain best-effort upstream lifecycle races documented by the plan and ADR. +- None identified beyond the documented design limitations. diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index 5ef76c8a..70715ef4 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -9,6 +9,10 @@ - `ensure_schema_ready_for_hooks()`: non-mutating hook-readiness check that delegates to the shared `TursoDb::ensure_schema_ready()` method with the Agent Trace–specific `AGENT_TRACE_SCHEMA_SETUP_GUIDANCE` constant (`"Run 'sce setup'."`); verifies the repository Agent Trace DB has the expected applied migration metadata in `__sce_migrations` for every ID in `AGENT_TRACE_REPOSITORY_MIGRATIONS`; missing/incomplete metadata fails with `Run 'sce setup'.` guidance instead of running migrations. - `DiffTraceInsert<'a>`: insert payload with `time_ms: i64`, `session_id: &'a str`, `patch: &'a str`, `model_id: Option<&'a str>`, `tool_name: &'a str`, nullable `tool_version: Option<&'a str>`, and `payload_type: &'a str` (using `PAYLOAD_TYPE_PATCH` or `PAYLOAD_TYPE_STRUCTURED` constants). - `PAYLOAD_TYPE_PATCH` / `PAYLOAD_TYPE_STRUCTURED`: string constants (`"patch"` / `"structured"`) for the `diff_traces.payload_type` discriminator column; `OpenCode` normalized diff-trace payloads use `patch`, `Claude` structured `PostToolUse` payloads use `structured`. +- `ClaudeModelStateObservation`: owned typed observation with canonical `session_id`, exact `agent_id` scope, normalized `model_id`, `ObservationKind`, source, and non-negative local `observed_at_ms`. +- `ObservationKind`: the two persisted lifecycle values `SessionStart` (`session_start`) and `PostModelSwitch` (`post_model_switch`). +- `upsert_claude_model_state()`: guarded latest-locally-observed register write. Strictly older observations are ignored; equal timestamps prefer `PostModelSwitch`, then use deterministic model/source lexical tie-breaks for same-kind observations, making identical replays no-ops. +- `claude_model_state_by_session_and_agent()`: exact `(session_id, agent_id)` lookup returning the typed observation or no row; it does not broaden the scope to another agent. - `insert_diff_trace()`: domain-specific insert helper using parameterized SQL. - `RecentDiffTracePatches`: parsed recent `diff_traces` query result containing valid parsed patches plus skipped-row reports. - `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)`: chronological `diff_traces` read helper for rows in the inclusive window `time_ms >= cutoff_time_ms AND time_ms <= end_time_ms`; parses raw patch text through `parse_patch` and skips malformed rows without failing the query. @@ -37,11 +41,11 @@ pub type RepositoryAgentTraceDb = TursoDb; ``` -This adapter has no canonical `DbSpec::db_path()`; callers must resolve `/sce/repos//agent-trace.db` first and use explicit-path `TursoDb` constructors. Its migration list is `generated_migrations::AGENT_TRACE_REPOSITORY_MIGRATIONS`: the fresh multi-statement baseline `cli/migrations/agent-trace-repository/001_repository_schema.sql` plus the additive `002_repository_source_instance_id.sql` (adds `repository_metadata.source_instance_id`). The baseline schema includes `repository_metadata` plus the existing repository-level Agent Trace tables, indexes, and triggers, and intentionally has no `checkout_id` columns on trace tables. `RepositoryAgentTraceDb::verify_or_initialize_repository_metadata(repository_id) -> Result` inserts the singleton metadata row on first initialization, errors if an existing DB stores a different repository ID, and atomically claims `source_instance_id` for this physical database via `UPDATE ... WHERE source_instance_id = ''` (a losing racer's generated candidate is discarded and an already-valid stored value is never overwritten), returning the typed `RepositoryMetadata { repository_id, source_instance_id }`. `source_instance_id` is generated by application code (`generate_source_instance_id()`, UUID v4) and validated with `is_valid_source_instance_id()` (non-empty once trimmed); it is never derived from `repository_id`, remote URL, checkout ID, filesystem path, hostname, or user/workspace identity, and stays stable across reopen and repeated `sce setup` runs. `RepositoryAgentTraceDb::repair_missing_repository_schema_migration_metadata()` is a narrow concurrent-first-open repair seam: it never creates trace tables, but if every required repository schema table already exists and only the one-file baseline migration record is missing, it records `001_repository_schema` and rechecks readiness. +This adapter has no canonical `DbSpec::db_path()`; callers must resolve `/sce/repos//agent-trace.db` first and use explicit-path `TursoDb` constructors. Its migration list is `generated_migrations::AGENT_TRACE_REPOSITORY_MIGRATIONS`: the fresh multi-statement baseline `cli/migrations/agent-trace-repository/001_repository_schema.sql` plus the additive `002_repository_source_instance_id.sql` (adds `repository_metadata.source_instance_id`) and `003_claude_model_state.sql` (adds the non-exported Claude model-state register). The baseline schema includes `repository_metadata` plus the existing repository-level Agent Trace tables, indexes, and triggers, and intentionally has no `checkout_id` columns on trace tables. `RepositoryAgentTraceDb::verify_or_initialize_repository_metadata(repository_id) -> Result` inserts the singleton metadata row on first initialization, errors if an existing DB stores a different repository ID, and atomically claims `source_instance_id` for this physical database via `UPDATE ... WHERE source_instance_id = ''` (a losing racer's generated candidate is discarded and an already-valid stored value is never overwritten), returning the typed `RepositoryMetadata { repository_id, source_instance_id }`. `source_instance_id` is generated by application code (`generate_source_instance_id()`, UUID v4) and validated with `is_valid_source_instance_id()` (non-empty once trimmed); it is never derived from `repository_id`, remote URL, checkout ID, filesystem path, hostname, or user/workspace identity, and stays stable across reopen and repeated `sce setup` runs. `RepositoryAgentTraceDb::repair_missing_repository_schema_migration_metadata()` is a narrow concurrent-first-open repair seam: it never creates trace tables, but if every required repository schema table already exists and only the one-file baseline migration record is missing, it records `001_repository_schema` and rechecks readiness. `RepositoryAgentTraceDb` exposes repository-level write helpers for the current row families by delegating to the same typed insert payloads and parameterized SQL used by the checkout-scoped adapter: `insert_diff_trace`, `insert_post_commit_patch_intersection`, `insert_agent_trace`, `insert_message`, `insert_messages`, `insert_part`, `insert_parts`, and `insert_conversation_text_event`. It also exposes `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` by delegating to the shared recent diff-trace query/parser helper, so repository-scoped attribution reads use the same chronological inclusive window semantics without a checkout filter. These methods preserve the existing row shapes and do not add checkout provenance columns or checkout-scoped write/query APIs. -The repository-scoped adapter is consumed by `agent_trace_storage`, active hook runtime opening, Agent Trace setup/doctor lifecycle, and `sce sync`. Hook writers/readers resolve the current repository storage context before using `RepositoryAgentTraceDb`. The migration-running `new_at(path)` constructor is used by setup and hook-runtime fallback initialization. There is no longer a checkout-scoped adapter or trace database inspection service. +The repository-scoped adapter is consumed by `agent_trace_storage`, active hook runtime opening, Agent Trace setup/doctor lifecycle, and `sce sync`. Hook writers/readers resolve the current repository storage context before using `RepositoryAgentTraceDb`. The migration-running `new_at(path)` constructor is used by setup/lifecycle; hook runtime uses the no-migration constructor and fails open with `Run 'sce setup'.` guidance when schema readiness is not met. There is no longer a checkout-scoped adapter or trace database inspection service. ## Non-goals @@ -78,9 +82,14 @@ SCE creates one Agent Trace DB per logical Git repository on demand through setu - `001_repository_schema.sql` (migration ID `001_repository_schema`) — creates `repository_metadata`, `diff_traces` (including `payload_type TEXT NOT NULL DEFAULT 'patch'`), `post_commit_patch_intersections`, `agent_traces`, `messages`, and `parts`, plus the lookup indexes and `updated_at` triggers, in one `execute_batch` statement recorded as a single migration ID. - `002_repository_source_instance_id.sql` (migration ID `002_repository_source_instance_id`) — additive `ALTER TABLE repository_metadata ADD COLUMN source_instance_id TEXT NOT NULL DEFAULT ''`; existing/placeholder rows default to an empty string and application code (never SQL) replaces it through the atomic claim in `verify_or_initialize_repository_metadata`. This migration does not rewrite `001_repository_schema.sql`. See [context/decisions/2026-08-10-agent-trace-source-instance-id.md](../decisions/2026-08-10-agent-trace-source-instance-id.md) for why `source_instance_id` exists as a second identity independent of `repository_id`. +- `003_claude_model_state.sql` (migration ID `003_claude_model_state`) — additive `claude_model_state` table keyed by `(session_id, agent_id)`, storing `model_id`, constrained `observation_kind`, `source`, and non-negative `observed_at_ms`. The repository adapter's guarded upsert treats it as a best-effort latest-locally-observed register: older observations do not replace newer ones, equal-time `PostModelSwitch` wins over `SessionStart`, and same-kind ties use deterministic model/source ordering. This table is local attribution state outside the four Agent Trace export streams and has no sync cursor. Repositories created before this migration must rerun `sce setup` so the setup lifecycle applies migration `003_claude_model_state`; hook paths do not migrate. The former checkout-scoped `AGENT_TRACE_MIGRATIONS` constant and its 15-file `cli/migrations/agent-trace/` chain (`001_create_diff_traces` … `015_add_diff_traces_payload_type`) were removed by the `retire-legacy-agent-trace-db` plan; `build.rs` auto-discovers migration directories, so deleting the directory dropped the constant on regeneration. The repository schema captures the same tables/columns/indexes/triggers that the old incremental chain produced. +### Migration-003 upgrade note + +Repositories created before `003_claude_model_state` must rerun `sce setup` to apply the additive migration. Hook runtimes, including `sce hooks claude-model-state`, never run migrations and instead surface the existing `Run 'sce setup'.` guidance until setup completes. + The shared `TursoDb` runner records applied IDs in the database-local `__sce_migrations` table. Migration SQL is executed with `execute_batch`, so the one-file repository baseline can contain multiple statements while still recording one migration ID. Repository-scoped storage resolution first resolves `agent_trace.repository_id` / `agent_trace.repository_remote` through config, then splits by caller into two resolution paths in `agent_trace_storage`, both sharing the same identity/checkout-ID setup and returning the same `ResolvedAgentTraceStorage { metadata: RepositoryMetadata, .. } `: @@ -181,8 +190,8 @@ Both triggers compare `OLD.*` vs `NEW.*` for all mutable columns (excluding `upd `sce hooks diff-trace` is the current runtime writer for `diff_traces`. -- The hook path validates required normalized STDIN `{ sessionID, diff, time, tool_name, tool_version }` fields and supported raw Claude structured events before persistence, with `model_id` accepted as optional (absent or `null`) and `tool_version` accepted as nullable. Missing attribution remains `None`; `diff_traces.model_id` is the only active model-attribution storage for diff traces and there is no session-level fallback lookup or cache. -- The resolved `model_id` and direct `tool_version` pass into `DiffTraceInsert`. The stored `session_id` is tool-prefixed before insert construction: `opencode` payloads store `oc_`, `claude` structured payloads store `cc_`, `pi` normalized payloads store `pi_`, and same-tool-prefixed values are not double-prefixed. The `payload_type` field is set to `PAYLOAD_TYPE_PATCH` for `OpenCode` normalized diff-trace payloads and `PAYLOAD_TYPE_STRUCTURED` for Claude structured `PostToolUse` payloads. Claude structured intake resolves direct `model`/`model_id`/`modelId` metadata, including nested `model.id` / `model.model` / `model.name`, before optionally matching the event's `tool_use_id` in its `transcript_path` JSONL assistant-message envelopes. Direct metadata always wins; transcript access and matching fail open; either resolved source is normalized once with the `claude/` prefix; and unresolved attribution remains `NULL`. +- The hook path validates required normalized STDIN `{ sessionID, diff, time, tool_name, tool_version }` fields and supported raw Claude structured events before persistence, with `model_id` accepted as optional (absent or `null`) and `tool_version` accepted as nullable. Raw structured Claude attribution remains `direct > exact transcript > exact session/agent state > NULL`: when direct and matching-transcript sources fail, the already-open repository DB is queried once for the canonical `(cc_, exact agent_id)` in the local `claude_model_state` register; normalized payloads with `tool_name="claude"` do not qualify. Missing state remains `None`; main-session missing/null agent context maps to `""`, and subagents do not inherit that scope. The separate lifecycle intake writes the register directly through the same no-migration hook-runtime DB boundary; the state is never exported or synchronized. +- The resolved `model_id` and direct `tool_version` pass into `DiffTraceInsert`. The stored `session_id` is tool-prefixed before insert construction: `opencode` payloads store `oc_`, `claude` structured payloads store `cc_`, `pi` normalized payloads store `pi_`, and same-tool-prefixed values are not double-prefixed. The `payload_type` field is set to `PAYLOAD_TYPE_PATCH` for `OpenCode` normalized diff-trace payloads and `PAYLOAD_TYPE_STRUCTURED` for Claude structured `PostToolUse` payloads. Claude structured intake resolves direct `model`/`model_id`/`modelId` metadata, including nested `model.id` / `model.model` / `model.name`, before optionally matching the event's `tool_use_id` in its `transcript_path` JSONL assistant-message envelopes. Direct metadata always wins; transcript access and matching fail open; either resolved source is normalized once with the `claude/` prefix; when both sources fail, persistence performs one exact local state lookup only for raw structured Claude payloads; normalized payloads do not receive that fallback, and unresolved attribution remains `NULL`. - `time` is accepted as a `u64` Unix epoch millisecond input and must fit the signed `i64` `time_ms` column before any persistence starts. - The hook inserts the parsed payload fields plus nullable event-local attribution through `RepositoryAgentTraceDb::insert_diff_trace()` without writing a parsed-payload artifact under `context/tmp`. - AgentTraceDb open failures are logged at error level through `sce.hooks.diff_trace.agent_trace_db_open_failed`; later conversion/insert failures retain `sce.hooks.diff_trace.agent_trace_db_write_failed`. Both failure classes preserve deterministic failed-persistence success text and create no artifact fallback. Open failures use the producer-native unprefixed session and do not also emit the write-failure event. @@ -201,7 +210,7 @@ Post-commit intersection rows are written by the active `post-commit` hook flow - No `context/tmp` artifact is written for conversation traces. - The generated OpenCode agent-trace plugin sends mixed-batch envelopes for conversation traces with `tool_name: "opencode"`: regular `message` and `message.part` events each carry one per-item `type`, while diff-backed `message` events send one envelope containing the synthetic parent message item plus patch part items. Pi sends `tool_name: "pi"` in its message-end and synthetic patch envelopes. -`sce hooks session-model` is no longer a supported command route, generated Claude settings no longer produce `SessionStart` model-attribution events, and the Agent Trace DB adapter no longer exposes a `session_models` API or fresh-schema table. See [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md). +`sce hooks session-model` is no longer a supported command route, generated Claude settings no longer produce `SessionStart` model-attribution events, and the Agent Trace DB adapter no longer exposes a `session_models` API or fresh-schema table. `sce hooks claude-model-state` is the separate Claude-specific lifecycle intake; it writes only the non-exported `claude_model_state` register and does not restore the generic session-model route. See [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md). `sce hooks codex`'s `UserPromptSubmit` and `Stop` arms are each a second, independent writer into `messages` and `parts`, calling the shared `insert_conversation_text_event` atomic primitive (see above) rather than the plain `insert_messages`/`insert_parts` calls — not a Codex-specific adapter, since the primitive itself is schema-agnostic and reusable. They store `cx_`-prefixed session IDs and a deterministic `cx::user`/`cx::assistant` message ID rather than a generated UUID. Its `PostToolUse(apply_patch)` arm is likewise a second, independent writer into `diff_traces`, reusing `insert_diff_trace` with `tool_name = "codex"`, `tool_version = NULL`, and `payload_type = "patch"` — not a new adapter, and no schema migration. See [codex-integration-runtime.md](codex-integration-runtime.md). @@ -218,7 +227,7 @@ Post-commit intersection rows are written by the active `post-commit` hook flow ## Export reader (read-only) -`cli/src/services/agent_trace_export/mod.rs` defines `AgentTraceExportReader<'a>`, a read-only incremental reader over one repository-scoped `RepositoryAgentTraceDb` (`AgentTraceExportReader::new(&db)`), composing directly with `ResolvedAgentTraceStorage` from `agent_trace_storage` without generating or owning `source_instance_id` itself. See [agent-trace-export-readers.md](agent-trace-export-readers.md) for the full reader boundary, the storage-resolver composition point, the four stream query shapes, and the explicit no-local-cursor / no-sync-db / no-Turso-Sync / no-ETL / no-DWH statement. +`cli/src/services/agent_trace_export/mod.rs` defines `AgentTraceExportReader<'a>`, a read-only incremental reader over one repository-scoped `RepositoryAgentTraceDb` (`AgentTraceExportReader::new(&db)`), composing directly with `ResolvedAgentTraceStorage` from `agent_trace_storage` without generating or owning `source_instance_id` itself. `claude_model_state` is outside this reader's four exported streams and is not synchronized. See [agent-trace-export-readers.md](agent-trace-export-readers.md) for the full reader boundary, the storage-resolver composition point, the four stream query shapes, and the explicit no-local-cursor / no-sync-db / no-Turso-Sync / no-ETL / no-DWH statement. ## Staged-diff AI-overlap evidence gate diff --git a/context/sce/agent-trace-export-readers.md b/context/sce/agent-trace-export-readers.md index 12907ac6..b57951e6 100644 --- a/context/sce/agent-trace-export-readers.md +++ b/context/sce/agent-trace-export-readers.md @@ -39,7 +39,7 @@ Four methods, one per capture stream, sharing one shape: `(cursor: i64, limit: u SELECT ... FROM WHERE id > ?1 ORDER BY id ASC LIMIT ?2 ``` -against `messages`, `parts`, `diff_traces`, and `agent_traces` respectively (`read_messages_after`, `read_parts_after`, `read_diff_traces_after`, `read_agent_traces_after`). `cursor` is the last server-accepted `id` for that stream; the reader makes no gap or contiguity assumption about IDs. `diff_traces.patch` / `payload_type` and `agent_traces.trace_json` are returned raw and unmodified — no patch parsing, no JSON reparsing. +against `messages`, `parts`, `diff_traces`, and `agent_traces` respectively (`read_messages_after`, `read_parts_after`, `read_diff_traces_after`, `read_agent_traces_after`). `cursor` is the last server-accepted `id` for that stream; the reader makes no gap or contiguity assumption about IDs. `claude_model_state` is local attribution state outside these four export streams and has no reader method or sync cursor. `diff_traces.patch` / `payload_type` and `agent_traces.trace_json` are returned raw and unmodified — no patch parsing, no JSON reparsing. Every call validates, before executing any query: diff --git a/context/sce/agent-trace-hook-doctor.md b/context/sce/agent-trace-hook-doctor.md index 9ef3a903..d08f57cd 100644 --- a/context/sce/agent-trace-hook-doctor.md +++ b/context/sce/agent-trace-hook-doctor.md @@ -43,7 +43,7 @@ The runtime in `cli/src/services/doctor/mod.rs` exposes the approved doctor comm - post-commit automatic-sync readiness from the installed canonical managed block and resolved `agent_trace.auto_sync` setting; enabled/current reports ready, explicit `false` reports a healthy disabled opt-out, and enabled-but-missing, stale, unreadable, or non-executable post-commit state reports not ready without launching sync - managed-block currency checks for required hook payloads against canonical embedded SCE hook assets (delegated to `HooksLifecycle::diagnose` and reused by doctor inspection); `post_commit_auto_sync` is an explanatory capability fact rather than a new problem category, with JSON `state`, `enabled`, `source`, and `config_source` fields, while existing hook problem records, remediation, and overall readiness remain authoritative; doctor never launches `sce sync` or another background process, and runtime launcher failures remain fail-open to a successful post-commit operation - integration target resolution that reads `integrations.target` from repo-local `.sce/config.json` when present, or falls back to detecting repo-root `.opencode/`, `.claude/`, `.pi/`, and `.codex/` directories when config has no `integrations` or `integrations.target`; only the resolved targets are inspected -- repo-root installed OpenCode integration inventory for typed `Plugins`, `Agents`, `Commands`, and `Skills` areas, Claude inventory for generated `Plugins`, `Commands`, and `Skills` areas with no `Agents` expectation, Pi inventory for `Extensions`, `Prompts`, and `Skills`, plus Codex inventory for `Skills` and `Hooks`, all scoped to the resolved targets +- repo-root installed OpenCode integration inventory for typed `Plugins`, `Agents`, `Commands`, and `Skills` areas, Claude inventory for generated `Plugins`, `Commands`, and `Skills` areas with no `Agents` expectation, Pi inventory for `Extensions`, `Prompts`, and `Skills`, plus Codex inventory for `Skills` and `Hooks`, all scoped to the resolved targets; Claude settings inventory includes the SCE `SessionStart` and `PostModelSwitch` model-state registrations, so either missing or stale lifecycle registration is reported as settings drift and repaired through the existing `sce setup` merge path - integration groups are rendered beneath typed, target-scoped `Claude Code`, `OpenCode`, `Pi`, and `Codex` nodes in deterministic target-specific area order; healthy groups render one concise status row without listing installed files - OpenCode plugin inventory includes the installed manifest file plus plugin/preset artifacts as required presence-only files; Claude groups are derived from embedded `.claude` assets (`settings.json` and `hooks/**` under `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, then `commands/**` and `skills/**`); Pi groups are derived from embedded `.pi` assets (`prompts/**` under `Pi prompts`, `skills/**` under `Pi skills`); Codex groups are derived from the embedded Codex catalog (`.agents/skills/**` under `Codex skills`, one row per required `.codex/hooks.json` registration plus `.codex/hooks/**` under `Codex hooks`, the former also gated on Codex's own read-only hook-trust state — see `context/sce/doctor-human-text-contract.md`); generated `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, and `config/.agents/**`/`config/.codex/**` trees are not inspected by doctor - repair-mode delegation to `ServiceLifecycle::fix` implementations: `HooksLifecycle::fix` reuses `install_required_git_hooks` for missing hooks directories plus missing, stale, or non-executable required hooks, so repair restores the canonical all-hook non-blocking missing-`sce` guidance, available-CLI argument/failure propagation, and post-commit-only remote forwarding contract; `LocalDbLifecycle::fix`, `AuthDbLifecycle::fix`, and `AgentTraceDbLifecycle::fix` handle bootstrap of missing canonical SCE-owned DB parent directories diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 8fe4d536..13e33cf3 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -14,6 +14,7 @@ - `sce hooks diff-trace` - `sce hooks conversation-trace` - `sce hooks codex` +- `sce hooks claude-model-state` ## Parser and dispatch behavior @@ -66,15 +67,15 @@ - After Agent Trace validation and `agent_traces` persistence succeed, post-commit resolves the config-file-only `agent_trace.auto_sync` gate. When it is `true`, the hook invokes the sync-owned one-shot launcher exactly once with the repository root; the launcher starts the current `sce` executable as detached `sync --format json` work and is not awaited. Explicit `false` configuration does not launch; omitted configuration launches, and validation or persistence failure reaches the existing error path before the gate. Launcher/current-executable/spawn failures are fail-open and do not change the successful post-commit result. No `pre-commit`, `diff-trace`, or `conversation-trace` path invokes automatic synchronization. - `post-rewrite` is a deterministic no-op entrypoint. - `diff-trace` reads STDIN JSON and classifies the payload: - - **Claude structured payloads** (detected by presence of top-level `hook_event_name`): the STDIN JSON is validated through `derive_claude_structured_patch`. Supported `PostToolUse` `Write` create and `Edit` structured-patch events produce a `DiffTracePayload` with `payload_type="structured"` and the raw event JSON stored as the `diff` column without conversion to unified-diff text. Model attribution is resolved event-locally and direct-first: top-level `model`, `model_id`, or `modelId`, or nested `model.id`, `model.model`, or `model.name`, wins when present. Otherwise, when the event provides both `transcript_path` and `tool_use_id`, Rust scans that Claude JSONL transcript for the assistant-message envelope whose `tool_use.id` matches, skipping malformed unrelated records. Either source is normalized once with the `claude/` prefix. Missing/unreadable transcripts, unmatched tool calls, missing models, or absent lookup fields leave `model_id` nullable without rejecting the hook, and downstream Agent Trace JSON omits contributor `model_id`. No session-level cache or lookup participates. Unsupported Claude events (non-`PostToolUse`, unsupported tools, invalid payloads) produce a deterministic `NoOp` success result. + - **Claude structured payloads** (detected by presence of top-level `hook_event_name`): the STDIN JSON is validated through `derive_claude_structured_patch`. Supported `PostToolUse` `Write` create and `Edit` structured-patch events produce a `DiffTracePayload` with `payload_type="structured"` and the raw event JSON stored as the `diff` column without conversion to unified-diff text. Model attribution is resolved with `direct > exact transcript > exact Claude state > NULL`: top-level `model`, `model_id`, or `modelId`, or nested `model.id`, `model.model`, or `model.name`, wins when present. Otherwise, when the event provides both `transcript_path` and `tool_use_id`, Rust scans that Claude JSONL transcript for the assistant-message envelope whose `tool_use.id` matches, skipping malformed unrelated records. Either source is normalized once with the `claude/` prefix. For these raw structured Claude payloads only, if both event-local sources are unavailable, persistence performs one exact `(cc_, agent_id)` lookup in the local `claude_model_state` register after opening the repository DB; normalized payloads with `tool_name="claude"` do not qualify, and absent state remains nullable. Ephemeral `agent_id` is trimmed for exact lookup, with missing/null main-session context mapped to `""`; subagents never inherit main-session state. Missing/unreadable transcripts, unmatched tool calls, missing models, or absent lookup fields leave the parser's `model_id` nullable without rejecting the hook. No polling, waiting, or stored-raw-event reparsing participates. Unsupported Claude events (non-`PostToolUse`, unsupported tools, invalid payloads) produce a deterministic `NoOp` success result. - **OpenCode normalized payloads** (no `hook_event_name`): existing flat `{ sessionID, diff, time, model_id?, tool_name, tool_version }` validation applies unchanged, with `payload_type="patch"`. - The `DiffTracePayload` struct carries a `payload_type: String` field consumed by `persist_diff_trace_payload_to_agent_trace_db_with` to pass the correct discriminator to `DiffTraceInsert`. - Before `DiffTraceInsert` construction, Rust prefixes the stored `diff_traces.session_id` by source tool: OpenCode normalized payloads store `oc_`, Claude structured payloads store `cc_`, Pi normalized payloads (`tool_name: "pi"`) store `pi_`, and already same-tool-prefixed values are left unchanged. Unknown `tool_name` values pass the raw session ID through unprefixed. Raw non-empty session-ID validation still happens before prefixing. - - Missing `model_id` or `tool_version` stays nullable. Claude's event-local transcript fallback is used only when direct event metadata is absent; Rust performs no session-level fallback attribution. The resolved event model and direct tool-version value are persisted after payload-specific validation/normalization, making `diff_traces.model_id` the only active model-attribution storage for diff traces. + - Missing `model_id` or `tool_version` stays nullable. Claude's event-local transcript fallback is used only when direct event metadata is absent; for raw structured Claude payloads only, when both event-local sources fail, persistence consults the exact Claude lifecycle-state scope once. The resolved model and direct tool-version value are persisted after payload-specific validation/normalization, making `diff_traces.model_id` the only exported model-attribution value; `claude_model_state` remains local-only. - Persistence: resolves the current repository-scoped `RepositoryAgentTraceDb` lazily and inserts the parsed payload fields via `DiffTraceInsert` + `insert_diff_trace()` using tool-prefixed `session_id` plus nullable event-resolved `model_id` and direct `tool_version`. No parsed-payload artifact is written under `context/tmp`. - Current producers are the OpenCode agent-trace plugin and the generated Claude `sce hooks` command hooks (no TypeScript intermediary). - OpenCode forwards user-message `message` diffs with `tool_name="opencode"`, always including `model_id`, and nullable OpenCode client-version metadata. - - Claude generated settings no longer register `SessionStart`; supported `PostToolUse` `Write|Edit|MultiEdit|NotebookEdit` events are routed directly to `sce hooks diff-trace`. Runtime persistence currently derives structured diff traces for `Write` create and `Edit` structured-patch payloads; unsupported Claude payload shapes are no-ops. + - Claude generated settings register `SessionStart` and `PostModelSwitch` for model-state intake only; supported `PostToolUse` `Write|Edit|MultiEdit|NotebookEdit` events remain routed directly to `sce hooks diff-trace`. Runtime persistence currently derives structured diff traces for `Write` create and `Edit` structured-patch payloads; unsupported Claude payload shapes are no-ops. - Neither TypeScript runtime writes `context/tmp/*-diff-trace.json` artifacts or AgentTraceDb rows directly. - `diff-trace` command success reports AgentTraceDb persistence only. AgentTraceDb open failures are logged at error level through `sce.hooks.diff_trace.agent_trace_db_open_failed`; later conversion/insert failures retain the warn-level `sce.hooks.diff_trace.agent_trace_db_write_failed` event. Both paths keep the deterministic failed-persistence success text and create no parsed-payload artifact fallback. Diagnostics route through the logger's optional session argument with the original producer-provided session ID, never the AgentTraceDb-only tool-prefixed value. A DB-open failure emits only the open-specific event, not the broader write-failure event. - `diff-trace` producer-facing intake failures are logged through `sce.hooks.diff_trace.error` and returned as hook success. Fail-open routing checks only the expected non-empty top-level session field: `session_id` when `hook_event_name` identifies a Claude raw event, otherwise `sessionID`; malformed JSON, wrong-shaped input, or a missing/empty expected field remains sessionless. The valid-payload path is DB-only and does not write parsed-payload artifacts. @@ -114,7 +115,8 @@ - Current valid-payload success output reports deterministic mixed-batch accounting: `conversation-trace hook persisted mixed payload batch to AgentTraceDb: attempted=, persisted_messages=, persisted_parts=, skipped=.` The hook does not persist `context/tmp` artifacts. - Fail-open output for conversation-trace intake failures is `conversation-trace hook intake failed open; error logged.` so hook callers do not receive app-level classified errors or non-zero exits for intake failures. - The generated OpenCode agent-trace plugin emits this mixed-batch shape for conversation-trace handoff with `tool_name: "opencode"`: ordinary message/part events produce one-item mixed envelopes, completed question-tool parts produce `message.part` items with `part_type: "question"`, and diff-backed message events produce one envelope containing the synthetic parent `message` item plus patch `message.part` items. The Pi extension uses the same shape with `tool_name: "pi"`. -- `session-model` is no longer a supported `sce hooks` subcommand and generated Claude settings no longer produce `SessionStart` model-attribution events. The `session_models` DB API/table and diff-trace fallback are removed from active code; upgraded databases may still contain the retired table, but runtime paths no longer read or write it. +- `sce hooks claude-model-state` is a silent, local-only lifecycle intake for raw Claude `SessionStart` and `PostModelSwitch` events. A model-bearing `SessionStart` writes normalized `claude/` state, while a `PostModelSwitch` validates `from_model` and `to_model` but writes normalized `to_model`; both use canonical `cc_` plus exact optional `agent_id` scope (`""` for the main conversation). Missing or null `agent_id` means the main scope; a present string is trimmed and must remain non-empty, so malformed empty or non-string values fail open without a state write. Current Claude sources include `command`, `picker`, `sdk`, `auto`, and `resume`; SCE accepts any non-empty source string and stores it opaquely. The command uses the existing guarded latest-locally-observed register and local SCE observation time. SessionStart without a model is a no-op that cannot clear existing state. The command reads and writes directly through the no-migration hook-runtime repository DB path before returning, does not migrate, sync, or access the network, and returns zero stdout bytes with logger-only fail-open diagnostics for input, clock, DB-open, and DB-write failures. Claude's SessionStart invocation is synchronous relative to Claude execution, while PostModelSwitch is asynchronous; overlapping hooks and the post-switch visibility race are accepted and local observation time does not prove Claude causal ordering. Generated Claude settings register both lifecycle events for this command, while the existing five SCE registrations remain unchanged. Claude Code 2.1.250 and 2.1.251 compatibility smoke passed with an unknown PostModelSwitch registration, so installation remains unconditional with no raised minimum or capability gate. +- `session-model` is no longer a supported `sce hooks` subcommand and generated Claude settings no longer produce the retired generic session-model route. The `session_models` DB API/table and generic fallback are removed from active code; upgraded databases may still contain the retired table, but runtime paths no longer read or write it. The separate `sce hooks claude-model-state` command is a Claude-specific local register, and `diff-trace` consults only its exact `(cc_, agent_id)` state after direct and transcript attribution fail; this does not restore the generic abstraction. - `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables, calling the shared `insert_conversation_text_event` atomic primitive rather than the plain `insert_messages`/`insert_parts` calls described above (so a replayed or concurrent duplicate delivery leaves exactly one message and one part row, not only the parent message row), with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. Its `PostToolUse(apply_patch)` arm is a second, independent writer into `diff_traces` via the existing `insert_diff_trace`, carrying `tool_name = "codex"` and the same `cx_`-prefixed `session_id` — no new adapter. Apply_patch persistence requires a trimmed non-empty session, preserves reported model IDs without fabricating a provider prefix, and returns empty stdout on every non-policy success or fail-open path. Before persistence it resolves source and move-destination paths independently from the event `cwd` against the canonical Git root: valid `..` components and absolute-inside paths are accepted, missing Add File targets are allowed through their nearest existing prefix, and outside or symlink-escaping mappings are rejected. The generated Codex command itself resolves that Git root at invocation time and invokes the installed helper with quoted paths, so root and nested cwd (including spaced repository paths) share one entrypoint; root-resolution failure is silent and fail-open. The Codex setup/doctor boundary is separate from evidence intake: `.codex/hooks.json` is merged through the shared structural `codex_hook_config` service, which preserves valid user handlers and recognizes only the generated helper path plus the `sce hooks codex` command contract. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. ## Explicit non-goals in the current baseline diff --git a/context/sce/claude-raw-hook-capture.md b/context/sce/claude-raw-hook-capture.md index 7af44a4f..5e80fc69 100644 --- a/context/sce/claude-raw-hook-capture.md +++ b/context/sce/claude-raw-hook-capture.md @@ -6,10 +6,10 @@ The `sce hooks claude-capture ` CLI route, `ClaudeCaptureEvent`, `Ho Rust now exposes these active intakes for Claude/OpenCode editor runtimes: -- `sce hooks diff-trace` — STDIN JSON intake for OpenCode normalized or Claude structured diff-trace payloads with optional/nullable direct attribution. Rust persists direct payload-derived `model_id` and `tool_version` values as-is; Claude model metadata is extracted best-effort from the raw `PostToolUse` event and remains nullable when Claude omits it. No session-level fallback lookup is performed. +- `sce hooks diff-trace` — STDIN JSON intake for OpenCode normalized or Claude structured diff-trace payloads with optional/nullable direct attribution. Rust persists direct payload-derived `model_id` and `tool_version` values as-is; Claude model metadata is extracted best-effort from the raw `PostToolUse` event and falls back to exact transcript and Claude lifecycle state when available. The state fallback is Claude-specific and does not restore the generic session-level abstraction. - `sce hooks conversation-trace` — STDIN JSON intake for normalized mixed-batch message/part payloads and supported raw Claude `UserPromptSubmit`, `Stop`, and `PostToolUse` events. Runtime intake failures log `sce.hooks.conversation_trace.error` and fail open to the hook producer. -`sce hooks session-model` is no longer supported, and generated Claude settings no longer register `SessionStart` for Agent Trace attribution. +`sce hooks session-model` is no longer supported. Generated Claude settings register `SessionStart` for local model-state attribution only and newly register `PostModelSwitch`; neither event is part of the removed raw-capture route. ## Historical artifact contract @@ -25,10 +25,10 @@ The generated Claude TypeScript runtime at `config/.claude/plugins/sce-agent-tra ## Current state -- Claude settings call the generated Bash helper `.claude/hooks/run-sce-or-show-install-guidance.sh` via generated `.claude/settings.json` command hooks before invoking `sce`: matched `PostToolUse Write|Edit|MultiEdit|NotebookEdit` pipes raw hook event JSON to `sce hooks diff-trace`, supported conversation events pipe raw hook event JSON to `sce hooks conversation-trace`, and `PreToolUse Bash` pipes raw hook event JSON to `sce policy bash`. The helper emits `sce CLI not found. Install it from https://sce.crocoder.dev/docs/getting-started#install-cli` and exits successfully when `sce` is missing; when `sce` exists it `exec`s the original command arguments so Rust receives stdin and owns stdout/stderr/exit behavior. Rust handles extraction, validation, and persistence without a TypeScript intermediary. `SessionStart` is no longer registered. +- Claude settings call the generated Bash helper `.claude/hooks/run-sce-or-show-install-guidance.sh` via generated `.claude/settings.json` command hooks before invoking `sce`: `SessionStart` and `PostModelSwitch` pipe lifecycle JSON to `sce hooks claude-model-state`, matched `PostToolUse Write|Edit|MultiEdit|NotebookEdit` pipes raw hook event JSON to `sce hooks diff-trace`, supported conversation events pipe raw hook event JSON to `sce hooks conversation-trace`, and `PreToolUse Bash` pipes raw hook event JSON to `sce policy bash`. The helper emits `sce CLI not found. Install it from https://sce.crocoder.dev/docs/getting-started#install-cli` and exits successfully when `sce` is missing; when `sce` exists it `exec`s the original command arguments so Rust receives stdin and owns stdout/stderr/exit behavior. Rust handles extraction, validation, and persistence without a TypeScript intermediary. The lifecycle registrations are model-state-only and do not restore raw event capture. - The former Claude TypeScript runtime at `config/.claude/plugins/sce-agent-trace.ts` was removed in T07 of the `claude-rust-diff-trace` plan. - Rust owns normalized persistence: `diff-trace` inserts into `diff_traces` with `payload_type` classification (`"patch"` for OpenCode, `"structured"` for Claude), while `conversation-trace` inserts normalized messages/parts. -- Claude `diff-trace` attempts best-effort direct `model_id` extraction from the raw `PostToolUse` payload and stores nullable direct attribution in `diff_traces`; OpenCode sends `model_id` directly and may send nullable `tool_version`. Missing metadata is persisted as `NULL` and is not resolved from any session-level cache. +- Claude `diff-trace` attempts best-effort direct `model_id` extraction from the raw `PostToolUse` payload, then exact transcript lookup and exact Claude lifecycle-state lookup, storing the resulting nullable attribution in `diff_traces`; OpenCode sends `model_id` directly and may send nullable `tool_version`. Missing metadata is persisted as `NULL`; Claude lifecycle state is local-only and is not a generic session cache. - No raw Claude hook payload artifacts are written by TypeScript or Rust. See also: [agent-trace-hooks-command-routing.md](./agent-trace-hooks-command-routing.md), [../context-map.md](../context-map.md)