claude-dev-env 2.27.0 → 2.28.1

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 (109) hide show
  1. package/.agents/skills/hitl/SKILL.md +45 -0
  2. package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
  3. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +16 -29
  4. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
  5. package/bin/ever-shipped-skills.mjs +1 -0
  6. package/docs/CODE_RULES.md +3 -3
  7. package/docs/agent-spawn-protocol.md +1 -1
  8. package/docs/references/prose-style-enforcement.md +5 -9
  9. package/hooks/blocking/AGENTS.md +3 -13
  10. package/hooks/blocking/code_rules_docstrings.py +10 -2138
  11. package/hooks/blocking/code_rules_enforcer.py +0 -121
  12. package/hooks/blocking/code_rules_imports_logging.py +1 -236
  13. package/hooks/blocking/code_rules_shared.py +23 -0
  14. package/hooks/blocking/code_rules_test_layout.py +8 -8
  15. package/hooks/blocking/config/prose_style_enforcement_constants.py +4 -2
  16. package/hooks/blocking/config/test_prose_style_enforcement_constants.py +5 -1
  17. package/hooks/blocking/precommit_code_rules_gate.py +8 -43
  18. package/hooks/blocking/state_description_blocker.py +1 -7
  19. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
  20. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +0 -8
  21. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +11 -112
  22. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +0 -1
  23. package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +1 -18
  24. package/hooks/blocking/test_code_rules_shared.py +12 -0
  25. package/hooks/blocking/test_precommit_code_rules_gate.py +32 -179
  26. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -1
  27. package/hooks/blocking/test_state_description_blocker.py +6 -4
  28. package/hooks/blocking/test_stop_dispatcher.py +5 -29
  29. package/hooks/hooks_constants/AGENTS.md +1 -6
  30. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -1
  31. package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
  32. package/hooks/hooks_constants/messages.py +0 -2
  33. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +3 -17
  34. package/hooks/hooks_constants/stop_dispatcher_constants.py +0 -2
  35. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +0 -1
  36. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +7 -0
  37. package/hooks/hooks_constants/test_messages.py +5 -3
  38. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +0 -2
  39. package/package.json +1 -1
  40. package/rules/AGENTS.md +3 -6
  41. package/rules/claims-as-quotes.md +0 -10
  42. package/rules/code-standards.md +6 -6
  43. package/rules/explore-thoroughly.md +0 -1
  44. package/rules/failure-blast-radius.md +0 -8
  45. package/rules/falsify-before-green.md +0 -8
  46. package/rules/file-global-constants.md +2 -2
  47. package/rules/filesystem-search.md +1 -1
  48. package/rules/git-workflow.md +1 -9
  49. package/rules/hedging-claims.md +2 -6
  50. package/rules/long-horizon-autonomy.md +1 -1
  51. package/rules/measurement-denominators.md +0 -9
  52. package/rules/research-mode.md +0 -6
  53. package/rules/verify-before-asking.md +0 -5
  54. package/rules/verify-runtime-state.md +0 -5
  55. package/scripts/codex_compat_materializer.py +99 -24
  56. package/scripts/sync_to_cursor/rules.py +0 -10
  57. package/scripts/tests/test_codex_compat_materializer.py +70 -19
  58. package/scripts/tests/test_engine.py +0 -1
  59. package/scripts/tests/test_rules.py +0 -1
  60. package/scripts/tests/test_sync_to_cursor.py +0 -1
  61. package/system-prompts/software-engineer.xml +3 -3
  62. package/hooks/blocking/code_rules_dead_argparse_argument.py +0 -554
  63. package/hooks/blocking/code_rules_dead_config_field.py +0 -568
  64. package/hooks/blocking/code_rules_dead_dataclass_field.py +0 -348
  65. package/hooks/blocking/code_rules_dead_module_constant.py +0 -757
  66. package/hooks/blocking/code_rules_dead_split_branch.py +0 -225
  67. package/hooks/blocking/code_rules_mock_completeness.py +0 -295
  68. package/hooks/blocking/code_rules_scope_binding.py +0 -151
  69. package/hooks/blocking/code_rules_unused_imports.py +0 -197
  70. package/hooks/blocking/hedging_language_blocker.py +0 -221
  71. package/hooks/blocking/intent_only_ending_blocker.py +0 -148
  72. package/hooks/blocking/test_code_rules_enforcer_dead_argparse_argument.py +0 -534
  73. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +0 -846
  74. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +0 -507
  75. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +0 -679
  76. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_alias.py +0 -133
  77. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +0 -103
  78. package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +0 -105
  79. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +0 -425
  80. package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +0 -176
  81. package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +0 -385
  82. package/hooks/blocking/test_code_rules_enforcer_docstring_fallback_branch.py +0 -398
  83. package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +0 -129
  84. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +0 -93
  85. package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +0 -198
  86. package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +0 -262
  87. package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +0 -93
  88. package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +0 -115
  89. package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +0 -226
  90. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +0 -207
  91. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +0 -262
  92. package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +0 -164
  93. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +0 -188
  94. package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +0 -157
  95. package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +0 -303
  96. package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +0 -111
  97. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +0 -656
  98. package/hooks/blocking/test_hedging_language_blocker.py +0 -261
  99. package/hooks/blocking/test_intent_only_ending_blocker.py +0 -209
  100. package/hooks/hooks_constants/dead_argparse_argument_constants.py +0 -28
  101. package/hooks/hooks_constants/dead_config_field_constants.py +0 -39
  102. package/hooks/hooks_constants/dead_dataclass_field_constants.py +0 -25
  103. package/hooks/hooks_constants/dead_module_constant_constants.py +0 -30
  104. package/hooks/hooks_constants/hedging_uncertainty_constants.py +0 -42
  105. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +0 -44
  106. package/rules/conservative-action.md +0 -17
  107. package/rules/context7.md +0 -8
  108. package/rules/few-words.md +0 -3
  109. package/rules/parallel-tools.md +0 -23
