claude-dev-env 8.73.0 → 8.73.2

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 (98) hide show
  1. package/.agents/agents/test_agent_frontmatter.py +23 -23
  2. package/.agents/agents-archived/clean-coder.md +5 -5
  3. package/.agents/skills/correction/playbooks/fix.md +1 -1
  4. package/.agents/skills/eli5/SKILL.md +3 -3
  5. package/.agents/skills/pr-lifecycle/SKILL.md +1 -1
  6. package/.agents/skills/recovering-codex-startup/SKILL.md +1 -1
  7. package/.agents/skills/recovering-codex-startup/reference/task-seeds.md +1 -1
  8. package/_shared/advisor/reference/third-party-bind.md +1 -1
  9. package/bin/install.mjs +1 -0
  10. package/bin/install.policy-lint.test.mjs +1 -0
  11. package/bin/install.profile-root.test.mjs +1 -1
  12. package/bin/install.test.mjs +1 -0
  13. package/commands/fix-followups.md +1 -1
  14. package/commands/sr-loop.md +1 -1
  15. package/docs/references/prose-style-enforcement.md +1 -1
  16. package/docs/references/working-style.md +1 -1
  17. package/docs/rule-guides/asd-ste100-language.md +9 -1
  18. package/docs/rule-guides/cleanup-temp-files.md +8 -2
  19. package/docs/rule-guides/destructive-commands.md +13 -3
  20. package/docs/rule-guides/explore-thoroughly.md +9 -1
  21. package/docs/rule-guides/features-start-with-an-eval.md +8 -2
  22. package/docs/rule-guides/filesystem-search.md +7 -1
  23. package/docs/rule-guides/long-horizon-autonomy.md +1 -1
  24. package/docs/rule-guides/memory-stores-durable-facts.md +9 -3
  25. package/docs/rule-guides/no-contrast-framing.md +10 -4
  26. package/docs/rule-guides/prefer-existing-tools.md +2 -2
  27. package/docs/rule-guides/proof-before-pull-request.md +5 -1
  28. package/docs/rule-guides/request-scope.md +5 -1
  29. package/docs/rule-guides/research-mode.md +9 -1
  30. package/docs/rule-guides/session-title.md +7 -1
  31. package/docs/rule-guides/shell-invocation.md +9 -1
  32. package/docs/rule-guides/verify-before-asking.md +7 -1
  33. package/docs/rule-guides/verify-runtime-state.md +7 -1
  34. package/hooks/blocking/code_rules_enforcer.py +16 -6
  35. package/hooks/blocking/code_rules_shared.py +23 -4
  36. package/hooks/blocking/reply_length_gate.py +74 -14
  37. package/hooks/blocking/state_description_blocker.py +1 -1
  38. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +54 -1
  39. package/hooks/blocking/test_code_rules_shared.py +33 -0
  40. package/hooks/blocking/test_reply_length_gate.py +184 -5
  41. package/hooks/blocking/test_state_description_blocker.py +1 -1
  42. package/hooks/features/conduct-gates.md +3 -6
  43. package/hooks/hooks.json +1 -6
  44. package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -1
  45. package/hooks/hooks_constants/reply_length_gate_constants.py +1 -0
  46. package/hooks/hooks_constants/session_env_cleanup_constants.py +0 -2
  47. package/hooks/hooks_constants/test_visual_reply_rules_constants.py +15 -0
  48. package/hooks/hooks_constants/test_windows_filesystem.py +57 -0
  49. package/hooks/hooks_constants/visual_reply_rules_constants.py +63 -0
  50. package/hooks/hooks_constants/windows_filesystem.py +51 -0
  51. package/hooks/session/session_env_cleanup.py +4 -30
  52. package/hooks/test_visual_reply_rules.py +164 -0
  53. package/hooks/visual_reply_rules.py +268 -0
  54. package/hooks/workflow/test_auto_formatter.py +4 -25
  55. package/package.json +1 -1
  56. package/rules/index.md +30 -0
  57. package/rules/visual-reply-rules.json +41 -0
  58. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  59. package/scripts/conftest.py +13 -5
  60. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +2 -2
  61. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +12 -0
  62. package/scripts/policy_lint/adapter_configuration.py +0 -5
  63. package/scripts/policy_lint/config/constants.py +0 -1
  64. package/scripts/test__code_review_test_support.py +40 -0
  65. package/scripts/test_active_capability_references.py +0 -5
  66. package/scripts/test_broker_state_isolation.py +3 -3
  67. package/scripts/test_codec_forwarding_test_support.py +28 -0
  68. package/scripts/test_digest_check.py +0 -5
  69. package/scripts/test_durable_post_lint.py +1 -5
  70. package/scripts/test_followup_cli.py +0 -6
  71. package/scripts/test_resolve_worker_spawn.py +5 -10
  72. package/scripts/test_setup_project_paths.py +0 -12
  73. package/scripts/test_shared_tree_paths.py +2 -6
  74. package/scripts/test_validate_instruction_pairs.py +0 -7
  75. package/scripts/test_verify_installable_package.py +0 -5
  76. package/scripts/tests/test_contrast_framing.py +3 -3
  77. package/scripts/tests/test_rule_index_shape.py +59 -5
  78. package/scripts/tests/test_rule_load_scopes.py +1 -16
  79. package/system-prompts/software-engineer.xml +4 -4
  80. package/hooks/blocking/artifact_look_gate.py +0 -223
  81. package/hooks/blocking/test_artifact_look_gate.py +0 -153
  82. package/hooks/hooks_constants/artifact_look_gate_constants.py +0 -32
  83. package/rules/asd-ste100-language.md +0 -9
  84. package/rules/cleanup-temp-files.md +0 -9
  85. package/rules/destructive-commands.md +0 -11
  86. package/rules/explore-thoroughly.md +0 -9
  87. package/rules/features-start-with-an-eval.md +0 -9
  88. package/rules/filesystem-search.md +0 -9
  89. package/rules/memory-stores-durable-facts.md +0 -9
  90. package/rules/no-contrast-framing.md +0 -9
  91. package/rules/proof-before-pull-request.md +0 -7
  92. package/rules/request-scope.md +0 -7
  93. package/rules/research-mode.md +0 -9
  94. package/rules/session-title.md +0 -9
  95. package/rules/shell-invocation.md +0 -9
  96. package/rules/skill-pointers.md +0 -3
  97. package/rules/verify-before-asking.md +0 -9
  98. package/rules/verify-runtime-state.md +0 -9
