Skip to main content

harness_loop_engine/
spec.rs

1//! `LoopSpec` — the declarative description of a loop.
2//!
3//! A spec is pure data: it says *what* the loop is, at what maturity, on
4//! what cadence, under what budget, and what the maker and checker should
5//! each try to do. It carries no models, tools, or `Arc`s — those are bound
6//! when you build a [`crate::LoopEngine`] from the spec. Keeping the spec
7//! inert means it can be cloned, serialized, diffed, and unit-tested on its
8//! own, and that the production [`crate::patterns`] are just constructors
9//! returning one of these.
10
11use crate::budget::TokenBudget;
12use crate::level::LoopLevel;
13use serde::{Deserialize, Serialize};
14
15/// Declarative definition of a single loop.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct LoopSpec {
18    /// Stable identifier — used in logs, memory keys, and scheduler jobs.
19    pub name: String,
20
21    /// **Intent.** One sentence stating what this loop is *supposed* to do.
22    ///
23    /// This is the antidote to *intent debt* — the drift between what a loop
24    /// was meant to do and what it actually does. Writing it down, surfacing
25    /// it in every report, and reviewing it keeps the gap visible. It is
26    /// also injected into the maker's task so the agent shares the framing.
27    pub intent: String,
28
29    /// Maturity level — governs write-capability and gate policy.
30    pub level: LoopLevel,
31
32    /// Cadence string parsed by `harness_daemon::Schedule`
33    /// (e.g. `"every 15m"`, `"daily 08:00"`, `"weekly mon 09:30"`).
34    pub cadence: String,
35
36    /// Spend ceiling enforced per round.
37    pub budget: TokenBudget,
38
39    /// What the maker sub-agent is asked to do this round (triage +
40    /// implement, or — at L1 — just investigate and report).
41    pub maker_prompt: String,
42
43    /// What the checker sub-agent is asked to verify (run tests, check
44    /// gates, look for regressions). The maker/checker split is loop
45    /// engineering's verification discipline made structural.
46    pub checker_prompt: String,
47
48    /// The `kind` of the action this loop proposes when its work verifies,
49    /// e.g. `"open-pr"`, `"commit"`, `"comment"`, `"report"`. The gate
50    /// matches its allowlist against this.
51    pub action_kind: String,
52}
53
54impl LoopSpec {
55    /// Minimal constructor; fill the rest with the builder methods.
56    pub fn new(name: impl Into<String>, intent: impl Into<String>, level: LoopLevel) -> Self {
57        Self {
58            name: name.into(),
59            intent: intent.into(),
60            level,
61            cadence: "daily 09:00".into(),
62            budget: TokenBudget::default(),
63            maker_prompt: String::new(),
64            checker_prompt: String::new(),
65            action_kind: "report".into(),
66        }
67    }
68
69    pub fn with_cadence(mut self, c: impl Into<String>) -> Self {
70        self.cadence = c.into();
71        self
72    }
73    pub fn with_budget(mut self, b: TokenBudget) -> Self {
74        self.budget = b;
75        self
76    }
77    pub fn with_maker_prompt(mut self, p: impl Into<String>) -> Self {
78        self.maker_prompt = p.into();
79        self
80    }
81    pub fn with_checker_prompt(mut self, p: impl Into<String>) -> Self {
82        self.checker_prompt = p.into();
83        self
84    }
85    pub fn with_action_kind(mut self, k: impl Into<String>) -> Self {
86        self.action_kind = k.into();
87        self
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn builder_roundtrips_through_json() {
97        let spec = LoopSpec::new(
98            "issue-triage",
99            "label and route new issues",
100            LoopLevel::L1Report,
101        )
102        .with_cadence("every 2h")
103        .with_action_kind("comment");
104        let json = serde_json::to_string(&spec).unwrap();
105        let back: LoopSpec = serde_json::from_str(&json).unwrap();
106        assert_eq!(back.name, "issue-triage");
107        assert_eq!(back.level, LoopLevel::L1Report);
108        assert_eq!(back.cadence, "every 2h");
109        assert_eq!(back.action_kind, "comment");
110    }
111}