claude-dev-env 1.92.1 → 1.93.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 (214) hide show
  1. package/CLAUDE.md +6 -2
  2. package/_shared/CLAUDE.md +2 -1
  3. package/_shared/advisor/CLAUDE.md +21 -0
  4. package/_shared/advisor/advisor-protocol.md +142 -0
  5. package/_shared/advisor/scripts/config/advisor_scripts_constants/__init__.py +1 -0
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +86 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +246 -0
  8. package/_shared/advisor/scripts/pyproject.toml +3 -0
  9. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +302 -0
  10. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +180 -0
  11. package/_shared/advisor/scripts/tier_model_ids.py +164 -0
  12. package/_shared/pr-loop/CLAUDE.md +1 -0
  13. package/_shared/pr-loop/precatch-rubric.md +65 -0
  14. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  15. package/_shared/pr-loop/scripts/code_rules_gate.py +137 -5
  16. package/_shared/pr-loop/scripts/post_audit_thread.py +1 -1
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  18. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +16 -0
  19. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +2 -1
  20. package/_shared/pr-loop/scripts/reviewer_availability.py +42 -12
  21. package/_shared/pr-loop/scripts/reviews_disabled.py +65 -21
  22. package/_shared/pr-loop/scripts/terminology_sweep.py +69 -21
  23. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -1
  24. package/_shared/pr-loop/scripts/tests/fixtures/{copilot_internal_user_jonecho.json → copilot_internal_user_example.json} +1 -1
  25. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +1 -1
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -2
  27. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +8 -8
  28. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +18 -5
  29. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +56 -5
  30. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +104 -17
  31. package/agents/CLAUDE.md +3 -2
  32. package/agents/clasp-deployment-orchestrator.md +2 -2
  33. package/agents/code-advisor.md +7 -5
  34. package/agents/code-verifier.md +6 -2
  35. package/agents/session-advisor.md +27 -0
  36. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  37. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -1
  38. package/audit-rubrics/prompts/category-a-api-contracts.md +2 -2
  39. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  40. package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
  41. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
  42. package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
  43. package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
  44. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
  45. package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
  46. package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
  47. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
  48. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
  49. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +2 -2
  50. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +2 -2
  51. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +2 -2
  52. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
  53. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +2 -2
  54. package/bin/CLAUDE.md +1 -0
  55. package/bin/expand_home_directory_tokens.mjs +75 -0
  56. package/bin/install.mjs +21 -7
  57. package/bin/install.test.mjs +220 -10
  58. package/hooks/CLAUDE.md +1 -1
  59. package/hooks/blocking/CLAUDE.md +4 -1
  60. package/hooks/blocking/_path_setup.py +13 -0
  61. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +281 -0
  62. package/hooks/blocking/code_rules_dead_module_constant.py +88 -16
  63. package/hooks/blocking/code_rules_enforcer.py +6 -0
  64. package/hooks/blocking/code_rules_imports_logging.py +34 -1
  65. package/hooks/blocking/code_rules_paths_syspath.py +1 -1
  66. package/hooks/blocking/code_rules_test_layout.py +272 -0
  67. package/hooks/blocking/nas_ssh_binary_enforcer.py +22 -12
  68. package/hooks/blocking/pii_prevention_blocker.py +781 -0
  69. package/hooks/blocking/pii_scanner.py +340 -0
  70. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  71. package/hooks/blocking/stop_dispatcher.py +84 -0
  72. package/hooks/blocking/test__path_setup.py +39 -0
  73. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +61 -0
  74. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +294 -0
  75. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +1 -1
  76. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +51 -0
  77. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +103 -0
  78. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +1 -1
  79. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +1 -1
  80. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +11 -11
  81. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +1 -1
  82. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +1 -1
  83. package/hooks/blocking/test_code_rules_enforcer_test_layout.py +111 -0
  84. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +76 -0
  85. package/hooks/blocking/test_destructive_command_blocker.py +29 -9
  86. package/hooks/blocking/test_gh_body_arg_blocker.py +3 -3
  87. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +96 -39
  88. package/hooks/blocking/test_pii_prevention_blocker.py +905 -0
  89. package/hooks/blocking/test_pii_prevention_windows_git_path.py +41 -0
  90. package/hooks/blocking/test_pii_scanner.py +165 -0
  91. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +1 -1
  92. package/hooks/blocking/test_pre_tool_use_dispatcher.py +14 -13
  93. package/hooks/blocking/test_stop_dispatcher.py +187 -0
  94. package/hooks/blocking/test_verdict_directory_write_blocker.py +1 -1
  95. package/hooks/blocking/test_volatile_path_in_post_blocker.py +3 -3
  96. package/hooks/blocking/volatile_path_in_post_blocker.py +54 -5
  97. package/hooks/diagnostic/migrations/README.md +25 -26
  98. package/hooks/diagnostic/test_hook_log_extractor.py +10 -10
  99. package/hooks/hooks.json +10 -120
  100. package/hooks/hooks_constants/CLAUDE.md +12 -3
  101. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +78 -0
  102. package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
  103. package/hooks/hooks_constants/hosted_hook_runner.py +73 -0
  104. package/hooks/hooks_constants/local_identity.py +182 -0
  105. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +4 -37
  106. package/hooks/hooks_constants/pii_prevention_constants.py +299 -0
  107. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  108. package/hooks/hooks_constants/python_style_checks_constants.py +15 -0
  109. package/hooks/hooks_constants/stop_dispatcher_constants.py +26 -0
  110. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +87 -0
  111. package/hooks/hooks_constants/test_hardcoded_user_path_constants.py +2 -2
  112. package/hooks/hooks_constants/test_hosted_hook_runner.py +101 -0
  113. package/hooks/hooks_constants/test_layout_constants.py +28 -0
  114. package/hooks/hooks_constants/test_local_identity.py +88 -0
  115. package/hooks/hooks_constants/test_session_env_cleanup_constants.py +1 -1
  116. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +32 -0
  117. package/hooks/validators/README.md +5 -6
  118. package/hooks/validators/__init__.py +2 -2
  119. package/hooks/validators/python_style_checks.py +298 -243
  120. package/hooks/validators/run_all_validators.py +6 -0
  121. package/hooks/validators/test_python_style_checks.py +278 -163
  122. package/package.json +2 -2
  123. package/rules/CLAUDE.md +2 -1
  124. package/rules/bdd.md +1 -1
  125. package/rules/nas-ssh-invocation.md +6 -4
  126. package/rules/no-justification-noise.md +61 -0
  127. package/scripts/test_setup_project_paths.py +1 -1
  128. package/skills/CLAUDE.md +6 -3
  129. package/skills/_shared/pr-loop/scripts/_path_resolver.py +2 -2
  130. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  131. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  132. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +42 -3
  133. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +11 -11
  134. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +26 -3
  135. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +49 -25
  136. package/skills/autoconverge/CLAUDE.md +3 -3
  137. package/skills/autoconverge/SKILL.md +113 -35
  138. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  139. package/skills/autoconverge/reference/convergence.md +44 -22
  140. package/skills/autoconverge/reference/gotchas.md +11 -0
  141. package/skills/autoconverge/reference/stop-conditions.md +23 -13
  142. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  143. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +15 -19
  144. package/skills/autoconverge/workflow/converge.contract.test.mjs +43 -42
  145. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +141 -16
  146. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +2 -2
  147. package/skills/autoconverge/workflow/converge.mjs +329 -68
  148. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +1 -1
  149. package/skills/autoconverge/workflow/converge.precatch.test.mjs +152 -0
  150. package/skills/autoconverge/workflow/converge.run-input.test.mjs +3 -3
  151. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +15 -15
  152. package/skills/autoconverge/workflow/test_convergence_summary.py +6 -6
  153. package/skills/bdd-protocol/SKILL.md +4 -5
  154. package/skills/bdd-protocol/references/anti-patterns.md +1 -1
  155. package/skills/bugteam/SKILL.md +8 -0
  156. package/skills/bugteam/reference/copilot-gap-analysis.md +20 -20
  157. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
  158. package/skills/bugteam/reference/team-setup.md +7 -5
  159. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  160. package/skills/copilot-finding-triage/SKILL.md +124 -0
  161. package/skills/copilot-finding-triage/reference/tier-rubric.md +49 -0
  162. package/skills/copilot-finding-triage/scripts/conftest.py +8 -0
  163. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/__init__.py +0 -0
  164. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/__init__.py +0 -0
  165. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/notify_ntfy_constants.py +32 -0
  166. package/skills/copilot-finding-triage/scripts/notify_ntfy.py +192 -0
  167. package/skills/copilot-finding-triage/scripts/test_notify_ntfy.py +141 -0
  168. package/skills/copilot-finding-triage/templates/notification.md +34 -0
  169. package/skills/copilot-review/SKILL.md +8 -6
  170. package/skills/findbugs/SKILL.md +4 -0
  171. package/skills/fixbugs/SKILL.md +8 -7
  172. package/skills/gotcha/CLAUDE.md +2 -2
  173. package/skills/gotcha/SKILL.md +4 -4
  174. package/skills/log-audit/SKILL.md +4 -6
  175. package/skills/monitor-open-prs/CLAUDE.md +1 -1
  176. package/skills/monitor-open-prs/SKILL.md +6 -1
  177. package/skills/orchestrator/SKILL.md +74 -78
  178. package/skills/orchestrator-refresh/SKILL.md +24 -12
  179. package/skills/post-audit-findings/SKILL.md +5 -9
  180. package/skills/pr-consistency-audit/SKILL.md +5 -1
  181. package/skills/pr-converge/CLAUDE.md +1 -1
  182. package/skills/pr-converge/SKILL.md +86 -47
  183. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  184. package/skills/pr-converge/reference/convergence-gates.md +20 -17
  185. package/skills/pr-converge/reference/examples.md +63 -47
  186. package/skills/pr-converge/reference/fix-protocol.md +15 -15
  187. package/skills/pr-converge/reference/ground-rules.md +11 -7
  188. package/skills/pr-converge/reference/multi-pr-orchestration.md +6 -5
  189. package/skills/pr-converge/reference/per-tick.md +129 -107
  190. package/skills/pr-converge/reference/state-schema.md +15 -10
  191. package/skills/pr-converge/scripts/check_convergence.py +5 -4
  192. package/skills/pr-converge/scripts/test_check_convergence.py +6 -4
  193. package/skills/pr-fix-protocol/SKILL.md +3 -8
  194. package/skills/pr-loop-cloud-transport/SKILL.md +102 -0
  195. package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +40 -0
  196. package/skills/pr-loop-cloud-transport/reference/substitution-matrix.md +48 -0
  197. package/skills/pr-loop-lifecycle/SKILL.md +5 -9
  198. package/skills/pr-scope-resolve/SKILL.md +3 -6
  199. package/skills/privacy-hygiene/SKILL.md +114 -0
  200. package/skills/qbug/SKILL.md +8 -8
  201. package/skills/rebase/SKILL.md +5 -1
  202. package/skills/refine/SKILL.md +4 -5
  203. package/skills/reviewer-gates/SKILL.md +7 -11
  204. package/skills/session-log/SKILL.md +4 -1
  205. package/skills/skill-builder/SKILL.md +3 -6
  206. package/skills/structure-prompt/SKILL.md +4 -5
  207. package/skills/team-advisor/SKILL.md +56 -0
  208. package/skills/test_markdown_link_integrity.py +10 -6
  209. package/skills/update/SKILL.md +5 -1
  210. package/skills/usage-pause/SKILL.md +14 -5
  211. package/skills/usage-pause/scripts/resolve_usage_window.py +83 -5
  212. package/skills/usage-pause/scripts/test_resolve_usage_window.py +185 -17
  213. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +4 -3
  214. package/skills/verified-build/SKILL.md +4 -9
