pycastle 0.1.3.8.dev0__tar.gz → 0.1.3.10.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/CONTEXT.md +22 -12
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/PKG-INFO +1 -1
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/agent_output_protocol.py +78 -39
- pycastle-0.1.3.10.dev0/src/pycastle/agent_runner.py +151 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/container_runner.py +18 -39
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/implement-prompt.md +0 -10
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__init__.py +22 -10
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/_deps.py +4 -3
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/_utils.py +4 -3
- pycastle-0.1.3.10.dev0/src/pycastle/iteration/implement.py +187 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/merge.py +9 -6
- pycastle-0.1.3.10.dev0/src/pycastle/iteration/planning.py +48 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/preflight.py +13 -8
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/orchestrator.py +35 -34
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/rich_status_display.py +18 -5
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/git_service.py +11 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/status_display.py +52 -43
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle.egg-info/PKG-INFO +1 -1
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle.egg-info/SOURCES.txt +0 -2
- pycastle-0.1.3.10.dev0/tests/test_agent_output_protocol.py +571 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_agent_runner.py +39 -221
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_container_runner.py +129 -75
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_deps.py +19 -14
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_git_service.py +47 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_implement.py +352 -30
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_iteration.py +200 -30
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_merge.py +101 -40
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_orchestrator.py +342 -221
- pycastle-0.1.3.10.dev0/tests/test_plain_status_display.py +267 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_planning.py +27 -17
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_preflight.py +77 -15
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_status_display.py +213 -4
- pycastle-0.1.3.8.dev0/src/pycastle/agent_runner.py +0 -215
- pycastle-0.1.3.8.dev0/src/pycastle/iteration/implement.py +0 -126
- pycastle-0.1.3.8.dev0/src/pycastle/iteration/planning.py +0 -45
- pycastle-0.1.3.8.dev0/src/pycastle/stream_parser.py +0 -22
- pycastle-0.1.3.8.dev0/tests/test_agent_output_protocol.py +0 -388
- pycastle-0.1.3.8.dev0/tests/test_plain_status_display.py +0 -5
- pycastle-0.1.3.8.dev0/tests/test_stream_parser.py +0 -222
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/.github/workflows/publish.yml +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/.gitignore +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/.python-version +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/CLAUDE.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/LICENSE +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/README.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/docs/adr/0001-runtime-dependency-installation.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/docs/agents/domain.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/docs/agents/issue-tracker.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/docs/agents/triage-labels.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/pyproject.toml +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/setup.cfg +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__init__.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/__init__.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/_types.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/agent_output_protocol.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/agent_result.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/agent_runner.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/build_command.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/container_runner.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/errors.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/init_command.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/labels.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/main.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/orchestrator.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/prompt_pipeline.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/prompt_utils.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/rich_status_display.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/stream_parser.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/worktree.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/_types.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/agent_result.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/build_command.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/config/__init__.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/config/__pycache__/__init__.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/config/__pycache__/loader.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/config/__pycache__/validator.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/config/loader.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/config/validator.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/.gitignore +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/Dockerfile +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/config.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/coding-standards/deep-modules.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/coding-standards/interfaces.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/coding-standards/mocking.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/coding-standards/refactoring.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/coding-standards/tests.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/merge-prompt.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/plan-prompt.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/preflight-issue.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/review-prompt.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/errors.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/init_command.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__pycache__/__init__.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__pycache__/_deps.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__pycache__/_utils.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__pycache__/implement.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__pycache__/merge.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__pycache__/planning.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__pycache__/preflight.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/labels.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/main.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/prompt_pipeline.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/prompt_utils.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/__init__.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/__pycache__/__init__.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/__pycache__/_base.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/__pycache__/claude_service.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/__pycache__/docker_service.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/__pycache__/git_service.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/__pycache__/github_service.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/_base.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/claude_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/docker_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/github_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/worktree.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle.egg-info/dependency_links.txt +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle.egg-info/entry_points.txt +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle.egg-info/requires.txt +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle.egg-info/top_level.txt +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__init__.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/__init__.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/conftest.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_agent_output_protocol.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_agent_result.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_agent_runner.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_build_command.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_claude_service.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_config_new.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_container_runner.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_default_prompts.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_deps.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_docker_service.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_errors.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_git_service.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_github_service.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_implement.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_init_command.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_integration.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_iteration.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_labels.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_main.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_merge.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_orchestrator.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_plan.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_planning.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_preflight.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_prompt_pipeline.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_prompt_utils.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_status_display.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_stream_parser.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_subprocess_service.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/test_worktree.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/conftest.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_agent_result.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_build_command.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_claude_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_config_new.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_default_prompts.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_docker_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_errors.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_github_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_init_command.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_integration.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_labels.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_main.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_plan.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_prompt_pipeline.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_prompt_utils.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_subprocess_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_worktree.py +0 -0
|
@@ -73,6 +73,12 @@
|
|
|
73
73
|
| **clean merge** | A `git merge --no-edit` that exits zero and requires no conflict resolution | conflict-free merge, successful merge |
|
|
74
74
|
| **conflicting branch** | A branch whose `git merge --no-edit` exits non-zero; `git merge --abort` is run immediately and the branch is collected for the Merger | failed merge branch |
|
|
75
75
|
| **RALPH** | The required commit message prefix for all Implementer commits (e.g. `RALPH: fix auth bug`) | — |
|
|
76
|
+
| **RALPH: Review -** | The required commit message prefix for all Reviewer commits (e.g. `RALPH: Review - improve error handling`); distinguished from Implementer commits by the `Review -` infix; each agent produces exactly one commit per branch | — |
|
|
77
|
+
| **in-flight issue** | An open issue that has an existing `pycastle/issue-<n>` branch or worktree from a previous interrupted iteration; signals that implement or review work is already partially or fully complete | mid-flight issue, resumed issue |
|
|
78
|
+
| **merge-time preflight skip** | The behavior when the Merger's Pre-flight phase returns failures: `merge_phase` logs a diagnostic, skips the Merger, and returns normally with conflict issues still pending; the next iteration's pre-planning preflight detects the broken baseline and recovers via the preflight-fix path | merge preflight abort |
|
|
79
|
+
| **planning skip** | The behavior in `run_iteration` when at least one open issue is in-flight: the Planner is not invoked and only the in-flight issues are used as the working set for the current iteration; issues with neither a branch nor a worktree are deferred | plan bypass |
|
|
80
|
+
| **implement skip** | The behavior in `run_issue` when a branch already has a `RALPH:` (non-review) commit: the Implementer is not spawned and the Reviewer runs directly via the existing-branch path | — |
|
|
81
|
+
| **review skip** | The behavior in `run_issue` when a branch already has a `RALPH: Review -` commit: both the Implementer and Reviewer are skipped and the issue is counted as completed immediately | — |
|
|
76
82
|
| **plan** | The structured JSON output by the Planner listing which issues to work on and the branch name for each; after parsing, `plan_phase()` sorts issues by ascending issue number so the orchestrator always processes older issues first | plan output, plan JSON |
|
|
77
83
|
| **issue** | A GitHub issue labeled for agent processing, representing one unit of work | ticket, task, card |
|
|
78
84
|
| **AFK issue** | An issue the Planner assigns to an Implementer because it can be resolved autonomously; labeled `ready-for-agent` | agent issue, auto issue |
|
|
@@ -109,14 +115,14 @@
|
|
|
109
115
|
|
|
110
116
|
| Term | Definition | Aliases to avoid |
|
|
111
117
|
| --- | --- | --- |
|
|
112
|
-
| **agent output protocol** | The contract between prompts and the orchestrator: the set of XML tags agents emit to signal structured output (`<plan>`, `<issue>`, `<promise>`), plus the module that owns
|
|
118
|
+
| **agent output protocol** | The contract between prompts and the orchestrator: the set of XML tags agents emit to signal structured output (`<plan>`, `<issue>`, `<promise>`), plus the module that owns the complete NDJSON stream → typed output pipeline | output format, agent tags, agent signals |
|
|
113
119
|
| **`<plan>` tag** | XML tag emitted by the Planner containing a JSON payload listing unblocked issues for the current iteration; extracted by the agent output protocol module | plan output, plan block |
|
|
114
120
|
| **`<issue>` tag** | XML tag emitted by the preflight-issue agent containing the GitHub issue number it filed; extracted by the agent output protocol module | issue output, issue number tag |
|
|
115
121
|
| **`<promise>COMPLETE</promise>`** | XML tag emitted by Implementers, Reviewers, and the Merger to declare that their work phase is complete; detected by the agent output protocol module | done signal, completion tag |
|
|
116
122
|
| **`AgentOutputProtocolError`** | Base exception raised by the agent output protocol module when a required tag is missing or malformed; subclassed by `PlanParseError`, `IssueParseError`, and `PromiseParseError` | parse error, protocol error |
|
|
117
|
-
| **`
|
|
118
|
-
| **`
|
|
119
|
-
| **Claude streaming envelope** | The NDJSON format Claude Code uses for structured output; lines are JSON objects and the agent's final result is carried in the `{"type": "result", "result": "..."}` line; unwrapped internally by
|
|
123
|
+
| **`process_stream()`** | Single entry point in the agent output protocol module; accepts an iterable of decoded NDJSON lines, an `on_turn` callback, an `AgentRole`, and `usage_limit_patterns`; drives the per-line loop, emits complete assistant turns via the callback, raises `UsageLimitError` immediately on detection, unwraps the result envelope, and returns a typed `AgentOutput`; the container runner is the only caller — phases never call it directly | protocol entry point, stream processor |
|
|
124
|
+
| **`on_turn` callback** | A `Callable[[str], None]` passed to `process_stream` by the container runner; invoked once per complete assistant turn during the Work phase; constructed by the container runner as a lambda over `StatusDisplay.print` so the agent output protocol module has no dependency on `StatusDisplay` | turn callback, display hook |
|
|
125
|
+
| **Claude streaming envelope** | The NDJSON format Claude Code uses for structured output; lines are JSON objects and the agent's final result is carried in the `{"type": "result", "result": "..."}` line; unwrapped internally by `process_stream` before tag extraction | streaming format, NDJSON output |
|
|
120
126
|
|
|
121
127
|
## Agent Lifecycle
|
|
122
128
|
|
|
@@ -143,7 +149,7 @@
|
|
|
143
149
|
| Term | Definition | Aliases to avoid |
|
|
144
150
|
| --- | --- | --- |
|
|
145
151
|
| **Dockerfile** | File in the pycastle directory defining the Docker image for agent containers — ships without baked-in credentials and without baked-in dev tools; system utilities (git, gh), Claude Code CLI, and the Python runtime are the only baked-in contents; all dev tools (e.g. ruff, mypy, pytest) must be declared in the consuming project's dependency file and are installed at runtime during the Setup phase | image definition |
|
|
146
|
-
| **container runner** | Package module that manages Docker container lifecycle, injects runtime secrets, and drives the four agent lifecycle phases (Setup, Pre-flight, Prepare, Work) via instance methods; holds `status_display` at construction time so phase methods can update terminal state without caller involvement | docker wrapper |
|
|
152
|
+
| **container runner** | Package module that manages Docker container lifecycle, injects runtime secrets, and drives the four agent lifecycle phases (Setup, Pre-flight, Prepare, Work) via instance methods; holds `status_display` at construction time so phase methods can update terminal state without caller involvement; during the Work phase owns Docker byte chunking, byte-to-line splitting, log writing, and idle timeout detection, then delegates the line stream to `process_stream` | docker wrapper |
|
|
147
153
|
| **host repo** | The git repository on the developer's machine that is mounted into each agent container | project repo, local repo |
|
|
148
154
|
| **volume mount** | A Docker bind mount attaching a host filesystem path to a container-internal path, with an explicit read/write mode | bind mount, volume |
|
|
149
155
|
| **RO mount** | A volume mount with `mode: "ro"` — the container cannot write to it; used for the host repo | read-only mount |
|
|
@@ -158,10 +164,11 @@
|
|
|
158
164
|
| **worktree contents check** | Guard step run after `git worktree add` that verifies `pyproject.toml` or `requirements.txt` is present; fails with the worktree path and directory listing if absent | checkout guard, file check |
|
|
159
165
|
| **`detached_worktree`** | Async context manager in `worktree.py` that creates a detached checkout at a given SHA, yields the path, and guarantees removal in `__aexit__` regardless of outcome; used by `planning_phase` and `preflight_phase` for their sandbox worktrees | managed_worktree |
|
|
160
166
|
| **`branch_worktree`** | Async context manager in `worktree.py` that creates a named-branch worktree at a given SHA, yields the path, and on exit removes the worktree and optionally deletes the branch; used by `merge_phase` for the merge-sandbox worktree | managed_worktree |
|
|
167
|
+
| **`_agent_worktree`** | Async context manager in `implement.py` that owns the full Implementer and Reviewer worktree lifecycle; accepts a branch name, SHA, `CancellationToken`, and `Deps`; on entry creates the worktree and gitdir overlay; on exit conditionally removes the worktree based on `token.wants_worktree_preserved` and working-tree cleanliness, and always removes the gitdir overlay; used by `run_issue` twice per issue — once for the Implementer (new-branch path) and once for the Reviewer (existing-branch path); defined in `implement.py` not `worktree.py` because its cleanup policy depends on agent-lifecycle state (`CancellationToken`) rather than being unconditional | managed_worktree |
|
|
161
168
|
| **`worktree_name_for_branch`** | Function in `worktree.py` that derives a short directory name from a branch string: extracts `issue-N` from `pycastle/issue-N-slug` or falls back to a sanitised slug; single authoritative definition replacing duplicated regex in `agent_runner` and `merge_phase` | — |
|
|
162
169
|
| **`worktree_path`** | Function in `worktree.py` that constructs the host filesystem path for a named worktree at `<repo_root>/<pycastle_dir>/.worktrees/<name>`; single authoritative path expression replacing duplication across all phase modules | — |
|
|
163
170
|
| **runtime injection** | The act of reading `~/.claude.json` from the host and writing it to `/home/agent/.claude.json` inside a container before the agent runs | baking in, build-time config |
|
|
164
|
-
| **StreamParser** |
|
|
171
|
+
| **StreamParser** | Retired — its assistant-turn assembly logic is now a private implementation detail of `process_stream` in the agent output protocol module; `stream_parser.py` no longer exists as a public module | stream processor, message parser |
|
|
165
172
|
| **agent message** | The text content emitted by an agent during a single assistant turn; excludes tool-use and tool-result blocks; during the Work phase, printed to the console prefixed with the agent name and followed by a blank line; not shown in the status panel | assistant message, agent output |
|
|
166
173
|
| **PycastleError** | Base exception class for all pycastle domain errors | — |
|
|
167
174
|
| **DockerError** | Error raised when a Docker operation (container start, stop, remove) fails | container error |
|
|
@@ -187,11 +194,11 @@
|
|
|
187
194
|
| **Logger** | Injectable abstraction that owns all structured log output for one iteration; exposes named channels (`log_error`, `log_agent_output`) each writing to a dedicated file under `logs/`; injected via `Deps` so tests never touch the filesystem | log writer, output handler |
|
|
188
195
|
| **RecordingLogger** | Test double for `Logger` that records every call in memory; tests assert on recorded calls rather than capturing stderr or reading log files | mock logger, spy logger |
|
|
189
196
|
| **StatusDisplay** | Injectable abstraction that owns the live terminal status panel and all formatted terminal output; exposes `register(caller, startup_message="started", work_body="")`, `update_phase`, `reset_idle_timer`, `remove(caller, shutdown_message="finished", shutdown_style="success")`, and `print(caller, message, style=None)` methods; backed by a `rich` `Live` display in production and a `PlainStatusDisplay` in tests; injected via `Deps` as a separate concern from `Logger`; defined in `status_display` module | terminal display, status bar |
|
|
190
|
-
| **caller** | The identity string passed as the first argument to `StatusDisplay.register`, `remove`, and `print`; rendered as a `[Caller]` prefix on every terminal output line; empty string `""` is the anonymous caller — no brackets are printed and the message is output as-is; a blank line is inserted before
|
|
197
|
+
| **caller** | The identity string passed as the first argument to `StatusDisplay.register`, `remove`, and `print`; rendered as a `[Caller]` prefix on every terminal output line; empty string `""` is the anonymous caller — no brackets are printed and the message is output as-is; a blank line is inserted before any output call (`register`, `remove`, or `print`) when the caller differs from the previous one, or unconditionally when the caller is `""` (anonymous outputs always stand alone); canonical callers — phase rows: `"Preflight"`, `"Plan"`, `"Implement"`, `"Merge"`; agents: `"Preflight Agent"`, `"Plan Agent"`, `"Implement Agent #N"`, `"Review Agent #N"`, `"Merge Agent"` | source, label |
|
|
191
198
|
| **work_body** | The caller-constructed string passed as the third argument to `register`; displayed in the body column of the status row during the Work phase; empty string for callers that do not reach Work | — |
|
|
192
|
-
| **PlainStatusDisplay** | Plain-terminal adapter for `StatusDisplay` defined in `status_display` module; panel methods (`update_phase`, `reset_idle_timer`) are no-ops; `register` and `remove` print their startup/shutdown messages; `print(caller, message, style=None)` formats output as `[Caller] message` with no ANSI colour codes and style ignored; used in tests so assertions can match the full formatted line | NullStatusDisplay |
|
|
199
|
+
| **PlainStatusDisplay** | Plain-terminal adapter for `StatusDisplay` defined in `status_display` module; panel methods (`update_phase`, `reset_idle_timer`) are no-ops; `register` and `remove` print their startup/shutdown messages; `print(caller, message, style=None)` formats output as `[Caller] message` with no ANSI colour codes, no bold, and style ignored; used in tests so assertions can match the full formatted line | NullStatusDisplay |
|
|
193
200
|
| **status row** | One headerless line in the `StatusDisplay` live panel; created by `register` and removed by `remove`; two kinds: **agent rows** (one per active agent — `"Preflight Agent"`, `"Plan Agent"`, `"Implement Agent #N"`, `"Review Agent #N"`, `"Merge Agent"`) and **phase rows** (one per active phase — `"Preflight"`, `"Plan"`, `"Implement"`, `"Merge"`); phase rows and agent rows within the same phase coexist; format: `elapsed \| Name \| idle \| body`; elapsed is dim and right-justified; name is bold with any numeric part styled bold cyan; idle is dim; body shows the current lifecycle phase name for all non-Work states, or the `work_body` string during Work; elapsed counts up from `register` and never resets; idle resets on each Docker stream chunk; the live panel is preceded by one blank line to visually separate it from scrollback; ordered by orchestration phase (plan → implement → review → merge) then by issue number | agent status row, status entry, agent row |
|
|
194
|
-
| **IterationOutcome** | Sealed return type of `run_iteration()`; one of four variants: `Continue` (iteration completed, keep looping), `Done` (no issues found, stop cleanly), `AbortedHITL` (HITL verdict — carries `issue_number`; orchestrator exits non-zero), `AbortedUsageLimit` (token ceiling hit — worktrees preserved,
|
|
201
|
+
| **IterationOutcome** | Sealed return type of `run_iteration()`; one of four variants: `Continue` (iteration completed, keep looping), `Done` (no issues found, stop cleanly), `AbortedHITL` (HITL verdict — carries `issue_number`; orchestrator exits non-zero), `AbortedUsageLimit` (token ceiling hit — worktrees preserved; orchestrator sleeps until 2 minutes past the next local-time full hour, then continues the loop to retry the current issue from scratch; repeats indefinitely on consecutive hits) | iteration result, loop result |
|
|
195
202
|
|
|
196
203
|
## Test Anti-Patterns (Red Flags)
|
|
197
204
|
|
|
@@ -215,16 +222,19 @@
|
|
|
215
222
|
- The **HITL verdict** is read by the orchestrator from the GitHub issue label after the **preflight-issue agent** completes; `ready-for-agent` triggers the **preflight-fix path**, `ready-for-human` aborts with a non-zero exit code.
|
|
216
223
|
- On the **preflight-fix path**, the Planner is skipped; one Implementer is spawned for the preflight issue, followed by one Reviewer, then a merge; a new iteration then begins.
|
|
217
224
|
- The **Planner** and all **Implementer** worktrees are created from the pinned **safe SHA**, never from HEAD directly; this guarantees every agent sees the same verified-clean committed state regardless of external commits that land on main after preflight passes.
|
|
225
|
+
- The **planning skip** is checked before every Planner invocation; it takes priority over normal planning when any open issue is **in-flight**. The **implement skip** and **review skip** are checked inside `run_issue` before any worktree is created; they are mutually exclusive with normal agent spawning for that phase. Both skips are triggered by commit prefix detection (`RALPH: Review -` → review skip; `RALPH:` without `Review -` → implement skip only).
|
|
226
|
+
- A **merge-time preflight skip** leaves conflict issues open; they become **in-flight issues** on the next iteration, triggering the **planning skip** and then the **implement skip** or **review skip** as appropriate once the baseline is fixed.
|
|
218
227
|
- In **sequential mode** (`max_parallel = 1`), the iteration processes issues one by one: after each issue's merge the safe SHA is re-pinned to the new HEAD, and the next Implementer starts from that SHA; a failed issue is skipped (remains `ready-for-agent`) and the queue continues; the Merger remains available as a fallback for unexpected conflicts; no additional pre-flight checks run between issues.
|
|
219
228
|
- The **Pre-flight phase** (agent lifecycle) runs quality checks inside the container and returns a list of failure tuples to the orchestrator; it never spawns agents internally.
|
|
220
|
-
- An **orphan sweep** runs once at orchestrator startup; **collision detection**
|
|
221
|
-
- **`detached_worktree`** is used by `planning_phase` (for the plan-sandbox worktree) and `preflight_phase` (for the pre-flight-sandbox worktree); **`branch_worktree`** is used by `merge_phase` (for the merge-sandbox worktree);
|
|
229
|
+
- An **orphan sweep** runs once at orchestrator startup; **collision detection** uses a per-branch `asyncio.Lock` held in `implement_phase` for the full duration of each `run_issue` call — from first worktree creation to final worktree teardown.
|
|
230
|
+
- **`detached_worktree`** is used by `planning_phase` (for the plan-sandbox worktree) and `preflight_phase` (for the pre-flight-sandbox worktree); **`branch_worktree`** is used by `merge_phase` (for the merge-sandbox worktree); **`_agent_worktree`** is used by `run_issue` in `implement.py` for Implementer and Reviewer worktrees — its cleanup is conditional on cancellation state, unlike the unconditional teardown in `detached_worktree` and `branch_worktree`. **`worktree_path`** and **`worktree_name_for_branch`** are the single authoritative path and name expressions used by all of the above.
|
|
222
231
|
- Host mounts per container: host repo → RO at `/home/agent/repo`; worktree → RW at `/home/agent/workspace`; `<host-repo>/.git` → RW at `/.pycastle-parent-git`; on Windows, gitdir overlay → RO over `/home/agent/workspace/.git`.
|
|
223
232
|
- A **Service** defines a Custom exception hierarchy so callers never handle raw subprocess exceptions; tests inject Default implementations from a test fixture and override per-test for error paths.
|
|
224
233
|
- **StatusDisplay** is a separate injectable in `Deps` alongside `Logger`; `Logger` owns file I/O, `StatusDisplay` owns the live terminal UI — they never overlap.
|
|
234
|
+
- Rich markup (e.g. `[red]...[/red]`) must never be embedded in a `StatusDisplay.print` message string; colouring is expressed exclusively via the `style` parameter (`"error"`, `"success"`).
|
|
225
235
|
- A **status row** is created by `StatusDisplay.register` and removed by `StatusDisplay.remove`; phase rows are registered at the start of each orchestration phase and removed at its end; agent rows are registered at container Setup and removed when the agent finishes or errors; the `rich` `Live` display is started on the first `register` call and stopped after the last `remove` call.
|
|
226
236
|
- All orchestrator-level terminal output (e.g. "Planning complete…") is routed through `StatusDisplay.print()` so `rich` can coordinate it with the live panel; bare `print()` calls are not used while a `StatusDisplay` is active.
|
|
227
|
-
-
|
|
237
|
+
- During the Work phase the container runner owns byte chunking, byte-to-line splitting, log writing, and idle timeout detection; it passes the decoded NDJSON line stream and an **`on_turn` callback** to **`process_stream`**, which assembles assistant turns (invoking the callback for each), detects usage limit lines and raises `UsageLimitError` immediately, unwraps the result envelope, and returns a typed `AgentOutput`; phases receive `AgentOutput` directly from `AgentRunner.run()` — no phase calls `parse()` or `assert_complete()`. Setup, Pre-flight, and Prepare phases produce no console output — their activity is reflected only in the body column of the agent status row.
|
|
228
238
|
|
|
229
239
|
## Example dialogue
|
|
230
240
|
|
|
@@ -2,7 +2,10 @@ import dataclasses
|
|
|
2
2
|
import enum
|
|
3
3
|
import json
|
|
4
4
|
import re
|
|
5
|
-
from
|
|
5
|
+
from collections.abc import Callable, Iterable
|
|
6
|
+
from typing import TypeAlias
|
|
7
|
+
|
|
8
|
+
from .errors import UsageLimitError
|
|
6
9
|
|
|
7
10
|
|
|
8
11
|
class AgentRole(enum.Enum):
|
|
@@ -48,21 +51,6 @@ class PromiseParseError(AgentOutputProtocolError):
|
|
|
48
51
|
pass
|
|
49
52
|
|
|
50
53
|
|
|
51
|
-
def _unwrap(output: str) -> str:
|
|
52
|
-
for line in output.splitlines():
|
|
53
|
-
line = line.strip()
|
|
54
|
-
if not line:
|
|
55
|
-
continue
|
|
56
|
-
try:
|
|
57
|
-
obj = json.loads(line)
|
|
58
|
-
except json.JSONDecodeError:
|
|
59
|
-
continue
|
|
60
|
-
if isinstance(obj, dict) and obj.get("type") == "result":
|
|
61
|
-
result = obj.get("result")
|
|
62
|
-
return result if isinstance(result, str) else output
|
|
63
|
-
return output
|
|
64
|
-
|
|
65
|
-
|
|
66
54
|
def _extract_planner_output(text: str) -> PlannerOutput:
|
|
67
55
|
match = re.search(r"<plan>([\s\S]*?)</plan>", text)
|
|
68
56
|
if not match:
|
|
@@ -111,20 +99,80 @@ def _extract_issue_output(text: str) -> IssueOutput:
|
|
|
111
99
|
return IssueOutput(labels=labels, number=number)
|
|
112
100
|
|
|
113
101
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
102
|
+
def _is_usage_limit_line(line: str, patterns: tuple[str, ...]) -> bool:
|
|
103
|
+
try:
|
|
104
|
+
obj = json.loads(line)
|
|
105
|
+
if isinstance(obj, dict):
|
|
106
|
+
if obj.get("type") == "result" and obj.get("is_error"):
|
|
107
|
+
if obj.get("api_error_status") == 429:
|
|
108
|
+
return True
|
|
109
|
+
result_text = obj.get("result")
|
|
110
|
+
if isinstance(result_text, str) and any(
|
|
111
|
+
p.lower() in result_text.lower() for p in patterns
|
|
112
|
+
):
|
|
113
|
+
return True
|
|
114
|
+
return False
|
|
115
|
+
except json.JSONDecodeError:
|
|
116
|
+
pass
|
|
117
|
+
line_lower = line.lower()
|
|
118
|
+
return any(p.lower() in line_lower for p in patterns)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _extract_turn(line: str) -> str | None:
|
|
122
|
+
try:
|
|
123
|
+
obj = json.loads(line)
|
|
124
|
+
except json.JSONDecodeError:
|
|
125
|
+
return None
|
|
126
|
+
if not isinstance(obj, dict) or obj.get("type") != "assistant":
|
|
127
|
+
return None
|
|
128
|
+
content = (obj.get("message") or {}).get("content") or []
|
|
129
|
+
parts: list[str] = []
|
|
130
|
+
for block in content:
|
|
131
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
132
|
+
text = (block.get("text") or "").strip()
|
|
133
|
+
if text:
|
|
134
|
+
parts.append(text)
|
|
135
|
+
return "\n\n".join(parts) if parts else None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def process_stream(
|
|
139
|
+
lines: Iterable[str],
|
|
140
|
+
on_turn: Callable[[str], None],
|
|
141
|
+
role: AgentRole,
|
|
142
|
+
usage_limit_patterns: tuple[str, ...],
|
|
143
|
+
) -> AgentOutput:
|
|
144
|
+
collected: list[str] = []
|
|
145
|
+
result_text: str | None = None
|
|
146
|
+
for line in lines:
|
|
147
|
+
collected.append(line)
|
|
148
|
+
if _is_usage_limit_line(line, usage_limit_patterns):
|
|
149
|
+
raise UsageLimitError(line)
|
|
150
|
+
turn = _extract_turn(line)
|
|
151
|
+
if turn is not None:
|
|
152
|
+
on_turn(turn)
|
|
153
|
+
if role in (AgentRole.IMPLEMENTER, AgentRole.REVIEWER, AgentRole.MERGER):
|
|
154
|
+
if re.search(r"<promise>COMPLETE</promise>", turn):
|
|
155
|
+
return CompletionOutput()
|
|
156
|
+
elif role == AgentRole.PLANNER:
|
|
157
|
+
try:
|
|
158
|
+
return _extract_planner_output(turn)
|
|
159
|
+
except PlanParseError:
|
|
160
|
+
pass
|
|
161
|
+
elif role == AgentRole.PREFLIGHT_ISSUE:
|
|
162
|
+
try:
|
|
163
|
+
return _extract_issue_output(turn)
|
|
164
|
+
except IssueParseError:
|
|
165
|
+
pass
|
|
166
|
+
try:
|
|
167
|
+
obj = json.loads(line)
|
|
168
|
+
except json.JSONDecodeError:
|
|
169
|
+
continue
|
|
170
|
+
if isinstance(obj, dict) and obj.get("type") == "result":
|
|
171
|
+
r = obj.get("result")
|
|
172
|
+
if isinstance(r, str):
|
|
173
|
+
result_text = r
|
|
174
|
+
break
|
|
175
|
+
text = result_text if result_text is not None else "\n".join(collected)
|
|
128
176
|
tail = f"\nOutput tail: {text[-300:]!r}"
|
|
129
177
|
if role == AgentRole.PREFLIGHT_ISSUE:
|
|
130
178
|
try:
|
|
@@ -141,12 +189,3 @@ def parse(output: str, role: AgentRole) -> AgentOutput:
|
|
|
141
189
|
f"Agent produced no <promise>COMPLETE</promise> tag.{tail}"
|
|
142
190
|
)
|
|
143
191
|
return CompletionOutput()
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def assert_complete(output: str) -> None:
|
|
147
|
-
text = _unwrap(output)
|
|
148
|
-
if not re.search(r"<promise>COMPLETE</promise>", text):
|
|
149
|
-
tail = text[-200:]
|
|
150
|
-
raise PromiseParseError(
|
|
151
|
-
f"Agent produced no <promise>COMPLETE</promise> tag. Output tail: {tail!r}"
|
|
152
|
-
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
from .agent_output_protocol import AgentOutput, AgentRole
|
|
6
|
+
from .agent_result import CancellationToken, PreflightFailure
|
|
7
|
+
from .config import Config
|
|
8
|
+
from .container_runner import ContainerRunner
|
|
9
|
+
from .errors import AgentTimeoutError, UsageLimitError
|
|
10
|
+
from .services import GitService
|
|
11
|
+
from .status_display import PlainStatusDisplay
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclasses.dataclass
|
|
15
|
+
class RunRequest:
|
|
16
|
+
name: str
|
|
17
|
+
prompt_file: Path
|
|
18
|
+
mount_path: Path
|
|
19
|
+
role: AgentRole = AgentRole.IMPLEMENTER
|
|
20
|
+
prompt_args: dict[str, str] | None = None
|
|
21
|
+
skip_preflight: bool = False
|
|
22
|
+
model: str = ""
|
|
23
|
+
effort: str = ""
|
|
24
|
+
stage: str = ""
|
|
25
|
+
token: CancellationToken | None = None
|
|
26
|
+
status_display: Any = None
|
|
27
|
+
issue_title: str = ""
|
|
28
|
+
work_body: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AgentRunnerProtocol(Protocol):
|
|
32
|
+
async def run(self, request: RunRequest) -> AgentOutput | PreflightFailure: ...
|
|
33
|
+
|
|
34
|
+
async def run_preflight(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
name: str,
|
|
38
|
+
mount_path: Path,
|
|
39
|
+
stage: str = "",
|
|
40
|
+
status_display=None,
|
|
41
|
+
work_body: str = "",
|
|
42
|
+
) -> list[tuple[str, str, str]]: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AgentRunner:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
env: dict[str, str],
|
|
49
|
+
cfg: Config,
|
|
50
|
+
git_service: GitService,
|
|
51
|
+
docker_client=None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._env = env
|
|
54
|
+
self._cfg = cfg
|
|
55
|
+
self._git_service = git_service
|
|
56
|
+
self._docker_client = docker_client
|
|
57
|
+
|
|
58
|
+
async def run(self, request: RunRequest) -> AgentOutput | PreflightFailure:
|
|
59
|
+
name = request.name
|
|
60
|
+
prompt_file = request.prompt_file
|
|
61
|
+
mount_path = request.mount_path
|
|
62
|
+
prompt_args = request.prompt_args
|
|
63
|
+
skip_preflight = request.skip_preflight
|
|
64
|
+
model = request.model
|
|
65
|
+
effort = request.effort
|
|
66
|
+
token = request.token
|
|
67
|
+
status_display = request.status_display
|
|
68
|
+
work_body = request.work_body
|
|
69
|
+
|
|
70
|
+
if status_display is None:
|
|
71
|
+
status_display = PlainStatusDisplay()
|
|
72
|
+
|
|
73
|
+
_token = token if token is not None else CancellationToken()
|
|
74
|
+
if _token.is_cancelled:
|
|
75
|
+
raise UsageLimitError("Agent cancelled due to usage limit")
|
|
76
|
+
|
|
77
|
+
runner = ContainerRunner(
|
|
78
|
+
name,
|
|
79
|
+
mount_path,
|
|
80
|
+
self._env,
|
|
81
|
+
model=model,
|
|
82
|
+
effort=effort,
|
|
83
|
+
docker_client=self._docker_client,
|
|
84
|
+
status_display=status_display,
|
|
85
|
+
cfg=self._cfg,
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
git_name = self._git_service.get_user_name()
|
|
89
|
+
git_email = self._git_service.get_user_email()
|
|
90
|
+
await runner.setup(git_name, git_email, work_body)
|
|
91
|
+
await runner.prepare(prompt_file, prompt_args or {})
|
|
92
|
+
if not skip_preflight:
|
|
93
|
+
failures = await runner.preflight(list(self._cfg.preflight_checks))
|
|
94
|
+
if failures:
|
|
95
|
+
return PreflightFailure(failures=tuple(failures))
|
|
96
|
+
retries_left = self._cfg.timeout_retries
|
|
97
|
+
while True:
|
|
98
|
+
try:
|
|
99
|
+
output = await runner.work(request.role)
|
|
100
|
+
return output
|
|
101
|
+
except AgentTimeoutError:
|
|
102
|
+
if retries_left <= 0:
|
|
103
|
+
raise
|
|
104
|
+
restart_num = self._cfg.timeout_retries - retries_left + 1
|
|
105
|
+
status_display.print(
|
|
106
|
+
name,
|
|
107
|
+
f"Timeout — restarting"
|
|
108
|
+
f" (attempt {restart_num}/{self._cfg.timeout_retries})",
|
|
109
|
+
)
|
|
110
|
+
retries_left -= 1
|
|
111
|
+
except UsageLimitError:
|
|
112
|
+
_token.cancel(preserve_worktree=True)
|
|
113
|
+
raise
|
|
114
|
+
finally:
|
|
115
|
+
status_display.remove(name)
|
|
116
|
+
try:
|
|
117
|
+
runner.__exit__(None, None, None)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
async def run_preflight(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
name: str,
|
|
125
|
+
mount_path: Path,
|
|
126
|
+
stage: str = "",
|
|
127
|
+
status_display=None,
|
|
128
|
+
work_body: str = "",
|
|
129
|
+
) -> list[tuple[str, str, str]]:
|
|
130
|
+
if status_display is None:
|
|
131
|
+
status_display = PlainStatusDisplay()
|
|
132
|
+
|
|
133
|
+
git_name = self._git_service.get_user_name()
|
|
134
|
+
git_email = self._git_service.get_user_email()
|
|
135
|
+
runner = ContainerRunner(
|
|
136
|
+
name,
|
|
137
|
+
mount_path,
|
|
138
|
+
self._env,
|
|
139
|
+
docker_client=self._docker_client,
|
|
140
|
+
status_display=status_display,
|
|
141
|
+
cfg=self._cfg,
|
|
142
|
+
)
|
|
143
|
+
try:
|
|
144
|
+
await runner.setup(git_name, git_email, work_body)
|
|
145
|
+
return await runner.preflight(list(self._cfg.preflight_checks))
|
|
146
|
+
finally:
|
|
147
|
+
status_display.remove(name)
|
|
148
|
+
try:
|
|
149
|
+
runner.__exit__(None, None, None)
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import io
|
|
3
|
-
import json
|
|
4
3
|
import os
|
|
5
4
|
import queue
|
|
6
5
|
import re
|
|
@@ -8,11 +7,13 @@ import shlex
|
|
|
8
7
|
import sys
|
|
9
8
|
import tarfile
|
|
10
9
|
import threading
|
|
10
|
+
from collections.abc import Callable, Generator
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
13
|
import docker
|
|
14
14
|
from docker.models.containers import Container as DockerContainer
|
|
15
15
|
|
|
16
|
+
from .agent_output_protocol import AgentOutput, AgentRole, process_stream
|
|
16
17
|
from .config import Config
|
|
17
18
|
from .errors import (
|
|
18
19
|
AgentTimeoutError,
|
|
@@ -21,33 +22,12 @@ from .errors import (
|
|
|
21
22
|
UsageLimitError,
|
|
22
23
|
)
|
|
23
24
|
from .status_display import PlainStatusDisplay
|
|
24
|
-
from .stream_parser import StreamParser
|
|
25
25
|
from .worktree import (
|
|
26
26
|
CONTAINER_PARENT_GIT,
|
|
27
27
|
patch_gitdir_for_container,
|
|
28
28
|
)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def _is_usage_limit_line(line: str, patterns: tuple[str, ...]) -> bool:
|
|
32
|
-
"""Return True if line signals a usage limit — plain-text or a JSON result error."""
|
|
33
|
-
try:
|
|
34
|
-
obj = json.loads(line)
|
|
35
|
-
if isinstance(obj, dict):
|
|
36
|
-
if obj.get("type") == "result" and obj.get("is_error"):
|
|
37
|
-
if obj.get("api_error_status") == 429:
|
|
38
|
-
return True
|
|
39
|
-
result_text = obj.get("result")
|
|
40
|
-
if isinstance(result_text, str) and any(
|
|
41
|
-
p.lower() in result_text.lower() for p in patterns
|
|
42
|
-
):
|
|
43
|
-
return True
|
|
44
|
-
return False
|
|
45
|
-
except json.JSONDecodeError:
|
|
46
|
-
pass
|
|
47
|
-
line_lower = line.lower()
|
|
48
|
-
return any(p.lower() in line_lower for p in patterns)
|
|
49
|
-
|
|
50
|
-
|
|
51
31
|
def _build_claude_command(model: str = "", effort: str = "") -> str:
|
|
52
32
|
flags = "--verbose --dangerously-skip-permissions --output-format stream-json -p -"
|
|
53
33
|
if model:
|
|
@@ -297,16 +277,19 @@ class ContainerRunner:
|
|
|
297
277
|
|
|
298
278
|
self._prompt = await prepare_prompt(prompt_file, prompt_args, container_exec)
|
|
299
279
|
|
|
300
|
-
async def work(self) ->
|
|
280
|
+
async def work(self, role: AgentRole) -> AgentOutput:
|
|
301
281
|
self._status_display.update_phase(self.name, "Work")
|
|
302
282
|
loop = asyncio.get_running_loop()
|
|
283
|
+
on_turn: Callable[[str], None] = lambda turn: self._status_display.print(
|
|
284
|
+
self.name, turn
|
|
285
|
+
)
|
|
303
286
|
return await loop.run_in_executor(
|
|
304
|
-
None, lambda: self.run_streaming(
|
|
287
|
+
None, lambda: self.run_streaming(role=role, on_turn=on_turn)
|
|
305
288
|
)
|
|
306
289
|
|
|
307
|
-
def run_streaming(self,
|
|
290
|
+
def run_streaming(self, role: AgentRole, on_turn: Callable[[str], None]) -> AgentOutput:
|
|
308
291
|
self.write_file(self._prompt, "/tmp/.pycastle_prompt")
|
|
309
|
-
|
|
292
|
+
exec_result = self._active_container.exec_run(
|
|
310
293
|
["bash", "-c", _build_claude_command(model=self.model, effort=self.effort)],
|
|
311
294
|
stream=True,
|
|
312
295
|
workdir=self._worktree_path,
|
|
@@ -317,18 +300,17 @@ class ContainerRunner:
|
|
|
317
300
|
|
|
318
301
|
def _feed():
|
|
319
302
|
try:
|
|
320
|
-
for chunk in
|
|
303
|
+
for chunk in exec_result.output:
|
|
321
304
|
q.put(chunk)
|
|
322
305
|
finally:
|
|
323
306
|
q.put(_sentinel)
|
|
324
307
|
|
|
325
308
|
threading.Thread(target=_feed, daemon=True).start()
|
|
326
309
|
|
|
327
|
-
|
|
328
|
-
line_buf = ""
|
|
329
|
-
parser = StreamParser()
|
|
310
|
+
log = open(self._log_path, "wb") # noqa: WPS515
|
|
330
311
|
try:
|
|
331
|
-
|
|
312
|
+
def _lines() -> Generator[str, None, None]:
|
|
313
|
+
line_buf = ""
|
|
332
314
|
while True:
|
|
333
315
|
try:
|
|
334
316
|
chunk = q.get(timeout=self._cfg.idle_timeout)
|
|
@@ -337,21 +319,19 @@ class ContainerRunner:
|
|
|
337
319
|
f"Agent idle for more than {self._cfg.idle_timeout}s"
|
|
338
320
|
)
|
|
339
321
|
if chunk is _sentinel:
|
|
340
|
-
|
|
322
|
+
return
|
|
341
323
|
log.write(chunk)
|
|
342
324
|
log.flush()
|
|
343
325
|
text = chunk.decode("utf-8", errors="replace")
|
|
344
|
-
parts.append(text)
|
|
345
326
|
self._status_display.reset_idle_timer(self.name)
|
|
346
327
|
line_buf += text
|
|
347
328
|
while "\n" in line_buf:
|
|
348
329
|
line, line_buf = line_buf.split("\n", 1)
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if print_output and turn is not None:
|
|
353
|
-
self._status_display.print(self.name, turn)
|
|
330
|
+
yield line
|
|
331
|
+
|
|
332
|
+
return process_stream(_lines(), on_turn, role, self._cfg.usage_limit_patterns)
|
|
354
333
|
finally:
|
|
334
|
+
log.close()
|
|
355
335
|
try:
|
|
356
336
|
self._active_container.exec_run(
|
|
357
337
|
["bash", "-c", "rm -f /tmp/.pycastle_prompt"],
|
|
@@ -359,4 +339,3 @@ class ContainerRunner:
|
|
|
359
339
|
)
|
|
360
340
|
except Exception:
|
|
361
341
|
pass
|
|
362
|
-
return "".join(parts)
|
{pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/implement-prompt.md
RENAMED
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
# Workflow
|
|
2
2
|
|
|
3
|
-
### 0. Prior Run Detection
|
|
4
|
-
|
|
5
|
-
Before starting, check whether prior RALPH work exists on this branch.
|
|
6
|
-
|
|
7
|
-
Run `git log main..HEAD --oneline`. If any commits are present, prior RALPH work is already done — emit `<promise>COMPLETE</promise>` and stop.
|
|
8
|
-
|
|
9
|
-
Otherwise, run `git status`. If the working tree is dirty, review the existing uncommitted changes and continue from the current state rather than starting over.
|
|
10
|
-
|
|
11
|
-
If both checks show a clean, empty branch, fall through to step 1 and proceed normally.
|
|
12
|
-
|
|
13
3
|
### 1. Task
|
|
14
4
|
|
|
15
5
|
Fix issue #{{ISSUE_NUMBER}}: {{ISSUE_TITLE}}
|