drydock-cli 3.0.125__tar.gz → 3.0.127__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 (141) hide show
  1. {drydock_cli-3.0.125/drydock_cli.egg-info → drydock_cli-3.0.127}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/agent.py +52 -22
  3. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/config.py +3 -0
  4. drydock_cli-3.0.127/drydock/events.py +68 -0
  5. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tui/app.py +3 -0
  6. drydock_cli-3.0.127/drydock/verification.py +75 -0
  7. {drydock_cli-3.0.125 → drydock_cli-3.0.127/drydock_cli.egg-info}/PKG-INFO +1 -1
  8. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock_cli.egg-info/SOURCES.txt +2 -0
  9. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/pyproject.toml +1 -1
  10. drydock_cli-3.0.127/tests/test_events.py +58 -0
  11. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_verification_gate.py +32 -0
  12. drydock_cli-3.0.125/drydock/verification.py +0 -34
  13. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/LICENSE +0 -0
  14. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/NOTICE +0 -0
  15. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/README.md +0 -0
  16. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/__init__.py +0 -0
  17. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/__main__.py +0 -0
  18. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/advisor.py +0 -0
  19. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/bash_safety.py +0 -0
  20. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/__init__.py +0 -0
  21. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/ml-data.md +0 -0
  22. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/ml-debug.md +0 -0
  23. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/ml-finetune.md +0 -0
  24. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/ml-metrics.md +0 -0
  25. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/ml-rl.md +0 -0
  26. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/ml-train.md +0 -0
  27. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/rmf-categorize.md +0 -0
  28. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/rmf-control.md +0 -0
  29. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/rmf-poam.md +0 -0
  30. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/rmf-review.md +0 -0
  31. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/stig-assess.md +0 -0
  32. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/stig-remediate.md +0 -0
  33. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/cci.py +0 -0
  34. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/cli.py +0 -0
  35. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/compaction.py +0 -0
  36. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/detect.py +0 -0
  37. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/extract.py +0 -0
  38. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/gittools.py +0 -0
  39. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/graphrag.py +0 -0
  40. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/guards.py +0 -0
  41. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/loop_detect.py +0 -0
  42. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/mcp.py +0 -0
  43. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/poam.py +0 -0
  44. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/providers.py +0 -0
  45. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/recipes.py +0 -0
  46. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/rmf.py +0 -0
  47. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/rmf_graph.py +0 -0
  48. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/skills.py +0 -0
  49. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/stig.py +0 -0
  50. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/suggest.py +0 -0
  51. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/task_state.py +0 -0
  52. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tool_registry.py +0 -0
  53. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tools/__init__.py +0 -0
  54. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tui/__init__.py +0 -0
  55. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tui/approval.py +0 -0
  56. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tui/messages.py +0 -0
  57. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tui/widgets.py +0 -0
  58. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tuning.py +0 -0
  59. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/web.py +0 -0
  60. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock_cli.egg-info/dependency_links.txt +0 -0
  61. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock_cli.egg-info/entry_points.txt +0 -0
  62. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock_cli.egg-info/requires.txt +0 -0
  63. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock_cli.egg-info/top_level.txt +0 -0
  64. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/setup.cfg +0 -0
  65. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_advisor.py +0 -0
  66. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_approval.py +0 -0
  67. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_back_command.py +0 -0
  68. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_background.py +0 -0
  69. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_binary_output.py +0 -0
  70. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_crossplatform.py +0 -0
  71. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_output_bounding.py +0 -0
  72. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_process_group.py +0 -0
  73. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_safety.py +0 -0
  74. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_sanitize.py +0 -0
  75. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_shell.py +0 -0
  76. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_stdin.py +0 -0
  77. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_stop_partial.py +0 -0
  78. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_timeout_network.py +0 -0
  79. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_timeout_param.py +0 -0
  80. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_cci.py +0 -0
  81. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_cli_agents.py +0 -0
  82. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_compact_command.py +0 -0
  83. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_compaction.py +0 -0
  84. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_config.py +0 -0
  85. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_config_migration.py +0 -0
  86. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_context_limit_config.py +0 -0
  87. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_detect.py +0 -0
  88. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_dispatch.py +0 -0
  89. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_e2e_connected.py +0 -0
  90. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_edit_replace_all.py +0 -0
  91. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_empty_response.py +0 -0
  92. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_extract.py +0 -0
  93. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_failure_loop.py +0 -0
  94. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_first_run_setup.py +0 -0
  95. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_gittools.py +0 -0
  96. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_glob_edit_edges.py +0 -0
  97. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_graphify_example.py +0 -0
  98. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_graphrag.py +0 -0
  99. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_graphrag_quoted_path.py +0 -0
  100. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_grep_and_read_robust.py +0 -0
  101. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_guards_and_tools.py +0 -0
  102. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_hallucinated_tools.py +0 -0
  103. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_leaked_tool_call.py +0 -0
  104. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_loop_detect.py +0 -0
  105. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_mcp.py +0 -0
  106. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_oneshot_unreachable.py +0 -0
  107. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_overthink_interrupt.py +0 -0
  108. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_plan_autocontinue.py +0 -0
  109. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_poam.py +0 -0
  110. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_providers_unreachable.py +0 -0
  111. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_read_index.py +0 -0
  112. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_recipes.py +0 -0
  113. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_repetition_interrupt.py +0 -0
  114. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_rmf.py +0 -0
  115. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_rmf_graph.py +0 -0
  116. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_rmf_stig_graph.py +0 -0
  117. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_runaway_repetition.py +0 -0
  118. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_screenshot.py +0 -0
  119. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_server_probe.py +0 -0
  120. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_skills.py +0 -0
  121. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_stall_retry.py +0 -0
  122. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_stig.py +0 -0
  123. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_stop.py +0 -0
  124. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_streaming_newlines.py +0 -0
  125. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_subagent.py +0 -0
  126. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_suggest.py +0 -0
  127. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_system_prompt_help.py +0 -0
  128. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_task_state.py +0 -0
  129. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_todo.py +0 -0
  130. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_tool_arg_coercion.py +0 -0
  131. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_tool_arg_coercion_more.py +0 -0
  132. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_tool_arg_parsing.py +0 -0
  133. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_tools_undo.py +0 -0
  134. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_tui.py +0 -0
  135. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_tuning.py +0 -0
  136. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_viewimage.py +0 -0
  137. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_vision_input.py +0 -0
  138. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_web_tools.py +0 -0
  139. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_windows_shell.py +0 -0
  140. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_write_content_coerce.py +0 -0
  141. {drydock_cli-3.0.125 → drydock_cli-3.0.127}/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.125
