claude-dev-env 2.9.0 → 2.11.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/CLAUDE.md +2 -2
- package/_shared/advisor/CLAUDE.md +3 -2
- package/_shared/advisor/advisor-protocol.md +74 -108
- package/_shared/advisor/reference/advisor-block.md +37 -0
- package/_shared/advisor/reference/cli-chain.md +45 -0
- package/_shared/advisor/reference/consult-format.md +41 -0
- package/_shared/advisor/reference/lifecycle.md +21 -0
- package/_shared/advisor/reference/sol-rung.md +31 -0
- package/_shared/advisor/reference/spawn-walk-log.md +31 -0
- package/_shared/advisor/reference/third-party-bind.md +30 -0
- package/_shared/advisor/reference/warm-up.md +33 -0
- package/_shared/advisor/scripts/codex_sol_advisor.py +449 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +21 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +19 -17
- package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +28 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +32 -9
- package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +474 -0
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +79 -0
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +39 -17
- package/_shared/advisor/scripts/tier_model_ids.py +24 -0
- package/_shared/pr-loop/CLAUDE.md +1 -1
- package/_shared/pr-loop/audit-contract.md +17 -6
- package/_shared/pr-loop/audit-reply-template.md +4 -4
- package/_shared/pr-loop/code-rules-gate.md +3 -5
- package/_shared/pr-loop/fix-protocol.md +2 -3
- package/_shared/pr-loop/gh-payloads.md +1 -1
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/README.md +1 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +16 -1
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +11 -5
- package/_shared/pr-loop/scripts/preflight.py +9 -4
- package/_shared/pr-loop/scripts/reviews_disabled.py +50 -22
- package/_shared/pr-loop/scripts/tests/conftest.py +20 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +6 -6
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +50 -6
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
- package/_shared/pr-loop/state-schema.md +5 -14
- package/agents/CLAUDE.md +2 -2
- package/agents/clean-coder.md +58 -548
- package/agents/code-quality-agent.md +10 -2
- package/agents/code-verifier.md +1 -1
- package/agents/test_agent_frontmatter.py +32 -40
- package/audit-rubrics/CLAUDE.md +2 -1
- package/audit-rubrics/audit-categories.json +704 -0
- package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
- package/bin/CLAUDE.md +16 -5
- package/bin/ever-shipped-skills.mjs +2 -0
- package/bin/install-plan.mjs +402 -0
- package/bin/install-transaction.mjs +455 -0
- package/bin/install.mjs +593 -147
- package/bin/install.plan.test.mjs +194 -0
- package/bin/install.profile-root.test.mjs +154 -0
- package/bin/install.profiles.test.mjs +253 -0
- package/bin/install.settings-defaults.test.mjs +200 -0
- package/bin/install.transaction.test.mjs +400 -0
- package/bin/install.uninstall-transaction.test.mjs +418 -0
- package/bin/merge_managed_permissions.mjs +130 -0
- package/bin/resolve-install-root.mjs +181 -0
- package/bin/select-install-targets.mjs +401 -0
- package/commands/CLAUDE.md +0 -2
- package/docs/references/CLAUDE.md +3 -1
- package/docs/references/advisor-tool.md +25 -7
- package/docs/references/prose-style-enforcement.md +25 -0
- package/docs/references/team-advisor-skill.md +3 -3
- package/docs/references/weak-executor-advisor.md +91 -0
- package/hooks/blocking/CLAUDE.md +6 -6
- package/hooks/blocking/_path_setup.py +9 -5
- package/hooks/blocking/code_rules_docstrings.py +124 -30
- package/hooks/blocking/code_rules_enforcer.py +161 -16
- package/hooks/blocking/code_rules_shared.py +40 -23
- package/hooks/blocking/config/CLAUDE.md +3 -5
- package/hooks/blocking/config/prose_style_enforcement_constants.py +38 -0
- package/hooks/blocking/config/test_prose_style_enforcement_constants.py +45 -0
- package/hooks/blocking/eli11_reply_enforcer.py +70 -113
- package/hooks/blocking/hedging_language_blocker.py +103 -20
- package/hooks/blocking/hook_prose_detector_consistency.py +6 -0
- package/hooks/blocking/intent_only_ending_blocker.py +6 -0
- package/hooks/blocking/plain_language_blocker.py +139 -20
- package/hooks/blocking/pre_tool_use_dispatcher.py +102 -20
- package/hooks/blocking/state_description_blocker.py +7 -1
- package/hooks/blocking/tdd_enforcer.py +8 -0
- package/hooks/blocking/test__path_setup.py +28 -0
- package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +99 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +232 -10
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +41 -0
- package/hooks/blocking/test_code_rules_enforcer_string_magic.py +98 -0
- package/hooks/blocking/test_eli11_reply_enforcer.py +98 -165
- package/hooks/blocking/test_fable_spawn_gate.py +18 -11
- package/hooks/blocking/test_hedging_language_blocker.py +120 -1
- package/hooks/blocking/test_hook_prose_detector_consistency.py +28 -8
- package/hooks/blocking/test_intent_only_ending_blocker.py +27 -2
- package/hooks/blocking/test_package_inventory_stale_blocker.py +11 -4
- package/hooks/blocking/test_plain_language_blocker.py +129 -19
- package/hooks/blocking/test_plain_language_blocker_allowlist.py +70 -26
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +99 -26
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +87 -50
- package/hooks/blocking/test_state_description_blocker.py +45 -2
- package/hooks/blocking/test_stop_dispatcher.py +11 -7
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +12 -12
- package/hooks/blocking/volatile_path_in_post_blocker.py +2 -2
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/CLAUDE.md +14 -3
- package/hooks/hooks_constants/ask_user_question_shape.py +281 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -1
- package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +5 -12
- package/hooks/hooks_constants/hedging_uncertainty_constants.py +42 -0
- package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +23 -0
- package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +23 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +4 -1
- package/hooks/hooks_constants/plain_language_blocker_constants.py +4 -1
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/project_paths_reader.py +31 -4
- package/hooks/hooks_constants/prose_matcher_precision_constants.py +40 -0
- package/hooks/hooks_constants/pytest_invocation.py +354 -0
- package/hooks/hooks_constants/session_start_injector.py +163 -0
- package/hooks/hooks_constants/session_start_injector_constants.py +46 -0
- package/hooks/hooks_constants/shell_command_pipeline.py +397 -0
- package/hooks/hooks_constants/shell_command_segments.py +5 -0
- package/hooks/hooks_constants/test_ask_user_question_shape.py +167 -0
- package/hooks/hooks_constants/test_project_paths_reader.py +29 -0
- package/hooks/hooks_constants/test_prose_metrics_parity.py +8 -0
- package/hooks/hooks_constants/test_pytest_invocation.py +130 -0
- package/hooks/hooks_constants/test_session_start_injector.py +168 -0
- package/hooks/hooks_constants/test_shell_command_pipeline.py +135 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +1 -1
- package/hooks/hooks_constants/working_style_prompt_constants.py +30 -0
- package/hooks/observability/CLAUDE.md +2 -0
- package/hooks/observability/prose_matcher_advisory.py +237 -0
- package/hooks/observability/test_prose_matcher_advisory.py +143 -0
- package/hooks/session/CLAUDE.md +9 -1
- package/hooks/session/_path_setup.py +13 -0
- package/hooks/session/issue_tracker_session_starter.py +135 -0
- package/hooks/session/orchestrator_auto_starter.py +100 -0
- package/hooks/session/test__path_setup.py +28 -0
- package/hooks/session/test_issue_tracker_session_starter.py +104 -0
- package/hooks/session/test_orchestrator_auto_starter.py +99 -0
- package/hooks/session/test_working_style_prompt.py +47 -0
- package/hooks/session/untracked_repo_detector.py +1 -24
- package/hooks/session/working_style_prompt.py +36 -0
- package/hooks/validators/_path_setup.py +19 -0
- package/hooks/validators/run_all_validators.py +8 -13
- package/installable-surfaces.manifest.json +21 -0
- package/output-styles/CLAUDE.md +1 -3
- package/package.json +4 -2
- package/rules/CLAUDE.md +1 -0
- package/rules/durable-post-artifacts.md +2 -2
- package/rules/eli11-replies.md +6 -1
- package/rules/hedging-claims.md +4 -2
- package/rules/long-horizon-autonomy.md +3 -1
- package/rules/opus5-communication-contract.md +45 -0
- package/rules/plain-language.md +2 -2
- package/rules/research-mode.md +1 -1
- package/scripts/CLAUDE.md +11 -0
- package/scripts/Sync-RepoMain.ps1 +215 -0
- package/scripts/active_capability_references.py +218 -0
- package/scripts/ci/windows-installer-lifecycle.ps1 +78 -0
- package/scripts/claude_chain_runner.py +394 -6
- package/scripts/claude_chain_usage.py +1 -1
- package/scripts/codex_compat_materializer.py +105 -85
- package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
- package/scripts/dev_env_scripts_constants/active_capability_constants.py +46 -0
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +74 -0
- package/scripts/dev_env_scripts_constants/verify_installable_package_constants.py +116 -0
- package/scripts/profile-isolation-launchers/config/mcp-bundles.json +25 -0
- package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +60 -0
- package/scripts/profile-isolation-launchers/config/profiles.manifest.json +54 -0
- package/scripts/profile-isolation-launchers/config/shared-allowlist.json +64 -0
- package/scripts/profile-isolation-launchers/launcher-runtime.mjs +180 -0
- package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +288 -0
- package/scripts/profile-isolation-launchers/mcp-bundles.mjs +275 -0
- package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +221 -0
- package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +108 -0
- package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +147 -0
- package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +102 -0
- package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +210 -0
- package/scripts/profile-isolation-launchers/version-compatibility.mjs +299 -0
- package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +127 -0
- package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +51 -0
- package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +77 -0
- package/scripts/spawn_grok_batch.py +3 -0
- package/scripts/test_active_capability_references.py +108 -0
- package/scripts/test_claude_chain_runner.py +414 -82
- package/scripts/test_claude_chain_usage.py +12 -12
- package/scripts/test_resolve_worker_spawn.py +2 -2
- package/scripts/test_verify_installable_package.py +208 -0
- package/scripts/tests/test_codex_compat_materializer.py +33 -0
- package/scripts/verify_installable_package.py +612 -0
- package/settings.json +10 -0
- package/skills/CLAUDE.md +2 -0
- package/skills/_shared/advisor/CLAUDE.md +1 -1
- package/skills/_shared/advisor/scripts/README.md +2 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/audit_category_schema.py +355 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/audit_category_schema_constants.py +32 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +7 -19
- package/skills/_shared/pr-loop/scripts/test_audit_category_schema.py +94 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +21 -0
- package/skills/autoconverge/reference/convergence.md +2 -1
- package/skills/autoconverge/reference/stop-conditions.md +5 -3
- package/skills/beat-sheet/SKILL.md +54 -0
- package/skills/beat-sheet/reference/visual-beats.md +29 -0
- package/skills/bugteam/CONSTRAINTS.md +4 -4
- package/skills/bugteam/EXAMPLES.md +1 -1
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/e-code-review/SKILL.md +26 -5
- package/skills/e-code-review/reference/effort-evaluation.md +35 -0
- package/skills/e-code-review/reference/medium.md +15 -4
- package/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +41 -0
- package/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +40 -0
- package/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +49 -0
- package/skills/e-code-review/scripts/effort_defaults_evidence.json +186 -0
- package/skills/e-code-review/scripts/effort_evaluation.py +362 -0
- package/skills/e-code-review/scripts/finding_pipeline.py +140 -0
- package/skills/e-code-review/scripts/fixtures/demanding.json +26 -0
- package/skills/e-code-review/scripts/fixtures/easy.json +14 -0
- package/skills/e-code-review/scripts/fixtures/medium.json +20 -0
- package/skills/e-code-review/scripts/grok_code_review.py +16 -7
- package/skills/e-code-review/scripts/test_effort_evaluation.py +180 -0
- package/skills/e-code-review/scripts/test_finding_pipeline.py +197 -0
- package/skills/e-code-review/scripts/test_grok_code_review.py +77 -0
- package/skills/grokify/SKILL.md +1 -1
- package/skills/grokify/templates/handoff-template.md +2 -2
- package/skills/orchestrator/SKILL.md +5 -4
- package/skills/plan-to-pr/scripts/create_packet.py +4 -4
- package/skills/plan-to-pr/scripts/load_skill_constants.py +41 -0
- package/skills/plan-to-pr/scripts/validate_packet.py +4 -4
- package/skills/plan-to-pr/scripts/validate_protocol.py +4 -1
- package/skills/plan-to-pr/scripts/validate_run.py +4 -1
- package/skills/pr-converge/scripts/check_convergence.py +21 -19
- package/skills/pr-converge/scripts/check_convergence_availability.py +50 -7
- package/skills/pr-converge/scripts/conftest.py +35 -0
- package/skills/pr-converge/scripts/test_check_convergence_availability.py +65 -0
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +11 -1
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +9 -2
- package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
- package/skills/rebase/SKILL.md +15 -3
- package/skills/reviewer-gates/SKILL.md +2 -2
- package/skills/show/SKILL.md +51 -0
- package/skills/show/references/accessibility.md +7 -0
- package/skills/show/references/art.md +3 -0
- package/skills/show/references/charts.md +3 -0
- package/skills/show/references/core-design.md +14 -0
- package/skills/show/references/erds.md +3 -0
- package/skills/show/references/flowcharts.md +3 -0
- package/skills/show/references/host-and-html.md +3 -0
- package/skills/show/references/illustrative-diagrams.md +10 -0
- package/skills/show/references/interaction.md +3 -0
- package/skills/show/references/mockups.md +3 -0
- package/skills/show/references/quality-gates.md +7 -0
- package/skills/show/references/structural-diagrams.md +3 -0
- package/skills/show/references/subject-inventory.md +21 -0
- package/skills/show/references/svg-contract.md +22 -0
- package/skills/show/routing.yaml +30 -0
- package/skills/show/samples/pr1262-v2.svg +222 -0
- package/skills/show/scripts/README.md +6 -0
- package/skills/show/scripts/validate-artifact.py +91 -0
- package/skills/show/scripts/validate-package.py +18 -0
- package/skills/show/templates/html-widget.html +4 -0
- package/skills/show/templates/svg-base.svg +19 -0
- package/skills/show/tests/fixtures/css-var.svg +6 -0
- package/skills/show/tests/fixtures/dead-ref.svg +7 -0
- package/skills/show/tests/fixtures/filled-glyph.svg +8 -0
- package/skills/show/tests/fixtures/inherited-fill.svg +18 -0
- package/skills/show/tests/fixtures/invalid.svg +1 -0
- package/skills/show/tests/fixtures/large-canvas.svg +21 -0
- package/skills/show/tests/fixtures/unfilled-connector.svg +15 -0
- package/skills/show/tests/fixtures/valid.html +1 -0
- package/skills/show/tests/test_validate-artifact.py +74 -0
- package/skills/show/tests/test_validators.py +59 -0
- package/skills/show/workflows/create-visual.md +13 -0
- package/skills/show/workflows/review-visual.md +20 -0
- package/skills/split-pr/SKILL.md +85 -0
- package/skills/split-pr/reference/path-layers.md +16 -0
- package/skills/split-pr/reference/proposal-format.md +15 -0
- package/skills/split-pr/reference/split-further-loop.md +10 -0
- package/skills/split-pr/reference/splitting-principles.md +26 -0
- package/skills/split-pr/scripts/analyze_pr.py +279 -0
- package/skills/split-pr/scripts/categorize_files.py +106 -0
- package/skills/split-pr/scripts/config/__init__.py +1 -0
- package/skills/split-pr/scripts/config/dependency_constants.py +14 -0
- package/skills/split-pr/scripts/config/git_operations_constants.py +36 -0
- package/skills/split-pr/scripts/config/packing_constants.py +61 -0
- package/skills/split-pr/scripts/config/plan_constants.py +49 -0
- package/skills/split-pr/scripts/config/split_pr_constants.py +110 -0
- package/skills/split-pr/scripts/execute_split_slices.py +82 -0
- package/skills/split-pr/scripts/pack_files_into_slices.py +212 -0
- package/skills/split-pr/scripts/split_pr_dependency_graph.py +70 -0
- package/skills/split-pr/scripts/split_pr_git_operations.py +184 -0
- package/skills/split-pr/scripts/split_pr_layer_order.py +58 -0
- package/skills/split-pr/scripts/split_pr_paginate.py +119 -0
- package/skills/split-pr/scripts/split_pr_process_runner.py +52 -0
- package/skills/split-pr/scripts/split_pr_script_types.py +126 -0
- package/skills/split-pr/scripts/split_pr_title.py +41 -0
- package/skills/split-pr/scripts/test_analyze_pr.py +228 -0
- package/skills/split-pr/scripts/test_categorize_files.py +55 -0
- package/skills/split-pr/scripts/test_categorize_files_packing.py +59 -0
- package/skills/split-pr/scripts/test_execute_split_slices.py +99 -0
- package/skills/split-pr/scripts/test_split_pr_dependency_graph.py +47 -0
- package/skills/split-pr/scripts/test_split_pr_git_operations.py +125 -0
- package/skills/split-pr/scripts/test_split_pr_layer_order.py +36 -0
- package/skills/split-pr/scripts/test_split_pr_paginate.py +65 -0
- package/skills/split-pr/scripts/test_split_pr_script_types.py +73 -0
- package/skills/split-pr/scripts/test_split_pr_title.py +28 -0
- package/skills/split-pr/scripts/test_verify_dependency_graph.py +46 -0
- package/skills/split-pr/scripts/test_verify_plan.py +56 -0
- package/skills/split-pr/scripts/test_verify_plan_contract.py +50 -0
- package/skills/split-pr/scripts/test_verify_plan_path_normalization.py +45 -0
- package/skills/split-pr/scripts/verify_dependency_graph.py +111 -0
- package/skills/split-pr/scripts/verify_plan.py +139 -0
- package/skills/team-advisor/SKILL.md +7 -4
- package/skills/team-advisor/reference/advisor-docs-review.md +207 -0
- package/system-prompts/software-engineer.xml +11 -2
- package/commands/initialize.md +0 -90
- package/commands/stubcheck.md +0 -88
- package/output-styles/caveman-agent.md +0 -37
|
@@ -2,12 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
import importlib.util
|
|
4
4
|
import json
|
|
5
|
+
import os
|
|
5
6
|
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
6
9
|
from types import ModuleType
|
|
10
|
+
from unittest.mock import patch
|
|
7
11
|
|
|
8
12
|
BLOCKER_PATH = Path(__file__).parent / "plain_language_blocker.py"
|
|
9
13
|
ALLOWLIST_RELATIVE_PATH = Path(".claude") / "plain-language-allow.json"
|
|
10
14
|
ALWAYS_HEAVY_WORD = "utilize"
|
|
15
|
+
_PROSE_STYLE_ENFORCEMENT_ENVIRONMENT = {
|
|
16
|
+
"CLAUDE_PROSE_STYLE_ENFORCEMENT": "1",
|
|
17
|
+
}
|
|
11
18
|
|
|
12
19
|
|
|
13
20
|
def _load_blocker() -> ModuleType:
|
|
@@ -22,6 +29,24 @@ def _load_blocker() -> ModuleType:
|
|
|
22
29
|
|
|
23
30
|
_BLOCKER = _load_blocker()
|
|
24
31
|
|
|
32
|
+
@pytest.fixture(autouse=True)
|
|
33
|
+
def enable_prose_style_enforcement(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
34
|
+
"""Opinionated heavy-word scan is default-off; these tests arm it."""
|
|
35
|
+
monkeypatch.setenv("CLAUDE_PROSE_STYLE_ENFORCEMENT", "1")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _evaluate_advisory(
|
|
40
|
+
payload_by_key: dict[str, object],
|
|
41
|
+
) -> tuple[str | None, str | None]:
|
|
42
|
+
"""Return (deny_reason, advisory_message) with prose enforcement on.
|
|
43
|
+
|
|
44
|
+
Heavy words never deny (OP-07D); allowlist coverage asserts on the advisory
|
|
45
|
+
message and on ``find_banned_terms`` so the scanner still sees domain terms.
|
|
46
|
+
"""
|
|
47
|
+
with patch.dict(os.environ, _PROSE_STYLE_ENFORCEMENT_ENVIRONMENT):
|
|
48
|
+
return _BLOCKER.evaluate_with_advisory(payload_by_key)
|
|
49
|
+
|
|
25
50
|
|
|
26
51
|
def _init_repo(root: Path) -> Path:
|
|
27
52
|
(root / ".git").mkdir(parents=True, exist_ok=True)
|
|
@@ -63,15 +88,17 @@ def test_allowlisted_word_passes_in_markdown_write(tmp_path: Path) -> None:
|
|
|
63
88
|
project_with_allowlist = _init_repo(tmp_path / "domain")
|
|
64
89
|
_write_allowlist(project_with_allowlist, ["submit"])
|
|
65
90
|
|
|
66
|
-
|
|
91
|
+
control_deny, control_advisory = _evaluate_advisory(
|
|
67
92
|
_markdown_write_payload(project_without_allowlist, prose)
|
|
68
93
|
)
|
|
69
|
-
|
|
94
|
+
allowlisted_deny, allowlisted_advisory = _evaluate_advisory(
|
|
70
95
|
_markdown_write_payload(project_with_allowlist, prose)
|
|
71
96
|
)
|
|
72
97
|
|
|
73
|
-
assert
|
|
74
|
-
assert
|
|
98
|
+
assert control_deny is None
|
|
99
|
+
assert control_advisory is not None and "submit" in control_advisory
|
|
100
|
+
assert allowlisted_deny is None
|
|
101
|
+
assert allowlisted_advisory is None
|
|
75
102
|
|
|
76
103
|
|
|
77
104
|
def test_allowlisted_word_passes_in_ask_user_question(tmp_path: Path) -> None:
|
|
@@ -80,26 +107,29 @@ def test_allowlisted_word_passes_in_ask_user_question(tmp_path: Path) -> None:
|
|
|
80
107
|
project_with_allowlist = _init_repo(tmp_path / "domain")
|
|
81
108
|
_write_allowlist(project_with_allowlist, ["identify"])
|
|
82
109
|
|
|
83
|
-
|
|
110
|
+
control_deny, control_advisory = _evaluate_advisory(
|
|
84
111
|
_ask_user_question_payload(project_without_allowlist, prose)
|
|
85
112
|
)
|
|
86
|
-
|
|
113
|
+
allowlisted_deny, allowlisted_advisory = _evaluate_advisory(
|
|
87
114
|
_ask_user_question_payload(project_with_allowlist, prose)
|
|
88
115
|
)
|
|
89
116
|
|
|
90
|
-
assert
|
|
91
|
-
assert
|
|
117
|
+
assert control_deny is None
|
|
118
|
+
assert control_advisory is not None and "identify" in control_advisory
|
|
119
|
+
assert allowlisted_deny is None
|
|
120
|
+
assert allowlisted_advisory is None
|
|
92
121
|
|
|
93
122
|
|
|
94
|
-
def
|
|
123
|
+
def test_non_allowlisted_heavy_word_still_flagged(tmp_path: Path) -> None:
|
|
95
124
|
_init_repo(tmp_path)
|
|
96
125
|
_write_allowlist(tmp_path, ["submit"])
|
|
97
126
|
|
|
98
|
-
deny_reason =
|
|
127
|
+
deny_reason, advisory_message = _evaluate_advisory(
|
|
99
128
|
_markdown_write_payload(tmp_path, f"Please {ALWAYS_HEAVY_WORD} the cache now.")
|
|
100
129
|
)
|
|
101
130
|
|
|
102
|
-
assert deny_reason is
|
|
131
|
+
assert deny_reason is None
|
|
132
|
+
assert advisory_message is not None and ALWAYS_HEAVY_WORD in advisory_message
|
|
103
133
|
|
|
104
134
|
|
|
105
135
|
def test_allowlist_match_is_case_insensitive(tmp_path: Path) -> None:
|
|
@@ -108,26 +138,29 @@ def test_allowlist_match_is_case_insensitive(tmp_path: Path) -> None:
|
|
|
108
138
|
project_with_allowlist = _init_repo(tmp_path / "domain")
|
|
109
139
|
_write_allowlist(project_with_allowlist, ["Submit"])
|
|
110
140
|
|
|
111
|
-
|
|
141
|
+
control_deny, control_advisory = _evaluate_advisory(
|
|
112
142
|
_markdown_write_payload(project_without_allowlist, prose)
|
|
113
143
|
)
|
|
114
|
-
|
|
144
|
+
allowlisted_deny, allowlisted_advisory = _evaluate_advisory(
|
|
115
145
|
_markdown_write_payload(project_with_allowlist, prose)
|
|
116
146
|
)
|
|
117
147
|
|
|
118
|
-
assert
|
|
119
|
-
assert
|
|
148
|
+
assert control_deny is None
|
|
149
|
+
assert control_advisory is not None and "submit" in control_advisory
|
|
150
|
+
assert allowlisted_deny is None
|
|
151
|
+
assert allowlisted_advisory is None
|
|
120
152
|
|
|
121
153
|
|
|
122
154
|
def test_malformed_allowlist_json_is_ignored(tmp_path: Path) -> None:
|
|
123
155
|
_init_repo(tmp_path)
|
|
124
156
|
_write_raw_allowlist(tmp_path, "{ this is not valid json ")
|
|
125
157
|
|
|
126
|
-
deny_reason =
|
|
158
|
+
deny_reason, advisory_message = _evaluate_advisory(
|
|
127
159
|
_markdown_write_payload(tmp_path, "Please submit the release notes.")
|
|
128
160
|
)
|
|
129
161
|
|
|
130
|
-
assert deny_reason is
|
|
162
|
+
assert deny_reason is None
|
|
163
|
+
assert advisory_message is not None and "submit" in advisory_message
|
|
131
164
|
|
|
132
165
|
|
|
133
166
|
def test_allowlist_in_a_different_project_root_is_not_applied(tmp_path: Path) -> None:
|
|
@@ -135,43 +168,54 @@ def test_allowlist_in_a_different_project_root_is_not_applied(tmp_path: Path) ->
|
|
|
135
168
|
project_without_allowlist = _init_repo(tmp_path / "project_b")
|
|
136
169
|
_write_allowlist(project_with_allowlist, ["submit"])
|
|
137
170
|
|
|
138
|
-
deny_reason =
|
|
171
|
+
deny_reason, advisory_message = _evaluate_advisory(
|
|
139
172
|
_markdown_write_payload(project_without_allowlist, "Please submit the notes.")
|
|
140
173
|
)
|
|
141
174
|
|
|
142
|
-
assert deny_reason is
|
|
175
|
+
assert deny_reason is None
|
|
176
|
+
assert advisory_message is not None and "submit" in advisory_message
|
|
143
177
|
|
|
144
178
|
|
|
145
179
|
def test_allowlist_above_the_repo_root_is_not_applied(tmp_path: Path) -> None:
|
|
146
180
|
_write_allowlist(tmp_path, ["submit"])
|
|
147
181
|
repository_root = _init_repo(tmp_path / "repo")
|
|
148
182
|
|
|
149
|
-
deny_reason =
|
|
183
|
+
deny_reason, advisory_message = _evaluate_advisory(
|
|
150
184
|
_markdown_write_payload(repository_root, "Please submit the notes.")
|
|
151
185
|
)
|
|
152
186
|
|
|
153
|
-
assert deny_reason is
|
|
187
|
+
assert deny_reason is None
|
|
188
|
+
assert advisory_message is not None and "submit" in advisory_message
|
|
154
189
|
|
|
155
190
|
|
|
156
191
|
def test_allowlist_at_the_repo_root_is_applied(tmp_path: Path) -> None:
|
|
157
192
|
repository_root = _init_repo(tmp_path / "repo")
|
|
158
193
|
_write_allowlist(repository_root, ["submit"])
|
|
159
194
|
|
|
160
|
-
deny_reason =
|
|
195
|
+
deny_reason, advisory_message = _evaluate_advisory(
|
|
161
196
|
_markdown_write_payload(repository_root, "Please submit the notes.")
|
|
162
197
|
)
|
|
163
198
|
|
|
164
199
|
assert deny_reason is None
|
|
200
|
+
assert advisory_message is None
|
|
165
201
|
|
|
166
202
|
|
|
167
203
|
def test_allowlist_without_a_repo_root_is_not_applied(tmp_path: Path) -> None:
|
|
204
|
+
"""An allowlist only applies when the walk finds a ``.git`` repository root.
|
|
205
|
+
|
|
206
|
+
The upward walk can hit a personal home ``.git`` on some developer machines,
|
|
207
|
+
so this case stubs the finder to the no-repository outcome rather than
|
|
208
|
+
relying on the ambient filesystem.
|
|
209
|
+
"""
|
|
168
210
|
_write_allowlist(tmp_path, ["submit"])
|
|
169
211
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
212
|
+
with patch.object(_BLOCKER, "_find_project_allowlist_file", return_value=None):
|
|
213
|
+
deny_reason, advisory_message = _evaluate_advisory(
|
|
214
|
+
_markdown_write_payload(tmp_path, "Please submit the notes.")
|
|
215
|
+
)
|
|
173
216
|
|
|
174
|
-
assert deny_reason is
|
|
217
|
+
assert deny_reason is None
|
|
218
|
+
assert advisory_message is not None and "submit" in advisory_message
|
|
175
219
|
|
|
176
220
|
|
|
177
221
|
def test_find_banned_terms_skips_allowlisted_terms() -> None:
|
|
@@ -14,6 +14,7 @@ fail-open malformed input.
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
|
+
import os
|
|
17
18
|
import subprocess
|
|
18
19
|
import sys
|
|
19
20
|
from pathlib import Path
|
|
@@ -39,13 +40,27 @@ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I
|
|
|
39
40
|
HostedHookEntry,
|
|
40
41
|
)
|
|
41
42
|
from pre_tool_use_dispatcher import ( # noqa: E402, I001
|
|
43
|
+
DispatcherDecision,
|
|
42
44
|
HostedHookResult,
|
|
45
|
+
_emit_deny_decision,
|
|
43
46
|
aggregate_hosted_hook_results,
|
|
44
47
|
run_hosted_hook,
|
|
48
|
+
unique_first_seen_strings,
|
|
45
49
|
)
|
|
46
50
|
|
|
47
51
|
_DISPATCHER_SCRIPT = str(_BLOCKING_DIR / "pre_tool_use_dispatcher.py")
|
|
48
52
|
|
|
53
|
+
_PROSE_STYLE_ENV_VAR = "CLAUDE_PROSE_STYLE_ENFORCEMENT"
|
|
54
|
+
_PROSE_STYLE_ENV_VALUE = "1"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _subprocess_environment() -> dict[str, str]:
|
|
58
|
+
"""Return process env with opinionated prose gates enabled for golden tests."""
|
|
59
|
+
environment_by_key = os.environ.copy()
|
|
60
|
+
environment_by_key[_PROSE_STYLE_ENV_VAR] = _PROSE_STYLE_ENV_VALUE
|
|
61
|
+
return environment_by_key
|
|
62
|
+
|
|
63
|
+
|
|
49
64
|
_TEMP_FILE_PATH = str(_HOOKS_ROOT.parent.parent.parent / "tmp" / "dispatcher_test_dummy.txt")
|
|
50
65
|
_MARKDOWN_FILE_PATH = str(_HOOKS_ROOT.parent.parent.parent / "tmp" / "dispatcher_test_dummy.md")
|
|
51
66
|
|
|
@@ -70,6 +85,7 @@ def _run_hook_subprocess(
|
|
|
70
85
|
capture_output=True,
|
|
71
86
|
text=True,
|
|
72
87
|
encoding="utf-8",
|
|
88
|
+
env=_subprocess_environment(),
|
|
73
89
|
)
|
|
74
90
|
|
|
75
91
|
|
|
@@ -89,6 +105,7 @@ def _run_dispatcher(payload_text: str) -> subprocess.CompletedProcess[str]:
|
|
|
89
105
|
capture_output=True,
|
|
90
106
|
text=True,
|
|
91
107
|
encoding="utf-8",
|
|
108
|
+
env=_subprocess_environment(),
|
|
92
109
|
)
|
|
93
110
|
|
|
94
111
|
|
|
@@ -272,8 +289,8 @@ def test_clean_write_allows_on_multi_edit_tool() -> None:
|
|
|
272
289
|
_assert_dispatcher_matches_individual_hooks(payload_text, MULTI_EDIT_TOOL_NAME)
|
|
273
290
|
|
|
274
291
|
|
|
275
|
-
def
|
|
276
|
-
"""Dispatcher
|
|
292
|
+
def test_plain_language_heavy_prose_allows_on_write_of_markdown_file() -> None:
|
|
293
|
+
"""Dispatcher matches individual hooks: heavy prose allows (OP-07D advisory)."""
|
|
277
294
|
payload_text = _write_payload(
|
|
278
295
|
_MARKDOWN_FILE_PATH,
|
|
279
296
|
"# Guide\n\nPlease utilize this functionality to commence the process.\n",
|
|
@@ -281,8 +298,8 @@ def test_plain_language_denial_on_write_of_markdown_file() -> None:
|
|
|
281
298
|
_assert_dispatcher_matches_individual_hooks(payload_text, WRITE_TOOL_NAME)
|
|
282
299
|
|
|
283
300
|
|
|
284
|
-
def
|
|
285
|
-
"""Dispatcher
|
|
301
|
+
def test_plain_language_heavy_prose_allows_on_edit_of_markdown_file() -> None:
|
|
302
|
+
"""Dispatcher matches individual hooks: heavy prose Edit allows (OP-07D)."""
|
|
286
303
|
payload_text = _edit_payload(
|
|
287
304
|
_MARKDOWN_FILE_PATH,
|
|
288
305
|
"old line",
|
|
@@ -291,8 +308,8 @@ def test_plain_language_denial_on_edit_of_markdown_file() -> None:
|
|
|
291
308
|
_assert_dispatcher_matches_individual_hooks(payload_text, EDIT_TOOL_NAME)
|
|
292
309
|
|
|
293
310
|
|
|
294
|
-
def
|
|
295
|
-
"""Dispatcher
|
|
311
|
+
def test_plain_language_heavy_prose_allows_on_multi_edit_of_markdown_file() -> None:
|
|
312
|
+
"""Dispatcher matches individual hooks: heavy prose MultiEdit allows (OP-07D)."""
|
|
296
313
|
payload_text = _multi_edit_payload(
|
|
297
314
|
_MARKDOWN_FILE_PATH,
|
|
298
315
|
[{"old_string": "old", "new_string": "Please utilize this functionality to commence."}],
|
|
@@ -312,13 +329,11 @@ def test_dispatcher_docstring_points_at_roster_not_hardcoded_counts() -> None:
|
|
|
312
329
|
def test_multi_edit_runs_only_group_b_hooks() -> None:
|
|
313
330
|
"""Dispatcher invokes only Group-B hooks on MultiEdit, not Group-A hooks.
|
|
314
331
|
|
|
315
|
-
A plain_language_blocker denial on a MultiEdit proves Group B runs.
|
|
316
332
|
The write_existing_file_blocker (Group A only) must not run on MultiEdit,
|
|
317
333
|
so a MultiEdit to any path that would trip a Group-A hook must still allow.
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
2. The set of applicable entries for MultiEdit contains no Group-A entries.
|
|
334
|
+
Heavy prose no longer denies (OP-07D); this test proves Group A is absent
|
|
335
|
+
from MultiEdit and that plain_language_blocker stays in the MultiEdit set
|
|
336
|
+
and still runs (allow-with-advisory on heavy prose).
|
|
322
337
|
"""
|
|
323
338
|
all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
|
|
324
339
|
all_write_only_entries = [
|
|
@@ -334,15 +349,20 @@ def test_multi_edit_runs_only_group_b_hooks() -> None:
|
|
|
334
349
|
f"Group-A hook {each_group_a_entry.script_relative_path!r} "
|
|
335
350
|
"appears in the MultiEdit applicable set — it must not"
|
|
336
351
|
)
|
|
352
|
+
assert "blocking/plain_language_blocker.py" in all_multi_edit_script_paths, (
|
|
353
|
+
"plain_language_blocker (Group B) must stay in the MultiEdit applicable set"
|
|
354
|
+
)
|
|
337
355
|
heavy_prose_payload = _multi_edit_payload(
|
|
338
356
|
_MARKDOWN_FILE_PATH,
|
|
339
357
|
[{"old_string": "old line", "new_string": "Utilize this to commence the process."}],
|
|
340
358
|
)
|
|
341
359
|
dispatcher_result = _run_dispatcher(heavy_prose_payload)
|
|
342
360
|
dispatcher_is_deny, _reason = _parse_hook_decision(dispatcher_result)
|
|
343
|
-
assert dispatcher_is_deny, (
|
|
344
|
-
"Dispatcher should
|
|
345
|
-
|
|
361
|
+
assert not dispatcher_is_deny, (
|
|
362
|
+
"Dispatcher should allow MultiEdit heavy prose (OP-07D advisory path)"
|
|
363
|
+
)
|
|
364
|
+
assert dispatcher_result.stdout.strip(), (
|
|
365
|
+
"plain_language_blocker should emit an allow advisory on MultiEdit heavy prose"
|
|
346
366
|
)
|
|
347
367
|
|
|
348
368
|
|
|
@@ -431,16 +451,17 @@ def test_context_survives_alongside_deny_reason(
|
|
|
431
451
|
def test_all_deny_reasons_present_when_multiple_hooks_deny() -> None:
|
|
432
452
|
"""When two or more hooks deny, all their reasons appear in the dispatcher output.
|
|
433
453
|
|
|
434
|
-
Uses a Write to
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
454
|
+
Uses a Write to an existing markdown path with a historical phrase
|
|
455
|
+
("previously") so write_existing_file_blocker and state_description_blocker
|
|
456
|
+
both deny. Heavy words no longer hard-deny (OP-07D), so plain_language is
|
|
457
|
+
not a denier.
|
|
438
458
|
"""
|
|
459
|
+
existing_markdown_path = str(Path(__file__).resolve().parent / "CLAUDE.md")
|
|
439
460
|
multi_deny_content = (
|
|
440
461
|
"# Guide\n\n"
|
|
441
|
-
"Previously the system
|
|
462
|
+
"Previously the system used a different mechanism.\n"
|
|
442
463
|
)
|
|
443
|
-
payload_text = _write_payload(
|
|
464
|
+
payload_text = _write_payload(existing_markdown_path, multi_deny_content)
|
|
444
465
|
_assert_dispatcher_matches_individual_hooks(payload_text, WRITE_TOOL_NAME)
|
|
445
466
|
|
|
446
467
|
dispatcher_result = _run_dispatcher(payload_text)
|
|
@@ -457,8 +478,8 @@ def test_all_deny_reasons_present_when_multiple_hooks_deny() -> None:
|
|
|
457
478
|
|
|
458
479
|
assert len(all_expected_deny_reasons) >= 2, (
|
|
459
480
|
f"Test payload must trip at least two hooks — got {len(all_expected_deny_reasons)}. "
|
|
460
|
-
"Check that
|
|
461
|
-
"triggers
|
|
481
|
+
"Check that an existing path triggers write_existing_file_blocker and "
|
|
482
|
+
"'previously' triggers state_description_blocker on a Write."
|
|
462
483
|
)
|
|
463
484
|
for each_reason in all_expected_deny_reasons:
|
|
464
485
|
assert each_reason in dispatcher_reason, (
|
|
@@ -613,16 +634,68 @@ def test_aggregate_explicit_allow_is_overridden_by_a_deny() -> None:
|
|
|
613
634
|
)
|
|
614
635
|
|
|
615
636
|
|
|
637
|
+
def test_unique_first_seen_strings_keeps_order_and_drops_exact_duplicates() -> None:
|
|
638
|
+
assert unique_first_seen_strings(["a", "b", "a", "c", "b"]) == ["a", "b", "c"]
|
|
639
|
+
assert unique_first_seen_strings(["Same", "same"]) == ["Same", "same"]
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def test_emit_deny_collapses_identical_reasons_and_context(
|
|
643
|
+
capsys: pytest.CaptureFixture[str],
|
|
644
|
+
) -> None:
|
|
645
|
+
"""Final JSON keeps each exact string once; distinct reasons stay; suppress stays."""
|
|
646
|
+
shared_text = "BLOCKED: same corrective text"
|
|
647
|
+
decision = DispatcherDecision(
|
|
648
|
+
should_deny=True,
|
|
649
|
+
should_allow=False,
|
|
650
|
+
all_deny_reasons=[shared_text, "BLOCKED: other reason", shared_text],
|
|
651
|
+
all_system_messages=[shared_text, shared_text, "other notice"],
|
|
652
|
+
all_additional_context=["ctx-a", "ctx-a", "ctx-b"],
|
|
653
|
+
should_suppress_output=True,
|
|
654
|
+
)
|
|
655
|
+
_emit_deny_decision(decision)
|
|
656
|
+
captured = capsys.readouterr().out
|
|
657
|
+
parsed = json.loads(captured)
|
|
658
|
+
reason = parsed["hookSpecificOutput"]["permissionDecisionReason"]
|
|
659
|
+
assert reason == f"{shared_text} | BLOCKED: other reason"
|
|
660
|
+
assert reason.count(shared_text) == 1
|
|
661
|
+
system_message = parsed["systemMessage"]
|
|
662
|
+
assert system_message == f"{shared_text}\nother notice"
|
|
663
|
+
assert parsed["hookSpecificOutput"]["additionalContext"] == "ctx-a\nctx-b"
|
|
664
|
+
assert parsed["suppressOutput"] is True
|
|
665
|
+
assert parsed["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def test_emit_deny_does_not_cross_collapse_reason_and_context(
|
|
669
|
+
capsys: pytest.CaptureFixture[str],
|
|
670
|
+
) -> None:
|
|
671
|
+
"""A deny reason equal to a systemMessage string stays in both fields."""
|
|
672
|
+
shared_text = "identical across fields"
|
|
673
|
+
decision = DispatcherDecision(
|
|
674
|
+
should_deny=True,
|
|
675
|
+
should_allow=False,
|
|
676
|
+
all_deny_reasons=[shared_text],
|
|
677
|
+
all_system_messages=[shared_text],
|
|
678
|
+
all_additional_context=[shared_text],
|
|
679
|
+
should_suppress_output=False,
|
|
680
|
+
)
|
|
681
|
+
_emit_deny_decision(decision)
|
|
682
|
+
parsed = json.loads(capsys.readouterr().out)
|
|
683
|
+
assert parsed["hookSpecificOutput"]["permissionDecisionReason"] == shared_text
|
|
684
|
+
assert parsed["systemMessage"] == shared_text
|
|
685
|
+
assert parsed["hookSpecificOutput"]["additionalContext"] == shared_text
|
|
686
|
+
|
|
687
|
+
|
|
616
688
|
def test_later_hook_deny_survives_early_hook_exit() -> None:
|
|
617
689
|
"""Dispatcher denies even when an earlier hook exits cleanly before a later hook denies.
|
|
618
690
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
SystemExit and continue, so the later
|
|
691
|
+
state_description_blocker denies a markdown write with a historical phrase.
|
|
692
|
+
Earlier hooks exit 0 (allow). Heavy words no longer hard-deny (OP-07D). The
|
|
693
|
+
dispatcher must catch each hook's SystemExit and continue, so the later
|
|
694
|
+
denial reaches the aggregator.
|
|
622
695
|
"""
|
|
623
696
|
payload_text = _write_payload(
|
|
624
697
|
_MARKDOWN_FILE_PATH,
|
|
625
|
-
"# Doc\n\
|
|
698
|
+
"# Doc\n\nPreviously this section used a different mechanism.\n",
|
|
626
699
|
)
|
|
627
700
|
_assert_dispatcher_matches_individual_hooks(payload_text, WRITE_TOOL_NAME)
|
|
628
701
|
|