@@ -118,9 +118,10 @@ from code_rules_paths_syspath import ( # noqa: E402
118
118
  check_hardcoded_user_paths,
119
119
  check_sys_path_insert_deduplication_guard,
120
120
  )
121
- from code_rules_shared import ( # noqa: E402
121
+ from code_rules_shared import (
122
122
  changed_line_numbers,
123
123
  get_file_extension,
124
+ is_agent_home_tooling_outside_working_directory,
124
125
  is_ephemeral_script_path,
125
126
  is_hook_infrastructure,
126
127
  is_test_file,
@@ -754,6 +755,13 @@ def prior_and_post_edit_content(
754
755
  return existing_content, apply_edits(existing_content, [one_edit])
755
756
 
756
757
 
758
+ def _is_exempt_tooling(file_path: str, repository_root: str) -> bool:
759
+ """Return whether the path is hook infrastructure or agent-home tooling outside the cwd."""
760
+ return is_hook_infrastructure(file_path) or is_agent_home_tooling_outside_working_directory(
761
+ file_path, repository_root
762
+ )
763
+
764
+
757
765
  def _is_validated_target(file_path: str, repository_root: str = "") -> bool:
758
766
  """Return whether the path is subject to code-rules validation.
759
767
 
@@ -764,14 +772,15 @@ def _is_validated_target(file_path: str, repository_root: str = "") -> bool:
764
772
  scratch merely because the repository sits under ``/tmp``.
765
773
 
766
774
  Returns:
767
- True when the path is non-empty, outside hook infrastructure, and
768
- carries a code extension; False for every exempt path.
775
+ Whether the path is non-empty, outside hook infrastructure, outside
776
+ agent-home tooling that sits beyond ``repository_root``, and carries a
777
+ code extension.
769
778
  """
770
779
  if not file_path:
771
780
  return False
772
781
  if is_ephemeral_script_path(file_path, repository_root):
773
782
  return False
774
- if is_hook_infrastructure(file_path):
783
+ if _is_exempt_tooling(file_path, repository_root):
775
784
  return False
776
785
  return get_file_extension(file_path) in ALL_CODE_EXTENSIONS
777
786
 
@@ -791,13 +800,14 @@ def _is_hook_infrastructure_python_target(file_path: str, repository_root: str =
791
800
  scratch merely because the repository sits under ``/tmp``.
792
801
 
793
802
  Returns:
794
- True when the path names a Python file inside hook infrastructure.
803
+ Whether the path names a Python file inside hook infrastructure or
804
+ inside agent-home tooling that sits beyond ``repository_root``.
795
805
  """
796
806
  if not file_path:
797
807
  return False
798
808
  if is_ephemeral_script_path(file_path, repository_root):
799
809
  return False
800
- if not is_hook_infrastructure(file_path):
810
+ if not _is_exempt_tooling(file_path, repository_root):
801
811
  return False
802
812
  return get_file_extension(file_path) in ALL_PYTHON_EXTENSIONS
803
813
 
@@ -69,6 +69,27 @@ def is_agent_home_tooling(file_path: str) -> bool:
69
69
  return any(each_pattern in path_lower for each_pattern in ALL_AGENT_HOME_TOOLING_PATTERNS)
70
70
 
71
71
 
72
+ def is_agent_home_tooling_outside_working_directory(file_path: str, repository_root: str) -> bool:
73
+ """Check whether a path is agent-home tooling that sits outside the session cwd.
74
+
75
+ A repository checked out under an agent home, such as
76
+ ``~/.grok/runs/<repo>/``, is project code whenever the session works inside
77
+ it, so only agent-home paths outside the payload ``cwd`` stay exempt.
78
+
79
+ Args:
80
+ file_path: The candidate path to classify.
81
+ repository_root: The PreToolUse payload's ``cwd``, or an empty string
82
+ when the payload names none.
83
+
84
+ Returns:
85
+ Whether the path sits under a recognized agent home directory and
86
+ outside ``repository_root``.
87
+ """
88
+ return is_agent_home_tooling(file_path) and not _is_at_or_under_payload_working_directory(
89
+ file_path, {"cwd": repository_root}
90
+ )
91
+
92
+
72
93
  def is_test_file(file_path: str) -> bool:
73
94
  """Check if file is a test file."""
74
95
  path_lower = file_path.lower()
@@ -502,7 +523,7 @@ def is_ephemeral_path(file_path: str, hook_payload: dict | None = None) -> bool:
502
523
  checked out under ``/tmp`` or ``~/.grok/runs/`` still receives full
503
524
  enforcement. The scratchpad match reads the session id from the payload
504
525
  when supplied, else the harness environment variable. The code-rules and TDD gates call the underlying predicates
505
- ``is_ephemeral_script_path``, ``is_agent_home_tooling``, and
526
+ ``is_ephemeral_script_path``, ``is_agent_home_tooling_outside_working_directory``, and
506
527
  ``is_under_session_scratchpad`` directly.
507
528
 
508
529
  Args:
@@ -517,9 +538,7 @@ def is_ephemeral_path(file_path: str, hook_payload: dict | None = None) -> bool:
517
538
  repository_root = str((hook_payload or {}).get("cwd") or "")
518
539
  if is_ephemeral_script_path(file_path, repository_root):
519
540
  return True
520
- if is_agent_home_tooling(file_path) and not _is_at_or_under_payload_working_directory(
521
- file_path, hook_payload or {}
522
- ):
541
+ if is_agent_home_tooling_outside_working_directory(file_path, repository_root):
523
542
  return True
524
543
  return is_under_session_scratchpad(file_path, hook_payload or {})
525
544
 
@@ -41,6 +41,15 @@ Each non-empty line counts as its own sentence, so a list counts one
41
41
  sentence per item. URLs, markdown link targets, inline code spans, and
42
42
  fenced blocks carry no words.
43
43
 
44
+ With visual reply mode on, the gate also runs the checks in
45
+ ``visual_reply_rules.py``. The mode is off until
46
+ ``~/.claude/visual-reply-mode.json`` holds ``{"enabled": true}``. A reply
47
+ or a decision card may hold no abbreviation and no tracker number outside
48
+ a link. A reply of more than one sentence needs a widget or a page earlier
49
+ in the turn. A turn sends one reply, so a reply after a delivered reply in
50
+ the same turn is denied. A widget may hold no anchor link, since a widget
51
+ link does not open in the Claude app.
52
+
44
53
  A length limit is a smell elsewhere in this package, recorded and fixed in a
45
54
  later pass. A posted reply reaches the user the moment it sends and has no
46
55
  later pass, so this gate denies it and the model resends a shorter one.
@@ -58,6 +67,15 @@ if hooks_root_directory not in sys.path:
58
67
  sys.path.insert(0, hooks_root_directory)
59
68
 
60
69
  from json_file_reader import read_json_object
70
+ from visual_reply_rules import (
71
+ VisualReplyRules,
72
+ abbreviation_violation,
73
+ load_rules,
74
+ mode_enabled,
75
+ second_reply_violation,
76
+ visual_violation,
77
+ widget_anchor_violation,
78
+ )
61
79
  from hooks_constants.hook_block_logger import log_hook_block
62
80
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
63
81
  from hooks_constants.reply_length_gate_constants import (
@@ -101,6 +119,7 @@ from hooks_constants.reply_length_gate_constants import (
101
119
  TOO_MANY_SENTENCES_MESSAGE,
102
120
  TOOL_INPUT_KEY,
103
121
  TOOL_NAME_KEY,
122
+ TRANSCRIPT_PATH_KEY,
104
123
  UNLINKED_PULL_REQUEST_MESSAGE,
105
124
  UNLINKED_PULL_REQUEST_PATTERN,
106
125
  UNSOURCED_CAUSE_MESSAGE,
@@ -176,12 +195,17 @@ def unsourced_cause_violation(reply_text: str) -> str | None:
176
195
  return None
177
196
 
178
197
 
179
- def unlinked_pull_request_violation(reply_text: str) -> str | None:
180
- """Return the deny reason for a pull request number outside a link, or None."""
198
+ def prose_outside_links(reply_text: str) -> str:
199
+ """Remove fenced blocks, code spans, whole markdown links and URLs."""
181
200
  without_fences = FENCED_BLOCK_PATTERN.sub(" ", reply_text)
182
201
  without_code = INLINE_CODE_PATTERN.sub(" ", without_fences)
183
202
  without_links = MARKDOWN_LINK_PATTERN.sub(" ", without_code)
184
- unlinked_match = UNLINKED_PULL_REQUEST_PATTERN.search(URL_PATTERN.sub(" ", without_links))
203
+ return URL_PATTERN.sub(" ", without_links)
204
+
205
+
206
+ def unlinked_pull_request_violation(reply_text: str) -> str | None:
207
+ """Return the deny reason for a pull request number outside a link, or None."""
208
+ unlinked_match = UNLINKED_PULL_REQUEST_PATTERN.search(prose_outside_links(reply_text))
185
209
  if unlinked_match is None:
186
210
  return None
187
211
  return UNLINKED_PULL_REQUEST_MESSAGE.format(reference=unlinked_match.group(0))
@@ -240,17 +264,24 @@ def all_card_texts(all_card_input: dict[str, object]) -> list[str]:
240
264
  ]
241
265
 
242
266
 
243
- def tool_violation(tool_name: object, all_tool_input: dict[str, object]) -> tuple[str, str] | None:
244
- """Return the deny reason and the checked text for one call, or None when it passes."""
245
- if tool_name == DECISION_CARD_TOOL_NAME:
246
- card_text = CARD_TEXT_SEPARATOR.join(all_card_texts(all_tool_input))
247
- card_violation = banned_word_violation(card_text, configured_banned_words())
248
- return None if card_violation is None else (card_violation, card_text)
249
- if tool_name not in ALL_CHECKED_TOOL_NAMES:
250
- return None
251
- reply_text = all_tool_input.get(TEXT_KEY)
252
- if not isinstance(reply_text, str):
267
+ def active_mode_rules() -> VisualReplyRules | None:
268
+ """Return the visual reply rules when the mode is on and the rule file parses."""
269
+ if not mode_enabled(Path.home() / CLAUDE_HOME_DIRECTORY_NAME):
253
270
  return None
271
+ return load_rules()
272
+
273
+
274
+ def card_violation(card_text: str, mode_rules: VisualReplyRules | None) -> str | None:
275
+ """Return the deny reason for a decision card's prose, or None."""
276
+ return banned_word_violation(card_text, configured_banned_words()) or (
277
+ None if mode_rules is None else abbreviation_violation(prose_outside_links(card_text), mode_rules)
278
+ )
279
+
280
+
281
+ def reply_violation(
282
+ reply_text: str, transcript_path: object, mode_rules: VisualReplyRules | None
283
+ ) -> str | None:
284
+ """Return the deny reason for a reply's text, or None."""
254
285
  violation = (
255
286
  length_violation(reply_text)
256
287
  or mid_sentence_colon_violation(reply_text)
@@ -259,6 +290,35 @@ def tool_violation(tool_name: object, all_tool_input: dict[str, object]) -> tupl
259
290
  or unsourced_cause_violation(reply_text)
260
291
  or banned_word_violation(reply_text, configured_banned_words())
261
292
  )
293
+ if violation is not None or mode_rules is None:
294
+ return violation
295
+ prose_text = prose_outside_links(reply_text)
296
+ return (
297
+ abbreviation_violation(prose_text, mode_rules)
298
+ or second_reply_violation(transcript_path, mode_rules)
299
+ or visual_violation(len(sentence_word_lists(reply_text)), transcript_path, mode_rules)
300
+ )
301
+
302
+
303
+ def tool_violation(
304
+ tool_name: object, all_tool_input: dict[str, object], transcript_path: object = None
305
+ ) -> tuple[str, str] | None:
306
+ """Return the deny reason and the checked text for one call, or None when it passes."""
307
+ mode_rules = active_mode_rules()
308
+ if isinstance(tool_name, str) and mode_rules is not None:
309
+ anchor_violation = widget_anchor_violation(tool_name, all_tool_input)
310
+ if anchor_violation is not None:
311
+ return anchor_violation, str(all_tool_input)
312
+ if tool_name == DECISION_CARD_TOOL_NAME:
313
+ card_text = CARD_TEXT_SEPARATOR.join(all_card_texts(all_tool_input))
314
+ violation = card_violation(card_text, mode_rules)
315
+ return None if violation is None else (violation, card_text)
316
+ if tool_name not in ALL_CHECKED_TOOL_NAMES:
317
+ return None
318
+ reply_text = all_tool_input.get(TEXT_KEY)
319
+ if not isinstance(reply_text, str):
320
+ return None
321
+ violation = reply_violation(reply_text, transcript_path, mode_rules)
262
322
  return None if violation is None else (violation, reply_text)
263
323
 
264
324
 
@@ -270,7 +330,7 @@ def main() -> int:
270
330
  tool_input = hook_input.get(TOOL_INPUT_KEY)
271
331
  if not isinstance(tool_input, dict):
272
332
  return ALLOW_EXIT_CODE
273
- checked = tool_violation(tool_name, tool_input)
333
+ checked = tool_violation(tool_name, tool_input, hook_input.get(TRANSCRIPT_PATH_KEY))
274
334
  if checked is None:
275
335
  return ALLOW_EXIT_CODE
276
336
  violation, reply_text = checked
@@ -500,7 +500,7 @@ def build_deny_payload(deny_reason: str) -> dict[str, object]:
500
500
  "only the current state. For example:\n"
501
501
  ' BAD: "Uses X instead of Y" → GOOD: "Uses X"\n'
502
502
  ' BAD: "Previously configured via Z" → GOOD: "Configured via Z"\n'
503
- "Apply ~/.claude/rules/asd-ste100-language.md for user-facing language."
503
+ "Apply ~/.claude/docs/rule-guides/asd-ste100-language.md for user-facing language."
504
504
  ),
505
505
  },
506
506
  "systemMessage": "Agent wrote comparative/historical language - describe current state only",
@@ -11,6 +11,7 @@ so the repository code rules do not govern it::
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import json
14
15
  import os
15
16
  from subprocess import CompletedProcess
16
17
  import tempfile
@@ -20,8 +21,9 @@ import pytest
20
21
 
21
22
  from blocking import _path_setup # noqa: F401 (pins this checkout ahead of sibling worktrees)
22
23
  import code_rules_enforcer_test_support
24
+ from blocking.code_rules_enforcer import main as enforcer_main
23
25
  from blocking.code_rules_shared import is_agent_home_tooling, is_ephemeral_path
24
- from code_rules_enforcer_test_support import run_precheck
26
+ from code_rules_enforcer_test_support import run_precheck, run_serialized_payload_entrypoint
25
27
 
26
28
  _VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"
27
29
 
@@ -122,3 +124,54 @@ def test_enforcer_still_flags_the_same_source_in_a_project_path() -> None:
122
124
  completed = _check_as(_VIOLATING_PRODUCTION_SOURCE, target)
123
125
 
124
126
  assert completed.returncode != 0
127
+
128
+
129
+ def _run_enforcer_with_payload(all_pretooluse_payload: dict[str, object]) -> str:
130
+ """Drive the enforcer's stdin entry point and return what it wrote to stdout."""
131
+ captured_stdout, _exit_code = run_serialized_payload_entrypoint(
132
+ enforcer_main, json.dumps(all_pretooluse_payload)
133
+ )
134
+ return captured_stdout
135
+
136
+
137
+ def test_enforcer_applies_every_rule_to_a_repository_checked_out_under_the_agent_home(
138
+ tmp_path_factory: pytest.TempPathFactory,
139
+ ) -> None:
140
+ home_root = tmp_path_factory.mktemp("agent-home")
141
+ repository_directory = home_root / "home" / "example" / ".grok" / "runs" / "checkout"
142
+ service_module = repository_directory / "src" / "service.py"
143
+ service_module.parent.mkdir(parents=True)
144
+ service_module.write_text(
145
+ "def process_data(payload: str) -> None:\n return None\n", encoding="utf-8"
146
+ )
147
+
148
+ captured_stdout = _run_enforcer_with_payload(
149
+ {
150
+ "tool_name": "Edit",
151
+ "tool_input": {
152
+ "file_path": str(service_module),
153
+ "old_string": " return None\n",
154
+ "new_string": " print(payload)\n",
155
+ },
156
+ "cwd": str(repository_directory),
157
+ }
158
+ )
159
+
160
+ assert '"permissionDecision": "deny"' in captured_stdout
161
+ assert "Library print()" in captured_stdout
162
+ assert "banned noun word" in captured_stdout
163
+
164
+
165
+ def test_enforcer_keeps_agent_home_tooling_outside_the_cwd_exempt() -> None:
166
+ helper_script = os.path.join("/home/example", ".grok", "runs", "worktree-health", "health.py")
167
+ repository_directory = os.path.join("/home/example", ".grok", "runs", "checkout")
168
+
169
+ captured_stdout = _run_enforcer_with_payload(
170
+ {
171
+ "tool_name": "Write",
172
+ "tool_input": {"file_path": helper_script, "content": _VIOLATING_PRODUCTION_SOURCE},
173
+ "cwd": repository_directory,
174
+ }
175
+ )
176
+
177
+ assert captured_stdout == ""
@@ -374,3 +374,36 @@ def test_ephemeral_path_stays_true_for_agent_home_file_outside_payload_cwd() ->
374
374
  helper_script = os.path.join("/home/example", ".grok", "runs", "worktree-health", "health.py")
375
375
 
376
376
  assert _SHARED_MODULE.is_ephemeral_path(helper_script, {"cwd": checkout_directory}) is True
377
+
378
+
379
+ def test_agent_home_tooling_under_the_cwd_is_not_exempt() -> None:
380
+ checkout_directory = os.path.join("/home/example", ".grok", "runs", "checkout")
381
+ repository_module = os.path.join(checkout_directory, "src", "service.py")
382
+
383
+ assert (
384
+ _SHARED_MODULE.is_agent_home_tooling_outside_working_directory(
385
+ repository_module, checkout_directory
386
+ )
387
+ is False
388
+ )
389
+
390
+
391
+ def test_agent_home_tooling_outside_the_cwd_is_exempt() -> None:
392
+ checkout_directory = os.path.join("/home/example", ".grok", "runs", "checkout")
393
+ helper_script = os.path.join("/home/example", ".grok", "runs", "worktree-health", "health.py")
394
+
395
+ assert _SHARED_MODULE.is_agent_home_tooling_outside_working_directory(
396
+ helper_script, checkout_directory
397
+ )
398
+
399
+
400
+ def test_agent_home_tooling_with_no_cwd_is_exempt() -> None:
401
+ helper_script = os.path.join("/home/example", ".grok", "runs", "worktree-health", "health.py")
402
+
403
+ assert _SHARED_MODULE.is_agent_home_tooling_outside_working_directory(helper_script, "")
404
+
405
+
406
+ def test_hook_infrastructure_excludes_agent_home_tooling() -> None:
407
+ helper_script = os.path.join("/home/example", ".grok", "runs", "worktree-health", "health.py")
408
+
409
+ assert _SHARED_MODULE.is_hook_infrastructure(helper_script) is False
@@ -22,12 +22,15 @@ def run_gate(
22
22
  capsys: pytest.CaptureFixture[str],
23
23
  tool_name: str,
24
24
  tool_input: dict[str, object],
25
+ transcript_path: Path | None = None,
25
26
  ) -> tuple[int, str]:
26
- hook_input = {
27
+ hook_input: dict[str, object] = {
27
28
  "hook_event_name": "PreToolUse",
28
29
  "tool_name": tool_name,
29
30
  "tool_input": tool_input,
30
31
  }
32
+ if transcript_path is not None:
33
+ hook_input["transcript_path"] = str(transcript_path)
31
34
  monkeypatch.setattr(
32
35
  "sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
33
36
  )
@@ -39,6 +42,9 @@ def run_gate(
39
42
  def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
40
43
  monkeypatch.setenv("HOME", str(tmp_path))
41
44
  monkeypatch.setenv("USERPROFILE", str(tmp_path))
45
+ switch_path = tmp_path / ".claude" / "visual-reply-mode.json"
46
+ switch_path.parent.mkdir(exist_ok=True)
47
+ switch_path.write_text('{"enabled": true}', encoding="utf-8")
42
48
 
43
49
 
44
50
  def test_should_allow_three_short_sentences(
@@ -48,7 +54,7 @@ def test_should_allow_three_short_sentences(
48
54
  monkeypatch,
49
55
  capsys,
50
56
  REPLY_TOOL_NAME,
51
- {"text": "The fix is in. Tests pass on CI. It ships with the next release."},
57
+ {"text": "The fix is in. Tests pass. It ships with the next release."},
52
58
  )
53
59
  assert (exit_code, stderr_text) == (0, "")
54
60
 
@@ -292,7 +298,7 @@ def test_should_replace_the_defaults_with_the_configured_list(
292
298
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
293
299
  ) -> None:
294
300
  config_path = tmp_path / ".claude" / "reply-banned-words.json"
295
- config_path.parent.mkdir()
301
+ config_path.parent.mkdir(exist_ok=True)
296
302
  config_path.write_text(json.dumps({"banned_words": ["synergy"]}), encoding="utf-8")
297
303
  likely_exit_code, _ = run_gate(
298
304
  monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "It likely passed."}
@@ -318,7 +324,7 @@ def test_should_keep_the_defaults_when_the_config_file_is_malformed(
318
324
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
319
325
  ) -> None:
320
326
  config_path = tmp_path / ".claude" / "reply-banned-words.json"
321
- config_path.parent.mkdir()
327
+ config_path.parent.mkdir(exist_ok=True)
322
328
  config_path.write_text("{not json", encoding="utf-8")
323
329
  exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "It likely passed."})
324
330
  assert exit_code == 2
@@ -426,7 +432,7 @@ def test_should_apply_the_configured_list_to_decision_cards(
426
432
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
427
433
  ) -> None:
428
434
  config_path = tmp_path / ".claude" / "reply-banned-words.json"
429
- config_path.parent.mkdir()
435
+ config_path.parent.mkdir(exist_ok=True)
430
436
  config_path.write_text(json.dumps({"banned_words": ["synergy"]}), encoding="utf-8")
431
437
  probably_exit_code, _ = run_gate(
432
438
  monkeypatch,
@@ -489,6 +495,179 @@ def test_should_deny_an_em_dash_and_allow_one_in_code(
489
495
  assert "em dash" in denied_text
490
496
 
491
497
 
498
+ WIDGET_TOOL_NAME = "mcp__hearthbot__post_widget"
499
+ TWO_SENTENCE_REPLY = "The deploy stopped on a missing file. The page shows each step."
500
+
501
+
502
+ def write_transcript(transcript_path: Path, all_entries: list[dict[str, object]]) -> Path:
503
+ transcript_path.write_text(
504
+ "\n".join(json.dumps(each_entry) for each_entry in all_entries), encoding="utf-8"
505
+ )
506
+ return transcript_path
507
+
508
+
509
+ def prompt_entry(text: str) -> dict[str, object]:
510
+ return {"type": "user", "message": {"role": "user", "content": text}}
511
+
512
+
513
+ def tool_call_entry(tool_name: str) -> dict[str, object]:
514
+ return {
515
+ "type": "assistant",
516
+ "message": {"content": [{"type": "tool_use", "id": "t1", "name": tool_name, "input": {}}]},
517
+ }
518
+
519
+
520
+ def turn_mode_off(tmp_path: Path) -> None:
521
+ switch_path = tmp_path / ".claude" / "visual-reply-mode.json"
522
+ switch_path.parent.mkdir(exist_ok=True)
523
+ switch_path.write_text('{"enabled": false}', encoding="utf-8")
524
+
525
+
526
+ def test_should_deny_an_abbreviation_in_a_reply(
527
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
528
+ ) -> None:
529
+ exit_code, stderr_text = run_gate(
530
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "The API deploy is fixed."}
531
+ )
532
+ assert exit_code == 2
533
+ assert 'Abbreviation "API"' in stderr_text
534
+
535
+
536
+ def test_should_allow_an_abbreviation_inside_a_pull_request_link(
537
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
538
+ ) -> None:
539
+ exit_code, _ = run_gate(
540
+ monkeypatch,
541
+ capsys,
542
+ REPLY_TOOL_NAME,
543
+ {"text": "It merged in [PR 5256](https://github.com/owner/repo/pull/5256)."},
544
+ )
545
+ assert exit_code == 0
546
+
547
+
548
+ def test_should_deny_a_tracker_item_number(
549
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
550
+ ) -> None:
551
+ exit_code, stderr_text = run_gate(
552
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "Item 6 is ready for you."}
553
+ )
554
+ assert exit_code == 2
555
+ assert 'Tracker number "Item 6"' in stderr_text
556
+
557
+
558
+ def test_should_deny_an_abbreviation_in_a_decision_card(
559
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
560
+ ) -> None:
561
+ exit_code, stderr_text = run_gate(
562
+ monkeypatch,
563
+ capsys,
564
+ DECISION_TOOL_NAME,
565
+ {"question": "Ship the API fix?", "options": [{"label": "Ship it", "consequence": "It merges."}]},
566
+ )
567
+ assert exit_code == 2
568
+ assert 'Abbreviation "API"' in stderr_text
569
+
570
+
571
+ def test_should_deny_two_sentences_with_no_visual_this_turn(
572
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
573
+ ) -> None:
574
+ transcript_path = write_transcript(
575
+ tmp_path / "t.jsonl",
576
+ [prompt_entry("why did the deploy stop"), tool_call_entry("Bash")],
577
+ )
578
+ exit_code, stderr_text = run_gate(
579
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": TWO_SENTENCE_REPLY}, transcript_path
580
+ )
581
+ assert exit_code == 2
582
+ assert "no visual this turn" in stderr_text
583
+
584
+
585
+ def test_should_allow_two_sentences_after_a_widget_this_turn(
586
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
587
+ ) -> None:
588
+ transcript_path = write_transcript(
589
+ tmp_path / "t.jsonl",
590
+ [prompt_entry("why did the deploy stop"), tool_call_entry(WIDGET_TOOL_NAME)],
591
+ )
592
+ exit_code, _ = run_gate(
593
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": TWO_SENTENCE_REPLY}, transcript_path
594
+ )
595
+ assert exit_code == 0
596
+
597
+
598
+ def test_should_deny_when_the_only_widget_came_before_the_last_prompt(
599
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
600
+ ) -> None:
601
+ transcript_path = write_transcript(
602
+ tmp_path / "t.jsonl",
603
+ [
604
+ prompt_entry("why did the deploy stop"),
605
+ tool_call_entry(WIDGET_TOOL_NAME),
606
+ prompt_entry("and the logs"),
607
+ ],
608
+ )
609
+ exit_code, _ = run_gate(
610
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": TWO_SENTENCE_REPLY}, transcript_path
611
+ )
612
+ assert exit_code == 2
613
+
614
+
615
+ def test_should_allow_one_sentence_with_no_visual(
616
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
617
+ ) -> None:
618
+ transcript_path = write_transcript(tmp_path / "t.jsonl", [prompt_entry("merge it")])
619
+ exit_code, _ = run_gate(
620
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "It merged."}, transcript_path
621
+ )
622
+ assert exit_code == 0
623
+
624
+
625
+ def test_should_deny_an_anchor_link_inside_widget_code(
626
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
627
+ ) -> None:
628
+ exit_code, stderr_text = run_gate(
629
+ monkeypatch,
630
+ capsys,
631
+ WIDGET_TOOL_NAME,
632
+ {"widget_code": '<div><a href="https://claude.ai/artifact/x">Review page</a></div>'},
633
+ )
634
+ assert exit_code == 2
635
+ assert "does not open in the Claude app" in stderr_text
636
+
637
+
638
+ def test_should_allow_widget_code_with_no_anchor(
639
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
640
+ ) -> None:
641
+ exit_code, _ = run_gate(
642
+ monkeypatch, capsys, WIDGET_TOOL_NAME, {"widget_code": "<div>Three steps</div>"}
643
+ )
644
+ assert exit_code == 0
645
+
646
+
647
+ def test_should_skip_the_mode_checks_when_the_switch_turns_the_mode_off(
648
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
649
+ ) -> None:
650
+ turn_mode_off(tmp_path)
651
+ transcript_path = write_transcript(tmp_path / "t.jsonl", [prompt_entry("status")])
652
+ all_exit_codes = [
653
+ run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "The API deploy is fixed."})[0],
654
+ run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "Which build ships first?"})[0],
655
+ run_gate(
656
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": TWO_SENTENCE_REPLY}, transcript_path
657
+ )[0],
658
+ run_gate(monkeypatch, capsys, WIDGET_TOOL_NAME, {"widget_code": '<a href="x">go</a>'})[0],
659
+ ]
660
+ assert all_exit_codes == [0, 0, 0, 0]
661
+
662
+
663
+ def test_should_keep_the_length_check_when_the_mode_is_off(
664
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
665
+ ) -> None:
666
+ turn_mode_off(tmp_path)
667
+ exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "One. Two. Three. Four."})
668
+ assert exit_code == 2
669
+
670
+
492
671
  def test_should_deny_the_quoted_cause_with_no_source(
493
672
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
494
673
  ) -> None:
@@ -741,7 +741,7 @@ def test_additional_context_contains_examples():
741
741
  ctx = output["hookSpecificOutput"].get("additionalContext", "")
742
742
  assert "BAD:" in ctx
743
743
  assert "GOOD:" in ctx
744
- assert "~/.claude/rules/asd-ste100-language.md" in ctx
744
+ assert "~/.claude/docs/rule-guides/asd-ste100-language.md" in ctx
745
745
 
746
746
 
747
747
  def test_handles_non_dict_stdin():