Skip to content

Commit 3a1da48

Browse files
Kasper Jungeclaude
authored andcommitted
feat: require named context placeholders, remove bulk {{ contexts }} and implicit append
Forces explicit placement of each context with {{ contexts.name }}. Contexts not referenced by a named placeholder are excluded from the prompt, preventing accidental data dumps. Closes #8 (comment) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1c0b938 commit 3a1da48

12 files changed

Lines changed: 55 additions & 164 deletions

File tree

docs/contributing/codebase-map.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,7 @@ Discovery is handled by `_discovery.py:discover_primitives()` which scans `.ralp
8686
Contexts use the resolver (`resolver.py:resolve_placeholders()`):
8787

8888
- `{{ contexts.git-log }}` — named placement for a specific context
89-
- `{{ contexts }}` — bulk placement for all remaining contexts
90-
- No placeholders at all — everything appended to the end of the prompt
89+
- Contexts not referenced by name are excluded from the prompt
9190

9291
### Event system
9392

docs/getting-started.md

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -172,13 +172,7 @@ By default, context output is appended to the end of the prompt. You can control
172172
You are an autonomous coding agent running in a loop...
173173
```
174174

175-
Or use `{{ contexts }}` to place all contexts at once:
176-
177-
```markdown
178-
{{ contexts }}
179-
180-
You are an autonomous coding agent...
181-
```
175+
Each context must be referenced by name — contexts not referenced are excluded from the prompt.
182176

183177
## Step 8: Verify and run
184178

docs/primitives.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,19 +110,17 @@ Context output is injected **regardless of the command's exit code**. Commands l
110110

111111
### Placement in the prompt
112112

113-
By default, all context output is appended to the end of the prompt. Control placement with placeholders in your `RALPH.md`:
113+
Use named placeholders in your `RALPH.md` to place specific contexts:
114114

115115
```markdown
116116
{{ contexts.git-log }}
117117

118118
Work on the next task.
119119

120-
{{ contexts }}
120+
{{ contexts.test-status }}
121121
```
122122

123-
- `{{ contexts.git-log }}` — places that specific context here
124-
- `{{ contexts }}` — places all remaining contexts (those not already placed by name)
125-
- If no placeholders are found, all context output is appended to the end
123+
Each context must be referenced by name with `{{ contexts.name }}`. Contexts not referenced by a placeholder are excluded from the prompt.
126124

127125
## Ralphs
128126

@@ -146,7 +144,7 @@ You are a documentation agent. Each iteration starts fresh.
146144

147145
Read the codebase and existing docs. Find the biggest gap and improve one page per iteration.
148146

