Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ keywords: ralphify changelog, release history, new features, version updates, br

All notable changes to ralphify are documented here.

## 0.2.5 — 2026-03-22

### Added

- **Context placeholders** — ralphs can now access runtime metadata via `{{ context.name }}` (ralph directory name), `{{ context.iteration }}` (current iteration, 1-based), and `{{ context.max_iterations }}` (total iterations if `-n` was set, empty otherwise). No frontmatter configuration needed.

---

## 0.2.4 — 2026-03-22

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion docs/contributing/codebase-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ src/ralphify/ # All source code
├── cli.py # CLI commands (run, new, init) — delegates to engine for the loop
├── engine.py # Core run loop orchestration with structured event emission
├── manager.py # Multi-run orchestration (concurrent runs via threads)
├── _resolver.py # Template placeholder resolution ({{ commands.* }}, {{ args.* }})
├── _resolver.py # Template placeholder resolution ({{ commands.* }}, {{ args.* }}, {{ context.* }})
├── _agent.py # Run agent subprocesses (streaming + blocking modes, log writing)
├── _run_types.py # RunConfig, RunState, RunStatus, Command — shared data types
├── _runner.py # Execute shell commands with timeout and capture output
Expand Down
13 changes: 12 additions & 1 deletion docs/quick-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,24 @@ Your instructions here. Use {{ args.dir }} for user arguments.
- `--` ends flag parsing: `ralph run my-ralph -- --verbose ./src` treats `--verbose` as a positional value
- Missing args resolve to empty string

### Context placeholders

```markdown
{{ context.name }} # Ralph directory name (e.g. "my-ralph")
{{ context.iteration }} # Current iteration number (1-based)
{{ context.max_iterations }} # Total iterations if -n was set, empty otherwise
```

- Automatically available — no frontmatter configuration needed
- Useful for progress tracking, naming logs, or adjusting behavior near the end of a run

## The loop

Each iteration:

1. Re-read `RALPH.md` from disk
2. Run all commands in order, capture output
3. Resolve `{{ commands.* }}` and `{{ args.* }}` placeholders
3. Resolve `{{ commands.* }}`, `{{ args.* }}`, and `{{ context.* }}` placeholders
4. Pipe assembled prompt to agent via stdin
5. Wait for agent to exit
6. Repeat
Expand Down
1 change: 1 addition & 0 deletions docs/writing-prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ Rules of thumb:
- **Commands:** Pick the 2-3 most useful signals. Don't add commands whose output the agent doesn't need.
- **Command output:** Can be long. If your commands produce verbose output, consider using scripts that filter to the relevant lines.
- **User args:** Use `{{ args.name }}` to make ralphs reusable — pass project-specific values from the CLI instead of hardcoding them in the prompt. Args also work in command `run` strings (e.g., `run: gh issue view {{ args.issue }}`).
- **Context placeholders:** Use `{{ context.name }}`, `{{ context.iteration }}`, and `{{ context.max_iterations }}` to access runtime metadata — the ralph's directory name, which iteration this is, and the total number if `--n` was set.
- **Working directory:** Commands run from the project root by default. Commands starting with `./` run from the ralph directory — handy for bundling helper scripts next to your `RALPH.md`.

## Next steps
Expand Down
1 change: 1 addition & 0 deletions src/ralphify/_frontmatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
FIELD_COMMANDS = "commands"
FIELD_ARGS = "args"
FIELD_CREDIT = "credit"
FIELD_CONTEXT = "context"

# Sub-field names within each command mapping.
CMD_FIELD_NAME = "name"
Expand Down
14 changes: 8 additions & 6 deletions src/ralphify/_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import re

from ralphify._frontmatter import CMD_NAME_RE, FIELD_ARGS, FIELD_COMMANDS
from ralphify._frontmatter import CMD_NAME_RE, FIELD_ARGS, FIELD_COMMANDS, FIELD_CONTEXT


# Pattern matching ``{{ args.<name> }}`` placeholders — used by resolve_args
Expand All @@ -35,26 +35,28 @@ def _replace(match: re.Match) -> str:
return _ARGS_PATTERN.sub(_replace, prompt)


# Single pattern matching both placeholder kinds for single-pass resolution.
# Single pattern matching all placeholder kinds for single-pass resolution.
_ALL_PATTERN = re.compile(
rf"\{{\{{\s*({FIELD_COMMANDS}|{FIELD_ARGS})\.({CMD_NAME_RE.pattern})\s*\}}\}}"
rf"\{{\{{\s*({FIELD_COMMANDS}|{FIELD_ARGS}|{FIELD_CONTEXT})\.({CMD_NAME_RE.pattern})\s*\}}\}}"
)


