claude-dev-env 8.73.1 → 8.74.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 (116) 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/cde.mjs +1 -0
  10. package/bin/cde.test.mjs +1 -0
  11. package/bin/install.mjs +1 -0
  12. package/bin/install.policy-lint.test.mjs +1 -0
  13. package/bin/install.profile-root.test.mjs +1 -1
  14. package/bin/install.settings-defaults.test.mjs +10 -2
  15. package/bin/install.test.mjs +1 -0
  16. package/bin/merge_settings_defaults.mjs +34 -1
  17. package/bin/merge_settings_defaults.test.mjs +53 -0
  18. package/commands/fix-followups.md +1 -1
  19. package/commands/sr-loop.md +1 -1
  20. package/docs/references/prose-style-enforcement.md +1 -1
  21. package/docs/references/working-style.md +1 -1
  22. package/docs/rule-guides/asd-ste100-language.md +9 -1
  23. package/docs/rule-guides/cleanup-temp-files.md +8 -2
  24. package/docs/rule-guides/destructive-commands.md +13 -3
  25. package/docs/rule-guides/explore-thoroughly.md +9 -1
  26. package/docs/rule-guides/features-start-with-an-eval.md +8 -2
  27. package/docs/rule-guides/filesystem-search.md +7 -1
  28. package/docs/rule-guides/flag-non-breaking-findings.md +3 -0
  29. package/docs/rule-guides/long-horizon-autonomy.md +1 -1
  30. package/docs/rule-guides/memory-stores-durable-facts.md +9 -3
  31. package/docs/rule-guides/no-contrast-framing.md +10 -4
  32. package/docs/rule-guides/prefer-existing-tools.md +2 -2
  33. package/docs/rule-guides/proof-before-pull-request.md +5 -1
  34. package/docs/rule-guides/request-scope.md +5 -1
  35. package/docs/rule-guides/research-mode.md +9 -1
  36. package/docs/rule-guides/session-title.md +7 -1
  37. package/docs/rule-guides/shell-invocation.md +9 -1
  38. package/docs/rule-guides/verify-before-asking.md +7 -1
  39. package/docs/rule-guides/verify-runtime-state.md +7 -1
  40. package/hooks/blocking/code_rules_enforcer.py +16 -6
  41. package/hooks/blocking/code_rules_shared.py +23 -4
  42. package/hooks/blocking/haiku_spawn_slim_gate.py +96 -0
  43. package/hooks/blocking/reply_length_gate.py +74 -14
  44. package/hooks/blocking/state_description_blocker.py +1 -1
  45. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +54 -1
  46. package/hooks/blocking/test_code_rules_shared.py +33 -0
  47. package/hooks/blocking/test_haiku_spawn_slim_gate.py +65 -0
  48. package/hooks/blocking/test_reply_length_gate.py +184 -5
  49. package/hooks/blocking/test_state_description_blocker.py +1 -1
  50. package/hooks/features/conduct-gates.md +3 -6
  51. package/hooks/features/spawn-routing.md +5 -1
  52. package/hooks/followup_ledger.py +52 -2
  53. package/hooks/hooks.json +11 -6
  54. package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -1
  55. package/hooks/hooks_constants/haiku_spawn_slim_gate_constants.py +29 -0
  56. package/hooks/hooks_constants/reply_length_gate_constants.py +1 -0
  57. package/hooks/hooks_constants/session_env_cleanup_constants.py +0 -2
  58. package/hooks/hooks_constants/test_visual_reply_rules_constants.py +15 -0
  59. package/hooks/hooks_constants/test_windows_filesystem.py +57 -0
  60. package/hooks/hooks_constants/visual_reply_rules_constants.py +63 -0
  61. package/hooks/hooks_constants/windows_filesystem.py +51 -0
  62. package/hooks/session/session_env_cleanup.py +4 -30
  63. package/hooks/test_followup_ledger.py +58 -0
  64. package/hooks/test_visual_reply_rules.py +164 -0
  65. package/hooks/visual_reply_rules.py +268 -0
  66. package/hooks/workflow/test_auto_formatter.py +4 -25
  67. package/package.json +1 -1
  68. package/rules/index.md +30 -0
  69. package/rules/visual-reply-rules.json +41 -0
  70. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  71. package/scripts/conftest.py +13 -5
  72. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +2 -2
  73. package/scripts/dev_env_scripts_constants/followup_constants.py +4 -1
  74. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +12 -0
  75. package/scripts/followup_cli.py +25 -1
  76. package/scripts/policy_lint/adapter_configuration.py +0 -5
  77. package/scripts/policy_lint/config/constants.py +1 -1
  78. package/scripts/repository_checks/config/constants.py +6 -0
  79. package/scripts/repository_checks/followup_ledger_duplicates.py +55 -0
  80. package/scripts/repository_checks/runner.py +7 -0
  81. package/scripts/test_active_capability_references.py +0 -5
  82. package/scripts/test_broker_state_isolation.py +3 -3
  83. package/scripts/test_digest_check.py +0 -5
  84. package/scripts/test_durable_post_lint.py +1 -5
  85. package/scripts/test_followup_cli.py +18 -6
  86. package/scripts/test_resolve_worker_spawn.py +5 -10
  87. package/scripts/test_setup_project_paths.py +0 -12
  88. package/scripts/test_shared_tree_paths.py +2 -6
  89. package/scripts/test_validate_instruction_pairs.py +0 -7
  90. package/scripts/test_verify_installable_package.py +0 -5
  91. package/scripts/tests/test_contrast_framing.py +3 -3
  92. package/scripts/tests/test_followup_ledger_duplicates.py +58 -0
  93. package/scripts/tests/test_repository_policy.py +2 -0
  94. package/scripts/tests/test_rule_index_shape.py +59 -5
  95. package/scripts/tests/test_rule_load_scopes.py +1 -16
  96. package/settings.json +9 -0
  97. package/system-prompts/software-engineer.xml +4 -4
  98. package/hooks/blocking/artifact_look_gate.py +0 -223
  99. package/hooks/blocking/test_artifact_look_gate.py +0 -153
  100. package/hooks/hooks_constants/artifact_look_gate_constants.py +0 -32
  101. package/rules/asd-ste100-language.md +0 -9
  102. package/rules/cleanup-temp-files.md +0 -9
  103. package/rules/destructive-commands.md +0 -11
  104. package/rules/explore-thoroughly.md +0 -9
  105. package/rules/features-start-with-an-eval.md +0 -9
  106. package/rules/filesystem-search.md +0 -9
  107. package/rules/memory-stores-durable-facts.md +0 -9
  108. package/rules/no-contrast-framing.md +0 -9
  109. package/rules/proof-before-pull-request.md +0 -7
  110. package/rules/request-scope.md +0 -7
  111. package/rules/research-mode.md +0 -9
  112. package/rules/session-title.md +0 -9
  113. package/rules/shell-invocation.md +0 -9
  114. package/rules/skill-pointers.md +0 -3
  115. package/rules/verify-before-asking.md +0 -9
  116. package/rules/verify-runtime-state.md +0 -9
