drydock-cli 3.1.28__tar.gz → 3.1.30__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 (222) hide show
  1. {drydock_cli-3.1.28/drydock_cli.egg-info → drydock_cli-3.1.30}/PKG-INFO +1 -1
  2. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/capacity.py +61 -31
  3. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/cli.py +9 -0
  4. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/config.py +5 -4
  5. drydock_cli-3.1.30/drydock/mission.py +471 -0
  6. drydock_cli-3.1.30/drydock/mission_cli.py +362 -0
  7. drydock_cli-3.1.30/drydock/mission_run.py +583 -0
  8. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/ratchet.py +16 -11
  9. {drydock_cli-3.1.28 → drydock_cli-3.1.30/drydock_cli.egg-info}/PKG-INFO +1 -1
  10. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock_cli.egg-info/SOURCES.txt +6 -0
  11. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/pyproject.toml +1 -1
  12. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_capacity.py +29 -9
  13. drydock_cli-3.1.30/tests/test_mission.py +136 -0
  14. drydock_cli-3.1.30/tests/test_mission_cli.py +155 -0
  15. drydock_cli-3.1.30/tests/test_mission_run.py +438 -0
  16. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_ratchet.py +13 -0
  17. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/LICENSE +0 -0
  18. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/NOTICE +0 -0
  19. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/README.md +0 -0
  20. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/__init__.py +0 -0
  21. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/__main__.py +0 -0
  22. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/advisor.py +0 -0
  23. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/agent.py +0 -0
  24. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/bash_safety.py +0 -0
  25. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/bottleneck.py +0 -0
  26. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/budget.py +0 -0
  27. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/__init__.py +0 -0
  28. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/document-canvas.md +0 -0
  29. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/fiar-assess.md +0 -0
  30. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/fiar-cap.md +0 -0
  31. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/fiar-evidence.md +0 -0
  32. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/fiar-readiness.md +0 -0
  33. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/ml-data.md +0 -0
  34. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/ml-debug.md +0 -0
  35. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/ml-finetune.md +0 -0
  36. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/ml-metrics.md +0 -0
  37. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/ml-rl.md +0 -0
  38. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/ml-train.md +0 -0
  39. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/nist-ai-rmf.md +0 -0
  40. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/nist-csf.md +0 -0
  41. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/rmf-categorize.md +0 -0
  42. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/rmf-control.md +0 -0
  43. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/rmf-poam.md +0 -0
  44. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/rmf-review.md +0 -0
  45. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/stig-assess.md +0 -0
  46. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/builtin_skills/stig-remediate.md +0 -0
  47. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/cci.py +0 -0
  48. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/comms/__init__.py +0 -0
  49. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/comms/attention.py +0 -0
  50. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/comms/channels.py +0 -0
  51. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/comms/events.py +0 -0
  52. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/compaction.py +0 -0
  53. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/detect.py +0 -0
  54. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/doccanvas.py +0 -0
  55. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/eratchet.py +0 -0
  56. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/events.py +0 -0
  57. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/extract.py +0 -0
  58. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/fiar.py +0 -0
  59. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/gittools.py +0 -0
  60. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/graphrag.py +0 -0
  61. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/groundtruth.py +0 -0
  62. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/guards.py +0 -0
  63. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/jobs.py +0 -0
  64. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/loop_detect.py +0 -0
  65. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/mcp.py +0 -0
  66. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/pdfredbox.py +0 -0
  67. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/phases.py +0 -0
  68. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/poam.py +0 -0
  69. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/predictions.py +0 -0
  70. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/progress.py +0 -0
  71. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/providers.py +0 -0
  72. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/recipes.py +0 -0
  73. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/recovery.py +0 -0
  74. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/resume.py +0 -0
  75. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/rmf.py +0 -0
  76. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/rmf_graph.py +0 -0
  77. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/skills.py +0 -0
  78. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/stig.py +0 -0
  79. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/subagents.py +0 -0
  80. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/suggest.py +0 -0
  81. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/swarm.py +0 -0
  82. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/task_state.py +0 -0
  83. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/tool_policy.py +0 -0
  84. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/tool_registry.py +0 -0
  85. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/tool_result.py +0 -0
  86. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/tool_select.py +0 -0
  87. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/tool_validate.py +0 -0
  88. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/tools/__init__.py +0 -0
  89. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/trajectory.py +0 -0
  90. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/tui/__init__.py +0 -0
  91. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/tui/app.py +0 -0
  92. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/tui/approval.py +0 -0
  93. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/tui/messages.py +0 -0
  94. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/tui/widgets.py +0 -0
  95. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/tuning.py +0 -0
  96. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/verification.py +0 -0
  97. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock/web.py +0 -0
  98. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock_cli.egg-info/dependency_links.txt +0 -0
  99. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock_cli.egg-info/entry_points.txt +0 -0
  100. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock_cli.egg-info/requires.txt +0 -0
  101. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/drydock_cli.egg-info/top_level.txt +0 -0
  102. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/setup.cfg +0 -0
  103. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_advisor.py +0 -0
  104. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_approval.py +0 -0
  105. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_back_command.py +0 -0
  106. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_bash_background.py +0 -0
  107. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_bash_binary_output.py +0 -0
  108. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_bash_crossplatform.py +0 -0
  109. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_bash_output_bounding.py +0 -0
  110. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_bash_process_group.py +0 -0
  111. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_bash_safety.py +0 -0
  112. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_bash_sanitize.py +0 -0
  113. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_bash_shell.py +0 -0
  114. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_bash_stdin.py +0 -0
  115. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_bash_stop_partial.py +0 -0
  116. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_bash_timeout_network.py +0 -0
  117. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_bash_timeout_param.py +0 -0
  118. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_bottleneck.py +0 -0
  119. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_budget.py +0 -0
  120. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_cci.py +0 -0
  121. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_cli_agents.py +0 -0
  122. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_comms.py +0 -0
  123. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_compact_command.py +0 -0
  124. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_compaction.py +0 -0
  125. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_config.py +0 -0
  126. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_config_migration.py +0 -0
  127. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_context_limit_config.py +0 -0
  128. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_context_limit_issue25.py +0 -0
  129. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_criteria_coverage.py +0 -0
  130. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_cycling_detection.py +0 -0
  131. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_declared_deps.py +0 -0
  132. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_degenerate_argument.py +0 -0
  133. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_detect.py +0 -0
  134. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_dispatch.py +0 -0
  135. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_doccanvas.py +0 -0
  136. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_e2e_connected.py +0 -0
  137. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_edit_replace_all.py +0 -0
  138. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_edit_thrash_and_compaction.py +0 -0
  139. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_effort_governor.py +0 -0
  140. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_empty_response.py +0 -0
  141. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_eratchet.py +0 -0
  142. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_events.py +0 -0
  143. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_extract.py +0 -0
  144. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_failure_loop.py +0 -0
  145. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_fiar.py +0 -0
  146. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_first_run_setup.py +0 -0
  147. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_gittools.py +0 -0
  148. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_glob_edit_edges.py +0 -0
  149. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_graphify_example.py +0 -0
  150. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_graphrag.py +0 -0
  151. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_graphrag_quoted_path.py +0 -0
  152. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_graphrag_sqlite.py +0 -0
  153. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_grep_and_read_robust.py +0 -0
  154. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_groundtruth_predictions.py +0 -0
  155. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_guards_and_tools.py +0 -0
  156. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_hallucinated_tools.py +0 -0
  157. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_jobs.py +0 -0
  158. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_knowledge_tool_available.py +0 -0
  159. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_leaked_tool_call.py +0 -0
  160. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_loop_detect.py +0 -0
  161. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_mcp.py +0 -0
  162. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_model_registry.py +0 -0
  163. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_oneshot_unreachable.py +0 -0
  164. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_overthink_interrupt.py +0 -0
  165. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_pdfredbox.py +0 -0
  166. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_phases.py +0 -0
  167. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_plan_autocontinue.py +0 -0
  168. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_poam.py +0 -0
  169. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_progress.py +0 -0
  170. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_providers_unreachable.py +0 -0
  171. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_ratchet_offer.py +0 -0
  172. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_read_index.py +0 -0
  173. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_recipes.py +0 -0
  174. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_recovery.py +0 -0
  175. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_recovery_config.py +0 -0
  176. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_recovery_integration.py +0 -0
  177. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_repeated_outcome.py +0 -0
  178. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_repetition_interrupt.py +0 -0
  179. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_resume_events.py +0 -0
  180. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_resume_restore.py +0 -0
  181. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_resume_snapshot.py +0 -0
  182. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_rmf.py +0 -0
  183. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_rmf_graph.py +0 -0
  184. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_rmf_stig_graph.py +0 -0
  185. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_rolling_plan.py +0 -0
  186. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_runaway_repetition.py +0 -0
  187. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_screenshot.py +0 -0
  188. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_server_probe.py +0 -0
  189. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_skills.py +0 -0
  190. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_sqlite_events.py +0 -0
  191. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_stall_retry.py +0 -0
  192. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_stig.py +0 -0
  193. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_stop.py +0 -0
  194. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_streaming_newlines.py +0 -0
  195. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_subagent.py +0 -0
  196. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_subagents.py +0 -0
  197. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_suggest.py +0 -0
  198. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_swarm.py +0 -0
  199. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_system_prompt_help.py +0 -0
  200. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_task_state.py +0 -0
  201. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_timeline.py +0 -0
  202. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_todo.py +0 -0
  203. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_tool_arg_coercion.py +0 -0
  204. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_tool_arg_coercion_more.py +0 -0
  205. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_tool_arg_parsing.py +0 -0
  206. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_tool_canonical.py +0 -0
  207. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_tool_policy.py +0 -0
  208. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_tool_result.py +0 -0
  209. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_tool_select.py +0 -0
  210. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_tool_validate.py +0 -0
  211. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_tools_undo.py +0 -0
  212. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_trajectory.py +0 -0
  213. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_tui.py +0 -0
  214. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_tuning.py +0 -0
  215. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_verification_gate.py +0 -0
  216. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_viewimage.py +0 -0
  217. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_vision_input.py +0 -0
  218. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_web_tools.py +0 -0
  219. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_windows_shell.py +0 -0
  220. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_worker_subagent.py +0 -0
  221. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_write_content_coerce.py +0 -0
  222. {drydock_cli-3.1.28 → drydock_cli-3.1.30}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.1.28
