claude-dev-env 1.94.0 → 2.0.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/_shared/advisor/CLAUDE.md +2 -2
- package/_shared/advisor/advisor-protocol.md +41 -33
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +17 -13
- package/_shared/advisor/scripts/model_tier_run_validator.py +27 -18
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +95 -25
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
- package/_shared/advisor/scripts/tier_model_ids.py +18 -18
- package/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
- package/_shared/pr-loop/scripts/README.md +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
- package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
- package/_shared/pr-loop/worker-spawn.md +186 -0
- package/agents/code-verifier.md +1 -1
- package/bin/CLAUDE.md +10 -1
- package/bin/ever-shipped-skills.mjs +73 -0
- package/bin/expand_home_directory_tokens.mjs +1 -1
- package/bin/install.mjs +140 -7
- package/bin/install.prune.test.mjs +457 -0
- package/docs/CODE_RULES.md +1 -1
- package/hooks/advisory/refactor_guard.py +3 -4
- package/hooks/blocking/CLAUDE.md +7 -1
- package/hooks/blocking/block_main_commit.py +2 -2
- package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
- package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
- package/hooks/blocking/code_rules_enforcer.py +4 -0
- package/hooks/blocking/code_rules_mock_completeness.py +1 -1
- package/hooks/blocking/code_rules_optional_params.py +2 -2
- package/hooks/blocking/code_rules_shared.py +83 -1
- package/hooks/blocking/code_rules_test_assertions.py +1 -1
- package/hooks/blocking/code_rules_test_layout.py +9 -3
- package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
- package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
- package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
- package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
- package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
- package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
- package/hooks/blocking/inventory_intent_records/records.py +271 -0
- package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
- package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
- package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
- package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
- package/hooks/blocking/pii_payload_scan.py +138 -42
- package/hooks/blocking/pii_prevention_blocker.py +185 -291
- package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
- package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
- package/hooks/blocking/plain_language_blocker.py +138 -4
- package/hooks/blocking/pr_description_command_parser.py +8 -4
- package/hooks/blocking/precommit_code_rules_gate.py +3 -3
- package/hooks/blocking/sensitive_file_protector.py +114 -48
- package/hooks/blocking/tdd_enforcer.py +97 -601
- package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
- package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
- package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
- package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
- package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
- package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
- package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
- package/hooks/blocking/test_code_rules_shared.py +181 -0
- package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
- package/hooks/blocking/test_pii_payload_scan.py +168 -0
- package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
- package/hooks/blocking/test_sensitive_file_protector.py +185 -0
- package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
- package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
- package/hooks/blocking/tests/conftest.py +10 -0
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
- package/hooks/blocking/tests/test_repository_exemption.py +105 -0
- package/hooks/blocking/tests/test_repository_resolution.py +108 -0
- package/hooks/diagnostic/hook_log_extractor.py +12 -10
- package/hooks/git-hooks/post_commit.py +3 -4
- package/hooks/hooks_constants/CLAUDE.md +4 -2
- package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
- package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
- package/hooks/hooks_constants/local_identity.py +59 -8
- package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
- package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
- package/hooks/hooks_constants/test_local_identity.py +105 -3
- package/hooks/pyproject.toml +52 -4
- package/hooks/session/plugin_data_dir_cleanup.py +0 -1
- package/hooks/validation/mypy_validator.py +2 -2
- package/hooks/validators/CLAUDE.md +1 -1
- package/hooks/validators/README.md +2 -0
- package/hooks/validators/health_check.py +1 -0
- package/hooks/validators/mypy_integration.py +2 -0
- package/hooks/validators/python_style_checks.py +114 -136
- package/hooks/validators/python_style_helpers.py +95 -0
- package/hooks/validators/ruff_integration.py +3 -0
- package/hooks/validators/test_python_style_checks.py +0 -164
- package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
- package/hooks/validators/test_python_style_fixes.py +251 -0
- package/hooks/validators/test_python_style_helpers.py +125 -0
- package/hooks/workflow/auto_formatter.py +5 -4
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/anti-corollary-tests.md +69 -0
- package/rules/bdd.md +1 -3
- package/rules/code-reviews.md +1 -1
- package/rules/gh-paginate.md +1 -1
- package/rules/plain-language.md +2 -0
- package/scripts/CLAUDE.md +4 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
- package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
- package/scripts/dev_env_scripts_constants/timing.py +7 -1
- package/scripts/grok_headless_runner.py +294 -0
- package/scripts/grok_worker_preflight.py +410 -0
- package/scripts/invoke_code_review.py +463 -0
- package/scripts/resolve_worker_spawn.py +619 -0
- package/scripts/spawn_grok_batch.py +672 -0
- package/scripts/test_grok_headless_runner.py +626 -0
- package/scripts/test_grok_worker_preflight.py +1054 -0
- package/scripts/test_invoke_code_review.py +672 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -0
- package/scripts/test_spawn_grok_batch.py +1017 -0
- package/skills/CLAUDE.md +6 -3
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
- package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
- package/skills/autoconverge/CLAUDE.md +3 -3
- package/skills/autoconverge/SKILL.md +9 -3
- package/skills/autoconverge/reference/CLAUDE.md +2 -2
- package/skills/autoconverge/reference/convergence.md +33 -11
- package/skills/autoconverge/reference/stop-conditions.md +16 -5
- package/skills/autoconverge/workflow/CLAUDE.md +2 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
- package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.mjs +202 -18
- package/skills/bugteam/CLAUDE.md +2 -2
- package/skills/bugteam/CONSTRAINTS.md +3 -2
- package/skills/bugteam/PROMPTS.md +7 -6
- package/skills/bugteam/SKILL.md +18 -13
- package/skills/bugteam/reference/README.md +2 -3
- package/skills/bugteam/reference/audit-and-teammates.md +215 -35
- package/skills/bugteam/reference/design-rationale.md +1 -1
- package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
- package/skills/bugteam/reference/team-setup.md +8 -2
- package/skills/closeout/SKILL.md +153 -0
- package/skills/closeout/reference/handoff-prompt-template.md +72 -0
- package/skills/closeout/reference/issue-body-templates.md +108 -0
- package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
- package/skills/codex-review/CLAUDE.md +46 -0
- package/skills/codex-review/SKILL.md +181 -0
- package/skills/codex-review/reference/CLAUDE.md +15 -0
- package/skills/codex-review/reference/cli-contract.md +253 -0
- package/skills/codex-review/reference/loop-integration.md +118 -0
- package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
- package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
- package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
- package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
- package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
- package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
- package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
- package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
- package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
- package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
- package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
- package/skills/codex-review/scripts/run_codex_review.py +415 -0
- package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
- package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
- package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
- package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
- package/skills/codex-review/test_skill_scaffold.py +192 -0
- package/skills/grok-spawn/CLAUDE.md +28 -0
- package/skills/grok-spawn/SKILL.md +226 -0
- package/skills/grok-spawn/reference/flag-profiles.md +132 -0
- package/skills/grok-spawn/reference/worker-briefs.md +152 -0
- package/skills/grokify/SKILL.md +9 -1
- package/skills/grokify/capability-claims.test.mjs +28 -0
- package/skills/grokify/evals/README.md +72 -0
- package/skills/grokify/evals/parse-payload.test.mjs +171 -0
- package/skills/grokify/evals/run-capability-evals.mjs +545 -0
- package/skills/orchestrator/SKILL.md +32 -22
- package/skills/orchestrator-refresh/SKILL.md +12 -8
- package/skills/pr-converge/CLAUDE.md +1 -1
- package/skills/pr-converge/SKILL.md +34 -13
- package/skills/pr-converge/reference/convergence-gates.md +42 -15
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +1 -1
- package/skills/pr-converge/reference/per-tick.md +130 -42
- package/skills/pr-converge/reference/state-schema.md +10 -0
- package/skills/pr-converge/scripts/CLAUDE.md +2 -0
- package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
- package/skills/pr-converge/scripts/check_convergence.py +605 -29
- package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
- package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
- package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
- package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
- package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
- package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
- package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
- package/skills/pr-converge/test_step5_host_branch.py +106 -0
- package/skills/pr-fix-protocol/SKILL.md +65 -0
- package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
- package/skills/reviewer-gates/SKILL.md +7 -5
- package/skills/skill-builder/references/skill-modularity.md +1 -1
- package/skills/team-advisor/SKILL.md +16 -12
- package/system-prompts/software-engineer.xml +7 -6
- package/hooks/validators/test_verify_paths.py +0 -32
- package/hooks/validators/verify_paths.py +0 -57
|
@@ -15,381 +15,75 @@ existing file, exempt files (``__init__.py``, ``conftest.py``, ``setup.py``,
|
|
|
15
15
|
per-file inventory (such as ``config/`` or ``tests/``) are out of scope.
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
import json
|
|
19
18
|
import os
|
|
20
19
|
import sys
|
|
21
20
|
from pathlib import Path
|
|
22
|
-
from typing import TextIO
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
BACKTICK_TOKEN_PATTERN,
|
|
36
|
-
CODE_FENCE_PATTERN,
|
|
37
|
-
GLOB_METACHARACTER_PATTERN,
|
|
38
|
-
MAX_INVENTORY_FILE_BYTES,
|
|
39
|
-
MINIMUM_INVENTORY_ENTRY_COUNT,
|
|
40
|
-
NON_FILENAME_TOKEN_PATTERN,
|
|
41
|
-
PYTHON_FILE_EXTENSION,
|
|
42
|
-
SCRIPTS_SUBDIRECTORY_NAME,
|
|
43
|
-
SKILL_INVENTORY_DOCUMENT_NAME,
|
|
44
|
-
STALE_INVENTORY_ADDITIONAL_CONTEXT,
|
|
45
|
-
STALE_INVENTORY_MESSAGE_TEMPLATE,
|
|
46
|
-
STALE_INVENTORY_SYSTEM_MESSAGE,
|
|
47
|
-
)
|
|
48
|
-
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
49
|
-
read_hook_input_dictionary_from_stdin,
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _basename_token(backtick_inner_text: str) -> str | None:
|
|
54
|
-
"""Return the bare filename a backticked token names, when it names one.
|
|
55
|
-
|
|
56
|
-
A token names a bare filename when it is a single filename or path token
|
|
57
|
-
carrying a known file extension. A token that holds a path keeps only its
|
|
58
|
-
final segment, so an inventory cell naming ``pipeline/seam_continuity.py``
|
|
59
|
-
yields ``seam_continuity.py`` — the basename the directory file would match.
|
|
60
|
-
A slash-command token (leading ``/``), a glob/pattern token carrying a
|
|
61
|
-
metacharacter (``*``, ``?``, brace or bracket range, so ``*.py`` and
|
|
62
|
-
``test_*.py`` name no literal file), a multi-word command-example span
|
|
63
|
-
carrying whitespace or shell punctuation (``:``, ``$``, ``<``, ``>``, so
|
|
64
|
-
``parent:node_modules package.json`` and ``python <file>.py`` name no
|
|
65
|
-
literal file), and a token with no file extension yield None.
|
|
66
|
-
|
|
67
|
-
Args:
|
|
68
|
-
backtick_inner_text: The text between a backtick pair, stripped.
|
|
69
|
-
|
|
70
|
-
Returns:
|
|
71
|
-
The bare basename the token references, or None when it names no file.
|
|
72
|
-
"""
|
|
73
|
-
inner_text = backtick_inner_text.strip()
|
|
74
|
-
if not inner_text or inner_text.startswith("/"):
|
|
75
|
-
return None
|
|
76
|
-
if GLOB_METACHARACTER_PATTERN.search(inner_text) is not None:
|
|
77
|
-
return None
|
|
78
|
-
if NON_FILENAME_TOKEN_PATTERN.search(inner_text) is not None:
|
|
79
|
-
return None
|
|
80
|
-
basename = os.path.basename(inner_text.replace("\\", "/").rstrip("/"))
|
|
81
|
-
if not basename:
|
|
82
|
-
return None
|
|
83
|
-
_, extension = os.path.splitext(basename)
|
|
84
|
-
if not extension:
|
|
85
|
-
return None
|
|
86
|
-
return basename
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _lines_outside_code_fences(inventory_content: str) -> list[str]:
|
|
90
|
-
"""Return the inventory lines that sit outside any fenced code block.
|
|
91
|
-
|
|
92
|
-
A line inside a ``` or ~~~ fence pair is example or sample text, not a live
|
|
93
|
-
listing, so it is dropped — mirroring the fence handling in
|
|
94
|
-
``claude_md_orphan_file_blocker``.
|
|
95
|
-
|
|
96
|
-
Args:
|
|
97
|
-
inventory_content: The text of a README.md or CLAUDE.md inventory.
|
|
98
|
-
|
|
99
|
-
Returns:
|
|
100
|
-
The lines that lie outside every code fence, in document order.
|
|
101
|
-
"""
|
|
102
|
-
live_lines: list[str] = []
|
|
103
|
-
is_inside_code_fence = False
|
|
104
|
-
for each_line in inventory_content.splitlines():
|
|
105
|
-
if CODE_FENCE_PATTERN.match(each_line) is not None:
|
|
106
|
-
is_inside_code_fence = not is_inside_code_fence
|
|
107
|
-
continue
|
|
108
|
-
if is_inside_code_fence:
|
|
109
|
-
continue
|
|
110
|
-
live_lines.append(each_line)
|
|
111
|
-
return live_lines
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def inventory_named_basenames(inventory_content: str) -> set[str]:
|
|
115
|
-
"""Return every bare filename a package inventory document names in backticks.
|
|
116
|
-
|
|
117
|
-
Lines inside a fenced code block are skipped as example text. Each backticked
|
|
118
|
-
token on a remaining line is examined; one that names a literal file (a single
|
|
119
|
-
filename or path token that carries an extension, no glob metacharacter, and
|
|
120
|
-
no whitespace or shell punctuation) contributes its basename, and a token
|
|
121
|
-
holding a path contributes its final segment. A multi-word command-example
|
|
122
|
-
span contributes nothing. This covers both a README.md table cell and a
|
|
123
|
-
CLAUDE.md bullet, since both name files in backticks.
|
|
124
|
-
|
|
125
|
-
Args:
|
|
126
|
-
inventory_content: The text of a README.md or CLAUDE.md inventory.
|
|
127
|
-
|
|
128
|
-
Returns:
|
|
129
|
-
The set of bare basenames the inventory names.
|
|
130
|
-
"""
|
|
131
|
-
named_basenames: set[str] = set()
|
|
132
|
-
for each_line in _lines_outside_code_fences(inventory_content):
|
|
133
|
-
for each_match in BACKTICK_TOKEN_PATTERN.finditer(each_line):
|
|
134
|
-
each_basename = _basename_token(each_match.group(1))
|
|
135
|
-
if each_basename is not None:
|
|
136
|
-
named_basenames.add(each_basename)
|
|
137
|
-
return named_basenames
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def _read_inventory_content(inventory_path: Path) -> str | None:
|
|
141
|
-
"""Return the text of an inventory document, or None when it is unreadable.
|
|
142
|
-
|
|
143
|
-
A document larger than the byte budget is skipped (None), so an oversized
|
|
144
|
-
file never stalls the hook.
|
|
145
|
-
|
|
146
|
-
Args:
|
|
147
|
-
inventory_path: The path of the README.md or CLAUDE.md to read.
|
|
148
|
-
|
|
149
|
-
Returns:
|
|
150
|
-
The document text, or None when it is missing, oversized, or undecodable.
|
|
151
|
-
"""
|
|
152
|
-
try:
|
|
153
|
-
if inventory_path.stat().st_size > MAX_INVENTORY_FILE_BYTES:
|
|
154
|
-
return None
|
|
155
|
-
return inventory_path.read_text(encoding="utf-8")
|
|
156
|
-
except (OSError, UnicodeDecodeError):
|
|
157
|
-
return None
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
class _InventorySurvey:
|
|
161
|
-
"""The inventory documents found beside a file and the files they name.
|
|
162
|
-
|
|
163
|
-
Attributes:
|
|
164
|
-
present_inventory_names: The inventory document basenames present in the
|
|
165
|
-
directory (``README.md``, ``CLAUDE.md``, and/or ``SKILL.md``).
|
|
166
|
-
named_basenames: Every bare filename the present inventories name.
|
|
167
|
-
"""
|
|
168
|
-
|
|
169
|
-
def __init__(
|
|
170
|
-
self, all_present_inventory_names: list[str], all_named_basenames: set[str]
|
|
171
|
-
) -> None:
|
|
172
|
-
self.present_inventory_names = all_present_inventory_names
|
|
173
|
-
self.named_basenames = all_named_basenames
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def survey_directory_inventories(package_directory: Path) -> _InventorySurvey:
|
|
177
|
-
"""Return the inventory documents beside a file and the basenames they name.
|
|
178
|
-
|
|
179
|
-
Reads each present ``README.md`` and ``CLAUDE.md`` in *package_directory* and
|
|
180
|
-
unions the basenames they name in backticks.
|
|
181
|
-
|
|
182
|
-
Args:
|
|
183
|
-
package_directory: The directory that holds the file being written.
|
|
184
|
-
|
|
185
|
-
Returns:
|
|
186
|
-
The survey pairing the present inventory document names with the union of
|
|
187
|
-
the basenames they name.
|
|
188
|
-
"""
|
|
189
|
-
present_inventory_names: list[str] = []
|
|
190
|
-
named_basenames: set[str] = set()
|
|
191
|
-
for each_inventory_name in sorted(ALL_INVENTORY_DOCUMENT_NAMES):
|
|
192
|
-
inventory_path = package_directory / each_inventory_name
|
|
193
|
-
inventory_content = _read_inventory_content(inventory_path)
|
|
194
|
-
if inventory_content is None:
|
|
195
|
-
continue
|
|
196
|
-
present_inventory_names.append(each_inventory_name)
|
|
197
|
-
named_basenames |= inventory_named_basenames(inventory_content)
|
|
198
|
-
return _InventorySurvey(present_inventory_names, named_basenames)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
def _is_test_file(basename: str) -> bool:
|
|
202
|
-
"""Return whether *basename* names a test file the inventory need not list.
|
|
203
|
-
|
|
204
|
-
Args:
|
|
205
|
-
basename: The bare filename of the file being written.
|
|
206
|
-
|
|
207
|
-
Returns:
|
|
208
|
-
True when the name matches a test-file shape (``test_*.py``,
|
|
209
|
-
``*_test.py``, ``*.spec.*``, or ``*.test.*``).
|
|
210
|
-
"""
|
|
211
|
-
if basename.startswith("test_") and basename.endswith(PYTHON_FILE_EXTENSION):
|
|
212
|
-
return True
|
|
213
|
-
if basename.endswith("_test" + PYTHON_FILE_EXTENSION):
|
|
214
|
-
return True
|
|
215
|
-
return any(each_marker in basename for each_marker in ALL_TEST_FILE_MARKERS)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def _is_under_exempt_directory(package_directory: Path) -> bool:
|
|
219
|
-
"""Return whether the file's directory is itself an exempt directory.
|
|
220
|
-
|
|
221
|
-
A file directly inside a directory that carries no per-file inventory (such
|
|
222
|
-
as ``config/`` or ``tests/``) has no individual entry, so its directory
|
|
223
|
-
exempts it.
|
|
224
|
-
|
|
225
|
-
Args:
|
|
226
|
-
package_directory: The directory that holds the file being written.
|
|
227
|
-
|
|
228
|
-
Returns:
|
|
229
|
-
True when the directory's own name is an exempt directory name.
|
|
230
|
-
"""
|
|
231
|
-
return package_directory.name in ALL_EXEMPT_DIRECTORY_NAMES
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
def is_inventoried_production_file(file_path: str) -> bool:
|
|
235
|
-
"""Return whether *file_path* is a production file an inventory should name.
|
|
236
|
-
|
|
237
|
-
A production file is a non-test, non-exempt code file (``.py``, ``.mjs``,
|
|
238
|
-
``.js``, ``.ts``, ``.ps1``, ``.sh``) outside a directory that carries no
|
|
239
|
-
per-file inventory (such as ``config/`` or ``tests/``). Exempt basenames
|
|
240
|
-
(``__init__.py``, ``conftest.py``, ``setup.py``, ``_path_setup.py``) and
|
|
241
|
-
test files are out of scope.
|
|
242
|
-
|
|
243
|
-
Args:
|
|
244
|
-
file_path: The destination path of the write.
|
|
245
|
-
|
|
246
|
-
Returns:
|
|
247
|
-
True when the file is one an inventory entry should name.
|
|
248
|
-
"""
|
|
249
|
-
basename = os.path.basename(file_path)
|
|
250
|
-
_, extension = os.path.splitext(basename)
|
|
251
|
-
if extension.lower() not in ALL_PRODUCTION_CODE_EXTENSIONS:
|
|
252
|
-
return False
|
|
253
|
-
if basename in ALL_EXEMPT_BASENAMES:
|
|
254
|
-
return False
|
|
255
|
-
if _is_test_file(basename):
|
|
256
|
-
return False
|
|
257
|
-
return not _is_under_exempt_directory(Path(file_path).resolve().parent)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def _sibling_named_basenames(
|
|
261
|
-
package_directory: Path, all_named_basenames: set[str]
|
|
262
|
-
) -> set[str]:
|
|
263
|
-
"""Return the named basenames that exist as files in *package_directory*.
|
|
264
|
-
|
|
265
|
-
A maintained inventory lists the directory's own files, so a named basename
|
|
266
|
-
counts toward the inventory only when a file with that basename sits directly
|
|
267
|
-
in the directory. A name the inventory mentions in passing — a file living in
|
|
268
|
-
another directory (``install.mjs``), a sibling doc — is dropped, so prose that
|
|
269
|
-
references non-sibling files never reads as a maintained inventory.
|
|
270
|
-
|
|
271
|
-
Args:
|
|
272
|
-
package_directory: The directory that holds the file being written.
|
|
273
|
-
all_named_basenames: Every bare basename the inventory documents name.
|
|
274
|
-
|
|
275
|
-
Returns:
|
|
276
|
-
The subset of *all_named_basenames* present as a file in the directory.
|
|
277
|
-
"""
|
|
278
|
-
sibling_basenames: set[str] = set()
|
|
279
|
-
for each_basename in all_named_basenames:
|
|
280
|
-
if (package_directory / each_basename).is_file():
|
|
281
|
-
sibling_basenames.add(each_basename)
|
|
282
|
-
return sibling_basenames
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
def _parent_skill_inventory(package_directory: Path) -> _InventorySurvey | None:
|
|
286
|
-
"""Return the parent skill ``SKILL.md`` survey for a ``scripts/`` directory.
|
|
287
|
-
|
|
288
|
-
A skill package keeps its ``SKILL.md`` at the skill root and maps the
|
|
289
|
-
``scripts/`` subdirectory in a Layout table whose rows name files by their
|
|
290
|
-
``scripts/<name>`` path. A production file landing in that ``scripts/``
|
|
291
|
-
directory is governed by the parent ``SKILL.md``, which sits one level up
|
|
292
|
-
rather than beside the file. This reads that parent ``SKILL.md`` and reports
|
|
293
|
-
the basenames it names. Any directory not named ``scripts/`` and any missing
|
|
294
|
-
or unreadable parent ``SKILL.md`` yield None.
|
|
295
|
-
|
|
296
|
-
Args:
|
|
297
|
-
package_directory: The directory that holds the file being written.
|
|
298
|
-
|
|
299
|
-
Returns:
|
|
300
|
-
The parent ``SKILL.md`` survey, or None when there is none to read.
|
|
301
|
-
"""
|
|
302
|
-
if package_directory.name != SCRIPTS_SUBDIRECTORY_NAME:
|
|
303
|
-
return None
|
|
304
|
-
skill_inventory_path = package_directory.parent / SKILL_INVENTORY_DOCUMENT_NAME
|
|
305
|
-
inventory_content = _read_inventory_content(skill_inventory_path)
|
|
306
|
-
if inventory_content is None:
|
|
307
|
-
return None
|
|
308
|
-
return _InventorySurvey(
|
|
309
|
-
[SKILL_INVENTORY_DOCUMENT_NAME], inventory_named_basenames(inventory_content)
|
|
22
|
+
try:
|
|
23
|
+
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
24
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
25
|
+
for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
|
|
26
|
+
if each_bootstrap_directory not in sys.path:
|
|
27
|
+
sys.path.insert(0, each_bootstrap_directory)
|
|
28
|
+
from inventory_intent_records import records
|
|
29
|
+
from package_inventory_stale_blocker_parts import decision, inventory_detection
|
|
30
|
+
|
|
31
|
+
from hooks_constants.pre_tool_use_stdin import (
|
|
32
|
+
read_hook_input_dictionary_from_stdin,
|
|
310
33
|
)
|
|
34
|
+
except ImportError as import_error:
|
|
35
|
+
raise ImportError(
|
|
36
|
+
"package_inventory_stale_blocker: cannot import its parts submodules; "
|
|
37
|
+
"ensure the hooks directory is importable."
|
|
38
|
+
) from import_error
|
|
311
39
|
|
|
312
40
|
|
|
313
|
-
|
|
314
|
-
|
|
41
|
+
inventory_named_basenames = inventory_detection.inventory_named_basenames
|
|
42
|
+
is_inventoried_production_file = inventory_detection.is_inventoried_production_file
|
|
43
|
+
find_stale_inventory = inventory_detection.find_stale_inventory
|
|
44
|
+
survey_directory_inventories = inventory_detection.survey_directory_inventories
|
|
45
|
+
deny_stale_inventory = decision.deny_stale_inventory
|
|
315
46
|
|
|
316
|
-
The file's own directory inventories are surveyed and, when the file sits in
|
|
317
|
-
a ``scripts/`` subdirectory, the parent skill ``SKILL.md`` Layout table is
|
|
318
|
-
surveyed too; the named basenames union across both. They are then filtered
|
|
319
|
-
to those that exist as files in the file's directory — the inventory's own
|
|
320
|
-
sibling files. The survey reports a stale inventory only when every condition
|
|
321
|
-
holds: at least one inventory document is present, those documents together
|
|
322
|
-
name at least the minimum entry count of on-disk sibling files (marking them
|
|
323
|
-
a maintained inventory rather than incidental prose that mentions files living
|
|
324
|
-
elsewhere), and no inventory already names this file's basename. When any
|
|
325
|
-
condition fails the file is in step with its inventory (or there is no
|
|
326
|
-
inventory to be out of step with), so None results.
|
|
327
47
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
Returns:
|
|
332
|
-
The inventory survey when the file is a stale omission, or None.
|
|
333
|
-
"""
|
|
334
|
-
package_directory = Path(file_path).resolve().parent
|
|
335
|
-
if not package_directory.is_dir():
|
|
336
|
-
return None
|
|
337
|
-
survey = survey_directory_inventories(package_directory)
|
|
338
|
-
present_inventory_names = list(survey.present_inventory_names)
|
|
339
|
-
named_basenames = set(survey.named_basenames)
|
|
340
|
-
parent_skill_survey = _parent_skill_inventory(package_directory)
|
|
341
|
-
if parent_skill_survey is not None:
|
|
342
|
-
present_inventory_names += parent_skill_survey.present_inventory_names
|
|
343
|
-
named_basenames |= parent_skill_survey.named_basenames
|
|
344
|
-
if not present_inventory_names:
|
|
345
|
-
return None
|
|
346
|
-
sibling_basenames = _sibling_named_basenames(package_directory, named_basenames)
|
|
347
|
-
if len(sibling_basenames) < MINIMUM_INVENTORY_ENTRY_COUNT:
|
|
348
|
-
return None
|
|
349
|
-
if os.path.basename(file_path) in named_basenames:
|
|
350
|
-
return None
|
|
351
|
-
return _InventorySurvey(present_inventory_names, sibling_basenames)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
def _build_block_payload(file_path: str, survey: _InventorySurvey) -> dict:
|
|
355
|
-
"""Build the PreToolUse deny payload for a stale-inventory omission.
|
|
48
|
+
def _resolve_payload(input_data: dict) -> tuple[str, dict, str]:
|
|
49
|
+
"""Return the tool name, tool input, and file path from a PreToolUse payload.
|
|
356
50
|
|
|
357
51
|
Args:
|
|
358
|
-
|
|
359
|
-
survey: The maintained-inventory survey the file is absent from.
|
|
52
|
+
input_data: The parsed PreToolUse payload.
|
|
360
53
|
|
|
361
54
|
Returns:
|
|
362
|
-
The
|
|
55
|
+
The tool name, the tool input dict, and the file path — each emptied when
|
|
56
|
+
its value is absent or the wrong type.
|
|
363
57
|
"""
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
return
|
|
373
|
-
"hookSpecificOutput": {
|
|
374
|
-
"hookEventName": "PreToolUse",
|
|
375
|
-
"permissionDecision": "deny",
|
|
376
|
-
"permissionDecisionReason": reason,
|
|
377
|
-
"additionalContext": STALE_INVENTORY_ADDITIONAL_CONTEXT,
|
|
378
|
-
},
|
|
379
|
-
"systemMessage": STALE_INVENTORY_SYSTEM_MESSAGE,
|
|
380
|
-
"suppressOutput": True,
|
|
381
|
-
}
|
|
58
|
+
raw_tool_name = input_data.get("tool_name", "")
|
|
59
|
+
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
60
|
+
tool_input = input_data.get("tool_input", {})
|
|
61
|
+
if not isinstance(tool_input, dict):
|
|
62
|
+
return tool_name, {}, ""
|
|
63
|
+
file_path = tool_input.get("file_path", "")
|
|
64
|
+
if not isinstance(file_path, str):
|
|
65
|
+
return tool_name, tool_input, ""
|
|
66
|
+
return tool_name, tool_input, file_path
|
|
382
67
|
|
|
383
68
|
|
|
384
|
-
def
|
|
385
|
-
|
|
69
|
+
def _emit_stale_decision(
|
|
70
|
+
input_data: dict, file_path: str, survey: inventory_detection._InventorySurvey
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Allow the write when a pending row intent matches, else record and deny.
|
|
386
73
|
|
|
387
74
|
Args:
|
|
388
|
-
|
|
389
|
-
|
|
75
|
+
input_data: The parsed PreToolUse payload.
|
|
76
|
+
file_path: The destination path of the new file.
|
|
77
|
+
survey: The maintained-inventory survey the file is absent from.
|
|
390
78
|
"""
|
|
391
|
-
|
|
392
|
-
|
|
79
|
+
session_id = str(input_data.get("session_id") or "")
|
|
80
|
+
directory = str(Path(file_path).resolve().parent)
|
|
81
|
+
filename = os.path.basename(file_path)
|
|
82
|
+
if records.has_fresh_row_intent(session_id, directory, filename):
|
|
83
|
+
records.consume_row_intent(session_id, directory, filename)
|
|
84
|
+
return
|
|
85
|
+
records.record_file_intent(session_id, directory, filename)
|
|
86
|
+
deny_stale_inventory(file_path, survey)
|
|
393
87
|
|
|
394
88
|
|
|
395
89
|
def main() -> None:
|
|
@@ -397,39 +91,15 @@ def main() -> None:
|
|
|
397
91
|
input_data = read_hook_input_dictionary_from_stdin()
|
|
398
92
|
if input_data is None:
|
|
399
93
|
sys.exit(0)
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
403
|
-
if tool_name != "Write":
|
|
404
|
-
sys.exit(0)
|
|
405
|
-
|
|
406
|
-
tool_input = input_data.get("tool_input", {})
|
|
407
|
-
if not isinstance(tool_input, dict):
|
|
408
|
-
sys.exit(0)
|
|
409
|
-
|
|
410
|
-
file_path = tool_input.get("file_path", "")
|
|
411
|
-
if not isinstance(file_path, str) or not file_path:
|
|
412
|
-
sys.exit(0)
|
|
413
|
-
|
|
414
|
-
if os.path.exists(file_path):
|
|
94
|
+
tool_name, _tool_input, file_path = _resolve_payload(input_data)
|
|
95
|
+
if tool_name != "Write" or not file_path:
|
|
415
96
|
sys.exit(0)
|
|
416
|
-
|
|
417
|
-
if not is_inventoried_production_file(file_path):
|
|
97
|
+
if os.path.exists(file_path) or not is_inventoried_production_file(file_path):
|
|
418
98
|
sys.exit(0)
|
|
419
|
-
|
|
420
99
|
survey = find_stale_inventory(file_path)
|
|
421
100
|
if survey is None:
|
|
422
101
|
sys.exit(0)
|
|
423
|
-
|
|
424
|
-
block_payload = _build_block_payload(file_path, survey)
|
|
425
|
-
log_hook_block(
|
|
426
|
-
calling_hook_name="package_inventory_stale_blocker.py",
|
|
427
|
-
hook_event="PreToolUse",
|
|
428
|
-
block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
|
|
429
|
-
tool_name=tool_name,
|
|
430
|
-
offending_input_preview=file_path,
|
|
431
|
-
)
|
|
432
|
-
_emit_hook_result(block_payload, sys.stdout)
|
|
102
|
+
_emit_stale_decision(input_data, file_path, survey)
|
|
433
103
|
sys.exit(0)
|
|
434
104
|
|
|
435
105
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# package_inventory_stale_blocker_parts
|
|
2
|
+
|
|
3
|
+
The concern modules `package_inventory_stale_blocker.py` wires together to block a
|
|
4
|
+
new production file its package inventory omits. The entry hook imports them and
|
|
5
|
+
re-exports their surface for the test suite.
|
|
6
|
+
|
|
7
|
+
## Modules
|
|
8
|
+
|
|
9
|
+
| File | Purpose |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `inventory_detection.py` | Surveys a directory's `README.md`/`CLAUDE.md`/`SKILL.md`, collects the filenames they name, and reports whether a maintained inventory omits the file being written |
|
|
12
|
+
| `decision.py` | Builds the deny payload naming the omitted file and closing with the retry hint, and emits the decision JSON |
|
|
13
|
+
| `__init__.py` | Package marker |
|
|
14
|
+
|
|
15
|
+
## Subdirectories
|
|
16
|
+
|
|
17
|
+
| Entry | Description |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `config/` | The retry hint the deny reason closes with and the inventory-name join separator (`inventory_blocker_constants.py`) |
|
|
20
|
+
| `tests/` | pytest suite with one test module per module above |
|
|
21
|
+
|
|
22
|
+
## Running tests
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
python -m pytest packages/claude-dev-env/hooks/blocking/package_inventory_stale_blocker_parts/tests/
|
|
26
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Concern modules the ``package_inventory_stale_blocker`` entry hook wires together."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration home for the package-inventory blocker parts constants."""
|
package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Constants for the package-inventory blocker parts.
|
|
2
|
+
|
|
3
|
+
Holds the separator that joins the present inventory document names in the deny
|
|
4
|
+
reason, and the retry hint the deny reason closes with once the denied file write
|
|
5
|
+
has been recorded as a pending intent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
INVENTORY_NAME_JOIN_SEPARATOR: str = ", "
|
|
11
|
+
|
|
12
|
+
FILE_FIRST_RETRY_HINT: str = (
|
|
13
|
+
" This file write has been recorded: add the inventory entry naming this file "
|
|
14
|
+
"now (a README.md/CLAUDE.md/SKILL.md row or bullet), and the file write will "
|
|
15
|
+
"be allowed when you retry it."
|
|
16
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Build, record, and emit the package-inventory blocker's deny decision.
|
|
2
|
+
|
|
3
|
+
The deny reason names the omitted file and closes with the retry hint that tells
|
|
4
|
+
the author to add the inventory row now, since this write has been recorded.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TextIO
|
|
12
|
+
|
|
13
|
+
from package_inventory_stale_blocker_parts.config.inventory_blocker_constants import (
|
|
14
|
+
FILE_FIRST_RETRY_HINT,
|
|
15
|
+
INVENTORY_NAME_JOIN_SEPARATOR,
|
|
16
|
+
)
|
|
17
|
+
from package_inventory_stale_blocker_parts.inventory_detection import _InventorySurvey
|
|
18
|
+
|
|
19
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
20
|
+
from hooks_constants.package_inventory_stale_blocker_constants import (
|
|
21
|
+
STALE_INVENTORY_ADDITIONAL_CONTEXT,
|
|
22
|
+
STALE_INVENTORY_MESSAGE_TEMPLATE,
|
|
23
|
+
STALE_INVENTORY_SYSTEM_MESSAGE,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def deny_stale_inventory(file_path: str, survey: _InventorySurvey) -> None:
|
|
28
|
+
"""Build, record, and emit the deny decision for a stale-inventory omission.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
file_path: The destination path of the denied new file.
|
|
32
|
+
survey: The maintained-inventory survey the file is absent from.
|
|
33
|
+
"""
|
|
34
|
+
block_payload = build_block_payload(file_path, survey)
|
|
35
|
+
log_hook_block(
|
|
36
|
+
calling_hook_name="package_inventory_stale_blocker.py",
|
|
37
|
+
hook_event="PreToolUse",
|
|
38
|
+
block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
|
|
39
|
+
tool_name="Write",
|
|
40
|
+
offending_input_preview=file_path,
|
|
41
|
+
)
|
|
42
|
+
emit_hook_result(block_payload, sys.stdout)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_block_payload(file_path: str, survey: _InventorySurvey) -> dict:
|
|
46
|
+
"""Build the PreToolUse deny payload for a stale-inventory omission.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
file_path: The destination path of the write.
|
|
50
|
+
survey: The maintained-inventory survey the file is absent from.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The hook-result dictionary the harness reads to deny the write, whose
|
|
54
|
+
reason closes with the retry hint.
|
|
55
|
+
"""
|
|
56
|
+
package_directory = str(Path(file_path).resolve().parent)
|
|
57
|
+
formatted_inventories = INVENTORY_NAME_JOIN_SEPARATOR.join(survey.present_inventory_names)
|
|
58
|
+
reason = STALE_INVENTORY_MESSAGE_TEMPLATE.format(
|
|
59
|
+
filename=os.path.basename(file_path),
|
|
60
|
+
directory=package_directory,
|
|
61
|
+
inventories=formatted_inventories,
|
|
62
|
+
entry_count=len(survey.named_basenames),
|
|
63
|
+
)
|
|
64
|
+
return {
|
|
65
|
+
"hookSpecificOutput": {
|
|
66
|
+
"hookEventName": "PreToolUse",
|
|
67
|
+
"permissionDecision": "deny",
|
|
68
|
+
"permissionDecisionReason": reason + FILE_FIRST_RETRY_HINT,
|
|
69
|
+
"additionalContext": STALE_INVENTORY_ADDITIONAL_CONTEXT,
|
|
70
|
+
},
|
|
71
|
+
"systemMessage": STALE_INVENTORY_SYSTEM_MESSAGE,
|
|
72
|
+
"suppressOutput": True,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
|
|
77
|
+
"""Write the hook result JSON to the given output stream.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
all_hook_data: The hook-result dictionary to serialize.
|
|
81
|
+
output_stream: The stream the harness reads the decision from.
|
|
82
|
+
"""
|
|
83
|
+
output_stream.write(json.dumps(all_hook_data) + "\n")
|
|
84
|
+
output_stream.flush()
|