1use crate::config::{CoreConfig, EventMetadata, ScratchpadConfig};
9use ralph_proto::Hat;
10use std::collections::HashMap;
11
12const DEFAULT_SCRATCHPAD_GUARDRAIL: &str = "Fresh context each iteration - scratchpad is memory";
13const DISABLED_SCRATCHPAD_GUARDRAIL: &str =
14 "Fresh context each iteration - runtime tasks and events are memory";
15
16#[derive(Debug)]
21pub struct InstructionBuilder {
22 core: CoreConfig,
23 events: HashMap<String, EventMetadata>,
25}
26
27impl InstructionBuilder {
28 pub fn new(core: CoreConfig) -> Self {
30 Self {
31 core,
32 events: HashMap::new(),
33 }
34 }
35
36 pub fn with_events(core: CoreConfig, events: HashMap<String, EventMetadata>) -> Self {
38 Self { core, events }
39 }
40
41 fn task_planning_behavior(scratchpad: &ScratchpadConfig) -> String {
50 if !scratchpad.enabled {
51 return "Analyze the task and create a plan.".to_string();
52 }
53
54 if scratchpad.path == ScratchpadConfig::default().path {
55 "Analyze the task and create a plan in the scratchpad.".to_string()
56 } else {
57 format!(
58 "Analyze the task and create a plan in the scratchpad at `{}`.",
59 scratchpad.path
60 )
61 }
62 }
63
64 fn changes_requested_behavior(scratchpad: &ScratchpadConfig) -> String {
65 if !scratchpad.enabled {
66 return "Add fix tasks with `ralph tools task` and dispatch.".to_string();
67 }
68
69 if scratchpad.path == ScratchpadConfig::default().path {
70 "Add fix tasks to scratchpad and dispatch.".to_string()
71 } else {
72 format!(
73 "Add fix tasks to the scratchpad at `{}` and dispatch.",
74 scratchpad.path
75 )
76 }
77 }
78
79 fn guardrails(&self, scratchpad: &ScratchpadConfig) -> String {
80 self.core
81 .guardrails
82 .iter()
83 .enumerate()
84 .map(|(i, guardrail)| {
85 let guardrail = if !scratchpad.enabled && guardrail == DEFAULT_SCRATCHPAD_GUARDRAIL
86 {
87 DISABLED_SCRATCHPAD_GUARDRAIL
88 } else {
89 guardrail
90 };
91 format!("{}. {guardrail}", 999 + i)
92 })
93 .collect::<Vec<_>>()
94 .join("\n")
95 }
96
97 fn derive_instructions_from_contract(
98 &self,
99 hat: &Hat,
100 scratchpad: &ScratchpadConfig,
101 ) -> String {
102 let mut behaviors: Vec<String> = Vec::new();
103
104 for trigger in &hat.subscriptions {
106 let trigger_str = trigger.as_str();
107
108 if let Some(meta) = self.events.get(trigger_str)
110 && !meta.on_trigger.is_empty()
111 {
112 behaviors.push(format!("**On `{}`:** {}", trigger_str, meta.on_trigger));
113 continue;
114 }
115
116 let default_behavior = match trigger_str {
118 "task.start" | "task.resume" => Some(Self::task_planning_behavior(scratchpad)),
119 "build.done" => {
120 Some("Review the completed work and decide next steps.".to_string())
121 }
122 "build.blocked" => Some(
123 "Analyze the blocker and decide how to unblock (simplify task, gather info, or escalate)."
124 .to_string(),
125 ),
126 "build.task" => Some(
127 "Implement the assigned task. Follow existing patterns. Run backpressure (tests/lint/typecheck/audit/coverage/specs; mutation testing when configured). Commit atomically when tests pass."
128 .to_string(),
129 ),
130 "review.request" => Some(
131 "Review the recent changes for correctness, tests, patterns, errors, and security."
132 .to_string(),
133 ),
134 "review.approved" => {
135 Some("Mark the task complete `[x]` and proceed to next task.".to_string())
136 }
137 "review.changes_requested" => Some(Self::changes_requested_behavior(scratchpad)),
138 _ => None,
139 };
140
141 if let Some(behavior) = default_behavior {
142 behaviors.push(format!("**On `{}`:** {}", trigger_str, behavior));
143 }
144 }
145
146 for publish in &hat.publishes {
148 let publish_str = publish.as_str();
149
150 if let Some(meta) = self.events.get(publish_str)
152 && !meta.on_publish.is_empty()
153 {
154 behaviors.push(format!(
155 "**Publish `{}`:** {}",
156 publish_str, meta.on_publish
157 ));
158 continue;
159 }
160
161 let default_behavior = match publish_str {
163 "build.task" => Some("Dispatch ONE AT A TIME for pending `[ ]` tasks.".to_string()),
164 "build.done" => Some("When implementation is finished and tests pass.".to_string()),
165 "build.blocked" => {
166 Some("When stuck - include what you tried and why it failed.".to_string())
167 }
168 "review.request" => {
169 Some("After build completion, before marking done.".to_string())
170 }
171 "review.approved" => {
172 Some("If changes look good and meet requirements.".to_string())
173 }
174 "review.changes_requested" => {
175 Some("If issues found - include specific feedback.".to_string())
176 }
177 _ => None,
178 };
179
180 if let Some(behavior) = default_behavior {
181 behaviors.push(format!("**Publish `{}`:** {}", publish_str, behavior));
182 }
183 }
184
185 if !hat.publishes.is_empty() {
187 let topics: Vec<&str> = hat.publishes.iter().map(|t| t.as_str()).collect();
188 behaviors.push(format!(
189 "You MUST publish one of: `{}` every iteration or the loop will terminate.",
190 topics.join("`, `")
191 ));
192 }
193
194 if behaviors.is_empty() {
195 "Follow the incoming event instructions.".to_string()
196 } else {
197 format!("### Derived Behaviors\n\n{}", behaviors.join("\n\n"))
198 }
199 }
200
201 pub fn build_custom_hat(
206 &self,
207 hat: &Hat,
208 events_context: &str,
209 scratchpad: &ScratchpadConfig,
210 ) -> String {
211 let guardrails = self.guardrails(scratchpad);
212 let role_instructions = if hat.instructions.is_empty() {
213 self.derive_instructions_from_contract(hat, scratchpad)
214 } else {
215 hat.instructions.clone()
216 };
217
218 let (publish_topics, must_publish) = if hat.publishes.is_empty() {
219 (String::new(), String::new())
220 } else {
221 let topics: Vec<&str> = hat.publishes.iter().map(|t| t.as_str()).collect();
222 let topics_list = topics.join(", ");
223 let topics_backticked = format!("`{}`", topics.join("`, `"));
224 let example_topic = topics.first().copied().unwrap_or("event.name");
225
226 (
227 format!("You publish to: {}", topics_list),
228 format!(
229 "\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.",
230 topics_backticked, example_topic
231 ),
232 )
233 };
234
235 format!(
236 r"You are {name}. You have fresh context each iteration.
237
238### 0. ORIENTATION
239You MUST study the incoming event context.
240You MUST NOT assume work isn't done — verify first.
241
242### 0b. TOOL DISCIPLINE
243Runtime work state lives in `ralph tools task`, not in ad hoc markdown checklists.
244You MUST check `<ready-tasks>` before creating more tasks.
245If this iteration creates or discovers durable work, you MUST represent it with `ralph tools task ensure`, `start`, `close`, `reopen`, or `fail` as appropriate.
246If you are entering an unfamiliar area, you SHOULD search memories with `ralph tools memory search` before acting.
247You SHOULD assume the workflow commands are available when the loop is already running and use the task-specific command you actually need.
248The loop sets `$RALPH_BIN` to the current Ralph executable. Prefer `$RALPH_BIN emit ...` and `$RALPH_BIN tools ...` when you need a direct command form.
249Do not spend iterations on shell or tool-availability diagnosis unless the task is explicitly about the runtime environment.
250If 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.
251Keep temporary artifacts where later steps can still inspect them, such as a repo-local `logs/` directory or `/var/tmp` when needed.
252If a command fails, a dependency is missing, or you become blocked, you MUST record a `fix` memory with `ralph tools memory add`.
253If the issue is not resolved in the same iteration, you MUST fail or reopen the relevant runtime task before stopping.
254If your confidence is 80 or below on a consequential decision, you MUST document it in `.ralph/agent/decisions.md`.
255If this turn is likely to run longer than a few minutes, you SHOULD send a non-blocking progress update with `ralph tools interact progress`.
256
257### 1. EXECUTE
258{role_instructions}
259You MUST NOT use more than 1 subagent for build/tests.
260
261### 2. VERIFY
262You MUST run tests and verify implementation before reporting done.
263You MUST NOT report completion without evidence (test output, build success).
264You MUST NOT close tasks unless ALL conditions are met:
265- Implementation is actually complete (not partially done)
266- Tests pass (run them and verify output)
267- Build succeeds (if applicable)
268
269### 3. REPORT
270You MUST publish a result event with evidence using `ralph emit`.
271{publish_topics}{must_publish}
272
273### GUARDRAILS
274{guardrails}
275
276---
277You MUST handle these events:
278{events}",
279 name = hat.name,
280 role_instructions = role_instructions,
281 publish_topics = publish_topics,
282 must_publish = must_publish,
283 guardrails = guardrails,
284 events = events_context,
285 )
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::config::ScratchpadConfig;
293
294 fn default_builder() -> InstructionBuilder {
295 InstructionBuilder::new(CoreConfig::default())
296 }
297
298 #[test]
299 fn test_custom_hat_with_rfc2119_patterns() {
300 let builder = default_builder();
301 let hat = Hat::new("reviewer", "Code Reviewer")
302 .with_instructions("Review PRs for quality and correctness.");
303
304 let instructions = builder.build_custom_hat(
305 &hat,
306 "PR #123 ready for review",
307 &ScratchpadConfig::default(),
308 );
309
310 assert!(instructions.contains("Code Reviewer"));
312 assert!(instructions.contains("You have fresh context each iteration"));
313
314 assert!(instructions.contains("### 0. ORIENTATION"));
316 assert!(instructions.contains("You MUST study the incoming event context"));
317 assert!(instructions.contains("You MUST NOT assume work isn't done"));
318
319 assert!(instructions.contains("### 0b. TOOL DISCIPLINE"));
320 assert!(instructions.contains("You MUST check `<ready-tasks>` before creating more tasks"));
321 assert!(
322 instructions
323 .contains("`ralph tools task ensure`, `start`, `close`, `reopen`, or `fail`")
324 );
325 assert!(instructions.contains("`ralph tools memory search`"));
326 assert!(instructions.contains("`ralph tools memory add`"));
327 assert!(instructions.contains(".ralph/agent/decisions.md"));
328 assert!(instructions.contains("ralph tools interact progress"));
329
330 assert!(instructions.contains("### 1. EXECUTE"));
332 assert!(instructions.contains("Review PRs for quality"));
333 assert!(instructions.contains("You MUST NOT use more than 1 subagent for build/tests"));
334
335 assert!(instructions.contains("### 2. VERIFY"));
337 assert!(instructions.contains("You MUST run tests and verify implementation"));
338 assert!(instructions.contains("You MUST NOT close tasks unless"));
339
340 assert!(instructions.contains("### 3. REPORT"));
342 assert!(
343 instructions
344 .contains("You MUST publish a result event with evidence using `ralph emit`")
345 );
346
347 assert!(instructions.contains("### GUARDRAILS"));
349 assert!(instructions.contains("999."));
350
351 assert!(instructions.contains("You MUST handle these events"));
353 assert!(instructions.contains("PR #123 ready for review"));
354 }
355
356 #[test]
357 fn test_custom_guardrails_injected() {
358 let scratchpad = ScratchpadConfig {
359 enabled: true,
360 path: ".workspace/plan.md".to_string(),
361 };
362 let custom_core = CoreConfig {
363 scratchpad: scratchpad.clone(),
364 specs_dir: "./specifications/".to_string(),
365 guardrails: vec!["Custom rule one".to_string(), "Custom rule two".to_string()],
366 workspace_root: std::path::PathBuf::from("."),
367 };
368 let builder = InstructionBuilder::new(custom_core);
369
370 let hat = Hat::new("worker", "Worker").with_instructions("Do the work.");
371 let instructions = builder.build_custom_hat(&hat, "context", &scratchpad);
372
373 assert!(instructions.contains("999. Custom rule one"));
375 assert!(instructions.contains("1000. Custom rule two"));
376 }
377
378 #[test]
379 fn test_must_publish_injected_for_explicit_instructions() {
380 use ralph_proto::Topic;
381
382 let builder = default_builder();
383 let hat = Hat::new("reviewer", "Code Reviewer")
384 .with_instructions("Review PRs for quality and correctness.")
385 .with_publishes(vec![
386 Topic::new("review.approved"),
387 Topic::new("review.changes_requested"),
388 ]);
389
390 let instructions =
391 builder.build_custom_hat(&hat, "PR #123 ready", &ScratchpadConfig::default());
392
393 assert!(
395 instructions.contains("You MUST emit exactly ONE of these events via `ralph emit"),
396 "Must-publish rule should be injected for custom hats with publishes"
397 );
398 assert!(instructions.contains("`review.approved`"));
399 assert!(instructions.contains("`review.changes_requested`"));
400 assert!(
401 instructions.contains("Plain-language summaries do NOT count as event publication")
402 );
403 assert!(instructions.contains("You MUST stop immediately after emitting"));
404 assert!(instructions.contains("You MUST NOT end the iteration without publishing"));
405 }
406
407 #[test]
408 fn test_must_publish_not_injected_when_no_publishes() {
409 let builder = default_builder();
410 let hat = Hat::new("observer", "Silent Observer")
411 .with_instructions("Observe and log, but do not emit events.");
412
413 let instructions =
414 builder.build_custom_hat(&hat, "Observe this", &ScratchpadConfig::default());
415
416 assert!(
420 !instructions.contains("You MUST emit exactly ONE of these events via `ralph emit"),
421 "Specific must-publish list should NOT be injected when hat has no publishes"
422 );
423 }
424
425 #[test]
426 fn test_derived_behaviors_when_no_explicit_instructions() {
427 use ralph_proto::Topic;
428
429 let builder = default_builder();
430 let hat = Hat::new("builder", "Builder")
431 .subscribe("build.task")
432 .with_publishes(vec![Topic::new("build.done"), Topic::new("build.blocked")]);
433
434 let instructions =
435 builder.build_custom_hat(&hat, "Implement feature X", &ScratchpadConfig::default());
436
437 assert!(instructions.contains("Derived Behaviors"));
439 assert!(instructions.contains("build.task"));
440 }
441
442 #[test]
443 fn test_derived_behaviors_keep_default_scratchpad_wording() {
444 let builder = default_builder();
445 let hat = Hat::new("planner", "Planner")
446 .subscribe("task.start")
447 .subscribe("review.changes_requested");
448
449 let instructions =
450 builder.build_custom_hat(&hat, "Plan the work", &ScratchpadConfig::default());
451
452 assert!(instructions.contains("Analyze the task and create a plan in the scratchpad."));
453 assert!(instructions.contains("Add fix tasks to scratchpad and dispatch."));
454 assert!(
455 !instructions.contains(".ralph/agent/scratchpad.md"),
456 "Default scratchpad path should not be newly injected into inherited defaults"
457 );
458 }
459
460 #[test]
461 fn test_derived_behaviors_omit_scratchpad_when_disabled() {
462 let builder = default_builder();
463 let scratchpad = ScratchpadConfig {
464 enabled: false,
465 path: ".ralph/agent/scratchpad.md".to_string(),
466 };
467 let hat = Hat::new("planner", "Planner")
468 .subscribe("task.start")
469 .subscribe("review.changes_requested");
470
471 let instructions = builder.build_custom_hat(&hat, "Plan the work", &scratchpad);
472
473 assert!(instructions.contains("Analyze the task and create a plan."));
474 assert!(instructions.contains("Add fix tasks with `ralph tools task` and dispatch."));
475 assert!(
476 !instructions.to_ascii_lowercase().contains("scratchpad"),
477 "Disabled hat prompts must not instruct agents to use scratchpad"
478 );
479 }
480
481 #[test]
482 fn test_derived_behaviors_name_custom_scratchpad_path() {
483 let builder = default_builder();
484 let scratchpad = ScratchpadConfig {
485 enabled: true,
486 path: ".ralph/agent/planner.md".to_string(),
487 };
488 let hat = Hat::new("planner", "Planner")
489 .subscribe("task.start")
490 .subscribe("review.changes_requested");
491
492 let instructions = builder.build_custom_hat(&hat, "Plan the work", &scratchpad);
493
494 assert!(instructions.contains(
495 "Analyze the task and create a plan in the scratchpad at `.ralph/agent/planner.md`."
496 ));
497 assert!(instructions.contains(
498 "Add fix tasks to the scratchpad at `.ralph/agent/planner.md` and dispatch."
499 ));
500 }
501}