coderio 0.4.0__py3-none-any.whl

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 (97) hide show
  1. coderio/__init__.py +14 -0
  2. coderio/agent/__init__.py +5 -0
  3. coderio/agent/_deepagents_compat.py +139 -0
  4. coderio/agent/command_review.py +140 -0
  5. coderio/agent/deep_loop.py +758 -0
  6. coderio/agent/harness.py +768 -0
  7. coderio/agent/harness_middleware.py +293 -0
  8. coderio/agent/permission_middleware.py +52 -0
  9. coderio/agent/prompts.py +304 -0
  10. coderio/agent/skill_tool.py +70 -0
  11. coderio/agent/state.py +144 -0
  12. coderio/agent/stream.py +93 -0
  13. coderio/cli/__init__.py +0 -0
  14. coderio/cli/app.py +257 -0
  15. coderio/cli/commands.py +339 -0
  16. coderio/cli/credentials.py +70 -0
  17. coderio/cli/mcp_cmd.py +227 -0
  18. coderio/cli/multimodal.py +94 -0
  19. coderio/cli/onboarding.py +270 -0
  20. coderio/cli/providers.py +113 -0
  21. coderio/cli/render.py +28 -0
  22. coderio/cli/repl.py +189 -0
  23. coderio/cli/run_cmd.py +171 -0
  24. coderio/cli/skills_cmd.py +77 -0
  25. coderio/cli/stream.py +224 -0
  26. coderio/cli/tui.py +1584 -0
  27. coderio/cli/tui_onboarding.py +465 -0
  28. coderio/cli/tui_screens.py +334 -0
  29. coderio/cli/tui_widgets.py +453 -0
  30. coderio/config/__init__.py +21 -0
  31. coderio/config/bootstrap.py +38 -0
  32. coderio/config/loader.py +273 -0
  33. coderio/config/models.py +193 -0
  34. coderio/config/trust.py +136 -0
  35. coderio/llm/__init__.py +3 -0
  36. coderio/llm/factory.py +164 -0
  37. coderio/llm/probe.py +153 -0
  38. coderio/mcp_loader.py +274 -0
  39. coderio/session/__init__.py +3 -0
  40. coderio/session/message.py +79 -0
  41. coderio/session/store.py +244 -0
  42. coderio/skills/__init__.py +4 -0
  43. coderio/skills/bundled/executing-plans/SKILL.md +15 -0
  44. coderio/skills/lion-skills/LICENSE +21 -0
  45. coderio/skills/lion-skills/README.md +114 -0
  46. coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
  47. coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
  48. coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
  49. coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
  50. coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
  51. coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
  52. coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
  53. coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
  54. coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
  55. coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
  56. coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
  57. coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
  58. coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
  59. coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
  60. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
  61. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
  62. coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
  63. coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
  64. coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
  65. coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
  66. coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
  67. coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
  68. coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
  69. coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
  70. coderio/skills/models.py +66 -0
  71. coderio/skills/parser.py +50 -0
  72. coderio/skills/store.py +102 -0
  73. coderio/tools/__init__.py +94 -0
  74. coderio/tools/base.py +55 -0
  75. coderio/tools/bash.py +168 -0
  76. coderio/tools/command_policy.py +364 -0
  77. coderio/tools/edit_file.py +55 -0
  78. coderio/tools/glob_tool.py +21 -0
  79. coderio/tools/grep_tool.py +90 -0
  80. coderio/tools/linux_sandbox.py +196 -0
  81. coderio/tools/list_dir.py +58 -0
  82. coderio/tools/multi_edit.py +62 -0
  83. coderio/tools/note.py +72 -0
  84. coderio/tools/permission.py +214 -0
  85. coderio/tools/read_file.py +51 -0
  86. coderio/tools/sandbox_runner.py +113 -0
  87. coderio/tools/todo.py +71 -0
  88. coderio/tools/web_fetch.py +188 -0
  89. coderio/tools/web_search.py +49 -0
  90. coderio/tools/win_job.py +214 -0
  91. coderio/tools/win_sandbox.py +639 -0
  92. coderio/tools/write_file.py +25 -0
  93. coderio-0.4.0.dist-info/METADATA +490 -0
  94. coderio-0.4.0.dist-info/RECORD +97 -0
  95. coderio-0.4.0.dist-info/WHEEL +4 -0
  96. coderio-0.4.0.dist-info/entry_points.txt +2 -0
  97. coderio-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,768 @@
