claude-dev-env 1.73.0 → 1.74.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.
Files changed (78) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  3. package/hooks/blocking/CLAUDE.md +3 -0
  4. package/hooks/blocking/block_main_commit.py +14 -0
  5. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  6. package/hooks/blocking/claude_md_orphan_file_blocker.py +14 -42
  7. package/hooks/blocking/code_rules_docstrings.py +223 -0
  8. package/hooks/blocking/code_rules_enforcer.py +16 -0
  9. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +10 -4
  10. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  11. package/hooks/blocking/destructive_command_blocker.py +7 -0
  12. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  13. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  14. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  15. package/hooks/blocking/hedging_language_blocker.py +16 -10
  16. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  17. package/hooks/blocking/intent_only_ending_blocker.py +17 -11
  18. package/hooks/blocking/md_to_html_blocker.py +10 -2
  19. package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
  20. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  21. package/hooks/blocking/plain_language_blocker.py +6 -0
  22. package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
  23. package/hooks/blocking/pr_description_enforcer.py +6 -0
  24. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  25. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  26. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -0
  27. package/hooks/blocking/question_to_user_enforcer.py +18 -12
  28. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  29. package/hooks/blocking/sensitive_file_protector.py +15 -1
  30. package/hooks/blocking/session_handoff_blocker.py +14 -8
  31. package/hooks/blocking/state_description_blocker.py +6 -0
  32. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  33. package/hooks/blocking/tdd_enforcer.py +6 -0
  34. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  36. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +45 -0
  37. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  38. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  39. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  40. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  41. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  42. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  43. package/hooks/blocking/test_shared_stdin_adoption.py +42 -0
  44. package/hooks/blocking/test_state_description_blocker.py +41 -0
  45. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  46. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  47. package/hooks/blocking/verdict_directory_write_blocker.py +10 -1
  48. package/hooks/blocking/verified_commit_gate.py +11 -0
  49. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  50. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  51. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  52. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  53. package/hooks/hooks.json +10 -0
  54. package/hooks/hooks_constants/CLAUDE.md +4 -0
  55. package/hooks/hooks_constants/blocking_check_limits.py +13 -0
  56. package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
  57. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  58. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  59. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  60. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  61. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +1 -2
  62. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +9 -1
  63. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  64. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  65. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  66. package/hooks/lifecycle/config_change_guard.py +12 -0
  67. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  68. package/hooks/validation/hook_format_validator.py +13 -0
  69. package/hooks/validation/mypy_validator.py +30 -1
  70. package/hooks/validation/test_hook_format_validator.py +64 -0
  71. package/hooks/validation/test_mypy_validator.py +22 -0
  72. package/package.json +1 -1
  73. package/rules/CLAUDE.md +1 -0
  74. package/rules/docstring-prose-matches-implementation.md +2 -1
  75. package/rules/package-inventory-stale-entry.md +24 -0
  76. package/skills/autoconverge/SKILL.md +18 -1
  77. package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
  78. package/skills/autoconverge/workflow/converge.mjs +2 -1
package/CLAUDE.md CHANGED
@@ -35,6 +35,8 @@ When I ask you to "show me", "open", "display", "let me see", or "pull up" a fil
35
35
 
36
36
  It sizes each image window to the image (scaled down to fit the screen) and opens non-image files in their default app; pass every path I name. Printing a path or attaching the file is not showing it — do that only when the file truly cannot be opened, and say why.
37
37
 
38
+ The `send_user_file_open_locally_blocker` hook backs this up: it blocks a desk-side `SendUserFile` attach and sends you back to this command, while a phone push (`status: "proactive"`) stays allowed.
39
+
38
40
  ## Test Philosophy
39
41
 
40
42
  When writing tests, always write tests that actually test the behavior of the function against actual, real data and environments.
