drydock-cli 3.1.29__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.29/drydock_cli.egg-info → drydock_cli-3.1.30}/PKG-INFO +1 -1
  2. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/cli.py +9 -0
  3. drydock_cli-3.1.30/drydock/mission.py +471 -0
  4. drydock_cli-3.1.30/drydock/mission_cli.py +362 -0
  5. drydock_cli-3.1.30/drydock/mission_run.py +583 -0
  6. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/ratchet.py +16 -11
  7. {drydock_cli-3.1.29 → drydock_cli-3.1.30/drydock_cli.egg-info}/PKG-INFO +1 -1
  8. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock_cli.egg-info/SOURCES.txt +6 -0
  9. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/pyproject.toml +1 -1
  10. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_capacity.py +2 -2
  11. drydock_cli-3.1.30/tests/test_mission.py +136 -0
  12. drydock_cli-3.1.30/tests/test_mission_cli.py +155 -0
  13. drydock_cli-3.1.30/tests/test_mission_run.py +438 -0
  14. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_ratchet.py +13 -0
  15. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/LICENSE +0 -0
  16. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/NOTICE +0 -0
  17. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/README.md +0 -0
  18. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/__init__.py +0 -0
  19. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/__main__.py +0 -0
  20. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/advisor.py +0 -0
  21. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/agent.py +0 -0
  22. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/bash_safety.py +0 -0
  23. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/bottleneck.py +0 -0
  24. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/budget.py +0 -0
  25. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/__init__.py +0 -0
  26. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/document-canvas.md +0 -0
  27. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/fiar-assess.md +0 -0
  28. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/fiar-cap.md +0 -0
  29. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/fiar-evidence.md +0 -0
  30. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/fiar-readiness.md +0 -0
  31. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/ml-data.md +0 -0
  32. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/ml-debug.md +0 -0
  33. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/ml-finetune.md +0 -0
  34. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/ml-metrics.md +0 -0
  35. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/ml-rl.md +0 -0
  36. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/ml-train.md +0 -0
  37. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/nist-ai-rmf.md +0 -0
  38. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/nist-csf.md +0 -0
  39. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/rmf-categorize.md +0 -0
  40. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/rmf-control.md +0 -0
  41. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/rmf-poam.md +0 -0
  42. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/rmf-review.md +0 -0
  43. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/stig-assess.md +0 -0
  44. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/builtin_skills/stig-remediate.md +0 -0
  45. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/capacity.py +0 -0
  46. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/cci.py +0 -0
  47. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/comms/__init__.py +0 -0
  48. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/comms/attention.py +0 -0
  49. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/comms/channels.py +0 -0
  50. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/comms/events.py +0 -0
  51. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/compaction.py +0 -0
  52. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/config.py +0 -0
  53. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/detect.py +0 -0
  54. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/doccanvas.py +0 -0
  55. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/eratchet.py +0 -0
  56. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/events.py +0 -0
  57. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/extract.py +0 -0
  58. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/fiar.py +0 -0
  59. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/gittools.py +0 -0
  60. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/graphrag.py +0 -0
  61. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/groundtruth.py +0 -0
  62. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/guards.py +0 -0
  63. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/jobs.py +0 -0
  64. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/loop_detect.py +0 -0
  65. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/mcp.py +0 -0
  66. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/pdfredbox.py +0 -0
  67. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/phases.py +0 -0
  68. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/poam.py +0 -0
  69. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/predictions.py +0 -0
  70. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/progress.py +0 -0
  71. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/providers.py +0 -0
  72. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/recipes.py +0 -0
  73. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/recovery.py +0 -0
  74. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/resume.py +0 -0
  75. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/rmf.py +0 -0
  76. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/rmf_graph.py +0 -0
  77. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/skills.py +0 -0
  78. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/stig.py +0 -0
  79. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/subagents.py +0 -0
  80. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/suggest.py +0 -0
  81. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/swarm.py +0 -0
  82. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/task_state.py +0 -0
  83. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/tool_policy.py +0 -0
  84. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/tool_registry.py +0 -0
  85. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/tool_result.py +0 -0
  86. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/tool_select.py +0 -0
  87. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/tool_validate.py +0 -0
  88. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/tools/__init__.py +0 -0
  89. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/trajectory.py +0 -0
  90. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/tui/__init__.py +0 -0
  91. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/tui/app.py +0 -0
  92. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/tui/approval.py +0 -0
  93. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/tui/messages.py +0 -0
  94. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/tui/widgets.py +0 -0
  95. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/tuning.py +0 -0
  96. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/verification.py +0 -0
  97. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock/web.py +0 -0
  98. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock_cli.egg-info/dependency_links.txt +0 -0
  99. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock_cli.egg-info/entry_points.txt +0 -0
  100. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock_cli.egg-info/requires.txt +0 -0
  101. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/drydock_cli.egg-info/top_level.txt +0 -0
  102. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/setup.cfg +0 -0
  103. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_advisor.py +0 -0
  104. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_approval.py +0 -0
  105. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_back_command.py +0 -0
  106. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_bash_background.py +0 -0
  107. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_bash_binary_output.py +0 -0
  108. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_bash_crossplatform.py +0 -0
  109. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_bash_output_bounding.py +0 -0
  110. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_bash_process_group.py +0 -0
  111. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_bash_safety.py +0 -0
  112. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_bash_sanitize.py +0 -0
  113. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_bash_shell.py +0 -0
  114. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_bash_stdin.py +0 -0
  115. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_bash_stop_partial.py +0 -0
  116. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_bash_timeout_network.py +0 -0
  117. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_bash_timeout_param.py +0 -0
  118. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_bottleneck.py +0 -0
  119. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_budget.py +0 -0
  120. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_cci.py +0 -0
  121. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_cli_agents.py +0 -0
  122. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_comms.py +0 -0
  123. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_compact_command.py +0 -0
  124. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_compaction.py +0 -0
  125. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_config.py +0 -0
  126. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_config_migration.py +0 -0
  127. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_context_limit_config.py +0 -0
  128. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_context_limit_issue25.py +0 -0
  129. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_criteria_coverage.py +0 -0
  130. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_cycling_detection.py +0 -0
  131. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_declared_deps.py +0 -0
  132. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_degenerate_argument.py +0 -0
  133. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_detect.py +0 -0
  134. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_dispatch.py +0 -0
  135. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_doccanvas.py +0 -0
  136. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_e2e_connected.py +0 -0
  137. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_edit_replace_all.py +0 -0
  138. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_edit_thrash_and_compaction.py +0 -0
  139. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_effort_governor.py +0 -0
  140. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_empty_response.py +0 -0
  141. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_eratchet.py +0 -0
  142. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_events.py +0 -0
  143. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_extract.py +0 -0
  144. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_failure_loop.py +0 -0
  145. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_fiar.py +0 -0
  146. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_first_run_setup.py +0 -0
  147. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_gittools.py +0 -0
  148. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_glob_edit_edges.py +0 -0
  149. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_graphify_example.py +0 -0
  150. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_graphrag.py +0 -0
  151. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_graphrag_quoted_path.py +0 -0
  152. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_graphrag_sqlite.py +0 -0
  153. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_grep_and_read_robust.py +0 -0
  154. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_groundtruth_predictions.py +0 -0
  155. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_guards_and_tools.py +0 -0
  156. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_hallucinated_tools.py +0 -0
  157. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_jobs.py +0 -0
  158. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_knowledge_tool_available.py +0 -0
  159. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_leaked_tool_call.py +0 -0
  160. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_loop_detect.py +0 -0
  161. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_mcp.py +0 -0
  162. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_model_registry.py +0 -0
  163. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_oneshot_unreachable.py +0 -0
  164. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_overthink_interrupt.py +0 -0
  165. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_pdfredbox.py +0 -0
  166. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_phases.py +0 -0
  167. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_plan_autocontinue.py +0 -0
  168. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_poam.py +0 -0
  169. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_progress.py +0 -0
  170. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_providers_unreachable.py +0 -0
  171. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_ratchet_offer.py +0 -0
  172. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_read_index.py +0 -0
  173. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_recipes.py +0 -0
  174. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_recovery.py +0 -0
  175. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_recovery_config.py +0 -0
  176. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_recovery_integration.py +0 -0
  177. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_repeated_outcome.py +0 -0
  178. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_repetition_interrupt.py +0 -0
  179. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_resume_events.py +0 -0
  180. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_resume_restore.py +0 -0
  181. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_resume_snapshot.py +0 -0
  182. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_rmf.py +0 -0
  183. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_rmf_graph.py +0 -0
  184. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_rmf_stig_graph.py +0 -0
  185. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_rolling_plan.py +0 -0
  186. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_runaway_repetition.py +0 -0
  187. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_screenshot.py +0 -0
  188. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_server_probe.py +0 -0
  189. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_skills.py +0 -0
  190. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_sqlite_events.py +0 -0
  191. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_stall_retry.py +0 -0
  192. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_stig.py +0 -0
  193. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_stop.py +0 -0
  194. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_streaming_newlines.py +0 -0
  195. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_subagent.py +0 -0
  196. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_subagents.py +0 -0
  197. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_suggest.py +0 -0
  198. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_swarm.py +0 -0
  199. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_system_prompt_help.py +0 -0
  200. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_task_state.py +0 -0
  201. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_timeline.py +0 -0
  202. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_todo.py +0 -0
  203. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_tool_arg_coercion.py +0 -0
  204. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_tool_arg_coercion_more.py +0 -0
  205. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_tool_arg_parsing.py +0 -0
  206. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_tool_canonical.py +0 -0
  207. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_tool_policy.py +0 -0
  208. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_tool_result.py +0 -0
  209. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_tool_select.py +0 -0
  210. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_tool_validate.py +0 -0
  211. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_tools_undo.py +0 -0
  212. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_trajectory.py +0 -0
  213. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_tui.py +0 -0
  214. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_tuning.py +0 -0
  215. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_verification_gate.py +0 -0
  216. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_viewimage.py +0 -0
  217. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_vision_input.py +0 -0
  218. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_web_tools.py +0 -0
  219. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_windows_shell.py +0 -0
  220. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_worker_subagent.py +0 -0
  221. {drydock_cli-3.1.29 → drydock_cli-3.1.30}/tests/test_write_content_coerce.py +0 -0
  222. {drydock_cli-3.1.29 → 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.29
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
@@ -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__}"
@@ -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 []