ralph-core 2.10.0

Core orchestration loop, configuration, and state management for Ralph Orchestrator
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
//! Instruction builder for Ralph agent prompts.
//!
//! Builds ghuntley-style prompts with numbered phases:
//! - 0a, 0b: Orientation (study specs, study context)
//! - 1, 2, 3: Workflow phases
//! - 999+: Guardrails (higher = more important)

use crate::config::{CoreConfig, EventMetadata, ScratchpadConfig};
use ralph_proto::Hat;
use std::collections::HashMap;

const DEFAULT_SCRATCHPAD_GUARDRAIL: &str = "Fresh context each iteration - scratchpad is memory";
const DISABLED_SCRATCHPAD_GUARDRAIL: &str =
    "Fresh context each iteration - runtime tasks and events are memory";

/// Builds instructions for custom hats.
///
/// Uses ghuntley methodology: numbered phases, specific verbs ("study"),
/// subagent limits (parallel for reads, single for builds).
#[derive(Debug)]
pub struct InstructionBuilder {
    core: CoreConfig,
    /// Event metadata for deriving instructions from pub/sub contracts.
    events: HashMap<String, EventMetadata>,
}

impl InstructionBuilder {
    /// Creates a new instruction builder with core configuration.
    pub fn new(core: CoreConfig) -> Self {
        Self {
            core,
            events: HashMap::new(),
        }
    }

    /// Creates a new instruction builder with event metadata for custom hats.
    pub fn with_events(core: CoreConfig, events: HashMap<String, EventMetadata>) -> Self {
        Self { core, events }
    }

    /// Derives instructions from a hat's pub/sub contract and event metadata.
    ///
    /// For each event the hat triggers on or publishes:
    /// 1. Check event metadata for on_trigger/on_publish instructions
    /// 2. Fall back to built-in defaults for well-known events
    ///
    /// This allows users to define custom events with custom behaviors,
    /// while still getting sensible defaults for standard events.
    fn task_planning_behavior(scratchpad: &ScratchpadConfig) -> String {
        if !scratchpad.enabled {
            return "Analyze the task and create a plan.".to_string();
        }

        if scratchpad.path == ScratchpadConfig::default().path {
            "Analyze the task and create a plan in the scratchpad.".to_string()
        } else {
            format!(
                "Analyze the task and create a plan in the scratchpad at `{}`.",
                scratchpad.path
            )
        }
    }

    fn changes_requested_behavior(scratchpad: &ScratchpadConfig) -> String {
        if !scratchpad.enabled {
            return "Add fix tasks with `ralph tools task` and dispatch.".to_string();
        }

        if scratchpad.path == ScratchpadConfig::default().path {
            "Add fix tasks to scratchpad and dispatch.".to_string()
        } else {
            format!(
                "Add fix tasks to the scratchpad at `{}` and dispatch.",
                scratchpad.path
            )
        }
    }

