use std::{env, ffi::OsStr, path::Path, process::ExitCode};
use anyhow::Result;
use crate::{
cli::{Agent, ReinjectArgs},
ledger::{LedgerEntry, LedgerStore},
};
const GROK_SESSION_ID_ENV: &str = "GROK_SESSION_ID";
const GROK_HOOK_EVENT_ENV: &str = "GROK_HOOK_EVENT";
pub fn run(
args: ReinjectArgs,
state_dir: &Path,
config: &crate::config::TruthMirrorConfig,
) -> Result<ExitCode> {
if should_skip_claude_reinject_under_grok(args.agent, under_grok_from_env()) {
let roots = grok_skill_search_roots(state_dir);
if roots
.iter()
.any(|root| crate::surface::grok_skill_installed(root))
{
tracing::debug!(
agent = ?args.agent,
"skipping Claude reinjection: Grok env + owned Grok skill installed"
);
return Ok(ExitCode::SUCCESS);
}
eprintln!(
"{} under Grok without owned {} — emitting Claude reinject output; install `truth-mirror install-hooks --grok` for the Grok skill path",
crate::messages::diagnostic_prefix(),
crate::surface::GROK_SKILL_RELATIVE
);
}
let entries = LedgerStore::new(state_dir).blocking_rejections()?;
let memory_skill_section = match crate::memory_skill::reinjection_section(state_dir, config) {
Ok(section) => section,
Err(error) => {
tracing::warn!(
error = %error,
"memory-skill reinjection failed; continuing with unresolved review findings"
);
eprintln!(
"truth-mirror memory-skill reinjection failed; continuing with unresolved review findings: {error}"
);
String::new()
}
};
let output = render(args.agent, &entries, &memory_skill_section);
if !output.is_empty() {
print!("{output}");
}
Ok(ExitCode::SUCCESS)
}
pub fn should_skip_claude_reinject_under_grok(agent: Agent, under_grok: bool) -> bool {
matches!(agent, Agent::Claude) && under_grok
}
pub fn under_grok_from_env() -> bool {
under_grok_from_vars(
env::var_os(GROK_SESSION_ID_ENV).as_deref(),
env::var_os(GROK_HOOK_EVENT_ENV).as_deref(),
)
}
fn under_grok_from_vars(session_id: Option<&OsStr>, hook_event: Option<&OsStr>) -> bool {
env_nonempty(session_id) || env_nonempty(hook_event)
}
fn env_nonempty(value: Option<&OsStr>) -> bool {
value.is_some_and(|v| !v.is_empty())
}
fn grok_skill_search_roots(state_dir: &Path) -> Vec<std::path::PathBuf> {
let mut roots = Vec::new();
if let Some(parent) = state_dir.parent() {
push_unique(&mut roots, parent.to_path_buf());
}
if let Ok(cwd) = env::current_dir() {
push_unique(&mut roots, cwd);
}
if let Some(git_root) = git_repo_root() {
push_unique(&mut roots, git_root);
}
if roots.is_empty() {
roots.push(Path::new(".").to_path_buf());
}
roots
}
fn push_unique(roots: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {
if !roots.iter().any(|root| root == &path) {
roots.push(path);
}
}
fn git_repo_root() -> Option<std::path::PathBuf> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let path = String::from_utf8(output.stdout).ok()?;
let path = path.trim();
if path.is_empty() {
None
} else {
Some(std::path::PathBuf::from(path))
}
}
pub fn render(agent: Agent, entries: &[LedgerEntry], memory_skill_section: &str) -> String {
if entries.is_empty() && memory_skill_section.is_empty() {
return String::new();
}
let agent_name = match agent {
Agent::Claude => "Claude",
Agent::Codex => "Codex",
Agent::Pi => "Pi",
Agent::Grok => "Grok",
};
let mut output = String::new();
let needs_human_count = entries.iter().filter(|e| e.is_needs_human()).count();
let rejection_count = entries.len() - needs_human_count;
if !entries.is_empty() {
if rejection_count > 0 {
output.push_str(&format!(
"Truth Mirror unresolved rejections for {agent_name}. Address these before claiming completion or pushing.\n"
));
} else {
output.push_str(&format!(
"Truth Mirror blockers for {agent_name}: every remaining item needs a HUMAN decision (waive or manual fix). Do not retry petitions; surface this to Ramiro.\n"
));
}
if needs_human_count > 0 {
if rejection_count > 0 {
output.push_str(&format!(
" - {rejection_count} open REJECT(s) require agent action\n"
));
}
output.push_str(&format!(
" - {needs_human_count} escalated to needs-human (visible but not agent-actionable)\n"
));
}
output.push('\n');
}
for entry in entries {
let status_label = if entry.is_needs_human() {
format!("{} {} (awaiting Ramiro)", entry.verdict, entry.disposition)
} else {
format!("{} {}", entry.verdict, entry.disposition)
};
output.push_str(&format!(
"- commit: {}\n status: {}\n claim: {}\n",
entry.commit_sha, status_label, entry.claim
));
if !entry.summary.trim().is_empty() {
output.push_str(&format!(" summary: {}\n", entry.summary));
}
if entry.findings.is_empty() {
output.push_str(" findings: none\n");
} else if !entry.structured_findings.is_empty() {
output.push_str(" findings:\n");
for finding in &entry.structured_findings {
output.push_str(&format!(" - {}\n", finding.display_line()));
}
} else {
output.push_str(" findings:\n");
for finding in &entry.findings {
output.push_str(&format!(" - {finding}\n"));
}
}
if !entry.next_steps.is_empty() {
output.push_str(" next steps:\n");
for step in &entry.next_steps {
output.push_str(&format!(" - {step}\n"));
}
}
if !entry.raw_reviewer_output.trim().is_empty() {
output.push_str(" raw reviewer output:\n");
for line in entry.raw_reviewer_output.trim().lines() {
output.push_str(&format!(" {line}\n"));
}
}
if entry.is_needs_human() {
output.push_str(
" awaiting: human review (agent petitions exhausted; use `truth-mirror resolve --waive --reason <text>` from a TTY)\n",
);
}
}
output.push_str(memory_skill_section);
output
}
#[cfg(test)]
mod tests {
use std::path::Path;
use crate::{
cli::Agent,
config::{MemorySkillCandidateKind, TruthMirrorConfig},
ledger::{
FindingSeverity, LedgerEntry, MACHINE_LEDGER_FILE, ReviewerConfig, StructuredFinding,
Verdict,
},
memory_skill::{
CANDIDATE_SCHEMA_VERSION, CandidateStatus, EvidenceRef, MemorySkillCandidate,
MemorySkillStore,
},
};
use super::{render, should_skip_claude_reinject_under_grok, under_grok_from_vars};
fn rejected_entry() -> LedgerEntry {
LedgerEntry::new_at(
"abc123",
Verdict::Reject,
"CLAIM: bad | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
ReviewerConfig::new("claude", "opus", false),
vec!["unsupported".to_owned()],
100,
)
}
fn structured_entry() -> LedgerEntry {
rejected_entry().with_structured_review(
"The claim is unsupported.",
vec![StructuredFinding {
severity: FindingSeverity::High,
title: "unsupported".to_owned(),
body: "The evidence pointer does not prove the claim.".to_owned(),
file: "src/lib.rs".to_owned(),
line_start: 3,
line_end: 4,
confidence: 95,
recommendation: "Add direct evidence.".to_owned(),
}],
vec!["Run cargo test.".to_owned()],
r#"{"verdict":"REJECT"}"#,
)
}
fn seed_memory_skill_candidate(state_dir: &Path, config: &TruthMirrorConfig) {
let ledger_ref = EvidenceRef {
kind: "ledger".to_owned(),
path: Some(MACHINE_LEDGER_FILE.to_owned()),
selector: Some("commit=abc123".to_owned()),
..EvidenceRef::default()
};
let store = MemorySkillStore::new(state_dir, &config.memory_skill).unwrap();
store
.write_candidate(&MemorySkillCandidate {
schema_version: CANDIDATE_SCHEMA_VERSION,
id: "msk_1".to_owned(),
fingerprint: "sha256:v1:test".to_owned(),
learning_key: "example".to_owned(),
status: CandidateStatus::Pending,
candidate_kind: MemorySkillCandidateKind::HowToSkill,
scope: "project".to_owned(),
source_commit: "abc123".to_owned(),
source_claim: "CLAIM: example | verified: cargo test | evidence: tests:example"
.to_owned(),
truth_label: Verdict::Pass,
ledger_entry_ref: ledger_ref.clone(),
source_run_ref: EvidenceRef {
kind: "run".to_owned(),
path: Some("runs/run-1.json".to_owned()),
run_id: Some("run-1".to_owned()),
artifact: Some("review.json".to_owned()),
..EvidenceRef::default()
},
occurrence_count: 2,
occurrence_refs: vec![ledger_ref.clone()],
slug: "example".to_owned(),
title: "Example".to_owned(),
description: "Capture an example reusable workflow.".to_owned(),
when_to_use: vec!["Use for example workflows.".to_owned()],
procedure_steps: vec!["Inspect the ledger evidence.".to_owned()],
pitfalls: vec!["Do not self-approve.".to_owned()],
verification_steps: vec!["Run cargo test.".to_owned()],
evidence: vec![ledger_ref],
created_at_unix: 100,
})
.unwrap();
}
#[test]
fn reinjection_is_quiet_when_empty() {
assert_eq!(render(Agent::Claude, &[], ""), "");
}
#[test]
fn reinjection_mentions_each_supported_agent() {
for agent in [Agent::Claude, Agent::Codex, Agent::Pi, Agent::Grok] {
let output = render(agent, &[rejected_entry()], "");
assert!(output.contains("Truth Mirror unresolved rejections"));
assert!(output.contains("abc123"));
assert!(output.contains("unsupported"));
}
}
#[test]
fn under_grok_requires_nonempty_grok_only_env() {
use std::ffi::OsStr;
assert!(!under_grok_from_vars(None, None));
assert!(!under_grok_from_vars(Some(OsStr::new("")), None));
assert!(!under_grok_from_vars(None, Some(OsStr::new(""))));
assert!(under_grok_from_vars(Some(OsStr::new("sess-1")), None));
assert!(under_grok_from_vars(
None,
Some(OsStr::new("user_prompt_submit"))
));
assert!(under_grok_from_vars(
Some(OsStr::new("sess-1")),
Some(OsStr::new("user_prompt_submit"))
));
}
#[test]
fn claude_reinject_is_gated_under_grok_other_agents_are_not() {
assert!(should_skip_claude_reinject_under_grok(Agent::Claude, true));
assert!(!should_skip_claude_reinject_under_grok(
Agent::Claude,
false
));
assert!(!should_skip_claude_reinject_under_grok(Agent::Grok, true));
assert!(!should_skip_claude_reinject_under_grok(Agent::Codex, true));
assert!(!should_skip_claude_reinject_under_grok(Agent::Pi, true));
}
#[test]
fn reinjection_includes_structured_provenance() {
let output = render(Agent::Codex, &[structured_entry()], "");
assert!(output.contains("status: REJECT open"));
assert!(output.contains("The claim is unsupported."));
assert!(output.contains("high [src/lib.rs:3-4] unsupported"));
assert!(output.contains("Run cargo test."));
assert!(output.contains(r#"{"verdict":"REJECT"}"#));
}
#[test]
fn reinjection_can_render_memory_skill_section_without_rejections() {
let temp = tempfile::tempdir().unwrap();
let mut config = TruthMirrorConfig::default();
config.skills.enabled = true;
config.memory_skill.enabled = true;
seed_memory_skill_candidate(temp.path(), &config);
let section = crate::memory_skill::reinjection_section(temp.path(), &config).unwrap();
let output = render(Agent::Codex, &[], §ion);
assert!(output.contains("pending memory/skill candidates"));
assert!(output.contains("msk_1 how_to_skill example"));
assert!(output.contains("memory-skill show msk_1"));
assert!(!output.contains("approve"));
assert!(!output.contains("apply"));
assert!(!output.contains("reject"));
}
#[test]
fn reinjection_combines_rejections_and_memory_skill_section() {
let temp = tempfile::tempdir().unwrap();
let mut config = TruthMirrorConfig::default();
config.skills.enabled = true;
config.memory_skill.enabled = true;
seed_memory_skill_candidate(temp.path(), &config);
let section = crate::memory_skill::reinjection_section(temp.path(), &config).unwrap();
let output = render(Agent::Codex, &[structured_entry()], §ion);
assert!(output.contains("Truth Mirror unresolved rejections for Codex"));
assert!(output.contains("high [src/lib.rs:3-4] unsupported"));
assert!(output.contains("pending memory/skill candidates"));
assert!(output.contains("msk_1 how_to_skill example"));
assert!(output.contains("raw reviewer output:\n {\"verdict\":\"REJECT\"}\n"));
assert!(output.contains("memory-skill show msk_1"));
}
}