def resolve_all(
prompt: str,
command_outputs: dict[str, str],
user_args: dict[str, str],
context: dict[str, str] | None = None,
) -> str:
"""Resolve all placeholders in a single pass to prevent cross-contamination.

Resolves both ``{{ commands.name }}`` and ``{{ args.name }}`` in a
single pass so values inserted by one kind of placeholder are not
re-processed as the other kind.
Resolves ``{{ commands.name }}``, ``{{ args.name }}``, and
``{{ context.name }}`` in a single pass so values inserted by one
kind of placeholder are not re-processed as the other kind.
"""
lookups: dict[str, dict[str, str]] = {
FIELD_COMMANDS: command_outputs,
FIELD_ARGS: user_args,
FIELD_CONTEXT: context or {},
}

def _replace(match: re.Match) -> str:
Expand Down
21 changes: 17 additions & 4 deletions src/ralphify/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,18 +120,31 @@ def _run_commands(
return results


def _build_context(config: RunConfig, state: RunState) -> dict[str, str]:
"""Build the context dict for ``{{ context.X }}`` placeholders."""
ctx: dict[str, str] = {
"name": config.ralph_dir.name,
"iteration": str(state.iteration),
}
if config.max_iterations is not None:
ctx["max_iterations"] = str(config.max_iterations)
return ctx


def _assemble_prompt(
config: RunConfig,
state: RunState,
command_outputs: dict[str, str],
) -> str:
"""Build the full prompt for one iteration.

Reads the RALPH.md body, resolves user args and command output
placeholders.
Reads the RALPH.md body, resolves user args, command output, and
context placeholders.
"""
raw = config.ralph_file.read_text(encoding="utf-8")
_, prompt = parse_frontmatter(raw)
prompt = resolve_all(prompt, command_outputs, config.args)
context = _build_context(config, state)
prompt = resolve_all(prompt, command_outputs, config.args, context)
if config.credit:
prompt += _CREDIT_INSTRUCTION
return prompt
Expand Down Expand Up @@ -227,7 +240,7 @@ def _run_iteration(
))

# Assemble prompt
prompt = _assemble_prompt(config, command_outputs)
prompt = _assemble_prompt(config, state, command_outputs)
emit(
EventType.PROMPT_ASSEMBLED,
PromptAssembledData(iteration=iteration, prompt_length=len(prompt)),
Expand Down
3 changes: 2 additions & 1 deletion src/ralphify/skills/new-ralph/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,10 @@ If any tests are failing above, fix them before continuing.

#### Body

The body is the prompt. It supports two placeholder types:
The body is the prompt. It supports three placeholder types:
- `{{ commands.<name> }}` — replaced with command output each iteration
- `{{ args.<name> }}` — replaced with CLI arguments
- `{{ context.<name> }}` — replaced with runtime metadata (`name`, `iteration`, `max_iterations`)

HTML comments (`<!-- ... -->`) are automatically stripped before the prompt is assembled. They never reach the agent. Use them for notes about why rules exist or TODOs for prompt maintenance.

Expand Down
74 changes: 66 additions & 8 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -796,8 +796,10 @@ class TestAssemblePrompt:

def test_reads_prompt_from_ralph_file(self, tmp_path):
config = make_config(tmp_path, "simple prompt", max_iterations=1, credit=False)
state = make_state()
state.iteration = 1

result = _assemble_prompt(config, {})
result = _assemble_prompt(config, state, {})

assert result == "simple prompt"

Expand All @@ -809,8 +811,10 @@ def test_resolves_command_placeholders(self, tmp_path):
max_iterations=1,
credit=False,
)
state = make_state()
state.iteration = 1

result = _assemble_prompt(config, {"tests": "all passed"})
result = _assemble_prompt(config, state, {"tests": "all passed"})

assert result == "Results: all passed"

Expand All @@ -822,37 +826,47 @@ def test_resolves_args_placeholders(self, tmp_path):
args={"dir": "./src"},
credit=False,
)
state = make_state()
state.iteration = 1

result = _assemble_prompt(config, {})
result = _assemble_prompt(config, state, {})

assert result == "Search ./src"

def test_clears_unresolved_placeholders(self, tmp_path):
config = make_config(tmp_path, "Before {{ args.missing }} after", max_iterations=1, args={}, credit=False)
state = make_state()
state.iteration = 1

result = _assemble_prompt(config, {})
result = _assemble_prompt(config, state, {})

assert result == "Before after"

