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 {
199 pub guard: bool,
200}
201
202impl HarnessAdapter for ClaudeCodeAdapter {
203 fn label(&self) -> &'static str {
204 "claude-code"
205 }
206 fn skills_dir(&self, repo_root: &Path) -> PathBuf {
207 repo_root.join(".claude").join("skills")
208 }
209 fn rewrites_frontmatter_name(&self) -> bool {
210 false
211 }
212 fn advertises_staged_slug_name(&self) -> bool {
213 false
214 }
215 fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
216 render_available_skills_block(skills)
217 }
218 fn skill_surface_phrase(&self) -> &'static str {
219 "via the Skill tool"
220 }
221 fn skill_unresolved_phrase(&self) -> &'static str {
222 "If the Skill tool cannot resolve that identifier"
223 }
224 fn plan_mode_profile(&self) -> &'static str {
225 include_str!("../../profiles/claude-code/plan-mode.md")
226 }
227 fn runbook_template(&self) -> &'static str {
228 include_str!("../../profiles/claude-code/runbook.md")
229 }
230 fn cli_events_filename(&self) -> Option<&'static str> {
231 Some("claude-events.jsonl")
232 }
233 fn cli_model_flag(&self) -> Option<&'static str> {
234 Some("--model")
235 }
236 fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
237 format!(
238 "\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`.",
239 claude_exec_command_template(self.cli_model_flag(), ctx.agent_model),
240 target_args = ctx.target_args,
241 iteration = ctx.iteration
242 )
243 }
244 fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
245 Some(vec![
246 "After all dispatches (Claude Code hybrid):".to_string(),
247 String::new(),
248 "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(),
249 String::new(),
250 "```bash".to_string(),
251 claude_exec_command_template(self.cli_model_flag(), ctx.agent_model),
252 "```".to_string(),
253 String::new(),
254 "Parallel dispatch from this iteration directory:".to_string(),
255 String::new(),
256 "```bash".to_string(),
257 claude_parallel_dispatch_recipe(self.cli_model_flag(), ctx.agent_model),
258 "```".to_string(),
259 String::new(),
260 "Then run `eval-magic ingest --harness claude-code --run-mode hybrid`; Claude hybrid ingest reads each task's `outputs/claude-events.jsonl`.".to_string(),
261 String::new(),
262 ])
263 }
264 fn cli_judge_next_steps(&self, _ctx: CliJudgeContext) -> Option<String> {
265 Some(claude_judge_dispatch_recipe(self.cli_model_flag()))
266 }
267 fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
268 parse_transcript(path)
269 }
270 fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
271 parse_transcript_full(path)
272 }
273 fn parse_cli_events(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
274 parse_claude_stream_json(path)
275 }
276 fn parse_cli_events_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
277 parse_claude_stream_json_full(path)
278 }
279 fn install_guard(
280 &self,
281 stage_root: &Path,
282 guard_exe: &Path,
283 ttl: Option<Duration>,
284 ) -> io::Result<PathBuf> {
285 crate::sandbox::install::install_claude_guard(stage_root, guard_exe, ttl)
286 }
287 fn guard_armed_message(&self) -> Option<&'static str> {
288 Some(
289 "\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",
290 )
291 }
292}
293
294impl HarnessAdapter for CodexAdapter {
295 fn label(&self) -> &'static str {
296 "codex"
297 }
298 fn skills_dir(&self, repo_root: &Path) -> PathBuf {
299 repo_root.join(".agents").join("skills")
300 }
301 fn rewrites_frontmatter_name(&self) -> bool {
302 true
303 }
304 fn advertises_staged_slug_name(&self) -> bool {
305 true
306 }
307 fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
308 render_codex_available_skills_block(skills)
309 }
310 fn skill_surface_phrase(&self) -> &'static str {
311 "as a Codex skill"
312 }
313 fn skill_unresolved_phrase(&self) -> &'static str {
314 "If it does not load as a Codex skill"
315 }
316 fn plan_mode_profile(&self) -> &'static str {
317 include_str!("../../profiles/codex/plan-mode.md")
318 }
319 fn cli_events_filename(&self) -> Option<&'static str> {
320 Some("codex-events.jsonl")
321 }
322 fn cli_model_flag(&self) -> Option<&'static str> {
323 Some("-m")
324 }
325 fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
326 format!(
327 "\nNext: iterate the tasks[] array in dispatch.json and dispatch each task with:\n{}\nThen run `ingest{target_args} --iteration {iteration} --harness codex`.",
328 codex_exec_command_template(self.cli_model_flag(), ctx.guard, ctx.agent_model),
329 target_args = ctx.target_args,
330 iteration = ctx.iteration
331 )
332 }
333 fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
334 Some(vec![
335 "After all dispatches (Codex):".to_string(),
336 String::new(),
337 "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(),
338 String::new(),
339 "```bash".to_string(),
340 codex_exec_command_template(self.cli_model_flag(), ctx.guard, ctx.agent_model),
341 "```".to_string(),
342 String::new(),
343 "Parallel dispatch from this iteration directory:".to_string(),
344 String::new(),
345 "```bash".to_string(),
346 codex_parallel_dispatch_recipe(self.cli_model_flag(), ctx.guard, ctx.agent_model),
347 "```".to_string(),
348 String::new(),
349 "Then run `eval-magic ingest --harness codex`; Codex transcript ingest reads each task's `outputs/codex-events.jsonl`.".to_string(),
350 String::new(),
351 ])
352 }
353 fn cli_judge_next_steps(&self, ctx: CliJudgeContext) -> Option<String> {
354 Some(codex_judge_dispatch_recipe(
355 self.cli_model_flag(),
356 ctx.guard,
357 ))
358 }
359 fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
360 parse_codex_events(path)
361 }
362 fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
363 parse_codex_events_full(path)
364 }
365 fn install_guard(
366 &self,
367 stage_root: &Path,
368 guard_exe: &Path,
369 ttl: Option<Duration>,
370 ) -> io::Result<PathBuf> {
371 crate::sandbox::install::install_codex_guard(stage_root, guard_exe, ttl)
372 }
373 fn guard_armed_message(&self) -> Option<&'static str> {
374 Some(
375 "\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",
376 )
377 }
378}
379
380impl HarnessAdapter for OpenCodeAdapter {
381 fn label(&self) -> &'static str {
382 "opencode"
383 }
384 fn skills_dir(&self, repo_root: &Path) -> PathBuf {
385 repo_root.join(".opencode").join("skills")
386 }
387 fn rewrites_frontmatter_name(&self) -> bool {
388 true
389 }
390 fn advertises_staged_slug_name(&self) -> bool {
391 false
392 }
393 fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
394 render_opencode_available_skills_block(skills)
395 }
396 fn skill_surface_phrase(&self) -> &'static str {
397 "as an OpenCode skill"
398 }
399 fn skill_unresolved_phrase(&self) -> &'static str {
400 "If it does not load as an OpenCode skill"
401 }
402 fn plan_mode_profile(&self) -> &'static str {
403 include_str!("../../profiles/opencode/plan-mode.md")
404 }
405 fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
406 let model_note = if ctx.agent_model.is_some() {
407 " Model selection was recorded as provenance, but the OpenCode adapter has no CLI model flag wired yet."
408 } else {
409 ""
410 };
411 format!(
412 "\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`.",
413 target_args = ctx.target_args,
414 iteration = ctx.iteration
415 )
416 }
417 fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
422 parse_transcript(path)
423 }
424 fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
425 parse_transcript_full(path)
426 }
427 fn install_guard(
428 &self,
429 _stage_root: &Path,
430 _guard_exe: &Path,
431 _ttl: Option<Duration>,
432 ) -> io::Result<PathBuf> {
433 Err(io::Error::new(
434 io::ErrorKind::Unsupported,
435 "--guard is not yet supported for the opencode harness",
436 ))
437 }
438}
439
440pub fn adapter_for(harness: Harness) -> &'static dyn HarnessAdapter {
444 match harness {
445 Harness::ClaudeCode => &ClaudeCodeAdapter,
446 Harness::Codex => &CodexAdapter,
447 Harness::OpenCode => &OpenCodeAdapter,
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
456 fn labels_match_kebab_case_identifiers() {
457 assert_eq!(adapter_for(Harness::ClaudeCode).label(), "claude-code");
458 assert_eq!(adapter_for(Harness::Codex).label(), "codex");
459 assert_eq!(adapter_for(Harness::OpenCode).label(), "opencode");
460 }
461
462 #[test]
463 fn skills_dir_is_harness_native() {
464 let root = Path::new("/repo");
465 assert_eq!(
466 adapter_for(Harness::ClaudeCode).skills_dir(root),
467 root.join(".claude").join("skills")
468 );
469 assert_eq!(
470 adapter_for(Harness::Codex).skills_dir(root),
471 root.join(".agents").join("skills")
472 );
473 assert_eq!(
474 adapter_for(Harness::OpenCode).skills_dir(root),
475 root.join(".opencode").join("skills")
476 );
477 }
478
479 #[test]
480 fn only_codex_and_opencode_rewrite_frontmatter() {
481 assert!(!adapter_for(Harness::ClaudeCode).rewrites_frontmatter_name());
482 assert!(adapter_for(Harness::Codex).rewrites_frontmatter_name());
483 assert!(adapter_for(Harness::OpenCode).rewrites_frontmatter_name());
484 }
485
486 #[test]
487 fn plan_mode_context_wraps_in_system_reminder_for_every_harness() {
488 for h in [Harness::ClaudeCode, Harness::Codex, Harness::OpenCode] {
489 let out = adapter_for(h).render_plan_mode_context("BODY");
490 assert_eq!(out, "<system-reminder>\nBODY\n</system-reminder>");
491 assert_eq!(adapter_for(h).render_plan_mode_context(" "), "");
492 }
493 }
494
495 #[test]
496 fn claude_adapter_advertises_cli_events_file_and_model_flag() {
497 let a = adapter_for(Harness::ClaudeCode);
498 assert_eq!(a.cli_events_filename(), Some("claude-events.jsonl"));
499 assert_eq!(a.cli_model_flag(), Some("--model"));
500 }
501
502 #[test]
503 fn guard_armed_message_is_harness_specific_and_absent_for_opencode() {
504 let claude = adapter_for(Harness::ClaudeCode)
507 .guard_armed_message()
508 .expect("claude code has a write guard");
509 assert!(
510 claude.contains(".claude/settings.local.json"),
511 "claude banner names its hook file: {claude}"
512 );
513
514 let codex = adapter_for(Harness::Codex)
515 .guard_armed_message()
516 .expect("codex has a write guard");
517 assert!(
518 codex.contains(".codex/hooks.json"),
519 "codex banner names its hook file: {codex}"
520 );
521
522 assert_eq!(adapter_for(Harness::OpenCode).guard_armed_message(), None);
525 }
526
527 #[test]
528 fn claude_parse_cli_events_full_reads_stream_json_result_event() {
529 use serde_json::json;
530 let dir = tempfile::TempDir::new().unwrap();
531 let path = dir.path().join("claude-events.jsonl");
532 let lines = [
534 json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [
535 {"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "ls"}}
536 ]}}),
537 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}}),
538 ];
539 let body = lines
540 .iter()
541 .map(|l| l.to_string())
542 .collect::<Vec<_>>()
543 .join("\n");
544 std::fs::write(&path, format!("{body}\n")).unwrap();
545
546 let a = adapter_for(Harness::ClaudeCode);
547 let summary = a.parse_cli_events_full(&path).unwrap();
548 assert_eq!(summary.final_text, Some("Done".into()));
549 assert_eq!(summary.duration_ms, Some(5637));
550 assert_eq!(summary.tool_invocations.len(), 1);
551 assert_eq!(summary.tool_invocations[0].name, "Bash");
552
553 assert_eq!(a.parse_transcript_full(&path).unwrap().duration_ms, None);
556 }
557
558 #[test]
559 fn codex_parse_cli_events_delegates_to_events_parser() {
560 use serde_json::json;
561 let dir = tempfile::TempDir::new().unwrap();
562 let path = dir.path().join("codex-events.jsonl");
563 let line = json!({"type": "item.completed", "item": {"id": "i1", "type": "command_execution", "command": "bun test", "output": "ok"}});
564 std::fs::write(&path, format!("{line}\n")).unwrap();
565
566 let inv = adapter_for(Harness::Codex).parse_cli_events(&path).unwrap();
567 assert_eq!(inv.len(), 1);
568 assert_eq!(inv[0].name, "command_execution");
569 }
570}