@@ -0,0 +1,302 @@
1
+ """Behavioral tests for the model-tier spawn-walk log validator."""
2
+
3
+ import importlib.util
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from types import ModuleType
8
+
9
+ import pytest
10
+
11
+
12
+ def _load_validator_module() -> ModuleType:
13
+ scripts_root = Path(__file__).parent.parent
14
+ constants_root = scripts_root / "config"
15
+ sys.path.insert(0, str(constants_root))
16
+ module_path = scripts_root / "model_tier_run_validator.py"
17
+ specification = importlib.util.spec_from_file_location(
18
+ "model_tier_run_validator", module_path
19
+ )
20
+ assert specification is not None
21
+ assert specification.loader is not None
22
+ module = importlib.util.module_from_spec(specification)
23
+ sys.modules[specification.name] = module
24
+ specification.loader.exec_module(module)
25
+ return module
26
+
27
+
28
+ model_tier_run_validator = _load_validator_module()
29
+ ModelTierRun = model_tier_run_validator.ModelTierRun
30
+ ModelTierRunError = model_tier_run_validator.ModelTierRunError
31
+ validate_model_tier_run = model_tier_run_validator.validate_model_tier_run
32
+ main = model_tier_run_validator.main
33
+ load_model_tier_run_from_json_path = (
34
+ model_tier_run_validator.load_model_tier_run_from_json_path
35
+ )
36
+
37
+
38
+ def test_clean_single_spawn_at_top_of_slice_passes() -> None:
39
+ run = ModelTierRun(
40
+ own_tier="Opus",
41
+ candidate_tiers=["Fable", "Opus"],
42
+ attempts=[{"tier": "Fable", "result": "spawned"}],
43
+ selected_tier="Fable",
44
+ )
45
+ assert validate_model_tier_run(run) is None
46
+
47
+
48
+ def test_fallthrough_to_floor_tier_passes() -> None:
49
+ run = ModelTierRun(
50
+ own_tier="Opus",
51
+ candidate_tiers=["Fable", "Opus"],
52
+ attempts=[
53
+ {"tier": "Fable", "result": "unavailable"},
54
+ {"tier": "Opus", "result": "spawned"},
55
+ ],
56
+ selected_tier="Opus",
57
+ )
58
+ assert validate_model_tier_run(run) is None
59
+
60
+
61
+ def test_fully_exhausted_walk_with_fallback_reason_passes() -> None:
62
+ run = ModelTierRun(
63
+ own_tier="Opus",
64
+ candidate_tiers=["Fable", "Opus"],
65
+ attempts=[
66
+ {"tier": "Fable", "result": "unavailable"},
67
+ {"tier": "Opus", "result": "unavailable"},
68
+ ],
69
+ selected_tier=None,
70
+ fallback_reason="every candidate tier failed; CLI fallback took over",
71
+ )
72
+ assert validate_model_tier_run(run) is None
73
+
74
+
75
+ def test_candidate_tiers_shorter_than_ladder_slice_raises() -> None:
76
+ run = ModelTierRun(
77
+ own_tier="Opus",
78
+ candidate_tiers=["Fable"],
79
+ attempts=[{"tier": "Fable", "result": "spawned"}],
80
+ selected_tier="Fable",
81
+ )
82
+ with pytest.raises(ModelTierRunError):
83
+ validate_model_tier_run(run)
84
+
85
+
86
+ def test_attempt_tier_outside_candidate_slice_raises() -> None:
87
+ run = ModelTierRun(
88
+ own_tier="Opus",
89
+ candidate_tiers=["Fable", "Opus"],
90
+ attempts=[{"tier": "Haiku", "result": "spawned"}],
91
+ selected_tier="Haiku",
92
+ )
93
+ with pytest.raises(ModelTierRunError):
94
+ validate_model_tier_run(run)
95
+
96
+
97
+ def test_attempts_out_of_ladder_order_raises() -> None:
98
+ run = ModelTierRun(
99
+ own_tier="Opus",
100
+ candidate_tiers=["Fable", "Opus"],
101
+ attempts=[
102
+ {"tier": "Opus", "result": "unavailable"},
103
+ {"tier": "Fable", "result": "spawned"},
104
+ ],
105
+ selected_tier="Fable",
106
+ )
107
+ with pytest.raises(ModelTierRunError):
108
+ validate_model_tier_run(run)
109
+
110
+
111
+ def test_selected_tier_not_first_spawned_attempt_raises() -> None:
112
+ run = ModelTierRun(
113
+ own_tier="Opus",
114
+ candidate_tiers=["Fable", "Opus"],
115
+ attempts=[
116
+ {"tier": "Fable", "result": "unavailable"},
117
+ {"tier": "Opus", "result": "spawned"},
118
+ ],
119
+ selected_tier="Fable",
120
+ )
121
+ with pytest.raises(ModelTierRunError):
122
+ validate_model_tier_run(run)
123
+
124
+
125
+ def test_exhausted_walk_with_non_null_selected_tier_raises() -> None:
126
+ run = ModelTierRun(
127
+ own_tier="Opus",
128
+ candidate_tiers=["Fable", "Opus"],
129
+ attempts=[
130
+ {"tier": "Fable", "result": "unavailable"},
131
+ {"tier": "Opus", "result": "unavailable"},
132
+ ],
133
+ selected_tier="Opus",
134
+ fallback_reason="every candidate tier failed",
135
+ )
136
+ with pytest.raises(ModelTierRunError):
137
+ validate_model_tier_run(run)
138
+
139
+
140
+ def test_exhausted_walk_missing_fallback_reason_raises() -> None:
141
+ run = ModelTierRun(
142
+ own_tier="Opus",
143
+ candidate_tiers=["Fable", "Opus"],
144
+ attempts=[
145
+ {"tier": "Fable", "result": "unavailable"},
146
+ {"tier": "Opus", "result": "unavailable"},
147
+ ],
148
+ selected_tier=None,
149
+ )
150
+ with pytest.raises(ModelTierRunError):
151
+ validate_model_tier_run(run)
152
+
153
+
154
+ def test_unknown_own_tier_raises() -> None:
155
+ run = ModelTierRun(
156
+ own_tier="Titan",
157
+ candidate_tiers=["Titan"],
158
+ attempts=[{"tier": "Titan", "result": "spawned"}],
159
+ selected_tier="Titan",
160
+ )
161
+ with pytest.raises(ModelTierRunError):
162
+ validate_model_tier_run(run)
163
+
164
+
165
+ def test_empty_attempts_with_null_selected_tier_raises() -> None:
166
+ run = ModelTierRun(
167
+ own_tier="Opus",
168
+ candidate_tiers=["Fable", "Opus"],
169
+ attempts=[],
170
+ selected_tier=None,
171
+ fallback_reason="skipped straight to CLI fallback",
172
+ )
173
+ with pytest.raises(ModelTierRunError):
174
+ validate_model_tier_run(run)
175
+
176
+
177
+ def test_incomplete_fallback_walk_before_floor_raises() -> None:
178
+ run = ModelTierRun(
179
+ own_tier="Opus",
180
+ candidate_tiers=["Fable", "Opus"],
181
+ attempts=[{"tier": "Fable", "result": "unavailable"}],
182
+ selected_tier=None,
183
+ fallback_reason="stopped after Fable without trying Opus",
184
+ )
185
+ with pytest.raises(ModelTierRunError):
186
+ validate_model_tier_run(run)
187
+
188
+
189
+ def test_lowercase_own_tier_and_candidates_pass() -> None:
190
+ run = ModelTierRun(
191
+ own_tier="opus",
192
+ candidate_tiers=["fable", "opus"],
193
+ attempts=[
194
+ {"tier": "fable", "result": "unavailable"},
195
+ {"tier": "opus", "result": "spawned"},
196
+ ],
197
+ selected_tier="opus",
198
+ )
199
+ assert validate_model_tier_run(run) is None
200
+
201
+
202
+ def test_cli_validates_json_log_file(tmp_path: Path) -> None:
203
+ log_path = tmp_path / "model-tier-run.json"
204
+ log_path.write_text(
205
+ json.dumps(
206
+ {
207
+ "own_tier": "Opus",
208
+ "candidate_tiers": ["Fable", "Opus"],
209
+ "attempts": [{"tier": "Fable", "result": "spawned"}],
210
+ "selected_tier": "Fable",
211
+ }
212
+ ),
213
+ encoding="utf-8",
214
+ )
215
+ assert main([str(log_path)]) == 0
216
+ loaded_run = load_model_tier_run_from_json_path(from_path=log_path)
217
+ assert loaded_run.selected_tier == "Fable"
218
+
219
+
220
+ def test_cli_rejects_incomplete_fallback_log(tmp_path: Path) -> None:
221
+ log_path = tmp_path / "incomplete-walk.json"
222
+ log_path.write_text(
223
+ json.dumps(
224
+ {
225
+ "own_tier": "Opus",
226
+ "candidate_tiers": ["Fable", "Opus"],
227
+ "attempts": [{"tier": "Fable", "result": "unavailable"}],
228
+ "selected_tier": None,
229
+ "fallback_reason": "incomplete",
230
+ }
231
+ ),
232
+ encoding="utf-8",
233
+ )
234
+ assert main([str(log_path)]) == 1
235
+
236
+
237
+ def test_cli_missing_path_returns_usage_exit_code() -> None:
238
+ assert main([]) == 2
239
+
240
+
241
+ def test_grok_host_single_tier_self_bind_passes() -> None:
242
+ run = ModelTierRun(
243
+ own_tier="Grok",
244
+ candidate_tiers=["Grok"],
245
+ attempts=[{"tier": "Grok", "result": "self"}],
246
+ selected_tier="Grok",
247
+ )
248
+ assert validate_model_tier_run(run) is None
249
+
250
+
251
+ def test_grok_host_lowercase_self_bind_passes() -> None:
252
+ run = ModelTierRun(
253
+ own_tier="grok",
254
+ candidate_tiers=["grok"],
255
+ attempts=[{"tier": "grok", "result": "self"}],
256
+ selected_tier="grok",
257
+ )
258
+ assert validate_model_tier_run(run) is None
259
+
260
+
261
+ def test_grok_host_multi_tier_candidate_list_raises() -> None:
262
+ run = ModelTierRun(
263
+ own_tier="Grok",
264
+ candidate_tiers=["Fable", "Opus", "Grok"],
265
+ attempts=[{"tier": "Grok", "result": "self"}],
266
+ selected_tier="Grok",
267
+ )
268
+ with pytest.raises(ModelTierRunError):
269
+ validate_model_tier_run(run)
270
+
271
+
272
+ def test_grok_host_spawned_token_is_not_self_bind_raises() -> None:
273
+ run = ModelTierRun(
274
+ own_tier="Grok",
275
+ candidate_tiers=["Grok"],
276
+ attempts=[{"tier": "Grok", "result": "spawned"}],
277
+ selected_tier="Grok",
278
+ )
279
+ with pytest.raises(ModelTierRunError):
280
+ validate_model_tier_run(run)
281
+
282
+
283
+ def test_grok_host_selected_tier_mismatch_raises() -> None:
284
+ run = ModelTierRun(
285
+ own_tier="Grok",
286
+ candidate_tiers=["Grok"],
287
+ attempts=[{"tier": "Grok", "result": "self"}],
288
+ selected_tier="Opus",
289
+ )
290
+ with pytest.raises(ModelTierRunError):
291
+ validate_model_tier_run(run)
292
+
293
+
294
+ def test_claude_host_self_token_is_not_spawn_success_raises() -> None:
295
+ run = ModelTierRun(
296
+ own_tier="Opus",
297
+ candidate_tiers=["Fable", "Opus"],
298
+ attempts=[{"tier": "Fable", "result": "self"}],
299
+ selected_tier="Fable",
300
+ )
301
+ with pytest.raises(ModelTierRunError):
302
+ validate_model_tier_run(run)
@@ -0,0 +1,180 @@
1
+ """Behavioral tests for ladder-tier to CLI model-alias resolution."""
2
+
3
+ import importlib.util
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+ from types import ModuleType
9
+
10
+ import pytest
11
+
12
+
13
+ def _load_tier_model_ids_module() -> ModuleType:
14
+ scripts_root = Path(__file__).parent.parent
15
+ module_path = scripts_root / "tier_model_ids.py"
16
+ specification = importlib.util.spec_from_file_location(
17
+ "tier_model_ids", module_path
18
+ )
19
+ assert specification is not None
20
+ assert specification.loader is not None
21
+ module = importlib.util.module_from_spec(specification)
22
+ sys.modules[specification.name] = module
23
+ specification.loader.exec_module(module)
24
+ return module
25
+
26
+
27
+ tier_model_ids = _load_tier_model_ids_module()
28
+ resolve_cli_model_id = tier_model_ids.resolve_cli_model_id
29
+ canonical_tier_name = tier_model_ids.canonical_tier_name
30
+ detect_host_profile = tier_model_ids.detect_host_profile
31
+ constants_root = Path(__file__).parent.parent / "config"
32
+ if str(constants_root) not in sys.path:
33
+ sys.path.insert(0, str(constants_root))
34
+
35
+ from advisor_scripts_constants.model_tier_run_validator_constants import ( # noqa: E402
36
+ ADVISOR_SENDMESSAGE_REPLY_WAIT_SECONDS,
37
+ ALL_CLI_MODEL_ID_BY_TIER,
38
+ ALL_KNOWN_TIER_NAMES,
39
+ ALL_MODEL_TIERS,
40
+ GROK_MODEL_TIER,
41
+ HOST_PROFILE_CLAUDE,
42
+ HOST_PROFILE_GROK,
43
+ )
44
+
45
+ SCRIPTS_ROOT = Path(__file__).parent.parent
46
+ DOCUMENTED_RESOLVE_ONE_LINER = (
47
+ "from tier_model_ids import resolve_cli_model_id; "
48
+ "print(resolve_cli_model_id('Opus'))"
49
+ )
50
+
51
+
52
+ @pytest.mark.parametrize(
53
+ ("tier_name", "expected_model_alias"),
54
+ [
55
+ ("Fable", "fable"),
56
+ ("Opus", "opus"),
57
+ ("Sonnet", "sonnet"),
58
+ ("Haiku", "haiku"),
59
+ ("Grok", "grok"),
60
+ ("fable", "fable"),
61
+ ("OPUS", "opus"),
62
+ ("sonnet", "sonnet"),
63
+ ("hAiKu", "haiku"),
64
+ ("grok", "grok"),
65
+ (" GROK ", "grok"),
66
+ (" Opus ", "opus"),
67
+ ("\thaiku\n", "haiku"),
68
+ ],
69
+ )
70
+ def test_resolve_cli_model_id_maps_known_tiers(
71
+ tier_name: str,
72
+ expected_model_alias: str,
73
+ ) -> None:
74
+ assert resolve_cli_model_id(tier_name) == expected_model_alias
75
+
76
+
77
+ def test_resolve_cli_model_id_rejects_unknown_tier() -> None:
78
+ with pytest.raises(ValueError, match="not a known model tier"):
79
+ resolve_cli_model_id("Titan")
80
+
81
+
82
+ def test_resolve_cli_model_id_rejects_empty_string() -> None:
83
+ with pytest.raises(ValueError, match="not a known model tier"):
84
+ resolve_cli_model_id("")
85
+
86
+
87
+ def test_resolve_cli_model_id_rejects_whitespace_only() -> None:
88
+ with pytest.raises(ValueError, match="not a known model tier"):
89
+ resolve_cli_model_id(" ")
90
+
91
+
92
+ def test_sendmessage_reply_wait_is_positive_bound() -> None:
93
+ assert ADVISOR_SENDMESSAGE_REPLY_WAIT_SECONDS > 0
94
+ assert ADVISOR_SENDMESSAGE_REPLY_WAIT_SECONDS == 120
95
+
96
+
97
+ def test_cli_model_alias_map_keys_match_known_tiers() -> None:
98
+ assert set(ALL_CLI_MODEL_ID_BY_TIER) == set(ALL_KNOWN_TIER_NAMES)
99
+ assert set(ALL_MODEL_TIERS).issubset(set(ALL_KNOWN_TIER_NAMES))
100
+ assert GROK_MODEL_TIER in ALL_KNOWN_TIER_NAMES
101
+ assert GROK_MODEL_TIER not in ALL_MODEL_TIERS
102
+ assert all(
103
+ ALL_CLI_MODEL_ID_BY_TIER[each_tier] for each_tier in ALL_KNOWN_TIER_NAMES
104
+ )
105
+
106
+
107
+ def test_canonical_tier_name_strips_and_normalizes() -> None:
108
+ assert canonical_tier_name(" opus ") == "Opus"
109
+ assert canonical_tier_name("grok") == "Grok"
110
+ assert canonical_tier_name("") is None
111
+ assert canonical_tier_name("Titan") is None
112
+
113
+
114
+ def test_detect_host_profile_defaults_to_claude() -> None:
115
+ assert detect_host_profile(setting_by_name={}) == HOST_PROFILE_CLAUDE
116
+
117
+
118
+ @pytest.mark.parametrize(
119
+ "truthy_value",
120
+ ["1", "true", "TRUE", "yes", "YES", "on", "On"],
121
+ )
122
+ def test_detect_host_profile_reads_grok_build_truthy_values(
123
+ truthy_value: str,
124
+ ) -> None:
125
+ assert (
126
+ detect_host_profile(setting_by_name={"GROK_BUILD": truthy_value})
127
+ == HOST_PROFILE_GROK
128
+ )
129
+
130
+
131
+ def test_detect_host_profile_reads_grok_build_flag() -> None:
132
+ assert (
133
+ detect_host_profile(setting_by_name={"GROK_BUILD": "0"})
134
+ == HOST_PROFILE_CLAUDE
135
+ )
136
+ assert (
137
+ detect_host_profile(setting_by_name={"GROK_BUILD": "false"})
138
+ == HOST_PROFILE_CLAUDE
139
+ )
140
+
141
+
142
+ def test_detect_host_profile_reads_explicit_override() -> None:
143
+ assert (
144
+ detect_host_profile(setting_by_name={"ADVISOR_HOST_PROFILE": "Grok"})
145
+ == HOST_PROFILE_GROK
146
+ )
147
+ assert (
148
+ detect_host_profile(setting_by_name={"ADVISOR_HOST_PROFILE": "claude"})
149
+ == HOST_PROFILE_CLAUDE
150
+ )
151
+ assert (
152
+ detect_host_profile(
153
+ setting_by_name={"ADVISOR_HOST_PROFILE": "Claude", "GROK_BUILD": "1"}
154
+ )
155
+ == HOST_PROFILE_CLAUDE
156
+ )
157
+
158
+
159
+ def test_detect_host_profile_rejects_unknown_explicit_value() -> None:
160
+ with pytest.raises(ValueError, match="not a known profile"):
161
+ detect_host_profile(setting_by_name={"ADVISOR_HOST_PROFILE": "Titan"})
162
+
163
+
164
+ def test_documented_resolve_one_liner_runs_without_prior_path_pollution() -> None:
165
+ clean_environment = {
166
+ each_key: each_value
167
+ for each_key, each_value in os.environ.items()
168
+ if each_key.upper() != "PYTHONPATH"
169
+ }
170
+ clean_environment["PYTHONPATH"] = ""
171
+ completed_process = subprocess.run(
172
+ [sys.executable, "-c", DOCUMENTED_RESOLVE_ONE_LINER],
173
+ cwd=str(SCRIPTS_ROOT),
174
+ capture_output=True,
175
+ text=True,
176
+ env=clean_environment,
177
+ check=False,
178
+ )
179
+ assert completed_process.returncode == 0, completed_process.stderr
180
+ assert completed_process.stdout.strip() == "opus"
@@ -0,0 +1,164 @@
1
+ """Map ladder tier names to Claude Code CLI / Agent short model aliases.
2
+
3
+ ::
4
+
5
+ resolve_cli_model_id("Opus") # ok: "opus"
6
+ resolve_cli_model_id("fable") # ok: "fable" (any letter case)
7
+ resolve_cli_model_id("Grok") # ok: "grok" (Grok host single tier)
8
+ resolve_cli_model_id(" Opus ") # ok: "opus" (leading/trailing whitespace)
9
+ resolve_cli_model_id("Titan") # flag: ValueError
10
+
11
+ detect_host_profile(setting_by_name={"GROK_BUILD": "1"}) # ok: "Grok"
12
+ detect_host_profile(
13
+ setting_by_name={"ADVISOR_HOST_PROFILE": "Claude"}
14
+ ) # ok: "Claude"
15
+
16
+ Values are stable short aliases (``opus``, ``sonnet``, ``grok``), not dated
17
+ full model IDs such as ``claude-opus-4-…``. The map lives in
18
+ ``advisor_scripts_constants`` so protocol text, skills, and tests share one
19
+ source of truth for the aliases the Agent tool ``model:`` field and CLI
20
+ ``--model`` flag already accept.
21
+
22
+ Args and returns for the public helpers are documented on each function.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import sys
29
+ from collections.abc import Mapping
30
+ from pathlib import Path
31
+
32
+ _config_directory = str(Path(__file__).resolve().parent / "config")
33
+ if _config_directory not in sys.path:
34
+ sys.path.insert(0, _config_directory)
35
+
36
+ from advisor_scripts_constants.model_tier_run_validator_constants import ( # noqa: E402
37
+ ALL_CLI_MODEL_ID_BY_TIER,
38
+ ALL_GROK_BUILD_TRUTHY_VALUES,
39
+ ALL_HOST_PROFILES,
40
+ ALL_KNOWN_TIER_NAMES,
41
+ GROK_BUILD_ENV_VAR,
42
+ HOST_PROFILE_CLAUDE,
43
+ HOST_PROFILE_ENV_VAR,
44
+ HOST_PROFILE_GROK,
45
+ UNKNOWN_HOST_PROFILE_ERROR,
46
+ UNKNOWN_LADDER_NAME_ERROR,
47
+ )
48
+
49
+
50
+ def canonical_tier_name(tier_name: str) -> str | None:
51
+ """Return the Title Case ladder name for ``tier_name``, or ``None``.
52
+
53
+ ::
54
+
55
+ canonical_tier_name("opus") # ok: "Opus"
56
+ canonical_tier_name("grok") # ok: "Grok"
57
+ canonical_tier_name(" Opus ") # ok: "Opus"
58
+ canonical_tier_name("") # ok: None
59
+ canonical_tier_name("Titan") # ok: None
60
+
61
+ Strips leading and trailing whitespace, then matches any letter case
62
+ against ``ALL_KNOWN_TIER_NAMES`` (Claude ladder plus the Grok host tier).
63
+
64
+ Args:
65
+ tier_name: Raw tier text from a spawn log, protocol walk, or caller.
66
+
67
+ Returns:
68
+ The canonical Title Case ladder name, or ``None`` when unknown.
69
+ """
70
+ stripped_tier_name = tier_name.strip()
71
+ if not stripped_tier_name:
72
+ return None
73
+ tier_by_lower_name = {
74
+ each_tier.lower(): each_tier for each_tier in ALL_KNOWN_TIER_NAMES
75
+ }
76
+ return tier_by_lower_name.get(stripped_tier_name.lower())
77
+
78
+
79
+ def resolve_cli_model_id(tier: str) -> str:
80
+ """Return the CLI / Agent short model alias for a ladder tier name.
81
+
82
+ ::
83
+
84
+ resolve_cli_model_id("Sonnet") # ok: "sonnet"
85
+ resolve_cli_model_id("HAIKU") # ok: "haiku"
86
+ resolve_cli_model_id("Grok") # ok: "grok"
87
+ resolve_cli_model_id(" Opus ") # ok: "opus"
88
+ resolve_cli_model_id("Titan") # flag: ValueError
89
+
90
+ Accepts any letter case and surrounding whitespace. The returned string is
91
+ the short alias used by the Agent tool ``model:`` field and the CLI
92
+ ``--model`` flag — not a dated full model ID.
93
+
94
+ Args:
95
+ tier: Ladder tier name (``Fable``, ``Opus``, ``Sonnet``, ``Haiku``,
96
+ or ``Grok``).
97
+
98
+ Returns:
99
+ The short model alias for that tier (for example ``"opus"``).
100
+
101
+ Raises:
102
+ ValueError: When ``tier`` is not a known ladder tier, or the alias map
103
+ is missing that ladder name.
104
+ """
105
+ maybe_canonical_tier = canonical_tier_name(tier)
106
+ if maybe_canonical_tier is None:
107
+ raise ValueError(UNKNOWN_LADDER_NAME_ERROR.format(tier))
108
+ maybe_model_alias = ALL_CLI_MODEL_ID_BY_TIER.get(maybe_canonical_tier)
109
+ if maybe_model_alias is None:
110
+ raise ValueError(UNKNOWN_LADDER_NAME_ERROR.format(tier))
111
+ return maybe_model_alias
112
+
113
+
114
+ def detect_host_profile(
115
+ setting_by_name: Mapping[str, str] | None = None,
116
+ ) -> str:
117
+ """Return the advisor host profile from environment detection hints.
118
+
119
+ ::
120
+
121
+ detect_host_profile(setting_by_name={"ADVISOR_HOST_PROFILE": "Grok"})
122
+ # ok: "Grok"
123
+ detect_host_profile(setting_by_name={"GROK_BUILD": "1"}) # ok: "Grok"
124
+ detect_host_profile(setting_by_name={}) # ok: "Claude"
125
+ detect_host_profile(setting_by_name={"ADVISOR_HOST_PROFILE": "X"})
126
+ # flag: ValueError
127
+
128
+ Order:
129
+
130
+ 1. ``ADVISOR_HOST_PROFILE`` when set (must be a known profile name).
131
+ 2. ``GROK_BUILD`` when truthy (``1``, ``true``, ``yes``, ``on``).
132
+ 3. Default ``Claude``.
133
+
134
+ Args:
135
+ setting_by_name: Env name → setting text (defaults to ``os.environ``).
136
+
137
+ Returns:
138
+ ``HOST_PROFILE_GROK`` or ``HOST_PROFILE_CLAUDE``.
139
+
140
+ Raises:
141
+ ValueError: When ``ADVISOR_HOST_PROFILE`` is set to an unknown name.
142
+ """
143
+ resolved_setting_by_name: Mapping[str, str] = (
144
+ os.environ if setting_by_name is None else setting_by_name
145
+ )
146
+ explicit_host_profile = resolved_setting_by_name.get(
147
+ HOST_PROFILE_ENV_VAR, ""
148
+ ).strip()
149
+ if explicit_host_profile:
150
+ host_profile_by_lower_name = {
151
+ each_profile.lower(): each_profile for each_profile in ALL_HOST_PROFILES
152
+ }
153
+ maybe_canonical_host = host_profile_by_lower_name.get(
154
+ explicit_host_profile.lower()
155
+ )
156
+ if maybe_canonical_host is None:
157
+ raise ValueError(UNKNOWN_HOST_PROFILE_ERROR.format(explicit_host_profile))
158
+ return maybe_canonical_host
159
+ raw_harness_marker = resolved_setting_by_name.get(
160
+ GROK_BUILD_ENV_VAR, ""
161
+ ).strip().lower()
162
+ if raw_harness_marker in ALL_GROK_BUILD_TRUTHY_VALUES:
163
+ return HOST_PROFILE_GROK
164
+ return HOST_PROFILE_CLAUDE
@@ -12,6 +12,7 @@ Runtime documents and scripts shared by every PR-loop skill. Changes here affect
12
12
  | `gh-payloads.md` | How to build GitHub review and reply payloads via MCP tools; describes the one-review-per-loop pattern |
13
13
  | `state-schema.md` | Fields each PR-loop workflow tracks across iterations; documents common fields and per-skill extensions |
14
14
  | `code-rules-gate.md` | Reference for the CODE_RULES pre-commit gate check; describes what the gate blocks and when it runs |
15
+ | `precatch-rubric.md` | Shared pre-catch lane checklist — deterministic sweep, doc-vs-code parity, test-assertion completeness, PR-description parity, adversarial audit — that autoconverge's lenses and pr-converge's CODE_REVIEW step read on demand |
15
16
 
16
17
  ## Subdirectory
17
18