@@ -1,7 +1,13 @@
1
- Back to the [rule entry](../../rules/no-contrast-framing.md).
1
+ Back to the [rules index](../../rules/index.md).
2
2
 
3
3
  # No contrast framing
4
4
 
5
+ ## In brief
6
+
7
+ State the chosen point directly and leave out the discarded reading. Remove these six forms: `trailing-comma-not`, `corrective-it-is-not`, `substitution-rather-than`, `additive-not-just`, `comparative-ranking`, `substitution-as-opposed-to`. Keep quantity comparisons. If a sentence loses substance, add the failing check, log line, measured number, or file and line.
8
+
9
+ **Enforcement:** staged policy lint and `scripts/durable_post_lint.py` check authored text; the agent checks chat.
10
+
5
11
  **When this applies:** Every sentence a person reads. A chat reply, a commit
6
12
  message, a pull request title or body, a review comment, an issue, a rule file,
7
13
  a document, a plan, a memory file.
@@ -55,7 +61,7 @@ file and the line.
55
61
  The pattern list lives in
56
62
  `scripts/dev_env_scripts_constants/contrast_framing_constants.py`, and both
57
63
  lints read that one list. A synchronization test requires the rule file
58
- `rules/no-contrast-framing.md` to name every form the list carries.
64
+ `docs/rule-guides/no-contrast-framing.md` to name every form the list carries.
59
65
 
