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,227 @@
|
|
|
1
|
+
"""Failing-first tests for hook_log_init.
|
|
2
|
+
|
|
3
|
+
Covers: environment variable verification, idempotent DDL apply, sentinel
|
|
4
|
+
insert/select/delete round-trip, and success report format.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest.mock import MagicMock, patch
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
_HOOKS_ROOT = Path(__file__).resolve().parent.parent
|
|
16
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
17
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
18
|
+
|
|
19
|
+
from diagnostic import hook_log_init
|
|
20
|
+
from config import hook_log_extractor_constants
|
|
21
|
+
from config.hook_log_extractor_constants import (
|
|
22
|
+
EXIT_CODE_ENVIRONMENT_MISSING,
|
|
23
|
+
NEON_DATABASE_URL_ENVIRONMENT_VARIABLE,
|
|
24
|
+
OUTCOME_INIT_PROBE,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_main_exits_1_when_neon_database_url_missing(
|
|
29
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
30
|
+
capsys: pytest.CaptureFixture[str],
|
|
31
|
+
) -> None:
|
|
32
|
+
monkeypatch.delenv(NEON_DATABASE_URL_ENVIRONMENT_VARIABLE, raising=False)
|
|
33
|
+
|
|
34
|
+
exit_code = hook_log_init.main()
|
|
35
|
+
|
|
36
|
+
assert exit_code == EXIT_CODE_ENVIRONMENT_MISSING
|
|
37
|
+
captured = capsys.readouterr()
|
|
38
|
+
assert NEON_DATABASE_URL_ENVIRONMENT_VARIABLE in captured.err
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_main_exits_1_when_neon_database_url_is_empty_string(
|
|
42
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
43
|
+
capsys: pytest.CaptureFixture[str],
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Empty NEON_HOOK_LOGS_DATABASE_URL must fire missing-env branch."""
|
|
46
|
+
monkeypatch.setenv(NEON_DATABASE_URL_ENVIRONMENT_VARIABLE, "")
|
|
47
|
+
|
|
48
|
+
exit_code = hook_log_init.main()
|
|
49
|
+
|
|
50
|
+
assert exit_code == EXIT_CODE_ENVIRONMENT_MISSING
|
|
51
|
+
captured = capsys.readouterr()
|
|
52
|
+
assert NEON_DATABASE_URL_ENVIRONMENT_VARIABLE in captured.err
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_main_exits_1_when_neon_database_url_is_whitespace_only(
|
|
56
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
57
|
+
capsys: pytest.CaptureFixture[str],
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Whitespace-only NEON_HOOK_LOGS_DATABASE_URL must fire missing-env branch."""
|
|
60
|
+
monkeypatch.setenv(NEON_DATABASE_URL_ENVIRONMENT_VARIABLE, " ")
|
|
61
|
+
|
|
62
|
+
exit_code = hook_log_init.main()
|
|
63
|
+
|
|
64
|
+
assert exit_code == EXIT_CODE_ENVIRONMENT_MISSING
|
|
65
|
+
captured = capsys.readouterr()
|
|
66
|
+
assert NEON_DATABASE_URL_ENVIRONMENT_VARIABLE in captured.err
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_apply_schema_executes_ddl_from_schema_sql() -> None:
|
|
70
|
+
fake_cursor = MagicMock()
|
|
71
|
+
fake_connection = MagicMock()
|
|
72
|
+
fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
|
|
73
|
+
|
|
74
|
+
hook_log_init.apply_schema(fake_connection)
|
|
75
|
+
|
|
76
|
+
all_executed_statements = [
|
|
77
|
+
each_call.args[0] for each_call in fake_cursor.execute.call_args_list
|
|
78
|
+
]
|
|
79
|
+
joined_schema_text = "\n".join(all_executed_statements)
|
|
80
|
+
assert "hook_events" in joined_schema_text
|
|
81
|
+
assert "CREATE TABLE IF NOT EXISTS" in joined_schema_text
|
|
82
|
+
assert "blocked_commands" in joined_schema_text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_run_sentinel_round_trip_inserts_selects_and_deletes() -> None:
|
|
86
|
+
fake_cursor = MagicMock()
|
|
87
|
+
fake_cursor.fetchone.side_effect = [(1,), (1,)]
|
|
88
|
+
fake_connection = MagicMock()
|
|
89
|
+
fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
|
|
90
|
+
|
|
91
|
+
hook_log_init.run_sentinel_round_trip(fake_connection)
|
|
92
|
+
|
|
93
|
+
all_executed_statements = [
|
|
94
|
+
each_call.args[0].upper() for each_call in fake_cursor.execute.call_args_list
|
|
95
|
+
]
|
|
96
|
+
assert any("INSERT" in each_statement for each_statement in all_executed_statements)
|
|
97
|
+
assert any("SELECT" in each_statement for each_statement in all_executed_statements)
|
|
98
|
+
assert any("DELETE" in each_statement for each_statement in all_executed_statements)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_run_sentinel_round_trip_uses_init_probe_outcome() -> None:
|
|
102
|
+
fake_cursor = MagicMock()
|
|
103
|
+
fake_cursor.fetchone.side_effect = [(1,), (1,)]
|
|
104
|
+
fake_connection = MagicMock()
|
|
105
|
+
fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
|
|
106
|
+
|
|
107
|
+
hook_log_init.run_sentinel_round_trip(fake_connection)
|
|
108
|
+
|
|
109
|
+
all_call_parameters = [
|
|
110
|
+
each_call.args for each_call in fake_cursor.execute.call_args_list
|
|
111
|
+
]
|
|
112
|
+
joined_parameter_text = " ".join(
|
|
113
|
+
str(each_parameter_tuple) for each_parameter_tuple in all_call_parameters
|
|
114
|
+
)
|
|
115
|
+
assert OUTCOME_INIT_PROBE in joined_parameter_text
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_print_success_report_includes_host_table_and_rowcount(
|
|
119
|
+
capsys: pytest.CaptureFixture[str],
|
|
120
|
+
) -> None:
|
|
121
|
+
hook_log_init.print_success_report(
|
|
122
|
+
neon_host="ep-fake-neon.aws.neon.tech",
|
|
123
|
+
table_name="hook_events",
|
|
124
|
+
row_count=42,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
captured = capsys.readouterr()
|
|
128
|
+
assert "ep-fake-neon.aws.neon.tech" in captured.out
|
|
129
|
+
assert "hook_events" in captured.out
|
|
130
|
+
assert "42" in captured.out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_main_happy_path_returns_zero(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
134
|
+
monkeypatch.setenv(
|
|
135
|
+
NEON_DATABASE_URL_ENVIRONMENT_VARIABLE,
|
|
136
|
+
"postgres://u:p@ep-fake-host.aws.neon.tech/db",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
fake_cursor = MagicMock()
|
|
140
|
+
fake_cursor.fetchone.side_effect = [(1,), (1,), (5,)]
|
|
141
|
+
fake_connection = MagicMock()
|
|
142
|
+
fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
|
|
143
|
+
|
|
144
|
+
with patch.object(hook_log_init, "connect_to_neon", return_value=fake_connection):
|
|
145
|
+
exit_code = hook_log_init.main()
|
|
146
|
+
|
|
147
|
+
assert exit_code == 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_connect_to_neon_raises_when_psycopg_missing(
|
|
151
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
152
|
+
) -> None:
|
|
153
|
+
monkeypatch.setenv(
|
|
154
|
+
NEON_DATABASE_URL_ENVIRONMENT_VARIABLE,
|
|
155
|
+
"postgres://u:p@ep-fake-host.aws.neon.tech/db",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
with patch.object(hook_log_init, "psycopg", None):
|
|
159
|
+
with pytest.raises(hook_log_init.MissingPsycopgDependencyError):
|
|
160
|
+
hook_log_init.connect_to_neon()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_run_sentinel_round_trip_raises_when_select_returns_nothing() -> None:
|
|
164
|
+
fake_cursor = MagicMock()
|
|
165
|
+
fake_cursor.fetchone.side_effect = [(1,), None]
|
|
166
|
+
fake_connection = MagicMock()
|
|
167
|
+
fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
|
|
168
|
+
|
|
169
|
+
with pytest.raises(RuntimeError):
|
|
170
|
+
hook_log_init.run_sentinel_round_trip(fake_connection)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_run_sentinel_round_trip_raises_when_select_returns_wrong_id() -> None:
|
|
174
|
+
fake_cursor = MagicMock()
|
|
175
|
+
fake_cursor.fetchone.side_effect = [(1,), (999,)]
|
|
176
|
+
fake_connection = MagicMock()
|
|
177
|
+
fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
|
|
178
|
+
|
|
179
|
+
with pytest.raises(RuntimeError):
|
|
180
|
+
hook_log_init.run_sentinel_round_trip(fake_connection)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_run_sentinel_round_trip_raises_when_insert_returns_no_row() -> None:
|
|
184
|
+
fake_cursor = MagicMock()
|
|
185
|
+
fake_cursor.fetchone.return_value = None
|
|
186
|
+
fake_connection = MagicMock()
|
|
187
|
+
fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
|
|
188
|
+
|
|
189
|
+
with pytest.raises(RuntimeError):
|
|
190
|
+
hook_log_init.run_sentinel_round_trip(fake_connection)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_claude_home_resolver_falls_back_to_home_when_env_var_is_empty(
|
|
194
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Empty CLAUDE_HOME must fall back to ~/.claude (not process CWD)."""
|
|
197
|
+
monkeypatch.setenv("CLAUDE_HOME", "")
|
|
198
|
+
|
|
199
|
+
resolved_home = hook_log_extractor_constants._resolve_claude_home_directory()
|
|
200
|
+
|
|
201
|
+
expected_home = Path.home() / ".claude"
|
|
202
|
+
assert resolved_home == expected_home
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_claude_home_resolver_falls_back_to_home_when_env_var_is_whitespace(
|
|
206
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Whitespace-only CLAUDE_HOME must fall back to ~/.claude."""
|
|
209
|
+
monkeypatch.setenv("CLAUDE_HOME", " ")
|
|
210
|
+
|
|
211
|
+
resolved_home = hook_log_extractor_constants._resolve_claude_home_directory()
|
|
212
|
+
|
|
213
|
+
expected_home = Path.home() / ".claude"
|
|
214
|
+
assert resolved_home == expected_home
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_claude_home_resolver_honors_explicit_path(
|
|
218
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
219
|
+
tmp_path: Path,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Explicit CLAUDE_HOME path must win over the home fallback."""
|
|
222
|
+
explicit_claude_home = tmp_path / "explicit-claude-home"
|
|
223
|
+
monkeypatch.setenv("CLAUDE_HOME", str(explicit_claude_home))
|
|
224
|
+
|
|
225
|
+
resolved_home = hook_log_extractor_constants._resolve_claude_home_directory()
|
|
226
|
+
|
|
227
|
+
assert resolved_home == explicit_claude_home
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Tests for hook_log_stop_wrapper — Stop-hook wrapper that never fails."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
_HOOKS_ROOT = Path(__file__).resolve().parent.parent
|
|
11
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
13
|
+
|
|
14
|
+
from diagnostic import hook_log_stop_wrapper
|
|
15
|
+
from config.hook_log_extractor_constants import (
|
|
16
|
+
BWS_ACCESS_TOKEN_ENV_VAR,
|
|
17
|
+
BWS_EXECUTABLE_NAME,
|
|
18
|
+
FLAG_INCREMENTAL,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_main_returns_zero_when_bws_missing_and_extractor_fails(
|
|
23
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
24
|
+
) -> None:
|
|
25
|
+
monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
|
|
26
|
+
monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
|
|
27
|
+
|
|
28
|
+
def _raise(*_args: object, **_kwargs: object) -> None:
|
|
29
|
+
raise RuntimeError("boom")
|
|
30
|
+
|
|
31
|
+
monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _raise)
|
|
32
|
+
|
|
33
|
+
assert hook_log_stop_wrapper.main() == 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_main_uses_bws_when_token_and_binary_present(
|
|
37
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
38
|
+
) -> None:
|
|
39
|
+
monkeypatch.setenv(BWS_ACCESS_TOKEN_ENV_VAR, "secret-value")
|
|
40
|
+
monkeypatch.setattr(
|
|
41
|
+
hook_log_stop_wrapper.shutil,
|
|
42
|
+
"which",
|
|
43
|
+
lambda _name: "/usr/local/bin/bws",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
captured_commands: list[list[str]] = []
|
|
47
|
+
|
|
48
|
+
def _fake_run(command_list: list[str], **_kwargs: object) -> None:
|
|
49
|
+
captured_commands.append(list(command_list))
|
|
50
|
+
|
|
51
|
+
monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _fake_run)
|
|
52
|
+
|
|
53
|
+
exit_code = hook_log_stop_wrapper.main()
|
|
54
|
+
|
|
55
|
+
assert exit_code == 0
|
|
56
|
+
assert len(captured_commands) == 1
|
|
57
|
+
assert captured_commands[0][0] == BWS_EXECUTABLE_NAME
|
|
58
|
+
assert FLAG_INCREMENTAL in captured_commands[0]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_main_skips_bws_when_token_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
62
|
+
monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
|
|
63
|
+
monkeypatch.setattr(
|
|
64
|
+
hook_log_stop_wrapper.shutil,
|
|
65
|
+
"which",
|
|
66
|
+
lambda _name: "/usr/local/bin/bws",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
captured_commands: list[list[str]] = []
|
|
70
|
+
|
|
71
|
+
def _fake_run(command_list: list[str], **_kwargs: object) -> None:
|
|
72
|
+
captured_commands.append(list(command_list))
|
|
73
|
+
|
|
74
|
+
monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _fake_run)
|
|
75
|
+
|
|
76
|
+
exit_code = hook_log_stop_wrapper.main()
|
|
77
|
+
|
|
78
|
+
assert exit_code == 0
|
|
79
|
+
assert len(captured_commands) == 1
|
|
80
|
+
assert BWS_EXECUTABLE_NAME not in captured_commands[0]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_main_skips_bws_when_binary_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
84
|
+
monkeypatch.setenv(BWS_ACCESS_TOKEN_ENV_VAR, "secret-value")
|
|
85
|
+
monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
|
|
86
|
+
|
|
87
|
+
captured_commands: list[list[str]] = []
|
|
88
|
+
|
|
89
|
+
def _fake_run(command_list: list[str], **_kwargs: object) -> None:
|
|
90
|
+
captured_commands.append(list(command_list))
|
|
91
|
+
|
|
92
|
+
monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _fake_run)
|
|
93
|
+
|
|
94
|
+
exit_code = hook_log_stop_wrapper.main()
|
|
95
|
+
|
|
96
|
+
assert exit_code == 0
|
|
97
|
+
assert len(captured_commands) == 1
|
|
98
|
+
assert BWS_EXECUTABLE_NAME not in captured_commands[0]
|
package/hooks/hooks.json
CHANGED
|
@@ -133,6 +133,16 @@
|
|
|
133
133
|
"type": "command",
|
|
134
134
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/hedging_language_blocker.py",
|
|
135
135
|
"timeout": 10
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"type": "command",
|
|
139
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/question_to_user_enforcer.py",
|
|
140
|
+
"timeout": 10
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"type": "command",
|
|
144
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/diagnostic/hook_log_stop_wrapper.py",
|
|
145
|
+
"timeout": 30
|
|
136
146
|
}
|
|
137
147
|
]
|
|
138
148
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# AskUserQuestion Required
|
|
2
|
+
|
|
3
|
+
**When this applies:** Any time you would ask the user a question during discovery, scoping, or implementation planning — after the `verify-before-asking` decision checklist confirms the question genuinely belongs to the user.
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
Route every user-directed question through the `AskUserQuestion` tool. Embedded plain-text questions in the final paragraph of an assistant message are blocked by a Stop hook, and the response must be re-output with the ask moved into an `AskUserQuestion` tool call.
|
|
8
|
+
|
|
9
|
+
## Detection Criteria
|
|
10
|
+
|
|
11
|
+
The `question_to_user_enforcer` Stop hook inspects the last non-empty paragraph of the response after stripping fenced code blocks, inline code (backticks), and blockquoted lines (`> …`). The response is blocked when either signal is present:
|
|
12
|
+
|
|
13
|
+
- The final paragraph's last sentence ends with a question mark.
|
|
14
|
+
- The final paragraph contains any of these preamble phrases (case-insensitive, word-boundary matched): `would you like`, `should I`, `do you want`, `which would you prefer`, `let me know if`, `let me know which`, `let me know whether`, `please confirm`, `please let me know`, `want me to`.
|
|
15
|
+
|
|
16
|
+
## Acceptable Plain-Text Question Patterns
|
|
17
|
+
|
|
18
|
+
These remain allowed and do not trigger the hook:
|
|
19
|
+
|
|
20
|
+
- **Rhetorical questions answered in the same paragraph.** `"What happens if the queue is empty? The handler short-circuits cleanly."` The question frames its own answer; the reader never has to respond.
|
|
21
|
+
- **Questions inside code, diffs, or documentation excerpts.** Code fences, inline backticks, and `>` blockquotes are stripped before detection. Quoting a GitHub issue title, a user's prior message, or a log line inside a blockquote is fine.
|
|
22
|
+
- **Middle-paragraph questions when the closing paragraph is declarative.** Only the final paragraph is scanned.
|
|
23
|
+
|
|
24
|
+
## AskUserQuestion Structure
|
|
25
|
+
|
|
26
|
+
When a question is genuinely for the user, call the tool with:
|
|
27
|
+
|
|
28
|
+
- A concise `question` string stating what is needed.
|
|
29
|
+
- A `header` of twelve characters or fewer summarizing the decision.
|
|
30
|
+
- Two to four `options`, each with a short `label` the user can pick. An "Other" free-text fallback is already provided by the UI; do not add one manually.
|
|
31
|
+
- `multiSelect: false` unless the user can genuinely combine choices.
|
|
32
|
+
|
|
33
|
+
## Why
|
|
34
|
+
|
|
35
|
+
- **Structured options reduce re-reading friction.** The user sees labeled choices directly rather than scanning prose for the ask.
|
|
36
|
+
- **Transcript clarity.** Tool-use entries are easy to locate in the JSONL transcript; prose questions disappear into the response text.
|
|
37
|
+
- **Reduced drift.** Claude's next turn cannot move past an unanswered structured question; prose questions can be silently bypassed.
|
|
38
|
+
|
|
39
|
+
## Enforcement
|
|
40
|
+
|
|
41
|
+
- Hook: `packages/claude-dev-env/hooks/blocking/question_to_user_enforcer.py`, registered on the `Stop` matcher in `packages/claude-dev-env/hooks/hooks.json`.
|
|
42
|
+
- Loop prevention: the hook honors Claude Code's `stop_hook_active` flag and does not re-block on retry.
|
|
43
|
+
- User-facing notice: `USER_FACING_ASKUSERQUESTION_NOTICE` in `packages/claude-dev-env/hooks/config/messages.py`.
|
|
44
|
+
- Related rule: `packages/claude-dev-env/rules/verify-before-asking.md` gates whether the question belongs to the user in the first place.
|
|
@@ -21,10 +21,6 @@ def _load_config_module():
|
|
|
21
21
|
groq_bugteam_config = _load_config_module()
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def test_spec_implementer_prompt_exists():
|
|
25
|
-
assert hasattr(groq_bugteam_config, "SPEC_IMPLEMENTER_SYSTEM_PROMPT")
|
|
26
|
-
|
|
27
|
-
|
|
28
24
|
def test_spec_implementer_prompt_is_non_empty_string():
|
|
29
25
|
prompt_text = groq_bugteam_config.SPEC_IMPLEMENTER_SYSTEM_PROMPT
|
|
30
26
|
assert isinstance(prompt_text, str)
|
|
@@ -36,14 +36,6 @@ def _load_spec_module():
|
|
|
36
36
|
groq_bugteam_spec = _load_spec_module()
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
def test_apply_fix_from_spec_is_callable():
|
|
40
|
-
assert callable(groq_bugteam_spec.apply_fix_from_spec)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def test_run_spec_mode_main_is_callable():
|
|
44
|
-
assert callable(groq_bugteam_spec.run_spec_mode_main)
|
|
45
|
-
|
|
46
|
-
|
|
47
39
|
def test_is_spec_mode_invocation_detects_flag_value_pair():
|
|
48
40
|
assert groq_bugteam_spec.is_spec_mode_invocation(["--mode", "spec"]) is True
|
|
49
41
|
assert groq_bugteam_spec.is_spec_mode_invocation(["--mode", "pipeline"]) is False
|