149-
{{ contexts }}
147+
{{ contexts.git-log }}
150148
```
151149

152150
### Running a named ralph

docs/troubleshooting.md

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -195,15 +195,7 @@ Run the context command manually to verify it produces output. Note that context
195195

196196
### Some contexts are missing from the prompt
197197

198-
If you use a **named placeholder** like `{{ contexts.git-log }}` but don't include a bulk `{{ contexts }}` placeholder, any other contexts are silently excluded. Add `{{ contexts }}` somewhere in your prompt to catch all remaining contexts:
199-
200-
```markdown
201-
{{ contexts.git-log }}
202-
203-
Do the work.
204-
205-
{{ contexts }}
206-
```
198+
Each context must be referenced by a named placeholder like `{{ contexts.git-log }}` to appear in the prompt. Contexts without a placeholder are excluded. Make sure every context you want included has a corresponding `{{ contexts.name }}` in your `RALPH.md`.
207199

208200
See [Placement in the prompt](primitives.md#placement-in-the-prompt) for full details.
209201

src/ralphify/contexts.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,9 @@ def resolve_contexts(prompt: str, results: list[ContextResult]) -> str:
144144
Callers are responsible for passing only the results they want
145145
resolved (the engine pre-filters via ``_discover_enabled_primitives``).
146146
147+
Only named placeholders are supported:
147148
- {{ contexts.<name> }} → specific context content
148-
- {{ contexts }} → all contexts not already placed
149-
- If no placeholders found → append all at end
149+
- Contexts not referenced by name are excluded
150150
"""
151151
available: dict[str, str] = {}
152152
for r in results:

src/ralphify/ralphs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class Ralph:
2222
2323
The *content* is the body text below the YAML frontmatter — this is the
2424
full prompt that gets piped to the agent. Context placeholders
25-
(``{{ contexts }}``) resolve the same way as in a root ``RALPH.md``.
25+
(``{{ contexts.name }}``) resolve the same way as in a root ``RALPH.md``.
2626
"""
2727

2828
name: str

src/ralphify/resolver.py

Lines changed: 10 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Template placeholder resolution for contexts.
22
3-
Handles three placement strategies:
3+
Only named placeholders are supported:
44
5-
1. **Named** — ``{{ kind.name }}`` inserts a specific primitive's content.
6-
2. **Bulk** — ``{{ kind }}`` inserts all remaining primitives (alphabetically).
7-
3. **Implicit** — when no placeholders exist, all content is appended to the end.
5+
``{{ kind.name }}`` inserts a specific primitive's content.
6+
7+
Contexts not referenced by a named placeholder are excluded from the
8+
prompt. This forces explicit placement and avoids accidental data dumps.
89
"""
910

1011
import re
@@ -15,42 +16,23 @@ def resolve_placeholders(
1516
available: dict[str, str],
1617
kind: str,
1718
) -> str:
18-
"""Replace template placeholders in a prompt string.
19+
"""Replace named template placeholders in a prompt string.
1920
2021
*kind* is the placeholder category (e.g. "contexts").
2122
22-
- Named placeholders (e.g. {{ kind.name }}) -> specific content
23-
- Bulk placeholder (e.g. {{ kind }}) -> all not already placed
24-
- No placeholders found -> append all at end
23+
- ``{{ kind.name }}`` → replaced with the matching content
24+
- Unknown names → replaced with empty string
25+
- Unreferenced items are silently excluded
2526
"""
2627
if not available:
2728
return prompt
2829

2930
named_pattern = re.compile(r"\{\{\s*" + re.escape(kind) + r"\.([a-zA-Z0-9_-]+)\s*\}\}")
30-
bulk_pattern = re.compile(r"\{\{\s*" + re.escape(kind) + r"\s*\}\}")
31-
32-
has_named = bool(named_pattern.search(prompt))
33-
placed: set[str] = set()
3431

3532
def _replace_named(match: re.Match) -> str:
3633
name = match.group(1)
3734
if name in available:
38-
placed.add(name)
3935
return available[name]
4036
return ""
4137

42-
result = named_pattern.sub(_replace_named, prompt)
43-
44-
remaining = [content for name, content in sorted(available.items()) if name not in placed]
45-
bulk_text = "\n\n".join(remaining)
46-
47-
if bulk_pattern.search(result):
48-
# Use a function replacement to prevent re.sub from interpreting
49-
# backslash sequences (e.g. \1, \d) in user-provided content.
50-
result = bulk_pattern.sub(lambda _: bulk_text, result)
51-
elif not has_named:
52-
# No placeholders found at all -> append
53-
if bulk_text:
54-
result = result + "\n\n" + bulk_text
55-
56-
return result
38+
return named_pattern.sub(_replace_named, prompt)

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ enabled: true
6060

6161
- Body text appears as a label above the command output
6262
- Contexts run regardless of command exit code
63-
- Place contexts in the prompt with `{{ contexts.context-name }}` or `{{ contexts }}` for all remaining
64-
- If no placeholders, all context output is appended to the end
63+
- Place contexts in the prompt with `{{ contexts.context-name }}` — each context must be referenced by name
6564

6665
### Scripts
6766

@@ -91,7 +90,7 @@ All primitive output is truncated to 5000 characters.
9190
- What it is and what it's doing
9291
- That each iteration starts fresh (progress lives in code and git)
9392
- Specific rules and constraints
94-
- Where to place context output (use `{{ contexts }}` or named placeholders)
93+
- Where to place context output (use named placeholders like `{{ contexts.name }}`)
9594
- Follow these prompt patterns:
9695
- Start with role and loop awareness: "You are an autonomous X agent running in a loop."
9796
- Include "Each iteration starts with a fresh context. Your progress lives in the code and git."

tests/test_cli.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -772,7 +772,7 @@ class TestRunContexts:
772772
def test_contexts_injected_into_prompt(self, mock_agent, mock_run_contexts, tmp_path, monkeypatch):
773773
monkeypatch.chdir(tmp_path)
774774
(tmp_path / CONFIG_FILENAME).write_text(RALPH_TOML_TEMPLATE)
775-
(tmp_path / "RALPH.md").write_text("Base.\n\n{{ contexts }}")
775+
(tmp_path / "RALPH.md").write_text("Base.\n\n{{ contexts.git-log }}")
776776
_setup_context(tmp_path, "git-log", "git log --oneline -5")
777777

778778
ctx = Context(name="git-log", path=Path("/fake"), command="git log", enabled=True)
@@ -784,7 +784,7 @@ def test_contexts_injected_into_prompt(self, mock_agent, mock_run_contexts, tmp_
784784
assert result.exit_code == 0
785785
prompt_sent = mock_agent.call_args.kwargs["input"]
786786
assert "abc123 fix bug" in prompt_sent
787-
assert "{{ contexts }}" not in prompt_sent
787+
assert "{{ contexts.git-log }}" not in prompt_sent
788788

789789

790790
@patch("ralphify.engine.run_all_contexts")
@@ -813,7 +813,7 @@ def test_disabled_contexts_not_run(self, mock_agent, mock_run_contexts, tmp_path
813813
def test_contexts_run_each_iteration(self, mock_agent, mock_run_contexts, tmp_path, monkeypatch):
814814
monkeypatch.chdir(tmp_path)
815815
(tmp_path / CONFIG_FILENAME).write_text(RALPH_TOML_TEMPLATE)
816-
(tmp_path / "RALPH.md").write_text("{{ contexts }}")
816+
(tmp_path / "RALPH.md").write_text("{{ contexts.info }}")
817817
_setup_context(tmp_path, "info", "echo hi")
818818

819819
ctx = Context(name="info", path=Path("/fake"), command="echo hi", enabled=True)

tests/test_contexts.py

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -387,10 +387,10 @@ def test_no_results_returns_prompt_unchanged(self):
387387
prompt = "Do the thing."
388388
assert resolve_contexts(prompt, []) == prompt
389389

390-
def test_no_placeholders_appends_at_end(self):
390+
def test_no_placeholders_leaves_prompt_unchanged(self):
391391
results = self._make_results(("git-log", "abc123 fix bug\n"))
392392
result = resolve_contexts("Base prompt.", results)
393-
assert result == "Base prompt.\n\nabc123 fix bug"
393+
assert result == "Base prompt."
394394

395395
def test_named_placeholder_replaced(self):
396396
results = self._make_results(("git-log", "abc123 fix\n"))
@@ -399,26 +399,15 @@ def test_named_placeholder_replaced(self):
399399
assert "abc123 fix" in result
400400
assert "{{ contexts.git-log }}" not in result
401401

402-
def test_bulk_placeholder_injects_all(self):
402+
def test_unreferenced_contexts_excluded(self):
403403
results = self._make_results(
404404
("alpha", "Alpha output\n"),
405405
("beta", "Beta output\n"),
406406
)
407-
prompt = "Start.\n\n{{ contexts }}\n\nEnd."
407+
prompt = "Only alpha: {{ contexts.alpha }}"
408408
result = resolve_contexts(prompt, results)
409409
assert "Alpha output" in result
410-
assert "Beta output" in result
411-
assert "{{ contexts }}" not in result
412-
413-
def test_named_excludes_from_bulk(self):
414-
results = self._make_results(
415-
("alpha", "Alpha output\n"),
416-
("beta", "Beta output\n"),
417-
)
418-
prompt = "{{ contexts.alpha }}\n\n{{ contexts }}"
419-
result = resolve_contexts(prompt, results)
420-
assert result.count("Alpha output") == 1
421-
assert "Beta output" in result
410+
assert "Beta output" not in result
422411

423412
def test_multiple_named_placeholders(self):
424413
results = self._make_results(
@@ -451,7 +440,7 @@ def test_static_content_included(self):
451440
static_content="Header:",
452441
)
453442
results = [ContextResult(context=ctx, output="dynamic\n", success=True)]
454-
prompt = "{{ contexts }}"
443+
prompt = "{{ contexts.info }}"
455444
result = resolve_contexts(prompt, results)
456445
assert "Header:" in result
457446
assert "dynamic" in result

0 commit comments

Comments
 (0)