brindle 0.2.1__tar.gz → 0.3.0__tar.gz

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. {brindle-0.2.1 → brindle-0.3.0}/PKG-INFO +51 -11
  2. {brindle-0.2.1 → brindle-0.3.0}/README.md +50 -10
  3. {brindle-0.2.1 → brindle-0.3.0}/pyproject.toml +3 -1
  4. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/agents.py +63 -10
  5. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/autopilot.py +5 -0
  6. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_agents/developer-light.md +2 -2
  7. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/ci_adapters.py +83 -11
  8. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/ci_client.py +368 -13
  9. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/ci_federation.py +250 -0
  10. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/ci_hosts.py +65 -6
  11. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/cli.py +53 -5
  12. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/config.py +6 -2
  13. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/cost.py +12 -1
  14. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/cull.py +61 -0
  15. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/doctor.py +80 -0
  16. brindle-0.3.0/src/brindle/keys_cmds.py +103 -0
  17. brindle-0.2.1/src/brindle/pro/credentials.py → brindle-0.3.0/src/brindle/keystore.py +145 -39
  18. brindle-0.3.0/src/brindle/model_access.py +293 -0
  19. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/native/runner.py +4 -1
  20. brindle-0.3.0/src/brindle/pane_auth.py +56 -0
  21. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pipeline.py +70 -0
  22. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pool.py +15 -11
  23. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pricing.py +2 -1
  24. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/account.py +68 -8
  25. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/auth.py +29 -6
  26. brindle-0.3.0/src/brindle/pro/credentials.py +53 -0
  27. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/managed_models.py +53 -3
  28. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/org_profiles.py +33 -0
  29. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/team_policy.py +39 -3
  30. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/procs.py +23 -0
  31. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/profiles.py +33 -2
  32. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/providers.py +162 -9
  33. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/quota.py +14 -0
  34. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/secrets.py +54 -8
  35. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/workspaces.py +27 -7
  36. {brindle-0.2.1 → brindle-0.3.0}/.gitignore +0 -0
  37. {brindle-0.2.1 → brindle-0.3.0}/LICENSE +0 -0
  38. {brindle-0.2.1 → brindle-0.3.0}/SCHEDULE-A +0 -0
  39. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/__init__.py +0 -0
  40. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/__main__.py +0 -0
  41. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/account.py +0 -0
  42. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/airgap.py +0 -0
  43. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/antigravity.py +0 -0
  44. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/budget.py +0 -0
  45. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_agents/__init__.py +0 -0
  46. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  47. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_agents/developer-codex.md +0 -0
  48. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  49. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_agents/developer-local.md +0 -0
  50. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_agents/developer.md +0 -0
  51. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_agents/qa.md +0 -0
  52. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
  53. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_agents/reviewer-local.md +0 -0
  54. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_agents/reviewer.md +0 -0
  55. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_agents/subagent.md +0 -0
  56. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_agents/supervisor.md +0 -0
  57. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_rules/__init__.py +0 -0
  58. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_rules/security/backend.md +0 -0
  59. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  60. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/builtin_rules/tests/only.md +0 -0
  61. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/codemap.py +0 -0
  62. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/codex_hook.py +0 -0
  63. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/conflicts.py +0 -0
  64. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/cost_estimate.py +0 -0
  65. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/db.py +0 -0
  66. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/demo.py +0 -0
  67. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/detect.py +0 -0
  68. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/escapes.py +0 -0
  69. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/events.py +0 -0
  70. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/gates.py +0 -0
  71. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/git.py +0 -0
  72. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/guardrails.py +0 -0
  73. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/history.py +0 -0
  74. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/inbox.py +0 -0
  75. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/learned_rules.py +0 -0
  76. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/learning.py +0 -0
  77. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/legacy.py +0 -0
  78. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/mcp_server.py +0 -0
  79. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/native/__init__.py +0 -0
  80. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/native/client.py +0 -0
  81. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/native/loop.py +0 -0
  82. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/native/permissions.py +0 -0
  83. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/native/serve.py +0 -0
  84. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/native/tools.py +0 -0
  85. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/permissions.py +0 -0
  86. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/plugins.py +0 -0
  87. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/policy.py +0 -0
  88. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/__init__.py +0 -0
  89. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/_files.py +0 -0
  90. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/audit_chain.py +0 -0
  91. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/audit_export.py +0 -0
  92. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/brief_learning.py +0 -0
  93. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/cost_centers.py +0 -0
  94. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/features.py +0 -0
  95. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/keys.py +0 -0
  96. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/learning.py +0 -0
  97. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/license.py +0 -0
  98. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/loopback.py +0 -0
  99. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/orgkey.py +0 -0
  100. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/rollout.py +0 -0
  101. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/settings_sync.py +0 -0
  102. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/status.py +0 -0
  103. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/pro/team_events.py +0 -0
  104. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/repo_cmds.py +0 -0
  105. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/repos.py +0 -0
  106. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/rewind.py +0 -0
  107. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/rule_checks.py +0 -0
  108. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/savings.py +0 -0
  109. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/scratch.py +0 -0
  110. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/services.py +0 -0
  111. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/sessions.py +0 -0
  112. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/sidebar_follow.py +0 -0
  113. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/status_cache.py +0 -0
  114. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/tasks.py +0 -0
  115. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/tmux.py +0 -0
  116. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/usage.py +0 -0
  117. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/view.py +0 -0
  118. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/watch.py +0 -0
  119. {brindle-0.2.1 → brindle-0.3.0}/src/brindle/watch_costs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brindle
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Run coding agents in parallel, each on its own git branch, with a supervisor that delegates, reviews, and merges
5
5
  Project-URL: Homepage, https://pawdelta.com/brindle/
