Skip to main content

harness_loop/experience/
revise.rs

1//! `SkillReviser` — fix a skill that turned out to be wrong.
2//!
3//! The other half of Hermes-style continuous learning: a skill is not
4//! write-once. When a run followed skill X and went badly, the skill's steps are
5//! a prime suspect, and the cheapest moment to fix them is right then, with the
6//! failing run still in hand.
7//!
8//! ```ignore
9//! // The run followed `deploy-runbook` and failed.
10//! let req = RevisionRequest::from_dir(&skills_root, "deploy-runbook", episode, complaint)?;
11//! match reviser.revise(&req).await? {
12//!     Revision::Revised(r) => queue_for_review(user_id, *r),   // caller decides
13//!     Revision::Refused(w)  => tracing::info!(?w, "revision declined"),
14//! }
15//! ```
16//!
17//! ## Drift, and the cap that stops it
18//!
19//! Every revision is a model rewriting instructions based on one bad run. Ten
20//! such rewrites in an afternoon, each triggered by a different flaky failure,
21//! will grind a good skill into mush — the classic failure mode of unsupervised
22//! self-modification. Two caps, both recorded in the skill's own frontmatter
23//! under `metadata.experience` ([`RevisionLedger`]):
24//!
25//! - **`max_revisions_per_day` (default 2)** — the drift brake. A model looping
26//!   on the same flaky failure gets two attempts, then stops until tomorrow. A
27//!   day is the right unit because a genuinely wrong skill fails repeatedly
28//!   *within a session*, which is exactly the burst this suppresses; a skill
29//!   that is wrong for a lasting reason will still be failing tomorrow, and
30//!   gets another two attempts then.
31//! - **`max_total_revisions` (default 10)** — the lifetime ceiling. A skill that
32//!   has been rewritten ten times isn't converging; it's the wrong abstraction,
33//!   and the correct action is for a human to retire it, not for the eleventh
34//!   rewrite to try again.
35//!
36//! **Why the ledger lives in the skill file and not in a sidecar table.** The
37//! skill directory is the unit that gets copied, synced, and scoped per tenant.
38//! An external counter desyncs the instant a skill is moved between users or
39//! restored from backup, and a multi-tenant server would need a table whose only
40//! job is this. Frontmatter travels with the artefact and — the part that
41//! actually matters — is legible to the human reviewing the skill: `revision: 7`
42//! on a file is a visible warning that this thing keeps needing fixing. The
43//! accepted cost is that a model holding `skill_manage` could edit its own
44//! counter. That is not the threat being defended against: the same model could
45//! rewrite the entire skill directly. The cap exists to stop an *automated loop*
46//! from drifting, not an adversary.
47//!
48//! ## What a revision may and may not change
49//!
50//! - **Body**: freely.
51//! - **Description**: only when the model returns a `description_change` with a
52//!   non-empty justification. Otherwise it is preserved byte for byte.
53//! - **Name**: never. Renaming is a delete-plus-create, not a revision: the
54//!   directory name, every `skill_manage` reference, every `skill:<name>` tag on
55//!   past episodes, and any recall keyed on the old name would silently dangle.
56//!   A revision that concludes the skill is misnamed is a signal to retire it,
57//!   which is a human's call.
58
59use 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
68/// Frontmatter key holding the revision ledger: `metadata.experience`.
69///
70/// Deliberately a sibling of `metadata.harness` rather than a child of it.
71/// `metadata.harness` deserializes into `harness_core::HarnessExt` (kind, risk,
72/// entrypoint); burying unrelated bookkeeping inside a typed struct invites the
73/// next person to add `deny_unknown_fields` and silently break this.
74pub const EXPERIENCE_METADATA_KEY: &str = "experience";
75
76/// The per-skill revision ledger stored at `metadata.experience`.
77#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78pub struct RevisionLedger {
79    /// How many times this skill has been revised. 0 = never.
80    #[serde(default)]
81    pub revision: u32,
82    /// UTC `YYYY-MM-DD` of the most recent revision. Empty = never revised.
83    #[serde(default, skip_serializing_if = "String::is_empty")]
84    pub revised_on: String,
85    /// Revisions made on [`revised_on`](Self::revised_on). Meaningless once the
86    /// date rolls over, which is precisely how the daily cap resets.
87    #[serde(default)]
88    pub revisions_today: u32,
89    /// How this skill came to exist. `Some("distilled")` = machine-authored by
90    /// [`crate::experience::SkillDistiller`]; `None` = hand-written or from elsewhere.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub origin: Option<String>,
93}
94
95impl RevisionLedger {
96    /// The ledger a freshly distilled skill starts with.
97    pub fn distilled() -> Self {
98        Self {
99            origin: Some("distilled".into()),
100            ..Default::default()
101        }
102    }
103
104    /// Read the ledger out of a manifest. A skill with no ledger (hand-written,
105    /// or predating this feature) reads as all-zero, i.e. never revised — the
106    /// permissive default, so an existing skill isn't locked out of its first fix.
107    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    /// Write the ledger back into a manifest's metadata, leaving every other
115    /// metadata key (notably `metadata.harness`) untouched.
116    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    /// The ledger after one more revision performed on `today`.
123    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    /// Revisions already made today, given today's date. Zero once the date
137    /// rolls over.
138    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/// When a skill may be revised.
148#[derive(Debug, Clone, PartialEq)]
149pub struct RevisePolicy {
150    /// Lifetime ceiling on revisions. See the module docs.
151    pub max_total_revisions: u32,
152    /// Ceiling on revisions per UTC day. See the module docs.
153    pub max_revisions_per_day: u32,
154    /// Refuse unless the skill appears in [`Episode::skills`] — i.e. the run
155    /// actually followed it. Turn off only if the host has no way to record
156    /// skill usage and is willing to revise on suspicion.
157    pub require_skill_in_episode: bool,
158    /// Refuse when the episode succeeded. A skill that was followed on a run
159    /// that worked has no evidence against it.
160    pub refuse_when_episode_succeeded: bool,
161    /// Output-token ceiling for the single revision call. Larger than the
162    /// distiller's: a revision must reproduce the whole body.
163    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/// Why a revision was declined. Refusals are normal control flow, not errors.
179#[derive(Debug, Clone, PartialEq)]
180#[non_exhaustive]
181pub enum RefusalReason {
182    /// Lifetime cap hit — the skill is the wrong abstraction; retire it.
183    LifetimeCapReached { revision: u32, max: u32 },
184    /// Daily cap hit — try again tomorrow.
185    DailyCapReached { today: u32, max: u32, date: String },
186    /// The run never followed this skill, so it isn't evidence about it.
187    SkillNotUsedInEpisode { skill: String },
188    /// The run worked. Nothing to fix.
189    EpisodeSucceeded,
190}
191
192/// Outcome of [`SkillReviser::check`] — the cap decision, with no model call.
193#[derive(Debug, Clone, PartialEq)]
194pub enum RevisionGate {
195    Revise {
196        /// Revisions this skill has already had, lifetime.
197        current_revision: u32,
198        /// …and today.
199        revisions_today: u32,
200    },
201    Refuse(RefusalReason),
202}
203
204/// Outcome of [`SkillReviser::revise`].
205#[derive(Debug, Clone, PartialEq)]
206pub enum Revision {
207    /// Boxed because `SkillRevision` carries the full skill body, and an enum is
208    /// as large as its largest variant.
209    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    /// The revised skill failed this workspace's own rules. Never silently
238    /// written — a revision that breaks the skill is worse than no revision.
239    #[error("revised skill is not valid: {}", .0.join("; "))]
240    Invalid(Vec<String>),
241}
242
243/// Everything the reviser needs: the skill as it stands, the run that went
244/// wrong, and why.
245#[derive(Debug, Clone)]
246pub struct RevisionRequest {
247    /// Name of the skill to revise. Must match the frontmatter name.
248    pub skill_name: String,
249    /// The current `SKILL.md` in full (frontmatter + body).
250    pub current_md: String,
251    /// The run that went badly.
252    pub episode: Episode,
253    /// What went wrong, in the most specific terms available — a model
254    /// self-report ("step 3 said `deploy.sh`, but the script is at
255    /// `bin/deploy.sh`"), a stderr excerpt, or user feedback. This is the single
256    /// highest-signal input; a vague complaint produces a vague revision.
257    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    /// Read `<skills_root>/<name>/SKILL.md` from disk. Reading only — the
276    /// revision still has to be written back explicitly.
277    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/// A proposed replacement for a skill. Not written anywhere until the caller
292/// says so.
293#[derive(Debug, Clone, PartialEq)]
294pub struct SkillRevision {
295    /// Unchanged from the original, always. See the module docs.
296    pub name: String,
297    /// Unchanged unless [`description_changed`](Self::description_changed).
298    pub description: String,
299    /// The revised markdown body.
300    pub body: String,
301    /// One sentence from the model on what it changed and why. Show this to
302    /// whoever approves the revision.
303    pub rationale: String,
304    /// True when the model justified a new description.
305    pub description_changed: bool,
306    /// The bumped ledger, already reflecting this revision.
307    pub ledger: RevisionLedger,
308    /// Full frontmatter metadata, with every pre-existing key preserved and
309    /// `experience` updated.
310    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    /// Opt-in persistence: overwrite `<skills_root>/<name>/SKILL.md`.
327    /// `write_skill_md` restores the prior content if the new one won't load.
328    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
333/// Produces revisions for skills that misled a run.
334pub struct SkillReviser {
335    model: Arc<dyn Model>,
336    policy: RevisePolicy,
337    /// Test/host seam for "today". `None` = real UTC clock.
338    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    /// Override "today" (`YYYY-MM-DD`). For tests, and for hosts that keep their
366    /// own clock (a scheduler replaying yesterday's episodes should date the
367    /// revision when it happens, not when the episode did).
368    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    /// The cap and eligibility decision, with no model call.
382    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    /// Check the caps, then (only if they allow) spend one model call to revise.
426    ///
427    /// Never writes. Hand the returned [`SkillRevision`] to a reviewer, or call
428    /// [`SkillRevision::write_to`].
429    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        // Description changes are opt-in and must be justified. Anything less —
461        // a bare new string, or a change with an empty `why` — and the original
462        // stands verbatim.
463        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            // Preserve every other metadata key (e.g. `harness.kind`) by
484            // updating in place rather than rebuilding.
485            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
506/// The single revision prompt.
507fn 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        // A pre-existing, unrelated metadata key that must survive revision.
560        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    // ---- the happy path ---------------------------------------------------
615
616    #[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        // Ledger bumped, and unrelated metadata preserved.
636        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    // ---- caps -------------------------------------------------------------
720
721    /// The K+1th revision in a day is refused, and costs no model call.
722    #[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, // == default max_revisions_per_day
729            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    /// …and the same skill is revisable again once the date rolls over.
747    #[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, // == default max_total_revisions
771            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    /// Walk a skill from fresh to refused with the caps set low, proving the
786    /// counters actually accumulate across separate revisions.
787    #[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    // ---- eligibility ------------------------------------------------------
822
823    #[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    // ---- degenerate model replies ----------------------------------------
859
860    #[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}