claude-dev-env 2.7.0 → 2.8.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 (88) hide show
  1. package/_shared/CLAUDE.md +1 -0
  2. package/_shared/advisor/advisor-protocol.md +19 -9
  3. package/_shared/pr-loop/audit-contract.md +4 -4
  4. package/_shared/pr-loop/precatch-rubric.md +2 -2
  5. package/_shared/pr-loop/worker-spawn.md +3 -1
  6. package/_shared/process-tree/CLAUDE.md +41 -0
  7. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
  8. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
  9. package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
  10. package/_shared/process-tree/scripts/pyproject.toml +16 -0
  11. package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
  12. package/agents/code-quality-agent.md +6 -5
  13. package/agents/deep-research.md +7 -24
  14. package/agents/docs-agent.md +1 -27
  15. package/agents/issue-tracker.md +1 -7
  16. package/agents/skill-writer-agent.md +1 -2
  17. package/agents/test_agent_frontmatter.py +309 -12
  18. package/hooks/blocking/CLAUDE.md +2 -0
  19. package/hooks/blocking/fable_spawn_gate.py +187 -0
  20. package/hooks/blocking/piped_pytest_blocker.py +1223 -0
  21. package/hooks/blocking/plain_language_blocker.py +287 -15
  22. package/hooks/blocking/test_fable_spawn_gate.py +374 -0
  23. package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
  24. package/hooks/blocking/test_plain_language_blocker.py +277 -2
  25. package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
  26. package/hooks/git-hooks/CLAUDE.md +2 -2
  27. package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
  28. package/hooks/git-hooks/pre_push.py +343 -54
  29. package/hooks/git-hooks/test_pre_push.py +852 -6
  30. package/hooks/hooks.json +9 -19
  31. package/hooks/hooks_constants/CLAUDE.md +2 -0
  32. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
  33. package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
  34. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
  35. package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
  36. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
  37. package/hooks/hooks_constants/shell_command_segments.py +1 -1
  38. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
  39. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
  40. package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
  41. package/package.json +1 -1
  42. package/rules/CLAUDE.md +1 -0
  43. package/rules/ask-user-question-required.md +26 -0
  44. package/rules/claims-as-quotes.md +65 -0
  45. package/scripts/CLAUDE.md +4 -4
  46. package/scripts/_code_review_test_support.py +6 -0
  47. package/scripts/check.ps1 +18 -5
  48. package/scripts/claude_chain_runner.py +203 -31
  49. package/scripts/codec_forwarding_test_support.py +2 -0
  50. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
  51. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
  52. package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
  53. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +83 -13
  54. package/scripts/grok_headless_runner.py +148 -18
  55. package/scripts/resolve_worker_spawn.py +56 -10
  56. package/scripts/spawn_grok_batch.py +81 -23
  57. package/scripts/test_claude_chain_runner.py +358 -0
  58. package/scripts/test_grok_headless_runner.py +547 -10
  59. package/scripts/test_invoke_code_review.py +298 -0
  60. package/scripts/test_resolve_worker_spawn.py +185 -15
  61. package/scripts/test_spawn_grok_batch.py +326 -22
  62. package/scripts/tests/CLAUDE.md +1 -0
  63. package/scripts/tests/test_grok_worker_constants.py +59 -0
  64. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
  65. package/skills/autoconverge/workflow/converge.contract.test.mjs +133 -8
  66. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +107 -1
  67. package/skills/autoconverge/workflow/converge.mjs +113 -31
  68. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
  69. package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
  70. package/skills/codex-review/scripts/run_codex_review.py +16 -64
  71. package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
  72. package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
  73. package/skills/e-code-review/SKILL.md +9 -8
  74. package/skills/e-code-review/reference/fix.md +29 -7
  75. package/skills/e-code-review/reference/loop.md +230 -14
  76. package/skills/e-code-review/reference/low.md +33 -15
  77. package/skills/e-code-review/reference/medium.md +55 -21
  78. package/skills/e-code-review/reference/xhigh.md +30 -12
  79. package/skills/fresh-branch/CLAUDE.md +5 -5
  80. package/skills/fresh-branch/SKILL.md +14 -6
  81. package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
  82. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
  83. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
  84. package/skills/grok-spawn/SKILL.md +10 -3
  85. package/skills/grok-spawn/reference/flag-profiles.md +3 -1
  86. package/skills/orchestrator/SKILL.md +4 -1
  87. package/skills/orchestrator-refresh/SKILL.md +5 -1
  88. package/skills/team-advisor/SKILL.md +4 -1
