claude-dev-env 2.7.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/_shared/CLAUDE.md +1 -0
  2. package/_shared/advisor/advisor-protocol.md +19 -9
  3. package/_shared/pr-loop/audit-contract.md +4 -4
  4. package/_shared/pr-loop/precatch-rubric.md +2 -2
  5. package/_shared/process-tree/CLAUDE.md +41 -0
  6. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
  7. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
  8. package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
  9. package/_shared/process-tree/scripts/pyproject.toml +16 -0
  10. package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
  11. package/agents/code-quality-agent.md +6 -5
  12. package/agents/deep-research.md +7 -24
  13. package/agents/docs-agent.md +1 -27
  14. package/agents/issue-tracker.md +1 -7
  15. package/agents/skill-writer-agent.md +1 -2
  16. package/agents/test_agent_frontmatter.py +309 -12
  17. package/hooks/blocking/CLAUDE.md +2 -0
  18. package/hooks/blocking/fable_spawn_gate.py +187 -0
  19. package/hooks/blocking/piped_pytest_blocker.py +1223 -0
  20. package/hooks/blocking/plain_language_blocker.py +287 -15
  21. package/hooks/blocking/test_fable_spawn_gate.py +374 -0
  22. package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
  23. package/hooks/blocking/test_plain_language_blocker.py +277 -2
  24. package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
  25. package/hooks/git-hooks/CLAUDE.md +2 -2
  26. package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
  27. package/hooks/git-hooks/pre_push.py +343 -54
  28. package/hooks/git-hooks/test_pre_push.py +852 -6
  29. package/hooks/hooks.json +9 -19
  30. package/hooks/hooks_constants/CLAUDE.md +2 -0
  31. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
  32. package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
  33. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
  34. package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
  35. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
  36. package/hooks/hooks_constants/shell_command_segments.py +1 -1
  37. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
  38. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
  39. package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
  40. package/package.json +1 -1
  41. package/rules/CLAUDE.md +1 -0
  42. package/rules/ask-user-question-required.md +26 -0
  43. package/rules/claims-as-quotes.md +65 -0
  44. package/scripts/CLAUDE.md +1 -1
  45. package/scripts/_code_review_test_support.py +6 -0
  46. package/scripts/check.ps1 +18 -5
  47. package/scripts/claude_chain_runner.py +203 -31
  48. package/scripts/codec_forwarding_test_support.py +2 -0
  49. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
  50. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
  51. package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
  52. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +28 -24
  53. package/scripts/grok_headless_runner.py +16 -83
  54. package/scripts/spawn_grok_batch.py +38 -1
  55. package/scripts/test_claude_chain_runner.py +358 -0
  56. package/scripts/test_grok_headless_runner.py +18 -63
  57. package/scripts/test_invoke_code_review.py +298 -0
  58. package/scripts/test_resolve_worker_spawn.py +6 -0
  59. package/scripts/test_spawn_grok_batch.py +101 -0
  60. package/scripts/tests/CLAUDE.md +1 -0
  61. package/scripts/tests/test_grok_worker_constants.py +59 -0
  62. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
  63. package/skills/autoconverge/workflow/converge.contract.test.mjs +105 -2
  64. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +34 -1
  65. package/skills/autoconverge/workflow/converge.mjs +66 -33
  66. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
  67. package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
  68. package/skills/codex-review/scripts/run_codex_review.py +16 -64
  69. package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
  70. package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
  71. package/skills/e-code-review/SKILL.md +9 -8
  72. package/skills/e-code-review/reference/fix.md +29 -7
  73. package/skills/e-code-review/reference/loop.md +230 -14
  74. package/skills/e-code-review/reference/low.md +33 -15
  75. package/skills/e-code-review/reference/medium.md +55 -21
  76. package/skills/e-code-review/reference/xhigh.md +30 -12
  77. package/skills/fresh-branch/CLAUDE.md +5 -5
  78. package/skills/fresh-branch/SKILL.md +14 -6
  79. package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
  80. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
  81. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
  82. package/skills/grok-spawn/SKILL.md +5 -0
  83. package/skills/orchestrator/SKILL.md +4 -1
  84. package/skills/orchestrator-refresh/SKILL.md +5 -1
  85. package/skills/team-advisor/SKILL.md +4 -1
@@ -1,17 +1,22 @@
1
1
  #!/usr/bin/env python3
