drydock-cli 3.0.122__tar.gz → 3.0.124__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 (138) hide show
  1. {drydock_cli-3.0.122/drydock_cli.egg-info → drydock_cli-3.0.124}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/agent.py +47 -0
  3. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/config.py +8 -0
  4. drydock_cli-3.0.124/drydock/task_state.py +84 -0
  5. drydock_cli-3.0.124/drydock/verification.py +34 -0
  6. {drydock_cli-3.0.122 → drydock_cli-3.0.124/drydock_cli.egg-info}/PKG-INFO +1 -1
  7. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock_cli.egg-info/SOURCES.txt +4 -0
  8. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/pyproject.toml +1 -1
  9. drydock_cli-3.0.124/tests/test_task_state.py +69 -0
  10. drydock_cli-3.0.124/tests/test_verification_gate.py +71 -0
  11. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/LICENSE +0 -0
  12. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/NOTICE +0 -0
  13. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/README.md +0 -0
  14. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/__init__.py +0 -0
  15. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/__main__.py +0 -0
  16. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/advisor.py +0 -0
  17. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/bash_safety.py +0 -0
  18. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/__init__.py +0 -0
  19. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/ml-data.md +0 -0
  20. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/ml-debug.md +0 -0
  21. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/ml-finetune.md +0 -0
  22. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/ml-metrics.md +0 -0
  23. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/ml-rl.md +0 -0
  24. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/ml-train.md +0 -0
  25. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/rmf-categorize.md +0 -0
  26. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/rmf-control.md +0 -0
  27. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/rmf-poam.md +0 -0
  28. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/rmf-review.md +0 -0
  29. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/stig-assess.md +0 -0
  30. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/stig-remediate.md +0 -0
  31. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/cci.py +0 -0
  32. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/cli.py +0 -0
  33. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/compaction.py +0 -0
  34. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/detect.py +0 -0
  35. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/extract.py +0 -0
  36. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/gittools.py +0 -0
  37. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/graphrag.py +0 -0
  38. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/guards.py +0 -0
  39. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/loop_detect.py +0 -0
  40. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/mcp.py +0 -0
  41. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/poam.py +0 -0
  42. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/providers.py +0 -0
  43. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/recipes.py +0 -0
  44. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/rmf.py +0 -0
  45. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/rmf_graph.py +0 -0
  46. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/skills.py +0 -0
  47. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/stig.py +0 -0
  48. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/suggest.py +0 -0
  49. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tool_registry.py +0 -0
  50. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tools/__init__.py +0 -0
  51. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tui/__init__.py +0 -0
  52. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tui/app.py +0 -0
  53. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tui/approval.py +0 -0
  54. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tui/messages.py +0 -0
  55. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tui/widgets.py +0 -0
  56. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tuning.py +0 -0
  57. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/web.py +0 -0
  58. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock_cli.egg-info/dependency_links.txt +0 -0
  59. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock_cli.egg-info/entry_points.txt +0 -0
  60. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock_cli.egg-info/requires.txt +0 -0
  61. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock_cli.egg-info/top_level.txt +0 -0
  62. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/setup.cfg +0 -0
  63. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_advisor.py +0 -0
  64. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_approval.py +0 -0
  65. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_back_command.py +0 -0
  66. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_background.py +0 -0
  67. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_binary_output.py +0 -0
  68. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_crossplatform.py +0 -0
  69. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_output_bounding.py +0 -0
  70. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_process_group.py +0 -0
  71. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_safety.py +0 -0
  72. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_sanitize.py +0 -0
  73. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_shell.py +0 -0
  74. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_stdin.py +0 -0
  75. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_stop_partial.py +0 -0
  76. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_timeout_network.py +0 -0
  77. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_timeout_param.py +0 -0
  78. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_cci.py +0 -0
  79. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_cli_agents.py +0 -0
  80. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_compact_command.py +0 -0
  81. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_compaction.py +0 -0
  82. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_config.py +0 -0
  83. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_config_migration.py +0 -0
  84. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_context_limit_config.py +0 -0
  85. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_detect.py +0 -0
  86. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_dispatch.py +0 -0
  87. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_e2e_connected.py +0 -0
  88. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_edit_replace_all.py +0 -0
  89. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_empty_response.py +0 -0
  90. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_extract.py +0 -0
  91. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_failure_loop.py +0 -0
  92. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_first_run_setup.py +0 -0
  93. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_gittools.py +0 -0
  94. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_glob_edit_edges.py +0 -0
  95. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_graphify_example.py +0 -0
  96. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_graphrag.py +0 -0
  97. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_graphrag_quoted_path.py +0 -0
  98. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_grep_and_read_robust.py +0 -0
  99. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_guards_and_tools.py +0 -0
  100. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_hallucinated_tools.py +0 -0
  101. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_leaked_tool_call.py +0 -0
  102. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_loop_detect.py +0 -0
  103. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_mcp.py +0 -0
  104. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_oneshot_unreachable.py +0 -0
  105. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_overthink_interrupt.py +0 -0
  106. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_plan_autocontinue.py +0 -0
  107. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_poam.py +0 -0
  108. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_providers_unreachable.py +0 -0
  109. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_read_index.py +0 -0
  110. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_recipes.py +0 -0
  111. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_repetition_interrupt.py +0 -0
  112. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_rmf.py +0 -0
  113. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_rmf_graph.py +0 -0
  114. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_rmf_stig_graph.py +0 -0
  115. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_runaway_repetition.py +0 -0
  116. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_screenshot.py +0 -0
  117. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_server_probe.py +0 -0
  118. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_skills.py +0 -0
  119. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_stall_retry.py +0 -0
  120. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_stig.py +0 -0
  121. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_stop.py +0 -0
  122. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_streaming_newlines.py +0 -0
  123. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_subagent.py +0 -0
  124. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_suggest.py +0 -0
  125. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_system_prompt_help.py +0 -0
  126. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_todo.py +0 -0
  127. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_tool_arg_coercion.py +0 -0
  128. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_tool_arg_coercion_more.py +0 -0
  129. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_tool_arg_parsing.py +0 -0
  130. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_tools_undo.py +0 -0
  131. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_tui.py +0 -0
  132. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_tuning.py +0 -0
  133. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_viewimage.py +0 -0
  134. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_vision_input.py +0 -0
  135. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_web_tools.py +0 -0
  136. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_windows_shell.py +0 -0
  137. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_write_content_coerce.py +0 -0
  138. {drydock_cli-3.0.122 → drydock_cli-3.0.124}/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.122