3
+ Version: 3.0.127
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
@@ -48,7 +48,8 @@ from drydock.compaction import (
48
48
  )
49
49
  from drydock.loop_detect import LoopTracker
50
50
  from drydock.task_state import TaskState
51
- from drydock.verification import looks_like_verification
51
+ from drydock.verification import looks_like_verification, parse_evidence
52
+ from drydock.events import emit as _emit
52
53
  from drydock.tuning import (
53
54
  filter_tool_schemas,
54
55
  hallucinated_tool_message,
@@ -83,6 +84,7 @@ class AgentState:
83
84
  turn_count: int = 0
84
85
  current_effort: str = "" # "high"/"low" of the in-flight LLM call (for the UI)
85
86
  task: "TaskState" = field(default_factory=lambda: TaskState()) # structured objective
87
+ events: object | None = None # optional EventLog for a durable execution trace
86
88
 
87
89
 
88
90
  def drop_last_turn(messages: list) -> bool:
@@ -121,6 +123,9 @@ def run(
121
123
  # outside the transcript. The original objective is authoritative for the task.
122
124
  if not state.task.is_set() and isinstance(user_message, str) and user_message.strip():
123
125
  state.task = TaskState.from_objective(user_message)
126
+ _emit(state, "task_start", objective=state.task.objective,
127
+ acceptance_criteria=state.task.acceptance_criteria)
128
+ _emit(state, "user_message", chars=len(user_message or ""))
124
129
  # Keep the objective + acceptance criteria in the SYSTEM PROMPT every turn, so
125
130
  # they survive compaction (which only touches the message transcript, never the
126
131
  # system prompt) — the model can't drift off the goal on a long task.
@@ -154,8 +159,8 @@ def run(
154
159
  return cancel is not None and cancel.is_set()
155
160
  tool_call_count = 0
156
161
  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
162
+ last_verification = None # VerificationEvidence of the most recent check (gate)
163
+ verify_gate_nudges = 0 # bounded "verify before you finish" nudges
159
164
  leaked_call_retries = 0
160
165
  plan_continue_nudges = 0 # consecutive "you stopped mid-plan" nudges
161
166
  empty_response_nudges = 0 # consecutive "you returned nothing" nudges
@@ -281,6 +286,9 @@ def run(
281
286
  state.total_output_tokens += assistant_turn.output_tokens
282
287
  state.last_input_tokens = assistant_turn.input_tokens
283
288
  yield TurnDone(assistant_turn.input_tokens, assistant_turn.output_tokens)
289
+ _emit(state, "turn", in_tok=assistant_turn.input_tokens,
290
+ out_tok=assistant_turn.output_tokens,
291
+ tool_calls=len(assistant_turn.tool_calls or []))
284
292
 
285
293
  # No tool calls = conversation complete — UNLESS the model emitted a
286
294
  # tool call as text (a Gemma quirk the API can't structure). In that
@@ -339,29 +347,42 @@ def run(
339
347
  # If the agent CHANGED files but never ran a test/check/its own code,
340
348
  # don't accept completion — make it verify first. Bounded so it can't
341
349
  # wedge; once it runs any check (ran_verification) the gate is satisfied.
342
- if (
350
+ # A text-only "done" after editing is accepted only when a check has
351
+ # PASSED. Never ran one → nudge to VERIFY. Ran one that FAILED → the work
352
+ # isn't done → nudge to REPAIR. Bounded so it can't wedge.
353
+ _needs_gate = (
343
354
  config.get("verify_gate", True)
344
355
  and allow is None # main task only — not scoped sub-agents
345
356
  and session_has_edited
346
- and not ran_verification
347
357
  and verify_gate_nudges < 2
348
- ):
358
+ and (last_verification is None or last_verification.status == "fail")
359
+ )
360
+ if _needs_gate:
349
361
  verify_gate_nudges += 1
350
- state.task.phase = "verify"
351
- state.messages.append({
352
- "role": "user",
353
- "content": (
362
+ if last_verification is None:
363
+ state.task.phase = "verify"
364
+ _emit(state, "verify_gate", kind="unverified", nudge=verify_gate_nudges)
365
+ msg = (
354
366
  "[SYSTEM] You changed files but have not VERIFIED the work. "
355
- "Before finishing, run a concrete check: the task's own test/"
356
- "eval/build (pytest, test.sh, make, npm test, …) or run the "
357
- "code/script you produced and confirm the output is correct "
358
- "and meets EVERY requirement. Do that now with a tool call; "
359
- "if it fails, fix it and re-check."
360
- ),
361
- })
367
+ "Run a concrete check now: the task's own test/eval/build "
368
+ "(pytest, test.sh, make, npm test, …) or run the code you "
369
+ "produced, and confirm it meets EVERY requirement."
370
+ )
371
+ else: # a check ran and FAILED
372
+ state.task.phase = "repair"
373
+ _emit(state, "verify_gate", kind="failed", nudge=verify_gate_nudges,
374
+ summary=last_verification.summary)
375
+ msg = (
376
+ f"[SYSTEM] Your verification FAILED ({last_verification.summary}). "
377
+ "The task is NOT complete. Read the failure, fix the cause, then "
378
+ "re-run the SAME check until it passes."
379
+ )
380
+ state.messages.append({"role": "user", "content": msg})
362
381
  continue
363
- if session_has_edited and ran_verification:
382
+ if session_has_edited and last_verification and last_verification.status == "pass":
364
383
  state.task.phase = "complete"
384
+ _emit(state, "done", phase=state.task.phase, edited=session_has_edited,
385
+ verified=bool(last_verification and last_verification.status == "pass"))
365
386
  break
366
387
 
367
388
  # Execute each tool call
@@ -369,10 +390,6 @@ def run(
369
390
  tool_call_count += 1
370
391
  if tc["name"] in ("Edit", "Write"):
371
392
  session_has_edited = True
372
- elif tc["name"] == "Bash" and looks_like_verification(
373
- (tc.get("input") or {}).get("command", "")
374
- ):
375
- ran_verification = True # the agent actually ran a test/check/its code
376
393
 
377
394
  # STOP pressed: don't run the remaining tools, but still record a
378
395
  # paired result for each (the assistant message already lists all
@@ -424,6 +441,19 @@ def run(
424
441
  # advisory note when the same call is made again.
425
442
  result = loop_tracker.annotate(tc["name"], tc["input"], result)
426
443
 
444
+ # Verification evidence: if this Bash call was a test/check/exec, parse
445
+ # its result so the completion gate knows whether it PASSED, not just ran.
446
+ if tc["name"] == "Bash":
447
+ _vcmd = (tc.get("input") or {}).get("command", "")
448
+ if looks_like_verification(_vcmd):
449
+ last_verification = parse_evidence(_vcmd, result)
450
+ _emit(state, "verification", status=last_verification.status,
451
+ exit_code=last_verification.exit_code)
452
+
453
+ _emit(state, "tool", name=tc["name"],
454
+ input=str(tc.get("input"))[:200],
455
+ result_chars=len(str(result)),
456
+ error=str(result)[:80].lower().startswith("error"))
427
457
  yield ToolEnd(tc["name"], result)
428
458
 
429
459
  # Append tool result
@@ -52,6 +52,9 @@ DEFAULTS: dict[str, object] = {
52
52
  # files but never ran a test/check/its own code, nudge it to verify first
53
53
  # instead of accepting completion. Bounded. Set false to disable.
54
54
  "verify_gate": True,
55
+ # Durable per-session execution trace (events.py) written as JSONL to
56
+ # ~/.drydock/events/ — inspect/diagnose/replay a task. Set false to disable.
57
+ "event_log": True,
55
58
  "theme": "harbor",
56
59
  # Optional SECOND model ("advisor") for a stronger second opinion — e.g. a
57
60
  # Gemini OpenAI-compatible endpoint on another box. Empty = disabled. The
@@ -0,0 +1,68 @@
1
+ """Durable execution event log (Agent-Buildout PRD, Epic: events).
2
+
3
+ Every task run appends structured events to a per-session JSONL file so a task can
4
+ be inspected, diagnosed, replayed, and later resumed. Append-only, one event per
5
+ line: ``{"seq", "ts", "type", ...}``. DEFENSIVE — a logging failure must never crash
6
+ the agent, so every I/O path swallows its own errors. All logic original to Drydock.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from pathlib import Path
13
+
14
+
15
+ class EventLog:
16
+ """Append-only JSONL sink for execution events. Cheap to call; never raises."""
17
+
18
+ def __init__(self, path):
19
+ self.path = Path(path)
20
+ self._seq = 0
21
+ try:
22
+ self.path.parent.mkdir(parents=True, exist_ok=True)
23
+ except OSError:
24
+ pass
25
+
26
+ def emit(self, type: str, **data) -> None:
27
+ self._seq += 1
28
+ rec = {"seq": self._seq, "ts": round(time.time(), 3), "type": type}
29
+ rec.update(data)
30
+ try:
31
+ with self.path.open("a", encoding="utf-8") as f:
32
+ f.write(json.dumps(rec, default=str, ensure_ascii=False) + "\n")
33
+ except (OSError, TypeError, ValueError):
34
+ pass # logging must never break a run
35
+
36
+ @staticmethod
37
+ def read(path) -> list[dict]:
38
+ """Parse an event-log JSONL into a list of event dicts (in order). Skips any
39
+ unparseable line; returns [] if the file is missing."""
40
+ out: list[dict] = []
41
+ try:
42
+ text = Path(path).read_text("utf-8", errors="ignore")
43
+ except OSError:
44
+ return out
45
+ for line in text.splitlines():
46
+ line = line.strip()
47
+ if not line:
48
+ continue
49
+ try:
50
+ out.append(json.loads(line))
51
+ except json.JSONDecodeError:
52
+ continue
53
+ return out
54
+
55
+
56
+ def emit(state, type: str, **data) -> None:
57
+ """Emit to the run's event log if it has one — a no-op otherwise. Lets the agent
58
+ loop log unconditionally without a log everywhere."""
59
+ log = getattr(state, "events", None)
60
+ if log is not None:
61
+ log.emit(type, **data)
62
+
63
+
64
+ def default_event_log_path():
65
+ """A fresh per-session JSONL path under ~/.drydock/events/."""
66
+ import time
67
+ from pathlib import Path
68
+ return Path.home() / ".drydock" / "events" / f"session-{int(time.time())}.jsonl"
@@ -259,6 +259,9 @@ class DrydockApp(App):
259
259
  super().__init__()
260
260
  self.config = config
261
261
  self.state = AgentState()
262
+ if config.get("event_log", True):
263
+ from drydock.events import EventLog, default_event_log_path
264
+ self.state.events = EventLog(config.get("event_log_path") or default_event_log_path())
262
265
  self.system = self._build_system(config.get("model"))
263
266
  from drydock.skills import load_skills
264
267
  self._skills = load_skills(config.get("cwd") or ".")
@@ -0,0 +1,75 @@
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
+ from dataclasses import dataclass
15
+
16
+ # Explicit test / lint / type-check / build commands.
17
+ _VERIFY_KEYWORDS = re.compile(
18
+ r"\b(pytest|py\.test|unittest|nosetests?|tox|test\.sh|run_tests|"
19
+ r"make(\s+\w+)?|npm\s+(test|run)|yarn\s+(test|run)|pnpm\s+(test|run)|jest|mocha|vitest|"
20
+ r"cargo\s+(test|check|build|run|clippy)|go\s+(test|build|run|vet)|"
21
+ r"ruff|flake8|pylint|mypy|pyright|eslint|tsc|prettier|"
22
+ r"ctest|cmake|gradle|mvn|rspec|phpunit|dotnet\s+test|bats)\b",
23
+ re.I,
24
+ )
25
+ # Executing something — running the produced script/binary counts as checking it.
26
+ _EXEC = re.compile(r"(?:^|[\s;&|(])(?:python3?|node|deno|bun|bash|sh|ruby|perl|"
27
+ r"java|javac|gcc|g\+\+|clang|\./|\.\\)")
28
+
29
+
30
+ def looks_like_verification(cmd) -> bool:
31
+ """True if a shell command constitutes verifying work — running tests/checks/
32
+ builds, or executing the produced code. Best-effort; never raises."""
33
+ if not cmd or not isinstance(cmd, str):
34
+ return False
35
+ return bool(_VERIFY_KEYWORDS.search(cmd) or _EXEC.search(cmd))
36
+
37
+
38
+ @dataclass
39
+ class VerificationEvidence:
40
+ """Structured outcome of a verification command — did the check PASS, not just
41
+ run (PRD verification/evidence). status: 'pass' | 'fail' | 'unknown'."""
42
+ command: str
43
+ status: str
44
+ tests_passed: int | None = None
45
+ tests_failed: int | None = None
46
+ exit_code: int | None = None
47
+ summary: str = ""
48
+
49
+
50
+ # tool_bash appends "[exit code: N]" ONLY on non-zero exit; absent => exit 0.
51
+ _EXIT = re.compile(r"\[exit code:\s*(-?\d+)\]")
52
+ # pytest/unittest-style tallies: "3 passed", "2 failed", "1 error".
53
+ _TALLY = re.compile(r"(\d+)\s+(passed|failed|errors?|skipped|xfailed|xpassed)\b", re.I)
54
+
55
+
56
+ def parse_evidence(command, result_text) -> VerificationEvidence:
57
+ """Classify a verification command's result as pass/fail. Exit code is the
58
+ primary signal (drydock reports non-zero); pytest-style tallies refine it.
59
+ Best-effort; never raises."""
60
+ cmd = command if isinstance(command, str) else ""
61
+ text = result_text if isinstance(result_text, str) else str(result_text or "")
62
+ m = _EXIT.search(text)
63
+ exit_code = int(m.group(1)) if m else 0
64
+ passed = failed = None
65
+ tally = {}
66
+ for n, k in _TALLY.findall(text):
67
+ tally[k.lower().rstrip("s")] = tally.get(k.lower().rstrip("s"), 0) + int(n)
68
+ if tally:
69
+ passed = tally.get("passed")
70
+ failed = tally.get("failed", 0) + tally.get("error", 0)
71
+ status = "fail" if ((failed and failed > 0) or exit_code != 0) else "pass"
72
+ parts = [f"exit {exit_code}"]
73
+ if passed is not None or failed:
74
+ parts.append(f"{passed or 0} passed, {failed or 0} failed")
75
+ return VerificationEvidence(cmd, status, passed, failed, exit_code, "; ".join(parts)[:200])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.125
3
+ Version: 3.0.127
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
@@ -12,6 +12,7 @@ drydock/cli.py
12
12
  drydock/compaction.py
13
13
  drydock/config.py
14
14
  drydock/detect.py
15
+ drydock/events.py
15
16
  drydock/extract.py
16
17
  drydock/gittools.py
17
18
  drydock/graphrag.py
@@ -83,6 +84,7 @@ tests/test_dispatch.py
83
84
  tests/test_e2e_connected.py
84
85
  tests/test_edit_replace_all.py
85
86
  tests/test_empty_response.py
87
+ tests/test_events.py
86
88
  tests/test_extract.py
87
89
  tests/test_failure_loop.py
88
90
  tests/test_first_run_setup.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.125"
10
+ version = "3.0.127"
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,58 @@
1
+ """Durable event log (Agent-Buildout PRD, Epic: events) — the run appends a JSONL
2
+ execution trace that can be read back for inspection/replay."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+
8
+ from drydock import agent
9
+ from drydock.events import EventLog
10
+ from drydock.providers import AssistantTurn
11
+
12
+
13
+ def test_emit_and_read_roundtrip():
14
+ p = os.path.join(tempfile.mkdtemp(), "e.jsonl")
15
+ log = EventLog(p)
16
+ log.emit("task_start", objective="do X", acceptance_criteria=["a", "b"])
17
+ log.emit("tool", name="Write", result_chars=10)
18
+ evs = EventLog.read(p)
19
+ assert [e["type"] for e in evs] == ["task_start", "tool"]
20
+ assert evs[0]["seq"] == 1 and evs[1]["seq"] == 2 and "ts" in evs[0]
21
+ assert evs[0]["acceptance_criteria"] == ["a", "b"]
22
+
23
+
24
+ def test_read_missing_file_is_empty():
25
+ assert EventLog.read("/no/such/file.jsonl") == []
26
+
27
+
28
+ def test_emit_never_raises_on_bad_path():
29
+ EventLog("/root/cannot/write/here.jsonl").emit("x", a=1) # no exception
30
+
31
+
32
+ def test_run_writes_full_trace():
33
+ p = os.path.join(tempfile.mkdtemp(), "run.jsonl")
34
+ st = agent.AgentState(); st.events = EventLog(p)
35
+ turns = [
36
+ AssistantTurn("", [{"id": "1", "name": "Write",
37
+ "input": {"file_path": "/tmp/dd_e.txt", "content": "x"}}], 10, 5),
38
+ AssistantTurn("", [{"id": "2", "name": "Bash", "input": {"command": "python -c '1'"}}], 8, 4),
39
+ AssistantTurn("done, verified", [], 6, 3),
40
+ ]
41
+ i = {"n": 0}
42
+ def fake(model, system, messages, tool_schemas, config):
43
+ t = turns[min(i["n"], len(turns) - 1)]; i["n"] += 1; yield t
44
+ orig = agent.stream; agent.stream = fake
45
+ try:
46
+ list(agent.run("Do the task.\n1. write out.txt", st,
47
+ {"model": "gemma4", "max_tokens": 8192, "context_limit": 65536}, "SYS"))
48
+ finally:
49
+ agent.stream = orig
50
+ evs = EventLog.read(p)
51
+ types = [e["type"] for e in evs]
52
+ assert types[0] == "task_start" and "turn" in types and "tool" in types and types[-1] == "done"
53
+ assert next(e for e in evs if e["type"] == "done")["phase"] == "complete"
54
+
55
+
56
+ def test_disabled_no_events_attr():
57
+ st = agent.AgentState()
58
+ assert st.events is None # off by default on a bare AgentState
@@ -69,3 +69,35 @@ def test_disabled():
69
69
  done = AssistantTurn("done", [], 5, 5)
70
70
  st, _ = _drive({**CFG, "verify_gate": False}, [write, done])
71
71
  assert not any("VERIFIED" in m.get("content", "") for m in st.messages if m.get("role") == "user")
72
+
73
+
74
+ def test_evidence_pass_fail():
75
+ from drydock.verification import parse_evidence
76
+ assert parse_evidence("pytest", "5 passed in 0.1s").status == "pass"
77
+ assert parse_evidence("pytest", "2 failed, 3 passed\n\n[exit code: 1]").status == "fail"
78
+ assert parse_evidence("python x.py", "boom\n\n[exit code: 1]").status == "fail"
79
+ ev = parse_evidence("pytest", "2 failed, 3 passed\n\n[exit code: 1]")
80
+ assert ev.tests_passed == 3 and ev.tests_failed == 2 and ev.exit_code == 1
81
+
82
+
83
+ def test_failed_check_triggers_repair_not_complete():
84
+ # write, then run a FAILING check, then claim done -> gate nudges to REPAIR
85
+ write = AssistantTurn("", [{"id": "1", "name": "Write",
86
+ "input": {"file_path": "/tmp/dd_vg_f.txt", "content": "x"}}], 5, 5)
87
+ fail = AssistantTurn("", [{"id": "2", "name": "Bash",
88
+ "input": {"command": "python -c 'import sys; sys.exit(1)'"}}], 5, 5)
89
+ done = AssistantTurn("all done", [], 5, 5)
90
+ st, n = _drive(CFG, [write, fail, done, done])
91
+ assert st.task.phase == "repair"
92
+ assert any("FAILED" in m.get("content", "") for m in st.messages if m.get("role") == "user")
93
+
94
+
95
+ def test_passing_check_accepts_completion():
96
+ write = AssistantTurn("", [{"id": "1", "name": "Write",
97
+ "input": {"file_path": "/tmp/dd_vg_p.txt", "content": "x"}}], 5, 5)
98
+ ok = AssistantTurn("", [{"id": "2", "name": "Bash",
99
+ "input": {"command": "python -c 'print(1)'"}}], 5, 5)
100
+ done = AssistantTurn("done, verified", [], 5, 5)
101
+ st, _ = _drive(CFG, [write, ok, done])
102
+ assert st.task.phase == "complete"
103
+ assert not any("FAILED" in m.get("content", "") for m in st.messages if m.get("role") == "user")
@@ -1,34 +0,0 @@
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))
File without changes
File without changes
File without changes
File without changes