-
Notifications
You must be signed in to change notification settings - Fork 4
Description
branch: modularize-ralph
Spec: Modularize Ralph into Python Package
Overview
Ralph has grown to ~3000 lines in a single file (scripts/ralph) with ~5000 lines of tests. This spec breaks it into a proper Python package under tools/ralph/ with shared code in tools/libs/dotlib/. The scripts/ralph entry point becomes a thin shell wrapper.
The Git class is duplicated identically in both scripts/ralph and scripts/ta-wt today — extracting it into tools/libs/dotlib/git.py eliminates this duplication and establishes the shared library pattern for the planned ta migration later.
Architecture
tools/
libs/
dotlib/ # shared Python package
__init__.py # re-exports: from dotlib.git import Git
git.py # Git class (currently duplicated in ralph + ta-wt)
pyproject.toml # name = "dotlib"
ralph/
src/
ralph/ # ralph Python package
__init__.py
__main__.py # python3 -m ralph entry point
cli.py # arg parsing + main() dispatch
util.py # parse_duration, parse_frontmatter, parse_issue_branch
github.py # GitHub class with retry logic
proxy.py # proxy lifecycle, health, keepalive
token.py # Keychain I/O, store/check/get/ensure
orchestration.py # resolve_repo, dependencies, worktree, fast-forward
loop.py # process_issue + poll_loop
selftest.py # selftest orchestration + backend checks
sandbox/
__init__.py # SandboxBackend base class + factory
docker.py # DockerSandbox
tart.py # TartSandbox
tests/
conftest.py # ralph test fixtures
test_util.py
test_github.py
test_proxy.py
test_token.py
test_orchestration.py
test_loop.py
test_selftest.py
test_sandbox_docker.py
test_sandbox_tart.py
test_cli.py
test_integration.py
pyproject.toml # name = "ralph", pythonpath = ["src", "../../libs"]
scripts/
ralph # thin shell wrapper (3 lines)
Module dependency graph (arrows = imports from):
cli ──→ token, selftest, loop, proxy, orchestration, util
loop ──→ sandbox, proxy, orchestration, github, util
selftest ──→ sandbox, proxy, token
orchestration ──→ dotlib.git, github, util
sandbox.docker ──→ sandbox base, dotlib.git
sandbox.tart ──→ sandbox base, dotlib.git
sandbox base ──→ token, proxy
proxy ──→ (stdlib only)
token ──→ (stdlib only)
github ──→ (stdlib only)
util ──→ (stdlib only)
Constraint: All Python code remains stdlib-only. No third-party runtime dependencies.
1. Directory and Package Structure
- Create
tools/libs/dotlib/andtools/ralph/src/ralph/andtools/ralph/src/ralph/sandbox/directories with__init__.pyfiles. tools/libs/pyproject.toml: minimal config withname = "dotlib".tools/ralph/pyproject.toml: pytest config withpythonpath = ["src", "../../libs"]andtestpaths = ["tests"].tools/ralph/src/ralph/__main__.py:from ralph.cli import main; main().- All
__init__.pyfiles can be empty initially (populated as modules are extracted).
2. Shell Wrapper
scripts/ralph becomes:
#!/bin/sh
DOTFILES_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PYTHONPATH="${DOTFILES_DIR}/tools/ralph/src:${DOTFILES_DIR}/tools/libs" exec python3 -m ralph "$@"This replaces the current ~3000-line Python script. The wrapper must resolve correctly both when called directly and via the ~/bin/ralph symlink.
3. Extraction Order and Module Boundaries
Extract in dependency order (leaves first). At each step, move the code and its tests together. After each extraction, all tests must pass.
Leaf modules (no internal dependencies):
dotlib.git—Gitclass from lines 35–56. Also updatescripts/ta-wtto import fromdotlib.git(addsys.pathinsert at top of ta-wt, remove its local Git class).ralph.util—parse_duration,parse_frontmatter,parse_issue_branch.ralph.github—GitHubclass and_SelftestAbortexception.ralph.proxy— constants (DEFAULT_PROXY_PORTS,DEFAULT_PROXY_PORT,MODEL_ALIASES),proxy_script_path,compute_proxy_version,proxy_pid_file,proxy_log_file,proxy_port_for_agent,proxy_health_check,start_proxy,stop_proxy,start_proxy_keepalive,ensure_proxy.ralph.token— constants (MS_PER_DAY,DEFAULT_EXPIRY_DAYS),keychain_service_name,read_token_from_keychain,write_token_to_keychain,format_expiry_date,run_claude_setup_token,_parse_and_store_token,store_token,check_token,get_token,ensure_token.
Mid-level modules:
ralph.sandbox—load_sandbox_config,create_sandbox_backendfactory,SandboxBackendbase class (withITERATION_PROMPT).ralph.sandbox.docker—DockerSandboxclass.ralph.sandbox.tart—TartSandboxclass.ralph.orchestration—resolve_repo,check_dependencies,unblock_ready_specs,ensure_worktree,try_fast_forward,check_dependencies_prereq.
Top-level modules:
ralph.selftest—selftest,_selftest_docker,_selftest_tart.ralph.loop—process_issue,poll_loop.ralph.cli—USAGEstring,usage(),main().
4. Test Migration
Move tests from tests/test_ralph.py, tests/test_ralph_proxy.py, tests/test_ralph_integration.py into tools/ralph/tests/, splitting by module. Tests import directly from the package (e.g., from ralph.util import parse_duration) — no import_script() hack needed.
Keep tests/test_ralph.bats in place (it tests the CLI entry point end-to-end via the shell wrapper).
Update tests/test_ralph.bats to work with the new shell wrapper entry point.
If tests/conftest.py is still needed by other test files (e.g., test_ta_wt.py), keep it. Remove the ralph-specific parts.
5. ta-wt Integration
Update scripts/ta-wt to import Git from dotlib instead of defining its own copy:
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tools", "libs"))
from dotlib.git import GitRemove the local Git class definition from ta-wt. Run ta-wt tests to verify.
Implementation Plan
Step 1: Create directory structure and pyproject.toml files [done]
Files:
tools/libs/dotlib/__init__.py— emptytools/libs/pyproject.toml— minimal project configtools/ralph/src/ralph/__init__.py— emptytools/ralph/src/ralph/__main__.py—from ralph.cli import main; main()tools/ralph/src/ralph/sandbox/__init__.py— emptytools/ralph/tests/conftest.py— empty (or minimal fixtures)tools/ralph/pyproject.toml— pytest config with pythonpath
Implement:
- Create all directories and files listed above.
tools/libs/pyproject.tomlshould have[project] name = "dotlib"andversion = "0.0.0".tools/ralph/pyproject.tomlshould have[project] name = "ralph",version = "0.0.0", and[tool.pytest.ini_options] pythonpath = ["src", "../libs"]andtestpaths = ["tests"].- Note: Changed
../../libsto../libs— fromtools/ralph/, the relative path totools/libsis../libs(one level up), not../../libs(two levels up).
- Note: Changed
Test:
cd tools/ralph && python3 -c "import ralph"succeeds (empty package imports).
Verify: Run the import check above. Fix any failures.
Review: Verify all directories and files exist with correct content.
Address feedback: Fix all review findings.
Step 2: Extract dotlib.git and update ta-wt [done]
Files:
tools/libs/dotlib/git.py— Git class extracted from ralphtools/libs/dotlib/__init__.py— re-export Gitscripts/ta-wt— remove local Git class, import from dotlibscripts/ralph— (still the monolith at this step) import Git from dotlib instead of defining locally
Implement:
- Copy the
Gitclass (lines 35–56 of current ralph) intotools/libs/dotlib/git.py. - Update
tools/libs/dotlib/__init__.pytofrom dotlib.git import Git. - In
scripts/ralph, addsys.path.insert(0, ...)fortools/libsnear the top, remove the localGitclass, and addfrom dotlib.git import Git. - In
scripts/ta-wt, addsys.path.insert(0, ...)fortools/libsnear the top, remove the localGitclass, and addfrom dotlib.git import Git.
Test:
ralph --helpstill works.ta-wt --helpstill works.- Existing tests for both ralph and ta-wt pass.
Verify: Run pytest tests/test_ralph.py tests/test_ralph_proxy.py -v and pytest tests/test_ta_wt.py -v. Fix any failures and re-run until all pass.
Review: Verify the Git class was moved cleanly with no behavior changes. Verify both scripts resolve the dotlib path correctly.
Address feedback: Fix all review findings. Re-run tests.
Step 3: Extract leaf modules (util, github, proxy, token) [done]
Files:
tools/ralph/src/ralph/util.py— pure utility functionstools/ralph/src/ralph/github.py— GitHub class + _SelftestAborttools/ralph/src/ralph/proxy.py— proxy lifecycle functions + constantstools/ralph/src/ralph/token.py— token management functions + constantstools/ralph/tests/test_util.py— tests from test_ralph.py for util functionstools/ralph/tests/test_github.py— tests for GitHub classtools/ralph/tests/test_proxy.py— tests from test_ralph_proxy.py + test_ralph.py proxy teststools/ralph/tests/test_token.py— tests for token functions
Implement:
- Extract
parse_duration,parse_frontmatter,parse_issue_branchintoralph/util.py. - Extract
GitHubclass and_SelftestAbortintoralph/github.py. - Extract all proxy functions and constants (
DEFAULT_PROXY_PORTS,DEFAULT_PROXY_PORT,MODEL_ALIASES,proxy_script_path,compute_proxy_version,proxy_pid_file,proxy_log_file,proxy_port_for_agent,proxy_health_check,start_proxy,stop_proxy,start_proxy_keepalive,ensure_proxy) intoralph/proxy.py. - Extract all token functions and constants (
MS_PER_DAY,DEFAULT_EXPIRY_DAYS,keychain_service_name,read_token_from_keychain,write_token_to_keychain,format_expiry_date,run_claude_setup_token,_parse_and_store_token,store_token,check_token,get_token,ensure_token) intoralph/token.py. - In the monolith
scripts/ralph, replace the moved code with imports from the new modules. - Move corresponding test classes from
tests/test_ralph.pyandtests/test_ralph_proxy.pyinto the new test files undertools/ralph/tests/. Update imports to use the package directly (e.g.,from ralph.util import parse_duration).
Test:
- All moved tests pass from
tools/ralph/tests/. - Remaining tests in
tests/test_ralph.pystill pass (they still import the monolith which re-imports from modules).
Verify: Run cd tools/ralph && pytest tests/ -v and pytest tests/test_ralph.py -v. Fix any failures and re-run until all pass.
Review: Verify no functions were lost or duplicated. Verify imports are clean.
Address feedback: Fix all review findings. Re-run tests.
Notes:
ralph.proxyimportsread_token_from_keychainfromralph.token(the spec's dependency graph listed proxy as stdlib-only, butstart_proxyrequires it).- The monolith sets
__path__to make sub-module imports work when loaded as theralphmodule by test infrastructure. tests/test_ralph_proxy.pywas NOT moved — it testsdocker/agent-loop/proxy/proxy.py, not the ralph proxy module.- Remaining monolith tests that call extracted functions through imported bindings needed patch path updates:
TestMainTokenSubcommandspatches →ralph.token.*,TestGitHubRetrypatches →ralph.github.*,TestEnsureProxyStaleCleanuppatches →ralph.proxy.*.
Step 4: Extract sandbox modules [done]
Files:
tools/ralph/src/ralph/sandbox/__init__.py—load_sandbox_config,create_sandbox_backend,SandboxBackendbase classtools/ralph/src/ralph/sandbox/docker.py—DockerSandboxtools/ralph/src/ralph/sandbox/tart.py—TartSandboxtools/ralph/tests/test_sandbox_docker.py— Docker sandbox teststools/ralph/tests/test_sandbox_tart.py— Tart sandbox tests
Implement:
- Move
SandboxBackendbase class (withITERATION_PROMPTandsandbox_name),load_sandbox_config, andcreate_sandbox_backendfactory intoralph/sandbox/__init__.py. - Move
DockerSandboxintoralph/sandbox/docker.py. Update its imports to usefrom dotlib.git import Git,from ralph.proxy import ...,from ralph.token import ...,from ralph.sandbox import SandboxBackend. - Move
TartSandboxintoralph/sandbox/tart.pywith similar import updates. - Update the factory in
sandbox/__init__.pyto import from.dockerand.tart. - In the monolith, replace sandbox code with imports.
- Move sandbox-related tests into the new test files.
Test:
- All sandbox tests pass from
tools/ralph/tests/. - Remaining monolith tests still pass.
Verify: Run cd tools/ralph && pytest tests/ -v and pytest tests/test_ralph.py -v. Fix any failures and re-run until all pass.
Review: Verify the sandbox hierarchy is intact, factory works, and all backend methods are preserved.
Address feedback: Fix all review findings. Re-run tests.
Notes:
sandbox/__init__.pyimportsproxy_health_checkfromralph.proxyandread_token_from_keychainfromralph.tokenfor thepreflight_checkmethod.create_sandbox_backenduses lazy imports to avoid circular imports (from ralph.sandbox.docker import DockerSandboxinside the function body).- The monolith imports the sandbox classes into its own namespace (
from ralph.sandbox.docker import DockerSandbox), so remaining monolith tests that patchralph.DockerSandbox.*orralph.TartSandbox.*continue to work. - Test patch paths were updated:
ralph.sandbox.docker.subprocess.runfor Docker backend subprocess calls,ralph.sandbox.tart.subprocess.runfor Tart backend calls,ralph.sandbox.proxy_health_checkandralph.sandbox.read_token_from_keychainfor preflight checks. TestTartCheckPrerequisiteswas moved from the monolith totest_sandbox_tart.pywith patch pathralph.sandbox.tart.shutil.which.- 282 module tests (including 142 Docker + 70 Tart sandbox tests) and 83 monolith tests all pass.
Step 5: Extract orchestration, loop, selftest, and CLI [done]
Files:
tools/ralph/src/ralph/orchestration.py— repo resolution, dependencies, worktree, fast-forwardtools/ralph/src/ralph/loop.py— process_issue, poll_looptools/ralph/src/ralph/selftest.py— selftest orchestrationtools/ralph/src/ralph/cli.py— USAGE, usage(), main()tools/ralph/tests/test_orchestration.pytools/ralph/tests/test_loop.pytools/ralph/tests/test_selftest.pytools/ralph/tests/test_cli.py
Implement:
- Extract
resolve_repo,check_dependencies,unblock_ready_specs,ensure_worktree,try_fast_forward,check_dependencies_prereqintoralph/orchestration.py. - Extract
process_issue,poll_loopintoralph/loop.py. - Extract
selftest,_selftest_docker,_selftest_tartintoralph/selftest.py. - Extract
USAGE,usage(),main()intoralph/cli.py. - Move all remaining tests from
tests/test_ralph.pyinto the appropriate new test files. - Move
tests/test_ralph_integration.pytotools/ralph/tests/test_integration.py, updating imports. tests/test_ralph.pyshould now be empty or nearly so — delete it.- Delete
tests/test_ralph_proxy.pyif all its tests have been moved.
Test:
- All tests pass from
tools/ralph/tests/. tests/test_ralph.batsstill passes.
Verify: Run cd tools/ralph && pytest tests/ -v and bats tests/test_ralph.bats. Fix any failures and re-run until all pass.
Review: Verify the monolith scripts/ralph is now empty of Python code. Verify no test was lost.
Address feedback: Fix all review findings. Re-run tests.
Notes:
- The monolith
scripts/ralphis now a thin re-export wrapper: it imports all public names from the extracted modules into its own namespace, so any code that patchesralph.<name>still works. tests/test_ralph.pywas deleted after all tests were moved to their respective module test files.tests/test_ralph_integration.pywas moved totools/ralph/tests/test_integration.pywith updated imports (uses package imports instead ofimport_script()).tests/test_ralph_proxy.pywas NOT deleted — it tests the Docker proxy server (docker/agent-loop/proxy/proxy.py), not the ralph proxy module.- Remaining tests from the monolith that belonged to already-extracted modules (
TestEnsureProxyStaleCleanup,TestGitHubRetry,TestIterationPrompt) were appended to the appropriate existing test files (test_proxy.py,test_github.py,test_sandbox_docker.py). cli.pyresolvesdotfiles_dirrelative to its own file path (../../../..fromtools/ralph/src/ralph/), which matches the repo root.- Patch paths in tests were updated from
ralph.<name>toralph.<module>.<name>(e.g.,ralph.loop.resolve_repo,ralph.selftest.subprocess.run,ralph.cli.sys.argv). - CLI tests for selftest routing were split from the
TestSelftestclass into a separateTestMainSelftestRoutingclass intest_cli.py. - 365 module tests pass, 6 integration tests skipped (require Docker + token).
batstest 5 ("ralph fails when gh is not installed") fails in this environment becauseghis installed at/usr/bin/gh— this is a pre-existing environment-specific issue, confirmed to fail identically on the pre-extraction monolith.
Step 6: Convert scripts/ralph to shell wrapper [done]
Files:
scripts/ralph— replace Python monolith with 3-line shell wrapper
Implement:
- Replace
scripts/ralphwith the shell wrapper:#!/bin/sh DOTFILES_DIR="$(cd "$(dirname "$0")/.." && pwd)" PYTHONPATH="${DOTFILES_DIR}/tools/ralph/src:${DOTFILES_DIR}/tools/libs" exec python3 -m ralph "$@"
- Verify
tools/ralph/src/ralph/__main__.pyexists and containsfrom ralph.cli import main; main(). - Update
tests/test_ralph.batsif needed to work with the shell wrapper (the CLI behavior should be identical).
Test:
ralph --helpworks.ralph selftestworks.tests/test_ralph.batspasses.
Verify: Run ralph --help, bats tests/test_ralph.bats, and cd tools/ralph && pytest tests/ -v. Fix any failures and re-run until all pass.
Review: Verify the shell wrapper resolves paths correctly from both direct invocation and ~/bin symlink.
Address feedback: Fix all review findings. Re-run tests.
Notes:
- The shell wrapper is 10 lines (not 3 as spec suggested) because portable symlink resolution requires a loop — macOS lacks
readlink -f. - The symlink resolution loop follows one-level symlinks using POSIX-compatible
readlink+dirname/cd/pwd. - The
__main__.pyentry point was already correct from Step 1 (from ralph.cli import main; main()). tests/test_ralph.batsrequired no changes — all 13 non-environment-specific tests pass. Test 5 ("ralph fails when gh is not installed") continues to fail in sandbox environments whereghis installed at/usr/bin/gh(pre-existing, documented in Step 5 notes).- 365 module pytest tests pass, 6 integration tests skipped.
Step 7: Run all checks [done]
Implement:
- Run the full test suite:
cd tools/ralph && pytest tests/ -v - Run bats tests:
bats tests/test_ralph.bats - Run ta-wt tests:
bats tests/test_ta_wt.bats - Run shellcheck on the new shell wrapper:
shellcheck scripts/ralph - Verify
ralph --helpworks. - Verify no stale test files remain in
tests/for code that was moved. - Fix any failures and commit the fixes.
Verify: All checks pass clean.
Notes:
- Spec said
pytest tests/test_ta_wt.pybut ta-wt tests are bats tests (tests/test_ta_wt.bats), not pytest. - ta-wt bats test 35 ("wt remove calls workspace kill") was failing because the test copies
ta-wtto a mock directory, breaking thesys.path.insertthat resolvesdotlibrelative to the script's location. Fixed by settingPYTHONPATHin the test'sruncommand so the copied script can finddotlib. - Ralph bats test 5 ("ralph fails when gh is not installed") continues to fail in sandbox environments where
ghis installed — pre-existing, documented in Step 5 notes. tests/test_ralph_proxy.pyremains intests/— it tests the Docker proxy server (docker/agent-loop/proxy/proxy.py), not the ralph proxy module.- Results: 365 module pytest passed (6 skipped), 13/14 ralph bats passed, 63/66 ta-wt bats passed (3 skipped: no zsh), shellcheck clean.
Conventions
- Language: Python 3 (stdlib only, no third-party runtime dependencies)
- Tests: pytest, run via
cd tools/ralph && pytest tests/ -v - Entry point:
scripts/ralphis a shell wrapper; actual code lives intools/ralph/src/ralph/ - Shared code:
tools/libs/dotlib/— only put code here when 2+ tools use it today - Imports: Use standard package imports (
from ralph.util import parse_duration), noimport_script()hack - Error messages: Prefix with
ralph:for user-facing errors - Exit codes: 0=success, 1=runtime error, 2=usage error