outerloop-science 0.2.0rc3__tar.gz → 0.2.0rc4__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 (189) hide show
  1. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/CHANGELOG.md +3 -0
  2. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/CITATION.cff +1 -1
  3. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/PKG-INFO +1 -1
  4. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/install.md +14 -4
  5. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/pyproject.toml +5 -0
  6. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/scripts/README.md +1 -1
  7. outerloop_science-0.2.0rc4/scripts/install_claude.sh +82 -0
  8. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/scripts/tick_deploy.sh +22 -0
  9. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/__init__.py +1 -1
  10. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/harness.py +1 -1
  11. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/init.py +48 -1
  12. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/measure.py +42 -2
  13. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/conftest.py +8 -0
  14. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_init.py +3 -2
  15. outerloop_science-0.2.0rc4/tests/test_install_harness.py +218 -0
  16. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_measure.py +100 -0
  17. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_start.py +1 -3
  18. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/.gitignore +0 -0
  19. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/.pre-commit-config.yaml +0 -0
  20. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/.python-version +0 -0
  21. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/CLAUDE.md +0 -0
  22. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/CONTRIBUTING.md +0 -0
  23. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/LICENSE +0 -0
  24. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/NOTICE +0 -0
  25. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/README.md +0 -0
  26. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/RELEASING.md +0 -0
  27. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/SECURITY.md +0 -0
  28. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/containers/README.md +0 -0
  29. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/containers/agent-py312.def +0 -0
  30. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/assets/icon-dark.svg +0 -0
  31. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/assets/icon-light.svg +0 -0
  32. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/assets/icon.svg +0 -0
  33. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/community.md +0 -0
  34. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/compute.md +0 -0
  35. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/contract.md +0 -0
  36. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/agent-protocols.md +0 -0
  37. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/agent-substrate.md +0 -0
  38. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/architecture.md +0 -0
  39. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/base-reintegration.md +0 -0
  40. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/consolidation.md +0 -0
  41. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/dispatcher.md +0 -0
  42. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/eval-cache.md +0 -0
  43. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/external.md +0 -0
  44. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/github-app-auth.md +0 -0
  45. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/headline.md +0 -0
  46. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/judge-placement.md +0 -0
  47. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/lifecycle.md +0 -0
  48. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/meta.md +0 -0
  49. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/onboarding.md +0 -0
  50. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/orchestrator-verify.md +0 -0
  51. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/public-surface.md +0 -0
  52. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/research-lines.md +0 -0
  53. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/research-loop-buildout.md +0 -0
  54. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/research-loop.md +0 -0
  55. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/resident-tick.md +0 -0
  56. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/review-placement.md +0 -0
  57. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/reviewer-infra.md +0 -0
  58. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/role-cli.md +0 -0
  59. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/roles.md +0 -0
  60. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/scaling.md +0 -0
  61. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/design/session-watcher.md +0 -0
  62. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/reviewer.md +0 -0
  63. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/roadmap.md +0 -0
  64. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/docs/validation/author-syscalls.md +0 -0
  65. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/examples/review.yml +0 -0
  66. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/scripts/install_codex.sh +0 -0
  67. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/scripts/install_hermes.sh +0 -0
  68. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/scripts/requeue_moved_successors.sh +0 -0
  69. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/scripts/setup_branch_protection.sh +0 -0
  70. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/scripts/sweep_git_locks.sh +0 -0
  71. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/scripts/tick_chain.sbatch +0 -0
  72. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/scripts/tick_resident.sh +0 -0
  73. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/__main__.py +0 -0
  74. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/appauth.py +0 -0
  75. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/appmanifest.py +0 -0
  76. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/attempt.py +0 -0
  77. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/brief.py +0 -0
  78. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/cli.py +0 -0
  79. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/climbboard.py +0 -0
  80. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/compute.py +0 -0
  81. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/contract.py +0 -0
  82. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/contract_cli.py +0 -0
  83. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/disk.py +0 -0
  84. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/dispatch.py +0 -0
  85. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/evalcache.py +0 -0
  86. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/github.py +0 -0
  87. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/housekeeping.py +0 -0
  88. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/hypothesis.py +0 -0
  89. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/image.py +0 -0
  90. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/inbox.py +0 -0
  91. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/intake.py +0 -0
  92. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/launchlog.py +0 -0
  93. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/limits.py +0 -0
  94. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/maintain.py +0 -0
  95. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/maintain_agent_cli.py +0 -0
  96. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/maintain_post_cli.py +0 -0
  97. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/markers.py +0 -0
  98. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/orchestrator.py +0 -0
  99. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/panel.py +0 -0
  100. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/paths.py +0 -0
  101. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/posting.py +0 -0
  102. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/progress.py +0 -0
  103. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/py.typed +0 -0
  104. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/review.py +0 -0
  105. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/review_agent.py +0 -0
  106. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/review_agent_cli.py +0 -0
  107. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/review_post_cli.py +0 -0
  108. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/review_summarize_cli.py +0 -0
  109. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/role_runner.py +0 -0
  110. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/roles.py +0 -0
  111. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/rolespec.py +0 -0
  112. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/runstate.py +0 -0
  113. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/steward.py +0 -0
  114. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/style.py +0 -0
  115. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/syscall.py +0 -0
  116. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/syscall_cli.py +0 -0
  117. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/tick.py +0 -0
  118. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/verifier.py +0 -0
  119. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/verify_agent.py +0 -0
  120. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/verify_agent_cli.py +0 -0
  121. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/verify_post_cli.py +0 -0
  122. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/src/outerloop/watcher.py +0 -0
  123. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/fakes.py +0 -0
  124. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/helpers.py +0 -0
  125. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_app_permissions.py +0 -0
  126. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_appauth.py +0 -0
  127. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_appmanifest.py +0 -0
  128. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_attempt.py +0 -0
  129. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_attempt_review.py +0 -0
  130. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_bot_aliases.py +0 -0
  131. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_bot_login.py +0 -0
  132. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_brief.py +0 -0
  133. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_channel_dir.py +0 -0
  134. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_climbboard.py +0 -0
  135. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_codex_harness.py +0 -0
  136. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_compute.py +0 -0
  137. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_contract.py +0 -0
  138. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_contract_cli.py +0 -0
  139. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_contract_names.py +0 -0
  140. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_disk.py +0 -0
  141. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_dispatch.py +0 -0
  142. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_evalcache.py +0 -0
  143. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_github.py +0 -0
  144. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_hardening.py +0 -0
  145. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_harness.py +0 -0
  146. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_hermes_harness.py +0 -0
  147. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_housekeeping.py +0 -0
  148. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_image.py +0 -0
  149. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_import.py +0 -0
  150. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_inbox.py +0 -0
  151. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_intake.py +0 -0
  152. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_launchlog.py +0 -0
  153. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_lifecycle_cli.py +0 -0
  154. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_limits.py +0 -0
  155. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_local_compute.py +0 -0
  156. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_maintain.py +0 -0
  157. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_markers.py +0 -0
  158. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_measure_and_decide.py +0 -0
  159. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_messages.py +0 -0
  160. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_orchestrator.py +0 -0
  161. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_packaging.py +0 -0
  162. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_panel.py +0 -0
  163. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_paths.py +0 -0
  164. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_posting.py +0 -0
  165. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_progress.py +0 -0
  166. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_requeue_moved_successors.py +0 -0
  167. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_review.py +0 -0
  168. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_review_agent.py +0 -0
  169. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_review_agent_cli.py +0 -0
  170. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_review_hardening.py +0 -0
  171. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_review_policy.py +0 -0
  172. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_review_summarize.py +0 -0
  173. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_role_runner.py +0 -0
  174. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_rolespec.py +0 -0
  175. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_runstate.py +0 -0
  176. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_steward.py +0 -0
  177. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_sweep_git_locks.py +0 -0
  178. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_syscall.py +0 -0
  179. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_syscall_cli.py +0 -0
  180. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_tick.py +0 -0
  181. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_tick_chain_successors.py +0 -0
  182. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_tick_resident.py +0 -0
  183. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_tiers.py +0 -0
  184. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_verifier.py +0 -0
  185. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_verify_agent.py +0 -0
  186. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_verify_agent_cli.py +0 -0
  187. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_version.py +0 -0
  188. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/tests/test_watcher.py +0 -0
  189. {outerloop_science-0.2.0rc3 → outerloop_science-0.2.0rc4}/uv.lock +0 -0