6
6
  Project-URL: Documentation, https://pawdelta.com/brindle/docs/recommended-use
@@ -89,7 +89,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
89
89
  brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
90
90
 
91
91
  `brindle --version` prints the installed version; the tmux status bar of every brindle
92
- session shows it too (`brindle 0.2.1`). A session started before an upgrade keeps
92
+ session shows it too (`brindle 0.3.0`). A session started before an upgrade keeps
93
93
  running the old code, and shows the old number, until you restart it.
94
94
 
95
95
  ## Quick start
@@ -350,6 +350,7 @@ your own status line prints, so what you see doesn't change.
350
350
  | `brindle transfer [REPO] [--from SESSION] [-b BRANCH]` | move a scratch session's work into a real repo |
351
351
  | `brindle ls [--all]` | workspaces and agents |
352
352
  | `brindle repo add PATH [--name ALIAS] / rm ALIAS / ls` | attach other local repos to the current session so workers can go there (brindle Pro; see "Several repos in one session") |
353
+ | `brindle keys set/unset/list [NAME]` | store model API keys (e.g. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) once in the OS keychain (or a 0600 file); every agent pane gets the ones its provider reads, unless you already export it. `set` reads the value from a hidden prompt or stdin, `list` shows names and the last 4 characters. `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) is never stored |
353
354
  | `brindle history [--limit N] [--kind K] [--all]` | durable log of worker results, reviews, merges and milestone checks |
354
355
  | `brindle cost [--days N] [--all]` | dollar spend at list prices over the last 30 days, by model |
355
356
  | `brindle cost report [--days N] [--all]` | Pro: spend by day, profile and goal, cost per merged branch, review pass rate per worker profile |
@@ -361,7 +362,7 @@ your own status line prints, so what you see doesn't change.
361
362
  | `brindle cost estimate [--tasks N] [--weight W] [--profile P] [--reviewer R]` | what the current goal (or N tasks) is likely to cost, as a range from this repo's history (median to p80 of past worker and review runs), plus a cheaper alternative with a different reviewer; says "low confidence" when there's too little history. Also added to `set_goal` and `assign` replies (brindle Pro) |
362
363
  | `brindle learning` | whether brindle Pro's hosted learning is on for this repo, and if not, why (nothing is learned on your machine) |
363
364
  | `brindle learning seed` | Pro: sends this repo's finished routing history to the hosted learner once, so it starts with what this machine has seen. Only the usual coarse record fields leave (kind/size one-hots, counters, HMAC keys), never task text or paths. A second run sends nothing new; decisions already sent live are skipped |
364
- | `brindle account [login\|logout\|status\|upgrade\|portal\|org]` | paid features: bare `brindle account` shows what your plan has and how to get the rest (see "brindle Pro and Team" below) |
365
+ | `brindle account [login\|logout\|status\|upgrade\|portal\|seats\|org]` | paid features: bare `brindle account` shows what your plan has and how to get the rest (see "brindle Pro and Team" below) |
365
366
  | `brindle audit verify\|export\|pubkey\|ship\|prune` | the local tamper-evident audit log, and shipping it to your SIEM (brindle Enterprise; see "Audit log" below) |
366
367
  | `brindle watch [--all] [--once]` | the dashboard on its own (the same view as the sidebar): enter attaches, `p` peeks, `x` closes |
367
368
  | `brindle sidebar` | bring this session's sidebar into the tmux session you're in (also `Ctrl-b S` in a window without one); restarts it if it was closed |
@@ -526,6 +527,7 @@ Autopilot, merge gates and cleanup:
526
527
  | `max_agents` | `4` | workers running at once per session (`0`: no cap) |
527
528
  | `check_timeout` | `900` | seconds each check may take |
528
529
  | `check_concurrency` | `2` | check runs at once on this machine, across branches; the rest wait their turn (`0`: no cap). A run far past its last duration is reported to the supervisor |
530
+ | `fable_escalation` | on, unless the worker runs on a cloud account (Bedrock, Vertex or Foundry) | retry a failed heavy task once on `developer-heavy` (Fable) before handing it to you; `true` turns it on for a cloud account, `false` off |
529
531
  | `usage_limit` | `90` | autopilot stops pushing on at this % of your Claude usage limit |
530
532
  | `limit_cooldown_minutes` | `300` for Antigravity | how long a provider that hit its limit counts as unavailable |
531
533
  | `graphify` | if the graph is there | point agents at the repo's [graphify](https://github.com/safishamsi/graphify) code map (`false` turns it off) |
@@ -589,9 +591,9 @@ keep theirs):
589
591
  ```json
590
592
  {
591
593
  "routing": {
592
- "light": ["developer", "developer-codex", "developer-local"],
594
+ "light": ["developer-light", "developer", "developer-codex", "developer-local"],
593
595
  "medium": ["developer", "developer-codex", "developer-heavy"],
594
- "heavy": ["developer-heavy", "developer", "developer-codex"]
596
+ "heavy": ["developer", "developer-heavy", "developer-codex"]
595
597
  }
596
598
  }
