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
@@ -0,0 +1,221 @@
1
+ """Constants for the hook-log extractor and init scripts.
2
+
3
+ Centralizes all named values used by ``hook_log_extractor.py`` and
4
+ ``hook_log_init.py`` so that production modules carry zero magic values.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+
12
+ COMMAND_EXCERPT_MAX_CHARACTERS: int = 300
13
+ STDOUT_EXCERPT_MAX_CHARACTERS: int = 500
14
+ STDERR_EXCERPT_MAX_CHARACTERS: int = 500
15
+
16
+ INSERT_BATCH_SIZE: int = 500
17
+ CONNECT_TIMEOUT_SECONDS: int = 5
18
+
19
+ def _resolve_claude_home_directory() -> Path:
20
+ """Return the root of the local ``~/.claude`` tree, honoring ``CLAUDE_HOME``.
21
+
22
+ An unset, empty, or whitespace-only ``CLAUDE_HOME`` falls back to
23
+ ``~/.claude`` so state and transcript paths do not silently resolve
24
+ to the process working directory.
25
+ """
26
+ claude_home_override = os.environ.get("CLAUDE_HOME", "").strip()
27
+ if claude_home_override:
28
+ return Path(claude_home_override).expanduser()
29
+ return Path.home() / ".claude"
30
+
31
+
32
+ OFFSET_STATE_FILE: str = str(
33
+ _resolve_claude_home_directory()
34
+ / "logs"
35
+ / "hooks"
36
+ / ".state"
37
+ / "offsets.json"
38
+ )
39
+ OFFLINE_WARNING_LOG: str = str(
40
+ _resolve_claude_home_directory() / "logs" / "hook-extractor.log"
41
+ )
42
+ PROJECTS_TRANSCRIPT_ROOT: str = str(_resolve_claude_home_directory() / "projects")
43
+
44
+ NEON_DATABASE_URL_ENVIRONMENT_VARIABLE: str = "NEON_HOOK_LOGS_DATABASE_URL"
45
+
46
+ ATTACHMENT_TYPE_PREFIX: str = "hook_"
47
+ TOP_LEVEL_ATTACHMENT_TYPE: str = "attachment"
48
+
49
+ ATTACHMENT_TYPE_HOOK_SUCCESS: str = "hook_success"
50
+ ATTACHMENT_TYPE_HOOK_BLOCKING_ERROR: str = "hook_blocking_error"
51
+ ATTACHMENT_TYPE_HOOK_SYSTEM_MESSAGE: str = "hook_system_message"
52
+ ATTACHMENT_TYPE_HOOK_ADDITIONAL_CONTEXT: str = "hook_additional_context"
53
+
54
+ OUTCOME_SUCCESS: str = "success"
55
+ OUTCOME_BLOCKED: str = "blocked"
56
+ OUTCOME_NON_BLOCKING_ERROR: str = "non_blocking_error"
57
+ OUTCOME_SYSTEM_MESSAGE: str = "system_message"
58
+ OUTCOME_ADDED_CONTEXT: str = "added_context"
59
+ OUTCOME_INIT_PROBE: str = "init_probe"
60
+
61
+ OUTCOME_BY_ATTACHMENT_TYPE: dict[str, str] = {
62
+ ATTACHMENT_TYPE_HOOK_SUCCESS: OUTCOME_SUCCESS,
63
+ ATTACHMENT_TYPE_HOOK_BLOCKING_ERROR: OUTCOME_BLOCKED,
64
+ "hook_non_blocking_error": OUTCOME_NON_BLOCKING_ERROR,
65
+ ATTACHMENT_TYPE_HOOK_SYSTEM_MESSAGE: OUTCOME_SYSTEM_MESSAGE,
66
+ ATTACHMENT_TYPE_HOOK_ADDITIONAL_CONTEXT: OUTCOME_ADDED_CONTEXT,
67
+ }
68
+
69
+ HOOK_CATEGORY_UNCATEGORIZED: str = "uncategorized"
70
+
71
+ KNOWN_HOOK_CATEGORIES: frozenset[str] = frozenset(
72
+ {
73
+ "advisory",
74
+ "blocking",
75
+ "config",
76
+ "context",
77
+ "diagnostic",
78
+ "git-hooks",
79
+ "github-action",
80
+ "lifecycle",
81
+ "notification",
82
+ "session",
83
+ "system",
84
+ "validation",
85
+ "validators",
86
+ "workflow",
87
+ "worktree",
88
+ },
89
+ )
90
+
91
+ HOOK_NAME_TOOL_SEPARATOR: str = ":"
92
+
93
+ SCHEMA_RELATIVE_PATH: str = "schema.sql"
94
+ QUERIES_DIRECTORY_NAME: str = "queries"
95
+ SQL_FILE_EXTENSION: str = ".sql"
96
+
97
+ DEFAULT_QUERY_FOR_SUMMARY: str = "top_blockers_last_24_hours"
98
+
99
+ JSONL_FILE_GLOB: str = "*.jsonl"
100
+
101
+ FLAG_INCREMENTAL: str = "--incremental"
102
+ FLAG_FULL_REBUILD: str = "--full-rebuild"
103
+ FLAG_SUMMARY: str = "--summary"
104
+ FLAG_QUERY: str = "--query"
105
+
106
+ EXIT_CODE_SUCCESS: int = 0
107
+ EXIT_CODE_ENVIRONMENT_MISSING: int = 1
108
+ EXIT_CODE_EXTRACTOR_ENVIRONMENT_MISSING: int = 0
109
+ EXIT_CODE_UNKNOWN_QUERY: int = 2
110
+
111
+ QUERY_NAME_PATTERN: str = r"[a-z0-9_]+"
112
+
113
+ SENTINEL_SESSION_ID: str = "__init_probe_session__"
114
+ SENTINEL_HOOK_EVENT: str = "InitProbe"
115
+ SENTINEL_HOOK_NAME: str = "init_probe"
116
+ SENTINEL_SOURCE_PATH: str = "__init_probe__"
117
+ SENTINEL_SOURCE_LINE_NUMBER: int = 0
118
+
119
+ SUMMARY_COLUMN_HEADINGS: tuple[str, str, str, str] = (
120
+ "hook_name",
121
+ "hook_category",
122
+ "block_count_last_24_hours",
123
+ "top_blocked_command_preview",
124
+ )
125
+
126
+ SUMMARY_NO_NEW_BLOCKS_MESSAGE: str = "No new blocks since last run."
127
+
128
+ QUERY_NO_ROWS_RETURNED_MESSAGE: str = "No rows returned."
129
+
130
+ TOP_BLOCKED_COMMAND_PREVIEW_MAX_CHARACTERS: int = 80
131
+
132
+ HOOK_EVENTS_TABLE_NAME: str = "hook_events"
133
+
134
+ HOOK_EVENTS_INSERT_SQL: str = (
135
+ "INSERT INTO hook_events ("
136
+ "event_timestamp, session_id, cwd, git_branch, hook_event, hook_name, "
137
+ "hook_category, script_path, tool_name, tool_use_id, outcome, exit_code, "
138
+ "duration_ms, command_excerpt, stdout_excerpt, stderr_excerpt, "
139
+ "source_jsonl_path, source_line_number"
140
+ ") VALUES ("
141
+ "%(event_timestamp)s, %(session_id)s, %(cwd)s, %(git_branch)s, "
142
+ "%(hook_event)s, %(hook_name)s, %(hook_category)s, %(script_path)s, "
143
+ "%(tool_name)s, %(tool_use_id)s, %(outcome)s, %(exit_code)s, "
144
+ "%(duration_ms)s, %(command_excerpt)s, %(stdout_excerpt)s, "
145
+ "%(stderr_excerpt)s, %(source_jsonl_path)s, %(source_line_number)s"
146
+ ") ON CONFLICT (source_jsonl_path, source_line_number) DO NOTHING"
147
+ )
148
+
149
+ HOOK_EVENTS_TRUNCATE_SQL: str = "TRUNCATE TABLE hook_events RESTART IDENTITY"
150
+
151
+ HOOK_EVENTS_ROW_COUNT_SQL: str = "SELECT COUNT(*) FROM hook_events"
152
+
153
+ SENTINEL_INSERT_SQL: str = (
154
+ "INSERT INTO hook_events ("
155
+ "event_timestamp, session_id, hook_event, hook_name, hook_category, "
156
+ "outcome, source_jsonl_path, source_line_number"
157
+ ") VALUES (NOW(), %s, %s, %s, %s, %s, %s, %s) RETURNING id"
158
+ )
159
+
160
+ SENTINEL_SELECT_SQL: str = "SELECT id FROM hook_events WHERE id = %s"
161
+
162
+ SENTINEL_DELETE_SQL: str = "DELETE FROM hook_events WHERE id = %s"
163
+
164
+ TOP_BLOCKERS_LAST_24_HOURS_SQL: str = (
165
+ "SELECT hook_name, hook_category, COUNT(*) AS block_count, "
166
+ "MIN(COALESCE(command_excerpt, stdout_excerpt, stderr_excerpt, '')) "
167
+ "AS top_blocked_command_preview "
168
+ "FROM hook_events WHERE outcome = 'blocked' "
169
+ "AND event_timestamp >= (NOW() - INTERVAL '1 day') "
170
+ "GROUP BY hook_name, hook_category "
171
+ "ORDER BY block_count DESC LIMIT 10"
172
+ )
173
+
174
+ EMPTY_STRING: str = ""
175
+ NEWLINE_JOINER: str = "\n"
176
+ SEMICOLON_SPLIT_TOKEN: str = ";"
177
+
178
+ HOOKS_DIRECTORY_TOKEN: str = "/hooks/"
179
+
180
+ SCRIPT_PATH_PYTHON_PREFIXES: tuple[str, ...] = ("python3 ", "python ")
181
+
182
+ SUMMARY_TABLE_COLUMN_GAP: str = " "
183
+
184
+ CATEGORY_PATH_MINIMUM_PARTS: int = 2
185
+ OFFSETS_JSON_INDENT: int = 2
186
+
187
+ MISSING_ENVIRONMENT_VARIABLE_PREFIX: str = "Missing required environment variable: "
188
+ SUCCESS_REPORT_HEADER: str = "Hook-log init succeeded."
189
+ NEON_HOST_REPORT_LABEL: str = "Neon host:"
190
+ TABLE_REPORT_LABEL: str = "Table:"
191
+ ROW_COUNT_REPORT_LABEL: str = "Row count:"
192
+ UNKNOWN_HOST_PLACEHOLDER: str = "unknown"
193
+ SENTINEL_HOOK_CATEGORY: str = "diagnostic"
194
+
195
+ MISSING_PSYCOPG_WARNING_LABEL: str = "missing_psycopg"
196
+ MISSING_NEON_DATABASE_URL_WARNING_LABEL: str = "missing_neon_database_url"
197
+ LEGACY_OFFSETS_FORMAT_WARNING_LABEL: str = "legacy_offsets_format"
198
+
199
+ SENTINEL_SELECT_FAILURE_MESSAGE: str = (
200
+ "Sentinel SELECT did not return the inserted id; round-trip failed."
201
+ )
202
+
203
+ SENTINEL_INSERT_FAILURE_MESSAGE: str = (
204
+ "Sentinel INSERT did not return a row; round-trip failed."
205
+ )
206
+
207
+ BYTE_OFFSET_KEY: str = "byte_offset"
208
+ LINE_NUMBER_KEY: str = "line_number"
209
+
210
+ UNKNOWN_QUERY_MESSAGE_PREFIX: str = "Unknown query: "
211
+ INVALID_QUERY_NAME_MESSAGE_PREFIX: str = "Invalid query name: "
212
+
213
+ BWS_EXECUTABLE_NAME: str = "bws"
214
+ BWS_ACCESS_TOKEN_ENV_VAR: str = "BWS_ACCESS_TOKEN"
215
+ BWS_RUN_SEPARATOR: str = "--"
216
+ BWS_RUN_SUBCOMMAND: str = "run"
217
+ STOP_WRAPPER_EXTRACTOR_SCRIPT_NAME: str = "hook_log_extractor.py"
218
+
219
+ LOCK_MAXIMUM_RETRY_COUNT: int = 30
220
+ LOCK_RETRY_SLEEP_SECONDS: float = 0.1
221
+
@@ -2,3 +2,6 @@
2
2
 
3
3
  USER_FACING_NOTICE = "Agent was found guessing - sourcing opinions..."
4
4
  USER_FACING_TDD_NOTICE = "TDD gate held - writing the failing test first..."
5
+ USER_FACING_ASKUSERQUESTION_NOTICE = (
6
+ "Agent asked the user in prose - rerouting through AskUserQuestion..."
7
+ )
@@ -0,0 +1,96 @@
1
+ """Behavior tests for query-name pattern and exit-code routing contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ _HOOKS_ROOT = Path(__file__).resolve().parent.parent
10
+ if str(_HOOKS_ROOT) not in sys.path:
11
+ sys.path.insert(0, str(_HOOKS_ROOT))
12
+
13
+ from config import hook_log_extractor_constants
14
+ from config.hook_log_extractor_constants import (
15
+ EXIT_CODE_ENVIRONMENT_MISSING,
16
+ EXIT_CODE_EXTRACTOR_ENVIRONMENT_MISSING,
17
+ EXIT_CODE_SUCCESS,
18
+ EXIT_CODE_UNKNOWN_QUERY,
19
+ LOCK_MAXIMUM_RETRY_COUNT,
20
+ LOCK_RETRY_SLEEP_SECONDS,
21
+ QUERY_NAME_PATTERN,
22
+ SENTINEL_INSERT_FAILURE_MESSAGE,
23
+ SENTINEL_SELECT_FAILURE_MESSAGE,
24
+ )
25
+
26
+
27
+ def _matches_query_pattern(candidate_name: str) -> bool:
28
+ return re.fullmatch(QUERY_NAME_PATTERN, candidate_name) is not None
29
+
30
+
31
+ def test_query_name_pattern_allows_canonical_pre_baked_query_name() -> None:
32
+ assert _matches_query_pattern("top_blockers_last_24_hours")
33
+
34
+
35
+ def test_query_name_pattern_rejects_path_traversal() -> None:
36
+ assert not _matches_query_pattern("../etc/passwd")
37
+
38
+
39
+ def test_query_name_pattern_rejects_uppercase() -> None:
40
+ assert not _matches_query_pattern("TopBlockers")
41
+
42
+
43
+ def test_query_name_pattern_rejects_hyphens() -> None:
44
+ assert not _matches_query_pattern("top-blockers")
45
+
46
+
47
+ def test_query_name_pattern_rejects_empty_string() -> None:
48
+ assert not _matches_query_pattern("")
49
+
50
+
51
+ def test_unknown_query_exit_code_distinguishes_from_success() -> None:
52
+ assert EXIT_CODE_UNKNOWN_QUERY != EXIT_CODE_SUCCESS
53
+
54
+
55
+ def test_unknown_query_exit_code_distinguishes_from_extractor_offline_fallback() -> None:
56
+ assert EXIT_CODE_UNKNOWN_QUERY != EXIT_CODE_EXTRACTOR_ENVIRONMENT_MISSING
57
+
58
+
59
+ def test_unknown_query_exit_code_distinguishes_from_init_environment_missing() -> None:
60
+ assert EXIT_CODE_UNKNOWN_QUERY != EXIT_CODE_ENVIRONMENT_MISSING
61
+
62
+
63
+ def test_extractor_offline_fallback_matches_success_so_stop_hook_does_not_surface_failure() -> None:
64
+ assert EXIT_CODE_EXTRACTOR_ENVIRONMENT_MISSING == EXIT_CODE_SUCCESS
65
+
66
+
67
+ def test_sentinel_insert_failure_message_is_distinct_from_select_failure() -> None:
68
+ assert SENTINEL_INSERT_FAILURE_MESSAGE != SENTINEL_SELECT_FAILURE_MESSAGE
69
+ assert SENTINEL_INSERT_FAILURE_MESSAGE
70
+
71
+
72
+ def test_resolver_falls_back_to_home_when_claude_home_is_empty(
73
+ monkeypatch,
74
+ ) -> None:
75
+ monkeypatch.setenv("CLAUDE_HOME", "")
76
+
77
+ assert (
78
+ hook_log_extractor_constants._resolve_claude_home_directory()
79
+ == Path.home() / ".claude"
80
+ )
81
+
82
+
83
+ def test_resolver_falls_back_to_home_when_claude_home_is_whitespace(
84
+ monkeypatch,
85
+ ) -> None:
86
+ monkeypatch.setenv("CLAUDE_HOME", " ")
87
+
88
+ assert (
89
+ hook_log_extractor_constants._resolve_claude_home_directory()
90
+ == Path.home() / ".claude"
91
+ )
92
+
93
+
94
+ def test_lock_retry_constants_are_positive_and_bounded() -> None:
95
+ assert LOCK_MAXIMUM_RETRY_COUNT > 0
96
+ assert LOCK_RETRY_SLEEP_SECONDS > 0
@@ -15,3 +15,8 @@ def test_user_facing_notice_is_nonempty_string() -> None:
15
15
  def test_user_facing_tdd_notice_is_nonempty_string() -> None:
16
16
  assert isinstance(messages.USER_FACING_TDD_NOTICE, str)
17
17
  assert messages.USER_FACING_TDD_NOTICE
18
+
19
+
20
+ def test_user_facing_askuserquestion_notice_is_nonempty_string() -> None:
21
+ assert isinstance(messages.USER_FACING_ASKUSERQUESTION_NOTICE, str)
22
+ assert messages.USER_FACING_ASKUSERQUESTION_NOTICE