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.
- package/.agents/agents/test_agent_frontmatter.py +23 -23
- package/.agents/agents-archived/clean-coder.md +5 -5
- package/.agents/skills/correction/playbooks/fix.md +1 -1
- package/.agents/skills/eli5/SKILL.md +3 -3
- package/.agents/skills/pr-lifecycle/SKILL.md +1 -1
- package/.agents/skills/recovering-codex-startup/SKILL.md +1 -1
- package/.agents/skills/recovering-codex-startup/reference/task-seeds.md +1 -1
- package/_shared/advisor/reference/third-party-bind.md +1 -1
- package/bin/cde.mjs +1 -0
- package/bin/cde.test.mjs +1 -0
- package/bin/install.mjs +1 -0
- package/bin/install.policy-lint.test.mjs +1 -0
- package/bin/install.profile-root.test.mjs +1 -1
- package/bin/install.settings-defaults.test.mjs +10 -2
- package/bin/install.test.mjs +1 -0
- package/bin/merge_settings_defaults.mjs +34 -1
- package/bin/merge_settings_defaults.test.mjs +53 -0
- package/commands/fix-followups.md +1 -1
- package/commands/sr-loop.md +1 -1
- package/docs/references/prose-style-enforcement.md +1 -1
- package/docs/references/working-style.md +1 -1
- package/docs/rule-guides/asd-ste100-language.md +9 -1
- package/docs/rule-guides/cleanup-temp-files.md +8 -2
- package/docs/rule-guides/destructive-commands.md +13 -3
- package/docs/rule-guides/explore-thoroughly.md +9 -1
- package/docs/rule-guides/features-start-with-an-eval.md +8 -2
- package/docs/rule-guides/filesystem-search.md +7 -1
- package/docs/rule-guides/flag-non-breaking-findings.md +3 -0
- package/docs/rule-guides/long-horizon-autonomy.md +1 -1
- package/docs/rule-guides/memory-stores-durable-facts.md +9 -3
- package/docs/rule-guides/no-contrast-framing.md +10 -4
- package/docs/rule-guides/prefer-existing-tools.md +2 -2
- package/docs/rule-guides/proof-before-pull-request.md +5 -1
- package/docs/rule-guides/request-scope.md +5 -1
- package/docs/rule-guides/research-mode.md +9 -1
- package/docs/rule-guides/session-title.md +7 -1
- package/docs/rule-guides/shell-invocation.md +9 -1
- package/docs/rule-guides/verify-before-asking.md +7 -1
- package/docs/rule-guides/verify-runtime-state.md +7 -1
- package/hooks/blocking/code_rules_enforcer.py +16 -6
- package/hooks/blocking/code_rules_shared.py +23 -4
- package/hooks/blocking/haiku_spawn_slim_gate.py +96 -0
- package/hooks/blocking/reply_length_gate.py +74 -14
- package/hooks/blocking/state_description_blocker.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +54 -1
- package/hooks/blocking/test_code_rules_shared.py +33 -0
- package/hooks/blocking/test_haiku_spawn_slim_gate.py +65 -0
- package/hooks/blocking/test_reply_length_gate.py +184 -5
- package/hooks/blocking/test_state_description_blocker.py +1 -1
- package/hooks/features/conduct-gates.md +3 -6
- package/hooks/features/spawn-routing.md +5 -1
- package/hooks/followup_ledger.py +52 -2
- package/hooks/hooks.json +11 -6
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -1
- package/hooks/hooks_constants/haiku_spawn_slim_gate_constants.py +29 -0
- package/hooks/hooks_constants/reply_length_gate_constants.py +1 -0
- package/hooks/hooks_constants/session_env_cleanup_constants.py +0 -2
- package/hooks/hooks_constants/test_visual_reply_rules_constants.py +15 -0
- package/hooks/hooks_constants/test_windows_filesystem.py +57 -0
- package/hooks/hooks_constants/visual_reply_rules_constants.py +63 -0
- package/hooks/hooks_constants/windows_filesystem.py +51 -0
- package/hooks/session/session_env_cleanup.py +4 -30
- package/hooks/test_followup_ledger.py +58 -0
- package/hooks/test_visual_reply_rules.py +164 -0
- package/hooks/visual_reply_rules.py +268 -0
- package/hooks/workflow/test_auto_formatter.py +4 -25
- package/package.json +1 -1
- package/rules/index.md +30 -0
- package/rules/visual-reply-rules.json +41 -0
- package/scripts/Migrate-ShellPolicy.ps1 +1 -1
- package/scripts/conftest.py +13 -5
- package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +2 -2
- package/scripts/dev_env_scripts_constants/followup_constants.py +4 -1
- package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +12 -0
- package/scripts/followup_cli.py +25 -1
- package/scripts/policy_lint/adapter_configuration.py +0 -5
- package/scripts/policy_lint/config/constants.py +1 -1
- package/scripts/repository_checks/config/constants.py +6 -0
- package/scripts/repository_checks/followup_ledger_duplicates.py +55 -0
- package/scripts/repository_checks/runner.py +7 -0
- package/scripts/test_active_capability_references.py +0 -5
- package/scripts/test_broker_state_isolation.py +3 -3
- package/scripts/test_digest_check.py +0 -5
- package/scripts/test_durable_post_lint.py +1 -5
- package/scripts/test_followup_cli.py +18 -6
- package/scripts/test_resolve_worker_spawn.py +5 -10
- package/scripts/test_setup_project_paths.py +0 -12
- package/scripts/test_shared_tree_paths.py +2 -6
- package/scripts/test_validate_instruction_pairs.py +0 -7
- package/scripts/test_verify_installable_package.py +0 -5
- package/scripts/tests/test_contrast_framing.py +3 -3
- package/scripts/tests/test_followup_ledger_duplicates.py +58 -0
- package/scripts/tests/test_repository_policy.py +2 -0
- package/scripts/tests/test_rule_index_shape.py +59 -5
- package/scripts/tests/test_rule_load_scopes.py +1 -16
- package/settings.json +9 -0
- package/system-prompts/software-engineer.xml +4 -4
- package/hooks/blocking/artifact_look_gate.py +0 -223
- package/hooks/blocking/test_artifact_look_gate.py +0 -153
- package/hooks/hooks_constants/artifact_look_gate_constants.py +0 -32
- package/rules/asd-ste100-language.md +0 -9
- package/rules/cleanup-temp-files.md +0 -9
- package/rules/destructive-commands.md +0 -11
- package/rules/explore-thoroughly.md +0 -9
- package/rules/features-start-with-an-eval.md +0 -9
- package/rules/filesystem-search.md +0 -9
- package/rules/memory-stores-durable-facts.md +0 -9
- package/rules/no-contrast-framing.md +0 -9
- package/rules/proof-before-pull-request.md +0 -7
- package/rules/request-scope.md +0 -7
- package/rules/research-mode.md +0 -9
- package/rules/session-title.md +0 -9
- package/rules/shell-invocation.md +0 -9
- package/rules/skill-pointers.md +0 -3
- package/rules/verify-before-asking.md +0 -9
- package/rules/verify-runtime-state.md +0 -9
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
Back to the [
|
|
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
|
-
`
|
|
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`](
|
|
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`](
|
|
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`](
|
|
22
|
-
| [`verify-before-asking.md`](
|
|
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 [
|
|
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 [
|
|
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 [
|
|
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 [
|
|
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 [
|
|
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 [
|
|
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 [
|
|
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 (
|
|
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
|
-
|
|
768
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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``, ``
|
|
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
|
|
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
|
|
180
|
-
"""
|
|
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
|
-
|
|
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
|
|
244
|
-
"""Return the
|
|
245
|
-
if
|
|
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/
|
|
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
|