diff --git a/docs/changelog.md b/docs/changelog.md index 59a9bcd8..2ff94dd5 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 + +- **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. + +--- + ## 0.2.4 — 2026-03-22 ### Fixed diff --git a/docs/contributing/codebase-map.md b/docs/contributing/codebase-map.md index 3e8b45c5..e4647625 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.* }}, {{ ralph.* }}) ├── _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..9e05ed11 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 +### Ralph placeholders + +```markdown +{{ ralph.name }} # Ralph directory name (e.g. "my-ralph") +{{ ralph.iteration }} # Current iteration number (1-based) +{{ ralph.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 `{{ ralph.* }}` 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..dbdac72e 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 }}`). +- **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. - **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/_frontmatter.py b/src/ralphify/_frontmatter.py index 0d4f6f3a..7a4afb9a 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_RALPH = "ralph" # 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..179f4ec3 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_RALPH # 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_RALPH})\.({CMD_NAME_RE.pattern})\s*\}}\}}" ) @@ -45,16 +45,18 @@ def resolve_all( prompt: str, command_outputs: dict[str, str], user_args: dict[str, str], + ralph_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 + ``{{ ralph.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_RALPH: ralph_context or {}, } def _replace(match: re.Match) -> str: diff --git a/src/ralphify/engine.py b/src/ralphify/engine.py index 897e6fc2..c861c56e 100644 --- a/src/ralphify/engine.py +++ b/src/ralphify/engine.py @@ -120,18 +120,31 @@ def _run_commands( return results +def _build_ralph_context(config: RunConfig, state: RunState) -> dict[str, str]: + """Build the context dict for ``{{ ralph.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) + ralph_context = _build_ralph_context(config, state) + prompt = resolve_all(prompt, command_outputs, config.args, ralph_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/src/ralphify/skills/new-ralph/SKILL.md b/src/ralphify/skills/new-ralph/SKILL.md index 32e4f0ca..96718cc9 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 +- `{{ ralph. }}` — 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. diff --git a/tests/test_engine.py b/tests/test_engine.py index 29e7902a..1db12e2a 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_ralph_placeholders(self, tmp_path): + config = make_config( + tmp_path, + "Name: {{ ralph.name }}, Iter: {{ ralph.iteration }}, Max: {{ ralph.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_ralph_max_iterations_empty_when_unlimited(self, tmp_path): + config = make_config( + tmp_path, + "Max: {{ ralph.max_iterations }}", + max_iterations=None, + credit=False, + ) + state = make_state() + state.iteration = 1 + + result = _assemble_prompt(config, state, {}) + + assert result == "Max: " + + def test_ralph_name_is_ralph_dir_name(self, tmp_path): + config = make_config( + tmp_path, + "Name: {{ ralph.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..9f14bf4e 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 TestResolveRalphContext: + """Tests for {{ ralph.X }} placeholders passed through resolve_all.""" + + def test_resolves_ralph_name(self): + result = resolve_all("Ralph: {{ ralph.name }}", {}, {}, {"name": "my-ralph"}) + assert result == "Ralph: my-ralph" + + def test_resolves_ralph_iteration(self): + result = resolve_all("Iter: {{ ralph.iteration }}", {}, {}, {"iteration": "3"}) + assert result == "Iter: 3" + + def test_resolves_ralph_max_iterations(self): + result = resolve_all( + "Max: {{ ralph.max_iterations }}", {}, {}, {"max_iterations": "10"}, + ) + assert result == "Max: 10" + + def test_unknown_ralph_key_resolves_to_empty(self): + result = resolve_all("{{ ralph.unknown }}", {}, {}, {"name": "test"}) + assert result == "" + + def test_no_ralph_context_clears_placeholders(self): + result = resolve_all("{{ ralph.name }}", {}, {}) + assert result == "" + + def test_ralph_with_commands_and_args(self): + result = resolve_all( + "{{ commands.tests }} {{ args.dir }} {{ ralph.iteration }}", + {"tests": "ok"}, {"dir": "./src"}, {"iteration": "2"}, + ) + assert result == "ok ./src 2" + + def test_ralph_value_not_resolved_as_command_placeholder(self): + result = resolve_all( + "Ctx: {{ ralph.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("{{ ralph.name }}", {}, {}, {"name": "test"}) + assert result == "test"