use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::core::{AvailableSkill, Harness, ToolInvocation};
use super::TranscriptSummary;
use super::codex_cli::{
codex_exec_command_template, codex_judge_dispatch_recipe, codex_parallel_dispatch_recipe,
};
use super::{
parse_codex_events, parse_codex_events_full, parse_transcript, parse_transcript_full,
render_available_skills_block, render_codex_available_skills_block,
render_opencode_available_skills_block,
};
pub trait HarnessAdapter {
fn label(&self) -> &'static str;
fn skills_dir(&self, repo_root: &Path) -> PathBuf;
fn rewrites_frontmatter_name(&self) -> bool;
fn advertises_staged_slug_name(&self) -> bool;
fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String;
fn skill_surface_phrase(&self) -> &'static str;
fn skill_unresolved_phrase(&self) -> &'static str;
fn plan_mode_profile(&self) -> &'static str;
fn render_plan_mode_context(&self, profile_text: &str) -> String {
let trimmed = profile_text.trim();
if trimmed.is_empty() {
return String::new();
}
format!("<system-reminder>\n{trimmed}\n</system-reminder>")
}
fn cli_events_filename(&self) -> Option<&'static str> {
None
}
fn cli_model_flag(&self) -> Option<&'static str> {
None
}
fn cli_next_steps(&self, _ctx: CliDispatchContext<'_>) -> String {
String::new()
}
fn cli_manifest_section(&self, _ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
None
}
fn cli_judge_next_steps(&self, _ctx: CliJudgeContext) -> Option<String> {
None
}
fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>>;
fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary>;
fn install_guard(
&self,
stage_root: &Path,
workspace_root: &Path,
guard_exe: &Path,
ttl: Option<Duration>,
) -> io::Result<PathBuf>;
}
pub struct ClaudeCodeAdapter;
pub struct CodexAdapter;
pub struct OpenCodeAdapter;
#[derive(Debug, Clone, Copy)]
pub struct CliDispatchContext<'a> {
pub guard: bool,
pub target_args: &'a str,
pub iteration: u32,
pub agent_model: Option<&'a str>,
}
#[derive(Debug, Clone, Copy)]
pub struct CliManifestContext<'a> {
pub guard: bool,
pub agent_model: Option<&'a str>,
}
#[derive(Debug, Clone, Copy)]
pub struct CliJudgeContext {
pub guard: bool,
}
impl HarnessAdapter for ClaudeCodeAdapter {
fn label(&self) -> &'static str {
"claude-code"
}
fn skills_dir(&self, repo_root: &Path) -> PathBuf {
repo_root.join(".claude").join("skills")
}
fn rewrites_frontmatter_name(&self) -> bool {
false
}
fn advertises_staged_slug_name(&self) -> bool {
false
}
fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
render_available_skills_block(skills)
}
fn skill_surface_phrase(&self) -> &'static str {
"via the Skill tool"
}
fn skill_unresolved_phrase(&self) -> &'static str {
"If the Skill tool cannot resolve that identifier"
}
fn plan_mode_profile(&self) -> &'static str {
include_str!("../../profiles/claude-code/plan-mode.md")
}
fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
parse_transcript(path)
}
fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
parse_transcript_full(path)
}
fn install_guard(
&self,
stage_root: &Path,
workspace_root: &Path,
guard_exe: &Path,
ttl: Option<Duration>,
) -> io::Result<PathBuf> {
crate::sandbox::install::install_claude_guard(stage_root, workspace_root, guard_exe, ttl)
}
}
impl HarnessAdapter for CodexAdapter {
fn label(&self) -> &'static str {
"codex"
}
fn skills_dir(&self, repo_root: &Path) -> PathBuf {
repo_root.join(".agents").join("skills")
}
fn rewrites_frontmatter_name(&self) -> bool {
true
}
fn advertises_staged_slug_name(&self) -> bool {
true
}
fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
render_codex_available_skills_block(skills)
}
fn skill_surface_phrase(&self) -> &'static str {
"as a Codex skill"
}
fn skill_unresolved_phrase(&self) -> &'static str {
"If it does not load as a Codex skill"
}
fn plan_mode_profile(&self) -> &'static str {
include_str!("../../profiles/codex/plan-mode.md")
}
fn cli_events_filename(&self) -> Option<&'static str> {
Some("codex-events.jsonl")
}
fn cli_model_flag(&self) -> Option<&'static str> {
Some("-m")
}
fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
format!(
"\nNext: iterate the tasks[] array in dispatch.json and dispatch each task with:\n{}\nThen run `ingest{target_args} --iteration {iteration} --harness codex`.",
codex_exec_command_template(self.cli_model_flag(), ctx.guard, ctx.agent_model),
target_args = ctx.target_args,
iteration = ctx.iteration
)
}
fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
Some(vec![
"After all dispatches (Codex):".to_string(),
String::new(),
"Run one fresh `codex exec --json` per task. Detach stdin with `</dev/null` so piped task data cannot become extra prompt context; capture stdout as `outputs/codex-events.jsonl` and stderr as `outputs/codex-stderr.log`.".to_string(),
String::new(),
"```bash".to_string(),
codex_exec_command_template(self.cli_model_flag(), ctx.guard, ctx.agent_model),
"```".to_string(),
String::new(),
"Parallel dispatch from this iteration directory:".to_string(),
String::new(),
"```bash".to_string(),
codex_parallel_dispatch_recipe(self.cli_model_flag(), ctx.guard, ctx.agent_model),
"```".to_string(),
String::new(),
"Then run `eval-magic ingest --harness codex`; Codex transcript ingest reads each task's `outputs/codex-events.jsonl`.".to_string(),
String::new(),
])
}
fn cli_judge_next_steps(&self, ctx: CliJudgeContext) -> Option<String> {
Some(codex_judge_dispatch_recipe(
self.cli_model_flag(),
ctx.guard,
))
}
fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
parse_codex_events(path)
}
fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
parse_codex_events_full(path)
}
fn install_guard(
&self,
stage_root: &Path,
workspace_root: &Path,
guard_exe: &Path,
ttl: Option<Duration>,
) -> io::Result<PathBuf> {
crate::sandbox::install::install_codex_guard(stage_root, workspace_root, guard_exe, ttl)
}
}
impl HarnessAdapter for OpenCodeAdapter {
fn label(&self) -> &'static str {
"opencode"
}
fn skills_dir(&self, repo_root: &Path) -> PathBuf {
repo_root.join(".opencode").join("skills")
}
fn rewrites_frontmatter_name(&self) -> bool {
true
}
fn advertises_staged_slug_name(&self) -> bool {
false
}
fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
render_opencode_available_skills_block(skills)
}
fn skill_surface_phrase(&self) -> &'static str {
"as an OpenCode skill"
}
fn skill_unresolved_phrase(&self) -> &'static str {
"If it does not load as an OpenCode skill"
}
fn plan_mode_profile(&self) -> &'static str {
include_str!("../../profiles/opencode/plan-mode.md")
}
fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
let model_note = if ctx.agent_model.is_some() {
" Model selection was recorded as provenance, but the OpenCode adapter has no CLI model flag wired yet."
} else {
""
};
format!(
"\nNext: iterate the tasks[] array in dispatch.json and dispatch each task with `opencode run`.{model_note} OpenCode transcript ingest is not yet wired, so assemble each task's `run.json`/`timing.json` manually (or capture `opencode run --format json` / `opencode export` output), then run `ingest{target_args} --iteration {iteration} --harness opencode`.",
target_args = ctx.target_args,
iteration = ctx.iteration
)
}
fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
parse_transcript(path)
}
fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
parse_transcript_full(path)
}
fn install_guard(
&self,
_stage_root: &Path,
_workspace_root: &Path,
_guard_exe: &Path,
_ttl: Option<Duration>,
) -> io::Result<PathBuf> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"--guard is not yet supported for the opencode harness",
))
}
}
pub fn adapter_for(harness: Harness) -> &'static dyn HarnessAdapter {
match harness {
Harness::ClaudeCode => &ClaudeCodeAdapter,
Harness::Codex => &CodexAdapter,
Harness::OpenCode => &OpenCodeAdapter,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn labels_match_kebab_case_identifiers() {
assert_eq!(adapter_for(Harness::ClaudeCode).label(), "claude-code");
assert_eq!(adapter_for(Harness::Codex).label(), "codex");
assert_eq!(adapter_for(Harness::OpenCode).label(), "opencode");
}
#[test]
fn skills_dir_is_harness_native() {
let root = Path::new("/repo");
assert_eq!(
adapter_for(Harness::ClaudeCode).skills_dir(root),
root.join(".claude").join("skills")
);
assert_eq!(
adapter_for(Harness::Codex).skills_dir(root),
root.join(".agents").join("skills")
);
assert_eq!(
adapter_for(Harness::OpenCode).skills_dir(root),
root.join(".opencode").join("skills")
);
}
#[test]
fn only_codex_and_opencode_rewrite_frontmatter() {
assert!(!adapter_for(Harness::ClaudeCode).rewrites_frontmatter_name());
assert!(adapter_for(Harness::Codex).rewrites_frontmatter_name());
assert!(adapter_for(Harness::OpenCode).rewrites_frontmatter_name());
}
#[test]
fn plan_mode_context_wraps_in_system_reminder_for_every_harness() {
for h in [Harness::ClaudeCode, Harness::Codex, Harness::OpenCode] {
let out = adapter_for(h).render_plan_mode_context("BODY");
assert_eq!(out, "<system-reminder>\nBODY\n</system-reminder>");
assert_eq!(adapter_for(h).render_plan_mode_context(" "), "");
}
}
}