597
599
  ```
@@ -599,7 +601,9 @@ keep theirs):
599
601
  No tier prefers another provider for its own sake: all-Claude or all-Codex may
600
602
  be what works best for you, and that's for the learner to find out.
601
603
 
602
- `developer-codex` runs on Codex; `developer-heavy` on Claude Fable at high effort.
604
+ `developer-light` runs on Claude Haiku at low effort; `developer` on your Claude
605
+ Code default model; `developer-codex` on Codex; `developer-heavy` on Claude Fable
606
+ at high effort.
603
607
  A profile is skipped when its CLI isn't installed (`claude`, `codex`, `agy`), the
604
608
  local model server isn't answering, or its provider is at your `usage_limit`. If
605
609
  hosted learning is on it chooses among the profiles left; otherwise the first
@@ -762,7 +766,9 @@ brindle account login # opens your browser to sign in (--device: enter a co
762
766
  brindle account status # your plan, features, hosted learning on or off, when the entitlement expires
763
767
  brindle account savings # what hosted learning's picks gained in this repo, this month and last: estimates, from this machine's records only
764
768
  brindle account upgrade # opens the checkout for brindle Pro (and prints its URL)
765
- brindle account portal # opens the billing portal (invoices, seats, cancellation); --org ORG for a team org
769
+ brindle account upgrade --enterprise [--trial] --seats N --org ORG # brindle Enterprise for a team org; --trial: free trial, at most 10 seats
770
+ brindle account portal # opens the billing portal (invoices, payment method, cancellation); --org ORG for a team org
771
+ brindle account seats N # set a Team or Enterprise org's seat count (billing admin); --org ORG; Stripe prorates the change
766
772
  brindle account org list # the orgs you belong to; `org use <id>` switches, `org policy` shows the current one
767
773
  brindle account org policy # the org's policy, its per-role overrides, and the policy that applies to you
768
774
  brindle account org profiles # the org's shared profiles and rule packs, and which are pinned
@@ -825,6 +831,16 @@ entitlement.
825
831
  * `deny_personal_keys`: the personal model keys (`ANTHROPIC_API_KEY`,
826
832
  `OPENAI_API_KEY`, ...) are taken out of every agent pane, and a profile that
827
833
  sets one is refused.
834
+ * An org-owned key: with `provider` `anthropic` (or `openai-compatible`), set
835
+ `key_env` to the name of a variable that holds the org's key (not a personal
836
+ one like `ANTHROPIC_API_KEY`), or `key_helper` to a command, as a list of
837
+ arguments, that prints the key (for example `["vault", "kv", "get",
838
+ "-field=key", "secret/anthropic"]`). brindle runs the helper on the person's
839
+ machine, without a shell and with a timeout, each time a worker starts, and
840
+ gives the key to that worker as `ANTHROPIC_API_KEY` (`OPENAI_API_KEY` for
841
+ Codex). It passes `deny_personal_keys`; a personal key doesn't. The key never
842
+ goes through brindle's servers. When the variable is missing or the helper
843
+ fails, the worker doesn't start.
828
844
 
829
845
  Two things to know when you turn on `deny_personal_keys`:
830
846
 
@@ -985,17 +1001,31 @@ workflows call these commands:
985
1001
  commits the workflows on a `brindle/ci-setup` branch, then puts you back on
986
1002
  the branch you started on and deletes the local setup branch, so `git pull`
987
1003
  after merging the setup pull request is clean (commit or stash your changes
988
- first: it refuses to start with uncommitted work).
1004
+ first: it refuses to start with uncommitted work). With your own cloud
1005
+ (`--credential bedrock|vertex|foundry`, keyless GitHub OIDC) it can read the
1006
+ IDs from the deployment template's outputs instead of asking:
1007
+ `--from-stack NAME [--region R]` (AWS CloudFormation), `--from-terraform DIR`
1008
+ (Google) or `--from-deployment NAME -g RG` (Azure). They are defaults for
1009
+ `--cloud-var`, which still overrides them, and every ID is format-checked. A
1010
+ missing `aws`, `terraform` or `az` CLI or output is an error that names the
1011
+ `--cloud-var` to pass instead. Vertex AI and Microsoft Foundry are in
1012
+ **preview** (not yet verified end to end): `init` refuses them unless you pass
1013
+ `--preview` or set `BRINDLE_CI_PREVIEW_CLOUDS=1`, and `doctor` marks them
1014
+ `(preview)`. Bedrock is generally available.
989
1015
  - `brindle ci doctor`: which provider CLIs and credential names a runner has, and
990
- which providers CI may use on this repository.
1016
+ which providers CI may use on this repository. `--models` also makes one tiny
1017
+ call per pinned model and says why any is refused (a quota, a form not filled in).
991
1018
  - `brindle ci start`: start a run for an issue, a goal text or a dispatched run,
992
1019
  or a validation of a pull request.
993
1020
  - `brindle ci run`: run what was started: the supervisor and the result upload,
994
1021
  or the pull request's checks and reviews and the evidence upload.
995
1022
  - `brindle ci report`: tell the service how the workflow's jobs ended.
996
1023
 
997
- Credential names are reported, never values. On a repository owned by a GitHub
998
- organization, a provider signed in only with a personal subscription is not used.
1024
+ Credential names are reported, never values. CI uses API keys, cloud sign-ins
1025
+ (Bedrock, Vertex, Foundry) or identity federation only: a provider signed in
1026
+ only with a subscription (`CLAUDE_CODE_OAUTH_TOKEN`, a Codex ChatGPT login) is
1027
+ not used, on personal and organization repositories alike. Add an API key
1028
+ secret instead.
999
1029
 
1000
1030
  Fix builds only fix the default branch's *required* status checks, and a new
1001
1031
  repository has none. When the default branch requires none, `brindle ci init`
@@ -1240,6 +1270,16 @@ needs no browser step. Put the key in your environment, or in a profile's
1240
1270
  `brindle doctor` names the variable each CLI gets, from your shell or from
1241
1271
  which profiles.
1242
1272
 
1273
+ A profile chooses between the two with `auth`:
1274
+
1275
+ | `auth:` | What the worker signs in with |
1276
+ |---|---|
1277
+ | `auto` (default) | A key from your environment or `brindle keys` if there is one, else the CLI's own login. |
1278
+ | `subscription` | Always the CLI's own login: brindle strips that provider's API keys from the pane. |
1279
+ | `api_key` | Only a key: the worker refuses to start without one. |
1280
+
1281
+ Your org's `deny_personal_keys` (Enterprise) still applies on top of `auth`.
1282
+
1243
1283
  ### Cheap workers
1244
1284
 
1245
1285
  By default a Claude Code worker loads everything your own `claude` does: your
@@ -59,7 +59,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
59
59
  brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
60
60
 
61
61
  `brindle --version` prints the installed version; the tmux status bar of every brindle
62
- session shows it too (`brindle 0.2.1`). A session started before an upgrade keeps
62
+ session shows it too (`brindle 0.3.0`). A session started before an upgrade keeps
63
63
  running the old code, and shows the old number, until you restart it.
64
64
 
65
65
  ## Quick start
@@ -320,6 +320,7 @@ your own status line prints, so what you see doesn't change.
320
320
  | `brindle transfer [REPO] [--from SESSION] [-b BRANCH]` | move a scratch session's work into a real repo |
321
321
  | `brindle ls [--all]` | workspaces and agents |
322
322
  | `brindle repo add PATH [--name ALIAS] / rm ALIAS / ls` | attach other local repos to the current session so workers can go there (brindle Pro; see "Several repos in one session") |
323
+ | `brindle keys set/unset/list [NAME]` | store model API keys (e.g. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) once in the OS keychain (or a 0600 file); every agent pane gets the ones its provider reads, unless you already export it. `set` reads the value from a hidden prompt or stdin, `list` shows names and the last 4 characters. `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) is never stored |
323
324
  | `brindle history [--limit N] [--kind K] [--all]` | durable log of worker results, reviews, merges and milestone checks |
