claude-dev-env 2.1.0 → 2.2.1

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 (44) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/pr-loop/scripts/pyproject.toml +22 -0
  3. package/hooks/blocking/code_rules_shared.py +4 -2
  4. package/hooks/blocking/pii_payload_scan.py +78 -20
  5. package/hooks/blocking/pii_prevention_blocker.py +3 -1
  6. package/hooks/blocking/test_pii_write_surface_ephemeral.py +221 -0
  7. package/hooks/validators/ruff_integration.py +2 -1
  8. package/hooks/validators/run_all_validators.py +460 -24
  9. package/hooks/validators/test_ruff_integration.py +21 -0
  10. package/hooks/validators/test_run_all_validators_pretooluse.py +223 -3
  11. package/package.json +1 -1
  12. package/rules/CLAUDE.md +1 -0
  13. package/rules/cleanup-command-forms.md +23 -0
  14. package/scripts/check.ps1 +32 -6
  15. package/skills/_shared/pr-loop/CLAUDE.md +18 -13
  16. package/skills/_shared/pr-loop/portable-driver.md +150 -0
  17. package/skills/_shared/pr-loop/scripts/CLAUDE.md +3 -0
  18. package/skills/_shared/pr-loop/scripts/build_converge_task_list.py +310 -0
  19. package/skills/_shared/pr-loop/scripts/portable_converge_driver.py +1637 -0
  20. package/skills/_shared/pr-loop/scripts/select_converge_pacer.py +215 -0
  21. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +3 -0
  22. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/converge_task_list_constants.py +56 -0
  23. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/pacer_constants.py +47 -0
  24. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/portable_driver_constants.py +181 -0
  25. package/skills/_shared/pr-loop/scripts/test_build_converge_task_list.py +140 -0
  26. package/skills/_shared/pr-loop/scripts/test_portable_converge_driver.py +1119 -0
  27. package/skills/_shared/pr-loop/scripts/test_select_converge_pacer.py +207 -0
  28. package/skills/autoconverge/CLAUDE.md +5 -3
  29. package/skills/autoconverge/SKILL.md +185 -74
  30. package/skills/autoconverge/reference/convergence.md +5 -5
  31. package/skills/autoconverge/reference/multi-pr.md +33 -2
  32. package/skills/autoconverge/reference/stop-conditions.md +9 -6
  33. package/skills/autoconverge/test_portable_pacer_gate.py +54 -0
  34. package/skills/copilot-finding-triage/SKILL.md +16 -4
  35. package/skills/pr-converge/CLAUDE.md +4 -3
  36. package/skills/pr-converge/SKILL.md +236 -44
  37. package/skills/pr-converge/reference/multi-pr-orchestration.md +5 -1
  38. package/skills/pr-converge/reference/per-tick.md +51 -24
  39. package/skills/pr-converge/test_portable_pacer_gate.py +67 -0
  40. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -3
  41. package/skills/usage-pause/SKILL.md +5 -4
  42. package/skills/usage-pause/scripts/resolve_usage_window.py +68 -13
  43. package/skills/usage-pause/scripts/test_resolve_usage_window.py +121 -9
  44. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +7 -3
@@ -6,12 +6,14 @@ Exit code 0 = all checks pass, 1 = violations found.
6
6
  # pragma: no-tdd-gate
7
7
 
8
8
  import argparse
9
+ import ast
9
10
  import json
10
11
  import os
11
12
  import subprocess
12
13
  import sys
13
14
  import tempfile
14
15
  import time
16
+ from collections import Counter
15
17
  from dataclasses import dataclass
16
18
  from datetime import datetime
17
19
  from pathlib import Path
