claude-dev-env 8.76.0 → 8.77.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.
@@ -57,6 +57,7 @@ later pass, so this gate denies it and the model resends a shorter one.
57
57
 
58
58
  from __future__ import annotations
59
59
 
60
+ import json
60
61
  import os
61
62
  import re
62
63
  import sys
@@ -75,9 +76,14 @@ from visual_reply_rules import (
75
76
  second_reply_violation,
76
77
  visual_violation,
77
78
  widget_anchor_violation,
79
+ widget_flowchart_advice,
78
80
  )
79
81
  from hooks_constants.hook_block_logger import log_hook_block
80
82
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
83
+ from hooks_constants.hook_specific_output_keys import (
84
+ HOOK_EVENT_NAME_KEY,
85
+ HOOK_SPECIFIC_OUTPUT_KEY,
86
+ )
81
87
  from hooks_constants.reply_length_gate_constants import (
82
88
  ALL_CHECKED_TOOL_NAMES,
83
89
  ALL_DECISION_CARD_PROSE_KEYS,
@@ -101,6 +107,7 @@ from hooks_constants.reply_length_gate_constants import (
101
107
  EM_DASH_MESSAGE,
102
108
  EM_DASH_PATTERN,
103
109
  FENCED_BLOCK_PATTERN,
110
+ ADDITIONAL_CONTEXT_KEY,
104
111
  HOOK_EVENT_NAME,
105
112
  INLINE_CODE_PATTERN,
106
113
  LINE_BREAK_PATTERN,
@@ -322,6 +329,18 @@ def tool_violation(
322
329
  return None if violation is None else (violation, reply_text)
323
330
 
324
331
 
332
+ def write_flowchart_advice(tool_name: object, all_tool_input: dict[str, object]) -> None:
333
+ """Print the flowchart advice for an allowed widget call that is not a flowchart."""
334
+ mode_rules = active_mode_rules()
335
+ if not isinstance(tool_name, str) or mode_rules is None:
336
+ return
337
+ advice = widget_flowchart_advice(tool_name, all_tool_input, mode_rules)
338
+ if advice is None:
339
+ return
340
+ hook_event_output = {HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME, ADDITIONAL_CONTEXT_KEY: advice}
341
+ sys.stdout.write(json.dumps({HOOK_SPECIFIC_OUTPUT_KEY: hook_event_output}))
342
+
343
+
325
344
  def main() -> int:
326
345
  hook_input = read_hook_input_dictionary_from_stdin()
327
346
  if hook_input is None:
@@ -332,6 +351,7 @@ def main() -> int:
332
351
  return ALLOW_EXIT_CODE
333
352
  checked = tool_violation(tool_name, tool_input, hook_input.get(TRANSCRIPT_PATH_KEY))
334
353
  if checked is None:
354
+ write_flowchart_advice(tool_name, tool_input)
335
355
  return ALLOW_EXIT_CODE
336
356
  violation, reply_text = checked
337
357
  block_reason = violation + RETRY_INSTRUCTION
@@ -635,13 +635,21 @@ def test_should_deny_an_anchor_link_inside_widget_code(
635
635
  assert "does not open in the Claude app" in stderr_text
636
636
 
637
637
 
638
- def test_should_allow_widget_code_with_no_anchor(
638
+ def test_should_allow_widget_code_with_no_anchor_and_advise_a_flowchart(
639
639
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
640
640
  ) -> None:
641
- exit_code, _ = run_gate(
642
- monkeypatch, capsys, WIDGET_TOOL_NAME, {"widget_code": "<div>Three steps</div>"}
641
+ hook_input = {
642
+ "hook_event_name": "PreToolUse",
643
+ "tool_name": WIDGET_TOOL_NAME,
644
+ "tool_input": {"widget_code": "<div>Three steps</div>"},
645
+ }
646
+ monkeypatch.setattr(
647
+ "sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
643
648
  )
649
+ exit_code = reply_length_gate.main()
644
650
  assert exit_code == 0
651
+ advice_output = json.loads(capsys.readouterr().out)
652
+ assert "Redraw this widget as one SVG flowchart" in advice_output["hookSpecificOutput"]["additionalContext"]
645
653
 
646
654
 
647
655
  def test_should_skip_the_mode_checks_when_the_switch_turns_the_mode_off(
@@ -30,7 +30,7 @@ Preconditions:
30
30
 
31
31
  - Run each command from the repository root with Python and pytest available. The tests create transcript and payload fixtures.
32
32
 
33
- - **Reply length.** Input is a reply with four sentences, a plain pull request number, an abbreviation, two sentences with no widget this turn, or widget code with an anchor link. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count, link, or mode reason.
33
+ - **Reply length.** Input is a reply with four sentences, a plain pull request number, an abbreviation, two sentences with no widget this turn, or widget code with an anchor link. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count, link, or mode reason. Widget code with no flowchart passes with a flowchart reminder in `additionalContext`.
34
34
  - **Edit marker.** Input is an update message with strikethrough or an edit note. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_edit_marker_gate.py -q`. The adjacent test observes a denial; clean replacement text passes.
35
35
  - **Handoff close.** Input is an issue comment that says the issue is closed and that the found defect belongs to another issue. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_issue_close_handoff_gate.py -q`. The adjacent test observes a denial; a close with a plain cross-reference and a routing comment on an open issue both pass.
36
36
  - **Session title.** Input is a set session title call with no leading emoji or a name over 25 characters. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_format_gate.py -q`. The adjacent test observes a denial that names the broken rule; a well-formed title passes.
@@ -88,3 +88,5 @@ BANNED_WORD_MESSAGE = 'Banned word "{banned_word}". Delete it and name the evide
88
88
  LONG_SENTENCE_MESSAGE = (
89
89
  'Sentence too long: {word_count} words, limit {word_limit}: "{sentence_preview}".'
90
90
  )
91
+
92
+ ADDITIONAL_CONTEXT_KEY = "additionalContext"
@@ -26,6 +26,7 @@ def test_rule_key_constants_name_rules_in_the_shipped_rule_list() -> None:
26
26
  constants.NO_PICTURE_RULE_KEY,
27
27
  constants.ABBREVIATIONS_RULE_KEY,
28
28
  constants.ONE_ITEM_RULE_KEY,
29
+ constants.FLOWCHART_RULE_KEY,
29
30
  } <= all_shipped_keys
30
31
  assert constants.ALLOWED_CAPITALIZED_WORDS_KEY in rules_document
31
32
  assert constants.LOWERCASE_ABBREVIATIONS_KEY in rules_document
@@ -36,6 +36,15 @@ TRACKER_NUMBER_PATTERN = re.compile(
36
36
  r"\b(?:issues?|items?|cards?|tickets?|tasks?)\s*#?\d+", re.IGNORECASE
37
37
  )
38
38
  WIDGET_ANCHOR_PATTERN = re.compile(r"<a\b[^>]*\bhref\s*=", re.IGNORECASE)
39
+ FLOWCHART_RULE_KEY = "not_flowchart"
40
+ WIDGET_CODE_KEY = "widget_code"
41
+ WIDGET_INPUT_KEY = "input"
42
+ FLOWCHART_SVG_PATTERN = re.compile(r"<svg\b", re.IGNORECASE)
43
+ FLOWCHART_ARROW_PATTERN = re.compile(r"marker-end", re.IGNORECASE)
44
+ FLOWCHART_MARKER_BLOCK_PATTERN = re.compile(r"<marker\b[^>]*(?<!/)>.*?</marker>", re.IGNORECASE | re.DOTALL)
45
+ FLOWCHART_DIAMOND_PATTERN = re.compile(
46
+ r"<polygon\b|<path\b[^>]*\bd=[\"']M[^\"']*L[^\"']*L[^\"']*L[^\"']*Z[\"']", re.IGNORECASE
47
+ )
39
48
 
40
49
  TRANSCRIPT_ENCODING = "utf-8"
41
50
  ENTRY_TYPE_KEY = "type"
@@ -57,6 +66,7 @@ ABBREVIATION_MESSAGE = 'Abbreviation "{abbreviation}" in the text. {reminder}'
57
66
  TRACKER_NUMBER_MESSAGE = 'Tracker number "{reference}" in the text. {reminder}'
58
67
  NO_VISUAL_MESSAGE = "This reply has {sentence_count} sentences and no visual this turn. {reminder}"
59
68
  SECOND_REPLY_MESSAGE = "A reply already went out this turn. {reminder}"
69
+ NOT_FLOWCHART_MESSAGE = "This widget is not a flowchart. {reminder}"
60
70
  WIDGET_ANCHOR_MESSAGE = (
61
71
  "A link sits inside the widget code, and a widget link does not open in the Claude app."
62
72
  " Remove the anchor and put the page link in the reply text."
@@ -24,6 +24,7 @@ CONTEXT_OUTPUT_PATTERN = re.compile(r"additionalContext|ADDITIONAL_CONTEXT|addit
24
24
  UNMEASURED_REASON_BY_SCRIPT = {
25
25
  "blocking/pre_tool_use_dispatcher.py": "relays its hosted hooks' output",
26
26
  "blocking/bash_pre_tool_use_dispatcher.py": "relays its hosted hooks' output",
27
+ "blocking/reply_length_gate.py": "advises only on a widget while visual reply mode is on",
27
28
  "lifecycle/nested_project_hooks.py": "relays a nested project's own hooks",
28
29
  "routing/thread_spawn_pace_hook.py": "quotes a usage reading capped by its own constant",
29
30
  "session/issue_tracker_session_starter.py": "emits only for a repository the user's "
@@ -8,6 +8,7 @@ _hooks_directory = str(Path(__file__).resolve().parent)
8
8
  if _hooks_directory not in sys.path:
9
9
  sys.path.insert(0, _hooks_directory)
10
10
 
11
+ from hooks_constants.visual_reply_rules_constants import RULES_FILE_PATH
11
12
  from visual_reply_rules import (
12
13
  abbreviation_violation,
13
14
  load_rules,
@@ -17,6 +18,7 @@ from visual_reply_rules import (
17
18
  visual_shown_this_turn,
18
19
  visual_violation,
19
20
  widget_anchor_violation,
21
+ widget_flowchart_advice,
20
22
  )
21
23
 
22
24
  SHIPPED_RULES = load_rules()
@@ -162,3 +164,46 @@ def test_second_reply_violation_denies_only_after_a_delivered_reply(tmp_path: Pa
162
164
  )
163
165
  assert second_reply_violation(str(transcript_path), SHIPPED_RULES) is not None
164
166
  assert second_reply_violation(str(tmp_path / "missing.jsonl"), SHIPPED_RULES) is None
167
+
168
+
169
+ FLOWCHART_CODE = (
170
+ '<svg><path d="M0 20L40 0L80 20L40 40Z"/>'
171
+ '<path d="M80 20H120" marker-end="url(#a)"/></svg>'
172
+ )
173
+
174
+
175
+ def test_widget_flowchart_advice_should_pass_an_svg_with_a_diamond_and_arrows() -> None:
176
+ rules = load_rules()
177
+ assert rules is not None
178
+ nested_input = {"input": {"widget_code": FLOWCHART_CODE}}
179
+ assert widget_flowchart_advice("mcp__hearthbot__post_widget", nested_input, rules) is None
180
+
181
+
182
+ def test_widget_flowchart_advice_should_advise_on_cards_and_shapes_without_a_decision() -> None:
183
+ rules = load_rules()
184
+ assert rules is not None
185
+ card_input = {"widget_code": "<div class='card'>Merged</div>"}
186
+ no_diamond_input = {"widget_code": '<svg><path marker-end="url(#a)"/></svg>'}
187
+ arrowhead_only_input = {
188
+ "widget_code": '<svg><marker id="a"><polygon points="0 0,9 5,0 9"/></marker>'
189
+ '<rect/><path marker-end="url(#a)"/></svg>'
190
+ }
191
+ for each_input in (card_input, no_diamond_input, arrowhead_only_input):
192
+ reason = widget_flowchart_advice("mcp__hearthbot__post_widget", each_input, rules)
193
+ assert reason is not None and "diamond" in reason
194
+ assert widget_flowchart_advice("mcp__hearthbot__reply", card_input, rules) is None
195
+
196
+
197
+ def test_widget_flowchart_advice_should_stay_quiet_when_the_rule_list_has_no_flowchart_rule(
198
+ tmp_path: Path,
199
+ ) -> None:
200
+ shipped_document = json.loads(RULES_FILE_PATH.read_text(encoding="utf-8"))
201
+ shipped_document["rules"] = [
202
+ each for each in shipped_document["rules"] if each["key"] != "not_flowchart"
203
+ ]
204
+ rules_path = tmp_path / "rules.json"
205
+ rules_path.write_text(json.dumps(shipped_document), encoding="utf-8")
206
+ rules = load_rules(rules_path)
207
+ assert rules is not None
208
+ card_input = {"widget_code": "<div class='card'>Merged</div>"}
209
+ assert widget_flowchart_advice("mcp__hearthbot__post_widget", card_input, rules) is None
@@ -58,6 +58,14 @@ from hooks_constants.visual_reply_rules_constants import (
58
58
  TRANSCRIPT_ENCODING,
59
59
  USER_ENTRY_TYPE,
60
60
  WIDGET_ANCHOR_MESSAGE,
61
+ FLOWCHART_ARROW_PATTERN,
62
+ FLOWCHART_DIAMOND_PATTERN,
63
+ FLOWCHART_MARKER_BLOCK_PATTERN,
64
+ FLOWCHART_RULE_KEY,
65
+ FLOWCHART_SVG_PATTERN,
66
+ NOT_FLOWCHART_MESSAGE,
67
+ WIDGET_CODE_KEY,
68
+ WIDGET_INPUT_KEY,
61
69
  WIDGET_ANCHOR_PATTERN,
62
70
  WIDGET_TOOL_NAME_SUFFIX,
63
71
  )
@@ -153,6 +161,35 @@ def widget_anchor_violation(tool_name: str, all_tool_input: dict[str, object]) -
153
161
  return WIDGET_ANCHOR_MESSAGE if WIDGET_ANCHOR_PATTERN.search(serialized_input) else None
154
162
 
155
163
 
164
+ def widget_flowchart_advice(
165
+ tool_name: str, all_tool_input: dict[str, object], rules: VisualReplyRules
166
+ ) -> str | None:
167
+ """Return the advice for widget code that is not a flowchart, else None.
168
+
169
+ The gate passes this advice to the session and still allows the call.
170
+
171
+ A flowchart widget is one SVG with at least one arrow marker and one
172
+ diamond shape for a yes or no check. Shapes inside a ``<marker>`` block
173
+ are arrowheads, so they never count as the diamond::
174
+
175
+ <svg><path d="M0 0L9 5L0 9L-9 5Z"/><path marker-end="url(#a)"/></svg>
176
+ """
177
+ all_rule_keys = {each.key for each in rules.all_rules}
178
+ if not tool_name.endswith(WIDGET_TOOL_NAME_SUFFIX) or FLOWCHART_RULE_KEY not in all_rule_keys:
179
+ return None
180
+ widget_code = all_tool_input.get(WIDGET_CODE_KEY)
181
+ if widget_code is None:
182
+ widget_input = all_tool_input.get(WIDGET_INPUT_KEY)
183
+ widget_code = widget_input.get(WIDGET_CODE_KEY) if isinstance(widget_input, dict) else None
184
+ code_text = FLOWCHART_MARKER_BLOCK_PATTERN.sub(
185
+ "", widget_code if isinstance(widget_code, str) else ""
186
+ )
187
+ all_shape_patterns = (FLOWCHART_SVG_PATTERN, FLOWCHART_ARROW_PATTERN, FLOWCHART_DIAMOND_PATTERN)
188
+ if all(each_pattern.search(code_text) for each_pattern in all_shape_patterns):
189
+ return None
190
+ return NOT_FLOWCHART_MESSAGE.format(reminder=rules.reminder(FLOWCHART_RULE_KEY))
191
+
192
+
156
193
  def _parsed_entries(all_transcript_lines: Iterable[str]) -> list[dict[str, object]]:
157
194
  all_entries = []
158
195
  for each_line in all_transcript_lines:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.76.0",
3
+ "version": "8.77.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "key": "no_picture",
10
10
  "label": "No picture",
11
- "reminder": "Show the item as one visual first, a widget or a published page. Then send one reply of one to three short sentences that states the outcome and each fact the user needs, and links the page."
11
+ "reminder": "Show the item as one visual first, a widget or a published page. Prefer a flowchart that runs left to right, with boxes for steps, a diamond for each yes or no check, and arrows between them. Then send one reply of one to three short sentences that states the outcome and each fact the user needs, and links the page."
12
12
  },
13
13
  {
14
14
  "key": "too_many_words",
@@ -25,6 +25,11 @@
25
25
  "label": "Wall of text",
26
26
  "reminder": "Put each point, and each step the reader takes, on its own line."
27
27
  },
28
+ {
29
+ "key": "not_flowchart",
30
+ "label": "No flowchart",
31
+ "reminder": "Redraw this widget as one SVG flowchart that runs left to right, with boxes for steps, a diamond for each yes or no check, and arrows between them, unless the item has no steps or checks."
32
+ },
28
33
  {
29
34
  "key": "use_flowchart",
30
35
  "label": "Use the flowchart",