claude-dev-env 1.30.0 → 1.31.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 (42) hide show
  1. package/CLAUDE.md +8 -0
  2. package/agents/clean-coder.md +275 -111
  3. package/agents/code-quality-agent.md +196 -209
  4. package/bin/install.mjs +81 -0
  5. package/bin/install.test.mjs +158 -0
  6. package/bin/install_mypy_ini.mjs +51 -0
  7. package/bin/install_mypy_ini.test.mjs +121 -0
  8. package/commands/hook-log-extract.md +70 -0
  9. package/commands/hook-log-init.md +76 -0
  10. package/docs/CODE_RULES.md +40 -0
  11. package/hooks/blocking/code_rules_enforcer.py +5 -3
  12. package/hooks/blocking/destructive_command_blocker.py +187 -0
  13. package/hooks/blocking/question_to_user_enforcer.py +140 -0
  14. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +39 -0
  15. package/hooks/blocking/test_destructive_command_blocker.py +397 -0
  16. package/hooks/blocking/test_question_to_user_enforcer.py +163 -0
  17. package/hooks/config/hook_log_extractor_constants.py +221 -0
  18. package/hooks/config/messages.py +3 -0
  19. package/hooks/config/test_hook_log_extractor_constants.py +96 -0
  20. package/hooks/config/test_messages.py +5 -0
  21. package/hooks/diagnostic/hook_log_extractor.py +907 -0
  22. package/hooks/diagnostic/hook_log_init.py +202 -0
  23. package/hooks/diagnostic/hook_log_stop_wrapper.py +84 -0
  24. package/hooks/diagnostic/migrations/2026-04-25-drop-themes-hook-events.sql +3 -0
  25. package/hooks/diagnostic/migrations/README.md +77 -0
  26. package/hooks/diagnostic/queries/block_details_for_hook.sql +26 -0
  27. package/hooks/diagnostic/queries/blocks_by_category.sql +10 -0
  28. package/hooks/diagnostic/queries/blocks_by_tool.sql +9 -0
  29. package/hooks/diagnostic/queries/blocks_last_7_days.sql +11 -0
  30. package/hooks/diagnostic/queries/top_blockers_last_24_hours.sql +12 -0
  31. package/hooks/diagnostic/queries/top_blockers_overall.sql +12 -0
  32. package/hooks/diagnostic/requirements-hook-logs-dev.txt +2 -0
  33. package/hooks/diagnostic/requirements-hook-logs.txt +1 -0
  34. package/hooks/diagnostic/schema.sql +51 -0
  35. package/hooks/diagnostic/test_hook_log_extractor.py +1531 -0
  36. package/hooks/diagnostic/test_hook_log_init.py +227 -0
  37. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +98 -0
  38. package/hooks/hooks.json +10 -0
  39. package/package.json +1 -1
  40. package/rules/ask-user-question-required.md +44 -0
  41. package/scripts/config/test_spec_implementer_prompt.py +0 -4
  42. package/scripts/test_groq_bugteam_spec.py +0 -8
