claude-dev-env 8.21.0 → 8.22.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 (40) hide show
  1. package/.agents/skills/sync-dev-env/SKILL.md +51 -0
  2. package/.agents/skills/sync-dev-env/scripts/link_plugin_skills.py +211 -0
  3. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/__init__.py +0 -0
  4. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/config/__init__.py +1 -0
  5. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/config/constants.py +26 -0
  6. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/config/test_constants.py +19 -0
  7. package/.agents/skills/sync-dev-env/scripts/sync_dev_env_constants/test___init__.py +5 -0
  8. package/.agents/skills/sync-dev-env/scripts/test_link_plugin_skills.py +142 -0
  9. package/bin/ever-shipped-skills.mjs +1 -0
  10. package/bin/merge_settings_defaults.test.mjs +43 -0
  11. package/hooks/blocking/edit_marker_gate.py +74 -0
  12. package/hooks/blocking/reply_length_gate.py +119 -0
  13. package/hooks/blocking/test_edit_marker_gate.py +110 -0
  14. package/hooks/blocking/test_reply_length_gate.py +193 -0
  15. package/hooks/hooks.json +20 -0
  16. package/hooks/hooks_constants/edit_marker_gate_constants.py +18 -0
  17. package/hooks/hooks_constants/reply_length_gate_constants.py +28 -0
  18. package/hooks/hooks_constants/test_edit_marker_gate_constants.py +9 -0
  19. package/hooks/hooks_constants/test_reply_length_gate_constants.py +15 -0
  20. package/package.json +1 -1
  21. package/rules/asd-ste100-language.md +1 -0
  22. package/rules/ci-owns-the-gate.md +28 -0
  23. package/rules/memory-stores-durable-facts.md +57 -0
  24. package/rules/review-closure-is-a-check.md +1 -0
  25. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +4 -0
  26. package/scripts/dev_env_scripts_constants/review_closure_constants.py +13 -0
  27. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +4 -0
  28. package/scripts/invoke_code_review.py +4 -1
  29. package/scripts/local_report_cli.py +50 -53
  30. package/scripts/policy_lint/adapter_configuration.py +8 -0
  31. package/scripts/policy_lint/config/constants.py +2 -0
  32. package/scripts/resolve_worker_spawn.py +4 -1
  33. package/scripts/review_closure_model.py +16 -3
  34. package/scripts/shared_tree_paths.py +27 -0
  35. package/scripts/test_resolve_worker_spawn.py +28 -0
  36. package/scripts/test_shared_tree_paths.py +27 -0
  37. package/scripts/tests/test_adapter_configuration.py +16 -0
  38. package/scripts/tests/test_local_report_publisher_cli.py +54 -0
  39. package/scripts/tests/test_review_closure.py +1 -0
  40. 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": [
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.21.0",
3
+ "version": "8.22.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,7 @@ definitions and dictionary decisions.
26
26
  - Use inclusive, neutral language.
27
27
  - Use periods, commas, colons, and bullets to show structure.
28
28
  - Preserve exact quoted labels, identifiers, formulas, titles, and interface text.
29
+ - Send the reader only a result, a blocker, or a question. Leave out a line that says nothing is needed from them, and leave out which agent, session, or coordinator did the work.
29
30
  - Use `WARNING` for a risk of injury or death. Use `CAUTION` for a risk of equipment, tool, or machine damage. State the command or condition first, then state the result.
30
31
  - Aim for 20 words or fewer in a procedure sentence when the technical content allows.
31
32
  - Aim for 25 words or fewer in a descriptive sentence when the technical content allows.
@@ -64,6 +64,34 @@ A cache stays available on one condition. CI derives the key itself from the
64
64
  tree it is about to test, looks for a previous run under that key, and
65
65
  republishes that result. CI computes, CI verifies, CI decides.
66
66
 
67
+ ## A repository that runs no CI
68
+
69
+ An owner can rule that a repository spends no CI minutes. There the local gate
70
+ is the gate, and the agent that drives the pull request runs it on every head
71
+ it asks to merge. The repository carries the gate as a `cde verify` manifest at
72
+ `.claude/local-gate.json`: tests with collection floors, and lint.
73
+
74
+ 1. Check out the pull request head with a clean tree and fetch its base.
75
+ 2. Run the manifest against the base commit the pull request names:
76
+
77
+ ```
78
+ python <cde>/scripts/local_verification/cli.py --manifest .claude/local-gate.json --repo . --base <base sha> --output <outside the repository>/report.json
79
+ ```
80
+
81
+ 3. Publish the report:
82
+
83
+ ```
84
+ python <cde>/scripts/local_report_publisher.py --token-environment GH_TOKEN --repository <owner>/<name> --pull-number <number> --local-repo . --manifest .claude/local-gate.json --report <outside the repository>/report.json
85
+ ```
86
+
87
+ The publisher checks the report against the manifest digest, the clean tree,
88
+ and the live head and base, then posts the `local-checks` commit status. A
89
+ report that fails any of those posts a failure or an error. The branch ruleset
90
+ lists `local-checks` as a required status check, so the host refuses a merge
91
+ until a passing report covers the head, and `agent_merge_check.py` prints
92
+ `HOLD` on the same requirement. A push moves the head and leaves the status
93
+ behind, so each new head runs the gate again.
94
+
67
95
  ## Sibling rules