@@ -22,6 +22,8 @@ tasks to finish.
22
22
 
23
23
  ### Added
24
24
 
25
+ - `outerloop init` installs a missing author CLI and records its path; `--no-install-harness` opts out. Claude has a pinned, SHA256-verified installer.
26
+
25
27
  - `OUTERLOOP_TICK_HOST=login` (or `--tick-host login`) runs a niced foreground tick loop against Slurm, with one tick lease per state root.
26
28
  - `OUTERLOOP_QOS` sets the QOS for every submitted Slurm job.
27
29
  - `OUTERLOOP_APPTAINER_BIN` selects the container executable on compute nodes.
@@ -33,6 +35,7 @@ tasks to finish.
33
35
 
34
36
  ### Fixed
35
37
 
38
+ - The measurer waits up to 90 seconds for the files of a finished evaluation job. A filesystem delay no longer ends a measured improvement as a negative result.
36
39
  - `outerloop start` refuses to run when the configured author's CLI is missing: it checks the recorded path, else PATH, else `~/.local/bin`, and names the install command and `outerloop init --force`.
37
40
 
38
41
  - Ending line snapshots push once, with the kernel's GitHub auth. Without auth nothing is pushed and the log says so. A refused push is retried only when the remote line moved.
@@ -13,5 +13,5 @@ authors:
13
13
  repository-code: "https://github.com/outerloop-science/outerloop"
14
14
  url: "https://outerloop.science"
15
15
  license: Apache-2.0
16
- version: 0.2.0rc3
16
+ version: 0.2.0rc4
17
17
  date-released: "2026-09-15"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: outerloop-science
3
- Version: 0.2.0rc3
3
+ Version: 0.2.0rc4
4
4
  Summary: Autonomous research agents that improve the benchmark you point them at, one verified pull request at a time
5
5
  Project-URL: Homepage, https://outerloop.science
6
6
  Project-URL: Repository, https://github.com/outerloop-science/outerloop
@@ -295,17 +295,27 @@ says so — rerun `outerloop init --force` online, or set the login in the
295
295
 
296
296
  ### 2c. Run the loop
