claude-dev-env 2.8.0 → 2.10.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 +8 -2
- 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 +62 -562
- package/agents/code-quality-agent.md +10 -2
- package/agents/code-verifier.md +1 -1
- package/agents/test_agent_frontmatter.py +58 -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/CODE_RULES.md +4 -2
- package/docs/references/CLAUDE.md +3 -2
- package/docs/references/advisor-tool.md +44 -6
- package/docs/references/prose-style-enforcement.md +25 -0
- package/docs/references/team-advisor-skill.md +14 -8
- 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_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/code_rules_path_utils_constants.py +1 -0
- 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 +15 -0
- package/package.json +5 -2
- package/rules/CLAUDE.md +1 -0
- package/rules/code-standards.md +33 -7
- package/rules/durable-post-artifacts.md +2 -2
- package/rules/eli11-replies.md +7 -2
- 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 +13 -2
- 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 +3 -1
- 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/grok_run_ledger_constants.py +50 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +104 -0
- package/scripts/dev_env_scripts_constants/verify_installable_package_constants.py +116 -0
- package/scripts/grok_patch_artifacts.py +123 -0
- package/scripts/grok_run_ledger.py +318 -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 +556 -9
- 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_grok_patch_artifacts.py +82 -0
- package/scripts/test_grok_run_ledger.py +116 -0
- package/scripts/test_resolve_worker_spawn.py +2 -2
- package/scripts/test_spawn_grok_batch.py +295 -0
- 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 +6 -2
- package/skills/_shared/CLAUDE.md +37 -4
- package/skills/_shared/advisor/CLAUDE.md +9 -0
- package/skills/_shared/advisor/advisor-protocol.md +5 -0
- package/skills/_shared/advisor/scripts/README.md +9 -0
- package/skills/_shared/end-of-run-gotcha-recommendations.md +156 -0
- package/skills/_shared/pr-loop/CLAUDE.md +18 -1
- package/skills/_shared/pr-loop/audit-contract.md +5 -0
- package/skills/_shared/pr-loop/audit-reply-template.md +5 -0
- package/skills/_shared/pr-loop/code-rules-gate.md +5 -0
- package/skills/_shared/pr-loop/fix-protocol.md +5 -0
- package/skills/_shared/pr-loop/gh-payloads.md +5 -0
- package/skills/_shared/pr-loop/post-audit-thread-contract.md +5 -0
- package/skills/_shared/pr-loop/precatch-rubric.md +5 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +9 -1
- package/skills/_shared/pr-loop/scripts/RUNTIME_SCRIPTS.md +29 -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/_shared/pr-loop/state-schema.md +5 -0
- package/skills/_shared/pr-loop/worker-spawn.md +5 -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 +29 -3
- 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/reference/runner-selection.md +40 -0
- 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/__init__.py +1 -0
- package/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +49 -0
- package/skills/e-code-review/scripts/e_code_review_scripts_constants/grok_code_review_constants.py +55 -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 +230 -0
- 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 +289 -0
- package/skills/grok-spawn/SKILL.md +5 -0
- package/skills/orchestrator/SKILL.md +5 -0
- 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/task-build/reference/tool-routing.md +3 -0
- package/skills/team-advisor/SKILL.md +23 -44
- package/system-prompts/software-engineer.xml +17 -5
- package/commands/initialize.md +0 -90
- package/commands/stubcheck.md +0 -88
- package/skills/test_markdown_link_integrity.py +0 -107
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
2
|
+
"""Stop hook for bare hedging words that mark unverified claims.
|
|
3
|
+
|
|
4
|
+
Words like "likely" or "probably" stand in for evidence. When prose-style
|
|
5
|
+
enforcement is on, a hedge word blocks the response unless the same sentence
|
|
6
|
+
carries an explicit uncertainty label (unverified, I don't know, no source for
|
|
7
|
+
this claim). A label in another sentence does not exempt a bare hedge.
|
|
8
|
+
|
|
9
|
+
::
|
|
4
10
|
|
|
5
|
-
|
|
6
|
-
|
|
11
|
+
pass: This claim is unverified; the deploy is probably blocked.
|
|
12
|
+
flag: This claim is unverified. The deploy is probably blocked.
|
|
13
|
+
|
|
14
|
+
When enforcement is off, surviving bare hedges emit privacy-safe advisory
|
|
15
|
+
candidates (OP-07B). See rules/hedging-claims.md and
|
|
16
|
+
docs/references/prose-style-enforcement.md.
|
|
7
17
|
"""
|
|
8
18
|
|
|
9
19
|
import json
|
|
@@ -16,9 +26,25 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
16
26
|
if _hooks_dir not in sys.path:
|
|
17
27
|
sys.path.insert(0, _hooks_dir)
|
|
18
28
|
|
|
29
|
+
from blocking.config.prose_style_enforcement_constants import ( # noqa: E402
|
|
30
|
+
prose_style_enforcement_enabled_in_environment,
|
|
31
|
+
)
|
|
32
|
+
from hooks_constants.hedging_uncertainty_constants import ( # noqa: E402
|
|
33
|
+
ALL_EXPLICIT_UNCERTAINTY_LABEL_PATTERNS,
|
|
34
|
+
BLOCK_REASON_PREFIX,
|
|
35
|
+
HEDGING_TERM_LIST_SEPARATOR,
|
|
36
|
+
POSITIVE_CORRECTIVE_GUIDANCE,
|
|
37
|
+
SENTENCE_BOUNDARY_PATTERN,
|
|
38
|
+
)
|
|
19
39
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
20
40
|
from hooks_constants.messages import USER_FACING_NOTICE # noqa: E402
|
|
41
|
+
from hooks_constants.prose_matcher_precision_constants import ( # noqa: E402
|
|
42
|
+
ADVISORY_CONTEXT_SNIPPET_MAX_CHARS,
|
|
43
|
+
MATCHER_ID_HEDGING_WORD,
|
|
44
|
+
MAXIMUM_ADVISORY_EMITS_PER_CALL,
|
|
45
|
+
)
|
|
21
46
|
from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
|
|
47
|
+
from observability.prose_matcher_advisory import emit_advisory_candidate # noqa: E402
|
|
22
48
|
|
|
23
49
|
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
24
50
|
|
|
@@ -59,18 +85,65 @@ ALL_HEDGING_PATTERNS = [
|
|
|
59
85
|
re.compile(pattern, re.IGNORECASE) for pattern in HEDGING_WORDS + HEDGING_PHRASES
|
|
60
86
|
]
|
|
61
87
|
|
|
62
|
-
def find_hedging_words(text: str) -> list[str]:
|
|
63
|
-
"""Return all hedging words/phrases found in the text."""
|
|
64
|
-
prose_text = strip_code_and_quotes(text)
|
|
65
|
-
matched_terms = []
|
|
66
88
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
89
|
+
def _sentence_carries_explicit_uncertainty(sentence: str) -> bool:
|
|
90
|
+
"""Return True when the sentence labels the claim as unknown or unverified."""
|
|
91
|
+
for each_label_pattern in ALL_EXPLICIT_UNCERTAINTY_LABEL_PATTERNS:
|
|
92
|
+
if each_label_pattern.search(sentence):
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _hedging_terms_in_prose_slice(prose_slice: str) -> list[str]:
|
|
98
|
+
"""Return unique hedge terms found in one prose slice (sentence or full text)."""
|
|
99
|
+
matched_terms: list[str] = []
|
|
100
|
+
for each_pattern in ALL_HEDGING_PATTERNS:
|
|
101
|
+
for each_match in each_pattern.findall(prose_slice):
|
|
70
102
|
normalized_term = each_match.strip().lower()
|
|
71
103
|
if normalized_term not in matched_terms:
|
|
72
104
|
matched_terms.append(normalized_term)
|
|
105
|
+
return matched_terms
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def find_hedging_words(text: str) -> list[str]:
|
|
109
|
+
"""Return all hedging words/phrases in the stripped prose (any sentence)."""
|
|
110
|
+
prose_text = strip_code_and_quotes(text)
|
|
111
|
+
return _hedging_terms_in_prose_slice(prose_text)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def find_blocking_hedging_terms(text: str) -> list[str]:
|
|
115
|
+
"""Return hedge terms that lack an explicit uncertainty label in-sentence.
|
|
116
|
+
|
|
117
|
+
::
|
|
118
|
+
|
|
119
|
+
find_blocking_hedging_terms("Unverified: the port is probably down.")
|
|
120
|
+
-> []
|
|
121
|
+
find_blocking_hedging_terms("Unverified. The port is probably down.")
|
|
122
|
+
-> ["probably"]
|
|
73
123
|
|
|
124
|
+
Args:
|
|
125
|
+
text: Full assistant message, including code and quotes.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Unique bare hedge terms that still warrant a block or advisory emit.
|
|
129
|
+
"""
|
|
130
|
+
prose_text = strip_code_and_quotes(text)
|
|
131
|
+
if not prose_text.strip():
|
|
132
|
+
return []
|
|
133
|
+
|
|
134
|
+
all_sentences = SENTENCE_BOUNDARY_PATTERN.split(prose_text)
|
|
135
|
+
if len(all_sentences) <= 1:
|
|
136
|
+
all_sentences = [prose_text]
|
|
137
|
+
|
|
138
|
+
matched_terms: list[str] = []
|
|
139
|
+
for each_sentence in all_sentences:
|
|
140
|
+
if not each_sentence.strip():
|
|
141
|
+
continue
|
|
142
|
+
if _sentence_carries_explicit_uncertainty(each_sentence):
|
|
143
|
+
continue
|
|
144
|
+
for each_term in _hedging_terms_in_prose_slice(each_sentence):
|
|
145
|
+
if each_term not in matched_terms:
|
|
146
|
+
matched_terms.append(each_term)
|
|
74
147
|
return matched_terms
|
|
75
148
|
|
|
76
149
|
|
|
@@ -88,12 +161,26 @@ def main() -> None:
|
|
|
88
161
|
if not assistant_message:
|
|
89
162
|
sys.exit(0)
|
|
90
163
|
|
|
91
|
-
found_hedging_terms =
|
|
164
|
+
found_hedging_terms = find_blocking_hedging_terms(assistant_message)
|
|
92
165
|
|
|
93
166
|
if not found_hedging_terms:
|
|
94
167
|
sys.exit(0)
|
|
95
168
|
|
|
96
|
-
|
|
169
|
+
if not prose_style_enforcement_enabled_in_environment():
|
|
170
|
+
try:
|
|
171
|
+
for each_term in found_hedging_terms[:MAXIMUM_ADVISORY_EMITS_PER_CALL]:
|
|
172
|
+
emit_advisory_candidate(
|
|
173
|
+
MATCHER_ID_HEDGING_WORD,
|
|
174
|
+
"Stop",
|
|
175
|
+
f"{each_term}:{assistant_message[:ADVISORY_CONTEXT_SNIPPET_MAX_CHARS]}",
|
|
176
|
+
)
|
|
177
|
+
except (ImportError, OSError, TypeError, ValueError):
|
|
178
|
+
pass
|
|
179
|
+
sys.exit(0)
|
|
180
|
+
|
|
181
|
+
formatted_term_list = HEDGING_TERM_LIST_SEPARATOR.join(
|
|
182
|
+
f'"{term}"' for term in found_hedging_terms
|
|
183
|
+
)
|
|
97
184
|
|
|
98
185
|
resolved_skill_path: str | None = None
|
|
99
186
|
for each_skill_path in RESEARCH_MODE_SKILL_SEARCH_PATHS:
|
|
@@ -110,13 +197,9 @@ def main() -> None:
|
|
|
110
197
|
)
|
|
111
198
|
|
|
112
199
|
block_reason = (
|
|
113
|
-
f"
|
|
114
|
-
f"{
|
|
115
|
-
f"
|
|
116
|
-
f"{skill_reference}\n\n"
|
|
117
|
-
f"Do NOT simply remove the hedging word and keep the unverified claim. "
|
|
118
|
-
f"Do more research to VERIFY it with a source, or prompt the user via AskUserQuestion with some potential options + context if you are unable to find anything online.\n\n"
|
|
119
|
-
f"You MUST re-output the complete, revised response with the corrections applied."
|
|
200
|
+
f"{BLOCK_REASON_PREFIX}{formatted_term_list}. "
|
|
201
|
+
f"Rewrite {skill_reference}\n\n"
|
|
202
|
+
f"{POSITIVE_CORRECTIVE_GUIDANCE}"
|
|
120
203
|
)
|
|
121
204
|
block_response = {
|
|
122
205
|
"decision": "block",
|
|
@@ -39,6 +39,9 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
39
39
|
if _hooks_dir not in sys.path:
|
|
40
40
|
sys.path.insert(0, _hooks_dir)
|
|
41
41
|
|
|
42
|
+
from blocking.config.prose_style_enforcement_constants import ( # noqa: E402
|
|
43
|
+
prose_style_enforcement_enabled_in_environment,
|
|
44
|
+
)
|
|
42
45
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
43
46
|
from hooks_constants.hook_prose_detector_consistency_constants import ( # noqa: E402
|
|
44
47
|
CONSTANTS_MODULE_SUFFIX,
|
|
@@ -118,6 +121,9 @@ def main() -> None:
|
|
|
118
121
|
except json.JSONDecodeError:
|
|
119
122
|
sys.exit(0)
|
|
120
123
|
|
|
124
|
+
if not prose_style_enforcement_enabled_in_environment():
|
|
125
|
+
sys.exit(0)
|
|
126
|
+
|
|
121
127
|
tool_name = hook_input.get("tool_name", "")
|
|
122
128
|
if tool_name not in (WRITE_TOOL_NAME, EDIT_TOOL_NAME):
|
|
123
129
|
sys.exit(0)
|
|
@@ -18,6 +18,9 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
18
18
|
if _hooks_dir not in sys.path:
|
|
19
19
|
sys.path.insert(0, _hooks_dir)
|
|
20
20
|
|
|
21
|
+
from blocking.config.prose_style_enforcement_constants import ( # noqa: E402
|
|
22
|
+
prose_style_enforcement_enabled_in_environment,
|
|
23
|
+
)
|
|
21
24
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
22
25
|
from hooks_constants.messages import USER_FACING_INTENT_ENDING_NOTICE # noqa: E402
|
|
23
26
|
from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
|
|
@@ -110,6 +113,9 @@ def main() -> None:
|
|
|
110
113
|
if hook_input.get("stop_hook_active", False):
|
|
111
114
|
sys.exit(0)
|
|
112
115
|
|
|
116
|
+
if not prose_style_enforcement_enabled_in_environment():
|
|
117
|
+
sys.exit(0)
|
|
118
|
+
|
|
113
119
|
assistant_message = hook_input.get("last_assistant_message", "")
|
|
114
120
|
|
|
115
121
|
if not assistant_message:
|
|
@@ -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:
|