pycastle 0.1.3.7.dev0__tar.gz → 0.1.3.9.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 (183) hide show
  1. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/CONTEXT.md +18 -15
  2. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/PKG-INFO +1 -1
  3. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/__init__.cpython-311.pyc +0 -0
  4. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/_types.cpython-311.pyc +0 -0
  5. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/agent_output_protocol.cpython-311.pyc +0 -0
  6. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/agent_result.cpython-311.pyc +0 -0
  7. pycastle-0.1.3.9.dev0/src/pycastle/__pycache__/agent_runner.cpython-311.pyc +0 -0
  8. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/build_command.cpython-311.pyc +0 -0
  9. pycastle-0.1.3.9.dev0/src/pycastle/__pycache__/container_runner.cpython-311.pyc +0 -0
  10. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/errors.cpython-311.pyc +0 -0
  11. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/init_command.cpython-311.pyc +0 -0
  12. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/labels.cpython-311.pyc +0 -0
  13. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/main.cpython-311.pyc +0 -0
  14. pycastle-0.1.3.9.dev0/src/pycastle/__pycache__/orchestrator.cpython-311.pyc +0 -0
  15. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/prompt_pipeline.cpython-311.pyc +0 -0
  16. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/prompt_utils.cpython-311.pyc +0 -0
  17. pycastle-0.1.3.9.dev0/src/pycastle/__pycache__/rich_status_display.cpython-311.pyc +0 -0
  18. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/stream_parser.cpython-311.pyc +0 -0
  19. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/worktree.cpython-311.pyc +0 -0
  20. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/agent_output_protocol.py +78 -39
  21. pycastle-0.1.3.9.dev0/src/pycastle/agent_runner.py +151 -0
  22. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/config/__pycache__/__init__.cpython-311.pyc +0 -0
  23. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/config/__pycache__/loader.cpython-311.pyc +0 -0
  24. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/config/__pycache__/validator.cpython-311.pyc +0 -0
  25. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/container_runner.py +21 -57
  26. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/__init__.py +24 -12
  27. pycastle-0.1.3.9.dev0/src/pycastle/iteration/__pycache__/__init__.cpython-311.pyc +0 -0
  28. pycastle-0.1.3.9.dev0/src/pycastle/iteration/__pycache__/_deps.cpython-311.pyc +0 -0
  29. pycastle-0.1.3.9.dev0/src/pycastle/iteration/__pycache__/_utils.cpython-311.pyc +0 -0
  30. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/__pycache__/implement.cpython-311.pyc +0 -0
  31. pycastle-0.1.3.9.dev0/src/pycastle/iteration/__pycache__/merge.cpython-311.pyc +0 -0
  32. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/__pycache__/planning.cpython-311.pyc +0 -0
  33. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/__pycache__/preflight.cpython-311.pyc +0 -0
  34. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/_deps.py +13 -38
  35. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/_utils.py +4 -3
  36. pycastle-0.1.3.9.dev0/src/pycastle/iteration/implement.py +179 -0
  37. pycastle-0.1.3.9.dev0/src/pycastle/iteration/merge.py +111 -0
  38. pycastle-0.1.3.9.dev0/src/pycastle/iteration/planning.py +48 -0
  39. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/preflight.py +14 -10
  40. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/orchestrator.py +42 -40
  41. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/rich_status_display.py +91 -37
  42. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/__pycache__/__init__.cpython-311.pyc +0 -0
  43. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/__pycache__/_base.cpython-311.pyc +0 -0
  44. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/__pycache__/claude_service.cpython-311.pyc +0 -0
  45. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/__pycache__/docker_service.cpython-311.pyc +0 -0
  46. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/__pycache__/git_service.cpython-311.pyc +0 -0
  47. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/__pycache__/github_service.cpython-311.pyc +0 -0
  48. pycastle-0.1.3.9.dev0/src/pycastle/status_display.py +52 -0
  49. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle.egg-info/PKG-INFO +1 -1
  50. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle.egg-info/SOURCES.txt +2 -2
  51. pycastle-0.1.3.9.dev0/tests/__pycache__/__init__.cpython-311.pyc +0 -0
  52. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/conftest.cpython-311-pytest-9.0.3.pyc +0 -0
  53. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_agent_output_protocol.cpython-311-pytest-9.0.3.pyc +0 -0
  54. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_agent_result.cpython-311-pytest-9.0.3.pyc +0 -0
  55. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_agent_runner.cpython-311-pytest-9.0.3.pyc +0 -0
  56. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_build_command.cpython-311-pytest-9.0.3.pyc +0 -0
  57. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_claude_service.cpython-311-pytest-9.0.3.pyc +0 -0
  58. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_config_new.cpython-311-pytest-9.0.3.pyc +0 -0
  59. pycastle-0.1.3.9.dev0/tests/__pycache__/test_container_runner.cpython-311-pytest-9.0.3.pyc +0 -0
  60. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_default_prompts.cpython-311-pytest-9.0.3.pyc +0 -0
  61. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_deps.cpython-311-pytest-9.0.3.pyc +0 -0
  62. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_docker_service.cpython-311-pytest-9.0.3.pyc +0 -0
  63. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_errors.cpython-311-pytest-9.0.3.pyc +0 -0
  64. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_git_service.cpython-311-pytest-9.0.3.pyc +0 -0
  65. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_github_service.cpython-311-pytest-9.0.3.pyc +0 -0
  66. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_implement.cpython-311-pytest-9.0.3.pyc +0 -0
  67. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_init_command.cpython-311-pytest-9.0.3.pyc +0 -0
  68. pycastle-0.1.3.9.dev0/tests/__pycache__/test_integration.cpython-311-pytest-9.0.3.pyc +0 -0
  69. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_iteration.cpython-311-pytest-9.0.3.pyc +0 -0
  70. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_labels.cpython-311-pytest-9.0.3.pyc +0 -0
  71. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_main.cpython-311-pytest-9.0.3.pyc +0 -0
  72. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_merge.cpython-311-pytest-9.0.3.pyc +0 -0
  73. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_orchestrator.cpython-311-pytest-9.0.3.pyc +0 -0
  74. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_plan.cpython-311-pytest-9.0.3.pyc +0 -0
  75. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_planning.cpython-311-pytest-9.0.3.pyc +0 -0
  76. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_preflight.cpython-311-pytest-9.0.3.pyc +0 -0
  77. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_prompt_pipeline.cpython-311-pytest-9.0.3.pyc +0 -0
  78. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_prompt_utils.cpython-311-pytest-9.0.3.pyc +0 -0
  79. pycastle-0.1.3.9.dev0/tests/__pycache__/test_status_display.cpython-311-pytest-9.0.3.pyc +0 -0
  80. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_stream_parser.cpython-311-pytest-9.0.3.pyc +0 -0
  81. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_subprocess_service.cpython-311-pytest-9.0.3.pyc +0 -0
  82. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_worktree.cpython-311-pytest-9.0.3.pyc +0 -0
  83. pycastle-0.1.3.9.dev0/tests/test_agent_output_protocol.py +571 -0
  84. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_agent_runner.py +45 -227
  85. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_container_runner.py +167 -117
  86. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_deps.py +19 -14
  87. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_implement.py +279 -46
  88. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_iteration.py +733 -598
  89. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_merge.py +84 -42
  90. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_orchestrator.py +2219 -2102
  91. pycastle-0.1.3.9.dev0/tests/test_plain_status_display.py +267 -0
  92. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_planning.py +29 -19
  93. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_preflight.py +87 -27
  94. pycastle-0.1.3.9.dev0/tests/test_status_display.py +1134 -0
  95. pycastle-0.1.3.7.dev0/src/pycastle/__pycache__/agent_runner.cpython-311.pyc +0 -0
  96. pycastle-0.1.3.7.dev0/src/pycastle/__pycache__/container_runner.cpython-311.pyc +0 -0
  97. pycastle-0.1.3.7.dev0/src/pycastle/__pycache__/orchestrator.cpython-311.pyc +0 -0
  98. pycastle-0.1.3.7.dev0/src/pycastle/__pycache__/rich_status_display.cpython-311.pyc +0 -0
  99. pycastle-0.1.3.7.dev0/src/pycastle/agent_runner.py +0 -218
  100. pycastle-0.1.3.7.dev0/src/pycastle/iteration/__pycache__/__init__.cpython-311.pyc +0 -0
  101. pycastle-0.1.3.7.dev0/src/pycastle/iteration/__pycache__/_deps.cpython-311.pyc +0 -0
  102. pycastle-0.1.3.7.dev0/src/pycastle/iteration/__pycache__/_utils.cpython-311.pyc +0 -0
  103. pycastle-0.1.3.7.dev0/src/pycastle/iteration/__pycache__/merge.cpython-311.pyc +0 -0
  104. pycastle-0.1.3.7.dev0/src/pycastle/iteration/implement.py +0 -126
  105. pycastle-0.1.3.7.dev0/src/pycastle/iteration/merge.py +0 -103
  106. pycastle-0.1.3.7.dev0/src/pycastle/iteration/planning.py +0 -45
  107. pycastle-0.1.3.7.dev0/src/pycastle/stream_parser.py +0 -22
  108. pycastle-0.1.3.7.dev0/tests/__pycache__/__init__.cpython-311.pyc +0 -0
  109. pycastle-0.1.3.7.dev0/tests/__pycache__/test_container_runner.cpython-311-pytest-9.0.3.pyc +0 -0
  110. pycastle-0.1.3.7.dev0/tests/__pycache__/test_integration.cpython-311-pytest-9.0.3.pyc +0 -0
  111. pycastle-0.1.3.7.dev0/tests/__pycache__/test_status_display.cpython-311-pytest-9.0.3.pyc +0 -0
  112. pycastle-0.1.3.7.dev0/tests/test_agent_output_protocol.py +0 -388
  113. pycastle-0.1.3.7.dev0/tests/test_status_display.py +0 -696
  114. pycastle-0.1.3.7.dev0/tests/test_stream_parser.py +0 -222
  115. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/.github/workflows/publish.yml +0 -0
  116. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/.gitignore +0 -0
  117. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/.python-version +0 -0
  118. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/CLAUDE.md +0 -0
  119. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/LICENSE +0 -0
  120. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/README.md +0 -0
  121. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/docs/adr/0001-runtime-dependency-installation.md +0 -0
  122. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/docs/agents/domain.md +0 -0
  123. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/docs/agents/issue-tracker.md +0 -0
  124. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/docs/agents/triage-labels.md +0 -0
  125. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/pyproject.toml +0 -0
  126. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/setup.cfg +0 -0
  127. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__init__.py +0 -0
  128. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/_types.py +0 -0
  129. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/agent_result.py +0 -0
  130. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/build_command.py +0 -0
  131. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/config/__init__.py +0 -0
  132. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/config/loader.py +0 -0
  133. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/config/validator.py +0 -0
  134. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/.gitignore +0 -0
  135. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/Dockerfile +0 -0
  136. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/config.py +0 -0
  137. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/coding-standards/deep-modules.md +0 -0
  138. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/coding-standards/interfaces.md +0 -0
  139. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/coding-standards/mocking.md +0 -0
  140. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/coding-standards/refactoring.md +0 -0
  141. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/coding-standards/tests.md +0 -0
  142. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/implement-prompt.md +0 -0
  143. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/merge-prompt.md +0 -0
  144. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/plan-prompt.md +0 -0
  145. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/preflight-issue.md +0 -0
  146. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/review-prompt.md +0 -0
  147. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/errors.py +0 -0
  148. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/init_command.py +0 -0
  149. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/labels.py +0 -0
  150. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/main.py +0 -0
  151. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/prompt_pipeline.py +0 -0
  152. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/prompt_utils.py +0 -0
  153. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/__init__.py +0 -0
  154. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/_base.py +0 -0
  155. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/claude_service.py +0 -0
  156. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/docker_service.py +0 -0
  157. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/git_service.py +0 -0
  158. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/github_service.py +0 -0
  159. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/worktree.py +0 -0
  160. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle.egg-info/dependency_links.txt +0 -0
  161. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle.egg-info/entry_points.txt +0 -0
  162. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle.egg-info/requires.txt +0 -0
  163. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle.egg-info/top_level.txt +0 -0
  164. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/__init__.py +0 -0
  165. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/conftest.py +0 -0
  166. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_agent_result.py +0 -0
  167. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_build_command.py +0 -0
  168. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_claude_service.py +0 -0
  169. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_config_new.py +0 -0
  170. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_default_prompts.py +0 -0
  171. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_docker_service.py +0 -0
  172. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_errors.py +0 -0
  173. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_git_service.py +0 -0
  174. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_github_service.py +0 -0
  175. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_init_command.py +0 -0
  176. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_integration.py +0 -0
  177. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_labels.py +0 -0
  178. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_main.py +0 -0
  179. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_plan.py +0 -0
  180. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_prompt_pipeline.py +0 -0
  181. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_prompt_utils.py +0 -0
  182. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_subprocess_service.py +0 -0
  183. {pycastle-0.1.3.7.dev0 → pycastle-0.1.3.9.dev0}/tests/test_worktree.py +0 -0