3
+ Version: 3.1.30
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -6,14 +6,15 @@ agents just queue — more tokens, no more wall-clock. So "hammer the 8-GPU vLLM
6
6
  down the -np 2 laptop" reduces to: estimate the server's concurrency C, then size the swarm
7
7
  to min(C, task-demand, budget). This module estimates C.
8
8
 
9
- Detection order (first hit wins), all swallow-error / never-raise:
10
- 1. explicit — `config['swarm_concurrency']` or $DRYDOCK_SWARM_CONCURRENCY (the operator
11
- knows their hardware; always the most reliable);
12
- 2. llama.cpp — GET /slots (its `-np` parallel slots);
13
- 3. empirical probe — fire a burst of tiny concurrent requests and infer parallelism from
14
- wall-time vs single-request latency (server-agnostic; the honest signal for vLLM, whose
15
- max_num_seqs isn't exposed over the OpenAI API);
16
- 4. fallback — a conservative default.
9
+ Detection is FULLY AUTOMATIC — the user configures nothing. Order (first hit wins), all
10
+ swallow-error / never-raise:
11
+ 1. llama.cpp — GET /slots (its `-np` parallel slots), a free exact read;
12
+ 2. empirical ramp probe — double a burst of tiny concurrent requests until the server
13
+ saturates (server-agnostic; the honest signal for vLLM, whose max_num_seqs isn't exposed
14
+ over the OpenAI API — an 8-GPU box reads big, a -np 2 laptop reads small, with no config);
15
+ 3. fallback — a conservative default.
16
+ An OPTIONAL escape hatch, checked first and needed by no one: `config['swarm_concurrency']`
17
+ or $DRYDOCK_SWARM_CONCURRENCY, for an operator who wants to pin it or skip probing entirely.
17
18
 
18
19
  Stdlib-only; the probe funcs are injectable so this is testable without a live server.
19
20
  """
@@ -71,37 +72,66 @@ def _default_requester(base_url: str, model: str) -> Callable[[], None]:
71
72
  return req
72
73
 
73
74
 
74
- def probe_concurrency(base_url: str, model: str, *, burst: int = 16,
75
- requester: Callable[[], None] | None = None) -> int:
76
- """Empirically estimate useful parallelism: one warm request costs t1; `burst` concurrent
77
- requests finish in wall time W. Total work ≈ burst·t1 done in W ⇒ parallelism ≈ burst·t1/W
78
- (≈burst if fully batched, ≈1 if serialized). Server-agnostic. Returns 1..burst."""
79
- req = requester or _default_requester(base_url, model)
75
+ _PROBE_CACHE: dict[str, int] = {}
76
+
77
+
78
+ def clear_probe_cache() -> None:
79
+ """Forget cached probe results (mainly for tests / a changed server)."""
80
+ _PROBE_CACHE.clear()
81
+
82
+
83
+ def _safe_call(req: Callable[[], None]) -> None:
80
84
  try:
81
- t0 = time.monotonic()
82
85
  req()
83
- t1 = time.monotonic() - t0
84
86
  except Exception: # noqa: BLE001
85
- return 1
86
- if t1 <= 0:
87
- return burst
87
+ pass
88
+
89
+
90
+ def _burst_wall(req: Callable[[], None], b: int) -> float:
88
91
  start = time.monotonic()
89
92
  try:
90
- with concurrent.futures.ThreadPoolExecutor(max_workers=burst) as ex:
91
- list(ex.map(lambda _: _safe_call(req), range(burst)))
93
+ with concurrent.futures.ThreadPoolExecutor(max_workers=b) as ex:
94
+ list(ex.map(lambda _: _safe_call(req), range(b)))
92
95
  except Exception: # noqa: BLE001
93
- return 1
94
- wall = time.monotonic() - start
95
- if wall <= 0:
96
- return burst
97
- return max(1, min(burst, round(burst * t1 / wall)))
98
-
99
-
100
- def _safe_call(req: Callable[[], None]) -> None:
96
+ return -1.0
97
+ return time.monotonic() - start
98
+
99
+
100
+ def probe_concurrency(base_url: str, model: str, *, max_probe: int = 64,
101
+ requester: Callable[[], None] | None = None, cache: bool = True) -> int:
102
+ """Empirically estimate useful parallelism with ZERO configuration, by RAMPING the burst
103
+ until the server saturates — so an 8-GPU vLLM box is detected as big and a -np 2 laptop
104
+ as small, without anyone declaring anything.
105
+
106
+ One warm request costs t1; a burst of B concurrent requests finishes in wall W, so
107
+ parallelism P ≈ B·t1/W (≈B if fully batched, ≈1 if serialized). Ramp B = 4, 8, 16, …:
108
+ while the server keeps the whole burst parallel (P ≈ B) it can take more, so double B;
109
+ the first time it can't (P < B) is its ceiling. Cached per server (the probe costs a
110
+ handful of max_tokens=1 requests, run once)."""
111
+ if cache and base_url in _PROBE_CACHE:
112
+ return _PROBE_CACHE[base_url]
113
+ req = requester or _default_requester(base_url, model)
101
114
  try:
115
+ t0 = time.monotonic()
102
116
  req()
103
- except Exception: # noqa: BLE001
104
- pass
117
+ t1 = time.monotonic() - t0
118
+ except Exception: # noqa: BLE001 — server unreachable
119
+ return 1
120
+ if t1 <= 0:
121
+ result = max_probe
122
+ else:
123
+ best, burst = 1, 4
124
+ while burst <= max_probe:
125
+ wall = _burst_wall(req, burst)
126
+ p = burst if wall <= 0 else max(1, min(burst, round(burst * t1 / wall)))
127
+ best = max(best, p)
128
+ if p < burst * 0.75: # couldn't keep the whole burst parallel → ceiling
129
+ break
130
+ burst *= 2
131
+ result = best
132
+ if cache:
133
+ _PROBE_CACHE[base_url] = result
134
+ return result
105
135
 
106
136
 
107
137
  def detect_concurrency(base_url: str, *, provider: str = "vllm", model: str = "",
@@ -383,6 +383,15 @@ def main():
383
383
  cfg["cwd"] = os.getcwd()
384
384
  sys.exit(swarm_run_cli(sys.argv[2:], config=cfg))
385
385
 
386
+ # `drydock mission <create|status|run|resume|…>` — long-horizon autonomous missions.
387
+ if len(sys.argv) > 1 and sys.argv[1] == "mission":
388
+ from drydock import config as cfgmod
389
+ from drydock.mission_cli import run_cli as mission_run_cli
390
+ cfg = cfgmod.resolve({}, cfgmod.default_config_path())
391
+ cfgmod.resolve_active_model(cfg)
392
+ cfg["cwd"] = os.getcwd()
393
+ sys.exit(mission_run_cli(sys.argv[2:], config=cfg))
394
+
386
395
  parser = argparse.ArgumentParser(description="DryDock — local coding agent")
387
396
  parser.add_argument(
388
397
  "--version", "-V", action="version", version=f"drydock {__version__}"
@@ -102,10 +102,11 @@ DEFAULTS: dict[str, object] = {
102
102
  # (docs/multi_agent_swarm_prd.md). On by default; set false to keep every request single-
103
103
  # agent unless the user runs /swarm explicitly.
104
104
  "swarm_auto_escalate": True,
105
- # Swarm sizing: the harness caps agents at the inference server's real concurrency
106
- # (drydock/capacity.py) — hammer an 8-GPU vLLM box, tone down a -np 2 laptop. Set
107
- # swarm_concurrency to your server's parallel capacity to skip auto-detection (most
108
- # reliable); 0 = auto-detect (llama.cpp /slots, else an empirical burst probe).
105
+ # Swarm sizing is FULLY AUTOMATIC: the harness detects the inference server's real
106
+ # concurrency (drydock/capacity.py — llama.cpp /slots, else an empirical ramp probe) and
107
+ # caps agents at it, so an 8-GPU vLLM box gets hammered and a -np 2 laptop toned down with
108
+ # NO configuration. swarm_concurrency is an optional pin (0 = auto-detect, the default);
109
+ # nobody needs to set it.
109
110
  "swarm_concurrency": 0,
110
111
  "swarm_concurrency_default": 4,
111
112
  # URL substrings the web tools refuse: WebSearch drops matching results,
@@ -0,0 +1,471 @@
1
+ """Long-horizon autonomous missions — durable state layer.
2
+
3
+ See docs/mission_prd.md. The architectural principle (§50/§52): *missions* run for
4
+ hours/days, *agents* are disposable workers doing bounded units against durable state — "do
5
+ not make the agent's loop longer, make the work durable." So the mission's objective, tasks,
6
+ experiments, knowledge, checkpoints, budgets, and event log all live OUTSIDE any model
7
+ context, in SQLite (§11), and survive worker crashes and Drydock restarts (§28, G1).
8
+
9
+ This module is the state layer (Phase 1 foundation): schema + `MissionStore`, a transactional,
10
+ model-neutral (§33) CRUD/queue API. Higher layers (planner, worker, evaluator, controller,
11
+ CLI) build on it. Stdlib-only (sqlite3); logging-style writes never raise.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ import sqlite3
18
+ import time
19
+ import uuid
20
+ from pathlib import Path
21
+
22
+ # ── mission lifecycle (§8) ────────────────────────────────────────────────────
23
+ M_CREATED = "CREATED"
24
+ M_INITIALIZING = "INITIALIZING"
25
+ M_BASELINING = "BASELINING"
26
+ M_PLANNING = "PLANNING"
27
+ M_EXECUTING = "EXECUTING"
28
+ M_EVALUATING = "EVALUATING"
29
+ M_STRATEGIC_REVIEW = "STRATEGIC_REVIEW"
30
+ M_COMPLETED = "COMPLETED"
31
+ M_PAUSED = "PAUSED"
32
+ M_BLOCKED = "BLOCKED"
33
+ M_FAILED = "FAILED"
34
+ M_CANCELLED = "CANCELLED"
35
+ M_BUDGET_EXHAUSTED = "BUDGET_EXHAUSTED"
36
+ M_AWAITING_HUMAN = "AWAITING_HUMAN"
37
+ _TERMINAL = {M_COMPLETED, M_FAILED, M_CANCELLED, M_BUDGET_EXHAUSTED}
38
+
39
+ # ── task lifecycle (§25) ──────────────────────────────────────────────────────
40
+ T_PENDING = "PENDING"
41
+ T_READY = "READY"
42
+ T_LEASED = "LEASED"
43
+ T_RUNNING = "RUNNING"
44
+ T_EVALUATING = "EVALUATING"
45
+ T_COMPLETED = "COMPLETED"
46
+ T_FAILED = "FAILED"
47
+ T_BLOCKED = "BLOCKED"
48
+ T_CANCELLED = "CANCELLED"
49
+
50
+ _SCHEMA = """
51
+ CREATE TABLE IF NOT EXISTS missions (
52
+ id TEXT PRIMARY KEY, objective TEXT NOT NULL, success_criteria TEXT, status TEXT,
53
+ budget TEXT, config TEXT, baseline TEXT, current_metric REAL, best_metric REAL,
54
+ created_at REAL, updated_at REAL
55
+ );
56
+ CREATE TABLE IF NOT EXISTS tasks (
57
+ id TEXT PRIMARY KEY, mission_id TEXT, milestone TEXT, objective TEXT, reason TEXT,
58
+ status TEXT, priority INTEGER, deps TEXT, inputs TEXT, constraints TEXT,
59
+ allowed_tools TEXT, success_criteria TEXT, assignee TEXT, lease_expires REAL,
60
+ attempts INTEGER DEFAULT 0, result TEXT, created_at REAL, updated_at REAL
61
+ );
62
+ CREATE INDEX IF NOT EXISTS idx_tasks_mission ON tasks(mission_id, status);
63
+ CREATE TABLE IF NOT EXISTS events (
64
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, mission_id TEXT, ts REAL, type TEXT, data TEXT
65
+ );
66
+ CREATE INDEX IF NOT EXISTS idx_events_mission ON events(mission_id, seq);
67
+ CREATE TABLE IF NOT EXISTS usage (
68
+ mission_id TEXT PRIMARY KEY, tokens INTEGER DEFAULT 0, wall_s REAL DEFAULT 0,
69
+ experiments INTEGER DEFAULT 0, failures INTEGER DEFAULT 0
70
+ );
71
+ CREATE TABLE IF NOT EXISTS knowledge (
72
+ id INTEGER PRIMARY KEY AUTOINCREMENT, mission_id TEXT, ts REAL, type TEXT,
73
+ statement TEXT, confidence REAL, sources TEXT
74
+ );
75
+ CREATE INDEX IF NOT EXISTS idx_knowledge_mission ON knowledge(mission_id, type);
76
+ CREATE TABLE IF NOT EXISTS reviews (
77
+ id INTEGER PRIMARY KEY AUTOINCREMENT, mission_id TEXT, ts REAL, summary TEXT
78
+ );
79
+ CREATE INDEX IF NOT EXISTS idx_reviews_mission ON reviews(mission_id, id);
80
+ """
81
+
82
+ # Knowledge types (§15). negative_result/failure are the "don't repeat" memory (§16).
83
+ K_FINDING = "finding"
84
+ K_ASSUMPTION = "assumption"
85
+ K_HYPOTHESIS = "hypothesis"
86
+ K_DECISION = "decision"
87
+ K_FAILURE = "failure"
88
+ K_CONSTRAINT = "constraint"
89
+ K_MEASUREMENT = "measurement"
90
+ K_NEGATIVE = "negative_result"
91
+ _STOP = {"the", "a", "an", "to", "of", "and", "or", "in", "on", "for", "is", "it", "this",
92
+ "that", "with", "as", "by", "at", "be", "make", "one", "toward", "objective"}
93
+
94
+
95
+ def _now() -> float:
96
+ return time.time()
97
+
98
+
99
+ def _j(v) -> str:
100
+ try:
101
+ return json.dumps(v, default=str)
102
+ except (TypeError, ValueError):
103
+ return "null"
104
+
105
+
106
+ def _u(s) -> object:
107
+ try:
108
+ return json.loads(s) if s else None
109
+ except (TypeError, ValueError):
110
+ return None
111
+
112
+
113
+ def _words(text: str) -> set[str]:
114
+ """Lower-cased content words (len>2, not stop-words) — the similarity key for §16."""
115
+ return {w for w in re.findall(r"[a-z0-9_]+", (text or "").lower())
116
+ if len(w) > 2 and w not in _STOP}
117
+
118
+
119
+ class MissionStore:
120
+ """SQLite-backed durable mission state. Transactional for every state change (§11);
121
+ the event log is append-only and immutable (§12). Model-neutral — a mission started by
122
+ one model resumes under another (§33)."""
123
+
124
+ def __init__(self, db_path: str | Path):
125
+ self.path = Path(db_path)
126
+ try:
127
+ self.path.parent.mkdir(parents=True, exist_ok=True)
128
+ except OSError:
129
+ pass
130
+ self.conn = sqlite3.connect(str(self.path), timeout=30, isolation_level=None)
131
+ self.conn.row_factory = sqlite3.Row
132
+ self.conn.execute("PRAGMA journal_mode=WAL")
133
+ self.conn.executescript(_SCHEMA)
134
+
135
+ def close(self) -> None:
136
+ try:
137
+ self.conn.close()
138
+ except sqlite3.Error:
139
+ pass
140
+
141
+ # ── missions ──────────────────────────────────────────────────────────────
142
+ def create_mission(self, objective: str, *, success_criteria: dict | None = None,
143
+ budget: dict | None = None, config: dict | None = None,
144
+ mission_id: str | None = None) -> str:
145
+ mid = mission_id or f"mission-{uuid.uuid4().hex[:8]}"
146
+ now = _now()
147
+ with self.conn:
148
+ self.conn.execute(
149
+ "INSERT INTO missions(id,objective,success_criteria,status,budget,config,"
150
+ "created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)",
151
+ (mid, objective, _j(success_criteria or {}), M_CREATED, _j(budget or {}),
152
+ _j(config or {}), now, now))
153
+ self.conn.execute("INSERT OR IGNORE INTO usage(mission_id) VALUES(?)", (mid,))
154
+ self.event(mid, "mission_created", objective=objective,
155
+ success_criteria=success_criteria or {}, budget=budget or {})
156
+ return mid
157
+
158
+ def get_mission(self, mission_id: str) -> dict | None:
159
+ r = self.conn.execute("SELECT * FROM missions WHERE id=?", (mission_id,)).fetchone()
160
+ if r is None:
161
+ return None
162
+ m = dict(r)
163
+ for k in ("success_criteria", "budget", "config", "baseline"):
164
+ m[k] = _u(m.get(k))
165
+ return m
166
+
167
+ def list_missions(self) -> list[dict]:
168
+ rows = self.conn.execute(
169
+ "SELECT id,objective,status,current_metric,best_metric,created_at "
170
+ "FROM missions ORDER BY created_at").fetchall()
171
+ return [dict(r) for r in rows]
172
+
173
+ def set_status(self, mission_id: str, status: str) -> None:
174
+ with self.conn:
175
+ self.conn.execute("UPDATE missions SET status=?,updated_at=? WHERE id=?",
176
+ (status, _now(), mission_id))
177
+ self.event(mission_id, "status", status=status)
178
+
179
+ def set_baseline(self, mission_id: str, baseline: dict) -> None:
180
+ with self.conn:
181
+ self.conn.execute("UPDATE missions SET baseline=?,updated_at=? WHERE id=?",
182
+ (_j(baseline), _now(), mission_id))
183
+ self.event(mission_id, "baseline", baseline=baseline) # immutable in history (§9)
184
+
185
+ def set_metric(self, mission_id: str, current: float) -> None:
186
+ """Record the mission's current metric, tracking the best seen (§38)."""
187
+ with self.conn:
188
+ m = self.conn.execute("SELECT best_metric FROM missions WHERE id=?",
189
+ (mission_id,)).fetchone()
190
+ best = m["best_metric"] if m and m["best_metric"] is not None else None
191
+ new_best = current if best is None else max(best, current)
192
+ self.conn.execute("UPDATE missions SET current_metric=?,best_metric=?,updated_at=? "
193
+ "WHERE id=?", (current, new_best, _now(), mission_id))
194
+
195
+ # ── tasks / queue (§25) ─────────────────────────────────────────────────────
196
+ def add_task(self, mission_id: str, objective: str, *, reason: str = "", priority: int = 0,
197
+ deps: list[str] | None = None, milestone: str = "", inputs: dict | None = None,
198
+ constraints: dict | None = None, allowed_tools: list[str] | None = None,
199
+ success_criteria: dict | None = None) -> str:
200
+ tid = f"task-{uuid.uuid4().hex[:8]}"
201
+ now = _now()
202
+ status = T_READY if not deps else T_PENDING
203
+ with self.conn:
204
+ self.conn.execute(
205
+ "INSERT INTO tasks(id,mission_id,milestone,objective,reason,status,priority,"
206
+ "deps,inputs,constraints,allowed_tools,success_criteria,created_at,updated_at) "
207
+ "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
208
+ (tid, mission_id, milestone, objective, reason, status, priority, _j(deps or []),
209
+ _j(inputs or {}), _j(constraints or {}), _j(allowed_tools or []),
210
+ _j(success_criteria or {}), now, now))
211
+ self.event(mission_id, "task_created", task=tid, objective=objective, priority=priority)
212
+ return tid
213
+
214
+ def get_task(self, task_id: str) -> dict | None:
215
+ r = self.conn.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
216
+ if r is None:
217
+ return None
218
+ t = dict(r)
219
+ for k in ("deps", "inputs", "constraints", "allowed_tools", "success_criteria", "result"):
220
+ t[k] = _u(t.get(k))
221
+ return t
222
+
223
+ def tasks(self, mission_id: str) -> list[dict]:
224
+ rows = self.conn.execute(
225
+ "SELECT * FROM tasks WHERE mission_id=? ORDER BY priority DESC, created_at",
226
+ (mission_id,)).fetchall()
227
+ out = []
228
+ for r in rows:
229
+ t = dict(r)
230
+ for k in ("deps", "inputs", "constraints", "allowed_tools", "success_criteria", "result"):
231
+ t[k] = _u(t.get(k))
232
+ out.append(t)
233
+ return out
234
+
235
+ def _completed_ids(self, mission_id: str) -> set[str]:
236
+ return {r["id"] for r in self.conn.execute(
237
+ "SELECT id FROM tasks WHERE mission_id=? AND status=?",
238
+ (mission_id, T_COMPLETED)).fetchall()}
239
+
240
+ def promote_ready(self, mission_id: str) -> int:
241
+ """Move PENDING tasks whose deps are all COMPLETED to READY. Returns #promoted."""
242
+ done = self._completed_ids(mission_id)
243
+ n = 0
244
+ with self.conn:
245
+ for r in self.conn.execute(
246
+ "SELECT id,deps FROM tasks WHERE mission_id=? AND status=?",
247
+ (mission_id, T_PENDING)).fetchall():
248
+ deps = _u(r["deps"])
249
+ deps = deps if isinstance(deps, list) else []
250
+ if all(d in done for d in deps):
251
+ self.conn.execute("UPDATE tasks SET status=?,updated_at=? WHERE id=?",
252
+ (T_READY, _now(), r["id"]))
253
+ n += 1
254
+ return n
255
+
256
+ def ready_tasks(self, mission_id: str) -> list[dict]:
257
+ self.promote_ready(mission_id)
258
+ rows = self.conn.execute(
259
+ "SELECT * FROM tasks WHERE mission_id=? AND status=? "
260
+ "ORDER BY priority DESC, created_at", (mission_id, T_READY)).fetchall()
261
+ return [t for t in (self.get_task(r["id"]) for r in rows) if t is not None]
262
+
263
+ def claim_task(self, task_id: str, worker: str, lease_secs: int = 300) -> bool:
264
+ """Atomically lease a READY task (§26). Returns False if it isn't claimable —
265
+ crash-safe and swarm-ready (many workers can race for the same queue)."""
266
+ now = _now()
267
+ with self.conn:
268
+ cur = self.conn.execute(
269
+ "UPDATE tasks SET status=?,assignee=?,lease_expires=?,attempts=attempts+1,"
270
+ "updated_at=? WHERE id=? AND status=?",
271
+ (T_RUNNING, worker, now + lease_secs, now, task_id, T_READY))
272
+ ok = cur.rowcount == 1
273
+ if ok:
274
+ t = self.get_task(task_id)
275
+ if t:
276
+ self.event(t["mission_id"], "task_leased", task=task_id, worker=worker,
277
+ lease_expires=now + lease_secs)
278
+ return ok
279
+
280
+ def heartbeat(self, task_id: str, lease_secs: int = 300) -> None:
281
+ with self.conn:
282
+ self.conn.execute("UPDATE tasks SET lease_expires=?,updated_at=? WHERE id=?",
283
+ (_now() + lease_secs, _now(), task_id))
284
+
285
+ def reclaim_expired(self, mission_id: str) -> int:
286
+ """Return RUNNING/LEASED tasks whose lease expired to READY (§26/§28 recovery)."""
287
+ now = _now()
288
+ n = 0
289
+ with self.conn:
290
+ for r in self.conn.execute(
291
+ "SELECT id FROM tasks WHERE mission_id=? AND status IN (?,?) "
292
+ "AND lease_expires IS NOT NULL AND lease_expires<?",
293
+ (mission_id, T_RUNNING, T_LEASED, now)).fetchall():
294
+ self.conn.execute("UPDATE tasks SET status=?,assignee=NULL,lease_expires=NULL,"
295
+ "updated_at=? WHERE id=?", (T_READY, now, r["id"]))
296
+ self.event(mission_id, "lease_expired", task=r["id"])
297
+ n += 1
298
+ return n
299
+
300
+ def complete_task(self, task_id: str, status: str, result: dict | None = None) -> None:
301
+ with self.conn:
302
+ self.conn.execute("UPDATE tasks SET status=?,result=?,assignee=NULL,"
303
+ "lease_expires=NULL,updated_at=? WHERE id=?",
304
+ (status, _j(result or {}), _now(), task_id))
305
+ t = self.get_task(task_id)
306
+ if t:
307
+ self.event(t["mission_id"], "task_done", task=task_id, status=status)
308
+ if status == T_FAILED:
309
+ self.add_usage(t["mission_id"], failures=1)
310
+
311
+ # ── event log (§12) — immutable, append-only ───────────────────────────────
312
+ def event(self, mission_id: str, type: str, **data) -> None:
313
+ try:
314
+ with self.conn:
315
+ self.conn.execute("INSERT INTO events(mission_id,ts,type,data) VALUES(?,?,?,?)",
316
+ (mission_id, _now(), type, _j(data)))
317
+ except sqlite3.Error:
318
+ pass # logging must never break the mission
319
+
320
+ def events(self, mission_id: str, limit: int = 0) -> list[dict]:
321
+ q = "SELECT seq,ts,type,data FROM events WHERE mission_id=? ORDER BY seq"
322
+ if limit:
323
+ q += f" DESC LIMIT {int(limit)}"
324
+ rows = self.conn.execute(q, (mission_id,)).fetchall()
325
+ out = []
326
+ for r in rows:
327
+ d = _u(r["data"])
328
+ d = d if isinstance(d, dict) else {}
329
+ out.append({"seq": r["seq"], "ts": r["ts"], "type": r["type"], **d})
330
+ return out[::-1] if limit else out
331
+
332
+ # ── knowledge store (§15) + negative knowledge (§16) ────────────────────────
333
+ def add_knowledge(self, mission_id: str, type: str, statement: str, *,
334
+ confidence: float = 0.5, sources: list | None = None) -> int:
335
+ now = _now()
336
+ with self.conn:
337
+ cur = self.conn.execute(
338
+ "INSERT INTO knowledge(mission_id,ts,type,statement,confidence,sources) "
339
+ "VALUES(?,?,?,?,?,?)", (mission_id, now, type, statement, confidence,
340
+ _j(sources or [])))
341
+ return int(cur.lastrowid or 0)
342
+
343
+ def knowledge(self, mission_id: str, type: str | None = None) -> list[dict]:
344
+ if type:
345
+ rows = self.conn.execute(
346
+ "SELECT * FROM knowledge WHERE mission_id=? AND type=? ORDER BY id",
347
+ (mission_id, type)).fetchall()
348
+ else:
349
+ rows = self.conn.execute("SELECT * FROM knowledge WHERE mission_id=? ORDER BY id",
350
+ (mission_id,)).fetchall()
351
+ out = []
352
+ for r in rows:
353
+ k = dict(r)
354
+ k["sources"] = _u(k.get("sources")) or []
355
+ out.append(k)
356
+ return out
357
+
358
+ def similar_knowledge(self, mission_id: str, text: str, *, type: str | None = None,
359
+ top: int = 5) -> list[dict]:
360
+ """Retrieve prior knowledge most similar to `text` by content-word overlap (§16). A
361
+ cheap, model-free relevance signal — the Planner uses it to surface failed approaches
362
+ before repeating them; semantic embeddings are a later phase."""
363
+ want = _words(text)
364
+ if not want:
365
+ return []
366
+ scored = []
367
+ for k in self.knowledge(mission_id, type):
368
+ have = _words(k["statement"])
369
+ if not have:
370
+ continue
371
+ overlap = len(want & have) / len(want | have)
372
+ if overlap > 0:
373
+ scored.append((overlap, k))
374
+ scored.sort(key=lambda x: x[0], reverse=True)
375
+ return [k for _, k in scored[:top]]
376
+
377
+ # ── strategic reviews (§24) ─────────────────────────────────────────────────
378
+ def record_review(self, mission_id: str, summary: dict) -> int:
379
+ with self.conn:
380
+ cur = self.conn.execute(
381
+ "INSERT INTO reviews(mission_id,ts,summary) VALUES(?,?,?)",
382
+ (mission_id, _now(), _j(summary)))
383
+ return int(cur.lastrowid or 0)
384
+
385
+ def reviews(self, mission_id: str) -> list[dict]:
386
+ rows = self.conn.execute("SELECT * FROM reviews WHERE mission_id=? ORDER BY id",
387
+ (mission_id,)).fetchall()
388
+ out = []
389
+ for r in rows:
390
+ d = dict(r)
391
+ d["summary"] = _u(d.get("summary")) or {}
392
+ out.append(d)
393
+ return out
394
+
395
+ def last_review_ts(self, mission_id: str) -> float:
396
+ """ts of the most recent review, else the mission's created_at (so the first
397
+ elapsed-time trigger measures from mission start). Survives resume (§28)."""
398
+ row = self.conn.execute(
399
+ "SELECT MAX(ts) AS t FROM reviews WHERE mission_id=?", (mission_id,)).fetchone()
400
+ if row and row["t"] is not None:
401
+ return float(row["t"])
402
+ m = self.get_mission(mission_id)
403
+ return float(m["created_at"]) if m else _now()
404
+
405
+ def experiments_since(self, mission_id: str, ts: float) -> int:
406
+ """Count of KEEP/REVERT experiments recorded after `ts` — the task-count review
407
+ trigger (§24). Derived from the durable event log, so it is resume-correct."""
408
+ row = self.conn.execute(
409
+ "SELECT COUNT(*) AS n FROM events WHERE mission_id=? AND type='experiment' AND ts>?",
410
+ (mission_id, ts)).fetchone()
411
+ return int(row["n"]) if row else 0
412
+
413
+ # ── budgets / usage (§29/§30) ───────────────────────────────────────────────
414
+ def add_usage(self, mission_id: str, *, tokens: int = 0, wall_s: float = 0.0,
415
+ experiments: int = 0, failures: int = 0) -> None:
416
+ with self.conn:
417
+ self.conn.execute(
418
+ "UPDATE usage SET tokens=tokens+?,wall_s=wall_s+?,experiments=experiments+?,"
419
+ "failures=failures+? WHERE mission_id=?",
420
+ (tokens, wall_s, experiments, failures, mission_id))
421
+
422
+ def usage(self, mission_id: str) -> dict:
423
+ r = self.conn.execute("SELECT * FROM usage WHERE mission_id=?", (mission_id,)).fetchone()
424
+ return dict(r) if r else {"tokens": 0, "wall_s": 0.0, "experiments": 0, "failures": 0}
425
+
426
+ def budget_exhausted(self, mission_id: str) -> tuple[bool, str]:
427
+ """True + reason if any finite budget is spent (§30). Wall-clock is measured from
428
+ mission creation; the others from recorded usage."""
429
+ m = self.get_mission(mission_id)
430
+ if not m:
431
+ return False, ""
432
+ b = m.get("budget") or {}
433
+ u = self.usage(mission_id)
434
+ wall_h = (_now() - (m.get("created_at") or _now())) / 3600.0
435
+ checks = [
436
+ ("wall_time_hours", wall_h, "wall-clock budget"),
437
+ ("token_budget", u["tokens"], "token budget"),
438
+ ("max_experiments", u["experiments"], "experiment budget"),
439
+ ("max_failures", u["failures"], "failure budget"),
440
+ ]
441
+ for key, spent, label in checks:
442
+ cap = b.get(key)
443
+ if cap and spent >= cap:
444
+ return True, f"{label} reached ({spent:.0f} ≥ {cap})"
445
+ return False, ""
446
+
447
+
448
+ # ── workspace + factory (§40) ─────────────────────────────────────────────────
449
+ def missions_dir(cwd: str | Path) -> Path:
450
+ return Path(cwd) / ".drydock" / "missions"
451
+
452
+
453
+ def open_store(cwd: str | Path, mission_id: str) -> MissionStore:
454
+ return MissionStore(missions_dir(cwd) / mission_id / "state.db")
455
+
456
+
457
+ def create_mission(cwd: str | Path, objective: str, *, success_criteria: dict | None = None,
458
+ budget: dict | None = None, config: dict | None = None) -> tuple[str, MissionStore]:
459
+ """Create a mission with its own workspace under <cwd>/.drydock/missions/<id>/ (§40)."""
460
+ mid = f"mission-{uuid.uuid4().hex[:8]}"
461
+ store = MissionStore(missions_dir(cwd) / mid / "state.db")
462
+ store.create_mission(objective, success_criteria=success_criteria, budget=budget,
463
+ config=config, mission_id=mid)
464
+ return mid, store
465
+
466
+
467
+ def list_missions(cwd: str | Path) -> list[str]:
468
+ try:
469
+ return sorted(p.name for p in missions_dir(cwd).iterdir() if p.is_dir())
470
+ except OSError:
471
+ return []