68
96
 
69
97
  | Rule | Role |
@@ -0,0 +1,57 @@
1
+ # Memory Stores Durable Facts
2
+
3
+ **When this applies:** Before you write or update a file in an auto-memory
4
+ directory, and before you add its line to `MEMORY.md`.
5
+
6
+ ## Rule
7
+
8
+ A memory must still help a fresh session, with no context, weeks later.
9
+
10
+ Run one test on the draft. Strip every date, pull request number, issue number,
11
+ commit, job path, session ID, and task number. When what remains is empty or
12
+ false, the draft is run state. Put it in the task tracker, the pull request, or
13
+ a handoff file beside the work, and save no memory.
14
+
15
+ ## What never becomes a memory
16
+
17
+ | Shape | Example | Where it belongs |
18
+ |---|---|---|
19
+ | Run or program state | "paused at", "resume from", "4 of 11 merged", a pointer to a handoff file | The task tracker or the handoff file |
20
+ | A pull request, issue, stack, or commit map | "#583, #605, #610 merged" | The epic issue or the pull request |
21
+ | A temporary condition | A concurrency schedule, credits out, a fleet that works this week, a fix "until X lands" | The current conversation |
22
+ | One session's topology | A session ID, a parallel session's worktree, a second-account dispatch setup | The current conversation |
23
+ | A workaround for a hook, gate, or verifier as it behaved one day | "the minter never fires, so skip the check" | A fix to the hook, per [`correction-lens.md`](correction-lens.md) |
24
+ | The story of one incident or bug fix | "the fold checkbox took five tries" | Git history and the pull request body |
25
+ | Where code lives, or a design recipe for one build phase | A module layout, a prompt recipe for one engine version | The repository and its inventories |
26
+ | A restatement of a rule file | "CI owns the gate" | The rule file already loaded |
27
+
28
+ ## What a memory holds
29
+
30
+ - A preference or standing order the user stated, with the date the user set it.
31
+ - A trap in the local setup or the platform that stays true, such as a shared
32
+ `rr-cache` that replays one-sided resolutions without a marker.
33
+ - A pointer to an external resource: a dashboard, a repository, a tracker.
34
+
35
+ Write the fact first. A date belongs only on a rule the user set on that date.
36
+
37
+ ## Keeping the folder current
38
+
39
+ When a memory goes stale, delete it and its `MEMORY.md` line in the same run.
40
+ A "superseded" note on a stale memory keeps a false fact loading into every
41
+ session.
42
+
43
+ ## Why
44
+
45
+ One project's memory folder held 104 index entries. A cleanup pass removed 57
46
+ of them, and each one failed the test above. Handoffs outlived their work,
47
+ pull request maps named merged branches, and a capacity schedule and a
48
+ dispatch topology contradicted newer standing orders. Every stale memory loads
49
+ into each session and reads as current.
50
+
51
+ ## Sibling rules
52
+
53
+ | Rule | Role |
54
+ |---|---|
55
+ | [`correction-lens.md`](correction-lens.md) | A memory records a decision, and the control holds the behavior |
56
+ | [`verify-before-asking.md`](verify-before-asking.md) | A recalled fact is a claim to re-check |
57
+ | [`cleanup-temp-files.md`](cleanup-temp-files.md) | Scratch output leaves with the task |
@@ -25,6 +25,7 @@ It prints `CLOSED` and exits 0 when every finding on the head is answered. It pr
25
25
  | A thread the driving account opened | Itself |
26
26
  | A blocking `Claude Approvals` row | A push, which moves the head the check reports on |
27
27
  | A top-level comment on the pull request | A later top-level comment from the driving account |
28
+ | A bot notice: a review-skipped note, a pointer to an updated summary, a Graphite verdict mirror, a Qodo change summary, a Qodo in-progress placeholder, or a Qodo review that found no issues | Itself |
28
29
 
29
30
  A review bot rewrites its summary comment on each pass. Its edit leaves an answered summary closed, because each new finding it has arrives as a review thread or a new comment. An edit from a person reopens the comment.
30
31
 
@@ -87,12 +87,16 @@ ROLE_BUGTEAM: str = "bugteam"
87
87
  ROLE_CLEAN_CODER: str = "clean-coder"
88
88
  """Compatibility role for FIX and standards-fix using poteto-agent."""
89
89
 
90
+ ROLE_TRANSLATOR: str = "translator"
91
+ """Role the shared_utils translation chain dispatches, served by poteto-agent."""
92
+
90
93
  DEFAULT_ROLE: str = ROLE_BUGTEAM
91
94
  """Role applied when the caller does not pass ``--role``."""
92
95
 
93
96
  ALL_AGENT_FILENAMES_BY_ROLE: dict[str, tuple[str, ...]] = {
94
97
  ROLE_BUGTEAM: ("poteto-agent.md",),
95
98
  ROLE_CLEAN_CODER: ("poteto-agent.md",),
99
+ ROLE_TRANSLATOR: ("poteto-agent.md",),
96
100
  }
97
101
  """Agent definition filenames required under ``agents/`` for each known role."""
98
102