drydock-cli 3.1.14__tar.gz → 3.1.16__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 (199) hide show
  1. {drydock_cli-3.1.14/drydock_cli.egg-info → drydock_cli-3.1.16}/PKG-INFO +10 -1
  2. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/README.md +6 -0
  3. drydock_cli-3.1.16/drydock/comms/__init__.py +58 -0
  4. drydock_cli-3.1.16/drydock/comms/attention.py +120 -0
  5. drydock_cli-3.1.16/drydock/comms/channels.py +97 -0
  6. drydock_cli-3.1.16/drydock/comms/events.py +86 -0
  7. {drydock_cli-3.1.14 → drydock_cli-3.1.16/drydock_cli.egg-info}/PKG-INFO +10 -1
  8. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock_cli.egg-info/SOURCES.txt +6 -0
  9. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock_cli.egg-info/requires.txt +3 -0
  10. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/pyproject.toml +12 -2
  11. drydock_cli-3.1.16/tests/test_comms.py +102 -0
  12. drydock_cli-3.1.16/tests/test_declared_deps.py +60 -0
  13. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/LICENSE +0 -0
  14. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/NOTICE +0 -0
  15. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/__init__.py +0 -0
  16. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/__main__.py +0 -0
  17. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/advisor.py +0 -0
  18. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/agent.py +0 -0
  19. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/bash_safety.py +0 -0
  20. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/budget.py +0 -0
  21. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/__init__.py +0 -0
  22. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/document-canvas.md +0 -0
  23. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/fiar-assess.md +0 -0
  24. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/fiar-cap.md +0 -0
  25. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/fiar-evidence.md +0 -0
  26. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/fiar-readiness.md +0 -0
  27. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/ml-data.md +0 -0
  28. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/ml-debug.md +0 -0
  29. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/ml-finetune.md +0 -0
  30. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/ml-metrics.md +0 -0
  31. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/ml-rl.md +0 -0
  32. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/ml-train.md +0 -0
  33. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/nist-ai-rmf.md +0 -0
  34. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/nist-csf.md +0 -0
  35. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/rmf-categorize.md +0 -0
  36. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/rmf-control.md +0 -0
  37. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/rmf-poam.md +0 -0
  38. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/rmf-review.md +0 -0
  39. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/stig-assess.md +0 -0
  40. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/stig-remediate.md +0 -0
  41. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/cci.py +0 -0
  42. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/cli.py +0 -0
  43. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/compaction.py +0 -0
  44. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/config.py +0 -0
  45. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/detect.py +0 -0
  46. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/doccanvas.py +0 -0
  47. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/events.py +0 -0
  48. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/extract.py +0 -0
  49. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/fiar.py +0 -0
  50. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/gittools.py +0 -0
  51. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/graphrag.py +0 -0
  52. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/guards.py +0 -0
  53. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/loop_detect.py +0 -0
  54. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/mcp.py +0 -0
  55. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/phases.py +0 -0
  56. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/poam.py +0 -0
  57. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/progress.py +0 -0
  58. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/providers.py +0 -0
  59. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/ratchet.py +0 -0
  60. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/recipes.py +0 -0
  61. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/recovery.py +0 -0
  62. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/resume.py +0 -0
  63. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/rmf.py +0 -0
  64. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/rmf_graph.py +0 -0
  65. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/skills.py +0 -0
  66. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/stig.py +0 -0
  67. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/subagents.py +0 -0
  68. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/suggest.py +0 -0
  69. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/task_state.py +0 -0
  70. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tool_policy.py +0 -0
  71. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tool_registry.py +0 -0
  72. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tool_result.py +0 -0
  73. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tool_select.py +0 -0
  74. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tool_validate.py +0 -0
  75. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tools/__init__.py +0 -0
  76. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/trajectory.py +0 -0
  77. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tui/__init__.py +0 -0
  78. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tui/app.py +0 -0
  79. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tui/approval.py +0 -0
  80. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tui/messages.py +0 -0
  81. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tui/widgets.py +0 -0
  82. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tuning.py +0 -0
  83. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/verification.py +0 -0
  84. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/web.py +0 -0
  85. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock_cli.egg-info/dependency_links.txt +0 -0
  86. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock_cli.egg-info/entry_points.txt +0 -0
  87. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock_cli.egg-info/top_level.txt +0 -0
  88. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/setup.cfg +0 -0
  89. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_advisor.py +0 -0
  90. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_approval.py +0 -0
  91. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_back_command.py +0 -0
  92. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_background.py +0 -0
  93. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_binary_output.py +0 -0
  94. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_crossplatform.py +0 -0
  95. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_output_bounding.py +0 -0
  96. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_process_group.py +0 -0
  97. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_safety.py +0 -0
  98. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_sanitize.py +0 -0
  99. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_shell.py +0 -0
  100. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_stdin.py +0 -0
  101. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_stop_partial.py +0 -0
  102. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_timeout_network.py +0 -0
  103. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_timeout_param.py +0 -0
  104. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_budget.py +0 -0
  105. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_cci.py +0 -0
  106. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_cli_agents.py +0 -0
  107. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_compact_command.py +0 -0
  108. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_compaction.py +0 -0
  109. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_config.py +0 -0
  110. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_config_migration.py +0 -0
  111. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_context_limit_config.py +0 -0
  112. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_context_limit_issue25.py +0 -0
  113. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_criteria_coverage.py +0 -0
  114. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_cycling_detection.py +0 -0
  115. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_degenerate_argument.py +0 -0
  116. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_detect.py +0 -0
  117. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_dispatch.py +0 -0
  118. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_doccanvas.py +0 -0
  119. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_e2e_connected.py +0 -0
  120. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_edit_replace_all.py +0 -0
  121. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_edit_thrash_and_compaction.py +0 -0
  122. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_effort_governor.py +0 -0
  123. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_empty_response.py +0 -0
  124. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_events.py +0 -0
  125. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_extract.py +0 -0
  126. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_failure_loop.py +0 -0
  127. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_fiar.py +0 -0
  128. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_first_run_setup.py +0 -0
  129. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_gittools.py +0 -0
  130. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_glob_edit_edges.py +0 -0
  131. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_graphify_example.py +0 -0
  132. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_graphrag.py +0 -0
  133. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_graphrag_quoted_path.py +0 -0
  134. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_graphrag_sqlite.py +0 -0
  135. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_grep_and_read_robust.py +0 -0
  136. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_guards_and_tools.py +0 -0
  137. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_hallucinated_tools.py +0 -0
  138. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_leaked_tool_call.py +0 -0
  139. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_loop_detect.py +0 -0
  140. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_mcp.py +0 -0
  141. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_model_registry.py +0 -0
  142. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_oneshot_unreachable.py +0 -0
  143. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_overthink_interrupt.py +0 -0
  144. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_phases.py +0 -0
  145. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_plan_autocontinue.py +0 -0
  146. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_poam.py +0 -0
  147. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_progress.py +0 -0
  148. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_providers_unreachable.py +0 -0
  149. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_ratchet.py +0 -0
  150. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_read_index.py +0 -0
  151. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_recipes.py +0 -0
  152. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_recovery.py +0 -0
  153. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_recovery_config.py +0 -0
  154. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_recovery_integration.py +0 -0
  155. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_repeated_outcome.py +0 -0
  156. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_repetition_interrupt.py +0 -0
  157. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_resume_events.py +0 -0
  158. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_resume_restore.py +0 -0
  159. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_resume_snapshot.py +0 -0
  160. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_rmf.py +0 -0
  161. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_rmf_graph.py +0 -0
  162. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_rmf_stig_graph.py +0 -0
  163. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_rolling_plan.py +0 -0
  164. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_runaway_repetition.py +0 -0
  165. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_screenshot.py +0 -0
  166. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_server_probe.py +0 -0
  167. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_skills.py +0 -0
  168. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_sqlite_events.py +0 -0
  169. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_stall_retry.py +0 -0
  170. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_stig.py +0 -0
  171. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_stop.py +0 -0
  172. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_streaming_newlines.py +0 -0
  173. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_subagent.py +0 -0
  174. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_subagents.py +0 -0
  175. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_suggest.py +0 -0
  176. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_system_prompt_help.py +0 -0
  177. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_task_state.py +0 -0
  178. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_timeline.py +0 -0
  179. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_todo.py +0 -0
  180. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_arg_coercion.py +0 -0
  181. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_arg_coercion_more.py +0 -0
  182. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_arg_parsing.py +0 -0
  183. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_canonical.py +0 -0
  184. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_policy.py +0 -0
  185. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_result.py +0 -0
  186. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_select.py +0 -0
  187. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_validate.py +0 -0
  188. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tools_undo.py +0 -0
  189. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_trajectory.py +0 -0
  190. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tui.py +0 -0
  191. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tuning.py +0 -0
  192. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_verification_gate.py +0 -0
  193. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_viewimage.py +0 -0
  194. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_vision_input.py +0 -0
  195. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_web_tools.py +0 -0
  196. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_windows_shell.py +0 -0
  197. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_worker_subagent.py +0 -0
  198. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_write_content_coerce.py +0 -0
  199. {drydock_cli-3.1.14 → drydock_cli-3.1.16}/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.14