1
+ """Harness state control for the single-agent loop (spec: 2026-06-26-coderio-harness-state-control).
2
+
3
+ This module turns the "write code then claim done without verifying" failure mode
4
+ from a soft prompt rule into a STRUCTURAL constraint. The harness sits inside
5
+ ``run_deep_agent`` and controls the one thing the model cannot override: whether a
6
+ "no tool_calls" response actually TERMINATES the turn, or gets force-continued with
7
+ an injected message. All decisions are based on observed tool calls/results
8
+ (ground truth), never on the model's self-report.
9
+
10
+ Four gates (spec §1):
11
+ * PlanGate (soft) — nudge to decompose before writing, when no todos exist.
12
+ * VerifyGate (hard) — block "done" while code is written-but-not-run.
13
+ * CompletionGate (hard) — block "done" while non-trivial todos remain pending.
14
+ * GroundingGate (hard) — block "done" when the model cites code locations it
15
+ never actually read (analyses built on assumptions,
16
+ not evidence). Guards the ANALYZE/QA failure mode where
17
+ the model trusts documentation or memory over source.
18
+
19
+ VerifyGate + CompletionGate + GroundingGate use progressive escalation:
20
+ force-continue twice, then release with a visible warning (never silently, never
21
+ infinite-loop).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from dataclasses import dataclass, field
28
+ from typing import TYPE_CHECKING, Any
29
+
30
+ if TYPE_CHECKING:
31
+ from coderio.tools.todo import TodoStore
32
+
33
+ # Tools that mutate files on disk. A successful one of these creates an
34
+ # "unverified write" that the VerifyGate watches.
35
+ WRITE_TOOLS: frozenset[str] = frozenset({"write_file", "edit_file", "multi_edit"})
36
+
37
+ # Read-only tools that "ground" a claim about a file — if the model cited a path
38
+ # and one of these tools touched it, the citation is evidence-backed.
39
+ READ_TOOLS: frozenset[str] = frozenset({"read_file", "grep", "list_dir", "glob"})
40
+ # Tools that actually READ FILE CONTENTS. Only these ground a citation about a
41
+ # file's internals — grep only matches a pattern (the model never sees the full
42
+ # file), and list_dir/glob return NAMES without contents. Crediting them would
43
+ # let the model cite "loader.py:81 does X" after a `grep pattern="loader"` or a
44
+ # `list_dir("src")` that only returned filenames.
45
+ CONTENT_READ_TOOLS: frozenset[str] = frozenset({"read_file"})
46
+
47
+ # Running a shell command counts as a verification attempt — even a failing one
48
+ # means the agent tried to run its code, so we stop nagging. This avoids both
49
+ # "wrote then claimed done" and infinite "it errored, keep trying" loops.
50
+ VERIFY_TOOL: str = "bash"
51
+
52
+ # After this many forced-continues a gate gives up and releases with a warning.
53
+ _MAX_GATE_ATTEMPTS: int = 2
54
+
55
+ # Match file paths the model might cite in an analysis: "loader.py", "src/x.py",
56
+ # "loader.py:81", "agent/loop.py". Requires a dotted extension so ordinary prose
57
+ # like "the loader" or "go to step 2" doesn't false-positive. Anchored to a word
58
+ # boundary on the left; the path body may include /, \, word chars, dash, dot.
59
+ _CITED_FILE_RE = re.compile(
60
+ r"(?<![\w/.\\-])" # not preceded by word/slash/dot/dash (prevents matching
61
+ # `self._live.py` — the `.` before `_live` blocks it)
62
+ r"(?:[\w./\\-]+[\\/])?" # optional dir prefix (src/, agent\)
63
+ r"(?:"
64
+ r"[\w-]+\." # basename stem + dot + extension
65
+ r"(?:py|js|ts|tsx|jsx|md|json|toml|yaml|yml|rs|go|java|rb|sh|css|html|sql|c|cpp|h|hpp|php|swift|kt|scala|lua|vim|el)"
66
+ r"|"
67
+ r"(?:Dockerfile|Makefile|makefile|Gemfile|Rakefile|CMakeLists\.txt|\.env|\.gitignore|\.dockerignore|requirements\.txt|package\.json|tsconfig\.json|go\.mod|go\.sum|Cargo\.toml|Cargo\.lock)"
68
+ r")"
69
+ r"(?::\d+)?" # optional :line-number
70
+ r"(?![\w])" # not followed by a word char
71
+ )
72
+
73
+
74
+ def _norm_path(p: str) -> str:
75
+ """Normalize a path for read-state dedup.
76
+
77
+ Lowercases, converts backslashes to forward slashes, collapses redundant
78
+ './' and '../' segments, and strips whitespace. On case-insensitive
79
+ filesystems (NTFS, APFS default) this correctly treats 'Loop.py' ==
80
+ 'loop.py'; on case-sensitive filesystems it's slightly over-eager but the
81
+ worst case is one missed grounding check, never a false positive.
82
+
83
+ This is the single source of truth for path comparison in the harness —
84
+ observe() normalizes on write, _was_read() normalizes on compare, and the
85
+ cross-turn pre-fill normalizes when seeding content_read_files.
86
+ Without it, a model that reads 'Loop.py' then cites 'loop.py:81' gets
87
+ force-re-read by GroundingGate (observed in real sessions: 'Loop.py' was
88
+ read 5x in one turn purely from case drift).
89
+ """
90
+ if not p:
91
+ return ""
92
+ p = p.strip().replace("\\", "/")
93
+ parts: list[str] = []
94
+ for part in p.split("/"):
95
+ if part in ("", "."):
96
+ continue
97
+ if part == "..":
98
+ if parts:
99
+ parts.pop()
100
+ continue
101
+ parts.append(part)
102
+ return "/".join(parts).lower()
103
+
104
+
105
+ def _is_success(result: str) -> bool:
106
+ """A write tool result counts as a real write unless it errored.
107
+
108
+ All three write tools report failures as ``"Error: ..."`` (verified against
109
+ write_file.py / edit_file.py / multi_edit.py). A failed write changed nothing
110
+ on disk, so it must not trigger the verify gate. Permission rejections from
111
+ the PermissionGate start with ``"Permission denied:"`` — those also changed
112
+ nothing, so they must not count as writes either.
113
+ """
114
+ if not result:
115
+ return False
116
+ return not (result.startswith("Error") or result.startswith("Permission denied"))
117
+
118
+
119
+ # Matches BOTH exit-code marker formats the VerifyGate may see:
120
+ # 1. ``[exit_code: N]`` — coderio's legacy BashTool format (bash.py).
121
+ # 2. ``[Command failed with exit code N]`` / ``[Command succeeded with exit
122
+ # code N]`` — deepagents' native format (deepagents/middleware/
123
+ # filesystem.py appends this to every execute result's content).
124
+ #
125
+ # REGRESSION (2026-08-14 report P0-1): the production engine (deepagents)
126
+ # wraps ExecuteResponse into a ToolMessage whose .exit_code attribute is
127
+ # GONE — the exit code only survives as text in the content. The regex below
128
+ # originally only matched format 1, so _parse_exit_code returned None for
129
+ # every production execute call → failed tests were treated as "neutral
130
+ # pass" → writes_since_verify was cleared → VerifyGate never blocked "done"
131
+ # after a FAILING test run. The gate (the project's headline feature) was
132
+ # completely inert in production. Both formats must match from now on.
133
+ _EXIT_CODE_RE = re.compile(r"\[exit_code:\s*(-?\d+)\]" r"|\[Command (?:succeeded|failed) with exit code (-?\d+)\]")
134
+
135
+
136
+ def _parse_exit_code(result: str) -> int | None:
137
+ """Extract the exit code from a shell-tool result, or None if absent.
138
+
139
+ Handles both marker formats (see _EXIT_CODE_RE): coderio's legacy
140
+ ``\\n[exit_code: N]`` and deepagents' ``[Command failed|succeeded with
141
+ exit code N]``. When present, a non-zero value means the verification
142
+ FAILED (tests errored, build broke, lint failed) and must NOT clear the
143
+ unverified-writes list.
144
+ """
145
+ m = _EXIT_CODE_RE.search(result or "")
146
+ if m:
147
+ # group(1) = coderio format, group(2) = deepagents format.
148
+ return int(m.group(1) or m.group(2))
149
+ return None
150
+
151
+
152
+ # File extensions that represent executable/buildable code — writing these
153
+ # triggers the VerifyGate (the model must run/test them before claiming done).
154
+ # Files NOT in this set (.md docs, .json configs, .yaml manifests, .txt notes,
155
+ # .toml, .cfg, .ini, .csv, images, etc.) skip the gate — they're verified by
156
+ # reading, not by running. This prevents the most common UX complaint: agent
157
+ # writes a .md analysis doc, harness forces pytest, pytest fails (wrong Python
158
+ # in bash env), agent concludes "environment is broken".
159
+ _VERIFIABLE_EXTS: frozenset[str] = frozenset(
160
+ {
161
+ ".py",
162
+ ".js",
163
+ ".ts",
164
+ ".tsx",
165
+ ".jsx",
166
+ ".go",
167
+ ".rs",
168
+ ".java",
169
+ ".rb",
170
+ ".sh",
171
+ ".bash",
172
+ ".zsh",
173
+ ".c",
174
+ ".cpp",
175
+ ".h",
176
+ ".hpp",
177
+ ".cs",
178
+ ".swift",
179
+ ".kt",
180
+ ".scala",
181
+ ".lua",
182
+ ".php",
183
+ ".vim",
184
+ ".sql",
185
+ # Build files that affect compilation/testing
186
+ ".cmake",
187
+ ".makefile",
188
+ # Web files that need to be tested in a browser/runtime
189
+ ".html",
190
+ ".css",
191
+ ".vue",
192
+ ".svelte",
193
+ }
194
+ )
195
+
196
+
197
+ def _is_verifiable_code(path: str) -> bool:
198
+ """Should writing this file trigger the VerifyGate?
199
+
200
+ Returns True for source code files (.py, .js, .ts, etc.) and False for
201
+ documentation/config/data files (.md, .json, .yaml, .txt, .toml, etc.).
202
+ The gate exists to catch 'wrote code but didn't run it' — a markdown
203
+ analysis document doesn't need pytest to verify.
204
+ """
205
+ import os
206
+
207
+ ext = os.path.splitext(path)[1].lower()
208
+ if ext in _VERIFIABLE_EXTS:
209
+ return True
210
+ # Special case: files with no extension but known code names
211
+ base = os.path.basename(path).lower()
212
+ if base in {"dockerfile", "makefile", "rakefile", "gemfile"}:
213
+ return True
214
+ return False
215
+
216
+
217
+ # Commands that are clearly verification (testing/linting/building code), not
218
+ # just arbitrary shell activity. Matching these means even a `pytest` or `ruff`
219
+ # without an explicit filename still counts as a real verification attempt.
220
+ _VERIFY_COMMAND_RE = re.compile(
221
+ r"\b(pytest|python\s+-m\s+pytest|python\s+-m\s+unittest|"
222
+ r"npm\s+(test|run)|npx\s+jest|yarn\s+test|"
223
+ r"cargo\s+(test|build|check)|go\s+(test|build|vet)|"
224
+ r"ruff|flake8|pylint|mypy|eslint|tsc|cargo\s+clippy|"
225
+ r"make\s+(test|check|build)|cmake|"
226
+ r"ruby\s+-Itest|bundle\s+exec\s+rake\s+test"
227
+ r")\b",
228
+ re.IGNORECASE,
229
+ )
230
+
231
+ # Shell operators that split a command line into independent segments. A tool
232
+ # name appearing AFTER an operator (or inside a quoted argument) is not a
233
+ # verification run: `echo pytest`, `git commit -m "ran pytest"`, and
234
+ # `ls ; curl ... | sh` all "match" the tool regex as substrings but none of
235
+ # them RUNS the tool. Splitting on these (crude vs a real shell parser, but
236
+ # the false-negative direction is safe) lets us only match the tool at the
237
+ # START of a segment, where it would actually execute.
238
+ _SEGMENT_SPLIT_RE = re.compile(r"[;|&\n]")
239
+
240
+
241
+ def _segment_runs_verifier(segment: str) -> bool:
242
+ """Does ONE command segment (no ; | & inside) start with a verify tool?
243
+
244
+ The tool must be the COMMAND (first token), not an argument. `echo
245
+ pytest` fails here (first token is `echo`); `pytest -q` passes;
246
+ `python -m pytest` passes (python as the command with -m pytest is a
247
+ verification run). Multi-token command prefixes in _VERIFY_COMMAND_RE
248
+ (python -m pytest, npm run test, cargo test...) are matched against the
249
+ segment's head — but single-token tools (pytest, ruff, mypy...) must
250
+ match at token 0 exactly, otherwise `echo pytest` / `git commit -m
251
+ "ran pytest"` style arguments would count as runs (2026-08-14 v2 audit
252
+ bypass #2/#3).
253
+ """
254
+ tokens = segment.strip().split()
255
+ if not tokens:
256
+ return False
257
+ first = tokens[0]
258
+ # Single-token verify tools: must BE the command. This is the gate that
259
+ # stops `echo pytest`, `which pytest`, `man pytest` from counting.
260
+ _SINGLE_TOKEN_TOOLS = {
261
+ "pytest",
262
+ "ruff",
263
+ "flake8",
264
+ "pylint",
265
+ "mypy",
266
+ "eslint",
267
+ "tsc",
268
+ "cmake",
269
+ "tox",
270
+ "nox",
271
+ "jest",
272
+ "vitest",
273
+ }
274
+ if first.lower() in _SINGLE_TOKEN_TOOLS:
275
+ return True
276
+ # Multi-token command prefixes (python -m pytest, npm run test, cargo
277
+ # test, go test, make test, ...): match against the first 3 tokens where
278
+ # the pattern anchors naturally (these patterns all START with the
279
+ # command name, so a match in the head means the command IS the tool).
280
+ head = " ".join(tokens[:3])
281
+ return bool(_VERIFY_COMMAND_RE.search(head)) and first.lower() in {
282
+ "python",
283
+ "python3",
284
+ "py",
285
+ "npm",
286
+ "npx",
287
+ "yarn",
288
+ "pnpm",
289
+ "bun",
290
+ "cargo",
291
+ "go",
292
+ "make",
293
+ "ruby",
294
+ "bundle",
295
+ "uv",
296
+ }
297
+
298
+
299
+ def _command_verifies_written(command: str, written_files: list[str]) -> bool:
300
+ """Does this bash command plausibly run or test the written code?
301
+
302
+ Returns True if:
303
+ - Any SEGMENT of the command starts with a known test/lint/build tool
304
+ (pytest, npm test, cargo test, ruff, etc.). Segments are split on
305
+ ``; | &`` — a tool name that appears only as an ARGUMENT (`echo
306
+ pytest`, commit messages) does NOT count (2026-08-14 v2 audit:
307
+ three bypass paths through substring matching, all closed here).
308
+ - The command references a written file by basename or path — e.g.
309
+ ``python src/foo.py`` or ``node app.js``.
310
+
311
+ Returns False for commands that don't touch the written files at all,
312
+ like ``echo done``, ``ls``, ``pwd``, ``git status`` — these would let the
313
+ agent bypass VerifyGate without actually running its code.
314
+ """
315
+ if not command:
316
+ return False
317
+ # Known verification tools count even without explicit file references —
318
+ # but only as the COMMAND of a segment, never as a string argument.
319
+ for segment in _SEGMENT_SPLIT_RE.split(command):
320
+ if _segment_runs_verifier(segment):
321
+ return True
322
+ # Check if any written file's basename or path appears in the command.
323
+ cmd_lower = command.lower()
324
+ for f in written_files:
325
+ basename = f.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
326
+ if basename and (basename.lower() in cmd_lower or f.lower() in cmd_lower):
327
+ return True
328
+ return False
329
+
330
+
331
+ @dataclass
332
+ class HarnessState:
333
+ """Per-turn harness state, derived purely from observed tool calls.
334
+
335
+ No field here is writable by the model — it is all ground truth accumulated
336
+ by ``Harness.observe`` as tools execute.
337
+ """
338
+
339
+ # File paths written since the last verification (bash). Non-empty here means
340
+ # "there is unverified code on disk".
341
+ writes_since_verify: list[str] = field(default_factory=list)
342
+ # Times the verify gate has force-continued this turn (resets to 0 on bash).
343
+ verify_attempts: int = 0
344
+ # Times the completion gate has force-continued this turn.
345
+ completion_attempts: int = 0
346
+ # Whether the plan gate has nudged already this turn (nudge at most once).
347
+ plan_nudged: bool = False
348
+ # Whether ANY write happened this turn (write_file/edit_file). Used by
349
+ # GroundingGate to distinguish CODE mode (writes → check citations) from
350
+ # ANALYZE mode (pure reads → skip, analysis is allowed to mention filenames).
351
+ # Unlike writes_since_verify, this is NOT cleared by verification.
352
+ has_wrote_this_turn: bool = False
353
+ # Files the model has actually READ this turn (via read_file/grep/glob/list_dir).
354
+ # The GroundingGate checks citations against this set: claiming "loader.py:81
355
+ # does X" without loader.py in here is an ungrounded assertion.
356
+ read_files: set[str] = field(default_factory=set)
357
+ # Subset of read_files opened with read_file (content-level reads). The
358
+ # GroundingGate trusts these for file-content citations; grep/list_dir/glob
359
+ # only land in read_files (directory/path awareness), NOT here — seeing a
360
+ # filename in `list_dir` output doesn't mean the model read the file's
361
+ # contents, so a citation like "loader.py:81 does X" should require an actual
362
+ # read_file of loader.py.
363
+ content_read_files: set[str] = field(default_factory=set)
364
+ # Times the grounding gate has force-continued this turn.
365
+ grounding_attempts: int = 0
366
+ # Paths the model TRIED to read_file but got "file not found". The
367
+ # GroundingGate skips these in its ungrounded-citation check — the regex
368
+ # can match path-like strings in prose (e.g. '.coderio/config.toml' in a
369
+ # README analysis) that aren't real files. Once the agent has confirmed a
370
+ # path doesn't exist, the gate stops forcing it to "read" it again.
371
+ not_found_files: set[str] = field(default_factory=set)
372
+
373
+
374
+ @dataclass
375
+ class Harness:
376
+ """The structural constraint layer. Stateless across turns except via state.
377
+
378
+ ``enabled`` short-circuits every method to a no-op/passthrough, so the loop
379
+ can always hold a Harness object and just flip this flag (or pass harness=None).
380
+ """
381
+
382
+ state: HarnessState
383
+ todos: "TodoStore"
384
+ enabled: bool = True
385
+ # Optional: agent state tracker + stream for phase observability. When set,
386
+ # observe()/check_termination() derive the current AgentState from ground
387
+ # truth and fire on_phase_change. None = phase tracking off (back-compat).
388
+ state_tracker: Any = None
389
+ stream: Any = None
390
+
391
+ # ------------------------------------------------------------------ observe
392
+ def observe(self, name: str, args: dict, result: str) -> None:
393
+ """Update internal state from a tool execution (called after every tool).
394
+
395
+ - successful write tool -> record path as an unverified write
396
+ - read tool -> record the path/pattern as "actually read"
397
+ - bash (any result) -> writes are now "verified attempt", clear + reset
398
+ """
399
+ if not self.enabled:
400
+ return
401
+ just_verified = False
402
+ if name in WRITE_TOOLS and _is_success(result):
403
+ path = str(args.get("path", ""))
404
+ self.state.has_wrote_this_turn = True
405
+ if path and path not in self.state.writes_since_verify:
406
+ # Skip non-code files — VerifyGate exists to catch "wrote code
407
+ # but didn't run it". Writing a .md doc, .json config, .yaml
408
+ # manifest, or .txt note does NOT need pytest/bash verification.
409
+ # The model reads it back to confirm formatting, which is the
410
+ # appropriate verification for documentation. Forcing bash on
411
+ # docs wastes rounds and triggers false "environment broken"
412
+ # conclusions (the most common UX complaint).
413
+ if _is_verifiable_code(path):
414
+ self.state.writes_since_verify.append(path)
415
+ elif name in READ_TOOLS:
416
+ # Record whatever path/pattern the read tool was aimed at. For grep
417
+ # the relevant arg is `pattern`/`path`; for read_file/list_dir/glob
418
+ # it is `path`/`file_path`/`pattern`. Store all candidates loosely —
419
+ # the GroundingGate matches by basename substring, so a dir read
420
+ # (list_dir "src/agent") still counts as having looked at files there.
421
+ for key in ("path", "file_path", "pattern"):
422
+ v = str(args.get(key, "")).strip()
423
+ if v:
424
+ self.state.read_files.add(v)
425
+ # read_file is the only tool that actually reads file CONTENTS. A
426
+ # grep only matches a pattern, list_dir/glob return names — none of
427
+ # those let the model truthfully cite a specific line's behavior, so
428
+ # they don't ground a content-level citation.
429
+ if name in CONTENT_READ_TOOLS:
430
+ for key in ("path", "file_path"):
431
+ v = str(args.get(key, "")).strip()
432
+ if v:
433
+ # Normalize on write so later _was_read comparisons are
434
+ # case- and slash-insensitive (see _norm_path).
435
+ self.state.content_read_files.add(_norm_path(v))
436
+ # If the read returned "file not found", record it so the
437
+ # GroundingGate doesn't force re-reading a nonexistent path
438
+ # (regex false positives on prose path-like strings).
439
+ # Match "not found" (not just "file not found") because
440
+ # deepagents' ReadResult uses "File '...' not found"
441
+ # and the backend uses "file not found" — both
442
+ # contain "not found".
443
+ if result and "not found" in result.lower():
444
+ self.state.not_found_files.add(_norm_path(v))
445
+ elif name == VERIFY_TOOL:
446
+ # A bash call clears "unverified writes" ONLY if it plausibly runs
447
+ # or tests the written code AND the run actually succeeded (exit 0).
448
+ # Two failure modes the old code missed:
449
+ # (1) a bare `echo done` / `ls` — doesn't test anything → reject.
450
+ # (_command_verifies_written already handles this.)
451
+ # (2) `pytest` ran but FAILED (exit != 0) → the code is NOT verified.
452
+ # The old code treated "ran = verified", letting the agent claim
453
+ # completion after a failing test run. Now we parse exit_code:
454
+ # only exit 0 clears the unverified-writes list.
455
+ command = str(args.get("command", ""))
456
+ if _command_verifies_written(command, self.state.writes_since_verify):
457
+ exit_code = _parse_exit_code(result)
458
+ # A command that FAILED TO RUN at all (permission denied, tool
459
+ # error) can never count as verification — parse its exit code
460
+ # would miss these because they carry no marker at all.
461
+ # (2026-08-14 v2 audit: `pytest -q` blocked by the permission
462
+ # gate returned "Permission denied: ..." with no marker, which
463
+ # fell into the "neutral pass" branch below and cleared the
464
+ # unverified-writes list. _is_success already recognizes the
465
+ # "Permission denied"/"Error" prefixes — it just wasn't called
466
+ # in this branch.)
467
+ if not _is_success(result):
468
+ pass # Did not run → not verified; writes stay pending.
469
+ elif exit_code is not None and exit_code != 0:
470
+ # Verification FAILED. Do NOT clear writes_since_verify — the
471
+ # agent must fix the code and re-run. Do NOT increment
472
+ # verify_attempts here: that counter is the _verify_gate's
473
+ # interception budget (how many times completion was refused).
474
+ # A failed bash run is a genuine verify attempt, not a
475
+ # completion attempt — it should NOT consume the gate budget.
476
+ # The gate still escalates after _MAX_GATE_ATTEMPTS
477
+ # interceptions regardless of whether the intervening bash
478
+ # calls passed or failed.
479
+ pass
480
+ else:
481
+ # Either exit 0 (passed) or no exit_code marker at all (old
482
+ # providers/tools that don't report it — preserve back-compat
483
+ # by treating absence as a neutral pass, same as before).
484
+ self.state.writes_since_verify.clear()
485
+ self.state.verify_attempts = 0
486
+ just_verified = True
487
+ # Derive + fire phase change after state is updated.
488
+ self._track_phase(just_verified=just_verified, hint=name)
489
+
490
+ def _track_phase(self, just_verified: bool = False, hint: str = "") -> None:
491
+ """Derive the current AgentState from ground truth and fire a transition.
492
+
493
+ No-op when state_tracker is None (phase tracking off). Debounce is inside
494
+ AgentStateTracker.transition — repeated same-phase calls don't bloat the
495
+ timeline.
496
+ """
497
+ if self.state_tracker is None:
498
+ return
499
+ phase = self.state_tracker.derive_phase(
500
+ writes_since_verify=self.state.writes_since_verify,
501
+ todos_exist=bool(self.todos.todos),
502
+ just_verified=just_verified,
503
+ )
504
+ # Only notify the stream when the phase ACTUALLY changed (debounce) —
505
+ # otherwise 10 read_file calls would fire 10 redundant on_phase_change.
506
+ if self.state_tracker.transition(phase, hint=hint):
507
+ if self.stream is not None and hasattr(self.stream, "on_phase_change"):
508
+ self.stream.on_phase_change(str(phase), 0, hint)
509
+
510
+ # ------------------------------------------------------- after_tool_call (plan gate)
511
+ def after_tool_call(self, name: str, args: dict, result: str) -> str | None:
512
+ """PlanGate (soft): if writing code with no task list, append a one-time nudge.
513
+
514
+ Returns text to APPEND to the tool result (the tool still ran), or None.
515
+ Soft = never blocks; only nudges. The hard check on todo completion lives
516
+ in check_termination (CompletionGate).
517
+ """
518
+ if not self.enabled:
519
+ return None
520
+ if name not in WRITE_TOOLS:
521
+ return None
522
+ if self.state.plan_nudged:
523
+ return None
524
+ if self.todos.todos: # already has a plan -> no nudge
525
+ return None
526
+ self.state.plan_nudged = True
527
+ return (
528
+ "\n[nudge] You're writing code but have no task list yet. For non-trivial "
529
+ 'work, call todo(action="add", ...) first to decompose the task into '
530
+ "verifiable steps. (Trivial fixes like a typo can ignore this.)"
531
+ )
532
+
533
+ # ------------------------------------------------------ check_termination (hard gates)
534
+ def check_termination(self, final_text: str) -> tuple[bool, str | None, str | None]:
535
+ """Decide whether a "no tool_calls" response may actually end the turn.
536
+
537
+ Returns ``(should_continue, inject_message, warn_message)``:
538
+ * should_continue=True, inject set -> loop must NOT return; append inject
539
+ as a user message and keep going.
540
+ * should_continue=False, warn set -> loop returns normally, but stream
541
+ must show the warning (escalation release).
542
+ * should_continue=False, warn None -> truly done, return silently.
543
+ """
544
+ if not self.enabled:
545
+ return (False, None, None)
546
+
547
+ # VerifyGate has priority: code written but never run is the core failure.
548
+ cont, inject, warn = self._verify_gate()
549
+ if cont or warn:
550
+ return (cont, inject, warn)
551
+
552
+ # CompletionGate: only meaningful when a non-trivial todo list exists.
553
+ cont, inject, warn = self._completion_gate()
554
+ if cont or warn:
555
+ return (cont, inject, warn)
556
+
557
+ # GroundingGate: only active in CODE mode (turn has writes). In ANALYZE
558
+ # mode (pure reads, no writes), citing filenames from docs/README is
559
+ # normal structural description, not a false code claim. Full-session
560
+ # audit (105 sessions, 203 outputs): 98.2% of grounding triggers were
561
+ # false positives on filename mentions in analysis — the gate never
562
+ # caught a real "hallucinated code citation" in practice. Restricting it
563
+ # to CODE mode eliminates the false positives while keeping the (rare)
564
+ # protection for code-change scenarios.
565
+ if self.state.has_wrote_this_turn:
566
+ return self._grounding_gate(final_text)
567
+ return (False, None, None)
568
+
569
+ # --------------------------------------------------------------- VerifyGate
570
+ def _verify_gate(self) -> tuple[bool, str | None, str | None]:
571
+ """Hard gate: unverified writes may not silently end the turn."""
572
+ if not self.state.writes_since_verify:
573
+ return (False, None, None)
574
+
575
+ attempt = self.state.verify_attempts
576
+ self.state.verify_attempts += 1
577
+
578
+ if attempt >= _MAX_GATE_ATTEMPTS:
579
+ # Escalation exhausted: release, but loudly. Never silent.
580
+ files = ", ".join(self.state.writes_since_verify)
581
+ return (
582
+ False,
583
+ None,
584
+ f"agent wrote code to [{files}] but never ran it to verify, "
585
+ "then declared completion. Output is UNVERIFIED — please review.",
586
+ )
587
+ if attempt == 0:
588
+ return (
589
+ True,
590
+ "[harness] You wrote code but haven't verified it. You MUST run it "
591
+ "(use bash to execute/test/lint the files you changed) before "
592
+ "declaring done. Do not summarize or claim completion — call bash now.",
593
+ None,
594
+ )
595
+ # attempt == 1: second interception, name the files and tighten the screws.
596
+ files = ", ".join(self.state.writes_since_verify)
597
+ return (
598
+ True,
599
+ f"[harness] STILL no verification. Files written but not run: {files}. "
600
+ "Run them with bash now. Do NOT reply with text — call bash.",
601
+ None,
602
+ )
603
+
604
+ # ----------------------------------------------------------- CompletionGate
605
+ def _completion_gate(self) -> tuple[bool, str | None, str | None]:
606
+ """Hard gate: pending todos may not silently end the turn.
607
+
608
+ Skipped entirely when there is no todo list (trivial-task exemption): a
609
+ small Q&A or typo fix that never built a plan should not be blocked here.
610
+ The PlanGate's soft nudge is the only pressure in that case.
611
+ """
612
+ if not self.todos.todos:
613
+ return (False, None, None)
614
+ pending = [t for t in self.todos.todos if t.status != "completed"]
615
+ if not pending:
616
+ return (False, None, None)
617
+
618
+ attempt = self.state.completion_attempts
619
+ self.state.completion_attempts += 1
620
+
621
+ if attempt >= _MAX_GATE_ATTEMPTS:
622
+ return (
623
+ False,
624
+ None,
625
+ f"agent declared completion with {len(pending)} unfinished todo(s). "
626
+ "Some planned work may be incomplete — please review the task list.",
627
+ )
628
+ return (
629
+ True,
630
+ f"[harness] Your task list has {len(pending)} unfinished item(s). Mark "
631
+ 'them complete via todo(action="update", status="completed") only if '
632
+ "truly done, or finish the remaining work. Do not claim overall "
633
+ "completion with pending todos.",
634
+ None,
635
+ )
636
+
637
+ # ----------------------------------------------------------- GroundingGate
638
+ def _grounding_gate(self, final_text: str) -> tuple[bool, str | None, str | None]:
639
+ """Hard gate: an analysis that cites code it never read is ungrounded.
640
+
641
+ Failure mode this catches: the model produces a confident-sounding review
642
+ ("loader.py:81 does X", "the harness never reads config.harness") built on
643
+ documentation, memory, or assumption — WITHOUT having opened the file. When
644
+ source and docs diverge (the docs are stale), this silently produces wrong
645
+ conclusions. This gate checks citations against the set of files actually
646
+ read this turn (``state.read_files``, populated by observe()).
647
+
648
+ Only fires when the model EXPLICITLY cites a file path (``foo.py``,
649
+ ``src/x.py:42``) — a pure-conversation answer with no file references is
650
+ left alone (it isn't claiming code-level facts).
651
+ """
652
+ cited = _cited_files(final_text)
653
+ if not cited:
654
+ return (False, None, None)
655
+ # Filter out cited paths that the agent already TRIED to read but got
656
+ # "file not found" — these aren't real code files, just regex false
657
+ # positives on path-like strings in prose. The agent already attempted
658
+ # and confirmed they don't exist; forcing another read wastes rounds.
659
+ ungrounded = [c for c in cited if not self._was_read(c) and c not in self.state.not_found_files]
660
+ if not ungrounded:
661
+ return (False, None, None)
662
+
663
+ attempt = self.state.grounding_attempts
664
+ self.state.grounding_attempts += 1
665
+
666
+ if attempt >= _MAX_GATE_ATTEMPTS:
667
+ files = ", ".join(sorted(ungrounded))
668
+ return (
669
+ False,
670
+ None,
671
+ f"agent cited code in [{files}] without reading it this turn. "
672
+ "Conclusions about that code may rest on stale docs or memory — "
673
+ "please verify against the actual source.",
674
+ )
675
+ if attempt == 0:
676
+ files = ", ".join(sorted(ungrounded))
677
+ return (
678
+ True,
679
+ f"[harness] You cited {files} but did not read it this turn. A claim "
680
+ "about code must be grounded in the actual source, not documentation "
681
+ "or memory (docs go stale; source is ground truth). Read the cited "
682
+ "file(s) with read_file now, then CONTINUE your analysis with the "
683
+ "grounded details. Do not restate or 'correct' your previous output — "
684
+ "just add what you find by reading the source.",
685
+ None,
686
+ )
687
+ files = ", ".join(sorted(ungrounded))
688
+ return (
689
+ True,
690
+ f"[harness] STILL unread: {files}. Read them with read_file before you "
691
+ "make any claim about their contents. If the file doesn't exist, say so "
692
+ "explicitly instead of asserting things about it.",
693
+ None,
694
+ )
695
+
696
+ @staticmethod
697
+ def _basename(path: str) -> str:
698
+ """Last path component (basename), slash/backslash agnostic. 'a/b.py' -> 'b.py'.
699
+
700
+ Operates on the NORMALIZED form (see _norm_path) so case-insensitive
701
+ filesystems (NTFS, APFS default) treat 'Loop.py' == 'loop.py' correctly —
702
+ otherwise the model citing 'loop.py:81' after reading 'Loop.py' would
703
+ trip the GroundingGate into a re-read loop."""
704
+ n = _norm_path(path)
705
+ if "/" in n:
706
+ n = n.rsplit("/", 1)[1]
707
+ return n
708
+
709
+ def _was_read(self, cited: str) -> bool:
710
+ """Did the model actually READ the cited file's contents this turn?
711
+
712
+ Only content-level reads (read_file) ground a citation about a file's
713
+ internals — grep/list_dir/glob don't, because they never show the file's
714
+ actual content (grep matches a pattern; list_dir/glob return names).
715
+
716
+ Matching: basename OR full-path exact match against the files opened with
717
+ read_file. The old loose substring match (``c in r or r in c``) was too
718
+ permissive — it let ``grep pattern="loop"`` satisfy a citation of
719
+ ``loop.py``, and ``list_dir("src")`` cover every file in src/.
720
+ A bare ``:line`` suffix on the citation is stripped first.
721
+
722
+ All comparisons go through _norm_path so 'Loop.py' == 'loop.py' and
723
+ 'a\\b.py' == 'a/b.py' (Windows path + case-insensitive filesystems).
724
+ Without this, the model citing 'loop.py:81' after reading 'Loop.py'
725
+ triggers a needless re-read — observed in real sessions as 'Loop.py'
726
+ being read 5x in one turn.
727
+ """
728
+ c = cited.rsplit(":", 1)[0] if ":" in cited else cited # drop :line
729
+ c_norm = _norm_path(c)
730
+ c_base = self._basename(c)
731
+ for r in self.state.content_read_files:
732
+ if not r:
733
+ continue
734
+ r_norm = _norm_path(r)
735
+ r_base = self._basename(r)
736
+ # Exact basename match (loader.py == loader.py) or full-path match
737
+ # on the normalized form (covers src/agent/loop.py, slash style, case).
738
+ if c_base == r_base or c_norm == r_norm:
739
+ return True
740
+ return False
741
+
742
+
743
+ def _cited_files(text: str) -> list[str]:
744
+ """Extract file-path citations from prose. Returns deduped list (order kept).
745
+
746
+ Matches patterns like ``loader.py``, ``src/agent/loop.py``, ``a.py:81``.
747
+ Requires a code-ish extension so ordinary words ("step 2", "the loader") do
748
+ not false-positive. De-dupes on basename+line so the same file cited twice
749
+ with different line numbers only lists once for the user-facing message.
750
+
751
+ EXCLUDES file paths inside markdown table rows (``| src/foo.py | ... |``):
752
+ those are structural listings in a project map ("this file does X"), not
753
+ code-level citations claiming specific behavior. Without this exclusion,
754
+ a project-analysis table listing every module triggers the GroundingGate
755
+ on files the model listed but didn't deep-read — defeating the purpose of
756
+ the onboarding skill's first-pass overview.
757
+ """
758
+ # Strip markdown table rows before matching — a path inside | ... | is a
759
+ # directory listing, not a citation. Only paths in prose/inline-code count.
760
+ prose = re.sub(r"^\s*\|.*\|\s*$", "", text, flags=re.MULTILINE)
761
+ seen: set[str] = set()
762
+ out: list[str] = []
763
+ for m in _CITED_FILE_RE.finditer(prose):
764
+ path = m.group(0)
765
+ if path not in seen:
766
+ seen.add(path)
767
+ out.append(path)
768
+ return out