324
325
  | `brindle cost [--days N] [--all]` | dollar spend at list prices over the last 30 days, by model |
325
326
  | `brindle cost report [--days N] [--all]` | Pro: spend by day, profile and goal, cost per merged branch, review pass rate per worker profile |
@@ -331,7 +332,7 @@ your own status line prints, so what you see doesn't change.
331
332
  | `brindle cost estimate [--tasks N] [--weight W] [--profile P] [--reviewer R]` | what the current goal (or N tasks) is likely to cost, as a range from this repo's history (median to p80 of past worker and review runs), plus a cheaper alternative with a different reviewer; says "low confidence" when there's too little history. Also added to `set_goal` and `assign` replies (brindle Pro) |
332
333
  | `brindle learning` | whether brindle Pro's hosted learning is on for this repo, and if not, why (nothing is learned on your machine) |
333
334
  | `brindle learning seed` | Pro: sends this repo's finished routing history to the hosted learner once, so it starts with what this machine has seen. Only the usual coarse record fields leave (kind/size one-hots, counters, HMAC keys), never task text or paths. A second run sends nothing new; decisions already sent live are skipped |
334
- | `brindle account [login\|logout\|status\|upgrade\|portal\|org]` | paid features: bare `brindle account` shows what your plan has and how to get the rest (see "brindle Pro and Team" below) |
335
+ | `brindle account [login\|logout\|status\|upgrade\|portal\|seats\|org]` | paid features: bare `brindle account` shows what your plan has and how to get the rest (see "brindle Pro and Team" below) |
335
336
  | `brindle audit verify\|export\|pubkey\|ship\|prune` | the local tamper-evident audit log, and shipping it to your SIEM (brindle Enterprise; see "Audit log" below) |
336
337
  | `brindle watch [--all] [--once]` | the dashboard on its own (the same view as the sidebar): enter attaches, `p` peeks, `x` closes |
337
338
  | `brindle sidebar` | bring this session's sidebar into the tmux session you're in (also `Ctrl-b S` in a window without one); restarts it if it was closed |
@@ -496,6 +497,7 @@ Autopilot, merge gates and cleanup:
496
497
  | `max_agents` | `4` | workers running at once per session (`0`: no cap) |
497
498
  | `check_timeout` | `900` | seconds each check may take |
498
499
  | `check_concurrency` | `2` | check runs at once on this machine, across branches; the rest wait their turn (`0`: no cap). A run far past its last duration is reported to the supervisor |
500
+ | `fable_escalation` | on, unless the worker runs on a cloud account (Bedrock, Vertex or Foundry) | retry a failed heavy task once on `developer-heavy` (Fable) before handing it to you; `true` turns it on for a cloud account, `false` off |
499
501
  | `usage_limit` | `90` | autopilot stops pushing on at this % of your Claude usage limit |
500
502
  | `limit_cooldown_minutes` | `300` for Antigravity | how long a provider that hit its limit counts as unavailable |
501
503
  | `graphify` | if the graph is there | point agents at the repo's [graphify](https://github.com/safishamsi/graphify) code map (`false` turns it off) |
@@ -559,9 +561,9 @@ keep theirs):
559
561
  ```json
560
562
  {
561
563
  "routing": {
562
- "light": ["developer", "developer-codex", "developer-local"],
564
+ "light": ["developer-light", "developer", "developer-codex", "developer-local"],
563
565
  "medium": ["developer", "developer-codex", "developer-heavy"],
564
- "heavy": ["developer-heavy", "developer", "developer-codex"]
566
+ "heavy": ["developer", "developer-heavy", "developer-codex"]
565
567
  }
566
568
  }
567
569
  ```
@@ -569,7 +571,9 @@ keep theirs):
569
571
  No tier prefers another provider for its own sake: all-Claude or all-Codex may
570
572
  be what works best for you, and that's for the learner to find out.
571
573
 
572
- `developer-codex` runs on Codex; `developer-heavy` on Claude Fable at high effort.
574
+ `developer-light` runs on Claude Haiku at low effort; `developer` on your Claude
575
+ Code default model; `developer-codex` on Codex; `developer-heavy` on Claude Fable
576
+ at high effort.
573
577
  A profile is skipped when its CLI isn't installed (`claude`, `codex`, `agy`), the
