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 pub fn new(core: CoreConfig) -> Self {
26 Self {
27 core,
28 events: HashMap::new(),
29 }
30 }
31
32 pub fn with_events(core: CoreConfig, events: HashMap<String, EventMetadata>) -> Self {
34 Self { core, events }
35 }
36
37 fn derive_instructions_from_contract(&self, hat: &Hat) -> String {
46 let mut behaviors: Vec<String> = Vec::new();
47
48 for trigger in &hat.subscriptions {
50 let trigger_str = trigger.as_str();
51
52 if let Some(meta) = self.events.get(trigger_str)
54 && !meta.on_trigger.is_empty()
55 {
56 behaviors.push(format!("**On `{}`:** {}", trigger_str, meta.on_trigger));
57 continue;
58 }
59
60 let default_behavior = match trigger_str {
62 "task.start" | "task.resume" => {
63 Some("Analyze the task and create a plan in the scratchpad.")
64 }
65 "build.done" => Some("Review the completed work and decide next steps."),
66 "build.blocked" => Some(
67 "Analyze the blocker and decide how to unblock (simplify task, gather info, or escalate).",
68 ),
69 "build.task" => Some(
70 "Implement the assigned task. Follow existing patterns. Run backpressure (tests/lint/typecheck/audit/coverage/specs; mutation testing when configured). Commit atomically when tests pass.",
71 ),
72 "review.request" => Some(
73 "Review the recent changes for correctness, tests, patterns, errors, and security.",
74 ),
75 "review.approved" => Some("Mark the task complete `[x]` and proceed to next task."),
76 "review.changes_requested" => Some("Add fix tasks to scratchpad and dispatch."),
77 _ => None,
78 };
79
80 if let Some(behavior) = default_behavior {
81 behaviors.push(format!("**On `{}`:** {}", trigger_str, behavior));
82 }
83 }
84
85 for publish in &hat.publishes {
87 let publish_str = publish.as_str();
88
89 if let Some(meta) = self.events.get(publish_str)
91 && !meta.on_publish.is_empty()
92 {
93 behaviors.push(format!(
94 "**Publish `{}`:** {}",
95 publish_str, meta.on_publish
96 ));
97 continue;
98 }
99
100 let default_behavior = match publish_str {
102 "build.task" => Some("Dispatch ONE AT A TIME for pending `[ ]` tasks."),
103 "build.done" => Some("When implementation is finished and tests pass."),
104 "build.blocked" => Some("When stuck - include what you tried and why it failed."),
105 "review.request" => Some("After build completion, before marking done."),
106 "review.approved" => Some("If changes look good and meet requirements."),
107 "review.changes_requested" => Some("If issues found - include specific feedback."),
108 _ => None,
109 };
110
111 if let Some(behavior) = default_behavior {
112 behaviors.push(format!("**Publish `{}`:** {}", publish_str, behavior));
113 }
114 }
115
116 if !hat.publishes.is_empty() {
118 let topics: Vec<&str> = hat.publishes.iter().map(|t| t.as_str()).collect();
119 behaviors.push(format!(
120 "You MUST publish one of: `{}` every iteration or the loop will terminate.",
121 topics.join("`, `")
122 ));
123 }
124
125 if behaviors.is_empty() {
126 "Follow the incoming event instructions.".to_string()
127 } else {
128 format!("### Derived Behaviors\n\n{}", behaviors.join("\n\n"))
129 }
130 }
131
132 pub fn build_custom_hat(&self, hat: &Hat, events_context: &str) -> String {
137 let guardrails = self
138 .core
139 .guardrails
140 .iter()
141 .enumerate()
142 .map(|(i, g)| format!("{}. {g}", 999 + i))
143 .collect::<Vec<_>>()
144 .join("\n");
145
146 let role_instructions = if hat.instructions.is_empty() {
147 self.derive_instructions_from_contract(hat)
148 } else {
149 hat.instructions.clone()
150 };
151
152 let (publish_topics, must_publish) = if hat.publishes.is_empty() {
153 (String::new(), String::new())
154 } else {
155 let topics: Vec<&str> = hat.publishes.iter().map(|t| t.as_str()).collect();
156 let topics_list = topics.join(", ");
157 let topics_backticked = format!("`{}`", topics.join("`, `"));
158 let example_topic = topics.first().copied().unwrap_or("event.name");
159
160 (
161 format!("You publish to: {}", topics_list),
162 format!(
163 "\n\nYou MUST emit exactly ONE of these events via `ralph emit \"<topic>\" \"<summary>\"`: {}\nUse `ralph emit \"{}\" \"<summary>\"` as the pattern.\nPlain-language summaries do NOT count as event publication.\nYou MUST stop immediately after emitting.\nYou MUST NOT end the iteration without publishing because this will terminate the loop.",
164 topics_backticked, example_topic
165 ),
166 )
167 };
168
169 format!(
170 r"You are {name}. You have fresh context each iteration.
171
172### 0. ORIENTATION
173You MUST study the incoming event context.
174You MUST NOT assume work isn't done — verify first.
175
176### 0b. TOOL DISCIPLINE
177Runtime work state lives in `ralph tools task`, not in ad hoc markdown checklists.
178You MUST check `<ready-tasks>` before creating more tasks.
179If this iteration creates or discovers durable work, you MUST represent it with `ralph tools task ensure`, `start`, `close`, `reopen`, or `fail` as appropriate.
180If you are entering an unfamiliar area, you SHOULD search memories with `ralph tools memory search` before acting.
181You SHOULD assume the workflow commands are available when the loop is already running and use the task-specific command you actually need.
182The loop sets `$RALPH_BIN` to the current Ralph executable. Prefer `$RALPH_BIN emit ...` and `$RALPH_BIN tools ...` when you need a direct command form.
183Do not spend iterations on shell or tool-availability diagnosis unless the task is explicitly about the runtime environment.
184If a command's stdout is empty or terse, verify the intended side effect in the task/event state or in the files and artifacts the command should have changed.
185Keep temporary artifacts where later steps can still inspect them, such as a repo-local `logs/` directory or `/var/tmp` when needed.
186If a command fails, a dependency is missing, or you become blocked, you MUST record a `fix` memory with `ralph tools memory add`.
187If the issue is not resolved in the same iteration, you MUST fail or reopen the relevant runtime task before stopping.
188If your confidence is 80 or below on a consequential decision, you MUST document it in `.ralph/agent/decisions.md`.
189If this turn is likely to run longer than a few minutes, you SHOULD send a non-blocking progress update with `ralph tools interact progress`.
190
191### 1. EXECUTE
192{role_instructions}
193You MUST NOT use more than 1 subagent for build/tests.
194
195### 2. VERIFY
196You MUST run tests and verify implementation before reporting done.
197You MUST NOT report completion without evidence (test output, build success).
198You MUST NOT close tasks unless ALL conditions are met:
199- Implementation is actually complete (not partially done)
200- Tests pass (run them and verify output)
201- Build succeeds (if applicable)
202
203### 3. REPORT
204You MUST publish a result event with evidence using `ralph emit`.
205{publish_topics}{must_publish}
206
207### GUARDRAILS
208{guardrails}
209
210---
211You MUST handle these events:
212{events}",
213 name = hat.name,
214 role_instructions = role_instructions,
215 publish_topics = publish_topics,
216 must_publish = must_publish,
217 guardrails = guardrails,
218 events = events_context,
219 )
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn default_builder() -> InstructionBuilder {
228 InstructionBuilder::new(CoreConfig::default())
229 }
230
231 #[test]
232 fn test_custom_hat_with_rfc2119_patterns() {
233 let builder = default_builder();
234 let hat = Hat::new("reviewer", "Code Reviewer")
235 .with_instructions("Review PRs for quality and correctness.");
236
237 let instructions = builder.build_custom_hat(&hat, "PR #123 ready for review");
238
239 assert!(instructions.contains("Code Reviewer"));
241 assert!(instructions.contains("You have fresh context each iteration"));
242
243 assert!(instructions.contains("### 0. ORIENTATION"));
245 assert!(instructions.contains("You MUST study the incoming event context"));
246 assert!(instructions.contains("You MUST NOT assume work isn't done"));
247
248 assert!(instructions.contains("### 0b. TOOL DISCIPLINE"));
249 assert!(instructions.contains("You MUST check `<ready-tasks>` before creating more tasks"));
250 assert!(
251 instructions
252 .contains("`ralph tools task ensure`, `start`, `close`, `reopen`, or `fail`")
253 );
254 assert!(instructions.contains("`ralph tools memory search`"));
255 assert!(instructions.contains("`ralph tools memory add`"));
256 assert!(instructions.contains(".ralph/agent/decisions.md"));
257 assert!(instructions.contains("ralph tools interact progress"));
258
259 assert!(instructions.contains("### 1. EXECUTE"));
261 assert!(instructions.contains("Review PRs for quality"));
262 assert!(instructions.contains("You MUST NOT use more than 1 subagent for build/tests"));
263
264 assert!(instructions.contains("### 2. VERIFY"));
266 assert!(instructions.contains("You MUST run tests and verify implementation"));
267 assert!(instructions.contains("You MUST NOT close tasks unless"));
268
269 assert!(instructions.contains("### 3. REPORT"));
271 assert!(
272 instructions
273 .contains("You MUST publish a result event with evidence using `ralph emit`")
274 );
275
276 assert!(instructions.contains("### GUARDRAILS"));
278 assert!(instructions.contains("999."));
279
280 assert!(instructions.contains("You MUST handle these events"));
282 assert!(instructions.contains("PR #123 ready for review"));
283 }
284
285 #[test]
286 fn test_custom_guardrails_injected() {
287 let custom_core = CoreConfig {
288 scratchpad: ".workspace/plan.md".to_string(),
289 specs_dir: "./specifications/".to_string(),
290 guardrails: vec!["Custom rule one".to_string(), "Custom rule two".to_string()],
291 workspace_root: std::path::PathBuf::from("."),
292 };
293 let builder = InstructionBuilder::new(custom_core);
294
295 let hat = Hat::new("worker", "Worker").with_instructions("Do the work.");
296 let instructions = builder.build_custom_hat(&hat, "context");
297
298 assert!(instructions.contains("999. Custom rule one"));
300 assert!(instructions.contains("1000. Custom rule two"));
301 }
302
303 #[test]
304 fn test_must_publish_injected_for_explicit_instructions() {
305 use ralph_proto::Topic;
306
307 let builder = default_builder();
308 let hat = Hat::new("reviewer", "Code Reviewer")
309 .with_instructions("Review PRs for quality and correctness.")
310 .with_publishes(vec![
311 Topic::new("review.approved"),
312 Topic::new("review.changes_requested"),
313 ]);
314
315 let instructions = builder.build_custom_hat(&hat, "PR #123 ready");
316
317 assert!(
319 instructions.contains("You MUST emit exactly ONE of these events via `ralph emit"),
320 "Must-publish rule should be injected for custom hats with publishes"
321 );
322 assert!(instructions.contains("`review.approved`"));
323 assert!(instructions.contains("`review.changes_requested`"));
324 assert!(
325 instructions.contains("Plain-language summaries do NOT count as event publication")
326 );
327 assert!(instructions.contains("You MUST stop immediately after emitting"));
328 assert!(instructions.contains("You MUST NOT end the iteration without publishing"));
329 }
330
331 #[test]
332 fn test_must_publish_not_injected_when_no_publishes() {
333 let builder = default_builder();
334 let hat = Hat::new("observer", "Silent Observer")
335 .with_instructions("Observe and log, but do not emit events.");
336
337 let instructions = builder.build_custom_hat(&hat, "Observe this");
338
339 assert!(
343 !instructions.contains("You MUST emit exactly ONE of these events via `ralph emit"),
344 "Specific must-publish list should NOT be injected when hat has no publishes"
345 );
346 }
347
348 #[test]
349 fn test_derived_behaviors_when_no_explicit_instructions() {
350 use ralph_proto::Topic;
351
352 let builder = default_builder();
353 let hat = Hat::new("builder", "Builder")
354 .subscribe("build.task")
355 .with_publishes(vec![Topic::new("build.done"), Topic::new("build.blocked")]);
356
357 let instructions = builder.build_custom_hat(&hat, "Implement feature X");
358
359 assert!(instructions.contains("Derived Behaviors"));
361 assert!(instructions.contains("build.task"));
362 }
363}