    fn guardrails(&self, scratchpad: &ScratchpadConfig) -> String {
        self.core
            .guardrails
            .iter()
            .enumerate()
            .map(|(i, guardrail)| {
                let guardrail = if !scratchpad.enabled && guardrail == DEFAULT_SCRATCHPAD_GUARDRAIL
                {
                    DISABLED_SCRATCHPAD_GUARDRAIL
                } else {
                    guardrail
                };
                format!("{}. {guardrail}", 999 + i)
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    fn derive_instructions_from_contract(
        &self,
        hat: &Hat,
        scratchpad: &ScratchpadConfig,
    ) -> String {
        let mut behaviors: Vec<String> = Vec::new();

        // Derive behaviors from triggers (what this hat responds to)
        for trigger in &hat.subscriptions {
            let trigger_str = trigger.as_str();

            // First, check event metadata
            if let Some(meta) = self.events.get(trigger_str)
                && !meta.on_trigger.is_empty()
            {
                behaviors.push(format!("**On `{}`:** {}", trigger_str, meta.on_trigger));
                continue;
            }

            // Fall back to built-in defaults for well-known events
            let default_behavior = match trigger_str {
                "task.start" | "task.resume" => Some(Self::task_planning_behavior(scratchpad)),
                "build.done" => {
                    Some("Review the completed work and decide next steps.".to_string())
                }
                "build.blocked" => Some(
                    "Analyze the blocker and decide how to unblock (simplify task, gather info, or escalate)."
                        .to_string(),
                ),
                "build.task" => Some(
                    "Implement the assigned task. Follow existing patterns. Run backpressure (tests/lint/typecheck/audit/coverage/specs; mutation testing when configured). Commit atomically when tests pass."
                        .to_string(),
                ),
                "review.request" => Some(
                    "Review the recent changes for correctness, tests, patterns, errors, and security."
                        .to_string(),
                ),
                "review.approved" => {
                    Some("Mark the task complete `[x]` and proceed to next task.".to_string())
                }
                "review.changes_requested" => Some(Self::changes_requested_behavior(scratchpad)),
                _ => None,
            };

            if let Some(behavior) = default_behavior {
                behaviors.push(format!("**On `{}`:** {}", trigger_str, behavior));
            }
        }

        // Derive behaviors from publishes (what this hat outputs)
        for publish in &hat.publishes {
            let publish_str = publish.as_str();

            // First, check event metadata
            if let Some(meta) = self.events.get(publish_str)
                && !meta.on_publish.is_empty()
            {
                behaviors.push(format!(
                    "**Publish `{}`:** {}",
                    publish_str, meta.on_publish
                ));
                continue;
            }

            // Fall back to built-in defaults for well-known events
            let default_behavior = match publish_str {
                "build.task" => Some("Dispatch ONE AT A TIME for pending `[ ]` tasks.".to_string()),
                "build.done" => Some("When implementation is finished and tests pass.".to_string()),
                "build.blocked" => {
                    Some("When stuck - include what you tried and why it failed.".to_string())
                }
                "review.request" => {
                    Some("After build completion, before marking done.".to_string())
                }
                "review.approved" => {
                    Some("If changes look good and meet requirements.".to_string())
                }
                "review.changes_requested" => {
                    Some("If issues found - include specific feedback.".to_string())
                }
                _ => None,
            };

            if let Some(behavior) = default_behavior {
                behaviors.push(format!("**Publish `{}`:** {}", publish_str, behavior));
            }
        }

        // Add must-publish rule if hat has publishable events
        if !hat.publishes.is_empty() {
            let topics: Vec<&str> = hat.publishes.iter().map(|t| t.as_str()).collect();
            behaviors.push(format!(
                "You MUST publish one of: `{}` every iteration or the loop will terminate.",
                topics.join("`, `")
            ));
        }

        if behaviors.is_empty() {
            "Follow the incoming event instructions.".to_string()
        } else {
            format!("### Derived Behaviors\n\n{}", behaviors.join("\n\n"))
        }
    }

    /// Builds custom hat instructions for extended multi-agent configurations.
    ///
    /// Use this for hats beyond the default Ralph.
    /// When instructions are empty, derives them from the pub/sub contract.
    pub fn build_custom_hat(
        &self,
        hat: &Hat,
        events_context: &str,
        scratchpad: &ScratchpadConfig,
    ) -> String {
        let guardrails = self.guardrails(scratchpad);
        let role_instructions = if hat.instructions.is_empty() {
            self.derive_instructions_from_contract(hat, scratchpad)
        } else {
            hat.instructions.clone()
        };

        let (publish_topics, must_publish) = if hat.publishes.is_empty() {
            (String::new(), String::new())
        } else {
            let topics: Vec<&str> = hat.publishes.iter().map(|t| t.as_str()).collect();
            let topics_list = topics.join(", ");
            let topics_backticked = format!("`{}`", topics.join("`, `"));
            let example_topic = topics.first().copied().unwrap_or("event.name");

            (
                format!("You publish to: {}", topics_list),
                format!(
                    "\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.",
                    topics_backticked, example_topic
                ),
            )
        };

        format!(
            r"You are {name}. You have fresh context each iteration.

### 0. ORIENTATION
You MUST study the incoming event context.
You MUST NOT assume work isn't done — verify first.

### 0b. TOOL DISCIPLINE
Runtime work state lives in `ralph tools task`, not in ad hoc markdown checklists.
You MUST check `<ready-tasks>` before creating more tasks.
If this iteration creates or discovers durable work, you MUST represent it with `ralph tools task ensure`, `start`, `close`, `reopen`, or `fail` as appropriate.
If you are entering an unfamiliar area, you SHOULD search memories with `ralph tools memory search` before acting.
You SHOULD assume the workflow commands are available when the loop is already running and use the task-specific command you actually need.
The loop sets `$RALPH_BIN` to the current Ralph executable. Prefer `$RALPH_BIN emit ...` and `$RALPH_BIN tools ...` when you need a direct command form.
Do not spend iterations on shell or tool-availability diagnosis unless the task is explicitly about the runtime environment.
If 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.
Keep temporary artifacts where later steps can still inspect them, such as a repo-local `logs/` directory or `/var/tmp` when needed.
If a command fails, a dependency is missing, or you become blocked, you MUST record a `fix` memory with `ralph tools memory add`.
If the issue is not resolved in the same iteration, you MUST fail or reopen the relevant runtime task before stopping.
If your confidence is 80 or below on a consequential decision, you MUST document it in `.ralph/agent/decisions.md`.
If this turn is likely to run longer than a few minutes, you SHOULD send a non-blocking progress update with `ralph tools interact progress`.

### 1. EXECUTE
{role_instructions}
You MUST NOT use more than 1 subagent for build/tests.

### 2. VERIFY
You MUST run tests and verify implementation before reporting done.
You MUST NOT report completion without evidence (test output, build success).
You MUST NOT close tasks unless ALL conditions are met:
- Implementation is actually complete (not partially done)
- Tests pass (run them and verify output)
- Build succeeds (if applicable)

### 3. REPORT
You MUST publish a result event with evidence using `ralph emit`.
{publish_topics}{must_publish}

### GUARDRAILS
{guardrails}

---
You MUST handle these events:
{events}",
            name = hat.name,
            role_instructions = role_instructions,
            publish_topics = publish_topics,
            must_publish = must_publish,
            guardrails = guardrails,
            events = events_context,
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::ScratchpadConfig;

    fn default_builder() -> InstructionBuilder {
        InstructionBuilder::new(CoreConfig::default())
    }

    #[test]
    fn test_custom_hat_with_rfc2119_patterns() {
        let builder = default_builder();
        let hat = Hat::new("reviewer", "Code Reviewer")
            .with_instructions("Review PRs for quality and correctness.");

        let instructions = builder.build_custom_hat(
            &hat,
            "PR #123 ready for review",
            &ScratchpadConfig::default(),
        );

        // Custom role with RFC2119 style identity
        assert!(instructions.contains("Code Reviewer"));
        assert!(instructions.contains("You have fresh context each iteration"));

        // Numbered orientation phase with RFC2119
        assert!(instructions.contains("### 0. ORIENTATION"));
        assert!(instructions.contains("You MUST study the incoming event context"));
        assert!(instructions.contains("You MUST NOT assume work isn't done"));

        assert!(instructions.contains("### 0b. TOOL DISCIPLINE"));
        assert!(instructions.contains("You MUST check `<ready-tasks>` before creating more tasks"));
        assert!(
            instructions
                .contains("`ralph tools task ensure`, `start`, `close`, `reopen`, or `fail`")
        );
        assert!(instructions.contains("`ralph tools memory search`"));
        assert!(instructions.contains("`ralph tools memory add`"));
        assert!(instructions.contains(".ralph/agent/decisions.md"));
        assert!(instructions.contains("ralph tools interact progress"));

        // Numbered execute phase with RFC2119
        assert!(instructions.contains("### 1. EXECUTE"));
        assert!(instructions.contains("Review PRs for quality"));
        assert!(instructions.contains("You MUST NOT use more than 1 subagent for build/tests"));

        // Verify phase with RFC2119 (task completion verification)
        assert!(instructions.contains("### 2. VERIFY"));
        assert!(instructions.contains("You MUST run tests and verify implementation"));
        assert!(instructions.contains("You MUST NOT close tasks unless"));

        // Report phase with RFC2119
        assert!(instructions.contains("### 3. REPORT"));
        assert!(
            instructions
                .contains("You MUST publish a result event with evidence using `ralph emit`")
        );

        // Guardrails section with high numbers
        assert!(instructions.contains("### GUARDRAILS"));
        assert!(instructions.contains("999."));

        // Event context is included with RFC2119 directive
        assert!(instructions.contains("You MUST handle these events"));
        assert!(instructions.contains("PR #123 ready for review"));
    }

    #[test]
    fn test_custom_guardrails_injected() {
        let scratchpad = ScratchpadConfig {
            enabled: true,
            path: ".workspace/plan.md".to_string(),
        };
        let custom_core = CoreConfig {
            scratchpad: scratchpad.clone(),
            specs_dir: "./specifications/".to_string(),
            guardrails: vec!["Custom rule one".to_string(), "Custom rule two".to_string()],
            workspace_root: std::path::PathBuf::from("."),
        };
        let builder = InstructionBuilder::new(custom_core);

        let hat = Hat::new("worker", "Worker").with_instructions("Do the work.");
        let instructions = builder.build_custom_hat(&hat, "context", &scratchpad);

        // Custom guardrails are injected with 999+ numbering
        assert!(instructions.contains("999. Custom rule one"));
        assert!(instructions.contains("1000. Custom rule two"));
    }

    #[test]
    fn test_must_publish_injected_for_explicit_instructions() {
        use ralph_proto::Topic;

        let builder = default_builder();
        let hat = Hat::new("reviewer", "Code Reviewer")
            .with_instructions("Review PRs for quality and correctness.")
            .with_publishes(vec![
                Topic::new("review.approved"),
                Topic::new("review.changes_requested"),
            ]);

        let instructions =
            builder.build_custom_hat(&hat, "PR #123 ready", &ScratchpadConfig::default());

        // Must-publish rule should be injected even with explicit instructions (RFC2119)
        assert!(
            instructions.contains("You MUST emit exactly ONE of these events via `ralph emit"),
            "Must-publish rule should be injected for custom hats with publishes"
        );
        assert!(instructions.contains("`review.approved`"));
        assert!(instructions.contains("`review.changes_requested`"));
        assert!(
            instructions.contains("Plain-language summaries do NOT count as event publication")
        );
        assert!(instructions.contains("You MUST stop immediately after emitting"));
        assert!(instructions.contains("You MUST NOT end the iteration without publishing"));
    }

    #[test]
    fn test_must_publish_not_injected_when_no_publishes() {
        let builder = default_builder();
        let hat = Hat::new("observer", "Silent Observer")
            .with_instructions("Observe and log, but do not emit events.");

        let instructions =
            builder.build_custom_hat(&hat, "Observe this", &ScratchpadConfig::default());

        // No must-publish rule when hat has no publishes
        // Note: The prompt says "You MUST publish a result event" in the REPORT section,
        // but the specific emitted-topics list should not appear
        assert!(
            !instructions.contains("You MUST emit exactly ONE of these events via `ralph emit"),
            "Specific must-publish list should NOT be injected when hat has no publishes"
        );
    }

    #[test]
    fn test_derived_behaviors_when_no_explicit_instructions() {
        use ralph_proto::Topic;

        let builder = default_builder();
        let hat = Hat::new("builder", "Builder")
            .subscribe("build.task")
            .with_publishes(vec![Topic::new("build.done"), Topic::new("build.blocked")]);

        let instructions =
            builder.build_custom_hat(&hat, "Implement feature X", &ScratchpadConfig::default());

        // Should derive behaviors from pub/sub contract
        assert!(instructions.contains("Derived Behaviors"));
        assert!(instructions.contains("build.task"));
    }

    #[test]
    fn test_derived_behaviors_keep_default_scratchpad_wording() {
        let builder = default_builder();
        let hat = Hat::new("planner", "Planner")
            .subscribe("task.start")
            .subscribe("review.changes_requested");

        let instructions =
            builder.build_custom_hat(&hat, "Plan the work", &ScratchpadConfig::default());

        assert!(instructions.contains("Analyze the task and create a plan in the scratchpad."));
        assert!(instructions.contains("Add fix tasks to scratchpad and dispatch."));
        assert!(
            !instructions.contains(".ralph/agent/scratchpad.md"),
            "Default scratchpad path should not be newly injected into inherited defaults"
        );
    }

    #[test]
    fn test_derived_behaviors_omit_scratchpad_when_disabled() {
        let builder = default_builder();
        let scratchpad = ScratchpadConfig {
            enabled: false,
            path: ".ralph/agent/scratchpad.md".to_string(),
        };
        let hat = Hat::new("planner", "Planner")
            .subscribe("task.start")
            .subscribe("review.changes_requested");

        let instructions = builder.build_custom_hat(&hat, "Plan the work", &scratchpad);

        assert!(instructions.contains("Analyze the task and create a plan."));
        assert!(instructions.contains("Add fix tasks with `ralph tools task` and dispatch."));
        assert!(
            !instructions.to_ascii_lowercase().contains("scratchpad"),
            "Disabled hat prompts must not instruct agents to use scratchpad"
        );
    }

    #[test]
    fn test_derived_behaviors_name_custom_scratchpad_path() {
        let builder = default_builder();
        let scratchpad = ScratchpadConfig {
            enabled: true,
            path: ".ralph/agent/planner.md".to_string(),
        };
        let hat = Hat::new("planner", "Planner")
            .subscribe("task.start")
            .subscribe("review.changes_requested");

        let instructions = builder.build_custom_hat(&hat, "Plan the work", &scratchpad);

        assert!(instructions.contains(
            "Analyze the task and create a plan in the scratchpad at `.ralph/agent/planner.md`."
        ));
        assert!(instructions.contains(
            "Add fix tasks to the scratchpad at `.ralph/agent/planner.md` and dispatch."
        ));
    }
}