use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::core::{AvailableSkill, HarnessRunCapabilities, ToolInvocation};
use crate::sandbox::GuardMarker;
use super::TranscriptSummary;
use super::skill_shadow::PluginShadowReport;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ToolVocabulary {
pub write_tools: Vec<String>,
pub patch_tools: Vec<String>,
pub shell_tools: Vec<String>,
pub read_tools: Vec<String>,
}
pub trait HarnessAdapter {
fn label(&self) -> String;
fn skills_dir(&self, repo_root: &Path) -> Option<PathBuf>;
fn run_capabilities(&self) -> HarnessRunCapabilities {
HarnessRunCapabilities {
supports_guard: false,
supports_bootstrap_with_no_stage: true,
supports_stage_name_with_no_stage: true,
}
}
fn config_dir_names(&self) -> Vec<String> {
Vec::new()
}
fn tool_vocabulary(&self) -> ToolVocabulary {
ToolVocabulary::default()
}
fn staged_slug(
&self,
prefix: &str,
iteration: u32,
condition: &str,
skill_name: &str,
) -> String {
format!("{prefix}{iteration}-{condition}__{skill_name}")
}
fn validate_stage_name(&self, _name: &str) -> Result<(), String> {
Ok(())
}
fn rewrites_frontmatter_name(&self) -> bool {
false
}
fn advertises_staged_slug_name(&self) -> bool {
false
}
fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
super::skills_block::render_skills_block(
super::skills_block::DEFAULT_HEADER,
super::skills_block::DEFAULT_ITEM,
"",
skills,
)
}
fn skill_surface_phrase(&self) -> String {
"as a discoverable skill".to_string()
}
fn skill_unresolved_phrase(&self) -> String {
"If the staged skill cannot be resolved".to_string()
}
fn cli_events_filename(&self) -> Option<String> {
None
}
fn parse_cli_events(&self, _path: &Path) -> io::Result<Vec<ToolInvocation>> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
format!(
"transcript ingest is not wired for the {} harness",
self.label()
),
))
}
fn parse_cli_events_full(&self, _path: &Path) -> io::Result<TranscriptSummary> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
format!(
"transcript ingest is not wired for the {} harness",
self.label()
),
))
}
fn transcript_surfaces_skill_invocation(&self) -> bool {
true
}
fn cli_model_flag(&self) -> Option<String> {
None
}
fn install_guard(
&self,
_stage_root: &Path,
_guard_exe: &Path,
_ttl: Option<Duration>,
) -> io::Result<PathBuf> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
format!("--guard is not supported for the {} harness", self.label()),
))
}
fn guard_armed_message(&self) -> Option<String> {
None
}
fn guard_verdict(&self, _payload: &str, _marker: Option<GuardMarker>) -> Option<String> {
None
}
fn guard_hook_cleanup_dir(&self, _stage_root: &Path) -> Option<PathBuf> {
None
}
fn detect_shadowed_skills(
&self,
_scan_root: &Path,
_staged_skill_names: &[&str],
) -> Option<PluginShadowReport> {
None
}
fn render_plan_mode_context(&self, profile_text: &str) -> String {
let trimmed = profile_text.trim();
if trimmed.is_empty() {
return String::new();
}
format!("<system-reminder>\n{trimmed}\n</system-reminder>")
}
fn has_dispatch_recipes(&self) -> bool {
false
}
fn cli_next_steps(&self, _ctx: CliDispatchContext<'_>) -> String {
String::new()
}
fn cli_manifest_section(&self, _ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
None
}
fn cli_judge_next_steps(&self, _ctx: CliJudgeContext<'_>) -> Option<String> {
None
}
}
pub const RUNBOOK_TEMPLATE: &str = include_str!("../../profiles/shared/runbook.md");
#[derive(Debug, Clone, Copy)]
pub struct CliDispatchContext<'a> {
pub guard: bool,
pub target_args: &'a str,
pub iteration: u32,
pub agent_model: Option<&'a str>,
}
#[derive(Debug, Clone, Copy)]
pub struct CliManifestContext<'a> {
pub guard: bool,
pub agent_model: Option<&'a str>,
}
#[derive(Debug, Clone, Copy)]
pub struct CliJudgeContext<'a> {
pub guard: bool,
pub iteration_dir: &'a Path,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapters::registry::adapter_for;
use crate::core::Harness;
#[test]
fn detect_shadowed_skills_defaults_to_none_for_harnesses_without_a_preflight() {
for h in [
Harness::resolve("codex").unwrap(),
Harness::resolve("opencode").unwrap(),
] {
assert_eq!(
adapter_for(h).detect_shadowed_skills(Path::new("/nonexistent"), &["any-skill"]),
None
);
}
}
#[test]
fn has_dispatch_recipes_matches_the_readme_support_table() {
assert!(adapter_for(Harness::resolve("claude-code").unwrap()).has_dispatch_recipes());
assert!(adapter_for(Harness::resolve("codex").unwrap()).has_dispatch_recipes());
assert!(!adapter_for(Harness::resolve("opencode").unwrap()).has_dispatch_recipes());
}
#[test]
fn skills_dir_is_harness_native() {
let root = Path::new("/repo");
assert_eq!(
adapter_for(Harness::resolve("claude-code").unwrap()).skills_dir(root),
Some(root.join(".claude").join("skills"))
);
assert_eq!(
adapter_for(Harness::resolve("codex").unwrap()).skills_dir(root),
Some(root.join(".agents").join("skills"))
);
assert_eq!(
adapter_for(Harness::resolve("opencode").unwrap()).skills_dir(root),
Some(root.join(".opencode").join("skills"))
);
}
#[test]
fn only_codex_and_opencode_rewrite_frontmatter() {
assert!(!adapter_for(Harness::resolve("claude-code").unwrap()).rewrites_frontmatter_name());
assert!(adapter_for(Harness::resolve("codex").unwrap()).rewrites_frontmatter_name());
assert!(adapter_for(Harness::resolve("opencode").unwrap()).rewrites_frontmatter_name());
}
#[test]
fn plan_mode_context_wraps_in_system_reminder_for_every_harness() {
for h in Harness::known() {
let out = adapter_for(h).render_plan_mode_context("BODY");
assert_eq!(out, "<system-reminder>\nBODY\n</system-reminder>");
assert_eq!(adapter_for(h).render_plan_mode_context(" "), "");
assert_eq!(
adapter_for(h).render_plan_mode_context("\n\n BODY \n\n"),
"<system-reminder>\nBODY\n</system-reminder>"
);
}
}
#[test]
fn run_capabilities_capture_run_option_support_by_harness() {
let claude = adapter_for(Harness::resolve("claude-code").unwrap()).run_capabilities();
assert!(claude.supports_guard);
assert!(claude.supports_bootstrap_with_no_stage);
assert!(claude.supports_stage_name_with_no_stage);
let codex = adapter_for(Harness::resolve("codex").unwrap()).run_capabilities();
assert!(codex.supports_guard);
assert!(!codex.supports_bootstrap_with_no_stage);
assert!(!codex.supports_stage_name_with_no_stage);
let opencode = adapter_for(Harness::resolve("opencode").unwrap()).run_capabilities();
assert!(!opencode.supports_guard);
assert!(opencode.supports_bootstrap_with_no_stage);
assert!(opencode.supports_stage_name_with_no_stage);
}
#[test]
fn guard_armed_message_is_harness_specific_and_absent_for_opencode() {
let claude = adapter_for(Harness::resolve("claude-code").unwrap())
.guard_armed_message()
.expect("claude code has a write guard");
assert!(
claude.contains(".claude/settings.local.json"),
"claude banner names its hook file: {claude}"
);
let codex = adapter_for(Harness::resolve("codex").unwrap())
.guard_armed_message()
.expect("codex has a write guard");
assert!(
codex.contains(".codex/hooks.json"),
"codex banner names its hook file: {codex}"
);
assert_eq!(
adapter_for(Harness::resolve("opencode").unwrap()).guard_armed_message(),
None
);
}
#[test]
fn staged_slug_default_and_opencode_override_preserve_the_prefix() {
let prefix = "slow-powers-eval-";
assert_eq!(
adapter_for(Harness::resolve("claude-code").unwrap()).staged_slug(
prefix,
2,
"with_skill",
"my-skill"
),
"slow-powers-eval-2-with_skill__my-skill"
);
assert_eq!(
adapter_for(Harness::resolve("opencode").unwrap()).staged_slug(
prefix,
2,
"with_skill",
"my-skill"
),
"slow-powers-eval-2-with-skill-my-skill"
);
}
}