From e0216f579572eafd697fcbea7b244ed2f140c1f9 Mon Sep 17 00:00:00 2001 From: malpou Date: Sun, 22 Mar 2026 08:40:40 +0100 Subject: [PATCH 1/3] feat: add {{ context.X }} placeholders for ralph name, iteration, and max_iterations Ralphs can now access runtime metadata via {{ context.name }}, {{ context.iteration }}, and {{ context.max_iterations }} placeholders. These are resolved alongside existing command and arg placeholders in a single pass to prevent cross-contamination. Closes #14 Co-authored-by: Ralphify --- src/ralphify/_frontmatter.py | 1 + src/ralphify/_resolver.py | 14 ++++--- src/ralphify/engine.py | 21 ++++++++-- tests/test_engine.py | 74 ++++++++++++++++++++++++++++++++---- tests/test_resolver.py | 47 +++++++++++++++++++++++ 5 files changed, 139 insertions(+), 18 deletions(-) diff --git a/src/ralphify/_frontmatter.py b/src/ralphify/_frontmatter.py index 0d4f6f3a..c3ac58ba 100644 --- a/src/ralphify/_frontmatter.py +++ b/src/ralphify/_frontmatter.py @@ -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" diff --git a/src/ralphify/_resolver.py b/src/ralphify/_resolver.py index eac7eb88..116bf668 100644 --- a/src/ralphify/_resolver.py +++ b/src/ralphify/_resolver.py @@ -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. }}`` placeholders — used by resolve_args @@ -35,9 +35,9 @@ 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*\}}\}}" ) @@ -45,16 +45,18 @@ 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: diff --git a/src/ralphify/engine.py b/src/ralphify/engine.py index a3a14495..90240fc0 100644 --- a/src/ralphify/engine.py +++ b/src/ralphify/engine.py @@ -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 @@ -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)), diff --git a/tests/test_engine.py b/tests/test_engine.py index 29e7902a..d3136c81 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -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" @@ -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" @@ -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 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 " 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" @@ -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) diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 0553c931..524409dc 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -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" From b41852cf8155b8dc8536af5b6919e20b08a2c0a9 Mon Sep 17 00:00:00 2001 From: malpou Date: Sun, 22 Mar 2026 08:42:13 +0100 Subject: [PATCH 2/3] docs: document context placeholders for name, iteration, and max_iterations Co-authored-by: Ralphify --- docs/changelog.md | 8 ++++++++ docs/contributing/codebase-map.md | 2 +- docs/quick-reference.md | 13 ++++++++++++- docs/writing-prompts.md | 1 + src/ralphify/skills/new-ralph/SKILL.md | 3 ++- 5 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 59a9bcd8..93f23a09 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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 diff --git a/docs/contributing/codebase-map.md b/docs/contributing/codebase-map.md index 3e8b45c5..c743890a 100644 --- a/docs/contributing/codebase-map.md +++ b/docs/contributing/codebase-map.md @@ -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 diff --git a/docs/quick-reference.md b/docs/quick-reference.md index 817ab554..9b10e700 100644 --- a/docs/quick-reference.md +++ b/docs/quick-reference.md @@ -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 diff --git a/docs/writing-prompts.md b/docs/writing-prompts.md index 642b9fd6..6ba85490 100644 --- a/docs/writing-prompts.md +++ b/docs/writing-prompts.md @@ -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 diff --git a/src/ralphify/skills/new-ralph/SKILL.md b/src/ralphify/skills/new-ralph/SKILL.md index 32e4f0ca..921dcdea 100644 --- a/src/ralphify/skills/new-ralph/SKILL.md +++ b/src/ralphify/skills/new-ralph/SKILL.md @@ -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. }}` — replaced with command output each iteration - `{{ args. }}` — replaced with CLI arguments +- `{{ context. }}` — 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. From 80ff908d52c9ea72a72b5178d52ede727389b8cc Mon Sep 17 00:00:00 2001 From: malpou Date: Sun, 22 Mar 2026 10:13:22 +0100 Subject: [PATCH 3/3] retrigger runs