1use serde_yaml::{Mapping, Value};
4use toml_edit::{DocumentMut, Item};
5
6use crate::claude;
7use crate::diagnostic::{PathSegment, SchemaDiagnostic, Severity};
8use crate::frontmatter::{Frontmatter, FrontmatterError};
9use crate::harness_overrides::{Moved, move_keys_under};
10use crate::item::DefinitionKind;
11use crate::{
12 agent::{REMOVED_AGENT_FIELDS, REMOVED_AGENT_VALUES},
13 skill,
14};
15
16#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub struct UpgradeResult {
18 pub text: String,
19 pub diagnostics: Vec<SchemaDiagnostic>,
21 pub fully_rewritten: bool,
24 pub edited_paths: Vec<Vec<PathSegment>>,
26}
27
28impl UpgradeResult {
29 pub(crate) fn changed_from(&self, input: &str) -> bool {
30 self.text != input
31 }
32}
33
34#[derive(Debug, thiserror::Error)]
35pub enum UpgradeError {
36 #[error(transparent)]
37 Frontmatter(#[from] FrontmatterError),
38 #[error("failed to parse mars.toml: {0}")]
39 Manifest(#[from] toml_edit::TomlError),
40 #[error("mars.toml at a package root must contain [package]")]
41 MissingPackage,
42}
43
44pub fn upgrade_definition(kind: DefinitionKind, text: &str) -> Result<UpgradeResult, UpgradeError> {
45 rewrite(text, |frontmatter, diagnostics| match kind {
46 DefinitionKind::Agent => upgrade_agent(frontmatter, diagnostics),
47 DefinitionKind::Skill => upgrade_skill(frontmatter, diagnostics),
48 })
49}
50
51pub fn relocate_removed_fields(
58 kind: DefinitionKind,
59 text: &str,
60) -> Result<UpgradeResult, UpgradeError> {
61 rewrite(text, |frontmatter, notes| {
62 rename_consumer_namespaces(frontmatter, notes);
63 if kind == DefinitionKind::Agent {
64 move_agent_fields(frontmatter, notes);
65 }
66 })
67}
68
69fn rewrite(
72 text: &str,
73 apply: impl FnOnce(&mut Frontmatter, &mut Vec<SchemaDiagnostic>),
74) -> Result<UpgradeResult, UpgradeError> {
75 let original = Frontmatter::parse(text)?;
76 let mut frontmatter = original.clone();
77 let mut diagnostics = Vec::new();
78 apply(&mut frontmatter, &mut diagnostics);
79 if frontmatter.mapping() == original.mapping() {
80 return Ok(UpgradeResult {
81 text: text.to_string(),
82 diagnostics,
83 fully_rewritten: false,
84 edited_paths: Vec::new(),
85 });
86 }
87
88 let edited_paths = changed_mapping_paths(original.mapping(), frontmatter.mapping());
89
90 let fallback = {
94 let mut rewritten = with_line_ending_of(text, frontmatter.render_frontmatter());
95 rewritten.push_str(original.body());
96 rewritten
97 };
98 let minimally_edited = edit_top_level_blocks(text, original.mapping(), frontmatter.mapping())
99 .and_then(|edited| {
100 Frontmatter::parse(&edited.text)
101 .ok()
102 .filter(|parsed| parsed.mapping() == frontmatter.mapping())
103 .map(|_| edited)
104 });
105 Ok(UpgradeResult {
106 fully_rewritten: minimally_edited
107 .as_ref()
108 .is_none_or(|edited| edited.re_rendered),
109 text: minimally_edited.map_or(fallback, |edited| edited.text),
110 diagnostics,
111 edited_paths,
112 })
113}
114
115pub fn upgrade_manifest(text: &str) -> Result<UpgradeResult, UpgradeError> {
121 let mut document = text.parse::<DocumentMut>()?;
122 let package = document
123 .get_mut("package")
124 .and_then(Item::as_table_mut)
125 .ok_or(UpgradeError::MissingPackage)?;
126 if package.get("schema").and_then(Item::as_integer) == Some(1) {
127 return Ok(UpgradeResult {
128 text: text.to_string(),
129 diagnostics: Vec::new(),
130 fully_rewritten: false,
131 edited_paths: Vec::new(),
132 });
133 }
134 let mut diagnostics = Vec::new();
135 let mut edited_paths = vec![package_path("schema")];
136 package.insert("schema", toml_edit::value(1));
137 if package.remove("visibility").is_some() {
138 diagnostics.push(removed(&["package", "visibility"], "visibility"));
139 edited_paths.push(package_path("visibility"));
140 }
141 Ok(UpgradeResult {
142 text: with_line_ending_of(text, document.to_string()),
143 diagnostics,
144 fully_rewritten: false,
145 edited_paths,
146 })
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct FloorOutcome {
152 pub text: String,
154 pub warning: Option<String>,
157}
158
159pub fn ensure_requires_mars_floor(text: &str) -> Result<FloorOutcome, UpgradeError> {
168 let mut document = text.parse::<DocumentMut>()?;
169 let package = document
170 .get_mut("package")
171 .ok_or(UpgradeError::MissingPackage)?;
172 let requirement = package
173 .as_table_like()
174 .and_then(|table| table.get("requires-mars"))
175 .and_then(Item::as_str)
176 .map(str::to_owned);
177 if requirement.is_none()
178 && let Some(table) = package.as_table_mut()
179 {
180 table.insert(
181 "requires-mars",
182 toml_edit::value(format!(">={}", crate::package::SCHEMA_1_MARS_FLOOR)),
183 );
184 return Ok(FloorOutcome {
185 text: with_line_ending_of(text, document.to_string()),
186 warning: None,
187 });
188 }
189 Ok(FloorOutcome {
190 text: text.to_string(),
191 warning: crate::package::schema_1_requires_mars_warning(requirement.as_deref()),
192 })
193}
194
195fn package_path(key: &str) -> Vec<PathSegment> {
196 vec![
197 PathSegment::Key("package".to_string()),
198 PathSegment::Key(key.to_string()),
199 ]
200}
201
202fn is_crlf(text: &str) -> bool {
204 text.find('\n')
205 .is_some_and(|end| text.as_bytes()[..end].ends_with(b"\r"))
206}
207
208fn with_line_ending_of(original: &str, written: String) -> String {
210 if !is_crlf(original) {
211 return written;
212 }
213 let mut out = String::with_capacity(written.len() + written.len() / 16);
214 let mut previous = '\0';
215 for character in written.chars() {
216 if character == '\n' && previous != '\r' {
217 out.push('\r');
218 }
219 out.push(character);
220 previous = character;
221 }
222 out
223}
224
225fn changed_mapping_paths(before: &Mapping, after: &Mapping) -> Vec<Vec<PathSegment>> {
226 let mut keys = before
227 .keys()
228 .chain(after.keys())
229 .filter_map(Value::as_str)
230 .collect::<Vec<_>>();
231 keys.sort_unstable();
232 keys.dedup();
233 keys.into_iter()
234 .filter(|key| {
235 let key = Value::String((*key).to_string());
236 before.get(&key) != after.get(&key)
237 })
238 .map(|key| vec![PathSegment::Key(key.to_string())])
239 .collect()
240}
241
242#[derive(Debug)]
243struct SourceBlock<'a> {
244 key: &'a str,
245 text: &'a str,
246 key_end: usize,
247 start: usize,
248}
249
250struct Edited {
253 text: String,
254 re_rendered: bool,
255}
256
257fn edit_top_level_blocks(text: &str, before: &Mapping, after: &Mapping) -> Option<Edited> {
260 let (prefix, yaml, suffix) = frontmatter_source(text)?;
261 let blocks = source_blocks(yaml)?;
262 let before_keys = before
263 .keys()
264 .map(Value::as_str)
265 .collect::<Option<Vec<_>>>()?;
266 if blocks.iter().map(|block| block.key).collect::<Vec<_>>() != before_keys {
267 return None;
268 }
269
270 let render = |key: &str, value: &Value| with_line_ending_of(text, render_block(key, value));
271 let mut output = String::with_capacity(text.len());
272 let mut re_rendered = false;
273 output.push_str(prefix);
274 let first_key_offset = blocks.first().map_or(yaml.len(), |block| block.start);
275 output.push_str(&yaml[..first_key_offset]);
276
277 let mut emitted = Vec::new();
278 for block in blocks {
279 let before_value = before.get(Value::String(block.key.to_string()))?;
280 if let Some(rename) = renamed_key(block.key, before, after) {
281 output.push_str(rename);
282 output.push_str(&block.text[block.key_end..]);
283 emitted.push(rename);
284 } else if let Some(after_value) = after.get(Value::String(block.key.to_string())) {
285 if before_value == after_value {
286 output.push_str(block.text);
287 } else if let Some(edited) = grown_in_place(&block, before_value, after_value)
288 .or_else(|| renamed_in_place(&block, before_value, after_value))
289 {
290 output.push_str(&edited);
291 } else {
292 re_rendered |= block.key == "harness-overrides";
296 output.push_str(&render(block.key, after_value));
297 }
298 emitted.push(block.key);
299 }
300 }
301
302 for (key, value) in after {
303 let key = key.as_str()?;
304 if emitted.contains(&key) {
305 continue;
306 }
307 if !output.ends_with('\n') {
308 output.push_str(&with_line_ending_of(text, "\n".to_owned()));
309 }
310 output.push_str(&render(key, value));
311 }
312 output.push_str(suffix);
313 Some(Edited {
314 text: output,
315 re_rendered,
316 })
317}
318
319fn grown_in_place(block: &SourceBlock<'_>, before: &Value, after: &Value) -> Option<String> {
323 let (Value::Mapping(before), Value::Mapping(after)) = (before, after) else {
324 return None;
325 };
326 if before
327 .iter()
328 .any(|(key, value)| after.get(key) != Some(value))
329 {
330 return None;
331 }
332 let added = after
333 .iter()
334 .filter(|(key, _)| before.get(key).is_none())
335 .collect::<Vec<_>>();
336 let indent = (!added.is_empty()).then(|| child_indent(block)).flatten()?;
337 let (body, tail) = split_trailing_run(block.text);
340 let mut grown = body.to_string();
341 if !grown.ends_with('\n') {
342 grown.push('\n');
343 }
344 for (key, value) in added {
345 let rendered = render_block(key.as_str()?, value);
346 let indented = rendered
348 .lines()
349 .map(|line| {
350 let depth = line.len() - line.trim_start().len();
351 format!("{}{}", indent.repeat(depth / 2 + 1), line.trim_start())
352 })
353 .collect::<Vec<_>>()
354 .join("\n");
355 grown.push_str(&indented);
356 grown.push('\n');
357 }
358 grown.push_str(tail);
359 Some(with_line_ending_of(block.text, grown))
360}
361
362fn renamed_in_place(block: &SourceBlock<'_>, before: &Value, after: &Value) -> Option<String> {
366 let (Value::Mapping(before), Value::Mapping(after)) = (before, after) else {
367 return None;
368 };
369 if before.len() != after.len() {
370 return None;
371 }
372 let mut renames = Vec::new();
373 for ((old, old_value), (new, new_value)) in before.iter().zip(after) {
374 if old_value != new_value {
375 return None;
376 }
377 if old != new {
378 let pair = (old.as_str()?, new.as_str()?);
379 if !crate::harness::RENAMED_CONSUMER_NAMESPACES.contains(&pair) {
380 return None;
381 }
382 renames.push(pair);
383 }
384 }
385 let indent = child_indent(block)?;
386 let mut done = 0;
387 let mut edited = String::with_capacity(block.text.len());
388 for line in block.text.split_inclusive('\n') {
389 let renamed = line.strip_prefix(indent).and_then(|rest| {
390 renames.iter().find_map(|&(old, new)| {
391 let after_key = rest.strip_prefix(old)?;
392 after_key
393 .trim_start_matches(' ')
394 .starts_with(':')
395 .then(|| format!("{indent}{new}{after_key}"))
396 })
397 });
398 match renamed {
399 Some(line) => {
400 done += 1;
401 edited.push_str(&line);
402 }
403 None => edited.push_str(line),
404 }
405 }
406 (done == renames.len()).then_some(edited)
407}
408
409fn split_trailing_run(text: &str) -> (&str, &str) {
412 let mut split = text.len();
413 for line in text.split_inclusive('\n').rev() {
414 let content = line.trim_end_matches(['\r', '\n']);
415 if !content.trim().is_empty() && !content.starts_with('#') {
416 break;
417 }
418 split -= line.len();
419 }
420 text.split_at(split)
421}
422
423fn child_indent<'a>(block: &'a SourceBlock<'a>) -> Option<&'a str> {
426 let first = block.text.split_inclusive('\n').next()?;
427 let rest = first[block.key_end + 1..].trim_end_matches(['\r', '\n']);
428 if !(rest.trim().is_empty() || rest.trim_start().starts_with('#')) {
429 return None;
430 }
431 for line in block.text.split_inclusive('\n').skip(1) {
432 let content = line.trim_end_matches(['\r', '\n']);
433 if content.trim().is_empty() || content.trim_start().starts_with('#') {
434 continue;
435 }
436 let indent = content.len() - content.trim_start().len();
437 return (indent > 0).then(|| &content[..indent]);
438 }
439 None
440}
441
442fn renamed_key<'a>(old: &str, before: &Mapping, after: &'a Mapping) -> Option<&'a str> {
443 let new = match old {
444 "model_invocable" => "model-invocable",
445 "user_invocable" => "user-invocable",
446 "disallowed_tools" => "disallowed-tools",
447 _ => return None,
448 };
449 if before.contains_key(Value::String(new.to_string())) {
450 return None;
451 }
452 let old_value = before.get(Value::String(old.to_string()))?;
453 (after.get(Value::String(new.to_string())) == Some(old_value)).then_some(new)
454}
455
456fn render_block(key: &str, value: &Value) -> String {
457 let mut mapping = Mapping::new();
458 mapping.insert(Value::String(key.to_string()), value.clone());
459 let yaml = serde_yaml::to_string(&mapping).expect("serializing an upgraded YAML block");
460 yaml.strip_prefix("---\n").unwrap_or(&yaml).to_string()
461}
462
463fn frontmatter_source(text: &str) -> Option<(&str, &str, &str)> {
464 let first_end = text.find('\n').map_or(text.len(), |offset| offset + 1);
465 if text[..first_end]
466 .strip_prefix('\u{feff}')
467 .unwrap_or(&text[..first_end])
468 .trim_end()
469 != "---"
470 {
471 return None;
472 }
473 let mut offset = first_end;
474 for line in text[first_end..].split_inclusive('\n') {
475 if line.trim_end() == "---" {
476 return Some((
477 &text[..first_end],
478 &text[first_end..offset],
479 &text[offset..],
480 ));
481 }
482 offset += line.len();
483 }
484 None
485}
486
487fn source_blocks(yaml: &str) -> Option<Vec<SourceBlock<'_>>> {
488 let mut starts = Vec::new();
489 let mut offset = 0;
490 for line in yaml.split_inclusive('\n') {
491 let content = line.trim_end_matches(['\r', '\n']);
492 if !content.is_empty()
493 && !content.starts_with(char::is_whitespace)
494 && !content.starts_with('#')
495 {
496 let colon = content.find(':')?;
497 let key = &content[..colon];
498 if key.is_empty()
499 || !key
500 .bytes()
501 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
502 {
503 return None;
504 }
505 starts.push((offset, key, colon));
506 }
507 offset += line.len();
508 }
509 let mut blocks = Vec::with_capacity(starts.len());
510 for (index, (start, key, key_end)) in starts.iter().copied().enumerate() {
511 let end = starts
512 .get(index + 1)
513 .map_or(yaml.len(), |(start, _, _)| *start);
514 blocks.push(SourceBlock {
515 key,
516 text: &yaml[start..end],
517 key_end,
518 start,
519 });
520 }
521 Some(blocks)
522}
523
524pub(crate) fn lift_mars_only_keys(
531 fm: &mut Frontmatter,
532 kind: DefinitionKind,
533 notes: &mut Vec<SchemaDiagnostic>,
534) -> bool {
535 let before = fm.mapping().clone();
536 rename_field(fm, "model_invocable", "model-invocable");
537 rename_field(fm, "user_invocable", "user-invocable");
538 if kind == DefinitionKind::Agent {
539 merge_host_set_spellings(fm, "network_allow", "network-allow");
540 merge_host_set_spellings(fm, "network_deny", "network-deny");
541 for field in REMOVED_AGENT_FIELDS.iter().filter(|field| field.discarded) {
542 if fm.remove(field.key).is_some() {
543 notes.push(removed(&[field.key], field.key));
544 }
545 }
546 for removed in REMOVED_AGENT_VALUES {
547 replace_scalar(fm, removed.key, removed.value, removed.replacement);
548 }
549 move_agent_fields(fm, notes);
550 strip_model_policy_overrides(fm, notes);
551 }
552 fm.mapping() != &before
553}
554
555fn upgrade_agent(fm: &mut Frontmatter, diagnostics: &mut Vec<SchemaDiagnostic>) {
556 rename_consumer_namespaces(fm, diagnostics);
559 upgrade_tools(fm);
560 lift_mars_only_keys(fm, DefinitionKind::Agent, diagnostics);
561 claude::translate::move_to_claude_overrides(fm, DefinitionKind::Agent, diagnostics);
563 set_aside_rejected_keys(fm, DefinitionKind::Agent, diagnostics);
564}
565
566fn upgrade_skill(fm: &mut Frontmatter, diagnostics: &mut Vec<SchemaDiagnostic>) {
567 rename_consumer_namespaces(fm, diagnostics);
568 lift_mars_only_keys(fm, DefinitionKind::Skill, diagnostics);
569
570 if let Some(skill_type) = fm.get("type").cloned() {
571 let metadata = fm
572 .get("metadata")
573 .cloned()
574 .unwrap_or_else(|| Value::Mapping(Mapping::new()));
575 if let Value::Mapping(mut metadata) = metadata {
576 let key = Value::String("type".to_string());
577 if !metadata.contains_key(&key) {
578 metadata.insert(key, skill_type);
579 fm.insert("metadata", Value::Mapping(metadata));
580 fm.remove("type");
581 }
582 }
583 }
584
585 if let Some(when) = fm
586 .get("when_to_use")
587 .and_then(Value::as_str)
588 .map(str::to_owned)
589 {
590 let description = match fm.get("description") {
591 Some(Value::String(description)) => Some(format!("{description}\n\n{when}")),
592 None => Some(when),
593 Some(_) => None,
594 };
595 if let Some(description) = description {
596 fm.insert("description", Value::String(description));
597 fm.remove("when_to_use");
598 }
599 }
600
601 for key in skill::tool_keys() {
602 if fm.remove(key).is_some() {
603 diagnostics.push(SchemaDiagnostic::new(
604 "skill.tools-dropped",
605 Severity::Warning,
606 vec![PathSegment::Key(key.to_string())],
607 format!("removed skill key `{key}`; skill tool rules have no v1 meaning"),
608 ));
609 }
610 }
611
612 claude::translate::move_to_claude_overrides(fm, DefinitionKind::Skill, diagnostics);
613 set_aside_rejected_keys(fm, DefinitionKind::Skill, diagnostics);
614}
615
616fn set_aside_rejected_keys(
621 fm: &mut Frontmatter,
622 kind: DefinitionKind,
623 diagnostics: &mut Vec<SchemaDiagnostic>,
624) {
625 crate::set_aside::set_aside_rejected_keys(
626 fm,
627 kind,
628 crate::set_aside::Destination::Nowhere,
629 diagnostics,
630 );
631}
632
633fn upgrade_tools(fm: &mut Frontmatter) {
634 rename_field(fm, "disallowed_tools", "disallowed-tools");
635 if let Some(Value::Sequence(entries)) = fm.get("tools").cloned()
636 && entries.iter().all(|entry| entry.as_str().is_some())
637 {
638 let policy = entries
639 .into_iter()
640 .map(|entry| (entry, Value::String("allow".to_string())))
641 .collect();
642 fm.insert("tools", Value::Mapping(policy));
643 }
644
645 let Some(Value::Sequence(disallowed)) = fm.get("disallowed-tools").cloned() else {
646 return;
647 };
648 if !disallowed.iter().all(|entry| entry.as_str().is_some()) {
649 return;
650 }
651 let policy = fm
652 .get("tools")
653 .cloned()
654 .unwrap_or_else(|| Value::Mapping(Mapping::new()));
655 let Value::Mapping(mut policy) = policy else {
656 return;
657 };
658 for entry in disallowed {
659 policy.insert(entry, Value::String("deny".to_string()));
660 }
661 fm.insert("tools", Value::Mapping(policy));
662 fm.remove("disallowed-tools");
663}
664
665const MOVED_AGENT_FIELDS: &[(&str, &str, &str)] = &[
672 ("meridian-flow", "permission", "permission"),
673 (
674 "meridian-cli",
675 "resident-rearm-budget",
676 "resident-rearm-budget",
677 ),
678 ("meridian-cli", "meridian-capabilities", "capabilities"),
679];
680
681fn moves_under_namespace(fm: &Frontmatter, key: &str) -> bool {
684 key != "permission"
685 || fm
686 .get(key)
687 .and_then(Value::as_str)
688 .is_some_and(|permission| matches!(permission, "read" | "edit"))
689}
690
691fn move_agent_fields(fm: &mut Frontmatter, notes: &mut Vec<SchemaDiagnostic>) {
695 let mut displaced = Vec::new();
696 for &namespace in crate::harness::RESERVED_CONSUMER_NAMESPACES {
697 let keys = MOVED_AGENT_FIELDS
698 .iter()
699 .filter_map(|(owner, from, to)| {
700 (*owner == namespace && moves_under_namespace(fm, from)).then_some((*from, *to))
701 })
702 .collect::<Vec<_>>();
703 if let Moved::Done {
704 displaced: dropped, ..
705 } = move_keys_under(fm, namespace, &keys)
706 {
707 displaced.extend(dropped.into_iter().map(|(from, to)| (namespace, from, to)));
708 }
709 }
710 for (namespace, from, to) in displaced {
711 notes.push(dropped_twin(
712 &agent_label(fm),
713 vec![PathSegment::Key(from.to_string())],
714 from,
715 namespace,
716 to,
717 ));
718 }
719}
720
721fn rename_consumer_namespaces(fm: &mut Frontmatter, notes: &mut Vec<SchemaDiagnostic>) {
725 let agent = agent_label(fm);
726 let Some(Value::Mapping(overrides)) = fm.get_mut("harness-overrides") else {
727 return;
728 };
729 for &(old, new) in crate::harness::RENAMED_CONSUMER_NAMESPACES {
730 let old_key = Value::String(old.to_string());
731 let new_key = Value::String(new.to_string());
732 let Some(old_block) = overrides.get(&old_key).cloned() else {
733 continue;
734 };
735 let block_path = vec![
736 PathSegment::Key("harness-overrides".to_string()),
737 PathSegment::Key(old.to_string()),
738 ];
739 let Some(new_block) = overrides.get_mut(&new_key) else {
740 *overrides = std::mem::take(overrides)
742 .into_iter()
743 .map(|(key, value)| {
744 if key == old_key {
745 (new_key.clone(), value)
746 } else {
747 (key, value)
748 }
749 })
750 .collect();
751 continue;
752 };
753 match (new_block, old_block) {
754 (Value::Mapping(new_block), Value::Mapping(old_block)) => {
755 let twin = Twin {
756 agent: &agent,
757 old,
758 new,
759 };
760 merge_old_block(new_block, old_block, &twin, &[], notes);
761 }
762 _ => notes.push(SchemaDiagnostic::new(
763 "field.removed",
764 Severity::Warning,
765 block_path,
766 format!(
767 "dropped `harness-overrides.{old}`; `harness-overrides.{new}` already sets it"
768 ),
769 )),
770 }
771 overrides.remove(&old_key);
772 }
773}
774
775fn agent_label(fm: &Frontmatter) -> String {
776 fm.get("name").and_then(Value::as_str).map_or_else(
777 || "this agent".to_string(),
778 |name| format!("agent `{name}`"),
779 )
780}
781
782struct Twin<'a> {
784 agent: &'a str,
785 old: &'a str,
786 new: &'a str,
787}
788
789fn merge_old_block(
793 new: &mut Mapping,
794 old: Mapping,
795 twin: &Twin,
796 rel: &[String],
797 notes: &mut Vec<SchemaDiagnostic>,
798) {
799 for (key, value) in old {
800 let name = key
801 .as_str()
802 .map_or_else(|| format!("{key:?}"), str::to_owned);
803 let mut key_rel = rel.to_vec();
804 key_rel.push(name);
805 match new.get_mut(&key) {
806 None => {
807 new.insert(key, value);
808 }
809 Some(existing) if *existing == value => {}
810 Some(Value::Mapping(existing)) if value.is_mapping() => {
811 let Value::Mapping(value) = value else {
812 unreachable!()
813 };
814 merge_old_block(existing, value, twin, &key_rel, notes);
815 }
816 Some(_) => {
817 let dotted = key_rel.join(".");
818 let mut path = vec![
819 PathSegment::Key("harness-overrides".to_string()),
820 PathSegment::Key(twin.old.to_string()),
821 ];
822 path.extend(key_rel.into_iter().map(PathSegment::Key));
823 let dropped = format!("harness-overrides.{}.{dotted}", twin.old);
824 notes.push(dropped_twin(twin.agent, path, &dropped, twin.new, &dotted));
825 }
826 }
827 }
828}
829
830fn dropped_twin(
835 agent: &str,
836 path: Vec<PathSegment>,
837 dropped: &str,
838 namespace: &str,
839 key: &str,
840) -> SchemaDiagnostic {
841 if key == "permission" || key == "capabilities" || key.starts_with("capabilities.") {
842 return SchemaDiagnostic::new(
843 crate::set_aside::RESTRICTION_LOST,
844 Severity::Warning,
845 path,
846 format!(
847 "{agent} lost the restriction `{key}`: it was dropped, because \
848 `harness-overrides.{namespace}.{key}` already sets a value, and an override \
849 written wins"
850 ),
851 );
852 }
853 SchemaDiagnostic::new(
854 "field.removed",
855 Severity::Warning,
856 path,
857 format!("dropped `{dropped}`; `harness-overrides.{namespace}.{key}` already sets it"),
858 )
859}
860
861fn strip_model_policy_overrides(fm: &mut Frontmatter, diagnostics: &mut Vec<SchemaDiagnostic>) {
862 let Some(Value::Sequence(policies)) = fm.get_mut("model-policies") else {
863 return;
864 };
865 for (index, policy) in policies.iter_mut().enumerate() {
866 let Value::Mapping(policy) = policy else {
867 continue;
868 };
869 let Some(Value::Mapping(overrides)) = policy.get_mut(Value::String("override".to_string()))
870 else {
871 continue;
872 };
873 for (old, new) in [("adaptive", "medium"), ("disabled", "none")] {
875 let key = Value::String("effort".to_string());
876 if overrides.get(&key).and_then(Value::as_str) == Some(old) {
877 overrides.insert(key, Value::String(new.to_string()));
878 }
879 }
880 for key in ["autocompact", "autocompact_pct", "harness"] {
881 if overrides.remove(Value::String(key.to_string())).is_some() {
882 diagnostics.push(SchemaDiagnostic::new(
883 "field.removed",
884 Severity::Warning,
885 vec![
886 PathSegment::Key("model-policies".to_string()),
887 PathSegment::Index(index),
888 PathSegment::Key("override".to_string()),
889 PathSegment::Key(key.to_string()),
890 ],
891 format!("removed model policy override key `{key}`"),
892 ));
893 }
894 }
895 }
896}
897
898fn rename_field(fm: &mut Frontmatter, old: &str, new: &str) {
899 let Some(value) = fm.remove(old) else {
900 return;
901 };
902 if fm.get(new).is_none() {
903 fm.insert(new, value);
904 }
905}
906
907fn merge_host_set_spellings(fm: &mut Frontmatter, old: &str, new: &str) {
911 let (Some(old_value), Some(new_value)) = (fm.get(old), fm.get(new)) else {
912 return rename_field(fm, old, new);
913 };
914 let (Some((old_hosts, old_replaces)), Some((mut hosts, new_replaces))) =
915 (host_set_parts(old_value), host_set_parts(new_value))
916 else {
917 return;
918 };
919 for host in old_hosts {
920 if !hosts.contains(&host) {
921 hosts.push(host);
922 }
923 }
924 let merged = if old_replaces || new_replaces {
925 let mut replace = serde_yaml::Mapping::new();
926 replace.insert(Value::String("replace".to_string()), Value::Sequence(hosts));
927 Value::Mapping(replace)
928 } else {
929 Value::Sequence(hosts)
930 };
931 fm.insert(new, merged);
932 fm.remove(old);
933}
934
935fn host_set_parts(value: &Value) -> Option<(Vec<Value>, bool)> {
936 match value {
937 Value::Sequence(hosts) => Some((hosts.clone(), false)),
938 Value::Mapping(map) if map.len() == 1 => map
939 .get("replace")
940 .and_then(Value::as_sequence)
941 .map(|hosts| (hosts.clone(), true)),
942 _ => None,
943 }
944}
945
946fn replace_scalar(fm: &mut Frontmatter, key: &str, old: &str, new: &str) {
947 if fm.get(key).and_then(Value::as_str) == Some(old) {
948 fm.insert(key, Value::String(new.to_string()));
949 }
950}
951
952fn removed(path: &[&str], key: &str) -> SchemaDiagnostic {
953 SchemaDiagnostic::new(
954 "field.removed",
955 Severity::Warning,
956 path.iter()
957 .map(|part| PathSegment::Key((*part).to_string()))
958 .collect(),
959 format!("removed legacy key `{key}`"),
960 )
961}