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}
76
77#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
80pub struct RunSection {
81 #[serde(default)]
82 pub supports_guard: bool,
83 #[serde(default = "default_true")]
84 pub supports_bootstrap_with_no_stage: bool,
85 #[serde(default = "default_true")]
86 pub supports_stage_name_with_no_stage: bool,
87}
88
89impl RunSection {
90 pub fn is_default(&self) -> bool {
93 *self == RunSection::default()
94 }
95}
96
97impl Default for RunSection {
98 fn default() -> Self {
99 RunSection {
100 supports_guard: false,
101 supports_bootstrap_with_no_stage: true,
102 supports_stage_name_with_no_stage: true,
103 }
104 }
105}
106
107#[derive(Debug, Clone, Default, Deserialize, Serialize)]
108pub struct ToolsSection {
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub write: Vec<String>,
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
112 pub patch: Vec<String>,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
114 pub shell: Vec<String>,
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub read: Vec<String>,
117}
118
119impl ToolsSection {
120 pub fn is_empty(&self) -> bool {
122 self.write.is_empty()
123 && self.patch.is_empty()
124 && self.shell.is_empty()
125 && self.read.is_empty()
126 }
127}
128
129#[derive(Debug, Clone, Default, Deserialize, Serialize)]
130pub struct StagingSection {
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub slug_template: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub slug_capability: Option<SlugCapability>,
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub stage_name_pattern: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub stage_name_max_len: Option<usize>,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub stage_name_invalid_message: Option<String>,
141 #[serde(default, skip_serializing_if = "is_false")]
142 pub rewrites_frontmatter_name: bool,
143 #[serde(default, skip_serializing_if = "is_false")]
144 pub advertises_staged_slug_name: bool,
145 #[serde(skip_serializing_if = "Option::is_none")]
146 pub surface_phrase: Option<String>,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 pub unresolved_phrase: Option<String>,
149}
150
151impl StagingSection {
152 pub fn is_configured(&self) -> bool {
155 !self.is_unconfigured()
156 }
157
158 fn is_unconfigured(&self) -> bool {
159 self.slug_template.is_none()
160 && self.slug_capability.is_none()
161 && self.stage_name_pattern.is_none()
162 && self.stage_name_max_len.is_none()
163 && self.stage_name_invalid_message.is_none()
164 && !self.rewrites_frontmatter_name
165 && !self.advertises_staged_slug_name
166 && self.surface_phrase.is_none()
167 && self.unresolved_phrase.is_none()
168 }
169}
170
171#[derive(Debug, Clone, Deserialize, Serialize)]
172pub struct SkillsBlockSection {
173 pub header: String,
174 pub item: String,
175 #[serde(default, skip_serializing_if = "String::is_empty")]
176 pub footer: String,
177}
178
179#[derive(Debug, Clone, Deserialize, Serialize)]
180pub struct TranscriptSection {
181 pub events_filename: String,
182 #[serde(skip_serializing_if = "Option::is_none")]
185 pub parser: Option<TranscriptParser>,
186 #[serde(skip_serializing_if = "Option::is_none")]
188 pub extract: Option<ExtractSpec>,
189 #[serde(default = "default_true", skip_serializing_if = "is_true")]
190 pub surfaces_skill_invocation: bool,
191}
192
193impl TranscriptSection {
194 pub(crate) fn parse(&self, path: &std::path::Path) -> std::io::Result<Vec<ToolInvocation>> {
197 match (&self.parser, &self.extract) {
198 (Some(parser), _) => parser.parse(path),
199 (None, Some(extract)) => super::extract::parse(extract, path),
200 (None, None) => Err(unwired_error()),
203 }
204 }
205
206 pub(crate) fn parse_full(&self, path: &std::path::Path) -> std::io::Result<TranscriptSummary> {
208 match (&self.parser, &self.extract) {
209 (Some(parser), _) => parser.parse_full(path),
210 (None, Some(extract)) => super::extract::parse_full(extract, path),
211 (None, None) => Err(unwired_error()),
212 }
213 }
214}
215
216fn unwired_error() -> std::io::Error {
217 std::io::Error::new(
218 std::io::ErrorKind::Unsupported,
219 "[transcript] declares neither a parser nor an extract block",
220 )
221}
222
223#[derive(Debug, Clone, Deserialize, Serialize)]
224pub struct ModelSection {
225 pub flag: String,
226}
227
228#[derive(Debug, Clone, Deserialize, Serialize)]
234pub struct GuardSection {
235 pub hooks_file: String,
238 pub matcher: String,
241 pub command_template: String,
243 pub hook_entry: String,
247 pub verdict_template: String,
250 pub armed_message: String,
252}
253
254#[derive(Debug, Clone, Deserialize, Serialize)]
255pub struct ShadowSection {
256 pub preflight: ShadowPreflight,
257}
258
259#[derive(Debug, Clone, Default, Deserialize, Serialize)]
260pub struct DispatchSection {
261 #[serde(skip_serializing_if = "Option::is_none")]
262 pub capture_prefix: Option<String>,
263 #[serde(skip_serializing_if = "Option::is_none")]
264 pub guard_args: Option<String>,
265 #[serde(skip_serializing_if = "Option::is_none")]
266 pub model_note: Option<String>,
267 #[serde(skip_serializing_if = "Option::is_none")]
268 pub next_steps_template: Option<String>,
269 #[serde(skip_serializing_if = "Option::is_none")]
270 pub exec_template: Option<String>,
271 #[serde(skip_serializing_if = "Option::is_none")]
272 pub parallel_command_template: Option<String>,
273 #[serde(skip_serializing_if = "Option::is_none")]
274 pub judge_command_template: Option<String>,
275 #[serde(skip_serializing_if = "Option::is_none")]
276 pub manifest_template: Option<String>,
277}
278
279impl DispatchSection {
280 pub fn is_empty(&self) -> bool {
282 self.capture_prefix.is_none()
283 && self.guard_args.is_none()
284 && self.model_note.is_none()
285 && self.next_steps_template.is_none()
286 && self.exec_template.is_none()
287 && self.parallel_command_template.is_none()
288 && self.judge_command_template.is_none()
289 && self.manifest_template.is_none()
290 }
291}
292
293fn default_true() -> bool {
294 true
295}
296
297fn is_false(value: &bool) -> bool {
299 !*value
300}
301
302fn is_true(value: &bool) -> bool {
303 *value
304}
305
306#[derive(Debug, thiserror::Error)]
309pub enum DescriptorError {
310 #[error("{path}: invalid TOML: {message}")]
311 Toml { path: String, message: String },
312 #[error(transparent)]
313 Validation(#[from] ValidationError),
314 #[error("{path}: {message}")]
315 Invariant { path: String, message: String },
316}
317
318pub fn load_descriptor(toml_src: &str, source: &str) -> Result<HarnessDescriptor, DescriptorError> {
321 finalize_descriptor(&parse_descriptor_value(toml_src, source)?, source)
322}
323
324pub fn parse_descriptor_value(
330 toml_src: &str,
331 source: &str,
332) -> Result<serde_json::Value, DescriptorError> {
333 let value: serde_json::Value = toml::from_str(toml_src).map_err(|e| DescriptorError::Toml {
334 path: source.to_string(),
335 message: e.to_string(),
336 })?;
337 let _: serde_json::Value =
338 validate_against_schema(SchemaName::HarnessDescriptor, &value, source)?;
339 Ok(value)
340}
341
342pub fn merge_descriptor_value(base: &mut serde_json::Value, overlay: serde_json::Value) {
347 match (base, overlay) {
348 (serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => {
349 for (key, value) in overlay {
350 match base.get_mut(&key) {
351 Some(slot) if slot.is_object() && value.is_object() => {
352 merge_descriptor_value(slot, value);
353 }
354 _ => {
355 base.insert(key, value);
356 }
357 }
358 }
359 }
360 (base, overlay) => *base = overlay,
361 }
362}
363
364pub fn finalize_descriptor(
368 value: &serde_json::Value,
369 provenance: &str,
370) -> Result<HarnessDescriptor, DescriptorError> {
371 let descriptor: HarnessDescriptor =
372 validate_against_schema(SchemaName::HarnessDescriptor, value, provenance)?;
373 validation::validate_descriptor(&descriptor, provenance)?;
374 Ok(descriptor)
375}
376
377pub(crate) fn subst(template: &str, vars: &[(&str, &str)]) -> String {
383 let mut out = String::with_capacity(template.len());
384 let mut rest = template;
385 while let Some(start) = rest.find('{') {
386 out.push_str(&rest[..start]);
387 let after = &rest[start + 1..];
388 let Some(end) = after.find('}') else {
389 out.push('{');
390 rest = after;
391 continue;
392 };
393 match vars.iter().find(|(k, _)| *k == &after[..end]) {
394 Some((_, value)) => {
395 out.push_str(value);
396 rest = &after[end + 1..];
397 }
398 None => {
399 out.push('{');
400 rest = after;
401 }
402 }
403 }
404 out.push_str(rest);
405 out
406}
407
408pub(crate) const DEFAULT_SLUG_TEMPLATE: &str = "{prefix}{iteration}-{condition}__{skill_name}";
411
412pub(crate) fn render_staged_slug(
415 staging: &StagingSection,
416 prefix: &str,
417 iteration: u32,
418 condition: &str,
419 skill_name: &str,
420) -> String {
421 if let Some(capability) = staging.slug_capability {
422 return capability.staged_slug(prefix, iteration, condition, skill_name);
423 }
424 let iteration = iteration.to_string();
425 subst(
426 staging
427 .slug_template
428 .as_deref()
429 .unwrap_or(DEFAULT_SLUG_TEMPLATE),
430 &[
431 ("prefix", prefix),
432 ("iteration", &iteration),
433 ("condition", condition),
434 ("skill_name", skill_name),
435 ],
436 )
437}
438
439pub(crate) fn stage_name_error(
442 staging: &StagingSection,
443 regex: Option<&Regex>,
444 name: &str,
445) -> Option<String> {
446 let len_ok = staging
447 .stage_name_max_len
448 .is_none_or(|max| name.len() <= max);
449 let pattern_ok = regex.is_none_or(|r| r.is_match(name));
450 if len_ok && pattern_ok {
451 return None;
452 }
453 Some(match &staging.stage_name_invalid_message {
454 Some(message) => subst(message, &[("name", name)]),
455 None => format!("stage name \"{name}\" violates the descriptor's naming rules"),
456 })
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 fn load(toml_src: &str) -> Result<HarnessDescriptor, DescriptorError> {
464 load_descriptor(toml_src, "test.toml")
465 }
466
467 fn err_of(toml_src: &str) -> String {
468 load(toml_src)
469 .expect_err("descriptor should be rejected")
470 .to_string()
471 }
472
473 const MINIMAL: &str = r#"
474label = "demo"
475skills_dir = ".demo/skills"
476config_dirs = [".demo"]
477"#;
478
479 const GUARDED: &str = r#"
480label = "demo"
481skills_dir = ".demo/skills"
482config_dirs = [".demo"]
483
484[run]
485supports_guard = true
486
487[tools]
488write = ["Edit", "MultiEdit", "NotebookEdit", "Write"]
489shell = ["Bash"]
490
491[guard]
492hooks_file = ".demo/hooks.json"
493matcher = "Write|Edit|MultiEdit|NotebookEdit|Bash"
494command_template = '"{exe}" guard-hook --harness demo "{marker}"'
495hook_entry = '{"matcher":"{matcher}","hooks":[{"type":"command","command":"{command}"}]}'
496verdict_template = '{"decision":"block","reason":"{reason}"}'
497armed_message = "guard armed"
498"#;
499
500 const EXTRACTED: &str = r#"
502label = "demo"
503skills_dir = ".demo/skills"
504config_dirs = [".demo"]
505
506[tools]
507write = ["file_change"]
508shell = ["command_execution"]
509
510[transcript]
511events_filename = "demo-events.jsonl"
512
513[transcript.extract.tools]
514where = { type = "item.completed" }
515item = "item"
516name_field = "type"
517skip_names = ["agent_message"]
518args_omit = ["id", "type", "output"]
519result_coalesce = ["output"]
520
521[transcript.extract.final_text]
522where = { type = "item.completed", "item.type" = "agent_message" }
523field = "item.text"
524
525[transcript.extract.tokens]
526where = { type = "turn.completed" }
527sum = ["usage.input_tokens", "usage.output_tokens"]
528
529[transcript.extract.duration]
530timestamp_spread = "timestamp"
531"#;
532
533 #[test]
534 fn extract_descriptor_loads_and_reserializes_to_loadable_toml() {
535 let d = load(EXTRACTED).unwrap();
536 let transcript = d.transcript.as_ref().expect("transcript section loads");
537 assert!(transcript.parser.is_none());
538 assert!(transcript.extract.is_some());
539
540 let shown = toml::to_string(&d).expect("descriptor re-serializes");
543 let reloaded = load(&shown).unwrap();
544 let extract = reloaded.transcript.unwrap().extract.unwrap();
545 assert_eq!(
546 extract.tokens.unwrap().sum,
547 vec!["usage.input_tokens", "usage.output_tokens"]
548 );
549 assert_eq!(
550 extract
551 .tools
552 .unwrap()
553 .r#where
554 .get("type")
555 .map(String::as_str),
556 Some("item.completed")
557 );
558 }
559
560 #[test]
561 fn transcript_section_dispatches_parse_to_the_extract_engine() {
562 let transcript = load(EXTRACTED).unwrap().transcript.unwrap();
563 let dir = tempfile::TempDir::new().unwrap();
564 let path = dir.path().join("demo-events.jsonl");
565 std::fs::write(
566 &path,
567 concat!(
568 r#"{"type":"item.completed","item":{"id":"i1","type":"command_execution","command":"ls","output":"ok"}}"#,
569 "\n",
570 r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"Done."}}"#,
571 "\n",
572 ),
573 )
574 .unwrap();
575
576 let invocations = transcript.parse(&path).unwrap();
577 assert_eq!(invocations.len(), 1);
578 assert_eq!(invocations[0].name, "command_execution");
579
580 let full = transcript.parse_full(&path).unwrap();
581 assert_eq!(full.final_text, Some("Done.".into()));
582 assert_eq!(full.tool_invocations.len(), 1);
583 }
584
585 #[test]
586 fn minimal_descriptor_loads() {
587 let d = load(MINIMAL).unwrap();
588 assert_eq!(d.label, "demo");
589 assert_eq!(d.skills_dir.as_deref(), Some(".demo/skills"));
590 assert_eq!(d.config_dirs, vec![".demo".to_string()]);
591 assert!(!d.run.supports_guard);
593 assert!(d.run.supports_bootstrap_with_no_stage);
594 assert!(d.run.supports_stage_name_with_no_stage);
595 assert!(!d.staging.rewrites_frontmatter_name);
596 assert!(d.guard.is_none());
597 assert!(d.transcript.is_none());
598 }
599
600 #[test]
601 fn guarded_descriptor_loads() {
602 let d = load(GUARDED).unwrap();
603 assert!(d.run.supports_guard);
604 let guard = d.guard.expect("guard section loads");
605 assert_eq!(guard.hooks_file, ".demo/hooks.json");
606 assert_eq!(guard.matcher, "Write|Edit|MultiEdit|NotebookEdit|Bash");
607 assert_eq!(
608 guard.command_template,
609 r#""{exe}" guard-hook --harness demo "{marker}""#
610 );
611 assert_eq!(guard.armed_message, "guard armed");
612 }
613
614 #[test]
615 fn label_only_descriptor_loads_as_baseline() {
616 let d = load("label = \"demo\"\n").unwrap();
617 assert_eq!(d.label, "demo");
618 assert!(d.skills_dir.is_none(), "no skills dir declared");
619 assert!(d.config_dirs.is_empty());
620 assert!(!d.run.supports_guard);
621 }
622
623 #[test]
624 fn rejects_staging_without_skills_dir() {
625 let err = err_of("label = \"demo\"\n\n[staging]\nsurface_phrase = \"skill\"\n");
626 assert!(err.contains("staging"), "{err}");
627 assert!(err.contains("skills_dir"), "{err}");
628 }
629
630 #[test]
631 fn rejects_guard_without_skills_dir() {
632 let toml = &GUARDED.replace("skills_dir = \".demo/skills\"\n", "");
633 let err = err_of(toml);
634 assert!(err.contains("guard"), "{err}");
635 assert!(err.contains("skills_dir"), "{err}");
636 }
637
638 #[test]
639 fn embedded_descriptors_load_and_validate() {
640 for (source, toml_src) in EMBEDDED_DESCRIPTORS {
641 let d = load_descriptor(toml_src, source)
642 .unwrap_or_else(|e| panic!("embedded descriptor {source} is invalid: {e}"));
643 assert!(!d.label.is_empty());
644 }
645 }
646
647 #[test]
648 fn rejects_invalid_toml_syntax() {
649 let err = err_of("label = ");
650 assert!(err.contains("test.toml"), "{err}");
651 assert!(err.contains("invalid TOML"), "{err}");
652 }
653
654 #[test]
655 fn rejects_unknown_top_level_field() {
656 let err = err_of(&format!("{MINIMAL}\nmystery = true\n"));
657 assert!(err.contains("harness-descriptor schema"), "{err}");
658 }
659
660 #[test]
661 fn rejects_non_kebab_case_label() {
662 let err = err_of(&MINIMAL.replace("\"demo\"", "\"Not_Kebab\""));
663 assert!(err.contains("harness-descriptor schema"), "{err}");
664 }
665
666 #[test]
670 fn rejects_the_retired_guard_engine_field() {
671 let err = err_of(&GUARDED.replace(
672 "hooks_file = \".demo/hooks.json\"",
673 "engine = \"claude-hooks\"",
674 ));
675 assert!(err.contains("harness-descriptor schema"), "{err}");
676 }
677
678 #[test]
679 fn merge_deep_merges_tables_and_replaces_scalars_and_arrays() {
680 let mut base: serde_json::Value = toml::from_str(
681 r#"
682label = "demo"
683config_dirs = [".demo"]
684
685[model]
686flag = "--model"
687
688[dispatch]
689capture_prefix = "demo"
690"#,
691 )
692 .unwrap();
693 let overlay: serde_json::Value = toml::from_str(
694 r#"
695label = "demo"
696config_dirs = [".other"]
697
698[model]
699flag = "--model-x"
700"#,
701 )
702 .unwrap();
703 merge_descriptor_value(&mut base, overlay);
704 assert_eq!(base["model"]["flag"], "--model-x");
707 assert_eq!(base["dispatch"]["capture_prefix"], "demo");
708 assert_eq!(base["config_dirs"], serde_json::json!([".other"]));
709 }
710
711 #[test]
712 fn finalize_names_every_contributing_file_on_invariant_breaks() {
713 let value: serde_json::Value =
716 toml::from_str("label = \"demo\"\nskills_dir = \".demo/skills\"\n").unwrap();
717 let err = finalize_descriptor(&value, "base.toml (built-in) + overlay.toml (project)")
718 .expect_err("missing config_dirs parent should be rejected")
719 .to_string();
720 assert!(
721 err.contains("base.toml (built-in) + overlay.toml (project)"),
722 "{err}"
723 );
724 }
725
726 #[test]
727 fn subst_replaces_tokens_and_passes_unknown_through() {
728 let out = subst(
729 "run {exec} with ${JOBS:-4} and -I{} on {exec}",
730 &[("exec", "demo-cmd")],
731 );
732 assert_eq!(out, "run demo-cmd with ${JOBS:-4} and -I{} on demo-cmd");
733 }
734
735 #[test]
736 fn subst_does_not_rescan_substituted_values() {
737 let out = subst("{a} {b}", &[("a", "holds-{b}"), ("b", "second")]);
738 assert_eq!(out, "holds-{b} second");
739 }
740}