claude-dev-env 1.50.4 → 1.52.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 +0 -8
- package/_shared/pr-loop/audit-contract.md +3 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/preflight_self_heal_constants.py +28 -0
- package/_shared/pr-loop/scripts/preflight.py +18 -6
- package/_shared/pr-loop/scripts/preflight_self_heal.py +164 -0
- package/_shared/pr-loop/scripts/tests/test_preflight.py +39 -0
- package/_shared/pr-loop/scripts/tests/test_preflight_self_heal.py +273 -0
- package/agents/clean-coder.md +1 -1
- package/agents/code-quality-agent.md +7 -5
- package/audit-rubrics/category_rubrics/category-a-api-contracts.md +3 -0
- package/audit-rubrics/category_rubrics/category-f-silent-failures.md +3 -0
- package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +8 -2
- package/audit-rubrics/category_rubrics/category-n-test-name-scenario-verifier.md +3 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +39 -0
- package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +40 -0
- package/audit-rubrics/prompts/category-a-api-contracts.md +11 -4
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/audit-rubrics/prompts/category-c-resource-cleanup.md +1 -1
- package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +1 -1
- package/audit-rubrics/prompts/category-e-dead-code.md +1 -1
- package/audit-rubrics/prompts/category-f-silent-failures.md +13 -2
- package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +1 -1
- package/audit-rubrics/prompts/category-h-security-boundaries.md +1 -1
- package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
- package/audit-rubrics/prompts/category-k-codebase-conflicts.md +15 -5
- package/audit-rubrics/prompts/category-l-behavior-equivalence.md +1 -1
- package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +1 -1
- package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +10 -3
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +74 -0
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +75 -0
- package/docs/CODE_RULES.md +24 -346
- package/hooks/blocking/code_rules_enforcer.py +367 -42
- package/hooks/blocking/tdd_enforcer.py +211 -19
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +519 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +1 -1
- package/hooks/blocking/test_tdd_enforcer.py +399 -0
- package/hooks/hooks.json +0 -15
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +5 -0
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +2 -41
- package/rules/confirm-implementation-forks.md +3 -44
- package/rules/gh-body-file.md +2 -78
- package/rules/gh-paginate.md +2 -78
- package/rules/plain-language.md +2 -41
- package/rules/prompt-workflow-context-controls.md +9 -38
- package/rules/shell-invocation-policy.md +2 -141
- package/rules/testing.md +10 -0
- package/rules/vault-context.md +3 -32
- package/rules/windows-filesystem-safe.md +3 -87
- package/scripts/sync_to_cursor/rules.py +201 -79
- package/scripts/tests/test_sync_to_cursor.py +122 -26
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +2 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +51 -4
- package/skills/auditing-claude-config/SKILL.md +6 -1
- package/skills/bugteam/CONSTRAINTS.md +1 -1
- package/skills/bugteam/PROMPTS.md +8 -6
- package/skills/bugteam/SKILL.md +5 -5
- package/skills/bugteam/reference/audit-and-teammates.md +1 -1
- package/skills/bugteam/reference/audit-contract.md +4 -4
- package/skills/bugteam/reference/design-rationale.md +1 -1
- package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
- package/skills/bugteam/reference/team-setup.md +17 -5
- package/skills/bugteam/scripts/bugteam_preflight.py +22 -10
- package/skills/bugteam/scripts/test_bugteam_preflight.py +32 -0
- package/skills/copilot-review/SKILL.md +5 -8
- package/skills/doc-gist/SKILL.md +5 -8
- package/skills/fixbugs/SKILL.md +1 -1
- package/skills/gh-paginate/SKILL.md +84 -0
- package/skills/pre-compact/SKILL.md +4 -9
- package/skills/refine/SKILL.md +8 -2
- package/skills/structure-prompt/SKILL.md +5 -10
- package/skills/update/SKILL.md +143 -0
|
@@ -690,3 +690,402 @@ def test_should_deny_code_rules_edit_when_split_family_sibling_is_stale(
|
|
|
690
690
|
completed = _run_hook_with_payload(payload)
|
|
691
691
|
|
|
692
692
|
assert _decision_from(completed) == "deny"
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def test_should_offer_nested_package_mirroring_candidate_for_subpackage_module(
|
|
696
|
+
tmp_path: Path,
|
|
697
|
+
) -> None:
|
|
698
|
+
sandbox = _sandbox(tmp_path)
|
|
699
|
+
package_root = sandbox / "pkg"
|
|
700
|
+
subpackage_directory = package_root / "services" / "mouse_movement"
|
|
701
|
+
subpackage_directory.mkdir(parents=True)
|
|
702
|
+
(package_root / "tests").mkdir()
|
|
703
|
+
production_module = subpackage_directory / "tremor.py"
|
|
704
|
+
|
|
705
|
+
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
706
|
+
|
|
707
|
+
assert package_root / "tests" / "services" / "test_tremor.py" in all_candidates
|
|
708
|
+
assert (
|
|
709
|
+
package_root / "tests" / "services" / "mouse_movement" / "test_tremor.py"
|
|
710
|
+
in all_candidates
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def test_should_offer_flat_candidate_alongside_nested_candidates(
|
|
715
|
+
tmp_path: Path,
|
|
716
|
+
) -> None:
|
|
717
|
+
sandbox = _sandbox(tmp_path)
|
|
718
|
+
package_root = sandbox / "pkg"
|
|
719
|
+
subpackage_directory = package_root / "services" / "mouse_movement"
|
|
720
|
+
subpackage_directory.mkdir(parents=True)
|
|
721
|
+
(package_root / "tests").mkdir()
|
|
722
|
+
production_module = subpackage_directory / "tremor.py"
|
|
723
|
+
|
|
724
|
+
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
725
|
+
|
|
726
|
+
assert package_root / "tests" / "test_tremor.py" in all_candidates
|
|
727
|
+
assert (
|
|
728
|
+
package_root / "tests" / "services" / "mouse_movement" / "test_tremor.py"
|
|
729
|
+
in all_candidates
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def test_should_allow_write_when_nested_package_mirroring_test_is_fresh(
|
|
734
|
+
tmp_path: Path,
|
|
735
|
+
) -> None:
|
|
736
|
+
sandbox = _sandbox(tmp_path)
|
|
737
|
+
package_root = sandbox / "pkg"
|
|
738
|
+
subpackage_directory = package_root / "services" / "mouse_movement"
|
|
739
|
+
subpackage_directory.mkdir(parents=True)
|
|
740
|
+
nested_tests_directory = package_root / "tests" / "services" / "mouse_movement"
|
|
741
|
+
nested_tests_directory.mkdir(parents=True)
|
|
742
|
+
production_module = subpackage_directory / "tremor.py"
|
|
743
|
+
production_module.write_text("def jitter(): pass\n")
|
|
744
|
+
nested_test = nested_tests_directory / "test_tremor.py"
|
|
745
|
+
nested_test.write_text("def test_jitter(): pass\n")
|
|
746
|
+
|
|
747
|
+
completed = _run_hook_with_payload(_make_write_payload(production_module))
|
|
748
|
+
|
|
749
|
+
assert _decision_from(completed) == "allow"
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def test_should_collect_tests_directories_from_every_ancestor_up_to_repo_boundary(
|
|
753
|
+
tmp_path: Path,
|
|
754
|
+
) -> None:
|
|
755
|
+
sandbox = _sandbox(tmp_path)
|
|
756
|
+
package_root = sandbox / "pkg"
|
|
757
|
+
subpackage_directory = package_root / "services"
|
|
758
|
+
subpackage_directory.mkdir(parents=True)
|
|
759
|
+
(package_root / "tests").mkdir()
|
|
760
|
+
(subpackage_directory / "tests").mkdir()
|
|
761
|
+
|
|
762
|
+
all_pairs = _PRODUCTION_MODULE._ancestor_tests_directories(subpackage_directory)
|
|
763
|
+
|
|
764
|
+
collected_tests_directories = [each_tests_directory for _, each_tests_directory in all_pairs]
|
|
765
|
+
assert subpackage_directory / "tests" in collected_tests_directories
|
|
766
|
+
assert package_root / "tests" in collected_tests_directories
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def test_ancestor_tests_walk_stops_at_repo_boundary(tmp_path: Path) -> None:
|
|
770
|
+
outer_tests_directory = tmp_path / "tests"
|
|
771
|
+
outer_tests_directory.mkdir()
|
|
772
|
+
sandbox = _sandbox(tmp_path)
|
|
773
|
+
package_directory = sandbox / "pkg"
|
|
774
|
+
package_directory.mkdir()
|
|
775
|
+
|
|
776
|
+
all_pairs = _PRODUCTION_MODULE._ancestor_tests_directories(package_directory)
|
|
777
|
+
|
|
778
|
+
collected_tests_directories = [each_tests_directory for _, each_tests_directory in all_pairs]
|
|
779
|
+
assert outer_tests_directory not in collected_tests_directories
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def test_ancestor_tests_walk_honors_parent_walk_limit(tmp_path: Path) -> None:
|
|
783
|
+
walk_limit = _PRODUCTION_MODULE._parent_walk_limit()
|
|
784
|
+
deep_directory = tmp_path
|
|
785
|
+
for each_level_index in range(walk_limit + 2):
|
|
786
|
+
deep_directory = deep_directory / f"level_{each_level_index}"
|
|
787
|
+
deep_directory.mkdir(parents=True)
|
|
788
|
+
top_tests_directory = tmp_path / "tests"
|
|
789
|
+
top_tests_directory.mkdir()
|
|
790
|
+
|
|
791
|
+
all_pairs = _PRODUCTION_MODULE._ancestor_tests_directories(deep_directory)
|
|
792
|
+
|
|
793
|
+
collected_tests_directories = [each_tests_directory for _, each_tests_directory in all_pairs]
|
|
794
|
+
assert top_tests_directory not in collected_tests_directories
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def test_should_deny_edit_that_swaps_an_import_target(tmp_path: Path) -> None:
|
|
798
|
+
sandbox = _sandbox(tmp_path)
|
|
799
|
+
production_module = sandbox / "orders.py"
|
|
800
|
+
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
801
|
+
|
|
802
|
+
completed = _run_hook_with_payload(
|
|
803
|
+
_make_edit_payload(
|
|
804
|
+
production_module,
|
|
805
|
+
old_string="import os",
|
|
806
|
+
new_string="import sys",
|
|
807
|
+
)
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
assert _decision_from(completed) == "deny"
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def test_should_allow_edit_that_removes_an_import_statement(tmp_path: Path) -> None:
|
|
814
|
+
sandbox = _sandbox(tmp_path)
|
|
815
|
+
production_module = sandbox / "orders.py"
|
|
816
|
+
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
817
|
+
|
|
818
|
+
completed = _run_hook_with_payload(
|
|
819
|
+
_make_edit_payload(
|
|
820
|
+
production_module,
|
|
821
|
+
old_string="import os\n",
|
|
822
|
+
new_string="",
|
|
823
|
+
)
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
assert _decision_from(completed) == "allow"
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def test_should_allow_multiedit_when_every_pair_removes_an_import(tmp_path: Path) -> None:
|
|
830
|
+
sandbox = _sandbox(tmp_path)
|
|
831
|
+
production_module = sandbox / "orders.py"
|
|
832
|
+
production_module.write_text("import os\nimport json\nimport time\n\ndef fulfill(): pass\n")
|
|
833
|
+
|
|
834
|
+
completed = _run_hook_with_payload(
|
|
835
|
+
_make_multiedit_payload(
|
|
836
|
+
production_module,
|
|
837
|
+
edits=[
|
|
838
|
+
{"old_string": "import os\n", "new_string": ""},
|
|
839
|
+
{"old_string": "import json\n", "new_string": ""},
|
|
840
|
+
],
|
|
841
|
+
)
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
assert _decision_from(completed) == "allow"
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def test_should_deny_multiedit_when_one_pair_is_not_import_only(tmp_path: Path) -> None:
|
|
848
|
+
sandbox = _sandbox(tmp_path)
|
|
849
|
+
production_module = sandbox / "orders.py"
|
|
850
|
+
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
851
|
+
|
|
852
|
+
completed = _run_hook_with_payload(
|
|
853
|
+
_make_multiedit_payload(
|
|
854
|
+
production_module,
|
|
855
|
+
edits=[
|
|
856
|
+
{"old_string": "import os", "new_string": "import sys"},
|
|
857
|
+
{
|
|
858
|
+
"old_string": "def fulfill(): pass",
|
|
859
|
+
"new_string": "def fulfill(): return 1",
|
|
860
|
+
},
|
|
861
|
+
],
|
|
862
|
+
)
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
assert _decision_from(completed) == "deny"
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def test_should_not_exempt_write_with_behavior_under_import_only_rule(
|
|
869
|
+
tmp_path: Path,
|
|
870
|
+
) -> None:
|
|
871
|
+
sandbox = _sandbox(tmp_path)
|
|
872
|
+
production_module = sandbox / "orders.py"
|
|
873
|
+
write_content_with_behavior = "import os\n\ndef fulfill(): return os.getpid()\n"
|
|
874
|
+
|
|
875
|
+
completed = _run_hook_with_payload(
|
|
876
|
+
_make_write_payload(production_module, write_content_with_behavior)
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
assert _decision_from(completed) == "deny"
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def test_should_deny_behavior_edit_without_a_fresh_candidate_test(tmp_path: Path) -> None:
|
|
883
|
+
sandbox = _sandbox(tmp_path)
|
|
884
|
+
production_module = sandbox / "orders.py"
|
|
885
|
+
production_module.write_text("def fulfill(): pass\n")
|
|
886
|
+
|
|
887
|
+
completed = _run_hook_with_payload(
|
|
888
|
+
_make_edit_payload(
|
|
889
|
+
production_module,
|
|
890
|
+
old_string="def fulfill(): pass",
|
|
891
|
+
new_string="def fulfill(): return 1",
|
|
892
|
+
)
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
assert _decision_from(completed) == "deny"
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def test_should_deny_edit_of_import_text_inside_a_string_literal(tmp_path: Path) -> None:
|
|
899
|
+
sandbox = _sandbox(tmp_path)
|
|
900
|
+
production_module = sandbox / "orders.py"
|
|
901
|
+
production_module.write_text('banner = "import os is great"\n\ndef fulfill(): return banner\n')
|
|
902
|
+
|
|
903
|
+
completed = _run_hook_with_payload(
|
|
904
|
+
_make_edit_payload(
|
|
905
|
+
production_module,
|
|
906
|
+
old_string="import os",
|
|
907
|
+
new_string="import sys",
|
|
908
|
+
)
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
assert _decision_from(completed) == "deny"
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def test_should_allow_import_reorder_when_old_string_carries_context_lines(
|
|
915
|
+
tmp_path: Path,
|
|
916
|
+
) -> None:
|
|
917
|
+
sandbox = _sandbox(tmp_path)
|
|
918
|
+
production_module = sandbox / "orders.py"
|
|
919
|
+
production_module.write_text("import os\nimport sys\n\ndef fulfill(): pass\n")
|
|
920
|
+
|
|
921
|
+
completed = _run_hook_with_payload(
|
|
922
|
+
_make_edit_payload(
|
|
923
|
+
production_module,
|
|
924
|
+
old_string="import os\nimport sys\n\ndef fulfill(): pass",
|
|
925
|
+
new_string="import sys\nimport os\n\ndef fulfill(): pass",
|
|
926
|
+
)
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
assert _decision_from(completed) == "allow"
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def test_should_allow_import_only_edit_after_a_constants_assignment(tmp_path: Path) -> None:
|
|
933
|
+
sandbox = _sandbox(tmp_path)
|
|
934
|
+
production_module = sandbox / "orders.py"
|
|
935
|
+
production_module.write_text("import os\nimport sys\n\nMAX_ORDERS = 5\n\ndef fulfill(): pass\n")
|
|
936
|
+
|
|
937
|
+
completed = _run_hook_with_payload(
|
|
938
|
+
_make_edit_payload(
|
|
939
|
+
production_module,
|
|
940
|
+
old_string="import os\n",
|
|
941
|
+
new_string="",
|
|
942
|
+
)
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
assert _decision_from(completed) == "allow"
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def test_should_deny_replace_all_edit_that_rewrites_call_sites(tmp_path: Path) -> None:
|
|
949
|
+
sandbox = _sandbox(tmp_path)
|
|
950
|
+
production_module = sandbox / "orders.py"
|
|
951
|
+
production_module.write_text("import json\n\ndef parse(line): return json.loads(line)\n")
|
|
952
|
+
|
|
953
|
+
completed = _run_hook_with_payload(
|
|
954
|
+
{
|
|
955
|
+
"tool_name": "Edit",
|
|
956
|
+
"tool_input": {
|
|
957
|
+
"file_path": str(production_module),
|
|
958
|
+
"old_string": "json",
|
|
959
|
+
"new_string": "pickle",
|
|
960
|
+
"replace_all": True,
|
|
961
|
+
},
|
|
962
|
+
}
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
assert _decision_from(completed) == "deny"
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def test_should_allow_edit_that_reorders_import_statements(tmp_path: Path) -> None:
|
|
969
|
+
sandbox = _sandbox(tmp_path)
|
|
970
|
+
production_module = sandbox / "orders.py"
|
|
971
|
+
production_module.write_text("import os\nimport sys\n\ndef fulfill(): pass\n")
|
|
972
|
+
|
|
973
|
+
completed = _run_hook_with_payload(
|
|
974
|
+
_make_edit_payload(
|
|
975
|
+
production_module,
|
|
976
|
+
old_string="import os\nimport sys",
|
|
977
|
+
new_string="import sys\nimport os",
|
|
978
|
+
)
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
assert _decision_from(completed) == "allow"
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def test_should_deny_edit_that_retargets_an_import_source(tmp_path: Path) -> None:
|
|
985
|
+
sandbox = _sandbox(tmp_path)
|
|
986
|
+
production_module = sandbox / "orders.py"
|
|
987
|
+
production_module.write_text("from fast import compute\n\ndef run(): return compute()\n")
|
|
988
|
+
|
|
989
|
+
completed = _run_hook_with_payload(
|
|
990
|
+
_make_edit_payload(
|
|
991
|
+
production_module,
|
|
992
|
+
old_string="from fast import compute",
|
|
993
|
+
new_string="from slow import compute",
|
|
994
|
+
)
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
assert _decision_from(completed) == "deny"
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def test_should_deny_edit_that_adds_a_future_import(tmp_path: Path) -> None:
|
|
1001
|
+
sandbox = _sandbox(tmp_path)
|
|
1002
|
+
production_module = sandbox / "orders.py"
|
|
1003
|
+
production_module.write_text("import os\n\ndef fulfill(): return os.getpid()\n")
|
|
1004
|
+
|
|
1005
|
+
completed = _run_hook_with_payload(
|
|
1006
|
+
_make_edit_payload(
|
|
1007
|
+
production_module,
|
|
1008
|
+
old_string="import os",
|
|
1009
|
+
new_string="from __future__ import annotations\nimport os",
|
|
1010
|
+
)
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
assert _decision_from(completed) == "deny"
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def test_should_deny_multiedit_with_an_empty_edits_list(tmp_path: Path) -> None:
|
|
1017
|
+
sandbox = _sandbox(tmp_path)
|
|
1018
|
+
production_module = sandbox / "orders.py"
|
|
1019
|
+
production_module.write_text("import os\n\ndef fulfill(): return os.getpid()\n")
|
|
1020
|
+
|
|
1021
|
+
completed = _run_hook_with_payload(
|
|
1022
|
+
_make_multiedit_payload(production_module, edits=[])
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
assert _decision_from(completed) == "deny"
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def test_should_deny_edit_that_removes_a_future_import(tmp_path: Path) -> None:
|
|
1029
|
+
sandbox = _sandbox(tmp_path)
|
|
1030
|
+
production_module = sandbox / "orders.py"
|
|
1031
|
+
production_module.write_text(
|
|
1032
|
+
"from __future__ import annotations\nimport os\n\ndef fulfill(): return os.getpid()\n"
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
completed = _run_hook_with_payload(
|
|
1036
|
+
_make_edit_payload(
|
|
1037
|
+
production_module,
|
|
1038
|
+
old_string="from __future__ import annotations\n",
|
|
1039
|
+
new_string="",
|
|
1040
|
+
)
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
assert _decision_from(completed) == "deny"
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def test_should_deny_edit_that_adds_an_import(tmp_path: Path) -> None:
|
|
1047
|
+
sandbox = _sandbox(tmp_path)
|
|
1048
|
+
production_module = sandbox / "orders.py"
|
|
1049
|
+
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
1050
|
+
|
|
1051
|
+
completed = _run_hook_with_payload(
|
|
1052
|
+
_make_edit_payload(
|
|
1053
|
+
production_module,
|
|
1054
|
+
old_string="import os",
|
|
1055
|
+
new_string="import os\nimport sys",
|
|
1056
|
+
)
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
assert _decision_from(completed) == "deny"
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def test_should_deny_edit_that_duplicates_an_import(tmp_path: Path) -> None:
|
|
1063
|
+
sandbox = _sandbox(tmp_path)
|
|
1064
|
+
production_module = sandbox / "orders.py"
|
|
1065
|
+
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
1066
|
+
|
|
1067
|
+
completed = _run_hook_with_payload(
|
|
1068
|
+
_make_edit_payload(
|
|
1069
|
+
production_module,
|
|
1070
|
+
old_string="import os",
|
|
1071
|
+
new_string="import os\nimport os",
|
|
1072
|
+
)
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
assert _decision_from(completed) == "deny"
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def test_should_deny_changing_a_future_import_on_a_constants_only_file(tmp_path: Path) -> None:
|
|
1079
|
+
sandbox = _sandbox(tmp_path)
|
|
1080
|
+
production_module = sandbox / "orders.py"
|
|
1081
|
+
production_module.write_text("from __future__ import annotations\nMAX_ORDERS = 5\n")
|
|
1082
|
+
|
|
1083
|
+
completed = _run_hook_with_payload(
|
|
1084
|
+
_make_edit_payload(
|
|
1085
|
+
production_module,
|
|
1086
|
+
old_string="from __future__ import annotations",
|
|
1087
|
+
new_string="from __future__ import division",
|
|
1088
|
+
)
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
assert _decision_from(completed) == "deny"
|
package/hooks/hooks.json
CHANGED
|
@@ -2,16 +2,6 @@
|
|
|
2
2
|
"description": "Code standards enforcement, safety guards, and development workflow hooks",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
|
-
{
|
|
6
|
-
"matcher": "Grep|Search",
|
|
7
|
-
"hooks": [
|
|
8
|
-
{
|
|
9
|
-
"type": "command",
|
|
10
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/content_search_to_zoekt_redirector.py",
|
|
11
|
-
"timeout": 10
|
|
12
|
-
}
|
|
13
|
-
]
|
|
14
|
-
},
|
|
15
5
|
{
|
|
16
6
|
"matcher": "Write|Edit",
|
|
17
7
|
"hooks": [
|
|
@@ -130,11 +120,6 @@
|
|
|
130
120
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/convergence_gate_blocker.py",
|
|
131
121
|
"timeout": 15
|
|
132
122
|
},
|
|
133
|
-
{
|
|
134
|
-
"type": "command",
|
|
135
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/content_search_to_zoekt_redirector.py",
|
|
136
|
-
"timeout": 10
|
|
137
|
-
},
|
|
138
123
|
{
|
|
139
124
|
"type": "command",
|
|
140
125
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/windows_rmtree_blocker.py",
|
|
@@ -124,6 +124,11 @@ FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX: str = (
|
|
|
124
124
|
"function review hint)"
|
|
125
125
|
)
|
|
126
126
|
|
|
127
|
+
PRECHECK_USAGE_EXIT_CODE: int = 2
|
|
128
|
+
PRECHECK_USAGE_MESSAGE: str = (
|
|
129
|
+
"usage: code_rules_enforcer.py --check <candidate> [--as <target>]\n"
|
|
130
|
+
)
|
|
131
|
+
|
|
127
132
|
BANNED_NOUN_SPAN_FRAGMENT_TEMPLATE: str = (
|
|
128
133
|
"(binding span at line {definition_line}, spanning {line_span} lines)"
|
|
129
134
|
)
|
package/package.json
CHANGED
|
@@ -1,44 +1,5 @@
|
|
|
1
1
|
# AskUserQuestion Required
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Route every user-directed question through the `AskUserQuestion` tool — never a plain-text question in a response's final paragraph. Structure: concise `question`, `header` of 12 chars or fewer, 2-4 options (the UI adds the "Other" fallback), `multiSelect` only when choices genuinely combine.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Route every user-directed question through the `AskUserQuestion` tool. Embedded plain-text questions in the final paragraph of an assistant message are blocked by a Stop hook, and the response must be re-output with the ask moved into an `AskUserQuestion` tool call.
|
|
8
|
-
|
|
9
|
-
## Detection Criteria
|
|
10
|
-
|
|
11
|
-
The `question_to_user_enforcer` Stop hook inspects the last non-empty paragraph of the response after stripping fenced code blocks, inline code (backticks), and blockquoted lines (`> …`). The response is blocked when either signal is present:
|
|
12
|
-
|
|
13
|
-
- The final paragraph's last sentence ends with a question mark.
|
|
14
|
-
- The final paragraph contains any of these preamble phrases (case-insensitive, word-boundary matched): `would you like`, `should I`, `do you want`, `which would you prefer`, `let me know if`, `let me know which`, `let me know whether`, `please confirm`, `please let me know`, `want me to`.
|
|
15
|
-
|
|
16
|
-
## Acceptable Plain-Text Question Patterns
|
|
17
|
-
|
|
18
|
-
These remain allowed and do not trigger the hook:
|
|
19
|
-
|
|
20
|
-
- **Rhetorical questions answered in the same paragraph.** `"What happens if the queue is empty? The handler short-circuits cleanly."` The question frames its own answer; the reader never has to respond.
|
|
21
|
-
- **Questions inside code, diffs, or documentation excerpts.** Code fences, inline backticks, and `>` blockquotes are stripped before detection. Quoting a GitHub issue title, a user's prior message, or a log line inside a blockquote is fine.
|
|
22
|
-
- **Middle-paragraph questions when the closing paragraph is declarative.** Only the final paragraph is scanned.
|
|
23
|
-
|
|
24
|
-
## AskUserQuestion Structure
|
|
25
|
-
|
|
26
|
-
When a question is genuinely for the user, call the tool with:
|
|
27
|
-
|
|
28
|
-
- A concise `question` string stating what is needed.
|
|
29
|
-
- A `header` of twelve characters or fewer summarizing the decision.
|
|
30
|
-
- Two to four `options`, each with a short `label` the user can pick. An "Other" free-text fallback is already provided by the UI; do not add one manually.
|
|
31
|
-
- `multiSelect: false` unless the user can genuinely combine choices.
|
|
32
|
-
|
|
33
|
-
## Why
|
|
34
|
-
|
|
35
|
-
- **Structured options reduce re-reading friction.** The user sees labeled choices directly rather than scanning prose for the ask.
|
|
36
|
-
- **Transcript clarity.** Tool-use entries are easy to locate in the JSONL transcript; prose questions disappear into the response text.
|
|
37
|
-
- **Reduced drift.** Claude's next turn cannot move past an unanswered structured question; prose questions can be silently bypassed.
|
|
38
|
-
|
|
39
|
-
## Enforcement
|
|
40
|
-
|
|
41
|
-
- Hook: `packages/claude-dev-env/hooks/blocking/question_to_user_enforcer.py`, registered on the `Stop` matcher in `packages/claude-dev-env/hooks/hooks.json`.
|
|
42
|
-
- Loop prevention: the hook honors Claude Code's `stop_hook_active` flag and does not re-block on retry.
|
|
43
|
-
- User-facing notice: `USER_FACING_ASKUSERQUESTION_NOTICE` in `packages/claude-dev-env/hooks/hooks_constants/messages.py`.
|
|
44
|
-
- Related rule: `packages/claude-dev-env/rules/verify-before-asking.md` gates whether the question belongs to the user in the first place.
|
|
5
|
+
The `question_to_user_enforcer` Stop hook blocks a response whose final paragraph (after stripping code fences, inline code, and blockquotes) ends in a question mark or contains ask-phrases ("would you like", "should I", "let me know if", ...). Rhetorical questions answered in the same paragraph, and questions inside code or blockquotes, pass. `verify-before-asking` gates whether the question belongs to the user at all.
|
|
@@ -1,48 +1,7 @@
|
|
|
1
1
|
# Confirm Implementation Forks
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
At a material fork — two or more viable paths that change the deliverable's scope, completeness, deferred work, dependencies, or a hard-to-reverse contract — stop and ask which path via `AskUserQuestion` before implementing. A path that defers work or leaves a placeholder is itself a fork to surface, never a silent default.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
How to ask: plain language, one option per path with its tradeoff in the description, recommended path listed first and flagged "(Recommended)", hold edits to the forked area until the answer arrives.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
A fork is **material** when any of these hold:
|
|
10
|
-
|
|
11
|
-
- The paths produce different deliverables, scope, or completeness against the stated target.
|
|
12
|
-
- One path defers work — a stub, placeholder, or partial wiring — that creates a follow-up task or hidden debt, while another path completes the work in the same change.
|
|
13
|
-
- The paths diverge on something hard to reverse: architecture, a public API or contract, a data schema, or a newly added dependency.
|
|
14
|
-
- The choice trades off something the user has a stake in: speed against completeness, a scope cut against full coverage, or a shortcut against the original plan's target.
|
|
15
|
-
|
|
16
|
-
When one path quietly drops part of the requested outcome or leaves work for later, that gap is itself the fork — surface it rather than choosing the smaller deliverable on the user's behalf.
|
|
17
|
-
|
|
18
|
-
## Not a fork — just proceed
|
|
19
|
-
|
|
20
|
-
- Trivially reversible, internal-only choices with no deliverable impact (a local variable name, the layout of a private helper, one of two equivalent standard-library calls).
|
|
21
|
-
- The codebase, the user's stated goal, or an existing rule already determines the answer — follow it (see `verify-before-asking`), and do not manufacture a choice.
|
|
22
|
-
- Only one path is actually viable — implement it; a false choice wastes a round-trip.
|
|
23
|
-
|
|
24
|
-
## How to ask
|
|
25
|
-
|
|
26
|
-
- Write the question and every option in plain language — short, common words and concrete phrasing a non-expert grasps on first read. Spell out or drop jargon, internal names, and acronyms the user has not already used.
|
|
27
|
-
- Give just enough to decide (progressive disclosure): state each path's outcome and its main tradeoff in a sentence or two, and hold deeper detail in reserve for when the user asks. Do not paste code, long file lists, or background the choice does not need — extra information raises the reader's effort without improving the decision.
|
|
28
|
-
- One option per path. Keep each `label` short; put the tradeoff in the `description`.
|
|
29
|
-
- Recommend the path that best meets the user's stated target and list it first, flagged as recommended (per the AskUserQuestion directive in `CLAUDE.md`).
|
|
30
|
-
- Hold all edits to the forked area until the answer arrives. Continue unrelated, unambiguous work if it helps.
|
|
31
|
-
|
|
32
|
-
## Examples
|
|
33
|
-
|
|
34
|
-
**Wrong:** Reach a point where the feature can be completed or partially wired with a placeholder, pick the placeholder, and move on — leaving the real implementation as an unrequested follow-up.
|
|
35
|
-
**Right:** Ask: "Complete the feature in this change, or land a placeholder and track the rest as a follow-up?" with each option's cost stated, and wait for the choice.
|
|
36
|
-
|
|
37
|
-
**Wrong:** Add a third-party dependency to solve a problem a few lines of existing code could handle, without flagging it.
|
|
38
|
-
**Right:** Ask whether to add the dependency or hand-roll the small helper, naming the maintenance and footprint tradeoff.
|
|
39
|
-
|
|
40
|
-
## Why
|
|
41
|
-
|
|
42
|
-
A fork is a scope-or-direction decision the user holds a stake in. Choosing silently commits their effort and tokens to a path they may not want, and a deferred-work path can hide a follow-up the user never agreed to. Surfacing the fork once, with the tradeoffs visible, costs one round-trip and avoids rework.
|
|
43
|
-
|
|
44
|
-
## Relationship to other rules
|
|
45
|
-
|
|
46
|
-
- **verify-before-asking** gates *whether* a question belongs to the user; a material fork is a judgment or scope call that always does. Resolve anything the codebase can answer first, then ask only about the genuine choice.
|
|
47
|
-
- **conservative-action** governs *whether* to act when intent is ambiguous; this rule names a specific trigger — divergent viable paths — that demands a choice before acting.
|
|
48
|
-
- **ask-user-question-required** governs *how* to ask: route the fork through `AskUserQuestion`, never a plain-text question.
|
|
7
|
+
Not a fork (just proceed): trivially reversible internal-only choices, anything the codebase / stated goal / an existing rule already determines (see `verify-before-asking`), or a single viable path.
|
package/rules/gh-body-file.md
CHANGED
|
@@ -1,81 +1,5 @@
|
|
|
1
1
|
# gh --body-file Rule
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Every `gh` command carrying markdown body content (`gh pr create/edit/comment/review`, `gh issue create/edit/comment`) uses `--body-file <path>` with a temp file — never a `--body`/`-b` string, where backticks land on GitHub as literal `\``. Write the temp file BOM-free: `[IO.File]::WriteAllText($bodyPath, $body, [Text.UTF8Encoding]::new($false))`. MCP GitHub tools take `body` as a structured parameter and are unaffected.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
**Rule:** All `gh` commands that include markdown body content **must** use `--body-file <path>` with a temp file. Never pass body text as a string argument to `--body` or its shorthand `-b`.
|
|
8
|
-
|
|
9
|
-
## Affected subcommands
|
|
10
|
-
|
|
11
|
-
- `gh issue create`
|
|
12
|
-
- `gh issue edit`
|
|
13
|
-
- `gh issue comment`
|
|
14
|
-
- `gh pr create`
|
|
15
|
-
- `gh pr edit`
|
|
16
|
-
- `gh pr comment`
|
|
17
|
-
- `gh pr review`
|
|
18
|
-
|
|
19
|
-
## Safe patterns
|
|
20
|
-
|
|
21
|
-
### Python (preferred)
|
|
22
|
-
|
|
23
|
-
```python
|
|
24
|
-
import subprocess
|
|
25
|
-
import tempfile
|
|
26
|
-
|
|
27
|
-
body = "## Summary\n\nFixes `foo` by updating `bar`.\n"
|
|
28
|
-
|
|
29
|
-
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
|
|
30
|
-
f.write(body)
|
|
31
|
-
body_path = f.name
|
|
32
|
-
|
|
33
|
-
subprocess.run(["gh", "pr", "create", "--title", "My PR", "--body-file", body_path], check=True)
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
### PowerShell (Windows — preferred for this repo)
|
|
37
|
-
|
|
38
|
-
`Set-Content -Encoding utf8` writes UTF-8-**with-BOM** on Windows PowerShell 5.1,
|
|
39
|
-
which causes `gh` to treat the leading BOM as part of the first heading character
|
|
40
|
-
and can corrupt rendering. Use the BOM-free pattern below — it works on both
|
|
41
|
-
Windows PowerShell 5.1 and PowerShell 7+.
|
|
42
|
-
|
|
43
|
-
```powershell
|
|
44
|
-
$bodyPath = [System.IO.Path]::ChangeExtension((New-TemporaryFile).FullName, '.md')
|
|
45
|
-
$body = @'
|
|
46
|
-
## Summary
|
|
47
|
-
|
|
48
|
-
Fixes `foo` by updating `bar`.
|
|
49
|
-
'@
|
|
50
|
-
[IO.File]::WriteAllText($bodyPath, $body, [Text.UTF8Encoding]::new($false))
|
|
51
|
-
|
|
52
|
-
gh pr create --title "My PR" --body-file $bodyPath
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
On PowerShell 7+ you can alternatively use `Set-Content -Encoding utf8NoBOM`,
|
|
56
|
-
but the `[IO.File]::WriteAllText` pattern above is version-agnostic.
|
|
57
|
-
|
|
58
|
-
### Bash
|
|
59
|
-
|
|
60
|
-
Safe Bash patterns are intentionally omitted from this rule file. This repo
|
|
61
|
-
is Windows/PowerShell-first, and Bash-only safe patterns such as `mktemp` and
|
|
62
|
-
heredocs are not applicable here. Use the PowerShell example above in shell
|
|
63
|
-
contexts in this repo, or the Python example when invoking `gh` from Python.
|
|
64
|
-
The "What NOT to do" examples below use Bash syntax for illustration only and
|
|
65
|
-
do not imply Bash is a supported safe-pattern environment.
|
|
66
|
-
|
|
67
|
-
## What NOT to do
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
# BAD — backticks become \` on GitHub
|
|
71
|
-
gh pr create --title "My PR" --body "Fixes \`foo\` by updating \`bar\`."
|
|
72
|
-
|
|
73
|
-
# BAD — disallowed by repo policy; markdown body content must use --body-file
|
|
74
|
-
gh issue create --title "T" --body 'Use `x` to do `y`'
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
## Enforcement
|
|
78
|
-
|
|
79
|
-
A PreToolUse hook (`gh_body_arg_blocker.py`) blocks any Bash call that uses
|
|
80
|
-
`gh <subcommand> ... --body <arg>` (without `-file`) and returns a corrective
|
|
81
|
-
message directing you to use `--body-file` instead.
|
|
5
|
+
`gh_body_arg_blocker.py` (PreToolUse on Bash) blocks `--body <arg>` and returns the corrective message.
|