Skip to content

Commit d3e8698

Browse files
authored
Merge pull request #37 from computerlovetech/ralph-placeholders
feat: add ralph.* placeholders for name, iteration, and max_iterations
2 parents b5bd1ab + d191e2d commit d3e8698

10 files changed

Lines changed: 163 additions & 21 deletions

File tree

docs/changelog.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ keywords: ralphify changelog, release history, new features, version updates, br
88

99
All notable changes to ralphify are documented here.
1010

11+
## 0.2.5 — 2026-03-22
12+
13+
### Added
14+
15+
- **Ralph placeholders** — ralphs can now access runtime metadata via `{{ ralph.name }}` (ralph directory name), `{{ ralph.iteration }}` (current iteration, 1-based), and `{{ ralph.max_iterations }}` (total iterations if `-n` was set, empty otherwise). No frontmatter configuration needed.
16+
17+
---
18+
1119
## 0.2.4 — 2026-03-22
1220

1321
### Fixed

docs/contributing/codebase-map.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ src/ralphify/ # All source code
2222
├── cli.py # CLI commands (run, new, init) — delegates to engine for the loop
2323
├── engine.py # Core run loop orchestration with structured event emission
2424
├── manager.py # Multi-run orchestration (concurrent runs via threads)
25-
├── _resolver.py # Template placeholder resolution ({{ commands.* }}, {{ args.* }})
25+
├── _resolver.py # Template placeholder resolution ({{ commands.* }}, {{ args.* }}, {{ ralph.* }})
2626
├── _agent.py # Run agent subprocesses (streaming + blocking modes, log writing)
2727
├── _run_types.py # RunConfig, RunState, RunStatus, Command — shared data types
2828
├── _runner.py # Execute shell commands with timeout and capture output

docs/quick-reference.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,24 @@ Your instructions here. Use {{ args.dir }} for user arguments.
107107
- `--` ends flag parsing: `ralph run my-ralph -- --verbose ./src` treats `--verbose` as a positional value
108108
- Missing args resolve to empty string
109109

110+
### Ralph placeholders
111+
112+
```markdown
113+
{{ ralph.name }} # Ralph directory name (e.g. "my-ralph")
114+
{{ ralph.iteration }} # Current iteration number (1-based)
115+
{{ ralph.max_iterations }} # Total iterations if -n was set, empty otherwise
116+
```
117+
118+
- Automatically available — no frontmatter configuration needed
119+
- Useful for progress tracking, naming logs, or adjusting behavior near the end of a run
120+
110121
## The loop
111122

112123
Each iteration:
113124

114125
1. Re-read `RALPH.md` from disk
115126
2. Run all commands in order, capture output
116-
3. Resolve `{{ commands.* }}` and `{{ args.* }}` placeholders
127+
3. Resolve `{{ commands.* }}`, `{{ args.* }}`, and `{{ ralph.* }}` placeholders
117128
4. Pipe assembled prompt to agent via stdin
118129
5. Wait for agent to exit
119130
6. Repeat

docs/writing-prompts.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ Rules of thumb:
341341
- **Commands:** Pick the 2-3 most useful signals. Don't add commands whose output the agent doesn't need.
342342
- **Command output:** Can be long. If your commands produce verbose output, consider using scripts that filter to the relevant lines.
343343
- **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 }}`).
344+
- **Ralph placeholders:** Use `{{ ralph.name }}`, `{{ ralph.iteration }}`, and `{{ ralph.max_iterations }}` to access runtime metadata — the ralph's directory name, which iteration this is, and the total number if `--n` was set.
344345
- **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`.
345346

346347
## Next steps

src/ralphify/_frontmatter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
FIELD_COMMANDS = "commands"
2626
FIELD_ARGS = "args"
2727
FIELD_CREDIT = "credit"
28+
FIELD_RALPH = "ralph"
2829

2930
# Sub-field names within each command mapping.
3031
CMD_FIELD_NAME = "name"

src/ralphify/_resolver.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import re
1414

15-
from ralphify._frontmatter import CMD_NAME_RE, FIELD_ARGS, FIELD_COMMANDS
15+
from ralphify._frontmatter import CMD_NAME_RE, FIELD_ARGS, FIELD_COMMANDS, FIELD_RALPH
1616

1717

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

3737

38-
# Single pattern matching both placeholder kinds for single-pass resolution.
38+
# Single pattern matching all placeholder kinds for single-pass resolution.
3939
_ALL_PATTERN = re.compile(
40-
rf"\{{\{{\s*({FIELD_COMMANDS}|{FIELD_ARGS})\.({CMD_NAME_RE.pattern})\s*\}}\}}"
40+
rf"\{{\{{\s*({FIELD_COMMANDS}|{FIELD_ARGS}|{FIELD_RALPH})\.({CMD_NAME_RE.pattern})\s*\}}\}}"
4141
)
4242

