claude-dev-env 1.67.2 → 1.68.0

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.
@@ -25,7 +25,7 @@ Decomposition is by the **kind of docstring claim** that needs to be cross-check
25
25
  | O3 | Predicate-name and -docstring vs body breadth | A boolean helper's name and docstring promise a narrow predicate. Walk the body's branches: every branch's `return True` path is consistent with the promised name. Bodies that accept inputs broader than the name (`_dir_value_resolves_to_shared_temp` also accepting HOME/TMP env-derived paths) are O3 findings. |
26
26
  | O4 | Step-ordering narrative | A docstring describes processing as `A then B then C`. Walk the body and confirm the call order matches. Mismatched order is an O4 finding regardless of whether the final output is the same. |
27
27
  | O5 | Named-sentinel / filename references | A docstring names a sentinel marker, environment variable, filename, or magic string. Confirm the named token actually exists in the module body or in the repo's naming convention. |
28
- | O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
28
+ | O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. The single-condition shared-fallback shape of this drift — a summary that scopes a fallback call to one condition while the body routes to that same call from two or more early-return guards — is gated deterministically at Write/Edit time by `check_docstring_fallback_branch_coverage`, so the audit lane focuses on the O6 shapes the gate cannot match. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
29
29
  | O7 | Module-doc-vs-split-module after refactor | When a refactor moves a responsibility to a sibling module, the originating module's docstring and the receiving module's docstring both describe the home of that responsibility. A module docstring should describe only the responsibilities it owns. |
30
30
 
31
31
  ---
@@ -20,10 +20,14 @@ from code_rules_shared import ( # noqa: E402
20
20
  )
21
21
 
22
22
  from hooks_constants.blocking_check_limits import ( # noqa: E402
23
+ ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES,
23
24
  ALL_DOCSTRING_EXEMPT_DECORATOR_NAMES,
24
25
  ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES,
26
+ ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES,
27
+ DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
25
28
  DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
26
29
  MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
30
+ MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
27
31
  MAX_DOCSTRING_FORMAT_ISSUES,
28
32
  )
29
33
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
@@ -306,3 +310,155 @@ def check_docstring_args_match_signature(content: str, file_path: str) -> list[s
306
310
  if len(issues) >= MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES:
307
311
  return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]
308
312
  return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]
313
+
314
+
315
+ def _callee_expression_name(expression: ast.expr) -> str:
316
+ if isinstance(expression, ast.Name):
317
+ return expression.id
318
+ if isinstance(expression, ast.Attribute):
319
+ receiver_name = _callee_expression_name(expression.value)
320
+ if not receiver_name:
321
+ return ast.unparse(expression)
322
+ return f"{receiver_name}.{expression.attr}"
323
+ return ""
324
+
325
+
326
+ def _call_callee_name(call_node: ast.Call) -> str:
327
+ return _callee_expression_name(call_node.func)
328
+
329
+
330
+ def _branch_routes_directly_to_call(branch_node: ast.If) -> str:
331
+ """Return the callee name an early-return guard routes to, or empty string.
332
+
333
+ A guard counts when its block contains exactly one call expression and then
334
+ returns. A second call expression disqualifies the block; non-call
335
+ statements such as an assignment or a loop are skipped and do not
336
+ disqualify it. The await wrapper around an async call is unwrapped first.
337
+ """
338
+ routed_callee = ""
339
+ saw_return = False
340
+ for each_statement in branch_node.body:
341
+ candidate_expression: ast.expr | None = None
342
+ if isinstance(each_statement, ast.Expr):
343
+ candidate_expression = each_statement.value
344
+ elif isinstance(each_statement, ast.Return):
345
+ saw_return = True
346
+ continue
347
+ if candidate_expression is None:
348
+ continue
349
+ if isinstance(candidate_expression, ast.Await):
350
+ candidate_expression = candidate_expression.value
351
+ if not isinstance(candidate_expression, ast.Call):
352
+ return ""
353
+ if routed_callee:
354
+ return ""
355
+ routed_callee = _call_callee_name(candidate_expression)
356
+ if not saw_return:
357
+ return ""
358
+ return routed_callee
359
+
360
+
361
+ def _shared_fallback_route_count(
362
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
363
+ ) -> tuple[str, int]:
364
+ route_count_by_callee: dict[str, int] = {}
365
+ for each_statement in function_node.body:
366
+ if not isinstance(each_statement, ast.If):
367
+ continue
368
+ routed_callee = _branch_routes_directly_to_call(each_statement)
369
+ if not routed_callee:
370
+ continue
371
+ route_count_by_callee[routed_callee] = (
372
+ route_count_by_callee.get(routed_callee, 0) + 1
373
+ )
374
+ if not route_count_by_callee:
375
+ return "", 0
376
+ busiest_callee = max(route_count_by_callee, key=lambda name: route_count_by_callee[name])
377
+ return busiest_callee, route_count_by_callee[busiest_callee]
378
+
379
+
380
+ def _summary_contains_phrase_at_word_boundary(summary_text: str, phrase: str) -> bool:
381
+ search_start = 0
382
+ while True:
383
+ match_index = summary_text.find(phrase, search_start)
384
+ if match_index == -1:
385
+ return False
386
+ preceding_is_boundary = (
387
+ match_index == 0 or not summary_text[match_index - 1].isalnum()
388
+ )
389
+ following_index = match_index + len(phrase)
390
+ following_is_boundary = (
391
+ following_index >= len(summary_text)
392
+ or not summary_text[following_index].isalnum()
393
+ )
394
+ if preceding_is_boundary and following_is_boundary:
395
+ return True
396
+ search_start = match_index + 1
397
+
398
+
399
+ def _summary_joins_multiple_conditions(summary_text: str) -> bool:
400
+ return any(
401
+ joining_phrase in summary_text
402
+ for joining_phrase in ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES
403
+ )
404
+
405
+
406
+ def _docstring_summary_scopes_a_single_condition(docstring_text: str) -> bool:
407
+ summary_text = docstring_text.split("\n\n", 1)[0].lower()
408
+ has_scope_phrase = any(
409
+ _summary_contains_phrase_at_word_boundary(summary_text, each_phrase)
410
+ for each_phrase in ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES
411
+ )
412
+ if not has_scope_phrase:
413
+ return False
414
+ return not _summary_joins_multiple_conditions(summary_text)
415
+
416
+
417
+ def check_docstring_fallback_branch_coverage(content: str, file_path: str) -> list[str]:
418
+ """Flag a fallback docstring that scopes a branch the body reaches twice.
419
+
420
+ The drift this catches: a function whose summary describes a fallback
421
+ action under a single condition (``only when``, ``falls back to ... when``)
422
+ while the body routes to that same fallback call from two or more distinct
423
+ early-return guards. The second guard fires under a condition the prose
424
+ never names, so the enumeration the reader trusts is incomplete. This is
425
+ the deterministic slice of Category O6 (docstring prose vs implementation
426
+ drift): a structural branch-count-versus-prose-condition mismatch.
427
+
428
+ Args:
429
+ content: The source text to inspect.
430
+ file_path: The path the source will be written to, used for exemptions.
431
+
432
+ Returns:
433
+ One issue per function whose fallback prose omits a second route to the
434
+ same call, capped at the module limit.
435
+ """
436
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
437
+ return []
438
+ try:
439
+ parsed_tree = ast.parse(content)
440
+ except SyntaxError:
441
+ return []
442
+ issues: list[str] = []
443
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
444
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
445
+ continue
446
+ if _function_has_exempt_decorator(each_node):
447
+ continue
448
+ docstring_text = _function_docstring_text(each_node)
449
+ if not docstring_text:
450
+ continue
451
+ if not _docstring_summary_scopes_a_single_condition(docstring_text):
452
+ continue
453
+ fallback_callee, route_count = _shared_fallback_route_count(each_node)
454
+ if route_count < DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT:
455
+ continue
456
+ issues.append(
457
+ f"Line {each_node.lineno}: {each_node.name}() docstring scopes a fallback to "
458
+ f"one condition, but the body routes to {fallback_callee}() from {route_count} "
459
+ "distinct branches — enumerate every condition that reaches the fallback "
460
+ "(Category O6 docstring-vs-implementation drift)"
461
+ )
462
+ if len(issues) >= MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES:
463
+ break
464
+ return issues[:MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES]
@@ -66,6 +66,7 @@ from code_rules_dead_module_constant import ( # noqa: E402
66
66
  )
