1use std::io;
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use regex::Regex;
11
12use crate::core::{AvailableSkill, HarnessRunCapabilities, ToolInvocation};
13use crate::sandbox::GuardMarker;
14
15use super::TranscriptSummary;
16use super::cli_command::{
17 render_cli_model_arg, render_judge_dispatch_recipe, render_parallel_dispatch_recipe,
18};
19use super::descriptor::{HarnessDescriptor, render_staged_slug, stage_name_error, subst};
20use super::harness::{
21 CliDispatchContext, CliJudgeContext, CliManifestContext, HarnessAdapter, ToolVocabulary,
22};
23use super::skill_shadow::PluginShadowReport;
24use super::skills_block::{DEFAULT_HEADER, DEFAULT_ITEM, render_skills_block};
25
26#[derive(Debug)]
28pub struct DescriptorAdapter {
29 descriptor: HarnessDescriptor,
30 stage_name_regex: Option<Regex>,
33}
34
35impl DescriptorAdapter {
36 pub fn from_descriptor(descriptor: HarnessDescriptor) -> Self {
40 let stage_name_regex = descriptor
41 .staging
42 .stage_name_pattern
43 .as_deref()
44 .map(|pattern| {
45 Regex::new(pattern).expect("stage_name_pattern is validated at descriptor load")
46 });
47 DescriptorAdapter {
48 descriptor,
49 stage_name_regex,
50 }
51 }
52
53 pub(crate) fn descriptor(&self) -> &HarnessDescriptor {
56 &self.descriptor
57 }
58
59 fn render_exec_command(&self, guard: bool, agent_model: Option<&str>) -> String {
62 let Some(template) = &self.descriptor.dispatch.exec_template else {
63 return String::new();
64 };
65 let model_arg = render_cli_model_arg(self.model_flag(), agent_model);
66 subst(
67 template,
68 &[
69 ("model_arg", &model_arg),
70 ("guard_args", self.guard_args(guard)),
71 ],
72 )
73 }
74
75 fn render_resume_command(&self, guard: bool, agent_model: Option<&str>) -> Option<String> {
76 let template = &self.descriptor.conversation.as_ref()?.resume_exec_template;
77 let model_arg = render_cli_model_arg(self.model_flag(), agent_model);
78 Some(subst(
79 template,
80 &[
81 ("model_arg", &model_arg),
82 ("guard_args", self.guard_args(guard)),
83 ],
84 ))
85 }
86
87 fn guard_args(&self, guard: bool) -> &str {
90 if guard {
91 self.descriptor.dispatch.guard_args.as_deref().unwrap_or("")
92 } else {
93 ""
94 }
95 }
96
97 fn model_flag(&self) -> Option<&str> {
98 self.descriptor.model.as_ref().map(|m| m.flag.as_str())
99 }
100}
101
102impl HarnessAdapter for DescriptorAdapter {
103 fn label(&self) -> String {
104 self.descriptor.label.clone()
105 }
106
107 fn skills_dir(&self, repo_root: &Path) -> Option<PathBuf> {
108 self.descriptor.skills_dir.as_ref().map(|dir| {
109 dir.split('/')
110 .fold(repo_root.to_path_buf(), |path, segment| path.join(segment))
111 })
112 }
113
114 fn run_capabilities(&self) -> HarnessRunCapabilities {
115 HarnessRunCapabilities {
116 supports_guard: self.descriptor.run.supports_guard,
117 supports_bootstrap_with_no_stage: self.descriptor.run.supports_bootstrap_with_no_stage,
118 supports_stage_name_with_no_stage: self
119 .descriptor
120 .run
121 .supports_stage_name_with_no_stage,
122 }
123 }
124
125 fn config_dir_names(&self) -> Vec<String> {
126 self.descriptor.config_dirs.clone()
127 }
128
129 fn tool_vocabulary(&self) -> ToolVocabulary {
130 ToolVocabulary {
131 write_tools: self.descriptor.tools.write.clone(),
132 patch_tools: self.descriptor.tools.patch.clone(),
133 shell_tools: self.descriptor.tools.shell.clone(),
134 read_tools: self.descriptor.tools.read.clone(),
135 }
136 }
137
138 fn staged_slug(
139 &self,
140 prefix: &str,
141 iteration: u32,
142 condition: &str,
143 skill_name: &str,
144 ) -> String {
145 render_staged_slug(
146 &self.descriptor.staging,
147 prefix,
148 iteration,
149 condition,
150 skill_name,
151 )
152 }
153
154 fn validate_stage_name(&self, name: &str) -> Result<(), String> {
155 match stage_name_error(
156 &self.descriptor.staging,
157 self.stage_name_regex.as_ref(),
158 name,
159 ) {
160 Some(message) => Err(message),
161 None => Ok(()),
162 }
163 }
164
165 fn rewrites_frontmatter_name(&self) -> bool {
166 self.descriptor.staging.rewrites_frontmatter_name
167 }
168
169 fn advertises_staged_slug_name(&self) -> bool {
170 self.descriptor.staging.advertises_staged_slug_name
171 }
172
173 fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
174 match &self.descriptor.skills_block {
175 Some(block) => render_skills_block(&block.header, &block.item, &block.footer, skills),
176 None => render_skills_block(DEFAULT_HEADER, DEFAULT_ITEM, "", skills),
177 }
178 }
179
180 fn skill_surface_phrase(&self) -> String {
181 self.descriptor
182 .staging
183 .surface_phrase
184 .clone()
185 .unwrap_or_else(|| "as a discoverable skill".to_string())
186 }
187
188 fn skill_unresolved_phrase(&self) -> String {
189 self.descriptor
190 .staging
191 .unresolved_phrase
192 .clone()
193 .unwrap_or_else(|| "If the staged skill cannot be resolved".to_string())
194 }
195
196 fn cli_events_filename(&self) -> Option<String> {
197 self.descriptor
198 .transcript
199 .as_ref()
200 .map(|t| t.events_filename.clone())
201 }
202
203 fn parse_cli_events(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
204 match &self.descriptor.transcript {
205 Some(transcript) => transcript.parse(path),
206 None => Err(io::Error::new(
207 io::ErrorKind::Unsupported,
208 format!(
209 "transcript ingest is not wired for the {} harness",
210 self.label()
211 ),
212 )),
213 }
214 }
215
216 fn parse_cli_events_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
217 match &self.descriptor.transcript {
218 Some(transcript) => transcript.parse_full(path),
219 None => Err(io::Error::new(
220 io::ErrorKind::Unsupported,
221 format!(
222 "transcript ingest is not wired for the {} harness",
223 self.label()
224 ),
225 )),
226 }
227 }
228
229 fn transcript_skill_invocation(&self) -> Option<(String, String)> {
230 match &self.descriptor.transcript {
231 Some(t) if !t.surfaces_skill_invocation => None,
232 Some(t) => Some((
233 t.skill_tool.clone().unwrap_or_else(|| "Skill".to_string()),
234 t.skill_arg.clone().unwrap_or_else(|| "skill".to_string()),
235 )),
236 None => Some(("Skill".to_string(), "skill".to_string())),
239 }
240 }
241
242 fn cli_model_flag(&self) -> Option<String> {
243 self.model_flag().map(str::to_string)
244 }
245
246 fn install_guard(
247 &self,
248 stage_root: &Path,
249 guard_exe: &Path,
250 ttl: Option<Duration>,
251 ) -> io::Result<PathBuf> {
252 match &self.descriptor.guard {
253 Some(guard) => {
254 let skills_dir = self
255 .skills_dir(stage_root)
256 .expect("descriptor validation pairs [guard] with skills_dir");
257 super::guard::install_guard(guard, &skills_dir, stage_root, guard_exe, ttl)
258 }
259 None => Err(io::Error::new(
260 io::ErrorKind::Unsupported,
261 format!("--guard is not supported for the {} harness", self.label()),
262 )),
263 }
264 }
265
266 fn guard_armed_message(&self) -> Option<String> {
267 self.descriptor
268 .guard
269 .as_ref()
270 .map(|g| g.armed_message.clone())
271 }
272
273 fn guard_verdict(&self, payload: &str, marker: Option<GuardMarker>) -> Option<String> {
274 let guard = self.descriptor.guard.as_ref()?;
275 super::guard::guard_verdict(guard, payload, marker)
276 }
277
278 fn guard_hook_cleanup_dir(&self, stage_root: &Path) -> Option<PathBuf> {
279 self.descriptor.guard.as_ref().and_then(|g| {
280 super::guard::hook_cleanup_dir(g, self.descriptor.skills_dir.as_deref(), stage_root)
281 })
282 }
283
284 fn detect_shadowed_skills(
285 &self,
286 scan_root: &Path,
287 staged_skill_names: &[&str],
288 ) -> Option<PluginShadowReport> {
289 self.descriptor
290 .shadow
291 .as_ref()
292 .and_then(|s| s.preflight.detect(scan_root, staged_skill_names))
293 }
294
295 fn format_shadow_banner(&self, report: &PluginShadowReport) -> String {
296 self.descriptor.shadow.as_ref().map_or_else(
297 || super::skill_shadow::format_shadow_banner(report),
298 |shadow| shadow.preflight.format_banner(report),
299 )
300 }
301
302 fn shadow_validity_warnings(&self, report: &PluginShadowReport) -> Vec<String> {
303 self.descriptor.shadow.as_ref().map_or_else(
304 || super::skill_shadow::shadow_validity_warnings(report),
305 |shadow| shadow.preflight.validity_warnings(report),
306 )
307 }
308
309 fn has_dispatch_recipes(&self) -> bool {
310 self.descriptor.dispatch.exec_template.is_some()
311 }
312
313 fn cli_exec_command(&self, guard: bool, agent_model: Option<&str>) -> Option<String> {
314 self.descriptor
315 .dispatch
316 .exec_template
317 .as_ref()
318 .map(|_| self.render_exec_command(guard, agent_model))
319 }
320
321 fn has_conversation_resume(&self) -> bool {
322 self.descriptor.conversation.is_some()
323 }
324
325 fn cli_resume_command(&self, guard: bool, agent_model: Option<&str>) -> Option<String> {
326 self.render_resume_command(guard, agent_model)
327 }
328
329 fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
330 let ingest_line = format!(
331 "ingest{} --iteration {} --harness {}",
332 ctx.target_args, ctx.iteration, self.descriptor.label
333 );
334 let Some(template) = &self.descriptor.dispatch.next_steps_template else {
335 return match &self.descriptor.dispatch.exec_template {
339 Some(_) => format!(
340 "\nNext: iterate the tasks[] array in dispatch.json and dispatch each task \
341 with:\n{}\nThen run `{ingest_line}`.",
342 self.render_exec_command(ctx.guard, ctx.agent_model)
343 ),
344 None => format!(
345 "\nNext: read dispatch-manifest.md and dispatch each task through your \
346 harness's one-shot CLI from the task's eval_root, saving the agent's \
347 final reply to outputs/final-message.md.\nThen run `{ingest_line}`."
348 ),
349 };
350 };
351 let exec_command = self.render_exec_command(ctx.guard, ctx.agent_model);
352 let iteration = ctx.iteration.to_string();
353 let model_note = if ctx.agent_model.is_some() {
354 self.descriptor.dispatch.model_note.as_deref().unwrap_or("")
355 } else {
356 ""
357 };
358 subst(
359 template,
360 &[
361 ("exec_command", &exec_command),
362 ("target_args", ctx.target_args),
363 ("iteration", &iteration),
364 ("model_note", model_note),
365 ],
366 )
367 }
368
369 fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
370 let Some(template) = self.descriptor.dispatch.manifest_template.as_ref() else {
371 self.descriptor.dispatch.exec_template.as_ref()?;
375 let exec_command = self.render_exec_command(ctx.guard, ctx.agent_model);
376 return Some(
377 format!(
378 "## Dispatch recipe\n\nFrom each task's `eval_root`, dispatch with:\n\
379 {exec_command}\n\nEnsure the agent's final reply lands in the task's \
380 `outputs/final-message.md` (capture it yourself if the command does not \
381 write it).\n"
382 )
383 .split('\n')
384 .map(String::from)
385 .collect(),
386 );
387 };
388 let exec_command = self.render_exec_command(ctx.guard, ctx.agent_model);
389 let parallel_recipe = match &self.descriptor.dispatch.parallel_command_template {
390 Some(block_template) => {
391 let model_arg = render_cli_model_arg(self.model_flag(), ctx.agent_model);
392 render_parallel_dispatch_recipe(
393 &subst(
394 block_template,
395 &[
396 ("model_arg", &model_arg),
397 ("guard_args", self.guard_args(ctx.guard)),
398 ],
399 ),
400 ctx.one_shot_only,
401 )
402 }
403 None => String::new(),
404 };
405 Some(
406 subst(
407 template,
408 &[
409 ("exec_command", &exec_command),
410 ("parallel_recipe", ¶llel_recipe),
411 ],
412 )
413 .split('\n')
414 .map(String::from)
415 .collect(),
416 )
417 }
418
419 fn cli_judge_next_steps(&self, ctx: CliJudgeContext<'_>) -> Option<String> {
420 let template = self.descriptor.dispatch.judge_command_template.as_ref()?;
421 let cwd = ctx.iteration_dir.display().to_string();
422 let command_line = subst(
423 template,
424 &[("cwd", &cwd), ("guard_args", self.guard_args(ctx.guard))],
425 );
426 Some(render_judge_dispatch_recipe(
427 &command_line,
428 self.model_flag().unwrap_or_default(),
430 self.descriptor
431 .dispatch
432 .capture_prefix
433 .as_deref()
434 .unwrap_or_default(),
435 ))
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use std::path::Path;
442
443 use crate::adapters::harness::{CliDispatchContext, CliJudgeContext};
444 use crate::adapters::registry::adapter_for;
445 use crate::core::{AvailableSkill, Harness};
446
447 fn skill(name: &str, description: &str) -> AvailableSkill {
448 AvailableSkill {
449 name: name.into(),
450 path: format!("/x/{name}/SKILL.md"),
451 description: description.into(),
452 }
453 }
454
455 fn next_steps(harness: Harness, agent_model: Option<&str>) -> String {
456 adapter_for(harness).cli_next_steps(CliDispatchContext {
457 guard: harness == Harness::resolve("codex").unwrap(),
458 target_args: " --skill-dir /tmp/skills --skill widget-skill",
459 iteration: 2,
460 agent_model,
461 })
462 }
463
464 fn adapter_from(toml_src: &str) -> super::DescriptorAdapter {
465 super::DescriptorAdapter::from_descriptor(
466 crate::adapters::descriptor::load_descriptor(toml_src, "test.toml").unwrap(),
467 )
468 }
469
470 #[test]
471 fn exec_template_alone_yields_generic_dispatch_recipes() {
472 use crate::adapters::harness::{CliManifestContext, HarnessAdapter};
473 let adapter = adapter_from(
474 "label = \"cool-custom-harness\"\n\n[dispatch]\nexec_template = \"cool-cli run <dispatch_prompt_path>{model_arg}\"\n",
475 );
476 let next = adapter.cli_next_steps(CliDispatchContext {
477 guard: false,
478 target_args: " --skill-dir /s --skill x",
479 iteration: 3,
480 agent_model: None,
481 });
482 assert!(next.contains("cool-cli run"), "{next}");
483 assert!(
484 next.contains(
485 "ingest --skill-dir /s --skill x --iteration 3 --harness cool-custom-harness"
486 ),
487 "{next}"
488 );
489 let manifest = adapter
490 .cli_manifest_section(CliManifestContext {
491 guard: false,
492 agent_model: None,
493 one_shot_only: false,
494 })
495 .expect("an exec template earns a generic manifest recipe")
496 .join("\n");
497 assert!(manifest.contains("cool-cli run"), "{manifest}");
498 assert!(manifest.contains("final-message.md"), "{manifest}");
499 }
500
501 #[test]
502 fn built_in_harnesses_render_native_resume_commands() {
503 for harness in [
504 Harness::resolve("claude-code").unwrap(),
505 Harness::resolve("codex").unwrap(),
506 Harness::resolve("opencode").unwrap(),
507 ] {
508 let adapter = adapter_for(harness);
509 assert!(
510 adapter.has_conversation_resume(),
511 "{} should support scripted follow-up turns",
512 adapter.label()
513 );
514 let command = adapter
515 .cli_resume_command(false, Some("test-model"))
516 .expect("built-in resume command");
517 assert!(command.contains("<eval-root>"), "{command}");
518 assert!(command.contains("<outputs_dir>"), "{command}");
519 assert!(command.contains("{session_arg}"), "{command}");
520 assert!(command.contains("{prompt_arg}"), "{command}");
521 assert!(command.contains("test-model"), "{command}");
522 }
523 }
524
525 #[test]
526 fn baseline_descriptor_yields_the_generic_handoff() {
527 use crate::adapters::harness::{CliManifestContext, HarnessAdapter};
528 let adapter = adapter_from("label = \"cool-custom-harness\"\n");
529 let next = adapter.cli_next_steps(CliDispatchContext {
530 guard: false,
531 target_args: " --skill x",
532 iteration: 1,
533 agent_model: None,
534 });
535 assert!(next.contains("one-shot CLI"), "{next}");
536 assert!(next.contains("outputs/final-message.md"), "{next}");
537 assert!(
538 next.contains("ingest --skill x --iteration 1 --harness cool-custom-harness"),
539 "{next}"
540 );
541 assert!(
542 adapter
543 .cli_manifest_section(CliManifestContext {
544 guard: false,
545 agent_model: None,
546 one_shot_only: false,
547 })
548 .is_none(),
549 "the manifest's generic header already covers the no-recipe baseline"
550 );
551 }
552
553 #[test]
554 fn exec_recipe_includes_model_only_when_declared() {
555 let with = next_steps(Harness::resolve("claude-code").unwrap(), Some("opus"));
556 assert!(with.contains("--model opus"), "{with}");
557 let without = next_steps(Harness::resolve("claude-code").unwrap(), None);
558 assert!(!without.contains("--model "), "{without}");
559 }
560
561 #[test]
562 fn codex_recipes_gate_hook_trust_on_guard() {
563 let guarded = next_steps(Harness::resolve("codex").unwrap(), Some("gpt-5-mini"));
564 assert!(
565 guarded.contains(
566 "codex --ask-for-approval never exec --cd <eval-root> --sandbox workspace-write --dangerously-bypass-hook-trust -m gpt-5-mini --json \\"
567 ),
568 "{guarded}"
569 );
570 let unguarded =
571 adapter_for(Harness::resolve("codex").unwrap()).cli_next_steps(CliDispatchContext {
572 guard: false,
573 target_args: "",
574 iteration: 2,
575 agent_model: None,
576 });
577 assert!(
578 !unguarded.contains("--dangerously-bypass-hook-trust"),
579 "{unguarded}"
580 );
581 }
582
583 #[test]
584 fn opencode_exec_recipe_carries_dir_auto_and_the_model_flag() {
585 let with = next_steps(
586 Harness::resolve("opencode").unwrap(),
587 Some("opencode/gpt-5-nano"),
588 );
589 assert!(
590 with.contains(
591 "opencode run --dir <eval-root> --format json --auto -m opencode/gpt-5-nano \\"
592 ),
593 "{with}"
594 );
595 let without = next_steps(Harness::resolve("opencode").unwrap(), None);
596 assert!(
597 without.contains("opencode run --dir <eval-root> --format json --auto \\"),
598 "{without}"
599 );
600 assert!(!without.contains(" -m "), "{without}");
601 }
602
603 #[test]
604 fn codex_judge_recipe_splices_model_arg_in_one_command_shape() {
605 let recipe = adapter_for(Harness::resolve("codex").unwrap())
606 .cli_judge_next_steps(CliJudgeContext {
607 guard: true,
608 iteration_dir: Path::new("/work/iter-1"),
609 })
610 .expect("codex judge recipe is wired");
611 assert!(
614 recipe.contains(
615 " codex --ask-for-approval never exec --cd \"/work/iter-1\" --sandbox workspace-write --dangerously-bypass-hook-trust $model_arg --json \\"
616 ),
617 "{recipe}"
618 );
619 assert!(
620 recipe.contains(" model_arg=\"\"; [ -n \"$model\" ] && model_arg=\"-m $model\""),
621 "{recipe}"
622 );
623 assert!(!recipe.contains("if [ -n"), "{recipe}");
624 }
625
626 #[test]
627 fn claude_judge_recipe_snapshot_is_stable() {
628 let recipe = adapter_for(Harness::resolve("claude-code").unwrap())
631 .cli_judge_next_steps(CliJudgeContext {
632 guard: false,
633 iteration_dir: Path::new("/work/iter-1"),
634 })
635 .expect("claude judge recipe is wired");
636 let expected = r#"Dispatch each judge task from judge-tasks.json with:
637
638```bash
639JOBS=${JOBS:-4}
640jq -j '.tasks[] | [.dispatch_prompt_path, .response_path, (.model // "")] | @tsv + "\u0000"' judge-tasks.json | \
641 xargs -0 -P "$JOBS" -I{} sh -c '
642 prompt_path="$(printf "%s" "$1" | cut -f1)"
643 response_path="$(printf "%s" "$1" | cut -f2)"
644 model="$(printf "%s" "$1" | cut -f3)"
645 response_base="${response_path%.json}"
646 mkdir -p "$(dirname "$response_path")"
647 model_arg=""; [ -n "$model" ] && model_arg="--model $model"
648 cd "/work/iter-1" && claude -p --output-format stream-json --verbose --permission-mode acceptEdits $model_arg \
649 "Read the file at $prompt_path and follow it exactly. You are a judge worker only: write the JSON verdict to $response_path, then reply with one sentence. Do not run eval-magic. Do not dispatch other judge tasks. Do not wait for other workers." \
650 </dev/null \
651 > "$response_base.claude-events.jsonl" \
652 2> "$response_base.claude-stderr.log"
653 ' sh {}
654```"#;
655 assert_eq!(recipe, expected);
656 }
657
658 #[test]
659 fn skills_blocks_render_each_harness_native_shape() {
660 let skills = vec![skill("zebra", "z skill"), skill("alpha", "a skill")];
661
662 let claude = adapter_for(Harness::resolve("claude-code").unwrap())
663 .render_available_skills_block(&skills);
664 assert!(
665 claude.starts_with("The following skills are available for use with the Skill tool:"),
666 "{claude}"
667 );
668 assert!(claude.contains("\n- alpha: a skill"), "{claude}");
669
670 let codex =
671 adapter_for(Harness::resolve("codex").unwrap()).render_available_skills_block(&skills);
672 assert!(codex.starts_with("## Skills"), "{codex}");
673 assert!(
674 codex.contains("- alpha: a skill (file: /x/alpha/SKILL.md)"),
675 "{codex}"
676 );
677
678 let opencode = adapter_for(Harness::resolve("opencode").unwrap())
679 .render_available_skills_block(&skills);
680 assert!(opencode.starts_with("<available_skills>"), "{opencode}");
681 assert!(opencode.ends_with("\n</available_skills>"), "{opencode}");
682 assert!(opencode.contains("<name>alpha</name>"), "{opencode}");
683 assert!(
684 opencode.contains("<description>a skill</description>"),
685 "{opencode}"
686 );
687
688 for harness in Harness::known() {
690 let block = adapter_for(harness).render_available_skills_block(&skills);
691 assert!(
692 block.find("alpha").unwrap() < block.find("zebra").unwrap(),
693 "{harness:?} sorts by name: {block}"
694 );
695 assert_eq!(adapter_for(harness).render_available_skills_block(&[]), "");
696 }
697 }
698
699 #[test]
700 fn opencode_stage_name_rules_match_the_old_validator() {
701 let adapter = adapter_for(Harness::resolve("opencode").unwrap());
702 assert!(adapter.validate_stage_name("valid-name-2").is_ok());
703 for invalid in [
704 "Invalid_Name",
705 "double--hyphen",
706 "-leading",
707 "trailing-",
708 "",
709 ] {
710 let err = adapter
711 .validate_stage_name(invalid)
712 .expect_err("name should be rejected");
713 assert!(
714 err.contains(&format!("OpenCode skill name \"{invalid}\" is invalid")),
715 "{err}"
716 );
717 }
718 assert!(adapter.validate_stage_name(&"a".repeat(64)).is_ok());
719 assert!(adapter.validate_stage_name(&"a".repeat(65)).is_err());
720 }
721}