@@ -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. A docstring step enumeration that names the body's linear steps but omits a corrective workflow step the body guards inside an `if`/`elif` branch (`if not await cancel_and_reinitiate_update(...): return`) is also an O4 finding: the reader trusts the step list to be complete and misses the conditional path. The branch-guarded-dispatch shape of this drift — a docstring that names two or more linear-step callees while the body guards a two-or-more-token dispatch callee inside a branch whose name the prose never spells out — is gated deterministically at Write/Edit time by `check_docstring_step_enumeration_dispatch_coverage` (`packages/claude-dev-env/hooks/blocking/code_rules_docstrings.py`), so the audit lane focuses on the step-ordering shapes the gate cannot match (re-ordered steps, plain unguarded steps the prose omits). |
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. When a docstring sentence excludes a named category of input from what the function flags (`X are not dispatch steps`, `Y is not a match`), confirm the axis the prose excludes on is the axis the body's branch condition actually keys on. A body that flags a call when it sits inside an `If.test` guard, paired with prose that excludes by the call's receiver shape (`method-on-local calls inside a branch are not dispatch steps`), is an O6 finding: a guarded method-on-local call is flagged even though the prose lists it as excluded — the exclusion is keyed to the wrong axis. 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`. |
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. When a docstring sentence excludes a named category of input from what the function flags (`X are not dispatch steps`, `Y is not a match`), confirm the axis the prose excludes on is the axis the body's branch condition actually keys on. A body that flags a call when it sits inside an `If.test` guard, paired with prose that excludes by the call's receiver shape (`method-on-local calls inside a branch are not dispatch steps`), is an O6 finding: a guarded method-on-local call is flagged even though the prose lists it as excluded — the exclusion is keyed to the wrong axis. 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. The exception-guard shape of this drift — a docstring that promises a malformed payload `resolves to None` while a payload subscript (`payload["key"]`, `float(payload["key"])`) sits outside the try/except whose handler returns None, so a present-but-malformed payload raises rather than resolving to None — is gated deterministically at Write/Edit time by `check_docstring_unguarded_malformed_payload_claim`, so the audit lane focuses on the wider Raises/None-on-failure claims 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
  | O8 | Companion-doc ordering/content vs producer | When a PR changes a producer function's ordering or union, read that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact (a file path, a JSON key, a named list). A doc sentence that claims the artifact is `sorted` / `alphabetical` / `in sorted order`, or holds `just the at-risk names` / `only the current set`, while the producer merges stored names with new names and appends — preserving file order, not re-sorting the union — is an O8 finding on both counts (wrong order claim, hidden merged-in entries). The finding stands even when the PR diff never touched the `.md` file, because the behavior change orphaned the doc claim. See `../../rules/docstring-prose-matches-implementation.md`. |
31
31
 
@@ -62,6 +62,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
62
62
  | `code_verifier_spawn_preflight_gate.py` | PreToolUse (Agent) | Spawning the `code-verifier` subagent when the branch has a merge conflict vs its base or a CODE_RULES violation on a working-tree-added line |
63
63
  | `convergence_gate_blocker.py` | PreToolUse (Bash) | Convergence workflow actions on a conflicting PR |
64
64
  | `destructive_command_blocker.py` | PreToolUse (Bash/PowerShell) | Shell commands with destructive literals (`rm -rf`, `git reset --hard`, etc.) |
65
+ | `docstring_rule_gate_count_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | A stale spelled-out gate-validator count in `docstring-prose-matches-implementation.md` — the "N more gate validators" / "M gated slices" count drifting from the `check_docstring_*` validators the prose names |
65
66
  | `es_exe_path_rewriter.py` | PreToolUse | Rewrites paths referencing `.exe` under the Everything search path |
66
67
  | `gh_body_arg_blocker.py` | PreToolUse (Bash) | `gh` commands passing `--body`/`-b` directly (requires `--body-file` instead) |
67
68
  | `gh_pr_author_enforcer.py` | PreToolUse | Enforces PR author identity rules |
@@ -71,12 +72,14 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
71
72
  | `intent_only_ending_blocker.py` | Stop | Responses that end on a plan or intent without doing the work |
72
73
  | `md_to_html_blocker.py` | PreToolUse (Write/Edit) | Writing `.md` files when an `.html` companion is required |
73
74
  | `open_questions_in_plans_blocker.py` | PreToolUse (Write/Edit) | Plan documents with unresolved open questions |
75
+ | `package_inventory_stale_blocker.py` | PreToolUse (Write) | A new production code file created in a directory whose `README.md`/`CLAUDE.md` inventory names two or more sibling files but no entry for the new file |
74
76
  | `plain_language_blocker.py` | PreToolUse (Write/Edit/AskUserQuestion) | Heavy or jargon words in user-facing prose |
