1use std::io;
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15use crate::core::{AvailableSkill, Harness, ToolInvocation};
16
17use super::TranscriptSummary;
18use super::claude_cli::{
19 claude_exec_command_template, claude_judge_dispatch_recipe, claude_parallel_dispatch_recipe,
20};
21use super::codex_cli::{
22 codex_exec_command_template, codex_judge_dispatch_recipe, codex_parallel_dispatch_recipe,
23};
24use super::{
25 parse_claude_stream_json, parse_claude_stream_json_full, parse_codex_events,
26 parse_codex_events_full, parse_transcript, parse_transcript_full,
27 render_available_skills_block, render_codex_available_skills_block,
28 render_opencode_available_skills_block,
29};
30
31pub trait HarnessAdapter {
34 fn label(&self) -> &'static str;
37
38 fn skills_dir(&self, repo_root: &Path) -> PathBuf;
40
41 fn rewrites_frontmatter_name(&self) -> bool;
44
45 fn advertises_staged_slug_name(&self) -> bool;
51
52 fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String;
56
57 fn skill_surface_phrase(&self) -> &'static str;
60
61 fn skill_unresolved_phrase(&self) -> &'static str;
64
65 fn plan_mode_profile(&self) -> &'static str;
67
68 fn render_plan_mode_context(&self, profile_text: &str) -> String {
71 let trimmed = profile_text.trim();
72 if trimmed.is_empty() {
73 return String::new();
74 }
75 format!("<system-reminder>\n{trimmed}\n</system-reminder>")
76 }
77
78 fn runbook_template(&self) -> &'static str {
86 HEADLESS_RUNBOOK_TEMPLATE
87 }
88
89 fn cli_events_filename(&self) -> Option<&'static str> {
94 None
95 }
96
97 fn cli_model_flag(&self) -> Option<&'static str> {
101 None
102 }
103
104 fn cli_next_steps(&self, _ctx: CliDispatchContext<'_>) -> String {
110 String::new()
111 }
112
113 fn cli_manifest_section(&self, _ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
117 None
118 }
119
120 fn cli_judge_next_steps(&self, _ctx: CliJudgeContext<'_>) -> Option<String> {
124 None
125 }
126
127 fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>>;
129
130 fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary>;
133
134 fn parse_cli_events(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
141 self.parse_transcript(path)
142 }
143
144 fn parse_cli_events_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
146 self.parse_transcript_full(path)
147 }
148
149 fn install_guard(
154 &self,
155 stage_root: &Path,
156 guard_exe: &Path,
157 ttl: Option<Duration>,
158 ) -> io::Result<PathBuf>;
159
160 fn guard_armed_message(&self) -> Option<&'static str> {
166 None
167 }
168}
169
170pub const HEADLESS_RUNBOOK_TEMPLATE: &str =
174 include_str!("../../profiles/shared/runbook-headless.md");
175
176pub struct ClaudeCodeAdapter;
177pub struct CodexAdapter;
178pub struct OpenCodeAdapter;
179
180#[derive(Debug, Clone, Copy)]
182pub struct CliDispatchContext<'a> {
183 pub guard: bool,
184 pub target_args: &'a str,
185 pub iteration: u32,
186 pub agent_model: Option<&'a str>,
187}
188
189#[derive(Debug, Clone, Copy)]
191pub struct CliManifestContext<'a> {
192 pub guard: bool,
193 pub agent_model: Option<&'a str>,
194}
195
196#[derive(Debug, Clone, Copy)]
198pub struct CliJudgeContext<'a> {
199 pub guard: bool,
200 pub iteration_dir: &'a Path,
201}
202
203impl HarnessAdapter for ClaudeCodeAdapter {
204 fn label(&self) -> &'static str {
205 "claude-code"
206 }
207 fn skills_dir(&self, repo_root: &Path) -> PathBuf {
208 repo_root.join(".claude").join("skills")
209 }
210 fn rewrites_frontmatter_name(&self) -> bool {
211 false
212 }
213 fn advertises_staged_slug_name(&self) -> bool {
214 false
215 }
216 fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
217 render_available_skills_block(skills)
218 }
219 fn skill_surface_phrase(&self) -> &'static str {
220 "via the Skill tool"
221 }
222 fn skill_unresolved_phrase(&self) -> &'static str {
223 "If the Skill tool cannot resolve that identifier"
224 }
225 fn plan_mode_profile(&self) -> &'static str {
226 include_str!("../../profiles/claude-code/plan-mode.md")
227 }
228 fn runbook_template(&self) -> &'static str {
229 include_str!("../../profiles/claude-code/runbook.md")
230 }
231 fn cli_events_filename(&self) -> Option<&'static str> {
232 Some("claude-events.jsonl")
233 }
234 fn cli_model_flag(&self) -> Option<&'static str> {
235 Some("--model")
236 }
237 fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
238 format!(
239 "\nNext: iterate the tasks[] array in dispatch.json and dispatch each task (from the env dir — `claude` has no --cd flag) with:\n{}\nThen run `ingest{target_args} --iteration {iteration} --harness claude-code`.",
240 claude_exec_command_template(self.cli_model_flag(), ctx.agent_model),
241 target_args = ctx.target_args,
242 iteration = ctx.iteration
243 )
244 }
245 fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
246 Some(vec![
247 "After all dispatches (Claude Code hybrid):".to_string(),
248 String::new(),
249 "Run one fresh `claude -p` per task from the env dir (`cd <eval-root>` — `claude` has no --cd flag). `--output-format stream-json` requires `--verbose`; detach stdin with `</dev/null` so a permission prompt cannot block and piped task data cannot become extra prompt context; capture stdout as `outputs/claude-events.jsonl` and stderr as `outputs/claude-stderr.log`.".to_string(),
250 String::new(),
251 "```bash".to_string(),
252 claude_exec_command_template(self.cli_model_flag(), ctx.agent_model),
253 "```".to_string(),
254 String::new(),
255 "Parallel dispatch from this iteration directory:".to_string(),
256 String::new(),
257 "```bash".to_string(),
258 claude_parallel_dispatch_recipe(self.cli_model_flag(), ctx.agent_model),
259 "```".to_string(),
260 String::new(),
261 "Then run `eval-magic ingest --harness claude-code --run-mode hybrid`; Claude hybrid ingest reads each task's `outputs/claude-events.jsonl`.".to_string(),
262 String::new(),
263 ])
264 }
265 fn cli_judge_next_steps(&self, ctx: CliJudgeContext<'_>) -> Option<String> {
266 Some(claude_judge_dispatch_recipe(
267 self.cli_model_flag(),
268 ctx.iteration_dir,
269 ))
270 }
271 fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
272 parse_transcript(path)
273 }
274 fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
275 parse_transcript_full(path)
276 }
277 fn parse_cli_events(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
278 parse_claude_stream_json(path)
279 }
280 fn parse_cli_events_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
281 parse_claude_stream_json_full(path)
282 }
283 fn install_guard(
284 &self,
285 stage_root: &Path,
286 guard_exe: &Path,
287 ttl: Option<Duration>,
288 ) -> io::Result<PathBuf> {
289 crate::sandbox::install::install_claude_guard(stage_root, guard_exe, ttl)
290 }
291 fn guard_armed_message(&self) -> Option<&'static str> {
292 Some(
293 "\n🛡 Write guard armed: a PreToolUse hook is staged in .claude/settings.local.json\n and will block writes/installs outside the eval sandbox during dispatches —\n both in-session subagents and `claude -p` (hybrid/headless), which loads the\n hook from the env cwd each dispatch runs in.\n It auto-expires in 6h and is removed on the next run; to remove it now:\n eval-magic teardown-guard",
294 )
295 }
296}
297
298impl HarnessAdapter for CodexAdapter {
299 fn label(&self) -> &'static str {
300 "codex"
301 }
302 fn skills_dir(&self, repo_root: &Path) -> PathBuf {
303 repo_root.join(".agents").join("skills")
304 }
305 fn rewrites_frontmatter_name(&self) -> bool {
306 true
307 }
308 fn advertises_staged_slug_name(&self) -> bool {
309 true
310 }
311 fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
312 render_codex_available_skills_block(skills)
313 }
314 fn skill_surface_phrase(&self) -> &'static str {
315 "as a Codex skill"
316 }
317 fn skill_unresolved_phrase(&self) -> &'static str {
318 "If it does not load as a Codex skill"
319 }
320 fn plan_mode_profile(&self) -> &'static str {
321 include_str!("../../profiles/codex/plan-mode.md")
322 }
323 fn cli_events_filename(&self) -> Option<&'static str> {
324 Some("codex-events.jsonl")
325 }
326 fn cli_model_flag(&self) -> Option<&'static str> {
327 Some("-m")
328 }
329 fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
330 format!(
331 "\nNext: iterate the tasks[] array in dispatch.json and dispatch each task with:\n{}\nThen run `ingest{target_args} --iteration {iteration} --harness codex`.",
332 codex_exec_command_template(self.cli_model_flag(), ctx.guard, ctx.agent_model),
333 target_args = ctx.target_args,
334 iteration = ctx.iteration
335 )
336 }
337 fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
338 Some(vec![
339 "After all dispatches (Codex):".to_string(),
340 String::new(),
341 "Run one fresh `codex --ask-for-approval never 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(),
342 String::new(),
343 "```bash".to_string(),
344 codex_exec_command_template(self.cli_model_flag(), ctx.guard, ctx.agent_model),
345 "```".to_string(),
346 String::new(),
347 "Parallel dispatch from this iteration directory:".to_string(),
348 String::new(),
349 "```bash".to_string(),
350 codex_parallel_dispatch_recipe(self.cli_model_flag(), ctx.guard, ctx.agent_model),
351 "```".to_string(),
352 String::new(),
353 "Then run `eval-magic ingest --harness codex`; Codex transcript ingest reads each task's `outputs/codex-events.jsonl`.".to_string(),
354 String::new(),
355 ])
356 }
357 fn cli_judge_next_steps(&self, ctx: CliJudgeContext<'_>) -> Option<String> {
358 Some(codex_judge_dispatch_recipe(
359 self.cli_model_flag(),
360 ctx.guard,
361 ctx.iteration_dir,
362 ))
363 }
364 fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
365 parse_codex_events(path)
366 }
367 fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
368 parse_codex_events_full(path)
369 }
370 fn install_guard(
371 &self,
372 stage_root: &Path,
373 guard_exe: &Path,
374 ttl: Option<Duration>,
375 ) -> io::Result<PathBuf> {
376 crate::sandbox::install::install_codex_guard(stage_root, guard_exe, ttl)
377 }
378 fn guard_armed_message(&self) -> Option<&'static str> {
379 Some(
380 "\n🛡 Write guard armed: a PreToolUse hook is staged in .codex/hooks.json\n and will block writes/installs outside the eval sandbox during Codex dispatches.\n Dispatch with codex --ask-for-approval never exec --dangerously-bypass-hook-trust so the vetted eval hook runs.\n It auto-expires in 6h and is removed on the next run; to remove it now:\n eval-magic teardown-guard",
381 )
382 }
383}
384
385impl HarnessAdapter for OpenCodeAdapter {
386 fn label(&self) -> &'static str {
387 "opencode"
388 }
389 fn skills_dir(&self, repo_root: &Path) -> PathBuf {
390 repo_root.join(".opencode").join("skills")
391 }
392 fn rewrites_frontmatter_name(&self) -> bool {
393 true
394 }
395 fn advertises_staged_slug_name(&self) -> bool {
396 false
397 }
398 fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
399 render_opencode_available_skills_block(skills)
400 }
401 fn skill_surface_phrase(&self) -> &'static str {
402 "as an OpenCode skill"
403 }
404 fn skill_unresolved_phrase(&self) -> &'static str {
405 "If it does not load as an OpenCode skill"
406 }
407 fn plan_mode_profile(&self) -> &'static str {
408 include_str!("../../profiles/opencode/plan-mode.md")
409 }
410 fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
411 let model_note = if ctx.agent_model.is_some() {
412 " Model selection was recorded as provenance, but the OpenCode adapter has no CLI model flag wired yet."
413 } else {
414 ""
415 };
416 format!(
417 "\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`.",
418 target_args = ctx.target_args,
419 iteration = ctx.iteration
420 )
421 }
422 fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
427 parse_transcript(path)
428 }
429 fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
430 parse_transcript_full(path)
431 }
432 fn install_guard(
433 &self,
434 _stage_root: &Path,
435 _guard_exe: &Path,
436 _ttl: Option<Duration>,
437 ) -> io::Result<PathBuf> {
438 Err(io::Error::new(
439 io::ErrorKind::Unsupported,
440 "--guard is not yet supported for the opencode harness",
441 ))
442 }
443}
444
445pub fn adapter_for(harness: Harness) -> &'static dyn HarnessAdapter {
449 match harness {
450 Harness::ClaudeCode => &ClaudeCodeAdapter,
451 Harness::Codex => &CodexAdapter,
452 Harness::OpenCode => &OpenCodeAdapter,
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 #[test]
461 fn labels_match_kebab_case_identifiers() {
462 assert_eq!(adapter_for(Harness::ClaudeCode).label(), "claude-code");
463 assert_eq!(adapter_for(Harness::Codex).label(), "codex");
464 assert_eq!(adapter_for(Harness::OpenCode).label(), "opencode");
465 }
466
467 #[test]
468 fn skills_dir_is_harness_native() {
469 let root = Path::new("/repo");
470 assert_eq!(
471 adapter_for(Harness::ClaudeCode).skills_dir(root),
472 root.join(".claude").join("skills")
473 );
474 assert_eq!(
475 adapter_for(Harness::Codex).skills_dir(root),
476 root.join(".agents").join("skills")
477 );
478 assert_eq!(
479 adapter_for(Harness::OpenCode).skills_dir(root),
480 root.join(".opencode").join("skills")
481 );
482 }
483
484 #[test]
485 fn only_codex_and_opencode_rewrite_frontmatter() {
486 assert!(!adapter_for(Harness::ClaudeCode).rewrites_frontmatter_name());
487 assert!(adapter_for(Harness::Codex).rewrites_frontmatter_name());
488 assert!(adapter_for(Harness::OpenCode).rewrites_frontmatter_name());
489 }
490
491 #[test]
492 fn plan_mode_context_wraps_in_system_reminder_for_every_harness() {
493 for h in [Harness::ClaudeCode, Harness::Codex, Harness::OpenCode] {
494 let out = adapter_for(h).render_plan_mode_context("BODY");
495 assert_eq!(out, "<system-reminder>\nBODY\n</system-reminder>");
496 assert_eq!(adapter_for(h).render_plan_mode_context(" "), "");
497 }
498 }
499
500 #[test]
501 fn claude_adapter_advertises_cli_events_file_and_model_flag() {
502 let a = adapter_for(Harness::ClaudeCode);
503 assert_eq!(a.cli_events_filename(), Some("claude-events.jsonl"));
504 assert_eq!(a.cli_model_flag(), Some("--model"));
505 }
506
507 #[test]
508 fn guard_armed_message_is_harness_specific_and_absent_for_opencode() {
509 let claude = adapter_for(Harness::ClaudeCode)
512 .guard_armed_message()
513 .expect("claude code has a write guard");
514 assert!(
515 claude.contains(".claude/settings.local.json"),
516 "claude banner names its hook file: {claude}"
517 );
518
519 let codex = adapter_for(Harness::Codex)
520 .guard_armed_message()
521 .expect("codex has a write guard");
522 assert!(
523 codex.contains(".codex/hooks.json"),
524 "codex banner names its hook file: {codex}"
525 );
526
527 assert_eq!(adapter_for(Harness::OpenCode).guard_armed_message(), None);
530 }
531
532 #[test]
533 fn claude_parse_cli_events_full_reads_stream_json_result_event() {
534 use serde_json::json;
535 let dir = tempfile::TempDir::new().unwrap();
536 let path = dir.path().join("claude-events.jsonl");
537 let lines = [
539 json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [
540 {"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "ls"}}
541 ]}}),
542 json!({"type": "result", "subtype": "success", "is_error": false, "result": "Done", "duration_ms": 5637, "usage": {"input_tokens": 1, "output_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}),
543 ];
544 let body = lines
545 .iter()
546 .map(|l| l.to_string())
547 .collect::<Vec<_>>()
548 .join("\n");
549 std::fs::write(&path, format!("{body}\n")).unwrap();
550
551 let a = adapter_for(Harness::ClaudeCode);
552 let summary = a.parse_cli_events_full(&path).unwrap();
553 assert_eq!(summary.final_text, Some("Done".into()));
554 assert_eq!(summary.duration_ms, Some(5637));
555 assert_eq!(summary.tool_invocations.len(), 1);
556 assert_eq!(summary.tool_invocations[0].name, "Bash");
557
558 assert_eq!(a.parse_transcript_full(&path).unwrap().duration_ms, None);
561 }
562
563 #[test]
564 fn codex_parse_cli_events_delegates_to_events_parser() {
565 use serde_json::json;
566 let dir = tempfile::TempDir::new().unwrap();
567 let path = dir.path().join("codex-events.jsonl");
568 let line = json!({"type": "item.completed", "item": {"id": "i1", "type": "command_execution", "command": "bun test", "output": "ok"}});
569 std::fs::write(&path, format!("{line}\n")).unwrap();
570
571 let inv = adapter_for(Harness::Codex).parse_cli_events(&path).unwrap();
572 assert_eq!(inv.len(), 1);
573 assert_eq!(inv[0].name, "command_execution");
574 }
575}