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