297
297
 
298
+ When the configured author's CLI is missing, `outerloop init` installs it from
299
+ a pinned release and records its path. An existing executable is kept.
300
+ Use `--no-install-harness` to skip this step. To install by hand from the
301
+ outerloop checkout, run `bash scripts/install_claude.sh [target_path]` or
302
+ `bash scripts/install_codex.sh [target_path]`, then rerun `outerloop init --force`.
303
+ The default target is `$OUTERLOOP_<BACKEND>_BIN`, else `~/.local/bin/<backend>`.
304
+ Claude 2.1.272 is pinned for Linux x64 (glibc/musl) and ARM64; other platforms
305
+ are refused. Installation needs `curl`, `sha256sum`, and a writable target
306
+ directory. Hermes remains a review backend, provisioned with
307
+ `bash scripts/install_hermes.sh [target_dir]`.
308
+
298
309
  **Host prerequisites for model backends.** From the outerloop checkout:
299
310
 
300
- - Claude, as author or reviewer: the Claude Code CLI ([official
301
- setup](https://code.claude.com/docs/en/setup)); install with
302
- `curl -fsSL https://claude.ai/install.sh | bash`.
311
+ - Claude, as author or reviewer: the pinned Claude Code CLI; install with
312
+ `bash scripts/install_claude.sh`.
303
313
  - Codex, as author or reviewer: the pinned Codex CLI; install with
304
314
  `bash scripts/install_codex.sh`.
305
315
  - Hermes, as reviewer only (not an author backend): the pinned hermes-agent
306
316
  source checkout, run through `uv`; install with `bash scripts/install_hermes.sh`.
307
317
 
308
- Install the selected backend before setup. `init` records the absolute Claude
318
+ `init` records the absolute Claude
309
319
  or Codex path found on PATH (or in `~/.local/bin`) as `OUTERLOOP_<BACKEND>_BIN`.
310
320
  `start` checks only the configured author's CLI: the recorded path takes
311
321
  precedence, otherwise it searches PATH, then `~/.local/bin`. A missing or non-executable CLI stops
@@ -59,6 +59,11 @@ path = "src/outerloop/__init__.py"
59
59
  [tool.hatch.build.targets.wheel]
60
60
  packages = ["src/outerloop"]
61
61
 
62
+ [tool.hatch.build.targets.wheel.force-include]
63
+ "scripts/install_claude.sh" = "outerloop/_installers/install_claude.sh"
64
+ "scripts/install_codex.sh" = "outerloop/_installers/install_codex.sh"
65
+ "scripts/install_hermes.sh" = "outerloop/_installers/install_hermes.sh"
66
+
62
67
  [tool.ruff]
63
68
  line-length = 100
64
69
  target-version = "py312"
@@ -12,4 +12,4 @@ runs the local loop elsewhere, reading placement from `~/.config/outerloop/.env`
12
12
  | `tick_resident.sh` | The resident loop: deploy → one tick under a timeout → sleep to the slot, one `afterany:self` successor, pause exits clean, shim changes resubmit the successor. |
13
13
  | `requeue_moved_successors.sh` | Cancel pending same-name jobs that are no longer on the requested partition. The tick chain runs this before adding successors. |
14
14
  | `sweep_git_locks.sh` | Remove stale `.git/*.lock` files from a checkout (older than N minutes, no live git process); the chain runs it before each deploy. |
15
- | `install_codex.sh`, `install_hermes.sh` | Pin and install the non-claude author/judge backends on the tick host. |
15
+ | `install_claude.sh`, `install_codex.sh`, `install_hermes.sh` | Pin and install author/judge backends on the host. |
@@ -0,0 +1,82 @@
1
+ #!/bin/bash
2
+ # Idempotent install of the harness-verified claude author binary.
3
+ #
4
+ # claude is a HOST prerequisite for the claude author backend (like uv, apptainer,
5
+ # and the .sif image) — deliberately NOT baked into the image, so it updates by
6
+ # swapping one host binary. Safe to run repeatedly: the fast path is a local
7
+ # `claude --version`, so it only touches the network on a version mismatch or a
8
+ # missing binary. Run at host setup, or best-effort from the tick chain.
9
+ #
10
+ # Usage: install_claude.sh [target_path]
11
+ # target_path where to place the binary
12
+ # (default: $OUTERLOOP_CLAUDE_BIN, else ~/.local/bin/claude)
13
+ set -euo pipefail
14
+
15
+ # Pinned release and per-platform SHA256. To bump WANT, hash the new binaries
16
+ # and update the pins together; downloaded bytes are verified before execution.
17
+ WANT="2.1.272"
18
+ TARGET="${1:-${OUTERLOOP_CLAUDE_BIN:-$HOME/.local/bin/claude}}"
19
+
20
+ have="$("$TARGET" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)"
21
+ if [ "$have" = "$WANT" ]; then
22
+ echo "install_claude: claude $WANT already at $TARGET"
23
+ exit 0
24
+ fi
25
+
26
+ platform="$(uname -s)-$(uname -m)"
27
+ case "$platform" in
28
+ Linux-x86_64)
29
+ platform="linux-x64"
30
+ WANT_SHA256="d81396a668eb76fbddb49a2a5841f1b5d7af96b4c1f6500ced92f2c988f5bcd4"
31
+ if compgen -G '/lib/libc.musl-*' > /dev/null; then
32
+ platform="linux-x64-musl"
33
+ WANT_SHA256="e30bb3ac07c4f1c3f63b47312e9a73ba6875256b7768c4cdb784d2b179417489"
34
+ fi
35
+ ;;
36
+ Linux-aarch64|Linux-arm64)
37
+ platform="linux-arm64"
38
+ WANT_SHA256="214a90efdd16ee0ea81132ffecced588dba394d178cc494f285ba04b5288c8de"
39
+ ;;
40
+ *)
41
+ echo "install_claude: unsupported platform $platform (no sha256 pin)" >&2
42
+ exit 1
43
+ ;;
44
+ esac
45
+ for tool in curl sha256sum; do
46
+ command -v "$tool" >/dev/null || {
47
+ echo "install_claude: required tool $tool is missing" >&2
48
+ exit 1
49
+ }
50
+ done
51
+ echo "install_claude: installing claude $WANT -> $TARGET (have '${have:-none}')"
52
+ url="https://downloads.claude.ai/claude-code-releases/$WANT/$platform/claude"
53
+ tmp="$(mktemp -d)"
54
+ staged="$TARGET.tmp.$$"
55
+ # clean BOTH the work dir and any staged binary on every exit, so a failed
56
+ # download/mv never leaves a stale temp next to the target
57
+ trap 'rm -rf "$tmp" "$staged"' EXIT
58
+ # bounded so a stalled download can't eat the tick's own walltime: this runs
59
+ # INSIDE the ~15-min tick job, so cap the fetch well under it (single attempt —
60
+ # a transient failure is retried by the next tick, since the install is idempotent)
61
+ curl -fsSL --connect-timeout 15 --max-time 120 --retry 0 "$url" -o "$tmp/claude"
62
+ # INTEGRITY GATE: verify the binary against the pin BEFORE installing or
63
+ # executing anything from it.
64
+ got_sha=$(sha256sum "$tmp/claude" | cut -d' ' -f1)
65
+ if [ "$got_sha" != "$WANT_SHA256" ]; then
66
+ echo "install_claude: sha256 mismatch (got $got_sha, want $WANT_SHA256) — refusing" >&2
67
+ exit 1
68
+ fi
69
+ # the target dir must exist BEFORE staging into it (the staged temp lives beside
70
+ # TARGET so the final mv is atomic on the same filesystem)
71
+ mkdir -p "$(dirname "$TARGET")"
72
+ install -m 0755 "$tmp/claude" "$staged"
73
+ # secondary sanity (integrity is already the sha256 gate above, so this runs
74
+ # VERIFIED bytes): the staged binary reports the pinned version before the mv
75
+ got="$("$staged" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)"
76
+ if [ "$got" != "$WANT" ]; then
77
+ echo "install_claude: downloaded claude reports '$got', wanted '$WANT' — not installing" >&2
78
+ exit 1
79
+ fi
80
+ # atomic replace on the same filesystem: never leave a half-written binary
81
+ mv -f "$staged" "$TARGET"
82
+ echo "install_claude: installed claude $got at $TARGET"
@@ -213,6 +213,28 @@ if [ -n "$ENV_TRUSTED" ]; then
213
213
  done
