claude-dev-env 1.30.1 → 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.
- package/agents/clean-coder.md +275 -111
- package/agents/code-quality-agent.md +196 -209
- package/bin/install.mjs +81 -0
- package/bin/install.test.mjs +158 -0
- package/bin/install_mypy_ini.mjs +51 -0
- package/bin/install_mypy_ini.test.mjs +121 -0
- package/commands/hook-log-extract.md +70 -0
- package/commands/hook-log-init.md +76 -0
- package/hooks/blocking/code_rules_enforcer.py +5 -3
- package/hooks/blocking/destructive_command_blocker.py +187 -0
- package/hooks/blocking/question_to_user_enforcer.py +140 -0
- package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +39 -0
- package/hooks/blocking/test_destructive_command_blocker.py +397 -0
- package/hooks/blocking/test_question_to_user_enforcer.py +163 -0
- package/hooks/config/hook_log_extractor_constants.py +221 -0
- package/hooks/config/messages.py +3 -0
- package/hooks/config/test_hook_log_extractor_constants.py +96 -0
- package/hooks/config/test_messages.py +5 -0
- package/hooks/diagnostic/hook_log_extractor.py +907 -0
- package/hooks/diagnostic/hook_log_init.py +202 -0
- package/hooks/diagnostic/hook_log_stop_wrapper.py +84 -0
- package/hooks/diagnostic/migrations/2026-04-25-drop-themes-hook-events.sql +3 -0
- package/hooks/diagnostic/migrations/README.md +77 -0
- package/hooks/diagnostic/queries/block_details_for_hook.sql +26 -0
- package/hooks/diagnostic/queries/blocks_by_category.sql +10 -0
- package/hooks/diagnostic/queries/blocks_by_tool.sql +9 -0
- package/hooks/diagnostic/queries/blocks_last_7_days.sql +11 -0
- package/hooks/diagnostic/queries/top_blockers_last_24_hours.sql +12 -0
- package/hooks/diagnostic/queries/top_blockers_overall.sql +12 -0
- package/hooks/diagnostic/requirements-hook-logs-dev.txt +2 -0
- package/hooks/diagnostic/requirements-hook-logs.txt +1 -0
- package/hooks/diagnostic/schema.sql +51 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +1531 -0
- package/hooks/diagnostic/test_hook_log_init.py +227 -0
- package/hooks/diagnostic/test_hook_log_stop_wrapper.py +98 -0
- package/hooks/hooks.json +10 -0
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +44 -0
- package/scripts/config/test_spec_implementer_prompt.py +0 -4
- 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
|
+
|
package/hooks/config/messages.py
CHANGED
|
@@ -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
|