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_surfaces_skill_invocation(&self) -> bool {
218 self.descriptor
219 .transcript
220 .as_ref()
221 .is_none_or(|t| t.surfaces_skill_invocation)
222 }
223
224 fn cli_model_flag(&self) -> Option<String> {
225 self.model_flag().map(str::to_string)
226 }
227
228 fn install_guard(
229 &self,
230 stage_root: &Path,
231 guard_exe: &Path,
232 ttl: Option<Duration>,
233 ) -> io::Result<PathBuf> {
234 match &self.descriptor.guard {
235 Some(guard) => {
236 let skills_dir = self
237 .skills_dir(stage_root)
238 .expect("descriptor validation pairs [guard] with skills_dir");
239 super::guard::install_guard(guard, &skills_dir, stage_root, guard_exe, ttl)
240 }
241 None => Err(io::Error::new(
242 io::ErrorKind::Unsupported,
243 format!("--guard is not supported for the {} harness", self.label()),
244 )),
245 }
246 }
247
248 fn guard_armed_message(&self) -> Option<String> {
249 self.descriptor
250 .guard
251 .as_ref()
252 .map(|g| g.armed_message.clone())
253 }
254
255 fn guard_verdict(&self, payload: &str, marker: Option<GuardMarker>) -> Option<String> {
256 let guard = self.descriptor.guard.as_ref()?;
257 super::guard::guard_verdict(guard, payload, marker)
258 }
259
260 fn guard_hook_cleanup_dir(&self, stage_root: &Path) -> Option<PathBuf> {
261 self.descriptor.guard.as_ref().and_then(|g| {
262 super::guard::hook_cleanup_dir(g, self.descriptor.skills_dir.as_deref(), stage_root)
263 })
264 }
265
266 fn detect_shadowed_skills(
267 &self,
268 scan_root: &Path,
269 staged_skill_names: &[&str],
270 ) -> Option<PluginShadowReport> {
271 self.descriptor
272 .shadow
273 .as_ref()
274 .and_then(|s| s.preflight.detect(scan_root, staged_skill_names))
275 }
276
277 fn has_dispatch_recipes(&self) -> bool {
278 self.descriptor.dispatch.exec_template.is_some()
279 }
280
281 fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
282 let ingest_line = format!(
283 "ingest{} --iteration {} --harness {}",
284 ctx.target_args, ctx.iteration, self.descriptor.label
285 );
286 let Some(template) = &self.descriptor.dispatch.next_steps_template else {
287 return match &self.descriptor.dispatch.exec_template {
291 Some(_) => format!(
292 "\nNext: iterate the tasks[] array in dispatch.json and dispatch each task \
293 with:\n{}\nThen run `{ingest_line}`.",
294 self.exec_command(ctx.guard, ctx.agent_model)
295 ),
296 None => format!(
297 "\nNext: read dispatch-manifest.md and dispatch each task through your \
298 harness's one-shot CLI from the task's eval_root, saving the agent's \
299 final reply to outputs/final-message.md.\nThen run `{ingest_line}`."
300 ),
301 };
302 };
303 let exec_command = self.exec_command(ctx.guard, ctx.agent_model);
304 let iteration = ctx.iteration.to_string();
305 let model_note = if ctx.agent_model.is_some() {
306 self.descriptor.dispatch.model_note.as_deref().unwrap_or("")
307 } else {
308 ""
309 };
310 subst(
311 template,
312 &[
313 ("exec_command", &exec_command),
314 ("target_args", ctx.target_args),
315 ("iteration", &iteration),
316 ("model_note", model_note),
317 ],
318 )
319 }
320
321 fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
322 let Some(template) = self.descriptor.dispatch.manifest_template.as_ref() else {
323 self.descriptor.dispatch.exec_template.as_ref()?;
327 let exec_command = self.exec_command(ctx.guard, ctx.agent_model);
328 return Some(
329 format!(
330 "## Dispatch recipe\n\nFrom each task's `eval_root`, dispatch with:\n\
331 {exec_command}\n\nEnsure the agent's final reply lands in the task's \
332 `outputs/final-message.md` (capture it yourself if the command does not \
333 write it).\n"
334 )
335 .split('\n')
336 .map(String::from)
337 .collect(),
338 );
339 };
340 let exec_command = self.exec_command(ctx.guard, ctx.agent_model);
341 let parallel_recipe = match &self.descriptor.dispatch.parallel_command_template {
342 Some(block_template) => {
343 let model_arg = render_cli_model_arg(self.model_flag(), ctx.agent_model);
344 render_parallel_dispatch_recipe(&subst(
345 block_template,
346 &[
347 ("model_arg", &model_arg),
348 ("guard_args", self.guard_args(ctx.guard)),
349 ],
350 ))
351 }
352 None => String::new(),
353 };
354 Some(
355 subst(
356 template,
357 &[
358 ("exec_command", &exec_command),
359 ("parallel_recipe", ¶llel_recipe),
360 ],
361 )
362 .split('\n')
363 .map(String::from)
364 .collect(),
365 )
366 }
367
368 fn cli_judge_next_steps(&self, ctx: CliJudgeContext<'_>) -> Option<String> {
369 let template = self.descriptor.dispatch.judge_command_template.as_ref()?;
370 let cwd = ctx.iteration_dir.display().to_string();
371 let command_line = subst(
372 template,
373 &[("cwd", &cwd), ("guard_args", self.guard_args(ctx.guard))],
374 );
375 Some(render_judge_dispatch_recipe(
376 &command_line,
377 self.model_flag().unwrap_or_default(),
379 self.descriptor
380 .dispatch
381 .capture_prefix
382 .as_deref()
383 .unwrap_or_default(),
384 ))
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use std::path::Path;
391
392 use crate::adapters::harness::{CliDispatchContext, CliJudgeContext};
393 use crate::adapters::registry::adapter_for;
394 use crate::core::{AvailableSkill, Harness};
395
396 fn skill(name: &str, description: &str) -> AvailableSkill {
397 AvailableSkill {
398 name: name.into(),
399 path: format!("/x/{name}/SKILL.md"),
400 description: description.into(),
401 }
402 }
403
404 fn next_steps(harness: Harness, agent_model: Option<&str>) -> String {
405 adapter_for(harness).cli_next_steps(CliDispatchContext {
406 guard: harness == Harness::resolve("codex").unwrap(),
407 target_args: " --skill-dir /tmp/skills --skill widget-skill",
408 iteration: 2,
409 agent_model,
410 })
411 }
412
413 fn adapter_from(toml_src: &str) -> super::DescriptorAdapter {
414 super::DescriptorAdapter::from_descriptor(
415 crate::adapters::descriptor::load_descriptor(toml_src, "test.toml").unwrap(),
416 )
417 }
418
419 #[test]
420 fn exec_template_alone_yields_generic_dispatch_recipes() {
421 use crate::adapters::harness::{CliManifestContext, HarnessAdapter};
422 let adapter = adapter_from(
423 "label = \"cool-custom-harness\"\n\n[dispatch]\nexec_template = \"cool-cli run <dispatch_prompt_path>{model_arg}\"\n",
424 );
425 let next = adapter.cli_next_steps(CliDispatchContext {
426 guard: false,
427 target_args: " --skill-dir /s --skill x",
428 iteration: 3,
429 agent_model: None,
430 });
431 assert!(next.contains("cool-cli run"), "{next}");
432 assert!(
433 next.contains(
434 "ingest --skill-dir /s --skill x --iteration 3 --harness cool-custom-harness"
435 ),
436 "{next}"
437 );
438 let manifest = adapter
439 .cli_manifest_section(CliManifestContext {
440 guard: false,
441 agent_model: None,
442 })
443 .expect("an exec template earns a generic manifest recipe")
444 .join("\n");
445 assert!(manifest.contains("cool-cli run"), "{manifest}");
446 assert!(manifest.contains("final-message.md"), "{manifest}");
447 }
448
449 #[test]
450 fn baseline_descriptor_yields_the_generic_handoff() {
451 use crate::adapters::harness::{CliManifestContext, HarnessAdapter};
452 let adapter = adapter_from("label = \"cool-custom-harness\"\n");
453 let next = adapter.cli_next_steps(CliDispatchContext {
454 guard: false,
455 target_args: " --skill x",
456 iteration: 1,
457 agent_model: None,
458 });
459 assert!(next.contains("one-shot CLI"), "{next}");
460 assert!(next.contains("outputs/final-message.md"), "{next}");
461 assert!(
462 next.contains("ingest --skill x --iteration 1 --harness cool-custom-harness"),
463 "{next}"
464 );
465 assert!(
466 adapter
467 .cli_manifest_section(CliManifestContext {
468 guard: false,
469 agent_model: None,
470 })
471 .is_none(),
472 "the manifest's generic header already covers the no-recipe baseline"
473 );
474 }
475
476 #[test]
477 fn exec_recipe_includes_model_only_when_declared() {
478 let with = next_steps(Harness::resolve("claude-code").unwrap(), Some("opus"));
479 assert!(with.contains("--model opus"), "{with}");
480 let without = next_steps(Harness::resolve("claude-code").unwrap(), None);
481 assert!(!without.contains("--model "), "{without}");
482 }
483
484 #[test]
485 fn codex_recipes_gate_hook_trust_on_guard() {
486 let guarded = next_steps(Harness::resolve("codex").unwrap(), Some("gpt-5-mini"));
487 assert!(
488 guarded.contains(
489 "codex --ask-for-approval never exec --cd <eval-root> --sandbox workspace-write --dangerously-bypass-hook-trust -m gpt-5-mini --json \\"
490 ),
491 "{guarded}"
492 );
493 let unguarded =
494 adapter_for(Harness::resolve("codex").unwrap()).cli_next_steps(CliDispatchContext {
495 guard: false,
496 target_args: "",
497 iteration: 2,
498 agent_model: None,
499 });
500 assert!(
501 !unguarded.contains("--dangerously-bypass-hook-trust"),
502 "{unguarded}"
503 );
504 }
505
506 #[test]
507 fn codex_judge_recipe_splices_model_arg_in_one_command_shape() {
508 let recipe = adapter_for(Harness::resolve("codex").unwrap())
509 .cli_judge_next_steps(CliJudgeContext {
510 guard: true,
511 iteration_dir: Path::new("/work/iter-1"),
512 })
513 .expect("codex judge recipe is wired");
514 assert!(
517 recipe.contains(
518 " codex --ask-for-approval never exec --cd \"/work/iter-1\" --sandbox workspace-write --dangerously-bypass-hook-trust $model_arg --json \\"
519 ),
520 "{recipe}"
521 );
522 assert!(
523 recipe.contains(" model_arg=\"\"; [ -n \"$model\" ] && model_arg=\"-m $model\""),
524 "{recipe}"
525 );
526 assert!(!recipe.contains("if [ -n"), "{recipe}");
527 }
528
529 #[test]
530 fn claude_judge_recipe_snapshot_is_stable() {
531 let recipe = adapter_for(Harness::resolve("claude-code").unwrap())
534 .cli_judge_next_steps(CliJudgeContext {
535 guard: false,
536 iteration_dir: Path::new("/work/iter-1"),
537 })
538 .expect("claude judge recipe is wired");
539 let expected = r#"Dispatch each judge task from judge-tasks.json with:
540
541```bash
542JOBS=${JOBS:-4}
543jq -j '.tasks[] | [.dispatch_prompt_path, .response_path, (.model // "")] | @tsv + "\u0000"' judge-tasks.json | \
544 xargs -0 -P "$JOBS" -I{} sh -c '
545 prompt_path="$(printf "%s" "$1" | cut -f1)"
546 response_path="$(printf "%s" "$1" | cut -f2)"
547 model="$(printf "%s" "$1" | cut -f3)"
548 response_base="${response_path%.json}"
549 mkdir -p "$(dirname "$response_path")"
550 model_arg=""; [ -n "$model" ] && model_arg="--model $model"
551 cd "/work/iter-1" && claude -p --output-format stream-json --verbose --permission-mode acceptEdits $model_arg \
552 "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." \
553 </dev/null \
554 > "$response_base.claude-events.jsonl" \
555 2> "$response_base.claude-stderr.log"
556 ' sh {}
557```"#;
558 assert_eq!(recipe, expected);
559 }
560
561 #[test]
562 fn skills_blocks_render_each_harness_native_shape() {
563 let skills = vec![skill("zebra", "z skill"), skill("alpha", "a skill")];
564
565 let claude = adapter_for(Harness::resolve("claude-code").unwrap())
566 .render_available_skills_block(&skills);
567 assert!(
568 claude.starts_with("The following skills are available for use with the Skill tool:"),
569 "{claude}"
570 );
571 assert!(claude.contains("\n- alpha: a skill"), "{claude}");
572
573 let codex =
574 adapter_for(Harness::resolve("codex").unwrap()).render_available_skills_block(&skills);
575 assert!(codex.starts_with("## Skills"), "{codex}");
576 assert!(
577 codex.contains("- alpha: a skill (file: /x/alpha/SKILL.md)"),
578 "{codex}"
579 );
580
581 let opencode = adapter_for(Harness::resolve("opencode").unwrap())
582 .render_available_skills_block(&skills);
583 assert!(opencode.starts_with("<available_skills>"), "{opencode}");
584 assert!(opencode.ends_with("\n</available_skills>"), "{opencode}");
585 assert!(opencode.contains("<name>alpha</name>"), "{opencode}");
586 assert!(
587 opencode.contains("<description>a skill</description>"),
588 "{opencode}"
589 );
590
591 for harness in Harness::known() {
593 let block = adapter_for(harness).render_available_skills_block(&skills);
594 assert!(
595 block.find("alpha").unwrap() < block.find("zebra").unwrap(),
596 "{harness:?} sorts by name: {block}"
597 );
598 assert_eq!(adapter_for(harness).render_available_skills_block(&[]), "");
599 }
600 }
601
602 #[test]
603 fn opencode_stage_name_rules_match_the_old_validator() {
604 let adapter = adapter_for(Harness::resolve("opencode").unwrap());
605 assert!(adapter.validate_stage_name("valid-name-2").is_ok());
606 for invalid in [
607 "Invalid_Name",
608 "double--hyphen",
609 "-leading",
610 "trailing-",
611 "",
612 ] {
613 let err = adapter
614 .validate_stage_name(invalid)
615 .expect_err("name should be rejected");
616 assert!(
617 err.contains(&format!("OpenCode skill name \"{invalid}\" is invalid")),
618 "{err}"
619 );
620 }
621 assert!(adapter.validate_stage_name(&"a".repeat(64)).is_ok());
622 assert!(adapter.validate_stage_name(&"a".repeat(65)).is_err());
623 }
624}