3
+ Version: 3.1.16
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
@@ -13,6 +13,9 @@ License-File: LICENSE
13
13
  License-File: NOTICE
14
14
  Requires-Dist: openai>=1.0
15
15
  Requires-Dist: textual>=0.80
16
+ Requires-Dist: httpx>=0.23
17
+ Requires-Dist: rich>=13.0
18
+ Requires-Dist: pypdf>=3.0
16
19
  Provides-Extra: dev
17
20
  Requires-Dist: pytest; extra == "dev"
18
21
  Requires-Dist: pytest-timeout; extra == "dev"
@@ -350,6 +353,12 @@ pip install drydock-cli
350
353
  drydock
351
354
  ```
352
355
 
356
+ > ⚠️ **The PyPI package is `drydock-cli`, not `drydock`.** `pip install drydock`
357
+ > fetches an unrelated project (a Docker-cluster tool) that ships its own
358
+ > `drydock/` package with no `cli.py`, so the `drydock` command then fails with
359
+ > `No module named 'drydock.cli'`. If that happened, run
360
+ > `pip uninstall -y drydock drydock-cli && pip install drydock-cli`.
361
+
353
362
  Requires Python 3.11+. From source instead:
354
363
  `git clone https://github.com/fbobe321/drydock.git && cd drydock && pip install -e .`
