1use crate::experience::episode::Episode;
60use crate::experience::llm;
61use crate::experience::skillmd;
62use harness_core::{Model, SkillError, SkillManifest};
63use serde::{Deserialize, Serialize};
64use std::collections::BTreeMap;
65use std::path::{Path, PathBuf};
66use std::sync::Arc;
67
68pub const EXPERIENCE_METADATA_KEY: &str = "experience";
75
76#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78pub struct RevisionLedger {
79 #[serde(default)]
81 pub revision: u32,
82 #[serde(default, skip_serializing_if = "String::is_empty")]
84 pub revised_on: String,
85 #[serde(default)]
88 pub revisions_today: u32,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub origin: Option<String>,
93}
94
95impl RevisionLedger {
96 pub fn distilled() -> Self {
98 Self {
99 origin: Some("distilled".into()),
100 ..Default::default()
101 }
102 }
103
104 pub fn from_manifest(m: &SkillManifest) -> Self {
108 m.metadata
109 .get(EXPERIENCE_METADATA_KEY)
110 .and_then(|v| serde_json::from_value(v.clone()).ok())
111 .unwrap_or_default()
112 }
113
114 pub fn write_into(&self, m: &mut SkillManifest) {
117 if let Ok(v) = serde_json::to_value(self) {
118 m.metadata.insert(EXPERIENCE_METADATA_KEY.to_string(), v);
119 }
120 }
121
122 pub fn bumped(&self, today: &str) -> Self {
124 Self {
125 revision: self.revision.saturating_add(1),
126 revisions_today: if self.revised_on == today {
127 self.revisions_today.saturating_add(1)
128 } else {
129 1
130 },
131 revised_on: today.to_string(),
132 origin: self.origin.clone(),
133 }
134 }
135
136 pub fn revisions_on(&self, today: &str) -> u32 {
139 if self.revised_on == today {
140 self.revisions_today
141 } else {
142 0
143 }
144 }
145}
146
147#[derive(Debug, Clone, PartialEq)]
149pub struct RevisePolicy {
150 pub max_total_revisions: u32,
152 pub max_revisions_per_day: u32,
154 pub require_skill_in_episode: bool,
158 pub refuse_when_episode_succeeded: bool,
161 pub max_output_tokens: u32,
164}
165
166impl Default for RevisePolicy {
167 fn default() -> Self {
168 Self {
169 max_total_revisions: 10,
170 max_revisions_per_day: 2,
171 require_skill_in_episode: true,
172 refuse_when_episode_succeeded: true,
173 max_output_tokens: 3_000,
174 }
175 }
176}
177
178#[derive(Debug, Clone, PartialEq)]
180#[non_exhaustive]
181pub enum RefusalReason {
182 LifetimeCapReached { revision: u32, max: u32 },
184 DailyCapReached { today: u32, max: u32, date: String },
186 SkillNotUsedInEpisode { skill: String },
188 EpisodeSucceeded,
190}
191
192#[derive(Debug, Clone, PartialEq)]
194pub enum RevisionGate {
195 Revise {
196 current_revision: u32,
198 revisions_today: u32,
200 },
201 Refuse(RefusalReason),
202}
203
204#[derive(Debug, Clone, PartialEq)]
206pub enum Revision {
207 Revised(Box<SkillRevision>),
210 Refused(RefusalReason),
211}
212
213impl Revision {
214 pub fn revision(&self) -> Option<&SkillRevision> {
215 match self {
216 Revision::Revised(r) => Some(r),
217 Revision::Refused(_) => None,
218 }
219 }
220 pub fn into_revision(self) -> Option<SkillRevision> {
221 match self {
222 Revision::Revised(r) => Some(*r),
223 Revision::Refused(_) => None,
224 }
225 }
226}
227
228#[derive(Debug, thiserror::Error)]
229#[non_exhaustive]
230pub enum ReviseError {
231 #[error("revision model call failed: {0}")]
232 Model(String),
233 #[error("model returned no usable revision: {0}")]
234 BadRevision(String),
235 #[error("current SKILL.md could not be read: {0}")]
236 Skill(#[from] SkillError),
237 #[error("revised skill is not valid: {}", .0.join("; "))]
240 Invalid(Vec<String>),
241}
242
243#[derive(Debug, Clone)]
246pub struct RevisionRequest {
247 pub skill_name: String,
249 pub current_md: String,
251 pub episode: Episode,
253 pub complaint: String,
258}
259
260impl RevisionRequest {
261 pub fn new(
262 skill_name: impl Into<String>,
263 current_md: impl Into<String>,
264 episode: Episode,
265 complaint: impl Into<String>,
266 ) -> Self {
267 Self {
268 skill_name: skill_name.into(),
269 current_md: current_md.into(),
270 episode,
271 complaint: complaint.into(),
272 }
273 }
274
275 pub fn from_dir(
278 skills_root: &Path,
279 skill_name: &str,
280 episode: Episode,
281 complaint: impl Into<String>,
282 ) -> Result<Self, ReviseError> {
283 harness_context::skills::validate_name(skill_name)?;
284 let path = skills_root.join(skill_name).join("SKILL.md");
285 let md = std::fs::read_to_string(&path)
286 .map_err(|e| SkillError::Io(format!("{}: {e}", path.display())))?;
287 Ok(Self::new(skill_name, md, episode, complaint))
288 }
289}
290
291#[derive(Debug, Clone, PartialEq)]
294pub struct SkillRevision {
295 pub name: String,
297 pub description: String,
299 pub body: String,
301 pub rationale: String,
304 pub description_changed: bool,
306 pub ledger: RevisionLedger,
308 pub metadata: BTreeMap<String, serde_json::Value>,
311}
312
313impl SkillRevision {
314 pub fn manifest(&self) -> SkillManifest {
315 skillmd::manifest(&self.name, &self.description, self.metadata.clone())
316 }
317
318 pub fn to_skill_md(&self) -> String {
319 skillmd::render_skill_md(&self.manifest(), &self.body)
320 }
321
322 pub fn validate(&self) -> Result<(), ReviseError> {
323 skillmd::validate_and_lint(&self.manifest(), &self.body).map_err(ReviseError::Invalid)
324 }
325
326 pub fn write_to(&self, skills_root: &Path) -> Result<PathBuf, SkillError> {
329 harness_context::skills::write_skill_md(skills_root, &self.name, &self.to_skill_md())
330 }
331}
332
333pub struct SkillReviser {
335 model: Arc<dyn Model>,
336 policy: RevisePolicy,
337 today: Option<String>,
339}
340
341impl SkillReviser {
342 pub fn new(model: Arc<dyn Model>) -> Self {
343 Self {
344 model,
345 policy: RevisePolicy::default(),
346 today: None,
347 }
348 }
349
350 pub fn with_policy(mut self, policy: RevisePolicy) -> Self {
351 self.policy = policy;
352 self
353 }
354
355 pub fn with_max_total_revisions(mut self, n: u32) -> Self {
356 self.policy.max_total_revisions = n;
357 self
358 }
359
360 pub fn with_max_revisions_per_day(mut self, n: u32) -> Self {
361 self.policy.max_revisions_per_day = n;
362 self
363 }
364
365 pub fn with_today(mut self, day: impl Into<String>) -> Self {
369 self.today = Some(day.into());
370 self
371 }
372
373 pub fn policy(&self) -> &RevisePolicy {
374 &self.policy
375 }
376
377 fn today(&self) -> String {
378 self.today.clone().unwrap_or_else(skillmd::utc_today)
379 }
380
381 pub fn check(&self, req: &RevisionRequest) -> Result<RevisionGate, ReviseError> {
383 let (manifest, _) = skillmd::split_skill_md(&req.current_md)?;
384 Ok(self.check_parsed(req, &manifest))
385 }
386
387 fn check_parsed(&self, req: &RevisionRequest, manifest: &SkillManifest) -> RevisionGate {
388 let p = &self.policy;
389 if p.refuse_when_episode_succeeded && req.episode.succeeded() {
390 return RevisionGate::Refuse(RefusalReason::EpisodeSucceeded);
391 }
392 if p.require_skill_in_episode
393 && !req
394 .episode
395 .skills
396 .iter()
397 .any(|s| s == &req.skill_name || s == &manifest.name)
398 {
399 return RevisionGate::Refuse(RefusalReason::SkillNotUsedInEpisode {
400 skill: req.skill_name.clone(),
401 });
402 }
403 let ledger = RevisionLedger::from_manifest(manifest);
404 if ledger.revision >= p.max_total_revisions {
405 return RevisionGate::Refuse(RefusalReason::LifetimeCapReached {
406 revision: ledger.revision,
407 max: p.max_total_revisions,
408 });
409 }
410 let today = self.today();
411 let today_count = ledger.revisions_on(&today);
412 if today_count >= p.max_revisions_per_day {
413 return RevisionGate::Refuse(RefusalReason::DailyCapReached {
414 today: today_count,
415 max: p.max_revisions_per_day,
416 date: today,
417 });
418 }
419 RevisionGate::Revise {
420 current_revision: ledger.revision,
421 revisions_today: today_count,
422 }
423 }
424
425 pub async fn revise(&self, req: &RevisionRequest) -> Result<Revision, ReviseError> {
430 let (manifest, body) = skillmd::split_skill_md(&req.current_md)?;
431 match self.check_parsed(req, &manifest) {
432 RevisionGate::Refuse(why) => return Ok(Revision::Refused(why)),
433 RevisionGate::Revise { .. } => {}
434 }
435
436 let raw = llm::one_shot(
437 &self.model,
438 revise_prompt(&manifest, &body, req),
439 self.policy.max_output_tokens,
440 )
441 .await
442 .map_err(ReviseError::Model)?;
443 let json = llm::extract_json(&raw).ok_or_else(|| {
444 ReviseError::BadRevision(format!(
445 "no JSON object in a {} char reply",
446 raw.trim().len()
447 ))
448 })?;
449
450 let new_body = llm::str_field(&json, "body").ok_or_else(|| {
451 ReviseError::BadRevision("model returned no `body`; refusing to blank the skill".into())
452 })?;
453 if new_body.trim().len() < 50 {
454 return Err(ReviseError::BadRevision(format!(
455 "revised body is {} chars; refusing to replace a skill with a stub",
456 new_body.trim().len()
457 )));
458 }
459
460 let (description, description_changed) = match json.get("description_change") {
464 Some(c) if c.is_object() => {
465 match (llm::str_field(c, "new"), llm::str_field(c, "why")) {
466 (Some(new), Some(_why)) => (
467 skillmd::finalize_description(
468 &new,
469 &req.episode.situation,
470 &manifest.description,
471 ),
472 true,
473 ),
474 _ => (manifest.description.clone(), false),
475 }
476 }
477 _ => (manifest.description.clone(), false),
478 };
479
480 let ledger = RevisionLedger::from_manifest(&manifest).bumped(&self.today());
481 let mut metadata = manifest.metadata.clone();
482 {
483 let mut tmp = manifest.clone();
486 tmp.metadata = metadata;
487 ledger.write_into(&mut tmp);
488 metadata = tmp.metadata;
489 }
490
491 let revision = SkillRevision {
492 name: manifest.name.clone(),
493 description,
494 body: new_body.trim().to_string(),
495 rationale: llm::str_field(&json, "rationale")
496 .unwrap_or_else(|| "(the model gave no rationale for this revision)".to_string()),
497 description_changed,
498 ledger,
499 metadata,
500 };
501 revision.validate()?;
502 Ok(Revision::Revised(Box::new(revision)))
503 }
504}
505
506fn revise_prompt(manifest: &SkillManifest, body: &str, req: &RevisionRequest) -> String {
508 let tools = if req.episode.tools.is_empty() {
509 "(none)".to_string()
510 } else {
511 req.episode.tools.join(" → ")
512 };
513 format!(
514 "An AI agent followed the skill below and the run went wrong. Revise the skill so a \
515 future agent following it would not hit the same problem.\n\n\
516 ---- CURRENT SKILL ----\n\
517 NAME (you may NOT change this): {name}\n\
518 DESCRIPTION: {description}\n\
519 BODY:\n{body}\n\
520 ---- END SKILL ----\n\n\
521 ---- THE RUN THAT WENT WRONG ----\n\
522 SITUATION: {situation}\n\
523 TOOLS CALLED, IN ORDER: {tools}\n\
524 OUTCOME: {outcome}\n\
525 WHAT WENT WRONG: {complaint}\n\
526 ---- END RUN ----\n\n\
527 Change the MINIMUM that fixes the problem. Keep every step that still holds, in the \
528 same order and wording. Do not rewrite the skill's voice, do not add sections that \
529 weren't there, and do not generalise away the concrete tool names and commands — those \
530 are the useful part. If the failure was caused by the environment rather than by the \
531 instructions, say so in the rationale and return the body essentially unchanged.\n\n\
532 Reply with ONLY a JSON object, no prose around it:\n\
533 {{\n\
534 \x20 \"body\": \"the COMPLETE revised markdown body, with no YAML frontmatter\",\n\
535 \x20 \"rationale\": \"one sentence: what you changed and why\",\n\
536 \x20 \"description_change\": null\n\
537 }}\n\n\
538 Set `description_change` to null unless the current description now genuinely \
539 mis-describes the skill. If it does, use \
540 {{\"new\": \"...\", \"why\": \"...\"}} instead of null.",
541 name = manifest.name,
542 description = manifest.description,
543 situation = req.episode.situation.trim(),
544 outcome = req.episode.outcome.trim(),
545 complaint = req.complaint.trim(),
546 )
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use harness_models::{MockModel, MockResponse};
553
554 const ORIGINAL_DESCRIPTION: &str = "Ship the marketing site to production and confirm it is live. Use when the user asks \
555 to deploy, ship, or release the site.";
556
557 fn skill_md(ledger: Option<RevisionLedger>) -> String {
558 let mut meta = BTreeMap::new();
559 meta.insert(
561 "harness".to_string(),
562 serde_json::json!({"kind": "inferential"}),
563 );
564 if let Some(l) = ledger {
565 meta.insert(
566 EXPERIENCE_METADATA_KEY.to_string(),
567 serde_json::to_value(l).unwrap(),
568 );
569 }
570 let m = skillmd::manifest("deploy-runbook", ORIGINAL_DESCRIPTION, meta);
571 skillmd::render_skill_md(
572 &m,
573 "# Deploy Runbook\n\n## Steps\n\n1. Run deploy.sh.\n2. Check the site returns 200.\n",
574 )
575 }
576
577 fn bad_episode() -> Episode {
578 Episode::new(
579 "deploy the marketing site to production",
580 "deploy.sh: no such file or directory; site was never updated",
581 )
582 .with_tools(["shell", "read_file"])
583 .with_skills(["deploy-runbook"])
584 .with_success(false)
585 }
586
587 fn good_reply() -> String {
588 serde_json::json!({
589 "body": "# Deploy Runbook\n\n## Steps\n\n1. Run bin/deploy.sh (it lives under bin/, \
590 not the repo root).\n2. Check the site returns 200.\n",
591 "rationale": "Corrected the script path: deploy.sh is at bin/deploy.sh.",
592 "description_change": null
593 })
594 .to_string()
595 }
596
597 fn model(reply: &str) -> Arc<dyn Model> {
598 Arc::new(MockModel::new().script(MockResponse::text(reply)))
599 }
600
601 fn reviser(reply: &str) -> SkillReviser {
602 SkillReviser::new(model(reply)).with_today("2026-08-16")
603 }
604
605 fn request(md: String) -> RevisionRequest {
606 RevisionRequest::new(
607 "deploy-runbook",
608 md,
609 bad_episode(),
610 "step 1 said `deploy.sh` but the script is at `bin/deploy.sh`",
611 )
612 }
613
614 #[tokio::test]
617 async fn reviser_preserves_name_and_description() {
618 let r = reviser(&good_reply());
619 let rev = r
620 .revise(&request(skill_md(None)))
621 .await
622 .unwrap()
623 .into_revision()
624 .expect("should revise");
625
626 assert_eq!(rev.name, "deploy-runbook", "name is never revised");
627 assert_eq!(
628 rev.description, ORIGINAL_DESCRIPTION,
629 "description must be preserved byte for byte when unjustified"
630 );
631 assert!(!rev.description_changed);
632 assert!(rev.body.contains("bin/deploy.sh"), "{}", rev.body);
633 assert!(rev.rationale.contains("bin/deploy.sh"));
634
635 assert_eq!(rev.ledger.revision, 1);
637 assert_eq!(rev.ledger.revised_on, "2026-08-16");
638 assert_eq!(rev.ledger.revisions_today, 1);
639 assert_eq!(rev.metadata["harness"]["kind"], "inferential");
640
641 rev.validate().expect("revised skill must be valid");
642 }
643
644 #[tokio::test]
645 async fn a_bare_description_string_without_a_why_is_ignored() {
646 let reply = serde_json::json!({
647 "body": "# Deploy Runbook\n\n## Steps\n\n1. Run bin/deploy.sh, which lives under bin/.\n",
648 "rationale": "path fix",
649 "description_change": {"new": "Something completely different."}
650 })
651 .to_string();
652 let rev = reviser(&reply)
653 .revise(&request(skill_md(None)))
654 .await
655 .unwrap()
656 .into_revision()
657 .unwrap();
658 assert_eq!(rev.description, ORIGINAL_DESCRIPTION);
659 assert!(!rev.description_changed);
660 }
661
662 #[tokio::test]
663 async fn a_justified_description_change_is_taken() {
664 let reply = serde_json::json!({
665 "body": "# Deploy Runbook\n\n## Steps\n\n1. Run bin/deploy.sh, which lives under bin/.\n",
666 "rationale": "the skill now covers staging too",
667 "description_change": {
668 "new": "Ship the site to production or staging and confirm it is live. Use when \
669 the user asks to deploy to any environment.",
670 "why": "the runbook now handles staging, which the old description excluded"
671 }
672 })
673 .to_string();
674 let rev = reviser(&reply)
675 .revise(&request(skill_md(None)))
676 .await
677 .unwrap()
678 .into_revision()
679 .unwrap();
680 assert!(rev.description_changed);
681 assert!(rev.description.contains("staging"));
682 assert_eq!(rev.name, "deploy-runbook", "still never the name");
683 rev.validate().unwrap();
684 }
685
686 #[tokio::test]
687 async fn revision_round_trips_through_write_skill_md() {
688 let root = std::env::temp_dir().join(format!(
689 "harness-revise-{}-{}",
690 std::process::id(),
691 std::time::SystemTime::now()
692 .duration_since(std::time::UNIX_EPOCH)
693 .unwrap()
694 .as_nanos()
695 ));
696 std::fs::create_dir_all(&root).unwrap();
697 harness_context::skills::write_skill_md(&root, "deploy-runbook", &skill_md(None)).unwrap();
698
699 let req = RevisionRequest::from_dir(&root, "deploy-runbook", bad_episode(), "wrong path")
700 .unwrap();
701 let rev = reviser(&good_reply())
702 .revise(&req)
703 .await
704 .unwrap()
705 .into_revision()
706 .unwrap();
707 rev.write_to(&root).unwrap();
708
709 let loaded = harness_context::skills::load_skill_dir(&root.join("deploy-runbook")).unwrap();
710 assert_eq!(loaded.manifest().description, ORIGINAL_DESCRIPTION);
711 assert!(loaded.body().contains("bin/deploy.sh"));
712 let ledger = RevisionLedger::from_manifest(loaded.manifest());
713 assert_eq!(ledger.revision, 1);
714 assert_eq!(ledger.revised_on, "2026-08-16");
715
716 let _ = std::fs::remove_dir_all(&root);
717 }
718
719 #[tokio::test]
723 async fn daily_cap_refuses_the_k_plus_first() {
724 let today = "2026-08-16";
725 let at_cap = RevisionLedger {
726 revision: 2,
727 revised_on: today.into(),
728 revisions_today: 2, origin: None,
730 };
731 let mock = Arc::new(MockModel::new().script(MockResponse::text(good_reply())));
732 let r = SkillReviser::new(mock.clone() as Arc<dyn Model>).with_today(today);
733
734 let req = request(skill_md(Some(at_cap)));
735 assert_eq!(
736 r.revise(&req).await.unwrap(),
737 Revision::Refused(RefusalReason::DailyCapReached {
738 today: 2,
739 max: 2,
740 date: today.into()
741 })
742 );
743 assert_eq!(mock.call_count(), 0, "a refused revision costs no tokens");
744 }
745
746 #[tokio::test]
748 async fn daily_cap_resets_tomorrow() {
749 let at_cap = RevisionLedger {
750 revision: 2,
751 revised_on: "2026-08-16".into(),
752 revisions_today: 2,
753 origin: None,
754 };
755 let r = SkillReviser::new(model(&good_reply())).with_today("2026-08-17");
756 let rev = r
757 .revise(&request(skill_md(Some(at_cap))))
758 .await
759 .unwrap()
760 .into_revision()
761 .expect("a new day gets a fresh budget");
762 assert_eq!(rev.ledger.revision, 3);
763 assert_eq!(rev.ledger.revised_on, "2026-08-17");
764 assert_eq!(rev.ledger.revisions_today, 1, "counter reset, not carried");
765 }
766
767 #[tokio::test]
768 async fn lifetime_cap_refuses_regardless_of_the_date() {
769 let worn_out = RevisionLedger {
770 revision: 10, revised_on: "2020-01-01".into(),
772 revisions_today: 1,
773 origin: Some("distilled".into()),
774 };
775 let r = reviser(&good_reply());
776 assert_eq!(
777 r.revise(&request(skill_md(Some(worn_out)))).await.unwrap(),
778 Revision::Refused(RefusalReason::LifetimeCapReached {
779 revision: 10,
780 max: 10
781 })
782 );
783 }
784
785 #[tokio::test]
788 async fn k_revisions_then_refusal() {
789 const K: u32 = 3;
790 let mut md = skill_md(None);
791 for i in 1..=K {
792 let r = SkillReviser::new(model(&good_reply()))
793 .with_today("2026-08-16")
794 .with_max_revisions_per_day(K)
795 .with_max_total_revisions(100);
796 let rev = r
797 .revise(&request(md.clone()))
798 .await
799 .unwrap()
800 .into_revision()
801 .unwrap_or_else(|| panic!("revision {i} of {K} should be allowed"));
802 assert_eq!(rev.ledger.revision, i);
803 assert_eq!(rev.ledger.revisions_today, i);
804 md = rev.to_skill_md();
805 }
806 let r = SkillReviser::new(model(&good_reply()))
807 .with_today("2026-08-16")
808 .with_max_revisions_per_day(K)
809 .with_max_total_revisions(100);
810 assert_eq!(
811 r.revise(&request(md)).await.unwrap(),
812 Revision::Refused(RefusalReason::DailyCapReached {
813 today: K,
814 max: K,
815 date: "2026-08-16".into()
816 }),
817 "the K+1th revision in a day must be refused"
818 );
819 }
820
821 #[tokio::test]
824 async fn refuses_when_the_run_never_followed_the_skill() {
825 let mut ep = bad_episode();
826 ep.skills = vec!["some-other-skill".into()];
827 let req = RevisionRequest::new("deploy-runbook", skill_md(None), ep, "broke");
828 assert_eq!(
829 reviser(&good_reply()).revise(&req).await.unwrap(),
830 Revision::Refused(RefusalReason::SkillNotUsedInEpisode {
831 skill: "deploy-runbook".into()
832 })
833 );
834 }
835
836 #[tokio::test]
837 async fn refuses_when_the_run_succeeded() {
838 let ep = bad_episode().with_success(true);
839 let req = RevisionRequest::new("deploy-runbook", skill_md(None), ep, "nitpick");
840 assert_eq!(
841 reviser(&good_reply()).revise(&req).await.unwrap(),
842 Revision::Refused(RefusalReason::EpisodeSucceeded)
843 );
844 }
845
846 #[test]
847 fn a_skill_with_no_ledger_is_revisable() {
848 let r = reviser("");
849 assert_eq!(
850 r.check(&request(skill_md(None))).unwrap(),
851 RevisionGate::Revise {
852 current_revision: 0,
853 revisions_today: 0
854 }
855 );
856 }
857
858 #[tokio::test]
861 async fn refuses_to_blank_a_skill() {
862 for reply in [
863 serde_json::json!({"rationale": "no idea"}).to_string(),
864 serde_json::json!({"body": " ", "rationale": "x"}).to_string(),
865 serde_json::json!({"body": "# Deploy", "rationale": "x"}).to_string(),
866 ] {
867 let err = reviser(&reply)
868 .revise(&request(skill_md(None)))
869 .await
870 .unwrap_err();
871 assert!(
872 matches!(err, ReviseError::BadRevision(_)),
873 "expected BadRevision for {reply}, got {err}"
874 );
875 }
876 }
877
878 #[test]
879 fn ledger_survives_a_frontmatter_round_trip() {
880 let l = RevisionLedger {
881 revision: 4,
882 revised_on: "2026-08-16".into(),
883 revisions_today: 2,
884 origin: Some("distilled".into()),
885 };
886 let mut m = skillmd::manifest("x", "y", BTreeMap::new());
887 l.write_into(&mut m);
888 let md = skillmd::render_skill_md(&m, "body body body body body body body body");
889 let (back, _) = skillmd::split_skill_md(&md).unwrap();
890 assert_eq!(RevisionLedger::from_manifest(&back), l);
891 }
892
893 #[test]
894 fn bumped_resets_on_a_new_day() {
895 let l = RevisionLedger {
896 revision: 1,
897 revised_on: "2026-08-16".into(),
898 revisions_today: 1,
899 origin: None,
900 };
901 assert_eq!(l.bumped("2026-08-16").revisions_today, 2);
902 assert_eq!(l.bumped("2026-08-17").revisions_today, 1);
903 assert_eq!(l.bumped("2026-08-17").revision, 2, "lifetime never resets");
904 assert_eq!(l.revisions_on("2026-08-17"), 0);
905 assert_eq!(l.revisions_on("2026-08-16"), 1);
906 }
907}