4343

4444
def resolve_all(
4545
prompt: str,
4646
command_outputs: dict[str, str],
4747
user_args: dict[str, str],
48+
ralph_context: dict[str, str] | None = None,
4849
) -> str:
4950
"""Resolve all placeholders in a single pass to prevent cross-contamination.
5051
51-
Resolves both ``{{ commands.name }}`` and ``{{ args.name }}`` in a
52-
single pass so values inserted by one kind of placeholder are not
53-
re-processed as the other kind.
52+
Resolves ``{{ commands.name }}``, ``{{ args.name }}``, and
53+
``{{ ralph.name }}`` in a single pass so values inserted by one
54+
kind of placeholder are not re-processed as the other kind.
5455
"""
5556
lookups: dict[str, dict[str, str]] = {
5657
FIELD_COMMANDS: command_outputs,
5758
FIELD_ARGS: user_args,
59+
FIELD_RALPH: ralph_context or {},
5860
}
5961

6062
def _replace(match: re.Match) -> str:

src/ralphify/engine.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,18 +120,31 @@ def _run_commands(
120120
return results
121121

122122

123+
def _build_ralph_context(config: RunConfig, state: RunState) -> dict[str, str]:
124+
"""Build the context dict for ``{{ ralph.X }}`` placeholders."""
125+
ctx: dict[str, str] = {
126+
"name": config.ralph_dir.name,
127+
"iteration": str(state.iteration),
128+
}
129+
if config.max_iterations is not None:
130+
ctx["max_iterations"] = str(config.max_iterations)
131+
return ctx
132+
133+
123134
def _assemble_prompt(
124135
config: RunConfig,
136+
state: RunState,
125137
command_outputs: dict[str, str],
126138
) -> str:
127139
"""Build the full prompt for one iteration.
128140
129-
Reads the RALPH.md body, resolves user args and command output
130-
placeholders.
141+
Reads the RALPH.md body, resolves user args, command output, and
142+
context placeholders.
131143
"""
132144
raw = config.ralph_file.read_text(encoding="utf-8")
133145
_, prompt = parse_frontmatter(raw)
134-
prompt = resolve_all(prompt, command_outputs, config.args)
146+
ralph_context = _build_ralph_context(config, state)
147+
prompt = resolve_all(prompt, command_outputs, config.args, ralph_context)
135148
if config.credit:
136149
prompt += _CREDIT_INSTRUCTION
137150
return prompt
@@ -227,7 +240,7 @@ def _run_iteration(
227240
))
228241

229242
# Assemble prompt
230-
prompt = _assemble_prompt(config, command_outputs)
243+
prompt = _assemble_prompt(config, state, command_outputs)
231244
emit(
232245
EventType.PROMPT_ASSEMBLED,
233246
PromptAssembledData(iteration=iteration, prompt_length=len(prompt)),

src/ralphify/skills/new-ralph/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,10 @@ If any tests are failing above, fix them before continuing.
8686

8787
#### Body
8888

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

9394
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.
9495

tests/test_engine.py

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -796,8 +796,10 @@ class TestAssemblePrompt:
796796

797797
def test_reads_prompt_from_ralph_file(self, tmp_path):
798798
config = make_config(tmp_path, "simple prompt", max_iterations=1, credit=False)
799+
state = make_state()
800+
state.iteration = 1
799801

800-
result = _assemble_prompt(config, {})
802+
result = _assemble_prompt(config, state, {})
801803

802804
assert result == "simple prompt"
803805

@@ -809,8 +811,10 @@ def test_resolves_command_placeholders(self, tmp_path):
809811
max_iterations=1,
810812
credit=False,
811813
)
814+
state = make_state()
815+
state.iteration = 1
812816

813-
result = _assemble_prompt(config, {"tests": "all passed"})
817+
result = _assemble_prompt(config, state, {"tests": "all passed"})
814818

815819
assert result == "Results: all passed"
816820

@@ -822,37 +826,47 @@ def test_resolves_args_placeholders(self, tmp_path):
822826
args={"dir": "./src"},
823827
credit=False,
824828
)
829+
state = make_state()
830+
state.iteration = 1
825831

826-
result = _assemble_prompt(config, {})
832+
result = _assemble_prompt(config, state, {})
827833

828834
assert result == "Search ./src"
829835

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

833-
result = _assemble_prompt(config, {})
841+
result = _assemble_prompt(config, state, {})
834842

835843
assert result == "Before after"
836844

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

840-
result = _assemble_prompt(config, {})
850+
result = _assemble_prompt(config, state, {})
841851

842852
assert result == "Before after"
843853

