From b01a863b5a7d8a6900c57edd7ec55c00d76d89c7 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sat, 5 Sep 2026 00:13:13 +0200 Subject: [PATCH 1/2] context: Add Claude bridge-session model inheritance plan Document the model-less Claude `/clear` SessionStart gap and the production evidence behind a bounded local fallback. Scope bridgeSessionId sibling discovery, exact-scope state inheritance, attribution coverage, and the best-effort model-switch tradeoff without schema or export changes. Plan: claude-clear-session-model-inheritance (T01-T03) Co-authored-by: SCE --- .../claude-clear-session-model-inheritance.md | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 context/plans/claude-clear-session-model-inheritance.md diff --git a/context/plans/claude-clear-session-model-inheritance.md b/context/plans/claude-clear-session-model-inheritance.md new file mode 100644 index 00000000..7b96e222 --- /dev/null +++ b/context/plans/claude-clear-session-model-inheritance.md @@ -0,0 +1,291 @@ +# Plan: claude-clear-session-model-inheritance + +## Change summary + +A Claude `/clear` fires a fresh `SessionStart` under a brand-new `session_id`, and +that event never carries a `model` field — confirmed tonight against real +captured hook payloads, not inferred. Today this is a legitimate, documented +silent no-op: `claude_model_state` never gets seeded for that session, and +because event-local transcript attribution loses its async-write race far more +often than the existing design assumed, the session then persists `NULL` model +attribution on every diff trace for its entire life unless an unrelated +`PostModelSwitch` happens to occur later. + +Claude Code's transcript file (not the hook payload) carries a `bridgeSessionId` +that stays constant across a `/clear`, letting a cleared session be correlated +with the session it continued. This plan adds that correlation as a new, +local-only discovery source for `claude_model_state`: when a `SessionStart` has +no `model`, read that session's own `transcript_path` (already present on every +`SessionStart` payload, confirmed including the model-less `/clear` shape) for +its `bridgeSessionId`, find the most recently modified sibling transcript in the +same Claude project directory sharing that id, and — if that sibling already has +a `claude_model_state` row — inherit its model into the new session's row with +`source="bridge_inherited"`. This extends how a `claude_model_state` observation +can be seeded; it does not change the table, its schema, its export boundary, or +its exact-scope read/write contract, and it does not touch `PostModelSwitch` +(which never lacks a model). No prior work in the repository has read or +correlated `bridgeSessionId`; this is new discovery logic, not an extension of an +existing helper. + +### Evidence gathered this session (2026-09-04, `improve-cli-errors` worktree) + +All of the following came from real Claude Code hook traffic and real local +files, not synthetic payloads, captured by temporarily instrumenting +`sce hooks claude-model-state` with forced (`warn`, bypasses `log_level`) +diagnostic log lines and rebuilding/redeploying the local dev binary for this +worktree only (`cli/target/debug/sce`, pointed to by a temporary edit to +`.claude/hooks/run-sce-or-show-install-guidance.sh`): + +- Three real `SessionStart` payloads were captured in full. Every one of them — + including the model-less `/clear` case — carried `transcript_path`: + - `source=clear`, no `model` key: `{cwd, hook_event_name, scratchpad_dir, session_id, source, transcript_path}`. + - `source=startup`, with `model`: `{cwd, hook_event_name, model, scratchpad_dir, session_id, source, transcript_path}`. + - A real `PostModelSwitch` payload: `{cache_ttl, context_tokens, cwd, estimated_cache_write_usd, from_model, hook_event_name, pricing, prompt_cache_warm, prompt_id, requested_model, scratchpad_dir, session_id, source, to_model, transcript_path}`. + - `bridgeSessionId` was absent from all three — confirmed by a recursive + key-name scan over the full parsed JSON tree, not just a top-level check. +- Repeated real `/clear` events across multiple sessions tonight + (`6f9d3d40-...`, `c80bd850-...`, `45f33845-...`, `19721678-...`, + `3baecb2c-...`, `6c40df5a-...`) all showed the identical pattern: `SessionStart` + with `source=clear` and no `model` key, landing as a silent no-op — this is not + a one-off, it is the deterministic behavior of `/clear`. +- Each session's own transcript file's second line + (`{"type":"bridge-session","sessionId":...,"bridgeSessionId":"cse_...",...}`) + was checked directly. Three real sibling pairs were confirmed sharing a + `bridgeSessionId` across a `/clear` boundary, e.g. `c80bd850-...` + (`source=clear`, no model) and `b850dadf-...` (`source=startup`, + `model=claude/claude-opus-5`) both carry `bridgeSessionId=cse_019wqdgx5vaHPWJNzrLRKDYp`. + This is the mechanism this plan builds on, not a hypothesis. +- Separately, the same investigation found and fixed an unrelated cause of + missing attribution: the shared Turso-backed repository `agent-trace.db` + intermittently failed to open with `I/O error: short read on WAL frame at + offset 309032`, observed across several real `SessionStart`/`PostModelSwitch`/ + diff-trace/conversation-trace hook calls over a multi-minute window. This was + manually repaired (backup taken, stale `.db-tshm`/`.db-wal` removed so Turso + rebuilt them, repair verified via real write round-trips through the actual + `sce`/Turso binary) and confirmed via a follow-up batch of real hook calls + that all persisted cleanly afterward. That bug is fixed and is **not** part of + this plan — it explains some, but not all, of the missing attribution seen + during this investigation; the `/clear`-with-no-model gap this plan targets is + independent and still present after the DB repair. + +### What has already been done, and what T01–T03 still need to do + +Already done, outside this plan's task stack (local investigation artifacts, not +committed change): + +- Temporary diagnostic logging in `claude_model_state.rs` (`diag_invoked`, + `diag_raw_payload`, `diag_resolved`, `diag_noop`, `diag_persisted`) that proved + the evidence above. T03 removes this, since it replaces the exact code path + the diagnostics were added to observe. +- A local dev build (`cli/target/debug/sce`) and a temporary redirect in this + worktree's `.claude/hooks/run-sce-or-show-install-guidance.sh` so real hook + traffic in `improve-cli-errors` runs that build instead of the installed Nix + binary. This redirect is local-environment wiring, not a source change, and is + out of this plan's scope to revert or keep; whoever implements T03 should + rebuild the same way to keep testing against real hook traffic (see below). +- The Turso WAL-open-failure repair described above (already fixed, unrelated to + this plan's task stack). + +Still to build: the bridge-session discovery helper (T02) and its wiring into +the `SessionStart` no-op path (T03). Nothing in `claude_bridge_session.rs` or the +inheritance branch exists yet. + +### How to retest against real Claude Code hook traffic + +Unit tests (`Verify:` lines on T02/T03) prove the logic in isolation. To confirm +it against real Claude Code behavior the way this evidence was gathered: + +1. Build the dev binary: `nix develop -c ./scripts/run-cli-cargo.sh build --manifest-path cli/Cargo.toml`. +2. Point this worktree's hooks at it (prepend `cli/target/debug` to `PATH` inside + `.claude/hooks/run-sce-or-show-install-guidance.sh` before its `exec "$@"`, or + restore the equivalent temporary redirect described above). +3. Trigger a real `/clear` in a Claude Code session running in this worktree. +4. Check that session's own log file, `context/tmp/sce--.log` + (find it with `ls -t context/tmp/*.log | head`): before T03, it shows + `diag_noop` for the model-less `SessionStart`; after T03, it should show the + new observation persisted with `source=bridge_inherited` (or an explicit log + line naming that path, if T03 adds one) instead. +5. Confirm the inherited row directly: + `RepositoryAgentTraceDb`'s existing exact-scope read for + `(cc_, "")`, e.g. through a focused test harness rather than + raw `sqlite3` — a stock SQLite client was used earlier in this investigation + to inspect the live Turso-managed DB and is suspected to have contributed to + the WAL corruption above; avoid it against this DB while Turso holds it open, + and prefer the `sce`/Turso binary or the repository's own test helpers for + any live inspection. +6. Send at least one real tool call (`Write`/`Edit`) in the new session and + confirm its `diff_traces.model_id` resolves to the inherited model (AC4), + the same way `claude_model_attribution`'s persisted-row tests check it. + +## 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. + +- [ ] AC1: A `SessionStart` event with no `model` field, whose `transcript_path` + file's leading records carry a `bridgeSessionId` that a sibling transcript in + the same directory also carries, and whose sibling already has a + `claude_model_state` row, causes the new session to persist a + `claude_model_state` row with the sibling's model and `source="bridge_inherited"`. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_state`. +- [ ] AC2: When `transcript_path` is missing/unreadable, the bridge record is + absent or malformed, no sibling shares the bridge id, or the sibling has no + recorded state, the handler behaves exactly as today: silent no-op, zero + stdout, no DB write, and existing state (if any) is never cleared or + overwritten. Every branch fails open. + - Validate: focused tests covering each failure branch under the same test command as AC1. +- [ ] AC3: Bridge discovery reads only the leading records of each candidate + transcript (never a full-file scan), performs no network access, and leaves + `PostModelSwitch` handling and the existing diff-trace precedence + (`direct > exact transcript > exact state > NULL`) unchanged. + - Validate: inspect the discovery helper for a bounded read; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model` and `claude_model_attribution` pass unchanged alongside new coverage. +- [ ] AC4: A diff-trace event in a session that inherited its model this way, with + no direct model and no winning transcript match, resolves `diff_traces.model_id` + from the inherited state exactly as it would from a normal `SessionStart.model` + seed. + - Validate: persisted-row regression under `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_attribution`. +- [ ] AC5: A decision record documents the production evidence (real captured + `/clear` `SessionStart` payloads confirmed to omit `model`; confirmed absence of + `bridgeSessionId` in any captured hook payload shape; confirmed presence of + `bridgeSessionId` in the transcript's bridge-session record; confirmed + sibling-transcript pairing across real sessions), the mechanism, its + best-effort/no-ordering-guarantee caveat, and why it stays within the existing + Claude-specific/local-only/non-exported/no-generic-abstraction guardrails from + the `2026-09-01-claude-model-attribution-state` decision. + - Validate: inspect the decision file for each listed element. + +### 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` + +### Context sync + +- `context/sce/agent-trace-hooks-command-routing.md` — describe the bridge-inheritance + fallback on the `SessionStart` no-op path and the `source="bridge_inherited"` value. +- `context/glossary.md` — add a `bridge session correlation` (or equivalent) term. +- `context/context-map.md` — update the `agent-trace-hooks-command-routing.md` annotation + if its summary would otherwise describe `SessionStart` as unconditionally a no-op + without a model. + +## 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/src/services/hooks/claude_model_state.rs`; a new + `cli/src/services/hooks/claude_bridge_session.rs` discovery module; focused Rust + tests; one new decision record; the listed context-sync files. +- **Out of scope:** Agent Trace DB schema/migration changes, `PostModelSwitch` + behavior, export/sync/control-plane changes, OpenCode/Pi/Codex attribution + behavior, historical backfill of already-`NULL` rows, and the unrelated Turso + WAL-open-failure issue diagnosed and manually repaired earlier this session + (that was a database-availability bug, not a missing-signal gap, and is not + part of this plan). +- **Constraints:** no schema/migration; local filesystem only, no network access; + bounded/fail-open reads (leading records only, never a full transcript scan); + `sce hooks claude-model-state` keeps its zero-stdout, fail-open, no-exit-2 + contract on every branch, including every new bridge-discovery branch; the + inherited write remains exact-scope `(cc_, agent_id)` and does not + change subagent isolation; no new dependency. +- **Non-goal:** does not restore `session_models` or a generic cross-editor + session cache; does not persist `bridgeSessionId` durably anywhere; does not + attempt bridge correlation for `PostModelSwitch` (which always carries + `to_model`); does not guarantee correctness when a user clears and switches + models before any tool call — this is best-effort inheritance, not a proof. + +## Assumptions + +- Bridge correlation applies to any model-less `SessionStart` regardless of + `source` (not only `source="clear"`): nothing in the captured data or existing + code restricts the gap to that one source value, and narrowing to it would + leave other model-less `SessionStart` shapes uncovered for no stated reason. +- The sibling's session id is read from its transcript filename stem, consistent + with how `session_id` is already read from the hook payload elsewhere in this + file and how `transcript_path` is already keyed to a session in + `claude_transcript.rs`. +- "Most recently modified other transcript sharing the bridge id, excluding + self" is an adequate deterministic tie-break for choosing the sibling to + inherit from. This is the same best-effort/local-observation framing the + `2026-09-01-claude-model-attribution-state` decision already accepted for + `claude_model_state` generally; it does not claim to prove Claude's causal + session ordering. + +## Task stack + +- [ ] T01: `Record the bridge-session model-inheritance decision` (status:todo) + - Task ID: T01 + - Scope: In — write `context/decisions/{date}-claude-bridge-session-model-inheritance.md` + covering the production evidence, mechanism, best-effort caveat, and guardrail + compliance listed in AC5. Out — any code change, any edit to another context + or plan file, any edit to the `2026-09-01-claude-model-attribution-state` + decision. + - Dependencies: none + - Done when: the decision file exists in ADR format and contains every element + AC5 names; no other file changes. + - Verify: inspect the file against AC5. + - Context synchronization: pending + +- [ ] T02: `Add bounded bridge-session discovery helper` (status:todo) + - Task ID: T02 + - Scope: In — new `cli/src/services/hooks/claude_bridge_session.rs` with two + fail-open functions: (a) extract `bridgeSessionId` from a transcript path's + leading records; (b) given a transcript path and a bridge id, scan sibling + `.jsonl` files in the same directory for the most recently modified other + file whose own leading records share that bridge id, and return its session + id. No DB access, no network, bounded reads only. Out — wiring into + `claude_model_state.rs`, any DB read/write. + - Dependencies: T01 + - Done when: against real-shaped fixture transcripts (matching the payload + shapes captured this session), the helper resolves the correct sibling + session id; returns `None` for a missing file, an unreadable file, a + missing/malformed bridge record, and no matching sibling; and its reads are + bounded, not full-file scans. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_bridge_session`. + - Context synchronization: pending + +- [ ] T03: `Wire bridge inheritance into SessionStart and prove end-to-end attribution` (status:todo) + - Task ID: T03 + - Scope: In — in `claude_model_state.rs`, when parsing yields no observation for + a model-less `SessionStart`, invoke T02's helper against the event's own + `transcript_path`; on a resolved sibling id, perform one exact-scope + `claude_model_state` read for `(cc_, "")`, and when found, + persist a new observation for the *current* session with + `observation_kind=SessionStart`, `source="bridge_inherited"`, and the + sibling's model, through the same guarded local-observation-time write path + used by any other observation; any failure at any step falls through + unchanged to today's silent no-op. Remove the temporary `diag_*` diagnostic + breadcrumbs added during this session's investigation, since this task + replaces the exact no-op branch they were instrumenting. Add a persisted-row + regression proving a diff-trace event in the newly-seeded session resolves + `model_id` from the inherited state. Out — schema/migration changes, + `PostModelSwitch` changes, export/sync changes. + - Dependencies: T02 + - Done when: AC1, AC2, AC3, and AC4 all hold, and the existing + `claude_model_state`, `claude_model`, and `claude_model_attribution` suites + pass unchanged alongside the new coverage. + - 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_attribution`; `nix flake check`. + - Context synchronization: pending + +## Open questions + +Bridge inheritance is a probabilistic guess, not a guarantee: a session that +clears and switches models before its first tool call inherits the *previous* +model and gets attributed to it instead of correctly staying `NULL`. Today's +baseline is 100% of `/clear` sessions unattributed, so trading silence for +"usually correct, occasionally wrong" is very likely still a net improvement — +but it changes the failure mode from "we don't know" to "we have a plausible but +sometimes-wrong answer," which is a different kind of wrong worth deciding on +deliberately rather than assuming away. From cd75fb456e0079035f1dc3529be4749e59914d32 Mon Sep 17 00:00:00 2001 From: stefanskoricdev Date: Tue, 8 Sep 2026 18:43:40 +0200 Subject: [PATCH 2/2] runtime: Inherit Claude model state across bridge-linked sessions Model-less Claude SessionStart events need attribution across /clear session boundaries. Add bounded, fail-open transcript bridge discovery and seed the new session from the newest matching sibling's exact main-session state. Add end-to-end regression coverage and document the behavior, guardrails, and residual ordering risk. Co-authored-by: SCE --- .../services/hooks/claude_bridge_session.rs | 247 ++++++++++++++++++ cli/src/services/hooks/claude_model_state.rs | 115 +++++++- cli/src/services/hooks/mod.rs | 109 ++++++++ context/context-map.md | 3 +- ...claude-bridge-session-model-inheritance.md | 109 ++++++++ context/glossary.md | 4 +- context/patterns.md | 2 +- .../claude-clear-session-model-inheritance.md | 119 +++++++-- .../sce/agent-trace-hooks-command-routing.md | 2 +- 9 files changed, 683 insertions(+), 27 deletions(-) create mode 100644 cli/src/services/hooks/claude_bridge_session.rs create mode 100644 context/decisions/2026-09-08-claude-bridge-session-model-inheritance.md diff --git a/cli/src/services/hooks/claude_bridge_session.rs b/cli/src/services/hooks/claude_bridge_session.rs new file mode 100644 index 00000000..464e7a25 --- /dev/null +++ b/cli/src/services/hooks/claude_bridge_session.rs @@ -0,0 +1,247 @@ +use std::fs::{self, File}; +use std::io::{self, BufRead, BufReader}; +use std::path::Path; +use std::time::SystemTime; + +use serde_json::Value; + +const MAX_LEADING_RECORDS: usize = 16; +const BRIDGE_SESSION_RECORD_TYPE: &str = "bridge-session"; + +/// Extract Claude's bridge-session identifier from the leading JSONL records. +/// +/// Transcript access and parsing are fail-open. Only a bounded number of +/// records are read so discovery never scans a complete transcript. +pub fn extract_claude_bridge_session_id(transcript_path: &Path) -> Option { + extract_claude_bridge_session_id_from_reader(File::open(transcript_path).map(BufReader::new)) +} + +/// Find the most recently modified sibling transcript sharing a bridge-session +/// identifier and return its session ID from the filename stem. +/// +/// Directory access, metadata, transcript reads, and JSON parsing are all +/// fail-open. The source transcript itself is excluded from the candidates. +pub fn find_claude_bridge_sibling_session_id( + transcript_path: &Path, + bridge_session_id: &str, +) -> Option { + let bridge_session_id = bridge_session_id.trim(); + if bridge_session_id.is_empty() { + return None; + } + + let directory = transcript_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let source_file_name = transcript_path.file_name(); + let mut newest_match: Option<(SystemTime, String)> = None; + + for entry in fs::read_dir(directory).ok()?.flatten() { + let candidate_path = entry.path(); + if candidate_path.file_name() == source_file_name + || candidate_path + .extension() + .and_then(|extension| extension.to_str()) + != Some("jsonl") + { + continue; + } + + let Ok(metadata) = entry.metadata() else { + continue; + }; + if !metadata.is_file() { + continue; + } + + let Some(candidate_session_id) = candidate_path + .file_stem() + .and_then(|stem| stem.to_str()) + .map(str::trim) + .filter(|session_id| !session_id.is_empty()) + .map(str::to_string) + else { + continue; + }; + + if extract_claude_bridge_session_id(&candidate_path).as_deref() != Some(bridge_session_id) { + continue; + } + + let Ok(modified) = metadata.modified() else { + continue; + }; + let should_replace = match &newest_match { + None => true, + Some((newest_modified, newest_session_id)) => { + modified > *newest_modified + || (modified == *newest_modified && candidate_session_id > *newest_session_id) + } + }; + if should_replace { + newest_match = Some((modified, candidate_session_id)); + } + } + + newest_match.map(|(_, session_id)| session_id) +} + +fn extract_claude_bridge_session_id_from_reader( + reader: io::Result, +) -> Option { + let reader = reader.ok()?; + + for line in reader.lines().take(MAX_LEADING_RECORDS) { + let line = line.ok()?; + let Ok(parsed) = serde_json::from_str::(&line) else { + continue; + }; + + let Some(record) = parsed.as_object() else { + continue; + }; + if record.get("type").and_then(Value::as_str) != Some(BRIDGE_SESSION_RECORD_TYPE) { + continue; + } + + if let Some(bridge_session_id) = record + .get("bridgeSessionId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(bridge_session_id.to_string()); + } + } + + None +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + io::Cursor, + path::{Path, PathBuf}, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, + }; + + use super::*; + + 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-bridge-{label}-{suffix}")); + fs::create_dir_all(&path).expect("temporary directory should be created"); + path + } + + fn transcript(bridge_session_id: &str, session_id: &str) -> String { + format!( + concat!( + "{{\"type\":\"file-history-snapshot\",\"messageId\":\"msg-1\"}}\n", + "{{\"type\":\"bridge-session\",\"sessionId\":\"{session_id}\",", + "\"bridgeSessionId\":\"{bridge_session_id}\"}}\n", + "{{\"type\":\"user\",\"sessionId\":\"{session_id}\"}}\n" + ), + bridge_session_id = bridge_session_id, + session_id = session_id, + ) + } + + #[test] + fn extracts_bridge_session_id_from_real_shaped_leading_records() { + let content = transcript("cse_bridge-123", "session-new"); + + assert_eq!( + extract_claude_bridge_session_id_from_reader(Ok(Cursor::new(content))), + Some(String::from("cse_bridge-123")) + ); + } + + #[test] + fn bridge_extraction_fails_open_for_missing_unreadable_or_malformed_records() { + let directory = unique_temp_dir("unreadable"); + let malformed = concat!( + r#"{"type":"bridge-session","sessionId":"session-1","bridgeSessionId":42}"#, + "\n" + ); + + assert_eq!( + extract_claude_bridge_session_id(Path::new("/does/not/exist.jsonl")), + None + ); + assert_eq!( + extract_claude_bridge_session_id_from_reader(Ok(Cursor::new(malformed))), + None + ); + assert_eq!(extract_claude_bridge_session_id(&directory), None); + + fs::remove_dir_all(directory).expect("temporary directory should be removed"); + } + + #[test] + fn bridge_extraction_does_not_scan_beyond_the_leading_record_bound() { + let mut content = String::new(); + for _ in 0..MAX_LEADING_RECORDS { + content.push_str("{\"type\":\"user\"}\n"); + } + content.push_str(&transcript("cse_too-late", "session-late")); + + assert_eq!( + extract_claude_bridge_session_id_from_reader(Ok(Cursor::new(content))), + None + ); + } + + #[test] + fn finds_the_most_recent_matching_sibling_and_excludes_the_source() { + let directory = unique_temp_dir("siblings"); + let source = directory.join("session-current.jsonl"); + let older = directory.join("session-older.jsonl"); + let newer = directory.join("session-newer.jsonl"); + let unrelated = directory.join("session-unrelated.jsonl"); + + fs::write(&older, transcript("cse_shared", "session-older")) + .expect("older transcript should be written"); + thread::sleep(Duration::from_millis(20)); + fs::write(&newer, transcript("cse_shared", "session-newer")) + .expect("newer transcript should be written"); + thread::sleep(Duration::from_millis(20)); + fs::write(&source, transcript("cse_shared", "session-current")) + .expect("source transcript should be written"); + fs::write(&unrelated, transcript("cse_other", "session-unrelated")) + .expect("unrelated transcript should be written"); + + assert_eq!( + find_claude_bridge_sibling_session_id(&source, "cse_shared"), + Some(String::from("session-newer")) + ); + assert_eq!( + find_claude_bridge_sibling_session_id(&source, "cse_missing"), + None + ); + + fs::remove_dir_all(directory).expect("temporary directory should be removed"); + } + + #[test] + fn sibling_discovery_fails_open_for_invalid_source_and_empty_bridge_id() { + let directory = unique_temp_dir("invalid"); + let source = directory.join("session-current.jsonl"); + fs::write(&source, transcript("cse_shared", "session-current")) + .expect("source transcript should be written"); + + assert_eq!(find_claude_bridge_sibling_session_id(&source, " "), None); + assert_eq!( + find_claude_bridge_sibling_session_id(&directory.join("missing.jsonl"), "cse_shared"), + Some(String::from("session-current")) + ); + + fs::remove_dir_all(directory).expect("temporary directory should be removed"); + } +} diff --git a/cli/src/services/hooks/claude_model_state.rs b/cli/src/services/hooks/claude_model_state.rs index dc446240..7dac8a9a 100644 --- a/cli/src/services/hooks/claude_model_state.rs +++ b/cli/src/services/hooks/claude_model_state.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; use serde_json::Value; @@ -16,8 +16,15 @@ 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_READ_FAILED_EVENT: &str = "sce.hooks.claude_model_state.agent_trace_db_read_failed"; const DB_WRITE_FAILED_EVENT: &str = "sce.hooks.claude_model_state.agent_trace_db_write_failed"; +struct BridgeInheritanceCandidate { + session: String, + agent: String, + sibling_session: String, +} + pub(super) fn run_claude_model_state_subcommand( repository_root: &Path, logger: Option<&dyn Logger>, @@ -122,9 +129,21 @@ where return String::new(); } }; - let Some(observation) = observation else { - return String::new(); + + let bridge_candidate = if observation.is_none() { + match bridge_inheritance_candidate(stdin_payload) { + Ok(candidate) => candidate, + Err(error) => { + log_fail_open(logger, ERROR_EVENT, &error, session_id.as_deref()); + return String::new(); + } + } + } else { + None }; + if observation.is_none() && bridge_candidate.is_none() { + return String::new(); + } let db = match open_db( repository_root, @@ -136,12 +155,49 @@ where logger, DB_OPEN_FAILED_EVENT, &error, - Some(&observation.session_id), + observation + .as_ref() + .map(|observation| observation.session_id.as_str()) + .or(session_id.as_deref()), ); return String::new(); } }; + let observation = if let Some(observation) = observation { + observation + } else { + let candidate = bridge_candidate + .expect("bridge candidate must exist when no direct observation exists"); + let sibling_state = match db.claude_model_state_by_session_and_agent( + &prefixed_diff_trace_session_id(CLAUDE_TOOL_NAME, &candidate.sibling_session), + "", + ) { + Ok(sibling_state) => sibling_state, + Err(error) => { + log_fail_open( + logger, + DB_READ_FAILED_EVENT, + &error, + Some(&candidate.session), + ); + return String::new(); + } + }; + let Some(sibling_state) = sibling_state else { + return String::new(); + }; + + ClaudeModelStateObservation { + session_id: candidate.session, + agent_id: candidate.agent, + model_id: sibling_state.model_id, + observation_kind: ObservationKind::SessionStart, + source: String::from("bridge_inherited"), + observed_at_ms, + } + }; + if let Err(error) = persist_claude_model_state(&db, observation) { log_fail_open(logger, DB_WRITE_FAILED_EVENT, &error, session_id.as_deref()); } @@ -149,6 +205,57 @@ where String::new() } +fn bridge_inheritance_candidate(stdin_payload: &str) -> 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.") + })?; + + if required_non_empty_string(payload, "hook_event_name")?.as_str() != SESSION_START_EVENT { + return Ok(None); + } + if optional_model_id(payload, "model")?.is_some() { + return Ok(None); + } + + // Keep the same required lifecycle fields as the ordinary SessionStart path. + required_non_empty_string(payload, "source")?; + 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)?; + let Some(transcript_path) = payload + .get("transcript_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + else { + return Ok(None); + }; + let Some(bridge_session_id) = + super::claude_bridge_session::extract_claude_bridge_session_id(&transcript_path) + else { + return Ok(None); + }; + let Some(sibling_session_id) = + super::claude_bridge_session::find_claude_bridge_sibling_session_id( + &transcript_path, + &bridge_session_id, + ) + else { + return Ok(None); + }; + + Ok(Some(BridgeInheritanceCandidate { + session: session_id, + agent: agent_id, + sibling_session: sibling_session_id, + })) +} + fn persist_claude_model_state( db: &RepositoryAgentTraceDb, observation: ClaudeModelStateObservation, diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index c8c48337..fc820910 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -38,6 +38,7 @@ use crate::services::structured_patch::{ ClaudeStructuredPatchDerivationResult, PatchBuildResult, }; use crate::services::sync::auto_sync; +pub mod claude_bridge_session; pub mod claude_model_state; pub mod claude_transcript; pub mod codex; @@ -3073,6 +3074,114 @@ mod tests { fs::remove_dir_all(state_root).expect("test state should be removed"); } + #[test] + fn claude_model_attribution_bridge_inheritance_seeds_state_and_diff_trace() { + let repo_root = init_attribution_git_repo("bridge-inheritance"); + let state_root = unique_attribution_db_path("bridge-inheritance-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 sibling_transcript = state_root.join("session-old.jsonl"); + let current_transcript = state_root.join("session-current.jsonl"); + fs::write( + &sibling_transcript, + concat!( + r#"{"type":"file-history-snapshot"}"#, + "\n", + r#"{"type":"bridge-session","sessionId":"session-old","bridgeSessionId":"cse_shared"}"#, + "\n", + ), + ) + .expect("sibling transcript fixture should be written"); + fs::write( + ¤t_transcript, + concat!( + r#"{"type":"file-history-snapshot"}"#, + "\n", + r#"{"type":"bridge-session","sessionId":"session-current","bridgeSessionId":"cse_shared"}"#, + "\n", + ), + ) + .expect("current transcript fixture should be written"); + + let db = open_agent_trace_db_for_hook_runtime_at_state_root( + &repo_root, + &state_root, + "test DB should open before bridge inheritance", + ) + .expect("test DB should open before bridge inheritance"); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-old"), + agent_id: String::new(), + model_id: String::from("claude/inherited-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 5, + }) + .expect("sibling state should be seeded"); + drop(db); + + let session_start = json!({ + "hook_event_name": "SessionStart", + "session_id": "session-current", + "source": "clear", + "transcript_path": current_transcript, + }); + 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 bridge inheritance", + ) + .expect("test DB should open after bridge inheritance"); + let inherited = db + .claude_model_state_by_session_and_agent("cc_session-current", "") + .expect("inherited state lookup should succeed") + .expect("current session should inherit sibling state"); + assert_eq!(inherited.model_id, "claude/inherited-model"); + assert_eq!(inherited.source, "bridge_inherited"); + assert_eq!(inherited.observation_kind, ObservationKind::SessionStart); + assert_eq!(inherited.observed_at_ms, 10); + + let diff_event = model_less_claude_diff_event("session-current", "tool-inherited", None); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&diff_event), + ) + .expect("inherited state should attribute the diff trace"); + assert_eq!( + persisted_model_ids(&db), + vec![Some(String::from("claude/inherited-model"))] + ); + + drop(db); + fs::remove_file(sibling_transcript).expect("sibling transcript should be removed"); + fs::remove_file(current_transcript).expect("current transcript 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"); diff --git a/context/context-map.md b/context/context-map.md index 253f11ac..159eea33 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -69,7 +69,7 @@ Feature/domain context: - `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 `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/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 with bounded bridge-session inheritance for model-less SessionStart events, `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) @@ -98,6 +98,7 @@ Supporting repo docs: Recent decision records: +- `context/decisions/2026-09-08-claude-bridge-session-model-inheritance.md` (accepts bounded, local-only inheritance of Claude model state across bridge-linked model-less `SessionStart` events, with fail-open behavior and no generic or exported session cache) - `context/decisions/2026-09-01-remove-top-level-config-timeout.md` (removes the unused top-level config timeout key, environment override, and config-command flags without introducing a replacement global timeout; nested retry and unrelated runtime timeout paths remain active) - `context/decisions/2026-08-23-codex-canonical-worktree-path-resolution.md` (accepts upstream-compatible Codex apply_patch parent/absolute paths only when canonical resolution remains inside the Git worktree, validates nearest existing prefixes for missing targets, and rejects symlink escapes) - `context/decisions/2026-08-23-codex-nondestructive-hook-ownership.md` (uses one shared structural ownership predicate and merge service for setup/doctor: Codex SCE handlers require the generated helper path plus the `sce hooks codex` contract, while unrelated hook configuration survives) diff --git a/context/decisions/2026-09-08-claude-bridge-session-model-inheritance.md b/context/decisions/2026-09-08-claude-bridge-session-model-inheritance.md new file mode 100644 index 00000000..bcc08df5 --- /dev/null +++ b/context/decisions/2026-09-08-claude-bridge-session-model-inheritance.md @@ -0,0 +1,109 @@ +# Decision: Inherit Claude model state across bridge-linked sessions + +Date: 2026-09-08 +Status: Accepted +Plan: `context/plans/claude-clear-session-model-inheritance.md` +Task: T01 + +## Context + +Real Claude Code hook traffic confirmed that `/clear` starts a new session with +a new `session_id`, while its `SessionStart` payload omits `model`. The captured +model-less `/clear` payload still included `transcript_path`. A recursive scan +of the captured hook payloads also confirmed that `bridgeSessionId` is absent +from the hook payload shape; it is available in the transcript instead. + +The leading bridge-session record in each inspected transcript contained a +`bridgeSessionId`. Real sibling transcript pairs across `/clear` boundaries +were confirmed to share that identifier, including a model-bearing startup +session and a later model-less clear session. The sibling session's existing +`claude_model_state` therefore provides a local, best-effort source for seeding +the new session when the lifecycle payload has no model. + +## Decision + +When a model-less `SessionStart` has a readable `transcript_path`, inspect only +the leading transcript records for its `bridgeSessionId`. In the same Claude +project directory, inspect the leading records of sibling `.jsonl` transcripts +and select the most recently modified other transcript sharing that bridge ID. +When that sibling has an existing exact-scope `claude_model_state` row, seed the +current session with the sibling's model using +`source="bridge_inherited"` and the existing `SessionStart` observation kind. + +Bridge discovery is local-only, bounded, and fail-open. A missing or unreadable +transcript, absent or malformed bridge record, missing matching sibling, or +missing sibling state leaves the existing silent no-op behavior unchanged. +`bridgeSessionId` is used only for transient discovery and is not persisted. + +## Rationale + +This addresses the deterministic `/clear` shape that otherwise leaves the new +session without a model-state seed, while preserving the existing state table, +exact-scope lookup, and write path. It uses the transcript signal Claude +actually emits without depending on network access, a full transcript scan, or +a generic cross-editor session cache. + +The most recently modified matching sibling is a deterministic local choice, +but it is not proof of Claude's causal session order. A clear followed by a +model switch before the first tool call can consequently inherit the previous +model. This changes some failures from unknown to plausibly attributed and is +accepted as a documented best-effort trade-off. + +## Alternatives considered + +- **Keep the model-less `SessionStart` as a silent no-op** — rejected because + real `/clear` sessions then remain unattributed for their entire lifetime + unless a later `PostModelSwitch` supplies state. +- **Scan the complete transcript or wait for transcript convergence** — + rejected because it violates the bounded, minimal-work, fail-open hook + boundary and still cannot establish causal ordering. +- **Persist `bridgeSessionId` or restore a generic session cache** — rejected + because the correlation is Claude-specific and local, and broadening the + shared persistence/export model is unnecessary. + +## Compatibility and risks + +- The fallback applies to model-less `SessionStart` events regardless of their + source; narrowing it to `source="clear"` would leave other model-less shapes + uncovered without a stated benefit. +- The sibling session ID is taken from the transcript filename stem, matching + the existing session/transcript naming convention. +- Filesystem races, malformed records, and database read failures remain + fail-open and preserve the existing no-op contract. +- The fallback cannot guarantee correctness when a user clears and switches + models before any tool call; no upstream ordering signal is available. + +## Guardrails + +- Keep the mechanism Claude-specific and local-only. +- Do not restore `session_models` or introduce a generic cross-editor + session-level attribution abstraction. +- Do not persist, export, synchronize, or expose `bridgeSessionId` or + `claude_model_state` through the control plane. +- Preserve exact `(session_id, agent_id)` state scoping and do not alter the + existing direct > exact transcript > exact state > `NULL` attribution + precedence. +- Do not change `PostModelSwitch`, which already carries the model needed for + its own observation. + +These guardrails remain consistent with the accepted +`2026-09-01-claude-model-attribution-state` decision. + +## Consequences + +New Claude sessions created by `/clear` can receive a local model-state seed +before their first tool call, improving diff-trace attribution without a schema +or export change. Some sessions may receive a stale-but-plausible previous +model when a model switch races the first tool call. Existing failure branches +remain silent, zero-stdout, and non-fatal. + +## Follow-up + +T02 implements bounded bridge-session discovery. T03 wires inheritance into the +model-less `SessionStart` path and adds persisted-row attribution regression +coverage. + +## References + +- Plan: [`claude-clear-session-model-inheritance`](../plans/claude-clear-session-model-inheritance.md) +- Existing model-state decision: [`Claude latest model state`](2026-09-01-claude-model-attribution-state.md) diff --git a/context/glossary.md b/context/glossary.md index e0827df9..c7454315 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -84,6 +84,7 @@ - `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. - `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. +- `bridge session correlation`: Claude-specific local correlation using the `bridgeSessionId` in leading transcript records to relate a model-less `SessionStart` transcript to the most recently modified sibling transcript sharing that ID. The accepted inheritance design is bounded and fail-open, uses an existing exact-scope `claude_model_state` row without persisting the bridge ID, and does not claim authoritative session ordering. See [the bridge-session inheritance decision](decisions/2026-09-08-claude-bridge-session-model-inheritance.md). - `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`. @@ -208,7 +209,6 @@ - `sce release authority contract`: Approved release topology where repo-root `.version` is the canonical checked-in release version source, GitHub Releases are the canonical publication surface for signed release artifacts, and Cargo/npm registry publication are separate downstream publish stages that consume already-versioned checked-in package metadata without workflow-side version bumping. - `sce release-npm-package app`: Root-flake app exposed as `nix run .#release-npm-package`; stages the checked-in npm package, rewrites the requested version, runs `npm pack`, and emits `sce-v-npm.tgz` plus `sce-v-npm.json` for release publication. - `sce release-flatpak-package app`: Linux-only root-flake app exposed as `nix run .#release-flatpak-package -- --version --out-dir `; runs the Nix-built `flatpak-version-parity-check` script (parity across `.version`, `cli/Cargo.toml`, `npm/package.json`, and Flatpak AppStream release metadata), requires a resolvable git release commit, stages `packaging/flatpak/` manifest/support files without mutating checked-in sources, uses the Nix manifest expression's commit-pinned flavor to emit the staged manifest, and produces deterministic Flatpak source-manifest tarball/checksum/JSON metadata. - - `sce release-flatpak-bundle app`: Linux-only root-flake app exposed as `nix run .#release-flatpak-bundle -- --version --arch --out-dir `; runs the Nix-built `flatpak-version-parity-check` script, uses the Nix manifest expression's local-checkout-override flavor to produce a Flatpak `type: dir` manifest, runs imperative `flatpak-builder --force-clean --arch=` plus `flatpak build-bundle`, and produces SHA-256 checksum and JSON metadata (`asset_type: flatpak-bundle`); used by `.github/workflows/release-sce-linux.yml` (x86_64) and `.github/workflows/release-sce-linux-arm.yml` (aarch64). - `sce split platform release workflows`: CLI release automation topology where `.github/workflows/release-sce.yml` orchestrates reusable per-platform workflow files; the current reusable workflow set and active orchestrated release matrix are `release-sce-linux.yml`, `release-sce-linux-arm.yml`, and `release-sce-macos-arm.yml`, producing the current automated release target set `x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl`, and `aarch64-apple-darwin`. Each native reusable workflow validates the generated archive before native artifact upload by extracting it, smoke-running `bin/sce version --format json`, and invoking the native portability audit for the lane platform. - `publish-crates workflow`: Dedicated crates.io publish automation in `.github/workflows/publish-crates.yml` that runs after a GitHub release is published (or by manual dispatch), validates `.version`, `cli/Cargo.toml`, and the requested release tag remain aligned, supports a dry-run validation path, and requires `CARGO_REGISTRY_TOKEN` for real publication. @@ -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`. -- `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). +- `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 best-effort seeds a model-less `SessionStart` from a bridge-linked sibling transcript using bounded leading-record reads, an exact main-session state lookup, and `source="bridge_inherited"`. Missing discovery/state and all other intake, clock, DB-open, DB-read, or DB-write failures remain fail-open with empty stdout 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/patterns.md b/context/patterns.md index 998e6190..52c5aa89 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -164,7 +164,7 @@ - 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 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 `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. For a model-less `SessionStart`, use only the event's `transcript_path` and bounded leading-record reads to find the most recently modified sibling transcript sharing its `bridgeSessionId`, then perform one exact main-session state lookup and seed the current session with `source="bridge_inherited"` when available; every discovery or state failure remains the existing no-op. Write directly through the no-migration repository hook path before the process exits. 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-clear-session-model-inheritance.md b/context/plans/claude-clear-session-model-inheritance.md index 7b96e222..0aa83cd9 100644 --- a/context/plans/claude-clear-session-model-inheritance.md +++ b/context/plans/claude-clear-session-model-inheritance.md @@ -123,29 +123,29 @@ 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. -- [ ] AC1: A `SessionStart` event with no `model` field, whose `transcript_path` +- [x] AC1: A `SessionStart` event with no `model` field, whose `transcript_path` file's leading records carry a `bridgeSessionId` that a sibling transcript in the same directory also carries, and whose sibling already has a `claude_model_state` row, causes the new session to persist a `claude_model_state` row with the sibling's model and `source="bridge_inherited"`. - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_state`. -- [ ] AC2: When `transcript_path` is missing/unreadable, the bridge record is +- [x] AC2: When `transcript_path` is missing/unreadable, the bridge record is absent or malformed, no sibling shares the bridge id, or the sibling has no recorded state, the handler behaves exactly as today: silent no-op, zero stdout, no DB write, and existing state (if any) is never cleared or overwritten. Every branch fails open. - Validate: focused tests covering each failure branch under the same test command as AC1. -- [ ] AC3: Bridge discovery reads only the leading records of each candidate +- [x] AC3: Bridge discovery reads only the leading records of each candidate transcript (never a full-file scan), performs no network access, and leaves `PostModelSwitch` handling and the existing diff-trace precedence (`direct > exact transcript > exact state > NULL`) unchanged. - Validate: inspect the discovery helper for a bounded read; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model` and `claude_model_attribution` pass unchanged alongside new coverage. -- [ ] AC4: A diff-trace event in a session that inherited its model this way, with +- [x] AC4: A diff-trace event in a session that inherited its model this way, with no direct model and no winning transcript match, resolves `diff_traces.model_id` from the inherited state exactly as it would from a normal `SessionStart.model` seed. - Validate: persisted-row regression under `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_attribution`. -- [ ] AC5: A decision record documents the production evidence (real captured +- [x] AC5: A decision record documents the production evidence (real captured `/clear` `SessionStart` payloads confirmed to omit `model`; confirmed absence of `bridgeSessionId` in any captured hook payload shape; confirmed presence of `bridgeSessionId` in the transcript's bridge-session record; confirmed @@ -225,7 +225,7 @@ Persist this field in every plan; this is durable plan state, not chat state: ## Task stack -- [ ] T01: `Record the bridge-session model-inheritance decision` (status:todo) +- [x] T01: `Record the bridge-session model-inheritance decision` (status:done) - Task ID: T01 - Scope: In — write `context/decisions/{date}-claude-bridge-session-model-inheritance.md` covering the production evidence, mechanism, best-effort caveat, and guardrail @@ -236,9 +236,28 @@ Persist this field in every plan; this is durable plan state, not chat state: - Done when: the decision file exists in ADR format and contains every element AC5 names; no other file changes. - Verify: inspect the file against AC5. - - Context synchronization: pending - -- [ ] T02: `Add bounded bridge-session discovery helper` (status:todo) + - Completed: 2026-09-08 + - Files changed: `context/decisions/2026-09-08-claude-bridge-session-model-inheritance.md` + - Result: Added the accepted decision for bounded, local-only inheritance of + Claude model state across bridge-linked model-less SessionStart events, + documenting production evidence, best-effort ordering caveats, and the + existing Claude-specific attribution guardrails. + - Verify: ADR inspection passed against AC5: the file records the real + model-less `/clear` payloads, absence of `bridgeSessionId` in hook payloads, + transcript bridge records and sibling pairing, the discovery mechanism, + bounded/fail-open semantics, best-effort/no-ordering-guarantee caveat, and + compliance with the 2026-09-01 Claude model-state decision's local-only, + non-exported, non-generic guardrails. Baseline-relative comparison found + only the new decision file changed before this plan record. + - Done checks: All satisfied — the ADR exists in repository format, contains + every AC5 element, and no implementation or unrelated context file changed. + - Context impact: cross-cutting decision — establishes the bounded + Claude-specific bridge-inheritance exception and its guardrails; context + synchronization must reconcile the decision and inspect the mandatory root + context files before another task starts. + - Context synchronization: synced + +- [x] T02: `Add bounded bridge-session discovery helper` (status:done) - Task ID: T02 - Scope: In — new `cli/src/services/hooks/claude_bridge_session.rs` with two fail-open functions: (a) extract `bridgeSessionId` from a transcript path's @@ -254,9 +273,25 @@ Persist this field in every plan; this is durable plan state, not chat state: missing/malformed bridge record, and no matching sibling; and its reads are bounded, not full-file scans. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_bridge_session`. - - Context synchronization: pending - -- [ ] T03: `Wire bridge inheritance into SessionStart and prove end-to-end attribution` (status:todo) + - Completed: 2026-09-08 + - Files changed: `cli/src/services/hooks/claude_bridge_session.rs`, + `cli/src/services/hooks/mod.rs` + - Result: Added bounded, fail-open bridge-session extraction and sibling + discovery for Claude JSONL transcripts, selecting the most recently modified + matching sibling and returning its filename-derived session ID without DB or + network access. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_bridge_session` passed: 5 tests passed, 0 failed. + - Done checks: All satisfied — real-shaped leading records resolve the bridge + ID and newest matching sibling; missing, unreadable, malformed, empty, and + unmatched cases fail open; and a regression proves records beyond the bounded + leading-record limit are not scanned. + - Context impact: cross-cutting implementation boundary — adds the + Claude-specific bounded bridge discovery module that T03 will call from the + model-less `SessionStart` path; context synchronization must reconcile the + new helper and inspect the mandatory root context files before T03 starts. + - Context synchronization: synced + +- [x] T03: `Wire bridge inheritance into SessionStart and prove end-to-end attribution` (status:done) - Task ID: T03 - Scope: In — in `claude_model_state.rs`, when parsing yields no observation for a model-less `SessionStart`, invoke T02's helper against the event's own @@ -272,12 +307,30 @@ Persist this field in every plan; this is durable plan state, not chat state: regression proving a diff-trace event in the newly-seeded session resolves `model_id` from the inherited state. Out — schema/migration changes, `PostModelSwitch` changes, export/sync changes. - - Dependencies: T02 - - Done when: AC1, AC2, AC3, and AC4 all hold, and the existing - `claude_model_state`, `claude_model`, and `claude_model_attribution` suites - pass unchanged alongside the new coverage. - - 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_attribution`; `nix flake check`. - - Context synchronization: pending + - Dependencies: T02 + - Done when: AC1, AC2, AC3, and AC4 all hold, and the existing + `claude_model_state`, `claude_model`, and `claude_model_attribution` suites + pass unchanged alongside the new coverage. + - 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_attribution`; `nix flake check`. + - Completed: 2026-09-08 + - Files changed: `cli/src/services/hooks/claude_model_state.rs`, + `cli/src/services/hooks/mod.rs` + - Result: Wired model-less Claude `SessionStart` events through bounded + bridge-session discovery, exact main-session state lookup, and the existing + guarded persistence path with `source="bridge_inherited"`; removed no + remaining diagnostic breadcrumbs and added persisted-row diff-trace + attribution coverage. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_state` passed: 16 tests passed, 0 failed; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_attribution` passed: 3 tests passed, 0 failed; `nix flake check` passed: all checks passed. The additional `claude_model` filter passed: 22 tests passed, 0 failed. + - Done checks: All satisfied — AC1 is proven by bridge-linked sibling state + inheritance with the expected source and observation kind; AC2 remains + fail-open for missing discovery/state and preserves empty stdout; AC3 is + covered by T02's bounded helper and unchanged model/precedence suites; and + AC4 is proven by the persisted inherited-state diff-trace regression. + - Context impact: cross-cutting implementation boundary — changes Claude + model-state lifecycle behavior and its attribution handoff; context + synchronization must reconcile the fallback and inspect the mandatory root + context files before another task or final validation. + - Context synchronization: synced ## Open questions @@ -289,3 +342,33 @@ baseline is 100% of `/clear` sessions unattributed, so trading silence for but it changes the failure mode from "we don't know" to "we have a plausible but sometimes-wrong answer," which is a different kind of wrong worth deciding on deliberately rather than assuming away. + +## Validation Report + +**Status:** validated +**Date:** 2026-09-08 + +### Commands run + +- `nix run .#pkl-check-generated` -> exit 0 (ephemeral Pkl generation passed: 141 files) +- `nix flake check` -> exit 0 (all checks passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_bridge_session` -> exit 0 (5 passed, 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_state` -> exit 0 (16 passed, 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model` -> exit 0 (22 passed, 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_attribution` -> exit 0 (3 passed, 0 failed) + +### Success-criteria verification + +- [x] AC1: Model-less `SessionStart` inherits the sibling model and persists `source="bridge_inherited"` -> persisted-row regression passed in `claude_model_attribution_bridge_inheritance_seeds_state_and_diff_trace`. +- [x] AC2: Discovery and state-missing/error branches fail open without output or destructive state changes -> focused model-state and bridge-session failure-path tests passed; implementation inspection confirmed missing/unreadable/malformed/unmatched inputs and missing sibling state return without writes. +- [x] AC3: Discovery is bounded/local-only and attribution precedence plus existing model suites remain unchanged -> bounded-reader regression passed, helper uses `take(MAX_LEADING_RECORDS)`, and `claude_bridge_session`, `claude_model`, and `claude_model_attribution` suites passed. +- [x] AC4: Inherited state supplies diff-trace model attribution -> persisted-row regression passed with `diff_traces.model_id=claude/inherited-model`. +- [x] AC5: Required production evidence, mechanism, caveat, and guardrails are documented -> inspected `context/decisions/2026-09-08-claude-bridge-session-model-inheritance.md`. + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- Bridge inheritance remains best-effort and may inherit a stale model if a model switch races the first tool call. diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 13e33cf3..71f9ea10 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -115,7 +115,7 @@ - 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"`. -- `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. +- `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. A model-less `SessionStart` first attempts bounded, local-only bridge-session correlation through the event's `transcript_path`: it reads only leading records, selects the most recently modified sibling `.jsonl` transcript sharing the `bridgeSessionId`, performs one exact main-session state lookup for that sibling, and seeds the current session with the sibling model as `source="bridge_inherited"` when state exists. Missing or malformed discovery inputs, missing sibling state, filesystem races, and DB reads fail open to the existing no-op. The command uses the existing guarded latest-locally-observed register and local SCE observation time. 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, DB-read, and DB-write failures. Claude's SessionStart invocation is synchronous relative to Claude execution, while PostModelSwitch is asynchronous; overlapping hooks and bridge/model-switch races 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.