2
- """Run a ``claude`` invocation through a usage-ranked fallback chain.
2
+ """Run a ``claude`` invocation through a fallback chain of account binaries.
3
3
 
4
4
  An automation that shells out to a single ``claude -p ...`` fails outright when
5
5
  that account hits a usage limit. Other logged-in installs sit idle meanwhile.
6
- This module probes remaining weekly usage once per call, ranks chain accounts
7
- highest remaining first, and tries that order. It falls over to the next
8
- ranked binary only on a usage-limit failure. Every other outcome returns to
9
- the caller unchanged.
6
+ By default this module probes remaining weekly usage once per call, ranks chain
7
+ accounts highest remaining first, and tries that order. It falls over to the
8
+ next ranked binary only on a usage-limit failure. Every other outcome returns
9
+ to the caller unchanged.
10
+
11
+ Ordered-account mode (``--routing-mode ordered_account``) walks the chain in
12
+ config order instead, still falling over only on a usage-limit signature.
13
+ Authentication, timeout, and other non-usage failures stop immediately with
14
+ ``terminal_status=advisor_blocked``.
10
15
 
11
16
  The chain lives in ``~/.claude/claude-chain.json``. Copy the committed
12
17
  ``claude-chain.example.json`` template there and list your account binaries.
13
- Try order comes from weekly remaining via ``claude_chain_usage`` (usage-pause
14
- OAuth probe), not from list position alone::
18
+ Default try order comes from weekly remaining via ``claude_chain_usage``
19
+ (usage-pause OAuth probe), not from list position alone::
15
20
 
16
21
  {"chain": [{"command": "claude", "extra_args": []},
17
22
  {"command": "claude-ev", "extra_args": []}]}
@@ -29,7 +34,8 @@ binary in the walk::
29
34
 
30
35
  Import ``run_claude`` for the outcome object, or run the module as a CLI::
31
36
 
32
- python claude_chain_runner.py [--timeout-seconds N] -- <claude args...>
37
+ python claude_chain_runner.py [--timeout-seconds N]
38
+ [--routing-mode usage_ranked|ordered_account] -- <claude args...>
33
39
  """
34
40
 
35
41
  from __future__ import annotations
@@ -51,6 +57,7 @@ if __name__ == "__main__":
51
57
  sys.modules.setdefault("claude_chain_runner", sys.modules[__name__])
52
58
 
53
59
  from dev_env_scripts_constants.claude_chain_constants import (
60
+ ALL_ROUTING_MODES,
54
61
  ALL_USAGE_LIMIT_SIGNATURES,
55
62
  ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
56
63
  ATTEMPT_STATUS_NONZERO_EXIT,
@@ -60,12 +67,14 @@ from dev_env_scripts_constants.claude_chain_constants import (
60
67
  ATTEMPT_SUMMARY_ENTRY_TEMPLATE,
61
68
  ATTEMPT_SUMMARY_JOIN_SEPARATOR,
62
69
  CARRIAGE_RETURN,
70
+ CHAIN_ADVISOR_BLOCKED_EXIT_CODE,
63
71
  CHAIN_CONFIG_ERROR_EXIT_CODE,
64
72
  CHAIN_EXHAUSTED_EXIT_CODE,
65
73
  CHAIN_EXHAUSTED_MESSAGE_TEMPLATE,
66
74
  CHAIN_USAGE_MODULE_NAME,
67
75
  CLAUDE_HOME_SUBDIRECTORY,
68
76
  CLI_ARGUMENTS_SEPARATOR,
77
+ CLI_ROUTING_MODE_FLAG,
69
78
  CLI_TIMEOUT_FLAG,
70
79
  CODEC_ERROR_STRATEGY,
71
80
  CONFIG_CHAIN_EMPTY_REASON,
@@ -85,10 +94,17 @@ from dev_env_scripts_constants.claude_chain_constants import (
85
94
  CONFIG_NOT_OBJECT_REASON,
86
95
  CONFIG_UNREADABLE_MESSAGE_TEMPLATE,
87
96
  CRLF_NEWLINE,
97
+ DEFAULT_ROUTING_MODE,
88
98
  DEFAULT_TIMEOUT_SECONDS,
89
99
  EXAMPLE_CONFIG_FILENAME,
90
100
  LINE_FEED,
91
101
  NO_COMPLETED_PROCESS_RETURN_CODE,
102
+ ROUTING_MODE_ORDERED_ACCOUNT,
103
+ SESSION_ID_JSON_KEY,
104
+ TERMINAL_STATUS_ADVISOR_BLOCKED,
105
+ TERMINAL_STATUS_CHAIN_EXHAUSTED,
106
+ TERMINAL_STATUS_SERVED,
107
+ TERMINAL_STATUS_TIMEOUT,
92
108
  UTF8_ENCODING,
93
109
  )
94
110
 
@@ -257,12 +273,21 @@ class ChainAttempt:
257
273
 
258
274
  @dataclass(frozen=True)
259
275
  class ChainInvocationOutcome:
260
- """Outcome of walking the chain for one call.
276
+ """Outcome of one chain walk: who served, how it ended, optional session id.
277
+
278
+ ::
261
279
 
262
- ``served_command`` names the binary whose response is returned. It is
263
- ``None`` when no binary served the call: every entry was usage-limited or
264
- missing, or the invocation timed out. The ``attempts`` trail records every
265
- binary tried and how it resolved.
280
+ zero exit with JSON session_id
281
+ -> served_command set, terminal_status=served, session_id filled
282
+ ordered_account auth/timeout/generic process error
283
+ -> served_command=None, terminal_status=advisor_blocked
284
+ usage_ranked TimeoutExpired mid-walk
285
+ -> served_command=None, terminal_status=timeout
286
+ every entry usage-limited or missing
287
+ -> served_command=None, terminal_status=chain_exhausted
288
+
289
+ ``attempts`` lists every binary tried. Callers resume later consults with
290
+ ``session_id`` when the bind returned one.
266
291
  """