def test_strips_html_comments(self, tmp_path):
config = make_config(tmp_path, "Before <!-- hidden --> after", max_iterations=1, credit=False)
state = make_state()
state.iteration = 1

result = _assemble_prompt(config, {})
result = _assemble_prompt(config, state, {})

assert result == "Before after"

def test_credit_instruction_appended_by_default(self, tmp_path):
config = make_config(tmp_path, "simple prompt", max_iterations=1)
state = make_state()
state.iteration = 1

result = _assemble_prompt(config, {})
result = _assemble_prompt(config, state, {})

assert result.startswith("simple prompt")
assert "Co-authored-by: Ralphify <noreply@ralphify.co>" in result

def test_credit_false_omits_instruction(self, tmp_path):
config = make_config(tmp_path, "simple prompt", max_iterations=1, credit=False)
state = make_state()
state.iteration = 1

result = _assemble_prompt(config, {})
result = _assemble_prompt(config, state, {})

assert result == "simple prompt"

Expand All @@ -869,12 +883,56 @@ def test_arg_values_not_resolved_as_command_placeholders(self, tmp_path):
commands=[Command(name="tests", run="pytest")],
credit=False,
)
state = make_state()
state.iteration = 1

result = _assemble_prompt(config, {"tests": "5 passed"})
result = _assemble_prompt(config, state, {"tests": "5 passed"})

assert "Filter: {{ commands.tests }}" in result
assert "Tests: 5 passed" in result

def test_resolves_context_placeholders(self, tmp_path):
config = make_config(
tmp_path,
"Name: {{ context.name }}, Iter: {{ context.iteration }}, Max: {{ context.max_iterations }}",
max_iterations=5,
credit=False,
)
state = make_state()
state.iteration = 3

result = _assemble_prompt(config, state, {})

assert result == "Name: my-ralph, Iter: 3, Max: 5"

def test_context_max_iterations_empty_when_unlimited(self, tmp_path):
config = make_config(
tmp_path,
"Max: {{ context.max_iterations }}",
max_iterations=None,
credit=False,
)
state = make_state()
state.iteration = 1

result = _assemble_prompt(config, state, {})

assert result == "Max: "

def test_context_name_is_ralph_dir_name(self, tmp_path):
config = make_config(
tmp_path,
"Name: {{ context.name }}",
max_iterations=1,
credit=False,
)
state = make_state()
state.iteration = 1

result = _assemble_prompt(config, state, {})

assert result == "Name: my-ralph"


class TestCreditInLoop:
@patch(MOCK_SUBPROCESS, side_effect=ok_result)
Expand Down
47 changes: 47 additions & 0 deletions tests/test_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,50 @@ def test_whitespace_tolerant(self):
def test_hyphenated_arg_name(self):
result = resolve_args("{{ args.my-dir }}", {"my-dir": "/tmp"})
assert result == "/tmp"


class TestResolveContext:
"""Tests for {{ context.X }} placeholders passed through resolve_all."""

def test_resolves_context_name(self):
result = resolve_all("Ralph: {{ context.name }}", {}, {}, {"name": "my-ralph"})
assert result == "Ralph: my-ralph"

def test_resolves_context_iteration(self):
result = resolve_all("Iter: {{ context.iteration }}", {}, {}, {"iteration": "3"})
assert result == "Iter: 3"

def test_resolves_context_max_iterations(self):
result = resolve_all(
"Max: {{ context.max_iterations }}", {}, {}, {"max_iterations": "10"},
)
assert result == "Max: 10"

def test_unknown_context_key_resolves_to_empty(self):
result = resolve_all("{{ context.unknown }}", {}, {}, {"name": "test"})
assert result == ""

def test_no_context_clears_placeholders(self):
result = resolve_all("{{ context.name }}", {}, {})
assert result == ""

def test_context_with_commands_and_args(self):
result = resolve_all(
"{{ commands.tests }} {{ args.dir }} {{ context.iteration }}",
{"tests": "ok"}, {"dir": "./src"}, {"iteration": "2"},
)
assert result == "ok ./src 2"

def test_context_value_not_resolved_as_command_placeholder(self):
result = resolve_all(
"Ctx: {{ context.name }}\nCmd: {{ commands.tests }}",
{"tests": "5 passed"},
{},
{"name": "{{ commands.tests }}"},
)
assert "Ctx: {{ commands.tests }}" in result
assert "Cmd: 5 passed" in result

def test_whitespace_tolerant(self):
result = resolve_all("{{ context.name }}", {}, {}, {"name": "test"})
assert result == "test"
Loading