574
578
  local model server isn't answering, or its provider is at your `usage_limit`. If
575
579
  hosted learning is on it chooses among the profiles left; otherwise the first
@@ -732,7 +736,9 @@ brindle account login # opens your browser to sign in (--device: enter a co
732
736
  brindle account status # your plan, features, hosted learning on or off, when the entitlement expires
733
737
  brindle account savings # what hosted learning's picks gained in this repo, this month and last: estimates, from this machine's records only
734
738
  brindle account upgrade # opens the checkout for brindle Pro (and prints its URL)
735
- brindle account portal # opens the billing portal (invoices, seats, cancellation); --org ORG for a team org
739
+ brindle account upgrade --enterprise [--trial] --seats N --org ORG # brindle Enterprise for a team org; --trial: free trial, at most 10 seats
740
+ brindle account portal # opens the billing portal (invoices, payment method, cancellation); --org ORG for a team org
741
+ brindle account seats N # set a Team or Enterprise org's seat count (billing admin); --org ORG; Stripe prorates the change
736
742
  brindle account org list # the orgs you belong to; `org use <id>` switches, `org policy` shows the current one
737
743
  brindle account org policy # the org's policy, its per-role overrides, and the policy that applies to you
738
744
  brindle account org profiles # the org's shared profiles and rule packs, and which are pinned
@@ -795,6 +801,16 @@ entitlement.
795
801
  * `deny_personal_keys`: the personal model keys (`ANTHROPIC_API_KEY`,
796
802
  `OPENAI_API_KEY`, ...) are taken out of every agent pane, and a profile that
797
803
  sets one is refused.
804
+ * An org-owned key: with `provider` `anthropic` (or `openai-compatible`), set
805
+ `key_env` to the name of a variable that holds the org's key (not a personal
806
+ one like `ANTHROPIC_API_KEY`), or `key_helper` to a command, as a list of
807
+ arguments, that prints the key (for example `["vault", "kv", "get",
808
+ "-field=key", "secret/anthropic"]`). brindle runs the helper on the person's
809
+ machine, without a shell and with a timeout, each time a worker starts, and
810
+ gives the key to that worker as `ANTHROPIC_API_KEY` (`OPENAI_API_KEY` for
811
+ Codex). It passes `deny_personal_keys`; a personal key doesn't. The key never
812
+ goes through brindle's servers. When the variable is missing or the helper
813
+ fails, the worker doesn't start.
798
814
 
799
815
  Two things to know when you turn on `deny_personal_keys`:
800
816
 
@@ -955,17 +971,31 @@ workflows call these commands:
955
971
  commits the workflows on a `brindle/ci-setup` branch, then puts you back on
956
972
  the branch you started on and deletes the local setup branch, so `git pull`
957
973
  after merging the setup pull request is clean (commit or stash your changes
958
- first: it refuses to start with uncommitted work).
974
+ first: it refuses to start with uncommitted work). With your own cloud
975
+ (`--credential bedrock|vertex|foundry`, keyless GitHub OIDC) it can read the
976
+ IDs from the deployment template's outputs instead of asking:
977
+ `--from-stack NAME [--region R]` (AWS CloudFormation), `--from-terraform DIR`
978
+ (Google) or `--from-deployment NAME -g RG` (Azure). They are defaults for
979
+ `--cloud-var`, which still overrides them, and every ID is format-checked. A
980
+ missing `aws`, `terraform` or `az` CLI or output is an error that names the
981
+ `--cloud-var` to pass instead. Vertex AI and Microsoft Foundry are in
982
+ **preview** (not yet verified end to end): `init` refuses them unless you pass
983
+ `--preview` or set `BRINDLE_CI_PREVIEW_CLOUDS=1`, and `doctor` marks them
984
+ `(preview)`. Bedrock is generally available.
959
985
  - `brindle ci doctor`: which provider CLIs and credential names a runner has, and
960
- which providers CI may use on this repository.
986
+ which providers CI may use on this repository. `--models` also makes one tiny
987
+ call per pinned model and says why any is refused (a quota, a form not filled in).
961
988
  - `brindle ci start`: start a run for an issue, a goal text or a dispatched run,
962
989
  or a validation of a pull request.
963
990
  - `brindle ci run`: run what was started: the supervisor and the result upload,
964
991
  or the pull request's checks and reviews and the evidence upload.
965
992
  - `brindle ci report`: tell the service how the workflow's jobs ended.
966
993
 
967
- Credential names are reported, never values. On a repository owned by a GitHub
968
- organization, a provider signed in only with a personal subscription is not used.
994
+ Credential names are reported, never values. CI uses API keys, cloud sign-ins
995
+ (Bedrock, Vertex, Foundry) or identity federation only: a provider signed in
996
+ only with a subscription (`CLAUDE_CODE_OAUTH_TOKEN`, a Codex ChatGPT login) is
997
+ not used, on personal and organization repositories alike. Add an API key
998
+ secret instead.
969
999
 
970
1000
  Fix builds only fix the default branch's *required* status checks, and a new
971
1001
  repository has none. When the default branch requires none, `brindle ci init`
@@ -1210,6 +1240,16 @@ needs no browser step. Put the key in your environment, or in a profile's
1210
1240
  `brindle doctor` names the variable each CLI gets, from your shell or from
1211
1241
  which profiles.
1212
1242
 
