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.
Files changed (170) hide show
  1. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/CONTEXT.md +22 -12
  2. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/PKG-INFO +1 -1
  3. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/agent_output_protocol.py +78 -39
  4. pycastle-0.1.3.10.dev0/src/pycastle/agent_runner.py +151 -0
  5. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/container_runner.py +18 -39
  6. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/implement-prompt.md +0 -10
  7. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__init__.py +22 -10
  8. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/_deps.py +4 -3
  9. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/_utils.py +4 -3
  10. pycastle-0.1.3.10.dev0/src/pycastle/iteration/implement.py +187 -0
  11. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/merge.py +9 -6
  12. pycastle-0.1.3.10.dev0/src/pycastle/iteration/planning.py +48 -0
  13. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/preflight.py +13 -8
  14. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/orchestrator.py +35 -34
  15. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/rich_status_display.py +18 -5
  16. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/git_service.py +11 -0
  17. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/status_display.py +52 -43
  18. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle.egg-info/PKG-INFO +1 -1
  19. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle.egg-info/SOURCES.txt +0 -2
  20. pycastle-0.1.3.10.dev0/tests/test_agent_output_protocol.py +571 -0
  21. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_agent_runner.py +39 -221
  22. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_container_runner.py +129 -75
  23. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_deps.py +19 -14
  24. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_git_service.py +47 -0
  25. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_implement.py +352 -30
  26. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_iteration.py +200 -30
  27. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_merge.py +101 -40
  28. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_orchestrator.py +342 -221
  29. pycastle-0.1.3.10.dev0/tests/test_plain_status_display.py +267 -0
  30. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_planning.py +27 -17
  31. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_preflight.py +77 -15
  32. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_status_display.py +213 -4
  33. pycastle-0.1.3.8.dev0/src/pycastle/agent_runner.py +0 -215
  34. pycastle-0.1.3.8.dev0/src/pycastle/iteration/implement.py +0 -126
  35. pycastle-0.1.3.8.dev0/src/pycastle/iteration/planning.py +0 -45
  36. pycastle-0.1.3.8.dev0/src/pycastle/stream_parser.py +0 -22
  37. pycastle-0.1.3.8.dev0/tests/test_agent_output_protocol.py +0 -388
  38. pycastle-0.1.3.8.dev0/tests/test_plain_status_display.py +0 -5
  39. pycastle-0.1.3.8.dev0/tests/test_stream_parser.py +0 -222
  40. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/.github/workflows/publish.yml +0 -0
  41. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/.gitignore +0 -0
  42. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/.python-version +0 -0
  43. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/CLAUDE.md +0 -0
  44. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/LICENSE +0 -0
  45. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/README.md +0 -0
  46. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/docs/adr/0001-runtime-dependency-installation.md +0 -0
  47. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/docs/agents/domain.md +0 -0
  48. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/docs/agents/issue-tracker.md +0 -0
  49. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/docs/agents/triage-labels.md +0 -0
  50. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/pyproject.toml +0 -0
  51. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/setup.cfg +0 -0
  52. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__init__.py +0 -0
  53. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/__init__.cpython-311.pyc +0 -0
  54. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/_types.cpython-311.pyc +0 -0
  55. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/agent_output_protocol.cpython-311.pyc +0 -0
  56. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/agent_result.cpython-311.pyc +0 -0
  57. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/agent_runner.cpython-311.pyc +0 -0
  58. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/build_command.cpython-311.pyc +0 -0
  59. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/container_runner.cpython-311.pyc +0 -0
  60. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/errors.cpython-311.pyc +0 -0
  61. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/init_command.cpython-311.pyc +0 -0
  62. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/labels.cpython-311.pyc +0 -0
  63. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/main.cpython-311.pyc +0 -0
  64. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/orchestrator.cpython-311.pyc +0 -0
  65. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/prompt_pipeline.cpython-311.pyc +0 -0
  66. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/prompt_utils.cpython-311.pyc +0 -0
  67. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/rich_status_display.cpython-311.pyc +0 -0
  68. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/stream_parser.cpython-311.pyc +0 -0
  69. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/__pycache__/worktree.cpython-311.pyc +0 -0
  70. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/_types.py +0 -0
  71. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/agent_result.py +0 -0
  72. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/build_command.py +0 -0
  73. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/config/__init__.py +0 -0
  74. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/config/__pycache__/__init__.cpython-311.pyc +0 -0
  75. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/config/__pycache__/loader.cpython-311.pyc +0 -0
  76. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/config/__pycache__/validator.cpython-311.pyc +0 -0
  77. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/config/loader.py +0 -0
  78. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/config/validator.py +0 -0
  79. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/.gitignore +0 -0
  80. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/Dockerfile +0 -0
  81. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/config.py +0 -0
  82. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/coding-standards/deep-modules.md +0 -0
  83. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/coding-standards/interfaces.md +0 -0
  84. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/coding-standards/mocking.md +0 -0
  85. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/coding-standards/refactoring.md +0 -0
  86. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/coding-standards/tests.md +0 -0
  87. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/merge-prompt.md +0 -0
  88. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/plan-prompt.md +0 -0
  89. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/preflight-issue.md +0 -0
  90. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/defaults/prompts/review-prompt.md +0 -0
  91. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/errors.py +0 -0
  92. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/init_command.py +0 -0
  93. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__pycache__/__init__.cpython-311.pyc +0 -0
  94. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__pycache__/_deps.cpython-311.pyc +0 -0
  95. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__pycache__/_utils.cpython-311.pyc +0 -0
  96. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__pycache__/implement.cpython-311.pyc +0 -0
  97. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__pycache__/merge.cpython-311.pyc +0 -0
  98. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__pycache__/planning.cpython-311.pyc +0 -0
  99. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/iteration/__pycache__/preflight.cpython-311.pyc +0 -0
  100. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/labels.py +0 -0
  101. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/main.py +0 -0
  102. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/prompt_pipeline.py +0 -0
  103. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/prompt_utils.py +0 -0
  104. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/__init__.py +0 -0
  105. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/__pycache__/__init__.cpython-311.pyc +0 -0
  106. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/__pycache__/_base.cpython-311.pyc +0 -0
  107. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/__pycache__/claude_service.cpython-311.pyc +0 -0
  108. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/__pycache__/docker_service.cpython-311.pyc +0 -0
  109. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/__pycache__/git_service.cpython-311.pyc +0 -0
  110. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/__pycache__/github_service.cpython-311.pyc +0 -0
  111. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/_base.py +0 -0
  112. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/claude_service.py +0 -0
  113. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/docker_service.py +0 -0
  114. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/services/github_service.py +0 -0
  115. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle/worktree.py +0 -0
  116. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle.egg-info/dependency_links.txt +0 -0
  117. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle.egg-info/entry_points.txt +0 -0
  118. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle.egg-info/requires.txt +0 -0
  119. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/src/pycastle.egg-info/top_level.txt +0 -0
  120. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__init__.py +0 -0
  121. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/__init__.cpython-311.pyc +0 -0
  122. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/__pycache__/conftest.cpython-311-pytest-9.0.3.pyc +0 -0
  123. {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
  124. {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
  125. {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
  126. {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
  127. {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
  128. {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
  129. {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
  130. {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
  131. {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
  132. {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
  133. {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
  134. {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
  135. {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
  136. {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
  137. {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
  138. {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
  139. {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
  140. {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
  141. {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
  142. {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
  143. {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
  144. {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
  145. {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
  146. {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
  147. {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
  148. {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
  149. {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
  150. {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
  151. {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
  152. {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
  153. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/conftest.py +0 -0
  154. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_agent_result.py +0 -0
  155. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_build_command.py +0 -0
  156. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_claude_service.py +0 -0
  157. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_config_new.py +0 -0
  158. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_default_prompts.py +0 -0
  159. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_docker_service.py +0 -0
  160. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_errors.py +0 -0
  161. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_github_service.py +0 -0
  162. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_init_command.py +0 -0
  163. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_integration.py +0 -0
  164. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_labels.py +0 -0
  165. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_main.py +0 -0
  166. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_plan.py +0 -0
  167. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_prompt_pipeline.py +0 -0
  168. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_prompt_utils.py +0 -0
  169. {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.10.dev0}/tests/test_subprocess_service.py +0 -0
  170. {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 parsing and extraction of those tags | output format, agent tags, agent signals |
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
- | **`parse()`** | Entry point for data-bearing roles (Planner, preflight-issue agent); returns a typed output (`PlannerOutput` or `IssueOutput`) resolved statically by role; also checks for `<promise>COMPLETE</promise>` | protocol parser, output extractor |
118
- | **`assert_complete()`** | Entry point for completion-only roles (Implementer, Reviewer, Merger); verifies the `<promise>COMPLETE</promise>` tag is present and raises `PromiseParseError` if not; returns `None` on success never a bool | parse_completion, check_promise |
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 the agent output protocol module before tag extraction | streaming format, NDJSON output |
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** | Stateful class in `stream_parser.py` that processes stream-json lines one at a time; `feed(line: str) -> Optional[str]` returns the full text of a complete assistant turn (all text content blocks joined with `\n\n`) when the turn boundary is reached, or `None` to suppress; tool-use and tool-result content blocks are always dropped; one `StreamParser` instance per agent run | stream processor, message parser |
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 a `print` call when the caller differs from the previous one; canonical callers orchestration: `"pycastle"`; phase rows: `"Preflight"`, `"Plan"`, `"Implement"`, `"Merge"`; agents: `"Preflight Agent"`, `"Plan Agent"`, `"Implement Agent #N"`, `"Review Agent #N"`, `"Merge Agent"` | source, label |
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, safe to retry; orchestrator exits non-zero) | iteration result, loop result |
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** holds a per-branch lock for the full duration of each agent run.
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); Implementer and Reviewer worktrees are still managed directly inside `agent_runner` because their cleanup is conditional on cancellation state. **`worktree_path`** and **`worktree_name_for_branch`** are the single authoritative path and name expressions used by all of the above.
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
- - The streaming loop calls `reset_idle_timer` on every Docker chunk (matching the kill-agent timeout behavior); each complete line is fed to `StreamParser.feed()` during the Work phase, if a complete assistant turn is returned, it is printed to the console via `StatusDisplay.print()` with the agent-name prefix; tool-use blocks are silently dropped; Setup, Pre-flight, and Prepare phases produce no console output — their activity is reflected only in the body column of the agent status row.
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycastle
3
- Version: 0.1.3.8.dev0
3
+ Version: 0.1.3.10.dev0
4
4
  Summary: Python orchestrator for autonomous Claude Code agents in Docker
5
5
  License: MIT License
6
6
 
@@ -2,7 +2,10 @@ import dataclasses
2
2
  import enum
3
3
  import json
4
4
  import re
5
- from typing import Literal, TypeAlias, overload
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
- @overload
115
- def parse(output: str, role: Literal[AgentRole.PLANNER]) -> PlannerOutput: ...
116
-
117
-
118
- @overload
119
- def parse(output: str, role: Literal[AgentRole.PREFLIGHT_ISSUE]) -> IssueOutput: ...
120
-
121
-
122
- @overload
123
- def parse(output: str, role: AgentRole) -> AgentOutput: ...
124
-
125
-
126
- def parse(output: str, role: AgentRole) -> AgentOutput:
127
- text = _unwrap(output)
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) -> str:
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(print_output=True)
287
+ None, lambda: self.run_streaming(role=role, on_turn=on_turn)
305
288
  )
306
289
 
307
- def run_streaming(self, print_output: bool = False) -> str:
290
+ def run_streaming(self, role: AgentRole, on_turn: Callable[[str], None]) -> AgentOutput:
308
291
  self.write_file(self._prompt, "/tmp/.pycastle_prompt")
309
- result = self._active_container.exec_run(
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 result.output:
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
- parts: list[str] = []
328
- line_buf = ""
329
- parser = StreamParser()
310
+ log = open(self._log_path, "wb") # noqa: WPS515
330
311
  try:
331
- with open(self._log_path, "wb") as log:
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
- break
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
- if _is_usage_limit_line(line, self._cfg.usage_limit_patterns):
350
- raise UsageLimitError(line)
351
- turn = parser.feed(line)
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)
@@ -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}}