1use regex::Regex;
16use serde::{Deserialize, Serialize};
17
18use crate::core::ToolInvocation;
19use crate::validation::{SchemaName, ValidationError, validate_against_schema};
20
21use super::capabilities::{ShadowPreflight, SlugCapability, TranscriptParser};
22use super::extract::ExtractSpec;
23use super::transcript::TranscriptSummary;
24
25pub mod layers;
26mod validation;
27
28pub const EMBEDDED_DESCRIPTORS: [(&str, &str); 3] = [
31 (
32 "harnesses/claude-code.toml",
33 include_str!("../../harnesses/claude-code.toml"),
34 ),
35 (
36 "harnesses/codex.toml",
37 include_str!("../../harnesses/codex.toml"),
38 ),
39 (
40 "harnesses/opencode.toml",
41 include_str!("../../harnesses/opencode.toml"),
42 ),
43];
44
45#[derive(Debug, Clone, Deserialize, Serialize)]
51pub struct HarnessDescriptor {
52 pub label: String,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub skills_dir: Option<String>,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub config_dirs: Vec<String>,
57 #[serde(default, skip_serializing_if = "RunSection::is_default")]
58 pub run: RunSection,
59 #[serde(default, skip_serializing_if = "ToolsSection::is_empty")]
60 pub tools: ToolsSection,
61 #[serde(default, skip_serializing_if = "StagingSection::is_unconfigured")]
62 pub staging: StagingSection,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub skills_block: Option<SkillsBlockSection>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub transcript: Option<TranscriptSection>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub model: Option<ModelSection>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub guard: Option<GuardSection>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub shadow: Option<ShadowSection>,
73 #[serde(default, skip_serializing_if = "DispatchSection::is_empty")]
74 pub dispatch: DispatchSection,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub conversation: Option<ConversationSection>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
82pub struct RunSection {
83 #[serde(default)]
84 pub supports_guard: bool,
85 #[serde(default = "default_true")]
86 pub supports_bootstrap_with_no_stage: bool,
87 #[serde(default = "default_true")]
88 pub supports_stage_name_with_no_stage: bool,
89}
90
91impl RunSection {
92 pub fn is_default(&self) -> bool {
95 *self == RunSection::default()
96 }
97}
98
99impl Default for RunSection {
100 fn default() -> Self {
101 RunSection {
102 supports_guard: false,
103 supports_bootstrap_with_no_stage: true,
104 supports_stage_name_with_no_stage: true,
105 }
106 }
107}
108
109#[derive(Debug, Clone, Default, Deserialize, Serialize)]
110pub struct ToolsSection {
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
112 pub write: Vec<String>,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
114 pub patch: Vec<String>,
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub shell: Vec<String>,
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub read: Vec<String>,
119}
120
121impl ToolsSection {
122 pub fn is_empty(&self) -> bool {
124 self.write.is_empty()
125 && self.patch.is_empty()
126 && self.shell.is_empty()
127 && self.read.is_empty()
128 }
129}
130
131#[derive(Debug, Clone, Default, Deserialize, Serialize)]
132pub struct StagingSection {
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub slug_template: Option<String>,
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub slug_capability: Option<SlugCapability>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub stage_name_pattern: Option<String>,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub stage_name_max_len: Option<usize>,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub stage_name_invalid_message: Option<String>,
143 #[serde(default, skip_serializing_if = "is_false")]
144 pub rewrites_frontmatter_name: bool,
145 #[serde(default, skip_serializing_if = "is_false")]
146 pub advertises_staged_slug_name: bool,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 pub surface_phrase: Option<String>,
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub unresolved_phrase: Option<String>,
151}
152
153impl StagingSection {
154 pub fn is_configured(&self) -> bool {
157 !self.is_unconfigured()
158 }
159
160 fn is_unconfigured(&self) -> bool {
161 self.slug_template.is_none()
162 && self.slug_capability.is_none()
163 && self.stage_name_pattern.is_none()
164 && self.stage_name_max_len.is_none()
165 && self.stage_name_invalid_message.is_none()
166 && !self.rewrites_frontmatter_name
167 && !self.advertises_staged_slug_name
168 && self.surface_phrase.is_none()
169 && self.unresolved_phrase.is_none()
170 }
171}
172
173#[derive(Debug, Clone, Deserialize, Serialize)]
174pub struct SkillsBlockSection {
175 pub header: String,
176 pub item: String,
177 #[serde(default, skip_serializing_if = "String::is_empty")]
178 pub footer: String,
179}
180
181#[derive(Debug, Clone, Deserialize, Serialize)]
182pub struct TranscriptSection {
183 pub events_filename: String,
184 #[serde(skip_serializing_if = "Option::is_none")]
187 pub parser: Option<TranscriptParser>,
188 #[serde(skip_serializing_if = "Option::is_none")]
190 pub extract: Option<ExtractSpec>,
191 #[serde(default = "default_true", skip_serializing_if = "is_true")]
192 pub surfaces_skill_invocation: bool,
193 #[serde(skip_serializing_if = "Option::is_none")]
197 pub skill_tool: Option<String>,
198 #[serde(skip_serializing_if = "Option::is_none")]
201 pub skill_arg: Option<String>,
202}
203
204impl TranscriptSection {
205 pub(crate) fn parse(&self, path: &std::path::Path) -> std::io::Result<Vec<ToolInvocation>> {
208 match (&self.parser, &self.extract) {
209 (Some(parser), _) => parser.parse(path),
210 (None, Some(extract)) => super::extract::parse(extract, path),
211 (None, None) => Err(unwired_error()),
214 }
215 }
216
217 pub(crate) fn parse_full(&self, path: &std::path::Path) -> std::io::Result<TranscriptSummary> {
219 match (&self.parser, &self.extract) {
220 (Some(parser), _) => parser.parse_full(path),
221 (None, Some(extract)) => super::extract::parse_full(extract, path),
222 (None, None) => Err(unwired_error()),
223 }
224 }
225}
226
227fn unwired_error() -> std::io::Error {
228 std::io::Error::new(
229 std::io::ErrorKind::Unsupported,
230 "[transcript] declares neither a parser nor an extract block",
231 )
232}
233
234#[derive(Debug, Clone, Deserialize, Serialize)]
235pub struct ModelSection {
236 pub flag: String,
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
244#[serde(rename_all = "kebab-case")]
245pub enum GuardEngine {
246 #[default]
247 JsonHooks,
248 OpencodePlugin,
249}
250
251impl GuardEngine {
252 fn is_default(&self) -> bool {
255 *self == GuardEngine::default()
256 }
257}
258
259#[derive(Debug, Clone, Deserialize, Serialize)]
269pub struct GuardSection {
270 #[serde(default, skip_serializing_if = "GuardEngine::is_default")]
272 pub engine: GuardEngine,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub hooks_file: Option<String>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub matcher: Option<String>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub command_template: Option<String>,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub hook_entry: Option<String>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub plugin_file: Option<String>,
296 pub verdict_template: String,
299 pub armed_message: String,
301}
302
303#[derive(Debug, Clone, Deserialize, Serialize)]
304pub struct ShadowSection {
305 pub preflight: ShadowPreflight,
306}
307
308#[derive(Debug, Clone, Default, Deserialize, Serialize)]
309pub struct DispatchSection {
310 #[serde(skip_serializing_if = "Option::is_none")]
311 pub capture_prefix: Option<String>,
312 #[serde(skip_serializing_if = "Option::is_none")]
313 pub guard_args: Option<String>,
314 #[serde(skip_serializing_if = "Option::is_none")]
315 pub model_note: Option<String>,
316 #[serde(skip_serializing_if = "Option::is_none")]
317 pub next_steps_template: Option<String>,
318 #[serde(skip_serializing_if = "Option::is_none")]
319 pub exec_template: Option<String>,
320 #[serde(skip_serializing_if = "Option::is_none")]
321 pub parallel_command_template: Option<String>,
322 #[serde(skip_serializing_if = "Option::is_none")]
323 pub judge_command_template: Option<String>,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 pub manifest_template: Option<String>,
326}
327
328#[derive(Debug, Clone, Deserialize, Serialize)]
330pub struct ConversationSection {
331 pub resume_exec_template: String,
335}
336
337impl DispatchSection {
338 pub fn is_empty(&self) -> bool {
340 self.capture_prefix.is_none()
341 && self.guard_args.is_none()
342 && self.model_note.is_none()
343 && self.next_steps_template.is_none()
344 && self.exec_template.is_none()
345 && self.parallel_command_template.is_none()
346 && self.judge_command_template.is_none()
347 && self.manifest_template.is_none()
348 }
349}
350
351fn default_true() -> bool {
352 true
353}
354
355fn is_false(value: &bool) -> bool {
357 !*value
358}
359
360fn is_true(value: &bool) -> bool {
361 *value
362}
363
364#[derive(Debug, thiserror::Error)]
367pub enum DescriptorError {
368 #[error("{path}: invalid TOML: {message}")]
369 Toml { path: String, message: String },
370 #[error(transparent)]
371 Validation(#[from] ValidationError),
372 #[error("{path}: {message}")]
373 Invariant { path: String, message: String },
374}
375
376pub fn load_descriptor(toml_src: &str, source: &str) -> Result<HarnessDescriptor, DescriptorError> {
379 finalize_descriptor(&parse_descriptor_value(toml_src, source)?, source)
380}
381
382pub fn parse_descriptor_value(
388 toml_src: &str,
389 source: &str,
390) -> Result<serde_json::Value, DescriptorError> {
391 let value: serde_json::Value = toml::from_str(toml_src).map_err(|e| DescriptorError::Toml {
392 path: source.to_string(),
393 message: e.to_string(),
394 })?;
395 let _: serde_json::Value =
396 validate_against_schema(SchemaName::HarnessDescriptor, &value, source)?;
397 Ok(value)
398}
399
400pub fn merge_descriptor_value(base: &mut serde_json::Value, overlay: serde_json::Value) {
405 match (base, overlay) {
406 (serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => {
407 for (key, value) in overlay {
408 match base.get_mut(&key) {
409 Some(slot) if slot.is_object() && value.is_object() => {
410 merge_descriptor_value(slot, value);
411 }
412 _ => {
413 base.insert(key, value);
414 }
415 }
416 }
417 }
418 (base, overlay) => *base = overlay,
419 }
420}
421
422pub fn finalize_descriptor(
426 value: &serde_json::Value,
427 provenance: &str,
428) -> Result<HarnessDescriptor, DescriptorError> {
429 let descriptor: HarnessDescriptor =
430 validate_against_schema(SchemaName::HarnessDescriptor, value, provenance)?;
431 validation::validate_descriptor(&descriptor, provenance)?;
432 Ok(descriptor)
433}
434
435pub(crate) fn subst(template: &str, vars: &[(&str, &str)]) -> String {
441 let mut out = String::with_capacity(template.len());
442 let mut rest = template;
443 while let Some(start) = rest.find('{') {
444 out.push_str(&rest[..start]);
445 let after = &rest[start + 1..];
446 let Some(end) = after.find('}') else {
447 out.push('{');
448 rest = after;
449 continue;
450 };
451 match vars.iter().find(|(k, _)| *k == &after[..end]) {
452 Some((_, value)) => {
453 out.push_str(value);
454 rest = &after[end + 1..];
455 }
456 None => {
457 out.push('{');
458 rest = after;
459 }
460 }
461 }
462 out.push_str(rest);
463 out
464}
465
466pub(crate) const DEFAULT_SLUG_TEMPLATE: &str = "{prefix}{iteration}-{condition}__{skill_name}";
469
470pub(crate) fn render_staged_slug(
473 staging: &StagingSection,
474 prefix: &str,
475 iteration: u32,
476 condition: &str,
477 skill_name: &str,
478) -> String {
479 if let Some(capability) = staging.slug_capability {
480 return capability.staged_slug(prefix, iteration, condition, skill_name);
481 }
482 let iteration = iteration.to_string();
483 subst(
484 staging
485 .slug_template
486 .as_deref()
487 .unwrap_or(DEFAULT_SLUG_TEMPLATE),
488 &[
489 ("prefix", prefix),
490 ("iteration", &iteration),
491 ("condition", condition),
492 ("skill_name", skill_name),
493 ],
494 )
495}
496
497pub(crate) fn stage_name_error(
500 staging: &StagingSection,
501 regex: Option<&Regex>,
502 name: &str,
503) -> Option<String> {
504 let len_ok = staging
505 .stage_name_max_len
506 .is_none_or(|max| name.len() <= max);
507 let pattern_ok = regex.is_none_or(|r| r.is_match(name));
508 if len_ok && pattern_ok {
509 return None;
510 }
511 Some(match &staging.stage_name_invalid_message {
512 Some(message) => subst(message, &[("name", name)]),
513 None => format!("stage name \"{name}\" violates the descriptor's naming rules"),
514 })
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 fn load(toml_src: &str) -> Result<HarnessDescriptor, DescriptorError> {
522 load_descriptor(toml_src, "test.toml")
523 }
524
525 fn err_of(toml_src: &str) -> String {
526 load(toml_src)
527 .expect_err("descriptor should be rejected")
528 .to_string()
529 }
530
531 const MINIMAL: &str = r#"
532label = "demo"
533skills_dir = ".demo/skills"
534config_dirs = [".demo"]
535"#;
536
537 const GUARDED: &str = r#"
538label = "demo"
539skills_dir = ".demo/skills"
540config_dirs = [".demo"]
541
542[run]
543supports_guard = true
544
545[tools]
546write = ["Edit", "MultiEdit", "NotebookEdit", "Write"]
547shell = ["Bash"]
548
549[guard]
550hooks_file = ".demo/hooks.json"
551matcher = "Write|Edit|MultiEdit|NotebookEdit|Bash"
552command_template = '"{exe}" guard-hook --harness demo "{marker}"'
553hook_entry = '{"matcher":"{matcher}","hooks":[{"type":"command","command":"{command}"}]}'
554verdict_template = '{"decision":"block","reason":"{reason}"}'
555armed_message = "guard armed"
556"#;
557
558 const EXTRACTED: &str = r#"
560label = "demo"
561skills_dir = ".demo/skills"
562config_dirs = [".demo"]
563
564[tools]
565write = ["file_change"]
566shell = ["command_execution"]
567
568[transcript]
569events_filename = "demo-events.jsonl"
570
571[transcript.extract.tools]
572where = { type = "item.completed" }
573item = "item"
574name_field = "type"
575skip_names = ["agent_message"]
576args_omit = ["id", "type", "output"]
577result_coalesce = ["output"]
578
579[transcript.extract.final_text]
580where = { type = "item.completed", "item.type" = "agent_message" }
581field = "item.text"
582
583[transcript.extract.tokens]
584where = { type = "turn.completed" }
585sum = ["usage.input_tokens", "usage.output_tokens"]
586
587[transcript.extract.duration]
588timestamp_spread = "timestamp"
589"#;
590
591 #[test]
592 fn extract_descriptor_loads_and_reserializes_to_loadable_toml() {
593 let d = load(EXTRACTED).unwrap();
594 let transcript = d.transcript.as_ref().expect("transcript section loads");
595 assert!(transcript.parser.is_none());
596 assert!(transcript.extract.is_some());
597
598 let shown = toml::to_string(&d).expect("descriptor re-serializes");
601 let reloaded = load(&shown).unwrap();
602 let extract = reloaded.transcript.unwrap().extract.unwrap();
603 assert_eq!(
604 extract.tokens.unwrap().sum,
605 vec!["usage.input_tokens", "usage.output_tokens"]
606 );
607 assert_eq!(
608 extract
609 .tools
610 .unwrap()
611 .r#where
612 .get("type")
613 .map(String::as_str),
614 Some("item.completed")
615 );
616 }
617
618 #[test]
619 fn transcript_section_dispatches_parse_to_the_extract_engine() {
620 let transcript = load(EXTRACTED).unwrap().transcript.unwrap();
621 let dir = tempfile::TempDir::new().unwrap();
622 let path = dir.path().join("demo-events.jsonl");
623 std::fs::write(
624 &path,
625 concat!(
626 r#"{"type":"item.completed","item":{"id":"i1","type":"command_execution","command":"ls","output":"ok"}}"#,
627 "\n",
628 r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"Done."}}"#,
629 "\n",
630 ),
631 )
632 .unwrap();
633
634 let invocations = transcript.parse(&path).unwrap();
635 assert_eq!(invocations.len(), 1);
636 assert_eq!(invocations[0].name, "command_execution");
637
638 let full = transcript.parse_full(&path).unwrap();
639 assert_eq!(full.final_text, Some("Done.".into()));
640 assert_eq!(full.tool_invocations.len(), 1);
641 }
642
643 #[test]
644 fn minimal_descriptor_loads() {
645 let d = load(MINIMAL).unwrap();
646 assert_eq!(d.label, "demo");
647 assert_eq!(d.skills_dir.as_deref(), Some(".demo/skills"));
648 assert_eq!(d.config_dirs, vec![".demo".to_string()]);
649 assert!(!d.run.supports_guard);
651 assert!(d.run.supports_bootstrap_with_no_stage);
652 assert!(d.run.supports_stage_name_with_no_stage);
653 assert!(!d.staging.rewrites_frontmatter_name);
654 assert!(d.guard.is_none());
655 assert!(d.transcript.is_none());
656 }
657
658 #[test]
659 fn guarded_descriptor_loads() {
660 let d = load(GUARDED).unwrap();
661 assert!(d.run.supports_guard);
662 let guard = d.guard.expect("guard section loads");
663 assert_eq!(guard.engine, GuardEngine::JsonHooks);
664 assert_eq!(guard.hooks_file.as_deref(), Some(".demo/hooks.json"));
665 assert_eq!(
666 guard.matcher.as_deref(),
667 Some("Write|Edit|MultiEdit|NotebookEdit|Bash")
668 );
669 assert_eq!(
670 guard.command_template.as_deref(),
671 Some(r#""{exe}" guard-hook --harness demo "{marker}""#)
672 );
673 assert_eq!(guard.plugin_file, None);
674 assert_eq!(guard.armed_message, "guard armed");
675 }
676
677 #[test]
678 fn label_only_descriptor_loads_as_baseline() {
679 let d = load("label = \"demo\"\n").unwrap();
680 assert_eq!(d.label, "demo");
681 assert!(d.skills_dir.is_none(), "no skills dir declared");
682 assert!(d.config_dirs.is_empty());
683 assert!(!d.run.supports_guard);
684 }
685
686 #[test]
687 fn rejects_staging_without_skills_dir() {
688 let err = err_of("label = \"demo\"\n\n[staging]\nsurface_phrase = \"skill\"\n");
689 assert!(err.contains("staging"), "{err}");
690 assert!(err.contains("skills_dir"), "{err}");
691 }
692
693 #[test]
694 fn rejects_guard_without_skills_dir() {
695 let toml = &GUARDED.replace("skills_dir = \".demo/skills\"\n", "");
696 let err = err_of(toml);
697 assert!(err.contains("guard"), "{err}");
698 assert!(err.contains("skills_dir"), "{err}");
699 }
700
701 #[test]
702 fn embedded_descriptors_load_and_validate() {
703 for (source, toml_src) in EMBEDDED_DESCRIPTORS {
704 let d = load_descriptor(toml_src, source)
705 .unwrap_or_else(|e| panic!("embedded descriptor {source} is invalid: {e}"));
706 assert!(!d.label.is_empty());
707 }
708 }
709
710 #[test]
711 fn rejects_invalid_toml_syntax() {
712 let err = err_of("label = ");
713 assert!(err.contains("test.toml"), "{err}");
714 assert!(err.contains("invalid TOML"), "{err}");
715 }
716
717 #[test]
718 fn rejects_unknown_top_level_field() {
719 let err = err_of(&format!("{MINIMAL}\nmystery = true\n"));
720 assert!(err.contains("harness-descriptor schema"), "{err}");
721 }
722
723 #[test]
724 fn rejects_non_kebab_case_label() {
725 let err = err_of(&MINIMAL.replace("\"demo\"", "\"Not_Kebab\""));
726 assert!(err.contains("harness-descriptor schema"), "{err}");
727 }
728
729 #[test]
735 fn rejects_the_retired_guard_engine_field() {
736 let err = err_of(&GUARDED.replace(
737 "hooks_file = \".demo/hooks.json\"",
738 "engine = \"claude-hooks\"",
739 ));
740 assert!(err.contains("harness-descriptor schema"), "{err}");
741 }
742
743 #[test]
744 fn merge_deep_merges_tables_and_replaces_scalars_and_arrays() {
745 let mut base: serde_json::Value = toml::from_str(
746 r#"
747label = "demo"
748config_dirs = [".demo"]
749
750[model]
751flag = "--model"
752
753[dispatch]
754capture_prefix = "demo"
755"#,
756 )
757 .unwrap();
758 let overlay: serde_json::Value = toml::from_str(
759 r#"
760label = "demo"
761config_dirs = [".other"]
762
763[model]
764flag = "--model-x"
765"#,
766 )
767 .unwrap();
768 merge_descriptor_value(&mut base, overlay);
769 assert_eq!(base["model"]["flag"], "--model-x");
772 assert_eq!(base["dispatch"]["capture_prefix"], "demo");
773 assert_eq!(base["config_dirs"], serde_json::json!([".other"]));
774 }
775
776 #[test]
777 fn finalize_names_every_contributing_file_on_invariant_breaks() {
778 let value: serde_json::Value =
781 toml::from_str("label = \"demo\"\nskills_dir = \".demo/skills\"\n").unwrap();
782 let err = finalize_descriptor(&value, "base.toml (built-in) + overlay.toml (project)")
783 .expect_err("missing config_dirs parent should be rejected")
784 .to_string();
785 assert!(
786 err.contains("base.toml (built-in) + overlay.toml (project)"),
787 "{err}"
788 );
789 }
790
791 #[test]
792 fn subst_replaces_tokens_and_passes_unknown_through() {
793 let out = subst(
794 "run {exec} with ${JOBS:-4} and -I{} on {exec}",
795 &[("exec", "demo-cmd")],
796 );
797 assert_eq!(out, "run demo-cmd with ${JOBS:-4} and -I{} on demo-cmd");
798 }
799
800 #[test]
801 fn subst_does_not_rescan_substituted_values() {
802 let out = subst("{a} {b}", &[("a", "holds-{b}"), ("b", "second")]);
803 assert_eq!(out, "holds-{b} second");
804 }
805}