claude-dev-env 8.6.1 → 8.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 (119) hide show
  1. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  2. package/.agents/skills/team-advisor/SKILL.md +2 -2
  3. package/_shared/advisor/reference/identity.md +1 -1
  4. package/_shared/advisor/reference/spawn-walk-log.md +1 -1
  5. package/_shared/advisor/reference/warm-up.md +3 -1
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +23 -5
  8. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +85 -6
  9. package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
  10. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
  11. package/bin/install.test.mjs +25 -0
  12. package/docs/high-trust-agent-delivery.md +132 -0
  13. package/docs/references/advisor-tool.md +3 -2
  14. package/docs/second-claude-account.md +50 -0
  15. package/hooks/advisory/refactor_guard.py +2 -2
  16. package/hooks/advisory/test_refactor_guard.py +21 -0
  17. package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
  18. package/hooks/blocking/code_rules_shared.py +4 -7
  19. package/hooks/blocking/conftest.py +22 -0
  20. package/hooks/blocking/state_description_blocker.py +10 -8
  21. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
  22. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
  23. package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
  24. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
  25. package/hooks/blocking/test_code_rules_shared.py +18 -0
  26. package/hooks/blocking/test_conftest.py +35 -0
  27. package/hooks/blocking/test_pii_scanner.py +8 -0
  28. package/hooks/blocking/test_state_description_blocker.py +16 -0
  29. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
  30. package/hooks/git-hooks/pull_request_handoff.py +2 -2
  31. package/hooks/git-hooks/test_config.py +10 -3
  32. package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
  33. package/hooks/git-hooks/test_verification_notice_state.py +50 -0
  34. package/hooks/git-hooks/verification_notice_state.py +3 -1
  35. package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
  36. package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
  37. package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
  38. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
  39. package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
  40. package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
  41. package/hooks/validators/exempt_paths.py +3 -18
  42. package/hooks/validators/test_exempt_paths.py +20 -0
  43. package/hooks/validators/test_python_antipattern_checks.py +14 -0
  44. package/package.json +1 -1
  45. package/rules/agent-merges-its-own-green-pull-request.md +54 -0
  46. package/rules/archiving-agent-config.md +10 -0
  47. package/rules/code-standards.md +1 -0
  48. package/rules/correction-lens.md +134 -0
  49. package/rules/flag-non-breaking-findings.md +11 -0
  50. package/rules/long-horizon-autonomy.md +11 -0
  51. package/rules/no-contrast-framing.md +68 -0
  52. package/rules/review-closure-is-a-check.md +44 -0
  53. package/scripts/Invoke-PesterSuites.ps1 +42 -0
  54. package/scripts/agent_merge_check.py +381 -0
  55. package/scripts/check.ps1 +12 -1
  56. package/scripts/ci/install-playtest.mjs +291 -0
  57. package/scripts/ci/install-playtest.test.mjs +84 -0
  58. package/scripts/ci/installer-lifecycle.mjs +11 -30
  59. package/scripts/ci/installer-lifecycle.test.mjs +14 -0
  60. package/scripts/ci/pstack-command-shim.test.mjs +33 -0
  61. package/scripts/ci/scratch-home-install.mjs +73 -0
  62. package/scripts/ci/scratch-home-install.test.mjs +49 -0
  63. package/scripts/claude_account_choice.py +246 -0
  64. package/scripts/claude_account_profile.py +371 -0
  65. package/scripts/claude_chain_usage.py +49 -5
  66. package/scripts/codex_compat_materializer.py +33 -16
  67. package/scripts/contrast_framing.py +101 -0
  68. package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
  69. package/scripts/dev_env_scripts_constants/claude_account_constants.py +166 -0
  70. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
  71. package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
  72. package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
  73. package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +21 -0
  74. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
  75. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
  76. package/scripts/durable_post_lint.py +30 -4
  77. package/scripts/followup_cli.py +6 -10
  78. package/scripts/merge_gate_checks.py +236 -0
  79. package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
  80. package/scripts/policy_lint/adapters.py +30 -0
  81. package/scripts/policy_lint/config/constants.py +2 -0
  82. package/scripts/policy_lint/registry.py +5 -0
  83. package/scripts/repository_checks/config/constants.py +12 -0
  84. package/scripts/repository_checks/config/policy_document.py +28 -2
  85. package/scripts/repository_checks/followups.py +54 -0
  86. package/scripts/repository_checks/match_exemptions.py +67 -0
  87. package/scripts/repository_checks/models.py +46 -2
  88. package/scripts/repository_checks/reporting.py +23 -7
  89. package/scripts/repository_checks/tracked_secrets.py +2 -2
  90. package/scripts/repository_policy.py +4 -2
  91. package/scripts/review_closure.py +134 -0
  92. package/scripts/review_closure_github.py +345 -0
  93. package/scripts/review_closure_model.py +360 -0
  94. package/scripts/test_agent_merge_check.py +317 -0
  95. package/scripts/test_claude_account_choice.py +217 -0
  96. package/scripts/test_claude_account_profile.py +220 -0
  97. package/scripts/test_claude_chain_usage.py +40 -0
  98. package/scripts/test_durable_post_lint.py +46 -0
  99. package/scripts/test_followup_cli.py +40 -0
  100. package/scripts/test_merge_gate_checks.py +92 -0
  101. package/scripts/tests/test_code_review_constants.py +78 -0
  102. package/scripts/tests/test_codex_compat_materializer.py +48 -4
  103. package/scripts/tests/test_contrast_framing.py +117 -0
  104. package/scripts/tests/test_followups.py +88 -0
  105. package/scripts/tests/test_match_exemptions.py +291 -0
  106. package/scripts/tests/test_models.py +67 -2
  107. package/scripts/tests/test_package_inventory.py +6 -4
  108. package/scripts/tests/test_pester_suites_run.py +53 -0
  109. package/scripts/tests/test_policy_document.py +39 -1
  110. package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
  111. package/scripts/tests/test_reporting.py +18 -0
  112. package/scripts/tests/test_repository_policy.py +43 -1
  113. package/scripts/tests/test_review_closure.py +126 -0
  114. package/scripts/tests/test_review_closure_github.py +240 -0
  115. package/scripts/tests/test_review_closure_model.py +299 -0
  116. package/scripts/tests/test_tracked_secrets.py +42 -0
  117. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
  118. package/scripts/repository_checks/email_exemptions.py +0 -39
  119. package/scripts/tests/test_email_exemptions.py +0 -158
