claude-dev-env 2.3.0 → 2.5.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 +53 -48
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +4 -2
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/CLAUDE.md +3 -1
- package/agents/caveman.md +0 -1
- package/agents/clasp-deployment-orchestrator.md +0 -1
- package/agents/clean-coder.md +0 -1
- package/agents/code-advisor.md +0 -1
- package/agents/code-quality-agent.md +1 -2
- package/agents/code-verifier.md +3 -4
- package/agents/deep-research.md +0 -1
- package/agents/docs-agent.md +0 -1
- package/agents/git-commit-crafter.md +0 -1
- package/agents/issue-tracker.md +42 -0
- package/agents/plan-packet-validator.md +0 -1
- package/agents/pr-description-writer.md +0 -1
- package/agents/skill-writer-agent.md +84 -0
- package/agents/test_agent_frontmatter.py +67 -18
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +105 -3
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
- package/bin/CLAUDE.md +68 -5
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install-constants.mjs +88 -0
- package/bin/install.mjs +1138 -114
- package/bin/install.prune.test.mjs +869 -19
- package/bin/install.test.mjs +906 -2
- package/commands/implement.md +1 -1
- package/commands/right-size.md +1 -1
- package/docs/CLAUDE.md +2 -0
- package/docs/host-pool-health-monitor.md +102 -0
- package/docs/references/CLAUDE.md +5 -2
- package/docs/references/advisor-tool.md +13 -0
- package/docs/references/code-review-enforcement.md +107 -0
- package/docs/references/team-advisor-skill.md +14 -0
- package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
- package/hooks/blocking/CLAUDE.md +9 -1
- package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
- package/hooks/blocking/code_review_gate_deny.py +74 -0
- package/hooks/blocking/code_review_pr_create_gate.py +198 -0
- package/hooks/blocking/code_review_push_gate.py +145 -0
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +348 -0
- package/hooks/blocking/code_review_stamp_store.py +233 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
- package/hooks/blocking/config/__init__.py +5 -5
- package/hooks/blocking/config/code_review_enforcement_constants.py +113 -0
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +113 -0
- package/hooks/blocking/config/verified_commit_constants.py +160 -155
- package/hooks/blocking/conftest.py +2 -0
- package/hooks/blocking/convergence_gate_blocker.py +112 -23
- package/hooks/blocking/destructive_command_blocker.py +19 -6
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
- package/hooks/blocking/pr_description_proof_of_work.py +52 -34
- package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
- package/hooks/blocking/test_code_review_gate_deny.py +54 -0
- package/hooks/blocking/test_code_review_pr_create_gate.py +199 -0
- package/hooks/blocking/test_code_review_push_gate.py +205 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +199 -0
- package/hooks/blocking/test_code_review_stamp_store.py +205 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
- package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
- package/hooks/blocking/test_destructive_command_blocker.py +1 -1
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +17 -8
- package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
- package/hooks/blocking/test_verification_verdict_store.py +974 -903
- package/hooks/blocking/test_verified_commit_gate.py +581 -581
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
- package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
- package/hooks/blocking/verification_verdict_store.py +1039 -1014
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
- package/hooks/blocking/verifier_verdict_minter.py +280 -280
- package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
- package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
- package/hooks/git-hooks/pre_push.py +89 -2
- package/hooks/git-hooks/test_pre_push.py +128 -0
- package/hooks/hooks.json +26 -1
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
- package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
- package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
- package/hooks/pyproject.toml +1 -0
- package/hooks/validators/CLAUDE.md +2 -0
- package/hooks/validators/config/__init__.py +0 -0
- package/hooks/validators/config/directory_exemption_constants.py +183 -0
- package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
- package/hooks/validators/conftest.py +4 -0
- package/hooks/validators/mypy_integration.py +63 -50
- package/hooks/validators/pyproject_config_discovery.py +101 -0
- package/hooks/validators/ruff_integration.py +257 -25
- package/hooks/validators/run_all_validators.py +223 -19
- package/hooks/validators/test_directory_exemption_constants.py +185 -0
- package/hooks/validators/test_mypy_integration.py +32 -0
- package/hooks/validators/test_pyproject_config_discovery.py +94 -0
- package/hooks/validators/test_python_antipattern_checks.py +110 -5
- package/hooks/validators/test_ruff_integration.py +160 -2
- package/hooks/validators/test_run_all_validators.py +140 -68
- package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
- package/package.json +10 -2
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +45 -67
- package/rules/durable-post-artifacts.md +7 -0
- package/rules/state-what-is.md +25 -0
- package/rules/verified-commit-gate-skip.md +1 -1
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Capture-PoolHealth.ps1 +410 -0
- package/scripts/_code_review_test_support.py +404 -0
- package/scripts/claude_chain_runner.py +141 -1
- package/scripts/codec_forwarding_test_support.py +83 -0
- package/scripts/conftest.py +23 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -2
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +53 -1
- package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
- package/scripts/invoke_code_review.py +550 -38
- package/scripts/resolve_worker_spawn.py +626 -619
- package/scripts/spawn_grok_batch.py +672 -672
- package/scripts/test_claude_chain_runner.py +131 -0
- package/scripts/test_invoke_code_review_chain.py +70 -0
- package/scripts/test_invoke_code_review_cli.py +192 -0
- package/scripts/test_invoke_code_review_codec.py +77 -0
- package/scripts/test_invoke_code_review_contract.py +256 -0
- package/scripts/test_invoke_code_review_git.py +123 -0
- package/scripts/test_invoke_code_review_mode.py +99 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -1014
- package/scripts/test_resolve_worker_spawn_codec.py +101 -0
- package/skills/CLAUDE.md +2 -0
- package/skills/auditing-claude-config/SKILL.md +114 -114
- package/skills/autoconverge/SKILL.md +427 -421
- package/skills/autoconverge/reference/convergence.md +26 -4
- package/skills/autoconverge/reference/multi-pr.md +6 -1
- package/skills/autoconverge/reference/stop-conditions.md +16 -10
- package/skills/autoconverge/workflow/CLAUDE.md +1 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1244
- package/skills/autoconverge/workflow/converge.mjs +191 -8
- package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
- package/skills/closeout/SKILL.md +33 -50
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
- package/skills/codex-review/scripts/run_codex_review.py +233 -1
- package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
- package/skills/condensing-instructions/SKILL.md +81 -0
- package/skills/copilot-review/SKILL.md +119 -119
- package/skills/e-code-review/SKILL.md +52 -0
- package/skills/e-code-review/reference/fix.md +54 -0
- package/skills/e-code-review/reference/loop.md +43 -0
- package/skills/e-code-review/reference/low.md +57 -0
- package/skills/e-code-review/reference/medium.md +153 -0
- package/skills/e-code-review/reference/xhigh.md +182 -0
- package/skills/e-simplify/SKILL.md +97 -0
- package/skills/fresh-branch/CLAUDE.md +2 -0
- package/skills/fresh-branch/SKILL.md +2 -0
- package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
- package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
- package/skills/fresh-branch/scripts/pytest.ini +4 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
- package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
- package/skills/issue-tracker/SKILL.md +92 -0
- package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
- package/skills/issue-tracker/reference/handoff-schema.md +64 -0
- package/skills/issue-tracker/reference/operation-matrix.md +41 -0
- package/skills/orchestrator/SKILL.md +162 -21
- package/skills/orchestrator/scripts/status_gate.py +625 -0
- package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
- package/skills/orchestrator/scripts/test_status_gate.py +439 -0
- package/skills/orchestrator-refresh/SKILL.md +110 -35
- package/skills/plan-to-pr/SKILL.md +155 -0
- package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
- package/skills/plan-to-pr/reference/model-routing.md +36 -0
- package/skills/plan-to-pr/reference/packet-contract.md +43 -0
- package/skills/plan-to-pr/reference/packet-schema.json +57 -0
- package/skills/plan-to-pr/reference/process-inventory.md +22 -0
- package/skills/plan-to-pr/reference/review-loop.md +33 -0
- package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
- package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
- package/skills/plan-to-pr/reference/task-seeds.md +14 -0
- package/skills/plan-to-pr/reference/task-ticket.md +38 -0
- package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
- package/skills/plan-to-pr/scripts/config/constants.py +193 -0
- package/skills/plan-to-pr/scripts/create_packet.py +173 -0
- package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
- package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
- package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
- package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
- package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
- package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
- package/skills/plan-to-pr/scripts/validate_run.py +173 -0
- package/skills/plan-to-pr/test_skill_contract.py +207 -0
- package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
- package/skills/pr-converge/SKILL.md +472 -469
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +7 -4
- package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/pr-fix-protocol/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -68
- package/skills/prototype/SKILL.md +86 -0
- package/skills/prototype/reference/honest-limitations.md +23 -0
- package/skills/prototype/reference/promotion-tasks.md +23 -0
- package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
- package/skills/prototype/scripts/conftest.py +15 -0
- package/skills/prototype/scripts/launch_sandbox.py +205 -0
- package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
- package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
- package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
- package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
- package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
- package/skills/prototype/workflows/promotion.md +27 -0
- package/skills/prototype/workflows/sandbox.md +35 -0
- package/skills/release-notes-html/SKILL.md +164 -0
- package/skills/skill-builder/CLAUDE.md +3 -3
- package/skills/skill-builder/SKILL.md +5 -5
- package/skills/skill-builder/references/CLAUDE.md +1 -1
- package/skills/skill-builder/references/delegation-map.md +3 -3
- package/skills/skill-builder/references/description-field.md +1 -1
- package/skills/skill-builder/references/skill-modularity.md +2 -3
- package/skills/skill-builder/workflows/CLAUDE.md +1 -1
- package/skills/skill-builder/workflows/improve-skill.md +1 -1
- package/skills/skill-builder/workflows/new-skill.md +2 -2
- package/skills/task-build/CLAUDE.md +8 -7
- package/skills/task-build/SKILL.md +16 -8
- package/skills/task-build/reference/tool-routing.md +19 -0
- package/skills/team-advisor/SKILL.md +2 -2
- package/scripts/test_invoke_code_review.py +0 -672
- package/skills/closeout/reference/issue-body-templates.md +0 -108
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse gate: deny orchestrator-refresh re-arm when the run is not active.
|
|
3
|
+
|
|
4
|
+
Fires on ``ScheduleWakeup`` and ``CronCreate`` when the prompt targets
|
|
5
|
+
``/orchestrator-refresh``. Uses the same status-file gate as
|
|
6
|
+
``skills/orchestrator/scripts/status_gate.py`` so idle loops
|
|
7
|
+
cannot be re-armed on the Claude host without an active status file.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from types import ModuleType
|
|
19
|
+
from typing import Mapping
|
|
20
|
+
|
|
21
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
22
|
+
if _hooks_dir not in sys.path:
|
|
23
|
+
sys.path.insert(0, _hooks_dir)
|
|
24
|
+
|
|
25
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
26
|
+
from hooks_constants.orchestrator_refresh_reschedule_gate_constants import ( # noqa: E402
|
|
27
|
+
ALL_HOME_SKILLS_RELATIVE_PARTS,
|
|
28
|
+
ALL_SCHEDULE_TOOL_NAMES,
|
|
29
|
+
ALL_SKILL_SCRIPTS_RELATIVE_PARTS,
|
|
30
|
+
CALLING_HOOK_NAME,
|
|
31
|
+
CRON_CREATE_TOOL_NAME,
|
|
32
|
+
CWD_FIELD_NAME,
|
|
33
|
+
DENY_REASON_TEMPLATE,
|
|
34
|
+
HOOK_EVENT_NAME,
|
|
35
|
+
ORCHESTRATOR_REFRESH_PROMPT_TOKEN,
|
|
36
|
+
PLUGIN_ROOT_ENV_VAR,
|
|
37
|
+
PROMPT_FIELD_NAME,
|
|
38
|
+
REASON_CRON_CREATE_FORBIDDEN,
|
|
39
|
+
RUN_SLUG_PROMPT_FLAG,
|
|
40
|
+
RUN_SLUG_PROMPT_VALUE_PATTERN,
|
|
41
|
+
TOOL_INPUT_FIELD_NAME,
|
|
42
|
+
TOOL_NAME_FIELD_NAME,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _candidate_skill_script_directories() -> list[Path]:
|
|
47
|
+
"""Return ordered paths that may contain status_gate.py.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Absolute candidate directories in preference order.
|
|
51
|
+
"""
|
|
52
|
+
all_candidates: list[Path] = []
|
|
53
|
+
plugin_root = os.environ.get(PLUGIN_ROOT_ENV_VAR)
|
|
54
|
+
if plugin_root:
|
|
55
|
+
all_candidates.append(
|
|
56
|
+
Path(plugin_root).joinpath(*ALL_SKILL_SCRIPTS_RELATIVE_PARTS)
|
|
57
|
+
)
|
|
58
|
+
package_hooks_parent = Path(__file__).resolve().parents[2]
|
|
59
|
+
all_candidates.append(
|
|
60
|
+
package_hooks_parent.joinpath(*ALL_SKILL_SCRIPTS_RELATIVE_PARTS)
|
|
61
|
+
)
|
|
62
|
+
all_candidates.append(Path.home().joinpath(*ALL_HOME_SKILLS_RELATIVE_PARTS))
|
|
63
|
+
return all_candidates
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _load_status_gate_module() -> ModuleType | None:
|
|
67
|
+
"""Import status_gate from the skill scripts package.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The loaded module, or None when no candidate path is usable.
|
|
71
|
+
"""
|
|
72
|
+
for each_directory in _candidate_skill_script_directories():
|
|
73
|
+
module_path = each_directory / "status_gate.py"
|
|
74
|
+
if not module_path.is_file():
|
|
75
|
+
continue
|
|
76
|
+
scripts_directory = str(each_directory)
|
|
77
|
+
if scripts_directory not in sys.path:
|
|
78
|
+
sys.path.insert(0, scripts_directory)
|
|
79
|
+
spec = importlib.util.spec_from_file_location(
|
|
80
|
+
"orchestrator_status_gate_for_reschedule_hook",
|
|
81
|
+
module_path,
|
|
82
|
+
)
|
|
83
|
+
if spec is None or spec.loader is None:
|
|
84
|
+
continue
|
|
85
|
+
status_gate_module = importlib.util.module_from_spec(spec)
|
|
86
|
+
sys.modules[spec.name] = status_gate_module
|
|
87
|
+
spec.loader.exec_module(status_gate_module)
|
|
88
|
+
return status_gate_module
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _prompt_targets_orchestrator_refresh(
|
|
93
|
+
all_tool_input: Mapping[str, object],
|
|
94
|
+
) -> bool:
|
|
95
|
+
"""Return True when the schedule prompt re-enters orchestrator-refresh.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
all_tool_input: PreToolUse tool_input mapping.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
True when the prompt field contains the refresh skill token.
|
|
102
|
+
"""
|
|
103
|
+
prompt_value = all_tool_input.get(PROMPT_FIELD_NAME, "")
|
|
104
|
+
if not isinstance(prompt_value, str):
|
|
105
|
+
return False
|
|
106
|
+
stripped_prompt = prompt_value.lstrip()
|
|
107
|
+
if not stripped_prompt.startswith(ORCHESTRATOR_REFRESH_PROMPT_TOKEN):
|
|
108
|
+
return False
|
|
109
|
+
if len(stripped_prompt) == len(ORCHESTRATOR_REFRESH_PROMPT_TOKEN):
|
|
110
|
+
return True
|
|
111
|
+
return stripped_prompt[len(ORCHESTRATOR_REFRESH_PROMPT_TOKEN)] in (" ", "\n", "\t")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _should_evaluate(all_payload_by_field: Mapping[str, object]) -> bool:
|
|
115
|
+
"""Return True for schedule tools re-arming orchestrator-refresh.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
all_payload_by_field: Full PreToolUse payload.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
True when this gate must run.
|
|
122
|
+
"""
|
|
123
|
+
tool_name = all_payload_by_field.get(TOOL_NAME_FIELD_NAME, "")
|
|
124
|
+
if tool_name not in ALL_SCHEDULE_TOOL_NAMES:
|
|
125
|
+
return False
|
|
126
|
+
tool_input = all_payload_by_field.get(TOOL_INPUT_FIELD_NAME, {})
|
|
127
|
+
if not isinstance(tool_input, dict):
|
|
128
|
+
return False
|
|
129
|
+
return _prompt_targets_orchestrator_refresh(tool_input)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _run_slug_from_prompt(prompt_value: str) -> str:
|
|
133
|
+
"""Extract ``--run-slug`` from a ScheduleWakeup prompt when present.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
prompt_value: The schedule tool prompt text.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The slug string, or empty when the flag is absent.
|
|
140
|
+
"""
|
|
141
|
+
run_slug_from_prompt_pattern = re.compile(
|
|
142
|
+
re.escape(RUN_SLUG_PROMPT_FLAG) + RUN_SLUG_PROMPT_VALUE_PATTERN
|
|
143
|
+
)
|
|
144
|
+
matched = run_slug_from_prompt_pattern.search(prompt_value)
|
|
145
|
+
if matched is None:
|
|
146
|
+
return ""
|
|
147
|
+
return matched.group(1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _resolve_status_file_path(
|
|
151
|
+
status_gate_module: ModuleType,
|
|
152
|
+
all_payload_by_field: Mapping[str, object],
|
|
153
|
+
) -> Path:
|
|
154
|
+
"""Resolve the run status file for this schedule attempt without chdir.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
status_gate_module: Loaded status_gate module.
|
|
158
|
+
all_payload_by_field: PreToolUse payload (may carry cwd and prompt).
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Absolute status file path.
|
|
162
|
+
"""
|
|
163
|
+
base_directory: Path | None = None
|
|
164
|
+
working_directory_value = all_payload_by_field.get(CWD_FIELD_NAME)
|
|
165
|
+
if isinstance(working_directory_value, str) and working_directory_value:
|
|
166
|
+
base_directory = Path(working_directory_value)
|
|
167
|
+
run_slug = ""
|
|
168
|
+
tool_input = all_payload_by_field.get(TOOL_INPUT_FIELD_NAME, {})
|
|
169
|
+
if isinstance(tool_input, dict):
|
|
170
|
+
prompt_value = tool_input.get(PROMPT_FIELD_NAME, "")
|
|
171
|
+
if isinstance(prompt_value, str):
|
|
172
|
+
run_slug = _run_slug_from_prompt(prompt_value)
|
|
173
|
+
return status_gate_module.resolve_status_file_path(
|
|
174
|
+
None, base_directory, run_slug
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def build_denial_response(reason_code: str) -> dict[str, dict[str, str]]:
|
|
179
|
+
"""Build the PreToolUse deny payload.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
reason_code: Machine reason from decide_should_reschedule.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Hook-specific deny JSON structure.
|
|
186
|
+
"""
|
|
187
|
+
denial_reason = DENY_REASON_TEMPLATE.format(reason=reason_code)
|
|
188
|
+
return {
|
|
189
|
+
"hookSpecificOutput": {
|
|
190
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
191
|
+
"permissionDecision": "deny",
|
|
192
|
+
"permissionDecisionReason": denial_reason,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _emit_denial(
|
|
198
|
+
reason_code: str,
|
|
199
|
+
all_payload_by_field: Mapping[str, object],
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Log and print a deny decision for the current schedule attempt.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
reason_code: Machine reason string.
|
|
205
|
+
all_payload_by_field: PreToolUse payload for logging context.
|
|
206
|
+
"""
|
|
207
|
+
denial = build_denial_response(reason_code)
|
|
208
|
+
hook_specific_output = denial["hookSpecificOutput"]
|
|
209
|
+
block_reason = hook_specific_output["permissionDecisionReason"]
|
|
210
|
+
log_hook_block(
|
|
211
|
+
calling_hook_name=CALLING_HOOK_NAME,
|
|
212
|
+
hook_event=HOOK_EVENT_NAME,
|
|
213
|
+
block_reason=block_reason,
|
|
214
|
+
tool_name=str(all_payload_by_field.get(TOOL_NAME_FIELD_NAME, "")),
|
|
215
|
+
offending_input_preview=str(
|
|
216
|
+
all_payload_by_field.get(TOOL_INPUT_FIELD_NAME, {})
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
print(json.dumps(denial))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def main() -> None:
|
|
223
|
+
"""Read PreToolUse stdin and deny idle orchestrator-refresh re-arms."""
|
|
224
|
+
try:
|
|
225
|
+
payload_by_field = json.load(sys.stdin)
|
|
226
|
+
except json.JSONDecodeError:
|
|
227
|
+
sys.exit(0)
|
|
228
|
+
|
|
229
|
+
if not isinstance(payload_by_field, dict):
|
|
230
|
+
sys.exit(0)
|
|
231
|
+
if not _should_evaluate(payload_by_field):
|
|
232
|
+
sys.exit(0)
|
|
233
|
+
|
|
234
|
+
tool_name = payload_by_field.get(TOOL_NAME_FIELD_NAME, "")
|
|
235
|
+
if tool_name == CRON_CREATE_TOOL_NAME:
|
|
236
|
+
_emit_denial(REASON_CRON_CREATE_FORBIDDEN, payload_by_field)
|
|
237
|
+
sys.exit(0)
|
|
238
|
+
|
|
239
|
+
status_gate_module = _load_status_gate_module()
|
|
240
|
+
if status_gate_module is None:
|
|
241
|
+
_emit_denial("status_gate_unavailable", payload_by_field)
|
|
242
|
+
sys.exit(0)
|
|
243
|
+
|
|
244
|
+
status_file_path = _resolve_status_file_path(status_gate_module, payload_by_field)
|
|
245
|
+
is_reschedule_allowed, reason_code = status_gate_module.decide_should_reschedule(
|
|
246
|
+
status_file_path
|
|
247
|
+
)
|
|
248
|
+
if is_reschedule_allowed:
|
|
249
|
+
sys.exit(0)
|
|
250
|
+
|
|
251
|
+
_emit_denial(reason_code, payload_by_field)
|
|
252
|
+
sys.exit(0)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
main()
|
|
@@ -20,10 +20,11 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
20
20
|
if _hooks_dir not in sys.path:
|
|
21
21
|
sys.path.insert(0, _hooks_dir)
|
|
22
22
|
|
|
23
|
-
from blocking.
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
from blocking.convergence_gate_blocker import ( # noqa: E402
|
|
24
|
+
_repo_flag_arguments,
|
|
25
|
+
_resolve_named_identity,
|
|
26
26
|
)
|
|
27
|
+
from blocking.pr_description_body_audit import _iter_section_headers # noqa: E402
|
|
27
28
|
from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
|
|
28
29
|
BULLET_MARKER_PATTERN,
|
|
29
30
|
FENCED_CODE_BLOCK_PATTERN,
|
|
@@ -32,7 +33,6 @@ from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
|
|
|
32
33
|
from hooks_constants.pr_description_proof_of_work_constants import ( # noqa: E402
|
|
33
34
|
ALL_HONEST_GAP_PHRASES,
|
|
34
35
|
ALL_PR_DIFF_SUBCOMMANDS,
|
|
35
|
-
ALL_PR_VIEW_NUMBER_ARGUMENTS,
|
|
36
36
|
ALL_PROOF_HEADING_KEYWORDS,
|
|
37
37
|
ALL_VISUAL_FILE_SUFFIXES,
|
|
38
38
|
COMMENT_BODY_JSON_FIELD,
|
|
@@ -49,7 +49,6 @@ from hooks_constants.pr_description_proof_of_work_constants import ( # noqa: E4
|
|
|
49
49
|
PLAN_LINKAGE_KEYWORD_PATTERN,
|
|
50
50
|
PR_COMMENTS_API_PATH_TEMPLATE,
|
|
51
51
|
PR_DIFF_NAME_ONLY_FLAG,
|
|
52
|
-
PR_NUMBER_JSON_FIELD,
|
|
53
52
|
PR_READY_GATE_MESSAGE_TEMPLATE,
|
|
54
53
|
PR_READY_INVOCATION_PATTERN,
|
|
55
54
|
PR_READY_UNDO_FLAG,
|
|
@@ -112,13 +111,18 @@ def audit_proof_comment_body(body: str, pr_number: int | None) -> list[str]:
|
|
|
112
111
|
an honest-gaps statement, and (when the PR diff is visual) an
|
|
113
112
|
image embed.
|
|
114
113
|
"""
|
|
115
|
-
is_visual_change = _detect_visual_change(pr_number)
|
|
114
|
+
is_visual_change = _detect_visual_change(pr_number, None)
|
|
116
115
|
return _missing_proof_parts(body, is_visual_change)
|
|
117
116
|
|
|
118
117
|
|
|
119
118
|
def evaluate_pr_ready_gate(command: str) -> str | None:
|
|
120
119
|
"""Decide whether a gh pr ready command may proceed.
|
|
121
120
|
|
|
121
|
+
Every gh read the gate runs binds to the repository the command names —
|
|
122
|
+
a full PR URL or a ``--repo``/``-R`` flag — so a command targeting one
|
|
123
|
+
repository from a working tree checked out to another audits the named
|
|
124
|
+
repository's PR, not the working tree's.
|
|
125
|
+
|
|
122
126
|
Args:
|
|
123
127
|
command: The raw shell command captured by the hook.
|
|
124
128
|
|
|
@@ -127,15 +131,14 @@ def evaluate_pr_ready_gate(command: str) -> str | None:
|
|
|
127
131
|
carries no passing proof comment, or None when a passing proof
|
|
128
132
|
comment exists or any gh query fails.
|
|
129
133
|
"""
|
|
130
|
-
|
|
131
|
-
if
|
|
132
|
-
resolved_pr_number = _resolve_current_pr_number()
|
|
133
|
-
if resolved_pr_number is None:
|
|
134
|
+
target_identity = _resolve_named_identity(command, None)
|
|
135
|
+
if target_identity is None:
|
|
134
136
|
return None
|
|
135
|
-
|
|
137
|
+
all_target_repo, resolved_pr_number = target_identity
|
|
138
|
+
all_comment_bodies = _fetch_pr_comment_bodies(resolved_pr_number, all_target_repo)
|
|
136
139
|
if all_comment_bodies is None:
|
|
137
140
|
return None
|
|
138
|
-
is_visual_change = _detect_visual_change(resolved_pr_number)
|
|
141
|
+
is_visual_change = _detect_visual_change(resolved_pr_number, all_target_repo)
|
|
139
142
|
for each_body in all_comment_bodies:
|
|
140
143
|
if not is_proof_shaped_body(each_body):
|
|
141
144
|
continue
|
|
@@ -173,29 +176,30 @@ def _run_gh_command(all_gh_arguments: list[str]) -> str | None:
|
|
|
173
176
|
return completed_process.stdout
|
|
174
177
|
|
|
175
178
|
|
|
176
|
-
def
|
|
177
|
-
"""
|
|
179
|
+
def _build_comments_api_path(pr_number: int, all_target_repo: tuple[str, str] | None) -> str:
|
|
180
|
+
"""Build the issues-comments API path for the named repository's PR.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
pr_number: The PR number whose comments to read.
|
|
184
|
+
all_target_repo: The (owner, repo) pair the command names, or None to
|
|
185
|
+
leave gh's ``{owner}``/``{repo}`` placeholders for gh to expand
|
|
186
|
+
from the current directory's repository.
|
|
178
187
|
|
|
179
188
|
Returns:
|
|
180
|
-
The
|
|
181
|
-
|
|
189
|
+
The ``repos/<owner>/<repo>/issues/<pr_number>/comments`` path with
|
|
190
|
+
the named owner and repo filled in, or the placeholder form when no
|
|
191
|
+
repository is named.
|
|
182
192
|
"""
|
|
183
|
-
|
|
184
|
-
if
|
|
185
|
-
return
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
except json.JSONDecodeError:
|
|
189
|
-
return None
|
|
190
|
-
if not isinstance(view_record, dict):
|
|
191
|
-
return None
|
|
192
|
-
resolved_number = view_record.get(PR_NUMBER_JSON_FIELD)
|
|
193
|
-
if isinstance(resolved_number, int):
|
|
194
|
-
return resolved_number
|
|
195
|
-
return None
|
|
193
|
+
comments_api_path = PR_COMMENTS_API_PATH_TEMPLATE.format(pr_number=pr_number)
|
|
194
|
+
if all_target_repo is None:
|
|
195
|
+
return comments_api_path
|
|
196
|
+
owner, repo = all_target_repo
|
|
197
|
+
return comments_api_path.format(owner=owner, repo=repo)
|
|
196
198
|
|
|
197
199
|
|
|
198
|
-
def _fetch_pr_comment_bodies(
|
|
200
|
+
def _fetch_pr_comment_bodies(
|
|
201
|
+
pr_number: int, all_target_repo: tuple[str, str] | None
|
|
202
|
+
) -> list[str] | None:
|
|
199
203
|
"""Read every comment body on a PR through the paginated issues API.
|
|
200
204
|
|
|
201
205
|
The query runs ``gh api --paginate --slurp`` so every page lands in one
|
|
@@ -204,13 +208,15 @@ def _fetch_pr_comment_bodies(pr_number: int) -> list[str] | None:
|
|
|
204
208
|
|
|
205
209
|
Args:
|
|
206
210
|
pr_number: The PR number whose comments to read.
|
|
211
|
+
all_target_repo: The (owner, repo) pair the command names, or None to
|
|
212
|
+
read the current directory's repository.
|
|
207
213
|
|
|
208
214
|
Returns:
|
|
209
215
|
The comment bodies across all pages, or None when gh fails or the
|
|
210
216
|
output is not the slurped page-array shape — the caller fails open
|
|
211
217
|
on None.
|
|
212
218
|
"""
|
|
213
|
-
comments_api_path =
|
|
219
|
+
comments_api_path = _build_comments_api_path(pr_number, all_target_repo)
|
|
214
220
|
api_stdout = _run_gh_command(
|
|
215
221
|
[GH_API_SUBCOMMAND, comments_api_path, GH_PAGINATE_FLAG, GH_SLURP_FLAG]
|
|
216
222
|
)
|
|
@@ -232,7 +238,9 @@ def _fetch_pr_comment_bodies(pr_number: int) -> list[str] | None:
|
|
|
232
238
|
return all_comment_bodies
|
|
233
239
|
|
|
234
240
|
|
|
235
|
-
def _detect_visual_change(
|
|
241
|
+
def _detect_visual_change(
|
|
242
|
+
pr_number: int | None, all_target_repo: tuple[str, str] | None
|
|
243
|
+
) -> bool:
|
|
236
244
|
"""Decide whether the PR diff carries visual work.
|
|
237
245
|
|
|
238
246
|
A diff is visual when a changed file carries an image, HTML, or
|
|
@@ -242,21 +250,31 @@ def _detect_visual_change(pr_number: int | None) -> bool:
|
|
|
242
250
|
Args:
|
|
243
251
|
pr_number: The PR number to diff, or None to diff the current
|
|
244
252
|
branch's PR.
|
|
253
|
+
all_target_repo: The (owner, repo) pair the command names, or None to
|
|
254
|
+
diff the current directory's repository.
|
|
245
255
|
|
|
246
256
|
Returns:
|
|
247
257
|
True when the diff is visual; False when it is not or when any gh
|
|
248
258
|
query fails — an unknowable diff never adds the visual requirement.
|
|
249
259
|
"""
|
|
250
260
|
all_number_arguments = [str(pr_number)] if pr_number is not None else []
|
|
261
|
+
all_repo_arguments = _repo_flag_arguments(all_target_repo)
|
|
251
262
|
name_only_stdout = _run_gh_command(
|
|
252
|
-
[
|
|
263
|
+
[
|
|
264
|
+
*ALL_PR_DIFF_SUBCOMMANDS,
|
|
265
|
+
*all_number_arguments,
|
|
266
|
+
*all_repo_arguments,
|
|
267
|
+
PR_DIFF_NAME_ONLY_FLAG,
|
|
268
|
+
]
|
|
253
269
|
)
|
|
254
270
|
if name_only_stdout is None:
|
|
255
271
|
return False
|
|
256
272
|
for each_changed_path in name_only_stdout.splitlines():
|
|
257
273
|
if Path(each_changed_path.strip()).suffix.lower() in ALL_VISUAL_FILE_SUFFIXES:
|
|
258
274
|
return True
|
|
259
|
-
diff_stdout = _run_gh_command(
|
|
275
|
+
diff_stdout = _run_gh_command(
|
|
276
|
+
[*ALL_PR_DIFF_SUBCOMMANDS, *all_number_arguments, *all_repo_arguments]
|
|
277
|
+
)
|
|
260
278
|
if diff_stdout is None:
|
|
261
279
|
return False
|
|
262
280
|
return HEX_COLOR_ADDED_LINE_PATTERN.search(diff_stdout[:MAX_DIFF_SCAN_CHARS]) is not None
|
|
@@ -7,10 +7,10 @@ declared in the constants module, aggregates the results, and emits one deny
|
|
|
7
7
|
decision when any hook denied (carrying every denying reason) or exits zero to
|
|
8
8
|
allow.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
Each tool runs the ``ALL_HOSTED_HOOK_ENTRIES`` whose ``applicable_tool_names``
|
|
11
|
+
list that tool; that roster is the live source of the per-tool coverage, and the
|
|
12
|
+
dispatcher roster tests in ``test_pre_tool_use_dispatcher.py`` assert the counts,
|
|
13
|
+
rather than a hardcoded matrix here that would drift as hooks are added.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
@@ -24,20 +24,18 @@ from collections.abc import Callable
|
|
|
24
24
|
from dataclasses import dataclass, field
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
if _hooks_directory not in sys.path:
|
|
29
|
-
sys.path.insert(0, _hooks_directory)
|
|
27
|
+
import _path_setup # noqa: F401
|
|
30
28
|
|
|
31
|
-
from plain_language_blocker import (
|
|
29
|
+
from plain_language_blocker import (
|
|
32
30
|
build_deny_payload as build_plain_language_deny_payload,
|
|
33
31
|
)
|
|
34
|
-
from plain_language_blocker import evaluate as evaluate_plain_language
|
|
35
|
-
from state_description_blocker import (
|
|
32
|
+
from plain_language_blocker import evaluate as evaluate_plain_language
|
|
33
|
+
from state_description_blocker import (
|
|
36
34
|
build_deny_payload as build_state_description_deny_payload,
|
|
37
35
|
)
|
|
38
|
-
from state_description_blocker import evaluate as evaluate_state_description
|
|
36
|
+
from state_description_blocker import evaluate as evaluate_state_description
|
|
39
37
|
|
|
40
|
-
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
38
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
41
39
|
ALL_HOSTED_HOOK_ENTRIES,
|
|
42
40
|
ALLOW_DECISION,
|
|
43
41
|
BLOCKING_CRASH_DENY_REASON,
|
|
@@ -49,7 +47,7 @@ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402
|
|
|
49
47
|
STATE_DESCRIPTION_BLOCKER_MODULE_NAME,
|
|
50
48
|
HostedHookEntry,
|
|
51
49
|
)
|
|
52
|
-
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
50
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
53
51
|
|
|
54
52
|
NativeEvaluator = Callable[[dict[str, object]], str | None]
|
|
55
53
|
DenyPayloadBuilder = Callable[[str], dict[str, object]]
|
|
@@ -70,6 +68,18 @@ class NativeHook:
|
|
|
70
68
|
build_deny_payload: DenyPayloadBuilder
|
|
71
69
|
|
|
72
70
|
|
|
71
|
+
_native_hook_by_module_name: dict[str, NativeHook] = {
|
|
72
|
+
STATE_DESCRIPTION_BLOCKER_MODULE_NAME: NativeHook(
|
|
73
|
+
evaluate=evaluate_state_description,
|
|
74
|
+
build_deny_payload=build_state_description_deny_payload,
|
|
75
|
+
),
|
|
76
|
+
PLAIN_LANGUAGE_BLOCKER_MODULE_NAME: NativeHook(
|
|
77
|
+
evaluate=evaluate_plain_language,
|
|
78
|
+
build_deny_payload=build_plain_language_deny_payload,
|
|
79
|
+
),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
73
83
|
@dataclass
|
|
74
84
|
class HostedHookResult:
|
|
75
85
|
"""Outcome of running one hosted hook inside the dispatcher process.
|
|
@@ -477,17 +487,7 @@ def _run_one_hosted_hook(
|
|
|
477
487
|
The HostedHookResult for this hook's run.
|
|
478
488
|
"""
|
|
479
489
|
if each_entry.native_module_name is not None:
|
|
480
|
-
|
|
481
|
-
STATE_DESCRIPTION_BLOCKER_MODULE_NAME: NativeHook(
|
|
482
|
-
evaluate=evaluate_state_description,
|
|
483
|
-
build_deny_payload=build_state_description_deny_payload,
|
|
484
|
-
),
|
|
485
|
-
PLAIN_LANGUAGE_BLOCKER_MODULE_NAME: NativeHook(
|
|
486
|
-
evaluate=evaluate_plain_language,
|
|
487
|
-
build_deny_payload=build_plain_language_deny_payload,
|
|
488
|
-
),
|
|
489
|
-
}
|
|
490
|
-
native_hook = native_hook_by_module_name[each_entry.native_module_name]
|
|
490
|
+
native_hook = _native_hook_by_module_name[each_entry.native_module_name]
|
|
491
491
|
return run_native_hook(native_hook, payload_by_key, each_entry.is_blocking)
|
|
492
492
|
script_path = _resolve_hook_script_path(each_entry.script_relative_path)
|
|
493
493
|
return run_hosted_hook(script_path, payload_text, each_entry.is_blocking)
|
|
@@ -213,7 +213,7 @@ def test_additional_context_is_collected_from_deciding_hooks() -> None:
|
|
|
213
213
|
|
|
214
214
|
|
|
215
215
|
def test_powershell_selects_the_three_shared_hooks() -> None:
|
|
216
|
-
"""Selecting for PowerShell yields the
|
|
216
|
+
"""Selecting for PowerShell yields the shared hooks in registration order."""
|
|
217
217
|
powershell_paths = [
|
|
218
218
|
each_entry.script_relative_path
|
|
219
219
|
for each_entry in select_applicable_entries(POWERSHELL_TOOL_NAME)
|
|
@@ -222,6 +222,9 @@ def test_powershell_selects_the_three_shared_hooks() -> None:
|
|
|
222
222
|
"blocking/pii_prevention_blocker.py",
|
|
223
223
|
"blocking/verified_commit_gate.py",
|
|
224
224
|
"blocking/verdict_directory_write_blocker.py",
|
|
225
|
+
"blocking/code_review_push_gate.py",
|
|
226
|
+
"blocking/code_review_pr_create_gate.py",
|
|
227
|
+
"blocking/code_review_stamp_directory_write_blocker.py",
|
|
225
228
|
]
|
|
226
229
|
|
|
227
230
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Regression tests for the code-review enforcement constants bootstrap.
|
|
2
|
+
|
|
3
|
+
These prove the shared constants module resolves to the sibling
|
|
4
|
+
``config/code_review_enforcement_constants.py`` file even when a foreign
|
|
5
|
+
``config`` package sits ahead of ``blocking`` on ``sys.path``, and that a
|
|
6
|
+
registration a caller placed first is left untouched.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
RunUnderConfigShadow = Callable[[str], subprocess.CompletedProcess[str]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_register_binds_dotted_name_to_sibling_constants_file(
|
|
16
|
+
run_under_config_shadow: RunUnderConfigShadow,
|
|
17
|
+
) -> None:
|
|
18
|
+
completed_probe = run_under_config_shadow(
|
|
19
|
+
"import code_review_enforcement_config_bootstrap as bootstrap\n"
|
|
20
|
+
"bootstrap.register_code_review_enforcement_constants()\n"
|
|
21
|
+
"import config.code_review_enforcement_constants as loaded\n"
|
|
22
|
+
"print(loaded.__file__)\n"
|
|
23
|
+
"print(loaded.STAMP_DIRECTORY_NAME)\n"
|
|
24
|
+
)
|
|
25
|
+
assert completed_probe.returncode == 0, completed_probe.stderr
|
|
26
|
+
assert "IS_DECOY_CONFIG" not in completed_probe.stdout
|
|
27
|
+
resolved_file_line, stamp_directory_line = completed_probe.stdout.splitlines()[:2]
|
|
28
|
+
assert resolved_file_line.replace("\\", "/").endswith(
|
|
29
|
+
"blocking/config/code_review_enforcement_constants.py"
|
|
30
|
+
)
|
|
31
|
+
assert stamp_directory_line == "code-review-stamps"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_register_binds_push_required_effort(
|
|
35
|
+
run_under_config_shadow: RunUnderConfigShadow,
|
|
36
|
+
) -> None:
|
|
37
|
+
completed_probe = run_under_config_shadow(
|
|
38
|
+
"import code_review_enforcement_config_bootstrap as bootstrap\n"
|
|
39
|
+
"bootstrap.register_code_review_enforcement_constants()\n"
|
|
40
|
+
"import config.code_review_enforcement_constants as loaded\n"
|
|
41
|
+
"print(loaded.PUSH_REQUIRED_EFFORT)\n"
|
|
42
|
+
)
|
|
43
|
+
assert completed_probe.returncode == 0, completed_probe.stderr
|
|
44
|
+
assert completed_probe.stdout.strip().splitlines()[-1] == "low"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_register_leaves_an_already_cached_module_untouched(
|
|
48
|
+
run_under_config_shadow: RunUnderConfigShadow,
|
|
49
|
+
) -> None:
|
|
50
|
+
completed_probe = run_under_config_shadow(
|
|
51
|
+
"import sys\n"
|
|
52
|
+
"import types\n"
|
|
53
|
+
"sentinel = types.ModuleType('config.code_review_enforcement_constants')\n"
|
|
54
|
+
"sentinel.ORIGIN = 'caller-seeded'\n"
|
|
55
|
+
"sys.modules['config.code_review_enforcement_constants'] = sentinel\n"
|
|
56
|
+
"import code_review_enforcement_config_bootstrap as bootstrap\n"
|
|
57
|
+
"bootstrap.register_code_review_enforcement_constants()\n"
|
|
58
|
+
"import config.code_review_enforcement_constants as loaded\n"
|
|
59
|
+
"print(getattr(loaded, 'ORIGIN', 'displaced'))\n"
|
|
60
|
+
)
|
|
61
|
+
assert completed_probe.returncode == 0, completed_probe.stderr
|
|
62
|
+
assert completed_probe.stdout.strip().splitlines()[-1] == "caller-seeded"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Behavioral tests for the shared code-review gate deny scaffold.
|
|
2
|
+
|
|
3
|
+
The tests drive the two shared helpers directly: the payload builder's shape and
|
|
4
|
+
the log-and-emit helper's stdout, so both code-review gates inherit one verified
|
|
5
|
+
deny path.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import ModuleType
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
_HOOK_DIR = Path(__file__).resolve().parent
|
|
19
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
20
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_module(module_name: str) -> ModuleType:
|
|
24
|
+
module_spec = importlib.util.spec_from_file_location(
|
|
25
|
+
module_name, _HOOK_DIR / f"{module_name}.py"
|
|
26
|
+
)
|
|
27
|
+
assert module_spec is not None
|
|
28
|
+
assert module_spec.loader is not None
|
|
29
|
+
loaded_module = importlib.util.module_from_spec(module_spec)
|
|
30
|
+
module_spec.loader.exec_module(loaded_module)
|
|
31
|
+
return loaded_module
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
deny_module = _load_module("code_review_gate_deny")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_build_payload_carries_reason_and_deny_decision() -> None:
|
|
38
|
+
deny_payload = deny_module.build_code_review_deny_payload("blocked reason")
|
|
39
|
+
hook_output = deny_payload["hookSpecificOutput"]
|
|
40
|
+
assert hook_output["permissionDecision"] == "deny"
|
|
41
|
+
assert hook_output["permissionDecisionReason"] == "blocked reason"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_log_and_emit_code_review_deny_writes_payload_to_stdout(
|
|
45
|
+
capsys: pytest.CaptureFixture[str],
|
|
46
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
47
|
+
tmp_path: Path,
|
|
48
|
+
) -> None:
|
|
49
|
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
50
|
+
deny_module.log_and_emit_code_review_deny("blocked reason", "Bash", "code_review_push_gate")
|
|
51
|
+
emitted_payload = json.loads(capsys.readouterr().out)
|
|
52
|
+
hook_output = emitted_payload["hookSpecificOutput"]
|
|
53
|
+
assert hook_output["permissionDecision"] == "deny"
|
|
54
|
+
assert hook_output["permissionDecisionReason"] == "blocked reason"
|