claude-dev-env 1.90.0 → 1.92.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 (63) hide show
  1. package/CLAUDE.md +0 -13
  2. package/agents/clean-coder.md +2 -2
  3. package/agents/code-verifier.md +0 -1
  4. package/agents/test_agent_frontmatter.py +78 -0
  5. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  6. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
  7. package/bin/install.mjs +1 -0
  8. package/docs/CODE_RULES.md +2 -2
  9. package/hooks/blocking/CLAUDE.md +5 -2
  10. package/hooks/blocking/code_rules_comments.py +2 -2
  11. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +44 -0
  12. package/hooks/blocking/config/verified_commit_constants.py +2 -0
  13. package/hooks/blocking/conftest.py +115 -0
  14. package/hooks/blocking/nas_ssh_binary_enforcer.py +191 -0
  15. package/hooks/blocking/pr_description_enforcer.py +46 -22
  16. package/hooks/blocking/pr_description_pr_number.py +5 -3
  17. package/hooks/blocking/pr_description_proof_of_work.py +367 -0
  18. package/hooks/blocking/precommit_code_rules_gate.py +5 -1
  19. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +8 -2
  20. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +71 -0
  21. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +168 -0
  22. package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +175 -0
  23. package/hooks/blocking/test_pr_description_proof_of_work.py +162 -0
  24. package/hooks/blocking/test_precommit_code_rules_gate.py +89 -0
  25. package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -0
  26. package/hooks/blocking/test_verification_verdict_store.py +11 -0
  27. package/hooks/blocking/test_verified_commit_config_bootstrap.py +49 -0
  28. package/hooks/blocking/test_verified_commit_gate.py +11 -0
  29. package/hooks/blocking/test_verifier_verdict_minter.py +11 -0
  30. package/hooks/blocking/verdict_directory_write_blocker.py +6 -0
  31. package/hooks/blocking/verification_verdict_store.py +73 -5
  32. package/hooks/blocking/verified_commit_config_bootstrap.py +51 -0
  33. package/hooks/blocking/verified_commit_gate.py +6 -0
  34. package/hooks/blocking/verifier_verdict_minter.py +6 -0
  35. package/hooks/hooks.json +7 -2
  36. package/hooks/hooks_constants/CLAUDE.md +2 -0
  37. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  38. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +3 -0
  39. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +59 -0
  40. package/hooks/hooks_constants/pr_description_enforcer_constants.py +2 -0
  41. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +111 -0
  42. package/hooks/validators/run_all_validators.py +216 -4
  43. package/hooks/validators/test_run_all_validators_pretooluse.py +102 -0
  44. package/package.json +1 -1
  45. package/rules/CLAUDE.md +2 -0
  46. package/rules/nas-ssh-invocation.md +21 -0
  47. package/rules/proof-of-work-pr-comments.md +26 -0
  48. package/scripts/CLAUDE.md +1 -0
  49. package/scripts/claude-chain.example.json +8 -0
  50. package/scripts/claude_chain_runner.py +400 -0
  51. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -0
  52. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +124 -0
  53. package/scripts/sync_to_cursor/rules.py +1 -1
  54. package/scripts/test_claude_chain_runner.py +472 -0
  55. package/skills/CLAUDE.md +3 -0
  56. package/skills/team-advisor/SKILL.md +188 -0
  57. package/skills/team-advisor-refresh/SKILL.md +25 -0
  58. package/skills/usage-pause/SKILL.md +108 -0
  59. package/skills/usage-pause/scripts/resolve_usage_window.py +462 -0
  60. package/skills/usage-pause/scripts/test_resolve_usage_window.py +278 -0
  61. package/skills/usage-pause/scripts/usage_pause_constants/__init__.py +1 -0
  62. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +65 -0
  63. package/system-prompts/software-engineer.xml +3 -2
