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}