844854
def test_credit_instruction_appended_by_default(self, tmp_path):
845855
config = make_config(tmp_path, "simple prompt", max_iterations=1)
856+
state = make_state()
857+
state.iteration = 1
846858

847-
result = _assemble_prompt(config, {})
859+
result = _assemble_prompt(config, state, {})
848860

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

852864
def test_credit_false_omits_instruction(self, tmp_path):
853865
config = make_config(tmp_path, "simple prompt", max_iterations=1, credit=False)
866+
state = make_state()
867+
state.iteration = 1
854868

855-
result = _assemble_prompt(config, {})
869+
result = _assemble_prompt(config, state, {})
856870

857871
assert result == "simple prompt"
858872

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

873-
result = _assemble_prompt(config, {"tests": "5 passed"})
889+
result = _assemble_prompt(config, state, {"tests": "5 passed"})
874890

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

894+
def test_resolves_ralph_placeholders(self, tmp_path):
895+
config = make_config(
896+
tmp_path,
897+
"Name: {{ ralph.name }}, Iter: {{ ralph.iteration }}, Max: {{ ralph.max_iterations }}",
898+
max_iterations=5,
899+
credit=False,
900+
)
901+
state = make_state()
902+
state.iteration = 3
903+
904+
result = _assemble_prompt(config, state, {})
905+
906+
assert result == "Name: my-ralph, Iter: 3, Max: 5"
907+
908+
def test_ralph_max_iterations_empty_when_unlimited(self, tmp_path):
909+
config = make_config(
910+
tmp_path,
911+
"Max: {{ ralph.max_iterations }}",
912+
max_iterations=None,
913+
credit=False,
914+
)
915+
state = make_state()
916+
state.iteration = 1
917+
918+
result = _assemble_prompt(config, state, {})
919+
920+
assert result == "Max: "
921+
922+
def test_ralph_name_is_ralph_dir_name(self, tmp_path):
923+
config = make_config(
924+
tmp_path,
925+
"Name: {{ ralph.name }}",
926+
max_iterations=1,
927+
credit=False,
928+
)
929+
state = make_state()
930+
state.iteration = 1
931+
932+
result = _assemble_prompt(config, state, {})
933+
934+
assert result == "Name: my-ralph"
935+
878936

879937
class TestCreditInLoop:
880938
@patch(MOCK_SUBPROCESS, side_effect=ok_result)

tests/test_resolver.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,50 @@ def test_whitespace_tolerant(self):
152152
def test_hyphenated_arg_name(self):
153153
result = resolve_args("{{ args.my-dir }}", {"my-dir": "/tmp"})
154154
assert result == "/tmp"
155+
156+
157+
class TestResolveRalphContext:
158+
"""Tests for {{ ralph.X }} placeholders passed through resolve_all."""
159+
160+
def test_resolves_ralph_name(self):
161+
result = resolve_all("Ralph: {{ ralph.name }}", {}, {}, {"name": "my-ralph"})
162+
assert result == "Ralph: my-ralph"
163+
164+
def test_resolves_ralph_iteration(self):
165+
result = resolve_all("Iter: {{ ralph.iteration }}", {}, {}, {"iteration": "3"})
166+
assert result == "Iter: 3"
167+
168+
def test_resolves_ralph_max_iterations(self):
169+
result = resolve_all(
170+
"Max: {{ ralph.max_iterations }}", {}, {}, {"max_iterations": "10"},
171+
)
172+
assert result == "Max: 10"
173+
174+
def test_unknown_ralph_key_resolves_to_empty(self):
175+
result = resolve_all("{{ ralph.unknown }}", {}, {}, {"name": "test"})
176+
assert result == ""
177+
178+
def test_no_ralph_context_clears_placeholders(self):
179+
result = resolve_all("{{ ralph.name }}", {}, {})
180+
assert result == ""
181+
182+
def test_ralph_with_commands_and_args(self):
183+
result = resolve_all(
184+
"{{ commands.tests }} {{ args.dir }} {{ ralph.iteration }}",
185+
{"tests": "ok"}, {"dir": "./src"}, {"iteration": "2"},
186+
)
187+
assert result == "ok ./src 2"
188+
189+
def test_ralph_value_not_resolved_as_command_placeholder(self):
190+
result = resolve_all(
191+
"Ctx: {{ ralph.name }}\nCmd: {{ commands.tests }}",
192+
{"tests": "5 passed"},
193+
{},
194+
{"name": "{{ commands.tests }}"},
195+
)
196+
assert "Ctx: {{ commands.tests }}" in result
197+
assert "Cmd: 5 passed" in result
198+
199+
def test_whitespace_tolerant(self):
200+
result = resolve_all("{{ ralph.name }}", {}, {}, {"name": "test"})
201+
assert result == "test"

0 commit comments

Comments
 (0)