Skip to content

Commit 995ce9b

Browse files
Kasper Jungeclaude
authored andcommitted
feat: pass RALPH_NAME env var to context and check scripts
Context and check scripts can now read $RALPH_NAME to dynamically resolve per-ralph state (e.g. context/workspace/ralphs/$RALPH_NAME/). The env var is set when running a named ralph and omitted for ad-hoc prompts. run_command() gains an env parameter that merges with the parent environment so scripts keep PATH etc. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7f1e3b2 commit 995ce9b

8 files changed

Lines changed: 150 additions & 9 deletions

File tree

src/ralphify/_runner.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from __future__ import annotations
99

10+
import os
1011
import shlex
1112
import subprocess
1213
from dataclasses import dataclass
@@ -31,6 +32,7 @@ def run_command(
3132
command: str | None,
3233
cwd: Path,
3334
timeout: int,
35+
env: dict[str, str] | None = None,
3436
) -> RunResult:
3537
"""Run a script or shell command and return the result.
3638
@@ -39,20 +41,27 @@ def run_command(
3941
Pipes, redirections, and ``&&`` chaining are not supported in
4042
commands — use a ``run.*`` script for complex logic.
4143
44+
When *env* is set, the given variables are merged on top of the
45+
current process environment so scripts keep ``PATH`` etc. When
46+
``None`` (default), ``subprocess.run`` inherits the parent env.
47+
4248
On timeout, returns ``exit_code=-1`` and ``timed_out=True``.
4349
"""
4450
if script:
4551
cmd = [str(script)]
4652
else:
4753
cmd = shlex.split(command)
4854