355
364
 
@@ -326,6 +326,12 @@ pip install drydock-cli
326
326
  drydock
327
327
  ```
328
328
 
329
+ > ⚠️ **The PyPI package is `drydock-cli`, not `drydock`.** `pip install drydock`
330
+ > fetches an unrelated project (a Docker-cluster tool) that ships its own
331
+ > `drydock/` package with no `cli.py`, so the `drydock` command then fails with
332
+ > `No module named 'drydock.cli'`. If that happened, run
333
+ > `pip uninstall -y drydock drydock-cli && pip install drydock-cli`.
334
+
329
335
  Requires Python 3.11+. From source instead:
330
336
  `git clone https://github.com/fbobe321/drydock.git && cd drydock && pip install -e .`
331
337
 
@@ -0,0 +1,58 @@
1
+ """Drydock Comms — the communication layer (PRD): decide what the human needs to
2
+ know, and how to tell them, separately from the agent's raw output.
3
+
4
+ Public API (PRD §28):
5
+ from drydock import comms
6
+ comms.emit_event("decision.required", severity=comms.Severity.APPROVAL_REQUIRED,
7
+ summary="Preserve the old API, or simplify and update callers?",
8
+ requires_response=True, task_id="passkeys")
9
+
10
+ emit_event runs the deterministic AttentionPolicy and routes to a channel; it
11
+ returns the Decision. Comms failures never raise into the caller.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from .events import Decision, Event, Presence, Severity, NON_SUPPRESSIBLE
16
+ from .attention import AttentionPolicy, AttentionConfig
17
+ from .channels import Delivery, TelegramProvider, LogChannel
18
+
19
+ _policy = AttentionPolicy()
20
+ _delivery = Delivery()
21
+ _presence = Presence.UNKNOWN
22
+
23
+
24
+ def configure(policy: AttentionPolicy | None = None,
25
+ delivery: Delivery | None = None) -> None:
26
+ global _policy, _delivery
27
+ if policy is not None:
28
+ _policy = policy
29
+ if delivery is not None:
30
+ _delivery = delivery
31
+
32
+
33
+ def set_presence(p: Presence) -> None:
34
+ global _presence
35
+ _presence = p
36
+
37
+
38
+ def emit_event(type: str, severity: Severity = Severity.INFORMATIONAL,
39
+ summary: str = "", details: str = "", requires_response: bool = False,
40
+ interruptibility: str = "normal", source: str = "agent",
41
+ task_id: str = "") -> Decision:
42
+ """Offer an event to the comms layer; returns where it went. Never raises."""
43
+ try:
44
+ e = Event(type=type, severity=severity, summary=summary, details=details,
45
+ requires_response=requires_response, interruptibility=interruptibility,
46
+ source=source, task_id=task_id)
47
+ decision = _policy.decide(e, _presence)
48
+ _delivery.deliver(e, decision)
49
+ return decision
50
+ except Exception: # noqa: BLE001 — a broken comms layer must not break the task
51
+ return Decision.LOG
52
+
53
+
54
+ __all__ = [
55
+ "Decision", "Event", "Presence", "Severity", "NON_SUPPRESSIBLE",
56
+ "AttentionPolicy", "AttentionConfig", "Delivery", "TelegramProvider",
57
+ "LogChannel", "emit_event", "configure", "set_presence",
58
+ ]
@@ -0,0 +1,120 @@
1
+ """Drydock Comms — the Attention Manager (PRD §6/§7/§25).
2
+
3
+ A DELIBERATELY DETERMINISTIC policy: event + context → Decision. No LLM in the
4
+ hot path (that would be latency, cost, and GPU contention with the coding model,
5
+ and it would make 'why did it stay silent?' untestable). An LLM is used only to
6
+ *word* the summary and to adjudicate the ambiguous mid-band — callers layer that
7
+ on top; this module decides the CHANNEL.
8
+
9
+ Guarantees (asserted in tests):
10
+ * Non-suppressible severities never fall below MESSAGE, regardless of cooldown,
11
+ presence (except DND still delivers), or dedup.
12
+ * Routine, low-severity, non-response events default to LOG/IGNORE.
13
+ * Repeated identical events are aggregated, not re-fired, until an escalation
14
+ threshold — then a single 'still happening' notice, once per escalation.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import time
19
+ from dataclasses import dataclass, field
20
+
21
+ from .events import Decision, Event, Presence, Severity
22
+
23
+
24
+ @dataclass
25
+ class AttentionConfig:
26
+ # score→channel thresholds (PRD §29). Score is 0..5.
27
+ speak_at: int = 3
28
+ message_at: int = 4
29
+ # anti-annoyance
30
+ cooldown_secs: float = 300.0 # per dedup-key silence window
31
+ escalate_after: int = 4 # Nth suppressed dup → one aggregated notice
32
+ max_notifies_per_hour: int = 20 # global circuit breaker
33
+
34
+
35
+ @dataclass
36
+ class _KeyState:
37
+ last_notified: float = 0.0
38
+ suppressed: int = 0 # dups seen inside the cooldown window
39
+
40
+
41
+ @dataclass
42
+ class AttentionPolicy:
43
+ cfg: AttentionConfig = field(default_factory=AttentionConfig)
44
+ _keys: dict = field(default_factory=dict)
45
+ _notify_times: list = field(default_factory=list) # for the hourly breaker
46
+
47
+ # ── scoring (0..5) ──────────────────────────────────────────────────────
48
+ def _score(self, e: Event) -> int:
49
+ s = int(e.severity) # 0..4 baseline from the authority ladder
50
+ if e.requires_response:
51
+ s = max(s, int(Severity.APPROVAL_REQUIRED))
52
+ if e.type in ("agent.stuck", "error.blocking", "security.warning"):
53
+ s = max(s, 3)
54
+ if e.type in ("task.completed", "milestone.reached"):
55
+ s = max(s, 2)
56
+ if e.type in ("task.progress", "test.passed", "test.started"):
57
+ s = min(s, 1)
58
+ return max(0, min(5, s))
59
+
60
+ def _channel_for_score(self, score: int, presence: Presence) -> Decision:
61
+ if score >= self.cfg.message_at:
62
+ # loud enough to reach a human: voice if they're listening, else message
63
+ return Decision.SPEAK if presence == Presence.VOICE else Decision.MESSAGE
64
+ if score >= self.cfg.speak_at:
65
+ if presence == Presence.VOICE:
66
+ return Decision.SPEAK
67
+ if presence == Presence.ACTIVE:
68
+ return Decision.DISPLAY
69
+ return Decision.MESSAGE # significant but user not at the box
70
+ if score >= 2:
71
+ return Decision.DISPLAY
72
+ if score >= 1:
73
+ return Decision.LOG
74
+ return Decision.IGNORE
75
+
76
+ def _hourly_ok(self, now: float) -> bool:
77
+ self._notify_times = [t for t in self._notify_times if now - t < 3600]
78
+ return len(self._notify_times) < self.cfg.max_notifies_per_hour
79
+
80
+ def decide(self, e: Event, presence: Presence = Presence.UNKNOWN,
81
+ now: float | None = None) -> Decision:
82
+ now = time.time() if now is None else now
83
+
84
+ # ── INVARIANT 1: non-suppressible severities always reach the human ──
85
+ # No cooldown/dedup/breaker/presence (except an explicit DND still gets a
86
+ # message — a blocking/approval/security event must not be lost).
87
+ if e.is_non_suppressible():
88
+ dec = Decision.SPEAK if presence == Presence.VOICE else Decision.MESSAGE
89
+ self._note_notify(e, now)
90
+ return dec
91
+
92
+ score = self._score(e)
93
+
94
+ # ── anti-annoyance: cooldown + dedup + aggregation ──────────────────
95
+ key = e.dedup_key()
96
+ st = self._keys.get(key)
97
+ if st and (now - st.last_notified) < self.cfg.cooldown_secs:
98
+ st.suppressed += 1
99
+ # escalate ONCE when repetition crosses the threshold (PRD §25)
100
+ if st.suppressed == self.cfg.escalate_after and score >= self.cfg.speak_at:
101
+ st.last_notified = now
102
+ return (Decision.MESSAGE if presence != Presence.ACTIVE
103
+ else Decision.DISPLAY)
104
+ return Decision.LOG # inside cooldown → quietly log the dup
105
+
106
+ dec = self._channel_for_score(score, presence)
107
+
108
+ # global circuit breaker for loud channels (never applies to invariant 1)
109
+ if dec >= Decision.MESSAGE and not self._hourly_ok(now):
110
+ dec = Decision.DISPLAY
111
+
112
+ if dec >= Decision.DISPLAY:
113
+ self._keys[key] = _KeyState(last_notified=now)
114
+ if dec >= Decision.MESSAGE:
115
+ self._note_notify(e, now)
116
+ return dec
117
+
118
+ def _note_notify(self, e: Event, now: float) -> None:
119
+ self._notify_times.append(now)
120
+ self._keys[e.dedup_key()] = _KeyState(last_notified=now)
@@ -0,0 +1,97 @@
1
+ """Drydock Comms — channel delivery (PRD §15/§17). Routes a Decision to a place:
2
+ LOG→file, DISPLAY→stdout/TUI, MESSAGE→messaging provider, SPEAK→TTS (stub).
3
+
4
+ Providers are pluggable (MessageProvider.send). The Telegram provider is
5
+ config-driven (token+chat via env or explicit args) so it works headless on the
6
+ fleet and can't accidentally hard-depend on a cloud service.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import json
12
+ import urllib.request
13
+ import urllib.parse
14
+
15
+ from .events import Decision, Event
16
+
17
+
18
+ class MessageProvider:
19
+ def send(self, text: str) -> bool: # pragma: no cover - interface
20
+ raise NotImplementedError
21
+
22
+
23
+ class TelegramProvider(MessageProvider):
24
+ """Minimal, dependency-free Telegram sender (stdlib urllib). Reads token/chat
25
+ from args or env (DRYDOCK_TG_TOKEN / DRYDOCK_TG_CHAT)."""
26
+
27
+ def __init__(self, token: str | None = None, chat_id: str | None = None):
28
+ self.token = token or os.environ.get("DRYDOCK_TG_TOKEN", "")
29
+ self.chat_id = chat_id or os.environ.get("DRYDOCK_TG_CHAT", "")
30
+
31
+ def configured(self) -> bool:
32
+ return bool(self.token and self.chat_id)
33
+
34
+ def send(self, text: str) -> bool:
35
+ if not self.configured():
36
+ return False
37
+ url = f"https://api.telegram.org/bot{self.token}/sendMessage"
38
+ data = urllib.parse.urlencode({"chat_id": self.chat_id, "text": text}).encode()
39
+ try:
40
+ with urllib.request.urlopen(url, data=data, timeout=10) as r:
41
+ return r.status == 200
42
+ except Exception: # noqa: BLE001 — comms failure must never crash the task
43
+ return False
44
+
45
+
46
+ class LogChannel:
47
+ def __init__(self, path: str = "/tmp/drydock_comms.log"):
48
+ self.path = path
49
+
50
+ def write(self, event: Event, decision: Decision) -> None:
51
+ try:
52
+ with open(self.path, "a") as f:
53
+ f.write(json.dumps({
54
+ "ts": event.ts, "decision": decision.name, "type": event.type,
55
+ "sev": int(event.severity), "task": event.task_id,
56
+ "summary": event.summary,
57
+ }) + "\n")
58
+ except Exception: # noqa: BLE001
59
+ pass
60
+
61
+
62
+ def format_for_human(event: Event) -> str:
63
+ """The text a human sees/hears. For non-suppressible events we deliver the
64
+ summary VERBATIM (no paraphrase that could change what's being authorized).
65
+ Callers may substitute an LLM-worded summary for LOW-severity events only."""
66
+ who = f"[{event.task_id}] " if event.task_id else ""
67
+ if event.requires_response:
68
+ return f"{who}{event.summary}".strip()
69
+ return f"{who}{event.summary}".strip()
70
+
71
+
72
+ class Delivery:
73
+ """Executes a Decision. Returns True if it reached the intended channel."""
74
+ def __init__(self, message_provider: MessageProvider | None = None,
75
+ log: LogChannel | None = None, display=print, speak=None):
76
+ self.message_provider = message_provider or TelegramProvider()
77
+ self.log = log or LogChannel()
78
+ self.display = display
79
+ self.speak = speak
80
+
81
+ def deliver(self, event: Event, decision: Decision) -> bool:
82
+ self.log.write(event, decision) # everything is always logged
83
+ text = format_for_human(event)
84
+ if decision == Decision.IGNORE or decision == Decision.LOG:
85
+ return True
86
+ if decision == Decision.DISPLAY:
87
+ self.display(f"drydock: {text}")
88
+ return True
89
+ if decision == Decision.MESSAGE:
90
+ return self.message_provider.send(f"Drydock: {text}")
91
+ if decision == Decision.SPEAK:
92
+ if self.speak:
93
+ self.speak(text)
94
+ return True
95
+ # no TTS wired yet → fall back to message so it isn't lost
96
+ return self.message_provider.send(f"Drydock: {text}")
97
+ return False
@@ -0,0 +1,86 @@
1
+ """Drydock Comms — typed events (the communication interface, separate from the
2
+ agent's raw output). See the Comms PRD §5/§24.
3
+
4
+ Design invariants (enforced in attention.py, asserted in tests):
5
+ * Certain severities are NON-SUPPRESSIBLE — the attention policy can never
6
+ downgrade them to LOG/IGNORE, and their summary is delivered faithfully
7
+ (never paraphrased-away). Silence is never approval.
8
+ * Everything else defaults to LOG/IGNORE unless it earns a louder channel.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from enum import IntEnum
15
+
16
+
17
+ class Severity(IntEnum):
18
+ """Human-authority ladder (PRD §24). Order matters: >= APPROVAL_REQUIRED is
19
+ non-suppressible."""
20
+ INFORMATIONAL = 0
21
+ ADVISORY = 1
22
+ APPROVAL_REQUIRED = 2
23
+ BLOCKING = 3
24
+ CRITICAL = 4
25
+
26
+
27
+ # Severities the policy may NEVER suppress or downgrade below MESSAGE, and whose
28
+ # summary must be delivered verbatim (no lossy LLM paraphrase changing meaning).
29
+ NON_SUPPRESSIBLE = frozenset(
30
+ {Severity.APPROVAL_REQUIRED, Severity.BLOCKING, Severity.CRITICAL}
31
+ )
32
+
33
+
34
+ class Decision(IntEnum):
35
+ """Where an event ends up. IGNORE < LOG < DISPLAY < MESSAGE < SPEAK by
36
+ 'loudness'; a non-suppressible event is floored at MESSAGE."""
37
+ IGNORE = 0
38
+ LOG = 1
39
+ DISPLAY = 2
40
+ MESSAGE = 3
41
+ SPEAK = 4
42
+
43
+
44
+ class Presence(IntEnum):
45
+ UNKNOWN = 0
46
+ AWAY = 1
47
+ NEARBY = 2
48
+ ACTIVE = 3 # at the TUI
49
+ VOICE = 4 # in a live voice session
50
+ DO_NOT_DISTURB = 5
51
+
52
+
53
+ # A small, stable event vocabulary. Not exhaustive — apps may emit others; the
54
+ # policy keys off severity/requires_response, not the exact string.
55
+ KNOWN_TYPES = frozenset({
56
+ "task.started", "task.progress", "task.completed",
57
+ "decision.required", "clarification.required", "permission.required",
58
+ "test.failed", "test.passed",
59
+ "error.recoverable", "error.blocking", "security.warning",
60
+ "agent.stuck", "agent.recovered", "milestone.reached",
61
+ "user.message",
62
+ })
63
+
64
+
65
+ @dataclass
66
+ class Event:
67
+ """A structured piece of agent activity offered to the comms layer."""
68
+ type: str
69
+ severity: Severity = Severity.INFORMATIONAL
70
+ summary: str = ""
71
+ details: str = ""
72
+ requires_response: bool = False
73
+ interruptibility: str = "normal" # "low" | "normal" | "high"
74
+ source: str = "agent"
75
+ task_id: str = ""
76
+ confidence: float = 1.0
77
+ ts: float = field(default_factory=time.time)
78
+
79
+ def dedup_key(self) -> str:
80
+ """Anti-annoyance key (PRD §25): same class + same task + same gist →
81
+ aggregate rather than re-notify. Excludes CRITICAL/approval (those are
82
+ never deduped away)."""
83
+ return f"{self.type}|{self.task_id}|{self.summary[:80]}"
84
+
85
+ def is_non_suppressible(self) -> bool:
86
+ return self.severity in NON_SUPPRESSIBLE
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.1.14
3
+ Version: 3.1.16
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
@@ -13,6 +13,9 @@ License-File: LICENSE
13
13
  License-File: NOTICE
14
14
  Requires-Dist: openai>=1.0
15
15
  Requires-Dist: textual>=0.80
16
+ Requires-Dist: httpx>=0.23
17
+ Requires-Dist: rich>=13.0
18
+ Requires-Dist: pypdf>=3.0
16
19
  Provides-Extra: dev
17
20
  Requires-Dist: pytest; extra == "dev"
18
21
  Requires-Dist: pytest-timeout; extra == "dev"
@@ -350,6 +353,12 @@ pip install drydock-cli
350
353
  drydock
351
354
  ```
352
355
 
356
+ > ⚠️ **The PyPI package is `drydock-cli`, not `drydock`.** `pip install drydock`
357
+ > fetches an unrelated project (a Docker-cluster tool) that ships its own
358
+ > `drydock/` package with no `cli.py`, so the `drydock` command then fails with
359
+ > `No module named 'drydock.cli'`. If that happened, run
360
+ > `pip uninstall -y drydock drydock-cli && pip install drydock-cli`.
361
+
353
362
  Requires Python 3.11+. From source instead:
354
363
  `git clone https://github.com/fbobe321/drydock.git && cd drydock && pip install -e .`
355
364
 
@@ -66,6 +66,10 @@ drydock/builtin_skills/rmf-poam.md
66
66
  drydock/builtin_skills/rmf-review.md
67
67
  drydock/builtin_skills/stig-assess.md
68
68
  drydock/builtin_skills/stig-remediate.md
69
+ drydock/comms/__init__.py
70
+ drydock/comms/attention.py
71
+ drydock/comms/channels.py
72
+ drydock/comms/events.py
69
73
  drydock/tools/__init__.py
70
74
  drydock/tui/__init__.py
71
75
  drydock/tui/app.py
@@ -96,6 +100,7 @@ tests/test_bash_timeout_param.py
96
100
  tests/test_budget.py
97
101
  tests/test_cci.py
98
102
  tests/test_cli_agents.py
103
+ tests/test_comms.py
99
104
  tests/test_compact_command.py
100
105
  tests/test_compaction.py
101
106
  tests/test_config.py
@@ -104,6 +109,7 @@ tests/test_context_limit_config.py
104
109
  tests/test_context_limit_issue25.py
105
110
  tests/test_criteria_coverage.py
106
111
  tests/test_cycling_detection.py
112
+ tests/test_declared_deps.py
107
113
  tests/test_degenerate_argument.py
108
114
  tests/test_detect.py
109
115
  tests/test_dispatch.py
@@ -1,5 +1,8 @@
1
1
  openai>=1.0
2
2
  textual>=0.80
3
+ httpx>=0.23
4
+ rich>=13.0
5
+ pypdf>=3.0
3
6
 
4
7
  [dev]
5
8
  pytest
@@ -7,14 +7,24 @@ build-backend = "setuptools.build_meta"
7
7
  # PyPI distribution name is drydock-cli (the established install name, continued
8
8
  # from the retired v2 fork); the import package + CLI command stay `drydock`.
9
9
  name = "drydock-cli"
10
- version = "3.1.14"
10
+ version = "3.1.16"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
14
14
  license = "Apache-2.0"
15
15
  license-files = ["LICENSE", "NOTICE"]
16
16
  authors = [{ name = "Frank Bobe III" }]
17
- dependencies = ["openai>=1.0", "textual>=0.80"]
17
+ # Every package imported directly by drydock/ must be declared here — do NOT rely
18
+ # on transitive deps (httpx via openai, rich via textual): a clean/partial install
19
+ # then fails at runtime with ModuleNotFoundError (httpx bug, v3.1.15). pypdf is not
20
+ # transitive at all, so PDF extraction silently broke on fresh installs.
21
+ dependencies = [
22
+ "openai>=1.0",
23
+ "textual>=0.80",
24
+ "httpx>=0.23",
25
+ "rich>=13.0",
26
+ "pypdf>=3.0",
27
+ ]
18
28
 
19
29
  [project.urls]
20
30
  Homepage = "https://github.com/fbobe321/drydock"
@@ -0,0 +1,102 @@
1
+ """Tests for drydock.comms — the attention policy is a PURE function, so 'did it
2
+ correctly stay silent?' is directly assertable (PRD §7/§24/§25)."""
3
+ from drydock.comms import (
4
+ AttentionPolicy, AttentionConfig, Decision, Event, Presence, Severity,
5
+ )
6
+
7
+
8
+ def P(**kw):
9
+ return AttentionPolicy(cfg=AttentionConfig(**kw)) if kw else AttentionPolicy()
10
+
11
+
12
+ def ev(t, **kw):
13
+ return Event(type=t, **kw)
14
+
15
+
16
+ # ── routine noise stays quiet ────────────────────────────────────────────────
17
+ def test_routine_activity_logs_or_ignores():
18
+ pol = P()
19
+ assert pol.decide(ev("task.progress", task_id="a")) <= Decision.LOG
20
+ assert pol.decide(ev("test.passed", task_id="a")) <= Decision.LOG
21
+ assert pol.decide(ev("task.started", severity=Severity.INFORMATIONAL)) <= Decision.DISPLAY
22
+
23
+
24
+ def test_completion_surfaces_but_is_not_loud_when_away():
25
+ # task.completed is worth a message when the user is away, a display when present
26
+ pol = P()
27
+ assert pol.decide(ev("task.completed", task_id="a"), Presence.ACTIVE) == Decision.DISPLAY
28
+
29
+
30
+ # ── INVARIANT: non-suppressible severities always reach the human ────────────
31
+ def test_approval_required_is_non_suppressible():
32
+ pol = P()
33
+ for presence in (Presence.UNKNOWN, Presence.AWAY, Presence.ACTIVE, Presence.DO_NOT_DISTURB):
34
+ d = pol.decide(ev("decision.required", severity=Severity.APPROVAL_REQUIRED,
35
+ requires_response=True, task_id="x"), presence)
36
+ assert d >= Decision.MESSAGE, (presence, d)
37
+
38
+
39
+ def test_blocking_and_security_never_ignored_even_under_cooldown():
40
+ pol = P(cooldown_secs=9999)
41
+ for _ in range(5): # would be cooled down if suppressible
42
+ d = pol.decide(ev("security.warning", severity=Severity.CRITICAL,
43
+ summary="credentials found in repo", task_id="x"))
44
+ assert d >= Decision.MESSAGE
45
+ d = pol.decide(ev("error.blocking", severity=Severity.BLOCKING, task_id="y"))
46
+ assert d >= Decision.MESSAGE
47
+
48
+
49
+ def test_hourly_breaker_never_gags_a_critical_event():
50
+ pol = P(max_notifies_per_hour=1)
51
+ pol.decide(ev("task.completed", severity=Severity.ADVISORY, requires_response=True))
52
+ # breaker is now tripped for loud channels, but a CRITICAL must still get through
53
+ d = pol.decide(ev("security.warning", severity=Severity.CRITICAL, summary="leak"))
54
+ assert d >= Decision.MESSAGE
55
+
56
+
57
+ # ── presence routing ─────────────────────────────────────────────────────────
58
+ def test_presence_routes_significant_events():
59
+ pol = P()
60
+ e = ev("decision.required", severity=Severity.APPROVAL_REQUIRED, requires_response=True, task_id="x")
61
+ assert pol.decide(e, Presence.VOICE) == Decision.SPEAK
62
+ assert P().decide(e, Presence.AWAY) == Decision.MESSAGE
63
+
64
+
65
+ # ── anti-annoyance: cooldown + dedup + aggregation ──────────────────────────
66
+ def test_single_test_failure_stays_quiet():
67
+ # a lone test.failed during the agent's own work is routine — it must NOT
68
+ # interrupt (the agent fixes it); only a stuck loop escalates.
69
+ pol = P()
70
+ assert pol.decide(ev("test.failed", severity=Severity.ADVISORY,
71
+ summary="auth tests failing", task_id="a"), Presence.AWAY) <= Decision.LOG
72
+
73
+
74
+ def test_repeated_notify_worthy_event_is_deduped_then_aggregated_once():
75
+ # agent.stuck IS notify-worthy (score 3). Repeated within cooldown → the first
76
+ # notifies, the rest are suppressed to LOG, and exactly one aggregated notice
77
+ # fires when repetition crosses the escalation threshold (PRD §25).
78
+ pol = P(cooldown_secs=9999, escalate_after=4)
79
+ e = ev("agent.stuck", severity=Severity.ADVISORY, summary="stuck on dep", task_id="a")
80
+ first = pol.decide(e, Presence.AWAY)
81
+ assert first >= Decision.MESSAGE # first one gets through
82
+ seen = [pol.decide(e, Presence.AWAY) for _ in range(5)]
83
+ assert Decision.LOG in seen # dups suppressed inside cooldown
84
+ assert sum(1 for d in seen if d >= Decision.MESSAGE) == 1, "escalate exactly once"
85
+
86
+
87
+ def test_non_suppressible_is_not_deduped_away():
88
+ pol = P(cooldown_secs=9999)
89
+ e = ev("permission.required", severity=Severity.APPROVAL_REQUIRED,
90
+ summary="delete credentials from git history?", requires_response=True, task_id="x")
91
+ decisions = [pol.decide(e) for _ in range(3)]
92
+ assert all(d >= Decision.MESSAGE for d in decisions), decisions
93
+
94
+
95
+ # ── faithful wording for approvals ──────────────────────────────────────────
96
+ def test_approval_summary_delivered_verbatim():
97
+ from drydock.comms.channels import format_for_human
98
+ e = ev("permission.required", severity=Severity.APPROVAL_REQUIRED,
99
+ summary="delete these credentials from git history?", requires_response=True,
100
+ task_id="repo")
101
+ out = format_for_human(e)
102
+ assert "delete these credentials from git history?" in out
@@ -0,0 +1,60 @@
1
+ """Regression guard for the v3.1.15 httpx bug: a module imported directly by the
2
+ package MUST be declared in pyproject dependencies. Relying on transitive deps
3
+ (httpx via openai, rich via textual) breaks a clean/partial `pip install` at
4
+ runtime with ModuleNotFoundError; pypdf isn't transitive at all.
5
+
6
+ This scans drydock/ with ast, drops stdlib + first-party, and asserts every
7
+ remaining top-level import is a declared dependency. It would have failed on
8
+ 3.1.14 (httpx, pypdf, rich undeclared)."""
9
+ import ast
10
+ import sys
11
+ import tomllib
12
+ from pathlib import Path
13
+
14
+ ROOT = Path(__file__).resolve().parent.parent
15
+ PKG = ROOT / "drydock"
16
+
17
+ # import-name -> distribution-name, only where they differ (none currently, but
18
+ # future-proofs the check for e.g. PIL->pillow, yaml->pyyaml).
19
+ IMPORT_TO_DIST = {"PIL": "pillow", "yaml": "pyyaml", "bs4": "beautifulsoup4"}
20
+
21
+
22
+ def _declared_dists() -> set[str]:
23
+ data = tomllib.loads((ROOT / "pyproject.toml").read_text())
24
+ out = set()
25
+ for spec in data["project"]["dependencies"]:
26
+ # strip version/extras/markers → bare distribution name, normalized
27
+ name = spec.split(";")[0].split("[")[0]
28
+ for sep in ("==", ">=", "<=", "~=", ">", "<", "!=", " "):
29
+ name = name.split(sep)[0]
30
+ out.add(name.strip().lower().replace("_", "-"))
31
+ return out
32
+
33
+
34
+ def _top_level_thirdparty_imports() -> set[str]:
35
+ stdlib = set(sys.stdlib_module_names)
36
+ mods: set[str] = set()
37
+ for py in PKG.rglob("*.py"):
38
+ tree = ast.parse(py.read_text(encoding="utf-8"), filename=str(py))
39
+ for node in ast.walk(tree):
40
+ if isinstance(node, ast.Import):
41
+ for a in node.names:
42
+ mods.add(a.name.split(".")[0])
43
+ elif isinstance(node, ast.ImportFrom):
44
+ if node.level == 0 and node.module: # skip relative imports
45
+ mods.add(node.module.split(".")[0])
46
+ return {m for m in mods if m and m not in stdlib and m != "drydock"}
47
+
48
+
49
+ def test_all_direct_imports_are_declared_dependencies():
50
+ declared = _declared_dists()
51
+ missing = []
52
+ for imp in sorted(_top_level_thirdparty_imports()):
53
+ dist = IMPORT_TO_DIST.get(imp, imp).lower().replace("_", "-")
54
+ if dist not in declared:
55
+ missing.append(f"{imp} (dist: {dist})")
56
+ assert not missing, (
57
+ "These packages are imported by drydock/ but NOT declared in "
58
+ f"pyproject dependencies (would break a clean pip install): {missing}. "
59
+ f"Declared: {sorted(declared)}"
60
+ )
File without changes
File without changes