@@ -22,15 +22,17 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
22
22
  BUILD_PROFILE_PROMPT_HEADER,
23
23
  CLASSIFICATION_ERROR,
24
24
  CLASSIFICATION_OK,
25
+ CLASSIFICATION_TIMEOUT,
25
26
  CLASSIFICATION_USAGE_LIMIT,
26
27
  DEBUG_FILE_FLAG,
27
28
  DEFAULT_ROLE,
28
- DEFAULT_WORKER_MAX_TURNS,
29
29
  DEFAULT_WORKER_TIMEOUT_SECONDS,
30
30
  DISABLE_WEB_SEARCH_FLAG,
31
31
  DISALLOWED_TOOLS_FLAG,
32
32
  LEADER_SOCKET_FILENAME_PREFIX,
33
33
  LEADER_SOCKET_FILENAME_SUFFIX,
34
+ MAX_TURNS_FLAG,
35
+ MAXIMUM_WORKER_TIMEOUT_SECONDS,
34
36
  OUTPUT_FILENAME_PREFIX,
35
37
  PROMPT_FILENAME_PREFIX,
36
38
  READONLY_DISALLOWED_TOOLS_VALUE,
@@ -53,7 +55,6 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
53
55
  TOOL_PROFILE_READONLY,
54
56
  UTF8_ENCODING,
55
57
  WORKER_SPEC_AGENT_NAME_KEY,
56
- WORKER_SPEC_MAX_TURNS_KEY,
57
58
  WORKER_SPEC_PROMPT_PARTS_KEY,
58
59
  WORKER_SPEC_TIMEOUT_KEY,
59
60
  )
@@ -61,8 +62,10 @@ from dev_env_scripts_constants.timing import WORKER_STAGGER_SECONDS # noqa: E40
61
62
  from grok_headless_runner import GrokRunnerOutcome # noqa: E402
62
63
  from grok_worker_preflight import PreflightOutcome # noqa: E402
63
64
 
65
+ RETIRED_MAX_TURNS_KEYWORD = "max_turns"
64
66
  FIXTURE_REPORT_TEXT = '{"status":"done","role":"investigator"}'
65
67
  FIXTURE_USAGE_LIMIT_TEXT = "rate limit exceeded (HTTP 429): quota exceeded"
68
+ FIXTURE_TIMEOUT_KILL_TEXT = "worker exceeded its timeout and was killed"
66
69
 
67
70
 