@@ -109,14 +109,14 @@
109
109
 
110
110
  | Term | Definition | Aliases to avoid |
111
111
  | --- | --- | --- |
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 |
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 the complete NDJSON stream typed output pipeline | output format, agent tags, agent signals |
113
113
  | **`<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
114
  | **`<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
115
  | **`<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
116
  | **`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 |
117
+ | **`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 |
118
+ | **`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 |
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 `process_stream` before tag extraction | streaming format, NDJSON output |
120
120
 
121
121
  ## Agent Lifecycle
122
122
 
@@ -143,7 +143,7 @@
143
143
  | Term | Definition | Aliases to avoid |
144
144
  | --- | --- | --- |
145
145
  | **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 |
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; 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
147
  | **host repo** | The git repository on the developer's machine that is mounted into each agent container | project repo, local repo |
148
148
  | **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
149
  | **RO mount** | A volume mount with `mode: "ro"` — the container cannot write to it; used for the host repo | read-only mount |
@@ -158,10 +158,11 @@
158
158
  | **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
159
  | **`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
160
  | **`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 |
161
+ | **`_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
162
  | **`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
163
  | **`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
164
  | **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 |
165
+ | **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
166
  | **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
167
  | **PycastleError** | Base exception class for all pycastle domain errors | — |
167
168
  | **DockerError** | Error raised when a Docker operation (container start, stop, remove) fails | container error |
@@ -186,11 +187,12 @@
186
187
  | **GithubService** | Service that encapsulates `gh` CLI calls for GitHub issue operations: closing issues, querying parent issues, listing open sub-issues, and reading issue labels | GitHub wrapper, gh provider |
187
188
  | **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
189
  | **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
- | **StatusDisplay** | Injectable abstraction that owns the live terminal status line; exposes `add_agent(name, phase, work_body="")`, `update_phase`, `reset_idle_timer`, `remove_agent`, and `print` methods; backed by a `rich` `Live` display in production and a `NullStatusDisplay` no-op in tests; injected via `Deps` as a separate concern from `Logger` | terminal display, status bar |
190
- | **work_body** | The caller-constructed string passed as the third argument to `add_agent`; displayed in the body column of the agent status row during the Work phase; empty string for agents that do not reach Work | |
191
- | **NullStatusDisplay** | Test/no-op implementation of `StatusDisplay`; all methods are no-ops; `print` falls back to `builtins.print` | — |
192
- | **agent status row** | One headerless line in the `StatusDisplay` live panel representing one active agent; format: `elapsed \| RoleName \| idle \| body`; elapsed is dim and right-justified so all role names align across rows; role name is bold, with any numeric part (e.g. `5` in `Implementer #5`) styled bold cyan; idle is dim; body column shows the current lifecycle phase name (e.g. `Setup`, `Running ruff Checks`, `Prepare`) for all non-Work states, or the `work_body` string during Work (caller-constructed: `"implementing \"{issue_title}\""` for Implementers, `"reviewing \"{issue_title}\""` for Reviewers, `"Creating Plan from {n} issues"` for the Planner, `"Merging {n} Branches"` for the Merger, `"Checking"` for the preflight-checks agent, `"reporting {check_name} issue"` for the preflight-issue agent); elapsed counts up from agent start 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 output; ordered by orchestration phase (plan → implement → review → merge) then by issue number; disappears when the agent finishes | status entry, agent row |
193
- | **IterationOutcome** | Sealed return type of `run_iteration()`; one of four variants: `Continue` (iteration completed, keep looping), `Done` (no issues found, stop cleanly), `AbortedHITL` (HITL verdictcarries `issue_number`; orchestrator exits non-zero), `AbortedUsageLimit` (token ceiling hit worktrees preserved, safe to retry; orchestrator exits non-zero) | iteration result, loop result |
190
+ | **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 |
191
+ | **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 |
192
+ | **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 | — |
193
+ | **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 |
194
+ | **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 |
195
+ | **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 |
194
196
 
