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::codex_cli::{
19 codex_exec_command_template, codex_judge_dispatch_recipe, codex_parallel_dispatch_recipe,
20};
21use super::{
22 parse_codex_events, parse_codex_events_full, parse_transcript, parse_transcript_full,
23 render_available_skills_block, render_codex_available_skills_block,
24 render_opencode_available_skills_block,
25};
26
27pub trait HarnessAdapter {
30 fn label(&self) -> &'static str;
33
34 fn skills_dir(&self, repo_root: &Path) -> PathBuf;
36
37 fn rewrites_frontmatter_name(&self) -> bool;
40
41 fn advertises_staged_slug_name(&self) -> bool;
47
48 fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String;
52
53 fn skill_surface_phrase(&self) -> &'static str;
56
57 fn skill_unresolved_phrase(&self) -> &'static str;
60
61 fn plan_mode_profile(&self) -> &'static str;
63
64 fn render_plan_mode_context(&self, profile_text: &str) -> String {
67 let trimmed = profile_text.trim();
68 if trimmed.is_empty() {
69 return String::new();
70 }
71 format!("<system-reminder>\n{trimmed}\n</system-reminder>")
72 }
73
74 fn cli_events_filename(&self) -> Option<&'static str> {
79 None
80 }
81
82 fn cli_model_flag(&self) -> Option<&'static str> {
86 None
87 }
88
89 fn cli_next_steps(&self, _ctx: CliDispatchContext<'_>) -> String {
95 String::new()
96 }
97
98 fn cli_manifest_section(&self, _ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
102 None
103 }
104
105 fn cli_judge_next_steps(&self, _ctx: CliJudgeContext) -> Option<String> {
109 None
110 }
111
112 fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>>;
114
115 fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary>;
118
119 fn install_guard(
122 &self,
123 stage_root: &Path,
124 workspace_root: &Path,
125 guard_exe: &Path,
126 ttl: Option<Duration>,
127 ) -> io::Result<PathBuf>;
128}
129
130pub struct ClaudeCodeAdapter;
131pub struct CodexAdapter;
132pub struct OpenCodeAdapter;
133
134#[derive(Debug, Clone, Copy)]
136pub struct CliDispatchContext<'a> {
137 pub guard: bool,
138 pub target_args: &'a str,
139 pub iteration: u32,
140 pub agent_model: Option<&'a str>,
141}
142
143#[derive(Debug, Clone, Copy)]
145pub struct CliManifestContext<'a> {
146 pub guard: bool,
147 pub agent_model: Option<&'a str>,
148}
149
150#[derive(Debug, Clone, Copy)]
152pub struct CliJudgeContext {
153 pub guard: bool,
154}
155
156impl HarnessAdapter for ClaudeCodeAdapter {
157 fn label(&self) -> &'static str {
158 "claude-code"
159 }
160 fn skills_dir(&self, repo_root: &Path) -> PathBuf {
161 repo_root.join(".claude").join("skills")
162 }
163 fn rewrites_frontmatter_name(&self) -> bool {
164 false
165 }
166 fn advertises_staged_slug_name(&self) -> bool {
167 false
168 }
169 fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
170 render_available_skills_block(skills)
171 }
172 fn skill_surface_phrase(&self) -> &'static str {
173 "via the Skill tool"
174 }
175 fn skill_unresolved_phrase(&self) -> &'static str {
176 "If the Skill tool cannot resolve that identifier"
177 }
178 fn plan_mode_profile(&self) -> &'static str {
179 include_str!("../../profiles/claude-code/plan-mode.md")
180 }
181 fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
182 parse_transcript(path)
183 }
184 fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
185 parse_transcript_full(path)
186 }
187 fn install_guard(
188 &self,
189 stage_root: &Path,
190 workspace_root: &Path,
191 guard_exe: &Path,
192 ttl: Option<Duration>,
193 ) -> io::Result<PathBuf> {
194 crate::sandbox::install::install_claude_guard(stage_root, workspace_root, guard_exe, ttl)
195 }
196}
197
198impl HarnessAdapter for CodexAdapter {
199 fn label(&self) -> &'static str {
200 "codex"
201 }
202 fn skills_dir(&self, repo_root: &Path) -> PathBuf {
203 repo_root.join(".agents").join("skills")
204 }
205 fn rewrites_frontmatter_name(&self) -> bool {
206 true
207 }
208 fn advertises_staged_slug_name(&self) -> bool {
209 true
210 }
211 fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
212 render_codex_available_skills_block(skills)
213 }
214 fn skill_surface_phrase(&self) -> &'static str {
215 "as a Codex skill"
216 }
217 fn skill_unresolved_phrase(&self) -> &'static str {
218 "If it does not load as a Codex skill"
219 }
220 fn plan_mode_profile(&self) -> &'static str {
221 include_str!("../../profiles/codex/plan-mode.md")
222 }
223 fn cli_events_filename(&self) -> Option<&'static str> {
224 Some("codex-events.jsonl")
225 }
226 fn cli_model_flag(&self) -> Option<&'static str> {
227 Some("-m")
228 }
229 fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
230 format!(
231 "\nNext: iterate the tasks[] array in dispatch.json and dispatch each task with:\n{}\nThen run `ingest{target_args} --iteration {iteration} --harness codex`.",
232 codex_exec_command_template(self.cli_model_flag(), ctx.guard, ctx.agent_model),
233 target_args = ctx.target_args,
234 iteration = ctx.iteration
235 )
236 }
237 fn cli_manifest_section(&self, ctx: CliManifestContext<'_>) -> Option<Vec<String>> {
238 Some(vec![
239 "After all dispatches (Codex):".to_string(),
240 String::new(),
241 "Run one fresh `codex 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(),
242 String::new(),
243 "```bash".to_string(),
244 codex_exec_command_template(self.cli_model_flag(), ctx.guard, ctx.agent_model),
245 "```".to_string(),
246 String::new(),
247 "Parallel dispatch from this iteration directory:".to_string(),
248 String::new(),
249 "```bash".to_string(),
250 codex_parallel_dispatch_recipe(self.cli_model_flag(), ctx.guard, ctx.agent_model),
251 "```".to_string(),
252 String::new(),
253 "Then run `eval-magic ingest --harness codex`; Codex transcript ingest reads each task's `outputs/codex-events.jsonl`.".to_string(),
254 String::new(),
255 ])
256 }
257 fn cli_judge_next_steps(&self, ctx: CliJudgeContext) -> Option<String> {
258 Some(codex_judge_dispatch_recipe(
259 self.cli_model_flag(),
260 ctx.guard,
261 ))
262 }
263 fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
264 parse_codex_events(path)
265 }
266 fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
267 parse_codex_events_full(path)
268 }
269 fn install_guard(
270 &self,
271 stage_root: &Path,
272 workspace_root: &Path,
273 guard_exe: &Path,
274 ttl: Option<Duration>,
275 ) -> io::Result<PathBuf> {
276 crate::sandbox::install::install_codex_guard(stage_root, workspace_root, guard_exe, ttl)
277 }
278}
279
280impl HarnessAdapter for OpenCodeAdapter {
281 fn label(&self) -> &'static str {
282 "opencode"
283 }
284 fn skills_dir(&self, repo_root: &Path) -> PathBuf {
285 repo_root.join(".opencode").join("skills")
286 }
287 fn rewrites_frontmatter_name(&self) -> bool {
288 true
289 }
290 fn advertises_staged_slug_name(&self) -> bool {
291 false
292 }
293 fn render_available_skills_block(&self, skills: &[AvailableSkill]) -> String {
294 render_opencode_available_skills_block(skills)
295 }
296 fn skill_surface_phrase(&self) -> &'static str {
297 "as an OpenCode skill"
298 }
299 fn skill_unresolved_phrase(&self) -> &'static str {
300 "If it does not load as an OpenCode skill"
301 }
302 fn plan_mode_profile(&self) -> &'static str {
303 include_str!("../../profiles/opencode/plan-mode.md")
304 }
305 fn cli_next_steps(&self, ctx: CliDispatchContext<'_>) -> String {
306 let model_note = if ctx.agent_model.is_some() {
307 " Model selection was recorded as provenance, but the OpenCode adapter has no CLI model flag wired yet."
308 } else {
309 ""
310 };
311 format!(
312 "\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`.",
313 target_args = ctx.target_args,
314 iteration = ctx.iteration
315 )
316 }
317 fn parse_transcript(&self, path: &Path) -> io::Result<Vec<ToolInvocation>> {
322 parse_transcript(path)
323 }
324 fn parse_transcript_full(&self, path: &Path) -> io::Result<TranscriptSummary> {
325 parse_transcript_full(path)
326 }
327 fn install_guard(
328 &self,
329 _stage_root: &Path,
330 _workspace_root: &Path,
331 _guard_exe: &Path,
332 _ttl: Option<Duration>,
333 ) -> io::Result<PathBuf> {
334 Err(io::Error::new(
335 io::ErrorKind::Unsupported,
336 "--guard is not yet supported for the opencode harness",
337 ))
338 }
339}
340
341pub fn adapter_for(harness: Harness) -> &'static dyn HarnessAdapter {
345 match harness {
346 Harness::ClaudeCode => &ClaudeCodeAdapter,
347 Harness::Codex => &CodexAdapter,
348 Harness::OpenCode => &OpenCodeAdapter,
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn labels_match_kebab_case_identifiers() {
358 assert_eq!(adapter_for(Harness::ClaudeCode).label(), "claude-code");
359 assert_eq!(adapter_for(Harness::Codex).label(), "codex");
360 assert_eq!(adapter_for(Harness::OpenCode).label(), "opencode");
361 }
362
363 #[test]
364 fn skills_dir_is_harness_native() {
365 let root = Path::new("/repo");
366 assert_eq!(
367 adapter_for(Harness::ClaudeCode).skills_dir(root),
368 root.join(".claude").join("skills")
369 );
370 assert_eq!(
371 adapter_for(Harness::Codex).skills_dir(root),
372 root.join(".agents").join("skills")
373 );
374 assert_eq!(
375 adapter_for(Harness::OpenCode).skills_dir(root),
376 root.join(".opencode").join("skills")
377 );
378 }
379
380 #[test]
381 fn only_codex_and_opencode_rewrite_frontmatter() {
382 assert!(!adapter_for(Harness::ClaudeCode).rewrites_frontmatter_name());
383 assert!(adapter_for(Harness::Codex).rewrites_frontmatter_name());
384 assert!(adapter_for(Harness::OpenCode).rewrites_frontmatter_name());
385 }
386
387 #[test]
388 fn plan_mode_context_wraps_in_system_reminder_for_every_harness() {
389 for h in [Harness::ClaudeCode, Harness::Codex, Harness::OpenCode] {
390 let out = adapter_for(h).render_plan_mode_context("BODY");
391 assert_eq!(out, "<system-reminder>\nBODY\n</system-reminder>");
392 assert_eq!(adapter_for(h).render_plan_mode_context(" "), "");
393 }
394 }
395}