68
71
  def _write_prompt_parts(
@@ -871,7 +874,108 @@ def test_load_batch_spec_missing_worker_keys_raise_value_error(
871
874
  ).lower() or "must be" in str(raised_error.value).lower()
872
875
 
873
876
 
874
- def test_load_batch_spec_rejects_non_positive_timeout_and_max_turns(
877
+ def test_load_batch_spec_rejects_an_unknown_worker_key(
878
+ tmp_path: Path,
879
+ ) -> None:
880
+ header_part, body_part = _write_prompt_parts(tmp_path)
881
+ working_directory = tmp_path / "project"
882
+ working_directory.mkdir()
883
+ worker_payload = _worker_payload(
884
+ role_name="stray-key-worker",
885
+ all_prompt_parts=[str(header_part), str(body_part)],
886
+ working_directory=working_directory,
887
+ tool_profile=TOOL_PROFILE_BUILD,
888
+ )
889
+ worker_payload["timeout_second"] = 30
890
+ specification_path = _write_batch_spec(
891
+ tmp_path, all_worker_payloads=[worker_payload]
892
+ )
893
+
894
+ with pytest.raises(ValueError) as raised_error:
895
+ batch.load_batch_spec(specification_path)
896
+
897
+ error_text = str(raised_error.value)
898
+ assert "timeout_second" in error_text
899
+ assert WORKER_SPEC_TIMEOUT_KEY in error_text
900
+
901
+
902
+ def test_load_batch_spec_names_every_unknown_worker_key(
903
+ tmp_path: Path,
904
+ ) -> None:
905
+ header_part, body_part = _write_prompt_parts(tmp_path)
906
+ working_directory = tmp_path / "project"
907
+ working_directory.mkdir()
908
+ worker_payload = _worker_payload(
909
+ role_name="two-stray-keys",
910
+ all_prompt_parts=[str(header_part), str(body_part)],
911
+ working_directory=working_directory,
912
+ tool_profile=TOOL_PROFILE_BUILD,
913
+ )
914
+ worker_payload["stray_cap"] = 5
915
+ worker_payload["notes"] = "operator scratch"
916
+ specification_path = _write_batch_spec(
917
+ tmp_path, all_worker_payloads=[worker_payload]
918
+ )
919
+
920
+ with pytest.raises(ValueError) as raised_error:
921
+ batch.load_batch_spec(specification_path)
922
+
923
+ error_text = str(raised_error.value)
924
+ assert "stray_cap" in error_text
925
+ assert "notes" in error_text
926
+
927
+
928
+ def test_load_batch_spec_rejects_the_retired_turn_cap_key(
929
+ tmp_path: Path,
930
+ ) -> None:
931
+ header_part, body_part = _write_prompt_parts(tmp_path)
932
+ working_directory = tmp_path / "project"
933
+ working_directory.mkdir()
934
+ worker_payload = _worker_payload(
935
+ role_name="retired-cap-worker",
936
+ all_prompt_parts=[str(header_part), str(body_part)],
937
+ working_directory=working_directory,
938
+ tool_profile=TOOL_PROFILE_BUILD,
939
+ )
940
+ worker_payload[RETIRED_MAX_TURNS_KEYWORD] = 5
941
+ specification_path = _write_batch_spec(
942
+ tmp_path, all_worker_payloads=[worker_payload]
943
+ )
944
+
945
+ with pytest.raises(ValueError) as raised_error:
946
+ batch.load_batch_spec(specification_path)
947
+
948
+ error_text = str(raised_error.value)
949
+ assert RETIRED_MAX_TURNS_KEYWORD in error_text
950
+ assert WORKER_SPEC_TIMEOUT_KEY in error_text
951
+
952
+
953
+ def test_load_batch_spec_accepts_every_documented_worker_key(
954
+ tmp_path: Path,
955
+ ) -> None:
956
+ header_part, body_part = _write_prompt_parts(tmp_path)
957
+ working_directory = tmp_path / "project"
958
+ working_directory.mkdir()
959
+ worker_payload: dict[str, object] = {
960
+ "role_name": "every-key-worker",
961
+ "prompt_parts": [str(header_part), str(body_part)],
962
+ "cwd": str(working_directory),
963
+ "tool_profile": TOOL_PROFILE_READONLY,
964
+ "timeout_seconds": 30,
965
+ "is_repo_only": True,
966
+ "agent_name": None,
967
+ }
968
+ specification_path = _write_batch_spec(
969
+ tmp_path, all_worker_payloads=[worker_payload]
970
+ )
971
+
972
+ batch_spec = batch.load_batch_spec(specification_path)
973
+
974
+ assert batch_spec.all_workers[0].role_name == "every-key-worker"
975
+ assert batch_spec.all_workers[0].is_repo_only is True
976
+
977
+
978
+ def test_load_batch_spec_rejects_non_positive_timeout(
875
979
  tmp_path: Path,
876
980
  ) -> None:
877
981
  header_part, body_part = _write_prompt_parts(tmp_path)
@@ -891,27 +995,11 @@ def test_load_batch_spec_rejects_non_positive_timeout_and_max_turns(
891
995
  zero_timeout_dir,
892
996
  all_worker_payloads=[zero_timeout_payload],
893
997
  )
894
- with pytest.raises(ValueError, match=WORKER_SPEC_TIMEOUT_KEY):
998
+ with pytest.raises(ValueError, match="MIN_WORKER_TIMEOUT_SECONDS"):
895
999
  batch.load_batch_spec(zero_timeout_path)
896
1000
 
897
- negative_turns_dir = tmp_path / "negative-turns"
898
- negative_turns_dir.mkdir()
899
- negative_turns_payload = _worker_payload(
900
- role_name="negative-turns",
901
- all_prompt_parts=[str(header_part), str(body_part)],
902
- working_directory=working_directory,
903
- tool_profile=TOOL_PROFILE_BUILD,
904
- )
905
- negative_turns_payload[WORKER_SPEC_MAX_TURNS_KEY] = -1
906
- negative_turns_path = _write_batch_spec(
907
- negative_turns_dir,
908
- all_worker_payloads=[negative_turns_payload],
909
- )
910
- with pytest.raises(ValueError, match=WORKER_SPEC_MAX_TURNS_KEY):
911
- batch.load_batch_spec(negative_turns_path)
912
-
913
1001
 
914
- def test_load_batch_spec_accepts_default_timeout_and_max_turns(
1002
+ def test_load_batch_spec_accepts_the_default_timeout(
915
1003
  tmp_path: Path,
916
1004
  ) -> None:
917
1005
  header_part, body_part = _write_prompt_parts(tmp_path)
@@ -931,7 +1019,223 @@ def test_load_batch_spec_accepts_default_timeout_and_max_turns(
931
1019
 
932
1020
  assert len(batch_spec.all_workers) == 1
933
1021
  assert batch_spec.all_workers[0].timeout_seconds == DEFAULT_WORKER_TIMEOUT_SECONDS
934
- assert batch_spec.all_workers[0].max_turns == DEFAULT_WORKER_MAX_TURNS
1022
+
1023
+
1024
+ def test_timeout_over_the_ceiling_is_refused_and_at_the_ceiling_passes(
1025
+ tmp_path: Path,
1026
+ ) -> None:
1027
+ """The launcher refuses a spec past the 90-minute ceiling; it never clamps.
1028
+
1029
+ ::
1030
+
1031
+ timeout_seconds 5401 flag: ValueError naming MAXIMUM_WORKER_TIMEOUT_SECONDS
1032
+ timeout_seconds 5400 ok: loads, value untouched
1033
+ timeout_seconds 30 ok: loads, value untouched
1034
+ """
1035
+ header_part, body_part = _write_prompt_parts(tmp_path)
1036
+ working_directory = tmp_path / "project"
1037
+ working_directory.mkdir()
1038
+
1039
+ over_ceiling_directory = tmp_path / "over-ceiling"
1040
+ over_ceiling_directory.mkdir()
1041
+ over_ceiling_path = _write_batch_spec(
1042
+ over_ceiling_directory,
1043
+ all_worker_payloads=[
1044
+ _worker_payload(
1045
+ role_name="over-ceiling",
1046
+ all_prompt_parts=[str(header_part), str(body_part)],
1047
+ working_directory=working_directory,
1048
+ tool_profile=TOOL_PROFILE_BUILD,
1049
+ timeout_seconds=MAXIMUM_WORKER_TIMEOUT_SECONDS + 1,
1050
+ )
1051
+ ],
1052
+ )
1053
+ with pytest.raises(ValueError, match="MAXIMUM_WORKER_TIMEOUT_SECONDS"):
1054
+ batch.load_batch_spec(over_ceiling_path)
1055
+
1056
+ at_ceiling_directory = tmp_path / "at-ceiling"
1057
+ at_ceiling_directory.mkdir()
1058
+ at_ceiling_path = _write_batch_spec(
1059
+ at_ceiling_directory,
1060
+ all_worker_payloads=[
1061
+ _worker_payload(
1062
+ role_name="at-ceiling",
1063
+ all_prompt_parts=[str(header_part), str(body_part)],
1064
+ working_directory=working_directory,
1065
+ tool_profile=TOOL_PROFILE_BUILD,
1066
+ timeout_seconds=MAXIMUM_WORKER_TIMEOUT_SECONDS,
1067
+ ),
1068
+ _worker_payload(
1069
+ role_name="well-under-ceiling",
1070
+ all_prompt_parts=[str(header_part), str(body_part)],
1071
+ working_directory=working_directory,
1072
+ tool_profile=TOOL_PROFILE_BUILD,
1073
+ timeout_seconds=30,
1074
+ ),
1075
+ ],
1076
+ )
1077
+ at_ceiling_spec = batch.load_batch_spec(at_ceiling_path)
1078
+
1079
+ assert at_ceiling_spec.all_workers[0].timeout_seconds == (
1080
+ MAXIMUM_WORKER_TIMEOUT_SECONDS
1081
+ )
1082
+ assert at_ceiling_spec.all_workers[1].timeout_seconds == 30
1083
+
1084
+
1085
+ def test_ceiling_timeout_reaches_the_runner_untouched(
1086
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1087
+ ) -> None:
1088
+ header_part, body_part = _write_prompt_parts(tmp_path, role_marker="long-worker")
1089
+ working_directory = tmp_path / "project"
1090
+ working_directory.mkdir()
1091
+ run_state_directory = tmp_path / "run-state"
1092
+ batch_spec = batch.load_batch_spec(
1093
+ _write_batch_spec(
1094
+ tmp_path,
1095
+ all_worker_payloads=[
1096
+ _worker_payload(
1097
+ role_name="long-worker",
1098
+ all_prompt_parts=[str(header_part), str(body_part)],
1099
+ working_directory=working_directory,
1100
+ tool_profile=TOOL_PROFILE_READONLY,
1101
+ timeout_seconds=MAXIMUM_WORKER_TIMEOUT_SECONDS,
1102
+ )
1103
+ ],
1104
+ )
1105
+ )
1106
+ recorder = _RunnerRecorder({"long-worker": _ok_outcome()})
1107
+ monkeypatch.setattr(
1108
+ batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
1109
+ )
1110
+ monkeypatch.setattr(batch, "batch_headless_runner", recorder)
1111
+ monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
1112
+
1113
+ batch.run_grok_batch(
1114
+ batch_spec=batch_spec,
1115
+ run_state_directory=run_state_directory,
1116
+ )
1117
+
1118
+ assert recorder.all_keyword_arguments[0]["timeout_seconds"] == (
1119
+ MAXIMUM_WORKER_TIMEOUT_SECONDS
1120
+ )
1121
+
1122
+
1123
+ def test_worker_invocations_carry_no_turn_cap(
1124
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1125
+ ) -> None:
1126
+ header_part, body_part = _write_prompt_parts(tmp_path, role_marker="uncapped")
1127
+ working_directory = tmp_path / "project"
1128
+ working_directory.mkdir()
1129
+ run_state_directory = tmp_path / "run-state"
1130
+ batch_spec = batch.load_batch_spec(
1131
+ _write_batch_spec(
1132
+ tmp_path,
1133
+ all_worker_payloads=[
1134
+ _worker_payload(
1135
+ role_name="uncapped",
1136
+ all_prompt_parts=[str(header_part), str(body_part)],
1137
+ working_directory=working_directory,
1138
+ tool_profile=TOOL_PROFILE_READONLY,
1139
+ )
1140
+ ],
1141
+ )
1142
+ )
1143
+ recorder = _RunnerRecorder({"uncapped": _ok_outcome()})
1144
+ monkeypatch.setattr(
1145
+ batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
1146
+ )
1147
+ monkeypatch.setattr(batch, "batch_headless_runner", recorder)
1148
+ monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
1149
+
1150
+ batch.run_grok_batch(
1151
+ batch_spec=batch_spec,
1152
+ run_state_directory=run_state_directory,
1153
+ )
1154
+
1155
+ launched_keyword_arguments = recorder.all_keyword_arguments[0]
1156
+ all_extra_arguments = launched_keyword_arguments["all_extra_arguments"]
1157
+ assert RETIRED_MAX_TURNS_KEYWORD not in launched_keyword_arguments
1158
+ assert isinstance(all_extra_arguments, tuple)
1159
+ assert MAX_TURNS_FLAG not in all_extra_arguments
1160
+
1161
+
1162
+ def test_timed_out_worker_reads_as_timeout_beside_a_completed_worker(
1163
+ monkeypatch: pytest.MonkeyPatch,
1164
+ tmp_path: Path,
1165
+ capsys: pytest.CaptureFixture[str],
1166
+ ) -> None:
1167
+ """A killed worker stays distinguishable from a completed one in the summary.
1168
+
1169
+ ::
1170
+
1171
+ killed worker ok: classification timeout, is_ok False, exit code 1
1172
+ completed worker ok: classification ok, is_ok True
1173
+ """
1174
+ header_done, body_done = _write_prompt_parts(tmp_path, role_marker="done-worker")
1175
+ header_killed, body_killed = _write_prompt_parts(
1176
+ tmp_path, role_marker="killed-worker"
1177
+ )
1178
+ working_directory = tmp_path / "project"
1179
+ working_directory.mkdir()
1180
+ run_state_directory = tmp_path / "run-state"
1181
+ specification_path = _write_batch_spec(
1182
+ tmp_path,
1183
+ all_worker_payloads=[
1184
+ _worker_payload(
1185
+ role_name="done-worker",
1186
+ all_prompt_parts=[str(header_done), str(body_done)],
1187
+ working_directory=working_directory,
1188
+ tool_profile=TOOL_PROFILE_BUILD,
1189
+ ),
1190
+ _worker_payload(
1191
+ role_name="killed-worker",
1192
+ all_prompt_parts=[str(header_killed), str(body_killed)],
1193
+ working_directory=working_directory,
1194
+ tool_profile=TOOL_PROFILE_BUILD,
1195
+ ),
1196
+ ],
1197
+ )
1198
+ recorder = _RunnerRecorder(
1199
+ {
1200
+ "done-worker": _ok_outcome(),
1201
+ "killed-worker": GrokRunnerOutcome(
1202
+ is_ok=False,
1203
+ returncode=-9,
1204
+ classification=CLASSIFICATION_TIMEOUT,
1205
+ stdout="",
1206
+ stderr=FIXTURE_TIMEOUT_KILL_TEXT,
1207
+ ),
1208
+ }
1209
+ )
1210
+ monkeypatch.setattr(
1211
+ batch, "batch_preflight", lambda **_kwargs: PreflightOutcome(True, None)
1212
+ )
1213
+ monkeypatch.setattr(batch, "batch_headless_runner", recorder)
1214
+ monkeypatch.setattr(batch, "batch_sleep", lambda _seconds: None)
1215
+
1216
+ exit_code = batch.main(
1217
+ [
1218
+ "--spec",
1219
+ str(specification_path),
1220
+ "--run-temp-dir",
1221
+ str(run_state_directory),
1222
+ ]
1223
+ )
1224
+
1225
+ summary_payload = json.loads(capsys.readouterr().out)
1226
+ payload_by_role_name = {
1227
+ each_payload[SUMMARY_ROLE_NAME_KEY]: each_payload
1228
+ for each_payload in summary_payload[SUMMARY_WORKERS_KEY]
1229
+ }
1230
+ killed_payload = payload_by_role_name["killed-worker"]
1231
+ done_payload = payload_by_role_name["done-worker"]
1232
+
1233
+ assert exit_code == 1
1234
+ assert killed_payload[SUMMARY_CLASSIFICATION_KEY] == CLASSIFICATION_TIMEOUT
1235
+ assert killed_payload[SUMMARY_IS_OK_KEY] is False
1236
+ assert killed_payload[SUMMARY_CLASSIFICATION_KEY] != CLASSIFICATION_OK
1237
+ assert done_payload[SUMMARY_CLASSIFICATION_KEY] == CLASSIFICATION_OK
1238
+ assert done_payload[SUMMARY_IS_OK_KEY] is True
935
1239
 
936
1240
 
937
1241
  def test_unwritable_report_file_keeps_the_worker_outcome(
@@ -10,6 +10,7 @@ pytest suite for the Python scripts and Pester suite for the PowerShell scripts
10
10
  | `test_setup_project_paths_config.py` | Configuration constants used by `setup_project_paths.py` |
11
11
  | `test_sweep_empty_dirs.py` | `sweep_empty_dirs.py` — age check, one-shot mode, and continuous-watch behavior |
12
12
  | `test_sync_to_cursor.py` | `sync_to_cursor/` package — mapping, hashing, manifest, and path resolution |
13
+ | `test_grok_worker_constants.py` | `grok_worker_constants.py` — the accepted batch worker-key set stays in step with the worker key constants, and the unknown-key message names both its placeholders |
13
14
 
14
15
  ## PowerShell test files
15
16
 
@@ -0,0 +1,59 @@
1
+ """Specifications for the accepted-key set on a batch worker entry.
2
+
3
+ The batch launcher rejects a worker entry carrying any key outside
4
+ ``ALL_KNOWN_WORKER_SPEC_KEYS``. That makes the set a gate on the published
5
+ JSON contract: a worker key the module defines but the set omits is rejected
6
+ from every spec that uses it, and a member the module declares nowhere keeps
7
+ accepting a key the launcher drops. Both drifts are silent, so they are
8
+ pinned here.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dev_env_scripts_constants import grok_worker_constants
14
+
15
+ WORKER_SPEC_KEY_NAME_PREFIX: str = "WORKER_SPEC_"
16
+ WORKER_SPEC_KEY_NAME_SUFFIX: str = "_KEY"
17
+
18
+
19
+ def _all_declared_worker_key_values() -> set[str]:
20
+ """Read every ``WORKER_SPEC_*_KEY`` value the constants module declares."""
21
+ return {
22
+ getattr(grok_worker_constants, each_name)
23
+ for each_name in dir(grok_worker_constants)
24
+ if each_name.startswith(WORKER_SPEC_KEY_NAME_PREFIX)
25
+ and each_name.endswith(WORKER_SPEC_KEY_NAME_SUFFIX)
26
+ }
27
+
28
+
29
+ def test_should_accept_every_declared_worker_spec_key() -> None:
30
+ all_declared_key_values = _all_declared_worker_key_values()
31
+ all_missing_key_values = (
32
+ all_declared_key_values - grok_worker_constants.ALL_KNOWN_WORKER_SPEC_KEYS
33
+ )
34
+
35
+ assert not all_missing_key_values, (
36
+ "worker key constants missing from the accepted set: "
37
+ f"{sorted(all_missing_key_values)}"
38
+ )
39
+
40
+
41
+ def test_should_accept_only_keys_the_module_declares() -> None:
42
+ all_declared_key_values = _all_declared_worker_key_values()
43
+ all_stale_key_values = (
44
+ grok_worker_constants.ALL_KNOWN_WORKER_SPEC_KEYS - all_declared_key_values
45
+ )
46
+
47
+ assert not all_stale_key_values, (
48
+ f"accepted keys with no worker key constant: {sorted(all_stale_key_values)}"
49
+ )
50
+
51
+
52
+ def test_should_name_both_placeholders_in_the_unknown_key_message() -> None:
53
+ formatted_message = grok_worker_constants.UNKNOWN_WORKER_KEY_ERROR_TEMPLATE.format(
54
+ unknown_keys="timeout_second",
55
+ accepted_keys="timeout_seconds",
56
+ )
57
+
58
+ assert "timeout_second;" in formatted_message
59
+ assert formatted_message.endswith("timeout_seconds")
@@ -378,3 +378,49 @@ def test_main_headless_flavor_emits_outcome_path_on_stdout(
378
378
  assert "post_audit_thread.py" in captured.out
379
379
  assert "Do not post reviews" in captured.out
380
380
  assert "add_comment_to_pending_review" not in captured.out
381
+
382
+
383
+ _PACKAGE_ROOT = _SCRIPTS_DIR.parents[3]
384
+ _CODE_QUALITY_AGENT_PATH = _PACKAGE_ROOT / "agents" / "code-quality-agent.md"
385
+ _AUDIT_CONTRACT_PATH = _PACKAGE_ROOT / "_shared" / "pr-loop" / "audit-contract.md"
386
+ _PRECATCH_RUBRIC_PATH = _PACKAGE_ROOT / "_shared" / "pr-loop" / "precatch-rubric.md"
387
+ _CATEGORY_Q_LABEL = (
388
+ "Cross-surface claim consistency "
389
+ "(terminology, PR-description claims, message-vs-guard)"
390
+ )
391
+ _CATEGORY_Q_RUBRIC_REFERENCE = (
392
+ "../audit-rubrics/category_rubrics/category-q-cross-surface-claims.md"
393
+ )
394
+ _A_THROUGH_Q_PATTERN = re.compile(r"A[\u2013-]Q")
395
+ _SEVENTEEN_CATEGORIES_PATTERN = re.compile(r"seventeen categor", re.IGNORECASE)
396
+ _STALE_SIXTEEN_PATTERN = re.compile(r"sixteen categor", re.IGNORECASE)
397
+ _STALE_A_THROUGH_P_PATTERN = re.compile(r"A[\u2013-]P")
398
+
399
+
400
+ def test_code_quality_agent_default_scope_is_a_through_q() -> None:
401
+ agent_text = _CODE_QUALITY_AGENT_PATH.read_text(encoding="utf-8")
402
+ assert _A_THROUGH_Q_PATTERN.search(agent_text) is not None
403
+ assert _SEVENTEEN_CATEGORIES_PATTERN.search(agent_text) is not None
404
+ assert _STALE_SIXTEEN_PATTERN.search(agent_text) is None
405
+ assert "A through Q" in agent_text
406
+ assert "A through P" not in agent_text
407
+ assert _STALE_A_THROUGH_P_PATTERN.search(agent_text) is None
408
+ assert "## Bug Categories A–Q" in agent_text
409
+ assert _CATEGORY_Q_LABEL in agent_text
410
+ assert _CATEGORY_Q_RUBRIC_REFERENCE in agent_text
411
+ assert "| Q |" in agent_text
412
+
413
+
414
+ def test_audit_contract_category_schema_includes_q() -> None:
415
+ contract_text = _AUDIT_CONTRACT_PATH.read_text(encoding="utf-8")
416
+ assert '"category": "A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P | Q"' in (
417
+ contract_text
418
+ )
419
+ assert _A_THROUGH_Q_PATTERN.search(contract_text) is not None
420
+ assert _STALE_A_THROUGH_P_PATTERN.search(contract_text) is None
421
+
422
+
423
+ def test_precatch_rubric_adversarial_lane_uses_a_through_q() -> None:
424
+ rubric_text = _PRECATCH_RUBRIC_PATH.read_text(encoding="utf-8")
425
+ assert _A_THROUGH_Q_PATTERN.search(rubric_text) is not None
426
+ assert _STALE_A_THROUGH_P_PATTERN.search(rubric_text) is None