55+
merged_env = {**os.environ, **env} if env else None
56+
4957
try:
5058
result = subprocess.run(
5159
cmd,
5260
capture_output=True,
5361
text=True,
5462
cwd=cwd,
5563
timeout=timeout,
64+
env=merged_env,
5665
)
5766
return RunResult(
5867
success=result.returncode == 0,

src/ralphify/checks.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,18 +118,23 @@ def discover_enabled_checks(root: Path, ralph_dir: Path | None = None) -> list[C
118118
return discover_enabled(root, ralph_dir, discover_checks, discover_checks_local)
119119

120120

121-
def run_check(check: Check, project_root: Path) -> CheckResult:
121+
def run_check(check: Check, project_root: Path, ralph_name: str | None = None) -> CheckResult:
122122
"""Run a single check and return the result.
123123
124124
The check's script or command executes with *project_root* as the
125125
working directory. On timeout, ``exit_code`` is ``-1`` and
126126
``timed_out`` is ``True``.
127+
128+
When *ralph_name* is set, a ``RALPH_NAME`` environment variable is
129+
passed to the subprocess so scripts can read per-ralph state.
127130
"""
131+
env = {"RALPH_NAME": ralph_name} if ralph_name else None
128132
r = run_command(
129133
script=check.script,
130134
command=check.command,
131135
cwd=project_root,
132136
timeout=check.timeout,
137+
env=env,
133138
)
134139
return CheckResult(
135140
check=check,
@@ -140,7 +145,7 @@ def run_check(check: Check, project_root: Path) -> CheckResult:
140145
)
141146

142147

143-
def run_all_checks(checks: list[Check], project_root: Path) -> list[CheckResult]:
148+
def run_all_checks(checks: list[Check], project_root: Path, ralph_name: str | None = None) -> list[CheckResult]:
144149
"""Run every check sequentially and return all results.
145150
146151
Checks execute in the order given (the engine sorts alphabetically by
@@ -150,8 +155,11 @@ def run_all_checks(checks: list[Check], project_root: Path) -> list[CheckResult]
150155
The engine passes the results to :func:`format_check_failures`, which
151156
formats them as markdown for injection into the next iteration's prompt.
152157
This is what drives the self-healing feedback loop.
158+
159+
When *ralph_name* is set, it is forwarded to each check subprocess
160+
as the ``RALPH_NAME`` environment variable.
153161
"""
154-
return [run_check(check, project_root) for check in checks]
162+
return [run_check(check, project_root, ralph_name) for check in checks]
155163

156164

157165
def format_check_failures(results: list[CheckResult]) -> str:

src/ralphify/contexts.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,22 +91,27 @@ def discover_enabled_contexts(root: Path, ralph_dir: Path | None = None) -> list
9191
return discover_enabled(root, ralph_dir, discover_contexts, discover_contexts_local)
9292

9393

94-
def run_context(context: Context, project_root: Path) -> ContextResult:
94+
def run_context(context: Context, project_root: Path, ralph_name: str | None = None) -> ContextResult:
9595
"""Run a single context and return the result.
9696
9797
Static-only contexts (no script or command) return immediately with
9898
``success=True`` and empty output. The static content is combined
9999
with command output later during prompt resolution.
100+
101+
When *ralph_name* is set, a ``RALPH_NAME`` environment variable is
102+
passed to the subprocess so scripts can read per-ralph state.
100103
"""
101104
if not context.script and not context.command:
102105
# Static-only context, no command to run
103106
return ContextResult(context=context, output="", success=True)
104107

108+
env = {"RALPH_NAME": ralph_name} if ralph_name else None
105109
r = run_command(
106110
script=context.script,
107111
command=context.command,
108112
cwd=project_root,
109113
timeout=context.timeout,
114+
env=env,
110115
)
111116
return ContextResult(
112117
context=context,
@@ -116,14 +121,17 @@ def run_context(context: Context, project_root: Path) -> ContextResult:
116121
)
117122

118123

119-
def run_all_contexts(contexts: list[Context], project_root: Path) -> list[ContextResult]:
124+
def run_all_contexts(contexts: list[Context], project_root: Path, ralph_name: str | None = None) -> list[ContextResult]:
120125
"""Run every context sequentially and return all results.
121126
122127
Each context's command (or script) executes with *project_root* as the
123128
working directory. Static-only contexts return immediately. Results
124129
are passed to :func:`resolve_contexts` for prompt injection.
130+
131+
When *ralph_name* is set, it is forwarded to each context subprocess
132+
as the ``RALPH_NAME`` environment variable.
125133
"""
126-
return [run_context(ctx, project_root) for ctx in contexts]
134+
return [run_context(ctx, project_root, ralph_name) for ctx in contexts]
127135

128136

129137
def resolve_contexts(prompt: str, results: list[ContextResult]) -> str:

src/ralphify/engine.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ def _run_checks_phase(
231231
project_root: Path,
232232
state: RunState,
233233
emit: _BoundEmitter,
234+
ralph_name: str | None = None,
234235
) -> str:
235236
"""Execute all checks, emit per-check and summary events.
236237
@@ -241,7 +242,7 @@ def _run_checks_phase(
241242

242243
emit(EventType.CHECKS_STARTED, {"iteration": iteration, "count": len(enabled_checks)})
243244

244-
check_results = run_all_checks(enabled_checks, project_root)
245+
check_results = run_all_checks(enabled_checks, project_root, ralph_name)
245246

246247
# Build per-result data once; reused for both per-check and summary events.
247248
results_data: list[dict] = []
@@ -287,7 +288,7 @@ def _run_iteration(
287288
context_results: list[ContextResult] = []
288289
if primitives.contexts:
289290
context_results = run_all_contexts(
290-
primitives.contexts, config.project_root,
291+
primitives.contexts, config.project_root, config.ralph_name,
291292
)
292293
emit(EventType.CONTEXTS_RESOLVED, {"iteration": iteration, "count": len(primitives.contexts)})
293294

@@ -307,7 +308,7 @@ def _run_iteration(
307308

308309
if primitives.checks:
309310
check_failures_text = _run_checks_phase(
310-
primitives.checks, config.project_root, state, emit,
311+
primitives.checks, config.project_root, state, emit, config.ralph_name,
311312
)
312313

313314
return check_failures_text, True

tests/test_checks.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,27 @@ def test_combines_stdout_and_stderr(self, mock_run):
365365
assert "out" in result.output
366366
assert "err" in result.output
367367

368+
@patch(_MOCK_SUBPROCESS)
369+
def test_ralph_name_passed_as_env(self, mock_run):
370+
mock_run.return_value = subprocess.CompletedProcess(
371+
args=[], returncode=0, stdout="", stderr=""
372+
)
373+
check = self._make_check()
374+
run_check(check, Path("/project"), ralph_name="docs")
375+
376+
passed_env = mock_run.call_args.kwargs["env"]
377+
assert passed_env["RALPH_NAME"] == "docs"
378+
379+
@patch(_MOCK_SUBPROCESS)
380+
def test_ralph_name_none_no_env(self, mock_run):
381+
mock_run.return_value = subprocess.CompletedProcess(
382+
args=[], returncode=0, stdout="", stderr=""
383+
)
384+
check = self._make_check()
385+
run_check(check, Path("/project"), ralph_name=None)
386+
387+
assert mock_run.call_args.kwargs["env"] is None
388+
368389

369390
class TestRunAllChecks:
370391
@patch(_MOCK_SUBPROCESS)

tests/test_contexts.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,27 @@ def test_combines_stdout_and_stderr(self, mock_run):
281281
assert "out" in result.output
282282
assert "err" in result.output
283283

284+
@patch(_MOCK_SUBPROCESS)
285+
def test_ralph_name_passed_as_env(self, mock_run):
286+
mock_run.return_value = subprocess.CompletedProcess(
287+
args=[], returncode=0, stdout="", stderr=""
288+
)
289+
ctx = self._make_context()
290+
run_context(ctx, Path("/project"), ralph_name="docs")
291+
292+
passed_env = mock_run.call_args.kwargs["env"]
293+
assert passed_env["RALPH_NAME"] == "docs"
294+
295+
@patch(_MOCK_SUBPROCESS)
296+
def test_ralph_name_none_no_env(self, mock_run):
297+
mock_run.return_value = subprocess.CompletedProcess(
298+
args=[], returncode=0, stdout="", stderr=""
299+
)
300+
ctx = self._make_context()
301+
run_context(ctx, Path("/project"), ralph_name=None)
302+
303+
assert mock_run.call_args.kwargs["env"] is None
304+
284305

285306
class TestRunAllContexts:
286307
@patch(_MOCK_SUBPROCESS)

tests/test_engine.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,3 +529,53 @@ def test_no_ralph_name_returns_none(self):
529529
ralph_file="RALPH.md",
530530
)
531531
assert _resolve_ralph_dir(config) is None
532+
533+
534+
class TestRalphNameEnv:
535+
"""Tests that ralph_name flows to context and check subprocesses."""
536+
537+
@patch(_MOCK_SUBPROCESS, side_effect=_ok)
538+
def test_ralph_name_passed_to_context_scripts(self, mock_run, tmp_path):
539+
"""When ralph_name is set, context scripts receive RALPH_NAME env var."""
540+
# Create a context with a command
541+
ctx_dir = tmp_path / ".ralphify" / "contexts" / "test-ctx"
542+
ctx_dir.mkdir(parents=True)
543+
(ctx_dir / "CONTEXT.md").write_text("---\ncommand: echo hi\n---\n")
544+
545+
config = _make_config(tmp_path, ralph_name="docs", max_iterations=1)
546+
state = _make_state()
547+
run_loop(config, state, NullEmitter())
548+
549+
# Find the context subprocess call (first call before the agent call)
550+
context_call = mock_run.call_args_list[0]
551+
assert context_call.kwargs["env"]["RALPH_NAME"] == "docs"
552+
553+
@patch(_MOCK_SUBPROCESS, side_effect=_ok)
554+
def test_ralph_name_passed_to_check_scripts(self, mock_run, tmp_path):
555+
"""When ralph_name is set, check scripts receive RALPH_NAME env var."""
556+
# Create a check with a command
557+
check_dir = tmp_path / ".ralphify" / "checks" / "test-chk"
558+
check_dir.mkdir(parents=True)
559+
(check_dir / "CHECK.md").write_text("---\ncommand: echo ok\n---\n")
560+
561+
config = _make_config(tmp_path, ralph_name="docs", max_iterations=1)
562+
state = _make_state()
563+
run_loop(config, state, NullEmitter())
564+
565+
# The check call is after the agent call
566+
check_call = mock_run.call_args_list[-1]
567+
assert check_call.kwargs["env"]["RALPH_NAME"] == "docs"
568+
569+
@patch(_MOCK_SUBPROCESS, side_effect=_ok)
570+
def test_no_ralph_name_no_env(self, mock_run, tmp_path):
571+
"""When ralph_name is None, no custom env is passed."""
572+
ctx_dir = tmp_path / ".ralphify" / "contexts" / "test-ctx"
573+
ctx_dir.mkdir(parents=True)
574+
(ctx_dir / "CONTEXT.md").write_text("---\ncommand: echo hi\n---\n")
575+
576+
config = _make_config(tmp_path, ralph_name=None, max_iterations=1)
577+
state = _make_state()
578+
run_loop(config, state, NullEmitter())
579+
580+
context_call = mock_run.call_args_list[0]
581+
assert context_call.kwargs["env"] is None

tests/test_runner.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import subprocess
23
from pathlib import Path
34
from unittest.mock import patch
@@ -88,3 +89,25 @@ def test_combines_stdout_and_stderr(self, mock_run):
8889

8990
assert "out" in result.output
9091
assert "err" in result.output
92+
93+
@patch(_MOCK_SUBPROCESS)
94+
def test_env_merged_with_parent(self, mock_run):
95+
mock_run.return_value = subprocess.CompletedProcess(
96+
args=[], returncode=0, stdout="", stderr=""
97+
)
98+
run_command(script=None, command="echo", cwd=Path("/project"), timeout=60, env={"RALPH_NAME": "docs"})
99+
100+
passed_env = mock_run.call_args.kwargs["env"]
101+
assert passed_env["RALPH_NAME"] == "docs"
102+
# Parent env vars (like PATH) should be preserved
103+
assert "PATH" in passed_env
104+
105+
@patch(_MOCK_SUBPROCESS)
106+
def test_env_none_inherits_parent(self, mock_run):
107+
mock_run.return_value = subprocess.CompletedProcess(
108+
args=[], returncode=0, stdout="", stderr=""
109+
)
110+
run_command(script=None, command="echo", cwd=Path("/project"), timeout=60, env=None)
111+
112+
# env=None means subprocess.run inherits parent env
113+
assert mock_run.call_args.kwargs["env"] is None

0 commit comments

Comments
 (0)