brindle 0.3.18__tar.gz → 0.3.20__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.
- {brindle-0.3.18 → brindle-0.3.20}/PKG-INFO +29 -3
- {brindle-0.3.18 → brindle-0.3.20}/README.md +28 -2
- {brindle-0.3.18 → brindle-0.3.20}/pyproject.toml +1 -1
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/agents.py +70 -19
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/autopilot.py +34 -1
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/cli.py +112 -5
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/config.py +19 -1
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/cull.py +18 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/db.py +16 -3
- brindle-0.3.20/src/brindle/key_check.py +66 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/keys_cmds.py +18 -1
- brindle-0.3.20/src/brindle/local_setup.py +259 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/mcp_server.py +6 -4
- brindle-0.3.20/src/brindle/picker.py +284 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pipeline.py +60 -38
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/team_policy.py +86 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/providers.py +9 -1
- brindle-0.3.20/src/brindle/worker_models.py +176 -0
- {brindle-0.3.18 → brindle-0.3.20}/.gitignore +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/LICENSE +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/SCHEDULE-A +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/__init__.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/__main__.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/account.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/airgap.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/antigravity.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/budget.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/backend-auditor.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/developer-codex.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/developer-fable.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/developer-gemini.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/developer-heavy.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/developer-light.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/developer-local.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/developer.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/qa.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/reviewer-local.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/reviewer.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_rules/__init__.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_rules/security/backend.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/builtin_rules/tests/only.md +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/ci_adapters.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/ci_client.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/ci_federation.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/ci_hosts.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/cloud_credential.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/cloud_login.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/codemap.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/codemap_docs.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/codex_hook.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/company_identity.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/company_login.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/conflicts.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/cost.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/cost_estimate.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/demo.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/detect.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/doctor.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/escapes.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/events.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/gates.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/git.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/guardrails.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/history.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/identity_lock.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/inbox.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/keystore.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/launch_health.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/learned_rules.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/learning.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/legacy.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/login_pause.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/model_access.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/model_availability.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/native/__init__.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/native/client.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/native/loop.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/native/permissions.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/native/runner.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/native/serve.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/native/tools.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pane_auth.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/permissions.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/plugins.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/policy.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pool.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pricing.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/_files.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/account.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/audit_export.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/auth.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/brief_learning.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/cost_centers.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/features.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/keys.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/learning.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/license.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/managed_models.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/org_profiles.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/rollout.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/route_health.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/settings_sync.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/status.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/pro/team_events.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/procs.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/profiles.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/prompt_answer.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/quota.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/repos.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/rewind.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/rule_checks.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/savings.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/scratch.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/secrets.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/self_serve.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/services.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/sessions.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/sidebar_follow.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/signin_pause.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/status_cache.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/tasks.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/tmux.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/updates.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/upgrade.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/usage.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/view.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/watch.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/watch_costs.py +0 -0
- {brindle-0.3.18 → brindle-0.3.20}/src/brindle/workspaces.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: brindle
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.20
|
|
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
|
|
@@ -94,7 +94,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
94
94
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
95
95
|
|
|
96
96
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
97
|
-
session shows it too (`brindle 0.3.
|
|
97
|
+
session shows it too (`brindle 0.3.20`). A session started before an upgrade keeps
|
|
98
98
|
running the old code, and shows the old number, until you restart it.
|
|
99
99
|
|
|
100
100
|
## Quick start
|
|
@@ -215,6 +215,31 @@ brindle pr fix-login # push + gh pr create
|
|
|
215
215
|
brindle rm fix-login # deletes the branch only if it's merged (-K keeps it)
|
|
216
216
|
```
|
|
217
217
|
|
|
218
|
+
### Choosing the supervisor and workers
|
|
219
|
+
|
|
220
|
+
The first time you run `brindle` it asks which CLI runs the supervisor (the
|
|
221
|
+
agent that plans the work and hands it out), and remembers your pick. After
|
|
222
|
+
that it starts straight away and prints one line naming the CLI. It asks again
|
|
223
|
+
only when your pick is no longer signed in or no longer allowed.
|
|
224
|
+
|
|
225
|
+
- `brindle -s` opens the picker; `brindle -s codex` switches directly. Either
|
|
226
|
+
is remembered.
|
|
227
|
+
- In the picker, `w` opens the workers screen: a model for each task size
|
|
228
|
+
(light, medium, heavy). Models are grouped by how they run: through a CLI
|
|
229
|
+
you sign in to, locally with Ollama, or hosted with an API key. Picking one
|
|
230
|
+
that isn't ready sets it up right there: signing in, installing Ollama and
|
|
231
|
+
downloading the model after a memory check, or asking for the API key.
|
|
232
|
+
- API keys are checked with the provider before they're stored, and stay in
|
|
233
|
+
this machine's keychain.
|
|
234
|
+
- The picker and the workers screen list only the CLIs and models you're
|
|
235
|
+
allowed to use. In an org that means its allowed providers and models for
|
|
236
|
+
your role, its managed routes, and whether personal API keys are allowed.
|
|
237
|
+
`-s` with anything else is refused, and saved picks are re-checked at every
|
|
238
|
+
launch.
|
|
239
|
+
- Without a terminal (scripts, CI), with `--yes`, or in `brindle demo`, nothing
|
|
240
|
+
asks: brindle uses your last pick, else the supervisor profile's CLI (Claude
|
|
241
|
+
Code unless you changed it).
|
|
242
|
+
|
|
218
243
|
## Recommended use
|
|
219
244
|
|
|
220
245
|
brindle pays off when work splits into pieces that can proceed in parallel, or
|
|
@@ -343,6 +368,7 @@ your own status line prints, so what you see doesn't change.
|
|
|
343
368
|
| | |
|
|
344
369
|
|---|---|
|
|
345
370
|
| `brindle` | a fresh supervisor chat here, dashboard alongside |
|
|
371
|
+
| `brindle -s [CLI]` | pick the supervisor's CLI (no value: open the picker; `w` there picks worker models); a picker choice is remembered, a named CLI is for this run only |
|
|
346
372
|
| `brindle supervisor` | the same, with `--branch`/`--worktree` to run it in another worktree |
|
|
347
373
|
| `brindle init` | detect setup and test commands, write `.brindle/config.json`, check tools |
|
|
348
374
|
| `brindle demo [--local]` | watch brindle finish a tiny practice repo: parallel workers, reviews, gated merges |
|
|
@@ -536,7 +562,7 @@ Autopilot, merge gates and cleanup:
|
|
|
536
562
|
| `pre_commit` | `true` | run [pre-commit](https://pre-commit.com) over the branch, if the repo uses it |
|
|
537
563
|
| `max_agents` | `4` | workers running at once per session (`0`: no cap) |
|
|
538
564
|
| `check_timeout` | `900` | seconds each check may take |
|
|
539
|
-
| `check_concurrency` | `
|
|
565
|
+
| `check_concurrency` | `4` | 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 |
|
|
540
566
|
| `fable_escalation` | on, unless the worker runs on a cloud account (Bedrock, Vertex or Foundry) | retry a failed heavy task once on `developer-heavy` before handing it to you; `true` turns it on for a cloud account, `false` off |
|
|
541
567
|
| `usage_limit` | `90` | autopilot stops pushing on at this % of your Claude usage limit |
|
|
542
568
|
| `limit_cooldown_minutes` | `300` for Antigravity | how long a provider that hit its limit counts as unavailable |
|
|
@@ -63,7 +63,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
63
63
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
64
64
|
|
|
65
65
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
66
|
-
session shows it too (`brindle 0.3.
|
|
66
|
+
session shows it too (`brindle 0.3.20`). A session started before an upgrade keeps
|
|
67
67
|
running the old code, and shows the old number, until you restart it.
|
|
68
68
|
|
|
69
69
|
## Quick start
|
|
@@ -184,6 +184,31 @@ brindle pr fix-login # push + gh pr create
|
|
|
184
184
|
brindle rm fix-login # deletes the branch only if it's merged (-K keeps it)
|
|
185
185
|
```
|
|
186
186
|
|
|
187
|
+
### Choosing the supervisor and workers
|
|
188
|
+
|
|
189
|
+
The first time you run `brindle` it asks which CLI runs the supervisor (the
|
|
190
|
+
agent that plans the work and hands it out), and remembers your pick. After
|
|
191
|
+
that it starts straight away and prints one line naming the CLI. It asks again
|
|
192
|
+
only when your pick is no longer signed in or no longer allowed.
|
|
193
|
+
|
|
194
|
+
- `brindle -s` opens the picker; `brindle -s codex` switches directly. Either
|
|
195
|
+
is remembered.
|
|
196
|
+
- In the picker, `w` opens the workers screen: a model for each task size
|
|
197
|
+
(light, medium, heavy). Models are grouped by how they run: through a CLI
|
|
198
|
+
you sign in to, locally with Ollama, or hosted with an API key. Picking one
|
|
199
|
+
that isn't ready sets it up right there: signing in, installing Ollama and
|
|
200
|
+
downloading the model after a memory check, or asking for the API key.
|
|
201
|
+
- API keys are checked with the provider before they're stored, and stay in
|
|
202
|
+
this machine's keychain.
|
|
203
|
+
- The picker and the workers screen list only the CLIs and models you're
|
|
204
|
+
allowed to use. In an org that means its allowed providers and models for
|
|
205
|
+
your role, its managed routes, and whether personal API keys are allowed.
|
|
206
|
+
`-s` with anything else is refused, and saved picks are re-checked at every
|
|
207
|
+
launch.
|
|
208
|
+
- Without a terminal (scripts, CI), with `--yes`, or in `brindle demo`, nothing
|
|
209
|
+
asks: brindle uses your last pick, else the supervisor profile's CLI (Claude
|
|
210
|
+
Code unless you changed it).
|
|
211
|
+
|
|
187
212
|
## Recommended use
|
|
188
213
|
|
|
189
214
|
brindle pays off when work splits into pieces that can proceed in parallel, or
|
|
@@ -312,6 +337,7 @@ your own status line prints, so what you see doesn't change.
|
|
|
312
337
|
| | |
|
|
313
338
|
|---|---|
|
|
314
339
|
| `brindle` | a fresh supervisor chat here, dashboard alongside |
|
|
340
|
+
| `brindle -s [CLI]` | pick the supervisor's CLI (no value: open the picker; `w` there picks worker models); a picker choice is remembered, a named CLI is for this run only |
|
|
315
341
|
| `brindle supervisor` | the same, with `--branch`/`--worktree` to run it in another worktree |
|
|
316
342
|
| `brindle init` | detect setup and test commands, write `.brindle/config.json`, check tools |
|
|
317
343
|
| `brindle demo [--local]` | watch brindle finish a tiny practice repo: parallel workers, reviews, gated merges |
|
|
@@ -505,7 +531,7 @@ Autopilot, merge gates and cleanup:
|
|
|
505
531
|
| `pre_commit` | `true` | run [pre-commit](https://pre-commit.com) over the branch, if the repo uses it |
|
|
506
532
|
| `max_agents` | `4` | workers running at once per session (`0`: no cap) |
|
|
507
533
|
| `check_timeout` | `900` | seconds each check may take |
|
|
508
|
-
| `check_concurrency` | `
|
|
534
|
+
| `check_concurrency` | `4` | 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 |
|
|
509
535
|
| `fable_escalation` | on, unless the worker runs on a cloud account (Bedrock, Vertex or Foundry) | retry a failed heavy task once on `developer-heavy` before handing it to you; `true` turns it on for a cloud account, `false` off |
|
|
510
536
|
| `usage_limit` | `90` | autopilot stops pushing on at this % of your Claude usage limit |
|
|
511
537
|
| `limit_cooldown_minutes` | `300` for Antigravity | how long a provider that hit its limit counts as unavailable |
|
|
@@ -1418,15 +1418,34 @@ def unread_notice(db: DB, messages: list) -> str:
|
|
|
1418
1418
|
f"(from {', '.join(senders)}). Call read_messages.")
|
|
1419
1419
|
|
|
1420
1420
|
|
|
1421
|
+
def one_line(db: DB, message_id: int, body: str, sender_id: str | None) -> str:
|
|
1422
|
+
"""A message as the single line a supervisor sees: sender, head line (the
|
|
1423
|
+
first line of ``body``) and how to fetch the rest."""
|
|
1424
|
+
if sender_id:
|
|
1425
|
+
sender = db.get_agent(sender_id)
|
|
1426
|
+
who = f"{sender.profile} {sender_id}" if sender else sender_id
|
|
1427
|
+
else:
|
|
1428
|
+
who = "brindle"
|
|
1429
|
+
head = first_line(body, 300)
|
|
1430
|
+
if not sender_id:
|
|
1431
|
+
head = head.removeprefix("[brindle] ") # the sender tag already says it
|
|
1432
|
+
return (f"[{who}] {head} "
|
|
1433
|
+
f"(full text: read_messages(message_id={message_id}))")
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def full_message(db: DB, agent_id: str, message_id: int) -> str:
|
|
1437
|
+
"""The whole text of one message to ``agent_id``, as read_messages(message_id=...) returns it."""
|
|
1438
|
+
m = db.get_message(agent_id, message_id)
|
|
1439
|
+
if m is None:
|
|
1440
|
+
return f"No message {message_id}."
|
|
1441
|
+
if m.sender_id:
|
|
1442
|
+
return format_message(db, m.body, m.sender_id)
|
|
1443
|
+
return f"<details><summary>Message from brindle</summary>\n\n{m.body}\n</details>"
|
|
1444
|
+
|
|
1445
|
+
|
|
1421
1446
|
def render_unread(db: DB, messages: list) -> str:
|
|
1422
|
-
"""What read_messages returns:
|
|
1423
|
-
|
|
1424
|
-
for m in messages:
|
|
1425
|
-
if m.sender_id:
|
|
1426
|
-
parts.append(m.body) # stored through format_message: sender and reply hint
|
|
1427
|
-
else:
|
|
1428
|
-
parts.append(f"<details><summary>Message from brindle</summary>\n\n{m.body}\n</details>")
|
|
1429
|
-
return "\n\n".join(parts)
|
|
1447
|
+
"""What read_messages returns: one line per message."""
|
|
1448
|
+
return "\n".join(one_line(db, m.id, m.body, m.sender_id) for m in messages)
|
|
1430
1449
|
|
|
1431
1450
|
|
|
1432
1451
|
def send_message(db: DB, to_id: str, body: str, sender_id: str | None = None,
|
|
@@ -1440,20 +1459,25 @@ def send_message(db: DB, to_id: str, body: str, sender_id: str | None = None,
|
|
|
1440
1459
|
identity_lock.ensure(agent)
|
|
1441
1460
|
if not person and runs_process(agent) and is_alive(agent) and pulls_messages(db, agent):
|
|
1442
1461
|
# Keep the message unread and deliver only a notice, once for everything unread.
|
|
1443
|
-
db.enqueue_held(agent.id,
|
|
1462
|
+
db.enqueue_held(agent.id, body, sender_id)
|
|
1444
1463
|
claimed = db.claim_notice(agent.id)
|
|
1445
1464
|
if claimed is None:
|
|
1446
1465
|
return "queued"
|
|
1447
1466
|
try:
|
|
1448
|
-
outcome = _send_message(db, agent, unread_notice(db, claimed), None)
|
|
1467
|
+
outcome = _send_message(db, agent, unread_notice(db, claimed), None, notice=True)
|
|
1449
1468
|
except BaseException:
|
|
1450
1469
|
db.reset_notice(agent.id)
|
|
1451
1470
|
raise
|
|
1452
1471
|
return outcome
|
|
1453
|
-
|
|
1472
|
+
# A supervisor sees a message from an agent or brindle as one line; the
|
|
1473
|
+
# person's own input is always delivered whole.
|
|
1474
|
+
return _send_message(db, agent, body, sender_id,
|
|
1475
|
+
condensed=agent.mode == "interactive" and not person
|
|
1476
|
+
and agent.provider != "shell")
|
|
1454
1477
|
|
|
1455
1478
|
|
|
1456
|
-
def _send_message(db: DB, agent: Agent, body: str, sender_id: str | None
|
|
1479
|
+
def _send_message(db: DB, agent: Agent, body: str, sender_id: str | None,
|
|
1480
|
+
condensed: bool = False, notice: bool = False) -> str:
|
|
1457
1481
|
if not runs_process(agent):
|
|
1458
1482
|
raise AgentError(
|
|
1459
1483
|
f"agent {agent.id} is a subagent run by its supervisor's own Agent tool, so brindle "
|
|
@@ -1466,8 +1490,14 @@ def _send_message(db: DB, agent: Agent, body: str, sender_id: str | None) -> str
|
|
|
1466
1490
|
"can resume its session with `brindle continue`; the message isn't kept.")
|
|
1467
1491
|
raise AgentError(f"agent {agent.id} is not running")
|
|
1468
1492
|
provider = get_provider(agent.provider)
|
|
1469
|
-
|
|
1470
|
-
|
|
1493
|
+
if condensed:
|
|
1494
|
+
# The full text is kept, already read, for read_messages(message_id=...).
|
|
1495
|
+
kept = db.enqueue_held(agent.id, body, sender_id)
|
|
1496
|
+
db.mark_delivered(kept)
|
|
1497
|
+
text = one_line(db, kept, body, sender_id)
|
|
1498
|
+
else:
|
|
1499
|
+
text = format_message(db, body, sender_id)
|
|
1500
|
+
if agent.provider == "antigravity" and not (condensed or notice):
|
|
1471
1501
|
from brindle.antigravity import format_delivered_message
|
|
1472
1502
|
text = format_delivered_message(text)
|
|
1473
1503
|
if agent.headless:
|
|
@@ -1479,7 +1509,7 @@ def _send_message(db: DB, agent: Agent, body: str, sender_id: str | None) -> str
|
|
|
1479
1509
|
tmux.paste(agent.tmux_window, text, lead=message_lead(db, agent, sender_id),
|
|
1480
1510
|
server=server_of(agent))
|
|
1481
1511
|
return "delivered"
|
|
1482
|
-
message_id = db.enqueue(agent.id, text, sender_id)
|
|
1512
|
+
message_id = db.enqueue(agent.id, text, sender_id, notice=notice)
|
|
1483
1513
|
from brindle import login_pause
|
|
1484
1514
|
|
|
1485
1515
|
if login_pause.holding(db, agent.id):
|
|
@@ -1644,7 +1674,8 @@ def flush(db: DB, agent_id: str) -> bool:
|
|
|
1644
1674
|
delivered = True
|
|
1645
1675
|
launch_health.delivered(db, agent, baseline)
|
|
1646
1676
|
else:
|
|
1647
|
-
db.enqueue(agent_id, msg.body, msg.sender_id
|
|
1677
|
+
db.enqueue(agent_id, msg.body, msg.sender_id, # back in the queue, for the pane
|
|
1678
|
+
notice=msg.noticed_at is not None)
|
|
1648
1679
|
break
|
|
1649
1680
|
if delivered:
|
|
1650
1681
|
return True
|
|
@@ -1743,6 +1774,19 @@ def complete_subagent(db: DB, agent_id: str, result: str) -> Agent:
|
|
|
1743
1774
|
return db.get_agent(agent.id) or agent
|
|
1744
1775
|
|
|
1745
1776
|
|
|
1777
|
+
def first_line(text: str, limit: int = 160) -> str:
|
|
1778
|
+
"""The first non-empty line of ``text``, cut to ``limit`` characters."""
|
|
1779
|
+
line = next((ln.strip() for ln in text.splitlines() if ln.strip()), "")
|
|
1780
|
+
return line if len(line) <= limit else line[: limit - 1].rstrip() + "…"
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def condense(head: str, *body: str) -> str:
|
|
1784
|
+
"""A message that opens with the one-line ``head`` and keeps the long
|
|
1785
|
+
``body`` sections inside a collapsible block."""
|
|
1786
|
+
return (f"{head}\n\n<details><summary>Details</summary>\n\n"
|
|
1787
|
+
+ "\n\n".join(b for b in body if b) + "\n\n</details>")
|
|
1788
|
+
|
|
1789
|
+
|
|
1746
1790
|
def report_result(db: DB, agent_id: str, result: str, forward: bool = True,
|
|
1747
1791
|
removed: tuple[Agent, Workspace] | None = None) -> str:
|
|
1748
1792
|
"""Record a worker's or reviewer's result. A piped worker's report goes
|
|
@@ -1794,7 +1838,7 @@ def report_result(db: DB, agent_id: str, result: str, forward: bool = True,
|
|
|
1794
1838
|
where = f" on branch `{ws.branch}` (workspace {ws.id})" if ws else ""
|
|
1795
1839
|
send_message(
|
|
1796
1840
|
db, agent.parent_id,
|
|
1797
|
-
f"Assigned task finished{where}
|
|
1841
|
+
condense(f"Assigned task finished{where}: {first_line(result)}", forwarded),
|
|
1798
1842
|
sender_id=agent.id,
|
|
1799
1843
|
)
|
|
1800
1844
|
return "result recorded and sent to your supervisor"
|
|
@@ -2712,7 +2756,10 @@ def _stop_decision(db: DB, agent: Agent, payload: dict, via_inbox: bool = False)
|
|
|
2712
2756
|
decision = pilot.on_stop(db, agent, payload)
|
|
2713
2757
|
if decision:
|
|
2714
2758
|
db.set_status(agent.id, "processing")
|
|
2715
|
-
|
|
2759
|
+
# Claude Code shows the reason in the chat: one line, like any brindle message.
|
|
2760
|
+
kept = db.enqueue_held(agent.id, decision["reason"], None)
|
|
2761
|
+
db.mark_delivered(kept)
|
|
2762
|
+
return {**decision, "reason": one_line(db, kept, decision["reason"], None)}
|
|
2716
2763
|
db.set_status(agent.id, "idle")
|
|
2717
2764
|
return None
|
|
2718
2765
|
|
|
@@ -2868,7 +2915,11 @@ def handle_hook(db: DB, agent_id: str, event: str, payload: dict) -> dict | None
|
|
|
2868
2915
|
|
|
2869
2916
|
decision = pilot.on_stop(db, agent, payload)
|
|
2870
2917
|
if decision:
|
|
2871
|
-
|
|
2918
|
+
# Like any message from brindle: one line (or a notice), the rest via read_messages.
|
|
2919
|
+
try:
|
|
2920
|
+
send_message(db, agent_id, decision["reason"])
|
|
2921
|
+
except AgentError:
|
|
2922
|
+
pass # gone since its turn ended: nobody to nudge
|
|
2872
2923
|
if (agent.mode in REPORTING_MODES and agent.result is None
|
|
2873
2924
|
and not db.pending_count(agent_id)):
|
|
2874
2925
|
# No Stop hook can remind it to report, and no hook says when
|
|
@@ -101,6 +101,9 @@ to be asked each step.
|
|
|
101
101
|
the checks pass, merges it and removes the worktree; you get one message
|
|
102
102
|
per branch: merged, or "needs you" with the details. Don't request_review
|
|
103
103
|
or merge_workspace a reported branch yourself unless brindle says so.
|
|
104
|
+
- Messages reach you as one line each; read_messages(message_id=N) gives the
|
|
105
|
+
full text only when you need it. Relay worker results to the user in one line.
|
|
106
|
+
Reply to an agent with send_message(to_agent_id=<sender id>).
|
|
104
107
|
- After merging, call `check_milestone`. Only brindle's check marks a milestone
|
|
105
108
|
done: never claim one is done yourself. When every milestone passes, brindle
|
|
106
109
|
audits the work against the goal; the goal is reached only when that audit
|
|
@@ -1293,7 +1296,7 @@ def check_milestones(db: DB, root_id: str, ws: Workspace, position: int | None =
|
|
|
1293
1296
|
if m.output and m.status == "failed":
|
|
1294
1297
|
lines.append(f"\nMilestone {m.position} check output:\n{m.output}")
|
|
1295
1298
|
if done and audit is None:
|
|
1296
|
-
lines.append("\
|
|
1299
|
+
lines.append("\n" + GOAL_REACHED)
|
|
1297
1300
|
clear_finished_goal(db, root_id)
|
|
1298
1301
|
elif done:
|
|
1299
1302
|
lines.append("\n" + AUDIT_HOLD[audit].format(note=(ap.note if ap else None) or ""))
|
|
@@ -1307,6 +1310,7 @@ def check_milestones(db: DB, root_id: str, ws: Workspace, position: int | None =
|
|
|
1307
1310
|
# adversarial reviewer compares the work with the original goal before the
|
|
1308
1311
|
# goal counts as reached (``goal_audit`` in .brindle/config.json turns it off).
|
|
1309
1312
|
|
|
1313
|
+
GOAL_REACHED = "Every milestone's check passes: the goal is reached. Tell the user."
|
|
1310
1314
|
AUDIT_HOLD = {
|
|
1311
1315
|
"start": ("Every milestone's check passes. Before the goal counts as reached, an independent "
|
|
1312
1316
|
"auditor is checking the work against the original goal; its verdict arrives as a "
|
|
@@ -1561,6 +1565,35 @@ def progress(db: DB, root_id: str) -> str:
|
|
|
1561
1565
|
return "\n".join(lines)
|
|
1562
1566
|
|
|
1563
1567
|
|
|
1568
|
+
def check_summary(db: DB, root_id: str, position: int | None, text: str) -> str:
|
|
1569
|
+
"""The one line pushed after a milestone check: the result, how many
|
|
1570
|
+
milestones are verified, and a REGRESSED note. ``text`` is what
|
|
1571
|
+
check_milestones returned; the full progress stays in get_progress."""
|
|
1572
|
+
from brindle.agents import first_line
|
|
1573
|
+
|
|
1574
|
+
ms = db.milestones(root_id)
|
|
1575
|
+
done, total = counts(db, root_id)
|
|
1576
|
+
if not ms:
|
|
1577
|
+
return first_line(text)
|
|
1578
|
+
chosen = [m for m in ms if position is None or m.position == position]
|
|
1579
|
+
parts = []
|
|
1580
|
+
for m in chosen if position is not None else [m for m in ms if m.status == "failed"]:
|
|
1581
|
+
if m.status == "failed":
|
|
1582
|
+
parts.append(f"Milestone {m.position} ✗ failed: {first_line(m.output or 'no output')}")
|
|
1583
|
+
else:
|
|
1584
|
+
parts.append(f"Milestone {m.position} {MARK.get(m.status, '·')} {m.status}")
|
|
1585
|
+
head = "; ".join(parts) or "Milestone check"
|
|
1586
|
+
head += f" ({done} of {total} verified)"
|
|
1587
|
+
first = text.splitlines()[0] if text else ""
|
|
1588
|
+
if first.startswith("REGRESSED:"):
|
|
1589
|
+
head += f". {first}"
|
|
1590
|
+
if GOAL_REACHED in text.splitlines():
|
|
1591
|
+
head += ". Goal reached: tell the user"
|
|
1592
|
+
elif ms and done == total:
|
|
1593
|
+
head += ". Not done yet: completion audit pending, has gaps, or needs a commit"
|
|
1594
|
+
return head
|
|
1595
|
+
|
|
1596
|
+
|
|
1564
1597
|
# -- workers and the cap ----------------------------------------------------------
|
|
1565
1598
|
|
|
1566
1599
|
|
|
@@ -34,7 +34,34 @@ class _App(typer.Typer):
|
|
|
34
34
|
raise SystemExit(1)
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
SUPERVISOR_FLAGS = ("-s", "--supervisor")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _bare_supervisor_flag(args: list[str], commands) -> list[str]:
|
|
41
|
+
"""``args`` with picker.PICK after each ``-s``/``--supervisor`` that has
|
|
42
|
+
no CLI after it (it's last, or an option or a command follows), so a bare
|
|
43
|
+
``brindle -s`` opens the picker: a Typer option can't take an optional
|
|
44
|
+
value."""
|
|
45
|
+
from brindle.picker import PICK
|
|
46
|
+
|
|
47
|
+
out = []
|
|
48
|
+
for i, a in enumerate(args):
|
|
49
|
+
out.append(a)
|
|
50
|
+
if a == "--":
|
|
51
|
+
return out + list(args[i + 1:])
|
|
52
|
+
if a in SUPERVISOR_FLAGS:
|
|
53
|
+
nxt = args[i + 1] if i + 1 < len(args) else None
|
|
54
|
+
if nxt is None or nxt.startswith("-") or nxt in commands:
|
|
55
|
+
out.append(PICK)
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _PickerGroup(typer.core.TyperGroup):
|
|
60
|
+
def parse_args(self, ctx, args):
|
|
61
|
+
return super().parse_args(ctx, _bare_supervisor_flag(args, self.commands))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
app = _App(cls=_PickerGroup, add_completion=False, help="""brindle: run coding agents in parallel, each on its own git branch.
|
|
38
65
|
|
|
39
66
|
Run `brindle` with no arguments to open (or reopen) a supervisor chat here, with
|
|
40
67
|
the live dashboard underneath. Outside a git repo it starts a scratch session;
|
|
@@ -177,7 +204,7 @@ def demo(
|
|
|
177
204
|
typer.echo(f"demo repo: {root}")
|
|
178
205
|
os.chdir(root)
|
|
179
206
|
start(prompt=None, provider=None, attach=attach, watch=True,
|
|
180
|
-
autopilot=True, branch=None, worktree=None)
|
|
207
|
+
autopilot=True, branch=None, worktree=None, demo=True)
|
|
181
208
|
|
|
182
209
|
|
|
183
210
|
@app.command()
|
|
@@ -193,6 +220,7 @@ def new(
|
|
|
193
220
|
no_fetch: bool = typer.Option(False, "--no-fetch", help="Don't fetch the base branch first."),
|
|
194
221
|
no_setup: bool = typer.Option(False, "--no-setup", help="Skip setup commands."),
|
|
195
222
|
attach: bool = typer.Option(False, "--attach", help="Attach to the tmux session afterward."),
|
|
223
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't open the supervisor picker: use your last pick, else the default.")] = False,
|
|
196
224
|
provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
|
|
197
225
|
) -> None:
|
|
198
226
|
"""Create a worktree on a new branch and start a supervisor in it."""
|
|
@@ -209,6 +237,12 @@ def new(
|
|
|
209
237
|
_fail("--base can't be combined with --pr (the PR's base branch is used)")
|
|
210
238
|
elif not branch:
|
|
211
239
|
_fail("missing BRANCH (or pass --pr <number>)")
|
|
240
|
+
if not no_agent:
|
|
241
|
+
try:
|
|
242
|
+
root = git.main_repo_root(os.getcwd())
|
|
243
|
+
except git.GitError:
|
|
244
|
+
root = None
|
|
245
|
+
provider = _resolve_supervisor(root, provider, yes=yes)
|
|
212
246
|
db = DB()
|
|
213
247
|
if pr is not None:
|
|
214
248
|
created = _run(workspaces.create_from_pr, db, os.getcwd(), pr, run_setup=not no_setup)
|
|
@@ -258,7 +292,9 @@ def start(
|
|
|
258
292
|
autopilot: Optional[bool] = typer.Option(None, "--autopilot/--no-autopilot", help="The supervisor drives toward a goal until it's verified (default: on, or `autopilot` in .brindle/config.json)."),
|
|
259
293
|
branch: Optional[str] = typer.Option(None, "--branch", "-b", help="Run in the worktree for this branch, creating the branch and worktree if needed."),
|
|
260
294
|
worktree: Optional[str] = typer.Option(None, "--worktree", "-w", help="Run in the worktree at this path (created with --branch if it doesn't exist)."),
|
|
295
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't open the supervisor picker: use your last pick, else the default.")] = False,
|
|
261
296
|
provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
|
|
297
|
+
demo: Annotated[bool, typer.Option("--demo-run", hidden=True)] = False,
|
|
262
298
|
) -> None:
|
|
263
299
|
"""Start a fresh supervisor chat here, with the dashboard alongside. A launch only ever starts a supervisor; it starts the other agents itself.
|
|
264
300
|
|
|
@@ -312,6 +348,7 @@ def start(
|
|
|
312
348
|
if provider and provider in NOT_SUPERVISOR:
|
|
313
349
|
_fail(f"the {provider} provider can't run the supervisor; use one of: "
|
|
314
350
|
f"{', '.join(SUPERVISOR_PROVIDERS)}")
|
|
351
|
+
provider = _resolve_supervisor(ws.repo_root, provider, yes=yes, demo=demo)
|
|
315
352
|
_preflight(agents.SUPERVISOR, provider, ws.repo_root)
|
|
316
353
|
_trust_codex_hook(ws.repo_root)
|
|
317
354
|
# Nothing slow before the chat starts: the paused session's leftover
|
|
@@ -366,6 +403,72 @@ def _preflight(agent: str, provider: Optional[str], repo_root: str) -> None:
|
|
|
366
403
|
_fail("brindle can't start yet. `brindle doctor` checks everything else.")
|
|
367
404
|
|
|
368
405
|
|
|
406
|
+
def _is_ci_env() -> bool:
|
|
407
|
+
for key in ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "CIRCLECI", "TF_BUILD", "JENKINS_URL", "BRINDLE_CI"):
|
|
408
|
+
val = (os.environ.get(key) or "").strip().lower()
|
|
409
|
+
if val and val not in ("0", "false", "no", "off"):
|
|
410
|
+
return True
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _can_prompt(yes: bool = False, demo: bool = False) -> bool:
|
|
415
|
+
return bool(sys.stdin.isatty() and yes is not True and demo is not True and not _is_ci_env())
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _resolve_supervisor(repo_root: str | None, provider: Optional[str], *,
|
|
419
|
+
yes: bool = False, demo: bool = False) -> Optional[str]:
|
|
420
|
+
"""The CLI the supervisor runs on (None: its profile's). ``-s CLI`` picks
|
|
421
|
+
it for this run only; a bare ``-s`` opens the picker, whose choice is
|
|
422
|
+
remembered. Otherwise the
|
|
423
|
+
person's last pick, asked again in the picker only on the first launch or
|
|
424
|
+
when that pick isn't signed in or allowed, and only where brindle may ask.
|
|
425
|
+
A CLI the org doesn't allow this person is refused, whichever way it came."""
|
|
426
|
+
from brindle import doctor as doctor_mod, picker
|
|
427
|
+
from brindle.config import SUPERVISOR_KEY, set_user, supervisor_pick
|
|
428
|
+
from brindle.pro import team_policy
|
|
429
|
+
|
|
430
|
+
acc = team_policy.access(repo_root)
|
|
431
|
+
pick = supervisor_pick()
|
|
432
|
+
|
|
433
|
+
def ask() -> str:
|
|
434
|
+
chosen = picker.pick_supervisor(acc, pick)
|
|
435
|
+
if chosen is None:
|
|
436
|
+
typer.echo("No supervisor picked; nothing was started.")
|
|
437
|
+
raise typer.Exit(1)
|
|
438
|
+
return chosen
|
|
439
|
+
|
|
440
|
+
from_picker = False
|
|
441
|
+
if provider == picker.PICK:
|
|
442
|
+
if not _can_prompt(yes, demo):
|
|
443
|
+
_fail("`brindle -s` on its own opens the supervisor picker, which needs a terminal; "
|
|
444
|
+
"name the CLI instead, e.g. `brindle -s codex`")
|
|
445
|
+
provider = ask()
|
|
446
|
+
from_picker = True
|
|
447
|
+
elif provider:
|
|
448
|
+
why = acc.refusal(provider)
|
|
449
|
+
if why:
|
|
450
|
+
_fail(f"can't run the supervisor on {provider}: {why}")
|
|
451
|
+
if _can_prompt(yes, demo) and provider not in doctor_mod.signed_in_providers():
|
|
452
|
+
if not picker.sign_in(provider):
|
|
453
|
+
raise typer.Exit(1)
|
|
454
|
+
elif _can_prompt(yes, demo) and not (pick and acc.allows(pick)
|
|
455
|
+
and pick in doctor_mod.signed_in_providers()):
|
|
456
|
+
provider = ask()
|
|
457
|
+
from_picker = True
|
|
458
|
+
elif pick and acc.allows(pick):
|
|
459
|
+
provider = pick
|
|
460
|
+
final = provider or _run(load_profile, agents.SUPERVISOR, repo_root).provider
|
|
461
|
+
why = acc.refusal(final)
|
|
462
|
+
if why:
|
|
463
|
+
_fail(f"can't run the supervisor on {final}: {why}. Pick another with `brindle -s`.")
|
|
464
|
+
if provider:
|
|
465
|
+
# only the interactive picker saves a choice; a -s flag is this session's override
|
|
466
|
+
if from_picker and provider != pick:
|
|
467
|
+
set_user(SUPERVISOR_KEY, provider)
|
|
468
|
+
typer.echo(f"Supervisor: {provider} ({'your pick' if from_picker else 'this run'}) · brindle -s to change")
|
|
469
|
+
return provider
|
|
470
|
+
|
|
471
|
+
|
|
369
472
|
def _trust_codex_hook(repo_root: str) -> None:
|
|
370
473
|
"""With the permission policy on and Codex installed, trust brindle's Codex
|
|
371
474
|
hook if it isn't yet (or brindle moved), and say so in one line, so Codex
|
|
@@ -761,6 +864,7 @@ def default(
|
|
|
761
864
|
None, "--supervisor", "-s", help="Run the supervisor on this CLI instead of Claude Code (codex, antigravity). Workers keep their own profiles' providers.",
|
|
762
865
|
),
|
|
763
866
|
show_version: bool = typer.Option(False, "--version", help="Print brindle's version and exit."),
|
|
867
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't open the supervisor picker: use your last pick, else the default.")] = False,
|
|
764
868
|
provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
|
|
765
869
|
) -> None:
|
|
766
870
|
"""Bare `brindle`: a fresh supervisor chat here (a scratch session outside git)."""
|
|
@@ -782,7 +886,7 @@ def default(
|
|
|
782
886
|
continue_cmd(session_id=None, attach=True)
|
|
783
887
|
else:
|
|
784
888
|
start(prompt=None, provider=provider, attach=True, watch=True,
|
|
785
|
-
autopilot=autopilot, branch=None, worktree=None)
|
|
889
|
+
autopilot=autopilot, branch=None, worktree=None, yes=yes)
|
|
786
890
|
|
|
787
891
|
|
|
788
892
|
@app.command()
|
|
@@ -796,12 +900,13 @@ def supervisor(
|
|
|
796
900
|
autopilot: Optional[bool] = typer.Option(None, "--autopilot/--no-autopilot", help="The supervisor drives toward a goal until it's verified (default: on, or `autopilot` in .brindle/config.json)."),
|
|
797
901
|
branch: Optional[str] = typer.Option(None, "--branch", "-b", help="Run in the worktree for this branch, creating the branch and worktree if needed."),
|
|
798
902
|
worktree: Optional[str] = typer.Option(None, "--worktree", "-w", help="Run in the worktree at this path (created with --branch if it doesn't exist)."),
|
|
903
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't open the supervisor picker: use your last pick, else the default.")] = False,
|
|
799
904
|
provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
|
|
800
905
|
) -> None:
|
|
801
906
|
"""Start an explicit supervisor session to coordinate coding agents."""
|
|
802
907
|
provider = provider or provider_alias
|
|
803
908
|
start(prompt=prompt, provider=provider, attach=attach, watch=watch,
|
|
804
|
-
autopilot=autopilot, branch=branch, worktree=worktree)
|
|
909
|
+
autopilot=autopilot, branch=branch, worktree=worktree, yes=yes)
|
|
805
910
|
|
|
806
911
|
|
|
807
912
|
def _session_root(db: DB) -> str:
|
|
@@ -2850,8 +2955,10 @@ def check_milestones_cmd(root_id: str, workspace_id: str,
|
|
|
2850
2955
|
try:
|
|
2851
2956
|
text = autopilot.check_milestones(db, root_id, ws, position)
|
|
2852
2957
|
record_milestone_changes(db, root_id, ws, before, root, position, text)
|
|
2958
|
+
head = autopilot.check_summary(db, root_id, position, text)
|
|
2853
2959
|
except Exception as e: # noqa: BLE001 - the supervisor must hear about it either way
|
|
2854
2960
|
text = f"The milestone check failed to run: {e}"
|
|
2961
|
+
head = agents.first_line(text)
|
|
2855
2962
|
try:
|
|
2856
2963
|
autopilot.start_audit(db, root_id, ws)
|
|
2857
2964
|
except Exception as e: # noqa: BLE001
|
|
@@ -2863,7 +2970,7 @@ def check_milestones_cmd(root_id: str, workspace_id: str,
|
|
|
2863
2970
|
db.update_autopilot(root_id, checking_since=None)
|
|
2864
2971
|
if db.get_agent(root_id) is None:
|
|
2865
2972
|
return
|
|
2866
|
-
body = f"[brindle]
|
|
2973
|
+
body = agents.condense(f"[brindle] {head}", text + "\n\n(The same progress, any time: get_progress.)")
|
|
2867
2974
|
try:
|
|
2868
2975
|
agents.send_message(db, root_id, body)
|
|
2869
2976
|
except (agents.AgentError, tmux.TmuxError):
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
13
|
+
import sys
|
|
13
14
|
from dataclasses import dataclass, field
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
|
|
@@ -94,7 +95,7 @@ class RepoConfig:
|
|
|
94
95
|
pre_commit: bool = True # run pre-commit (the framework) over a branch before merging
|
|
95
96
|
max_agents: int = 5 # workers running at once per session; 0 means no cap
|
|
96
97
|
check_timeout: int = 900 # seconds allowed for each check command
|
|
97
|
-
check_concurrency: int =
|
|
98
|
+
check_concurrency: int = 4 # check runs at once on this machine, across branches; the rest queue (0: no cap)
|
|
98
99
|
usage_limit: int = 90 # autopilot stops pushing on at this % of the Claude usage limit
|
|
99
100
|
limit_cooldown_minutes: int | None = None # how long a provider that hit its limit counts as unavailable (default 300 for Antigravity)
|
|
100
101
|
graphify: bool | None = None # point agents at graphify-out/graph.json (None: if it's there)
|
|
@@ -267,6 +268,9 @@ def load_repo_config(repo_root: str | Path) -> RepoConfig:
|
|
|
267
268
|
if norm_tier in WEIGHTS and isinstance(names, list):
|
|
268
269
|
cfg.routing[norm_tier] = [n for n in names if isinstance(n, str)]
|
|
269
270
|
cfg.routing_sources[norm_tier] = str(path)
|
|
271
|
+
from brindle import worker_models
|
|
272
|
+
|
|
273
|
+
worker_models.apply(cfg, user, str(repo_root)) # the models picked on the picker's workers screen
|
|
270
274
|
for source in (shared, local): # per weight, so a repo can override one and keep the rest
|
|
271
275
|
second = source.get("second_review")
|
|
272
276
|
if isinstance(second, dict):
|
|
@@ -311,9 +315,23 @@ def user_settings() -> dict:
|
|
|
311
315
|
return _read_json(user_config_path())
|
|
312
316
|
|
|
313
317
|
|
|
318
|
+
SUPERVISOR_KEY = "supervisor" # ~/.brindle/config.json: the CLI the person last picked for the supervisor
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def supervisor_pick() -> str | None:
|
|
322
|
+
"""The supervisor CLI this person last picked (in the picker or with
|
|
323
|
+
``-s``), or None before the first pick."""
|
|
324
|
+
pick = user_settings().get(SUPERVISOR_KEY)
|
|
325
|
+
return pick if isinstance(pick, str) and pick else None
|
|
326
|
+
|
|
327
|
+
|
|
314
328
|
def set_user(key: str, value: object) -> Path:
|
|
315
329
|
"""Set one key in ``~/.brindle/config.json`` (other keys are kept)."""
|
|
316
330
|
path = user_config_path()
|
|
331
|
+
if os.environ.get("BRINDLE_AGENT_ID"):
|
|
332
|
+
# A brindle agent's CLI call must not change the person's own settings.
|
|
333
|
+
print("brindle: running as an agent, so ~/.brindle/config.json was not changed", file=sys.stderr)
|
|
334
|
+
return path
|
|
317
335
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
318
336
|
data = _read_json(path)
|
|
319
337
|
data[key] = value
|
|
@@ -49,6 +49,9 @@ from brindle.pro import route_health
|
|
|
49
49
|
|
|
50
50
|
# An agent this new may not have its window recorded yet.
|
|
51
51
|
LAUNCH_GRACE = 60.0
|
|
52
|
+
# How long a message waits for its normal delivery (a hook, the start-up
|
|
53
|
+
# flush) before a sweep retries it.
|
|
54
|
+
FLUSH_RETRY_AFTER = 30.0
|
|
52
55
|
|
|
53
56
|
|
|
54
57
|
def _root(db: DB, a: Agent) -> str:
|
|
@@ -202,6 +205,21 @@ def sweep(db: DB, now: float | None = None) -> list[str]:
|
|
|
202
205
|
done.append(f"closed {'idle' if finished else 'stopped'} worker {a.id} "
|
|
203
206
|
f"after {int(idle_for // 60)} min")
|
|
204
207
|
|
|
208
|
+
# 2b. Messages a flush left queued because the pane wasn't ready (the
|
|
209
|
+
# person typing, say): an idle agent with no turn-end hook (agy), or one
|
|
210
|
+
# just waiting, would never get them otherwise. flush itself still waits
|
|
211
|
+
# for typed input, a held login, and claims the agent before pasting.
|
|
212
|
+
for a in db.list_agents():
|
|
213
|
+
oldest = db.oldest_pending(a.id)
|
|
214
|
+
if (oldest is not None and now - oldest >= FLUSH_RETRY_AFTER and a.status == "idle"
|
|
215
|
+
and not a.headless and a.dismissed_at is None and agents.runs_process(a)
|
|
216
|
+
and here(a) and alive(a)):
|
|
217
|
+
try:
|
|
218
|
+
if agents.flush(db, a.id):
|
|
219
|
+
done.append(f"delivered a queued message to {a.id}")
|
|
220
|
+
except (agents.AgentError, tmux.TmuxError):
|
|
221
|
+
pass # stays queued for the next sweep
|
|
222
|
+
|
|
205
223
|
# 3. Launches stopped on a startup prompt; workers stuck on a prompt
|
|
206
224
|
# nobody is answering, or gone silent.
|
|
207
225
|
done.extend(settle_startups(db, now, panes))
|