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.
- {drydock_cli-3.1.14/drydock_cli.egg-info → drydock_cli-3.1.16}/PKG-INFO +10 -1
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/README.md +6 -0
- drydock_cli-3.1.16/drydock/comms/__init__.py +58 -0
- drydock_cli-3.1.16/drydock/comms/attention.py +120 -0
- drydock_cli-3.1.16/drydock/comms/channels.py +97 -0
- drydock_cli-3.1.16/drydock/comms/events.py +86 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16/drydock_cli.egg-info}/PKG-INFO +10 -1
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock_cli.egg-info/SOURCES.txt +6 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock_cli.egg-info/requires.txt +3 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/pyproject.toml +12 -2
- drydock_cli-3.1.16/tests/test_comms.py +102 -0
- drydock_cli-3.1.16/tests/test_declared_deps.py +60 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/LICENSE +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/NOTICE +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/__init__.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/__main__.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/advisor.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/agent.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/budget.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/document-canvas.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/fiar-assess.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/fiar-cap.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/fiar-evidence.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/fiar-readiness.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/ml-data.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/ml-debug.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/ml-finetune.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/ml-metrics.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/ml-rl.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/ml-train.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/nist-ai-rmf.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/nist-csf.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/builtin_skills/stig-remediate.md +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/cci.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/cli.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/compaction.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/config.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/detect.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/doccanvas.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/events.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/extract.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/fiar.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/gittools.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/graphrag.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/guards.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/mcp.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/phases.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/poam.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/progress.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/providers.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/ratchet.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/recipes.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/recovery.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/resume.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/rmf.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/rmf_graph.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/skills.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/stig.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/subagents.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/suggest.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/task_state.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tool_policy.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tool_result.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tool_select.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tool_validate.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/trajectory.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tui/app.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/tuning.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/verification.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock/web.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/setup.cfg +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_advisor.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_approval.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_back_command.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_background.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_binary_output.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_crossplatform.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_sanitize.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_shell.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_stdin.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_stop_partial.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_bash_timeout_param.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_budget.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_cci.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_compaction.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_config.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_context_limit_issue25.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_criteria_coverage.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_cycling_detection.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_degenerate_argument.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_detect.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_doccanvas.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_edit_replace_all.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_edit_thrash_and_compaction.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_effort_governor.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_events.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_extract.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_fiar.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_gittools.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_glob_edit_edges.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_graphify_example.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_graphrag_quoted_path.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_graphrag_sqlite.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_grep_and_read_robust.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_mcp.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_model_registry.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_overthink_interrupt.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_phases.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_poam.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_progress.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_ratchet.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_read_index.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_recipes.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_recovery.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_recovery_config.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_recovery_integration.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_repeated_outcome.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_repetition_interrupt.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_resume_events.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_resume_restore.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_resume_snapshot.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_rmf.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_rmf_stig_graph.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_rolling_plan.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_screenshot.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_server_probe.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_skills.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_sqlite_events.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_stall_retry.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_stig.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_stop.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_subagent.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_subagents.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_suggest.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_task_state.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_timeline.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_todo.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_arg_coercion.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_arg_coercion_more.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_canonical.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_policy.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_result.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_select.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tool_validate.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_trajectory.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tui.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_tuning.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_verification_gate.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_viewimage.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_web_tools.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_windows_shell.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_worker_subagent.py +0 -0
- {drydock_cli-3.1.14 → drydock_cli-3.1.16}/tests/test_write_content_coerce.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|