claude-dev-env 8.21.0 → 8.23.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 (45) hide show
  1. package/.agents/skills/eli5/SKILL.md +7 -3
  2. package/.agents/skills/eli5/reference/task-seeds.md +5 -4
  3. package/.agents/skills/sync-dev-env/SKILL.md +51 -0
  4. package/.agents/skills/sync-dev-env/scripts/link_plugin_skills.py +211 -0
  5. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/__init__.py +0 -0
  6. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/config/__init__.py +1 -0
  7. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/config/constants.py +26 -0
  8. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/config/test_constants.py +19 -0
  9. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/test___init__.py +5 -0
  10. package/.agents/skills/sync-dev-env/scripts/test_link_plugin_skills.py +142 -0
  11. package/bin/ever-shipped-skills.mjs +1 -0
  12. package/bin/merge_settings_defaults.test.mjs +43 -0
  13. package/hooks/blocking/edit_marker_gate.py +74 -0
  14. package/hooks/blocking/reply_length_gate.py +119 -0
  15. package/hooks/blocking/test_edit_marker_gate.py +110 -0
  16. package/hooks/blocking/test_reply_length_gate.py +193 -0
  17. package/hooks/hooks.json +25 -0
  18. package/hooks/hooks_constants/advisor_rules_prompt_constants.py +43 -0
  19. package/hooks/hooks_constants/edit_marker_gate_constants.py +18 -0
  20. package/hooks/hooks_constants/reply_length_gate_constants.py +28 -0
  21. package/hooks/hooks_constants/test_edit_marker_gate_constants.py +9 -0
  22. package/hooks/hooks_constants/test_reply_length_gate_constants.py +15 -0
  23. package/hooks/session/advisor_rules_prompt.py +76 -0
  24. package/hooks/session/test_advisor_rules_prompt.py +86 -0
  25. package/package.json +1 -1
  26. package/rules/asd-ste100-language.md +1 -0
  27. package/rules/ci-owns-the-gate.md +28 -0
  28. package/rules/memory-stores-durable-facts.md +57 -0
  29. package/rules/review-closure-is-a-check.md +1 -0
  30. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +4 -0
  31. package/scripts/dev_env_scripts_constants/review_closure_constants.py +13 -0
  32. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +4 -0
  33. package/scripts/invoke_code_review.py +4 -1
  34. package/scripts/local_report_cli.py +50 -53
  35. package/scripts/policy_lint/adapter_configuration.py +8 -0
  36. package/scripts/policy_lint/config/constants.py +2 -0
  37. package/scripts/resolve_worker_spawn.py +4 -1
  38. package/scripts/review_closure_model.py +16 -3
  39. package/scripts/shared_tree_paths.py +27 -0
  40. package/scripts/test_resolve_worker_spawn.py +28 -0
  41. package/scripts/test_shared_tree_paths.py +27 -0
  42. package/scripts/tests/test_adapter_configuration.py +16 -0
  43. package/scripts/tests/test_local_report_publisher_cli.py +54 -0
  44. package/scripts/tests/test_review_closure.py +1 -0
  45. package/scripts/tests/test_review_closure_model.py +70 -0
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook that caps the length of a chat reply before it posts.
3
+
4
+ The gate reads the ``text`` field of the chat tools that post to the user.
5
+ It denies the call when the text holds more than MAXIMUM_SENTENCE_COUNT
6
+ sentences, or a sentence longer than MAXIMUM_WORDS_PER_SENTENCE words.
7
+ Each non-empty line counts as its own sentence, so a list counts one
8
+ sentence per item. URLs, markdown link targets, inline code spans, and
9
+ fenced blocks carry no words.
10
+
11
+ A length limit is a smell elsewhere in this package, recorded and fixed in a
12
+ later pass. A posted reply reaches the user the moment it sends and has no
13
+ later pass, so this gate denies it and the model resends a shorter one.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
22
+ if hooks_root_directory not in sys.path:
23
+ sys.path.insert(0, hooks_root_directory)
24
+
25
+ from hooks_constants.hook_block_logger import log_hook_block
26
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
27
+ from hooks_constants.reply_length_gate_constants import (
28
+ ALL_CHECKED_TOOL_NAMES,
29
+ ALLOW_EXIT_CODE,
30
+ BLOCK_EXIT_CODE,
31
+ FENCED_BLOCK_PATTERN,
32
+ HOOK_EVENT_NAME,
33
+ INLINE_CODE_PATTERN,
34
+ LINE_BREAK_PATTERN,
35
+ LINK_TARGET_PATTERN,
36
+ LONG_SENTENCE_MESSAGE,
37
+ MAXIMUM_SENTENCE_COUNT,
38
+ MAXIMUM_WORDS_PER_SENTENCE,
39
+ RETRY_INSTRUCTION,
40
+ SENTENCE_END_PATTERN,
41
+ SENTENCE_PREVIEW_SUFFIX,
42
+ SENTENCE_PREVIEW_WORD_COUNT,
43
+ TEXT_KEY,
44
+ TOO_MANY_SENTENCES_MESSAGE,
45
+ TOOL_INPUT_KEY,
46
+ TOOL_NAME_KEY,
47
+ URL_PATTERN,
48
+ WORD_PATTERN,
49
+ WORD_SEPARATOR,
50
+ )
51
+
52
+
53
+ def countable_text(reply_text: str) -> str:
54
+ """Remove the spans that carry no words: fenced blocks, code, link targets, URLs."""
55
+ without_fences = FENCED_BLOCK_PATTERN.sub(" ", reply_text)
56
+ without_code = INLINE_CODE_PATTERN.sub(" ", without_fences)
57
+ without_link_targets = LINK_TARGET_PATTERN.sub("]", without_code)
58
+ return URL_PATTERN.sub(" ", without_link_targets)
59
+
60
+
61
+ def sentence_word_lists(reply_text: str) -> list[list[str]]:
62
+ """Split the text into sentences, one per line or terminal mark, as word lists."""
63
+ all_sentence_texts = [
64
+ each_sentence
65
+ for each_line in LINE_BREAK_PATTERN.split(countable_text(reply_text))
66
+ for each_sentence in SENTENCE_END_PATTERN.split(each_line)
67
+ ]
68
+ all_word_lists = [WORD_PATTERN.findall(each_sentence) for each_sentence in all_sentence_texts]
69
+ return [each_word_list for each_word_list in all_word_lists if each_word_list]
70
+
71
+
72
+ def length_violation(reply_text: str) -> str | None:
73
+ """Return the deny reason for an over-long reply, or None when it fits."""
74
+ all_sentences = sentence_word_lists(reply_text)
75
+ if len(all_sentences) > MAXIMUM_SENTENCE_COUNT:
76
+ return TOO_MANY_SENTENCES_MESSAGE.format(
77
+ sentence_count=len(all_sentences), sentence_limit=MAXIMUM_SENTENCE_COUNT
78
+ )
79
+ for each_sentence in all_sentences:
80
+ if len(each_sentence) > MAXIMUM_WORDS_PER_SENTENCE:
81
+ sentence_preview = WORD_SEPARATOR.join(each_sentence[:SENTENCE_PREVIEW_WORD_COUNT])
82
+ return LONG_SENTENCE_MESSAGE.format(
83
+ word_count=len(each_sentence),
84
+ word_limit=MAXIMUM_WORDS_PER_SENTENCE,
85
+ sentence_preview=sentence_preview + SENTENCE_PREVIEW_SUFFIX,
86
+ )
87
+ return None
88
+
89
+
90
+ def main() -> int:
91
+ hook_input = read_hook_input_dictionary_from_stdin()
92
+ if hook_input is None:
93
+ return ALLOW_EXIT_CODE
94
+ tool_name = hook_input.get(TOOL_NAME_KEY)
95
+ if tool_name not in ALL_CHECKED_TOOL_NAMES:
96
+ return ALLOW_EXIT_CODE
97
+ tool_input = hook_input.get(TOOL_INPUT_KEY)
98
+ if not isinstance(tool_input, dict):
99
+ return ALLOW_EXIT_CODE
100
+ reply_text = tool_input.get(TEXT_KEY)
101
+ if not isinstance(reply_text, str):
102
+ return ALLOW_EXIT_CODE
103
+ violation = length_violation(reply_text)
104
+ if violation is None:
105
+ return ALLOW_EXIT_CODE
106
+ block_reason = violation + RETRY_INSTRUCTION
107
+ log_hook_block(
108
+ Path(__file__).name,
109
+ HOOK_EVENT_NAME,
110
+ block_reason,
111
+ tool_name=str(tool_name),
112
+ offending_input_preview=reply_text,
113
+ )
114
+ sys.stderr.write(block_reason)
115
+ return BLOCK_EXIT_CODE
116
+
117
+
118
+ if __name__ == "__main__":
119
+ sys.exit(main())
@@ -0,0 +1,110 @@
1
+ import io
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ import edit_marker_gate
8
+
9
+ UPDATE_TOOL_NAME = "mcp__chat__update_message"
10
+
11
+
12
+ def run_gate(
13
+ monkeypatch: pytest.MonkeyPatch,
14
+ capsys: pytest.CaptureFixture[str],
15
+ tool_name: str,
16
+ tool_input: dict[str, object],
17
+ ) -> tuple[int, str]:
18
+ hook_input = {
19
+ "hook_event_name": "PreToolUse",
20
+ "tool_name": tool_name,
21
+ "tool_input": tool_input,
22
+ }
23
+ monkeypatch.setattr(
24
+ "sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
25
+ )
26
+ exit_code = edit_marker_gate.main()
27
+ return exit_code, capsys.readouterr().err
28
+
29
+
30
+ @pytest.fixture(autouse=True)
31
+ def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
32
+ monkeypatch.setenv("HOME", str(tmp_path))
33
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
34
+
35
+
36
+ def test_should_allow_a_clean_replacement_text(
37
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
38
+ ) -> None:
39
+ exit_code, stderr_text = run_gate(
40
+ monkeypatch, capsys, UPDATE_TOOL_NAME, {"message_id": "m1", "text": "The fix is filed."}
41
+ )
42
+ assert (exit_code, stderr_text) == (0, "")
43
+
44
+
45
+ def test_should_deny_strikethrough_and_ask_for_the_clean_text(
46
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
47
+ ) -> None:
48
+ exit_code, stderr_text = run_gate(
49
+ monkeypatch,
50
+ capsys,
51
+ UPDATE_TOOL_NAME,
52
+ {"text": "~~Please pass this on.~~ The thread files it."},
53
+ )
54
+ assert exit_code == 2
55
+ assert "strikethrough" in stderr_text
56
+ assert "clean new text" in stderr_text
57
+
58
+
59
+ @pytest.mark.parametrize(
60
+ "edited_text",
61
+ [
62
+ "Please pass this on. [Edit: nothing needed from you here.]",
63
+ "Done. [edit: the thread filed it]",
64
+ "Done. [ Edit : filed]",
65
+ ],
66
+ )
67
+ def test_should_deny_an_edit_note_in_any_case(
68
+ monkeypatch: pytest.MonkeyPatch,
69
+ capsys: pytest.CaptureFixture[str],
70
+ edited_text: str,
71
+ ) -> None:
72
+ exit_code, stderr_text = run_gate(monkeypatch, capsys, UPDATE_TOOL_NAME, {"text": edited_text})
73
+ assert exit_code == 2
74
+ assert "[Edit:" in stderr_text
75
+
76
+
77
+ def test_should_deny_a_marker_inside_a_replacement_card(
78
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
79
+ ) -> None:
80
+ card = {"blocks": [{"type": "section", "text": "~~Old plan~~ New plan."}]}
81
+ exit_code, _ = run_gate(
82
+ monkeypatch, capsys, UPDATE_TOOL_NAME, {"text": "New plan.", "card": card}
83
+ )
84
+ assert exit_code == 2
85
+
86
+
87
+ def test_should_allow_markers_on_tools_that_edit_no_message(
88
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
89
+ ) -> None:
90
+ exit_code, _ = run_gate(
91
+ monkeypatch, capsys, "mcp__chat__reply", {"text": "~~old~~ [Edit: new]"}
92
+ )
93
+ assert exit_code == 0
94
+
95
+
96
+ def test_should_allow_a_single_tilde_pair_and_the_word_edit(
97
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
98
+ ) -> None:
99
+ exit_code, _ = run_gate(
100
+ monkeypatch,
101
+ capsys,
102
+ UPDATE_TOOL_NAME,
103
+ {"text": "Edit the file at ~/notes, about ~5 lines."},
104
+ )
105
+ assert exit_code == 0
106
+
107
+
108
+ def test_should_allow_input_without_a_payload(monkeypatch: pytest.MonkeyPatch) -> None:
109
+ monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(b"not json")))
110
+ assert edit_marker_gate.main() == 0
@@ -0,0 +1,193 @@
1
+ import io
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ import reply_length_gate
8
+
9
+ REPLY_TOOL_NAME = "mcp__hearthbot__reply"
10
+ POST_TOOL_NAME = "mcp__hearthbot__post_message"
11
+ SIXTEEN_WORD_SENTENCE = (
12
+ "One two three four five six seven eight nine ten eleven twelve thirteen "
13
+ "fourteen fifteen sixteen."
14
+ )
15
+ FIFTEEN_WORD_SENTENCE = (
16
+ "One two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen."
17
+ )
18
+
19
+
20
+ def run_gate(
21
+ monkeypatch: pytest.MonkeyPatch,
22
+ capsys: pytest.CaptureFixture[str],
23
+ tool_name: str,
24
+ tool_input: dict[str, object],
25
+ ) -> tuple[int, str]:
26
+ hook_input = {
27
+ "hook_event_name": "PreToolUse",
28
+ "tool_name": tool_name,
29
+ "tool_input": tool_input,
30
+ }
31
+ monkeypatch.setattr(
32
+ "sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
33
+ )
34
+ exit_code = reply_length_gate.main()
35
+ return exit_code, capsys.readouterr().err
36
+
37
+
38
+ @pytest.fixture(autouse=True)
39
+ def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
40
+ monkeypatch.setenv("HOME", str(tmp_path))
41
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
42
+
43
+
44
+ def test_should_allow_three_short_sentences(
45
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
46
+ ) -> None:
47
+ exit_code, stderr_text = run_gate(
48
+ monkeypatch,
49
+ capsys,
50
+ REPLY_TOOL_NAME,
51
+ {"text": "The fix is in. Tests pass on CI. It ships with the next release."},
52
+ )
53
+ assert (exit_code, stderr_text) == (0, "")
54
+
55
+
56
+ def test_should_allow_a_fifteen_word_sentence(
57
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
58
+ ) -> None:
59
+ exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": FIFTEEN_WORD_SENTENCE})
60
+ assert exit_code == 0
61
+
62
+
63
+ def test_should_deny_four_sentences_and_name_the_count(
64
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
65
+ ) -> None:
66
+ exit_code, stderr_text = run_gate(
67
+ monkeypatch,
68
+ capsys,
69
+ REPLY_TOOL_NAME,
70
+ {"text": "One. Two. Three. Four."},
71
+ )
72
+ assert exit_code == 2
73
+ assert "4 sentences" in stderr_text
74
+ assert "3" in stderr_text
75
+
76
+
77
+ def test_should_deny_a_sixteen_word_sentence_and_name_it(
78
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
79
+ ) -> None:
80
+ exit_code, stderr_text = run_gate(
81
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": SIXTEEN_WORD_SENTENCE}
82
+ )
83
+ assert exit_code == 2
84
+ assert "16 words" in stderr_text
85
+ assert "15" in stderr_text
86
+ assert "One two three" in stderr_text
87
+
88
+
89
+ def test_should_check_the_project_chat_post_tool(
90
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
91
+ ) -> None:
92
+ exit_code, _ = run_gate(monkeypatch, capsys, POST_TOOL_NAME, {"text": "One. Two. Three. Four."})
93
+ assert exit_code == 2
94
+
95
+
96
+ def test_should_count_each_line_of_a_list_as_a_sentence(
97
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
98
+ ) -> None:
99
+ exit_code, stderr_text = run_gate(
100
+ monkeypatch,
101
+ capsys,
102
+ REPLY_TOOL_NAME,
103
+ {"text": "Done:\n- first item\n- second item\n- third item"},
104
+ )
105
+ assert exit_code == 2
106
+ assert "4 sentences" in stderr_text
107
+
108
+
109
+ def test_should_not_count_urls_as_words(
110
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
111
+ ) -> None:
112
+ long_url = "https://example.com/" + "/".join(["segment"] * 20)
113
+ exit_code, _ = run_gate(
114
+ monkeypatch,
115
+ capsys,
116
+ REPLY_TOOL_NAME,
117
+ {"text": f"The draft is up at {long_url} for review."},
118
+ )
119
+ assert exit_code == 0
120
+
121
+
122
+ def test_should_not_count_link_targets_as_words(
123
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
124
+ ) -> None:
125
+ exit_code, _ = run_gate(
126
+ monkeypatch,
127
+ capsys,
128
+ REPLY_TOOL_NAME,
129
+ {
130
+ "text": "Read [the pull request](https://example.com/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p) now."
131
+ },
132
+ )
133
+ assert exit_code == 0
134
+
135
+
136
+ def test_should_not_count_backticked_spans_as_words(
137
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
138
+ ) -> None:
139
+ exit_code, _ = run_gate(
140
+ monkeypatch,
141
+ capsys,
142
+ REPLY_TOOL_NAME,
143
+ {
144
+ "text": "Run `one two three four five six seven eight nine ten eleven twelve thirteen` now."
145
+ },
146
+ )
147
+ assert exit_code == 0
148
+
149
+
150
+ def test_should_not_count_fenced_blocks(
151
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
152
+ ) -> None:
153
+ fenced_block = "```\nOne.\nTwo.\nThree.\nFour.\n" + SIXTEEN_WORD_SENTENCE + "\n```"
154
+ exit_code, _ = run_gate(
155
+ monkeypatch,
156
+ capsys,
157
+ REPLY_TOOL_NAME,
158
+ {"text": f"Paste this block.\n{fenced_block}"},
159
+ )
160
+ assert exit_code == 0
161
+
162
+
163
+ def test_should_not_split_on_decimal_points(
164
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
165
+ ) -> None:
166
+ exit_code, _ = run_gate(
167
+ monkeypatch,
168
+ capsys,
169
+ REPLY_TOOL_NAME,
170
+ {"text": "Version 4.5 shipped. Speed rose 2.5 times. Nothing else moved."},
171
+ )
172
+ assert exit_code == 0
173
+
174
+
175
+ def test_should_allow_other_tools(
176
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
177
+ ) -> None:
178
+ exit_code, _ = run_gate(
179
+ monkeypatch, capsys, "mcp__other__reply", {"text": "One. Two. Three. Four."}
180
+ )
181
+ assert exit_code == 0
182
+
183
+
184
+ def test_should_allow_a_call_without_text(
185
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
186
+ ) -> None:
187
+ exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"card": {"blocks": []}})
188
+ assert exit_code == 0
189
+
190
+
191
+ def test_should_allow_empty_stdin(monkeypatch: pytest.MonkeyPatch) -> None:
192
+ monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(b"")))
193
+ assert reply_length_gate.main() == 0
package/hooks/hooks.json CHANGED
@@ -52,6 +52,26 @@
52
52
  }
