use std::path::Path;
use anyhow::Result;
use serde::Serialize;
use serde_json::Value;
use crate::commands::start;
use crate::db;
use crate::memory::evidence::{
self, MemoryEvidenceEnvelope, MemoryEvidenceSubmitReport, SubmitRuntimeHints,
};
use crate::output::CommandReport;
use crate::output::{self, RADAR_STATE_FIELD_FILTER_SPEC};
use crate::paths::state::StateLayout;
use crate::profile;
use crate::repo;
use crate::session_boundary::SessionBoundaryRecommendation;
use crate::state::session;
use crate::state::{protected_write::ExclusiveWriteOptions, radar, work_stream_decay};
use crate::telemetry::host::HostContextSnapshot;
use crate::telemetry::host_loop::{
self, ContextDiagnosticsView, ContextSessionSnapshot, ContextTelemetryView,
};
const ON_SESSION_START: &str = "on_session_start";
const BEFORE_PROMPT_BUILD: &str = "before_prompt_build";
const ON_COMPACTION_NOTICE: &str = "on_compaction_notice";
const ON_AGENT_END: &str = "on_agent_end";
const ON_SESSION_END: &str = "on_session_end";
const SUPERVISOR_TICK: &str = "supervisor_tick";
const CHECKPOINT_FIELDS: &[&str] = &[
"command",
"mode",
"session_boundary",
"session_state",
"execution_gates",
"escalation",
"projection_telemetry",
"work_stream_decay",
"context_health",
"behavioral_drift",
"evaluation",
"workflow_hints",
"approval_steps",
];
pub(crate) struct HookInput<'a> {
pub(crate) repo_root: &'a Path,
pub(crate) profile: &'a str,
pub(crate) protected_write: &'a ExclusiveWriteOptions,
pub(crate) session_start: HostSessionStartOptions,
pub(crate) diagnostics: bool,
pub(crate) host_total_context_chars: Option<u64>,
pub(crate) attempt_outcome: Option<work_stream_decay::AttemptOutcome>,
}
#[derive(Clone)]
pub(crate) struct HostSessionStartOptions {
pub(crate) mode: Option<session::SessionMode>,
pub(crate) lifecycle: session::SessionLifecycle,
pub(crate) owner_kind: Option<session::SessionOwnerKind>,
pub(crate) actor_id: Option<String>,
pub(crate) supervisor_id: Option<String>,
pub(crate) lease_ttl_secs: Option<u64>,
pub(crate) host_session_id: Option<String>,
pub(crate) host_run_id: Option<String>,
pub(crate) host_task_id: Option<String>,
}
impl Default for HostSessionStartOptions {
fn default() -> Self {
Self {
mode: None,
lifecycle: session::SessionLifecycle::Interactive,
owner_kind: None,
actor_id: None,
supervisor_id: None,
lease_ttl_secs: None,
host_session_id: None,
host_run_id: None,
host_task_id: None,
}
}
}
impl HostSessionStartOptions {
fn into_session_start_options(self) -> session::SessionStartOptions {
session::SessionStartOptions {
mode: self.mode,
lifecycle: self.lifecycle,
owner_kind: self.owner_kind,
actor_id: self.actor_id,
supervisor_id: self.supervisor_id,
lease_ttl_secs: self.lease_ttl_secs,
}
}
fn runtime_view(&self) -> Option<HostRuntimeView> {
if self.host_session_id.is_none()
&& self.host_run_id.is_none()
&& self.host_task_id.is_none()
{
return None;
}
Some(HostRuntimeView {
host_session_id: self.host_session_id.clone(),
host_run_id: self.host_run_id.clone(),
host_task_id: self.host_task_id.clone(),
})
}
}
#[derive(Serialize)]
pub(crate) struct HostRuntimeView {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) host_session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) host_run_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) host_task_id: Option<String>,
}
pub(crate) trait HostAdapter: Sync {
fn name(&self) -> &'static str;
fn snapshot(&self, repo_root: &Path) -> Result<Option<HostContextSnapshot>>;
fn on_session_start(&self, input: HookInput<'_>) -> Result<HostHookReport> {
handle_session_start(self.name(), input)
}
fn before_prompt_build(&self, input: HookInput<'_>) -> Result<HostHookReport> {
handle_before_prompt_build(self.name(), input)
}
fn on_compaction_notice(
&self,
input: CompactionNoticeInput<'_>,
) -> Result<CompactionNoticeReport> {
handle_compaction_notice(self.name(), input)
}
fn on_agent_end(&self, input: HookInput<'_>) -> Result<HostHookReport> {
handle_agent_end(self.name(), input)
}
fn on_session_end(&self, input: HookInput<'_>) -> Result<HostHookReport> {
handle_session_end(self.name(), input)
}
fn supervisor_tick(&self, input: HookInput<'_>) -> Result<HostHookReport> {
handle_supervisor_tick(self.name(), input)
}
}
#[derive(Serialize)]
pub(crate) struct HostHookReport {
pub(crate) adapter: &'static str,
pub(crate) hook: &'static str,
pub(crate) status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) session_boundary: Option<SessionBoundaryRecommendation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) context: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) telemetry: Option<ContextTelemetryView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) diagnostics: Option<ContextDiagnosticsView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) remember_selection: Option<start::RememberSelectionView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) runtime: Option<HostRuntimeView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) checkpoint: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) session_start: Option<session::SessionStateStartReport>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) session_clear: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) session_heartbeat: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) context_check: Option<radar::ContextCheckReport>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) warnings: Vec<String>,
}
pub(crate) struct CompactionNoticeInput<'a> {
pub(crate) repo_root: &'a Path,
pub(crate) profile: Option<&'a str>,
pub(crate) capture: Option<MemoryEvidenceEnvelope>,
pub(crate) protected_write: ExclusiveWriteOptions,
pub(crate) runtime_hints: SubmitRuntimeHints,
}
#[derive(Serialize)]
pub(crate) struct CompactionNoticeReport {
pub(crate) adapter: &'static str,
pub(crate) hook: &'static str,
pub(crate) status: &'static str,
pub(crate) context_check: radar::ContextCheckReport,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) capture: Option<MemoryEvidenceSubmitReport>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) warnings: Vec<String>,
}
#[derive(Serialize)]
pub(crate) struct HostHookRunReport {
command: &'static str,
ok: bool,
path: String,
profile: String,
host: String,
hook: String,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
session_boundary: Option<SessionBoundaryRecommendation>,
#[serde(skip_serializing_if = "Option::is_none")]
context: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
telemetry: Option<ContextTelemetryView>,
#[serde(skip_serializing_if = "Option::is_none")]
diagnostics: Option<ContextDiagnosticsView>,
#[serde(skip_serializing_if = "Option::is_none")]
remember_selection: Option<start::RememberSelectionView>,
#[serde(skip_serializing_if = "Option::is_none")]
runtime: Option<HostRuntimeView>,
#[serde(skip_serializing_if = "Option::is_none")]
checkpoint: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
session_start: Option<session::SessionStateStartReport>,
#[serde(skip_serializing_if = "Option::is_none")]
session_clear: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
session_heartbeat: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
context_check: Option<radar::ContextCheckReport>,
#[serde(skip_serializing_if = "Option::is_none")]
capture: Option<MemoryEvidenceSubmitReport>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
warnings: Vec<String>,
}
impl CommandReport for HostHookRunReport {
fn exit_code(&self) -> std::process::ExitCode {
std::process::ExitCode::SUCCESS
}
fn render_text(&self) {
println!("{} {} -> {}", self.host, self.hook, self.status);
if let Some(detail) = &self.detail {
println!("{detail}");
}
render_section("session boundary", self.session_boundary.as_ref());
render_section("context", self.context.as_ref());
render_section("telemetry", self.telemetry.as_ref());
render_section("diagnostics", self.diagnostics.as_ref());
render_section("remember selection", self.remember_selection.as_ref());
render_section("runtime", self.runtime.as_ref());
render_section("checkpoint", self.checkpoint.as_ref());
render_section("session start", self.session_start.as_ref());
render_section("session clear", self.session_clear.as_ref());
render_section("session heartbeat", self.session_heartbeat.as_ref());
if let Some(context_check) = &self.context_check {
println!();
context_check.render_text();
}
if let Some(capture) = &self.capture {
println!();
capture.render_text();
}
for warning in &self.warnings {
println!("warning: {warning}");
}
}
}
pub(crate) fn named(name: &str) -> Option<&'static dyn HostAdapter> {
let canonical = match name {
"claude-code" => "claude",
"open-code" => "opencode",
other => other,
};
all()
.into_iter()
.find(|adapter| adapter.name() == canonical)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_hook(
repo_root: &Path,
profile: &str,
adapter_name: &str,
hook: &str,
session_start: HostSessionStartOptions,
diagnostics: bool,
host_total_context_chars: Option<u64>,
attempt_outcome: Option<work_stream_decay::AttemptOutcome>,
capture: Option<MemoryEvidenceEnvelope>,
protected_write: ExclusiveWriteOptions,
runtime_hints: SubmitRuntimeHints,
) -> Result<HostHookRunReport> {
let adapter = named(adapter_name)
.ok_or_else(|| anyhow::anyhow!("unknown host adapter `{adapter_name}`"))?;
if attempt_outcome.is_some() && hook != ON_AGENT_END {
anyhow::bail!("--attempt-outcome is only supported for --hook on-agent-end");
}
let mut report = HostHookRunReport {
command: "host-hook",
ok: true,
path: repo_root.display().to_string(),
profile: profile.to_owned(),
host: adapter.name().to_owned(),
hook: hook.to_owned(),
status: String::new(),
detail: None,
session_boundary: None,
context: None,
telemetry: None,
diagnostics: None,
remember_selection: None,
runtime: None,
checkpoint: None,
session_start: None,
session_clear: None,
session_heartbeat: None,
context_check: None,
capture: None,
warnings: Vec::new(),
};
match hook {
ON_SESSION_START => {
let hook_report = adapter.on_session_start(HookInput {
repo_root,
profile,
protected_write: &protected_write,
session_start,
diagnostics,
host_total_context_chars,
attempt_outcome,
})?;
apply_hook_report(&mut report, hook_report);
}
BEFORE_PROMPT_BUILD => {
let hook_report = adapter.before_prompt_build(HookInput {
repo_root,
profile,
protected_write: &protected_write,
session_start,
diagnostics,
host_total_context_chars,
attempt_outcome,
})?;
apply_hook_report(&mut report, hook_report);
}
ON_COMPACTION_NOTICE => {
let hook_report = adapter.on_compaction_notice(CompactionNoticeInput {
repo_root,
profile: Some(profile),
capture,
protected_write,
runtime_hints,
})?;
report.hook = hook_report.hook.to_owned();
report.status = hook_report.status.to_owned();
report.context_check = Some(hook_report.context_check);
report.capture = hook_report.capture;
report.warnings = hook_report.warnings;
}
ON_AGENT_END => {
let hook_report = adapter.on_agent_end(HookInput {
repo_root,
profile,
protected_write: &protected_write,
session_start,
diagnostics,
host_total_context_chars,
attempt_outcome,
})?;
apply_hook_report(&mut report, hook_report);
}
ON_SESSION_END => {
let hook_report = adapter.on_session_end(HookInput {
repo_root,
profile,
protected_write: &protected_write,
session_start,
diagnostics,
host_total_context_chars,
attempt_outcome,
})?;
apply_hook_report(&mut report, hook_report);
}
SUPERVISOR_TICK => {
let hook_report = adapter.supervisor_tick(HookInput {
repo_root,
profile,
protected_write: &protected_write,
session_start,
diagnostics,
host_total_context_chars,
attempt_outcome,
})?;
apply_hook_report(&mut report, hook_report);
}
_ => anyhow::bail!("unknown host hook `{hook}`"),
}
Ok(report)
}
pub(crate) fn all() -> [&'static dyn HostAdapter; 6] {
[
&CODEX_ADAPTER,
&CLAUDE_ADAPTER,
&GEMINI_ADAPTER,
&OPENCODE_ADAPTER,
&OPENCLAW_ADAPTER,
&HERMES_ADAPTER,
]
}
fn handle_compaction_notice(
adapter: &'static str,
input: CompactionNoticeInput<'_>,
) -> Result<CompactionNoticeReport> {
let context_check = radar::run_context_check(
input.repo_root,
input.profile,
radar::ContextCheckTrigger::PreCompaction,
)?;
let mut warnings = Vec::new();
let capture = match input.capture {
Some(envelope) => match evidence::submit(
input.repo_root,
input.profile,
envelope,
input.protected_write,
&input.runtime_hints,
) {
Ok(report) => Some(report),
Err(error) => {
warnings.push(format!(
"best-effort compaction capture failed after one submit attempt: {error}"
));
None
}
},
None => None,
};
Ok(CompactionNoticeReport {
adapter,
hook: ON_COMPACTION_NOTICE,
status: "bounded",
context_check,
capture,
warnings,
})
}
fn apply_hook_report(report: &mut HostHookRunReport, hook_report: HostHookReport) {
report.hook = hook_report.hook.to_owned();
report.status = hook_report.status.to_owned();
report.detail = hook_report.detail;
report.session_boundary = hook_report.session_boundary;
report.context = hook_report.context;
report.telemetry = hook_report.telemetry;
report.diagnostics = hook_report.diagnostics;
report.remember_selection = hook_report.remember_selection;
report.runtime = hook_report.runtime;
report.checkpoint = hook_report.checkpoint;
report.session_start = hook_report.session_start;
report.session_clear = hook_report.session_clear;
report.session_heartbeat = hook_report.session_heartbeat;
report.context_check = hook_report.context_check;
report.warnings.extend(hook_report.warnings);
}
fn session_snapshot_from_start_report(
report: &session::SessionStateStartReport,
) -> ContextSessionSnapshot {
ContextSessionSnapshot {
session_id: report.session_id().map(str::to_owned),
started_at_epoch_s: Some(report.started_at_epoch_s()),
last_started_at_epoch_s: Some(report.last_started_at_epoch_s()),
start_count: Some(report.start_count()),
}
}
#[allow(clippy::too_many_arguments)]
fn observe_context(
repo_root: &Path,
profile_name: &str,
host: &str,
hook: &str,
status: &str,
session_boundary: &SessionBoundaryRecommendation,
source_fingerprint: &str,
context: &Value,
sections: &[host_loop::ContextSectionSpec],
session: &ContextSessionSnapshot,
host_total_context_chars: Option<u64>,
include_diagnostics: bool,
) -> Result<(
ContextTelemetryView,
Option<ContextDiagnosticsView>,
Option<String>,
)> {
let profile = profile::resolve(Some(profile_name))?;
let layout = StateLayout::resolve(repo_root, profile)?;
let previous = db::host_loop::latest_for_host_hook(&layout.state_db_path(), host, hook)
.ok()
.flatten();
let observation = host_loop::build_context_observation(
session::now_epoch_s()?,
host,
hook,
status,
Some(session_boundary.action.as_str()),
source_fingerprint,
context,
sections,
previous.as_ref(),
session,
host_total_context_chars,
)?;
let mut warning = None;
match db::StateDb::open_for_layout(&layout) {
Ok(state_db) => {
if let Err(error) = db::host_loop::insert(state_db.conn(), &observation.record) {
warning = Some(format!("failed to persist host-loop telemetry: {error}"));
}
}
Err(error) => {
warning = Some(format!(
"failed to open state DB for host-loop telemetry: {error}"
));
}
}
Ok((
observation.telemetry,
include_diagnostics.then_some(observation.diagnostics),
warning,
))
}
fn handle_session_start(adapter: &'static str, input: HookInput<'_>) -> Result<HostHookReport> {
let locality_id = repo::marker::load(input.repo_root)?.map(|marker| marker.locality_id);
let context_payload = start::build_host_context(
input.repo_root,
Some(input.profile),
true,
start::HostContextMode::Startup,
)?;
let runtime = input.session_start.runtime_view();
let session_start = session::start(
input.repo_root,
Some(input.profile),
locality_id.as_deref(),
input.session_start.clone().into_session_start_options(),
)?;
let mut warnings = Vec::new();
if let Some(warning) = session_start.warning_message() {
warnings.push(warning.to_owned());
}
let session_boundary = context_payload.session_boundary.clone();
let (telemetry, diagnostics) = match observe_context(
input.repo_root,
input.profile,
adapter,
ON_SESSION_START,
"session_bootstrapped",
&session_boundary,
&context_payload.source_fingerprint,
&context_payload.context,
&context_payload.sections,
&session_snapshot_from_start_report(&session_start),
input.host_total_context_chars,
input.diagnostics,
) {
Ok((telemetry, diagnostics, warning)) => {
if let Some(warning) = warning {
warnings.push(warning);
}
(Some(telemetry), diagnostics)
}
Err(error) => {
warnings.push(format!(
"best-effort context telemetry unavailable: {error}"
));
(None, None)
}
};
let context_check = match radar::run_context_check(
input.repo_root,
Some(input.profile),
radar::ContextCheckTrigger::Resume,
) {
Ok(report) => Some(report),
Err(error) => {
warnings.push(format!(
"best-effort resume context check failed after bootstrap: {error}"
));
None
}
};
Ok(HostHookReport {
adapter,
hook: ON_SESSION_START,
status: "session_bootstrapped",
detail: Some(
"bounded startup context is ready and CCD session bootstrap has been recorded"
.to_owned(),
),
session_boundary: Some(session_boundary),
context: Some(context_payload.context),
telemetry,
diagnostics,
remember_selection: input
.diagnostics
.then_some(context_payload.remember_selection)
.flatten(),
runtime,
checkpoint: None,
session_start: Some(session_start),
session_clear: None,
session_heartbeat: None,
context_check,
warnings,
})
}
fn handle_before_prompt_build(
adapter: &'static str,
input: HookInput<'_>,
) -> Result<HostHookReport> {
let context_payload = start::build_host_context(
input.repo_root,
Some(input.profile),
false,
start::HostContextMode::PromptBuild,
)?;
let mut warnings = Vec::new();
let (telemetry, diagnostics) = match observe_context(
input.repo_root,
input.profile,
adapter,
BEFORE_PROMPT_BUILD,
"bounded_context",
&context_payload.session_boundary,
&context_payload.source_fingerprint,
&context_payload.context,
&context_payload.sections,
&context_payload.session,
input.host_total_context_chars,
input.diagnostics,
) {
Ok((telemetry, diagnostics, warning)) => {
if let Some(warning) = warning {
warnings.push(warning);
}
(Some(telemetry), diagnostics)
}
Err(error) => {
warnings.push(format!(
"best-effort context telemetry unavailable: {error}"
));
(None, None)
}
};
Ok(HostHookReport {
adapter,
hook: BEFORE_PROMPT_BUILD,
status: "bounded_context",
detail: Some("bounded CCD prompt-build context is ready for host injection".to_owned()),
session_boundary: Some(context_payload.session_boundary),
context: Some(context_payload.context),
telemetry,
diagnostics,
remember_selection: input
.diagnostics
.then_some(context_payload.remember_selection)
.flatten(),
runtime: None,
checkpoint: None,
session_start: None,
session_clear: None,
session_heartbeat: None,
context_check: None,
warnings,
})
}
fn handle_agent_end(adapter: &'static str, input: HookInput<'_>) -> Result<HostHookReport> {
let mut warnings = Vec::new();
if let Some(attempt_outcome) = input.attempt_outcome {
let profile = profile::resolve(Some(input.profile))?;
let layout = StateLayout::resolve(input.repo_root, profile)?;
match session::load_session_id(&layout)? {
Some(session_id) => {
if let Err(error) = work_stream_decay::record_attempt_outcome(
&layout,
&session_id,
attempt_outcome,
session::now_epoch_s()?,
) {
warnings.push(format!(
"best-effort work-stream decay update unavailable: {error}"
));
}
}
None => warnings.push(
"attempt outcome was ignored because no active CCD session is tracked".to_owned(),
),
}
}
Ok(HostHookReport {
adapter,
hook: ON_AGENT_END,
status: "checkpoint_evaluated",
detail: Some(
"lightweight checkpoint evaluation is ready; the session lease remains active"
.to_owned(),
),
session_boundary: None,
context: None,
telemetry: None,
diagnostics: None,
remember_selection: None,
runtime: None,
checkpoint: Some(build_checkpoint_payload(
input.repo_root,
Some(input.profile),
)?),
session_start: None,
session_clear: None,
session_heartbeat: None,
context_check: None,
warnings,
})
}
fn handle_session_end(adapter: &'static str, input: HookInput<'_>) -> Result<HostHookReport> {
let locality_id = repo::marker::load(input.repo_root)?
.map(|marker| marker.locality_id)
.unwrap_or_default();
let session_clear = session::clear(
input.repo_root,
Some(input.profile),
(!locality_id.is_empty()).then_some(locality_id.as_str()),
session::SessionClearOptions {
actor_id: input.protected_write.actor_id.clone(),
reason: Some("host-hook session_end".to_owned()),
},
)?;
let session_clear_value = serde_json::to_value(&session_clear)?;
let status = if session_clear_value
.get("cleared")
.and_then(Value::as_bool)
.unwrap_or(false)
{
"session_cleared"
} else {
"already_clear"
};
Ok(HostHookReport {
adapter,
hook: ON_SESSION_END,
status,
detail: Some("session boundary close-out completed through CCD session clear".to_owned()),
session_boundary: None,
context: None,
telemetry: None,
diagnostics: None,
remember_selection: None,
runtime: None,
checkpoint: None,
session_start: None,
session_clear: Some(session_clear_value),
session_heartbeat: None,
context_check: None,
warnings: Vec::new(),
})
}
fn handle_supervisor_tick(adapter: &'static str, input: HookInput<'_>) -> Result<HostHookReport> {
let context_check = radar::run_context_check(
input.repo_root,
Some(input.profile),
radar::ContextCheckTrigger::SupervisorPoll,
)?;
let session_heartbeat = input
.protected_write
.actor_id
.clone()
.map(|actor_id| {
session::heartbeat(
input.repo_root,
Some(input.profile),
session::SessionHeartbeatOptions {
actor_id,
activity: Some("host-hook supervisor_tick".to_owned()),
},
)
})
.transpose()?
.map(|report| serde_json::to_value(&report))
.transpose()?;
let (status, detail) = if session_heartbeat.is_some() {
(
"heartbeat_recorded",
"supervisor poll recorded a fresh autonomous-session heartbeat".to_owned(),
)
} else {
(
"supervisor_reviewed",
"supervisor poll evaluated refresh state; pass --actor-id to refresh an autonomous lease"
.to_owned(),
)
};
Ok(HostHookReport {
adapter,
hook: SUPERVISOR_TICK,
status,
detail: Some(detail),
session_boundary: None,
context: None,
telemetry: None,
diagnostics: None,
remember_selection: None,
runtime: None,
checkpoint: None,
session_start: None,
session_clear: None,
session_heartbeat,
context_check: Some(context_check),
warnings: Vec::new(),
})
}
fn build_checkpoint_payload(repo_root: &Path, explicit_profile: Option<&str>) -> Result<Value> {
let report = radar::run(repo_root, explicit_profile, true)?;
let value = serde_json::to_value(&report)?;
output::try_filter_json_fields(
value,
&CHECKPOINT_FIELDS
.iter()
.map(|field| field.to_string())
.collect::<Vec<_>>(),
&RADAR_STATE_FIELD_FILTER_SPEC,
)
}
fn render_section<T: Serialize>(title: &str, value: Option<&T>) {
let Some(value) = value else {
return;
};
println!();
println!("{title}:");
match serde_json::to_string_pretty(value) {
Ok(rendered) => println!("{rendered}"),
Err(_) => println!("<unrenderable {title}>"),
}
}
struct CodexAdapter;
struct ClaudeAdapter;
struct GeminiAdapter;
struct OpenCodeAdapter;
struct OpenClawAdapter;
struct HermesAdapter;
static CODEX_ADAPTER: CodexAdapter = CodexAdapter;
static CLAUDE_ADAPTER: ClaudeAdapter = ClaudeAdapter;
static GEMINI_ADAPTER: GeminiAdapter = GeminiAdapter;
static OPENCODE_ADAPTER: OpenCodeAdapter = OpenCodeAdapter;
static OPENCLAW_ADAPTER: OpenClawAdapter = OpenClawAdapter;
static HERMES_ADAPTER: HermesAdapter = HermesAdapter;
impl HostAdapter for CodexAdapter {
fn name(&self) -> &'static str {
"codex"
}
fn snapshot(&self, _repo_root: &Path) -> Result<Option<HostContextSnapshot>> {
crate::telemetry::codex::current()
}
}
impl HostAdapter for ClaudeAdapter {
fn name(&self) -> &'static str {
"claude"
}
fn snapshot(&self, repo_root: &Path) -> Result<Option<HostContextSnapshot>> {
crate::telemetry::claude::current(repo_root)
}
}
impl HostAdapter for GeminiAdapter {
fn name(&self) -> &'static str {
"gemini"
}
fn snapshot(&self, repo_root: &Path) -> Result<Option<HostContextSnapshot>> {
crate::telemetry::gemini::current(repo_root)
}
}
impl HostAdapter for OpenCodeAdapter {
fn name(&self) -> &'static str {
"opencode"
}
fn snapshot(&self, repo_root: &Path) -> Result<Option<HostContextSnapshot>> {
crate::telemetry::opencode::current(repo_root)
}
}
impl HostAdapter for OpenClawAdapter {
fn name(&self) -> &'static str {
"openclaw"
}
fn snapshot(&self, _repo_root: &Path) -> Result<Option<HostContextSnapshot>> {
Ok(None)
}
}
impl HostAdapter for HermesAdapter {
fn name(&self) -> &'static str {
"hermes"
}
fn snapshot(&self, _repo_root: &Path) -> Result<Option<HostContextSnapshot>> {
Ok(None)
}
}