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 #[serde(skip_serializing_if = "Option::is_none")]
195 pub skill_tool: Option<String>,
196 #[serde(skip_serializing_if = "Option::is_none")]
199 pub skill_arg: Option<String>,
200}
201
202impl TranscriptSection {
203 pub(crate) fn parse(&self, path: &std::path::Path) -> std::io::Result<Vec<ToolInvocation>> {
206 match (&self.parser, &self.extract) {
207 (Some(parser), _) => parser.parse(path),
208 (None, Some(extract)) => super::extract::parse(extract, path),
209 (None, None) => Err(unwired_error()),
212 }
213 }
214
215 pub(crate) fn parse_full(&self, path: &std::path::Path) -> std::io::Result<TranscriptSummary> {
217 match (&self.parser, &self.extract) {
218 (Some(parser), _) => parser.parse_full(path),
219 (None, Some(extract)) => super::extract::parse_full(extract, path),
220 (None, None) => Err(unwired_error()),
221 }
222 }
223}
224
225fn unwired_error() -> std::io::Error {
226 std::io::Error::new(
227 std::io::ErrorKind::Unsupported,
228 "[transcript] declares neither a parser nor an extract block",
229 )
230}
231
232#[derive(Debug, Clone, Deserialize, Serialize)]
233pub struct ModelSection {
234 pub flag: String,
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
242#[serde(rename_all = "kebab-case")]
243pub enum GuardEngine {
244 #[default]
245 JsonHooks,
246 OpencodePlugin,
247}
248
249impl GuardEngine {
250 fn is_default(&self) -> bool {
253 *self == GuardEngine::default()
254 }
255}
256
257#[derive(Debug, Clone, Deserialize, Serialize)]
267pub struct GuardSection {
268 #[serde(default, skip_serializing_if = "GuardEngine::is_default")]
270 pub engine: GuardEngine,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub hooks_file: Option<String>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub matcher: Option<String>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub command_template: Option<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub hook_entry: Option<String>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub plugin_file: Option<String>,
294 pub verdict_template: String,
297 pub armed_message: String,
299}
300
301#[derive(Debug, Clone, Deserialize, Serialize)]
302pub struct ShadowSection {
303 pub preflight: ShadowPreflight,
304}
305
306#[derive(Debug, Clone, Default, Deserialize, Serialize)]
307pub struct DispatchSection {
308 #[serde(skip_serializing_if = "Option::is_none")]
309 pub capture_prefix: Option<String>,
310 #[serde(skip_serializing_if = "Option::is_none")]
311 pub guard_args: Option<String>,
312 #[serde(skip_serializing_if = "Option::is_none")]
313 pub model_note: Option<String>,
314 #[serde(skip_serializing_if = "Option::is_none")]
315 pub next_steps_template: Option<String>,
316 #[serde(skip_serializing_if = "Option::is_none")]
317 pub exec_template: Option<String>,
318 #[serde(skip_serializing_if = "Option::is_none")]
319 pub parallel_command_template: Option<String>,
320 #[serde(skip_serializing_if = "Option::is_none")]
321 pub judge_command_template: Option<String>,
322 #[serde(skip_serializing_if = "Option::is_none")]
323 pub manifest_template: Option<String>,
324}
325
326impl DispatchSection {
327 pub fn is_empty(&self) -> bool {
329 self.capture_prefix.is_none()
330 && self.guard_args.is_none()
331 && self.model_note.is_none()
332 && self.next_steps_template.is_none()
333 && self.exec_template.is_none()
334 && self.parallel_command_template.is_none()
335 && self.judge_command_template.is_none()
336 && self.manifest_template.is_none()
337 }
338}
339
340fn default_true() -> bool {
341 true
342}
343
344fn is_false(value: &bool) -> bool {
346 !*value
347}
348
349fn is_true(value: &bool) -> bool {
350 *value
351}
352
353#[derive(Debug, thiserror::Error)]
356pub enum DescriptorError {
357 #[error("{path}: invalid TOML: {message}")]
358 Toml { path: String, message: String },
359 #[error(transparent)]
360 Validation(#[from] ValidationError),
361 #[error("{path}: {message}")]
362 Invariant { path: String, message: String },
363}
364
365pub fn load_descriptor(toml_src: &str, source: &str) -> Result<HarnessDescriptor, DescriptorError> {
368 finalize_descriptor(&parse_descriptor_value(toml_src, source)?, source)
369}
370
371pub fn parse_descriptor_value(
377 toml_src: &str,
378 source: &str,
379) -> Result<serde_json::Value, DescriptorError> {
380 let value: serde_json::Value = toml::from_str(toml_src).map_err(|e| DescriptorError::Toml {
381 path: source.to_string(),
382 message: e.to_string(),
383 })?;
384 let _: serde_json::Value =
385 validate_against_schema(SchemaName::HarnessDescriptor, &value, source)?;
386 Ok(value)
387}
388
389pub fn merge_descriptor_value(base: &mut serde_json::Value, overlay: serde_json::Value) {
394 match (base, overlay) {
395 (serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => {
396 for (key, value) in overlay {
397 match base.get_mut(&key) {
398 Some(slot) if slot.is_object() && value.is_object() => {
399 merge_descriptor_value(slot, value);
400 }
401 _ => {
402 base.insert(key, value);
403 }
404 }
405 }
406 }
407 (base, overlay) => *base = overlay,
408 }
409}
410
411pub fn finalize_descriptor(
415 value: &serde_json::Value,
416 provenance: &str,
417) -> Result<HarnessDescriptor, DescriptorError> {
418 let descriptor: HarnessDescriptor =
419 validate_against_schema(SchemaName::HarnessDescriptor, value, provenance)?;
420 validation::validate_descriptor(&descriptor, provenance)?;
421 Ok(descriptor)
422}
423
424pub(crate) fn subst(template: &str, vars: &[(&str, &str)]) -> String {
430 let mut out = String::with_capacity(template.len());
431 let mut rest = template;
432 while let Some(start) = rest.find('{') {
433 out.push_str(&rest[..start]);
434 let after = &rest[start + 1..];
435 let Some(end) = after.find('}') else {
436 out.push('{');
437 rest = after;
438 continue;
439 };
440 match vars.iter().find(|(k, _)| *k == &after[..end]) {
441 Some((_, value)) => {
442 out.push_str(value);
443 rest = &after[end + 1..];
444 }
445 None => {
446 out.push('{');
447 rest = after;
448 }
449 }
450 }
451 out.push_str(rest);
452 out
453}
454
455pub(crate) const DEFAULT_SLUG_TEMPLATE: &str = "{prefix}{iteration}-{condition}__{skill_name}";
458
459pub(crate) fn render_staged_slug(
462 staging: &StagingSection,
463 prefix: &str,
464 iteration: u32,
465 condition: &str,
466 skill_name: &str,
467) -> String {
468 if let Some(capability) = staging.slug_capability {
469 return capability.staged_slug(prefix, iteration, condition, skill_name);
470 }
471 let iteration = iteration.to_string();
472 subst(
473 staging
474 .slug_template
475 .as_deref()
476 .unwrap_or(DEFAULT_SLUG_TEMPLATE),
477 &[
478 ("prefix", prefix),
479 ("iteration", &iteration),
480 ("condition", condition),
481 ("skill_name", skill_name),
482 ],
483 )
484}
485
486pub(crate) fn stage_name_error(
489 staging: &StagingSection,
490 regex: Option<&Regex>,
491 name: &str,
492) -> Option<String> {
493 let len_ok = staging
494 .stage_name_max_len
495 .is_none_or(|max| name.len() <= max);
496 let pattern_ok = regex.is_none_or(|r| r.is_match(name));
497 if len_ok && pattern_ok {
498 return None;
499 }
500 Some(match &staging.stage_name_invalid_message {
501 Some(message) => subst(message, &[("name", name)]),
502 None => format!("stage name \"{name}\" violates the descriptor's naming rules"),
503 })
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 fn load(toml_src: &str) -> Result<HarnessDescriptor, DescriptorError> {
511 load_descriptor(toml_src, "test.toml")
512 }
513
514 fn err_of(toml_src: &str) -> String {
515 load(toml_src)
516 .expect_err("descriptor should be rejected")
517 .to_string()
518 }
519
520 const MINIMAL: &str = r#"
521label = "demo"
522skills_dir = ".demo/skills"
523config_dirs = [".demo"]
524"#;
525
526 const GUARDED: &str = r#"
527label = "demo"
528skills_dir = ".demo/skills"
529config_dirs = [".demo"]
530
531[run]
532supports_guard = true
533
534[tools]
535write = ["Edit", "MultiEdit", "NotebookEdit", "Write"]
536shell = ["Bash"]
537
538[guard]
539hooks_file = ".demo/hooks.json"
540matcher = "Write|Edit|MultiEdit|NotebookEdit|Bash"
541command_template = '"{exe}" guard-hook --harness demo "{marker}"'
542hook_entry = '{"matcher":"{matcher}","hooks":[{"type":"command","command":"{command}"}]}'
543verdict_template = '{"decision":"block","reason":"{reason}"}'
544armed_message = "guard armed"
545"#;
546
547 const EXTRACTED: &str = r#"
549label = "demo"
550skills_dir = ".demo/skills"
551config_dirs = [".demo"]
552
553[tools]
554write = ["file_change"]
555shell = ["command_execution"]
556
557[transcript]
558events_filename = "demo-events.jsonl"
559
560[transcript.extract.tools]
561where = { type = "item.completed" }
562item = "item"
563name_field = "type"
564skip_names = ["agent_message"]
565args_omit = ["id", "type", "output"]
566result_coalesce = ["output"]
567
568[transcript.extract.final_text]
569where = { type = "item.completed", "item.type" = "agent_message" }
570field = "item.text"
571
572[transcript.extract.tokens]
573where = { type = "turn.completed" }
574sum = ["usage.input_tokens", "usage.output_tokens"]
575
576[transcript.extract.duration]
577timestamp_spread = "timestamp"
578"#;
579
580 #[test]
581 fn extract_descriptor_loads_and_reserializes_to_loadable_toml() {
582 let d = load(EXTRACTED).unwrap();
583 let transcript = d.transcript.as_ref().expect("transcript section loads");
584 assert!(transcript.parser.is_none());
585 assert!(transcript.extract.is_some());
586
587 let shown = toml::to_string(&d).expect("descriptor re-serializes");
590 let reloaded = load(&shown).unwrap();
591 let extract = reloaded.transcript.unwrap().extract.unwrap();
592 assert_eq!(
593 extract.tokens.unwrap().sum,
594 vec!["usage.input_tokens", "usage.output_tokens"]
595 );
596 assert_eq!(
597 extract
598 .tools
599 .unwrap()
600 .r#where
601 .get("type")
602 .map(String::as_str),
603 Some("item.completed")
604 );
605 }
606
607 #[test]
608 fn transcript_section_dispatches_parse_to_the_extract_engine() {
609 let transcript = load(EXTRACTED).unwrap().transcript.unwrap();
610 let dir = tempfile::TempDir::new().unwrap();
611 let path = dir.path().join("demo-events.jsonl");
612 std::fs::write(
613 &path,
614 concat!(
615 r#"{"type":"item.completed","item":{"id":"i1","type":"command_execution","command":"ls","output":"ok"}}"#,
616 "\n",
617 r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"Done."}}"#,
618 "\n",
619 ),
620 )
621 .unwrap();
622
623 let invocations = transcript.parse(&path).unwrap();
624 assert_eq!(invocations.len(), 1);
625 assert_eq!(invocations[0].name, "command_execution");
626
627 let full = transcript.parse_full(&path).unwrap();
628 assert_eq!(full.final_text, Some("Done.".into()));
629 assert_eq!(full.tool_invocations.len(), 1);
630 }
631
632 #[test]
633 fn minimal_descriptor_loads() {
634 let d = load(MINIMAL).unwrap();
635 assert_eq!(d.label, "demo");
636 assert_eq!(d.skills_dir.as_deref(), Some(".demo/skills"));
637 assert_eq!(d.config_dirs, vec![".demo".to_string()]);
638 assert!(!d.run.supports_guard);
640 assert!(d.run.supports_bootstrap_with_no_stage);
641 assert!(d.run.supports_stage_name_with_no_stage);
642 assert!(!d.staging.rewrites_frontmatter_name);
643 assert!(d.guard.is_none());
644 assert!(d.transcript.is_none());
645 }
646
647 #[test]
648 fn guarded_descriptor_loads() {
649 let d = load(GUARDED).unwrap();
650 assert!(d.run.supports_guard);
651 let guard = d.guard.expect("guard section loads");
652 assert_eq!(guard.engine, GuardEngine::JsonHooks);
653 assert_eq!(guard.hooks_file.as_deref(), Some(".demo/hooks.json"));
654 assert_eq!(
655 guard.matcher.as_deref(),
656 Some("Write|Edit|MultiEdit|NotebookEdit|Bash")
657 );
658 assert_eq!(
659 guard.command_template.as_deref(),
660 Some(r#""{exe}" guard-hook --harness demo "{marker}""#)
661 );
662 assert_eq!(guard.plugin_file, None);
663 assert_eq!(guard.armed_message, "guard armed");
664 }
665
666 #[test]
667 fn label_only_descriptor_loads_as_baseline() {
668 let d = load("label = \"demo\"\n").unwrap();
669 assert_eq!(d.label, "demo");
670 assert!(d.skills_dir.is_none(), "no skills dir declared");
671 assert!(d.config_dirs.is_empty());
672 assert!(!d.run.supports_guard);
673 }
674
675 #[test]
676 fn rejects_staging_without_skills_dir() {
677 let err = err_of("label = \"demo\"\n\n[staging]\nsurface_phrase = \"skill\"\n");
678 assert!(err.contains("staging"), "{err}");
679 assert!(err.contains("skills_dir"), "{err}");
680 }
681
682 #[test]
683 fn rejects_guard_without_skills_dir() {
684 let toml = &GUARDED.replace("skills_dir = \".demo/skills\"\n", "");
685 let err = err_of(toml);
686 assert!(err.contains("guard"), "{err}");
687 assert!(err.contains("skills_dir"), "{err}");
688 }
689
690 #[test]
691 fn embedded_descriptors_load_and_validate() {
692 for (source, toml_src) in EMBEDDED_DESCRIPTORS {
693 let d = load_descriptor(toml_src, source)
694 .unwrap_or_else(|e| panic!("embedded descriptor {source} is invalid: {e}"));
695 assert!(!d.label.is_empty());
696 }
697 }
698
699 #[test]
700 fn rejects_invalid_toml_syntax() {
701 let err = err_of("label = ");
702 assert!(err.contains("test.toml"), "{err}");
703 assert!(err.contains("invalid TOML"), "{err}");
704 }
705
706 #[test]
707 fn rejects_unknown_top_level_field() {
708 let err = err_of(&format!("{MINIMAL}\nmystery = true\n"));
709 assert!(err.contains("harness-descriptor schema"), "{err}");
710 }
711
712 #[test]
713 fn rejects_non_kebab_case_label() {
714 let err = err_of(&MINIMAL.replace("\"demo\"", "\"Not_Kebab\""));
715 assert!(err.contains("harness-descriptor schema"), "{err}");
716 }
717
718 #[test]
724 fn rejects_the_retired_guard_engine_field() {
725 let err = err_of(&GUARDED.replace(
726 "hooks_file = \".demo/hooks.json\"",
727 "engine = \"claude-hooks\"",
728 ));
729 assert!(err.contains("harness-descriptor schema"), "{err}");
730 }
731
732 #[test]
733 fn merge_deep_merges_tables_and_replaces_scalars_and_arrays() {
734 let mut base: serde_json::Value = toml::from_str(
735 r#"
736label = "demo"
737config_dirs = [".demo"]
738
739[model]
740flag = "--model"
741
742[dispatch]
743capture_prefix = "demo"
744"#,
745 )
746 .unwrap();
747 let overlay: serde_json::Value = toml::from_str(
748 r#"
749label = "demo"
750config_dirs = [".other"]
751
752[model]
753flag = "--model-x"
754"#,
755 )
756 .unwrap();
757 merge_descriptor_value(&mut base, overlay);
758 assert_eq!(base["model"]["flag"], "--model-x");
761 assert_eq!(base["dispatch"]["capture_prefix"], "demo");
762 assert_eq!(base["config_dirs"], serde_json::json!([".other"]));
763 }
764
765 #[test]
766 fn finalize_names_every_contributing_file_on_invariant_breaks() {
767 let value: serde_json::Value =
770 toml::from_str("label = \"demo\"\nskills_dir = \".demo/skills\"\n").unwrap();
771 let err = finalize_descriptor(&value, "base.toml (built-in) + overlay.toml (project)")
772 .expect_err("missing config_dirs parent should be rejected")
773 .to_string();
774 assert!(
775 err.contains("base.toml (built-in) + overlay.toml (project)"),
776 "{err}"
777 );
778 }
779
780 #[test]
781 fn subst_replaces_tokens_and_passes_unknown_through() {
782 let out = subst(
783 "run {exec} with ${JOBS:-4} and -I{} on {exec}",
784 &[("exec", "demo-cmd")],
785 );
786 assert_eq!(out, "run demo-cmd with ${JOBS:-4} and -I{} on demo-cmd");
787 }
788
789 #[test]
790 fn subst_does_not_rescan_substituted_values() {
791 let out = subst("{a} {b}", &[("a", "holds-{b}"), ("b", "second")]);
792 assert_eq!(out, "holds-{b} second");
793 }
794}