3
+ Version: 3.0.124
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
@@ -47,6 +47,8 @@ from drydock.compaction import (
47
47
  maybe_compact, emergency_compact, is_context_length_error, is_image_load_error,
48
48
  )
49
49
  from drydock.loop_detect import LoopTracker
50
+ from drydock.task_state import TaskState
51
+ from drydock.verification import looks_like_verification
50
52
  from drydock.tuning import (
51
53
  filter_tool_schemas,
52
54
  hallucinated_tool_message,
@@ -80,6 +82,7 @@ class AgentState:
80
82
  last_input_tokens: int = 0 # prompt tokens the SERVER counted on the last call
81
83
  turn_count: int = 0
82
84
  current_effort: str = "" # "high"/"low" of the in-flight LLM call (for the UI)
85
+ task: "TaskState" = field(default_factory=lambda: TaskState()) # structured objective
83
86
 
84
87
 
85
88
  def drop_last_turn(messages: list) -> bool:
@@ -114,6 +117,18 @@ def run(
114
117
  """
115
118
  state.messages.append({"role": "user", "content": user_message})
116
119
 
120
+ # Capture the structured objective the FIRST time a task arrives, so it lives
121
+ # outside the transcript. The original objective is authoritative for the task.
122
+ if not state.task.is_set() and isinstance(user_message, str) and user_message.strip():
123
+ state.task = TaskState.from_objective(user_message)
124
+ # Keep the objective + acceptance criteria in the SYSTEM PROMPT every turn, so
125
+ # they survive compaction (which only touches the message transcript, never the
126
+ # system prompt) — the model can't drift off the goal on a long task.
127
+ if config.get("task_anchor", True) and state.task.is_set():
128
+ anchor = state.task.anchor_text()
129
+ if anchor:
130
+ system_prompt = system_prompt + "\n\n" + anchor
131
+
117
132
  # Recipe retrieval: give the model the TECHNIQUE this task needs (forensics,
118
133
  # git-history rewrite, numpy-2.0 fix, cert gen, …) by appending the relevant
119
134
  # bundled recipes to the system prompt. Keyword-matched, so only fitting ones
@@ -139,6 +154,8 @@ def run(
139
154
  return cancel is not None and cancel.is_set()
140
155
  tool_call_count = 0
141
156
  session_has_edited = False
157
+ ran_verification = False # a test/check/exec command ran (verification gate)
158
+ verify_gate_nudges = 0 # bounded "verify before you finish" nudges
142
159
  leaked_call_retries = 0
143
160
  plan_continue_nudges = 0 # consecutive "you stopped mid-plan" nudges
144
161
  empty_response_nudges = 0 # consecutive "you returned nothing" nudges
@@ -318,6 +335,32 @@ def run(
318
335
  ),
319
336
  })
320
337
  continue
338
+ # VERIFICATION GATE (PRD Epic B): a text-only "done" is not evidence.
339
+ # If the agent CHANGED files but never ran a test/check/its own code,
340
+ # don't accept completion — make it verify first. Bounded so it can't
341
+ # wedge; once it runs any check (ran_verification) the gate is satisfied.
342
+ if (
343
+ config.get("verify_gate", True)
344
+ and session_has_edited
345
+ and not ran_verification
346
+ and verify_gate_nudges < 2
347
+ ):
348
+ verify_gate_nudges += 1
349
+ state.task.phase = "verify"
350
+ state.messages.append({
351
+ "role": "user",
352
+ "content": (
353
+ "[SYSTEM] You changed files but have not VERIFIED the work. "
354
+ "Before finishing, run a concrete check: the task's own test/"
355
+ "eval/build (pytest, test.sh, make, npm test, …) or run the "
356
+ "code/script you produced and confirm the output is correct "
357
+ "and meets EVERY requirement. Do that now with a tool call; "
358
+ "if it fails, fix it and re-check."
359
+ ),
360
+ })
361
+ continue
362
+ if session_has_edited and ran_verification:
363
+ state.task.phase = "complete"
321
364
  break
322
365
 
323
366
  # Execute each tool call
@@ -325,6 +368,10 @@ def run(
325
368
  tool_call_count += 1
326
369
  if tc["name"] in ("Edit", "Write"):
327
370
  session_has_edited = True
371
+ elif tc["name"] == "Bash" and looks_like_verification(
372
+ (tc.get("input") or {}).get("command", "")
373
+ ):
374
+ ran_verification = True # the agent actually ran a test/check/its code
328
375
 
329
376
  # STOP pressed: don't run the remaining tools, but still record a
330
377
  # paired result for each (the assistant message already lists all
@@ -44,6 +44,14 @@ DEFAULTS: dict[str, object] = {
44
44
  # instead of guessing. Retrieval is keyword-overlap; only relevant recipes are
45
45
  # added (none = nothing injected). Set false to disable.
46
46
  "recipes": True,
47
+ # Keep the structured objective + acceptance criteria (task_state.py) in the
48
+ # system prompt every turn so they survive context compaction — the model can't
49
+ # drift off the original goal on a long task. Set false to disable.
50
+ "task_anchor": True,
51
+ # Verification gate (PRD Epic B): if the model claims "done" after changing
52
+ # files but never ran a test/check/its own code, nudge it to verify first
53
+ # instead of accepting completion. Bounded. Set false to disable.
54
+ "verify_gate": True,
47
55
  "theme": "harbor",
48
56
  # Optional SECOND model ("advisor") for a stronger second opinion — e.g. a
49
57
  # Gemini OpenAI-compatible endpoint on another box. Empty = disabled. The
@@ -0,0 +1,84 @@
1
+ """Structured task state — the authoritative objective + acceptance criteria kept
2
+ OUTSIDE the chat transcript, so they survive context compaction and the model never
3
+ loses sight of the goal on a long task.
4
+
5
+ First increment toward the Agent-Buildout PRD (Epic A: Structured Task State /
6
+ Principle 4.2 "structured state is authoritative"). Deliberately small — a dataclass
7
+ the agent loop populates once and re-injects as a compact anchor after compaction —
8
+ so it drops into the current loop without a rewrite. All logic original to Drydock.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass, field
14
+
15
+ # Requirement-like lines: numbered (1. / 1)) or bulleted (- * •).
16
+ _LIST_ITEM = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s+(.*\S)\s*$", re.M)
17
+ # Imperative "must/should/exactly/required" sentences (fallback when there's no list).
18
+ _IMPERATIVE = re.compile(r"\b(must|should|exactly|required|ensure|has to|needs? to)\b", re.I)
19
+
20
+
21
+ def extract_acceptance_criteria(objective: str, limit: int = 10) -> list[str]:
22
+ """Pull discrete acceptance criteria out of the objective: numbered/bulleted
23
+ lines first, else imperative must/should sentences. Best-effort; never raises."""
24
+ if not objective or not isinstance(objective, str):
25
+ return []
26
+ items = [m.group(1).strip() for m in _LIST_ITEM.finditer(objective)]
27
+ if not items:
28
+ for s in re.split(r"(?<=[.!?])\s+", objective):
29
+ s = s.strip()
30
+ if s and len(s) < 240 and _IMPERATIVE.search(s):
31
+ items.append(s)
32
+ # de-dupe preserving order
33
+ seen: set[str] = set()
34
+ out = []
35
+ for it in items:
36
+ k = it.lower()
37
+ if k not in seen:
38
+ seen.add(k)
39
+ out.append(it)
40
+ return out[:limit]
41
+
42
+
43
+ @dataclass
44
+ class TaskState:
45
+ """Authoritative task facts, independent of the message transcript."""
46
+ objective: str = ""
47
+ acceptance_criteria: list[str] = field(default_factory=list)
48
+ phase: str = "understand"
49
+
50
+ @classmethod
51
+ def from_objective(cls, objective: str) -> TaskState:
52
+ obj = (objective or "").strip() if isinstance(objective, str) else str(objective or "").strip()
53
+ return cls(objective=obj, acceptance_criteria=extract_acceptance_criteria(obj))
54
+
55
+ def is_set(self) -> bool:
56
+ return bool(self.objective)
57
+
58
+ def to_dict(self) -> dict:
59
+ """Serializable WITHOUT the transcript — the task can be reconstructed from this."""
60
+ return {
61
+ "objective": self.objective,
62
+ "acceptance_criteria": list(self.acceptance_criteria),
63
+ "phase": self.phase,
64
+ }
65
+
66
+ @classmethod
67
+ def from_dict(cls, d: dict) -> TaskState:
68
+ return cls(
69
+ objective=d.get("objective", ""),
70
+ acceptance_criteria=list(d.get("acceptance_criteria", [])),
71
+ phase=d.get("phase", "understand"),
72
+ )
73
+
74
+ def anchor_text(self) -> str:
75
+ """A compact, durable reminder re-injected after compaction so a summarized
76
+ transcript can never lose the original objective + criteria. '' if unset."""
77
+ if not self.objective:
78
+ return ""
79
+ head = self.objective if len(self.objective) <= 700 else self.objective[:700] + " …"
80
+ lines = [f"[TASK — do not lose sight of the original objective]\n{head}"]
81
+ if self.acceptance_criteria:
82
+ lines.append("Acceptance criteria (all must be met):")
83
+ lines += [f" - {c}" for c in self.acceptance_criteria]
84
+ return "\n".join(lines)
@@ -0,0 +1,34 @@
1
+ """Verification gating (Agent-Buildout PRD, Epic B / verification):
2
+
3
+ "A text-only model response is not evidence that a task is complete."
4
+
5
+ The agent loop uses this to decide, when the model claims "done", whether it
6
+ actually VERIFIED — i.e. ran a test, a linter/type-check, a build, or executed the
7
+ code it produced. If it changed files but ran nothing, the loop nudges it to verify
8
+ instead of accepting completion. Deliberately small (a command classifier + the loop
9
+ gate) so it drops into the current loop without a rewrite. Logic original to Drydock.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ # Explicit test / lint / type-check / build commands.
16
+ _VERIFY_KEYWORDS = re.compile(
17
+ r"\b(pytest|py\.test|unittest|nosetests?|tox|test\.sh|run_tests|"
18
+ r"make(\s+\w+)?|npm\s+(test|run)|yarn\s+(test|run)|pnpm\s+(test|run)|jest|mocha|vitest|"
19
+ r"cargo\s+(test|check|build|run|clippy)|go\s+(test|build|run|vet)|"
20
+ r"ruff|flake8|pylint|mypy|pyright|eslint|tsc|prettier|"
21
+ r"ctest|cmake|gradle|mvn|rspec|phpunit|dotnet\s+test|bats)\b",
22
+ re.I,
23
+ )
24
+ # Executing something — running the produced script/binary counts as checking it.
25
+ _EXEC = re.compile(r"(?:^|[\s;&|(])(?:python3?|node|deno|bun|bash|sh|ruby|perl|"
26
+ r"java|javac|gcc|g\+\+|clang|\./|\.\\)")
27
+
28
+
29
+ def looks_like_verification(cmd) -> bool:
30
+ """True if a shell command constitutes verifying work — running tests/checks/
31
+ builds, or executing the produced code. Best-effort; never raises."""
32
+ if not cmd or not isinstance(cmd, str):
33
+ return False
34
+ return bool(_VERIFY_KEYWORDS.search(cmd) or _EXEC.search(cmd))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.122
3
+ Version: 3.0.124
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
@@ -26,8 +26,10 @@ drydock/rmf_graph.py
26
26
  drydock/skills.py
27
27
  drydock/stig.py
28
28
  drydock/suggest.py
29
+ drydock/task_state.py
29
30
  drydock/tool_registry.py
30
31
  drydock/tuning.py
32
+ drydock/verification.py
31
33
  drydock/web.py
32
34
  drydock/builtin_skills/__init__.py
33
35
  drydock/builtin_skills/ml-data.md
@@ -117,6 +119,7 @@ tests/test_streaming_newlines.py
117
119
  tests/test_subagent.py
118
120
  tests/test_suggest.py
119
121
  tests/test_system_prompt_help.py
122
+ tests/test_task_state.py
120
123
  tests/test_todo.py
121
124
  tests/test_tool_arg_coercion.py
122
125
  tests/test_tool_arg_coercion_more.py
@@ -124,6 +127,7 @@ tests/test_tool_arg_parsing.py
124
127
  tests/test_tools_undo.py
125
128
  tests/test_tui.py
126
129
  tests/test_tuning.py
130
+ tests/test_verification_gate.py
127
131
  tests/test_viewimage.py
128
132
  tests/test_vision_input.py
129
133
  tests/test_web_tools.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.122"
10
+ version = "3.0.124"
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,69 @@
1
+ """Structured task state (Agent-Buildout PRD Epic A): the objective + acceptance
2
+ criteria live outside the transcript, are serializable, and survive compaction."""
3
+ from __future__ import annotations
4
+
5
+ from drydock import agent
6
+ from drydock.providers import AssistantTurn
7
+ from drydock.task_state import TaskState, extract_acceptance_criteria
8
+
9
+
10
+ def test_extracts_numbered_and_bulleted_criteria():
11
+ obj = "Build a parser. Requirements:\n1. Read /app/in.txt\n2. Write /app/out.json\n- Exit 0"
12
+ c = extract_acceptance_criteria(obj)
13
+ assert c == ["Read /app/in.txt", "Write /app/out.json", "Exit 0"]
14
+
15
+
16
+ def test_extracts_imperative_when_no_list():
17
+ c = extract_acceptance_criteria("The script must print the CN and it should exit 0.")
18
+ assert any("must print" in x for x in c)
19
+
20
+
21
+ def test_from_objective_and_serializable_without_transcript():
22
+ ts = TaskState.from_objective("Do X.\n1. A\n2. B")
23
+ assert ts.is_set() and ts.acceptance_criteria == ["A", "B"] and ts.phase == "understand"
24
+ d = ts.to_dict() # A1.1: serializable without the transcript
25
+ assert d["objective"] and d["acceptance_criteria"] == ["A", "B"]
26
+ assert TaskState.from_dict(d).objective == ts.objective # reconstructable
27
+
28
+
29
+ def test_anchor_text_includes_objective_and_criteria():
30
+ a = TaskState.from_objective("Fix the bug.\n1. tests pass\n2. no regressions").anchor_text()
31
+ assert "Fix the bug" in a and "tests pass" in a and "no regressions" in a
32
+ assert TaskState().anchor_text() == "" # empty when unset
33
+
34
+
35
+ def _run_capture(cfg, msg, state=None):
36
+ seen = {}
37
+ def fake_stream(model, system, messages, tool_schemas, config):
38
+ seen["system"] = system
39
+ yield AssistantTurn("done", [], 5, 5)
40
+ orig = agent.stream; agent.stream = fake_stream
41
+ try:
42
+ st = state or agent.AgentState()
43
+ list(agent.run(msg, st, cfg, "BASE"))
44
+ return seen["system"], st
45
+ finally:
46
+ agent.stream = orig
47
+
48
+
49
+ def test_objective_captured_and_anchored_in_system_prompt():
50
+ cfg = {"model": "gemma4", "max_tokens": 8192, "context_limit": 65536, "task_anchor": True}
51
+ sysp, st = _run_capture(cfg, "Recover the password.\n1. it is 23 chars\n2. write it to out.txt")
52
+ assert st.task.is_set() and st.task.acceptance_criteria == ["it is 23 chars", "write it to out.txt"]
53
+ assert "TASK" in sysp and "23 chars" in sysp # anchor present in system prompt
54
+
55
+
56
+ def test_anchor_survives_a_compacted_follow_up_turn():
57
+ # A2: after the transcript is replaced (compaction), the objective is still in the
58
+ # system prompt on the next turn because it lives in TaskState, not the messages.
59
+ cfg = {"model": "gemma4", "max_tokens": 8192, "context_limit": 65536, "task_anchor": True}
60
+ _, st = _run_capture(cfg, "Original objective X.\n1. criterion one")
61
+ st.messages = [{"role": "user", "content": "(transcript summarized away)"}] # simulate compaction
62
+ sysp2, _ = _run_capture(cfg, "keep going", state=st)
63
+ assert "Original objective X" in sysp2 and "criterion one" in sysp2
64
+
65
+
66
+ def test_disabled_when_task_anchor_false():
67
+ cfg = {"model": "gemma4", "max_tokens": 8192, "context_limit": 65536, "task_anchor": False}
68
+ sysp, _ = _run_capture(cfg, "Do the thing.\n1. a criterion")
69
+ assert "TASK" not in sysp
@@ -0,0 +1,71 @@
1
+ """Verification gating (Agent-Buildout PRD Epic B): a text-only 'done' after editing
2
+ files is not accepted until the agent runs a check. Model can't self-declare done."""
3
+ from __future__ import annotations
4
+
5
+ from drydock import agent
6
+ from drydock.providers import AssistantTurn
7
+ from drydock.verification import looks_like_verification as v
8
+
9
+
10
+ def test_classifier():
11
+ for cmd in ("pytest -q", "python -m pytest tests/", "bash test.sh", "make test",
12
+ "npm test", "cargo test", "ruff check .", "mypy src", "python solve.py",
13
+ "./run_check", "node index.js"):
14
+ assert v(cmd), cmd
15
+ for cmd in ("ls -la", "cat file.txt", "grep foo bar", "echo hi", "git status", ""):
16
+ assert not v(cmd), cmd
17
+ assert not v(None) and not v(123)
18
+
19
+
20
+ def _drive(cfg, turns):
21
+ """turns: list of AssistantTurn to yield in order; returns the messages seen."""
22
+ calls = {"i": 0}
23
+ def fake_stream(model, system, messages, tool_schemas, config):
24
+ i = calls["i"]; calls["i"] += 1
25
+ yield turns[min(i, len(turns) - 1)]
26
+ orig = agent.stream; agent.stream = fake_stream
27
+ try:
28
+ st = agent.AgentState()
29
+ list(agent.run("do it", st, cfg, "SYS"))
30
+ return st, calls["i"]
31
+ finally:
32
+ agent.stream = orig
33
+
34
+
35
+ CFG = {"model": "gemma4", "max_tokens": 8192, "context_limit": 65536, "verify_gate": True}
36
+
37
+
38
+ def test_gate_fires_after_edit_without_verification():
39
+ # turn 1: Write a file (no tool result needed — execute() runs the real tool);
40
+ # turn 2: text-only "done". Gate should nudge -> a 3rd call happens.
41
+ write = AssistantTurn("", [{"id": "1", "name": "Write",
42
+ "input": {"file_path": "/tmp/dd_vg.txt", "content": "x"}}], 5, 5)
43
+ done = AssistantTurn("All done!", [], 5, 5)
44
+ st, n_calls = _drive(CFG, [write, done, done])
45
+ # the gate injected a verify nudge → the loop asked the model again (>2 calls)
46
+ assert n_calls >= 3
47
+ assert any("VERIFIED" in m.get("content", "") for m in st.messages if m.get("role") == "user")
48
+
49
+
50
+ def test_no_gate_when_verification_ran():
51
+ write = AssistantTurn("", [{"id": "1", "name": "Write",
52
+ "input": {"file_path": "/tmp/dd_vg2.txt", "content": "x"}}], 5, 5)
53
+ check = AssistantTurn("", [{"id": "2", "name": "Bash", "input": {"command": "python -c 'print(1)'"}}], 5, 5)
54
+ done = AssistantTurn("Done, verified.", [], 5, 5)
55
+ st, _ = _drive(CFG, [write, check, done])
56
+ assert not any("VERIFIED" in m.get("content", "") for m in st.messages if m.get("role") == "user")
57
+ assert st.task.phase == "complete"
58
+
59
+
60
+ def test_no_gate_when_no_edits():
61
+ # pure Q&A: no files changed → nothing to verify → accept immediately
62
+ st, _ = _drive(CFG, [AssistantTurn("The answer is 42.", [], 5, 5)])
63
+ assert not any("VERIFIED" in m.get("content", "") for m in st.messages if m.get("role") == "user")
64
+
65
+
66
+ def test_disabled():
67
+ write = AssistantTurn("", [{"id": "1", "name": "Write",
68
+ "input": {"file_path": "/tmp/dd_vg3.txt", "content": "x"}}], 5, 5)
69
+ done = AssistantTurn("done", [], 5, 5)
70
+ st, _ = _drive({**CFG, "verify_gate": False}, [write, done])
71
+ assert not any("VERIFIED" in m.get("content", "") for m in st.messages if m.get("role") == "user")
File without changes
File without changes
File without changes
File without changes