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