From b01a863b5a7d8a6900c57edd7ec55c00d76d89c7 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sat, 5 Sep 2026 00:13:13 +0200 Subject: [PATCH 1/7] 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/7] 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. From cfe68224e9904cb0d98959e126b86bf4fff6e3e4 Mon Sep 17 00:00:00 2001 From: David Abram Date: Thu, 10 Sep 2026 10:32:17 +0200 Subject: [PATCH 3/7] context: Record Claude diff-trace bridge-seeding decision Document why lazy transcript creation makes SessionStart inheritance inert and move the accepted seeding design to the diff-trace state-miss path. Record newest-observation selection, amended attribution precedence, and the follow-up task sequence. Plan: claude-clear-session-model-inheritance Task: T04 Co-authored-by: SCE --- context/context-map.md | 1 + ...-10-claude-bridge-seeding-on-diff-trace.md | 198 +++++++++++++ .../claude-clear-session-model-inheritance.md | 260 +++++++++++++++++- 3 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 context/decisions/2026-09-10-claude-bridge-seeding-on-diff-trace.md diff --git a/context/context-map.md b/context/context-map.md index 159eea33..4d5acdc5 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -98,6 +98,7 @@ Supporting repo docs: Recent decision records: +- `context/decisions/2026-09-10-claude-bridge-seeding-on-diff-trace.md` (moves bridge-derived Claude model-state seeding from the model-less `SessionStart` path — structurally unable to read its own lazily created transcript — to the diff-trace state miss, amends attribution precedence to `direct > exact transcript > exact state > bridge-derived chain state > NULL`, accepts one exact-scope state write on that resolution path, and selects the newest chain observation by `observed_at_ms` rather than the chain origin or the newest sibling transcript; supersedes the trigger point and selection rule of the 2026-09-08 decision) - `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) diff --git a/context/decisions/2026-09-10-claude-bridge-seeding-on-diff-trace.md b/context/decisions/2026-09-10-claude-bridge-seeding-on-diff-trace.md new file mode 100644 index 00000000..9c6259cf --- /dev/null +++ b/context/decisions/2026-09-10-claude-bridge-seeding-on-diff-trace.md @@ -0,0 +1,198 @@ +# Decision: Seed Claude model state from the bridge chain on the diff-trace read path + +Date: 2026-09-10 +Status: Accepted +Plan: `context/plans/claude-clear-session-model-inheritance.md` +Task: T04 + +## Context + +The `2026-09-08-claude-bridge-session-model-inheritance` decision placed +bridge-linked model inheritance on the model-less `SessionStart` path. That +mechanism shipped, was validated by unit and persisted-row regressions, and is +inert in production: no `claude_model_state` row has ever been written with +`source="bridge_inherited"` (live repository database: `picker|3`, `startup|6`, +nine rows across two sources). + +The cause is not the discovery logic. The deployed binary contains the feature, +the hook is registered for all `SessionStart` sources with no matcher, every real +bridge record sits at transcript line 2–3 well inside `MAX_LEADING_RECORDS = 16`, +real siblings share `bridgeSessionId` correctly, and each observed `/clear` +session had a bridge-linked sibling holding state. + +**Claude creates a session's `.jsonl` transcript lazily, after the `SessionStart` +hook has already run and exited.** Measured on one real session: `2a56e9bc` wrote +its `claude_model_state` row at 07:56:03Z, and its transcript file did not exist +until 07:56:16Z — 13 seconds later, on the same code path. `transcript_path` is +present on the payload exactly as the prior decision recorded, but it names a file +that does not exist yet. `File::open` returns `ENOENT`, bridge-session extraction +fails open to `None`, and the inheritance candidate resolves to `Ok(None)` before +any sibling scan or database read. Unit tests pass because every fixture +pre-creates the transcript, and the prior acceptance criteria explicitly bless the +missing-transcript branch as correct fail-open behavior — in production it is the +only branch ever taken. `SessionStart` is therefore structurally unable to read its +own transcript, and no amount of discovery hardening changes that. + +Two further findings from the same investigation shape the selection rule: + +- Transcript records past the header carry a snake_case `session_id` alongside the + camelCase `sessionId`, pointing at the chain's **origin** session. This was + confirmed across both observed chains and a fresh session, which points at + itself. The origin is nevertheless the wrong member to inherit from: for chain + member `2e1257fa` the origin `2a56e9bc` held sonnet-5, while the model actually + in force was opus-5, set by an intervening `PostModelSwitch`. +- Symmetrically, "most recently modified sibling transcript" — the prior + decision's rule — is only a proxy for "most recent model observation". It + happened to be correct in all three observed cases by coincidence of mtime + ordering, and nothing guarantees that ordering. + +The filesystem is the right authority for chain *membership*; the database is the +right authority for which member's model *wins*. + +## Decision + +Move bridge-derived model-state seeding from `SessionStart` to the diff-trace +persistence path, which already reads `claude_model_state` and already holds the +event's `transcript_path` at a point where the transcript is guaranteed to exist. + +When the existing exact-scope `claude_model_state` read misses for a raw +structured Claude diff-trace payload that carries a `transcript_path`: + +1. Resolve every bridge-linked chain member from the transcript's + `bridgeSessionId` using bounded leading-record reads of sibling `.jsonl` files + in the same Claude project directory, excluding self. +2. Perform one exact-scope `claude_model_state` read per chain member and select + the **newest observation by `observed_at_ms`**, with a deterministic tie-break. + Neither the chain origin nor the newest transcript file decides the winner. +3. Persist a `claude_model_state` row for the current session with + `source="bridge_inherited"` through the existing guarded write path, and use + that model for the trace in hand. + +Because the seed is written on the first miss, discovery runs at most once per +session; the second and later diff traces of that session resolve from the +session's own exact-scope state with no repeated discovery and no second write. + +This amends the Claude diff-trace attribution precedence to: + +`direct > exact transcript > exact state > bridge-derived chain state > NULL` + +A resolution path consequently **writes** state. That is a deliberate departure +from the previous read-only character of diff-trace attribution resolution: the +write is what bounds the cost of the new precedence tier to one discovery per +session, and it targets the same exact-scope `(cc_, agent_id)` row the +`SessionStart` path would have written. + +`SessionStart`'s bridge attempt is kept rather than removed. It costs one failed +`File::open` and begins working unchanged if Claude ever creates transcripts +eagerly. Both call sites use one shared selection rule; the superseded +mtime-newest single-sibling picker is removed so no second rule remains in the +code. + +Every step fails open. An absent `transcript_path`, a missing or unreadable +transcript, an absent or malformed bridge record, no other chain member, no member +state, and any database read or write failure each leave `diff_traces.model_id` as +it would have been, write no state row, keep the hook successful, and emit zero +stdout. `transcript_path` is carried to this resolution as an ephemeral, +non-serialized field: it is never written to `diff_traces`, any other column, or +any exported payload, and `bridgeSessionId` remains transient discovery input that +is never persisted. + +This supersedes the trigger point and the selection rule of the +`2026-09-08-claude-bridge-session-model-inheritance` decision. That decision's +evidence, its bounded and fail-open discovery contract, and its guardrails stand +unchanged and are not edited. + +## Rationale + +Attribution can only be inherited at a moment when the signal it depends on +exists. The measured 13-second transcript-creation lag makes `SessionStart` that +wrong moment by construction, while the diff-trace path is both guaranteed to have +the transcript and the exact point where a missing model actually costs something. + +Selecting the newest observation across chain members replaces two proxies — +chain origin, and sibling file mtime — with the quantity the attribution actually +wants: the most recent model Claude was observed using anywhere in this chain. The +origin proxy is demonstrably wrong (sonnet-5 vs. the in-force opus-5); the mtime +proxy is unfalsified but unprincipled. The marginal cost of the correct rule is one +exact-scope read per chain member on a path that runs once per session. + +## Alternatives considered + +- **Harden `SessionStart` discovery (retry, wait for the transcript, poll)** — + rejected. It cannot fix a lag measured in seconds without blocking the hook, + which violates the bounded, minimal-work hook boundary. +- **Keep seeding on `SessionStart` and accept the inert feature** — rejected. It + leaves every cleared session that edits a file silently unattributed, which is + the whole gap the plan targets. +- **Resolve without writing (pure read-path resolution per trace)** — rejected. + It repeats chain discovery and per-member state reads on every diff trace of the + session instead of once, for no correctness gain. +- **Inherit from the chain origin named by the transcript's snake_case + `session_id`** — rejected on direct evidence: the origin held sonnet-5 while the + model in force was opus-5. +- **Keep the mtime-newest sibling rule** — rejected as a proxy that is correct + only by coincidence, in favor of the observation the database already records. +- **Persist `bridgeSessionId`, export chain state, or restore a generic + cross-editor session cache** — rejected, unchanged from the `2026-09-01` and + `2026-09-08` decisions. + +## Compatibility and risks + +- Inheritance remains a probabilistic, best-effort guess with no upstream causal + ordering guarantee. A session that clears and switches models before its first + tool call inherits the previous model and is attributed to it instead of staying + `NULL`. The newest-observation rule narrows this window relative to the mtime + rule but does not close it. +- Attribution resolution now performs a write on one branch. It is exact-scope, + guarded by the existing write path, and bounded to one occurrence per session. +- Chain discovery cost scales with the number of bridge-linked members, one + bounded leading-record read and one exact-scope state read each, incurred once + per session on a state miss. +- No schema, migration, export, sync, or control-plane change. `PostModelSwitch` + is untouched; it always carries its own model. +- Non-Claude producers (OpenCode, Pi, Codex) are unaffected: the ephemeral + `transcript_path` field stays `None` and no new branch is reachable for them. + +## Guardrails + +- Keep the mechanism Claude-specific and local-only: bounded leading-record reads, + no full-transcript scan, no network access. +- Do not restore `session_models` or introduce a generic cross-editor + session-level attribution abstraction. +- Do not persist, export, synchronize, or expose `bridgeSessionId`, + `transcript_path`, or `claude_model_state` through the control plane. +- Preserve exact `(session_id, agent_id)` state scoping; subagent isolation is + unchanged. +- Keep every branch fail-open with zero stdout, hook success, and no exit 2. +- Maintain exactly one chain-selection rule shared by both call sites. +- Do not change `PostModelSwitch`. + +These remain consistent with the `2026-09-01-claude-model-attribution-state` and +`2026-09-08-claude-bridge-session-model-inheritance` decisions; this record amends +only the attribution precedence, the trigger point, and the selection rule those +decisions established, and adds the read-path write as an explicitly accepted +exception to their otherwise read-only resolution contract. + +## Consequences + +Cleared Claude sessions receive model attribution at the moment they first produce +a diff trace, which is the first moment attribution matters and the first moment +the transcript reliably exists. Attribution precedence gains a documented +bridge-derived tier below exact state, and diff-trace resolution gains a single, +bounded, exact-scope state write. Some sessions may still be attributed a +stale-but-plausible previous model. All existing failure branches remain silent, +zero-stdout, and non-fatal. + +## Follow-up + +T05 carries `transcript_path` through `DiffTracePayload` as an ephemeral field. +T06 returns all bridge-linked chain members from discovery. T07 adds the shared +newest-chain-observation resolver and wires seeding into the diff-trace state +miss. T08 points `SessionStart` at the shared selection and removes the superseded +mtime-newest picker. + +## References + +- Plan: [`claude-clear-session-model-inheritance`](../plans/claude-clear-session-model-inheritance.md) +- Prior bridge-inheritance decision: [`Inherit Claude model state across bridge-linked sessions`](2026-09-08-claude-bridge-session-model-inheritance.md) +- Existing model-state decision: [`Claude latest model state`](2026-09-01-claude-model-attribution-state.md) diff --git a/context/plans/claude-clear-session-model-inheritance.md b/context/plans/claude-clear-session-model-inheritance.md index 0aa83cd9..bb8b57db 100644 --- a/context/plans/claude-clear-session-model-inheritance.md +++ b/context/plans/claude-clear-session-model-inheritance.md @@ -27,6 +27,47 @@ its exact-scope read/write contract, and it does not touch `PostModelSwitch` correlated `bridgeSessionId`; this is new discovery logic, not an extension of an existing helper. +### Second phase (2026-09-10): the shipped inheritance never fires + +T01–T03 shipped and validated, and production shows the feature is inert. Zero +`claude_model_state` rows have ever been written with `source="bridge_inherited"` +(live repository DB: `picker|3`, `startup|6`, nine rows, two sources). The cause is +not the discovery logic: the deployed Nix binary contains the feature, the hook is +registered for all `SessionStart` sources with no matcher, every real bridge record +sits at transcript line 2–3 well inside `MAX_LEADING_RECORDS = 16`, real siblings +share `bridgeSessionId` correctly, and for each of the three observed `/clear` +sessions a bridge-linked sibling with existing state was available. + +**Claude creates a session's `.jsonl` transcript lazily, after the `SessionStart` +hook has already run and exited.** Measured: session `2a56e9bc` wrote its +`claude_model_state` row at 07:56:03Z and its transcript file was not created until +07:56:16Z — 13 seconds later, on the same code path. So `transcript_path` is present +on the payload exactly as T01's evidence recorded, but names a file that does not +exist yet; `File::open` returns `ENOENT`, `extract_claude_bridge_session_id` fails +open to `None`, and `bridge_inheritance_candidate` returns `Ok(None)` before the +sibling scan or any DB read. The unit tests pass because every fixture pre-creates +the transcript, and AC2 explicitly blesses the missing-transcript branch as correct +fail-open behavior — in production that branch is the only branch ever taken. + +This phase moves the inheritance to a point where the transcript is guaranteed to +exist: the diff-trace path, which already reads `claude_model_state` and already +holds the event's `transcript_path`. On a state miss for a raw structured Claude +payload it runs bridge discovery, seeds a `bridge_inherited` row for the current +session, and uses it for the trace in hand, so discovery happens at most once per +session. Two further findings shape the selection rule. First, transcript records +past the header carry a snake_case `session_id` alongside camelCase `sessionId`, +pointing at the chain's **origin** session (confirmed across both observed chains +and a fresh session, which points at itself) — but the origin is the wrong member to +inherit from: for `2e1257fa` the origin `2a56e9bc` was sonnet-5 while the model +actually in force was opus-5, set by an intervening `PostModelSwitch`. Second, and +symmetrically, "most recently modified sibling transcript" is only a proxy for +"most recent model observation"; it was correct in all three observed cases by +coincidence of mtime ordering. The correct target is the newest +`claude_model_state` observation across all chain members by `observed_at_ms` — the +filesystem supplies chain membership, the DB decides which member's model wins. +T01–T03 and their evidence stand unchanged; this phase changes the trigger point +and the selection rule. + ### 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 @@ -154,6 +195,46 @@ performs final validation. 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. +- [ ] AC6: A raw structured Claude `PostToolUse` diff-trace event in a session with + no `claude_model_state` row of its own, whose transcript is bridge-linked to chain + members that do have state, persists `diff_traces.model_id` from that chain and + writes a `claude_model_state` row for the current session with + `source="bridge_inherited"`. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_attribution`. +- [ ] AC7: Chain selection resolves the newest `claude_model_state` observation by + `observed_at_ms`, not the chain origin and not the newest transcript file: for a + chain whose root holds sonnet-5, whose mid-chain member holds opus-5 from a later + switch, and whose cleared member's mtime-newest sibling is the root, the resolved + model is opus-5. + - Validate: focused regression under the same test command as AC6. +- [ ] AC8: Every discovery and state branch fails open exactly as today — absent + `transcript_path`, missing or unreadable transcript, absent or malformed bridge + record, no other chain member, no member state, and DB read or write failure each + leave `diff_traces.model_id` as it would have been, write no state row, keep hook + success, and emit zero stdout. + - Validate: focused per-branch tests under the same test command as AC6. +- [ ] AC9: `transcript_path` carried for this resolution stays ephemeral: it is never + written to `diff_traces`, any other column, or any exported payload. + - Validate: parser regression asserting the field is absent from the stored row, in + the shape of the existing `claude_diff_trace_parser_keeps_agent_id_ephemeral_and_storage_free` test. +- [ ] AC10: The second and later diff traces of a seeded session resolve from the + session's own exact-scope state with no repeated bridge discovery and no second + state write. + - Validate: focused regression asserting one discovery and one state write across + two consecutive diff-trace events in one session. +- [ ] AC11: Discovery stays bounded and local-only — leading records only, no + full-transcript scan, no network — and one shared selection rule serves both the + `SessionStart` and diff-trace call sites, with no second rule left in the code. + - Validate: inspect the discovery and selection helpers for a bounded read and a + single selection implementation; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_bridge_session` and `claude_model` pass alongside new coverage. +- [ ] AC12: A decision record documents the amended attribution precedence + (`direct > exact transcript > exact state > bridge-derived chain state > NULL`), + that a resolution path now writes state, the measured transcript-creation race that + makes `SessionStart` unable to read its own transcript, the origin-vs-newest + selection evidence, and why all of it stays inside the local-only, + non-exported, Claude-specific guardrails of the `2026-09-01` and `2026-09-08` + decisions. + - Validate: inspect the decision file for each listed element. ### Full validation @@ -172,6 +253,21 @@ which criterion they map to. if its summary would otherwise describe `SessionStart` as unconditionally a no-op without a model. +Second phase: + +- `context/sce/agent-trace-hooks-command-routing.md` — correct the current claim that + model-less `SessionStart` inheritance seeds state (it cannot read its own + transcript); describe the diff-trace state-miss seeding path, the amended + `direct > exact transcript > exact state > bridge-derived chain state > NULL` + precedence, and the newest-chain-observation selection rule. +- `context/glossary.md` — update `bridge session correlation` (selection is the newest + chain observation, not the most recently modified sibling), `Claude diff-trace + attribution` (amended precedence and the read-path seeding write), and + `sce hooks claude-model-state` (its inheritance branch no longer carries the fix on + its own). +- `context/context-map.md` — refresh the `agent-trace-hooks-command-routing.md` and + decisions-index annotations for the amended precedence and the new decision record. + ## Task context synchronization lifecycle Persist this field in every plan; this is durable plan state, not chat state: @@ -187,7 +283,9 @@ Persist this field in every plan; this is durable plan state, not chat state: - **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. + tests; one new decision record; the listed context-sync files. Second phase adds + `cli/src/services/hooks/mod.rs` (the `DiffTracePayload` ephemeral field and the + diff-trace state-miss seeding path) and one further decision record. - **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 @@ -205,6 +303,19 @@ Persist this field in every plan; this is durable plan state, not chat state: 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. +- **Second-phase constraints:** the amended precedence appends one step and never + reorders the existing three; the diff-trace hook keeps its fail-open, zero-stdout, + no-exit-2 contract on every new branch; discovery stays bounded to leading records + with no network and no full-transcript scan; seeding writes only the exact + `(cc_, agent_id)` scope through the existing guarded write path, so + subagents still never inherit main-session state; only raw structured Claude + payloads qualify, matching the existing exact-state lookup restriction; no schema, + migration, export, or sync change; no new dependency. +- **Second-phase non-goal:** does not register a new Claude hook event, change + `.claude/settings.json`, or regenerate Pkl-owned settings — the diff-trace hook + already runs at the required moment; does not backfill the existing `NULL` + `diff_traces.model_id` rows; does not make `claude_model_state` exported or + readable by any other producer. ## Assumptions @@ -221,7 +332,25 @@ Persist this field in every plan; this is durable plan state, not chat state: 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. + session ordering. **Superseded by the second phase:** selection becomes the newest + `claude_model_state` observation across chain members by `observed_at_ms`, on the + evidence in *Second phase* above. + +Second phase: + +- Seeding lives in the diff-trace persistence flow beside the existing exact-state + read rather than inside a pure resolver helper, so the one write on that path stays + explicit and in a single place. +- `transcript_path` is threaded to persistence as a `#[serde(skip)]` field on + `DiffTracePayload`, mirroring how ephemeral `agent_id` is already carried and + populated at Claude structured parse time (`cli/src/services/hooks/mod.rs:995`). +- New coverage follows the existing `claude_bridge_session.rs` module's temp-directory + fixture precedent rather than `context/patterns.md`'s no-filesystem unit-test rule, + matching the surrounding module; the drift between that rule and the existing tests + is pre-existing and out of scope here. +- The `SessionStart` bridge attempt is kept rather than removed: it costs one failed + `File::open` and begins working unchanged if Claude ever creates transcripts + eagerly. ## Task stack @@ -332,6 +461,112 @@ Persist this field in every plan; this is durable plan state, not chat state: context files before another task or final validation. - Context synchronization: synced +- [x] T04: `Record the read-path bridge-seeding decision` (status:done) + - Task ID: T04 + - Scope: In — write `context/decisions/{date}-claude-bridge-seeding-on-diff-trace.md` + covering every element AC12 names: the measured transcript-creation race that makes + `SessionStart` structurally unable to read its own transcript, the amended + `direct > exact transcript > exact state > bridge-derived chain state > NULL` + precedence, the fact that a resolution path now writes state, the + origin-vs-mtime-vs-newest-observation selection evidence, and guardrail compliance + against the `2026-09-01` and `2026-09-08` decisions. Out — any code change, any + edit to the two existing decision records, any context-sync file edit. + - Dependencies: T03 + - Done when: the decision file exists in repository ADR format and contains every + element AC12 names; no other file changes. + - Verify: inspect the file against AC12. + - Completed: 2026-09-10 + - Files changed: `context/decisions/2026-09-10-claude-bridge-seeding-on-diff-trace.md` + - Result: Added the accepted decision moving bridge-derived Claude model-state + seeding from `SessionStart` to the diff-trace state-miss path, recording the + measured transcript-creation race, the amended attribution precedence, the + read-path state write, the newest-chain-observation selection rule with its + origin and mtime counter-evidence, and guardrail compliance with the + `2026-09-01` and `2026-09-08` decisions. + - Verify: ADR inspection passed against AC12: the file records the measured + 13-second transcript-creation lag that makes `SessionStart` structurally + unable to read its own transcript, the amended + `direct > exact transcript > exact state > bridge-derived chain state > NULL` + precedence, the explicit statement that a resolution path now writes state, + the origin-vs-mtime-vs-newest-observation selection evidence (chain origin + holding sonnet-5 against the in-force opus-5; mtime correct only by + coincidence), and compliance with the local-only, non-exported, + Claude-specific guardrails of the `2026-09-01` and `2026-09-08` decisions. + Baseline-relative comparison found only the new decision file changed before + this plan record. + - Done checks: All satisfied — the ADR exists in repository ADR format with the + established section structure, contains every AC12 element, and no + implementation or other context file changed. + - Context impact: cross-cutting decision — amends the Claude diff-trace + attribution precedence and accepts a state write on a resolution path; + context synchronization must reconcile the decision and inspect the mandatory + root context files before another task starts. + - Context synchronization: synced + +- [ ] T05: `Carry ephemeral transcript_path through DiffTracePayload` (status:todo) + - Task ID: T05 + - Scope: In — add a `#[serde(skip)]` `transcript_path: Option` field to + `DiffTracePayload` in `cli/src/services/hooks/mod.rs`, populate it at Claude + structured parse time from the raw event's `transcript_path` exactly as + `agent_id` is populated, leave it `None` for every non-Claude producer, and add + the ephemerality regression. Out — any use of the field in resolution or + persistence, any selection or discovery change. + - Dependencies: T04 + - Done when: the field is carried to the persistence boundary, absent from + `diff_traces` and every serialized payload, `None` for OpenCode/Pi/Codex inputs, + and AC9's regression passes with no behavior change to attribution. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_diff_trace`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_attribution`. + - Context synchronization: pending + +- [ ] T06: `Return all bridge-linked chain members from discovery` (status:todo) + - Task ID: T06 + - Scope: In — in `cli/src/services/hooks/claude_bridge_session.rs`, add a fail-open + function returning every sibling `.jsonl` session ID sharing the transcript's + `bridgeSessionId` (bounded leading-record reads, self excluded, deterministic + order), with tests for the multi-member, single-member, and no-member cases plus + the existing failure branches. Out — any selection-by-state logic, any DB access, + any call-site rewiring. + - Dependencies: T05 + - Done when: chain members are returned for real-shaped multi-member fixtures, + failure branches still return an empty result fail-open, and reads remain bounded + with a regression proving records past the leading-record limit are not scanned. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_bridge_session`. + - Context synchronization: pending + +- [ ] T07: `Seed and resolve Claude model state on the diff-trace state miss` (status:todo) + - Task ID: T07 + - Scope: In — add a shared newest-chain-observation resolver (chain members from + T06, one exact-scope state read per member, winner by greatest `observed_at_ms` + with a deterministic tie-break) and wire it into the diff-trace persistence flow + in `cli/src/services/hooks/mod.rs`: when the existing exact-scope state read + misses for a raw structured Claude payload that carries T05's `transcript_path`, + resolve the chain, persist a `claude_model_state` row for the current session with + `source="bridge_inherited"` through the existing guarded write path, and use that + model for the trace in hand. Every step falls through to today's `NULL` on + failure. Out — changing the `SessionStart` call site, removing the superseded + single-sibling picker, any schema or export change. + - Dependencies: T06 + - Done when: AC6, AC7, AC8, and AC10 all hold, and the existing + `claude_model_state`, `claude_model`, `claude_bridge_session`, 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_attribution`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model`; `nix flake check`. + - Context synchronization: pending + +- [ ] T08: `Point SessionStart at the shared selection and drop the superseded picker` (status:todo) + - Task ID: T08 + - Scope: In — switch the model-less `SessionStart` bridge path in + `cli/src/services/hooks/claude_model_state.rs` to T07's shared + newest-chain-observation resolver, remove the now-unused mtime-newest + `find_claude_bridge_sibling_session_id` picker and its tests, and keep the + `SessionStart` attempt itself in place. Out — any behavior change to the + diff-trace path, any change to `PostModelSwitch`. + - Dependencies: T07 + - Done when: exactly one selection rule exists in the code, both call sites use it, + AC11 holds, and the `claude_model_state` suite passes with its bridge coverage + updated to the shared rule. + - 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_bridge_session`; `nix flake check`. + - Context synchronization: pending + ## Open questions Bridge inheritance is a probabilistic guess, not a guarantee: a session that @@ -343,6 +578,27 @@ 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. +Second phase: + +- The realized cost of this bug today is zero. All three observed `/clear` sessions + wrote no diff traces at all (single-turn `hello` tests with no file edits), and the + 638 `NULL` Claude `diff_traces.model_id` rows are overwhelmingly July/early-August + history predating `claude_model_state` entirely. So T04–T08 finish a shipped-inert + feature rather than stop active data loss. That is still worth doing — the next + cleared session that edits a file loses its attribution silently — but if something + else is competing for the same time, this is a defensible thing to defer. +- The selection change (T06–T08) fixes a failure that has never actually occurred: + the mtime-newest rule was correct in all three real cases. It is planned here + because T07 is already reading state per candidate, making the marginal cost one + read per chain member on a path that runs once per session. If that reasoning does + not convince, the smaller version is T04, T05, and a T07 that keeps the existing + single-sibling pick — the timing fix alone, which is what makes inheritance fire at + all. +- `fix-direction.md` at the repository root is an uncommitted working note holding + this phase's full investigation, and T04's decision record will own the durable + version of it. Whether it should be deleted, or moved under `context/`, is + unresolved and does not block implementation. + ## Validation Report **Status:** validated From da7d3e0904ccf2316ca35dab3c08e5b0dd50f3f5 Mon Sep 17 00:00:00 2001 From: David Abram Date: Thu, 10 Sep 2026 10:37:00 +0200 Subject: [PATCH 4/7] hooks: Carry Claude transcript paths in diff trace payloads Preserve transcript_path in memory at structured Claude parse time while excluding it from serialization and leaving normalized producer payloads unset. Add regressions for carry-through, omission, and missing or non-Claude inputs. Plan: claude-clear-session-model-inheritance (T05) Co-authored-by: SCE --- cli/src/services/hooks/mod.rs | 58 +++++++++++++++++++ .../claude-clear-session-model-inheritance.md | 24 +++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index fc820910..96f6a620 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -111,6 +111,8 @@ struct DiffTracePayload { model_id: Option, #[serde(skip)] agent_id: Option, + #[serde(skip)] + transcript_path: Option, tool_name: String, tool_version: Option, payload_type: String, @@ -958,6 +960,7 @@ fn parse_diff_trace_payload(stdin_payload: &str) -> Result time, model_id, agent_id: None, + transcript_path: None, tool_name, tool_version, payload_type: PAYLOAD_TYPE_PATCH.to_string(), @@ -993,6 +996,8 @@ fn parse_claude_diff_trace_payload( time: patch.time, model_id: resolve_claude_model_id(payload), agent_id: extract_claude_agent_id(payload)?, + transcript_path: non_empty_string(payload.get("transcript_path")) + .map(str::to_string), tool_name: patch.tool_name, tool_version: patch.tool_version, payload_type: PAYLOAD_TYPE_STRUCTURED.to_string(), @@ -2718,6 +2723,7 @@ mod tests { time: 1_800_000_000_000_u64, model_id: model_id.map(String::from), agent_id: None, + transcript_path: None, tool_name: String::from(tool_name), tool_version: tool_version.map(String::from), payload_type: String::from(payload_type), @@ -2918,6 +2924,58 @@ mod tests { .is_none()); } + #[test] + fn claude_diff_trace_parser_keeps_transcript_path_ephemeral_and_storage_free() { + let transcript_path = Path::new("/virtual/session-123.jsonl"); + let event = claude_model_test_event(transcript_path, "tool-123"); + + let payload = parsed_claude_diff_trace(&event); + + assert_eq!( + payload.transcript_path.as_deref(), + Some("/virtual/session-123.jsonl") + ); + assert!(serde_json::to_value(&payload) + .expect("internal payload should serialize") + .get("transcript_path") + .is_none()); + } + + #[test] + fn claude_diff_trace_parser_leaves_transcript_path_none_without_the_field() { + 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") + .remove("transcript_path"); + + assert_eq!(parsed_claude_diff_trace(&event).transcript_path, None); + } + + #[test] + fn claude_diff_trace_normalized_opencode_payload_carries_no_transcript_path() { + let stdin_payload = serde_json::json!({ + "sessionID": "session-123", + "diff": "diff text", + "time": 1_800_000_000_000_u64, + "model_id": "anthropic/claude-opus-4", + "tool_name": "opencode", + "tool_version": null + }) + .to_string(); + + let parsed = parse_diff_trace_payload(&stdin_payload) + .expect("normalized OpenCode diff-trace payload should parse"); + let payload = match parsed { + DiffTraceParseResult::Persist(payload) => payload, + DiffTraceParseResult::NoOp(message) => { + panic!("normalized OpenCode payload should persist, got no-op: {message}") + } + }; + + assert_eq!(payload.transcript_path, None); + } + #[test] #[allow(clippy::too_many_lines)] fn claude_model_attribution_end_to_end_persists_lifecycle_fallback_precedence_and_scope() { diff --git a/context/plans/claude-clear-session-model-inheritance.md b/context/plans/claude-clear-session-model-inheritance.md index bb8b57db..d7729084 100644 --- a/context/plans/claude-clear-session-model-inheritance.md +++ b/context/plans/claude-clear-session-model-inheritance.md @@ -503,7 +503,7 @@ Second phase: root context files before another task starts. - Context synchronization: synced -- [ ] T05: `Carry ephemeral transcript_path through DiffTracePayload` (status:todo) +- [x] T05: `Carry ephemeral transcript_path through DiffTracePayload` (status:done) - Task ID: T05 - Scope: In — add a `#[serde(skip)]` `transcript_path: Option` field to `DiffTracePayload` in `cli/src/services/hooks/mod.rs`, populate it at Claude @@ -516,7 +516,27 @@ Second phase: `diff_traces` and every serialized payload, `None` for OpenCode/Pi/Codex inputs, and AC9's regression passes with no behavior change to attribution. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_diff_trace`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_attribution`. - - Context synchronization: pending + - Completed: 2026-09-10 + - Files changed: `cli/src/services/hooks/mod.rs` + - Result: Added a `#[serde(skip)] transcript_path: Option` field to + `DiffTracePayload`, populated from the raw event's `transcript_path` at Claude + structured parse time (via `non_empty_string`, mirroring `agent_id`), left + `None` in the OpenCode/Pi normalized branch and all test constructors. No + resolution or persistence code reads the field yet. Added three regressions: + the field is carried on a Claude structured parse, absent from the serialized + payload, `None` when the raw field is missing, and `None` for a normalized + OpenCode payload. + - Verify: `claude_diff_trace` -> exit 0 (6 passed, 0 failed, incl. the 3 new + regressions); `claude_model_attribution` -> exit 0 (3 passed, 0 failed, + unchanged). + - Done checks: All satisfied — the field reaches the persistence boundary as an + in-memory-only value, `#[serde(skip)]` keeps it out of every serialized + payload and `diff_traces`, non-Claude producers get `None`, and the + attribution suite is unchanged. + - Context impact: local — adds an unused ephemeral carrier field consumed by a + later task; no user-visible behavior, interface, schema, or terminology + change. Root-context pass still required before the next task. + - Context synchronization: synced - [ ] T06: `Return all bridge-linked chain members from discovery` (status:todo) - Task ID: T06 From 6162db6fcfb345e17999474dd526bc5b11fd401a Mon Sep 17 00:00:00 2001 From: David Abram Date: Thu, 10 Sep 2026 10:43:20 +0200 Subject: [PATCH 5/7] hooks: Add Claude bridge chain discovery Return every sibling session linked by the same bridge identifier in deterministic order while preserving bounded, fail-open transcript reads. Ref: claude-clear-session-model-inheritance T06 Co-authored-by: SCE --- .../services/hooks/claude_bridge_session.rs | 150 ++++++++++++++++++ .../claude-clear-session-model-inheritance.md | 27 +++- 2 files changed, 175 insertions(+), 2 deletions(-) diff --git a/cli/src/services/hooks/claude_bridge_session.rs b/cli/src/services/hooks/claude_bridge_session.rs index 464e7a25..329e859b 100644 --- a/cli/src/services/hooks/claude_bridge_session.rs +++ b/cli/src/services/hooks/claude_bridge_session.rs @@ -87,6 +87,67 @@ pub fn find_claude_bridge_sibling_session_id( newest_match.map(|(_, session_id)| session_id) } +#[allow(dead_code)] +pub fn find_claude_bridge_chain_session_ids( + transcript_path: &Path, + bridge_session_id: &str, +) -> Vec { + let bridge_session_id = bridge_session_id.trim(); + if bridge_session_id.is_empty() { + return Vec::new(); + } + + 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 Ok(entries) = fs::read_dir(directory) else { + return Vec::new(); + }; + + let mut session_ids = Vec::new(); + for entry in entries.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; + } + + session_ids.push(candidate_session_id); + } + + session_ids.sort(); + session_ids.dedup(); + session_ids +} + fn extract_claude_bridge_session_id_from_reader( reader: io::Result, ) -> Option { @@ -244,4 +305,93 @@ mod tests { fs::remove_dir_all(directory).expect("temporary directory should be removed"); } + + #[test] + fn returns_every_chain_member_session_id_in_deterministic_order() { + let directory = unique_temp_dir("chain-multi"); + let source = directory.join("session-current.jsonl"); + let member_b = directory.join("session-b.jsonl"); + let member_a = directory.join("session-a.jsonl"); + let unrelated = directory.join("session-unrelated.jsonl"); + + fs::write(&source, transcript("cse_shared", "session-current")) + .expect("source transcript should be written"); + fs::write(&member_b, transcript("cse_shared", "session-b")) + .expect("member transcript should be written"); + fs::write(&member_a, transcript("cse_shared", "session-a")) + .expect("member transcript should be written"); + fs::write(&unrelated, transcript("cse_other", "session-unrelated")) + .expect("unrelated transcript should be written"); + + assert_eq!( + find_claude_bridge_chain_session_ids(&source, "cse_shared"), + vec![String::from("session-a"), String::from("session-b")] + ); + + fs::remove_dir_all(directory).expect("temporary directory should be removed"); + } + + #[test] + fn returns_the_single_chain_member_when_only_one_sibling_matches() { + let directory = unique_temp_dir("chain-single"); + let source = directory.join("session-current.jsonl"); + let member = directory.join("session-only.jsonl"); + + fs::write(&source, transcript("cse_shared", "session-current")) + .expect("source transcript should be written"); + fs::write(&member, transcript("cse_shared", "session-only")) + .expect("member transcript should be written"); + + assert_eq!( + find_claude_bridge_chain_session_ids(&source, "cse_shared"), + vec![String::from("session-only")] + ); + + fs::remove_dir_all(directory).expect("temporary directory should be removed"); + } + + #[test] + fn chain_discovery_fails_open_to_an_empty_result() { + let directory = unique_temp_dir("chain-none"); + let source = directory.join("session-current.jsonl"); + let unrelated = directory.join("session-unrelated.jsonl"); + + 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!(find_claude_bridge_chain_session_ids(&source, "cse_shared").is_empty()); + assert!(find_claude_bridge_chain_session_ids(&source, " ").is_empty()); + assert!( + find_claude_bridge_chain_session_ids( + Path::new("/does/not/exist/session.jsonl"), + "cse_shared" + ) + .is_empty() + ); + + fs::remove_dir_all(directory).expect("temporary directory should be removed"); + } + + #[test] + fn chain_discovery_ignores_a_sibling_whose_bridge_record_is_past_the_leading_bound() { + let directory = unique_temp_dir("chain-bounded"); + let source = directory.join("session-current.jsonl"); + let late = directory.join("session-late.jsonl"); + + fs::write(&source, transcript("cse_shared", "session-current")) + .expect("source transcript should be written"); + + let mut late_content = String::new(); + for _ in 0..MAX_LEADING_RECORDS { + late_content.push_str("{\"type\":\"user\"}\n"); + } + late_content.push_str(&transcript("cse_shared", "session-late")); + fs::write(&late, late_content).expect("late transcript should be written"); + + assert!(find_claude_bridge_chain_session_ids(&source, "cse_shared").is_empty()); + + fs::remove_dir_all(directory).expect("temporary directory should be removed"); + } } diff --git a/context/plans/claude-clear-session-model-inheritance.md b/context/plans/claude-clear-session-model-inheritance.md index d7729084..0d805cde 100644 --- a/context/plans/claude-clear-session-model-inheritance.md +++ b/context/plans/claude-clear-session-model-inheritance.md @@ -538,7 +538,7 @@ Second phase: change. Root-context pass still required before the next task. - Context synchronization: synced -- [ ] T06: `Return all bridge-linked chain members from discovery` (status:todo) +- [x] T06: `Return all bridge-linked chain members from discovery` (status:done) - Task ID: T06 - Scope: In — in `cli/src/services/hooks/claude_bridge_session.rs`, add a fail-open function returning every sibling `.jsonl` session ID sharing the transcript's @@ -551,7 +551,30 @@ Second phase: failure branches still return an empty result fail-open, and reads remain bounded with a regression proving records past the leading-record limit are not scanned. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_bridge_session`. - - Context synchronization: pending + - Completed: 2026-09-10 + - Files changed: `cli/src/services/hooks/claude_bridge_session.rs` + - Result: Added `find_claude_bridge_chain_session_ids`, a fail-open helper that + returns every sibling `.jsonl` session ID whose bounded leading records share + the transcript's `bridgeSessionId`, excluding the source file, sorted and + deduplicated for deterministic order. Empty/whitespace bridge id, an + unreadable directory, and any per-candidate metadata/read/parse failure each + contribute no member rather than aborting. Marked `#[allow(dead_code)]` (repo + precedent) until T07 wires the call site. Added four tests: multi-member + deterministic order, single member, fail-open-to-empty (no match, empty id, + missing directory), and a bounded-read regression proving a sibling whose + bridge record sits past `MAX_LEADING_RECORDS` is not matched. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_bridge_session` + -> exit 0 (9 passed, 0 failed, incl. the 4 new tests); `clippy` -> exit 0 + (no warnings). + - Done checks: All satisfied — real-shaped multi-member fixtures return the + chain members in deterministic order, every failure branch returns an empty + `Vec` fail-open, and the bounded-read regression proves records past the + leading-record limit are not scanned. + - Context impact: local — adds one unused (until T07) bounded, local-only, + fail-open discovery helper in the existing Claude bridge module; no + user-visible behavior, interface, schema, or terminology change. Root-context + pass still required before the next task. + - Context synchronization: synced - [ ] T07: `Seed and resolve Claude model state on the diff-trace state miss` (status:todo) - Task ID: T07 From 277a471789d479acce38d570a805f4cdbc04109e Mon Sep 17 00:00:00 2001 From: David Abram Date: Thu, 10 Sep 2026 10:56:41 +0200 Subject: [PATCH 6/7] hooks: Inherit Claude models from bridge chains Resolve raw main-session diff traces from the newest observed model across bridge-linked Claude sessions when exact state is absent. Seed the current session through the guarded state path so later traces reuse exact state, while failures and subagent scopes remain unattributed. Plan: claude-clear-session-model-inheritance T07 Co-authored-by: SCE --- .../services/hooks/claude_bridge_session.rs | 13 +- cli/src/services/hooks/claude_model_state.rs | 34 +++ cli/src/services/hooks/mod.rs | 268 +++++++++++++++++- context/architecture.md | 2 +- context/context-map.md | 2 +- context/glossary.md | 10 +- context/overview.md | 6 +- context/patterns.md | 4 +- .../claude-clear-session-model-inheritance.md | 55 +++- context/sce/agent-trace-db.md | 2 +- .../sce/agent-trace-hooks-command-routing.md | 6 +- 11 files changed, 369 insertions(+), 33 deletions(-) diff --git a/cli/src/services/hooks/claude_bridge_session.rs b/cli/src/services/hooks/claude_bridge_session.rs index 329e859b..0f4d4da2 100644 --- a/cli/src/services/hooks/claude_bridge_session.rs +++ b/cli/src/services/hooks/claude_bridge_session.rs @@ -87,7 +87,6 @@ pub fn find_claude_bridge_sibling_session_id( newest_match.map(|(_, session_id)| session_id) } -#[allow(dead_code)] pub fn find_claude_bridge_chain_session_ids( transcript_path: &Path, bridge_session_id: &str, @@ -363,13 +362,11 @@ mod tests { assert!(find_claude_bridge_chain_session_ids(&source, "cse_shared").is_empty()); assert!(find_claude_bridge_chain_session_ids(&source, " ").is_empty()); - assert!( - find_claude_bridge_chain_session_ids( - Path::new("/does/not/exist/session.jsonl"), - "cse_shared" - ) - .is_empty() - ); + assert!(find_claude_bridge_chain_session_ids( + Path::new("/does/not/exist/session.jsonl"), + "cse_shared" + ) + .is_empty()); 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 7dac8a9a..7b4a52a0 100644 --- a/cli/src/services/hooks/claude_model_state.rs +++ b/cli/src/services/hooks/claude_model_state.rs @@ -256,6 +256,40 @@ fn bridge_inheritance_candidate(stdin_payload: &str) -> Result Option { + let bridge_session_id = + super::claude_bridge_session::extract_claude_bridge_session_id(transcript_path)?; + let members = super::claude_bridge_session::find_claude_bridge_chain_session_ids( + transcript_path, + &bridge_session_id, + ); + + let mut winner: Option<(i64, String, String)> = None; + for member in members { + let member_session_id = prefixed_diff_trace_session_id(CLAUDE_TOOL_NAME, &member); + let Ok(Some(state)) = db.claude_model_state_by_session_and_agent(&member_session_id, "") + else { + continue; + }; + + let should_replace = match &winner { + None => true, + Some((best_ms, best_session_id, _)) => { + state.observed_at_ms > *best_ms + || (state.observed_at_ms == *best_ms && member_session_id > *best_session_id) + } + }; + if should_replace { + winner = Some((state.observed_at_ms, member_session_id, state.model_id)); + } + } + + winner.map(|(_, _, model_id)| model_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 96f6a620..1e79004e 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -15,9 +15,9 @@ use crate::services::agent_trace::{ }; use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; use crate::services::agent_trace_db::{ - AgentTraceInsert, DiffTraceInsert, InsertMessageInsert, InsertPartInsert, MessageRole, - PartType, PostCommitPatchIntersectionInsert, RecentDiffTracePatches, PAYLOAD_TYPE_PATCH, - PAYLOAD_TYPE_STRUCTURED, + AgentTraceInsert, ClaudeModelStateObservation, DiffTraceInsert, InsertMessageInsert, + InsertPartInsert, MessageRole, ObservationKind, PartType, PostCommitPatchIntersectionInsert, + RecentDiffTracePatches, PAYLOAD_TYPE_PATCH, PAYLOAD_TYPE_STRUCTURED, }; #[cfg(test)] use crate::services::agent_trace_storage::{ @@ -1377,9 +1377,43 @@ fn resolve_diff_trace_model_id( 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)) + if let Some(state) = db.claude_model_state_by_session_and_agent(&session_id, agent_id)? { + return Ok(Some(state.model_id)); + } + + Ok(seed_diff_trace_model_from_bridge_chain( + db, + payload, + &session_id, + agent_id, + )) +} + +fn seed_diff_trace_model_from_bridge_chain( + db: &RepositoryAgentTraceDb, + payload: &DiffTracePayload, + session_id: &str, + agent_id: &str, +) -> Option { + if !agent_id.is_empty() { + return None; + } + + let transcript_path = payload.transcript_path.as_deref()?; + let model_id = claude_model_state::newest_bridge_chain_model(db, Path::new(transcript_path))?; + + let observed_at_ms = current_unix_time_ms().ok()?; + match db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: session_id.to_string(), + agent_id: String::new(), + model_id: model_id.clone(), + observation_kind: ObservationKind::SessionStart, + source: String::from("bridge_inherited"), + observed_at_ms, + }) { + Ok(_) => Some(model_id), + Err(_) => None, + } } #[cfg(test)] @@ -2383,7 +2417,8 @@ mod tests { fs, path::{Path, PathBuf}, process::Command, - time::{SystemTime, UNIX_EPOCH}, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use super::*; @@ -3388,6 +3423,225 @@ mod tests { .expect("test DB directory should be removed"); } + fn write_bridge_transcript(path: &Path, bridge_session_id: &str) { + fs::write( + path, + format!( + concat!( + "{{\"type\":\"file-history-snapshot\"}}\n", + "{{\"type\":\"bridge-session\",\"sessionId\":\"s\",", + "\"bridgeSessionId\":\"{bridge_session_id}\"}}\n" + ), + bridge_session_id = bridge_session_id, + ), + ) + .expect("bridge transcript fixture should be written"); + } + + #[test] + fn claude_diff_trace_seeds_bridge_chain_state_on_state_miss_and_reuses_it() { + let db_path = unique_attribution_db_path("bridge-chain-seed"); + let dir = db_path + .parent() + .expect("test DB should have a parent") + .to_path_buf(); + fs::create_dir_all(&dir).expect("test DB directory should be created"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-member"), + agent_id: String::new(), + model_id: String::from("claude/chain-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 100, + }) + .expect("chain member state should seed"); + + let current_transcript = dir.join("session-current.jsonl"); + let member_transcript = dir.join("session-member.jsonl"); + write_bridge_transcript(¤t_transcript, "cse_chain"); + write_bridge_transcript(&member_transcript, "cse_chain"); + + let payload = parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "a")); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) + .expect("bridge chain seeding should persist"); + + assert_eq!( + persisted_model_ids(&db), + vec![Some(String::from("claude/chain-model"))] + ); + let seeded = db + .claude_model_state_by_session_and_agent("cc_session-123", "") + .expect("seeded lookup should succeed") + .expect("current session should be seeded"); + assert_eq!(seeded.model_id, "claude/chain-model"); + assert_eq!(seeded.source, "bridge_inherited"); + + fs::remove_file(&member_transcript).expect("member transcript should be removed"); + let payload_two = + parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "b")); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload_two) + .expect("second diff trace should persist"); + assert_eq!( + persisted_model_ids(&db), + vec![ + Some(String::from("claude/chain-model")), + Some(String::from("claude/chain-model")), + ] + ); + let after = db + .claude_model_state_by_session_and_agent("cc_session-123", "") + .expect("lookup should succeed") + .expect("row should still exist"); + assert_eq!(after.observed_at_ms, seeded.observed_at_ms); + + drop(db); + fs::remove_dir_all(&dir).expect("test DB directory should be removed"); + } + + #[test] + fn claude_diff_trace_bridge_chain_selects_newest_observation_across_members() { + let db_path = unique_attribution_db_path("bridge-chain-newest"); + let dir = db_path + .parent() + .expect("test DB should have a parent") + .to_path_buf(); + fs::create_dir_all(&dir).expect("test DB directory should be created"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-root"), + agent_id: String::new(), + model_id: String::from("claude/sonnet-5"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 10, + }) + .expect("root state should seed"); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-mid"), + agent_id: String::new(), + model_id: String::from("claude/opus-5"), + observation_kind: ObservationKind::PostModelSwitch, + source: String::from("picker"), + observed_at_ms: 20, + }) + .expect("mid state should seed"); + + let current_transcript = dir.join("session-current.jsonl"); + let root_transcript = dir.join("session-root.jsonl"); + let mid_transcript = dir.join("session-mid.jsonl"); + write_bridge_transcript(&mid_transcript, "cse_chain"); + write_bridge_transcript(¤t_transcript, "cse_chain"); + thread::sleep(Duration::from_millis(15)); + write_bridge_transcript(&root_transcript, "cse_chain"); + + let payload = parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "x")); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) + .expect("newest-observation resolution should persist"); + + assert_eq!( + persisted_model_ids(&db), + vec![Some(String::from("claude/opus-5"))] + ); + + drop(db); + fs::remove_dir_all(&dir).expect("test DB directory should be removed"); + } + + #[test] + fn claude_diff_trace_bridge_chain_fails_open_without_write_or_attribution() { + let db_path = unique_attribution_db_path("bridge-chain-fail-open"); + let dir = db_path + .parent() + .expect("test DB should have a parent") + .to_path_buf(); + fs::create_dir_all(&dir).expect("test DB directory should be created"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + + let mut event = + claude_model_test_event(Path::new("/virtual/missing.jsonl"), "no-transcript"); + event + .as_object_mut() + .expect("event should be an object") + .remove("transcript_path"); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&event), + ) + .expect("missing transcript should fail open"); + + let current_transcript = dir.join("session-current.jsonl"); + let member_transcript = dir.join("session-member.jsonl"); + write_bridge_transcript(¤t_transcript, "cse_chain"); + write_bridge_transcript(&member_transcript, "cse_chain"); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "no-state")), + ) + .expect("stateless chain should fail open"); + + assert_eq!(persisted_model_ids(&db), vec![None, None]); + assert!( + db.claude_model_state_by_session_and_agent("cc_session-123", "") + .expect("lookup should succeed") + .is_none(), + "no state row should be written on a fail-open branch" + ); + + drop(db); + fs::remove_dir_all(&dir).expect("test DB directory should be removed"); + } + + #[test] + fn claude_diff_trace_bridge_chain_does_not_seed_subagent_scope() { + let db_path = unique_attribution_db_path("bridge-chain-subagent"); + let dir = db_path + .parent() + .expect("test DB should have a parent") + .to_path_buf(); + fs::create_dir_all(&dir).expect("test DB directory should be created"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-member"), + agent_id: String::new(), + model_id: String::from("claude/chain-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 100, + }) + .expect("chain member state should seed"); + + let current_transcript = dir.join("session-current.jsonl"); + let member_transcript = dir.join("session-member.jsonl"); + write_bridge_transcript(¤t_transcript, "cse_chain"); + write_bridge_transcript(&member_transcript, "cse_chain"); + + let mut event = claude_model_test_event(¤t_transcript, "subagent"); + event + .as_object_mut() + .expect("event should be an object") + .insert("agent_id".to_string(), json!("subagent-1")); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&event), + ) + .expect("subagent diff trace should persist"); + + assert_eq!(persisted_model_ids(&db), vec![None]); + assert!( + db.claude_model_state_by_session_and_agent("cc_session-123", "subagent-1") + .expect("lookup should succeed") + .is_none(), + "subagent scope must not inherit main-session chain state" + ); + + drop(db); + fs::remove_dir_all(&dir).expect("test DB directory should be removed"); + } + #[test] fn prefixed_diff_trace_session_id_prefixes_fresh_pi_session_id() { assert_eq!( diff --git a/context/architecture.md b/context/architecture.md index 81d50b21..88028932 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -132,7 +132,7 @@ 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`), 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. +- `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 > bridge-derived chain 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. Only for a raw structured payload in the exact main-session scope, a state miss then resolves the newest `claude_model_state` observation across the transcript's bridge-linked chain members (bounded leading-record reads, one exact-scope read per member, winner by `observed_at_ms`) and, on a hit, writes a `claude_model_state` row for the current session (`source="bridge_inherited"`) before using that model — the only diff-trace resolution path that writes state. 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. diff --git a/context/context-map.md b/context/context-map.md index 4d5acdc5..e2281ab3 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 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/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 > bridge-derived chain 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, a main-session-scope-only bridge chain state-miss fallback that seeds a `source="bridge_inherited"` row for the current session (the one diff-trace path that writes state), `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 whose own model-less `SessionStart` bridge attempt cannot read its lazily created transcript, `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) diff --git a/context/glossary.md b/context/glossary.md index c7454315..aa9bee3c 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -83,8 +83,8 @@ - `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. -- `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). +- `Claude diff-trace attribution`: Diff-trace enrichment rule where one Claude `PostToolUse` event resolves its model with `direct > exact transcript > exact session/agent state > bridge-derived chain 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`. Only for a raw structured Claude payload in the exact main-session scope (`agent_id` empty), a state miss then falls back to `bridge session correlation` across the transcript's bridge-linked chain; when that resolves, persistence writes a `claude_model_state` row for the current session (`source="bridge_inherited"`) — the one diff-trace resolution path that writes state — and uses that model, so later traces of the same session resolve from their own exact-scope row. 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 Claude session's transcript to its sibling transcripts sharing that ID. Selection resolves the newest `claude_model_state` observation across all bridge-linked chain members by `observed_at_ms` (deterministic session-ID tie-break), not the chain origin and not the most recently modified sibling transcript. Discovery is bounded (leading records only) and fail-open, uses existing exact-scope `claude_model_state` rows without persisting the bridge ID, and does not claim authoritative session ordering. The `sce hooks claude-model-state` `SessionStart` path still uses the superseded most-recently-modified-sibling pick until it is switched to the shared rule. See [the read-path bridge-seeding decision](decisions/2026-09-10-claude-bridge-seeding-on-diff-trace.md) and [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`. @@ -169,7 +169,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`, 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. +- `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 > bridge-derived chain 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. @@ -185,7 +185,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` 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. +- `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 > bridge-derived chain 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`. -- `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). +- `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"`. In practice this `SessionStart` attempt cannot read its own lazily created transcript, so `bridge session correlation` for a cleared session is carried by the `diff-trace` state-miss path (see `Claude diff-trace attribution`); the `SessionStart` attempt is kept for the case Claude ever creates transcripts eagerly. 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/overview.md b/context/overview.md index f7ba0b26..213ff67e 100644 --- a/context/overview.md +++ b/context/overview.md @@ -41,7 +41,7 @@ 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 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... +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 > bridge-derived chain 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 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. @@ -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 > 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 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 > bridge-derived chain 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`, `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 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 > bridge-derived chain 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`. diff --git a/context/patterns.md b/context/patterns.md index 52c5aa89..e7cb6a13 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -163,8 +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 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. 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 raw structured Claude diff-trace attribution persistence, keep model resolution ordered `direct > exact transcript > exact session/agent state > bridge-derived chain state > NULL`: prefer direct payload metadata, then use only that event's `transcript_path` and `tool_use_id` for fail-open JSONL lookup, then one exact lookup in the local `claude_model_state` register using canonical `cc_` plus the event's exact agent scope. Only when that misses for a raw structured payload in the exact main-session scope, resolve `bridge session correlation` across the event's `transcript_path` chain (bounded leading-record reads of sibling transcripts, one exact-scope state read per member, winner by greatest `observed_at_ms`) and, on a hit, write one `claude_model_state` row for the current session (`source="bridge_inherited"`, `observation_kind=session_start`) through the existing guarded write path before using that model — the only diff-trace resolution path that writes state, and self-limiting because the seeded row satisfies the exact-scope read on every later trace. Normalized payloads, even with `tool_name="claude"`, are not eligible for the state fallback or the bridge fallback. Normalize model values through the `claude/` convention and store unresolved attribution as `NULL` in `diff_traces`; persist `tool_version` directly. Every bridge branch fails open to `NULL` with no state write. Do not restore the former generic `session_models` abstraction, broaden subagent scope to the main session, poll/wait for lifecycle state, run a second selection rule, scan a full transcript, 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. For a model-less `SessionStart`, use only the event's `transcript_path` and bounded leading-record reads to find a bridge-linked 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. This `SessionStart` attempt fires before Claude has created the session's transcript, so it always no-ops in practice — bridge inheritance for a cleared session is carried by the `diff-trace` state-miss path above — but it is kept in place cheaply against the chance Claude ever creates transcripts eagerly. 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 0d805cde..6b6cb48e 100644 --- a/context/plans/claude-clear-session-model-inheritance.md +++ b/context/plans/claude-clear-session-model-inheritance.md @@ -576,7 +576,7 @@ Second phase: pass still required before the next task. - Context synchronization: synced -- [ ] T07: `Seed and resolve Claude model state on the diff-trace state miss` (status:todo) +- [x] T07: `Seed and resolve Claude model state on the diff-trace state miss` (status:done) - Task ID: T07 - Scope: In — add a shared newest-chain-observation resolver (chain members from T06, one exact-scope state read per member, winner by greatest `observed_at_ms` @@ -593,7 +593,58 @@ Second phase: `claude_model_state`, `claude_model`, `claude_bridge_session`, 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_attribution`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model`; `nix flake check`. - - Context synchronization: pending + - Completed: 2026-09-10 + - Files changed: `cli/src/services/hooks/claude_bridge_session.rs`, + `cli/src/services/hooks/claude_model_state.rs`, + `cli/src/services/hooks/mod.rs` + - Result: Added `newest_bridge_chain_model` in `claude_model_state.rs` — the + single newest-chain-observation selection rule: chain members from T06's + `find_claude_bridge_chain_session_ids`, one exact-scope `(cc_, "")` + state read per member, winner by greatest `observed_at_ms` with the prefixed + session ID as a deterministic tie-break, fail-open to `None` at every step. + Wired it into the diff-trace persistence flow: `resolve_diff_trace_model_id` + now, on an exact-scope state miss for a raw structured Claude payload that + carries T05's `transcript_path`, calls `seed_diff_trace_model_from_bridge_chain`, + which resolves the chain model, persists a `claude_model_state` row for the + current session with `source="bridge_inherited"` and + `observation_kind=SessionStart` through the existing guarded + `upsert_claude_model_state` path, and returns that model for the trace in + hand. Seeding is confined to the exact main-session scope (`agent_id` empty), + so subagent traces never inherit main-session state. Any failure — absent + `transcript_path`, unreadable transcript, absent bridge record, no other + chain member, no member state, or a write failure — falls through to today's + `NULL`. Removed the now-unused `#[allow(dead_code)]` from the T06 chain + helper. The `SessionStart` call site and the superseded single-sibling picker + are untouched (T08). + - Verify: `claude_model_attribution` -> exit 0 (3 passed, 0 failed); + `claude_model` -> exit 0 (22 passed, 0 failed); `nix flake check` -> all + checks passed. Also `claude_diff_trace` -> exit 0 (10 passed, incl. 4 new + regressions), `claude_bridge_session` -> exit 0 (9 passed), `claude_model_state` + -> exit 0 (16 passed), `cargo clippy --all-targets` -> exit 0 (no warnings). + - Done checks: All satisfied — AC6 proven by + `claude_diff_trace_seeds_bridge_chain_state_on_state_miss_and_reuses_it` + (diff trace attributed from chain state, `bridge_inherited` row written for + the current session); AC7 by + `claude_diff_trace_bridge_chain_selects_newest_observation_across_members` + (newer `observed_at_ms` opus-5 wins over the mtime-newest sibling holding + sonnet-5); AC8 by + `claude_diff_trace_bridge_chain_fails_open_without_write_or_attribution` + (absent transcript and stateless chain both leave `model_id` NULL and write + no state row) plus the fail-open branches in `newest_bridge_chain_model`; + AC10 by the same first test (second trace resolves from the session's own + seeded state, `observed_at_ms` unchanged, no second row) and + `claude_diff_trace_bridge_chain_does_not_seed_subagent_scope`. Existing + `claude_model_state`, `claude_model`, `claude_bridge_session`, and + `claude_model_attribution` suites pass unchanged. + - Context impact: cross-cutting implementation boundary — adds a Claude + diff-trace resolution path that now writes `claude_model_state`, amends the + effective attribution precedence to + `direct > exact transcript > exact state > bridge-derived chain state > NULL`, + and establishes the newest-chain-observation selection rule shared with the + `SessionStart` path in T08; context synchronization must reconcile the + amended precedence, the read-path state write, and the selection rule, and + inspect the mandatory root context files before another task starts. + - Context synchronization: synced - [ ] T08: `Point SessionStart at the shared selection and drop the superseded picker` (status:todo) - Task ID: T08 diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index 70715ef4..a430f412 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -190,7 +190,7 @@ 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. 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 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 > bridge-derived chain 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`. diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 71f9ea10..bb42c90f 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -67,7 +67,7 @@ - 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 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. + - **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 > bridge-derived chain 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. When that exact lookup also misses and the payload is in the exact main-session scope (`agent_id` empty) and carries an ephemeral `transcript_path`, persistence resolves the newest `claude_model_state` observation across the transcript's bridge-linked chain members — bounded leading-record reads of sibling `.jsonl` transcripts sharing the `bridgeSessionId`, one exact-scope `(cc_, "")` read per member, winner by greatest `observed_at_ms` with a deterministic session-ID tie-break — and, on a hit, writes one `claude_model_state` row for the current session (`source="bridge_inherited"`, `observation_kind=session_start`) through the existing guarded write path before using that model for the trace in hand. This is the only `diff-trace` resolution path that writes state; the seeded row satisfies the exact lookup on every later trace, so bridge discovery runs at most once per session. Ephemeral `agent_id` is trimmed for exact lookup, with missing/null main-session context mapped to `""`; subagents never inherit main-session state or trigger the bridge fallback. The ephemeral `transcript_path` used for this resolution is carried on `DiffTracePayload` as a `#[serde(skip)]` field and is never written to `diff_traces` or any exported payload. Missing/unreadable transcripts, unmatched tool calls, missing models, absent lookup fields, an absent or malformed bridge record, no other chain member, no member state, or a state read/write failure leave the parser's `model_id` nullable and write no state row without rejecting the hook. No polling, waiting, second selection rule, full-transcript scan, 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. @@ -115,8 +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"`. -- `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 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. In practice this `SessionStart` attempt fires before Claude has created the session's transcript file, so it reads nothing and always no-ops; bridge inheritance for a cleared session is actually carried by the `diff-trace` state-miss path above, and this attempt is kept only for the case Claude ever creates transcripts eagerly. 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 its exact `(cc_, agent_id)` state after direct and transcript attribution fail, then — for a main-session-scope raw structured payload only — the newest observation across the transcript's bridge-linked chain, seeding a `source="bridge_inherited"` row for the current session; this remains Claude-specific and 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 From bfe051346f5cfb7951874051eee85fb103599610 Mon Sep 17 00:00:00 2001 From: David Abram Date: Thu, 10 Sep 2026 11:04:21 +0200 Subject: [PATCH 7/7] hooks: Use shared bridge-chain model selection for SessionStart Route model-less Claude SessionStart inheritance through the shared newest-chain observation rule so lifecycle and diff-trace paths select state consistently. Remove the superseded mtime-based sibling picker and align the bridge-correlation documentation. Plan: claude-clear-session-model-inheritance (T08) Co-authored-by: SCE --- .../services/hooks/claude_bridge_session.rs | 122 +----------------- cli/src/services/hooks/claude_model_state.rs | 39 +----- context/glossary.md | 4 +- context/patterns.md | 2 +- .../claude-clear-session-model-inheritance.md | 91 ++++++++++--- .../sce/agent-trace-hooks-command-routing.md | 2 +- 6 files changed, 82 insertions(+), 178 deletions(-) diff --git a/cli/src/services/hooks/claude_bridge_session.rs b/cli/src/services/hooks/claude_bridge_session.rs index 0f4d4da2..73391a5a 100644 --- a/cli/src/services/hooks/claude_bridge_session.rs +++ b/cli/src/services/hooks/claude_bridge_session.rs @@ -1,7 +1,6 @@ use std::fs::{self, File}; use std::io::{self, BufRead, BufReader}; use std::path::Path; -use std::time::SystemTime; use serde_json::Value; @@ -16,77 +15,6 @@ pub fn extract_claude_bridge_session_id(transcript_path: &Path) -> Option 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) -} - pub fn find_claude_bridge_chain_session_ids( transcript_path: &Path, bridge_session_id: &str, @@ -184,8 +112,7 @@ mod tests { fs, io::Cursor, path::{Path, PathBuf}, - thread, - time::{Duration, SystemTime, UNIX_EPOCH}, + time::{SystemTime, UNIX_EPOCH}, }; use super::*; @@ -258,53 +185,6 @@ mod tests { ); } - #[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"); - } - #[test] fn returns_every_chain_member_session_id_in_deterministic_order() { let directory = unique_temp_dir("chain-multi"); diff --git a/cli/src/services/hooks/claude_model_state.rs b/cli/src/services/hooks/claude_model_state.rs index 7b4a52a0..4ee2aad6 100644 --- a/cli/src/services/hooks/claude_model_state.rs +++ b/cli/src/services/hooks/claude_model_state.rs @@ -16,13 +16,12 @@ 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, + transcript_path: PathBuf, } pub(super) fn run_claude_model_state_subcommand( @@ -169,29 +168,14 @@ where } 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 { + let Some(model_id) = newest_bridge_chain_model(&db, &candidate.transcript_path) else { return String::new(); }; ClaudeModelStateObservation { session_id: candidate.session, agent_id: candidate.agent, - model_id: sibling_state.model_id, + model_id, observation_kind: ObservationKind::SessionStart, source: String::from("bridge_inherited"), observed_at_ms, @@ -219,7 +203,6 @@ fn bridge_inheritance_candidate(stdin_payload: &str) -> Result Result`, 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 > bridge-derived chain 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`. Only for a raw structured Claude payload in the exact main-session scope (`agent_id` empty), a state miss then falls back to `bridge session correlation` across the transcript's bridge-linked chain; when that resolves, persistence writes a `claude_model_state` row for the current session (`source="bridge_inherited"`) — the one diff-trace resolution path that writes state — and uses that model, so later traces of the same session resolve from their own exact-scope row. 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 Claude session's transcript to its sibling transcripts sharing that ID. Selection resolves the newest `claude_model_state` observation across all bridge-linked chain members by `observed_at_ms` (deterministic session-ID tie-break), not the chain origin and not the most recently modified sibling transcript. Discovery is bounded (leading records only) and fail-open, uses existing exact-scope `claude_model_state` rows without persisting the bridge ID, and does not claim authoritative session ordering. The `sce hooks claude-model-state` `SessionStart` path still uses the superseded most-recently-modified-sibling pick until it is switched to the shared rule. See [the read-path bridge-seeding decision](decisions/2026-09-10-claude-bridge-seeding-on-diff-trace.md) and [the bridge-session inheritance decision](decisions/2026-09-08-claude-bridge-session-model-inheritance.md). +- `bridge session correlation`: Claude-specific local correlation using the `bridgeSessionId` in leading transcript records to relate a Claude session's transcript to its sibling transcripts sharing that ID. Selection resolves the newest `claude_model_state` observation across all bridge-linked chain members by `observed_at_ms` (deterministic session-ID tie-break), not the chain origin and not the most recently modified sibling transcript. Discovery is bounded (leading records only) and fail-open, uses existing exact-scope `claude_model_state` rows without persisting the bridge ID, and does not claim authoritative session ordering. One selection rule serves both call sites — the `diff-trace` state-miss path and the `sce hooks claude-model-state` `SessionStart` path — with no most-recently-modified-sibling pick left in the code. See [the read-path bridge-seeding decision](decisions/2026-09-10-claude-bridge-seeding-on-diff-trace.md) and [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`. @@ -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 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"`. In practice this `SessionStart` attempt cannot read its own lazily created transcript, so `bridge session correlation` for a cleared session is carried by the `diff-trace` state-miss path (see `Claude diff-trace attribution`); the `SessionStart` attempt is kept for the case Claude ever creates transcripts eagerly. 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). +- `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 its bridge-linked chain using the same shared newest-chain-observation selection rule as the `diff-trace` state-miss path (bounded leading-record reads, one exact-scope state read per chain member, winner by greatest `observed_at_ms`) and `source="bridge_inherited"`. In practice this `SessionStart` attempt cannot read its own lazily created transcript, so `bridge session correlation` for a cleared session is carried by the `diff-trace` state-miss path (see `Claude diff-trace attribution`); the `SessionStart` attempt is kept for the case Claude ever creates transcripts eagerly. 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 e7cb6a13..b0a10805 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 > bridge-derived chain state > NULL`: prefer direct payload metadata, then use only that event's `transcript_path` and `tool_use_id` for fail-open JSONL lookup, then one exact lookup in the local `claude_model_state` register using canonical `cc_` plus the event's exact agent scope. Only when that misses for a raw structured payload in the exact main-session scope, resolve `bridge session correlation` across the event's `transcript_path` chain (bounded leading-record reads of sibling transcripts, one exact-scope state read per member, winner by greatest `observed_at_ms`) and, on a hit, write one `claude_model_state` row for the current session (`source="bridge_inherited"`, `observation_kind=session_start`) through the existing guarded write path before using that model — the only diff-trace resolution path that writes state, and self-limiting because the seeded row satisfies the exact-scope read on every later trace. Normalized payloads, even with `tool_name="claude"`, are not eligible for the state fallback or the bridge fallback. Normalize model values through the `claude/` convention and store unresolved attribution as `NULL` in `diff_traces`; persist `tool_version` directly. Every bridge branch fails open to `NULL` with no state write. Do not restore the former generic `session_models` abstraction, broaden subagent scope to the main session, poll/wait for lifecycle state, run a second selection rule, scan a full transcript, 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. For a model-less `SessionStart`, use only the event's `transcript_path` and bounded leading-record reads to find a bridge-linked 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. This `SessionStart` attempt fires before Claude has created the session's transcript, so it always no-ops in practice — bridge inheritance for a cleared session is carried by the `diff-trace` state-miss path above — but it is kept in place cheaply against the chance Claude ever creates transcripts eagerly. 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 `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 the same shared newest-chain-observation selection rule as the `diff-trace` state-miss path (bounded leading-record reads of chain siblings sharing the `bridgeSessionId`, one exact-scope state read per member, winner by greatest `observed_at_ms`) and seed the current session with `source="bridge_inherited"` when a chain observation exists; every discovery or state failure remains the existing no-op. Keep exactly one selection rule for both call sites — no most-recently-modified-sibling pick. This `SessionStart` attempt fires before Claude has created the session's transcript, so it always no-ops in practice — bridge inheritance for a cleared session is carried by the `diff-trace` state-miss path above — but it is kept in place cheaply against the chance Claude ever creates transcripts eagerly. 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 6b6cb48e..cda0313a 100644 --- a/context/plans/claude-clear-session-model-inheritance.md +++ b/context/plans/claude-clear-session-model-inheritance.md @@ -195,39 +195,39 @@ performs final validation. 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. -- [ ] AC6: A raw structured Claude `PostToolUse` diff-trace event in a session with +- [x] AC6: A raw structured Claude `PostToolUse` diff-trace event in a session with no `claude_model_state` row of its own, whose transcript is bridge-linked to chain members that do have state, persists `diff_traces.model_id` from that chain and writes a `claude_model_state` row for the current session with `source="bridge_inherited"`. - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_model_attribution`. -- [ ] AC7: Chain selection resolves the newest `claude_model_state` observation by +- [x] AC7: Chain selection resolves the newest `claude_model_state` observation by `observed_at_ms`, not the chain origin and not the newest transcript file: for a chain whose root holds sonnet-5, whose mid-chain member holds opus-5 from a later switch, and whose cleared member's mtime-newest sibling is the root, the resolved model is opus-5. - Validate: focused regression under the same test command as AC6. -- [ ] AC8: Every discovery and state branch fails open exactly as today — absent +- [x] AC8: Every discovery and state branch fails open exactly as today — absent `transcript_path`, missing or unreadable transcript, absent or malformed bridge record, no other chain member, no member state, and DB read or write failure each leave `diff_traces.model_id` as it would have been, write no state row, keep hook success, and emit zero stdout. - Validate: focused per-branch tests under the same test command as AC6. -- [ ] AC9: `transcript_path` carried for this resolution stays ephemeral: it is never +- [x] AC9: `transcript_path` carried for this resolution stays ephemeral: it is never written to `diff_traces`, any other column, or any exported payload. - Validate: parser regression asserting the field is absent from the stored row, in the shape of the existing `claude_diff_trace_parser_keeps_agent_id_ephemeral_and_storage_free` test. -- [ ] AC10: The second and later diff traces of a seeded session resolve from the +- [x] AC10: The second and later diff traces of a seeded session resolve from the session's own exact-scope state with no repeated bridge discovery and no second state write. - Validate: focused regression asserting one discovery and one state write across two consecutive diff-trace events in one session. -- [ ] AC11: Discovery stays bounded and local-only — leading records only, no +- [x] AC11: Discovery stays bounded and local-only — leading records only, no full-transcript scan, no network — and one shared selection rule serves both the `SessionStart` and diff-trace call sites, with no second rule left in the code. - Validate: inspect the discovery and selection helpers for a bounded read and a single selection implementation; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_bridge_session` and `claude_model` pass alongside new coverage. -- [ ] AC12: A decision record documents the amended attribution precedence +- [x] AC12: A decision record documents the amended attribution precedence (`direct > exact transcript > exact state > bridge-derived chain state > NULL`), that a resolution path now writes state, the measured transcript-creation race that makes `SessionStart` unable to read its own transcript, the origin-vs-newest @@ -646,7 +646,7 @@ Second phase: inspect the mandatory root context files before another task starts. - Context synchronization: synced -- [ ] T08: `Point SessionStart at the shared selection and drop the superseded picker` (status:todo) +- [x] T08: `Point SessionStart at the shared selection and drop the superseded picker` (status:done) - Task ID: T08 - Scope: In — switch the model-less `SessionStart` bridge path in `cli/src/services/hooks/claude_model_state.rs` to T07's shared @@ -659,7 +659,45 @@ Second phase: AC11 holds, and the `claude_model_state` suite passes with its bridge coverage updated to the shared rule. - 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_bridge_session`; `nix flake check`. - - Context synchronization: pending + - Completed: 2026-09-10 + - Files changed: `cli/src/services/hooks/claude_model_state.rs`, + `cli/src/services/hooks/claude_bridge_session.rs` + - Result: The model-less `SessionStart` bridge path now resolves its model + through T07's shared `newest_bridge_chain_model` selection rule instead of a + private sibling lookup. `bridge_inheritance_candidate` keeps only the cheap + bounded `extract_claude_bridge_session_id` guard (so a non-bridge or + not-yet-created transcript still fails open before any DB open) and now + carries the event's `transcript_path`; after the DB opens, the no-direct- + observation branch calls `newest_bridge_chain_model(&db, &candidate.transcript_path)` + and falls through to today's silent no-op on `None`. The superseded + mtime-newest `find_claude_bridge_sibling_session_id` picker, its doc comment, + and its two unit tests are removed, along with the now-unused + `DB_READ_FAILED_EVENT` constant and the `SystemTime`/`thread`/`Duration` + test imports left unreferenced. The `SessionStart` bridge attempt itself is + unchanged. Per a mid-task instruction, comments in the touched code were + also dropped (the `// Keep the same required lifecycle fields` note and the + relocated doc comment). + - Verify: `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_bridge_session` + -> exit 0 (7 passed, 0 failed — the two sibling-picker tests removed, chain + coverage retained); `nix flake check` -> all checks passed (clippy, fmt, + tests). Also `claude_model_attribution` -> exit 0 (3 passed, incl. the + end-to-end `SessionStart` bridge-inheritance regression unchanged) and + `cargo clippy --all-targets` -> exit 0 (no warnings). + - Done checks: All satisfied — exactly one selection rule + (`newest_bridge_chain_model`) exists in the code and both call sites + (`claude_model_state.rs` `SessionStart`, `mod.rs` diff-trace) use it; + AC11 holds (discovery still reads leading records only via + `take(MAX_LEADING_RECORDS)`, no full-transcript scan, no network, single + selection implementation); the `claude_model_state` suite passes with its + bridge behavior now exercised through the shared rule. + - Context impact: cross-cutting implementation boundary — the `SessionStart` + model-less bridge path and the diff-trace state-miss path now share one + newest-chain-observation selection rule, and the earlier mtime-newest + single-sibling picker no longer exists; context synchronization must + reconcile that the two call sites are unified on one rule and inspect the + mandatory root context files before final validation. + - Context synchronization: synced ## Open questions @@ -695,25 +733,37 @@ Second phase: ## Validation Report -**Status:** validated -**Date:** 2026-09-08 +**Status:** validated +**Date:** 2026-09-10 ### 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) +- `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_diff_trace` -> exit 0 (10 passed, 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_bridge_session` -> exit 0 (7 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) +- Inspection: `context/decisions/2026-09-08-claude-bridge-session-model-inheritance.md` against AC5 +- Inspection: `context/decisions/2026-09-10-claude-bridge-seeding-on-diff-trace.md` against AC12 +- Inspection: `cli/src/services/hooks/claude_bridge_session.rs`, `claude_model_state.rs`, `mod.rs` for bounded reads and a single shared selection rule +- `git status` -> working tree clean of leftover artifacts (`db`, `db-wal`, `fix-direction.md` removed; committed in `cbec39f8`) ### 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`. +- [x] AC1: Model-less `SessionStart` with a bridge-linked sibling holding state persists `source="bridge_inherited"` -> `claude_model_attribution` + `claude_model_state` suites pass (T03 regressions). +- [x] AC2: Discovery/state failure branches fail open, zero stdout, no destructive writes -> `claude_bridge_session` and `claude_model_state` failure-path tests pass; helper inspection confirms fail-open returns. +- [x] AC3: Bounded local-only discovery; `PostModelSwitch` and diff-trace precedence unchanged -> `take(MAX_LEADING_RECORDS)` in `claude_bridge_session.rs`; `claude_model`, `claude_model_attribution` pass unchanged. +- [x] AC4: Inherited state feeds diff-trace `model_id` -> persisted-row regression in `claude_model_attribution` passes. +- [x] AC5: 2026-09-08 decision documents the production evidence, mechanism, caveat, guardrails -> file inspection confirms each element. +- [x] AC6: Raw structured Claude diff-trace state miss on a bridge-linked chain persists `model_id` and a `bridge_inherited` row -> `claude_diff_trace_seeds_bridge_chain_state_on_state_miss_and_reuses_it` passes. +- [x] AC7: Chain selection resolves the newest `observed_at_ms` observation, not origin and not newest transcript file -> `claude_diff_trace_bridge_chain_selects_newest_observation_across_members` passes (opus-5 wins over the mtime-newest sonnet-5 sibling). +- [x] AC8: Every discovery/state branch fails open, no state row, hook success, zero stdout -> `claude_diff_trace_bridge_chain_fails_open_without_write_or_attribution` and `newest_bridge_chain_model` fail-open branches pass. +- [x] AC9: `transcript_path` stays ephemeral, absent from the stored row and every serialized payload -> `claude_diff_trace_parser_keeps_transcript_path_ephemeral_and_storage_free`, `..._leaves_transcript_path_none_without_the_field`, `..._normalized_opencode_payload_carries_no_transcript_path` pass; field carries `#[serde(skip)]`. +- [x] AC10: Second and later diff traces resolve from the session's own state, no repeated discovery, no second write -> reuse assertion in `..._seeds_bridge_chain_state_on_state_miss_and_reuses_it` plus `claude_diff_trace_bridge_chain_does_not_seed_subagent_scope` pass. +- [x] AC11: Bounded local-only discovery and one shared selection rule for both call sites -> `find_claude_bridge_sibling_session_id` removed; only `newest_bridge_chain_model` remains, used by both `claude_model_state.rs` `SessionStart` and `mod.rs` diff-trace; `claude_bridge_session`, `claude_model` pass. +- [x] AC12: 2026-09-10 decision documents the amended precedence, the read-path state write, the measured transcript-creation race, the origin-vs-newest selection evidence, and guardrail compliance -> file inspection confirms each element. ### Failed checks and follow-ups @@ -721,4 +771,5 @@ Second phase: ### Residual risks -- Bridge inheritance remains best-effort and may inherit a stale model if a model switch races the first tool call. +- Bridge inheritance stays best-effort: a session that clears and switches models before its first tool call inherits the previous model and is attributed to it rather than staying `NULL`. The newest-observation rule narrows but does not close this window. +- One diff-trace resolution branch now performs a state write; it is exact-scope, guarded, and bounded to once per session. diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index bb42c90f..93c7e588 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. 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. In practice this `SessionStart` attempt fires before Claude has created the session's transcript file, so it reads nothing and always no-ops; bridge inheritance for a cleared session is actually carried by the `diff-trace` state-miss path above, and this attempt is kept only for the case Claude ever creates transcripts eagerly. 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. +- `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 for the `bridgeSessionId`, then resolves the model through the same single newest-chain-observation selection rule the `diff-trace` state-miss path uses — bounded leading-record reads of sibling `.jsonl` transcripts sharing that `bridgeSessionId`, one exact-scope `(cc_, "")` read per member, winner by greatest `observed_at_ms` with a deterministic session-ID tie-break — and seeds the current session with that model as `source="bridge_inherited"` when a chain observation exists. There is no separate SessionStart selection rule and no most-recently-modified-sibling pick. Missing or malformed discovery inputs, no chain observation, filesystem races, and DB reads fail open to the existing no-op. In practice this `SessionStart` attempt fires before Claude has created the session's transcript file, so it reads nothing and always no-ops; bridge inheritance for a cleared session is actually carried by the `diff-trace` state-miss path above, and this attempt is kept only for the case Claude ever creates transcripts eagerly. 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 its exact `(cc_, agent_id)` state after direct and transcript attribution fail, then — for a main-session-scope raw structured payload only — the newest observation across the transcript's bridge-linked chain, seeding a `source="bridge_inherited"` row for the current session; this remains Claude-specific and 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.