67
67
  from code_rules_docstrings import ( # noqa: E402
68
68
  check_docstring_args_match_signature,
69
+ check_docstring_fallback_branch_coverage,
69
70
  check_docstring_format,
70
71
  )
71
72
  from code_rules_duplicate_body import ( # noqa: E402
@@ -248,6 +249,7 @@ def validate_content(
248
249
  all_issues.extend(check_boundary_types(effective_content, file_path))
249
250
  all_issues.extend(check_docstring_format(effective_content, file_path))
250
251
  all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
252
+ all_issues.extend(check_docstring_fallback_branch_coverage(effective_content, file_path))
251
253
  all_issues.extend(
252
254
  check_boolean_naming(
253
255
  effective_content,
@@ -0,0 +1,398 @@
1
+ """Tests for check_docstring_fallback_branch_coverage — O6 fallback-branch drift.
2
+
3
+ A function whose summary scopes a fallback to one condition while the body
4
+ routes to that same fallback call from two or more distinct early-return guards
5
+ hides the second condition from the reader. This is the deterministic slice of
6
+ Category O6 (docstring prose vs implementation drift).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.util
12
+ from pathlib import Path
13
+ from types import ModuleType
14
+
15
+
16
+ def _load_enforcer_module() -> ModuleType:
17
+ module_path = Path(__file__).parent / "code_rules_enforcer.py"
18
+ spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
19
+ assert spec is not None
20
+ assert spec.loader is not None
21
+ module = importlib.util.module_from_spec(spec)
22
+ spec.loader.exec_module(module)
23
+ return module
24
+
25
+
26
+ code_rules_enforcer = _load_enforcer_module()
27
+
28
+
29
+ def check_docstring_fallback_branch_coverage(content: str, file_path: str) -> list[str]:
30
+ return code_rules_enforcer.check_docstring_fallback_branch_coverage(content, file_path)
31
+
32
+
33
+ def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
34
+ return code_rules_enforcer.validate_content(content, file_path, old_content)
35
+
36
+
37
+ PRODUCTION_FILE_PATH = "/project/src/human_actions.py"
38
+ TEST_FILE_PATH = "/project/src/test_human_actions.py"
39
+ HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/example.py"
40
+
41
+
42
+ def _drifted_scroll_method() -> str:
43
+ return (
44
+ "class HumanActions:\n"
45
+ " async def _scroll_once_toward_target(\n"
46
+ " self, all_container_screen_bounds: object\n"
47
+ " ) -> None:\n"
48
+ ' """Drive one scrollbar pass, falling back to the keyboard when'
49
+ ' the bar has no geometry."""\n'
50
+ " if all_container_screen_bounds is None:\n"
51
+ " await self._activate_then_press_right_arrow(None)\n"
52
+ " return\n"
53
+ " if random.random() < wheel_scroll_config.keyboard_scroll_fallback_probability:\n"
54
+ " await self._activate_then_press_right_arrow(all_container_screen_bounds)\n"
55
+ " return\n"
56
+ " await self._drive_scrollbar_gesture(all_container_screen_bounds)\n"
57
+ )
58
+
59
+
60
+ def _enumerated_scroll_method() -> str:
61
+ return (
62
+ "class HumanActions:\n"
63
+ " async def _scroll_once_toward_target(\n"
64
+ " self, all_container_screen_bounds: object\n"
65
+ " ) -> None:\n"
66
+ ' """Drive one scrollbar pass.\n'
67
+ "\n"
68
+ " Route to the Right-Arrow keyboard burst either when the bar has\n"
69
+ " no geometry or, on a random keyboard_scroll_fallback_probability\n"
70
+ " fraction of passes, when geometry is available.\n"
71
+ ' """\n'
72
+ " if all_container_screen_bounds is None:\n"
73
+ " await self._activate_then_press_right_arrow(None)\n"
74
+ " return\n"
75
+ " if random.random() < wheel_scroll_config.keyboard_scroll_fallback_probability:\n"
76
+ " await self._activate_then_press_right_arrow(all_container_screen_bounds)\n"
77
+ " return\n"
78
+ " await self._drive_scrollbar_gesture(all_container_screen_bounds)\n"
79
+ )
80
+
81
+
82
+ def test_should_flag_two_branches_routing_to_one_scoped_fallback() -> None:
83
+ issues = check_docstring_fallback_branch_coverage(
84
+ _drifted_scroll_method(), PRODUCTION_FILE_PATH
85
+ )
86
+ assert any("_activate_then_press_right_arrow" in each for each in issues), (
87
+ f"Expected the second fallback route to be flagged, got: {issues!r}"
88
+ )
89
+ assert len(issues) == 1
90
+
91
+
92
+ def test_should_report_the_route_count_in_the_message() -> None:
93
+ issues = check_docstring_fallback_branch_coverage(
94
+ _drifted_scroll_method(), PRODUCTION_FILE_PATH
95
+ )
96
+ assert any("2 distinct branches" in each for each in issues), (
97
+ f"Expected the branch count in the message, got: {issues!r}"
98
+ )
99
+
100
+
101
+ def test_should_not_flag_when_both_conditions_are_enumerated() -> None:
102
+ issues = check_docstring_fallback_branch_coverage(
103
+ _enumerated_scroll_method(), PRODUCTION_FILE_PATH
104
+ )
105
+ assert issues == [], (
106
+ f"A docstring that enumerates both routes must not be flagged, got: {issues!r}"
107
+ )
108
+
109
+
110
+ def test_should_not_flag_single_branch_fallback() -> None:
111
+ source = (
112
+ "def render(view: object) -> str:\n"
113
+ ' """Render the view, falling back to the empty string when absent."""\n'
114
+ " if view is None:\n"
115
+ " return ''\n"
116
+ " return view.body\n"
117
+ )
118
+ issues = check_docstring_fallback_branch_coverage(source, PRODUCTION_FILE_PATH)
119
+ assert issues == [], f"One fallback route under one named condition is correct, got: {issues!r}"
120
+
121
+
122
+ def test_should_not_flag_two_branches_to_different_callees() -> None:
123
+ source = (
124
+ "def dispatch(event: object) -> None:\n"
125
+ ' """Dispatch the event, falling back to the logger when unroutable."""\n'
126
+ " if event is None:\n"
127
+ " log_warning('empty')\n"
128
+ " return\n"
129
+ " if event.is_stale:\n"
130
+ " drop_event(event)\n"
131
+ " return\n"
132
+ " route_event(event)\n"
133
+ )
134
+ issues = check_docstring_fallback_branch_coverage(source, PRODUCTION_FILE_PATH)
135
+ assert issues == [], (
136
+ f"Distinct callees per branch are not a shared-fallback drift, got: {issues!r}"
137
+ )
138
+
139
+
140
+ def test_should_not_flag_when_docstring_has_no_scope_phrase() -> None:
141
+ source = (
142
+ "def select(target: object) -> None:\n"
143
+ ' """Pick the first matching candidate from the registry."""\n'
144
+ " if target is None:\n"
145
+ " await _press(None)\n"
146
+ " return\n"
147
+ " if target.is_idle:\n"
148
+ " await _press(target)\n"
149
+ " return\n"
150
+ " await _drive(target)\n"
151
+ )
152
+ issues = check_docstring_fallback_branch_coverage(source, PRODUCTION_FILE_PATH)
153
+ assert issues == [], (
154
+ f"No exclusive-scope phrase means no fallback claim to check, got: {issues!r}"
155
+ )
156
+
157
+
158
+ def test_should_not_flag_when_scope_phrase_is_a_substring_of_another_word() -> None:
159
+ source = (
160
+ "def refresh(cache: object) -> None:\n"
161
+ ' """Rebuild the cache; commonly when idle it reuses the warm copy."""\n'
162
+ " if cache is None:\n"
163
+ " rebuild_cache(None)\n"
164
+ " return\n"
165
+ " if cache.is_cold:\n"
166
+ " rebuild_cache(cache)\n"
167
+ " return\n"
168
+ " serve_cache(cache)\n"
169
+ )
170
+ issues = check_docstring_fallback_branch_coverage(source, PRODUCTION_FILE_PATH)
171
+ assert issues == [], (
172
+ "'commonly when' must not match the 'only when' scope phrase as a bare "
173
+ f"substring, got: {issues!r}"
174
+ )
175
+
176
+
177
+ def test_should_still_flag_word_boundary_scope_phrase() -> None:
178
+ source = (
179
+ "def refresh(cache: object) -> None:\n"
180
+ ' """Rebuild the cache only when it is invalid."""\n'
181
+ " if cache is None:\n"
182
+ " rebuild_cache(None)\n"
183
+ " return\n"
184
+ " if cache.is_cold:\n"
185
+ " rebuild_cache(cache)\n"
186
+ " return\n"
187
+ " serve_cache(cache)\n"
188
+ )
189
+ issues = check_docstring_fallback_branch_coverage(source, PRODUCTION_FILE_PATH)
190
+ assert any("rebuild_cache" in each for each in issues), (
191
+ f"A genuine 'only when' scope phrase must still be flagged, got: {issues!r}"
192
+ )
193
+
194
+
195
+ def test_should_not_flag_when_summary_enumerates_both_conditions_inline() -> None:
196
+ source = (
197
+ "def scroll(bar: object) -> None:\n"
198
+ ' """Drive a scrollbar pass, falling back to the keyboard when the bar'
199
+ ' lacks geometry or on a random fraction of passes."""\n'
200
+ " if bar is None:\n"
201
+ " _keyboard(None)\n"
202
+ " return\n"
203
+ " if bar.is_random:\n"
204
+ " _keyboard(bar)\n"
205
+ " return\n"
206
+ " _drive(bar)\n"
207
+ )
208
+ issues = check_docstring_fallback_branch_coverage(source, PRODUCTION_FILE_PATH)
209
+ assert issues == [], (
210
+ "A summary that enumerates both fallback conditions inline with 'or' is not "
211
+ f"a single-condition scope and must not be flagged, got: {issues!r}"
212
+ )
213
+
214
+
215
+ def test_should_still_flag_single_condition_fallback_with_two_routes() -> None:
216
+ source = (
217
+ "def scroll(bar: object) -> None:\n"
218
+ ' """Drive a scrollbar pass, falling back to the keyboard when the bar'
219
+ ' lacks geometry."""\n'
220
+ " if bar is None:\n"
221
+ " _keyboard(None)\n"
222
+ " return\n"
223
+ " if bar.is_random:\n"
224
+ " _keyboard(bar)\n"
225
+ " return\n"
226
+ " _drive(bar)\n"
227
+ )
228
+ issues = check_docstring_fallback_branch_coverage(source, PRODUCTION_FILE_PATH)
229
+ assert any("_keyboard" in each for each in issues), (
230
+ "A summary scoping the fallback to one named condition while two routes reach "
231
+ f"it must still be flagged, got: {issues!r}"
232
+ )
233
+
234
+
235
+ def test_should_not_flag_when_scope_phrase_is_a_left_anchored_prefix() -> None:
236
+ source = (
237
+ "def forward(packet: object) -> None:\n"
238
+ ' """Forward the packet; falls back toward the default sink when both'
239
+ ' checks miss."""\n'
240
+ " if packet is None:\n"
241
+ " send_to_sink(None)\n"
242
+ " return\n"
243
+ " if packet.is_stale:\n"
244
+ " send_to_sink(packet)\n"
245
+ " return\n"
246
+ " deliver(packet)\n"
247
+ )
248
+ issues = check_docstring_fallback_branch_coverage(source, PRODUCTION_FILE_PATH)
249
+ assert issues == [], (
250
+ "'falls back toward' must not match the 'falls back to' scope phrase as a "
251
+ f"left-anchored prefix, got: {issues!r}"
252
+ )
253
+
254
+
255
+ def test_should_not_flag_only_whenever_left_anchored_prefix() -> None:
256
+ source = (
257
+ "def refresh(cache: object) -> None:\n"
258
+ ' """Rebuild the cache only whenever it is invalid or stale."""\n'
259
+ " if cache is None:\n"
260
+ " rebuild_cache(None)\n"
261
+ " return\n"
262
+ " if cache.is_cold:\n"
263
+ " rebuild_cache(cache)\n"
264
+ " return\n"
265
+ " serve_cache(cache)\n"
266
+ )
267
+ issues = check_docstring_fallback_branch_coverage(source, PRODUCTION_FILE_PATH)
268
+ assert issues == [], (
269
+ "'only whenever' must not match the 'only when' scope phrase as a "
270
+ f"left-anchored prefix, got: {issues!r}"
271
+ )
272
+
273
+
274
+ def test_should_not_flag_two_branches_to_same_method_on_distinct_indexed_receivers() -> None:
275
+ source = (
276
+ "class Pool:\n"
277
+ " def shutdown(self, signal: object) -> None:\n"
278
+ ' """Close resources only when a shutdown signal arrives."""\n'
279
+ " if signal is None:\n"
280
+ " self.pool[0].close(signal)\n"
281
+ " return\n"
282
+ " if signal.is_secondary:\n"
283
+ " self.pool[1].close(signal)\n"
284
+ " return\n"
285
+ " self.pool[2].drain(signal)\n"
286
+ )
287
+ issues = check_docstring_fallback_branch_coverage(source, PRODUCTION_FILE_PATH)
288
+ assert issues == [], (
289
+ "Distinct indexed receivers calling the same method name are different "
290
+ f"fallbacks, got: {issues!r}"
291
+ )
292
+
293
+
294
+ def test_should_not_flag_two_branches_to_same_named_method_on_distinct_receivers() -> None:
295
+ source = (
296
+ "class Closer:\n"
297
+ " def shutdown(self, signal: object) -> None:\n"
298
+ ' """Close resources only when a shutdown signal arrives."""\n'
299
+ " if signal is None:\n"
300
+ " self.primary.close(signal)\n"
301
+ " return\n"
302
+ " if signal.is_secondary:\n"
303
+ " self.secondary.close(signal)\n"
304
+ " return\n"
305
+ " self.tertiary.close(signal)\n"
306
+ )
307
+ issues = check_docstring_fallback_branch_coverage(source, PRODUCTION_FILE_PATH)
308
+ assert issues == [], (
309
+ "Distinct receivers calling the same method name are different fallbacks, "
310
+ f"got: {issues!r}"
311
+ )
312
+
313
+
314
+ def test_should_flag_two_branches_to_same_method_on_one_receiver() -> None:
315
+ source = (
316
+ "class Closer:\n"
317
+ " def shutdown(self, signal: object) -> None:\n"
318
+ ' """Close resources only when a shutdown signal arrives."""\n'
319
+ " if signal is None:\n"
320
+ " self.primary.close(signal)\n"
321
+ " return\n"
322
+ " if signal.is_secondary:\n"
323
+ " self.primary.close(signal)\n"
324
+ " return\n"
325
+ " self.primary.drain(signal)\n"
326
+ )
327
+ issues = check_docstring_fallback_branch_coverage(source, PRODUCTION_FILE_PATH)
328
+ assert any("self.primary.close" in each for each in issues), (
329
+ f"Two routes to the same receiver.method must be flagged, got: {issues!r}"
330
+ )
331
+
332
+
333
+ def test_should_flag_multi_statement_guard_with_one_call_before_return() -> None:
334
+ source = (
335
+ "def select(target: object) -> None:\n"
336
+ ' """Pick a candidate, falling back to the press action when idle."""\n'
337
+ " if target is None:\n"
338
+ " attempt = 1\n"
339
+ " _press(None)\n"
340
+ " return\n"
341
+ " if target.is_idle:\n"
342
+ " attempt = 1\n"
343
+ " _press(target)\n"
344
+ " return\n"
345
+ " _drive(target)\n"
346
+ )
347
+ issues = check_docstring_fallback_branch_coverage(source, PRODUCTION_FILE_PATH)
348
+ assert any("_press" in each for each in issues), (
349
+ "A guard with a non-call statement before its single call still routes "
350
+ f"to that call, got: {issues!r}"
351
+ )
352
+
353
+
354
+ def test_should_not_flag_guard_with_a_second_call_expression() -> None:
355
+ source = (
356
+ "def select(target: object) -> None:\n"
357
+ ' """Pick a candidate, falling back to the press action when idle."""\n'
358
+ " if target is None:\n"
359
+ " _press(None)\n"
360
+ " _press(None)\n"
361
+ " return\n"
362
+ " if target.is_idle:\n"
363
+ " _press(target)\n"
364
+ " _press(target)\n"
365
+ " return\n"
366
+ " _drive(target)\n"
367
+ )
368
+ issues = check_docstring_fallback_branch_coverage(source, PRODUCTION_FILE_PATH)
369
+ assert issues == [], (
370
+ f"A second call expression disqualifies the block as a route, got: {issues!r}"
371
+ )
372
+
373
+
374
+ def test_should_skip_test_file() -> None:
375
+ issues = check_docstring_fallback_branch_coverage(_drifted_scroll_method(), TEST_FILE_PATH)
376
+ assert issues == [], f"Test files exempt, got: {issues!r}"
377
+
378
+
379
+ def test_should_skip_hook_infrastructure() -> None:
380
+ issues = check_docstring_fallback_branch_coverage(
381
+ _drifted_scroll_method(), HOOK_INFRASTRUCTURE_PATH
382
+ )
383
+ assert issues == [], f"Hook infrastructure exempt, got: {issues!r}"
384
+
385
+
386
+ def test_should_handle_syntax_error_gracefully() -> None:
387
+ issues = check_docstring_fallback_branch_coverage("def fetch(\n", PRODUCTION_FILE_PATH)
388
+ assert issues == [], f"Syntax error must yield no issues, got: {issues!r}"
389
+
390
+
391
+ def test_validate_content_surfaces_fallback_branch_drift() -> None:
392
+ issues = validate_content(_drifted_scroll_method(), PRODUCTION_FILE_PATH, old_content="")
393
+ matching_issues = [
394
+ each for each in issues if "_activate_then_press_right_arrow" in each and "O6" in each
395
+ ]
396
+ assert matching_issues, (
397
+ f"Expected validate_content to surface the O6 fallback-branch drift, got: {issues!r}"
398
+ )
@@ -25,6 +25,22 @@ MAX_LOGGING_FSTRING_ISSUES: int = 3
25
25
  MAX_WINDOWS_API_NONE_ISSUES: int = 3
26
26
  MAX_E2E_TEST_NAMING_ISSUES: int = 3
27
27
  DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT: int = 3
28
+ MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES: int = 3
29
+ DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT: int = 2
30
+
31
+ ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES: tuple[str, ...] = (
32
+ "only when",
33
+ "only if",
34
+ "falls back to",
35
+ "falling back to",
36
+ "fall back to",
37
+ )
38
+
39
+ ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES: tuple[str, ...] = (
40
+ " or ",
41
+ "either",
42
+ "both",
43
+ )
28
44
 
29
45
  ALL_BARE_EXCEPT_BANNED_HANDLER_NAMES: frozenset[str] = frozenset({"Exception", "BaseException"})
30
46
  ALL_BOUNDARY_TYPE_EXEMPT_FILENAMES: frozenset[str] = frozenset({"protocols.py", "types.py"})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.67.2",
3
+ "version": "1.68.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,7 +6,7 @@
6
6
 
7
7
  When a docstring enumerates the behaviors a body applies, the enumeration covers every behavior the body applies. A reader trusts the list to be complete: an item the code applies but the prose omits is a silent gap that misleads every future reader and reviewer.
8
8
 
9
- The gate validator `check_docstring_args_match_signature` covers the `Args:` section parameter names. Free-form prose — `"a field counts as read when ..."`, `"resolves to shared temp only"`, `"strip ceremony, then drop blockquotes"` — has no signature to compare against, so the gate cannot catch its drift. This rule is the judgment standard for that prose. It carries documented-but-pending hook coverage; the audit lane below is the enforcement until a deterministic gate check exists.
9
+ The gate validator `check_docstring_args_match_signature` covers the `Args:` section parameter names. A second gate validator, `check_docstring_fallback_branch_coverage`, covers one deterministic slice of the free-form prose: a summary that scopes a fallback to a single condition (`only when`, `falls back to ... when`) while the body routes to that same fallback call from two or more distinct early-return guards. The remaining free-form prose — `"a field counts as read when ..."`, `"resolves to shared temp only"`, `"strip ceremony, then drop blockquotes"` — has no signature and no single structural shape to compare against, so the gate cannot catch its drift. This rule is the judgment standard for that prose; the audit lane below is the enforcement for everything outside the two gated slices.
10
10
 
11
11
  ## What to check before you write the docstring
12
12
 
@@ -14,6 +14,7 @@ Read the body and the docstring side by side:
14
14
 
15
15
  - **Read-source / match-source unions.** A body that computes `read_names = a | b | c` (or any union of "what counts") names each union member in the prose enumeration. A union member the code applies but the prose omits is a gap.
16
16
  - **Suppressor / skip lists.** A body with several early returns that suppress the check names each suppressor in the prose.
17
+ - **Shared fallback routes.** A summary that scopes a fallback call to one condition names every condition that reaches that call. When the body routes to the same fallback from two or more early-return guards (`if a is None: fallback(); return` and `if random() < p: fallback(); return`), the prose enumerates both guards. The `check_docstring_fallback_branch_coverage` gate blocks the single-condition form of this drift at Write/Edit time.
17
18
  - **Step order.** A docstring that says `A then B then C` matches the call order in the body.
18
19
  - **Predicate breadth.** A boolean helper whose prose promises a narrow check accepts only the inputs the prose names — no broader input class the name and prose do not mention.
19
20
 
@@ -36,7 +37,7 @@ A docstring that enumerates "attribute read, augmented-assignment target, class-
36
37
 
37
38
  ## Enforcement (audit lane)
38
39
 
39
- This drift class is sub-bucket **O6** in `packages/claude-dev-env/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md` (free-form `Note:` / `Returns:` / responsibility-list claims). The audit teammate lists every prose enumeration in a changed docstring and verifies each item against the body, and lists every union member / suppressor / step in the body and verifies each appears in the prose. A union member or suppressor in the body that the prose omits is an O6 finding.
40
+ This drift class is sub-bucket **O6** in `packages/claude-dev-env/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md` (free-form `Note:` / `Returns:` / responsibility-list claims). The audit teammate lists every prose enumeration in a changed docstring and verifies each item against the body, and lists every union member / suppressor / step in the body and verifies each appears in the prose. A union member or suppressor in the body that the prose omits is an O6 finding. The single-condition shared-fallback shape of this drift is gated deterministically by `check_docstring_fallback_branch_coverage` (`packages/claude-dev-env/hooks/blocking/code_rules_docstrings.py`); the audit lane covers every O6 shape the gate cannot match.
40
41
 
41
42
  ## Why
42
43
 
@@ -183,3 +183,138 @@ test('the round-loop fix-stalled blockers survive the recovery wiring', () => {
183
183
  assert.match(convergeSource, /fix lens landed no push for/);
184
184
  assert.match(convergeSource, /copilot fix lens landed no push for/);
185
185
  });
186
+
187
+ const verifyObjectionModule = new Function(
188
+ `${constantLine('VERIFY_OBJECTION_FALLBACK')}\n` +
189
+ `${functionSource('renderVerifyObjectionLine')}\n` +
190
+ `${functionSource('extractVerifyObjection')}\n` +
191
+ 'return { extractVerifyObjection, VERIFY_OBJECTION_FALLBACK };',
192
+ )();
193
+
194
+ const { extractVerifyObjection, VERIFY_OBJECTION_FALLBACK } = verifyObjectionModule;
195
+
196
+ test('extractVerifyObjection falls back for a non-string transcript', () => {
197
+ assert.equal(extractVerifyObjection(null), VERIFY_OBJECTION_FALLBACK);
198
+ });
199
+
200
+ test('extractVerifyObjection falls back when no verdict fence is present', () => {
201
+ assert.equal(
202
+ extractVerifyObjection('the verifier wrote prose with no verdict fence'),
203
+ VERIFY_OBJECTION_FALLBACK,
204
+ );
205
+ });
206
+
207
+ test('extractVerifyObjection falls back when the verdict fence carries no findings', () => {
208
+ const transcript = '```verdict\n{"all_pass": false, "findings": []}\n```';
209
+ assert.equal(extractVerifyObjection(transcript), VERIFY_OBJECTION_FALLBACK);
210
+ });
211
+
212
+ test('extractVerifyObjection renders each verdict finding as check then detail', () => {
213
+ const transcript =
214
+ '```verdict\n{"all_pass": false, "findings": [{"check": "Finding 1", "detail": "still over-blocks"}, {"check": "Finding 2", "detail": "boundary unchecked"}]}\n```';
215
+ const objection = extractVerifyObjection(transcript);
216
+ assert.match(objection, /1\. Finding 1 — still over-blocks/);
217
+ assert.match(objection, /2\. Finding 2 — boundary unchecked/);
218
+ });
219
+
220
+ test('extractVerifyObjection reads the LAST verdict fence', () => {
221
+ const transcript =
222
+ '```verdict\n{"all_pass": false, "findings": [{"check": "stale", "detail": "old"}]}\n```\nretry\n```verdict\n{"all_pass": false, "findings": [{"check": "fresh", "detail": "new"}]}\n```';
223
+ const objection = extractVerifyObjection(transcript);
224
+ assert.match(objection, /fresh — new/);
225
+ assert.doesNotMatch(objection, /stale/);
226
+ });
227
+
228
+ test('extractVerifyObjection renders bare string findings as their text', () => {
229
+ const transcript =
230
+ '```verdict\n{"all_pass": false, "findings": ["boundary still over-blocks", "missing test for empty input"]}\n```';
231
+ const objection = extractVerifyObjection(transcript);
232
+ assert.match(objection, /1\. boundary still over-blocks/);
233
+ assert.match(objection, /2\. missing test for empty input/);
234
+ assert.doesNotMatch(objection, /unnamed check/);
235
+ });
236
+
237
+ test('extractVerifyObjection renders alternate-keyed objects (title, message, description, issue)', () => {
238
+ const transcript =
239
+ '```verdict\n{"all_pass": false, "findings": [{"title": "over-blocks", "detail": "boundary unchecked"}, {"message": "regex too broad"}, {"description": "no fallback path"}, {"issue": "stale fixture"}]}\n```';
240
+ const objection = extractVerifyObjection(transcript);
241
+ assert.match(objection, /over-blocks — boundary unchecked/);
242
+ assert.match(objection, /regex too broad/);
243
+ assert.match(objection, /no fallback path/);
244
+ assert.match(objection, /stale fixture/);
245
+ assert.doesNotMatch(objection, /unnamed check/);
246
+ assert.doesNotMatch(objection, /no detail/);
247
+ });
248
+
249
+ test('extractVerifyObjection renders mixed string and object findings', () => {
250
+ const transcript =
251
+ '```verdict\n{"all_pass": false, "findings": ["plain concern", {"check": "named", "detail": "explained"}]}\n```';
252
+ const objection = extractVerifyObjection(transcript);
253
+ assert.match(objection, /1\. plain concern/);
254
+ assert.match(objection, /2\. named — explained/);
255
+ });
256
+
257
+ test('extractVerifyObjection stringifies an object whose keys it does not recognize', () => {
258
+ const transcript = '```verdict\n{"all_pass": false, "findings": [{"severity": "P1", "line": 42}]}\n```';
259
+ const objection = extractVerifyObjection(transcript);
260
+ assert.match(objection, /severity/);
261
+ assert.match(objection, /42/);
262
+ assert.doesNotMatch(objection, /unnamed check/);
263
+ });
264
+
265
+ test('extractVerifyObjection falls back when no finding yields usable text', () => {
266
+ const transcript = '```verdict\n{"all_pass": false, "findings": [null, {}, ""]}\n```';
267
+ assert.equal(extractVerifyObjection(transcript), VERIFY_OBJECTION_FALLBACK);
268
+ });
269
+
270
+ test('recoverVerifyFailEdit is a clean-coder edit step bound to the verifier objection and leaves changes uncommitted', () => {
271
+ const recoverBody = functionSource('recoverVerifyFailEdit');
272
+ assert.match(recoverBody, /agentType:\s*'clean-coder'/, 'expected the fixer to use clean-coder');
273
+ assert.match(recoverBody, /schema:\s*EDIT_SCHEMA/, 'expected the fixer to reuse EDIT_SCHEMA');
274
+ assert.match(recoverBody, /label:\s*`fix-verify-recover:/, 'expected the fix-verify-recover label');
275
+ assert.match(recoverBody, /objection/, 'expected the fixer prompt to consume the verifier objection');
276
+ assert.match(
277
+ recoverBody,
278
+ /do not commit and do not push|Do NOT commit|leave .*uncommitted|uncommitted/i,
279
+ 'expected the fixer to leave its fix uncommitted for the re-verify and retry commit',
280
+ );
281
+ });
282
+
283
+ test('verifyWithRecovery bounds the loop, re-fixes on a failed verdict, and re-verifies', () => {
284
+ const recoveryBody = functionSource('verifyWithRecovery');
285
+ assert.match(recoveryBody, /verdictPassed\(/, 'expected the loop guard to call verdictPassed');
286
+ assert.match(
287
+ recoveryBody,
288
+ /attempt\s*<\s*FIX_RECOVERY_MAX_ATTEMPTS/,
289
+ 'expected the loop to be bounded by FIX_RECOVERY_MAX_ATTEMPTS',
290
+ );
291
+ assert.match(recoveryBody, /runRecoverEdit\(/, 'expected the loop to spawn the verify-recovery fixer');
292
+ assert.match(recoveryBody, /runVerify\(/, 'expected the loop to re-verify after the fixer edit');
293
+ assert.match(
294
+ recoveryBody,
295
+ /extractVerifyObjection\(/,
296
+ 'expected the loop to feed the fixer the verifier objection',
297
+ );
298
+ const editGuardIndex = recoveryBody.search(/edited\s*!==\s*true/);
299
+ assert.notEqual(editGuardIndex, -1, 'expected an early break when the fixer made no edit');
300
+ const recoverEditIndex = recoveryBody.search(/runRecoverEdit\(/);
301
+ const reverifyIndex = recoveryBody.lastIndexOf('runVerify(');
302
+ assert.ok(recoverEditIndex < reverifyIndex, 'expected order recover-edit -> re-verify, so a swap fails');
303
+ });
304
+
305
+ test('applyFixes routes its verify through verifyWithRecovery before commitWithRecovery', () => {
306
+ const applyFixesBody = functionSource('applyFixes');
307
+ assert.match(applyFixesBody, /verifyWithRecovery\(/, 'expected applyFixes to call verifyWithRecovery');
308
+ assert.match(applyFixesBody, /runVerify:\s*\(\)\s*=>\s*verifyFixesInWorkingTree\(/);
309
+ assert.match(applyFixesBody, /runRecoverEdit:[\s\S]*?recoverVerifyFailEdit\(/);
310
+ const verifyIndex = applyFixesBody.search(/verifyWithRecovery\(/);
311
+ const commitIndex = applyFixesBody.search(/commitWithRecovery\(/);
312
+ assert.ok(verifyIndex < commitIndex, 'expected verify-recovery to precede commit-recovery');
313
+ });
314
+
315
+ test('repairConvergence routes its verify through verifyWithRecovery wired to the repair verify step', () => {
316
+ const repairBody = functionSource('repairConvergence');
317
+ assert.match(repairBody, /verifyWithRecovery\(/, 'expected repairConvergence to call verifyWithRecovery');
318
+ assert.match(repairBody, /runVerify:\s*\(\)\s*=>\s*verifyRepairChanges\(/);
319
+ assert.match(repairBody, /runRecoverEdit:[\s\S]*?recoverVerifyFailEdit\(/);
320
+ });
@@ -406,6 +406,68 @@ function verdictPassed(verifyTranscript) {
406
406
  }
407
407
  }
408
408
 
409
+ const VERIFY_OBJECTION_FALLBACK = 'The verify step rejected the working-tree fixes without a parseable verdict; re-read the fix-verify transcript above and address every concern it raised.'
410
+
411
+ /**
412
+ * Render one verdict finding as a single objection line, tolerant of the shapes a
413
+ * verifier realistically emits: a bare string, an object keyed by any of
414
+ * check/title/message/description/issue for the headline and detail/description
415
+ * for the body, or any other object (stringified so its content survives). A
416
+ * headline and a detail render as "headline — detail"; a headline alone renders
417
+ * as the headline; an entry that yields no usable text returns null so the caller
418
+ * can fall back rather than emit a content-free placeholder.
419
+ * @param {unknown} eachFinding one entry from the verdict findings array
420
+ * @returns {string|null} the rendered objection line, or null when unusable
421
+ */
422
+ function renderVerifyObjectionLine(eachFinding) {
423
+ if (typeof eachFinding === 'string') {
424
+ const trimmedFinding = eachFinding.trim()
425
+ return trimmedFinding.length > 0 ? trimmedFinding : null
426
+ }
427
+ if (eachFinding === null || typeof eachFinding !== 'object') return null
428
+ const headline =
429
+ eachFinding.check || eachFinding.title || eachFinding.message || eachFinding.description || eachFinding.issue
430
+ const detail = eachFinding.detail || (headline === eachFinding.description ? '' : eachFinding.description)
431
+ if (typeof headline === 'string' && headline.length > 0) {
432
+ return typeof detail === 'string' && detail.length > 0 ? `${headline} — ${detail}` : headline
433
+ }
434
+ const stringifiedFinding = JSON.stringify(eachFinding)
435
+ return stringifiedFinding === '{}' ? null : stringifiedFinding
436
+ }
437
+
438
+ /**
439
+ * Pull the verifier's stated objections out of a failed verify transcript so the
440
+ * re-fix step knows what the verdict rejected. Reads the last fenced verdict JSON
441
+ * (the same block verdictPassed reads) and renders each finding through
442
+ * renderVerifyObjectionLine into a numbered list. A missing fence, a parse
443
+ * failure, an empty findings list, or a findings list where no entry yields
444
+ * usable text falls back to a generic re-read instruction, so the re-fix step
445
+ * always receives actionable text.
446
+ * @param {string|null|undefined} verifyTranscript the failed verifier transcript text
447
+ * @returns {string} a human-readable block of the verifier's objections
448
+ */
449
+ function extractVerifyObjection(verifyTranscript) {
450
+ if (typeof verifyTranscript !== 'string') return VERIFY_OBJECTION_FALLBACK
451
+ const fencePattern = /```verdict\s*\n([\s\S]*?)```/g
452
+ let lastFenceBody = null
453
+ let eachMatch
454
+ while ((eachMatch = fencePattern.exec(verifyTranscript)) !== null) {
455
+ lastFenceBody = eachMatch[1]
456
+ }
457
+ if (lastFenceBody === null) return VERIFY_OBJECTION_FALLBACK
458
+ try {
459
+ const verdictRecord = JSON.parse(lastFenceBody)
460
+ const allObjections = Array.isArray(verdictRecord?.findings) ? verdictRecord.findings : []
461
+ const renderedObjections = allObjections
462
+ .map((eachFinding) => renderVerifyObjectionLine(eachFinding))
463
+ .filter((eachLine) => eachLine !== null)
464
+ if (renderedObjections.length === 0) return VERIFY_OBJECTION_FALLBACK
465
+ return renderedObjections.map((eachLine, position) => `${position + 1}. ${eachLine}`).join('\n')
466
+ } catch {
467
+ return VERIFY_OBJECTION_FALLBACK
468
+ }
469
+ }
470
+
409
471
  /**
410
472
  * Decide whether a fix lens actually advanced the round: a pushed fix that moved
411
473
  * HEAD progressed, and so did an all-stale round whose findings were every one
@@ -808,6 +870,32 @@ function recoverCommitBlockEdit(head, blockerDetail, sourceLabel, attempt) {
808
870
  )
809
871
  }
810
872
 
873
+ /**
874
+ * Verify-recovery fixer: when the verify step rejects the working-tree fixes, one
875
+ * clean-coder re-fixes against the verdict's stated objections, test-first, and
876
+ * leaves the work uncommitted so the re-verify step can bind a fresh verdict. The
877
+ * objection text names which findings the verifier judged unresolved and why, so
878
+ * the fixer addresses those concerns; it does not touch GitHub review threads —
879
+ * the edit step already replied to and resolved those.
880
+ * @param {string} head PR HEAD SHA the fixes were raised against
881
+ * @param {string} objection the verifier's rendered objections from the failed verdict
882
+ * @param {string} sourceLabel short description of where the findings came from
883
+ * @param {number} attempt the 1-based recovery attempt number
884
+ * @returns {Promise<object>} EDIT_SCHEMA result
885
+ */
886
+ function recoverVerifyFailEdit(head, objection, sourceLabel, attempt) {
887
+ return convergeAgent(
888
+ `You are the VERIFY-RECOVERY fixer (attempt ${attempt}) for fixes (${sourceLabel}) on ${prCoordinates}, HEAD ${head}. The verify step rejected the working-tree fixes; its verdict named what is still unresolved. A separate verify step then a separate commit step run after you.\n\n` +
889
+ `The verify step's objections:\n${objection}\n\n` +
890
+ `Rules:\n` +
891
+ `- Confirm the working tree is on the PR branch at HEAD ${head} with the prior fixes still present.\n` +
892
+ `- Address every objection above test-first (failing test, then minimum code to pass) per CODE_RULES, so each named concern is genuinely resolved the way the verdict requires. Do not touch GitHub review threads — the edit step already handled those.\n` +
893
+ `- Leave the corrected fixes in the working tree. Do NOT commit and do NOT push — the verify step re-binds a verdict and the commit step pushes after you.\n\n` +
894
+ `Return values: edited=true with a one-line summary when you changed code to address the objections; edited=false, resolvedWithoutCommit=false when the objections cannot be cleared with a code change.`,
895
+ { label: `fix-verify-recover:${sourceLabel}`, phase: 'Converge', schema: EDIT_SCHEMA, agentType: 'clean-coder' },
896
+ )
897
+ }
898
+
811
899
  const FIX_RECOVERY_MAX_ATTEMPTS = 2
812
900
 
813
901
  /**
@@ -836,6 +924,29 @@ async function commitWithRecovery({ runCommit, runVerify, runRecoverEdit }) {
836
924
  return commitResult
837
925
  }
838
926
 
927
+ /**
928
+ * Run the verify step and, when its verdict fails, route back to a fixer: re-fix
929
+ * against the verifier's objection, then re-verify — bounded by
930
+ * FIX_RECOVERY_MAX_ATTEMPTS. The loop breaks early when the fixer makes no edit,
931
+ * returning the last failed verify transcript so the caller's verdict-failed
932
+ * handling still applies; a verify that passes on any attempt returns its passing
933
+ * transcript so the caller proceeds to commit.
934
+ * @param {{runVerify: function, runRecoverEdit: function}} steps the verify and verify-recovery-edit thunks
935
+ * @returns {Promise<string>} the final verify transcript — passing, or the last failed one
936
+ */
937
+ async function verifyWithRecovery({ runVerify, runRecoverEdit }) {
938
+ let verifyTranscript = await runVerify()
939
+ let attempt = 0
940
+ while (!verdictPassed(verifyTranscript) && attempt < FIX_RECOVERY_MAX_ATTEMPTS) {
941
+ attempt += 1
942
+ const objection = extractVerifyObjection(verifyTranscript)
943
+ const recoverEdit = await runRecoverEdit(objection, attempt)
944
+ if (recoverEdit?.edited !== true) break
945
+ verifyTranscript = await runVerify()
946
+ }
947
+ return verifyTranscript
948
+ }
949
+
839
950
  /**
840
951
  * Fix lens: edit (clean-coder, no commit) -> verify (code-verifier emits a
841
952
  * verdict fence binding the working tree) -> commit (clean-coder, one commit +
@@ -862,7 +973,10 @@ async function applyFixes(head, findings, sourceLabel) {
862
973
  blockerDetail: '',
863
974
  }
864
975
  }
865
- const verifyTranscript = await verifyFixesInWorkingTree(head, findings, sourceLabel)
976
+ const verifyTranscript = await verifyWithRecovery({
977
+ runVerify: () => verifyFixesInWorkingTree(head, findings, sourceLabel),
978
+ runRecoverEdit: (objection, attempt) => recoverVerifyFailEdit(head, objection, sourceLabel, attempt),
979
+ })
866
980
  if (!verdictPassed(verifyTranscript)) {
867
981
  return {
868
982
  newSha: head,
@@ -1099,7 +1213,10 @@ async function repairConvergence(head, failures) {
1099
1213
  blockerDetail: '',
1100
1214
  }
1101
1215
  }
1102
- const verifyTranscript = await verifyRepairChanges(head, failures)
1216
+ const verifyTranscript = await verifyWithRecovery({
1217
+ runVerify: () => verifyRepairChanges(head, failures),
1218
+ runRecoverEdit: (objection, attempt) => recoverVerifyFailEdit(head, objection, 'repair', attempt),
1219
+ })
1103
1220
  if (!verdictPassed(verifyTranscript)) {
1104
1221
  return {
1105
1222
  newSha: head,