195
197
  ## Test Anti-Patterns (Red Flags)
196
198
 
@@ -216,14 +218,15 @@
216
218
  - 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.
217
219
  - 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.
218
220
  - 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.
219
- - An **orphan sweep** runs once at orchestrator startup; **collision detection** holds a per-branch lock for the full duration of each agent run.
220
- - **`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.
221
+ - 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.
222
+ - **`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.
221
223
  - 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`.
222
224
  - 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.
223
225
  - **StatusDisplay** is a separate injectable in `Deps` alongside `Logger`; `Logger` owns file I/O, `StatusDisplay` owns the live terminal UI — they never overlap.
224
- - The **agent status row** is created when the Setup phase begins; the row is removed when the agent finishes or errors; the `rich` `Live` display is started on the first `add_agent` call and stopped after the last `remove_agent` call.
226
+ - 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"`).
227
+ - 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.
225
228
  - 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.
226
- - 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 and a trailing blank line; 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.
229
+ - 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.
227
230
 
228
231
  ## Example dialogue
229
232
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycastle
3
- Version: 0.1.3.7.dev0
3
+ Version: 0.1.3.9.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,12 +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
- from rich.text import Text
15
14
  from docker.models.containers import Container as DockerContainer
16
15
 
16
+ from .agent_output_protocol import AgentOutput, AgentRole, process_stream
17
17
  from .config import Config
