Skip to main content

harness_loop_engine/
patterns.rs

1//! The production-loop catalogue.
2//!
3//! Loop engineering names seven recurring loops that teams actually run.
4//! Each is just a [`LoopSpec`] constructor — sensible defaults for level,
5//! cadence, budget, and the maker/checker prompts — that you then bind to a
6//! model, tools, sandbox, and gate via [`crate::LoopEngine`], or hand to the
7//! [`crate::LoopScheduler`] to run on its cadence.
8//!
9//! | Pattern             | Default cadence | Level | Action     |
10//! |---------------------|-----------------|-------|------------|
11//! | `daily_triage`      | daily 09:00     | L1    | report     |
12//! | `pr_babysitter`     | every 10m       | L1    | comment    |
13//! | `ci_sweeper`        | every 10m       | L2    | apply-patch|
14//! | `dependency_sweeper`| daily 04:00     | L2    | open-pr    |
15//! | `changelog_drafter` | daily 18:00     | L1    | draft      |
16//! | `post_merge_cleanup`| every 6h        | L1    | report     |
17//! | `issue_triage`      | every 2h        | L1    | comment    |
18//!
19//! Defaults are deliberately conservative — start a loop where the table
20//! says and graduate its level as you build trust.
21
22use crate::budget::TokenBudget;
23use crate::level::LoopLevel;
24use crate::spec::LoopSpec;
25
26/// **Daily Triage** — scan the project on a daily cadence and surface what
27/// needs a human's attention. Report-only; the cheapest loop to start with.
28pub fn daily_triage() -> LoopSpec {
29    LoopSpec::new(
30        "daily-triage",
31        "Surface anything in the project that needs human attention today.",
32        LoopLevel::L1Report,
33    )
34    .with_cadence("daily 09:00")
35    .with_budget(TokenBudget::iters(10))
36    .with_action_kind("report")
37    .with_maker_prompt(
38        "Review recent activity (open issues, failing checks, stale branches, \
39         TODOs) and list the few items that most need attention today, with a \
40         one-line reason each. Be terse.",
41    )
42    .with_checker_prompt(
43        "Confirm each surfaced item is real and not already resolved. Drop \
44         anything stale.",
45    )
46}
47
48/// **PR Babysitter** — watch open pull requests and report when one needs a
49/// nudge (failing CI, requested changes, merge conflicts, gone quiet).
50/// High cadence, so keep the budget tight.
51pub fn pr_babysitter() -> LoopSpec {
52    LoopSpec::new(
53        "pr-babysitter",
54        "Keep open PRs moving; flag any that are stuck.",
55        LoopLevel::L1Report,
56    )
57    .with_cadence("every 10m")
58    .with_budget(TokenBudget::iters(8))
59    .with_action_kind("comment")
60    .with_maker_prompt(
61        "For each open PR, determine if it is blocked (red CI, conflicts, \
62         unanswered review, idle > 24h). List only the blocked ones and the \
63         single next action each needs.",
64    )
65    .with_checker_prompt("Verify each flagged PR is genuinely blocked right now.")
66}
67
68/// **CI Sweeper** — when CI is red, investigate and propose a fix.
69/// Assisted: the maker may patch inside a sandbox, but a human gates the
70/// change. Cautious and potentially expensive — budget accordingly.
71pub fn ci_sweeper() -> LoopSpec {
72    LoopSpec::new(
73        "ci-sweeper",
74        "Keep the default branch green by proposing fixes for CI failures.",
75        LoopLevel::L2Assisted,
76    )
77    .with_cadence("every 10m")
78    .with_budget(TokenBudget::iters(20).with_max_total_tokens(400_000))
79    .with_action_kind("apply-patch")
80    .with_maker_prompt(
81        "If CI is failing, reproduce the failure, find the root cause, and \
82         make the smallest change that fixes it. If you cannot fix it \
83         confidently, explain what you found and stop.",
84    )
85    .with_checker_prompt(
86        "Run the build and the failing tests. Confirm they now pass and that \
87         nothing else regressed. Report DoneWithConcerns if the fix looks \
88         risky or broad.",
89    )
90}
91
92/// **Dependency Sweeper** — find safe dependency updates and open a PR for
93/// them. Assisted, patch-only, low cadence.
94pub fn dependency_sweeper() -> LoopSpec {
95    LoopSpec::new(
96        "dependency-sweeper",
97        "Keep dependencies current via small, verified update PRs.",
98        LoopLevel::L2Assisted,
99    )
100    .with_cadence("daily 04:00")
101    .with_budget(TokenBudget::iters(16).with_max_total_tokens(300_000))
102    .with_action_kind("open-pr")
103    .with_maker_prompt(
104        "Identify outdated dependencies with low-risk updates (patch/minor). \
105         Update them and adjust any code the update requires. One coherent \
106         batch only.",
107    )
108    .with_checker_prompt(
109        "Build and test against the updated dependencies. Confirm green. Flag \
110         any major-version or behaviour-changing update for human review.",
111    )
112}
113
114/// **Changelog Drafter** — draft release notes from recent merges.
115/// Report-only; runs in the evening or on tag.
116pub fn changelog_drafter() -> LoopSpec {
117    LoopSpec::new(
118        "changelog-drafter",
119        "Draft accurate, readable changelog entries from recent changes.",
120        LoopLevel::L1Report,
121    )
122    .with_cadence("daily 18:00")
123    .with_budget(TokenBudget::iters(10))
124    .with_action_kind("draft")
125    .with_maker_prompt(
126        "Summarize changes merged since the last release into changelog \
127         entries grouped by Added / Changed / Fixed. User-facing language.",
128    )
129    .with_checker_prompt(
130        "Confirm each entry maps to a real change and nothing significant is \
131         missing.",
132    )
133}
134
135/// **Post-Merge Cleanup** — after merges, look for follow-ups: dead code,
136/// stale branches, leftover TODOs. Report-only, off-peak.
137pub fn post_merge_cleanup() -> LoopSpec {
138    LoopSpec::new(
139        "post-merge-cleanup",
140        "Catch loose ends left behind by recent merges.",
141        LoopLevel::L1Report,
142    )
143    .with_cadence("every 6h")
144    .with_budget(TokenBudget::iters(10))
145    .with_action_kind("report")
146    .with_maker_prompt(
147        "Look for cleanup opportunities from recently merged work: merged \
148         branches not deleted, newly dead code, TODOs introduced, docs that \
149         drifted. List concrete items.",
150    )
151    .with_checker_prompt("Confirm each cleanup item is still applicable.")
152}
153
154/// **Issue Triage** — label and route new issues, propose-only.
155pub fn issue_triage() -> LoopSpec {
156    LoopSpec::new(
157        "issue-triage",
158        "Label, prioritize, and route incoming issues consistently.",
159        LoopLevel::L1Report,
160    )
161    .with_cadence("every 2h")
162    .with_budget(TokenBudget::iters(8))
163    .with_action_kind("comment")
164    .with_maker_prompt(
165        "For each new, untriaged issue, propose labels, a priority, and an \
166         owner/area, with a one-line justification. Propose only.",
167    )
168    .with_checker_prompt(
169        "Sanity-check the proposed triage against the issue text; flag any \
170         that need a human.",
171    )
172}
173
174/// Every built-in pattern, in catalogue order. Handy for listing or for
175/// registering a whole suite with the scheduler.
176pub fn catalogue() -> Vec<LoopSpec> {
177    vec![
178        daily_triage(),
179        pr_babysitter(),
180        ci_sweeper(),
181        dependency_sweeper(),
182        changelog_drafter(),
183        post_merge_cleanup(),
184        issue_triage(),
185    ]
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn catalogue_has_seven_uniquely_named_loops() {
194        let cat = catalogue();
195        assert_eq!(cat.len(), 7);
196        let mut names: Vec<_> = cat.iter().map(|s| s.name.clone()).collect();
197        names.sort();
198        names.dedup();
199        assert_eq!(names.len(), 7, "loop names must be unique");
200    }
201
202    #[test]
203    fn sweepers_are_assisted_everything_else_reports() {
204        assert_eq!(ci_sweeper().level, LoopLevel::L2Assisted);
205        assert_eq!(dependency_sweeper().level, LoopLevel::L2Assisted);
206        assert_eq!(daily_triage().level, LoopLevel::L1Report);
207        assert_eq!(issue_triage().level, LoopLevel::L1Report);
208    }
209
210    #[test]
211    fn every_pattern_cadence_parses() {
212        for spec in catalogue() {
213            assert!(
214                harness_daemon::Schedule::parse(&spec.cadence).is_ok(),
215                "cadence `{}` for `{}` must parse",
216                spec.cadence,
217                spec.name
218            );
219        }
220    }
221}