eval_magic/adapters/harness.rs
1//! The harness adapter API — the single seam between generic dispatch code and
2//! harness-specific behavior.
3//!
4//! The trait is tiered into a **baseline** every harness must implement and
5//! **enhancements** that raise fidelity when a harness has the native support:
6//!
7//! - **Baseline (required):** [`label`](HarnessAdapter::label) and
8//! [`skills_dir`](HarnessAdapter::skills_dir). A new harness compiles with
9//! just these two methods; dispatched through its one-shot CLI (with
10//! `--no-stage` inlining the skill when native staging isn't wired), it
11//! already supports `llm_judge` grading and the `detect-stray-writes`
12//! post-pass.
13//! - **Enhancements (defaulted):** every other method has a default — either a
14//! working generic fallback (e.g. the plain available-skills block) or an
15//! `Unsupported` error naming the enhancement it belongs to (e.g. transcript
16//! ingest, the write guard). Override the methods of an enhancement to wire
17//! it for a harness.
18//!
19//! Generic code resolves an adapter with [`adapter_for`](super::registry::adapter_for)
20//! and then calls the trait — so the [`registry`](super::registry) is the one
21//! place that names a concrete harness for this surface. The impls live in the
22//! per-harness modules ([`claude_code`](super::claude_code),
23//! [`codex`](super::codex), [`opencode`](super::opencode)).
24
25use std::io;
26use std::path::{Path, PathBuf};
27use std::time::Duration;
28
29use crate::core::{AvailableSkill, HarnessRunCapabilities, ToolInvocation};
30use crate::sandbox::GuardMarker;
31
32use super::TranscriptSummary;
33use super::skill_shadow::PluginShadowReport;
34
35/// One harness's tool-name vocabulary: every name its guard hook payloads or
36/// transcript parser can produce, grouped by role. Consumers match against the
37/// union across all harnesses ([`all_tool_vocabulary`](super::registry::all_tool_vocabulary)).
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub struct ToolVocabulary {
40 /// Tools that write the filesystem with a single target path argument.
41 pub write_tools: Vec<String>,
42 /// apply_patch-style tools whose payload carries multiple patch targets.
43 pub patch_tools: Vec<String>,
44 /// Shell-execution tools carrying a `command` argument.
45 pub shell_tools: Vec<String>,
46 /// Read-only tools carrying a target path argument.
47 pub read_tools: Vec<String>,
48}
49
50/// The behavior that varies by harness. Generic dispatch code depends on this
51/// trait, never on a concrete harness variant. See the module docs for the
52/// baseline-vs-enhancement contract.
53pub trait HarnessAdapter {
54 // ── Baseline (required) — every harness implements these ────────────────
55
56 /// **Baseline.** The kebab-case identifier used in CLI flags,
57 /// `dispatch.json`, and the staged `conditions.json`.
58 fn label(&self) -> String;
59
60 /// **Baseline.** The project-local directory staged skills live under for
61 /// this harness. Under `--no-stage` nothing is staged into it, so a
62 /// baseline harness may point this at any repo-local path its discovery
63 /// would read. `None` when the harness declares no skills directory —
64 /// native staging is then unavailable and the run preflight forces
65 /// `--no-stage` (each SKILL.md is inlined into its dispatch prompt).
66 fn skills_dir(&self, repo_root: &Path) -> Option<PathBuf>;
67
68 // ── Run-option capabilities (defaulted) ──────────────────────────────────
69
70 /// The run options the generic `run` preflight may accept for this
71 /// harness. The default is the baseline: no write guard, `--bootstrap` and
72 /// `--stage-name` allowed alongside `--no-stage`. Override alongside the
73 /// enhancement that changes support (e.g. wiring the write guard flips
74 /// `supports_guard`).
75 fn run_capabilities(&self) -> HarnessRunCapabilities {
76 HarnessRunCapabilities {
77 supports_guard: false,
78 supports_bootstrap_with_no_stage: true,
79 supports_stage_name_with_no_stage: true,
80 }
81 }
82
83 /// The project-local config dir names this harness reads or the adapter
84 /// writes (e.g. `.claude`). Staging excludes every harness's config dirs
85 /// when copying a skill's sibling assets, so a stray checked-in config dir
86 /// never rides into a staged env. Via
87 /// [`all_config_dir_names`](super::registry::all_config_dir_names) this list
88 /// also feeds the guard's Bash tamper rule and detect-stray-writes'
89 /// staging-dir lookbehind, so adding a dir here automatically grows the
90 /// write-guard's deny surface. List the parent of
91 /// [`skills_dir`](Self::skills_dir) plus any hook/config dirs the adapter
92 /// writes.
93 fn config_dir_names(&self) -> Vec<String> {
94 Vec::new()
95 }
96
97 /// The tool names this harness's guard hook payloads and parsed transcripts
98 /// use, grouped by role. Via
99 /// [`all_tool_vocabulary`](super::registry::all_tool_vocabulary) this feeds the guard
100 /// arbiter's tool classification and detect-stray-writes' invocation audit,
101 /// so list every name this harness's surfaces produce — even names another
102 /// harness also uses; the union dedups. Default empty: a harness with no
103 /// guard and no transcript parser contributes nothing.
104 fn tool_vocabulary(&self) -> ToolVocabulary {
105 ToolVocabulary::default()
106 }
107
108 // ── Enhancement: native skill staging (defaulted) ────────────────────────
109 // Fallback without it: `--no-stage` inlines each SKILL.md into its
110 // dispatch prompt instead of staging files for native discovery.
111
112 /// **Enhancement: native staging.** Build the conspicuous staged-skill
113 /// slug. The default underscore form is fine for any harness without
114 /// naming rules; a harness with constrained skill names (e.g. OpenCode)
115 /// overrides it. `prefix` must be preserved so cleanup prefix-scans still
116 /// find the staged dir.
117 fn staged_slug(
118 &self,
119 prefix: &str,
120 iteration: u32,
121 condition: &str,
122 skill_name: &str,
123 ) -> String {
124 format!("{prefix}{iteration}-{condition}__{skill_name}")
125 }
126
127 /// **Enhancement: native staging.** Validate a staged-skill identifier
128 /// (generated slug or `--stage-name` override) against this harness's
129 /// naming rules. The default accepts anything.
130 fn validate_stage_name(&self, _name: &str) -> Result<(), String> {
131 Ok(())
132 }
133
134 /// **Enhancement: native staging.** Whether a staged skill's frontmatter
135 /// `name:` is rewritten to its slug so the harness's repo-local discovery
136 /// resolves the staged copy.
137 fn rewrites_frontmatter_name(&self) -> bool {
138 false
139 }
140
141 /// **Enhancement: native staging.** Whether the skill-under-test is
142 /// advertised in the available-skills block under its staged slug (vs. its
143 /// natural name). True for Codex, whose repo-local discovery keys on the
144 /// rewritten frontmatter name. (OpenCode also rewrites the frontmatter to
145 /// the slug yet still advertises the natural name — a known inconsistency
146 /// tracked for a separate fix.)
147 fn advertises_staged_slug_name(&self) -> bool {
148 false
149 }
150
151 /// **Enhancement: native staging.** Render the discoverable skills the way
152 /// this harness natively surfaces them (e.g. Claude Code's Skill-tool
153 /// list, Codex's `## Skills`, OpenCode's `<available_skills>` XML). The
154 /// default is a neutral bulleted list.
155 fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
156 super::skills_block::render_skills_block(
157 super::skills_block::DEFAULT_HEADER,
158 super::skills_block::DEFAULT_ITEM,
159 "",
160 skills,
161 )
162 }
163
164 /// **Enhancement: native staging.** How a staged skill is described as
165 /// discoverable in the neutral slug-disambiguation line (e.g. "via the
166 /// Skill tool").
167 fn skill_surface_phrase(&self) -> String {
168 "as a discoverable skill".to_string()
169 }
170
171 /// **Enhancement: native staging.** The lead-in for the fallback "read the
172 /// skill from `<path>`" instruction when the staged identifier can't be
173 /// resolved.
174 fn skill_unresolved_phrase(&self) -> String {
175 "If the staged skill cannot be resolved".to_string()
176 }
177
178 // ── Enhancement: transcript parser (defaulted) ───────────────────────────
179 // Fallback without it: `transcript_check` assertions grade as
180 // unverifiable, `llm_judge` carries the grading, token/cost/duration go
181 // unrecorded, and run records are assembled by hand (or from
182 // `outputs/final-message.md`) instead of auto-ingested.
183
184 /// **Enhancement: transcript parser.** The filename (under a task's
185 /// `outputs/` dir) this harness's one-shot CLI writes the captured
186 /// transcript to. `None` when no transcript ingest is wired — the ingest
187 /// pipeline then never calls the parsers below.
188 fn cli_events_filename(&self) -> Option<String> {
189 None
190 }
191
192 /// **Enhancement: transcript parser.** Parse the events file this
193 /// harness's one-shot CLI wrote (the captured transcript) into ordered
194 /// tool invocations.
195 fn parse_cli_events(&self, _path: &Path) -> io::Result<Vec<ToolInvocation>> {
196 Err(io::Error::new(
197 io::ErrorKind::Unsupported,
198 format!(
199 "transcript ingest is not wired for the {} harness",
200 self.label()
201 ),
202 ))
203 }
204
205 /// **Enhancement: transcript parser.** The full-summary counterpart of
206 /// [`parse_cli_events`](Self::parse_cli_events): tool invocations, deduped
207 /// token usage, duration, and final message text.
208 fn parse_cli_events_full(&self, _path: &Path) -> io::Result<TranscriptSummary> {
209 Err(io::Error::new(
210 io::ErrorKind::Unsupported,
211 format!(
212 "transcript ingest is not wired for the {} harness",
213 self.label()
214 ),
215 ))
216 }
217
218 /// **Enhancement: transcript parser.** Whether the parsed transcript
219 /// exposes a deterministic skill-invocation event the `__skill_invoked`
220 /// meta-check can match. False for Codex (its JSONL has no skill-tool
221 /// event), which routes the meta-check to the LLM-judge fallback.
222 fn transcript_surfaces_skill_invocation(&self) -> bool {
223 true
224 }
225
226 // ── Enhancement: model flag (defaulted) ──────────────────────────────────
227 // Fallback without it: `--agent-model` / `--judge-model` are recorded as
228 // provenance only; dispatches run on the harness's default model.
229
230 /// **Enhancement: model flag.** The native model-selection flag accepted
231 /// by this harness's CLI. `None` means no model-selection support is
232 /// wired.
233 fn cli_model_flag(&self) -> Option<String> {
234 None
235 }
236
237 // ── Enhancement: write guard (defaulted) ─────────────────────────────────
238 // Fallback without it: the `detect-stray-writes` post-pass (folded into
239 // `ingest`) audits out-of-bounds writes after the fact.
240
241 /// **Enhancement: write guard.** Arm the write guard using this harness's
242 /// native pre-tool hook surface, returning the staged marker path. The
243 /// guard's allowed roots are derived from `stage_root` (the isolated env /
244 /// agent cwd), so it bounds the agent to the same env boundary that
245 /// isolates its reads.
246 fn install_guard(
247 &self,
248 _stage_root: &Path,
249 _guard_exe: &Path,
250 _ttl: Option<Duration>,
251 ) -> io::Result<PathBuf> {
252 Err(io::Error::new(
253 io::ErrorKind::Unsupported,
254 format!("--guard is not supported for the {} harness", self.label()),
255 ))
256 }
257
258 /// **Enhancement: write guard.** The banner printed after `--guard`
259 /// successfully arms, describing the harness's native hook surface and how
260 /// to remove it. `None` for a harness with no write guard (its
261 /// [`install_guard`](Self::install_guard) errors), in which case no banner
262 /// is printed.
263 fn guard_armed_message(&self) -> Option<String> {
264 None
265 }
266
267 /// **Enhancement: write guard.** Evaluate a PreToolUse hook `payload`
268 /// against `marker`, returning the serialized deny verdict to print on
269 /// stdout, or `None` to allow. The default fails open — a harness with no
270 /// guard never denies — matching the hook entry points' contract that a
271 /// guard invocation can never brick a session.
272 fn guard_verdict(&self, _payload: &str, _marker: Option<GuardMarker>) -> Option<String> {
273 None
274 }
275
276 /// **Enhancement: write guard.** A hook-config dir the guard install
277 /// created outside [`skills_dir`](Self::skills_dir) (e.g. Codex's
278 /// `.codex/`), which teardown prunes when restoring the original config
279 /// leaves it empty. `None` when the guard writes only under existing dirs.
280 fn guard_hook_cleanup_dir(&self, _stage_root: &Path) -> Option<PathBuf> {
281 None
282 }
283
284 // ── Enhancement: shadow preflight (defaulted) ────────────────────────────
285 // Fallback without it: no preflight — the run proceeds with no shadow
286 // report, exactly as for a harness whose dispatches load nothing global.
287
288 /// **Enhancement: shadow preflight.** Detect staged skill names that are
289 /// also discoverable from the operator's live environment (e.g. Claude
290 /// Code's enabled plugins or global skills dir), which contaminates the
291 /// with/without comparison. `scan_root` is a real staged env root — its
292 /// project-local settings participate in detection. `None` when the
293 /// harness has no shadow preflight (the default) or nothing is shadowed.
294 fn detect_shadowed_skills(
295 &self,
296 _scan_root: &Path,
297 _staged_skill_names: &[&str],
298 ) -> Option<PluginShadowReport> {
299 None
300 }
301
302 // ── Enhancement: plan-mode context (defaulted) ───────────────────────────
303
304 /// **Enhancement: plan-mode context.** Wrap a plan-mode profile as an
305 /// operating-context layer. The shared `<system-reminder>` default
306 /// usually suffices; a harness with a real native plan mode could inject
307 /// it differently.
308 fn render_plan_mode_context(&self, profile_text: &str) -> String {
309 let trimmed = profile_text.trim();
310 if trimmed.is_empty() {
311 return String::new();
312 }
313 format!("<system-reminder>\n{trimmed}\n</system-reminder>")
314 }
315
316 // ── Enhancement: dispatch recipes (defaulted) ────────────────────────────
317 // Fallback without them: `run` prints the generic handoff and the runbook
318 // carries no copy-pasteable per-task command.
319
320 /// **Enhancement: dispatch recipes.** Whether a copy-pasteable per-task
321 /// exec command is wired (the descriptor's `[dispatch] exec_template`).
322 /// `false` means `RUNBOOK.md` / `dispatch-manifest.md` carry handoff
323 /// guidance without a per-task command recipe, and the `run` preflight
324 /// warns naming that limitation.
325 fn has_dispatch_recipes(&self) -> bool {
326 false
327 }
328
329 /// **Enhancement: dispatch recipes.** The `Next:` guidance printed after
330 /// `run`: how to dispatch each task through this harness's one-shot CLI
331 /// and then ingest. Empty when no dispatch recipe is wired.
332 fn cli_next_steps(&self, _ctx: CliDispatchContext<'_>) -> String {
333 String::new()
334 }
335
336 /// **Enhancement: dispatch recipes.** Extra `dispatch-manifest.md` lines
337 /// describing this harness's dispatch recipe (command template, parallel
338 /// recipe, ingest note). `None` when the harness contributes no manifest
339 /// section.
340 fn cli_manifest_section(&self, _ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
341 None
342 }
343
344 /// **Enhancement: dispatch recipes.** The post-`grade` / post-`ingest`
345 /// judge dispatch guidance for this harness. `None` leaves the generic
346 /// judge handoff in place.
347 fn cli_judge_next_steps(&self, _ctx: CliJudgeContext<'_>) -> Option<String> {
348 None
349 }
350}
351
352/// The shared (human-followed) `RUNBOOK.md` template used by every run,
353/// regardless of harness (Claude Code, Codex, OpenCode).
354pub const RUNBOOK_TEMPLATE: &str = include_str!("../../profiles/shared/runbook.md");
355
356/// Context for rendering a harness's one-shot CLI agent-dispatch guidance.
357#[derive(Debug, Clone, Copy)]
358pub struct CliDispatchContext<'a> {
359 pub guard: bool,
360 pub target_args: &'a str,
361 pub iteration: u32,
362 pub agent_model: Option<&'a str>,
363}
364
365/// Context for rendering a harness's `dispatch-manifest.md` CLI recipe.
366#[derive(Debug, Clone, Copy)]
367pub struct CliManifestContext<'a> {
368 pub guard: bool,
369 pub agent_model: Option<&'a str>,
370}
371
372/// Context for rendering a harness's one-shot CLI judge-dispatch guidance.
373#[derive(Debug, Clone, Copy)]
374pub struct CliJudgeContext<'a> {
375 pub guard: bool,
376 pub iteration_dir: &'a Path,
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use crate::adapters::registry::adapter_for;
383 use crate::core::Harness;
384
385 // Cross-method invariants (guard/banner lockstep, hook-matcher ⊆
386 // vocabulary, slug ↔ naming rules, …) are enforced at descriptor load
387 // time in `descriptor::validation`; only per-value pins live here.
388
389 #[test]
390 fn detect_shadowed_skills_defaults_to_none_for_harnesses_without_a_preflight() {
391 for h in [
392 Harness::resolve("codex").unwrap(),
393 Harness::resolve("opencode").unwrap(),
394 ] {
395 assert_eq!(
396 adapter_for(h).detect_shadowed_skills(Path::new("/nonexistent"), &["any-skill"]),
397 None
398 );
399 }
400 }
401
402 #[test]
403 fn has_dispatch_recipes_matches_the_readme_support_table() {
404 assert!(adapter_for(Harness::resolve("claude-code").unwrap()).has_dispatch_recipes());
405 assert!(adapter_for(Harness::resolve("codex").unwrap()).has_dispatch_recipes());
406 assert!(!adapter_for(Harness::resolve("opencode").unwrap()).has_dispatch_recipes());
407 }
408
409 #[test]
410 fn skills_dir_is_harness_native() {
411 let root = Path::new("/repo");
412 assert_eq!(
413 adapter_for(Harness::resolve("claude-code").unwrap()).skills_dir(root),
414 Some(root.join(".claude").join("skills"))
415 );
416 assert_eq!(
417 adapter_for(Harness::resolve("codex").unwrap()).skills_dir(root),
418 Some(root.join(".agents").join("skills"))
419 );
420 assert_eq!(
421 adapter_for(Harness::resolve("opencode").unwrap()).skills_dir(root),
422 Some(root.join(".opencode").join("skills"))
423 );
424 }
425
426 #[test]
427 fn only_codex_and_opencode_rewrite_frontmatter() {
428 assert!(!adapter_for(Harness::resolve("claude-code").unwrap()).rewrites_frontmatter_name());
429 assert!(adapter_for(Harness::resolve("codex").unwrap()).rewrites_frontmatter_name());
430 assert!(adapter_for(Harness::resolve("opencode").unwrap()).rewrites_frontmatter_name());
431 }
432
433 #[test]
434 fn plan_mode_context_wraps_in_system_reminder_for_every_harness() {
435 for h in Harness::known() {
436 let out = adapter_for(h).render_plan_mode_context("BODY");
437 assert_eq!(out, "<system-reminder>\nBODY\n</system-reminder>");
438 assert_eq!(adapter_for(h).render_plan_mode_context(" "), "");
439 assert_eq!(
440 adapter_for(h).render_plan_mode_context("\n\n BODY \n\n"),
441 "<system-reminder>\nBODY\n</system-reminder>"
442 );
443 }
444 }
445
446 #[test]
447 fn run_capabilities_capture_run_option_support_by_harness() {
448 let claude = adapter_for(Harness::resolve("claude-code").unwrap()).run_capabilities();
449 assert!(claude.supports_guard);
450 assert!(claude.supports_bootstrap_with_no_stage);
451 assert!(claude.supports_stage_name_with_no_stage);
452
453 let codex = adapter_for(Harness::resolve("codex").unwrap()).run_capabilities();
454 assert!(codex.supports_guard);
455 assert!(!codex.supports_bootstrap_with_no_stage);
456 assert!(!codex.supports_stage_name_with_no_stage);
457
458 let opencode = adapter_for(Harness::resolve("opencode").unwrap()).run_capabilities();
459 assert!(!opencode.supports_guard);
460 assert!(opencode.supports_bootstrap_with_no_stage);
461 assert!(opencode.supports_stage_name_with_no_stage);
462 }
463
464 #[test]
465 fn guard_armed_message_is_harness_specific_and_absent_for_opencode() {
466 // The post-arm `--guard` banner names the harness's native hook surface,
467 // so it lives behind the adapter rather than in generic run code.
468 let claude = adapter_for(Harness::resolve("claude-code").unwrap())
469 .guard_armed_message()
470 .expect("claude code has a write guard");
471 assert!(
472 claude.contains(".claude/settings.local.json"),
473 "claude banner names its hook file: {claude}"
474 );
475
476 let codex = adapter_for(Harness::resolve("codex").unwrap())
477 .guard_armed_message()
478 .expect("codex has a write guard");
479 assert!(
480 codex.contains(".codex/hooks.json"),
481 "codex banner names its hook file: {codex}"
482 );
483
484 // OpenCode has no write guard (its install_guard errors), so there is no
485 // banner to print.
486 assert_eq!(
487 adapter_for(Harness::resolve("opencode").unwrap()).guard_armed_message(),
488 None
489 );
490 }
491
492 #[test]
493 fn staged_slug_default_and_opencode_override_preserve_the_prefix() {
494 let prefix = "slow-powers-eval-";
495 assert_eq!(
496 adapter_for(Harness::resolve("claude-code").unwrap()).staged_slug(
497 prefix,
498 2,
499 "with_skill",
500 "my-skill"
501 ),
502 "slow-powers-eval-2-with_skill__my-skill"
503 );
504 assert_eq!(
505 adapter_for(Harness::resolve("opencode").unwrap()).staged_slug(
506 prefix,
507 2,
508 "with_skill",
509 "my-skill"
510 ),
511 "slow-powers-eval-2-with-skill-my-skill"
512 );
513 }
514}