60
66
  A chat reply reaches no check, so the same list is what the writer reads the
61
67
  sentence against: a comma followed by `not`, a `rather than`, a `not just`, a
@@ -65,6 +71,6 @@ ranking of one thing over another.
65
71
 
66
72
  | Rule | Role |
67
73
  |---|---|
68
- | [`asd-ste100-language.md`](../../rules/asd-ste100-language.md) | Plain word choice, sentence style, and tone |
74
+ | [`asd-ste100-language.md`](asd-ste100-language.md) | Plain word choice, sentence style, and tone |
69
75
  | [`correction-lens.md`](../../rules/correction-lens.md) | Every correction becomes a control at the highest layer that can hold it |
70
- | [`research-mode.md`](../../rules/research-mode.md) | A claim carries its source |
76
+ | [`research-mode.md`](research-mode.md) | A claim carries its source |
@@ -18,5 +18,5 @@ Code written here needs local maintenance. A widely used tool draws fixes and ne
18
18
 
19
19
  | Rule | Role |
20
20
  |---|---|
21
- | [`explore-thoroughly.md`](../../rules/explore-thoroughly.md) | Read existing code before proposing a change |
22
- | [`verify-before-asking.md`](../../rules/verify-before-asking.md) | Check a question with a tool before asking it |
21
+ | [`explore-thoroughly.md`](explore-thoroughly.md) | Read existing code before proposing a change |
22
+ | [`verify-before-asking.md`](verify-before-asking.md) | Check a question with a tool before asking it |
@@ -1,7 +1,11 @@
1
- Back to the [rule entry](../../rules/proof-before-pull-request.md).
1
+ Back to the [rules index](../../rules/index.md).
2
2
 
3
3
  # Proof before a pull request
4
4
 
5
+ ## In brief
6
+
7
+ Run the change where it runs, with and without it, and quote both results under "Proof in practice" in the body. A hook denies a body without one. Unproven: leave it unopened and tell the user.
8
+
5
9
  **When this applies:** Every session that builds a change and opens a pull request, in every project.
6
10
 
7
11
  ## Rule
@@ -1,7 +1,11 @@
1
- Back to the [rule entry](../../rules/request-scope.md).
1
+ Back to the [rules index](../../rules/index.md).
2
2
 
3
3
  # Request scope
4
4
 
5
+ ## In brief
6
+
7
+ A shipped gate makes each block line restating what it enforces dead text. Propose removing all of them, past the part asked about, and give each kept line's reason. Read the output back before reporting done.
8
+
5
9
  **When this applies:** The user asks whether part of a prompt, rule, card, or check is still needed, or asks you to change one.
6
10
 
7
11
  ## Rule
@@ -1,7 +1,15 @@
1
- Back to the [rule entry](../../rules/research-mode.md).
1
+ Back to the [rules index](../../rules/index.md).
2
2
 
3
3
  # Research mode (global)
4
4
 
5
+ ## In brief
6
+
7
+ **When:** Making a factual claim, recommendation, or piece of advice.
8
+
9
+ State no claim without a credible source; do not guess or infer. Before an answer depends on an unsettled fact, run each tool that could settle it and that reads state or that permissions already allow, and stop at the first one that settles it. When none can, name the exact check and who can run or approve it. Cite a project file, web source, named expert or paper, or official documentation for each claim. Retract claims a source cannot support. Extract document text before analysis and ground the answer in word-for-word quotes. In chat, cite compactly with a link or `file:line`; put full quotes and citation lists in artifacts or replies that request them. Ground synthesis in sourced inputs. Creative ideas need no citation.
10
+
11
+ **Enforcement:** none.
12
+
5
13
  Three anti-hallucination constraints are always active.