75
77
  | `pr_converge_bugteam_enforcer.py` | PreToolUse | Enforces that bugteam runs in parallel with bugbot in pr-converge loops |
76
78
  | `pr_description_enforcer.py` | PreToolUse (Bash) | `gh pr create`/`edit` without a PR-description-writer-authored body |
77
79
  | `precommit_code_rules_gate.py` | PreToolUse (Bash) | Staged changes that fail the CODE_RULES gate at commit time |
78
80
  | `pytest_testpaths_orphan_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | New `test_*.py` files created under a directory absent from a package's explicit pytest `testpaths` allowlist |
79
81
  | `question_to_user_enforcer.py` | Stop | User-directed questions not routed through `AskUserQuestion` |
82
+ | `send_user_file_open_locally_blocker.py` | PreToolUse (SendUserFile) | A desk-side file attach (`SendUserFile` with `status` not `proactive`); points to opening the file locally via `Show-Asset.ps1` |
80
83
  | `sensitive_file_protector.py` | PreToolUse (Write/Edit) | Writes to sensitive credential or config files |
81
84
  | `session_handoff_blocker.py` | Stop | Responses suggesting a new session mid-task |
82
85
  | `state_description_blocker.py` | PreToolUse (Write/Edit) | Historical/comparative language in documentation |
@@ -15,6 +15,13 @@ import os
15
15
  import re
16
16
  import subprocess
17
17
  import sys
18
+ from pathlib import Path
19
+
20
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
21
+ if _hooks_dir not in sys.path:
22
+ sys.path.insert(0, _hooks_dir)
23
+
24
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
18
25
 
19
26
  GIT_COMMAND_TIMEOUT_SECONDS = 5
20
27
  PROTECTED_BRANCHES = ("main", "master")
@@ -172,6 +179,13 @@ def main() -> None:
172
179
  sys.exit(0)
173
180
 
174
181
  denial = build_denial_response(current_branch, target_dir)
182
+ log_hook_block(
183
+ calling_hook_name="block_main_commit.py",
184
+ hook_event="PreToolUse",
185
+ block_reason=denial["hookSpecificOutput"]["permissionDecisionReason"],
186
+ tool_name="Bash",
187
+ offending_input_preview=bash_command,
188
+ )
175
189
  print(json.dumps(denial))
176
190
  sys.exit(0)
177
191
 
@@ -21,6 +21,7 @@ from hooks_constants.bot_mention_comment_blocker_constants import ( # noqa: E40
21
21
  CURSOR_MENTION_TOKEN,
22
22
  TOOL_NAME,
23
23
  )
24
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
24
25
 
25
26
 
26
27
  def _body_contains_token(body: str, token: str) -> bool:
@@ -60,6 +61,12 @@ def main() -> None:
60
61
  "permissionDecisionReason": corrective_message,
61
62
  }
62
63
  }
64
+ log_hook_block(
65
+ calling_hook_name="bot_mention_comment_blocker.py",
66
+ hook_event="PreToolUse",
67
+ block_reason=corrective_message,
68
+ tool_name=TOOL_NAME,
69
+ )
63
70
  print(json.dumps(deny_payload))
64
71
  sys.stdout.flush()
65
72
  sys.exit(0)
@@ -39,6 +39,11 @@ from hooks_constants.claude_md_orphan_file_blocker_constants import ( # noqa: E
39
39
  SEPARATOR_CELL_PATTERN,
40
40
  TABLE_ROW_PATTERN,
41
41
  )
42
+ from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
43
+ apply_edits,
44
+ edits_for_tool,
45
+ )
46
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
42
47
  from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
43
48
  read_hook_input_dictionary_from_stdin,
44
49
  )
@@ -409,29 +414,6 @@ def _read_existing_file_content(file_path: str) -> str | None:
409
414
  return None
410
415
 
411
416
 
412
- def _apply_edits(existing_content: str, all_edits: list[dict]) -> str:
413
- """Return *existing_content* with each MultiEdit replacement applied in order.
414
-
415
- Args:
416
- existing_content: The current on-disk file content.
417
- all_edits: The MultiEdit ``edits`` list, each a mapping with an
418
- ``old_string`` and a ``new_string``.
419
-
420
- Returns:
421
- The content after replacing the first occurrence of each edit's
422
- ``old_string`` with its ``new_string``, in list order.
423
- """
424
- edited_content = existing_content
425
- for each_edit in all_edits:
426
- if not isinstance(each_edit, dict):
427
- continue
428
- old_string = each_edit.get("old_string", "")
429
- new_string = each_edit.get("new_string", "")
430
- if isinstance(old_string, str) and isinstance(new_string, str) and old_string:
431
- edited_content = edited_content.replace(old_string, new_string, 1)
432
- return edited_content
433
-
434
-
435
417
  def _edit_fragments(all_edits: list[dict]) -> list[str]:
436
418
  """Return each MultiEdit ``new_string`` fragment present as a non-empty string.
437
419
 
@@ -498,29 +480,12 @@ def _build_orphan_scan_plan(
498
480
  content = tool_input.get("content", "")
499
481
  candidate_contents = [content] if isinstance(content, str) and content else []
500
482
  return _OrphanScanPlan(candidate_contents, set())
501
- all_edits = _edits_for_tool(tool_name, tool_input)
483
+ all_edits = edits_for_tool(tool_name, tool_input)
502
484
  existing_content = _read_existing_file_content(file_path)
503
485
  if existing_content is None:
504
486
  return _OrphanScanPlan(_edit_fragments(all_edits), set())
505
487
  baseline_missing = set(find_missing_filenames(existing_content, claude_md_directory))
506
- return _OrphanScanPlan([_apply_edits(existing_content, all_edits)], baseline_missing)
507
-
508
-
509
- def _edits_for_tool(tool_name: str, tool_input: dict) -> list[dict]:
510
- """Return the edit mappings an Edit or MultiEdit payload carries.
511
-
512
- Args:
513
- tool_name: The intercepted tool — ``Edit`` or ``MultiEdit``.
514
- tool_input: The tool's input payload.
515
-
516
- Returns:
517
- A single-element list holding the Edit payload, or the MultiEdit
518
- ``edits`` list when it is present as a list; an empty list otherwise.
519
- """
520
- if tool_name == "Edit":
521
- return [tool_input]
522
- all_edits = tool_input.get("edits", [])
523
- return all_edits if isinstance(all_edits, list) else []
488
+ return _OrphanScanPlan([apply_edits(existing_content, all_edits)], baseline_missing)
524
489
 
525
490
 
526
491
  def _collect_missing_filenames(scan_plan: _OrphanScanPlan, claude_md_directory: Path) -> list[str]:
@@ -623,6 +588,13 @@ def main() -> None:
623
588
  sys.exit(0)
624
589
 
625
590
  block_payload = _build_block_payload(missing_filenames, str(claude_md_directory))
591
+ log_hook_block(
592
+ calling_hook_name="claude_md_orphan_file_blocker.py",
593
+ hook_event="PreToolUse",
594
+ block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
595
+ tool_name=tool_name,
596
+ offending_input_preview=file_path,
597
+ )
626
598
  _emit_hook_result(block_payload, sys.stdout)
627
599
  sys.exit(0)
628
600
 
@@ -25,6 +25,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
25
25
  ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES,
26
26
  ALL_DOCSTRING_EXEMPT_DECORATOR_NAMES,
27
27
  ALL_DOCSTRING_FILE_REFERENCE_SUFFIXES,
28
+ ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES,
28
29
  ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES,
29
30
  ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES,
30
31
  ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES,
@@ -42,14 +43,17 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
42
43
  MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES,
43
44
  MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES,
44
45
  MAX_DOCSTRING_STEP_DISPATCH_ISSUES,
46
+ MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES,
45
47
  MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES,
46
48
  MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES,
49
+ MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES,
47
50
  MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES,
48
51
  MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK,
49
52
  MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER,
50
53
  MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
51
54
  MINIMUM_TOKENS_FOR_DISPATCH_CALLEE,
52
55
  MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION,
56
+ SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT,
53
57
  )
54
58
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
55
59
  ALL_CAPS_WITH_UNDERSCORE_PATTERN,
@@ -57,6 +61,7 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
57
61
  ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
58
62
  ALL_SELF_AND_CLS_PARAMETER_NAMES,
59
63
  DOCSTRING_ARG_ENTRY_PATTERN,
64
+ DOCSTRING_PLURAL_FAMILY_STOP_PATTERN,
60
65
  IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN,
61
66
  INLINE_CODE_TOKEN_PATTERN,
62
67
  )
@@ -641,6 +646,123 @@ def check_docstring_no_consumer_claim(content: str, file_path: str) -> list[str]
641
646
  return issues[:MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES]
642
647
 
643
648
 
649
+ def _docstring_claims_malformed_payload_is_guarded(docstring_text: str) -> str:
650
+ collapsed_docstring = " ".join(docstring_text.lower().split())
651
+ for each_phrase in ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES:
652
+ if each_phrase in collapsed_docstring:
653
+ return each_phrase
654
+ return ""
655
+
656
+
657
+ def _try_handler_returns_none(try_node: ast.Try) -> bool:
658
+ for each_handler in try_node.handlers:
659
+ for each_statement in each_handler.body:
660
+ if isinstance(each_statement, ast.Return) and isinstance(
661
+ each_statement.value, ast.Constant
662
+ ):
663
+ if each_statement.value.value is None:
664
+ return True
665
+ return False
666
+
667
+
668
+ def _names_bound_in_try_body(try_node: ast.Try) -> set[str]:
669
+ all_bound_names: set[str] = set()
670
+ for each_statement in try_node.body:
671
+ for each_descendant in ast.walk(each_statement):
672
+ if isinstance(each_descendant, ast.Name) and isinstance(
673
+ each_descendant.ctx, ast.Store
674
+ ):
675
+ all_bound_names.add(each_descendant.id)
676
+ return all_bound_names
677
+
678
+
679
+ def _statement_subscripts_one_of(
680
+ statement: ast.stmt, all_payload_names: set[str]
681
+ ) -> bool:
682
+ for each_descendant in ast.walk(statement):
683
+ if (
684
+ isinstance(each_descendant, ast.Subscript)
685
+ and isinstance(each_descendant.value, ast.Name)
686
+ and each_descendant.value.id in all_payload_names
687
+ ):
688
+ return True
689
+ return False
690
+
691
+
692
+ def _function_has_unguarded_payload_dereference(
693
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
694
+ ) -> bool:
695
+ all_payload_names: set[str] = set()
696
+ saw_returning_guard = False
697
+ for each_statement in function_node.body:
698
+ if isinstance(each_statement, ast.Try) and _try_handler_returns_none(
699
+ each_statement
700
+ ):
701
+ all_payload_names |= _names_bound_in_try_body(each_statement)
702
+ saw_returning_guard = True
703
+ continue
704
+ if not saw_returning_guard:
705
+ continue
706
+ if _statement_subscripts_one_of(each_statement, all_payload_names):
707
+ return True
708
+ return False
709
+
710
+
711
+ def check_docstring_unguarded_malformed_payload_claim(
712
+ content: str, file_path: str
713
+ ) -> list[str]:
714
+ """Flag a docstring that promises malformed-payload safety the guard misses.
715
+
716
+ The drift this catches: a function whose docstring states that a malformed
717
+ payload "resolves to None" while a subscript dereference of that payload
718
+ (``payload["key"]``, ``float(payload["key"])``) sits OUTSIDE the try/except
719
+ whose handler returns None. A present-but-malformed payload then raises
720
+ KeyError or TypeError from that unguarded access and propagates rather than
721
+ resolving to None, so the docstring overstates the protection. This is the
722
+ deterministic slice of Category O6 (docstring prose vs implementation drift)
723
+ for an exception-guard claim: move the dereference inside the guarded block,
724
+ or narrow the docstring to the failures the guard actually catches.
725
+
726
+ Args:
727
+ content: The source text to inspect.
728
+ file_path: The path the source will be written to, used for exemptions.
729
+
730
+ Returns:
731
+ One issue per function whose malformed-payload claim outruns its guard,
732
+ capped at the module limit.
733
+ """
734
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
735
+ return []
736
+ try:
737
+ parsed_tree = ast.parse(content)
738
+ except SyntaxError:
739
+ return []
740
+ issues: list[str] = []
741
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
742
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
743
+ continue
744
+ if _function_has_exempt_decorator(each_node):
745
+ continue
746
+ docstring_text = _function_docstring_text(each_node)
747
+ if not docstring_text:
748
+ continue
749
+ matched_phrase = _docstring_claims_malformed_payload_is_guarded(docstring_text)
750
+ if not matched_phrase:
751
+ continue
752
+ if not _function_has_unguarded_payload_dereference(each_node):
753
+ continue
754
+ issues.append(
755
+ f"Line {each_node.lineno}: {each_node.name}() docstring claims "
756
+ f"'{matched_phrase}' but a payload subscript sits outside the try/except that "
757
+ "returns None — a malformed-but-present payload raises rather than resolving to "
758
+ "None; move the dereference inside the guard or narrow the docstring "
759
+ "(Category O6 docstring-vs-implementation drift)"
760
+ )
761
+ if len(issues) >= MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES:
762
+ break
763
+ return issues[:MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES]
764
+
765
+
644
766
  def _module_docstring_claims_no_inline_literal(module_docstring: str) -> str:
645
767
  collapsed_docstring = " ".join(module_docstring.lower().split())
646
768
  for each_phrase in ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES:
@@ -888,6 +1010,107 @@ def check_docstring_tuple_enumeration_match(content: str, file_path: str) -> lis
888
1010
  return issues[:MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES]
889
1011
 
890
1012
 
1013
+ def _returns_section_text(docstring_text: str) -> str:
1014
+ docstring_lines = docstring_text.splitlines()
1015
+ returns_section_lines: list[str] = []
1016
+ inside_returns_section = False
1017
+ for each_line in docstring_lines:
1018
+ stripped_line = each_line.strip()
1019
+ if stripped_line in ("Returns:", "Yields:"):
1020
+ inside_returns_section = True
1021
+ continue
1022
+ if not inside_returns_section:
1023
+ continue
1024
+ if _is_docstring_terminating_section_header(stripped_line):
1025
+ break
1026
+ returns_section_lines.append(stripped_line)
1027
+ return " ".join(returns_section_lines)
1028
+
1029
+
1030
+ def _plural_families_in_returns_section(returns_section_text: str) -> set[str]:
1031
+ return {
1032
+ each_match.group(1)
1033
+ for each_match in DOCSTRING_PLURAL_FAMILY_STOP_PATTERN.finditer(returns_section_text)
1034
+ }
1035
+
1036
+
1037
+ def _returned_dict_key_names(
1038
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
1039
+ ) -> set[str]:
1040
+ all_key_names: set[str] = set()
1041
+ for each_node in ast.walk(function_node):
1042
+ if not isinstance(each_node, ast.Return):
1043
+ continue
1044
+ if not isinstance(each_node.value, ast.Dict):
1045
+ continue
1046
+ for each_key in each_node.value.keys:
1047
+ if isinstance(each_key, ast.Constant) and isinstance(each_key.value, str):
1048
+ all_key_names.add(each_key.value)
1049
+ return all_key_names
1050
+
1051
+
1052
+ def _family_prefixed_key_count(family: str, all_key_names: set[str]) -> int:
1053
+ family_prefix = f"{family}_"
1054
+ return sum(1 for each_key in all_key_names if each_key.startswith(family_prefix))
1055
+
1056
+
1057
+ def check_docstring_returns_plural_cardinality(content: str, file_path: str) -> list[str]:
1058
+ """Flag a Returns clause plural noun that names one dict key in its family.
1059
+
1060
+ The drift this catches: a function returns a dict literal whose keys carry
1061
+ prefix families (``sheen_mid``, ``body_highlight``), and its Returns clause
1062
+ names one family with a plural noun (``the sheen stops``) while exactly one
1063
+ key in that family exists. The plural prose claims two or more entries the
1064
+ dict no longer holds — the shape that appears when a producer removes the
1065
+ second key in a family but leaves the plural prose untouched. The check binds
1066
+ only when the plural family prefixes exactly one returned dict key, so a
1067
+ singular noun, a family with two or more keys, and a family absent from the
1068
+ dict are all left alone. This is the deterministic single-key slice of
1069
+ Category O6 docstring-prose-vs-implementation drift.
1070
+
1071
+ Args:
1072
+ content: The source text to inspect.
1073
+ file_path: The path the source will be written to, used for exemptions.
1074
+
1075
+ Returns:
1076
+ One issue per function whose Returns clause names a plural family that
1077
+ prefixes a single returned dict key, capped at the module limit.
1078
+ """
1079
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
1080
+ return []
1081
+ try:
1082
+ parsed_tree = ast.parse(content)
1083
+ except SyntaxError:
1084
+ return []
1085
+ issues: list[str] = []
1086
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1087
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1088
+ continue
1089
+ docstring_text = _function_docstring_text(each_node)
1090
+ if not docstring_text:
1091
+ continue
1092
+ returns_section_text = _returns_section_text(docstring_text)
1093
+ if not returns_section_text:
1094
+ continue
1095
+ plural_families = _plural_families_in_returns_section(returns_section_text)
1096
+ if not plural_families:
1097
+ continue
1098
+ all_key_names = _returned_dict_key_names(each_node)
1099
+ for each_family in sorted(plural_families):
1100
+ matching_key_count = _family_prefixed_key_count(each_family, all_key_names)
1101
+ if matching_key_count != SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT:
1102
+ continue
1103
+ issues.append(
1104
+ f"Line {each_node.lineno}: {each_node.name}() Returns clause says "
1105
+ f"'the {each_family} stops' (plural) but the returned dict holds a "
1106
+ f"single {each_family}_ key — match the noun to the cardinality "
1107
+ "(Category O6 docstring-vs-implementation drift)"
1108
+ )
1109
+ if len(issues) >= MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES:
1110
+ return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
1111
+ return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
1112
+
1113
+
891
1114
  def _called_callee_name(statement: ast.stmt) -> str:
892
1115
  candidate_expression: ast.expr | None = None
893
1116
  if isinstance(statement, ast.Expr):
@@ -72,8 +72,10 @@ from code_rules_docstrings import ( # noqa: E402
72
72
  check_docstring_names_undefined_constant,
73
73
  check_docstring_no_consumer_claim,
74
74
  check_docstring_no_inline_literal_claim,
75
+ check_docstring_returns_plural_cardinality,
75
76
  check_docstring_step_enumeration_dispatch_coverage,
76
77
  check_docstring_tuple_enumeration_match,
78
+ check_docstring_unguarded_malformed_payload_claim,
77
79
  check_module_docstring_names_public_checks,
78
80
  )
79
81
  from code_rules_duplicate_body import ( # noqa: E402
@@ -159,6 +161,7 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
159
161
  PRECHECK_USAGE_EXIT_CODE,
160
162
  PRECHECK_USAGE_MESSAGE,
161
163
  )
164
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
162
165
  from hooks_constants.setup_project_paths_constants import ( # noqa: E402
163
166
  UTF8_BYTE_ORDER_MARK,
164
167
  )
@@ -260,6 +263,11 @@ def validate_content(
260
263
  all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
261
264
  all_issues.extend(check_docstring_fallback_branch_coverage(effective_content, file_path))
262
265
  all_issues.extend(check_docstring_no_consumer_claim(effective_content, file_path))
266
+ all_issues.extend(
267
+ check_docstring_unguarded_malformed_payload_claim(
268
+ effective_content, file_path
269
+ )
270
+ )
263
271
  all_issues.extend(
264
272
  check_docstring_no_inline_literal_claim(effective_content, file_path)
265
273
  )
@@ -277,6 +285,9 @@ def validate_content(
277
285
  effective_content, file_path
278
286
  )
279
287
  )
288
+ all_issues.extend(
289
+ check_docstring_returns_plural_cardinality(effective_content, file_path)
290
+ )
280
291
  all_issues.extend(
281
292
  check_docstring_names_undefined_constant(effective_content, file_path)
282
293
  )
@@ -783,6 +794,11 @@ def _write_deny_payload(deny_reason: str, deny_stream: TextIO) -> None:
783
794
  "permissionDecisionReason": deny_reason,
784
795
  }
785
796
  }
797
+ log_hook_block(
798
+ calling_hook_name="code_rules_enforcer.py",
799
+ hook_event="PreToolUse",
800
+ block_reason=deny_reason,
801
+ )
786
802
  deny_stream.write(json.dumps(deny_payload) + "\n")
787
803
  deny_stream.flush()
788
804
 
@@ -25,14 +25,14 @@ import sys
25
25
  from pathlib import Path
26
26
  from typing import TextIO
27
27
 
28
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
29
- if _hooks_dir not in sys.path:
30
- sys.path.insert(0, _hooks_dir)
31
-
32
28
  _blocking_dir = str(Path(__file__).resolve().parent)
33
29
  if _blocking_dir not in sys.path:
34
30
  sys.path.insert(0, _blocking_dir)
35
31
 
32
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
33
+ if _hooks_dir not in sys.path:
34
+ sys.path.append(_hooks_dir)
35
+
36
36
  from verification_verdict_store import ( # noqa: E402
37
37
  candidate_base_references,
38
38
  resolve_merge_base,
@@ -54,6 +54,7 @@ from hooks_constants.code_verifier_spawn_preflight_gate_constants import ( # no
54
54
  MERGE_TREE_CONFLICT_EXIT_CODE,
55
55
  MERGE_TREE_TIMEOUT_SECONDS,
56
56
  )
57
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
57
58
  from hooks_constants.pr_converge_bugteam_enforcer_constants import ( # noqa: E402
58
59
  AGENT_TOOL_NAME,
59
60
  )
@@ -370,6 +371,11 @@ def _emit_deny_payload(output_stream: TextIO, reason: str) -> None:
370
371
  "permissionDecisionReason": reason,
371
372
  }
372
373
  }
374
+ log_hook_block(
375
+ calling_hook_name="code_verifier_spawn_preflight_gate.py",
376
+ hook_event="PreToolUse",
377
+ block_reason=reason,
378
+ )
373
379
  output_stream.write(json.dumps(deny_payload) + "\n")
374
380
  output_stream.flush()
375
381
 
@@ -14,6 +14,13 @@ import subprocess
14
14
  import sys
15
15
  from pathlib import Path
16
16
 
17
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
18
+ if _hooks_dir not in sys.path:
19
+ sys.path.insert(0, _hooks_dir)
20
+
21
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
22
+
23
+
17
24
  def _resolve_pr_number(command: str, cwd: str | None) -> int | None:
18
25
  direct_match = re.search(r"\bgh\s+pr\s+ready\s+(\d+)", command)
19
26
  if direct_match:
@@ -112,15 +119,22 @@ def main() -> None:
112
119
  if completed_process.returncode in (0, 2):
113
120
  sys.exit(0)
114
121
 
122
+ block_reason = (
123
+ "Convergence check failed — PR is not ready to mark ready:\n\n" + completed_process.stdout
124
+ )
115
125
  deny_payload = {
116
126
  "hookSpecificOutput": {
117
127
  "hookEventName": "PreToolUse",
118
128
  "permissionDecision": "deny",
119
- "permissionDecisionReason": (
120
- "Convergence check failed — PR is not ready to mark ready:\n\n" + completed_process.stdout
121
- ),
129
+ "permissionDecisionReason": block_reason,
122
130
  }
123
131
  }
132
+ log_hook_block(
133
+ calling_hook_name="convergence_gate_blocker.py",
134
+ hook_event="PreToolUse",
135
+ block_reason=block_reason,
136
+ tool_name="Bash",
137
+ )
124
138
  print(json.dumps(deny_payload))
125
139
  sys.stdout.flush()
126
140
  sys.exit(0)
@@ -19,6 +19,7 @@ from hooks_constants.convergence_branch_constants import ( # noqa: E402
19
19
  CONVERGENCE_BRANCH_SUFFIX_PATTERN,
20
20
  CONVERGENCE_FORCE_PUSH_DETECTION_PATTERN,
21
21
  )
22
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
22
23
  from hooks_constants.destructive_command_segment_constants import ( # noqa: E402
23
24
  ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS,
24
25
  ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS,
@@ -197,6 +198,12 @@ def _build_silent_gh_deny_response(matched_description: str) -> dict:
197
198
  "Bash call prevents duplicate execution."
198
199
  )
199
200
  _append_destructive_gate_log_entry(brief_label, full_reason)
201
+ log_hook_block(
202
+ calling_hook_name="destructive_command_blocker.py",
203
+ hook_event="PreToolUse",
204
+ block_reason=full_reason,
205
+ tool_name="Bash",
206
+ )
200
207
  return {
201
208
  "hookSpecificOutput": {
202
209
  "hookEventName": "PreToolUse",