@@ -0,0 +1,472 @@
1
+ """Behavioral tests for the claude fallback-chain runner."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
12
+ if str(_SCRIPTS_DIR) not in sys.path:
13
+ sys.path.insert(0, str(_SCRIPTS_DIR))
14
+
15
+ import claude_chain_runner as runner # noqa: E402
16
+ from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
17
+ ALL_USAGE_LIMIT_SIGNATURES,
18
+ ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
19
+ ATTEMPT_STATUS_NONZERO_EXIT,
20
+ ATTEMPT_STATUS_SERVED,
21
+ ATTEMPT_STATUS_TIMEOUT,
22
+ ATTEMPT_STATUS_USAGE_LIMITED,
23
+ ATTEMPT_SUMMARY_ENTRY_TEMPLATE,
24
+ ATTEMPT_SUMMARY_JOIN_SEPARATOR,
25
+ CHAIN_CONFIG_ERROR_EXIT_CODE,
26
+ CHAIN_EXHAUSTED_EXIT_CODE,
27
+ CHAIN_EXHAUSTED_MESSAGE_TEMPLATE,
28
+ CLAUDE_HOME_SUBDIRECTORY,
29
+ CLI_ARGUMENTS_SEPARATOR,
30
+ CLI_TIMEOUT_FLAG,
31
+ CONFIG_CHAIN_EMPTY_REASON,
32
+ CONFIG_CHAIN_KEY,
33
+ CONFIG_CHAIN_NOT_LIST_REASON,
34
+ CONFIG_COMMAND_KEY,
35
+ CONFIG_ENTRY_COMMAND_MISSING_REASON,
36
+ CONFIG_ENTRY_EXTRA_ARGS_INVALID_REASON,
37
+ CONFIG_ENTRY_NOT_OBJECT_REASON,
38
+ CONFIG_EXTRA_ARGS_KEY,
39
+ CONFIG_FILENAME,
40
+ CONFIG_INVALID_SHAPE_MESSAGE_TEMPLATE,
41
+ CONFIG_MALFORMED_MESSAGE_TEMPLATE,
42
+ CONFIG_MISSING_MESSAGE_TEMPLATE,
43
+ CONFIG_NOT_OBJECT_REASON,
44
+ CONFIG_UNREADABLE_MESSAGE_TEMPLATE,
45
+ DEFAULT_TIMEOUT_SECONDS,
46
+ EXAMPLE_CONFIG_FILENAME,
47
+ NO_COMPLETED_PROCESS_RETURN_CODE,
48
+ UTF8_ENCODING,
49
+ )
50
+
51
+ _A_SIGNATURE = ALL_USAGE_LIMIT_SIGNATURES[0]
52
+ _PROMPT_ARGUMENTS = ["-p", "hello"]
53
+
54
+
55
+ def _completed(command, returncode, stdout="", stderr=""):
56
+ return subprocess.CompletedProcess(
57
+ args=[command], returncode=returncode, stdout=stdout, stderr=stderr
58
+ )
59
+
60
+
61
+ def _entry(command, extra_args=None):
62
+ return {
63
+ CONFIG_COMMAND_KEY: command,
64
+ CONFIG_EXTRA_ARGS_KEY: extra_args if extra_args is not None else [],
65
+ }
66
+
67
+
68
+ def _write_chain_config(tmp_path, chain_entries):
69
+ config_file = tmp_path / CONFIG_FILENAME
70
+ config_file.write_text(
71
+ json.dumps({CONFIG_CHAIN_KEY: chain_entries}), encoding=UTF8_ENCODING
72
+ )
73
+ return config_file
74
+
75
+
76
+ class _Recorder:
77
+ def __init__(self, behavior_by_command):
78
+ self.behavior_by_command = behavior_by_command
79
+ self.invocations = []
80
+ self.timeouts = []
81
+
82
+ def __call__(self, invocation, **keyword_arguments):
83
+ self.invocations.append(invocation)
84
+ self.timeouts.append(keyword_arguments.get("timeout"))
85
+ behavior = self.behavior_by_command[invocation[0]]
86
+ if isinstance(behavior, BaseException):
87
+ raise behavior
88
+ return behavior
89
+
90
+
91
+ def _install(monkeypatch, config_file, behavior_by_command):
92
+ recorder = _Recorder(behavior_by_command)
93
+ monkeypatch.setattr(runner, "chain_config_path", lambda: config_file)
94
+ monkeypatch.setattr(runner, "chain_subprocess_runner", recorder)
95
+ return recorder
96
+
97
+
98
+ def test_usage_limited_primary_falls_over_to_second(
99
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
100
+ ) -> None:
101
+ config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
102
+ _install(
103
+ monkeypatch,
104
+ config_file,
105
+ {
106
+ "claude": _completed("claude", 1, stderr=_A_SIGNATURE),
107
+ "claude-ev": _completed("claude-ev", 0, stdout="ok"),
108
+ },
109
+ )
110
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
111
+ assert chain_result.served_command == "claude-ev"
112
+ assert chain_result.returncode == 0
113
+ assert chain_result.stdout == "ok"
114
+ assert [each_attempt.command for each_attempt in chain_result.attempts] == [
115
+ "claude",
116
+ "claude-ev",
117
+ ]
118
+ assert chain_result.attempts[0].status == ATTEMPT_STATUS_USAGE_LIMITED
119
+ assert chain_result.attempts[1].status == ATTEMPT_STATUS_SERVED
120
+
121
+
122
+ def test_nonzero_exit_without_signature_does_not_fall_over(
123
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
124
+ ) -> None:
125
+ config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
126
+ _install(
127
+ monkeypatch,
128
+ config_file,
129
+ {
130
+ "claude": _completed("claude", 2, stderr="unknown flag"),
131
+ "claude-ev": _completed("claude-ev", 0, stdout="should not run"),
132
+ },
133
+ )
134
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
135
+ assert chain_result.served_command == "claude"
136
+ assert chain_result.returncode == 2
137
+ assert len(chain_result.attempts) == 1
138
+ assert chain_result.attempts[0].status == ATTEMPT_STATUS_NONZERO_EXIT
139
+
140
+
141
+ def test_timeout_on_primary_does_not_fall_over(
142
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
143
+ ) -> None:
144
+ config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
145
+ _install(
146
+ monkeypatch,
147
+ config_file,
148
+ {
149
+ "claude": subprocess.TimeoutExpired(cmd=["claude"], timeout=5),
150
+ "claude-ev": _completed("claude-ev", 0),
151
+ },
152
+ )
153
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
154
+ assert chain_result.served_command is None
155
+ assert chain_result.returncode == NO_COMPLETED_PROCESS_RETURN_CODE
156
+ assert len(chain_result.attempts) == 1
157
+ assert chain_result.attempts[0].status == ATTEMPT_STATUS_TIMEOUT
158
+
159
+
160
+ def test_missing_primary_binary_does_not_fall_over(
161
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
162
+ ) -> None:
163
+ config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
164
+ _install(
165
+ monkeypatch,
166
+ config_file,
167
+ {
168
+ "claude": FileNotFoundError(),
169
+ "claude-ev": _completed("claude-ev", 0),
170
+ },
171
+ )
172
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
173
+ assert chain_result.served_command is None
174
+ assert len(chain_result.attempts) == 1
175
+ assert chain_result.attempts[0].status == ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND
176
+
177
+
178
+ def test_missing_fallback_binary_is_skipped_and_walk_continues(
179
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
180
+ ) -> None:
181
+ config_file = _write_chain_config(
182
+ tmp_path, [_entry("claude"), _entry("claude-ev"), _entry("claude-editor")]
183
+ )
184
+ _install(
185
+ monkeypatch,
186
+ config_file,
187
+ {
188
+ "claude": _completed("claude", 1, stderr=_A_SIGNATURE),
189
+ "claude-ev": FileNotFoundError(),
190
+ "claude-editor": _completed("claude-editor", 0, stdout="done"),
191
+ },
192
+ )
193
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
194
+ assert chain_result.served_command == "claude-editor"
195
+ assert [each_attempt.status for each_attempt in chain_result.attempts] == [
196
+ ATTEMPT_STATUS_USAGE_LIMITED,
197
+ ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
198
+ ATTEMPT_STATUS_SERVED,
199
+ ]
200
+
201
+
202
+ def test_zero_exit_mentioning_usage_limit_is_success(
203
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
204
+ ) -> None:
205
+ config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
206
+ _install(
207
+ monkeypatch,
208
+ config_file,
209
+ {
210
+ "claude": _completed("claude", 0, stdout=f"note: {_A_SIGNATURE} earlier"),
211
+ "claude-ev": _completed("claude-ev", 0),
212
+ },
213
+ )
214
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
215
+ assert chain_result.served_command == "claude"
216
+ assert chain_result.returncode == 0
217
+ assert len(chain_result.attempts) == 1
218
+ assert chain_result.attempts[0].status == ATTEMPT_STATUS_SERVED
219
+
220
+
221
+ def test_exhausted_chain_records_every_attempt(
222
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
223
+ ) -> None:
224
+ config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
225
+ _install(
226
+ monkeypatch,
227
+ config_file,
228
+ {
229
+ "claude": _completed("claude", 1, stderr=_A_SIGNATURE),
230
+ "claude-ev": _completed("claude-ev", 1, stderr=_A_SIGNATURE),
231
+ },
232
+ )
233
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
234
+ assert chain_result.served_command is None
235
+ assert chain_result.returncode == 1
236
+ assert [each_attempt.status for each_attempt in chain_result.attempts] == [
237
+ ATTEMPT_STATUS_USAGE_LIMITED,
238
+ ATTEMPT_STATUS_USAGE_LIMITED,
239
+ ]
240
+
241
+
242
+ def test_reordering_config_changes_walk_order(
243
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
244
+ ) -> None:
245
+ behavior_by_command = {
246
+ "claude": _completed("claude", 0, stdout="from-claude"),
247
+ "claude-ev": _completed("claude-ev", 0, stdout="from-ev"),
248
+ }
249
+ first_config = tmp_path / "first"
250
+ first_config.mkdir()
251
+ config_a = _write_chain_config(
252
+ first_config, [_entry("claude"), _entry("claude-ev")]
253
+ )
254
+ _install(monkeypatch, config_a, behavior_by_command)
255
+ result_a = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
256
+ assert result_a.served_command == "claude"
257
+
258
+ second_config = tmp_path / "second"
259
+ second_config.mkdir()
260
+ config_b = _write_chain_config(
261
+ second_config, [_entry("claude-ev"), _entry("claude")]
262
+ )
263
+ _install(monkeypatch, config_b, behavior_by_command)
264
+ result_b = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
265
+ assert result_b.served_command == "claude-ev"
266
+
267
+
268
+ def test_extra_args_are_appended_to_invocation(
269
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
270
+ ) -> None:
271
+ config_file = _write_chain_config(
272
+ tmp_path, [_entry("claude", extra_args=["--account", "ev"])]
273
+ )
274
+ recorder = _install(monkeypatch, config_file, {"claude": _completed("claude", 0)})
275
+ runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
276
+ assert recorder.invocations[0] == ["claude", "-p", "hello", "--account", "ev"]
277
+
278
+
279
+ def test_signature_matching_is_case_insensitive(
280
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
281
+ ) -> None:
282
+ config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
283
+ _install(
284
+ monkeypatch,
285
+ config_file,
286
+ {
287
+ "claude": _completed("claude", 1, stderr=_A_SIGNATURE.upper()),
288
+ "claude-ev": _completed("claude-ev", 0),
289
+ },
290
+ )
291
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
292
+ assert chain_result.served_command == "claude-ev"
293
+
294
+
295
+ def test_cli_passthrough_builds_argument_list_and_timeout(
296
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
297
+ ) -> None:
298
+ config_file = _write_chain_config(tmp_path, [_entry("claude")])
299
+ recorder = _install(monkeypatch, config_file, {"claude": _completed("claude", 0)})
300
+ exit_code = runner.main(
301
+ [CLI_TIMEOUT_FLAG, "9", CLI_ARGUMENTS_SEPARATOR, "-p", "hi"]
302
+ )
303
+ assert exit_code == 0
304
+ assert recorder.invocations[0] == ["claude", "-p", "hi"]
305
+ assert recorder.timeouts[0] == 9
306
+
307
+
308
+ def test_cli_uses_default_timeout_when_flag_omitted(
309
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
310
+ ) -> None:
311
+ config_file = _write_chain_config(tmp_path, [_entry("claude")])
312
+ recorder = _install(monkeypatch, config_file, {"claude": _completed("claude", 0)})
313
+ runner.main([CLI_ARGUMENTS_SEPARATOR, "-p", "hi"])
314
+ assert recorder.timeouts[0] == DEFAULT_TIMEOUT_SECONDS
315
+
316
+
317
+ def test_cli_served_nonzero_exit_is_passed_through(
318
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
319
+ ) -> None:
320
+ config_file = _write_chain_config(tmp_path, [_entry("claude")])
321
+ _install(
322
+ monkeypatch,
323
+ config_file,
324
+ {"claude": _completed("claude", 7, stdout="out", stderr="err")},
325
+ )
326
+ exit_code = runner.main([CLI_ARGUMENTS_SEPARATOR, "-p", "hi"])
327
+ captured = capsys.readouterr()
328
+ assert exit_code == 7
329
+ assert captured.out == "out"
330
+ assert "err" in captured.err
331
+
332
+
333
+ def test_cli_exhausted_chain_exits_nonzero_with_attempt_summary(
334
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
335
+ ) -> None:
336
+ config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
337
+ _install(
338
+ monkeypatch,
339
+ config_file,
340
+ {
341
+ "claude": _completed("claude", 1, stderr=_A_SIGNATURE),
342
+ "claude-ev": _completed("claude-ev", 1, stderr=_A_SIGNATURE),
343
+ },
344
+ )
345
+ exit_code = runner.main([CLI_ARGUMENTS_SEPARATOR, "-p", "hi"])
346
+ captured = capsys.readouterr()
347
+ assert exit_code == CHAIN_EXHAUSTED_EXIT_CODE
348
+ expected_summary = ATTEMPT_SUMMARY_JOIN_SEPARATOR.join(
349
+ ATTEMPT_SUMMARY_ENTRY_TEMPLATE.format(
350
+ command=each_command, status=ATTEMPT_STATUS_USAGE_LIMITED
351
+ )
352
+ for each_command in ("claude", "claude-ev")
353
+ )
354
+ assert (
355
+ CHAIN_EXHAUSTED_MESSAGE_TEMPLATE.format(attempt_summary=expected_summary)
356
+ in captured.err
357
+ )
358
+
359
+
360
+ def test_cli_missing_config_exits_config_error(
361
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
362
+ ) -> None:
363
+ missing_config = tmp_path / CONFIG_FILENAME
364
+ monkeypatch.setattr(runner, "chain_config_path", lambda: missing_config)
365
+ exit_code = runner.main([CLI_ARGUMENTS_SEPARATOR, "-p", "hi"])
366
+ captured = capsys.readouterr()
367
+ assert exit_code == CHAIN_CONFIG_ERROR_EXIT_CODE
368
+ assert EXAMPLE_CONFIG_FILENAME in captured.err
369
+
370
+
371
+ def test_missing_config_raises_naming_path_and_example(tmp_path: Path) -> None:
372
+ missing_config = tmp_path / CONFIG_FILENAME
373
+ with pytest.raises(runner.ChainConfigurationError) as raised:
374
+ runner.load_chain(missing_config)
375
+ assert str(raised.value) == CONFIG_MISSING_MESSAGE_TEMPLATE.format(
376
+ config_path=missing_config, example_filename=EXAMPLE_CONFIG_FILENAME
377
+ )
378
+
379
+
380
+ def test_malformed_json_raises_configuration_error(tmp_path: Path) -> None:
381
+ config_file = tmp_path / CONFIG_FILENAME
382
+ config_file.write_text("{ not valid json", encoding=UTF8_ENCODING)
383
+ with pytest.raises(runner.ChainConfigurationError) as raised:
384
+ runner.load_chain(config_file)
385
+ malformed_prefix = CONFIG_MALFORMED_MESSAGE_TEMPLATE.split("{config_path}")[0]
386
+ assert malformed_prefix in str(raised.value)
387
+ assert EXAMPLE_CONFIG_FILENAME in str(raised.value)
388
+
389
+
390
+ def test_unreadable_config_raises_configuration_error(tmp_path: Path) -> None:
391
+ config_file = tmp_path / CONFIG_FILENAME
392
+ config_file.write_text("{}", encoding=UTF8_ENCODING)
393
+ with patch.object(Path, "read_text", side_effect=OSError("locked")):
394
+ with pytest.raises(runner.ChainConfigurationError) as raised:
395
+ runner.load_chain(config_file)
396
+ unreadable_prefix = CONFIG_UNREADABLE_MESSAGE_TEMPLATE.split("{config_path}")[0]
397
+ assert unreadable_prefix in str(raised.value)
398
+
399
+
400
+ def test_top_level_not_object_raises_invalid_shape(tmp_path: Path) -> None:
401
+ config_file = tmp_path / CONFIG_FILENAME
402
+ config_file.write_text(json.dumps(["claude"]), encoding=UTF8_ENCODING)
403
+ with pytest.raises(runner.ChainConfigurationError) as raised:
404
+ runner.load_chain(config_file)
405
+ assert str(raised.value) == CONFIG_INVALID_SHAPE_MESSAGE_TEMPLATE.format(
406
+ config_path=config_file,
407
+ reason=CONFIG_NOT_OBJECT_REASON,
408
+ example_filename=EXAMPLE_CONFIG_FILENAME,
409
+ )
410
+
411
+
412
+ def test_chain_key_not_list_raises_invalid_shape(tmp_path: Path) -> None:
413
+ config_file = tmp_path / CONFIG_FILENAME
414
+ config_file.write_text(
415
+ json.dumps({CONFIG_CHAIN_KEY: "claude"}), encoding=UTF8_ENCODING
416
+ )
417
+ with pytest.raises(runner.ChainConfigurationError) as raised:
418
+ runner.load_chain(config_file)
419
+ assert CONFIG_CHAIN_NOT_LIST_REASON in str(raised.value)
420
+
421
+
422
+ def test_empty_chain_raises_invalid_shape(tmp_path: Path) -> None:
423
+ config_file = _write_chain_config(tmp_path, [])
424
+ with pytest.raises(runner.ChainConfigurationError) as raised:
425
+ runner.load_chain(config_file)
426
+ assert CONFIG_CHAIN_EMPTY_REASON in str(raised.value)
427
+
428
+
429
+ def test_entry_not_object_raises_invalid_shape(tmp_path: Path) -> None:
430
+ config_file = _write_chain_config(tmp_path, ["claude"])
431
+ with pytest.raises(runner.ChainConfigurationError) as raised:
432
+ runner.load_chain(config_file)
433
+ assert CONFIG_ENTRY_NOT_OBJECT_REASON in str(raised.value)
434
+
435
+
436
+ def test_entry_without_command_raises_invalid_shape(tmp_path: Path) -> None:
437
+ config_file = _write_chain_config(tmp_path, [{CONFIG_EXTRA_ARGS_KEY: []}])
438
+ with pytest.raises(runner.ChainConfigurationError) as raised:
439
+ runner.load_chain(config_file)
440
+ assert CONFIG_ENTRY_COMMAND_MISSING_REASON in str(raised.value)
441
+
442
+
443
+ def test_entry_with_non_list_extra_args_raises_invalid_shape(tmp_path: Path) -> None:
444
+ config_file = _write_chain_config(
445
+ tmp_path, [{CONFIG_COMMAND_KEY: "claude", CONFIG_EXTRA_ARGS_KEY: "nope"}]
446
+ )
447
+ with pytest.raises(runner.ChainConfigurationError) as raised:
448
+ runner.load_chain(config_file)
449
+ assert CONFIG_ENTRY_EXTRA_ARGS_INVALID_REASON in str(raised.value)
450
+
451
+
452
+ def test_extra_args_default_to_empty_when_omitted(tmp_path: Path) -> None:
453
+ config_file = _write_chain_config(tmp_path, [{CONFIG_COMMAND_KEY: "claude"}])
454
+ all_entries = runner.load_chain(config_file)
455
+ assert all_entries[0].command == "claude"
456
+ assert all_entries[0].extra_args == ()
457
+
458
+
459
+ def test_load_chain_parses_command_and_extra_args(tmp_path: Path) -> None:
460
+ config_file = _write_chain_config(
461
+ tmp_path, [_entry("claude", extra_args=["--account", "ev"])]
462
+ )
463
+ all_entries = runner.load_chain(config_file)
464
+ assert all_entries == [
465
+ runner.ChainEntry(command="claude", extra_args=("--account", "ev"))
466
+ ]
467
+
468
+
469
+ def test_chain_config_path_points_at_home_config() -> None:
470
+ config_path = runner.chain_config_path()
471
+ assert config_path.name == CONFIG_FILENAME
472
+ assert CLAUDE_HOME_SUBDIRECTORY in config_path.parts
package/skills/CLAUDE.md CHANGED
@@ -26,6 +26,8 @@ Skills install to `~/.claude/skills/<skill-name>/` via `packages/claude-dev-env/
26
26
  - `implement` — structured implementation from an existing plan packet
27
27
  - `bdd-protocol` — BDD depth: Example Mapping, scenario quality, outside-in layout
28
28
  - `verified-build` — build + test loop that gates on a verifier verdict
29
+ - `team-advisor` — turns the session into the advisor-orchestrator: it spawns executor subagents to do the code edits and test runs, and answers a blocked executor with a plan, correction, or stop
30
+ - `team-advisor-refresh` — sub-skill fired by the `/team-advisor` loop to re-assert the executor-advisor discipline mid-run
29
31
 
30
32
  **PR review and convergence**
31
33
  - `autoconverge` — autonomous single-run workflow that drives a PR to ready
@@ -66,6 +68,7 @@ Skills install to `~/.claude/skills/<skill-name>/` via `packages/claude-dev-env/
66
68
  - `monitor-open-prs` — polls open PRs for status
67
69
  - `pre-compact` — compact-safe session handoff
68
70
  - `qbug` — required baseline PR audit; one clean-coder subagent loops audit → fix → commit → push until clean or stuck
71
+ - `usage-pause` — waits out the 5-hour usage window in ScheduleWakeup stages that keep agent contexts warm; probes the OAuth usage endpoint or takes a manual reset override
69
72
  - `skill-builder` — complete skill-building lifecycle
70
73
  - `auditing-claude-config` — audits a Claude Code setup for context-budget waste and produces a migration table with savings
71
74
  - `log-audit` — background agent that audits this repo's own logs for recurring errors and timing regressions and files grouped fixes
@@ -0,0 +1,188 @@
1
+ ---
2
+ name: team-advisor
3
+ description: >-
4
+ Turns the session into the advisor-orchestrator — the user's sole interface,
5
+ to plan
6
+ and delegate while workflow-backed agent spawns do the token-heavy
7
+ execution with the required agent type and model for each work category.
8
+ Executors do the code editing, verification, script driving, PR
9
+ descriptions, and searches; the advisor answers blockers with one of three
10
+ brief signals — a plan, a correction, or a stop. The advisor never edits
11
+ code or runs tests itself. Caps consultations per task (default 5), reuses
12
+ warm workflow agents before spawning new ones, and re-asserts the
13
+ discipline every 20 minutes through the /team-advisor-refresh loop. Adapts
14
+ Anthropic's coordinator pattern (plan big, execute small) to Claude Code.
15
+ Triggers: '/team-advisor', 'team advisor strategy', 'run with a team
16
+ advisor', 'executor-advisor mode', 'team advisor enforcement', 'agent
17
+ routing', 'orchestrate'.
18
+ ---
19
+
20
+ # Team Advisor Strategy
21
+
22
+ ## Principle
23
+
24
+ A frontier model plans and synthesizes while cheap workers do the
25
+ token-heavy reading and doing — Anthropic's coordinator pattern, source:
26
+ https://github.com/anthropics/claude-cookbooks/blob/main/managed_agents/CMA_plan_big_execute_small.ipynb
27
+ ("Coordinator pattern: big models for planning, small models for
28
+ execution"). On the cookbook's own measured run, a coordinator
29
+ delegating to Sonnet-5 workers came out cheaper and faster
30
+ than a solo frontier agent held to the same verification rigor, with
31
+ 84-98% of the team's input tokens billed at the worker rate.
32
+
33
+ Claude Code has no `multiagent` coordinator field or Managed-Agents-style
34
+ `create_agent`/`send_to_agent` primitives; this skill reaches the same
35
+ shape with the tools Claude Code already has. Under this skill the session
36
+ is the advisor-orchestrator. In Claude Code the user always talks to the session and never to a
37
+ subagent, so the session is the user's sole interface: all user-facing
38
+ communication flows through it. It spawns and resumes executor subagents
39
+ — `clean-coder` and the like — and those executors do every bit of the
40
+ execution: the code edits, the build runs, the test runs. The advisor
41
+ drives the plan and answers the executors when they get stuck.
42
+
43
+ ## Gotchas
44
+
45
+ - **Double invocation duplicates the reminder loop.** A second `/team-advisor`
46
+ in the same session schedules a second refresh loop. Check whether the loop
47
+ is already running before you schedule one (see the invocation guard in
48
+ Process step 1).
49
+ - **The advisor never executes.** The moment it edits a file or runs the tests
50
+ itself, the pairing breaks and the executor's warm context is wasted. Hand
51
+ every code edit and every build or test run to an executor; keep the
52
+ advisor's own tool use to orchestration and light verification reads.
53
+ - **Flat ad hoc spawns bypass routing.** Every execution task goes through a
54
+ workflow-backed spawn or workflow resume so the required agent type, model,
55
+ prompt packet, and sidecar metadata stay attached to the work.
56
+ - **Wrong agent or model is an enforcement failure.** If a task category maps
57
+ to `clean-coder` on `opus`, a `general-purpose` Sonnet spawn is not a cost
58
+ optimization; it is the wrong executor for the contract.
59
+ - **Resuming an unnamed background agent needs its agentId.** A background
60
+ spawn returns an `agentId` (format `a...-...`); keep it so `SendMessage` can
61
+ reach that agent later. A named agent is reachable by name.
62
+ - **Consultations past the cap signal a scoping problem.** When five
63
+ consultations do not clear the blocker, the task needs re-scoping or a
64
+ hand-off to the user — not a sixth round of advice.
65
+
66
+
67
+ ## Process
68
+
69
+ 1. **Check whether the refresh loop is already running this
70
+ session. If it is, skip straight to orchestration — do not schedule a
71
+ second loop.
72
+
73
+ 2. **Register the discipline reminder.** By default, schedule it with
74
+ `ScheduleWakeup` at `delaySeconds: 1200`, prompt `/team-advisor-refresh`,
75
+ where each refresh re-schedules the next one — a 1200-second wakeup costs
76
+ one prompt-cache miss per firing and nothing more (see Gotchas). The loop
77
+ mechanism (`/loop 20m /team-advisor-refresh`) is the escape hatch when
78
+ `ScheduleWakeup` is not available. Either way the reminder is the
79
+ enforcement surface: each firing re-asserts the discipline while the run is
80
+ in flight.
81
+
82
+ 3. **Orchestrate the task.** Hold the plan and the user conversation. Execute
83
+ workflow-backed spawns or resumes using the routing table below, and keep
84
+ driving while they work. Your own tool use stays orchestration and light
85
+ verification reads. Keep your task list updated religiously.
86
+
87
+ ## Workflow Agent Routing
88
+
89
+ Every delegated task runs through a workflow-backed agent invocation. Do not
90
+ spawn a flat subagent directly when a workflow invocation or workflow resume is
91
+ available.
92
+
93
+ | Work | Agent type | Model |
94
+ |---|---|---|
95
+ | Feature, bug, and refactor coding | `clean-coder` | `opus` |
96
+ | Verification passes | `code-verifier` | `opus` |
97
+ | Script runs, GitHub posting, and backfill driving | `general-purpose` runner | `sonnet` |
98
+ | PR descriptions | `pr-description-writer` | `sonnet`, with file-list grounding check |
99
+ | Fan-out searches and checklist verification reads | `Explore` | `haiku`; use `sonnet` when judgment-heavy |
100
+ | Escalated blockers needing a second opinion beyond the orchestrator's own judgment | `code-advisor` | Fable preferred (matches the orchestrating session); opus if Fable is unavailable. |
101
+
102
+ Routing rules:
103
+
104
+ - Use a workflow invocation or resume for each row above. The workflow prompt
105
+ must name the selected agent type, model, work category, task scope, and
106
+ expected output.
107
+ - Resume a warm workflow agent before creating a new workflow run when the warm
108
+ agent holds the relevant context.
109
+ - `clean-coder` owns code edits. `code-verifier` owns verification. The same
110
+ workflow agent never grades work it wrote.
111
+ - PR-description workflows include the actual changed-file list in the prompt
112
+ and verify the final body against that file list before posting or returning
113
+ it.
114
+ - Exploration workflows return file paths, line numbers, and direct evidence;
115
+ they do not write code or mutate repo state.
116
+
117
+ 4. **Executors consult at a hard decision.** Each executor's spawn prompt tells
118
+ it to stop and message you — with the task, what it tried, and the exact
119
+ blocker (plus any short code excerpt that helps) — when one of these holds:
120
+ - It has tried the same problem twice or more and it still fails.
121
+ - A decision changes the deliverable's scope or a contract that is hard to
122
+ reverse.
123
+ - Two constraints conflict and it cannot satisfy both.
124
+ - It is unsure whether to stop or keep going.
125
+
126
+ 5. **Answer with one signal.** On a consultation, reply with exactly one
127
+ signal, brief (about 400 to 700 tokens):
128
+ - **PLAN** — a different approach, as concrete ordered steps the executor
129
+ can run. When a warm agent fits the plan, name which one to resume.
130
+ - **CORRECTION** — the executor's approach is right, one thing is wrong;
131
+ name the wrong step and the fix.
132
+ - **STOP** — no path satisfies the task as assigned; say why so it can be
133
+ reported upward.
134
+ The executor resumes the moment your reply lands.
135
+
136
+ A worked consultation:
137
+
138
+ ```
139
+ Executor → advisor
140
+ Task: add a retry to the upload client.
141
+ Tried: wrapped upload() in a three-attempt loop; the second attempt
142
+ double-posts.
143
+ Blocker: the server takes no idempotency key, so a retry after a timeout
144
+ creates a duplicate record.
145
+
146
+ Advisor → executor
147
+ CORRECTION — the retry loop is right; the missing piece is a stable request
148
+ id. Generate one client-side on the first attempt and send the same id on
149
+ every retry, so the server treats the retries as one request.
150
+ ```
151
+
152
+ For a decision the advisor itself cannot settle, it may use a workflow
153
+ escalation to the tool-less `code-advisor` agent for a second opinion — an
154
+ optional escalation, not a required step.
155
+
156
+ ## Agent reuse (non-negotiable)
157
+
158
+ - **Resume before you spawn, always.** A warm agent carries its context and
159
+ cached tokens; a fresh spawn starts cold and pays to rebuild both.
160
+ Resume the existing workflow agent by name or `agentId` when it holds
161
+ relevant context. Prefer that path every time an existing workflow agent
162
+ matches the routing table.
163
+ - **Spawn a fresh agent only when** no existing agent holds relevant context,
164
+ or a genuine task switch needs a clean context.
165
+ - **Name the agent to resume.** When you answer with a PLAN and a warm agent
166
+ fits, say which agent to resume and where.
167
+
168
+ ## Constraints
169
+
170
+ - One `/team-advisor` per session; the invocation guard blocks a second
171
+ reminder loop.
172
+ - The advisor orchestrates and advises but never edits code or runs a build or
173
+ test itself — executors do that.
174
+ - Delegated execution uses workflow-backed agent invocations and follows the
175
+ Workflow Agent Routing table exactly.
176
+ - Consultations are capped at five per task by default. At the cap, re-scope
177
+ or hand off; do not keep answering.
178
+ - Reuse a warm agent over a cold spawn whenever one holds relevant context.
179
+
180
+ ## File Index
181
+
182
+ | File | Purpose |
183
+ |---|---|
184
+ | `SKILL.md` | Advisor strategy, workflow routing contract, consultation protocol, reuse rules, and constraints. |
185
+
186
+ ## Folder Map
187
+
188
+ - `SKILL.md` — complete advisor workflow instructions.