@@ -779,6 +781,22 @@ def validate_proposed_file(
779
781
  return run_file_scoped_validators([temporary_file])
780
782
 
781
783
 
784
+ def _validator_summaries(results: List[ValidatorResult]) -> str:
785
+ """Join one ``name (checks): output`` summary per result with a separator.
786
+
787
+ Args:
788
+ results: The validator results to summarize.
789
+
790
+ Returns:
791
+ The joined summary text shared by the deny reason and the warning.
792
+ """
793
+ validator_summary_separator = " | "
794
+ return validator_summary_separator.join(
795
+ f"{each_result.name} (checks {each_result.checks}): {each_result.output.strip()}"
796
+ for each_result in results
797
+ )
798
+
799
+
782
800
  def _proposed_content_deny_reason(failed_results: List[ValidatorResult]) -> str:
783
801
  """Compose the deny reason naming each failing validator and its output.
784
802
 
@@ -788,15 +806,9 @@ def _proposed_content_deny_reason(failed_results: List[ValidatorResult]) -> str:
788
806
  Returns:
789
807
  The composed ``permissionDecisionReason`` text.
790
808
  """
791
- violation_summaries = [
792
- f"{each_result.name} (checks {each_result.checks}): {each_result.output.strip()}"
793
- for each_result in failed_results
794
- ]
795
- deny_reason_item_separator = " | "
796
- joined_summaries = deny_reason_item_separator.join(violation_summaries)
797
809
  return (
798
810
  f"BLOCKED: [validators] {len(failed_results)} "
799
- f"validator(s) failed: {joined_summaries}"
811
+ f"validator(s) failed: {_validator_summaries(failed_results)}"
800
812
  )
801
813
 
802
814
 
@@ -818,16 +830,447 @@ def _emit_pre_tool_use_deny(deny_reason: str) -> None:
818
830
  sys.stdout.flush()
819
831
 
820
832
 
833
+ def _record_function_spans(
834
+ parent_node: ast.AST, name_prefix: str, name_by_line: dict[int, str]
835
+ ) -> None:
836
+ """Assign each line inside a function to that function's qualified name.
837
+
838
+ Inner functions overwrite the enclosing name, so a line resolves to its
839
+ innermost function; a method resolves to ``Class.method``.
840
+
841
+ Args:
842
+ parent_node: The AST node whose children are walked.
843
+ name_prefix: The dotted qualifier accumulated from enclosing scopes.
844
+ name_by_line: The line-to-name map filled in place.
845
+ """
846
+ for each_child in ast.iter_child_nodes(parent_node):
847
+ if isinstance(each_child, ast.ClassDef):
848
+ _record_function_spans(each_child, f"{name_prefix}{each_child.name}.", name_by_line)
849
+ elif isinstance(each_child, ast.FunctionDef | ast.AsyncFunctionDef):
850
+ qualified_name = f"{name_prefix}{each_child.name}"
851
+ last_line = each_child.end_lineno or each_child.lineno
852
+ for each_line in range(each_child.lineno, last_line + 1):
853
+ name_by_line[each_line] = qualified_name
854
+ _record_function_spans(each_child, f"{qualified_name}.", name_by_line)
855
+ else:
856
+ _record_function_spans(each_child, name_prefix, name_by_line)
857
+
858
+
859
+ def _enclosing_function_name_by_line(content: str) -> dict[int, str]:
860
+ """Map each source line to its innermost enclosing function's qualified name.
861
+
862
+ ::
863
+
864
+ def outer(): # lines 1-4 -> "outer"
865
+ def inner(): # lines 2-3 -> "outer.inner"
866
+ return None
867
+ return inner
868
+ log_start() # line 5 -> "" (module scope)
869
+
870
+ Args:
871
+ content: The full source text to parse.
872
+
873
+ Returns:
874
+ A line-to-name map; a line outside every function has no entry, so a
875
+ lookup yields the empty string for module scope. An unparseable source
876
+ yields an empty map.
877
+ """
878
+ try:
879
+ tree = ast.parse(content)
880
+ except SyntaxError:
881
+ return {}
882
+ name_by_line: dict[int, str] = {}
883
+ _record_function_spans(tree, "", name_by_line)
884
+ return name_by_line
885
+
886
+
887
+ def _violation_line_number(output_line: str) -> int:
888
+ """Return the source line a validator's location prefix names.
889
+
890
+ ::
891
+
892
+ /pkg/legacy_module.py:37: magic number -> line number 37
893
+ /pkg/legacy_module.py:37:5: F401 unused -> line number 37
894
+ a summary line with no file location -> line number 0
895
+ 1 | import os (ruff code frame) -> line number 0
896
+
897
+ The line is the first colon-delimited field that is all digits, with every
898
+ later prefix field also all digits (the column) and every earlier field
899
+ reading like a path — no pipes or quotes, though spaces are allowed so a
900
+ spaced directory or file name still parses. A ruff code-frame line quoting
901
+ source text carries a pipe or quote before any digits, so frame and
902
+ summary noise resolves to 0.
903
+
904
+ Args:
905
+ output_line: One printed ``Violation`` line from a validator.
906
+
907
+ Returns:
908
+ The parsed line number, or 0 for a line with no ``file:line`` prefix.
909
+ """
910
+ prefix_fields = output_line.partition(": ")[0].split(":")
911
+ for each_field_index, each_field in enumerate(prefix_fields):
912
+ if not each_field.isdigit():
913
+ continue
914
+ return _line_number_when_prefix_is_a_location(prefix_fields, each_field_index)
915
+ return 0
916
+
917
+
918
+ def _line_number_when_prefix_is_a_location(
919
+ prefix_fields: List[str], digit_field_index: int
920
+ ) -> int:
921
+ """Return the digit field as a line number when its prefix reads ``path:line``.
922
+
923
+ Args:
924
+ prefix_fields: The colon-split fields of the text before the message.
925
+ digit_field_index: The index of the first all-digit field.
926
+
927
+ Returns:
928
+ The line number, or 0 when the surrounding fields do not form a
929
+ ``path:line[:col]`` location.
930
+ """
931
+ non_path_characters = ("|", '"')
932
+ if digit_field_index == 0:
933
+ return 0
934
+ path_fields = prefix_fields[:digit_field_index]
935
+ looks_like_a_path = not any(
936
+ each_character in each_path_field
937
+ for each_path_field in path_fields
938
+ for each_character in non_path_characters
939
+ )
940
+ trailing_fields = prefix_fields[digit_field_index + 1 :]
941
+ if looks_like_a_path and all(each_field.isdigit() for each_field in trailing_fields):
942
+ return int(prefix_fields[digit_field_index])
943
+ return 0
944
+
945
+
946
+ def _identity_scope(output_line: str, name_by_line: dict[int, str]) -> str:
947
+ """Return the enclosing-function name a single violation line belongs to.
948
+
949
+ Args:
950
+ output_line: One printed ``Violation`` line from a validator.
951
+ name_by_line: The line-to-name map for the content that produced it.
952
+
953
+ Returns:
954
+ The enclosing function's qualified name, or the empty string for a
955
+ module-scope or unlocatable violation.
956
+ """
957
+ return name_by_line.get(_violation_line_number(output_line), "")
958
+
959
+
960
+ def _failed_results(all_results: List[ValidatorResult]) -> List[ValidatorResult]:
961
+ """Return the results that fired — not passed and not skipped."""
962
+ return [
963
+ each_result
964
+ for each_result in all_results
965
+ if not each_result.passed and not each_result.skipped
966
+ ]
967
+
968
+
969
+ ViolationIdentity = tuple[str, str, str]
970
+
971
+
972
+ def _violation_message(output_line: str) -> str:
973
+ """Return the message text after the ``path:line[:col]: `` location prefix.
974
+
975
+ Args:
976
+ output_line: One located violation line from a validator.
977
+
978
+ Returns:
979
+ The text after the first colon-space separator.
980
+ """
981
+ return output_line.partition(": ")[2]
982
+
983
+
984
+ def _located_violation_lines(each_result: ValidatorResult) -> List[str]:
985
+ """Return the result's output lines that carry a real ``file:line`` location.
986
+
987
+ Ruff code frames, help hints, and ``Found N errors`` summaries carry no
988
+ location, so they are dropped rather than classified.
989
+
990
+ Args:
991
+ each_result: The failing validator result to filter.
992
+
993
+ Returns:
994
+ The located violation lines in output order.
995
+ """
996
+ return [
997
+ each_output_line
998
+ for each_output_line in each_result.output.splitlines()
999
+ if _violation_line_number(each_output_line) > 0
1000
+ ]
1001
+
1002
+
1003
+ def _line_identity(
1004
+ validator_name: str, output_line: str, name_by_line: dict[int, str]
1005
+ ) -> ViolationIdentity:
1006
+ """Return one line's ``(validator, enclosing function, message)`` identity key.
1007
+
1008
+ Args:
1009
+ validator_name: The name of the validator that printed the line.
1010
+ output_line: One located violation line.
1011
+ name_by_line: The line-to-name map for the content that produced it.
1012
+
1013
+ Returns:
1014
+ The identity key for baseline comparison.
1015
+ """
1016
+ return (
1017
+ validator_name,
1018
+ _identity_scope(output_line, name_by_line),
1019
+ _violation_message(output_line),
1020
+ )
1021
+
1022
+
1023
+ def _violation_identities(
1024
+ failed_results: List[ValidatorResult], content: str
1025
+ ) -> Counter[ViolationIdentity]:
1026
+ """Count each located violation line by its identity key.
1027
+
1028
+ Keying on the enclosing function rather than the raw line number keeps a
1029
+ key stable when an edit shifts that function, and counting rather than set
1030
+ membership keeps a second violation of the same validator in the same
1031
+ function visible as new.
1032
+
1033
+ Args:
1034
+ failed_results: The validator results that fired.
1035
+ content: The source text those results were produced against.
1036
+
1037
+ Returns:
1038
+ The multiset of violation identity keys.
1039
+ """
1040
+ name_by_line = _enclosing_function_name_by_line(content)
1041
+ return Counter(
1042
+ _line_identity(each_result.name, each_output_line, name_by_line)
1043
+ for each_result in failed_results
1044
+ for each_output_line in _located_violation_lines(each_result)
1045
+ )
1046
+
1047
+
1048
+ def _baseline_violation_identities(file_path: str) -> Counter[ViolationIdentity]:
1049
+ """Return the violation identity counts the on-disk file already carries.
1050
+
1051
+ Args:
1052
+ file_path: The write's target path, read as the pre-edit baseline.
1053
+
1054
+ Returns:
1055
+ The baseline identity multiset, empty when the file is absent or empty.
1056
+ """
1057
+ baseline_content = _read_target_file_content(file_path)
1058
+ if not baseline_content:
1059
+ return Counter()
1060
+ baseline_failed = _failed_results(validate_proposed_file(file_path, baseline_content))
1061
+ return _violation_identities(baseline_failed, baseline_content)
1062
+
1063
+
1064
+ def _identity_key_counts(
1065
+ baseline_identities: Counter[ViolationIdentity],
1066
+ ) -> Counter[tuple[str, str]]:
1067
+ """Sum baseline counts down to ``(validator, enclosing function)`` keys.
1068
+
1069
+ Args:
1070
+ baseline_identities: The baseline identity multiset.
1071
+
1072
+ Returns:
1073
+ The per-key line counts with messages ignored, so a violation whose
1074
+ message drifted with the edit still finds its baseline budget.
1075
+ """
1076
+ key_counts: Counter[tuple[str, str]] = Counter()
1077
+ for each_identity, each_count in baseline_identities.items():
1078
+ key_counts[each_identity[:2]] += each_count
1079
+ return key_counts
1080
+
1081
+
1082
+ def _result_with_output(
1083
+ source_result: ValidatorResult, all_output_lines: List[str]
1084
+ ) -> ValidatorResult:
1085
+ """Return a copy of *source_result* carrying only *all_output_lines*."""
1086
+ output_line_separator = "\n"
1087
+ return ValidatorResult(
1088
+ name=source_result.name,
1089
+ checks=source_result.checks,
1090
+ passed=False,
1091
+ output=output_line_separator.join(all_output_lines),
1092
+ )
1093
+
1094
+
1095
+ def _consume_exact_matches(
1096
+ all_line_identities: List[ViolationIdentity],
1097
+ remaining_exact: Counter[ViolationIdentity],
1098
+ remaining_by_key: Counter[tuple[str, str]],
1099
+ ) -> set[int]:
1100
+ """Mark the lines whose full identity matches an unconsumed baseline entry.
1101
+
1102
+ Args:
1103
+ all_line_identities: One identity per located line, in output order.
1104
+ remaining_exact: The unconsumed baseline identity budget, decremented
1105
+ in place per match.
1106
+ remaining_by_key: The unconsumed per-key budget, decremented in step.
1107
+
1108
+ Returns:
1109
+ The indexes of the exactly-matched lines.
1110
+ """
1111
+ all_matched_line_indexes: set[int] = set()
1112
+ for each_line_index, each_identity in enumerate(all_line_identities):
1113
+ if remaining_exact[each_identity] <= 0:
1114
+ continue
1115
+ remaining_exact[each_identity] -= 1
1116
+ remaining_by_key[each_identity[:2]] -= 1
1117
+ all_matched_line_indexes.add(each_line_index)
1118
+ return all_matched_line_indexes
1119
+
1120
+
1121
+ def _line_is_preexisting(
1122
+ line_index: int,
1123
+ all_line_identities: List[ViolationIdentity],
1124
+ all_matched_line_indexes: set[int],
1125
+ remaining_by_key: Counter[tuple[str, str]],
1126
+ ) -> bool:
1127
+ """Return whether one located line matches the baseline budget.
1128
+
1129
+ An exact identity match is pre-existing. A leftover line whose
1130
+ ``(validator, enclosing function)`` key still has baseline budget is
1131
+ pre-existing with a drifted message. A line beyond its key's budget is new.
1132
+
1133
+ Args:
1134
+ line_index: The line's position in the located-line order.
1135
+ all_line_identities: One identity per located line.
1136
+ all_matched_line_indexes: The exactly-matched line indexes.
1137
+ remaining_by_key: The unconsumed per-key budget, decremented in place.
1138
+
1139
+ Returns:
1140
+ True when the line is pre-existing, False when it is new.
1141
+ """
1142
+ if line_index in all_matched_line_indexes:
1143
+ return True
1144
+ each_key = all_line_identities[line_index][:2]
1145
+ if remaining_by_key[each_key] > 0:
1146
+ remaining_by_key[each_key] -= 1
1147
+ return True
1148
+ return False
1149
+
1150
+
1151
+ def _partition_output_lines(
1152
+ each_result: ValidatorResult,
1153
+ name_by_line: dict[int, str],
1154
+ remaining_exact: Counter[ViolationIdentity],
1155
+ remaining_by_key: Counter[tuple[str, str]],
1156
+ ) -> tuple[List[str], List[str]]:
1157
+ """Split one result's located lines into a (new, pre-existing) line pair.
1158
+
1159
+ The exact and per-key baseline budgets are consumed in place, exact
1160
+ matches first, so a later result never double-spends an earlier match.
1161
+ """
1162
+ located_lines = _located_violation_lines(each_result)
1163
+ all_line_identities = [
1164
+ _line_identity(each_result.name, each_line, name_by_line)
1165
+ for each_line in located_lines
1166
+ ]
1167
+ all_matched_line_indexes = _consume_exact_matches(
1168
+ all_line_identities, remaining_exact, remaining_by_key
1169
+ )
1170
+ all_new_lines: List[str] = []
1171
+ all_preexisting_lines: List[str] = []
1172
+ for each_line_index, each_line in enumerate(located_lines):
1173
+ is_preexisting = _line_is_preexisting(
1174
+ each_line_index, all_line_identities, all_matched_line_indexes, remaining_by_key
1175
+ )
1176
+ (all_preexisting_lines if is_preexisting else all_new_lines).append(each_line)
1177
+ return all_new_lines, all_preexisting_lines
1178
+
1179
+
1180
+ def _grouped_result_lines(
1181
+ each_result: ValidatorResult, all_partitioned_lines: tuple[List[str], List[str]]
1182
+ ) -> tuple[List[ValidatorResult], List[ValidatorResult]]:
1183
+ """Return one result's (new, pre-existing) groups from its partitioned lines.
1184
+
1185
+ A failed result with no located line at all cannot be baseline-matched, so
1186
+ it stays new in full — the gate fails closed rather than letting an
1187
+ unlocatable failure through.
1188
+ """
1189
+ all_new_lines, all_preexisting_lines = all_partitioned_lines
1190
+ if not all_new_lines and not all_preexisting_lines:
1191
+ return [each_result], []
1192
+ new_results = [_result_with_output(each_result, all_new_lines)] if all_new_lines else []
1193
+ preexisting_results = (
1194
+ [_result_with_output(each_result, all_preexisting_lines)]
1195
+ if all_preexisting_lines
1196
+ else []
1197
+ )
1198
+ return new_results, preexisting_results
1199
+
1200
+
1201
+ def _scope_new_and_preexisting(
1202
+ all_proposed_failed_results: List[ValidatorResult],
1203
+ proposed_content: str,
1204
+ baseline_identities: Counter[ViolationIdentity],
1205
+ ) -> tuple[List[ValidatorResult], List[ValidatorResult]]:
1206
+ """Group proposed violations into newly-introduced and pre-existing results.
1207
+
1208
+ Args:
1209
+ all_proposed_failed_results: The validators that fired on the proposed file.
1210
+ proposed_content: The reconstructed post-edit source text.
1211
+ baseline_identities: The identity counts the on-disk baseline carries.
1212
+
1213
+ Returns:
1214
+ A ``(new_results, preexisting_results)`` pair.
1215
+ """
1216
+ name_by_line = _enclosing_function_name_by_line(proposed_content)
1217
+ remaining_exact = Counter(baseline_identities)
1218
+ remaining_by_key = _identity_key_counts(baseline_identities)
1219
+ all_new_results: List[ValidatorResult] = []
1220
+ all_preexisting_results: List[ValidatorResult] = []
1221
+ for each_result in all_proposed_failed_results:
1222
+ new_results, preexisting_results = _grouped_result_lines(
1223
+ each_result,
1224
+ _partition_output_lines(each_result, name_by_line, remaining_exact, remaining_by_key),
1225
+ )
1226
+ all_new_results.extend(new_results)
1227
+ all_preexisting_results.extend(preexisting_results)
1228
+ return all_new_results, all_preexisting_results
1229
+
1230
+
1231
+ def _emit_pre_existing_warning(all_preexisting_results: List[ValidatorResult]) -> None:
1232
+ """Write a stderr advisory naming each pre-existing violation left in place."""
1233
+ advisory_summaries = _validator_summaries(all_preexisting_results)
1234
+ sys.stderr.write(
1235
+ "[run_all_validators] allowed with warning: "
1236
+ f"pre-existing violation(s) unchanged: {advisory_summaries}\n"
1237
+ )
1238
+ sys.stderr.flush()
1239
+
1240
+
1241
+ def _decide_pre_tool_use(file_path: str, proposed_content: str) -> None:
1242
+ """Deny only violations absent from the baseline; warn on the ones that persist.
1243
+
1244
+ Args:
1245
+ file_path: The write's target path.
1246
+ proposed_content: The reconstructed post-edit content of that file.
1247
+ """
1248
+ all_proposed_failed = _failed_results(
1249
+ validate_proposed_file(file_path, proposed_content)
1250
+ )
1251
+ if not all_proposed_failed:
1252
+ return
1253
+ baseline_identities = _baseline_violation_identities(file_path)
1254
+ all_new_results, all_preexisting_results = _scope_new_and_preexisting(
1255
+ all_proposed_failed, proposed_content, baseline_identities
1256
+ )
1257
+ if all_preexisting_results:
1258
+ _emit_pre_existing_warning(all_preexisting_results)
1259
+ if all_new_results:
1260
+ _emit_pre_tool_use_deny(_proposed_content_deny_reason(all_new_results))
1261
+
1262
+
821
1263
  def _evaluate_pre_tool_use_payload() -> None:
822
- """Read the PreToolUse payload from stdin and deny a violating file write.
823
-
824
- Reconstructs the proposed post-edit content of the one target file and runs
825
- the file-scoped validators against it. The path-based exemption decision runs
826
- against the real target path from the payload, so an ephemeral scratch or
827
- session scratchpad target passes without validation even though the content
828
- is validated through a temporary copy. Emits a deny decision naming each
829
- failing validator when any fires; writes nothing for a clean file, an exempt
830
- target, an unparseable payload, or a payload no validator covers.
1264
+ """Read the PreToolUse payload from stdin and deny only newly-introduced violations.
1265
+
1266
+ The path-based exemption decision runs against the real target path from the
1267
+ payload, so an ephemeral scratch or session scratchpad target passes without
1268
+ validation before any baseline-scoped decision runs. For a non-exempt target,
1269
+ each located violation is keyed by validator name, enclosing function, and
1270
+ message, then counted against the on-disk baseline. A violation beyond the
1271
+ baseline's budget for its key denies the write; one the baseline already
1272
+ carries passes with a stderr advisory. Writes nothing for a clean file, an
1273
+ exempt target, or an unparseable payload.
831
1274
  """
832
1275
  pre_tool_use_payload = json.load(sys.stdin)
833
1276
  if not isinstance(pre_tool_use_payload, dict):
@@ -844,14 +1287,7 @@ def _evaluate_pre_tool_use_payload() -> None:
844
1287
  proposed_content = reconstruct_proposed_content(tool_name, tool_input)
845
1288
  if not proposed_content:
846
1289
  return
847
- all_results = validate_proposed_file(file_path, proposed_content)
848
- failed_results = [
849
- each_result
850
- for each_result in all_results
851
- if not each_result.passed and not each_result.skipped
852
- ]
853
- if failed_results:
854
- _emit_pre_tool_use_deny(_proposed_content_deny_reason(failed_results))
1290
+ _decide_pre_tool_use(file_path, proposed_content)
855
1291
 
856
1292
 
857
1293
  def run_pre_tool_use_gate() -> int:
@@ -25,3 +25,24 @@ def test_run_ruff_check_returns_passed_for_empty_files() -> None:
25
25
  result = run_ruff_check([])
26
26
  assert result.passed is True
27
27
  assert "No files" in result.output
28
+
29
+
30
+ def test_run_ruff_check_emits_location_prefixed_lines(tmp_path: Path) -> None:
31
+ """Each reported violation carries a ``path:line:col:`` prefix on its own line.
32
+
33
+ The run_all_validators PreToolUse gate parses these prefixes to scope
34
+ violations to a baseline, so the output format is pinned to the concise
35
+ shape rather than the ruff default, which moved locations onto separate
36
+ ``-->`` lines.
37
+ """
38
+ violating_file = tmp_path / "unused_import_module.py"
39
+ violating_file.write_text("import os\n", encoding="utf-8")
40
+
41
+ result = run_ruff_check([violating_file])
42
+
43
+ assert result.passed is False
44
+ location_prefix = f"{violating_file}:1:8:"
45
+ assert any(
46
+ each_line.startswith(location_prefix) and "F401" in each_line
47
+ for each_line in result.output.splitlines()
48
+ ), result.output