6
14
 
7
15
  Source: [Anthropic - Reduce Hallucinations](https://docs.anthropic.com/en/docs/test-and-evaluate/strengthen-guardrails/reduce-hallucinations)
@@ -1,7 +1,13 @@
1
- Back to the [rule entry](../../rules/session-title.md).
1
+ Back to the [rules index](../../rules/index.md).
2
2
 
3
3
  # Status in the session title
4
4
 
5
+ ## In brief
6
+
7
+ Before each turn ends, set the title to `<emoji> <name>`. Use the red flag when the user must act, the check mark when the work is done, and the hourglass while work, CI, or a merge queue runs. The name has 25 characters or fewer, in sentence case, with concrete nouns and no IDs, dates, or branch names.
8
+
9
+ **Enforcement:** `hooks/blocking/session_title_format_gate.py` denies a malformed title; `hooks/blocking/session_title_stop_gate.py` blocks a turn end with no title.
10
+
5
11
  **When this applies:** The session has a tool whose name ends in `__set_session_title`, such as `mcp__claude-code-remote__set_session_title` in a cloud session or `mcp__ccd_session_mgmt__set_session_title` in the desktop app.
6
12
 
7
13
  ## Rule
@@ -1,7 +1,15 @@
1
- Back to the [rule entry](../../rules/shell-invocation.md).
1
+ Back to the [rules index](../../rules/index.md).
2
2
 
3
3
  # Shell Invocation
4
4
 
5
+ ## In brief
6
+
7
+ **When:** Issuing a Bash-tool shell command, or writing a `settings.json` permission rule.
8
+
9
+ On Windows, run shell work through `pwsh`: `pwsh -NoProfile -File '<script>.ps1' <args>` for a script, and `pwsh -NoProfile -Command "..."` or the `PowerShell` tool for inline work. A direct interpreter call such as `python script.py` conforms as written. Keep `powershell`, `cmd /c`, and `bash -c` out of permission rules. Keep `$(...)`, unescaped backticks, and `<(...)` or `>(...)` out of Bash commands; split the call or use a flag form such as `git -C "<path>"`. Arithmetic `$((...))` passes.
10
+
11
+ **Enforcement:** none; a permission prompt on a wrapped command is the signal. `blocking/msys_rev_path_rewriter.py` only rewrites a `<rev>:<path>` argument.
12
+
5
13
  Two constraints govern every shell command an agent issues: which shell runs it, and what the command string may contain.
6
14
 
7
15
  ## Use pwsh
@@ -1,7 +1,13 @@
1
- Back to the [rule entry](../../rules/verify-before-asking.md).
1
+ Back to the [rules index](../../rules/index.md).
2
2
 
3
3
  # Verify before asking
4
4
 
5
+ ## In brief
6
+
7
+ Inspect files, directories, configuration, environment, databases, and available tools for the answer. Recheck facts recalled from earlier sessions. Apply any criterion the user already supplied and state the decision. Ask only for a judgment, preference, or inaccessible fact.
8
+
9
+ **Enforcement:** none.
10
+
5
11
  **When this applies:** Before asking the user any clarifying question during discovery, scoping, or implementation planning.
6
12
 
7
13
  ## Rule
@@ -1,7 +1,13 @@
1
- Back to the [rule entry](../../rules/verify-runtime-state.md).
1
+ Back to the [rules index](../../rules/index.md).
2
2
 
3
3
  # Verify runtime state
4
4
 
5
+ ## In brief
6
+
7
+ Gather a live signal this session: process list, port probe, log, status code, or fresh reproduction. Read the user's terminal and named error log while a reported failure is still visible. Check the effect the work should produce, including each job result, loaded config, or deployed artifact; a success status alone does not establish the effect. When testing code, identify the loaded module path.
8
+
9
+ **Enforcement:** none.
10
+
5
11
  **When this applies:** Before stating that a component is fine, healthy, innocent, or working: during debugging, triage, or any judgment about whether something runs.
6
12
 
7
13
  ## Rule
@@ -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
 
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse gate: deny a Haiku agent spawned inside this session.
3
+
4
+ Registered on ``Agent|Task`` and ``mcp__hearthbot__start_thread_session``. An
5
+ in-session spawn shares this session's settings, plugins, MCP servers, and
6
+ CLAUDE.md files, so a Haiku agent started that way cannot run on the slim
7
+ profile. The deny reason gives the headless command that loads the profile.
8
+
9
+ ::
10
+
11
+ model "haiku" -> deny with the headless command
12
+ model "claude-haiku-5-5" -> deny with the headless command
13
+ model "opus", "sonnet", or omitted -> no output; the call runs unchanged
14
+
15
+ A spawn that names no model passes, even when the subagent's definition or
16
+ ``CLAUDE_CODE_SUBAGENT_MODEL`` resolves it to Haiku.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
26
+ if hooks_root_directory not in sys.path:
27
+ sys.path.insert(0, hooks_root_directory)
28
+
29
+ from hooks_constants.haiku_spawn_slim_gate_constants import (
30
+ DENY_DECISION,
31
+ HAIKU_MODEL_FAMILY,
32
+ HOOK_EVENT_NAME,
33
+ MODEL_INPUT_KEY,
34
+ PERMISSION_DECISION_REASON_KEY,
35
+ SLIM_PROFILE_DENY_REASON,
36
+ TOOL_INPUT_KEY,
37
+ )
38
+ from hooks_constants.hook_specific_output_keys import (
39
+ HOOK_EVENT_NAME_KEY,
40
+ HOOK_SPECIFIC_OUTPUT_KEY,
41
+ PERMISSION_DECISION_KEY,
42
+ )
43
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
44
+
45
+
46
+ def is_haiku_model(model: object) -> bool:
47
+ """Return True when the spawn's model field names the Haiku family.
48
+
49
+ ::
50
+
51
+ "haiku" -> True "claude-haiku-4-5-20251001" -> True
52
+ "opus" -> False None -> False
53
+
54
+ Args:
55
+ model: The ``model`` field of the spawn input.
56
+ """
57
+ return isinstance(model, str) and HAIKU_MODEL_FAMILY in model.lower()
58
+
59
+
60
+ def decide_hook_output(tool_input: object) -> dict[str, object] | None:
61
+ """Choose the hook's output for one spawn.
62
+
63
+ Args:
64
+ tool_input: The ``tool_input`` of the PreToolUse payload.
65
+
66
+ Returns:
67
+ None to pass the call through unchanged, else the deny output.
68
+ """
69
+ if not isinstance(tool_input, dict) or not is_haiku_model(tool_input.get(MODEL_INPUT_KEY)):
70
+ return None
71
+ return {
72
+ HOOK_SPECIFIC_OUTPUT_KEY: {
73
+ HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
74
+ PERMISSION_DECISION_KEY: DENY_DECISION,
75
+ PERMISSION_DECISION_REASON_KEY: SLIM_PROFILE_DENY_REASON,
76
+ }
77
+ }
78
+
79
+
80
+ def main() -> int:
81
+ """Read the PreToolUse payload and print the decision.
82
+
83
+ Returns:
84
+ 0 in every case.
85
+ """
86
+ hook_payload = read_hook_input_dictionary_from_stdin()
87
+ tool_input = hook_payload.get(TOOL_INPUT_KEY) if hook_payload is not None else None
88
+ hook_output = decide_hook_output(tool_input)
89
+ if hook_output is not None:
90
+ sys.stdout.write(json.dumps(hook_output))
91
+ sys.stdout.flush()
92
+ return 0
93
+
94
+
95
+ if __name__ == "__main__":
96
+ sys.exit(main())
@@ -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