@@ -0,0 +1,317 @@
1
+ """Tests for the agent merge-readiness check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ _SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
11
+ if str(_SCRIPTS_DIRECTORY) not in sys.path:
12
+ sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
13
+
14
+ import agent_merge_check
15
+ from dev_env_scripts_constants.agent_merge_check_constants import (
16
+ BEHIND_HOLD_REASON,
17
+ BLOCKED_HOLD_REASON,
18
+ DIRTY_HOLD_REASON,
19
+ DRAFT_HOLD_REASON,
20
+ HOLD_VERDICT_LABEL,
21
+ MERGE_VERDICT_LABEL,
22
+ SETTLE_ATTEMPT_COUNT,
23
+ UNSTABLE_HOLD_REASON,
24
+ )
25
+
26
+ CLEAN_PULL_REQUEST = {
27
+ "number": 1442,
28
+ "draft": False,
29
+ "mergeable_state": "clean",
30
+ "head": {"sha": "ab845eb6945650055ebe852312a9057b9f067d6a"},
31
+ }
32
+
33
+
34
+ def _pull_request(**all_overrides: object) -> dict[str, object]:
35
+ return {**CLEAN_PULL_REQUEST, **all_overrides}
36
+
37
+
38
+ def test_clean_pull_request_with_no_open_thread_may_merge() -> None:
39
+ assert agent_merge_check.hold_reason(CLEAN_PULL_REQUEST, 0) is None
40
+
41
+
42
+ @pytest.mark.parametrize(
43
+ ("all_overrides", "expected_reason"),
44
+ [
45
+ ({"draft": True}, DRAFT_HOLD_REASON),
46
+ ({"mergeable_state": "behind"}, BEHIND_HOLD_REASON),
47
+ ({"mergeable_state": "dirty"}, DIRTY_HOLD_REASON),
48
+ ({"mergeable_state": "blocked"}, BLOCKED_HOLD_REASON),
49
+ ({"mergeable_state": "unstable"}, UNSTABLE_HOLD_REASON),
50
+ ],
51
+ )
52
+ def test_each_unready_state_holds_with_its_own_reason(
53
+ all_overrides: dict[str, object],
54
+ expected_reason: str,
55
+ ) -> None:
56
+ assert (
57
+ agent_merge_check.hold_reason(_pull_request(**all_overrides), 0)
58
+ == expected_reason
59
+ )
60
+
61
+
62
+ def test_a_draft_holds_even_when_its_merge_state_is_clean() -> None:
63
+ assert (
64
+ agent_merge_check.hold_reason(_pull_request(draft=True), 0) == DRAFT_HOLD_REASON
65
+ )
66
+
67
+
68
+ def test_an_unreported_merge_state_holds_and_names_what_github_said() -> None:
69
+ reason = agent_merge_check.hold_reason(_pull_request(mergeable_state="unknown"), 0)
70
+ assert reason is not None
71
+ assert "unknown" in reason
72
+
73
+
74
+ def test_an_open_review_thread_holds_a_green_pull_request() -> None:
75
+ reason = agent_merge_check.hold_reason(CLEAN_PULL_REQUEST, 2)
76
+ assert reason is not None
77
+ assert "2" in reason
78
+
79
+
80
+ def test_an_open_thread_count_reaches_the_verdict_line() -> None:
81
+ reason = agent_merge_check.hold_reason(CLEAN_PULL_REQUEST, 1)
82
+ line = agent_merge_check.verdict_line(
83
+ "jl-cmd/claude-dev-env", CLEAN_PULL_REQUEST, reason
84
+ )
85
+ assert line.startswith(HOLD_VERDICT_LABEL)
86
+ assert "jl-cmd/claude-dev-env#1442" in line
87
+
88
+
89
+ def test_the_ready_verdict_line_names_the_repository_and_head() -> None:
90
+ line = agent_merge_check.verdict_line(
91
+ "jl-cmd/claude-dev-env", CLEAN_PULL_REQUEST, None
92
+ )
93
+ assert line.startswith(MERGE_VERDICT_LABEL)
94
+ assert "jl-cmd/claude-dev-env#1442" in line
95
+ assert "ab845eb" in line
96
+
97
+
98
+ def test_a_resolved_thread_and_an_outdated_thread_hold_nothing_back() -> None:
99
+ all_thread_records = [
100
+ {"isResolved": True, "isOutdated": False},
101
+ {"isResolved": False, "isOutdated": True},
102
+ ]
103
+ assert agent_merge_check.count_unresolved_threads(all_thread_records) == 0
104
+
105
+
106
+ def test_an_unresolved_current_thread_counts() -> None:
107
+ all_thread_records = [
108
+ {"isResolved": False, "isOutdated": False},
109
+ {"isResolved": True, "isOutdated": False},
110
+ {"isResolved": False, "isOutdated": False},
111
+ ]
112
+ assert agent_merge_check.count_unresolved_threads(all_thread_records) == 2
113
+
114
+
115
+ def test_a_thread_record_of_another_shape_counts_as_nothing() -> None:
116
+ assert agent_merge_check.count_unresolved_threads(["", None]) == 0
117
+
118
+
119
+ def test_read_pull_request_returns_the_fields_the_api_answered_with(
120
+ monkeypatch: pytest.MonkeyPatch,
121
+ ) -> None:
122
+ monkeypatch.setattr(
123
+ agent_merge_check,
124
+ "_request_json",
125
+ lambda url, token, all_payload_fields: CLEAN_PULL_REQUEST,
126
+ )
127
+ assert (
128
+ agent_merge_check.read_pull_request("jl-cmd/claude-dev-env", 1442, "token")
129
+ == CLEAN_PULL_REQUEST
130
+ )
131
+
132
+
133
+ def test_read_pull_request_rejects_an_answer_of_another_shape(
134
+ monkeypatch: pytest.MonkeyPatch,
135
+ ) -> None:
136
+ monkeypatch.setattr(
137
+ agent_merge_check,
138
+ "_request_json",
139
+ lambda url, token, all_payload_fields: ["not a pull request"],
140
+ )
141
+ with pytest.raises(agent_merge_check.MergeCheckError):
142
+ agent_merge_check.read_pull_request("jl-cmd/claude-dev-env", 1442, "token")
143
+
144
+
145
+ def test_read_unresolved_thread_count_reads_the_rest_route(
146
+ monkeypatch: pytest.MonkeyPatch,
147
+ ) -> None:
148
+ monkeypatch.setattr(
149
+ agent_merge_check,
150
+ "_request_json",
151
+ lambda url, token, all_payload_fields: [
152
+ {"isResolved": False, "isOutdated": False},
153
+ {"isResolved": True, "isOutdated": False},
154
+ ],
155
+ )
156
+ assert (
157
+ agent_merge_check.read_unresolved_thread_count(
158
+ "jl-cmd/claude-dev-env", 1442, "token"
159
+ )
160
+ == 1
161
+ )
162
+
163
+
164
+ def test_read_unresolved_thread_count_falls_back_to_the_query(
165
+ monkeypatch: pytest.MonkeyPatch,
166
+ ) -> None:
167
+ def _answer(
168
+ url: str,
169
+ token: str,
170
+ all_payload_fields: object,
171
+ ) -> object:
172
+ if all_payload_fields is None:
173
+ raise agent_merge_check.MergeCheckError("HTTP Error 403: Forbidden")
174
+ return {
175
+ "data": {
176
+ "repository": {
177
+ "pullRequest": {
178
+ "reviewThreads": {
179
+ "nodes": [{"isResolved": False, "isOutdated": False}]
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ monkeypatch.setattr(agent_merge_check, "_request_json", _answer)
187
+ assert (
188
+ agent_merge_check.read_unresolved_thread_count(
189
+ "jl-cmd/claude-dev-env", 1442, "token"
190
+ )
191
+ == 1
192
+ )
193
+
194
+
195
+ def test_read_unresolved_thread_count_rejects_a_query_answer_of_another_shape(
196
+ monkeypatch: pytest.MonkeyPatch,
197
+ ) -> None:
198
+ def _answer(
199
+ url: str,
200
+ token: str,
201
+ all_payload_fields: object,
202
+ ) -> object:
203
+ if all_payload_fields is None:
204
+ raise agent_merge_check.MergeCheckError("HTTP Error 403: Forbidden")
205
+ return {"errors": [{"message": "Bad credentials"}]}
206
+
207
+ monkeypatch.setattr(agent_merge_check, "_request_json", _answer)
208
+ with pytest.raises(agent_merge_check.MergeCheckError):
209
+ agent_merge_check.read_unresolved_thread_count(
210
+ "jl-cmd/claude-dev-env", 1442, "token"
211
+ )
212
+
213
+
214
+ def test_read_settled_pull_request_reads_again_while_the_state_is_unknown(
215
+ monkeypatch: pytest.MonkeyPatch,
216
+ ) -> None:
217
+ all_answers = [
218
+ _pull_request(mergeable_state="unknown"),
219
+ _pull_request(mergeable_state="unknown"),
220
+ CLEAN_PULL_REQUEST,
221
+ ]
222
+ all_waits: list[float] = []
223
+ monkeypatch.setattr(
224
+ agent_merge_check,
225
+ "read_pull_request",
226
+ lambda slug, number, token: all_answers.pop(0),
227
+ )
228
+ settled = agent_merge_check.read_settled_pull_request(
229
+ "jl-cmd/claude-dev-env", 1442, "token", all_waits.append
230
+ )
231
+ assert settled == CLEAN_PULL_REQUEST
232
+ assert len(all_waits) == 2
233
+
234
+
235
+ def test_read_settled_pull_request_stops_after_its_last_attempt(
236
+ monkeypatch: pytest.MonkeyPatch,
237
+ ) -> None:
238
+ all_waits: list[float] = []
239
+ read_count = 0
240
+
241
+ def _unknown(slug: str, number: int, token: str) -> dict[str, object]:
242
+ nonlocal read_count
243
+ read_count += 1
244
+ return _pull_request(mergeable_state="unknown")
245
+
246
+ monkeypatch.setattr(agent_merge_check, "read_pull_request", _unknown)
247
+ settled = agent_merge_check.read_settled_pull_request(
248
+ "jl-cmd/claude-dev-env", 1442, "token", all_waits.append
249
+ )
250
+ assert settled["mergeable_state"] == "unknown"
251
+ assert read_count == SETTLE_ATTEMPT_COUNT
252
+ assert len(all_waits) == SETTLE_ATTEMPT_COUNT - 1
253
+
254
+
255
+ def test_read_settled_pull_request_waits_for_no_settled_state(
256
+ monkeypatch: pytest.MonkeyPatch,
257
+ ) -> None:
258
+ all_waits: list[float] = []
259
+ monkeypatch.setattr(
260
+ agent_merge_check,
261
+ "read_pull_request",
262
+ lambda slug, number, token: CLEAN_PULL_REQUEST,
263
+ )
264
+ agent_merge_check.read_settled_pull_request(
265
+ "jl-cmd/claude-dev-env", 1442, "token", all_waits.append
266
+ )
267
+ assert all_waits == []
268
+
269
+
270
+ def test_a_missing_token_reports_the_error_exit_code(
271
+ monkeypatch: pytest.MonkeyPatch,
272
+ ) -> None:
273
+ monkeypatch.delenv("GH_TOKEN", raising=False)
274
+ monkeypatch.delenv("GITHUB_TOKEN", raising=False)
275
+ assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 2
276
+
277
+
278
+ def test_a_ready_pull_request_exits_zero(monkeypatch: pytest.MonkeyPatch) -> None:
279
+ monkeypatch.setenv("GH_TOKEN", "token")
280
+ monkeypatch.setattr(
281
+ agent_merge_check,
282
+ "read_pull_request",
283
+ lambda slug, number, token: CLEAN_PULL_REQUEST,
284
+ )
285
+ monkeypatch.setattr(agent_merge_check.time, "sleep", lambda seconds: None)
286
+ monkeypatch.setattr(
287
+ agent_merge_check,
288
+ "read_unresolved_thread_count",
289
+ lambda slug, number, token: 0,
290
+ )
291
+ assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 0
292
+
293
+
294
+ def test_a_held_pull_request_exits_one(monkeypatch: pytest.MonkeyPatch) -> None:
295
+ monkeypatch.setenv("GH_TOKEN", "token")
296
+ monkeypatch.setattr(
297
+ agent_merge_check,
298
+ "read_pull_request",
299
+ lambda slug, number, token: _pull_request(mergeable_state="behind"),
300
+ )
301
+ monkeypatch.setattr(
302
+ agent_merge_check,
303
+ "read_unresolved_thread_count",
304
+ lambda slug, number, token: 0,
305
+ )
306
+ assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 1
307
+
308
+
309
+ def test_an_unreadable_pull_request_exits_two(
310
+ monkeypatch: pytest.MonkeyPatch,
311
+ ) -> None:
312
+ def _fail(slug: str, number: int, token: str) -> dict[str, object]:
313
+ raise agent_merge_check.MergeCheckError("no route to host")
314
+
315
+ monkeypatch.setenv("GH_TOKEN", "token")
316
+ monkeypatch.setattr(agent_merge_check, "read_pull_request", _fail)
317
+ assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 2
@@ -0,0 +1,217 @@
1
+ """Tests for the picker that names which Claude account a runner job uses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ import claude_account_choice as choice
12
+ from claude_chain_usage import AccountUsageMeters, WeeklyUtilizationProbeError
13
+
14
+ NOW = datetime(2026, 9, 22, 21, 0, tzinfo=timezone.utc)
15
+
16
+
17
+ def meters(
18
+ *,
19
+ session_used: float | None,
20
+ weekly_used: float | None,
21
+ weekly_resets_in: timedelta | None,
22
+ session_resets_in: timedelta = timedelta(hours=2),
23
+ ) -> AccountUsageMeters:
24
+ return AccountUsageMeters(
25
+ session_utilization=session_used,
26
+ session_resets_at=NOW + session_resets_in,
27
+ weekly_utilization=weekly_used,
28
+ weekly_resets_at=None if weekly_resets_in is None else NOW + weekly_resets_in,
29
+ )
30
+
31
+
32
+ class TestChooseAccount:
33
+ def should_pick_main_in_jons_example(self) -> None:
34
+ main_meters = meters(
35
+ session_used=10.0, weekly_used=80.0, weekly_resets_in=timedelta(hours=5)
36
+ )
37
+ second_meters = meters(
38
+ session_used=10.0, weekly_used=82.0, weekly_resets_in=timedelta(days=4)
39
+ )
40
+ decision = choice.choose_account(
41
+ main_meters=main_meters, second_meters=second_meters, now=NOW
42
+ )
43
+ assert decision.account == choice.CHOICE_MAIN
44
+
45
+ def should_pick_second_when_main_week_resets_later_than_a_day(self) -> None:
46
+ main_meters = meters(
47
+ session_used=0.0, weekly_used=5.0, weekly_resets_in=timedelta(hours=30)
48
+ )
49
+ second_meters = meters(
50
+ session_used=10.0, weekly_used=40.0, weekly_resets_in=timedelta(days=4)
51
+ )
52
+ decision = choice.choose_account(
53
+ main_meters=main_meters, second_meters=second_meters, now=NOW
54
+ )
55
+ assert decision.account == choice.CHOICE_SECOND
56
+
57
+ def should_keep_main_once_it_nears_its_weekly_ceiling(self) -> None:
58
+ main_meters = meters(
59
+ session_used=0.0, weekly_used=90.0, weekly_resets_in=timedelta(hours=3)
60
+ )
61
+ second_meters = meters(
62
+ session_used=0.0, weekly_used=10.0, weekly_resets_in=timedelta(days=4)
63
+ )
64
+ decision = choice.choose_account(
65
+ main_meters=main_meters, second_meters=second_meters, now=NOW
66
+ )
67
+ assert decision.account == choice.CHOICE_SECOND
68
+
69
+ def should_keep_main_when_its_five_hour_window_is_half_spent(self) -> None:
70
+ main_meters = meters(
71
+ session_used=50.0, weekly_used=20.0, weekly_resets_in=timedelta(hours=3)
72
+ )
73
+ second_meters = meters(
74
+ session_used=0.0, weekly_used=10.0, weekly_resets_in=timedelta(days=4)
75
+ )
76
+ decision = choice.choose_account(
77
+ main_meters=main_meters, second_meters=second_meters, now=NOW
78
+ )
79
+ assert decision.account == choice.CHOICE_SECOND
80
+
81
+ def should_never_pick_main_when_its_meter_is_unread(self) -> None:
82
+ second_meters = meters(
83
+ session_used=95.0, weekly_used=99.0, weekly_resets_in=timedelta(days=4)
84
+ )
85
+ decision = choice.choose_account(
86
+ main_meters=None, second_meters=second_meters, now=NOW
87
+ )
88
+ assert decision.account == choice.CHOICE_WAIT
89
+
90
+ def should_never_pick_main_when_its_reset_time_is_missing(self) -> None:
91
+ main_meters = meters(session_used=0.0, weekly_used=0.0, weekly_resets_in=None)
92
+ second_meters = meters(
93
+ session_used=95.0, weekly_used=99.0, weekly_resets_in=timedelta(days=4)
94
+ )
95
+ decision = choice.choose_account(
96
+ main_meters=main_meters, second_meters=second_meters, now=NOW
97
+ )
98
+ assert decision.account == choice.CHOICE_WAIT
99
+
100
+ def should_pick_second_when_its_meter_is_unread(self) -> None:
101
+ decision = choice.choose_account(main_meters=None, second_meters=None, now=NOW)
102
+ assert decision.account == choice.CHOICE_SECOND
103
+ assert decision.reason == choice.REASON_SECOND_UNREAD
104
+
105
+ def should_wait_and_name_the_session_reset_when_the_five_hour_window_blocks(
106
+ self,
107
+ ) -> None:
108
+ second_meters = meters(
109
+ session_used=90.0,
110
+ weekly_used=50.0,
111
+ weekly_resets_in=timedelta(days=4),
112
+ session_resets_in=timedelta(hours=1),
113
+ )
114
+ decision = choice.choose_account(
115
+ main_meters=None, second_meters=second_meters, now=NOW
116
+ )
117
+ assert decision.account == choice.CHOICE_WAIT
118
+ assert (NOW + timedelta(hours=1)).isoformat() in decision.reason
119
+
120
+ def should_wait_and_name_the_weekly_reset_when_the_week_blocks(self) -> None:
121
+ second_meters = meters(
122
+ session_used=10.0, weekly_used=95.0, weekly_resets_in=timedelta(days=2)
123
+ )
124
+ decision = choice.choose_account(
125
+ main_meters=None, second_meters=second_meters, now=NOW
126
+ )
127
+ assert decision.account == choice.CHOICE_WAIT
128
+ assert (NOW + timedelta(days=2)).isoformat() in decision.reason
129
+
130
+
131
+ class TestReadAccountMeters:
132
+ def should_return_none_when_the_probe_cannot_read_the_account(
133
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
134
+ ) -> None:
135
+ def failing_probe(credentials_path: Path) -> AccountUsageMeters:
136
+ raise WeeklyUtilizationProbeError(f"no token in {credentials_path}")
137
+
138
+ monkeypatch.setattr(choice, "probe_account_meters", failing_probe)
139
+ assert choice.read_account_meters(tmp_path / ".credentials.json") is None
140
+
141
+ def should_return_the_probed_meters(
142
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
143
+ ) -> None:
144
+ probed = meters(
145
+ session_used=1.0, weekly_used=2.0, weekly_resets_in=timedelta(days=1)
146
+ )
147
+ monkeypatch.setattr(choice, "probe_account_meters", lambda credentials_path: probed)
148
+ assert choice.read_account_meters(tmp_path / ".credentials.json") == probed
149
+
150
+
151
+ class TestDecisionPayload:
152
+ def should_name_no_config_directory_for_a_wait(self) -> None:
153
+ decision = choice.AccountDecision(account=choice.CHOICE_WAIT, reason="full")
154
+ assert choice.decision_payload(decision, config_directory=None) == {
155
+ "account": "wait",
156
+ "config_dir": None,
157
+ "reason": "full",
158
+ }
159
+
160
+
161
+ class TestMain:
162
+ def should_print_the_choice_and_the_config_directory_of_the_chosen_account(
163
+ self,
164
+ tmp_path: Path,
165
+ monkeypatch: pytest.MonkeyPatch,
166
+ capsys: pytest.CaptureFixture[str],
167
+ ) -> None:
168
+ main_home = tmp_path / "main"
169
+ second_home = tmp_path / "second"
170
+ meters_by_credentials_path = {
171
+ main_home / ".credentials.json": None,
172
+ second_home / ".credentials.json": meters(
173
+ session_used=0.0, weekly_used=10.0, weekly_resets_in=timedelta(days=4)
174
+ ),
175
+ }
176
+ monkeypatch.setattr(
177
+ choice,
178
+ "read_account_meters",
179
+ lambda credentials_path: meters_by_credentials_path[credentials_path],
180
+ )
181
+ exit_code = choice.main(
182
+ [
183
+ "--main-config-dir",
184
+ str(main_home),
185
+ "--second-config-dir",
186
+ str(second_home),
187
+ ]
188
+ )
189
+ printed = json.loads(capsys.readouterr().out)
190
+ assert exit_code == 0
191
+ assert printed["account"] == choice.CHOICE_SECOND
192
+ assert printed["config_dir"] == str(second_home)
193
+
194
+ def should_print_no_config_directory_when_the_job_waits(
195
+ self,
196
+ tmp_path: Path,
197
+ monkeypatch: pytest.MonkeyPatch,
198
+ capsys: pytest.CaptureFixture[str],
199
+ ) -> None:
200
+ blocked_meters = meters(
201
+ session_used=99.0, weekly_used=99.0, weekly_resets_in=timedelta(days=4)
202
+ )
203
+ monkeypatch.setattr(
204
+ choice, "read_account_meters", lambda credentials_path: blocked_meters
205
+ )
206
+ exit_code = choice.main(
207
+ [
208
+ "--main-config-dir",
209
+ str(tmp_path / "main"),
210
+ "--second-config-dir",
211
+ str(tmp_path / "second"),
212
+ ]
213
+ )
214
+ printed = json.loads(capsys.readouterr().out)
215
+ assert exit_code == 0
216
+ assert printed["account"] == choice.CHOICE_WAIT
217
+ assert printed["config_dir"] is None