214
214
  fi
215
215
 
216
+ # The configured author's CLI is a host prerequisite (pinned installers under
217
+ # scripts/); provision it when missing. Best-effort: a failure here must never
218
+ # break the chain — start's preflight and the climb report a missing CLI.
219
+ case "${OUTERLOOP_AUTHOR_BACKEND:-claude}" in
220
+ claude)
221
+ # a recorded path is the one the jobs will use, so it alone decides;
222
+ # otherwise an absolute PATH hit or the default install location counts
223
+ # (jobs ignore relative PATH entries, so the check does too)
224
+ if [ -n "${OUTERLOOP_CLAUDE_BIN:-}" ]; then
225
+ target="$OUTERLOOP_CLAUDE_BIN"; found=""
226
+ [ -x "$target" ] && found="$target"
227
+ else
228
+ target="$HOME/.local/bin/claude"
229
+ found="$(command -v claude 2>/dev/null || true)"
230
+ case "$found" in /*) ;; *) found="" ;; esac
231
+ [ -z "$found" ] && [ -x "$target" ] && found="$target"
232
+ fi
233
+ if [ -z "$found" ]; then
234
+ bash "$OUTERLOOP_HOME/scripts/install_claude.sh" "$target" || echo "deploy: claude install failed"
235
+ fi
236
+ ;;
237
+ esac
216
238
  # The codex author binary is a host prerequisite; install it (idempotent, fast
217
239
  # path is a local version check) when ANY codex role is deployed — the fleet
218
240
  # author, or a codex panel lens (a claude-author/codex-panel rollout still
@@ -1,3 +1,3 @@
1
1
  """Outerloop: autonomous research agents that improve a benchmark on your own code."""
2
2
 
3
- __version__ = "0.2.0rc3"
3
+ __version__ = "0.2.0rc4"
@@ -210,7 +210,7 @@ def redact(text: str, secrets: tuple[str, ...]) -> str:
210
210
 
211
211
 
212
212
  HARNESS_INSTALL = {
213
- "claude": "curl -fsSL https://claude.ai/install.sh | bash",
213
+ "claude": "bash scripts/install_claude.sh",
214
214
  "codex": "bash scripts/install_codex.sh",
215
215
  "hermes": "bash scripts/install_hermes.sh",
216
216
  }
@@ -21,6 +21,8 @@ import getpass
21
21
  import json
22
22
  import logging
23
23
  import os
24
+ import shlex
25
+ import subprocess
24
26
  import sys
25
27
  import time
26
28
  import urllib.error
@@ -108,11 +110,16 @@ def author_bin_env(backend: str) -> str:
108
110
 
109
111
 
110
112
  def locate_harness(backend: str) -> str:
111
- """The absolute path of `backend`'s CLI on this machine: PATH first, then
113
+ """The absolute path of `backend`'s CLI: configured environment path, then PATH, then
112
114
  ~/.local/bin (where the native installers put it and where a Slurm job,
113
115
  with no login PATH, would not find it); "" when absent. Recorded in .env so
114
116
  every job spawns the same binary the operator installed."""
115
117
  name = backend or AUTHOR_BACKENDS[0]
118
+ recorded = os.environ.get(author_bin_env(name), "")
119
+ if recorded:
120
+ # an operator's explicit path is kept when it works and reported when it does not
121
+ path = Path(recorded).expanduser().absolute()
122
+ return str(path) if path.is_file() and os.access(path, os.X_OK) else ""
116
123
  # the harness's own lookup, minus any path already recorded: absolute PATH
117
124
  # entries only (never the working directory), then ~/.local/bin
118
125
  env = {k: v for k, v in os.environ.items() if k != f"OUTERLOOP_{name.upper()}_BIN"}
@@ -120,6 +127,34 @@ def locate_harness(backend: str) -> str:
120
127
  return found if os.path.isabs(found) else ""
121
128
 
122
129
 
130
+ def cli_install_wanted(author_bin: str, args: argparse.Namespace) -> bool:
131
+ """Install the author's CLI only in the full setup, when none was found and
132
+ the operator did not opt out; the focused --github-app run sets up the App."""
133
+ return not author_bin and not args.no_install_harness and not args.github_app
134
+
135
+
136
+ def install_harness(backend: str) -> str:
137
+ """Install a missing author CLI and return its executable host path."""
138
+ name = backend or AUTHOR_BACKENDS[0]
139
+ target = Path(os.environ.get(author_bin_env(name)) or Path.home() / ".local/bin" / name)
140
+ target = target.expanduser().absolute()
141
+ command = shlex.split(HARNESS_INSTALL[name])
142
+ script = Path(__file__).resolve().parents[2] / command[1]
143
+ if not script.is_file():
144
+ script = Path(__file__).resolve().parent / "_installers" / Path(command[1]).name
145
+ argv = [command[0], str(script), str(target)]
146
+ try:
147
+ subprocess.run(argv, check=True)
148
+ if not target.is_file() or not os.access(target, os.X_OK):
149
+ raise OSError(f"installer did not produce an executable at {target}")
150
+ except (OSError, subprocess.CalledProcessError) as exc:
151
+ raise ValueError(
152
+ f"{name} installation failed: {exc}. Run manually: {shlex.join(argv)}"
153
+ ) from exc
154
+ print(f"installed {name} at {target}")
155
+ return str(target)
156
+
157
+
123
158
  def _harness_hint(answers: InitAnswers) -> None:
124
159
  if not answers.author_bin:
125
160
  name = answers.author_backend or AUTHOR_BACKENDS[0]
@@ -643,6 +678,11 @@ def main(argv: list[str] | None = None) -> int:
643
678
  dest="author_backend",
644
679
  help=f"climbing author's backend ({' or '.join(AUTHOR_BACKENDS)}; default claude)",
645
680
  )
681
+ parser.add_argument(
682
+ "--no-install-harness",
683
+ action="store_true",
684
+ help="skip installing a missing author CLI",
685
+ )
646
686
  parser.add_argument("--author-model", dest="author_model", help="climbing author's model")
647
687
  image_flags = parser.add_mutually_exclusive_group() # one or the other, never both
648
688
  image_flags.add_argument(
@@ -727,6 +767,13 @@ def main(argv: list[str] | None = None) -> int:
727
767
  answers.author_key_file = str(key_path)
728
768
  print(f"wrote {key_path} (0600)")
729
769
 
770
+ if cli_install_wanted(answers.author_bin, args):
771
+ try:
772
+ answers.author_bin = install_harness(answers.author_backend)
773
+ except ValueError as exc:
774
+ print(f"outerloop init: {exc}", file=sys.stderr)
775
+ return 1
776
+
730
777
  # The image, only now: every check that could still end the run has passed
731
778
  # and the overwrite question is answered, so a download is never wasted.
732
779
  # An existing image is used, else the published one is fetched on a machine
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
  import hashlib
21
21
  import json
22
22
  import logging
23
+ import time
23
24
  from dataclasses import dataclass
24
25
  from pathlib import Path
25
26
  from typing import Any
@@ -148,6 +149,12 @@ def _baseline_cache_path(cache_dir: Path, benchmark: str, base_sha: str) -> Path
148
149
  return cache_dir / f"{benchmark}@{base_sha}.json"
149
150
 
150
151
 
152
+ # how long a finished job's files may lag the scheduler's terminal state on a
153
+ # shared filesystem before the measurer calls it "no result"
154
+ RESULT_SETTLE_S = 90.0
155
+ RESULT_POLL_S = 3.0
156
+
157
+
151
158
  def _no_result_note(job_id: str, state: str) -> str:
152
159
  """The note for a job that ended with no result. A TIMEOUT is the walltime
153
160
  the submit declared (or the contract's default), and the author must
@@ -332,6 +339,32 @@ class DispatchedMeasurer:
332
339
  def _done(self, m: Measure) -> bool:
333
340
  return (self._ev(m) / "exit-code").exists()
334
341
 
342
+ def _readable(self, m: Measure) -> bool:
343
+ """The result can be read: a nonzero exit-code is final on its own (the
344
+ job writer records a failed checkout as exit 97 with no stdout); a
345
+ clean exit also needs its stdout, which can arrive later on a lagging
346
+ filesystem."""
347
+ ev = self._ev(m)
348
+ code, out = ev / "exit-code", ev / "stdout"
349
+ if not code.exists():
350
+ return False
351
+ if code.read_text().strip() != "0":
352
+ return True
353
+ return out.exists() and out.stat().st_size > 0
354
+
355
+ def _settled(self, m: Measure) -> bool:
356
+ """Wait a little for a finished job's files. A shared filesystem can
357
+ show the scheduler's terminal state before the job's exit-code and
358
+ stdout reach the node reading them (Empire AI Alpha, 2026-09-15: a
359
+ measured improvement was read as "no result"). True once the result
360
+ is readable; False after RESULT_SETTLE_S."""
361
+ deadline = time.monotonic() + RESULT_SETTLE_S
362
+ while not self._readable(m):
363
+ if time.monotonic() >= deadline:
364
+ return False
365
+ time.sleep(RESULT_POLL_S)
366
+ return True
367
+
335
368
  def _ended_without_result(self, m: Measure) -> str:
336
369
  """Why a dispatched job produced no result; a TIMEOUT means the eval
337
370
  needs more walltime."""
@@ -405,7 +438,10 @@ class DispatchedMeasurer:
405
438
  pending.append(live) # queued or running — the real job id
406
439
  continue
407
440
  if self._marker(m):
408
- # was dispatched, not live, no result -> died before result
441
+ # was dispatched, not live, no result -> died before result,
442
+ # unless the files are merely late on the shared filesystem
443
+ if self._settled(m):
444
+ continue
409
445
  raise EvalError(f"measure {m.name}: {self._ended_without_result(m)}")
410
446
  # No marker, not live, no result -> never dispatched -> dispatch.
411
447
  # RESIDUAL (bounded, accepted): if a prior process died in the
@@ -425,13 +461,17 @@ class DispatchedMeasurer:
425
461
  if state and is_terminal(state):
426
462
  # the job already ENDED without writing a result (a local
427
463
  # timeout, an instant cluster failure): parking would wait on
428
- # a job that will never deliver — fail like a vanished job
464
+ # a job that will never deliver — fail like a vanished job,
465
+ # once the filesystem has had its moment
466
+ if self._settled(m):
467
+ continue
429
468
  raise EvalError(f"measure {m.name}: {_no_result_note(job_id, state)}")
430
469
  pending.append(job_id)
431
470
  if pending or blind:
432
471
  raise MeasurementPending(tuple(pending))
433
472
  out: dict[str, float] = {}
434
473
  for m in measures:
474
+ self._settled(m) # exit-code seen, stdout possibly still on its way
435
475
  try:
436
476
  out[m.name] = read_eval_result(self.run_dir, self._slot(m), m.metric)
437
477
  except EvalError as exc:
@@ -66,3 +66,11 @@ def _no_tick_lease_settle(monkeypatch):
66
66
  """The settle read-back after a fresh tick lease is a one-second sleep in
67
67
  production; tests that want it pass settle_s explicitly."""
68
68
  monkeypatch.setattr("outerloop.runstate.TICK_LEASE_SETTLE_S", 0.0)
69
+
70
+
71
+ @pytest.fixture(autouse=True)
72
+ def _no_result_settle(monkeypatch):
73
+ """The measurer waits up to RESULT_SETTLE_S for a finished job's files;
74
+ tests that want the wait set it explicitly."""
75
+ monkeypatch.setattr("outerloop.measure.RESULT_SETTLE_S", 0.0)
76
+ monkeypatch.setattr("outerloop.measure.RESULT_POLL_S", 0.0)
@@ -142,6 +142,7 @@ def test_main_yes_writes_config(tmp_path: Path, monkeypatch, capsys) -> None:
142
142
  def _no_image_download(monkeypatch, request):
143
143
  """init never reaches the network in tests: no image is found or fetched, the
144
144
  token's login is not looked up, and no harness binary is found on this machine."""
145
+ monkeypatch.setattr(init, "install_harness", lambda backend: "")
145
146
  monkeypatch.setattr(init, "ensure_image", lambda **kw: "")
146
147
  monkeypatch.setattr(init, "_token_login", lambda token: "")
147
148
  if not request.node.get_closest_marker("real_locate_harness"):
@@ -694,9 +695,9 @@ def test_init_records_the_harness_binary_or_says_how_to_install_it(
694
695
  assert init.main([*base, "--author-backend", "codex"]) == 0
695
696
  assert "OUTERLOOP_CODEX_BIN=/opt/bin/codex" in (tmp_path / ".env").read_text()
696
697
  monkeypatch.setattr(init, "locate_harness", lambda backend: "")
697
- assert init.main([*base, "--force"]) == 0
698
+ assert init.main([*base, "--force", "--no-install-harness"]) == 0
698
699
  out = capsys.readouterr().out
699
- assert "no `claude` binary" in out and "curl -fsSL https://claude.ai/install.sh | bash" in out
700
+ assert "no `claude` binary" in out and "bash scripts/install_claude.sh" in out
700
701
  assert "OUTERLOOP_CLAUDE_BIN" not in (tmp_path / ".env").read_text()
701
702
 
702
703
 
@@ -0,0 +1,218 @@
1
+ """Pinned installation and init's missing-only provisioning."""
2
+
3
+ import hashlib
4
+ import os
5
+ import re
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from outerloop import init
12
+ from outerloop.harness import HARNESS_INSTALL
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+
16
+
17
+ def test_installer_mapping():
18
+ assert set(init.AUTHOR_BACKENDS) <= HARNESS_INSTALL.keys()
19
+ assert "hermes" in HARNESS_INSTALL
20
+ for command in HARNESS_INSTALL.values():
21
+ assert (ROOT / command.split()[1]).is_file()
22
+
23
+
24
+ @pytest.mark.parametrize("backend", init.AUTHOR_BACKENDS)
25
+ @pytest.mark.parametrize(
26
+ "mode", ["missing", "present", "skip", "failure", "no_output", "permission"]
27
+ )
28
+ def test_init_install(tmp_path, monkeypatch, capsys, backend, mode):
29
+ monkeypatch.setattr(init, "CONFIG_DIR", tmp_path / "config")
30
+ monkeypatch.setattr(init, "ensure_image", lambda **kw: "")
31
+ target = tmp_path / "bin" / backend
32
+ monkeypatch.setenv(init.author_bin_env(backend), str(target))
33
+ monkeypatch.setattr(init, "locate_harness", lambda _: str(target) if mode == "present" else "")
34
+ calls = []
35
+
36
+ def run(argv, *, check):
37
+ calls.append(argv)
38
+ assert check
39
+ assert argv == ["bash", str(ROOT / f"scripts/install_{backend}.sh"), str(target)]
40
+ if mode == "permission":
41
+ raise PermissionError("read-only target")
42
+ if mode == "failure":
43
+ raise subprocess.CalledProcessError(1, argv)
44
+ if mode != "no_output":
45
+ target.parent.mkdir()
46
+ target.write_text("#!/bin/sh\n")
47
+ target.chmod(0o755)
48
+
49
+ monkeypatch.setattr(init.subprocess, "run", run)
50
+ args = ["--yes", "--compute", "local", "--target", "o/r", "--author-backend", backend]
51
+ if mode == "skip":
52
+ args.append("--no-install-harness")
53
+ rc = init.main(args)
54
+ if mode in ("failure", "no_output", "permission"):
55
+ assert rc == 1
56
+ assert "Run manually: bash " in capsys.readouterr().err
57
+ assert not (tmp_path / "config" / ".env").exists()
58
+ else:
59
+ assert rc == 0
60
+ env = (tmp_path / "config" / ".env").read_text()
61
+ assert (f"{init.author_bin_env(backend)}={target}" in env) == (mode != "skip")
62
+ assert len(calls) == (mode not in ("present", "skip"))
63
+ if mode == "missing":
64
+ monkeypatch.setattr(init, "locate_harness", lambda _: str(target))
65
+ assert init.main([*args, "--force"]) == 0
66
+ assert len(calls) == 1
67
+
68
+
69
+ @pytest.mark.parametrize("valid", [True, False])
70
+ def test_claude_checksum_gate(tmp_path, valid):
71
+ # Substitute only the test copy's pins, never the production verification
72
+ # or hash tool: a small executable stands in for the 227 MB release asset.
73
+ payload = b"#!/bin/sh\necho '2.1.272 (Claude Code)'\n"
74
+ asset = tmp_path / "asset"
75
+ asset.write_bytes(payload if valid else b"untrusted")
76
+ script = tmp_path / "installer.sh"
77
+ source = (ROOT / "scripts/install_claude.sh").read_text()
78
+ script.write_text(
79
+ re.sub(
80
+ r'WANT_SHA256="[0-9a-f]{64}"',
81
+ f'WANT_SHA256="{hashlib.sha256(payload).hexdigest()}"',
82
+ source,
83
+ )
84
+ )
85
+ shim = tmp_path / "shim"
86
+ shim.mkdir()
87
+ for name, body in {
88
+ "curl": '#!/bin/bash\nwhile [ "$1" != "-o" ]; do shift; done\ncp "$ASSET" "$2"\n',
89
+ "uname": '#!/bin/sh\ncase "$1" in -s) echo Linux;; -m) echo x86_64;; esac\n',
90
+ }.items():
91
+ path = shim / name
92
+ path.write_text(body)
93
+ path.chmod(0o755)
94
+ target = tmp_path / "installed" / "claude"
95
+ env = {**os.environ, "PATH": f"{shim}:{os.environ['PATH']}", "ASSET": str(asset)}
96
+ result = subprocess.run(
97
+ ["bash", str(script), str(target)], env=env, capture_output=True, text=True
98
+ )
99
+ assert (result.returncode == 0) == valid, result.stderr
100
+ assert target.exists() == valid
101
+ assert not list(tmp_path.rglob("*.tmp.*"))
102
+ if valid:
103
+ assert target.read_bytes() == payload
104
+ assert os.access(target, os.X_OK)
105
+ (shim / "curl").write_text("#!/bin/sh\nexit 99\n")
106
+ assert subprocess.run(["bash", str(script), str(target)], env=env).returncode == 0
107
+ else:
108
+ assert "sha256 mismatch" in result.stderr
109
+
110
+
111
+ def _claude_provisioning_block(deploy: str) -> str:
112
+ start = deploy.index("# The configured author's CLI is a host prerequisite")
113
+ end = deploy.index("\nesac\n", start) + len("\nesac\n") # the outer case, at column 0
114
+ return deploy[start:end]
115
+
116
+
117
+ @pytest.mark.parametrize(
118
+ ("author", "present", "on_path", "recorded", "relative_path", "expected"),
119
+ [
120
+ (
121
+ "claude",
122
+ False,
123
+ False,
124
+ "",
125
+ False,
126
+ "default",
127
+ ), # missing everywhere: install to ~/.local/bin
128
+ ("claude", True, False, "", False, ""), # already under ~/.local/bin
129
+ ("claude", False, True, "", False, ""), # already on PATH (absolute entry)
130
+ (
131
+ "claude",
132
+ False,
133
+ True,
134
+ "",
135
+ True,
136
+ "default",
137
+ ), # only through a relative PATH entry: jobs cannot use it
138
+ (
139
+ "claude",
140
+ False,
141
+ True,
142
+ "stale",
143
+ False,
144
+ "recorded",
145
+ ), # recorded path gone: PATH does not excuse it
146
+ ("claude", True, False, "present", False, ""), # recorded path present
147
+ ("codex", False, False, "", False, ""), # not the configured author
148
+ ],
149
+ )
150
+ def test_deploy_provisions_the_configured_author_cli(
151
+ tmp_path, author, present, on_path, recorded, relative_path, expected
152
+ ):
153
+ """The chain's deploy step runs the pinned claude installer only when the
154
+ configured author is claude and the CLI the jobs will use is missing: the
155
+ recorded path when there is one, else an absolute PATH hit or ~/.local/bin."""
156
+ home = tmp_path / "checkout"
157
+ (home / "scripts").mkdir(parents=True)
158
+ log = tmp_path / "calls"
159
+ (home / "scripts" / "install_claude.sh").write_text(f'#!/bin/bash\necho "claude $1" >> {log}\n')
160
+ userhome = tmp_path / "userhome"
161
+ (userhome / ".local" / "bin").mkdir(parents=True)
162
+ path_dir = tmp_path / "path"
163
+ path_dir.mkdir()
164
+ if present:
165
+ binary = userhome / ".local" / "bin" / "claude"
166
+ binary.write_text("#!/bin/sh\n")
167
+ binary.chmod(0o755)
168
+ if on_path:
169
+ binary = path_dir / "claude"
170
+ binary.write_text("#!/bin/sh\n")
171
+ binary.chmod(0o755)
172
+ env = {
173
+ "HOME": str(userhome),
174
+ "OUTERLOOP_HOME": str(home),
175
+ "OUTERLOOP_AUTHOR_BACKEND": author,
176
+ "PATH": f"{path_dir}:/usr/bin:/bin",
177
+ }
178
+ recorded_path = tmp_path / "recorded" / "claude"
179
+ if recorded == "present":
180
+ recorded_path.parent.mkdir()
181
+ recorded_path.write_text("#!/bin/sh\n")
182
+ recorded_path.chmod(0o755)
183
+ if recorded:
184
+ env["OUTERLOOP_CLAUDE_BIN"] = str(recorded_path)
185
+ if relative_path:
186
+ env["PATH"] = ".:/usr/bin:/bin"
187
+ block = _claude_provisioning_block((ROOT / "scripts/tick_deploy.sh").read_text())
188
+ subprocess.run(
189
+ ["/bin/bash", "-c", block],
190
+ env=env,
191
+ check=True,
192
+ capture_output=True,
193
+ text=True,
194
+ cwd=path_dir,
195
+ )
196
+ calls = log.read_text().splitlines() if log.exists() else []
197
+ want = {
198
+ "": [],
199
+ "default": [f"claude {userhome / '.local' / 'bin' / 'claude'}"],
200
+ "recorded": [f"claude {recorded_path}"],
201
+ }[expected]
202
+ assert calls == want
203
+
204
+
205
+ @pytest.mark.parametrize(
206
+ ("author_bin", "github_app", "no_install", "wanted"),
207
+ [
208
+ ("", False, False, True),
209
+ ("/usr/local/bin/claude", False, False, False),
210
+ ("", True, False, False), # the focused App run sets up the App only
211
+ ("", False, True, False),
212
+ ],
213
+ )
214
+ def test_cli_install_wanted(author_bin, github_app, no_install, wanted):
215
+ import argparse
216
+
217
+ args = argparse.Namespace(github_app=github_app, no_install_harness=no_install)
218
+ assert init.cli_install_wanted(author_bin, args) is wanted