267
292
 
268
293
  served_command: str | None
@@ -270,6 +295,8 @@ class ChainInvocationOutcome:
270
295
  stdout: str
271
296
  stderr: str
272
297
  attempts: tuple[ChainAttempt, ...]
298
+ terminal_status: str
299
+ session_id: str | None = None
273
300
 
274
301
 
275
302
  class WeeklyUsageAccountReport(Protocol):
@@ -444,17 +471,70 @@ def _is_usage_limit_failure(completion: subprocess.CompletedProcess[str]) -> boo
444
471
  )
445
472
 
446
473
 
474
+ def extract_session_id_from_stdout(stdout_text: str) -> str | None:
475
+ """Return the first ``session_id`` found in Claude JSON stdout.
476
+
477
+ ::
478
+
479
+ '{"type":"result","session_id":"abc","result":"ok"}'
480
+ -> "abc"
481
+ 'not json'
482
+ -> None
483
+
484
+ Accepts a single JSON object or NDJSON event lines. The first non-empty
485
+ string value under the ``session_id`` key wins.
486
+
487
+ Args:
488
+ stdout_text: Captured stdout from a Claude ``--output-format json`` run.
489
+
490
+ Returns:
491
+ The session id string, or ``None`` when none is present.
492
+ """
493
+ stripped_stdout = stdout_text.strip()
494
+ if not stripped_stdout:
495
+ return None
496
+ maybe_session_id = _session_id_from_json_text(stripped_stdout)
497
+ if maybe_session_id is not None:
498
+ return maybe_session_id
499
+ for each_line in stripped_stdout.splitlines():
500
+ stripped_line = each_line.strip()
501
+ if not stripped_line:
502
+ continue
503
+ maybe_session_id = _session_id_from_json_text(stripped_line)
504
+ if maybe_session_id is not None:
505
+ return maybe_session_id
506
+ return None
507
+
508
+
509
+ def _session_id_from_json_text(json_text: str) -> str | None:
510
+ try:
511
+ parsed_payload = json.loads(json_text)
512
+ except json.JSONDecodeError:
513
+ return None
514
+ if not isinstance(parsed_payload, dict):
515
+ return None
516
+ raw_session_id = parsed_payload.get(SESSION_ID_JSON_KEY)
517
+ if isinstance(raw_session_id, str) and raw_session_id:
518
+ return raw_session_id
519
+ return None
520
+
521
+
447
522
  def _served_outcome(
448
523
  served_command: str,
449
524
  completion: subprocess.CompletedProcess[str],
450
525
  all_attempts: list[ChainAttempt],
451
526
  ) -> ChainInvocationOutcome:
527
+ maybe_session_id = None
528
+ if completion.returncode == 0:
529
+ maybe_session_id = extract_session_id_from_stdout(completion.stdout)
452
530
  return ChainInvocationOutcome(
453
531
  served_command=served_command,
454
532
  returncode=completion.returncode,
455
533
  stdout=completion.stdout,
456
534
  stderr=completion.stderr,
457
535
  attempts=tuple(all_attempts),
536
+ terminal_status=TERMINAL_STATUS_SERVED,
537
+ session_id=maybe_session_id,
458
538
  )
459
539
 
460
540
 
@@ -475,6 +555,8 @@ def _timeout_streams(
475
555
  def _no_process_outcome(
476
556
  all_attempts: list[ChainAttempt],
477
557
  timeout_error: subprocess.TimeoutExpired | None,
558
+ *,
559
+ terminal_status: str,
478
560
  ) -> ChainInvocationOutcome:
479
561
  captured_stdout, captured_stderr = _timeout_streams(timeout_error)
480
562
  return ChainInvocationOutcome(
@@ -483,6 +565,23 @@ def _no_process_outcome(
483
565
  stdout=captured_stdout,
484
566
  stderr=captured_stderr,
485
567
  attempts=tuple(all_attempts),
568
+ terminal_status=terminal_status,
569
+ session_id=None,
570
+ )
571
+
572
+
573
+ def _advisor_blocked_outcome(
574
+ completion: subprocess.CompletedProcess[str],
575
+ all_attempts: list[ChainAttempt],
576
+ ) -> ChainInvocationOutcome:
577
+ return ChainInvocationOutcome(
578
+ served_command=None,
579
+ returncode=completion.returncode,
580
+ stdout=completion.stdout,
581
+ stderr=completion.stderr,
582
+ attempts=tuple(all_attempts),
583
+ terminal_status=TERMINAL_STATUS_ADVISOR_BLOCKED,
584
+ session_id=None,
486
585
  )
487
586
 
488
587
 
@@ -491,13 +590,19 @@ def _exhausted_outcome(
491
590
  last_usage_limited: subprocess.CompletedProcess[str] | None,
492
591
  ) -> ChainInvocationOutcome:
493
592
  if last_usage_limited is None:
494
- return _no_process_outcome(all_attempts, None)
593
+ return _no_process_outcome(
594
+ all_attempts,
595
+ None,
596
+ terminal_status=TERMINAL_STATUS_CHAIN_EXHAUSTED,
597
+ )
495
598
  return ChainInvocationOutcome(
496
599
  served_command=None,
497
600
  returncode=last_usage_limited.returncode,
498
601
  stdout=last_usage_limited.stdout,
499
602
  stderr=last_usage_limited.stderr,
500
603
  attempts=tuple(all_attempts),
604
+ terminal_status=TERMINAL_STATUS_CHAIN_EXHAUSTED,
605
+ session_id=None,
501
606
  )
502
607
 
503
608
 
@@ -505,6 +610,8 @@ def _classify_completion(
505
610
  entry: ChainEntry,
506
611
  completion: subprocess.CompletedProcess[str],
507
612
  all_attempts: list[ChainAttempt],
613
+ *,
614
+ routing_mode: str,
508
615
  ) -> ChainInvocationOutcome | None:
509
616
  if completion.returncode == 0:
510
617
  all_attempts.append(ChainAttempt(entry.command, ATTEMPT_STATUS_SERVED))
@@ -513,6 +620,8 @@ def _classify_completion(
513
620
  all_attempts.append(ChainAttempt(entry.command, ATTEMPT_STATUS_USAGE_LIMITED))
514
621
  return None
515
622
  all_attempts.append(ChainAttempt(entry.command, ATTEMPT_STATUS_NONZERO_EXIT))
623
+ if routing_mode == ROUTING_MODE_ORDERED_ACCOUNT:
624
+ return _advisor_blocked_outcome(completion, all_attempts)
516
625
  return _served_outcome(entry.command, completion, all_attempts)
517
626
 
518
627
 
@@ -529,27 +638,50 @@ def _ranked_entries_or_config_order(
529
638
  return list(all_entries)
530
639
 
531
640
 
641
+ def _resolve_walk_entries(
642
+ all_entries: list[ChainEntry],
643
+ config_path: Path,
644
+ routing_mode: str,
645
+ ) -> list[ChainEntry]:
646
+ if routing_mode == ROUTING_MODE_ORDERED_ACCOUNT:
647
+ return list(all_entries)
648
+ return _ranked_entries_or_config_order(all_entries, config_path)
649
+
650
+
651
+ def _require_known_routing_mode(routing_mode: str) -> str:
652
+ if routing_mode not in ALL_ROUTING_MODES:
653
+ raise ValueError(
654
+ f"Unknown routing_mode {routing_mode!r}; "
655
+ f"expected one of {sorted(ALL_ROUTING_MODES)}"
656
+ )
657
+ return routing_mode
658
+
659
+
532
660
  def run_claude(
533
661
  all_claude_arguments: list[str],
534
662
  *,
535
663
  timeout_seconds: int,
536
664
  stdin_text: str | None = None,
665
+ routing_mode: str = DEFAULT_ROUTING_MODE,
537
666
  ) -> ChainInvocationOutcome:
538
- """Run *all_claude_arguments* through the usage-ranked fallback chain.
667
+ """Run *all_claude_arguments* through the fallback chain.
539
668
 
540
669
  ::
541
670
 
542
- highest remaining usage-limited, next ranked ok
543
- -> served_command=next ranked, returncode=0
544
- first try nonzero without usage-limit signature
545
- -> served_command=first try (no fallover)
546
- stdin_text set
547
- -> same text on every attempt's stdin
671
+ usage_ranked (default): highest remaining first
672
+ ordered_account: config order; usage-limit-only fallover
673
+ ordered_account + auth/timeout/generic process error
674
+ -> terminal_status=advisor_blocked (no fallover)
675
+ zero exit with JSON session_id
676
+ -> outcome.session_id set for later --resume
548
677
 
549
- Probes weekly remaining once, ranks highest first, then walks that order.
678
+ Default mode probes weekly remaining once, ranks highest first, then walks
679
+ that order. Ordered-account mode walks config order and never probes usage.
550
680
  Only a usage-limit failure falls over. Missing binaries are skipped and the
551
- walk continues; timeout and other nonzero exits stop. When usage ranking
552
- infrastructure fails to load, the walk uses config order instead.
681
+ walk continues; timeout and other nonzero exits stop. In ordered-account
682
+ mode those non-usage stops report ``advisor_blocked``. When usage ranking
683
+ infrastructure fails to load under usage-ranked mode, the walk uses config
684
+ order instead.
553
685
 
554
686
  Args:
555
687
  all_claude_arguments: Arguments passed after the binary name, such as
@@ -557,20 +689,25 @@ def run_claude(
557
689
  timeout_seconds: Timeout applied to each binary invocation.
558
690
  stdin_text: Optional UTF-8 text forwarded as stdin to every binary.
559
691
  ``None`` leaves the subprocess without a piped stdin body.
692
+ routing_mode: ``usage_ranked`` (default) or ``ordered_account``.
560
693
 
561
694
  Returns:
562
- The outcome of the walk, naming the serving binary and the full
563
- attempt trail.
695
+ The outcome of the walk, naming the serving binary, terminal status,
696
+ optional session id, and the full attempt trail.
564
697
 
565
698
  Raises:
566
699
  ChainConfigurationError: When the chain configuration cannot be loaded.
700
+ ValueError: When *routing_mode* is not a known mode.
567
701
  """
702
+ selected_routing_mode = _require_known_routing_mode(routing_mode)
568
703
  config_path = chain_config_path()
569
704
  all_entries = load_chain(config_path)
570
- all_ranked_entries = _ranked_entries_or_config_order(all_entries, config_path)
705
+ all_walk_entries = _resolve_walk_entries(
706
+ all_entries, config_path, selected_routing_mode
707
+ )
571
708
  all_attempts: list[ChainAttempt] = []
572
709
  last_usage_limited: subprocess.CompletedProcess[str] | None = None
573
- for each_entry in all_ranked_entries:
710
+ for each_entry in all_walk_entries:
574
711
  try:
575
712
  completion = chain_subprocess_runner(
576
713
  _build_invocation(each_entry, all_claude_arguments),
@@ -586,13 +723,27 @@ def run_claude(
586
723
  all_attempts.append(
587
724
  ChainAttempt(each_entry.command, ATTEMPT_STATUS_TIMEOUT)
588
725
  )
589
- return _no_process_outcome(all_attempts, timeout_error)
726
+ timeout_terminal_status = (
727
+ TERMINAL_STATUS_ADVISOR_BLOCKED
728
+ if selected_routing_mode == ROUTING_MODE_ORDERED_ACCOUNT
729
+ else TERMINAL_STATUS_TIMEOUT
730
+ )
731
+ return _no_process_outcome(
732
+ all_attempts,
733
+ timeout_error,
734
+ terminal_status=timeout_terminal_status,
735
+ )
590
736
  except FileNotFoundError:
591
737
  all_attempts.append(
592
738
  ChainAttempt(each_entry.command, ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND)
593
739
  )
594
740
  continue
595
- terminal_outcome = _classify_completion(each_entry, completion, all_attempts)
741
+ terminal_outcome = _classify_completion(
742
+ each_entry,
743
+ completion,
744
+ all_attempts,
745
+ routing_mode=selected_routing_mode,
746
+ )
596
747
  if terminal_outcome is not None:
597
748
  return terminal_outcome
598
749
  last_usage_limited = completion
@@ -610,6 +761,16 @@ def _build_argument_parser() -> argparse.ArgumentParser:
610
761
  default=DEFAULT_TIMEOUT_SECONDS,
611
762
  help="Timeout in seconds applied to each binary invocation.",
612
763
  )
764
+ parser.add_argument(
765
+ CLI_ROUTING_MODE_FLAG,
766
+ dest="routing_mode",
767
+ choices=sorted(ALL_ROUTING_MODES),
768
+ default=DEFAULT_ROUTING_MODE,
769
+ help=(
770
+ "Chain routing: usage_ranked (default) or ordered_account "
771
+ "(config order, usage-limit-only fallover)."
772
+ ),
773
+ )
613
774
  parser.add_argument("passthrough", nargs=argparse.REMAINDER)
614
775
  return parser
615
776
 
@@ -639,12 +800,18 @@ def _read_piped_stdin_text() -> str | None:
639
800
  def main(all_command_arguments: list[str]) -> int:
640
801
  """Walk the chain for CLI arguments and return the process exit code.
641
802
 
803
+ ::
804
+
805
+ main(["--", "-p", "hi"])
806
+ main(["--routing-mode", "ordered_account", "--", "-p", "hi"])
807
+
642
808
  Args:
643
809
  all_command_arguments: The argument vector after the program name.
644
810
 
645
811
  Returns:
646
812
  The served binary's return code, a distinct code when the chain is
647
- exhausted, or a distinct code when the configuration cannot be loaded.
813
+ exhausted or advisor-blocked, or a distinct code when the configuration
814
+ cannot be loaded.
648
815
  """
649
816
  parser = _build_argument_parser()
650
817
  parsed_arguments = parser.parse_args(all_command_arguments)
@@ -655,10 +822,15 @@ def main(all_command_arguments: list[str]) -> int:
655
822
  all_claude_arguments,
656
823
  timeout_seconds=parsed_arguments.timeout_seconds,
657
824
  stdin_text=maybe_stdin_text,
825
+ routing_mode=parsed_arguments.routing_mode,
658
826
  )
659
827
  except ChainConfigurationError as configuration_error:
660
828
  print(str(configuration_error), file=sys.stderr)
661
829
  return CHAIN_CONFIG_ERROR_EXIT_CODE
830
+ if chain_outcome.terminal_status == TERMINAL_STATUS_ADVISOR_BLOCKED:
831
+ sys.stdout.write(chain_outcome.stdout)
832
+ sys.stderr.write(chain_outcome.stderr)
833
+ return CHAIN_ADVISOR_BLOCKED_EXIT_CODE
662
834
  if chain_outcome.served_command is None:
663
835
  print(_exhausted_message(chain_outcome.attempts), file=sys.stderr)
664
836
  return CHAIN_EXHAUSTED_EXIT_CODE
@@ -8,6 +8,7 @@ from collections.abc import Sequence
8
8
  import claude_chain_runner as chain_runner
9
9
  import pytest
10
10
  from claude_chain_runner import ChainAttempt, ChainInvocationOutcome
11
+ from dev_env_scripts_constants.claude_chain_constants import TERMINAL_STATUS_SERVED
11
12
 
12
13
  FIXTURE_ENCODING_KEYWORD_NAME = "encoding"
13
14
  FIXTURE_ERRORS_KEYWORD_NAME = "errors"
@@ -23,6 +24,7 @@ def build_served_outcome(*, chain_stdout: str) -> ChainInvocationOutcome:
23
24
  stdout=chain_stdout,
24
25
  stderr="",
25
26
  attempts=(ChainAttempt(command=FIXTURE_SERVED_COMMAND, status="served"),),
27
+ terminal_status=TERMINAL_STATUS_SERVED,
26
28
  )
27
29
 
28
30
 
@@ -8,12 +8,12 @@ Named constants for scripts in `scripts/`. Follows the project convention that t
8
8
  |---|---|
9
9
  | `timing.py` | `sweep_empty_dirs.py` - `DEFAULT_AGE_SECONDS` (smallest age before an empty directory is eligible for removal) and `DEFAULT_POLL_INTERVAL` (seconds between sweep passes in continuous-watch mode); `spawn_grok_batch.py` - `WORKER_STAGGER_SECONDS` (seconds between staggered headless grok worker starts); `invoke_code_review.py` - `DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS` (timeout for one headless `/code-review` chain run) |
10
10
  | `gh_artifact_upload_constants.py` | `gh_artifact_upload.py` - the `artifacts` release tag, title, and notes body, the GitHub CLI binary name, the asset-name timestamp format and template, the asset download URL template, the notes-file suffix, and the text encoding |
11
- | `claude_chain_constants.py` | `claude_chain_runner.py` - the chain config filename and home subdirectory, the usage-limit signature text, the per-binary status labels, the default timeout, CLI flag and separator tokens, config JSON keys (including optional `credentials_path`), invalid-shape reason text, config-error and exhausted-chain message templates, and CLI exit codes; `invoke_code_review.py` and `resolve_worker_spawn.py` - the text-codec keyword names and the `collect_forwarded_text_codec` helper that forwards them to the subprocess runner for text-mode capture |
11
+ | `claude_chain_constants.py` | `claude_chain_runner.py` - the chain config filename and home subdirectory, the usage-limit signature text, the per-binary status labels, routing-mode tokens (`usage_ranked` / `ordered_account`), outcome `terminal_status` values (`served` / `advisor_blocked` / `chain_exhausted` / `timeout`), the default timeout, CLI flag and separator tokens, config JSON keys (including optional `credentials_path`), invalid-shape reason text, config-error and exhausted-chain message templates, and CLI exit codes (including advisor-blocked); `invoke_code_review.py` and `resolve_worker_spawn.py` - the text-codec keyword names and the `collect_forwarded_text_codec` helper that forwards them to the subprocess runner for text-mode capture |
12
12
  | `claude_chain_usage_constants.py` | `claude_chain_usage.py` - full weekly percent scale, usage-pause skill path segments, CLI config-path flag, JSON report keys, and probe error message templates |
13
- | `grok_worker_constants.py` | `grok_worker_preflight.py`, `grok_headless_runner.py`, `spawn_grok_batch.py`, and `resolve_worker_spawn.py` - the `grok` binary name and CLI flags, model and subcommand tokens, leader-socket and scratch-file name parts, auth and usage-limit signature lists, outcome classifications, fallthrough reasons, tool-profile names and prompt headers, worker timeouts (default, plus the floor and the `MAXIMUM_WORKER_TIMEOUT_SECONDS` ceiling that the batch spec parse, the headless runner, and the spawn dispatcher all enforce, each bound carrying one rejection template shared by those sites, plus launch-failure return code and post-kill grace), the process-tree kill command, its flags, its attempt limit and kill-failed classification, the preflight ping's single-turn cap, ping-cache keys and TTL, batch-spec and summary JSON keys, the prompt-part and report-stream join separators, and the CLI launch-error stderr prefix |
14
- | `code_review_constants.py` | `invoke_code_review.py` - the `/code-review ultra --fix` prompt, opus model alias, permission-mode flag and value, result mode and JSON keys, session-model CLI flag, git dirty-check tokens, and in-session return markers |
13
+ | `grok_worker_constants.py` | `grok_worker_preflight.py`, `grok_headless_runner.py`, `spawn_grok_batch.py`, and `resolve_worker_spawn.py` - the `grok` binary name and CLI flags, model and subcommand tokens, leader-socket and scratch-file name parts, auth and usage-limit signature lists, outcome classifications, fallthrough reasons, tool-profile names and prompt headers, worker timeouts (default, plus the floor and the `MAXIMUM_WORKER_TIMEOUT_SECONDS` ceiling that the batch spec parse, the headless runner, and the spawn dispatcher all enforce, each bound carrying one rejection template shared by those sites, plus launch-failure return code and post-kill grace), the process-tree kill attempt limit and kill-failed classification, the preflight ping's single-turn cap, ping-cache keys and TTL, batch-spec and summary JSON keys, the prompt-part and report-stream join separators, and the CLI launch-error stderr prefix |
14
+ | `code_review_constants.py` | `invoke_code_review.py` - the `/code-review ultra --fix` prompt, opus model alias, permission-mode flag and value, result mode and JSON keys, session-model CLI flag, git dirty-check tokens, and in-session return markers; finding severity tokens (`blocker` / `high` / `medium` / `low` / `nit`), verification verdict tokens, loop terminal tokens (`clean` / `nits_fixed` / `advisor_blocked`), and pure helpers for retained-finding checks, head recording, terminal resolution, and terminal encoding |
15
15
  | `__init__.py` | Empty package marker |
16
16
 
17
17
  ## Convention
18
18
 
19
- Every constant is `UPPER_SNAKE_CASE` with an explicit type annotation and a docstring. Scripts import from here rather than embedding literal values in their bodies.
19
+ Every constant is `UPPER_SNAKE_CASE` with an explicit type annotation and a docstring. Scripts import from here rather than embedding literal values in their bodies. `code_review_constants.py` also exports pure helpers that resolve review-loop terminals from those tokens.
@@ -126,9 +126,47 @@ CHAIN_CONFIG_ERROR_EXIT_CODE: int = 3
126
126
  CLI_TIMEOUT_FLAG: str = "--timeout-seconds"
127
127
  """CLI flag that overrides the per-invocation timeout in seconds."""
128
128
 
129
+ CLI_ROUTING_MODE_FLAG: str = "--routing-mode"
130
+ """CLI flag that selects usage-ranked or ordered-account chain routing."""
131
+
129
132
  CLI_ARGUMENTS_SEPARATOR: str = "--"
130
133
  """CLI token separating runner flags from the passthrough claude arguments."""
131
134
 
135
+ ROUTING_MODE_USAGE_RANKED: str = "usage_ranked"
136
+ """Default routing: probe weekly remaining and try highest remaining first."""
137
+
138
+ ROUTING_MODE_ORDERED_ACCOUNT: str = "ordered_account"
139
+ """Explicit routing: walk chain entries in config order; usage-limit-only fallover."""
140
+
141
+ DEFAULT_ROUTING_MODE: str = ROUTING_MODE_USAGE_RANKED
142
+ """Routing mode applied when the caller does not name one."""
143
+
144
+ ALL_ROUTING_MODES: frozenset[str] = frozenset(
145
+ {
146
+ ROUTING_MODE_USAGE_RANKED,
147
+ ROUTING_MODE_ORDERED_ACCOUNT,
148
+ }
149
+ )
150
+ """Accepted values for the routing-mode parameter and CLI flag."""
151
+
152
+ TERMINAL_STATUS_SERVED: str = "served"
153
+ """Outcome status when a binary served the call (zero or non-usage nonzero)."""
154
+
155
+ TERMINAL_STATUS_ADVISOR_BLOCKED: str = "advisor_blocked"
156
+ """Outcome status when ordered-account mode stops on a non-usage failure."""
157
+
158
+ TERMINAL_STATUS_CHAIN_EXHAUSTED: str = "chain_exhausted"
159
+ """Outcome status when every chain entry was usage-limited or missing."""
160
+
161
+ TERMINAL_STATUS_TIMEOUT: str = "timeout"
162
+ """Outcome status when a usage-ranked walk stops on TimeoutExpired mid-walk."""
163
+
164
+ SESSION_ID_JSON_KEY: str = "session_id"
165
+ """JSON key read from Claude ``--output-format json`` events for resume."""
166
+
167
+ CHAIN_ADVISOR_BLOCKED_EXIT_CODE: int = 4
168
+ """CLI exit code when ordered-account mode stops with advisor_blocked."""
169
+
132
170
  CONFIG_NOT_OBJECT_REASON: str = "the top-level value is not a JSON object"
133
171
  """Reason detail when the config root is not an object."""
134
172