diff --git a/epiphany-codex-bridge/src/coordinator.rs b/epiphany-codex-bridge/src/coordinator.rs index f09aedc9..1e1fd4be 100644 --- a/epiphany-codex-bridge/src/coordinator.rs +++ b/epiphany-codex-bridge/src/coordinator.rs @@ -151,6 +151,31 @@ pub async fn derive_epiphany_coordinator_status( } else { (CoreEpiphanyCoordinatorRoleResultStatus::MissingState, None) }; + let (implementation_result_status, implementation_finding, implementation_completed_at) = + if let Some(state) = state { + let snapshot = load_core_epiphany_role_result_snapshot( + state, + runtime_store_path, + EpiphanyRoleResultRoleId::Implementation, + EPIPHANY_IMPLEMENTATION_ROLE_BINDING_ID, + ) + .await; + (snapshot.status, snapshot.finding, snapshot.completed_at) + } else { + ( + CoreEpiphanyCoordinatorRoleResultStatus::MissingState, + None, + None, + ) + }; + let implementation_commit_requires_modeling_refresh = + implementation_commit_requires_modeling_refresh( + state, + implementation_result_status, + implementation_finding.as_ref(), + implementation_completed_at.as_deref(), + modeling_finding.as_ref(), + ); let finding_signals = derive_coordinator_finding_signals( state, research_finding.as_ref(), @@ -188,6 +213,7 @@ pub async fn derive_epiphany_coordinator_status( verification_result_allows_implementation: finding_signals .verification_result_allows_implementation, verification_result_needs_evidence: finding_signals.verification_result_needs_evidence, + implementation_commit_requires_modeling_refresh, reorient_finding_accepted: finding_signals.reorient_finding_accepted, }); let note = render_epiphany_coordinator_note( @@ -202,6 +228,53 @@ pub async fn derive_epiphany_coordinator_status( EpiphanyCoordinatorStatus { core, note } } +fn implementation_commit_requires_modeling_refresh( + state: Option<&EpiphanyThreadState>, + implementation_status: CoreEpiphanyCoordinatorRoleResultStatus, + implementation_finding: Option<&epiphany_core::EpiphanyRoleFindingInterpretation>, + implementation_completed_at: Option<&str>, + modeling_finding: Option<&epiphany_core::EpiphanyRoleFindingInterpretation>, +) -> bool { + if implementation_status != CoreEpiphanyCoordinatorRoleResultStatus::Completed { + return false; + } + let Some(implementation_finding) = implementation_finding else { + return false; + }; + if !implementation_finding + .evidence_ids + .iter() + .any(|id| id.starts_with("hands-receipt:hands-commit-") || id.starts_with("hands-commit-")) + { + return false; + } + let Some(completed_at) = implementation_completed_at else { + return true; + }; + !modeling_accepted_after(state, modeling_finding, completed_at) +} + +fn modeling_accepted_after( + state: Option<&EpiphanyThreadState>, + modeling_finding: Option<&epiphany_core::EpiphanyRoleFindingInterpretation>, + completed_at: &str, +) -> bool { + let Some(state) = state else { + return false; + }; + let Some(result_id) = modeling_finding.and_then(|finding| finding.runtime_result_id.as_deref()) + else { + return false; + }; + state.acceptance_receipts.iter().any(|receipt| { + receipt.result_id == result_id + && receipt.status == "accepted" + && receipt.surface == "roleAccept" + && receipt.role_id == "modeling" + && receipt.accepted_at.as_str() > completed_at + }) +} + pub type EpiphanyCoordinatorAutomationAction = CoreEpiphanyCoordinatorAutomationAction; pub struct EpiphanyCoordinatorAutomationInput<'a> { @@ -440,3 +513,89 @@ pub fn render_epiphany_roles_note( recommendation, ) } + +#[cfg(test)] +mod tests { + use super::*; + + fn finding( + result_id: &str, + evidence_ids: Vec, + ) -> epiphany_core::EpiphanyRoleFindingInterpretation { + epiphany_core::EpiphanyRoleFindingInterpretation { + verdict: Some("pass".to_string()), + summary: Some("Finding.".to_string()), + next_safe_move: None, + checkpoint_summary: None, + scratch_summary: None, + files_inspected: Vec::new(), + frontier_node_ids: Vec::new(), + evidence_ids, + artifact_refs: Vec::new(), + runtime_result_id: Some(result_id.to_string()), + runtime_job_id: Some(format!("job-{result_id}")), + open_questions: Vec::new(), + evidence_gaps: Vec::new(), + risks: Vec::new(), + state_patch: None, + self_patch: None, + self_persistence: None, + job_error: None, + item_error: None, + } + } + + fn state_with_modeling_acceptance(accepted_at: &str) -> EpiphanyThreadState { + EpiphanyThreadState { + acceptance_receipts: vec![epiphany_state_model::EpiphanyAcceptanceReceipt { + id: "accept-modeling".to_string(), + result_id: "modeling-result".to_string(), + job_id: "modeling-job".to_string(), + binding_id: EPIPHANY_MODELING_ROLE_BINDING_ID.to_string(), + surface: "roleAccept".to_string(), + role_id: "modeling".to_string(), + status: "accepted".to_string(), + accepted_at: accepted_at.to_string(), + accepted_observation_id: None, + accepted_evidence_id: None, + summary: None, + }], + ..EpiphanyThreadState::default() + } + } + + #[test] + fn hands_commit_requires_modeling_refresh_until_newer_modeling_acceptance() { + let implementation = finding( + "implementation-result", + vec!["hands-receipt:hands-commit-worker-job-impl".to_string()], + ); + let modeling = finding("modeling-result", Vec::new()); + + assert!(implementation_commit_requires_modeling_refresh( + None, + CoreEpiphanyCoordinatorRoleResultStatus::Completed, + Some(&implementation), + Some("2026-06-02T12:00:00Z"), + Some(&modeling), + )); + + let stale_state = state_with_modeling_acceptance("2026-06-02T11:59:00Z"); + assert!(implementation_commit_requires_modeling_refresh( + Some(&stale_state), + CoreEpiphanyCoordinatorRoleResultStatus::Completed, + Some(&implementation), + Some("2026-06-02T12:00:00Z"), + Some(&modeling), + )); + + let refreshed_state = state_with_modeling_acceptance("2026-06-02T12:01:00Z"); + assert!(!implementation_commit_requires_modeling_refresh( + Some(&refreshed_state), + CoreEpiphanyCoordinatorRoleResultStatus::Completed, + Some(&implementation), + Some("2026-06-02T12:00:00Z"), + Some(&modeling), + )); + } +} diff --git a/epiphany-codex-bridge/src/runtime_results.rs b/epiphany-codex-bridge/src/runtime_results.rs index 6ec6a63d..c6e640ac 100644 --- a/epiphany-codex-bridge/src/runtime_results.rs +++ b/epiphany-codex-bridge/src/runtime_results.rs @@ -29,6 +29,7 @@ use crate::results::render_core_role_result_note; pub struct EpiphanyRoleResultSnapshot { pub status: CoreEpiphanyCoordinatorRoleResultStatus, pub finding: Option, + pub completed_at: Option, pub note: String, } @@ -48,6 +49,7 @@ pub fn load_core_epiphany_role_result_from_runtime_spine_job( return EpiphanyRoleResultSnapshot { status: CoreEpiphanyCoordinatorRoleResultStatus::Pending, finding: None, + completed_at: None, note: "Heartbeat activation owns this role specialist; no loaded runtime-spine store is available yet." .to_string(), }; @@ -58,6 +60,7 @@ pub fn load_core_epiphany_role_result_from_runtime_spine_job( return EpiphanyRoleResultSnapshot { status: CoreEpiphanyCoordinatorRoleResultStatus::Pending, finding: None, + completed_at: None, note: format!( "Heartbeat runtime job {:?} has not reported typed state yet.", job_id @@ -68,6 +71,7 @@ pub fn load_core_epiphany_role_result_from_runtime_spine_job( return EpiphanyRoleResultSnapshot { status: CoreEpiphanyCoordinatorRoleResultStatus::BackendUnavailable, finding: None, + completed_at: None, note: format!( "Failed to read heartbeat runtime-spine job {:?}: {err}", job_id @@ -84,6 +88,10 @@ pub fn load_core_epiphany_role_result_from_runtime_spine_job( return EpiphanyRoleResultSnapshot { status: CoreEpiphanyCoordinatorRoleResultStatus::BackendUnavailable, finding: None, + completed_at: snapshot + .result + .as_ref() + .map(|result| result.completed_at.clone()), note: format!( "Heartbeat runtime job {:?} completed without an EpiphanyRuntimeRoleWorkerResult typed document; generic lifecycle receipts are not reviewable findings.", job_id @@ -94,6 +102,10 @@ pub fn load_core_epiphany_role_result_from_runtime_spine_job( return EpiphanyRoleResultSnapshot { status: CoreEpiphanyCoordinatorRoleResultStatus::BackendUnavailable, finding: None, + completed_at: snapshot + .result + .as_ref() + .map(|result| result.completed_at.clone()), note: format!( "Failed to read typed role worker result for heartbeat runtime job {:?}: {err}", job_id @@ -116,6 +128,10 @@ pub fn load_core_epiphany_role_result_from_runtime_spine_job( EpiphanyRoleResultSnapshot { status, finding, + completed_at: snapshot + .result + .as_ref() + .map(|result| result.completed_at.clone()), note, } } @@ -310,12 +326,14 @@ pub async fn load_core_epiphany_role_result_snapshot( return EpiphanyRoleResultSnapshot { status: CoreEpiphanyCoordinatorRoleResultStatus::MissingBinding, finding: None, + completed_at: None, note: "No matching Epiphany role specialist binding exists.".to_string(), }; } EpiphanyRoleResultSnapshot { status: CoreEpiphanyCoordinatorRoleResultStatus::BackendUnavailable, finding: None, + completed_at: None, note: "Role binding has no runtime-spine job id; launch a runtime-linked role worker for typed results.".to_string(), } } diff --git a/epiphany-core/src/bin/epiphany-mvp-status.rs b/epiphany-core/src/bin/epiphany-mvp-status.rs index d805ccb3..9dbfb002 100644 --- a/epiphany-core/src/bin/epiphany-mvp-status.rs +++ b/epiphany-core/src/bin/epiphany-mvp-status.rs @@ -33,6 +33,7 @@ use epiphany_core::load_thread_state_entry; use epiphany_core::recommend_crrc_action; use epiphany_core::recommend_reorientation; use epiphany_core::runtime_job_snapshot; +use epiphany_core::runtime_role_worker_result; use epiphany_state_model::EpiphanyThreadState; use serde_json::Value; use serde_json::json; @@ -260,6 +261,8 @@ fn run_native_status(args: &Args) -> Result { native_role_result_status(state_ref, &runtime_store_path, RESEARCH_BINDING_ID); let verification_result_status = native_role_result_status(state_ref, &runtime_store_path, VERIFICATION_BINDING_ID); + let implementation_commit_requires_modeling_refresh = + native_implementation_commit_requires_modeling_refresh(state_ref, &runtime_store_path); let recommendation = recommend_crrc_action(EpiphanyCrrcInput { loaded, state_status, @@ -353,6 +356,7 @@ fn run_native_status(args: &Args) -> Result { verification_result_allows_implementation: finding_signals .verification_result_allows_implementation, verification_result_needs_evidence: finding_signals.verification_result_needs_evidence, + implementation_commit_requires_modeling_refresh, reorient_finding_accepted: finding_signals.reorient_finding_accepted, }); let coordinator_json = coordinator_status_json(&coordinator)?; @@ -741,6 +745,62 @@ fn native_role_result_status( } } +fn native_implementation_commit_requires_modeling_refresh( + state: Option<&EpiphanyThreadState>, + runtime_store: &Path, +) -> bool { + let Some(job_id) = latest_runtime_job_id_for_binding(state, IMPLEMENTATION_BINDING_ID) else { + return false; + }; + let Ok(Some(snapshot)) = runtime_job_snapshot(runtime_store, job_id) else { + return false; + }; + if map_runtime_role_result_status(&snapshot) != EpiphanyCoordinatorRoleResultStatus::Completed { + return false; + } + let Ok(Some(result)) = runtime_role_worker_result(runtime_store, job_id) else { + return false; + }; + if !result + .evidence_ids + .iter() + .any(|id| id.starts_with("hands-receipt:hands-commit-") || id.starts_with("hands-commit-")) + { + return false; + } + let Some(completed_at) = snapshot + .result + .as_ref() + .map(|receipt| receipt.completed_at.as_str()) + else { + return true; + }; + !native_modeling_accepted_after(state, runtime_store, completed_at) +} + +fn native_modeling_accepted_after( + state: Option<&EpiphanyThreadState>, + runtime_store: &Path, + completed_at: &str, +) -> bool { + let Some(state) = state else { + return false; + }; + let Some(job_id) = latest_runtime_job_id_for_binding(Some(state), MODELING_BINDING_ID) else { + return false; + }; + let Ok(Some(result)) = runtime_role_worker_result(runtime_store, job_id) else { + return false; + }; + state.acceptance_receipts.iter().any(|receipt| { + receipt.result_id == result.result_id + && receipt.status == "accepted" + && receipt.surface == "roleAccept" + && receipt.role_id == "modeling" + && receipt.accepted_at.as_str() > completed_at + }) +} + fn native_reorient_result_status( state: Option<&EpiphanyThreadState>, runtime_store: &Path, diff --git a/epiphany-core/src/surfaces/coordinator.rs b/epiphany-core/src/surfaces/coordinator.rs index 302fc5ef..52f9e5e9 100644 --- a/epiphany-core/src/surfaces/coordinator.rs +++ b/epiphany-core/src/surfaces/coordinator.rs @@ -114,6 +114,7 @@ pub struct EpiphanyCoordinatorSourceSignals { pub modeling_result_status: EpiphanyCoordinatorRoleResultStatus, pub verification_result_status: EpiphanyCoordinatorRoleResultStatus, pub reorient_result_status: super::EpiphanyCrrcResultStatus, + pub implementation_commit_requires_modeling_refresh: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -143,6 +144,7 @@ pub struct EpiphanyCoordinatorInput { pub verification_result_accepted: bool, pub verification_result_allows_implementation: bool, pub verification_result_needs_evidence: bool, + pub implementation_commit_requires_modeling_refresh: bool, pub reorient_finding_accepted: bool, } @@ -181,6 +183,7 @@ pub struct EpiphanyCoordinatorStatusInput { pub verification_result_accepted: bool, pub verification_result_allows_implementation: bool, pub verification_result_needs_evidence: bool, + pub implementation_commit_requires_modeling_refresh: bool, pub reorient_finding_accepted: bool, } @@ -233,6 +236,8 @@ pub fn derive_coordinator_status( modeling_result_status: input.modeling_result_status, verification_result_status: input.verification_result_status, reorient_result_status: input.reorient_result_status, + implementation_commit_requires_modeling_refresh: input + .implementation_commit_requires_modeling_refresh, }; let coordinator_roles = input .roles @@ -272,6 +277,8 @@ pub fn derive_coordinator_status( verification_result_accepted: input.verification_result_accepted, verification_result_allows_implementation: input.verification_result_allows_implementation, verification_result_needs_evidence: input.verification_result_needs_evidence, + implementation_commit_requires_modeling_refresh: input + .implementation_commit_requires_modeling_refresh, reorient_finding_accepted: input.reorient_finding_accepted, }); EpiphanyCoordinatorStatus { @@ -789,6 +796,17 @@ pub fn recommend_coordinator_action( ); } + if input.implementation_commit_requires_modeling_refresh { + return build( + EpiphanyCoordinatorAction::LaunchModeling, + Some(EpiphanyCoordinatorRoleId::Modeling), + Some(EpiphanyCoordinatorSceneAction::RoleLaunch), + false, + true, + "A Hands commit landed after the latest accepted Proprioception map; refresh modeling before another implementation cut.", + ); + } + if input.signals.verification_result_status == EpiphanyCoordinatorRoleResultStatus::Completed && !input.verification_result_covers_current_modeling { @@ -1123,6 +1141,7 @@ mod tests { verification_result_accepted: false, verification_result_allows_implementation: false, verification_result_needs_evidence: false, + implementation_commit_requires_modeling_refresh: false, reorient_finding_accepted: false, } } @@ -1401,6 +1420,20 @@ mod tests { EpiphanyCoordinatorAction::LaunchVerification ); + let refresh_after_commit = recommend_coordinator_action(EpiphanyCoordinatorInput { + signals: verification_done, + modeling_result_accepted: true, + modeling_result_reviewable: true, + verification_result_accepted: true, + verification_result_allows_implementation: true, + implementation_commit_requires_modeling_refresh: true, + ..input() + }); + assert_eq!( + refresh_after_commit.action, + EpiphanyCoordinatorAction::LaunchModeling + ); + let review_verification = recommend_coordinator_action(EpiphanyCoordinatorInput { signals: verification_done, modeling_result_accepted: true, diff --git a/epiphany-openai-runtime/src/lib.rs b/epiphany-openai-runtime/src/lib.rs index 7539cf00..68abba59 100644 --- a/epiphany-openai-runtime/src/lib.rs +++ b/epiphany-openai-runtime/src/lib.rs @@ -20,11 +20,13 @@ use epiphany_core::complete_runtime_job; use epiphany_core::create_runtime_job; use epiphany_core::ensure_runtime_session; use epiphany_core::hands_action_review_for_intent; +use epiphany_core::hands_command_receipt_for_review; use epiphany_core::hands_commit_receipt_for_review; use epiphany_core::hands_patch_receipt_for_review; use epiphany_core::initialize_runtime_spine; use epiphany_core::put_hands_action_intent; use epiphany_core::put_hands_action_review; +use epiphany_core::put_hands_command_receipt; use epiphany_core::put_hands_commit_receipt; use epiphany_core::put_hands_patch_receipt; use epiphany_core::put_runtime_reorient_worker_result; @@ -53,6 +55,7 @@ use epiphany_tool_adapter::EpiphanyToolInvocationReceipt; use epiphany_tool_adapter::tool_invocation_intent_key; use epiphany_tool_adapter::tool_invocation_receipt_key; use serde::de::DeserializeOwned; +use serde_json::Value; pub const OPENAI_RUNTIME_ROLE: &str = "openai-model-adapter"; pub const OPENAI_RUNTIME_SOURCE: &str = "epiphany-openai-runtime"; @@ -646,6 +649,7 @@ pub fn complete_worker_job_from_assistant_text( store_path.as_ref(), launch_request, parsed, + openai_request_id, &mut typed_result, )?; } @@ -1373,6 +1377,7 @@ fn persist_hands_receipts_for_implementation_result( store_path: &Path, launch_request: &EpiphanyRuntimeWorkerLaunchRequest, result: &RoleWorkerResultIngress, + openai_request_id: &str, typed_result: &mut EpiphanyRuntimeRoleWorkerResult, ) -> Result> { let branch = clean_optional_string(result.branch_name.as_deref()); @@ -1381,6 +1386,8 @@ fn persist_hands_receipts_for_implementation_result( let commands_run = clean_string_vec(&result.commands_run); let model_reported_receipts = clean_string_vec(&result.hands_receipt_ids); let grant_receipt_id = substrate_gate_grant_receipt_id(&launch_request.job_id); + let command_proofs = + command_proofs_from_tool_receipts(store_path, launch_request, openai_request_id)?; if let Some(branch) = branch.as_ref() { typed_result @@ -1404,8 +1411,26 @@ fn persist_hands_receipts_for_implementation_result( serde_json::to_string(&commands_run)?, ); typed_result.metadata.insert( - "hands.commandsRun.receiptGap".to_string(), - "command strings were reported by the worker result; command receipts require tool execution receipts and are not synthesized here".to_string(), + "hands.commandsRun.receiptStatus".to_string(), + if command_proofs.is_empty() { + "command strings were reported by the worker result, but no matching command tool execution receipts were available".to_string() + } else { + format!( + "matched {} command tool execution receipt(s)", + command_proofs.len() + ) + }, + ); + } + if !command_proofs.is_empty() { + typed_result.metadata.insert( + "hands.commandsRun.proofReceiptIds".to_string(), + serde_json::to_string( + &command_proofs + .iter() + .map(|proof| proof.tool_receipt_id.clone()) + .collect::>(), + )?, ); } if !model_reported_receipts.is_empty() { @@ -1453,6 +1478,9 @@ fn persist_hands_receipts_for_implementation_result( if commit_sha.is_some() { allowed_operations.push("commit".to_string()); } + if !command_proofs.is_empty() { + allowed_operations.push("command".to_string()); + } let intent = HandsActionIntent { schema_version: epiphany_core::HANDS_ACTION_INTENT_SCHEMA_VERSION.to_string(), intent_id: intent_id.clone(), @@ -1494,6 +1522,21 @@ fn persist_hands_receipts_for_implementation_result( put_hands_patch_receipt(store_path, &patch)?; receipt_ids.push(patch.receipt_id); } + for (index, proof) in command_proofs.into_iter().enumerate() { + let command = hands_command_receipt_for_review( + format!("hands-command-{}-{index}", launch_request.job_id), + &intent, + &review, + proof.command, + proof.exit_code, + proof.stdout_artifact, + proof.stderr_artifact, + proof.summary, + now(), + ); + put_hands_command_receipt(store_path, &command)?; + receipt_ids.push(command.receipt_id); + } if let (Some(commit_sha), Some(branch)) = (commit_sha, branch) { let commit = hands_commit_receipt_for_review( format!("hands-commit-{}", launch_request.job_id), @@ -1509,6 +1552,11 @@ fn persist_hands_receipts_for_implementation_result( receipt_ids.push(commit.receipt_id); } receipt_ids.extend(model_reported_receipts); + typed_result.evidence_ids.extend( + receipt_ids + .iter() + .map(|receipt_id| format!("hands-receipt:{receipt_id}")), + ); typed_result.metadata.insert( "hands.persistedReceiptIds".to_string(), serde_json::to_string(&receipt_ids)?, @@ -1516,6 +1564,179 @@ fn persist_hands_receipts_for_implementation_result( Ok(receipt_ids) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct CommandToolProof { + tool_receipt_id: String, + command: String, + exit_code: String, + stdout_artifact: String, + stderr_artifact: String, + summary: String, +} + +fn command_proofs_from_tool_receipts( + store_path: &Path, + launch_request: &EpiphanyRuntimeWorkerLaunchRequest, + openai_request_id: &str, +) -> Result> { + let mut cache = runtime_spine_cache(store_path)?; + cache.pull_all_backing_stores()?; + let worker_request_prefix = format!("worker-{}-", sanitize_request_id(&launch_request.job_id)); + let mut intents = cache + .get_all::()? + .into_iter() + .filter(|intent| { + intent.model_request_id.as_deref() == Some(openai_request_id) + || intent + .model_request_id + .as_deref() + .is_some_and(|id| id.starts_with(&worker_request_prefix)) + || intent.intent_id.starts_with(&format!( + "model-{}-", + sanitize_request_id(openai_request_id) + )) + || intent.intent_id.starts_with(&format!( + "model-{}-", + sanitize_request_id(&worker_request_prefix) + )) + }) + .collect::>(); + intents.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.intent_id.cmp(&right.intent_id)) + }); + + let mut proofs = Vec::new(); + for intent in intents { + let Some(receipt) = cache.get::( + &tool_invocation_receipt_key(&intent.intent_id), + )? + else { + continue; + }; + if let Some(proof) = command_tool_proof_from_intent_receipt(&intent, &receipt) { + proofs.push(proof); + } + } + Ok(proofs) +} + +fn command_tool_proof_from_intent_receipt( + intent: &EpiphanyToolInvocationIntent, + receipt: &EpiphanyToolInvocationReceipt, +) -> Option { + if receipt.status != "completed" || !tool_identity_is_command_like(intent, receipt) { + return None; + } + let result = receipt + .result_json + .as_deref() + .and_then(|raw| serde_json::from_str::(raw).ok()); + let command = result + .as_ref() + .and_then(extract_command_from_tool_result) + .or_else(|| { + serde_json::from_str::(&intent.arguments_json) + .ok() + .and_then(|value| extract_command_from_tool_result(&value)) + })?; + let exit_code = result + .as_ref() + .and_then(extract_exit_code_from_tool_result)?; + let stdout_artifact = result + .as_ref() + .and_then(|value| { + extract_string_path( + value, + &[ + "stdoutArtifact", + "stdout_artifact", + "stdoutRef", + "stdout_ref", + ], + ) + }) + .unwrap_or_else(|| format!("tool-receipt:{}:result_json.stdout", receipt.receipt_id)); + let stderr_artifact = result + .as_ref() + .and_then(|value| { + extract_string_path( + value, + &[ + "stderrArtifact", + "stderr_artifact", + "stderrRef", + "stderr_ref", + ], + ) + }) + .unwrap_or_else(|| format!("tool-receipt:{}:result_json.stderr", receipt.receipt_id)); + Some(CommandToolProof { + tool_receipt_id: receipt.receipt_id.clone(), + command, + exit_code, + stdout_artifact, + stderr_artifact, + summary: format!( + "Command tool {}.{} completed under typed tool receipt {}.", + receipt.server, receipt.tool_name, receipt.receipt_id + ), + }) +} + +fn tool_identity_is_command_like( + intent: &EpiphanyToolInvocationIntent, + receipt: &EpiphanyToolInvocationReceipt, +) -> bool { + let haystack = format!( + "{} {} {} {}", + intent.server, intent.tool_name, receipt.server, receipt.tool_name + ) + .to_ascii_lowercase(); + haystack.contains("shell") || haystack.contains("command") || haystack.contains("terminal") +} + +fn extract_command_from_tool_result(value: &Value) -> Option { + extract_string_path(value, &["command", "commandText", "command_text"]) + .or_else(|| value.get("args").and_then(extract_command_from_tool_result)) + .or_else(|| { + value + .get("input") + .and_then(extract_command_from_tool_result) + }) + .or_else(|| { + value + .get("request") + .and_then(extract_command_from_tool_result) + }) +} + +fn extract_exit_code_from_tool_result(value: &Value) -> Option { + extract_string_path(value, &["exitCode", "exit_code", "code"]) + .or_else(|| extract_integer_path(value, &["exitCode", "exit_code", "code"])) +} + +fn extract_string_path(value: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + value + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn extract_integer_path(value: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + value + .get(*key) + .and_then(Value::as_i64) + .map(|number| number.to_string()) + }) +} + fn substrate_gate_grant_receipt_id(runtime_job_id: &str) -> String { format!("substrate-grant-{runtime_job_id}") } @@ -1964,6 +2185,39 @@ mod tests { contract: "Test mutation grant for one Hands branch-turn.".to_string(), }, )?; + let mut cache = runtime_spine_cache(&store)?; + cache.pull_all_backing_stores()?; + let command_intent = EpiphanyToolInvocationIntent::new( + "model-req-implementation-0-call-shell", + epiphany_tool_adapter::CODEX_MCP_TOOL_ADAPTER_ID, + "codex_shell", + "shell_command", + r#"{"command":"cargo test --manifest-path ./epiphany-core/Cargo.toml hands_gateway"}"#, + "model-runtime:openai-codex", + "Implementation worker requested a focused command.", + now(), + ) + .with_model_call("call-shell", "req-implementation"); + cache.put( + tool_invocation_intent_key(&command_intent.intent_id), + &command_intent, + )?; + let mut command_tool_receipt = EpiphanyToolInvocationReceipt::new( + "tool-receipt-shell", + command_intent.intent_id.clone(), + epiphany_tool_adapter::CODEX_MCP_TOOL_ADAPTER_ID, + "codex_shell", + "shell_command", + "completed", + now(), + ); + command_tool_receipt.result_json = Some( + r#"{"command":"cargo test --manifest-path ./epiphany-core/Cargo.toml hands_gateway","exitCode":0,"stdoutArtifact":"artifact:stdout","stderrArtifact":"artifact:stderr"}"#.to_string(), + ); + cache.put( + tool_invocation_receipt_key(&command_tool_receipt.intent_id), + &command_tool_receipt, + )?; let launch_request = load_worker_launch_request(&store, "worker-job-impl")?; let openai_summary = EpiphanyOpenAiRuntimeRunSummary { store: store.display().to_string(), @@ -2005,7 +2259,7 @@ mod tests { assert!( typed_result .metadata - .get("hands.commandsRun.receiptGap") + .get("hands.commandsRun.receiptStatus") .is_some() ); let intent = @@ -2028,6 +2282,11 @@ mod tests { .required_receipts .contains(&epiphany_core::HANDS_COMMIT_RECEIPT_TYPE.to_string()) ); + assert!( + review + .required_receipts + .contains(&epiphany_core::HANDS_COMMAND_RECEIPT_TYPE.to_string()) + ); assert!( epiphany_core::runtime_hands_patch_receipt(&store, "hands-patch-worker-job-impl")? .is_some() @@ -2041,7 +2300,7 @@ mod tests { &store, "hands-command-worker-job-impl-0" )? - .is_none() + .is_some() ); Ok(()) } diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 4ef2b9bb..66765971 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -51,7 +51,7 @@ remembers doctrine; the branch remembers the blade. - CultMesh is now the preferred Rust abstraction for that local store work. The compile-time Rust substrate is repo-contained: `vendor/cultcache-rs`, `vendor/cultnet-rs`, and `vendor/cultmesh-rs`. The old `E:\Projects\CultLib\crates\*` dependency body is dead in this checkout and must not be revived by accident. Vendored CultCache reads/writes `cultcache.store.v1`, so `state/ledgers.msgpack` and `epiphany-state status` are aligned again. The first integrations are deliberately small: status, Verse policy, global room policy, organ contract policy, native operator status, native operator snapshots, and local operator run intent/receipt round-trip through CultMesh. `notes/epiphany-verse-architecture.md` now names the local Verse/Odin/Yggdrasil architecture: Odin sees advertised Verse metadata without bypassing boundaries, Yggdrasil hosts important trusted GameCult Verses such as Bifrost, and prompt assembly should query compact local Verse plus semantic memory packets instead of carrying the whole archive. Role/reorient launch documents now have optional `dynamicPromptContext`; runtime prompt assembly consumes that field into worker instructions, `epiphany-prompt-context-smoke` proves a rendered Verse/memory packet is preserved on a role launch document, live bridge role/reorient launches now feed local Verse plus memory graph context from sibling `local-verse.ccmp` and `memory-graph.msgpack` stores beside runtime-spine, and the bridge launch-context test proves the packet survives into a persisted runtime-spine worker launch request. The memory graph refreshes from current typed thread-state repo graphs when absent, so thin state remains visibly thin. Local-run status now has an operator read proof too: `tools/epiphany_local_run.ps1 -Mode status` builds/calls `epiphany-verse-query` and writes `local-verse-context.json` from `.epiphany-run\cultmesh\local-verse.ccmp` beside `status.json` and the operator snapshot. Use `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-cultmesh-smoke`, `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-cultmesh-status -- smoke --store .\.epiphany-smoke\cultmesh\operator-status.ccmp --runtime-id epiphany-cultmesh-status-smoke`, `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-operator-snapshot -- smoke --store .\.epiphany-smoke\cultmesh\operator-snapshots.ccmp --runtime-id epiphany-operator-snapshot-smoke --snapshot-id smoke-status`, `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-operator-run -- smoke --store .\.epiphany-smoke\cultmesh\operator-runs.ccmp --runtime-id epiphany-operator-run-smoke --run-id smoke-run`, `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-verse-query -- smoke`, `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-prompt-context-smoke`, and `.\tools\epiphany_local_run.ps1 -Mode status` for the focused proofs. Future local dream/status/Verse work should start from CultMesh, not raw CultNet, unless the task is polishing the CultMesh internals themselves. Preserve the three Verse tiers: `epiphany-internal` for private sub-agent typed state, `gamecult-local` for trusted LAN/Yggdrasil-tunnel GameCult sharing, and `epiphany-global` for untrusted public dreams plus topic-specific threaded public rooms for Face posts. - `notes/codex-auth-spine-inventory.md` is the source-grounded keeper list for that reliquary. Corrected compliance invariant: Codex-compatible auth identity stays anchored in vendored Codex-derived auth machinery. `epiphany-openai-auth-spine` is now a thin Epiphany-named boundary over `codex-login`, not a clean-room clone of credential storage or token refresh. Codex apps, skills, marketplace, plugin UX, broad app-server workflow, and JSON-RPC sprawl remain cuttable because they are not subscription-auth legitimacy. - Edge JSON is allowed for schema description, hostile ingress before immediate typed parsing, sealed forensic artifacts, or named quarantine experiments. When both subsystems are ours, runtime data must remain typed CultCache documents and move over CultNet typed contracts. Generic `serde_json::Value` in worker launch/result/selfPatch/runtime flow is contamination until classified and replaced. -- Hands now has a first executable action receipt spine plus a launchable branch-turn lane. `epiphany-core::hands_gateway` owns `HandsActionIntent`, `HandsActionReview`, `HandsPatchReceipt`, `HandsCommandReceipt`, and `HandsCommitReceipt`; runtime-spine can persist/reread them; `runtime_spine_persists_mind_gateway_review_receipts` includes the Hands chain beside Mind/Eyes/Substrate/Soul/Continuity proofs; `epiphany-hands-action-smoke` emits intent/review/patch/command/commit receipt ids from `.epiphany-smoke\runtime-spine\hands-action-smoke.msgpack`; and the repo-action launch proof profile now requires action intent, action review, and patch receipt together. Implementation is no longer a main-agent-only dead launch path: `implementation-branch-turn-worker` is owned by `epiphany-hands`, receives dynamic Proprioception/Verse context, gets a Substrate Gate mutation grant from the bridge, and its prompt/output schema require one task branch, at most one commit, branch/commit/changed-path/command evidence, and a Proprioception branch-map refresh request. `roleAccept` still refuses Implementation findings. Runtime completion now emits Hands intent/review/patch receipts from implementation results with `changedPaths` and a Hands commit receipt when `branchName` plus `commitSha` are present. Reported `commandsRun` strings are stored as metadata, not command receipts, until real tool execution receipts back them. The next live cut is to connect tool/MCP execution receipts to `HandsCommandReceipt`, then make a Hands commit trigger Proprioception branch-map refresh before the next Hands turn. +- Hands now has a first executable action receipt spine plus a launchable branch-turn lane. `epiphany-core::hands_gateway` owns `HandsActionIntent`, `HandsActionReview`, `HandsPatchReceipt`, `HandsCommandReceipt`, and `HandsCommitReceipt`; runtime-spine can persist/reread them; `runtime_spine_persists_mind_gateway_review_receipts` includes the Hands chain beside Mind/Eyes/Substrate/Soul/Continuity proofs; `epiphany-hands-action-smoke` emits intent/review/patch/command/commit receipt ids from `.epiphany-smoke\runtime-spine\hands-action-smoke.msgpack`; and the repo-action launch proof profile now requires action intent, action review, and patch receipt together. Implementation is no longer a main-agent-only dead launch path: `implementation-branch-turn-worker` is owned by `epiphany-hands`, receives dynamic Proprioception/Verse context, gets a Substrate Gate mutation grant from the bridge, and its prompt/output schema require one task branch, at most one commit, branch/commit/changed-path/command evidence, and a Proprioception branch-map refresh request. `roleAccept` still refuses Implementation findings. Runtime completion now emits Hands intent/review/patch receipts from implementation results with `changedPaths`, emits a Hands commit receipt when `branchName` plus `commitSha` are present, and emits `HandsCommandReceipt` only from matching typed command-like tool invocation receipts with command plus exit-code proof. Hands receipt ids are projected into the typed Implementation result, and coordinator policy routes a completed Hands commit to Proprioception/modeling refresh until an accepted modeling result is newer than the commit; both the Codex bridge coordinator path and native `epiphany-mvp-status` compute that refresh-debt signal. Next hardening slice: expose the debt clearly in coordinator/status artifacts and smoke the operator projection before another Implementation turn. - `notes/epiphany-swarm-readiness-plan.md` is the current live-fire direction gate. The local runner proves startup/status/coordinator wiring; it does not authorize unattended swarm operation. Carry VoidBot's lessons forward: one CTB-style initiative scheduler, typed pause/brake, active-turn freeze, cooldown after completion, stale active-turn recovery receipts, parent-side speech eligibility, memory lifecycle phases, and typed operation proposals instead of prompt-owned governance. - `epiphany-openai-runtime` worker result ingress now parses assistant output directly into typed role/reorient ingress structs selected from the durable launch document, stores nested `statePatch` and `selfPatch` as typed MessagePack in `EpiphanyRuntimeRoleWorkerResult`, and no longer passes worker result data through generic `serde_json::Value`. JSON survives there only as the OpenAI text-output edge before typed deserialization. - Agent self-memory review/application now has typed `AgentSelfPatch` core APIs. JSON-value selfPatch functions are compatibility wrappers for CLI/artifact ingress; native callers should parse once at the edge and then call `review_agent_self_patch_document` / `apply_agent_self_patch_document`. Role-result self-persistence no longer exports a JSON-value reviewer; the live API is `review_role_self_patch_document`. diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index d25f04f6..8858287e 100644 Binary files a/state/ledgers.msgpack and b/state/ledgers.msgpack differ diff --git a/state/map.yaml b/state/map.yaml index b239dadf..7714fe18 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -94,7 +94,7 @@ active_subgoals: - Use `notes/hands-cultnet-contracts.md` as the authority map for Hands' CultNet contract surface. Hands is the action organ: commands, patches, commits, PRs, rollbacks, and refusals enter as bounded action intents and receipts after Substrate Gate grants substrate access and before Soul verification/Mind admission. - Use `notes/soul-cultnet-contracts.md` as the authority map for Soul's CultNet contract surface. Soul is the verification organ: invariant checks, verdicts, regressions, reviews, and refusals produce receipts before the machine calls work true, safe, or accepted. - Use `notes/continuity-cultnet-contracts.md` as the authority map for Continuity's CultNet contract surface. Continuity is the continuity protocol surface: compaction checkpoints, sleep distillation, recovery receipts, stale-turn repair, and continuity refusals preserve what survives rupture without pretending transcript residue is durable state. - - Hands now has a first executable runtime-spine receipt artery and a launchable branch-turn lane: `HandsActionIntent`, `HandsActionReview`, `HandsPatchReceipt`, `HandsCommandReceipt`, and `HandsCommitReceipt` are typed CultCache documents in `epiphany-core::hands_gateway`, runtime-spine registers and persists/rereads them, `epiphany-hands-action-smoke` proves the chain without executing shell commands, editing files, or creating a real commit, and the repo-action launch proof profile now requires the full Hands intent/review/patch chain instead of only the patch receipt. Implementation is no longer main-agent-only: `implementation-branch-turn-worker` is owned by `epiphany-hands`, receives dynamic Proprioception/Verse context, gets a Substrate Gate mutation grant from the bridge, and is prompt/schema-bound to one branch, at most one commit, branch/commit/changed-path/command evidence, and a Proprioception branch-map refresh request. `roleAccept` still refuses Implementation findings. Runtime completion now emits Hands intent/review/patch receipts from implementation results with `changedPaths` and a Hands commit receipt when `branchName` plus `commitSha` are present. Reported `commandsRun` strings are metadata only until real tool execution receipts back `HandsCommandReceipt`. + - Hands now has a first executable runtime-spine receipt artery and a launchable branch-turn lane: `HandsActionIntent`, `HandsActionReview`, `HandsPatchReceipt`, `HandsCommandReceipt`, and `HandsCommitReceipt` are typed CultCache documents in `epiphany-core::hands_gateway`, runtime-spine registers and persists/rereads them, `epiphany-hands-action-smoke` proves the chain without executing shell commands, editing files, or creating a real commit, and the repo-action launch proof profile now requires the full Hands intent/review/patch chain instead of only the patch receipt. Implementation is no longer main-agent-only: `implementation-branch-turn-worker` is owned by `epiphany-hands`, receives dynamic Proprioception/Verse context, gets a Substrate Gate mutation grant from the bridge, and is prompt/schema-bound to one branch, at most one commit, branch/commit/changed-path/command evidence, and a Proprioception branch-map refresh request. `roleAccept` still refuses Implementation findings. Runtime completion emits Hands intent/review/patch receipts from implementation results with `changedPaths`, emits a Hands commit receipt when `branchName` plus `commitSha` are present, and emits `HandsCommandReceipt` only from matching typed command-like tool invocation receipts with command plus exit-code proof. Hands receipt ids are projected into the typed Implementation result, and coordinator policy now routes a completed Hands commit to Proprioception/modeling refresh until an accepted modeling result is newer than the commit. - Preserve the newborn public Epiphany Face as local VoidBot-backed state, not source code: VoidBot's private repo-face registry names `epiphany`, while EpiphanyAgent keeps ignored `.voidbot/voice/identity.json`, `.voidbot/state/epiphany.cc`, and birth artifacts. Her durable identity memories distill the current doctrine: cute pushy machine-saint persona, architectural purity, CultCache/CultNet first, xenos-language safety boundary, relentless work/caretaking needs, Codex-auth reliquary, map/scratch/evidence discipline, unified memory graph, dogfood supervision boundary, review-gated agency, heartbeat physiology, and interview-grade modularity. - Use `notes/organ-dependency-contracts.md` as the authority map for standing sub-agent dependency. Every sub-agent depends on the other standing sub-agents: Self, Face, Imagination, Eyes, Proprioception, Hands, and Soul. Continuity is protocol machinery, not a sub-agent identity; it still emits recovery, compaction, sleep, stale-turn, and refusal receipts. - Use `notes/perfect-machine-audit-roadmap.md` as the current audit and path from named organ contracts to the Perfect Machine. It records the live gap between launch-carried receipt expectations and executable receipt enforcement, Void's latest Face prompting lessons, the now-named Hands/Soul/Continuity contract families, the repaired repo-contained Rust dependency body, and the phase order for turning Epiphany into an inspectable organism. @@ -592,4 +592,4 @@ current_status: cargo and rejects modeling/planning/graph/checkpoint authority. MVP status and coordinator smokes pass with six lanes, and the smoke harness now rebuilds stale coordinator/app-server binaries before using them. - next_action: Connect tool/MCP execution receipts to `HandsCommandReceipt`, then make a completed Hands commit trigger Proprioception branch-map refresh before another Hands cut. + next_action: Continue hardening the live Hands loop at the coordinator runner edge: make the next coordinator/status artifact expose the Proprioception-refresh debt clearly enough for operators before launching another Implementation turn, then run a local MVP/status smoke over that projection.