18
18
  from .errors import (
19
19
  AgentTimeoutError,
@@ -21,33 +21,13 @@ from .errors import (
21
21
  DockerTimeoutError,
22
22
  UsageLimitError,
23
23
  )
24
- from .stream_parser import StreamParser
24
+ from .status_display import PlainStatusDisplay
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:
@@ -57,16 +37,6 @@ def _build_claude_command(model: str = "", effort: str = "") -> str:
57
37
  return f"claude {flags} < /tmp/.pycastle_prompt"
58
38
 
59
39
 
60
- def _build_agent_prefix(name: str) -> Text:
61
- """Return a styled Text object for the agent output prefix, e.g. ``[Implementer #1] ``."""
62
- msg = Text()
63
- msg.append("[", style="bold")
64
- for segment in re.split(r"(\d+)", name):
65
- if segment:
66
- msg.append(segment, style="bold cyan" if segment.isdigit() else "bold")
67
- msg.append("] ", style="bold")
68
- return msg
69
-
70
40
 
71
41
  class ContainerRunner:
72
42
  def __init__(
@@ -94,9 +64,7 @@ class ContainerRunner:
94
64
  self.effort = effort
95
65
  self._cfg = cfg
96
66
  if status_display is None:
97
- from .iteration._deps import NullStatusDisplay
98
-
99
- status_display = NullStatusDisplay()
67
+ status_display = PlainStatusDisplay()
100
68
  self._status_display = status_display
101
69
  self._owns_client = docker_client is None
102
70
  self._client = docker_client if docker_client is not None else docker.from_env()
@@ -268,7 +236,7 @@ class ContainerRunner:
268
236
  async def setup(self, git_name: str, git_email: str, work_body: str = "") -> None:
269
237
  loop = asyncio.get_running_loop()
270
238
  await loop.run_in_executor(None, self.__enter__)
271
- self._status_display.add_agent(self.name, "Setup", work_body)
239
+ self._status_display.register(self.name, work_body=work_body)
272
240
  await loop.run_in_executor(
273
241
  None,
274
242
  self.exec_simple,
@@ -309,16 +277,19 @@ class ContainerRunner:
309
277
 
310
278
  self._prompt = await prepare_prompt(prompt_file, prompt_args, container_exec)
311
279
 
312
- async def work(self) -> str:
280
+ async def work(self, role: AgentRole) -> AgentOutput:
313
281
  self._status_display.update_phase(self.name, "Work")
314
282
  loop = asyncio.get_running_loop()
283
+ on_turn: Callable[[str], None] = lambda turn: self._status_display.print(
284
+ self.name, turn
285
+ )
315
286
  return await loop.run_in_executor(
316
- None, lambda: self.run_streaming(print_output=True)
287
+ None, lambda: self.run_streaming(role=role, on_turn=on_turn)
317
288
  )
318
289
 
319
- def run_streaming(self, print_output: bool = False) -> str:
290
+ def run_streaming(self, role: AgentRole, on_turn: Callable[[str], None]) -> AgentOutput:
320
291
  self.write_file(self._prompt, "/tmp/.pycastle_prompt")
321
- result = self._active_container.exec_run(
292
+ exec_result = self._active_container.exec_run(
322
293
  ["bash", "-c", _build_claude_command(model=self.model, effort=self.effort)],
323
294
  stream=True,
324
295
  workdir=self._worktree_path,
@@ -329,18 +300,17 @@ class ContainerRunner:
329
300
 
330
301
  def _feed():
331
302
  try:
332
- for chunk in result.output:
303
+ for chunk in exec_result.output:
333
304
  q.put(chunk)
334
305
  finally:
335
306
  q.put(_sentinel)
336
307
 
337
308
  threading.Thread(target=_feed, daemon=True).start()
338
309
 
339
- parts: list[str] = []
340
- line_buf = ""
341
- parser = StreamParser()
310
+ log = open(self._log_path, "wb") # noqa: WPS515
342
311
  try:
343
- with open(self._log_path, "wb") as log:
312
+ def _lines() -> Generator[str, None, None]:
313
+ line_buf = ""
344
314
  while True:
345
315
  try:
346
316
  chunk = q.get(timeout=self._cfg.idle_timeout)
@@ -349,23 +319,19 @@ class ContainerRunner:
349
319
  f"Agent idle for more than {self._cfg.idle_timeout}s"
350
320
  )
351
321
  if chunk is _sentinel:
352
- break
322
+ return
353
323
  log.write(chunk)
354
324
  log.flush()
355
325
  text = chunk.decode("utf-8", errors="replace")
356
- parts.append(text)
357
326
  self._status_display.reset_idle_timer(self.name)
358
327
  line_buf += text
359
328
  while "\n" in line_buf:
360
329
  line, line_buf = line_buf.split("\n", 1)
361
- if _is_usage_limit_line(line, self._cfg.usage_limit_patterns):
362
- raise UsageLimitError(line)
363
- turn = parser.feed(line)
364
- if print_output and turn is not None:
365
- msg = _build_agent_prefix(self.name)
366
- msg.append(f"{turn}\n")
367
- self._status_display.print(msg, source=self.name)
330
+ yield line
331
+
332
+ return process_stream(_lines(), on_turn, role, self._cfg.usage_limit_patterns)
368
333
  finally:
334
+ log.close()
369
335
  try:
370
336
  self._active_container.exec_run(
371
337
  ["bash", "-c", "rm -f /tmp/.pycastle_prompt"],
@@ -373,5 +339,3 @@ class ContainerRunner:
373
339
  )
374
340
  except Exception:
375
341
  pass
376
- return "".join(parts)
377
-