claude-dev-env 2.5.0 → 2.7.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.
- package/CLAUDE.md +20 -57
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -1
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
- package/agents/CLAUDE.md +1 -1
- package/agents/code-verifier.md +36 -7
- package/bin/codex-compat.mjs +104 -0
- package/bin/codex-compat.test.mjs +51 -0
- package/codex-capability-map.json +13 -0
- package/docs/CODE_RULES.md +2 -0
- package/docs/codex-compatibility.md +25 -0
- package/docs/nas-ssh-invocation.md +96 -12
- package/docs/references/code-review-enforcement.md +31 -6
- package/hooks/blocking/CLAUDE.md +3 -0
- package/hooks/blocking/config/code_review_enforcement_constants.py +40 -10
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +56 -3
- package/hooks/blocking/eli11_reply_enforcer.py +479 -0
- package/hooks/blocking/gh_body_arg_blocker.py +1 -1
- package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
- package/hooks/blocking/shell_substitution_blocker.py +129 -0
- package/hooks/blocking/state_description_blocker.py +1 -1
- package/hooks/blocking/stop_dispatcher.py +1 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
- package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
- package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
- package/hooks/blocking/test_stop_dispatcher.py +23 -0
- package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -8
- package/hooks/blocking/unscoped_search_blocker.py +391 -0
- package/hooks/git-hooks/CLAUDE.md +3 -0
- package/hooks/git-hooks/conftest.py +30 -0
- package/hooks/git-hooks/gate_utils.py +2 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
- package/hooks/git-hooks/pre_push.py +75 -4
- package/hooks/git-hooks/pre_push_base_reference.py +166 -0
- package/hooks/git-hooks/test_config.py +0 -15
- package/hooks/git-hooks/test_gate_utils.py +3 -15
- package/hooks/git-hooks/test_pre_commit.py +1 -15
- package/hooks/git-hooks/test_pre_push.py +236 -27
- package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
- package/hooks/hooks.json +0 -12
- package/hooks/hooks_constants/CLAUDE.md +5 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
- package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
- package/hooks/hooks_constants/shell_command_segments.py +82 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
- package/package.json +4 -2
- package/rules/CLAUDE.md +17 -23
- package/rules/agent-spawn-protocol.md +6 -6
- package/rules/anti-corollary-tests.md +1 -1
- package/rules/bdd.md +1 -1
- package/rules/cleanup-temp-files.md +10 -4
- package/rules/code-standards.md +7 -0
- package/rules/conservative-action.md +1 -5
- package/rules/context7.md +0 -4
- package/rules/destructive-commands.md +47 -0
- package/rules/doc-inventory-integrity.md +48 -0
- package/rules/doc-prose-cuts.md +58 -0
- package/rules/docstring-prose-matches-implementation.md +10 -2
- package/rules/durable-post-artifacts.md +0 -4
- package/rules/eli11-replies.md +31 -0
- package/rules/explore-thoroughly.md +4 -4
- package/rules/falsify-before-green.md +68 -0
- package/rules/file-global-constants.md +1 -1
- package/rules/filesystem-search.md +51 -0
- package/rules/gh-cli-conventions.md +27 -0
- package/rules/git-workflow.md +26 -0
- package/rules/hedging-claims.md +9 -0
- package/rules/long-horizon-autonomy.md +0 -4
- package/rules/measurement-denominators.md +48 -0
- package/rules/nas-ssh-invocation.md +23 -5
- package/rules/parallel-tools.md +2 -2
- package/rules/plain-illustrative-docstrings.md +3 -7
- package/rules/plain-language.md +2 -0
- package/rules/proof-of-work-pr-comments.md +0 -4
- package/rules/re-stage-before-commit.md +2 -0
- package/rules/research-mode.md +10 -0
- package/rules/shell-invocation.md +21 -0
- package/rules/testing.md +4 -0
- package/rules/verified-commit-gate-skip.md +3 -27
- package/rules/verify-before-asking.md +5 -0
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +4 -0
- package/scripts/Migrate-ShellPolicy.ps1 +1 -1
- package/scripts/codex_capability_bridge.py +171 -0
- package/scripts/codex_compat_materializer.py +1087 -0
- package/scripts/codex_compat_watcher.py +502 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
- package/scripts/invoke_code_review.py +11 -4
- package/scripts/sync_to_cursor/rules.py +0 -10
- package/scripts/test_invoke_code_review.py +143 -0
- package/scripts/test_invoke_code_review_chain.py +1 -1
- package/scripts/test_invoke_code_review_contract.py +1 -1
- package/scripts/tests/test_code_review_constants.py +80 -0
- package/scripts/tests/test_codex_capability_bridge.py +91 -0
- package/scripts/tests/test_codex_compat_materializer.py +632 -0
- package/scripts/tests/test_codex_compat_watcher.py +599 -0
- package/scripts/tests/test_sync_to_cursor.py +0 -1
- package/skills/autoconverge/workflow/converge.mjs +1 -1
- package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
- package/skills/condensing-instructions/SKILL.md +42 -51
- package/skills/fresh-branch/CLAUDE.md +1 -1
- package/skills/fresh-branch/SKILL.md +5 -6
- package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
- package/skills/orchestrator/SKILL.md +23 -9
- package/skills/orchestrator-refresh/SKILL.md +20 -1
- package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/skills/session-log/SKILL.md +1 -1
- package/rules/claude-md-orphan-file.md +0 -28
- package/rules/cleanup-command-forms.md +0 -23
- package/rules/code-reviews.md +0 -11
- package/rules/env-var-table-code-drift.md +0 -10
- package/rules/gh-body-file.md +0 -5
- package/rules/gh-paginate.md +0 -3
- package/rules/hook-prose-matches-detector.md +0 -15
- package/rules/no-historical-clutter.md +0 -26
- package/rules/no-inline-destructive-literals.md +0 -9
- package/rules/no-justification-noise.md +0 -61
- package/rules/package-inventory-stale-entry.md +0 -25
- package/rules/right-sized-engineering.md +0 -28
- package/rules/self-contained-docs.md +0 -17
- package/rules/shell-invocation-policy.md +0 -5
- package/rules/state-what-is.md +0 -25
- package/rules/tdd.md +0 -7
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Stop hook that blocks a final reply breaking the short action-first reply shape.
|
|
4
|
+
|
|
5
|
+
The `eli11-replies` rule asks every chat reply to lead with the action, keep
|
|
6
|
+
findings to a few bullets, and stay short. This hook reads the final assistant
|
|
7
|
+
message, strips code fences, inline code, blockquotes, table rows, and link
|
|
8
|
+
targets, then judges what a reader actually sees: the word count against a hard
|
|
9
|
+
cap, whether numbered steps lead a reply that tells the user to act, the number
|
|
10
|
+
of bullet lines, and how many lines pack more than one idea's worth of words.
|
|
11
|
+
A reply opening with "Long form:" opts out, so a user-requested full report
|
|
12
|
+
still goes through.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
20
|
+
if _hooks_dir not in sys.path:
|
|
21
|
+
sys.path.insert(0, _hooks_dir)
|
|
22
|
+
|
|
23
|
+
from hooks_constants.eli11_reply_enforcer_constants import ( # noqa: E402
|
|
24
|
+
ACTION_FIRST_LEAD_LINE_COUNT,
|
|
25
|
+
ALL_IMPERATIVE_INSTRUCTION_VERBS,
|
|
26
|
+
ALL_IMPERATIVE_OBJECT_LEAD_WORDS,
|
|
27
|
+
ALPHABETIC_WORD_PATTERN,
|
|
28
|
+
BULLET_LINE_PATTERN,
|
|
29
|
+
COUNTABLE_WORD_PATTERN,
|
|
30
|
+
IMPERATIVE_OBJECT_TOKEN_PATTERN,
|
|
31
|
+
LINK_TARGET_PATTERN,
|
|
32
|
+
LIST_MARKER_PREFIX_PATTERN,
|
|
33
|
+
LONG_FORM_ESCAPE_PREFIX,
|
|
34
|
+
MARKDOWN_LEAD_MARKER_PATTERN,
|
|
35
|
+
MAXIMUM_BULLET_LINE_COUNT,
|
|
36
|
+
MAXIMUM_OVERPACKED_LINE_COUNT,
|
|
37
|
+
MAXIMUM_REPLY_WORD_COUNT,
|
|
38
|
+
MAXIMUM_WORDS_PER_LINE,
|
|
39
|
+
MINIMUM_ENFORCED_WORD_COUNT,
|
|
40
|
+
NUMBERED_STEP_PATTERN,
|
|
41
|
+
TABLE_ROW_PATTERN,
|
|
42
|
+
TARGET_BULLET_LINE_COUNT,
|
|
43
|
+
USER_FACING_ELI11_NOTICE,
|
|
44
|
+
VIOLATION_SEPARATOR,
|
|
45
|
+
)
|
|
46
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
47
|
+
from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def extract_reply_prose(assistant_message: str) -> str:
|
|
51
|
+
"""Return the reader-visible prose of a reply.
|
|
52
|
+
|
|
53
|
+
::
|
|
54
|
+
|
|
55
|
+
in: "See https://example.com/a now"
|
|
56
|
+
out: "See now"
|
|
57
|
+
|
|
58
|
+
Fenced code, inline code, and blockquotes come off through the shared
|
|
59
|
+
stripper; table rows and link targets come off here.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
assistant_message: The raw final assistant message.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The message with code, quotes, table rows, and link targets removed.
|
|
66
|
+
"""
|
|
67
|
+
prose_text = strip_code_and_quotes(assistant_message)
|
|
68
|
+
prose_text = TABLE_ROW_PATTERN.sub("", prose_text)
|
|
69
|
+
return LINK_TARGET_PATTERN.sub("", prose_text)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def count_reply_words(prose_text: str) -> int:
|
|
73
|
+
"""Return the number of reader-visible words in the stripped prose.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
prose_text: Prose already cleaned of code, quotes, tables, and links.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The count of whitespace-separated tokens carrying a letter or digit.
|
|
80
|
+
"""
|
|
81
|
+
return len(COUNTABLE_WORD_PATTERN.findall(prose_text))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def collect_non_empty_lines(prose_text: str) -> list[str]:
|
|
85
|
+
"""Return every line of the stripped prose that carries visible text.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
prose_text: Prose already cleaned of code, quotes, tables, and links.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The lines with content, in order, blank lines dropped.
|
|
92
|
+
"""
|
|
93
|
+
return [
|
|
94
|
+
each_line for each_line in prose_text.splitlines() if each_line.strip()
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def leading_word_of(prose_line: str) -> str:
|
|
99
|
+
"""Return the first word of a line once list and bold markers come off.
|
|
100
|
+
|
|
101
|
+
::
|
|
102
|
+
|
|
103
|
+
in: "- **Click** Save"
|
|
104
|
+
out: "click"
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
prose_line: One non-empty line of reply prose.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The lowercased first alphabetic word, or an empty string when the line
|
|
111
|
+
carries no letters.
|
|
112
|
+
"""
|
|
113
|
+
unmarked_line = LIST_MARKER_PREFIX_PATTERN.sub("", prose_line, count=1)
|
|
114
|
+
all_leading_words = ALPHABETIC_WORD_PATTERN.findall(unmarked_line)
|
|
115
|
+
if not all_leading_words:
|
|
116
|
+
return ""
|
|
117
|
+
return all_leading_words[0].lower()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def object_word_after_leading_verb(prose_line: str) -> str:
|
|
121
|
+
"""Return the word a line puts right after its opening verb.
|
|
122
|
+
|
|
123
|
+
::
|
|
124
|
+
|
|
125
|
+
in: "Run the migration script."
|
|
126
|
+
out: "the"
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
prose_line: One non-empty line of reply prose.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The lowercased token following the first alphabetic word, or an empty
|
|
133
|
+
string when the line carries no such token.
|
|
134
|
+
"""
|
|
135
|
+
unmarked_line = LIST_MARKER_PREFIX_PATTERN.sub("", prose_line, count=1)
|
|
136
|
+
leading_word_match = ALPHABETIC_WORD_PATTERN.search(unmarked_line)
|
|
137
|
+
if leading_word_match is None:
|
|
138
|
+
return ""
|
|
139
|
+
all_trailing_tokens = COUNTABLE_WORD_PATTERN.findall(
|
|
140
|
+
unmarked_line[leading_word_match.end():]
|
|
141
|
+
)
|
|
142
|
+
if not all_trailing_tokens:
|
|
143
|
+
return ""
|
|
144
|
+
return all_trailing_tokens[0].lower()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def names_imperative_object(object_word: str) -> bool:
|
|
148
|
+
"""Return True when a word reads as the thing an imperative acts on.
|
|
149
|
+
|
|
150
|
+
::
|
|
151
|
+
|
|
152
|
+
ok: "the" (Run the script), "3" (Do 3 things), "scripts/run.py"
|
|
153
|
+
flag: "questions" (Open questions remain), "is" (Merge is complete)
|
|
154
|
+
|
|
155
|
+
An imperative names its object through a determiner, a count, a path, or a
|
|
156
|
+
filename; a narrative sentence puts a bare subject noun there instead.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
object_word: The lowercased word following a line's opening verb.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
True when the word marks the opening verb as a real imperative.
|
|
163
|
+
"""
|
|
164
|
+
if not object_word:
|
|
165
|
+
return False
|
|
166
|
+
if object_word in ALL_IMPERATIVE_OBJECT_LEAD_WORDS:
|
|
167
|
+
return True
|
|
168
|
+
return IMPERATIVE_OBJECT_TOKEN_PATTERN.search(object_word) is not None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def is_imperative_instruction_line(prose_line: str) -> bool:
|
|
172
|
+
"""Return True when a line tells the user to act.
|
|
173
|
+
|
|
174
|
+
::
|
|
175
|
+
|
|
176
|
+
ok: "Open questions remain about the stripper." -> False
|
|
177
|
+
flag: "Open the pull request." -> True
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
prose_line: One non-empty line of reply prose.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
True when the line opens with a tracked instruction verb naming an
|
|
184
|
+
object an imperative acts on.
|
|
185
|
+
"""
|
|
186
|
+
if leading_word_of(prose_line) not in ALL_IMPERATIVE_INSTRUCTION_VERBS:
|
|
187
|
+
return False
|
|
188
|
+
return names_imperative_object(object_word_after_leading_verb(prose_line))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def has_imperative_instruction_line(all_prose_lines: list[str]) -> bool:
|
|
192
|
+
"""Return True when any line opens with a verb telling the user to act.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
all_prose_lines: The non-empty lines of reply prose.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
True when a line reads as an imperative instruction.
|
|
199
|
+
"""
|
|
200
|
+
return any(
|
|
201
|
+
is_imperative_instruction_line(each_line) for each_line in all_prose_lines
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def has_leading_numbered_step(all_prose_lines: list[str]) -> bool:
|
|
206
|
+
"""Return True when a numbered step appears among the lead lines.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
all_prose_lines: The non-empty lines of reply prose.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
True when one of the first lead lines opens a numbered list.
|
|
213
|
+
"""
|
|
214
|
+
all_lead_lines = all_prose_lines[:ACTION_FIRST_LEAD_LINE_COUNT]
|
|
215
|
+
return any(
|
|
216
|
+
NUMBERED_STEP_PATTERN.match(each_line) for each_line in all_lead_lines
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def count_bullet_lines(all_prose_lines: list[str]) -> int:
|
|
221
|
+
"""Return how many lines of the reply are bullets.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
all_prose_lines: The non-empty lines of reply prose.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
The count of lines opening with a bullet marker.
|
|
228
|
+
"""
|
|
229
|
+
return sum(
|
|
230
|
+
1 for each_line in all_prose_lines if BULLET_LINE_PATTERN.match(each_line)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def count_words_in_line(prose_line: str) -> int:
|
|
235
|
+
"""Return the countable words a line carries once its list marker comes off.
|
|
236
|
+
|
|
237
|
+
::
|
|
238
|
+
|
|
239
|
+
in: "- **Save** the draft"
|
|
240
|
+
out: 3
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
prose_line: One non-empty line of reply prose.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
The count of tokens carrying a letter or digit, marker text excluded.
|
|
247
|
+
"""
|
|
248
|
+
unmarked_line = LIST_MARKER_PREFIX_PATTERN.sub("", prose_line, count=1)
|
|
249
|
+
return len(COUNTABLE_WORD_PATTERN.findall(unmarked_line))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def count_overpacked_lines(all_prose_lines: list[str]) -> int:
|
|
253
|
+
"""Return how many lines carry more words than one idea needs.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
all_prose_lines: The non-empty lines of reply prose.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
The count of lines over the per-line word cap.
|
|
260
|
+
"""
|
|
261
|
+
return sum(
|
|
262
|
+
1
|
|
263
|
+
for each_line in all_prose_lines
|
|
264
|
+
if count_words_in_line(each_line) > MAXIMUM_WORDS_PER_LINE
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def strip_markdown_lead_markers(prose_line: str) -> str:
|
|
269
|
+
"""Return a line with its opening blockquote, heading, and bold markers off.
|
|
270
|
+
|
|
271
|
+
::
|
|
272
|
+
|
|
273
|
+
in: "> **Long form:** the report follows"
|
|
274
|
+
out: "Long form:** the report follows"
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
prose_line: One line of raw reply text.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
The line with every leading markdown marker and its padding removed.
|
|
281
|
+
"""
|
|
282
|
+
return MARKDOWN_LEAD_MARKER_PATTERN.sub("", prose_line.strip(), count=1)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def opens_with_long_form_escape(assistant_message: str) -> bool:
|
|
286
|
+
"""Return True when the reply opts out through the Long form prefix.
|
|
287
|
+
|
|
288
|
+
::
|
|
289
|
+
|
|
290
|
+
ok: "**Long form:** the report follows" -> True
|
|
291
|
+
flag: "The report follows" -> False
|
|
292
|
+
|
|
293
|
+
A bold, blockquote, or heading wrapper around the prefix still opts out.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
assistant_message: The raw final assistant message.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
True when the first line with content starts with the escape prefix.
|
|
300
|
+
"""
|
|
301
|
+
for each_line in assistant_message.splitlines():
|
|
302
|
+
if not each_line.strip():
|
|
303
|
+
continue
|
|
304
|
+
unwrapped_line = strip_markdown_lead_markers(each_line)
|
|
305
|
+
return unwrapped_line.lower().startswith(LONG_FORM_ESCAPE_PREFIX)
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def describe_word_cap_violation(reply_word_count: int) -> str:
|
|
310
|
+
"""Return the violation text naming the reply length and the cap."""
|
|
311
|
+
return (
|
|
312
|
+
f"{reply_word_count} words, over the {MAXIMUM_REPLY_WORD_COUNT}-word cap"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def describe_bullet_cap_violation(bullet_line_count: int) -> str:
|
|
317
|
+
"""Return the violation text naming the bullet count and the target."""
|
|
318
|
+
return (
|
|
319
|
+
f"{bullet_line_count} bullets, over the {MAXIMUM_BULLET_LINE_COUNT}-bullet "
|
|
320
|
+
f"cap - cut findings to {TARGET_BULLET_LINE_COUNT} bullets"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def describe_overpacked_line_violation(overpacked_line_count: int) -> str:
|
|
325
|
+
"""Return the violation text naming how many lines pack too much in."""
|
|
326
|
+
return (
|
|
327
|
+
f"{overpacked_line_count} lines carry too many words - one idea per line, "
|
|
328
|
+
f"at most {MAXIMUM_WORDS_PER_LINE} words each"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def describe_action_first_violation() -> str:
|
|
333
|
+
"""Return the violation text for instructions buried under prose."""
|
|
334
|
+
return (
|
|
335
|
+
"the reply tells the user to act but leads with prose - put the steps "
|
|
336
|
+
"first, as a numbered list"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def is_action_first_violation(
|
|
341
|
+
reply_word_count: int, all_prose_lines: list[str]
|
|
342
|
+
) -> bool:
|
|
343
|
+
"""Return True when instructions sit below the lead of a long reply.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
reply_word_count: The reader-visible word count of the reply.
|
|
347
|
+
all_prose_lines: The non-empty lines of reply prose.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
True when the reply is long, carries instruction lines, and shows no
|
|
351
|
+
numbered step among its lead lines.
|
|
352
|
+
"""
|
|
353
|
+
if reply_word_count < MINIMUM_ENFORCED_WORD_COUNT:
|
|
354
|
+
return False
|
|
355
|
+
if not has_imperative_instruction_line(all_prose_lines):
|
|
356
|
+
return False
|
|
357
|
+
return not has_leading_numbered_step(all_prose_lines)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def collect_shape_violations(
|
|
361
|
+
reply_word_count: int, all_prose_lines: list[str]
|
|
362
|
+
) -> list[str]:
|
|
363
|
+
"""Return the violation texts for every reply-shape rule the reply breaks.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
reply_word_count: The reader-visible word count of the reply.
|
|
367
|
+
all_prose_lines: The non-empty lines of reply prose.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
One violation text per broken rule, empty when the reply is in shape.
|
|
371
|
+
"""
|
|
372
|
+
all_violations: list[str] = []
|
|
373
|
+
if reply_word_count > MAXIMUM_REPLY_WORD_COUNT:
|
|
374
|
+
all_violations.append(describe_word_cap_violation(reply_word_count))
|
|
375
|
+
if is_action_first_violation(reply_word_count, all_prose_lines):
|
|
376
|
+
all_violations.append(describe_action_first_violation())
|
|
377
|
+
bullet_line_count = count_bullet_lines(all_prose_lines)
|
|
378
|
+
if bullet_line_count > MAXIMUM_BULLET_LINE_COUNT:
|
|
379
|
+
all_violations.append(describe_bullet_cap_violation(bullet_line_count))
|
|
380
|
+
overpacked_line_count = count_overpacked_lines(all_prose_lines)
|
|
381
|
+
if overpacked_line_count > MAXIMUM_OVERPACKED_LINE_COUNT:
|
|
382
|
+
all_violations.append(
|
|
383
|
+
describe_overpacked_line_violation(overpacked_line_count)
|
|
384
|
+
)
|
|
385
|
+
return all_violations
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def find_reply_shape_violations(assistant_message: str) -> list[str]:
|
|
389
|
+
"""Return every reply-shape violation the final assistant message carries.
|
|
390
|
+
|
|
391
|
+
::
|
|
392
|
+
|
|
393
|
+
ok: a 40-word outcome sentence -> []
|
|
394
|
+
flag: a 260-word wall -> ["260 words, over the 120-word cap"]
|
|
395
|
+
|
|
396
|
+
A reply opening with the escape prefix, and a reply under the enforced word
|
|
397
|
+
floor, are both returned as in shape.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
assistant_message: The raw final assistant message.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
One violation text per broken rule, empty when the reply is in shape.
|
|
404
|
+
"""
|
|
405
|
+
if opens_with_long_form_escape(assistant_message):
|
|
406
|
+
return []
|
|
407
|
+
prose_text = extract_reply_prose(assistant_message)
|
|
408
|
+
reply_word_count = count_reply_words(prose_text)
|
|
409
|
+
if reply_word_count < MINIMUM_ENFORCED_WORD_COUNT:
|
|
410
|
+
return []
|
|
411
|
+
return collect_shape_violations(
|
|
412
|
+
reply_word_count, collect_non_empty_lines(prose_text)
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def build_block_reason(all_violations: list[str]) -> str:
|
|
417
|
+
"""Return the corrective message naming each violation and the escape hatch.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
all_violations: The violation texts the reply earned.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
The full block reason the model rewrites its reply against.
|
|
424
|
+
"""
|
|
425
|
+
formatted_violation_list = VIOLATION_SEPARATOR.join(all_violations)
|
|
426
|
+
return (
|
|
427
|
+
f"ELI11 REPLY SHAPE: Your reply breaks the short action-first shape "
|
|
428
|
+
f"({formatted_violation_list}).\n\n"
|
|
429
|
+
f"Rewrite it short. When the user must act, open with numbered "
|
|
430
|
+
f"click-by-click steps, one short line each. When nothing is needed, open "
|
|
431
|
+
f"with the outcome in one sentence. Keep findings to at most "
|
|
432
|
+
f"{TARGET_BULLET_LINE_COUNT} short bullets and stay under "
|
|
433
|
+
f"{MAXIMUM_REPLY_WORD_COUNT} words. Code fences, blockquotes, tables, and "
|
|
434
|
+
f"links are already exempt from the count.\n\n"
|
|
435
|
+
f"When the user asked for a full report, start the reply with "
|
|
436
|
+
f'"Long form:" to opt out of this check.\n\n'
|
|
437
|
+
f"You MUST re-output the complete, revised response with the correction "
|
|
438
|
+
f"applied."
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def main() -> None:
|
|
443
|
+
"""Read the Stop payload and block a reply that breaks the ELI11 shape."""
|
|
444
|
+
try:
|
|
445
|
+
hook_input = json.load(sys.stdin)
|
|
446
|
+
except json.JSONDecodeError:
|
|
447
|
+
sys.exit(0)
|
|
448
|
+
|
|
449
|
+
if hook_input.get("stop_hook_active", False):
|
|
450
|
+
sys.exit(0)
|
|
451
|
+
|
|
452
|
+
assistant_message = hook_input.get("last_assistant_message", "")
|
|
453
|
+
|
|
454
|
+
if not assistant_message:
|
|
455
|
+
sys.exit(0)
|
|
456
|
+
|
|
457
|
+
all_violations = find_reply_shape_violations(assistant_message)
|
|
458
|
+
|
|
459
|
+
if not all_violations:
|
|
460
|
+
sys.exit(0)
|
|
461
|
+
|
|
462
|
+
block_reason = build_block_reason(all_violations)
|
|
463
|
+
block_response = {
|
|
464
|
+
"decision": "block",
|
|
465
|
+
"reason": block_reason,
|
|
466
|
+
"systemMessage": USER_FACING_ELI11_NOTICE,
|
|
467
|
+
"suppressOutput": True,
|
|
468
|
+
}
|
|
469
|
+
log_hook_block(
|
|
470
|
+
calling_hook_name="eli11_reply_enforcer.py",
|
|
471
|
+
hook_event="Stop",
|
|
472
|
+
block_reason=block_reason,
|
|
473
|
+
)
|
|
474
|
+
print(json.dumps(block_response))
|
|
475
|
+
sys.exit(0)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
if __name__ == "__main__":
|
|
479
|
+
main()
|
|
@@ -68,7 +68,7 @@ _CORRECTIVE_MESSAGE = (
|
|
|
68
68
|
" '@\n"
|
|
69
69
|
" [IO.File]::WriteAllText($bodyPath, $body, [Text.UTF8Encoding]::new($false))\n"
|
|
70
70
|
" gh ... --body-file $bodyPath\n\n"
|
|
71
|
-
"See ~/.claude/rules/gh-
|
|
71
|
+
"See ~/.claude/rules/gh-cli-conventions.md for full guidance."
|
|
72
72
|
)
|
|
73
73
|
|
|
74
74
|
|
|
@@ -46,20 +46,16 @@ from hooks_constants.local_identity import ( # noqa: E402
|
|
|
46
46
|
nas_host,
|
|
47
47
|
)
|
|
48
48
|
from hooks_constants.nas_ssh_binary_enforcer_constants import ( # noqa: E402
|
|
49
|
-
ALL_LAUNCHER_WRAPPER_COMMANDS,
|
|
50
49
|
ALL_OPENSSH_BINARY_PATH_SUFFIXES,
|
|
51
|
-
ALL_SHELL_CONTROL_OPERATOR_TOKENS,
|
|
52
50
|
ALL_SSH_FAMILY_COMMAND_BASENAMES,
|
|
53
51
|
BASH_TOOL_NAME,
|
|
54
52
|
BATCH_MODE_PATTERN,
|
|
55
|
-
CONTROL_OPERATOR_SPLIT_PATTERN,
|
|
56
|
-
LAUNCHER_DURATION_PATTERN,
|
|
57
|
-
LEADING_ASSIGNMENT_PATTERN,
|
|
58
53
|
)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
54
|
+
from hooks_constants.shell_command_segments import ( # noqa: E402
|
|
55
|
+
effective_leading_program,
|
|
56
|
+
split_into_segments,
|
|
57
|
+
token_basename,
|
|
58
|
+
)
|
|
63
59
|
|
|
64
60
|
|
|
65
61
|
def _is_openssh_binary_path(token: str) -> bool:
|
|
@@ -74,40 +70,6 @@ def _nas_host_token_pattern() -> re.Pattern[str]:
|
|
|
74
70
|
return re.compile(rf"(?:^|@){re.escape(nas_host())}(?::|$)")
|
|
75
71
|
|
|
76
72
|
|
|
77
|
-
def _split_into_segments(all_command_tokens: list[str]) -> list[list[str]]:
|
|
78
|
-
all_exploded_tokens: list[str] = []
|
|
79
|
-
for each_token in all_command_tokens:
|
|
80
|
-
for each_fragment in CONTROL_OPERATOR_SPLIT_PATTERN.split(each_token):
|
|
81
|
-
if each_fragment:
|
|
82
|
-
all_exploded_tokens.append(each_fragment)
|
|
83
|
-
all_segments: list[list[str]] = []
|
|
84
|
-
current_segment: list[str] = []
|
|
85
|
-
for each_token in all_exploded_tokens:
|
|
86
|
-
if each_token in ALL_SHELL_CONTROL_OPERATOR_TOKENS:
|
|
87
|
-
all_segments.append(current_segment)
|
|
88
|
-
current_segment = []
|
|
89
|
-
continue
|
|
90
|
-
current_segment.append(each_token)
|
|
91
|
-
all_segments.append(current_segment)
|
|
92
|
-
return all_segments
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _effective_leading_program(all_segment_tokens: list[str]) -> str | None:
|
|
96
|
-
has_seen_launcher_wrapper = False
|
|
97
|
-
for each_token in all_segment_tokens:
|
|
98
|
-
if LEADING_ASSIGNMENT_PATTERN.match(each_token):
|
|
99
|
-
continue
|
|
100
|
-
if _token_basename(each_token) in ALL_LAUNCHER_WRAPPER_COMMANDS:
|
|
101
|
-
has_seen_launcher_wrapper = True
|
|
102
|
-
continue
|
|
103
|
-
if has_seen_launcher_wrapper and (
|
|
104
|
-
each_token.startswith("-") or LAUNCHER_DURATION_PATTERN.match(each_token)
|
|
105
|
-
):
|
|
106
|
-
continue
|
|
107
|
-
return each_token
|
|
108
|
-
return None
|
|
109
|
-
|
|
110
|
-
|
|
111
73
|
def _segment_references_nas(
|
|
112
74
|
all_segment_tokens: list[str], nas_host_pattern: re.Pattern[str]
|
|
113
75
|
) -> bool:
|
|
@@ -145,11 +107,11 @@ def _find_nas_ssh_violation(command: str) -> str | None:
|
|
|
145
107
|
except ValueError:
|
|
146
108
|
return None
|
|
147
109
|
nas_host_pattern = _nas_host_token_pattern()
|
|
148
|
-
for each_segment in
|
|
149
|
-
leading_program =
|
|
110
|
+
for each_segment in split_into_segments(all_command_tokens):
|
|
111
|
+
leading_program = effective_leading_program(each_segment)
|
|
150
112
|
if leading_program is None:
|
|
151
113
|
continue
|
|
152
|
-
if
|
|
114
|
+
if token_basename(leading_program) not in ALL_SSH_FAMILY_COMMAND_BASENAMES:
|
|
153
115
|
continue
|
|
154
116
|
if not _segment_references_nas(each_segment, nas_host_pattern):
|
|
155
117
|
continue
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: block Bash commands containing shell command substitution.
|
|
3
|
+
|
|
4
|
+
Root cause: Claude Code's auto-allowlist matcher operates on the raw command
|
|
5
|
+
string, not on parsed syntax, so it does not descend into command substitutions
|
|
6
|
+
such as $(...), unescaped backticks, or bash process substitutions <(...) and
|
|
7
|
+
>(...). A compound of the form
|
|
8
|
+
|
|
9
|
+
cd X && echo "$(git rev-parse HEAD)"
|
|
10
|
+
|
|
11
|
+
therefore falls outside the auto-allowed set for `cd` + `echo` + `git rev-parse`
|
|
12
|
+
and forces a permission prompt, even though every segment would be auto-allowed
|
|
13
|
+
individually. The same mechanics apply to `diff <(cat a) <(cat b)` and
|
|
14
|
+
`tee >(gzip > out.gz)`: each inner command would be auto-allowed on its own,
|
|
15
|
+
but the outer wrapper defeats the matcher. Splitting into two separate Bash
|
|
16
|
+
tool calls, or collapsing to a substitution-free form like
|
|
17
|
+
`git -C X rev-parse HEAD`, avoids the prompt.
|
|
18
|
+
|
|
19
|
+
Detection: a regex match for `$(`, an unescaped backtick, or `<(` / `>(`, after
|
|
20
|
+
stripping single-quoted literal runs. Single-quoted regions are intentionally
|
|
21
|
+
ignored because bash performs no substitution inside them (for example
|
|
22
|
+
`echo '$(not-executed)'`). Backslash-escaped backticks outside single quotes
|
|
23
|
+
are treated as literal only when the count of preceding backslashes is odd;
|
|
24
|
+
an even count (including zero) means the backtick is a live substitution.
|
|
25
|
+
|
|
26
|
+
Bash arithmetic expansion ``$((...))`` is explicitly NOT blocked: it does not
|
|
27
|
+
spawn a subshell and does not defeat the allowlist matcher, so the
|
|
28
|
+
``$(`` pattern uses a negative lookahead to skip the ``$((`` case.
|
|
29
|
+
|
|
30
|
+
False positives are still possible when the user is intentionally writing
|
|
31
|
+
shell-script content (for example, authoring a .sh file via heredoc) and the
|
|
32
|
+
substitution is literal payload rather than something to execute. In that
|
|
33
|
+
case, write via the Write tool instead of a Bash heredoc.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import sys
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
43
|
+
if _hooks_dir not in sys.path:
|
|
44
|
+
sys.path.insert(0, _hooks_dir)
|
|
45
|
+
|
|
46
|
+
from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
|
|
47
|
+
BASH_TOOL_NAME,
|
|
48
|
+
COMMAND_KEY,
|
|
49
|
+
CORRECTIVE_MESSAGE,
|
|
50
|
+
DENY_DECISION,
|
|
51
|
+
DOLLAR_PAREN_PATTERN,
|
|
52
|
+
EVEN_BACKSLASH_BACKTICK_PATTERN,
|
|
53
|
+
HOOK_EVENT_NAME,
|
|
54
|
+
HOOK_EVENT_NAME_KEY,
|
|
55
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
56
|
+
PERMISSION_DECISION_KEY,
|
|
57
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
58
|
+
PROCESS_SUBSTITUTION_PATTERN,
|
|
59
|
+
SINGLE_QUOTED_RUN_PATTERN,
|
|
60
|
+
STRIPPED_RUN_REPLACEMENT,
|
|
61
|
+
TOOL_INPUT_KEY,
|
|
62
|
+
TOOL_NAME_KEY,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _strip_single_quoted_runs(command: str) -> str:
|
|
67
|
+
return SINGLE_QUOTED_RUN_PATTERN.sub(STRIPPED_RUN_REPLACEMENT, command)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def has_shell_substitution(command: str) -> bool:
|
|
71
|
+
"""Return True when a Bash command carries a live shell substitution.
|
|
72
|
+
|
|
73
|
+
::
|
|
74
|
+
|
|
75
|
+
echo "$(git rev-parse HEAD)" flag: command substitution
|
|
76
|
+
echo `date` flag: live backtick
|
|
77
|
+
diff <(cat a) <(cat b) flag: process substitution
|
|
78
|
+
echo '$(not-executed)' ok: single-quoted, inert
|
|
79
|
+
echo $((2 + 2)) ok: arithmetic expansion
|
|
80
|
+
|
|
81
|
+
Single-quoted runs are stripped first because bash substitutes nothing
|
|
82
|
+
inside them. A backtick preceded by an odd number of backslashes is escaped
|
|
83
|
+
and stays allowed.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
command: The raw Bash command string from the tool input.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
True when a substitution the allowlist matcher cannot descend into is
|
|
90
|
+
present outside single quotes.
|
|
91
|
+
"""
|
|
92
|
+
scannable_command = _strip_single_quoted_runs(command)
|
|
93
|
+
if DOLLAR_PAREN_PATTERN.search(scannable_command):
|
|
94
|
+
return True
|
|
95
|
+
if EVEN_BACKSLASH_BACKTICK_PATTERN.search(scannable_command):
|
|
96
|
+
return True
|
|
97
|
+
if PROCESS_SUBSTITUTION_PATTERN.search(scannable_command):
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main() -> None:
|
|
103
|
+
try:
|
|
104
|
+
hook_input = json.load(sys.stdin)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
sys.exit(0)
|
|
107
|
+
|
|
108
|
+
tool_name = hook_input.get(TOOL_NAME_KEY, "")
|
|
109
|
+
if tool_name != BASH_TOOL_NAME:
|
|
110
|
+
sys.exit(0)
|
|
111
|
+
|
|
112
|
+
command = hook_input.get(TOOL_INPUT_KEY, {}).get(COMMAND_KEY, "")
|
|
113
|
+
if not command or not has_shell_substitution(command):
|
|
114
|
+
sys.exit(0)
|
|
115
|
+
|
|
116
|
+
deny_payload = {
|
|
117
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
118
|
+
HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
|
|
119
|
+
PERMISSION_DECISION_KEY: DENY_DECISION,
|
|
120
|
+
PERMISSION_DECISION_REASON_KEY: CORRECTIVE_MESSAGE,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
print(json.dumps(deny_payload))
|
|
124
|
+
sys.stdout.flush()
|
|
125
|
+
sys.exit(0)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|