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.
Files changed (73) hide show
  1. package/CLAUDE.md +0 -8
  2. package/_shared/pr-loop/audit-contract.md +3 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/preflight_self_heal_constants.py +28 -0
  4. package/_shared/pr-loop/scripts/preflight.py +18 -6
  5. package/_shared/pr-loop/scripts/preflight_self_heal.py +164 -0
  6. package/_shared/pr-loop/scripts/tests/test_preflight.py +39 -0
  7. package/_shared/pr-loop/scripts/tests/test_preflight_self_heal.py +273 -0
  8. package/agents/clean-coder.md +1 -1
  9. package/agents/code-quality-agent.md +7 -5
  10. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +3 -0
  11. package/audit-rubrics/category_rubrics/category-f-silent-failures.md +3 -0
  12. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +8 -2
  13. package/audit-rubrics/category_rubrics/category-n-test-name-scenario-verifier.md +3 -0
  14. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +39 -0
  15. package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +40 -0
  16. package/audit-rubrics/prompts/category-a-api-contracts.md +11 -4
  17. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  18. package/audit-rubrics/prompts/category-c-resource-cleanup.md +1 -1
  19. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +1 -1
  20. package/audit-rubrics/prompts/category-e-dead-code.md +1 -1
  21. package/audit-rubrics/prompts/category-f-silent-failures.md +13 -2
  22. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +1 -1
  23. package/audit-rubrics/prompts/category-h-security-boundaries.md +1 -1
  24. package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
  25. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
  26. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +15 -5
  27. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +1 -1
  28. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +1 -1
  29. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +10 -3
  30. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +74 -0
  31. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +75 -0
  32. package/docs/CODE_RULES.md +24 -346
  33. package/hooks/blocking/code_rules_enforcer.py +367 -42
  34. package/hooks/blocking/tdd_enforcer.py +211 -19
  35. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +519 -0
  36. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +1 -1
  37. package/hooks/blocking/test_tdd_enforcer.py +399 -0
  38. package/hooks/hooks.json +0 -15
  39. package/hooks/hooks_constants/code_rules_enforcer_constants.py +5 -0
  40. package/package.json +1 -1
  41. package/rules/ask-user-question-required.md +2 -41
  42. package/rules/confirm-implementation-forks.md +3 -44
  43. package/rules/gh-body-file.md +2 -78
  44. package/rules/gh-paginate.md +2 -78
  45. package/rules/plain-language.md +2 -41
  46. package/rules/prompt-workflow-context-controls.md +9 -38
  47. package/rules/shell-invocation-policy.md +2 -141
  48. package/rules/testing.md +10 -0
  49. package/rules/vault-context.md +3 -32
  50. package/rules/windows-filesystem-safe.md +3 -87
  51. package/scripts/sync_to_cursor/rules.py +201 -79
  52. package/scripts/tests/test_sync_to_cursor.py +122 -26
  53. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +2 -0
  54. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +51 -4
  55. package/skills/auditing-claude-config/SKILL.md +6 -1
  56. package/skills/bugteam/CONSTRAINTS.md +1 -1
  57. package/skills/bugteam/PROMPTS.md +8 -6
  58. package/skills/bugteam/SKILL.md +5 -5
  59. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  60. package/skills/bugteam/reference/audit-contract.md +4 -4
  61. package/skills/bugteam/reference/design-rationale.md +1 -1
  62. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
  63. package/skills/bugteam/reference/team-setup.md +17 -5
  64. package/skills/bugteam/scripts/bugteam_preflight.py +22 -10
  65. package/skills/bugteam/scripts/test_bugteam_preflight.py +32 -0
  66. package/skills/copilot-review/SKILL.md +5 -8
  67. package/skills/doc-gist/SKILL.md +5 -8
  68. package/skills/fixbugs/SKILL.md +1 -1
  69. package/skills/gh-paginate/SKILL.md +84 -0
  70. package/skills/pre-compact/SKILL.md +4 -9
  71. package/skills/refine/SKILL.md +8 -2
  72. package/skills/structure-prompt/SKILL.md +5 -10
  73. 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,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.50.4",
3
+ "version": "1.52.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,44 +1,5 @@
1
1
  # AskUserQuestion Required
2
2
 
3
- **When this applies:** Any time you would ask the user a question during discovery, scoping, or implementation planning after the `verify-before-asking` decision checklist confirms the question genuinely belongs to the user.
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
- ## Rule
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
- **When this applies:** During planning or implementation, whenever two or more viable paths would satisfy the goal and the choice changes the deliverableits scope, completeness, the work it defers, the dependencies it adds, or a contract that is hard to reverse.
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
- ## Rule
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
- At a material fork, stop and ask the user which path to take through `AskUserQuestion`. Do not silently pick one path and proceed. Present each path as an option whose description states its tradeoff: what it delivers, what it defers, the follow-up cost it creates, and how reversible it is. Begin implementing only after the user chooses.
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.
@@ -1,81 +1,5 @@
1
1
  # gh --body-file Rule
2
2
 
3
- **MCP note:** MCP tools accept `body` as a structured string parameter and are unaffected by shell quoting. This rule applies to `gh` CLI invocations issued through the `Bash` tool.
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
- **Root cause:** In shell-invoked `gh` command contexts used in this repo, passing markdown body text via `--body "..."` can cause backticks to be stored as `\`` literals on GitHub instead of rendering as markdown code formatting. Quoting and escaping rules vary by execution environment (Bash, PowerShell, CMD), but the failure mode is the same: inline code and code fences can be broken in issues, PR descriptions, comments, and reviews written this way.
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.