1243
+ A profile chooses between the two with `auth`:
1244
+
1245
+ | `auth:` | What the worker signs in with |
1246
+ |---|---|
1247
+ | `auto` (default) | A key from your environment or `brindle keys` if there is one, else the CLI's own login. |
1248
+ | `subscription` | Always the CLI's own login: brindle strips that provider's API keys from the pane. |
1249
+ | `api_key` | Only a key: the worker refuses to start without one. |
1250
+
1251
+ Your org's `deny_personal_keys` (Enterprise) still applies on top of `auth`.
1252
+
1213
1253
  ### Cheap workers
1214
1254
 
1215
1255
  By default a Claude Code worker loads everything your own `claude` does: your
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brindle"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  description = "Run coding agents in parallel, each on its own git branch, with a supervisor that delegates, reviews, and merges"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -52,6 +52,8 @@ audit_export = "brindle.pro.audit_export:make"
52
52
  dev = [
53
53
  "pytest>=8",
54
54
  "pytest-xdist>=3.8.0",
55
+ "python-hcl2>=8.1.4",
56
+ "pyyaml>=6.0.3",
55
57
  ]
56
58
 
57
59
  [build-system]
@@ -20,7 +20,7 @@ import sys
20
20
  import time
21
21
  import uuid
22
22
 
23
- from brindle import git, secrets, tmux, workspaces
23
+ from brindle import git, keystore, pane_auth, providers, secrets, tmux, workspaces
24
24
  from brindle.config import RepoConfig
25
25
  from brindle.db import DB, Agent, Workspace
26
26
  from brindle.profiles import load_profile, missing_add_dirs