@@ -1,197 +0,0 @@
1
- """Unused module-level import check and its import-range helpers."""
2
-
3
- import ast
4
- import sys
5
- from pathlib import Path
6
-
7
- _blocking_directory = str(Path(__file__).resolve().parent)
8
- _hooks_directory = str(Path(__file__).resolve().parent.parent)
9
- if _blocking_directory not in sys.path:
10
- sys.path.insert(0, _blocking_directory)
11
- if _hooks_directory not in sys.path:
12
- sys.path.insert(0, _hooks_directory)
13
-
14
- from code_rules_scope_binding import ( # noqa: E402
15
- _attribute_root_name_if_loaded,
16
- _collect_string_annotation_names,
17
- _load_name_is_shadowed,
18
- )
19
- from code_rules_shared import ( # noqa: E402
20
- _build_parent_map,
21
- is_migration_file,
22
- )
23
-
24
- from hooks_constants.unused_module_import_constants import ( # noqa: E402
25
- MAX_UNUSED_IMPORT_ISSUES,
26
- UNUSED_IMPORT_GUIDANCE,
27
- line_suppresses_unused_import_via_noqa,
28
- )
29
-
30
-
31
- def _import_alias_pairs(
32
- import_node: ast.Import | ast.ImportFrom,
33
- ) -> list[tuple[str, int, int | None]]:
34
- """Return (binding_name, alias_line, from_keyword_line) for each name introduced.
35
-
36
- The from-keyword line is None for plain `import X` statements; for
37
- `from X import (...)` it carries the line of the `from` keyword so
38
- callers can honor a `# noqa` placed on the opening line of a
39
- multi-line import block.
40
- """
41
- bindings: list[tuple[str, int, int | None]] = []
42
- from_keyword_line = import_node.lineno if isinstance(import_node, ast.ImportFrom) else None
43
- for each_alias in import_node.names:
44
- if each_alias.name == "*":
45
- continue
46
- binding_name = each_alias.asname if each_alias.asname else each_alias.name.split(".")[0]
47
- alias_line = each_alias.lineno or import_node.lineno
48
- bindings.append((binding_name, alias_line, from_keyword_line))
49
- return bindings
50
-
51
-
52
- def _import_statement_line_ranges(tree: ast.Module) -> list[tuple[int, int]]:
53
- ranges: list[tuple[int, int]] = []
54
- for each_node in tree.body:
55
- if isinstance(each_node, (ast.Import, ast.ImportFrom)):
56
- start_line = each_node.lineno
57
- end_line = each_node.end_lineno or each_node.lineno
58
- ranges.append((start_line, end_line))
59
- return ranges
60
-
61
-
62
- def _line_number_falls_in_import_ranges(
63
- line_number: int,
64
- all_import_line_ranges: list[tuple[int, int]],
65
- ) -> bool:
66
- for each_start, each_end in all_import_line_ranges:
67
- if each_start <= line_number <= each_end:
68
- return True
69
- return False
70
-
71
-
72
- def _collect_load_names_outside_import_ranges(
73
- tree: ast.Module,
74
- all_import_line_ranges: list[tuple[int, int]],
75
- ) -> set[str]:
76
- parent_by_node_id = _build_parent_map(tree)
77
- referenced_names: set[str] = set()
78
- for each_node in ast.walk(tree):
79
- if isinstance(each_node, ast.Name) and isinstance(each_node.ctx, ast.Load):
80
- line_number = each_node.lineno
81
- if line_number is None or _line_number_falls_in_import_ranges(
82
- line_number,
83
- all_import_line_ranges,
84
- ):
85
- continue
86
- if _load_name_is_shadowed(each_node, each_node.id, parent_by_node_id):
87
- continue
88
- referenced_names.add(each_node.id)
89
- elif isinstance(each_node, ast.Attribute) and isinstance(
90
- each_node.ctx, ast.Load
91
- ):
92
- line_number = each_node.lineno
93
- if line_number is None or _line_number_falls_in_import_ranges(
94
- line_number,
95
- all_import_line_ranges,
96
- ):
97
- continue
98
- root_name = _attribute_root_name_if_loaded(each_node)
99
- if root_name is not None and not _load_name_is_shadowed(
100
- root_name,
101
- root_name.id,
102
- parent_by_node_id,
103
- ):
104
- referenced_names.add(root_name.id)
105
- referenced_names.update(_collect_string_annotation_names(tree))
106
- return referenced_names
107
-
108
-
109
- def _module_declares_dunder_all(tree: ast.Module) -> bool:
110
- """Return True when the module body assigns or annotates ``__all__``."""
111
- return any(
112
- (
113
- isinstance(each_node, ast.Assign)
114
- and any(
115
- isinstance(each_target, ast.Name) and each_target.id == "__all__"
116
- for each_target in each_node.targets
117
- )
118
- )
119
- or (
120
- isinstance(each_node, ast.AnnAssign)
121
- and isinstance(each_node.target, ast.Name)
122
- and each_node.target.id == "__all__"
123
- )
124
- for each_node in tree.body
125
- )
126
-
127
-
128
- def check_unused_module_level_imports(
129
- content: str,
130
- file_path: str,
131
- full_file_content: str | None = None,
132
- ) -> list[str]:
133
- """Flag module-level imports that are never referenced in the rest of the file.
134
-
135
- References are detected from AST ``Name`` / ``Attribute`` loads outside import
136
- statements so mentions in comments or string literals do not count. Files
137
- declaring ``__all__`` (including annotated assignments) are skipped. A
138
- ``if TYPE_CHECKING:`` block does not exempt the file: its guarded imports
139
- are nested inside the ``If`` node and are never scanned as top-level
140
- bindings, while a dead top-level runtime import in the same file is still
141
- flagged. Suppression honors bare ``# noqa`` or an explicit ``F401`` code in
142
- the noqa list only.
143
-
144
- When ``full_file_content`` is provided, ``content`` is treated as an Edit
145
- fragment containing the imports being added or replaced, while the
146
- ``__all__`` gate detection and reference scanning run
147
- against ``full_file_content`` (the post-edit file as it will look once the
148
- Edit applies). This prevents false-positive flags on imports added in the
149
- same Edit as their consumers.
150
- """
151
- if is_migration_file(file_path):
152
- return []
153
- try:
154
- fragment_tree = ast.parse(content)
155
- except SyntaxError:
156
- return []
157
- reference_source = full_file_content if full_file_content is not None else content
158
- try:
159
- reference_tree = ast.parse(reference_source)
160
- except SyntaxError:
161
- return []
162
- if _module_declares_dunder_all(reference_tree):
163
- return []
164
- fragment_lines = content.splitlines()
165
- reference_import_ranges = _import_statement_line_ranges(reference_tree)
166
- referenced_names = _collect_load_names_outside_import_ranges(
167
- reference_tree,
168
- reference_import_ranges,
169
- )
170
- import_bindings: list[tuple[str, int, int | None]] = []
171
- for each_node in fragment_tree.body:
172
- if isinstance(each_node, (ast.Import, ast.ImportFrom)):
173
- if isinstance(each_node, ast.ImportFrom) and each_node.module == "__future__":
174
- continue
175
- for each_binding in _import_alias_pairs(each_node):
176
- import_bindings.append(each_binding)
177
- issues: list[str] = []
178
- for each_name, each_line_number, each_from_keyword_line in import_bindings:
179
- if 1 <= each_line_number <= len(fragment_lines):
180
- if line_suppresses_unused_import_via_noqa(fragment_lines[each_line_number - 1]):
181
- continue
182
- if each_from_keyword_line is not None and 1 <= each_from_keyword_line <= len(
183
- fragment_lines
184
- ):
185
- if line_suppresses_unused_import_via_noqa(
186
- fragment_lines[each_from_keyword_line - 1]
187
- ):
188
- continue
189
- if each_name in referenced_names:
190
- continue
191
- issues.append(
192
- f"Line {each_line_number}: unused module-level import {each_name!r}"
193
- f" — {UNUSED_IMPORT_GUIDANCE}"
194
- )
195
- if len(issues) >= MAX_UNUSED_IMPORT_ISSUES:
196
- break
197
- return issues
@@ -1,221 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Stop hook for bare hedging words that mark unverified claims.
3
-
4
- Words like "likely" or "probably" stand in for evidence. When prose-style
5
- enforcement is on, a hedge word blocks the response unless the same sentence
6
- carries an explicit uncertainty label (unverified, I don't know, no source for
7
- this claim). A label in another sentence does not exempt a bare hedge.
8
-
9
- ::
10
-
11
- pass: This claim is unverified; the deploy is probably blocked.
12
- flag: This claim is unverified. The deploy is probably blocked.
13
-
14
- When enforcement is off, surviving bare hedges emit privacy-safe advisory
15
- candidates (OP-07B). See rules/hedging-claims.md and
16
- docs/references/prose-style-enforcement.md.
17
- """
18
-
19
- import json
20
- import os
21
- import re
22
- import sys
23
- from pathlib import Path
24
-
25
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
26
- if _hooks_dir not in sys.path:
27
- sys.path.insert(0, _hooks_dir)
28
-
29
- from blocking.config.prose_style_enforcement_constants import ( # noqa: E402
30
- prose_style_enforcement_enabled_in_environment,
31
- )
32
- from hooks_constants.hedging_uncertainty_constants import ( # noqa: E402
33
- ALL_EXPLICIT_UNCERTAINTY_LABEL_PATTERNS,
34
- BLOCK_REASON_PREFIX,
35
- HEDGING_TERM_LIST_SEPARATOR,
36
- POSITIVE_CORRECTIVE_GUIDANCE,
37
- SENTENCE_BOUNDARY_PATTERN,
38
- )
39
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
40
- from hooks_constants.messages import USER_FACING_NOTICE # noqa: E402
41
- from hooks_constants.prose_matcher_precision_constants import ( # noqa: E402
42
- ADVISORY_CONTEXT_SNIPPET_MAX_CHARS,
43
- MATCHER_ID_HEDGING_WORD,
44
- MAXIMUM_ADVISORY_EMITS_PER_CALL,
45
- )
46
- from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
47
- from observability.prose_matcher_advisory import emit_advisory_candidate # noqa: E402
48
-
49
- PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
50
-
51
- RESEARCH_MODE_SKILL_SEARCH_PATHS = [
52
- os.path.join(PLUGIN_ROOT, ".agents", "skills", "research-mode", "SKILL.md"),
53
- os.path.join(PLUGIN_ROOT, "skills", "research-mode", "SKILL.md"),
54
- os.path.join(os.path.expanduser("~"), ".claude", "skills", "research-mode", "SKILL.md"),
55
- os.path.join(os.path.expanduser("~"), ".claude", "plugins", "marketplaces", "claude-deep-research", "skills", "research-mode", "SKILL.md"),
56
- ]
57
-
58
- HEDGING_WORDS = [
59
- r"\blikely\b",
60
- r"\bunlikely\b",
61
- r"\bprobably\b",
62
- r"\bprobable\b",
63
- r"\bpresumably\b",
64
- r"\bperhaps\b",
65
- r"\bpossibly\b",
66
- r"\bseemingly\b",
67
- r"\bapparently\b",
68
- r"\barguably\b",
69
- r"\bsupposedly\b",
70
- r"\bostensibly\b",
71
- r"\bconceivably\b",
72
- r"\bplausibly\b",
73
- ]
74
-
75
- HEDGING_PHRASES = [
76
- r"\bmight be\b",
77
- r"\bcould be\b",
78
- r"\bseems to be\b",
79
- r"\bappears to be\b",
80
- r"\bin all likelihood\b",
81
- r"\bmore likely than not\b",
82
- r"\bit.s possible that\b",
83
- ]
84
-
85
- ALL_HEDGING_PATTERNS = [
86
- re.compile(pattern, re.IGNORECASE) for pattern in HEDGING_WORDS + HEDGING_PHRASES
87
- ]
88
-
89
-
90
- def _sentence_carries_explicit_uncertainty(sentence: str) -> bool:
91
- """Return True when the sentence labels the claim as unknown or unverified."""
92
- for each_label_pattern in ALL_EXPLICIT_UNCERTAINTY_LABEL_PATTERNS:
93
- if each_label_pattern.search(sentence):
94
- return True
95
- return False
96
-
97
-
98
- def _hedging_terms_in_prose_slice(prose_slice: str) -> list[str]:
99
- """Return unique hedge terms found in one prose slice (sentence or full text)."""
100
- matched_terms: list[str] = []
101
- for each_pattern in ALL_HEDGING_PATTERNS:
102
- for each_match in each_pattern.findall(prose_slice):
103
- normalized_term = each_match.strip().lower()
104
- if normalized_term not in matched_terms:
105
- matched_terms.append(normalized_term)
106
- return matched_terms
107
-
108
-
109
- def find_hedging_words(text: str) -> list[str]:
110
- """Return all hedging words/phrases in the stripped prose (any sentence)."""
111
- prose_text = strip_code_and_quotes(text)
112
- return _hedging_terms_in_prose_slice(prose_text)
113
-
114
-
115
- def find_blocking_hedging_terms(text: str) -> list[str]:
116
- """Return hedge terms that lack an explicit uncertainty label in-sentence.
117
-
118
- ::
119
-
120
- find_blocking_hedging_terms("Unverified: the port is probably down.")
121
- -> []
122
- find_blocking_hedging_terms("Unverified. The port is probably down.")
123
- -> ["probably"]
124
-
125
- Args:
126
- text: Full assistant message, including code and quotes.
127
-
128
- Returns:
129
- Unique bare hedge terms that still warrant a block or advisory emit.
130
- """
131
- prose_text = strip_code_and_quotes(text)
132
- if not prose_text.strip():
133
- return []
134
-
135
- all_sentences = SENTENCE_BOUNDARY_PATTERN.split(prose_text)
136
- if len(all_sentences) <= 1:
137
- all_sentences = [prose_text]
138
-
139
- matched_terms: list[str] = []
140
- for each_sentence in all_sentences:
141
- if not each_sentence.strip():
142
- continue
143
- if _sentence_carries_explicit_uncertainty(each_sentence):
144
- continue
145
- for each_term in _hedging_terms_in_prose_slice(each_sentence):
146
- if each_term not in matched_terms:
147
- matched_terms.append(each_term)
148
- return matched_terms
149
-
150
-
151
- def main() -> None:
152
- try:
153
- hook_input = json.load(sys.stdin)
154
- except json.JSONDecodeError:
155
- sys.exit(0)
156
-
157
- if hook_input.get("stop_hook_active", False):
158
- sys.exit(0)
159
-
160
- assistant_message = hook_input.get("last_assistant_message", "")
161
-
162
- if not assistant_message:
163
- sys.exit(0)
164
-
165
- found_hedging_terms = find_blocking_hedging_terms(assistant_message)
166
-
167
- if not found_hedging_terms:
168
- sys.exit(0)
169
-
170
- if not prose_style_enforcement_enabled_in_environment():
171
- try:
172
- for each_term in found_hedging_terms[:MAXIMUM_ADVISORY_EMITS_PER_CALL]:
173
- emit_advisory_candidate(
174
- MATCHER_ID_HEDGING_WORD,
175
- "Stop",
176
- f"{each_term}:{assistant_message[:ADVISORY_CONTEXT_SNIPPET_MAX_CHARS]}",
177
- )
178
- except (ImportError, OSError, TypeError, ValueError):
179
- pass
180
- sys.exit(0)
181
-
182
- formatted_term_list = HEDGING_TERM_LIST_SEPARATOR.join(
183
- f'"{term}"' for term in found_hedging_terms
184
- )
185
-
186
- resolved_skill_path: str | None = None
187
- for each_skill_path in RESEARCH_MODE_SKILL_SEARCH_PATHS:
188
- if os.path.exists(each_skill_path):
189
- resolved_skill_path = each_skill_path
190
- break
191
-
192
- if resolved_skill_path is not None:
193
- skill_reference = f"under the research-mode constraints defined in:\n\n{resolved_skill_path}"
194
- else:
195
- skill_reference = (
196
- "under research-mode constraints "
197
- "(no research-mode skill installed; verify with sources or prompt the user via AskUserQuestion with potential options + context)"
198
- )
199
-
200
- block_reason = (
201
- f"{BLOCK_REASON_PREFIX}{formatted_term_list}. "
202
- f"Rewrite {skill_reference}\n\n"
203
- f"{POSITIVE_CORRECTIVE_GUIDANCE}"
204
- )
205
- block_response = {
206
- "decision": "block",
207
- "reason": block_reason,
208
- "systemMessage": USER_FACING_NOTICE,
209
- "suppressOutput": True,
210
- }
211
- log_hook_block(
212
- calling_hook_name="hedging_language_blocker.py",
213
- hook_event="Stop",
214
- block_reason=block_reason,
215
- )
216
- print(json.dumps(block_response))
217
- sys.exit(0)
218
-
219
-
220
- if __name__ == "__main__":
221
- main()
@@ -1,148 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Stop hook that blocks a turn ending with a promise of future work.
3
-
4
- When a turn ends with a forward-looking statement of intent ("I'll now run the
5
- tests", "Let me implement the fix"), complete the work with tool calls during
6
- the turn. Route work requiring user-only input through AskUserQuestion. The
7
- rule name is long-horizon-autonomy.
8
- """
9
-
10
- import json
11
- import re
12
- import sys
13
- from pathlib import Path
14
-
15
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
16
- if _hooks_dir not in sys.path:
17
- sys.path.insert(0, _hooks_dir)
18
-
19
- from blocking.config.prose_style_enforcement_constants import ( # noqa: E402
20
- prose_style_enforcement_enabled_in_environment,
21
- )
22
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
23
- from hooks_constants.messages import USER_FACING_INTENT_ENDING_NOTICE # noqa: E402
24
- from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
25
-
26
-
27
- def extract_final_paragraph(text: str) -> str:
28
- """Return the last non-empty paragraph of the prose after stripping code and quotes.
29
-
30
- Args:
31
- text: The raw assistant message to extract the closing paragraph from.
32
- """
33
- paragraph_split_pattern = re.compile(r"\n\s*\n")
34
- prose_text = strip_code_and_quotes(text)
35
- candidate_paragraphs = [
36
- each_paragraph.strip()
37
- for each_paragraph in paragraph_split_pattern.split(prose_text)
38
- if each_paragraph.strip()
39
- ]
40
- if not candidate_paragraphs:
41
- return ""
42
- return candidate_paragraphs[-1]
43
-
44
-
45
- def extract_first_sentence(paragraph: str) -> str:
46
- """Return the first sentence of a paragraph.
47
-
48
- Args:
49
- paragraph: The paragraph whose leading sentence is needed.
50
- """
51
- sentence_boundary_pattern = re.compile(r"(?<=[.!?])\s+")
52
- sentences = sentence_boundary_pattern.split(paragraph.strip(), maxsplit=1)
53
- if not sentences:
54
- return ""
55
- return sentences[0].strip()
56
-
57
-
58
- def find_intent_only_ending(text: str) -> bool:
59
- """Return whether the final paragraph ends the turn on a promise about undone work.
60
-
61
- Args:
62
- text: The raw assistant message to evaluate.
63
- """
64
- future_intent_opener_pattern = re.compile(
65
- r"^(?:(?:now|next|then|okay|alright)\s*,?\s*)?"
66
- r"(?:i['’]ll(?:\s+now|\s+go\s+ahead\s+and|\s+proceed\s+to)?"
67
- r"|i\s+will"
68
- r"|i['’]m\s+going\s+to"
69
- r"|i\s+am\s+going\s+to"
70
- r"|i['’]m\s+about\s+to"
71
- r"|i\s+plan\s+to"
72
- r"|let\s+me"
73
- r"|let['’]s"
74
- r"|going\s+to)\b",
75
- re.IGNORECASE,
76
- )
77
- undone_work_verb_pattern = re.compile(
78
- r"\b(?:run|start|implement|create|write|add|fix|update|check|test|wire"
79
- r"|build|deploy|push|git\s+commit|commit\s+the\s+changes"
80
- r"|commit\s+the\s+fix|investigate|set\s+up|refactor|generate"
81
- r"|install|continue|look\s+into)\b",
82
- re.IGNORECASE,
83
- )
84
- next_steps_lead_in_pattern = re.compile(r"^next\s+steps?:", re.IGNORECASE)
85
- second_person_subject_pattern = re.compile(
86
- r"\b(?:you|your|you['’]?re|user['’]?s?)\b",
87
- re.IGNORECASE,
88
- )
89
-
90
- final_paragraph = extract_final_paragraph(text)
91
- if not final_paragraph:
92
- return False
93
-
94
- if next_steps_lead_in_pattern.match(final_paragraph):
95
- return not second_person_subject_pattern.search(final_paragraph)
96
-
97
- first_sentence = extract_first_sentence(final_paragraph)
98
- if not future_intent_opener_pattern.match(first_sentence):
99
- return False
100
-
101
- return bool(undone_work_verb_pattern.search(first_sentence))
102
-
103
-
104
- def main() -> None:
105
- """Read the stop-hook payload and block turns that promise future work."""
106
- try:
107
- hook_input = json.load(sys.stdin)
108
- except json.JSONDecodeError:
109
- sys.exit(0)
110
-
111
- if hook_input.get("stop_hook_active", False):
112
- sys.exit(0)
113
-
114
- if not prose_style_enforcement_enabled_in_environment():
115
- sys.exit(0)
116
-
117
- assistant_message = hook_input.get("last_assistant_message", "")
118
-
119
- if not assistant_message:
120
- sys.exit(0)
121
-
122
- if not find_intent_only_ending(assistant_message):
123
- sys.exit(0)
124
-
125
- block_reason = (
126
- "LONG-HORIZON-AUTONOMY GUARDRAIL: This turn ends with a promise of future "
127
- "work. Complete that work with tool calls during this turn. Route questions "
128
- "requiring user-only input through AskUserQuestion, then end the turn cleanly.\n\n"
129
- "Re-output the complete response with the work performed, per the "
130
- "long-horizon-autonomy rule."
131
- )
132
- block_response = {
133
- "decision": "block",
134
- "reason": block_reason,
135
- "systemMessage": USER_FACING_INTENT_ENDING_NOTICE,
136
- "suppressOutput": True,
137
- }
138
- log_hook_block(
139
- calling_hook_name="intent_only_ending_blocker.py",
140
- hook_event="Stop",
141
- block_reason=block_reason,
142
- )
143
- print(json.dumps(block_response))
144
- sys.exit(0)
145
-
146
-
147
- if __name__ == "__main__":
148
- main()