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
|
@@ -31,6 +31,9 @@ if _hooks_dir not in sys.path:
|
|
|
31
31
|
sys.path.insert(0, _hooks_dir)
|
|
32
32
|
|
|
33
33
|
from blocking.code_rules_shared import is_ephemeral_path # noqa: E402
|
|
34
|
+
from blocking.config.prose_style_enforcement_constants import ( # noqa: E402
|
|
35
|
+
prose_style_enforcement_enabled_in_environment,
|
|
36
|
+
)
|
|
34
37
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
35
38
|
from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
|
|
36
39
|
ALL_CHAT_DETAIL_MARKERS,
|
|
@@ -44,6 +47,7 @@ from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
|
|
|
44
47
|
DOT_CLAUDE_DIRECTORY_NAME,
|
|
45
48
|
FENCED_CODE_BLOCK_PATTERN,
|
|
46
49
|
FILE_PATH_PATTERN,
|
|
50
|
+
HEAVY_WORD_SWAP_LIST_SEPARATOR,
|
|
47
51
|
INLINE_CODE_PATTERN,
|
|
48
52
|
INLINE_CODE_PLACEHOLDER,
|
|
49
53
|
INLINE_CODE_SPAN_PATTERN,
|
|
@@ -65,6 +69,12 @@ from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
|
|
|
65
69
|
USER_FACING_LEAN_QUESTION_NOTICE,
|
|
66
70
|
USER_FACING_PLAIN_LANGUAGE_NOTICE,
|
|
67
71
|
)
|
|
72
|
+
from hooks_constants.prose_matcher_precision_constants import ( # noqa: E402
|
|
73
|
+
ADVISORY_CONTEXT_SNIPPET_MAX_CHARS,
|
|
74
|
+
MATCHER_ID_PLAIN_LANGUAGE_HEAVY_WORD,
|
|
75
|
+
MAXIMUM_ADVISORY_EMITS_PER_CALL,
|
|
76
|
+
)
|
|
77
|
+
from observability.prose_matcher_advisory import emit_advisory_candidate # noqa: E402
|
|
68
78
|
|
|
69
79
|
|
|
70
80
|
def strip_non_prose_regions(text: str) -> str:
|
|
@@ -234,7 +244,12 @@ def _load_project_allowlist(
|
|
|
234
244
|
|
|
235
245
|
|
|
236
246
|
def build_block_reason(all_matches: list[tuple[str, str]]) -> str:
|
|
237
|
-
"""Return a
|
|
247
|
+
"""Return a legacy block-reason string naming each term and plain swap.
|
|
248
|
+
|
|
249
|
+
Heavy-word enforcement no longer hard-denies (OP-07D). Callers that still
|
|
250
|
+
need the named swap list for notices or tests use this helper. Lean-block
|
|
251
|
+
denials use ``build_lean_block_reason`` instead.
|
|
252
|
+
"""
|
|
238
253
|
swap_phrases = ", ".join(
|
|
239
254
|
f'use "{each_replacement}" instead of "{each_term}"'
|
|
240
255
|
for each_term, each_replacement in all_matches
|
|
@@ -246,6 +261,31 @@ def build_block_reason(all_matches: list[tuple[str, str]]) -> str:
|
|
|
246
261
|
)
|
|
247
262
|
|
|
248
263
|
|
|
264
|
+
def build_advisory_system_message(all_matches: list[tuple[str, str]]) -> str:
|
|
265
|
+
"""Return allow-path advisory text naming everyday swaps for heavy words.
|
|
266
|
+
|
|
267
|
+
::
|
|
268
|
+
|
|
269
|
+
build_advisory_system_message([("initiate", "start")])
|
|
270
|
+
-> names use "start" instead of "initiate" (no permission deny)
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
all_matches: ``(heavy_term, everyday_swap)`` pairs from the word scan.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
A systemMessage string for PreToolUse allow with advisory guidance.
|
|
277
|
+
"""
|
|
278
|
+
swap_phrases = HEAVY_WORD_SWAP_LIST_SEPARATOR.join(
|
|
279
|
+
f'use "{each_replacement}" instead of "{each_term}"'
|
|
280
|
+
for each_term, each_replacement in all_matches
|
|
281
|
+
)
|
|
282
|
+
return (
|
|
283
|
+
f"[PLAIN_LANGUAGE advisory] {swap_phrases}. "
|
|
284
|
+
"Reach for the everyday word the reader understands on the first pass. "
|
|
285
|
+
"This notice does not deny the write; lean-block structure denials still apply."
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
249
289
|
def _normalize_line_endings(prose_text: str) -> str:
|
|
250
290
|
"""Return the prose with every line ending spelled as a bare line feed.
|
|
251
291
|
|
|
@@ -572,50 +612,125 @@ def _emit_deny(deny_reason: str, output_stream: TextIO) -> None:
|
|
|
572
612
|
output_stream.flush()
|
|
573
613
|
|
|
574
614
|
|
|
615
|
+
def build_allow_advisory_payload(system_message: str) -> dict[str, object]:
|
|
616
|
+
"""Build an allow payload that still surfaces everyday-word guidance.
|
|
617
|
+
|
|
618
|
+
Args:
|
|
619
|
+
system_message: Advisory text naming heavy terms and plain swaps.
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
A PreToolUse payload without permissionDecision deny.
|
|
623
|
+
"""
|
|
624
|
+
return {
|
|
625
|
+
"hookSpecificOutput": {
|
|
626
|
+
"hookEventName": "PreToolUse",
|
|
627
|
+
"permissionDecision": "allow",
|
|
628
|
+
},
|
|
629
|
+
"systemMessage": system_message,
|
|
630
|
+
"suppressOutput": False,
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _emit_allow_advisory(system_message: str, output_stream: TextIO) -> None:
|
|
635
|
+
output_stream.write(json.dumps(build_allow_advisory_payload(system_message)))
|
|
636
|
+
output_stream.flush()
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _emit_plain_language_advisory_candidates(
|
|
640
|
+
surface: str,
|
|
641
|
+
all_matches: list[tuple[str, str]],
|
|
642
|
+
prose_text: str,
|
|
643
|
+
) -> None:
|
|
644
|
+
"""Record privacy-safe advisory hits when enforcement is off (fail open)."""
|
|
645
|
+
try:
|
|
646
|
+
for each_match in all_matches[:MAXIMUM_ADVISORY_EMITS_PER_CALL]:
|
|
647
|
+
each_term = each_match[0]
|
|
648
|
+
emit_advisory_candidate(
|
|
649
|
+
MATCHER_ID_PLAIN_LANGUAGE_HEAVY_WORD,
|
|
650
|
+
surface,
|
|
651
|
+
f"{each_term}:{prose_text[:ADVISORY_CONTEXT_SNIPPET_MAX_CHARS]}",
|
|
652
|
+
)
|
|
653
|
+
except (ImportError, OSError, TypeError, ValueError):
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
|
|
575
657
|
def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
576
|
-
"""
|
|
658
|
+
"""Return a lean-block deny reason, or None when the payload is allowed.
|
|
577
659
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
660
|
+
Heavy-word matches never produce a deny reason (OP-07D). When prose-style
|
|
661
|
+
enforcement is on, main still emits an allow advisory naming everyday swaps
|
|
662
|
+
for AskUserQuestion and ``.md`` writes. When enforcement is off, matches
|
|
663
|
+
only record privacy-safe advisory candidates (OP-07B).
|
|
582
664
|
|
|
583
665
|
Args:
|
|
584
666
|
payload_by_key: The PreToolUse payload with tool_name and tool_input.
|
|
585
667
|
|
|
586
668
|
Returns:
|
|
587
|
-
The permissionDecisionReason text
|
|
588
|
-
|
|
669
|
+
The permissionDecisionReason text for a lean-block denial, or None when
|
|
670
|
+
the payload is allowed (with or without a heavy-word advisory).
|
|
671
|
+
"""
|
|
672
|
+
deny_reason, _advisory_system_message = evaluate_with_advisory(payload_by_key)
|
|
673
|
+
return deny_reason
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def evaluate_with_advisory(
|
|
677
|
+
payload_by_key: dict[str, object],
|
|
678
|
+
all_allowlisted_terms: frozenset[str] | None = None,
|
|
679
|
+
) -> tuple[str | None, str | None]:
|
|
680
|
+
"""Return ``(deny_reason, advisory_system_message)`` for one PreToolUse payload.
|
|
681
|
+
|
|
682
|
+
::
|
|
683
|
+
|
|
684
|
+
lean chat detail in AskUserQuestion -> (lean deny reason, None)
|
|
685
|
+
heavy word, enforcement ON -> (None, advisory systemMessage)
|
|
686
|
+
heavy word, enforcement OFF -> (None, None) + privacy JSONL
|
|
687
|
+
clean payload -> (None, None)
|
|
688
|
+
|
|
689
|
+
Args:
|
|
690
|
+
payload_by_key: The PreToolUse payload with tool_name and tool_input.
|
|
691
|
+
all_allowlisted_terms: Optional project-vocabulary exemptions. When
|
|
692
|
+
omitted, the project allowlist file is loaded for the payload path.
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
A pair of optional deny reason and optional allow-path advisory message.
|
|
589
696
|
"""
|
|
590
697
|
raw_tool_name = payload_by_key.get("tool_name", "")
|
|
591
698
|
raw_tool_input = payload_by_key.get("tool_input", {})
|
|
592
699
|
if not isinstance(raw_tool_name, str) or not isinstance(raw_tool_input, dict):
|
|
593
|
-
return None
|
|
700
|
+
return None, None
|
|
594
701
|
|
|
595
702
|
if raw_tool_name in ALL_WRITE_EDIT_TOOL_NAMES:
|
|
596
703
|
target_file_path = raw_tool_input.get("file_path", "")
|
|
597
704
|
if isinstance(target_file_path, str) and is_ephemeral_path(
|
|
598
705
|
target_file_path, payload_by_key
|
|
599
706
|
):
|
|
600
|
-
return None
|
|
707
|
+
return None, None
|
|
601
708
|
|
|
602
709
|
if raw_tool_name == ASK_USER_QUESTION_TOOL_NAME:
|
|
603
710
|
all_block_violations = find_question_block_violations(raw_tool_input)
|
|
604
711
|
if all_block_violations:
|
|
605
|
-
return build_lean_block_reason(all_block_violations)
|
|
712
|
+
return build_lean_block_reason(all_block_violations), None
|
|
606
713
|
|
|
607
714
|
prose_text = _collect_prose_for_tool(raw_tool_name, raw_tool_input)
|
|
608
715
|
if not prose_text:
|
|
609
|
-
return None
|
|
716
|
+
return None, None
|
|
610
717
|
|
|
611
|
-
|
|
612
|
-
|
|
718
|
+
resolved_allowlisted_terms = (
|
|
719
|
+
all_allowlisted_terms
|
|
720
|
+
if all_allowlisted_terms is not None
|
|
721
|
+
else _load_project_allowlist(raw_tool_name, raw_tool_input, payload_by_key)
|
|
722
|
+
)
|
|
723
|
+
all_matches = find_banned_terms(
|
|
724
|
+
prose_text, all_allowlisted_terms=resolved_allowlisted_terms
|
|
613
725
|
)
|
|
614
|
-
all_matches = find_banned_terms(prose_text, all_allowlisted_terms)
|
|
615
726
|
if not all_matches:
|
|
616
|
-
return None
|
|
727
|
+
return None, None
|
|
617
728
|
|
|
618
|
-
|
|
729
|
+
if not prose_style_enforcement_enabled_in_environment():
|
|
730
|
+
_emit_plain_language_advisory_candidates(raw_tool_name, all_matches, prose_text)
|
|
731
|
+
return None, None
|
|
732
|
+
|
|
733
|
+
return None, build_advisory_system_message(all_matches)
|
|
619
734
|
|
|
620
735
|
|
|
621
736
|
def main() -> None:
|
|
@@ -627,11 +742,15 @@ def main() -> None:
|
|
|
627
742
|
if not isinstance(input_data, dict):
|
|
628
743
|
sys.exit(0)
|
|
629
744
|
|
|
630
|
-
deny_reason =
|
|
631
|
-
if deny_reason is None:
|
|
745
|
+
deny_reason, advisory_system_message = evaluate_with_advisory(input_data)
|
|
746
|
+
if deny_reason is not None:
|
|
747
|
+
_emit_deny(deny_reason, sys.stdout)
|
|
748
|
+
sys.exit(0)
|
|
749
|
+
|
|
750
|
+
if advisory_system_message is not None:
|
|
751
|
+
_emit_allow_advisory(advisory_system_message, sys.stdout)
|
|
632
752
|
sys.exit(0)
|
|
633
753
|
|
|
634
|
-
_emit_deny(deny_reason, sys.stdout)
|
|
635
754
|
sys.exit(0)
|
|
636
755
|
|
|
637
756
|
|
|
@@ -26,10 +26,16 @@ from pathlib import Path
|
|
|
26
26
|
|
|
27
27
|
import _path_setup # noqa: F401
|
|
28
28
|
|
|
29
|
+
from plain_language_blocker import (
|
|
30
|
+
build_allow_advisory_payload as build_plain_language_allow_advisory_payload,
|
|
31
|
+
)
|
|
29
32
|
from plain_language_blocker import (
|
|
30
33
|
build_deny_payload as build_plain_language_deny_payload,
|
|
31
34
|
)
|
|
32
35
|
from plain_language_blocker import evaluate as evaluate_plain_language
|
|
36
|
+
from plain_language_blocker import (
|
|
37
|
+
evaluate_with_advisory as evaluate_plain_language_with_advisory,
|
|
38
|
+
)
|
|
33
39
|
from state_description_blocker import (
|
|
34
40
|
build_deny_payload as build_state_description_deny_payload,
|
|
35
41
|
)
|
|
@@ -40,32 +46,46 @@ from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
|
40
46
|
ALLOW_DECISION,
|
|
41
47
|
BLOCKING_CRASH_DENY_REASON,
|
|
42
48
|
BLOCKING_CRASH_EXIT_CODE,
|
|
49
|
+
CONTEXT_JOIN_SEPARATOR,
|
|
43
50
|
DENY_DECISION,
|
|
44
51
|
EXIT_CODE_TWO_DENY_REASON,
|
|
45
52
|
HOOK_EVENT_NAME,
|
|
46
53
|
PLAIN_LANGUAGE_BLOCKER_MODULE_NAME,
|
|
54
|
+
REASON_JOIN_SEPARATOR,
|
|
47
55
|
STATE_DESCRIPTION_BLOCKER_MODULE_NAME,
|
|
56
|
+
SYSTEM_MESSAGE_JOIN_SEPARATOR,
|
|
48
57
|
HostedHookEntry,
|
|
49
58
|
)
|
|
50
59
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
51
60
|
|
|
52
61
|
NativeEvaluator = Callable[[dict[str, object]], str | None]
|
|
53
62
|
DenyPayloadBuilder = Callable[[str], dict[str, object]]
|
|
63
|
+
AdvisoryEvaluator = Callable[
|
|
64
|
+
[dict[str, object]], tuple[str | None, str | None]
|
|
65
|
+
]
|
|
66
|
+
AllowAdvisoryPayloadBuilder = Callable[[str], dict[str, object]]
|
|
54
67
|
|
|
55
68
|
|
|
56
69
|
@dataclass(frozen=True)
|
|
57
70
|
class NativeHook:
|
|
58
|
-
"""A nativized hook's evaluator paired with its full
|
|
71
|
+
"""A nativized hook's evaluator paired with its full payload builders.
|
|
59
72
|
|
|
60
73
|
Attributes:
|
|
61
74
|
evaluate: The hook's evaluate function returning a deny-reason or None.
|
|
62
75
|
build_deny_payload: The hook's builder that turns a deny-reason into the
|
|
63
76
|
full deny payload the standalone hook writes (carrying systemMessage,
|
|
64
77
|
additionalContext, and suppressOutput).
|
|
78
|
+
evaluate_with_advisory: Optional evaluator that returns
|
|
79
|
+
``(deny_reason, advisory_system_message)`` so allow-path notices
|
|
80
|
+
match the standalone hook (plain_language OP-07D).
|
|
81
|
+
build_allow_advisory_payload: Optional builder for an allow payload that
|
|
82
|
+
carries the advisory systemMessage without denying.
|
|
65
83
|
"""
|
|
66
84
|
|
|
67
85
|
evaluate: NativeEvaluator
|
|
68
86
|
build_deny_payload: DenyPayloadBuilder
|
|
87
|
+
evaluate_with_advisory: AdvisoryEvaluator | None = None
|
|
88
|
+
build_allow_advisory_payload: AllowAdvisoryPayloadBuilder | None = None
|
|
69
89
|
|
|
70
90
|
|
|
71
91
|
_native_hook_by_module_name: dict[str, NativeHook] = {
|
|
@@ -76,6 +96,8 @@ _native_hook_by_module_name: dict[str, NativeHook] = {
|
|
|
76
96
|
PLAIN_LANGUAGE_BLOCKER_MODULE_NAME: NativeHook(
|
|
77
97
|
evaluate=evaluate_plain_language,
|
|
78
98
|
build_deny_payload=build_plain_language_deny_payload,
|
|
99
|
+
evaluate_with_advisory=evaluate_plain_language_with_advisory,
|
|
100
|
+
build_allow_advisory_payload=build_plain_language_allow_advisory_payload,
|
|
79
101
|
),
|
|
80
102
|
}
|
|
81
103
|
|
|
@@ -180,19 +202,28 @@ def run_native_hook(
|
|
|
180
202
|
Calls the evaluator directly with the payload dict, builds the full deny JSON
|
|
181
203
|
via the hook's own deny-payload builder so the captured stdout matches the
|
|
182
204
|
standalone hook's deny shape (carrying systemMessage, additionalContext, and
|
|
183
|
-
suppressOutput),
|
|
205
|
+
suppressOutput), emits an allow-with-systemMessage payload when the hook
|
|
206
|
+
returns an advisory without a deny, and catches a non-SystemExit crash to
|
|
207
|
+
log and classify it.
|
|
184
208
|
|
|
185
209
|
Args:
|
|
186
|
-
native_hook: The hook's evaluator paired with its
|
|
210
|
+
native_hook: The hook's evaluator paired with its payload builders.
|
|
187
211
|
payload_by_key: The parsed payload dict to pass to the evaluator.
|
|
188
212
|
is_blocking: Whether a crash from this hook surfaces a blocking signal.
|
|
189
213
|
|
|
190
214
|
Returns:
|
|
191
|
-
A HostedHookResult carrying the captured deny
|
|
192
|
-
the crash flag, and the blocking
|
|
215
|
+
A HostedHookResult carrying the captured deny or allow-advisory JSON
|
|
216
|
+
(empty when the payload is clean), the crash flag, and the blocking
|
|
217
|
+
classification.
|
|
193
218
|
"""
|
|
194
219
|
try:
|
|
195
|
-
|
|
220
|
+
if native_hook.evaluate_with_advisory is not None:
|
|
221
|
+
deny_reason, advisory_system_message = native_hook.evaluate_with_advisory(
|
|
222
|
+
payload_by_key
|
|
223
|
+
)
|
|
224
|
+
else:
|
|
225
|
+
deny_reason = native_hook.evaluate(payload_by_key)
|
|
226
|
+
advisory_system_message = None
|
|
196
227
|
except Exception as error:
|
|
197
228
|
_log_hook_crash(native_hook.evaluate.__module__, error)
|
|
198
229
|
return HostedHookResult(
|
|
@@ -202,9 +233,17 @@ def run_native_hook(
|
|
|
202
233
|
is_blocking=is_blocking,
|
|
203
234
|
)
|
|
204
235
|
|
|
205
|
-
|
|
206
|
-
json.dumps(native_hook.build_deny_payload(deny_reason))
|
|
207
|
-
|
|
236
|
+
if deny_reason is not None:
|
|
237
|
+
captured_stdout = json.dumps(native_hook.build_deny_payload(deny_reason))
|
|
238
|
+
elif (
|
|
239
|
+
advisory_system_message is not None
|
|
240
|
+
and native_hook.build_allow_advisory_payload is not None
|
|
241
|
+
):
|
|
242
|
+
captured_stdout = json.dumps(
|
|
243
|
+
native_hook.build_allow_advisory_payload(advisory_system_message)
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
captured_stdout = ""
|
|
208
247
|
return HostedHookResult(
|
|
209
248
|
exit_code=0,
|
|
210
249
|
captured_stdout=captured_stdout,
|
|
@@ -394,40 +433,79 @@ def aggregate_hosted_hook_results(
|
|
|
394
433
|
)
|
|
395
434
|
|
|
396
435
|
|
|
436
|
+
def unique_first_seen_strings(all_values: list[str]) -> list[str]:
|
|
437
|
+
"""Return values in first-seen order with exact-string duplicates dropped.
|
|
438
|
+
|
|
439
|
+
::
|
|
440
|
+
|
|
441
|
+
unique_first_seen_strings(["a", "b", "a"]) -> ["a", "b"]
|
|
442
|
+
|
|
443
|
+
Comparison is exact full-string equality only (no case fold, no trim).
|
|
444
|
+
Each field of the deny payload (reasons, systemMessage, additionalContext)
|
|
445
|
+
is collapsed separately so a deny reason identical to a context string never
|
|
446
|
+
cross-collapses.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
all_values: Strings collected from hosted hooks in run order.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
A new list with the first occurrence of each exact string kept.
|
|
453
|
+
"""
|
|
454
|
+
seen_values: set[str] = set()
|
|
455
|
+
unique_values: list[str] = []
|
|
456
|
+
for each_value in all_values:
|
|
457
|
+
if each_value in seen_values:
|
|
458
|
+
continue
|
|
459
|
+
seen_values.add(each_value)
|
|
460
|
+
unique_values.append(each_value)
|
|
461
|
+
return unique_values
|
|
462
|
+
|
|
463
|
+
|
|
397
464
|
def _emit_deny_decision(decision: DispatcherDecision) -> None:
|
|
398
465
|
"""Write one deny JSON object to stdout carrying all deny reasons and context.
|
|
399
466
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
467
|
+
Collapses exact-duplicate reasons, systemMessage lines, and additionalContext
|
|
468
|
+
lines at emit time (first-seen order) so the final payload is compact while
|
|
469
|
+
aggregate_hosted_hook_results still records every firing for logging.
|
|
403
470
|
|
|
404
471
|
Args:
|
|
405
472
|
decision: The aggregated dispatcher decision with deny reasons, context,
|
|
406
473
|
and the suppressOutput flag.
|
|
407
474
|
"""
|
|
408
|
-
|
|
475
|
+
all_unique_deny_reasons = unique_first_seen_strings(decision.all_deny_reasons)
|
|
476
|
+
all_unique_system_messages = unique_first_seen_strings(decision.all_system_messages)
|
|
477
|
+
all_unique_additional_context = unique_first_seen_strings(
|
|
478
|
+
decision.all_additional_context
|
|
479
|
+
)
|
|
480
|
+
combined_reason = REASON_JOIN_SEPARATOR.join(all_unique_deny_reasons)
|
|
409
481
|
hook_specific: dict[str, object] = {
|
|
410
482
|
"hookEventName": HOOK_EVENT_NAME,
|
|
411
483
|
"permissionDecision": DENY_DECISION,
|
|
412
484
|
"permissionDecisionReason": combined_reason,
|
|
413
485
|
}
|
|
414
|
-
if
|
|
415
|
-
hook_specific["additionalContext"] =
|
|
486
|
+
if all_unique_additional_context:
|
|
487
|
+
hook_specific["additionalContext"] = CONTEXT_JOIN_SEPARATOR.join(
|
|
488
|
+
all_unique_additional_context
|
|
489
|
+
)
|
|
416
490
|
deny_payload: dict[str, object] = {"hookSpecificOutput": hook_specific}
|
|
417
|
-
if
|
|
418
|
-
deny_payload["systemMessage"] =
|
|
491
|
+
if all_unique_system_messages:
|
|
492
|
+
deny_payload["systemMessage"] = CONTEXT_JOIN_SEPARATOR.join(
|
|
493
|
+
all_unique_system_messages
|
|
494
|
+
)
|
|
419
495
|
if decision.should_suppress_output:
|
|
420
496
|
deny_payload["suppressOutput"] = True
|
|
421
497
|
sys.stdout.write(json.dumps(deny_payload) + "\n")
|
|
422
498
|
sys.stdout.flush()
|
|
423
499
|
|
|
424
500
|
|
|
425
|
-
def _emit_allow_decision() -> None:
|
|
501
|
+
def _emit_allow_decision(decision: DispatcherDecision) -> None:
|
|
426
502
|
"""Write one explicit allow JSON object to stdout.
|
|
427
503
|
|
|
428
504
|
Matches the shape a standalone hosted hook emits when it auto-approves the
|
|
429
505
|
write, so a write a hosted hook allows explicitly is auto-approved under the
|
|
430
|
-
dispatcher rather than falling back to the default permission flow.
|
|
506
|
+
dispatcher rather than falling back to the default permission flow. When a
|
|
507
|
+
hook also carried an advisory systemMessage (plain-language heavy words),
|
|
508
|
+
that notice rides on the allow payload without denying.
|
|
431
509
|
"""
|
|
432
510
|
allow_payload: dict[str, object] = {
|
|
433
511
|
"hookSpecificOutput": {
|
|
@@ -435,6 +513,10 @@ def _emit_allow_decision() -> None:
|
|
|
435
513
|
"permissionDecision": ALLOW_DECISION,
|
|
436
514
|
}
|
|
437
515
|
}
|
|
516
|
+
if decision.all_system_messages:
|
|
517
|
+
allow_payload["systemMessage"] = SYSTEM_MESSAGE_JOIN_SEPARATOR.join(
|
|
518
|
+
decision.all_system_messages
|
|
519
|
+
)
|
|
438
520
|
sys.stdout.write(json.dumps(allow_payload) + "\n")
|
|
439
521
|
sys.stdout.flush()
|
|
440
522
|
|
|
@@ -522,7 +604,7 @@ def dispatch(
|
|
|
522
604
|
_emit_deny_decision(aggregated_decision)
|
|
523
605
|
return
|
|
524
606
|
if aggregated_decision.should_allow:
|
|
525
|
-
_emit_allow_decision()
|
|
607
|
+
_emit_allow_decision(aggregated_decision)
|
|
526
608
|
|
|
527
609
|
|
|
528
610
|
def main() -> None:
|
|
@@ -20,6 +20,9 @@ if _hooks_dir not in sys.path:
|
|
|
20
20
|
sys.path.insert(0, _hooks_dir)
|
|
21
21
|
|
|
22
22
|
from blocking.code_rules_shared import is_ephemeral_path # noqa: E402
|
|
23
|
+
from blocking.config.prose_style_enforcement_constants import ( # noqa: E402
|
|
24
|
+
prose_style_enforcement_enabled_in_environment,
|
|
25
|
+
)
|
|
23
26
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
24
27
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
|
|
25
28
|
from hooks_constants.state_description_blocker_constants import ( # noqa: E402
|
|
@@ -278,7 +281,8 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
278
281
|
|
|
279
282
|
Applies the same tool-name gate, file-extension gate, content selection, and
|
|
280
283
|
pattern scan the standalone hook applies. Returns the deny-reason text when a
|
|
281
|
-
historical phrase is found, or None to allow.
|
|
284
|
+
historical phrase is found, or None to allow. Opinionated prose enforcement
|
|
285
|
+
is default-off via ``prose_style_enforcement_enabled_in_environment()``.
|
|
282
286
|
|
|
283
287
|
Args:
|
|
284
288
|
payload_by_key: The PreToolUse payload with tool_name and tool_input.
|
|
@@ -287,6 +291,8 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
287
291
|
The permissionDecisionReason text when the write is denied, or None when
|
|
288
292
|
the write is allowed.
|
|
289
293
|
"""
|
|
294
|
+
if not prose_style_enforcement_enabled_in_environment():
|
|
295
|
+
return None
|
|
290
296
|
raw_tool_name = payload_by_key.get("tool_name", "")
|
|
291
297
|
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
292
298
|
if tool_name not in ("Write", "Edit"):
|
|
@@ -17,6 +17,7 @@ try:
|
|
|
17
17
|
if each_bootstrap_directory not in sys.path:
|
|
18
18
|
sys.path.insert(0, each_bootstrap_directory)
|
|
19
19
|
from code_rules_shared import (
|
|
20
|
+
is_agent_home_tooling,
|
|
20
21
|
is_ephemeral_script_path,
|
|
21
22
|
is_under_session_scratchpad,
|
|
22
23
|
)
|
|
@@ -40,6 +41,11 @@ def _is_session_scratchpad_write(file_path: str, input_data: dict) -> bool:
|
|
|
40
41
|
return is_under_session_scratchpad(file_path, input_data)
|
|
41
42
|
|
|
42
43
|
|
|
44
|
+
def _is_agent_home_tooling_write(file_path: str) -> bool:
|
|
45
|
+
"""A coding agent's own home-directory tooling is outside the TDD gate."""
|
|
46
|
+
return is_agent_home_tooling(file_path)
|
|
47
|
+
|
|
48
|
+
|
|
43
49
|
production_extensions = path_classification.production_extensions
|
|
44
50
|
skip_extensions = path_classification.skip_extensions
|
|
45
51
|
_is_inside_dotclaude_segment = path_classification._is_inside_dotclaude_segment
|
|
@@ -69,6 +75,8 @@ def _resolve_payload(input_data: dict) -> tuple[str, dict, str]:
|
|
|
69
75
|
def _is_silently_skipped(file_path: str, extension: str, path: Path) -> bool:
|
|
70
76
|
if _is_inside_dotclaude_segment(file_path) or is_ephemeral_script_path(file_path):
|
|
71
77
|
return True
|
|
78
|
+
if _is_agent_home_tooling_write(file_path):
|
|
79
|
+
return True
|
|
72
80
|
if extension in skip_extensions():
|
|
73
81
|
return True
|
|
74
82
|
if extension not in production_extensions():
|
|
@@ -37,3 +37,31 @@ def test_importing_path_setup_puts_the_hooks_directory_on_sys_path() -> None:
|
|
|
37
37
|
)
|
|
38
38
|
assert completed.returncode == 0, completed.stderr
|
|
39
39
|
assert completed.stdout.strip() == "ok"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_importing_path_setup_puts_the_blocking_directory_on_sys_path() -> None:
|
|
43
|
+
"""A module reached through the package finds its own directory on sys.path.
|
|
44
|
+
|
|
45
|
+
``code_rules_shared`` imports both ``hooks_constants`` (a hooks-directory
|
|
46
|
+
package) and its blocking-directory siblings, so the bootstrap must place
|
|
47
|
+
both roots. Only the hooks directory is on PYTHONPATH here, so the blocking
|
|
48
|
+
entry can only come from the bootstrap itself.
|
|
49
|
+
"""
|
|
50
|
+
driver = (
|
|
51
|
+
"import sys\n"
|
|
52
|
+
"from blocking import _path_setup\n"
|
|
53
|
+
"from pathlib import Path\n"
|
|
54
|
+
"blocking_dir = str(Path(_path_setup.__file__).resolve().parent)\n"
|
|
55
|
+
"assert blocking_dir in sys.path, blocking_dir\n"
|
|
56
|
+
"print('ok')\n"
|
|
57
|
+
)
|
|
58
|
+
subprocess_environment = {**os.environ, "PYTHONPATH": str(_HOOKS_DIR)}
|
|
59
|
+
completed = subprocess.run(
|
|
60
|
+
[sys.executable, "-c", driver],
|
|
61
|
+
check=False,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
env=subprocess_environment,
|
|
65
|
+
)
|
|
66
|
+
assert completed.returncode == 0, completed.stderr
|
|
67
|
+
assert completed.stdout.strip() == "ok"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Agent-home tooling exemption tests for the classifier and the enforcer.
|
|
2
|
+
|
|
3
|
+
A coding agent installs its own helper scripts under a dot-directory in the
|
|
4
|
+
user's home — ``~/.grok/runs/`` sits beside the already-exempt
|
|
5
|
+
``~/.claude/hooks/``. That tree is vendored tooling rather than project code,
|
|
6
|
+
so the repository code rules do not govern it::
|
|
7
|
+
|
|
8
|
+
/home/example/.grok/runs/worktree-health/health.py -> exempt
|
|
9
|
+
C:/dev/my-project/src/grokking.py -> governed
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from blocking import _path_setup # noqa: F401 (pins this checkout ahead of sibling worktrees)
|
|
21
|
+
from blocking.code_rules_shared import is_agent_home_tooling, is_ephemeral_path
|
|
22
|
+
|
|
23
|
+
_ENFORCER_SCRIPT = Path(__file__).resolve().parent / "code_rules_enforcer.py"
|
|
24
|
+
|
|
25
|
+
_VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check_as(candidate_source: str, target_path: str) -> subprocess.CompletedProcess[str]:
|
|
29
|
+
"""Run the enforcer's pre-check on a candidate judged as another path.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
candidate_source: Python source written to the candidate file.
|
|
33
|
+
target_path: Destination path the candidate is judged as.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The completed process carrying stdout, stderr, and the exit code.
|
|
37
|
+
"""
|
|
38
|
+
with tempfile.TemporaryDirectory() as scratch:
|
|
39
|
+
candidate = Path(scratch) / "candidate.py"
|
|
40
|
+
candidate.write_text(candidate_source, encoding="utf-8")
|
|
41
|
+
return subprocess.run(
|
|
42
|
+
[sys.executable, str(_ENFORCER_SCRIPT), "--check", str(candidate), "--as", target_path],
|
|
43
|
+
input="",
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
check=False,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_posix_agent_home_path_is_agent_tooling() -> None:
|
|
51
|
+
assert is_agent_home_tooling("/home/example/.grok/runs/worktree-health/health.py")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_windows_agent_home_path_is_agent_tooling() -> None:
|
|
55
|
+
assert is_agent_home_tooling(r"C:\Users\example\.grok\runs\worktree-health\health.py")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_any_depth_under_the_agent_home_is_agent_tooling() -> None:
|
|
59
|
+
assert is_agent_home_tooling("/home/example/.grok/hooks/force_worktree.py")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_ordinary_project_code_is_not_agent_tooling() -> None:
|
|
63
|
+
assert not is_agent_home_tooling("/dev/my-project/src/service.py")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_a_filename_spelled_like_the_agent_is_not_agent_tooling() -> None:
|
|
67
|
+
assert not is_agent_home_tooling("/dev/my-project/src/grokking.py")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_a_directory_without_the_leading_dot_is_not_agent_tooling() -> None:
|
|
71
|
+
assert not is_agent_home_tooling("/dev/my-project/grok/runner.py")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_an_empty_path_is_not_agent_tooling() -> None:
|
|
75
|
+
assert not is_agent_home_tooling("")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_agent_home_path_is_exempt_from_the_repository_gates() -> None:
|
|
79
|
+
assert is_ephemeral_path("/home/example/.grok/runs/worktree-health/health.py")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_project_code_is_not_exempt_from_the_repository_gates() -> None:
|
|
83
|
+
assert not is_ephemeral_path("/dev/my-project/src/service.py")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_enforcer_allows_violating_source_under_the_agent_home() -> None:
|
|
87
|
+
target = os.path.join("/home/example", ".grok", "runs", "worktree-health", "health.py")
|
|
88
|
+
|
|
89
|
+
completed = _check_as(_VIOLATING_PRODUCTION_SOURCE, target)
|
|
90
|
+
|
|
91
|
+
assert completed.returncode == 0, completed.stdout + completed.stderr
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_enforcer_still_flags_the_same_source_in_a_project_path() -> None:
|
|
95
|
+
target = os.path.join("/dev/my-project", "src", "service.py")
|
|
96
|
+
|
|
97
|
+
completed = _check_as(_VIOLATING_PRODUCTION_SOURCE, target)
|
|
98
|
+
|
|
99
|
+
assert completed.returncode != 0
|