@@ -180,7 +180,10 @@ def agent_env(ws: Workspace, agent_id: str, agent: Agent | None = None,
180
180
  profile_env = {}
181
181
  if m is _UNREAD:
182
182
  m = managed(ws.repo_root)
183
- env = {**profile_env, **managed_models.agent_env(m, agent.provider), **env}
183
+ # Claude Code's own managed settings (apiKeyHelper, a managed env) pick
184
+ # the credential and backend: brindle adds no provider_config env there.
185
+ org = agent.provider == "claude" and providers.claude_org_managed()
186
+ env = {**profile_env, **({} if org else managed_models.agent_env(m, agent.provider)), **env}
184
187
  if agent is not None and preload_tools(agent, ws):
185
188
  # Claude Code defers MCP tools and loads them on demand, which costs a
186
189
  # worker an extra round trip at the moment it's told to report (and
@@ -419,11 +422,33 @@ def _open_window(db: DB, agent: Agent, ws: Workspace, name: str, argv: list[str]
419
422
  # reach the pane, kept or not.
420
423
  if m is _UNREAD:
421
424
  m = managed(ws.repo_root)
422
- target = tmux.new_window(ws.tmux_session, name, ws.path, argv, agent_env(ws, agent.id, agent, m),
423
- tag=(AGENT_TAG, agent.id),
424
- keep=secrets.agent_credentials(agent.provider, agent.profile, ws.repo_root),
425
- **({"allow": allow} if allow is not None else {}),
426
- **({"deny": m.denied_keys} if m is not None and m.denied_keys else {}))
425
+ env = agent_env(ws, agent.id, agent, m)
426
+ credentials = secrets.agent_credentials(agent.provider, agent.profile, ws.repo_root)
427
+ # Keys stored with `brindle keys` reach this window's env only (never the
428
+ # tmux server's), unless the environment already has the name or an org's
429
+ # deny_personal_keys keeps it out. deny/allow strip it as they do an exported key.
430
+ # The profile's `auth` (subscription strips the provider's keys) and Claude
431
+ # Code's managed settings (a personal key mustn't bypass them) add to the
432
+ # org's denied keys; the org's own key (key_env / key_helper) is set last and
433
+ # passes the deny (secrets.org_key), a personal key under its name doesn't.
434
+ from brindle.pro import managed_models
435
+
436
+ org_managed = agent.provider == "claude" and bool(providers.claude_org_managed())
437
+ deny = pane_auth.deny_names(agent.provider, guarded.auth if guarded is not None else "auto",
438
+ m, org_managed)
439
+ try:
440
+ org_env = managed_models.org_key_env(m, agent.provider)
441
+ except managed_models.ManagedUnavailable as e:
442
+ raise AgentError(f"managed models: {e}") from None
443
+ # Claude Code's managed settings supply the credential: no stored Claude key.
444
+ no_stored = set(deny) | (set(providers._ENV_AUTH["claude"]) if org_managed else set())
445
+ env = {**keystore.pane_keys(credentials - no_stored, {**env, **org_env}), **env, **org_env}
446
+ with secrets.org_key(org_env):
447
+ target = tmux.new_window(ws.tmux_session, name, ws.path, argv, env,
448
+ tag=(AGENT_TAG, agent.id),
449
+ keep=credentials,
450
+ **({"allow": allow} if allow is not None else {}),
451
+ **({"deny": deny} if deny else {}))
427
452
  # The pane id only means anything on the server that issued it (a
428
453
  # resumed agent may come back on a different one), so record both.
429
454
  server = tmux.current_server()
@@ -591,9 +616,16 @@ def _launch(db: DB, agent: Agent, ws: Workspace, *, prompt: str | None,
591
616
  # no personal keys (blank, so the launcher's own copy doesn't count).
592
617
  from brindle.pro import managed_models
593
618
 
594
- why = signed_out(provider.name, {**profile.env, **managed_models.agent_env(m, provider.name),
595
- **{k: "" for k in secrets.pane_deny(m.denied_keys if m else (),
596
- {**profile.env, **managed_models.agent_env(m, provider.name)})}})
619
+ launch_env = {**profile.env, **managed_models.agent_env(m, provider.name)}
620
+ deny = pane_auth.deny_names(provider.name, profile.auth, m,
621
+ provider.name == "claude" and bool(providers.claude_org_managed()))
622
+ org_key = managed_models.has_org_key(m, provider.name)
623
+ why = pane_auth.key_problem(profile, provider.name, deny, org_key)
624
+ if why:
625
+ raise AgentError(why)
626
+ # The org's own key signs the agent in: there is no login to check.
627
+ why = None if org_key else signed_out(
628
+ provider.name, {**launch_env, **{k: "" for k in secrets.pane_deny(deny, launch_env)}})
597
629
  if why:
598
630
  raise AgentError(why)
599
631
  # Here rather than in spawn, so a resume checks too: a directory can be
@@ -2319,6 +2351,22 @@ def agent_for_session(db: DB, session_id: object) -> str | None:
2319
2351
  return matches[-1].id if matches else None
2320
2352
 
2321
2353
 
2354
+ def command_running(db: DB, agent: Agent) -> bool:
2355
+ """Whether a command the agent started (possibly in the background) is
2356
+ still running under it. Never raises: when unsure, it says no."""
2357
+ if agent.provider != "claude":
2358
+ return False
2359
+ try:
2360
+ from brindle import procs
2361
+
2362
+ window = agent.tmux_window if same_server(agent) and owns_pane(db, agent) else ""
2363
+ pane_pids = tmux.window_pids(window) if window else []
2364
+ return bool(procs.running_commands(agent.id, pane_pids))
2365
+ except Exception: # noqa: BLE001
2366
+ log.exception("brindle: couldn't look for %s's running commands", agent.id)
2367
+ return False
2368
+
2369
+
2322
2370
  def tell_parent_unreported(db: DB, agent: Agent) -> None:
2323
2371
  """A worker stopped again after being reminded to report, still without a
2324
2372
  result. It won't be reminded again on its own, and its supervisor has
@@ -2336,6 +2384,11 @@ def tell_parent_unreported(db: DB, agent: Agent) -> None:
2336
2384
  return # its verdict for the current commit is already recorded
2337
2385
  if agent.unreported_noted:
2338
2386
  return # the supervisor was told once; no repeats
2387
+ if command_running(db, agent):
2388
+ # Its turn ended while a long or background command (a test run) is
2389
+ # still going: it isn't stopped, and the command finishing wakes it.
2390
+ # Not marked noted, so a real stop later still gets its one alert.
2391
+ return
2339
2392
  db.update_agent(agent.id, unreported_noted=1)
2340
2393
  where = f" (branch `{ws.branch}`, workspace {ws.id})" if ws else ""
2341
2394
  tool = "submit_review" if agent.mode == "review" else "report_result"
@@ -451,6 +451,11 @@ def _unavailable(name: str, cfg: RepoConfig, repo_root: str, gate=None,
451
451
  over = gate.why_not(name, weight) if gate is not None and gate.active else None
452
452
  if over:
453
453
  return f"over budget, skipped {name} ({over})"
454
+ from brindle import model_access
455
+
456
+ denied = model_access.refused(name, repo_root)
457
+ if denied:
458
+ return f"model refused for this account ({denied.cause}; {denied.fix}), skipped {name}"
454
459
  cli = CLI_FOR_PROVIDER.get(p.provider)
455
460
  if cli and unusable(p.provider, p.env):
456
461
  return (f"{cli} isn't signed in, skipped {name}" if signed_out(p.provider, p.env)
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: developer-light
3
- description: Implements a small, well-specified task on its own branch, on Sonnet with low effort (the baseline of the light tier of weight routing)
3
+ description: Implements a small, well-specified task on its own branch, on Haiku with low effort (the baseline of the light tier of weight routing)
4
4
  extends: developer
5
- model: sonnet
5
+ model: haiku
6
6
  effort: low
7
7
  ---
@@ -19,11 +19,12 @@ An adapter answers four questions and does three things:
19
19
  its raw reply.
20
20
  * ``usage``: token usage of a session, by model.
21
21
 
22
- The credential rule (:func:`usable`): on a repository owned by a GitHub
23
- organization, a provider whose only credential is a personal subscription is
24
- unavailable for CI. Personal repositories may use it. Whether the owner is
25
- an organization comes from the workflow's event payload or the GitHub API
26
- (:func:`repo_is_org`); when neither says, the stricter answer is used.
22
+ The credential rule (:func:`usable`): CI uses API keys, cloud sign-ins or
23
+ identity federation only. A provider whose only credential is a personal
24
+ subscription (``CLAUDE_CODE_OAUTH_TOKEN``, a Codex ChatGPT login) is
25
+ unavailable for CI on every repository, personal or organization-owned.
26
+ The repository owner (:func:`repo_is_org`) is still reported but no longer
27
+ changes the rule.
27
28
 
28
29
  Nothing here decides anything about the run itself: no verdicts, no stall
29
30
  detection, no rendering. The server does that.
@@ -44,7 +45,7 @@ from pathlib import Path
44
45
  from typing import Mapping, MutableMapping
45
46
 
46
47
 
47
- from brindle import providers
48
+ from brindle import model_access, providers
48
49
  from brindle.secrets import is_job_secret
49
50
 
50
51
  log = logging.getLogger(__name__)
@@ -57,7 +58,8 @@ RATE_LIMIT = "rate_limit"
57
58
  AUTH = "auth"
58
59
 
59
60
  CLAUDE_API_KEYS = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")
60
- CLAUDE_CLOUD = ("CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY")
61
+ CLAUDE_CLOUD = ("CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY",
62
+ "CLAUDE_CODE_USE_MANTLE")
61
63
  CLAUDE_SUBSCRIPTION = ("CLAUDE_CODE_OAUTH_TOKEN",)
62
64
  CODEX_API_KEYS = ("OPENAI_API_KEY", "CODEX_API_KEY")
63
65
  CODEX_LOGIN = "codex login (auth.json)" # the name shown for a ChatGPT sign-in
@@ -181,6 +183,11 @@ class Adapter:
181
183
  person answers), or None. Default: never."""
182
184
  return None
183
185
 
186
+ def notices(self, db, root) -> list[str]:
187
+ """Warnings the CLI printed that a run's notes should carry (Claude
188
+ Code's alias fallback to an older model). Default: none."""
189
+ return []
190
+
184
191
  def review(self, instructions: str, cwd: str, env: Mapping[str, str], *,
185
192
  timeout: float = REVIEW_TIMEOUT, profile: str | None = None) -> Review:
186
193
  raise AdapterError(f"{self.name} can't review")
@@ -340,12 +347,30 @@ class ClaudeAdapter(Adapter):
340
347
  since = now
341
348
  self._api_errors[agent.id] = (error, since)
342
349
  line = api_error_line(error)
350
+ cause = model_access.classify(error)
351
+ if cause is not None:
352
+ # The account can't call the model: no retry will help.
353
+ # under the repo's fingerprint, which session_event reads the notes with
354
+ ws = db.get_workspace(agent.workspace_id)
355
+ model_access.refuse(agent.profile, cause, repo_root=ws.repo_root if ws else None)
356
+ return f"{who} can't call its model: {cause.line()} ({line})"
343
357
  if providers.ClaudeCode.FATAL_API_ERROR.match(error):
344
358
  return f"{who} stopped on an error it won't retry: {line}"
345
359
  if now - since >= TRANSIENT_API_ERROR_S:
346
360
  return f"{who} has been stopped on an API error for {int(now - since)}s: {line}"
347
361
  return None
348
362
 
363
+ def notices(self, db, root) -> list[str]:
364
+ from brindle import agents
365
+
366
+ found = []
367
+ for agent in [root, *(a for a in (agents.tree(db, root.id) if root else [])
368
+ if a.id != root.id and a.provider == self.name)]:
369
+ warning = model_access.alias_fallback(self._screen(agent) or "")
370
+ if warning and warning not in found:
371
+ found.append(warning)
372
+ return found
373
+
349
374
  @staticmethod
350
375
  def _screen(root) -> str | None:
351
376
  from brindle import agents, tmux
@@ -610,10 +635,10 @@ def usable(adapter: Adapter, env: Mapping[str, str], org: bool | None) -> tuple[
610
635
  ok, why = adapter.available(env)
611
636
  if not ok:
612
637
  return False, why
613
- if adapter.credential(env).kind == SUBSCRIPTION and org is not False:
614
- who = "an organization" if org else "unknown (treated as an organization)"
615
- return False, (f"its only credential is a personal subscription and the repository owner is "
616
- f"{who}: use an API key or a cloud sign-in")
638
+ if adapter.credential(env).kind == SUBSCRIPTION:
639
+ return False, ("its only credential is a personal subscription, which CI may not use "
640
+ "(Anthropic's terms bar third parties from routing subscription credentials): "
641
+ "use an API key or a cloud sign-in")
617
642
  return True, ""
618
643
 
619
644
 
@@ -642,6 +667,53 @@ def doctor(adapters: Mapping[str, Adapter], env: Mapping[str, str], org: bool |
642
667
  return rows
643
668
 
644
669
 
670
+ def pinned_models(cwd: str) -> list[tuple[str, str]]:
671
+ """(profile, model) for each distinct model a Claude profile in the routing
672
+ tiers pins, in routing order."""
673
+ from brindle.config import DEFAULT_ROUTING, load_repo_config
674
+ from brindle.profiles import load_profile
675
+
676
+ try:
677
+ routing = load_repo_config(cwd).routing
678
+ except ValueError:
679
+ routing = DEFAULT_ROUTING
680
+ seen: dict[str, str] = {}
681
+ for tier in routing.values():
682
+ for name in tier:
683
+ try:
684
+ p = load_profile(name, cwd)
685
+ except KeyError:
686
+ continue
687
+ if p.provider == "claude" and p.model and p.model not in seen:
688
+ seen[p.model] = name
689
+ return [(profile, model) for model, profile in seen.items()]
690
+
691
+
692
+ def check_models(adapters: Mapping[str, Adapter], env: Mapping[str, str], cwd: str,
693
+ org: bool | None, *, run=subprocess.run) -> list[str]:
694
+ """One tiny call per pinned model through the claude CLI: a line each,
695
+ with the cause and fix of a model-access refusal. A refused profile is
696
+ remembered, so routing moves its tasks to the next profile of the tier."""
697
+ claude = adapters.get("claude")
698
+ if claude is None or not usable(claude, env, org)[0]:
699
+ return ["models: skipped (claude isn't usable here)"]
700
+ pinned = pinned_models(cwd)
701
+ if not pinned:
702
+ return ["models: none pinned"]
703
+ binary = claude.cli_path(env) or "claude"
704
+ lines = []
705
+ for profile, model in pinned:
706
+ ok, cause, text = model_access.probe(binary, model, cwd, claude_env(env), run=run)
707
+ if ok:
708
+ lines.append(f"model {model} ({profile}): ok")
709
+ elif cause is not None:
710
+ model_access.refuse(profile, cause, repo_root=cwd)
711
+ lines.append(f"model {model} ({profile}): refused: {cause.line()}")
712
+ else:
713
+ lines.append(f"model {model} ({profile}): failed: {api_error_line(text)}")
714
+ return lines
715
+
716
+
645
717
  def format_doctor(rows: list[dict], repo: str | None, org: bool | None) -> str:
646
718
  owner = {True: "an organization", False: "a personal account"}.get(org, "unknown; treated as an organization")
647
719
  lines = [f"repository: {repo or '(unknown)'}, owner is {owner}"]