53
53
  ]
54
54
  },
55
+ {
56
+ "matcher": "mcp__hearthbot__reply|mcp__hearthbot__post_message",
57
+ "hooks": [
58
+ {
59
+ "type": "command",
60
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/reply_length_gate.py",
61
+ "timeout": 10
62
+ }
63
+ ]
64
+ },
65
+ {
66
+ "matcher": "mcp__.*__update_message",
67
+ "hooks": [
68
+ {
69
+ "type": "command",
70
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/edit_marker_gate.py",
71
+ "timeout": 10
72
+ }
73
+ ]
74
+ },
55
75
  {
56
76
  "matcher": "*",
57
77
  "hooks": [
@@ -109,6 +129,11 @@
109
129
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/working_style_prompt.py",
110
130
  "timeout": 10
111
131
  },
132
+ {
133
+ "type": "command",
134
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/advisor_rules_prompt.py",
135
+ "timeout": 10
136
+ },
112
137
  {
113
138
  "type": "command",
114
139
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/orchestrator_auto_starter.py",
@@ -0,0 +1,43 @@
1
+ """Advisor consult guidance for the SessionStart advisor_rules_prompt hook.
2
+
3
+ Each sentence in ``ALL_ADVISOR_RULE_SENTENCES`` is copied word for word from
4
+ ``docs/references/advisor-tool.md``, which stays the one source. A
5
+ synchronization test fails when a sentence drifts from that document.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __all__ = [
11
+ "ADVISOR_DISABLE_ENV_VAR",
12
+ "ADVISOR_MODEL_SETTINGS_KEY",
13
+ "ADVISOR_RULES_HEADER",
14
+ "ADVISOR_RULES_PROMPT",
15
+ "ALL_ADVISOR_RULE_SENTENCES",
16
+ "ALL_ADVISOR_DISABLE_ENV_TRUE_VALUES",
17
+ "CLAUDE_CONFIG_DIR_ENV_VAR",
18
+ "DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME",
19
+ "USER_SETTINGS_FILE_NAME",
20
+ ]
21
+
22
+ ADVISOR_MODEL_SETTINGS_KEY = "advisorModel"
23
+ ADVISOR_DISABLE_ENV_VAR = "CLAUDE_CODE_DISABLE_ADVISOR_TOOL"
24
+ ALL_ADVISOR_DISABLE_ENV_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
25
+ CLAUDE_CONFIG_DIR_ENV_VAR = "CLAUDE_CONFIG_DIR"
26
+ DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME = ".claude"
27
+ USER_SETTINGS_FILE_NAME = "settings.json"
28
+
29
+ ADVISOR_RULES_HEADER = (
30
+ "ADVISOR, when `advisor` is in your tool list. Use your own judgment on when a"
31
+ " consult helps. Starting points from ~/.claude/docs/references/advisor-tool.md:"
32
+ )
33
+
34
+ ALL_ADVISOR_RULE_SENTENCES = (
35
+ "Call for design, architecture, and risk questions where you will not touch a file.",
36
+ "On tasks longer than a few steps, aim for an early approach consult and a completion review.",
37
+ "This cadence guides planning and leaves the task free to follow its evidence.",
38
+ "Short reactive tasks may use the single consult that best fits the live decision.",
39
+ "Ask the advisor to hunt for missing requirements, untested behavior, wrong assumptions, unhandled edge cases, evidence gaps, and early completion claims.",
40
+ "Give the advice serious weight.",
41
+ )
42
+
43
+ ADVISOR_RULES_PROMPT = " ".join((ADVISOR_RULES_HEADER, *ALL_ADVISOR_RULE_SENTENCES))
@@ -0,0 +1,18 @@
1
+ """Constants for the edit marker gate PreToolUse hook."""
2
+
3
+ import re
4
+
5
+ MESSAGE_EDIT_TOOL_SUFFIX = "update_message"
6
+ TOOL_NAME_KEY = "tool_name"
7
+ TOOL_INPUT_KEY = "tool_input"
8
+ HOOK_EVENT_NAME = "PreToolUse"
9
+ ALLOW_EXIT_CODE = 0
10
+ BLOCK_EXIT_CODE = 2
11
+ STRIKETHROUGH_PATTERN = re.compile(r"~~[^~]+~~")
12
+ EDIT_NOTE_PATTERN = re.compile(r"\[\s*edit\s*:", re.IGNORECASE)
13
+ STRIKETHROUGH_MESSAGE = "Edited message holds strikethrough (~~text~~)."
14
+ EDIT_NOTE_MESSAGE = 'Edited message holds an "[Edit:" note.'
15
+ RETRY_INSTRUCTION = (
16
+ " An edit replaces the whole message. Resend the clean new text,"
17
+ " with nothing kept from the old version."
18
+ )
@@ -0,0 +1,28 @@
1
+ """Constants for the reply length gate PreToolUse hook."""
2
+
3
+ import re
4
+
5
+ MAXIMUM_SENTENCE_COUNT = 3
6
+ MAXIMUM_WORDS_PER_SENTENCE = 15
7
+ ALL_CHECKED_TOOL_NAMES = frozenset({"mcp__hearthbot__reply", "mcp__hearthbot__post_message"})
8
+ TOOL_NAME_KEY = "tool_name"
9
+ TOOL_INPUT_KEY = "tool_input"
10
+ TEXT_KEY = "text"
11
+ HOOK_EVENT_NAME = "PreToolUse"
12
+ ALLOW_EXIT_CODE = 0
13
+ BLOCK_EXIT_CODE = 2
14
+ FENCED_BLOCK_PATTERN = re.compile(r"```.*?(?:```|\Z)", re.DOTALL)
15
+ INLINE_CODE_PATTERN = re.compile(r"`[^`\n]*`")
16
+ LINK_TARGET_PATTERN = re.compile(r"\]\([^)\s]*\)")
17
+ URL_PATTERN = re.compile(r"(?:https?://|www\.)\S+")
18
+ LINE_BREAK_PATTERN = re.compile(r"\n+")
19
+ SENTENCE_END_PATTERN = re.compile(r"(?<=[.!?])\s+")
20
+ WORD_PATTERN = re.compile(r"[A-Za-z0-9]+(?:['.,-][A-Za-z0-9]+)*")
21
+ SENTENCE_PREVIEW_WORD_COUNT = 6
22
+ WORD_SEPARATOR = " "
23
+ SENTENCE_PREVIEW_SUFFIX = "..."
24
+ RETRY_INSTRUCTION = " Cut the text and resend the call."
25
+ TOO_MANY_SENTENCES_MESSAGE = "Reply too long: {sentence_count} sentences, limit {sentence_limit}."
26
+ LONG_SENTENCE_MESSAGE = (
27
+ 'Sentence too long: {word_count} words, limit {word_limit}: "{sentence_preview}".'
28
+ )
@@ -0,0 +1,9 @@
1
+ from hooks_constants import edit_marker_gate_constants as constants
2
+
3
+
4
+ def test_gate_checks_every_message_edit_tool() -> None:
5
+ assert constants.MESSAGE_EDIT_TOOL_SUFFIX == "update_message"
6
+
7
+
8
+ def test_gate_exit_codes_match_the_hook_contract() -> None:
9
+ assert (constants.ALLOW_EXIT_CODE, constants.BLOCK_EXIT_CODE) == (0, 2)
@@ -0,0 +1,15 @@
1
+ from hooks_constants import reply_length_gate_constants as constants
2
+
3
+
4
+ def test_gate_limits_replies_to_three_sentences_of_fifteen_words() -> None:
5
+ assert (constants.MAXIMUM_SENTENCE_COUNT, constants.MAXIMUM_WORDS_PER_SENTENCE) == (3, 15)
6
+
7
+
8
+ def test_gate_checks_the_two_chat_tools_that_post_to_the_user() -> None:
9
+ assert constants.ALL_CHECKED_TOOL_NAMES == frozenset(
10
+ {"mcp__hearthbot__reply", "mcp__hearthbot__post_message"}
11
+ )
12
+
13
+
14
+ def test_gate_exit_codes_match_the_hook_contract() -> None:
15
+ assert (constants.ALLOW_EXIT_CODE, constants.BLOCK_EXIT_CODE) == (0, 2)
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart hook: inject open advisor consult guidance when the advisor is on.
3
+
4
+ The built-in advisor tool brings its own guidance on when to call it. This hook
5
+ adds the team-advisor starting points, such as a consult on a design question,
6
+ and leaves each call to the session's judgment. It emits them only when the user settings carry an
7
+ ``advisorModel`` value and ``CLAUDE_CODE_DISABLE_ADVISOR_TOOL`` is not set, so a
8
+ session without the advisor is never told to call it.
9
+ The hook writes nothing and runs no tools itself.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
20
+ if _hooks_directory not in sys.path:
21
+ sys.path.insert(0, _hooks_directory)
22
+
23
+ from hooks_constants.advisor_rules_prompt_constants import (
24
+ ADVISOR_DISABLE_ENV_VAR,
25
+ ADVISOR_MODEL_SETTINGS_KEY,
26
+ ADVISOR_RULES_PROMPT,
27
+ ALL_ADVISOR_DISABLE_ENV_TRUE_VALUES,
28
+ CLAUDE_CONFIG_DIR_ENV_VAR,
29
+ DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME,
30
+ USER_SETTINGS_FILE_NAME,
31
+ )
32
+
33
+
34
+ def user_settings_path() -> Path:
35
+ """Return the user settings file under the active Claude config directory."""
36
+ configured_directory = os.environ.get(CLAUDE_CONFIG_DIR_ENV_VAR, "").strip()
37
+ if configured_directory:
38
+ return Path(configured_directory) / USER_SETTINGS_FILE_NAME
39
+ return Path.home() / DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME / USER_SETTINGS_FILE_NAME
40
+
41
+
42
+ def is_advisor_disabled_by_environment() -> bool:
43
+ """Return True when the environment turns the advisor tool off."""
44
+ raw_value = os.environ.get(ADVISOR_DISABLE_ENV_VAR, "").strip().lower()
45
+ return raw_value in ALL_ADVISOR_DISABLE_ENV_TRUE_VALUES
46
+
47
+
48
+ def is_advisor_configured(settings_path: Path) -> bool:
49
+ """Return True when the settings file names a non-empty advisor model."""
50
+ try:
51
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
52
+ except (OSError, ValueError):
53
+ return False
54
+ if not isinstance(settings, dict):
55
+ return False
56
+ advisor_model = settings.get(ADVISOR_MODEL_SETTINGS_KEY)
57
+ return isinstance(advisor_model, str) and advisor_model.strip() != ""
58
+
59
+
60
+ def main() -> None:
61
+ """Emit the advisor guidance as SessionStart additionalContext when the advisor is on."""
62
+ if is_advisor_disabled_by_environment():
63
+ return
64
+ if not is_advisor_configured(user_settings_path()):
65
+ return
66
+ session_start_output = {
67
+ "hookSpecificOutput": {
68
+ "hookEventName": "SessionStart",
69
+ "additionalContext": ADVISOR_RULES_PROMPT,
70
+ }
71
+ }
72
+ sys.stdout.write(json.dumps(session_start_output) + "\n")
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()