drydock-cli 3.0.162__tar.gz → 3.0.166__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 (179) hide show
  1. {drydock_cli-3.0.162/drydock_cli.egg-info → drydock_cli-3.0.166}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/agent.py +71 -3
  3. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/config.py +5 -0
  4. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/loop_detect.py +24 -6
  5. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/recovery.py +28 -2
  6. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tool_result.py +6 -1
  7. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/verification.py +42 -0
  8. {drydock_cli-3.0.162 → drydock_cli-3.0.166/drydock_cli.egg-info}/PKG-INFO +1 -1
  9. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock_cli.egg-info/SOURCES.txt +3 -0
  10. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/pyproject.toml +1 -1
  11. drydock_cli-3.0.166/tests/test_criteria_coverage.py +122 -0
  12. drydock_cli-3.0.166/tests/test_cycling_detection.py +73 -0
  13. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_recovery.py +14 -0
  14. drydock_cli-3.0.166/tests/test_repeated_outcome.py +54 -0
  15. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_result.py +12 -0
  16. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/LICENSE +0 -0
  17. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/NOTICE +0 -0
  18. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/README.md +0 -0
  19. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/__init__.py +0 -0
  20. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/__main__.py +0 -0
  21. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/advisor.py +0 -0
  22. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/bash_safety.py +0 -0
  23. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/budget.py +0 -0
  24. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/__init__.py +0 -0
  25. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/ml-data.md +0 -0
  26. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/ml-debug.md +0 -0
  27. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/ml-finetune.md +0 -0
  28. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/ml-metrics.md +0 -0
  29. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/ml-rl.md +0 -0
  30. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/ml-train.md +0 -0
  31. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/nist-ai-rmf.md +0 -0
  32. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/nist-csf.md +0 -0
  33. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/rmf-categorize.md +0 -0
  34. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/rmf-control.md +0 -0
  35. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/rmf-poam.md +0 -0
  36. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/rmf-review.md +0 -0
  37. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/stig-assess.md +0 -0
  38. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/stig-remediate.md +0 -0
  39. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/cci.py +0 -0
  40. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/cli.py +0 -0
  41. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/compaction.py +0 -0
  42. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/detect.py +0 -0
  43. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/events.py +0 -0
  44. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/extract.py +0 -0
  45. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/gittools.py +0 -0
  46. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/graphrag.py +0 -0
  47. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/guards.py +0 -0
  48. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/mcp.py +0 -0
  49. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/phases.py +0 -0
  50. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/poam.py +0 -0
  51. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/progress.py +0 -0
  52. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/providers.py +0 -0
  53. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/recipes.py +0 -0
  54. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/resume.py +0 -0
  55. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/rmf.py +0 -0
  56. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/rmf_graph.py +0 -0
  57. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/skills.py +0 -0
  58. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/stig.py +0 -0
  59. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/subagents.py +0 -0
  60. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/suggest.py +0 -0
  61. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/task_state.py +0 -0
  62. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tool_policy.py +0 -0
  63. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tool_registry.py +0 -0
  64. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tool_select.py +0 -0
  65. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tool_validate.py +0 -0
  66. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tools/__init__.py +0 -0
  67. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/trajectory.py +0 -0
  68. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tui/__init__.py +0 -0
  69. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tui/app.py +0 -0
  70. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tui/approval.py +0 -0
  71. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tui/messages.py +0 -0
  72. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tui/widgets.py +0 -0
  73. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tuning.py +0 -0
  74. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/web.py +0 -0
  75. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock_cli.egg-info/dependency_links.txt +0 -0
  76. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock_cli.egg-info/entry_points.txt +0 -0
  77. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock_cli.egg-info/requires.txt +0 -0
  78. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock_cli.egg-info/top_level.txt +0 -0
  79. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/setup.cfg +0 -0
  80. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_advisor.py +0 -0
  81. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_approval.py +0 -0
  82. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_back_command.py +0 -0
  83. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_background.py +0 -0
  84. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_binary_output.py +0 -0
  85. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_crossplatform.py +0 -0
  86. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_output_bounding.py +0 -0
  87. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_process_group.py +0 -0
  88. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_safety.py +0 -0
  89. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_sanitize.py +0 -0
  90. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_shell.py +0 -0
  91. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_stdin.py +0 -0
  92. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_stop_partial.py +0 -0
  93. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_timeout_network.py +0 -0
  94. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_timeout_param.py +0 -0
  95. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_budget.py +0 -0
  96. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_cci.py +0 -0
  97. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_cli_agents.py +0 -0
  98. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_compact_command.py +0 -0
  99. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_compaction.py +0 -0
  100. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_config.py +0 -0
  101. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_config_migration.py +0 -0
  102. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_context_limit_config.py +0 -0
  103. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_context_limit_issue25.py +0 -0
  104. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_detect.py +0 -0
  105. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_dispatch.py +0 -0
  106. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_e2e_connected.py +0 -0
  107. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_edit_replace_all.py +0 -0
  108. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_empty_response.py +0 -0
  109. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_events.py +0 -0
  110. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_extract.py +0 -0
  111. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_failure_loop.py +0 -0
  112. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_first_run_setup.py +0 -0
  113. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_gittools.py +0 -0
  114. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_glob_edit_edges.py +0 -0
  115. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_graphify_example.py +0 -0
  116. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_graphrag.py +0 -0
  117. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_graphrag_quoted_path.py +0 -0
  118. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_graphrag_sqlite.py +0 -0
  119. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_grep_and_read_robust.py +0 -0
  120. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_guards_and_tools.py +0 -0
  121. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_hallucinated_tools.py +0 -0
  122. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_leaked_tool_call.py +0 -0
  123. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_loop_detect.py +0 -0
  124. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_mcp.py +0 -0
  125. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_model_registry.py +0 -0
  126. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_oneshot_unreachable.py +0 -0
  127. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_overthink_interrupt.py +0 -0
  128. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_phases.py +0 -0
  129. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_plan_autocontinue.py +0 -0
  130. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_poam.py +0 -0
  131. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_progress.py +0 -0
  132. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_providers_unreachable.py +0 -0
  133. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_read_index.py +0 -0
  134. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_recipes.py +0 -0
  135. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_recovery_config.py +0 -0
  136. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_recovery_integration.py +0 -0
  137. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_repetition_interrupt.py +0 -0
  138. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_resume_events.py +0 -0
  139. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_resume_restore.py +0 -0
  140. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_resume_snapshot.py +0 -0
  141. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_rmf.py +0 -0
  142. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_rmf_graph.py +0 -0
  143. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_rmf_stig_graph.py +0 -0
  144. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_rolling_plan.py +0 -0
  145. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_runaway_repetition.py +0 -0
  146. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_screenshot.py +0 -0
  147. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_server_probe.py +0 -0
  148. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_skills.py +0 -0
  149. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_sqlite_events.py +0 -0
  150. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_stall_retry.py +0 -0
  151. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_stig.py +0 -0
  152. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_stop.py +0 -0
  153. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_streaming_newlines.py +0 -0
  154. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_subagent.py +0 -0
  155. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_subagents.py +0 -0
  156. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_suggest.py +0 -0
  157. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_system_prompt_help.py +0 -0
  158. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_task_state.py +0 -0
  159. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_timeline.py +0 -0
  160. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_todo.py +0 -0
  161. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_arg_coercion.py +0 -0
  162. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_arg_coercion_more.py +0 -0
  163. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_arg_parsing.py +0 -0
  164. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_canonical.py +0 -0
  165. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_policy.py +0 -0
  166. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_select.py +0 -0
  167. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_validate.py +0 -0
  168. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tools_undo.py +0 -0
  169. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_trajectory.py +0 -0
  170. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tui.py +0 -0
  171. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tuning.py +0 -0
  172. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_verification_gate.py +0 -0
  173. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_viewimage.py +0 -0
  174. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_vision_input.py +0 -0
  175. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_web_tools.py +0 -0
  176. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_windows_shell.py +0 -0
  177. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_worker_subagent.py +0 -0
  178. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_write_content_coerce.py +0 -0
  179. {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.162
3
+ Version: 3.0.166
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
@@ -49,7 +49,12 @@ from drydock.compaction import (
49
49
  )
50
50
  from drydock.loop_detect import LoopTracker
51
51
  from drydock.task_state import TaskState
52
- from drydock.verification import looks_like_verification, parse_evidence
52
+ from drydock.verification import (
53
+ looks_like_verification,
54
+ parse_evidence,
55
+ extract_artifacts,
56
+ uncovered_artifacts,
57
+ )
53
58
  from drydock.progress import ProgressTracker, assess_action, NO_PROGRESS_WINDOW
54
59
  from drydock.recovery import RecoveryController, SUPPRESSION_ITERATIONS
55
60
  from drydock.loop_detect import tool_signature
@@ -205,6 +210,9 @@ def run(
205
210
  window=config.get("recovery_no_progress_window", NO_PROGRESS_WINDOW),
206
211
  ) # scores each action; flags a stalled run
207
212
  prev_failing = None # failing-test count from the previous verification (delta)
213
+ verif_fail_counts: dict[str, int] = {} # failure-summary -> times seen (Epic J3)
214
+ verification_text = "" # concatenated verification commands+results (coverage)
215
+ coverage_nudges = 0 # bounded "your checks never touched X" nudges
208
216
  recovery = RecoveryController(
209
217
  suppression_iterations=config.get("recovery_suppression_iterations",
210
218
  SUPPRESSION_ITERATIONS),
@@ -446,6 +454,25 @@ def run(
446
454
  state.messages.append({"role": "user", "content": msg})
447
455
  continue
448
456
  if session_has_edited and last_verification and last_verification.status == "pass":
457
+ # Criteria coverage (bench finding: exit-0 self-checks that never
458
+ # touch the files the task is judged on). If the objective names
459
+ # concrete artifacts and NONE of this session's verifications ever
460
+ # referenced some of them, nudge ONCE with exactly what's missing.
461
+ # Advisory + bounded: a second completion claim is accepted.
462
+ _artifacts = extract_artifacts(
463
+ state.task.objective + "\n" + "\n".join(state.task.acceptance_criteria))
464
+ _missing = uncovered_artifacts(_artifacts, verification_text)
465
+ if _missing and coverage_nudges < 1:
466
+ coverage_nudges += 1
467
+ _emit(state, "verify_gate", kind="uncovered", missing=_missing[:6])
468
+ state.messages.append({"role": "user", "content": (
469
+ "[SYSTEM] Your check passed, but no verification this session "
470
+ f"has touched: {', '.join(_missing[:6])} — and the task will be "
471
+ "judged on them. Run a check that directly validates the stated "
472
+ "requirement(s) against those files (diff/compare/execute as "
473
+ "appropriate), then finish."
474
+ )})
475
+ continue
449
476
  # A check ran and PASSED — verification evidence exists, so the
450
477
  # controller approves completion (from VERIFY, its only legal
451
478
  # predecessor). This is the one path to COMPLETE.
@@ -595,16 +622,41 @@ def run(
595
622
  identical_repeat_streak, last_call_sig = 1, sig
596
623
  # Guide (never block) on exact-repeat tool calls: prepend an
597
624
  # advisory note when the same call is made again.
625
+ _raw_result = result # outcome identity = the un-annotated body
598
626
  result = loop_tracker.annotate(tc["name"], tc["input"], result)
627
+ # Cycling detection (gov162 bench, large-scale-text-editing): the same
628
+ # exact (call, result) pair recurring — NON-consecutively — is a loop
629
+ # even when interleaved actions look productive (an identical macro
630
+ # rewritten 22× between exit-0 test runs). annotate() just recorded
631
+ # this pair, so the count includes the current call. Polling is exempt
632
+ # (its result changes → different pair each time).
633
+ _same_outcome = loop_tracker.outcome_count(tc["name"], tc["input"], _raw_result)
599
634
 
600
635
  # Verification evidence: if this Bash call was a test/check/exec, parse
601
636
  # its result so the completion gate knows whether it PASSED, not just ran.
637
+ _repeated_outcome = False
602
638
  if tc["name"] == "Bash":
603
639
  _vcmd = (tc.get("input") or {}).get("command", "")
604
640
  if looks_like_verification(_vcmd):
605
641
  last_verification = parse_evidence(_vcmd, result)
642
+ # Accumulate what the checks touched (command + result text),
643
+ # capped, for criteria-coverage at completion time.
644
+ if len(verification_text) < 200_000:
645
+ verification_text += f"\n{_vcmd}\n{result[:2000]}"
606
646
  _emit(state, "verification", status=last_verification.status,
607
647
  exit_code=last_verification.exit_code)
648
+ # Repeated-outcome detection (PRD Epic J3): the SAME failing
649
+ # verification outcome recurring — regardless of which command
650
+ # produced it (pytest vs python -m pytest vs a reworded check)
651
+ # — is a stall signal even though no exact call repeats. Keyed
652
+ # on the parsed failure summary, so a CHANGING failure (real
653
+ # progress) never counts. Found in the wild: a bench task ran
654
+ # 25 failing verifications in 38 turns with zero recovery
655
+ # because every command differed slightly.
656
+ if last_verification.status == "fail":
657
+ _vkey = last_verification.summary or f"exit:{last_verification.exit_code}"
658
+ verif_fail_counts[_vkey] = verif_fail_counts.get(_vkey, 0) + 1
659
+ _repeated_outcome = verif_fail_counts[_vkey] >= 2
608
660
 
609
661
  # Progress evaluation (PRD Epic L): score what this action actually
610
662
  # achieved and slide it through a window so a stalled run — repeated
@@ -618,10 +670,15 @@ def run(
618
670
  _failing_after = last_verification.tests_failed
619
671
  prev_failing = _failing_after
620
672
  assessment = progress_tracker.record(assess_action(
673
+ # OUTCOME-aware repeat count: "repeated action without new
674
+ # information" means the same call returned the SAME result again
675
+ # — a poll whose output changes each time is novel information and
676
+ # must not accrue stall (found suppressing legit polling).
621
677
  changed_state=bool(tool_result and tool_result.changed_state),
622
- repeat_count=loop_tracker.count(tc["name"], tc["input"]),
678
+ repeat_count=_same_outcome,
623
679
  failing_tests_before=_failing_before if _failing_after is not None else None,
624
680
  failing_tests_after=_failing_after,
681
+ repeated_outcome=_repeated_outcome,
625
682
  ))
626
683
  _emit(state, "progress", score=assessment.progress_score,
627
684
  types=assessment.progress_types,
@@ -633,10 +690,21 @@ def run(
633
690
  # result; stage 3 suppresses THIS looping signature next time; stage 5
634
691
  # asks the loop to stop honestly. The offender is the current call —
635
692
  # only suppressed when the window says it's a no-progress loop.
693
+ _rec_stage = progress_tracker.recommended_stage()
694
+ # A call whose exact (call, result) pair has recurred past the
695
+ # threshold is cycling regardless of the global streak (interleaved
696
+ # "productive" actions kept resetting it) — floor the recommendation
697
+ # at stage 3 so THIS signature gets suppressed. Cycles alternate with
698
+ # other actions, so arm a window wide enough to outlast the period
699
+ # (the default window expires exactly when a period-2 cycle returns).
700
+ _cycling = _same_outcome >= config.get("recovery_same_outcome_threshold", 6)
701
+ if _cycling:
702
+ _rec_stage = max(_rec_stage or 0, 3)
636
703
  directive = recovery.escalate(
637
- progress_tracker.recommended_stage(),
704
+ _rec_stage,
638
705
  offender_signature=recovery_sig,
639
706
  iteration=run_iteration,
707
+ suppress_window=(recovery.suppression_iterations + 3) if _cycling else None,
640
708
  )
641
709
  if directive.active:
642
710
  # Count real interventions (reflection and beyond, not the mild
@@ -68,6 +68,11 @@ DEFAULTS: dict[str, object] = {
68
68
  "recovery_no_progress_window": 5,
69
69
  "recovery_suppression_iterations": 2,
70
70
  "max_recovery_attempts": 0,
71
+ # An exact (call, result) pair recurring this many times — consecutively or
72
+ # not — is treated as cycling and that signature is suppressed (stage 3),
73
+ # even when interleaved actions look productive. Polling is exempt (its
74
+ # result changes each time).
75
+ "recovery_same_outcome_threshold": 6,
71
76
  # Verified-trajectory export (RSI training-data collection). When set to a path,
72
77
  # drydock writes the full task trajectory (system prompt + transcript) there at
73
78
  # task end; a benchmark harness keeps only verifier-passing ones. Empty = off.
@@ -149,6 +149,15 @@ class LoopTracker:
149
149
  annotate() has already recorded the call."""
150
150
  return self._counts.get(tool_signature(name, inputs), 0)
151
151
 
152
+ def outcome_count(self, name: str, inputs: dict, result: str) -> int:
153
+ """How many times this exact (call, result) pair has been seen so far,
154
+ WITHOUT recording. Non-consecutive repeats count — the signal that a call
155
+ is cycling (an identical Write re-issued between other actions) even when
156
+ it never repeats back-to-back. Polling is naturally exempt: its result
157
+ changes, so each poll is a different pair."""
158
+ key = f"{tool_signature(name, inputs)}\x00{hash(result or '')}"
159
+ return self._outcome_counts.get(key, 0)
160
+
152
161
  def record_outcome(self, name: str, inputs: dict, result: str) -> int:
153
162
  """Record that this exact call produced this exact result body; return how
154
163
  many times that (call, outcome) pair has now been seen. A repeated-OUTCOME
@@ -177,13 +186,22 @@ class LoopTracker:
177
186
  count = self.record(name, inputs)
178
187
  failed = (result or "").lstrip().startswith(("Error", "REFUSED"))
179
188
  outcome_count = self.record_outcome(name, inputs, result)
180
- note = loop_note(name, count, failed=failed)
189
+ # Outcome-aware repeat handling: a repeated call whose RESULT is also
190
+ # repeating is a loop; a repeated call whose result CHANGES is polling —
191
+ # legit, and must get neither notes nor pruning (pruning it to a constant
192
+ # stub both loses the fresh output AND makes the poll look like a loop
193
+ # downstream). Failed repeats keep their fix-it notes on call identity
194
+ # alone (the error text is the fix, sameness of body not required).
195
+ if failed:
196
+ note = loop_note(name, count, failed=True)
197
+ else:
198
+ note = loop_note(name, outcome_count, failed=False)
181
199
  path_note = self.record_path_write(name, inputs)
182
- # Prune the BODY of a repeated *successful* call (3rd+ identical): the
183
- # model already has this content, and re-feeding it both wastes context
184
- # and lets it mindlessly re-call. Replace the body with a stub so the
185
- # repeat yields nothing new.
186
- if note and not failed and count >= 3:
200
+ # Prune the BODY of a repeated *successful, unchanged* outcome (3rd+ time
201
+ # this exact call returned this exact body): the model already has this
202
+ # content, and re-feeding it both wastes context and lets it mindlessly
203
+ # re-call. Replace the body with a stub so the repeat yields nothing new.
204
+ if not failed and outcome_count >= 3:
187
205
  result = (
188
206
  "(identical to your earlier call to this tool — body omitted. "
189
207
  "Re-running it returns nothing new; act on the content you "
@@ -61,6 +61,10 @@ class RecoveryController:
61
61
  self.suppression_iterations = max(1, suppression_iterations)
62
62
  # signature -> iteration index at which the suppression expires
63
63
  self._suppressed: dict[str, int] = {}
64
+ # signature -> how many times it has EVER been suppressed this run; a
65
+ # second arming means suppression didn't change the model's behavior —
66
+ # per the PRD that's the stage-5 condition (controlled, honest stop).
67
+ self._suppress_counts: dict[str, int] = {}
64
68
 
65
69
  # --- suppression bookkeeping ---
66
70
 
@@ -91,6 +95,7 @@ class RecoveryController:
91
95
  *,
92
96
  offender_signature: str | None,
93
97
  iteration: int,
98
+ suppress_window: int | None = None,
94
99
  ) -> RecoveryDirective:
95
100
  """Advance to `recommended_stage` (never downgrade mid-plateau) and return
96
101
  the directive for the current action. `offender_signature` is the exact
@@ -119,8 +124,29 @@ class RecoveryController:
119
124
  "action you will avoid repeating.]"
120
125
  ))
121
126
  if stage == 3:
122
- if offender_signature:
123
- self._suppressed[offender_signature] = iteration + self.suppression_iterations
127
+ if offender_signature and offender_signature not in self._suppressed:
128
+ # A FRESH arming (not a refresh of a live window). A second fresh
129
+ # arming of the same signature means the first suppression ran its
130
+ # course — guidance included — and the model came straight back to
131
+ # the same call with the same result. Stage 4 failed implicitly;
132
+ # stop honestly rather than suppress-expire-repeat forever
133
+ # (PRD K1.6).
134
+ self._suppress_counts[offender_signature] = \
135
+ self._suppress_counts.get(offender_signature, 0) + 1
136
+ if self._suppress_counts[offender_signature] >= 2:
137
+ self.stage = 5
138
+ return RecoveryDirective(stage=5, terminate=True, note=(
139
+ "[Recovery ceiling reached: this exact action kept "
140
+ "repeating even after being suppressed once — continuing "
141
+ "will not make progress. Stop and report honestly what "
142
+ "was accomplished and what remains; do NOT claim the "
143
+ "task is complete if it is not verified.]"
144
+ ))
145
+ # A caller may widen the window (`suppress_window`) — an action
146
+ # cycling with period N evades the default window when N >= it
147
+ # (armed at k, retried at k+N, already expired).
148
+ self._suppressed[offender_signature] = iteration + (
149
+ suppress_window or self.suppression_iterations)
124
150
  return RecoveryDirective(stage=3, suppress=bool(offender_signature), note=(
125
151
  "[This repeated action is now temporarily suppressed because it "
126
152
  "has not been making progress. Take a genuinely different step.]"
@@ -39,9 +39,14 @@ class ToolResult:
39
39
 
40
40
  _EXIT = re.compile(r"\[exit code:\s*(-?\d+)\]")
41
41
  _MUTATING_BASH = re.compile(
42
+ # Word-y mutating commands need the trailing \b; output redirection (>, >>)
43
+ # must NOT — `echo hi > file` has no word char right after '>', so a shared
44
+ # \b silently missed every space-separated redirect (found via a bench trace
45
+ # where 38 heredoc/redirect writes all scored as "nothing changed").
42
46
  r"(?:^|[\s;&|])(?:rm|mv|cp|mkdir|touch|tee|sed\s+-i|dd|chmod|chown|ln|"
43
47
  r"git\s+(?:commit|add|apply|checkout|reset|rm|mv)|pip\s+install|npm\s+i|"
44
- r"apt|make(?:\s|$)|cargo\s+build|>|>>)\b", re.I,
48
+ r"apt|make(?:\s|$)|cargo\s+build)\b"
49
+ r"|(?:^|[\s;&|])>>?", re.I,
45
50
  )
46
51
  # Error phrases that usually mean "try again might work" (transient).
47
52
  _RETRYABLE = re.compile(
@@ -35,6 +35,48 @@ def looks_like_verification(cmd) -> bool:
35
35
  return bool(_VERIFY_KEYWORDS.search(cmd) or _EXEC.search(cmd))
36
36
 
37
37
 
38
+ # ── Criteria-tied evidence (bench finding: exit-0 self-checks ≠ acceptance
39
+ # criteria). Three separate traces showed the model "verifying" with checks that
40
+ # passed but never touched the files the objective is actually judged on (e.g. a
41
+ # vim macro run 23× cleanly while expected.csv was never diffed). These helpers
42
+ # let the gate name exactly which stated artifacts the checks never covered.
43
+
44
+ # File-ish tokens: a path or name with a real extension (/app/expected.csv,
45
+ # apply_macros.vim, test.sh). Excludes bare version numbers and trailing dots.
46
+ _ARTIFACT = re.compile(r"(?:/[\w.\-/]+/)?([\w\-]+\.[A-Za-z]{1,6})\b")
47
+ # Extensions that are prose, not artifacts ("e.g.", "i.e.", "v1.0", "No.1").
48
+ _NON_ARTIFACT_EXT = frozenset({"g", "e", "i", "etc", "vs", "no"})
49
+
50
+
51
+ def extract_artifacts(text) -> list[str]:
52
+ """File basenames named in an objective / acceptance criteria — the concrete
53
+ things a verification should touch. Order-preserving, deduped, best-effort;
54
+ [] when the text names no files (the gate then behaves as before)."""
55
+ if not text or not isinstance(text, str):
56
+ return []
57
+ out: list[str] = []
58
+ seen: set[str] = set()
59
+ for m in _ARTIFACT.finditer(text):
60
+ base = m.group(1)
61
+ ext = base.rsplit(".", 1)[-1].lower()
62
+ if ext in _NON_ARTIFACT_EXT or ext.isdigit():
63
+ continue
64
+ if base.lower() not in seen:
65
+ seen.add(base.lower())
66
+ out.append(base)
67
+ return out
68
+
69
+
70
+ def uncovered_artifacts(artifacts, verification_text) -> list[str]:
71
+ """The stated artifacts that `verification_text` (the concatenated commands +
72
+ results of every verification run so far) never references. What comes back
73
+ is exactly what the completion nudge should name."""
74
+ if not artifacts:
75
+ return []
76
+ low = (verification_text or "").lower()
77
+ return [a for a in artifacts if a.lower() not in low]
78
+
79
+
38
80
  @dataclass
39
81
  class VerificationEvidence:
40
82
  """Structured outcome of a verification command — did the check PASS, not just
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.162
3
+ Version: 3.0.166
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
@@ -94,6 +94,8 @@ tests/test_config.py
94
94
  tests/test_config_migration.py
95
95
  tests/test_context_limit_config.py
96
96
  tests/test_context_limit_issue25.py
97
+ tests/test_criteria_coverage.py
98
+ tests/test_cycling_detection.py
97
99
  tests/test_detect.py
98
100
  tests/test_dispatch.py
99
101
  tests/test_e2e_connected.py
@@ -128,6 +130,7 @@ tests/test_recipes.py
128
130
  tests/test_recovery.py
129
131
  tests/test_recovery_config.py
130
132
  tests/test_recovery_integration.py
133
+ tests/test_repeated_outcome.py
131
134
  tests/test_repetition_interrupt.py
132
135
  tests/test_resume_events.py
133
136
  tests/test_resume_restore.py
@@ -7,7 +7,7 @@ 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.0.162"
10
+ version = "3.0.166"
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"
@@ -0,0 +1,122 @@
1
+ """Tests for criteria-tied verification (task #22 / bench finding: exit-0
2
+ self-checks that never touch the files the task is judged on). Advisory: one
3
+ bounded nudge naming the uncovered artifacts, then completion proceeds."""
4
+ from __future__ import annotations
5
+
6
+ import tempfile
7
+
8
+ import drydock.agent as agent_mod
9
+ from drydock.agent import AgentState, run
10
+ from drydock.providers import AssistantTurn
11
+ from drydock.verification import extract_artifacts, uncovered_artifacts
12
+
13
+ LSTE_OBJECTIVE = (
14
+ "Transform /app/input.csv (1 million rows) to match /app/expected.csv exactly "
15
+ "using keystroke-efficient Vim macros. Save your script as /app/apply_macros.vim."
16
+ )
17
+
18
+
19
+ def test_extract_artifacts_from_objective():
20
+ arts = extract_artifacts(LSTE_OBJECTIVE)
21
+ assert "input.csv" in arts
22
+ assert "expected.csv" in arts
23
+ assert "apply_macros.vim" in arts
24
+
25
+
26
+ def test_extract_ignores_prose_dots():
27
+ arts = extract_artifacts("Fix the bug, e.g. the crash in v1.2 — i.e. the parser. No.1 priority.")
28
+ assert arts == []
29
+
30
+
31
+ def test_uncovered_artifacts():
32
+ arts = ["input.csv", "expected.csv", "apply_macros.vim"]
33
+ checks = "cp /app/test_input.csv t.csv\nvim -Es t.csv -S /app/apply_macros.vim\n[ok]"
34
+ missing = uncovered_artifacts(arts, checks)
35
+ assert missing == ["expected.csv"] # never diffed — the LSTE blind spot
36
+ assert uncovered_artifacts([], "anything") == []
37
+
38
+
39
+ def _write(path, content="x"):
40
+ return {"name": "Write", "input": {"file_path": path, "content": content}}
41
+
42
+
43
+ def test_uncovered_check_gets_one_nudge_then_covered_completes(monkeypatch):
44
+ d = tempfile.mkdtemp()
45
+ with open(f"{d}/expected.csv", "w") as f:
46
+ f.write("A\n")
47
+ turns = [
48
+ AssistantTurn("", [dict(id="1", **_write(f"{d}/out.csv", "A\n"))], 1, 1),
49
+ # verification that touches NOTHING named in the objective
50
+ AssistantTurn("", [{"id": "2", "name": "Bash",
51
+ "input": {"command": "python3 -c 'print(1)'"}}], 1, 1),
52
+ AssistantTurn("done", [], 1, 1), # claim -> should get coverage nudge
53
+ # now a check that touches expected.csv
54
+ AssistantTurn("", [{"id": "3", "name": "Bash",
55
+ "input": {"command": f"python3 -c 'print(open(\"{d}/expected.csv\").read())'"}}], 1, 1),
56
+ AssistantTurn("done for real", [], 1, 1), # accepted
57
+ ]
58
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([turns.pop(0)]))
59
+ st = AgentState()
60
+ list(run(f"Make out.csv match {d}/expected.csv exactly.", st,
61
+ {"model": "m", "cwd": d}, "sys"))
62
+ nudges = [m["content"] for m in st.messages
63
+ if m.get("role") == "user" and "has touched" in str(m.get("content"))]
64
+ assert len(nudges) == 1
65
+ assert "expected.csv" in nudges[0]
66
+ assert any(m.get("content") == "done for real" for m in st.messages)
67
+ assert str(st.task.phase) == "complete"
68
+
69
+
70
+ def test_nudge_is_bounded_second_claim_accepted(monkeypatch):
71
+ # The model ignores the nudge and claims done again -> advisory, accepted.
72
+ d = tempfile.mkdtemp()
73
+ turns = [
74
+ AssistantTurn("", [dict(id="1", **_write(f"{d}/a.py"))], 1, 1),
75
+ AssistantTurn("", [{"id": "2", "name": "Bash",
76
+ "input": {"command": "python3 -c 'print(1)'"}}], 1, 1),
77
+ AssistantTurn("done", [], 1, 1), # nudged (goal.csv never touched)
78
+ AssistantTurn("still done", [], 1, 1), # accepted anyway
79
+ ]
80
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([turns.pop(0)]))
81
+ st = AgentState()
82
+ list(run("Produce goal.csv from the data.", st, {"model": "m", "cwd": d}, "sys"))
83
+ assert any(m.get("content") == "still done" for m in st.messages)
84
+ assert str(st.task.phase) == "complete"
85
+
86
+
87
+ def test_no_artifacts_in_objective_no_nudge(monkeypatch):
88
+ # An objective naming no files behaves exactly as before: pass -> complete.
89
+ d = tempfile.mkdtemp()
90
+ turns = [
91
+ AssistantTurn("", [dict(id="1", **_write(f"{d}/x.py", "print(1)"))], 1, 1),
92
+ AssistantTurn("", [{"id": "2", "name": "Bash",
93
+ "input": {"command": f"python3 {d}/x.py"}}], 1, 1),
94
+ AssistantTurn("all done", [], 1, 1),
95
+ ]
96
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([turns.pop(0)]))
97
+ st = AgentState()
98
+ list(run("write a script that prints 1 and run it", st, {"model": "m", "cwd": d}, "sys"))
99
+ nudges = [m for m in st.messages
100
+ if m.get("role") == "user" and "has touched" in str(m.get("content"))]
101
+ assert nudges == []
102
+ assert str(st.task.phase) == "complete"
103
+
104
+
105
+ def test_covered_checks_complete_without_nudge(monkeypatch):
106
+ # Checks that DO touch the named artifact -> no nudge at all.
107
+ d = tempfile.mkdtemp()
108
+ with open(f"{d}/expected.csv", "w") as f:
109
+ f.write("A\n")
110
+ turns = [
111
+ AssistantTurn("", [dict(id="1", **_write(f"{d}/out.csv", "A\n"))], 1, 1),
112
+ AssistantTurn("", [{"id": "2", "name": "Bash",
113
+ "input": {"command": f"python3 -c 'print(open(\"{d}/expected.csv\").read() == open(\"{d}/out.csv\").read())'"}}], 1, 1),
114
+ AssistantTurn("matches", [], 1, 1),
115
+ ]
116
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([turns.pop(0)]))
117
+ st = AgentState()
118
+ list(run(f"Make out.csv match {d}/expected.csv exactly.", st, {"model": "m", "cwd": d}, "sys"))
119
+ nudges = [m for m in st.messages
120
+ if m.get("role") == "user" and "has touched" in str(m.get("content"))]
121
+ assert nudges == []
122
+ assert str(st.task.phase) == "complete"
@@ -0,0 +1,73 @@
1
+ """Regression for the gov162 large-scale-text-editing finding: the same exact
2
+ (call, result) pair recurring NON-consecutively is a loop, even when interleaved
3
+ actions look productive (identical macro rewritten 22x between exit-0 test runs
4
+ whose +2 scores kept resetting the stall streak). Polling stays exempt."""
5
+ from __future__ import annotations
6
+
7
+ import tempfile
8
+
9
+ import drydock.agent as agent_mod
10
+ from drydock.agent import AgentState, run
11
+ from drydock.loop_detect import LoopTracker
12
+ from drydock.providers import AssistantTurn
13
+
14
+
15
+ def test_outcome_count_getter():
16
+ t = LoopTracker()
17
+ t.record_outcome("Write", {"file_path": "m.vim", "content": "x"}, "ok")
18
+ t.record_outcome("Write", {"file_path": "m.vim", "content": "x"}, "ok")
19
+ assert t.outcome_count("Write", {"file_path": "m.vim", "content": "x"}, "ok") == 2
20
+ # different result -> different pair
21
+ assert t.outcome_count("Write", {"file_path": "m.vim", "content": "x"}, "other") == 0
22
+
23
+
24
+ def _cycling_model(macro_path):
25
+ """The gov162 pattern: identical Write alternating with VARYING, succeeding
26
+ Bash commands — the interleaved +2s reset the global stall streak, so only
27
+ per-signature cycling detection can catch it."""
28
+ def stream(**kw):
29
+ n = stream.n
30
+ stream.n += 1
31
+ if n % 2 == 0:
32
+ tc = {"id": str(n), "name": "Write",
33
+ "input": {"file_path": macro_path, "content": "call setreg('a', \"gUU\")\n"}}
34
+ else:
35
+ # a different, harmless, succeeding command each time
36
+ tc = {"id": str(n), "name": "Bash",
37
+ "input": {"command": f"echo run {n} && touch t{n}.txt"}}
38
+ return iter([AssistantTurn("", [tc], 1, 1)])
39
+ stream.n = 0
40
+ return stream
41
+
42
+
43
+ def test_nonconsecutive_identical_pair_gets_suppressed(monkeypatch):
44
+ macro = tempfile.NamedTemporaryFile(suffix=".vim", delete=False).name
45
+ monkeypatch.setattr(agent_mod, "stream", _cycling_model(macro))
46
+ st = AgentState()
47
+ cfg = {"model": "m", "cwd": tempfile.mkdtemp(), "verify_gate": False,
48
+ "max_turns": 40}
49
+ list(run("transform the file with a macro", st, cfg, "sys"))
50
+ # After the same Write+result pair recurs past the threshold, stage 3 fires
51
+ # and the signature is suppressed — visible as the redirect message in a
52
+ # tool result.
53
+ tool_msgs = [m.get("content", "") for m in st.messages if m.get("role") == "tool"]
54
+ assert any("temporarily unavailable" in c for c in tool_msgs), \
55
+ "cycling Write was never suppressed"
56
+
57
+
58
+ def test_polling_with_changing_results_never_suppressed(monkeypatch):
59
+ # Same command, DIFFERENT result each time (a poll): each (call, result)
60
+ # pair is unique, so cycling detection must stay quiet.
61
+ seq = []
62
+ for i in range(10):
63
+ seq.append(AssistantTurn(
64
+ "", [{"id": str(i), "name": "Bash",
65
+ "input": {"command": "date +%N"}}], 1, 1)) # changing output
66
+ seq.append(AssistantTurn("done polling", [], 1, 1))
67
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
68
+ st = AgentState()
69
+ list(run("watch the clock", st,
70
+ {"model": "m", "cwd": tempfile.mkdtemp(), "verify_gate": False}, "sys"))
71
+ tool_msgs = [m.get("content", "") for m in st.messages if m.get("role") == "tool"]
72
+ assert not any("temporarily unavailable" in c for c in tool_msgs)
73
+ assert any(m.get("content") == "done polling" for m in st.messages)
@@ -83,6 +83,20 @@ def test_escalation_is_monotonic_until_progress():
83
83
  assert rc.stage == 0
84
84
 
85
85
 
86
+ def test_second_fresh_suppression_of_same_signature_terminates():
87
+ # PRD K1.6: the first suppression ran its course and the model came straight
88
+ # back to the same call — suppress-expire-repeat forever is dishonest; stop.
89
+ rc = RecoveryController(suppression_iterations=2)
90
+ d1 = rc.escalate(3, offender_signature="sig", iteration=1)
91
+ assert d1.stage == 3 and not d1.terminate
92
+ # window refresh while still suppressed does NOT count as a second arming
93
+ d2 = rc.escalate(3, offender_signature="sig", iteration=2)
94
+ assert not d2.terminate
95
+ rc.tick(10) # suppression expires
96
+ d3 = rc.escalate(3, offender_signature="sig", iteration=10) # fresh re-arm
97
+ assert d3.stage == 5 and d3.terminate is True
98
+
99
+
86
100
  def test_directive_dataclass_defaults():
87
101
  d = RecoveryDirective()
88
102
  assert d.stage == 0 and d.note is None and not d.suppress and not d.terminate
@@ -0,0 +1,54 @@
1
+ """Regression for the dna-insert bench finding (PRD Epic J3): the SAME failing
2
+ verification outcome recurring through DIFFERENT commands must accrue toward
3
+ recovery — no exact call repeats, so repeat_count alone never fires."""
4
+ from __future__ import annotations
5
+
6
+ import tempfile
7
+
8
+ import drydock.agent as agent_mod
9
+ from drydock.agent import AgentState, run
10
+ from drydock.providers import AssistantTurn
11
+
12
+
13
+ def _varying_failing_verifications():
14
+ """Model that re-runs a failing check with a slightly different command each
15
+ time (comment varies) — same failing outcome, no byte-identical repeats."""
16
+ def stream(**kw):
17
+ n = stream.n
18
+ stream.n += 1
19
+ cmd = f'python3 -m pytest nonexistent_{0}.py # attempt {n}'
20
+ return iter([AssistantTurn(
21
+ "", [{"id": str(n), "name": "Bash", "input": {"command": cmd}}], 1, 1)])
22
+ stream.n = 0
23
+ return stream
24
+
25
+
26
+ def test_same_failing_outcome_different_commands_triggers_recovery(monkeypatch):
27
+ monkeypatch.setattr(agent_mod, "stream", _varying_failing_verifications())
28
+ st = AgentState()
29
+ list(run("make the tests pass", st,
30
+ {"model": "m", "cwd": tempfile.mkdtemp(), "verify_gate": False,
31
+ "max_turns": 60}, "sys"))
32
+ # The identical failing outcome (same exit/summary through varying commands)
33
+ # must drive recovery well short of the 60-turn ceiling.
34
+ assert st.turn_count < 30, f"ran {st.turn_count} turns — repeated outcome never fired"
35
+ assert st.recovery_stage >= 3
36
+
37
+
38
+ def test_changing_failure_does_not_count_as_repeated_outcome(monkeypatch):
39
+ # Failures that CHANGE (different summary each time) are progress signals,
40
+ # not stalls — a healthy fix-test-fix loop must not trip J3.
41
+ seq = []
42
+ for i in range(1, 5):
43
+ # each command exits with a DIFFERENT code -> different summary key
44
+ seq.append(AssistantTurn(
45
+ "", [{"id": str(i), "name": "Bash",
46
+ "input": {"command": f'python3 -m pytest x.py; exit {i} # run'}}], 1, 1))
47
+ seq.append(AssistantTurn("done for now", [], 1, 1))
48
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
49
+ st = AgentState()
50
+ list(run("check things", st,
51
+ {"model": "m", "cwd": tempfile.mkdtemp(), "verify_gate": False}, "sys"))
52
+ # ran to its natural end without recovery escalating past advisory
53
+ assert any(m.get("content") == "done for now" for m in st.messages)
54
+ assert st.recovery_stage <= 1
@@ -40,6 +40,18 @@ def test_classify_changed_state_and_retryable():
40
40
  assert r.status == "error" and r.retryable and r.error_code == "transient"
41
41
 
42
42
 
43
+ def test_classify_redirect_writes_are_mutations():
44
+ # Regression (gov162 make-mips-interpreter trace): the shared \b after '>'
45
+ # missed every space-separated redirect, so 38 heredoc/redirect writes all
46
+ # scored as "nothing changed".
47
+ assert classify("Bash", {"command": "echo hi > out.txt"}, "", 1).changed_state
48
+ assert classify("Bash", {"command": "cat <<EOF > prog.py\nx\nEOF"}, "", 1).changed_state
49
+ assert classify("Bash", {"command": "python gen.py >> log.txt"}, "", 1).changed_state
50
+ # fd-redirection is NOT a file write; plain reads stay non-mutating
51
+ assert not classify("Bash", {"command": "diff a b"}, "", 1).changed_state
52
+ assert not classify("Bash", {"command": "cat file.txt"}, "x", 1).changed_state
53
+
54
+
43
55
  def test_error_prefix_string():
44
56
  r = classify("Edit", {}, "Error: old_string not found", 1)
45
57
  assert r.status == "error" and not r.changed_state # failed edit didn't mutate
File without changes