1use crate::config::{CoreConfig, EventMetadata};
9use ralph_proto::Hat;
10use std::collections::HashMap;
11
12#[derive(Debug)]
17pub struct InstructionBuilder {
18 core: CoreConfig,
19 events: HashMap<String, EventMetadata>,
21}
22
23impl InstructionBuilder {
24 #[allow(unused_variables)]
26 pub fn new(completion_promise: impl Into<String>, core: CoreConfig) -> Self {
27 Self {
28 core,
29 events: HashMap::new(),
30 }
31 }
32
33 #[allow(unused_variables)]
35 pub fn with_events(
36 completion_promise: impl Into<String>,
37 core: CoreConfig,
38 events: HashMap<String, EventMetadata>,
39 ) -> Self {
40 Self { core, events }
41 }
42
43 fn derive_instructions_from_contract(&self, hat: &Hat) -> String {
52 let mut behaviors: Vec<String> = Vec::new();
53
54 for trigger in &hat.subscriptions {
56 let trigger_str = trigger.as_str();
57
58 if let Some(meta) = self.events.get(trigger_str)
60 && !meta.on_trigger.is_empty()
61 {
62 behaviors.push(format!("**On `{}`:** {}", trigger_str, meta.on_trigger));
63 continue;
64 }
65
66 let default_behavior = match trigger_str {
68 "task.start" | "task.resume" => {
69 Some("Analyze the task and create a plan in the scratchpad.")
70 }
71 "build.done" => Some("Review the completed work and decide next steps."),
72 "build.blocked" => Some(
73 "Analyze the blocker and decide how to unblock (simplify task, gather info, or escalate).",
74 ),
75 "build.task" => Some(
76 "Implement the assigned task. Follow existing patterns. Run backpressure (tests/checks). Commit when done.",
77 ),
78 "review.request" => Some(
79 "Review the recent changes for correctness, tests, patterns, errors, and security.",
80 ),
81 "review.approved" => Some("Mark the task complete `[x]` and proceed to next task."),
82 "review.changes_requested" => Some("Add fix tasks to scratchpad and dispatch."),
83 _ => None,
84 };
85
86 if let Some(behavior) = default_behavior {
87 behaviors.push(format!("**On `{}`:** {}", trigger_str, behavior));
88 }
89 }
90
91 for publish in &hat.publishes {
93 let publish_str = publish.as_str();
94
95 if let Some(meta) = self.events.get(publish_str)
97 && !meta.on_publish.is_empty()
98 {
99 behaviors.push(format!(
100 "**Publish `{}`:** {}",
101 publish_str, meta.on_publish
102 ));
103 continue;
104 }
105
106 let default_behavior = match publish_str {
108 "build.task" => Some("Dispatch ONE AT A TIME for pending `[ ]` tasks."),
109 "build.done" => Some("When implementation is finished and tests pass."),
110 "build.blocked" => Some("When stuck - include what you tried and why it failed."),
111 "review.request" => Some("After build completion, before marking done."),
112 "review.approved" => Some("If changes look good and meet requirements."),
113 "review.changes_requested" => Some("If issues found - include specific feedback."),
114 _ => None,
115 };
116
117 if let Some(behavior) = default_behavior {
118 behaviors.push(format!("**Publish `{}`:** {}", publish_str, behavior));
119 }
120 }
121
122 if !hat.publishes.is_empty() {
124 let topics: Vec<&str> = hat.publishes.iter().map(|t| t.as_str()).collect();
125 behaviors.push(format!(
126 "**IMPORTANT:** Every iteration MUST publish one of: `{}` or the loop will terminate.",
127 topics.join("`, `")
128 ));
129 }
130
131 if behaviors.is_empty() {
132 "Follow the incoming event instructions.".to_string()
133 } else {
134 format!("### Derived Behaviors\n\n{}", behaviors.join("\n\n"))
135 }
136 }
137
138 pub fn build_custom_hat(&self, hat: &Hat, events_context: &str) -> String {
143 let guardrails = self
144 .core
145 .guardrails
146 .iter()
147 .enumerate()
148 .map(|(i, g)| format!("{}. {g}", 999 + i))
149 .collect::<Vec<_>>()
150 .join("\n");
151
152 let role_instructions = if hat.instructions.is_empty() {
153 self.derive_instructions_from_contract(hat)
154 } else {
155 hat.instructions.clone()
156 };
157
158 let (publish_topics, must_publish) = if hat.publishes.is_empty() {
159 (String::new(), String::new())
160 } else {
161 let topics: Vec<&str> = hat.publishes.iter().map(|t| t.as_str()).collect();
162 let topics_list = topics.join(", ");
163 let topics_backticked = format!("`{}`", topics.join("`, `"));
164
165 (
166 format!("You publish to: {}", topics_list),
167 format!(
168 "\n\n**You MUST publish one of these events:** {}\nFailure to publish will terminate the loop.",
169 topics_backticked
170 ),
171 )
172 };
173
174 format!(
175 r"You are {name}. Fresh context each iteration.
176
177### 0. ORIENTATION
178Study the incoming event context.
179Don't assume work isn't done—verify first.
180
181### 1. EXECUTE
182{role_instructions}
183Only 1 subagent for build/tests.
184
185### 2. REPORT
186Publish result event with evidence.
187{publish_topics}{must_publish}
188
189### GUARDRAILS
190{guardrails}
191
192---
193INCOMING:
194{events}",
195 name = hat.name,
196 role_instructions = role_instructions,
197 publish_topics = publish_topics,
198 must_publish = must_publish,
199 guardrails = guardrails,
200 events = events_context,
201 )
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn default_builder(promise: &str) -> InstructionBuilder {
210 InstructionBuilder::new(promise, CoreConfig::default())
211 }
212
213 #[test]
214 fn test_custom_hat_with_ghuntley_patterns() {
215 let builder = default_builder("DONE");
216 let hat = Hat::new("reviewer", "Code Reviewer")
217 .with_instructions("Review PRs for quality and correctness.");
218
219 let instructions = builder.build_custom_hat(&hat, "PR #123 ready for review");
220
221 assert!(instructions.contains("Code Reviewer"));
223 assert!(instructions.contains("Fresh context each iteration"));
224
225 assert!(instructions.contains("### 0. ORIENTATION"));
227 assert!(instructions.contains("Study the incoming event context"));
228 assert!(instructions.contains("Don't assume work isn't done"));
229
230 assert!(instructions.contains("### 1. EXECUTE"));
232 assert!(instructions.contains("Review PRs for quality"));
233 assert!(instructions.contains("Only 1 subagent for build/tests"));
234
235 assert!(instructions.contains("### 2. REPORT"));
237
238 assert!(instructions.contains("### GUARDRAILS"));
240 assert!(instructions.contains("999."));
241
242 assert!(instructions.contains("PR #123 ready for review"));
244 }
245
246 #[test]
247 fn test_custom_guardrails_injected() {
248 let custom_core = CoreConfig {
249 scratchpad: ".workspace/plan.md".to_string(),
250 specs_dir: "./specifications/".to_string(),
251 guardrails: vec!["Custom rule one".to_string(), "Custom rule two".to_string()],
252 };
253 let builder = InstructionBuilder::new("DONE", custom_core);
254
255 let hat = Hat::new("worker", "Worker").with_instructions("Do the work.");
256 let instructions = builder.build_custom_hat(&hat, "context");
257
258 assert!(instructions.contains("999. Custom rule one"));
260 assert!(instructions.contains("1000. Custom rule two"));
261 }
262
263 #[test]
264 fn test_must_publish_injected_for_explicit_instructions() {
265 use ralph_proto::Topic;
266
267 let builder = default_builder("DONE");
268 let hat = Hat::new("reviewer", "Code Reviewer")
269 .with_instructions("Review PRs for quality and correctness.")
270 .with_publishes(vec![
271 Topic::new("review.approved"),
272 Topic::new("review.changes_requested"),
273 ]);
274
275 let instructions = builder.build_custom_hat(&hat, "PR #123 ready");
276
277 assert!(
279 instructions.contains("You MUST publish one of these events"),
280 "Must-publish rule should be injected for custom hats with publishes"
281 );
282 assert!(instructions.contains("`review.approved`"));
283 assert!(instructions.contains("`review.changes_requested`"));
284 assert!(instructions.contains("Failure to publish will terminate the loop"));
285 }
286
287 #[test]
288 fn test_must_publish_not_injected_when_no_publishes() {
289 let builder = default_builder("DONE");
290 let hat = Hat::new("observer", "Silent Observer")
291 .with_instructions("Observe and log, but do not emit events.");
292
293 let instructions = builder.build_custom_hat(&hat, "Observe this");
294
295 assert!(
297 !instructions.contains("You MUST publish"),
298 "Must-publish rule should NOT be injected when hat has no publishes"
299 );
300 }
301
302 #[test]
303 fn test_derived_behaviors_when_no_explicit_instructions() {
304 use ralph_proto::Topic;
305
306 let builder = default_builder("DONE");
307 let hat = Hat::new("builder", "Builder")
308 .subscribe("build.task")
309 .with_publishes(vec![Topic::new("build.done"), Topic::new("build.blocked")]);
310
311 let instructions = builder.build_custom_hat(&hat, "Implement feature X");
312
313 assert!(instructions.contains("Derived Behaviors"));
315 assert!(instructions.contains("build.task"));
316 }
317}