Skip to main content

eval_magic/adapters/
harness.rs

1//! The harness adapter API — the single seam between generic run-mode code and
2//! harness-specific behavior.
3//!
4//! Every harness-specific concern hangs off the [`HarnessAdapter`] trait: how
5//! discoverable skills are presented in a dispatch prompt, how a persisted
6//! transcript is parsed, where staged skills live, and which native hook the
7//! write guard installs. Generic code resolves an adapter with [`adapter_for`]
8//! and then calls the trait — so [`adapter_for`] is the one place that names a
9//! concrete harness for this surface.
10
11use std::io;
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15use crate::core::{AvailableSkill, Harness, ToolInvocation};
16
17use super::TranscriptSummary;
18use super::codex_cli::{
19    codex_exec_command_template, codex_judge_dispatch_recipe, codex_parallel_dispatch_recipe,
20};
21use super::{
22    parse_codex_events, parse_codex_events_full, parse_transcript, parse_transcript_full,
23    render_available_skills_block, render_codex_available_skills_block,
24    render_opencode_available_skills_block,
25};
26
27/// The behavior that varies by harness. Generic run-mode code depends on this
28/// trait, never on a concrete harness variant.
29pub trait HarnessAdapter {
30    /// The kebab-case identifier used in CLI flags, `dispatch.json`, and the
31    /// staged `conditions.json`.
32    fn label(&self) -> &'static str;
33
34    /// The project-local directory staged skills live under for this harness.
35    fn skills_dir(&self, repo_root: &Path) -> PathBuf;
36
37    /// Whether a staged skill's frontmatter `name:` is rewritten to its slug so
38    /// the harness's repo-local discovery resolves the staged copy.
39    fn rewrites_frontmatter_name(&self) -> bool;
40
41    /// Whether the skill-under-test is advertised in the available-skills block
42    /// under its staged slug (vs. its natural name). True for Codex, whose
43    /// repo-local discovery keys on the rewritten frontmatter name. (OpenCode
44    /// also rewrites the frontmatter to the slug yet still advertises the natural
45    /// name — a known inconsistency tracked for a separate fix.)
46    fn advertises_staged_slug_name(&self) -> bool;
47
48    /// Render the discoverable skills the way this harness natively surfaces
49    /// them (e.g. Claude Code's Skill-tool list, Codex's `## Skills`, OpenCode's
50    /// `<available_skills>` XML).
51    fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String;
52
53    /// How a staged skill is described as discoverable in the neutral
54    /// slug-disambiguation line (e.g. "via the Skill tool").
55    fn skill_surface_phrase(&self) -> &'static str;
56
57    /// The lead-in for the fallback "read the skill from `<path>`" instruction
58    /// when the staged identifier can't be resolved.
59    fn skill_unresolved_phrase(&self) -> &'static str;
60
61    /// The verbatim plan-mode procedure profile bundled for this harness.
62    fn plan_mode_profile(&self) -> &'static str;
63
64    /// Wrap a plan-mode profile as a `<system-reminder>` operating-context
65    /// layer. The default usually suffices.
66    fn render_plan_mode_context(&self, profile_text: &str) -> String {
67        let trimmed = profile_text.trim();
68        if trimmed.is_empty() {
69            return String::new();
70        }
71        format!("<system-reminder>\n{trimmed}\n</system-reminder>")
72    }
73
74    /// For a [`Cli`](crate::core::DispatchMechanism::Cli)-dispatch harness, the
75    /// filename (under a task's `outputs/` dir) its one-shot CLI writes the
76    /// transcript to. `None` when the harness dispatches in-session (no local
77    /// transcript) or has no Cli-mechanism transcript wired yet.
78    fn cli_events_filename(&self) -> Option<&'static str> {
79        None
80    }
81
82    /// For a [`Cli`](crate::core::DispatchMechanism::Cli)-dispatch harness, the
83    /// native model-selection flag accepted by the harness CLI. `None` means the
84    /// adapter has no model-selection support wired yet.
85    fn cli_model_flag(&self) -> Option<&'static str> {
86        None
87    }
88
89    /// The `Next:` guidance printed after `run` for a
90    /// [`Cli`](crate::core::DispatchMechanism::Cli)-dispatch harness: how to
91    /// dispatch each task through this harness's one-shot CLI and then ingest.
92    /// Empty for in-session harnesses (their guidance is the mechanism's, not the
93    /// adapter's).
94    fn cli_next_steps(&self, _ctx: CliDispatchContext<'_>) -> String {
95        String::new()
96    }
97
98    /// Extra `dispatch-manifest.md` lines describing this harness's Cli dispatch
99    /// recipe (command template, parallel recipe, ingest note). `None` when the
100    /// harness contributes no Cli-specific manifest section.
101    fn cli_manifest_section(&self, _ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
102        None
103    }
104
105    /// The post-`grade` / post-`ingest` judge dispatch guidance for a
106    /// [`Cli`](crate::core::DispatchMechanism::Cli)-dispatch harness. `None`
107    /// leaves the generic in-session-style judge handoff in place.
108    fn cli_judge_next_steps(&self, _ctx: CliJudgeContext) -> Option<String> {
109        None
110    }
111
112    /// Parse a persisted transcript into its ordered tool invocations.
113    fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>>;
114
115    /// Parse a persisted transcript into the full summary: tool invocations,
116    /// deduped token usage, duration, and final message text.
117    fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary>;
118
119    /// Arm the write guard using this harness's native pre-tool hook surface,
120    /// returning the staged marker path.
121    fn install_guard(
122        &self,
123        stage_root: &Path,
124        workspace_root: &Path,
125        guard_exe: &Path,
126        ttl: Option<Duration>,
127    ) -> io::Result<PathBuf>;
128}
129
130pub struct ClaudeCodeAdapter;
131pub struct CodexAdapter;
132pub struct OpenCodeAdapter;
133
134/// Context for rendering a harness's one-shot CLI agent-dispatch guidance.
135#[derive(Debug, Clone, Copy)]
136pub struct CliDispatchContext<'a> {
137    pub guard: bool,
138    pub target_args: &'a str,
139    pub iteration: u32,
140    pub agent_model: Option<&'a str>,
141}
142
143/// Context for rendering a harness's `dispatch-manifest.md` CLI recipe.
144#[derive(Debug, Clone, Copy)]
145pub struct CliManifestContext<'a> {
146    pub guard: bool,
147    pub agent_model: Option<&'a str>,
148}
149
150/// Context for rendering a harness's one-shot CLI judge-dispatch guidance.
151#[derive(Debug, Clone, Copy)]
152pub struct CliJudgeContext {
153    pub guard: bool,
154}
155
156impl HarnessAdapter for ClaudeCodeAdapter {
157    fn label(&self) -> &'static str {
158        "claude-code"
159    }
160    fn skills_dir(&self, repo_root: &Path) -> PathBuf {
161        repo_root.join(".claude").join("skills")
162    }
163    fn rewrites_frontmatter_name(&self) -> bool {
164        false
165    }
166    fn advertises_staged_slug_name(&self) -> bool {
167        false
168    }
169    fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
170        render_available_skills_block(skills)
171    }
172    fn skill_surface_phrase(&self) -> &'static str {
173        "via the Skill tool"
174    }
175    fn skill_unresolved_phrase(&self) -> &'static str {
176        "If the Skill tool cannot resolve that identifier"
177    }
178    fn plan_mode_profile(&self) -> &'static str {
179        include_str!("../../profiles/claude-code/plan-mode.md")
180    }
181    fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
182        parse_transcript(path)
183    }
184    fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
185        parse_transcript_full(path)
186    }
187    fn install_guard(
188        &self,
189        stage_root: &Path,
190        workspace_root: &Path,
191        guard_exe: &Path,
192        ttl: Option<Duration>,
193    ) -> io::Result<PathBuf> {
194        crate::sandbox::install::install_claude_guard(stage_root, workspace_root, guard_exe, ttl)
195    }
196}
197
198impl HarnessAdapter for CodexAdapter {
199    fn label(&self) -> &'static str {
200        "codex"
201    }
202    fn skills_dir(&self, repo_root: &Path) -> PathBuf {
203        repo_root.join(".agents").join("skills")
204    }
205    fn rewrites_frontmatter_name(&self) -> bool {
206        true
207    }
208    fn advertises_staged_slug_name(&self) -> bool {
209        true
210    }
211    fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
212        render_codex_available_skills_block(skills)
213    }
214    fn skill_surface_phrase(&self) -> &'static str {
215        "as a Codex skill"
216    }
217    fn skill_unresolved_phrase(&self) -> &'static str {
218        "If it does not load as a Codex skill"
219    }
220    fn plan_mode_profile(&self) -> &'static str {
221        include_str!("../../profiles/codex/plan-mode.md")
222    }
223    fn cli_events_filename(&self) -> Option<&'static str> {
224        Some("codex-events.jsonl")
225    }
226    fn cli_model_flag(&self) -> Option<&'static str> {
227        Some("-m")
228    }
229    fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
230        format!(
231            "\nNext: iterate the tasks[] array in dispatch.json and dispatch each task with:\n{}\nThen run `ingest{target_args} --iteration {iteration} --harness codex`.",
232            codex_exec_command_template(self.cli_model_flag(), ctx.guard, ctx.agent_model),
233            target_args = ctx.target_args,
234            iteration = ctx.iteration
235        )
236    }
237    fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
238        Some(vec![
239            "After all dispatches (Codex):".to_string(),
240            String::new(),
241            "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(),
242            String::new(),
243            "```bash".to_string(),
244            codex_exec_command_template(self.cli_model_flag(), ctx.guard, ctx.agent_model),
245            "```".to_string(),
246            String::new(),
247            "Parallel dispatch from this iteration directory:".to_string(),
248            String::new(),
249            "```bash".to_string(),
250            codex_parallel_dispatch_recipe(self.cli_model_flag(), ctx.guard, ctx.agent_model),
251            "```".to_string(),
252            String::new(),
253            "Then run `eval-magic ingest --harness codex`; Codex transcript ingest reads each task's `outputs/codex-events.jsonl`.".to_string(),
254            String::new(),
255        ])
256    }
257    fn cli_judge_next_steps(&self, ctx: CliJudgeContext) -> Option<String> {
258        Some(codex_judge_dispatch_recipe(
259            self.cli_model_flag(),
260            ctx.guard,
261        ))
262    }
263    fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
264        parse_codex_events(path)
265    }
266    fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
267        parse_codex_events_full(path)
268    }
269    fn install_guard(
270        &self,
271        stage_root: &Path,
272        workspace_root: &Path,
273        guard_exe: &Path,
274        ttl: Option<Duration>,
275    ) -> io::Result<PathBuf> {
276        crate::sandbox::install::install_codex_guard(stage_root, workspace_root, guard_exe, ttl)
277    }
278}
279
280impl HarnessAdapter for OpenCodeAdapter {
281    fn label(&self) -> &'static str {
282        "opencode"
283    }
284    fn skills_dir(&self, repo_root: &Path) -> PathBuf {
285        repo_root.join(".opencode").join("skills")
286    }
287    fn rewrites_frontmatter_name(&self) -> bool {
288        true
289    }
290    fn advertises_staged_slug_name(&self) -> bool {
291        false
292    }
293    fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
294        render_opencode_available_skills_block(skills)
295    }
296    fn skill_surface_phrase(&self) -> &'static str {
297        "as an OpenCode skill"
298    }
299    fn skill_unresolved_phrase(&self) -> &'static str {
300        "If it does not load as an OpenCode skill"
301    }
302    fn plan_mode_profile(&self) -> &'static str {
303        include_str!("../../profiles/opencode/plan-mode.md")
304    }
305    fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
306        let model_note = if ctx.agent_model.is_some() {
307            " Model selection was recorded as provenance, but the OpenCode adapter has no CLI model flag wired yet."
308        } else {
309            ""
310        };
311        format!(
312            "\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`.",
313            target_args = ctx.target_args,
314            iteration = ctx.iteration
315        )
316    }
317    // OpenCode transcript ingest is not yet wired. In the current dispatch flow
318    // this is unreachable (no subagents dir and no events file), so delegating to
319    // the shared JSONL parser preserves the pre-refactor behavior of the
320    // transcript-source branch until OpenCode ingest lands.
321    fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
322        parse_transcript(path)
323    }
324    fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
325        parse_transcript_full(path)
326    }
327    fn install_guard(
328        &self,
329        _stage_root: &Path,
330        _workspace_root: &Path,
331        _guard_exe: &Path,
332        _ttl: Option<Duration>,
333    ) -> io::Result<PathBuf> {
334        Err(io::Error::new(
335            io::ErrorKind::Unsupported,
336            "--guard is not yet supported for the opencode harness",
337        ))
338    }
339}
340
341/// Resolve the adapter for a [`Harness`]. This is the single dispatch point on
342/// the harness variant for all harness-specific behavior; every other module
343/// goes through the returned trait object.
344pub fn adapter_for(harness: Harness) -> &'static dyn HarnessAdapter {
345    match harness {
346        Harness::ClaudeCode => &ClaudeCodeAdapter,
347        Harness::Codex => &CodexAdapter,
348        Harness::OpenCode => &OpenCodeAdapter,
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn labels_match_kebab_case_identifiers() {
358        assert_eq!(adapter_for(Harness::ClaudeCode).label(), "claude-code");
359        assert_eq!(adapter_for(Harness::Codex).label(), "codex");
360        assert_eq!(adapter_for(Harness::OpenCode).label(), "opencode");
361    }
362
363    #[test]
364    fn skills_dir_is_harness_native() {
365        let root = Path::new("/repo");
366        assert_eq!(
367            adapter_for(Harness::ClaudeCode).skills_dir(root),
368            root.join(".claude").join("skills")
369        );
370        assert_eq!(
371            adapter_for(Harness::Codex).skills_dir(root),
372            root.join(".agents").join("skills")
373        );
374        assert_eq!(
375            adapter_for(Harness::OpenCode).skills_dir(root),
376            root.join(".opencode").join("skills")
377        );
378    }
379
380    #[test]
381    fn only_codex_and_opencode_rewrite_frontmatter() {
382        assert!(!adapter_for(Harness::ClaudeCode).rewrites_frontmatter_name());
383        assert!(adapter_for(Harness::Codex).rewrites_frontmatter_name());
384        assert!(adapter_for(Harness::OpenCode).rewrites_frontmatter_name());
385    }
386
387    #[test]
388    fn plan_mode_context_wraps_in_system_reminder_for_every_harness() {
389        for h in [Harness::ClaudeCode, Harness::Codex, Harness::OpenCode] {
390            let out = adapter_for(h).render_plan_mode_context("BODY");
391            assert_eq!(out, "<system-reminder>\nBODY\n</system-reminder>");
392            assert_eq!(adapter_for(h).render_plan_mode_context("   "), "");
393        }
394    }
395}