@@ -291,6 +291,184 @@ def _ephemeral_recursive_rm_auto_allow_granted(command: str, matched_description
291
291
  return matched_description.startswith(("rm -rf", "rm --recursive")) and rm_targets_only_ephemeral_paths(command)
292
292
 
293
293
 
294
+ def _extract_leading_cd_target(command: str) -> str | None:
295
+ """Return the target of a ``cd`` that starts the command, or None if absent.
296
+
297
+ Uses ``shlex.split`` with POSIX rules to tokenize the command so adjacent
298
+ quoted string concatenation (``"/tmp/a""/../../etc"``) resolves to the
299
+ same path the shell would cd to (``/tmp/a/../../etc``). A regex-based
300
+ extractor cannot see past the first quoted segment and would
301
+ misclassify the cd target as ephemeral while the shell ends up
302
+ somewhere else entirely.
303
+
304
+ Returns None when the command does not start with ``cd``, when tokenization
305
+ fails (unbalanced quotes), when the cd target is missing, or when the
306
+ target contains any shell-expansion character (``$`` for variable /
307
+ command substitution, `` ` `` for backtick subshell) that the shell
308
+ would resolve *before* cd runs. Hook authors cannot safely know what
309
+ ``$(rm -rf ~)`` expands to, so the conservative answer is "don't
310
+ auto-allow".
311
+ """
312
+ shell_expansion_characters_that_execute_code = ("$", "`")
313
+ try:
314
+ all_command_tokens = shlex.split(command, posix=True)
315
+ except ValueError:
316
+ return None
317
+ if len(all_command_tokens) < 2 or all_command_tokens[0] != "cd":
318
+ return None
319
+ cd_target_token = all_command_tokens[1]
320
+ for each_shell_expansion_character in shell_expansion_characters_that_execute_code:
321
+ if each_shell_expansion_character in cd_target_token:
322
+ return None
323
+ return cd_target_token
324
+
325
+
326
+ def _resolve_declared_effective_working_directory(command: str, tool_input: dict) -> str | None:
327
+ """Return the declared cwd for the command, or None when none is declared.
328
+
329
+ Precedence: leading ``cd "X"`` in the command, then the
330
+ ``tool_input['cwd']`` field passed in by the Bash tool call. Returns
331
+ None when neither source is present so the broad auto-allow gate never
332
+ depends on the hook process's own ``os.getcwd()`` (which can itself be
333
+ ephemeral when Claude Code runs inside a worktree, and would otherwise
334
+ auto-allow every destructive command). Paths are user-expanded and
335
+ normalized so downstream ``directory_is_ephemeral`` comparisons see a
336
+ canonical form on both POSIX and Windows.
337
+ """
338
+ leading_cd_target = _extract_leading_cd_target(command)
339
+ if leading_cd_target is not None:
340
+ return os.path.normpath(os.path.expanduser(leading_cd_target))
341
+ tool_input_cwd_value = tool_input.get("cwd") if isinstance(tool_input, dict) else None
342
+ if isinstance(tool_input_cwd_value, str) and tool_input_cwd_value.strip():
343
+ return os.path.normpath(os.path.expanduser(tool_input_cwd_value))
344
+ return None
345
+
346
+
347
+ def _effective_working_directory_is_ephemeral(command: str, tool_input: dict) -> bool:
348
+ """Return True when the command's declared effective cwd is a specific ephemeral directory.
349
+
350
+ Auto-allow trust model: if the destructive command declares (via leading
351
+ ``cd`` or ``tool_input['cwd']``) that it will execute inside a concrete
352
+ ephemeral directory (a temp-dir subfolder, a git worktrees directory, or
353
+ a subfolder of the OS temp root), treat that directory as a disposable
354
+ trust boundary and skip the destructive-action prompt. Rejects bare
355
+ ephemeral roots (``/tmp``, ``/temp``, the OS temp root, ``/worktrees``,
356
+ ``/worktree``) so auto-allow only triggers inside a named scratch area,
357
+ not at the root of a shared scratch namespace. Returns False when no
358
+ cwd is declared; the narrower target-based auto-allow still applies in
359
+ that case.
360
+ """
361
+ declared_effective_cwd = _resolve_declared_effective_working_directory(command, tool_input)
362
+ if declared_effective_cwd is None:
363
+ return False
364
+ if _path_is_bare_ephemeral_root(declared_effective_cwd):
365
+ return False
366
+ return directory_is_ephemeral(declared_effective_cwd)
367
+
368
+
369
+ CWD_SCOPED_DESTRUCTIVE_DESCRIPTIONS_ELIGIBLE_FOR_BROAD_EPHEMERAL_AUTO_ALLOW = (
370
+ "rm -rf",
371
+ "rm --recursive",
372
+ "git reset --hard",
373
+ )
374
+
375
+
376
+ def _destructive_match_is_cwd_scoped(matched_description: str) -> bool:
377
+ """Return True when the matched destructive pattern's blast radius is bounded by cwd.
378
+
379
+ ``rm -rf``, ``rm --recursive``, and ``git reset --hard`` only affect
380
+ files inside the working directory (or paths resolved relative to it
381
+ when the rm target is relative). Patterns whose blast radius is NOT
382
+ bounded by cwd — ``git push --force`` / ``git push -f`` (remote
383
+ history rewrite), ``git clean`` variants (untracked deletion outside
384
+ what the user can audit at the current prompt), ``mkfs`` / ``dd``
385
+ (raw device), ``DROP TABLE`` / ``DROP DATABASE`` / ``TRUNCATE TABLE``
386
+ (database) — must still prompt even when the command runs from an
387
+ ephemeral worktree. Being in a scratch directory is not a trust zone
388
+ for remote or out-of-band effects.
389
+ """
390
+ return matched_description.startswith(
391
+ CWD_SCOPED_DESTRUCTIVE_DESCRIPTIONS_ELIGIBLE_FOR_BROAD_EPHEMERAL_AUTO_ALLOW
392
+ )
393
+
394
+
395
+ def _command_contains_any_non_cwd_scoped_destructive_pattern(command: str) -> bool:
396
+ """Return True when the command matches any destructive pattern outside the cwd-scoped whitelist.
397
+
398
+ ``find_destructive_pattern`` returns the *first* match in the
399
+ ``DESTRUCTIVE_BASH_PATTERNS`` table, where ``rm -rf`` sits at the
400
+ very front. That means a compound like ``cd /tmp/scratch && rm -rf
401
+ cache && git push --force`` reports ``rm -rf`` to the main gate,
402
+ passes the cwd-scoped whitelist, and ends up auto-allowing the
403
+ remote force-push even though the whitelist docstring says
404
+ non-cwd-scoped patterns must still prompt. This helper scans *every*
405
+ destructive pattern and returns True the moment it finds one that
406
+ is not in the cwd-scoped whitelist, so the broad auto-allow can
407
+ decline the whole command rather than trust the first-match report.
408
+ """
409
+ for each_pattern_regex, each_pattern_description in DESTRUCTIVE_BASH_PATTERNS:
410
+ if each_pattern_regex.search(command) and not each_pattern_description.startswith(
411
+ CWD_SCOPED_DESTRUCTIVE_DESCRIPTIONS_ELIGIBLE_FOR_BROAD_EPHEMERAL_AUTO_ALLOW
412
+ ):
413
+ return True
414
+ return False
415
+
416
+
417
+ def _command_rm_targets_include_unsafe_path(command: str, tool_input: dict) -> bool:
418
+ """Return True when the command contains an ``rm`` whose targets are unsafe.
419
+
420
+ Unsafe means any of: bare ephemeral root (``/tmp``, ``/temp``, the OS
421
+ temp root, ``/worktrees``, ``/worktree``), bare named worktrees
422
+ container, absolute path outside the ephemeral namespace, relative
423
+ path that resolves (against the declared effective cwd) outside the
424
+ ephemeral namespace, wildcard glob metacharacter in the target
425
+ basename, or unsafe ``rm`` flag before ``--`` (``--files0-from=...``,
426
+ unknown long option, non-whitelisted short flag) as enforced by
427
+ ``_rm_flags_before_double_dash_are_unsafe``.
428
+
429
+ Fails closed: returns True on parse failure (``ValueError`` from
430
+ unbalanced quotes) or when a relative target is encountered without
431
+ a declared effective cwd to resolve it against. The broad auto-allow
432
+ must decline rather than grant on input the hook cannot conclusively
433
+ bound.
434
+ """
435
+ try:
436
+ all_command_tokens = _split_command_preserving_windows_backslashes(command)
437
+ except ValueError:
438
+ return True
439
+ declared_effective_cwd = _resolve_declared_effective_working_directory(command, tool_input)
440
+ for each_token_index in range(len(all_command_tokens)):
441
+ if all_command_tokens[each_token_index] != "rm":
442
+ continue
443
+ tokens_after_rm = all_command_tokens[each_token_index + 1:]
444
+ if _rm_flags_before_double_dash_are_unsafe(tokens_after_rm):
445
+ return True
446
+ all_target_tokens = _collect_rm_target_tokens(tokens_after_rm)
447
+ for each_target_token in all_target_tokens:
448
+ each_expanded_target = os.path.expanduser(each_target_token)
449
+ each_is_absolute = (
450
+ os.path.isabs(each_expanded_target)
451
+ or each_expanded_target.replace("\\", "/").startswith("/")
452
+ )
453
+ if each_is_absolute:
454
+ each_resolved_target = os.path.normpath(each_expanded_target)
455
+ else:
456
+ if declared_effective_cwd is None:
457
+ return True
458
+ each_resolved_target = os.path.normpath(
459
+ os.path.join(declared_effective_cwd, each_expanded_target)
460
+ )
461
+ if _path_basename_is_shell_glob_wildcard(each_resolved_target):
462
+ return True
463
+ if _path_is_bare_ephemeral_root(each_resolved_target):
464
+ return True
465
+ if _path_is_bare_named_worktrees_container(each_resolved_target):
466
+ return True
467
+ if not directory_is_ephemeral(each_resolved_target):
468
+ return True
469
+ return False
470
+
471
+
294
472
  def _git_reset_hard_allowed_for_command(command: str, current_working_directory: str) -> bool:
295
473
  if directory_is_ephemeral(current_working_directory):
296
474
  return True
@@ -330,6 +508,15 @@ def main() -> None:
330
508
  if matched_description is not None and targets_only_claude_directory(command):
331
509
  sys.exit(0)
332
510
 
511
+ if (
512
+ matched_description is not None
513
+ and _destructive_match_is_cwd_scoped(matched_description)
514
+ and _effective_working_directory_is_ephemeral(command, tool_input)
515
+ and not _command_rm_targets_include_unsafe_path(command, tool_input)
516
+ and not _command_contains_any_non_cwd_scoped_destructive_pattern(command)
517
+ ):
518
+ sys.exit(0)
519
+
333
520
  if matched_description is not None and _ephemeral_recursive_rm_auto_allow_granted(command, matched_description):
334
521
  sys.exit(0)
335
522
 
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Stop hook that blocks Claude responses asking the user questions in prose.
4
+
5
+ User-directed questions must route through the AskUserQuestion tool so the user
6
+ sees structured options with labels. When the final paragraph of the response
7
+ ends with a question mark or contains a recognized preamble phrase
8
+ ("would you like", "should I", "do you want", "want me to", etc.), Claude is
9
+ forced to re-output the response with the ask moved into an AskUserQuestion
10
+ tool call.
11
+ """
12
+
13
+ import json
14
+ import re
15
+ import sys
16
+ from pathlib import Path
17
+
18
+
19
+ def _insert_hooks_tree_for_imports() -> None:
20
+ hooks_tree = Path(__file__).resolve().parent.parent
21
+ hooks_tree_string = str(hooks_tree)
22
+ if hooks_tree_string not in sys.path:
23
+ sys.path.insert(0, hooks_tree_string)
24
+
25
+
26
+ _insert_hooks_tree_for_imports()
27
+
28
+ from config.messages import USER_FACING_ASKUSERQUESTION_NOTICE
29
+
30
+
31
+ def strip_code_and_quotes(text: str) -> str:
32
+ """Remove code blocks, inline code, and blockquotes to avoid false positives."""
33
+ code_block_pattern = re.compile(r"```[\s\S]*?```", re.MULTILINE)
34
+ inline_code_pattern = re.compile(r"`[^`]+`")
35
+ quoted_block_pattern = re.compile(r"^>.*$", re.MULTILINE)
36
+ text = code_block_pattern.sub("", text)
37
+ text = inline_code_pattern.sub("", text)
38
+ text = quoted_block_pattern.sub("", text)
39
+ return text
40
+
41
+
42
+ def extract_final_paragraph(text: str) -> str:
43
+ """Return the last non-empty paragraph of the prose after stripping code and quotes."""
44
+ paragraph_split_pattern = re.compile(r"\n\s*\n")
45
+ prose_text = strip_code_and_quotes(text)
46
+ candidate_paragraphs = [
47
+ each_paragraph.strip()
48
+ for each_paragraph in paragraph_split_pattern.split(prose_text)
49
+ if each_paragraph.strip()
50
+ ]
51
+ if not candidate_paragraphs:
52
+ return ""
53
+ return candidate_paragraphs[-1]
54
+
55
+
56
+ def find_user_directed_question_indicators(text: str) -> list[str]:
57
+ """Return indicator names for every user-directed question signal in the final paragraph."""
58
+ all_preamble_patterns = [
59
+ (re.compile(regex_text, re.IGNORECASE), display_name)
60
+ for regex_text, display_name in [
61
+ (r"\bwould\s+you\s+like\b", "would you like"),
62
+ (r"\bshould\s+i\b", "should i"),
63
+ (r"\bdo\s+you\s+want\b", "do you want"),
64
+ (r"\bwhich\s+would\s+you\s+prefer\b", "which would you prefer"),
65
+ (r"\blet\s+me\s+know\s+if\b", "let me know if"),
66
+ (r"\blet\s+me\s+know\s+which\b", "let me know which"),
67
+ (r"\blet\s+me\s+know\s+whether\b", "let me know whether"),
68
+ (r"\bplease\s+confirm\b", "please confirm"),
69
+ (r"\bplease\s+let\s+me\s+know\b", "please let me know"),
70
+ (r"\bwant\s+me\s+to\b", "want me to"),
71
+ ]
72
+ ]
73
+ terminal_question_mark_pattern = re.compile(r"\?[\s\"'\)\]\}]*\Z")
74
+ terminal_question_mark_indicator = "terminal question mark in final paragraph"
75
+
76
+ final_paragraph = extract_final_paragraph(text)
77
+ if not final_paragraph:
78
+ return []
79
+
80
+ matched_indicators: list[str] = []
81
+
82
+ if terminal_question_mark_pattern.search(final_paragraph.rstrip()):
83
+ matched_indicators.append(terminal_question_mark_indicator)
84
+
85
+ for each_pattern, each_display_name in all_preamble_patterns:
86
+ if (
87
+ each_pattern.search(final_paragraph)
88
+ and each_display_name not in matched_indicators
89
+ ):
90
+ matched_indicators.append(each_display_name)
91
+
92
+ return matched_indicators
93
+
94
+
95
+ def main() -> None:
96
+ try:
97
+ hook_input = json.load(sys.stdin)
98
+ except json.JSONDecodeError:
99
+ sys.exit(0)
100
+
101
+ if hook_input.get("stop_hook_active", False):
102
+ sys.exit(0)
103
+
104
+ assistant_message = hook_input.get("last_assistant_message", "")
105
+
106
+ if not assistant_message:
107
+ sys.exit(0)
108
+
109
+ matched_indicators = find_user_directed_question_indicators(assistant_message)
110
+
111
+ if not matched_indicators:
112
+ sys.exit(0)
113
+
114
+ formatted_indicator_list = ", ".join(
115
+ f'"{each_indicator}"' for each_indicator in matched_indicators
116
+ )
117
+
118
+ block_response = {
119
+ "decision": "block",
120
+ "reason": (
121
+ f"ASKUSERQUESTION GUARDRAIL: Your response asks the user a question in prose "
122
+ f"(indicators: {formatted_indicator_list}). "
123
+ f"User-directed questions must route through the AskUserQuestion tool so the user "
124
+ f"sees structured options with labels.\n\n"
125
+ f"Re-output your response with the trailing question removed from prose and moved "
126
+ f"into an AskUserQuestion tool call. Rhetorical questions answered in the same "
127
+ f"paragraph are allowed; questions inside code fences, inline code, and blockquotes "
128
+ f"are ignored.\n\n"
129
+ f"You MUST re-output the complete, revised response with the correction applied."
130
+ ),
131
+ "systemMessage": USER_FACING_ASKUSERQUESTION_NOTICE,
132
+ "suppressOutput": True,
133
+ }
134
+
135
+ print(json.dumps(block_response))
136
+ sys.exit(0)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
@@ -29,6 +29,9 @@ code_rules_enforcer = _load_enforcer_module()
29
29
  PRODUCTION_FILE_PATH = "packages/claude-dev-env/hooks/blocking/example_production.py"
30
30
  TEST_FILE_PATH = "packages/claude-dev-env/hooks/blocking/test_example.py"
31
31
  TYPESCRIPT_FILE_PATH = "packages/claude-dev-env/hooks/blocking/example.ts"
32
+ TOP_LEVEL_CONFIG_FILE_PATH = "config/timing.py"
33
+ NESTED_CONFIG_FILE_PATH = "packages/claude-dev-env/hooks/config/example_constants.py"
34
+ BACKSLASH_CONFIG_FILE_PATH = "packages\\claude-dev-env\\hooks\\config\\example_constants.py"
32
35
 
33
36
 
34
37
  def test_should_flag_constant_used_by_only_one_function() -> None:
@@ -181,3 +184,39 @@ def test_should_skip_non_python_files() -> None:
181
184
  source, TYPESCRIPT_FILE_PATH
182
185
  )
183
186
  assert issues == [], f"Expected TypeScript file to be skipped, got: {issues}"
187
+
188
+
189
+ def test_should_exempt_top_level_config_files() -> None:
190
+ source = "UPPER = 1\n\ndef lonely_caller():\n return UPPER\n"
191
+ issues = code_rules_enforcer.check_file_global_constants_use_count(
192
+ source, TOP_LEVEL_CONFIG_FILE_PATH
193
+ )
194
+ assert issues == [], (
195
+ f"Expected config/ file to be exempt per file-global-constants rule, got: {issues}"
196
+ )
197
+
198
+
199
+ def test_should_exempt_nested_config_files() -> None:
200
+ source = (
201
+ "ATTACHMENT_TYPE_HOOK_SUCCESS = 'hook_success'\n"
202
+ "\n"
203
+ "OUTCOME_BY_ATTACHMENT_TYPE = {\n"
204
+ " ATTACHMENT_TYPE_HOOK_SUCCESS: 'success',\n"
205
+ "}\n"
206
+ )
207
+ issues = code_rules_enforcer.check_file_global_constants_use_count(
208
+ source, NESTED_CONFIG_FILE_PATH
209
+ )
210
+ assert issues == [], (
211
+ f"Expected nested config/ file to be exempt per file-global-constants rule, got: {issues}"
212
+ )
213
+
214
+
215
+ def test_should_exempt_backslash_config_path() -> None:
216
+ source = "UPPER = 1\n\ndef lonely_caller():\n return UPPER\n"
217
+ issues = code_rules_enforcer.check_file_global_constants_use_count(
218
+ source, BACKSLASH_CONFIG_FILE_PATH
219
+ )
220
+ assert issues == [], (
221
+ f"Expected backslash config path to be exempt, got: {issues}"
222
+ )