brindle 0.1.0__tar.gz → 0.2.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.
- {brindle-0.1.0 → brindle-0.2.0}/PKG-INFO +15 -11
- {brindle-0.1.0 → brindle-0.2.0}/README.md +14 -10
- {brindle-0.1.0 → brindle-0.2.0}/pyproject.toml +6 -2
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/agents.py +8 -2
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/autopilot.py +26 -1
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/budget.py +3 -1
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/ci_adapters.py +1 -1
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/ci_client.py +14 -3
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/cli.py +68 -15
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/config.py +5 -4
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/cost.py +44 -20
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/cost_estimate.py +12 -7
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/cull.py +9 -2
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/db.py +14 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/events.py +42 -1
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/mcp_server.py +58 -7
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pricing.py +2 -1
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/account.py +14 -3
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/rollout.py +39 -12
- brindle-0.2.0/src/brindle/pro/status.py +591 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/team_events.py +37 -3
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/team_policy.py +42 -5
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/profiles.py +9 -2
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/savings.py +4 -2
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/scratch.py +34 -2
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/tasks.py +86 -3
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/tmux.py +29 -3
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/usage.py +12 -6
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/watch.py +83 -9
- brindle-0.2.0/src/brindle/watch_costs.py +298 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/workspaces.py +58 -0
- {brindle-0.1.0 → brindle-0.2.0}/.gitignore +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/LICENSE +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/SCHEDULE-A +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/__init__.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/__main__.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/account.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/airgap.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/antigravity.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/backend-auditor.md +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/developer-codex.md +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/developer-heavy.md +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/developer-local.md +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/developer.md +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/qa.md +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/reviewer-local.md +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/reviewer.md +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_rules/__init__.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_rules/security/backend.md +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_rules/tests/only.md +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/ci_federation.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/ci_hosts.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/codemap.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/codex_hook.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/conflicts.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/demo.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/detect.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/doctor.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/gates.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/git.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/guardrails.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/history.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/inbox.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/learned_rules.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/learning.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/legacy.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/native/__init__.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/native/client.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/native/loop.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/native/permissions.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/native/runner.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/native/serve.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/native/tools.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/permissions.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pipeline.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/plugins.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/policy.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pool.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/_files.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/audit_export.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/auth.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/cost_centers.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/features.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/keys.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/learning.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/license.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/managed_models.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/org_profiles.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/settings_sync.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/procs.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/providers.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/quota.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/repos.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/rewind.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/rule_checks.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/secrets.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/services.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/sessions.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/sidebar_follow.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/status_cache.py +0 -0
- {brindle-0.1.0 → brindle-0.2.0}/src/brindle/view.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: brindle
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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.
|
|
92
|
+
session shows it too (`brindle 0.2.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
|
|
@@ -389,12 +389,13 @@ knowing them helps when you tell the supervisor how to work.
|
|
|
389
389
|
|
|
390
390
|
| Tool | Used by | |
|
|
391
391
|
|---|---|---|
|
|
392
|
-
| `assign` / `handoff` / `wait_for_worker` | supervisor | start a worker (return now / wait for its result / keep waiting) |
|
|
392
|
+
| `assign` / `handoff` / `wait_for_worker` | supervisor | start a worker (return now / wait for its result / keep waiting); `dry_run=true` on `assign`/`handoff` starts nothing and reports the profile, what each `files` glob matches, overlaps and whether it would queue |
|
|
393
393
|
| `send_message` | any agent | message another agent; delivered when it's idle |
|
|
394
394
|
| `read_messages` | supervisor | read the messages agents and brindle sent you and mark them read; with `message_delivery` `"pull"` (the default) you get a one-line "N new messages" notice instead of each message |
|
|
395
|
-
| `list_agents` / `list_tasks` / `list_agent_profiles` | supervisor | who's running, what's queued, which profiles exist |
|
|
395
|
+
| `list_agents` / `list_tasks` / `list_agent_profiles` | supervisor | who's running, what's queued, which profiles exist; `list_tasks(full=true)` also shows each task's brief and `done_when` |
|
|
396
396
|
| `list_repos` | supervisor | the repos attached to the session (brindle Pro); `repo=<alias>` on `assign`/`handoff` puts a worker there |
|
|
397
397
|
| `cancel_task` | supervisor | cancel a queued task (and its dependents) to re-plan |
|
|
398
|
+
| `requeue` | supervisor | queue a cancelled task again with the same brief, files and profile; `depends_on` replaces its dependencies |
|
|
398
399
|
| `workspace_diff` | supervisor | a worker branch's changes against its base |
|
|
399
400
|
| `request_review` / `submit_review` | supervisor / reviewer | start a reviewer on a branch / record its verdict |
|
|
400
401
|
| `merge_workspace` / `remove_workspace` | supervisor | merge through the gates / delete the worktree |
|
|
@@ -437,8 +438,10 @@ count as unknown.
|
|
|
437
438
|
`brindle cost` prices the last 30 days of history at each model's list price
|
|
438
439
|
(per million tokens: input, output, cache write, cache read), copied from
|
|
439
440
|
Anthropic's and OpenAI's pricing pages and dated; brindle warns when they're
|
|
440
|
-
more than 90 days old. Local models cost $0
|
|
441
|
-
|
|
441
|
+
more than 90 days old. Local models cost $0, and usage on a model known to be
|
|
442
|
+
free reads "Free" (its tokens still count; a mix reads "$1.20 (+40k tokens
|
|
443
|
+
free)"). A model brindle has no price for is shown as unpriced tokens, never
|
|
444
|
+
guessed. Add or correct prices under
|
|
442
445
|
`pricing` in `~/.brindle/config.json` or `.brindle/config.json`:
|
|
443
446
|
|
|
444
447
|
```json
|
|
@@ -538,7 +541,7 @@ Autopilot, merge gates and cleanup:
|
|
|
538
541
|
| `protected_paths` | `[]` | paths or globs (`"migrations/"`, `"*.lock"`) whose merge conflicts brindle never hands to a worker to resolve: the branch waits for you instead (see "Conflict-aware merging") |
|
|
539
542
|
| `pool_size` | `1` if `setup` is set, else `0` | pre-built worktrees (checked out, files copied, setup run) kept ready so a new worker doesn't wait on `setup`; `0` disables it |
|
|
540
543
|
| `add_dirs` | `[]` | directories outside the worktree that Claude Code agents may use (`--add-dir`; full tool access, see "Directories outside the workspace") |
|
|
541
|
-
| `local_models` | `
|
|
544
|
+
| `local_models` | `true` | When a native profile points at Ollama on this machine and it isn't running, `brindle` starts `ollama serve` in the background (with the context length the profiles need) and loads their models, then stops it when the last session closes. `false`: brindle uses a server that's already running and never starts or preloads one |
|
|
542
545
|
| `sidebar` | `"left"` | where the dashboard sits in each window: `"left"` of the chat, or `"bottom"` (full-width rows under it) |
|
|
543
546
|
| `delegation` | `"balanced"` | how readily the supervisor hands work to workers. `"conservative"` does most work in its own chat (fewest tokens), `"fast"` splits any multi-part request across parallel workers straight away (quickest, most tokens). `brindle delegation fast` saves it for every repo and session (in `~/.brindle/config.json`; `--repo` for this repo only) and tells a running supervisor |
|
|
544
547
|
| `delete_merged_branches` | `true` | removing a worktree (after a merge, `brindle rm`, `brindle prune`, session cleanup) also deletes its branch once every commit is in its base, so finished branches don't pile up. An unmerged branch is always kept; `false` keeps them all. If GitHub keeps merged PR branches, the first `brindle pr` in a repo offers to turn on its automatic deletion with your `gh` login (repo admins only) |
|
|
@@ -742,10 +745,11 @@ how to use them, and the next step to get the rest:
|
|
|
742
745
|
| Brindle-CI (fixes builds, issues into verified pull requests, validation) | Team | `brindle ci init` |
|
|
743
746
|
| org profile and rule-pack library, pinned so a repo can't loosen it | Team | `brindle account org profiles` |
|
|
744
747
|
| org budgets and protected paths | Team | `budget`, `protected_paths` in your org policy (`brindle account org policy`) |
|
|
748
|
+
| per-member budgets, pause, and admin messages (sidebar "Messages", the bottom bar, `brindle org messages`) | Team | `brindle org messages [--keep]` lists and marks read the notices your admins sent you; a pause refuses new workers and stops running ones |
|
|
745
749
|
| audit log, air-gap | Enterprise | `brindle audit verify`, `"airgap": true` |
|
|
746
750
|
| audit export | Enterprise | `"audit_export"` in `~/.brindle/config.json`, `brindle audit ship` |
|
|
747
751
|
| managed models | Enterprise | `provider_config`, `deny_personal_keys` in your org policy (see "Managed models" below) |
|
|
748
|
-
| wider CI, cost centers, managed rollout | Enterprise | `brindle ci init --host gitlab`, `brindle cost request`, and `min_version`, required packs and the kill switch in your org policy |
|
|
752
|
+
| wider CI, cost centers, managed rollout | Enterprise | `brindle ci init --host gitlab`, `brindle cost request`, and `min_version`, required packs and the kill switch in your org policy; admins can also shut down or throttle a member, role or org remotely (it arrives within about a minute) |
|
|
749
753
|
|
|
750
754
|
`brindle ci init` checks your plan before it changes anything: it refuses
|
|
751
755
|
without Brindle-CI on the org you're using, and on Pro it sets up only the fix
|
|
@@ -1548,9 +1552,9 @@ ollama pull qwen3-coder:30b
|
|
|
1548
1552
|
brindle doctor # "model qwen3-coder:30b ... is available"
|
|
1549
1553
|
```
|
|
1550
1554
|
|
|
1551
|
-
Run `ollama serve` yourself, or let brindle do it
|
|
1552
|
-
of GPU memory, so
|
|
1553
|
-
|
|
1555
|
+
Run `ollama serve` yourself, or let brindle do it, with no setup. A 30B model holds
|
|
1556
|
+
about 20 GB of GPU memory, so set `"local_models": false` if you don't want brindle
|
|
1557
|
+
to start or preload one. When `brindle` (or `brindle continue`)
|
|
1554
1558
|
starts and a native profile points at Ollama on this machine that isn't answering,
|
|
1555
1559
|
brindle starts it in the background with `OLLAMA_CONTEXT_LENGTH` set to the largest
|
|
1556
1560
|
`context_tokens` any profile asks of it plus room for the reply (40960 for the
|
|
@@ -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.
|
|
62
|
+
session shows it too (`brindle 0.2.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
|
|
@@ -359,12 +359,13 @@ knowing them helps when you tell the supervisor how to work.
|
|
|
359
359
|
|
|
360
360
|
| Tool | Used by | |
|
|
361
361
|
|---|---|---|
|
|
362
|
-
| `assign` / `handoff` / `wait_for_worker` | supervisor | start a worker (return now / wait for its result / keep waiting) |
|
|
362
|
+
| `assign` / `handoff` / `wait_for_worker` | supervisor | start a worker (return now / wait for its result / keep waiting); `dry_run=true` on `assign`/`handoff` starts nothing and reports the profile, what each `files` glob matches, overlaps and whether it would queue |
|
|
363
363
|
| `send_message` | any agent | message another agent; delivered when it's idle |
|
|
364
364
|
| `read_messages` | supervisor | read the messages agents and brindle sent you and mark them read; with `message_delivery` `"pull"` (the default) you get a one-line "N new messages" notice instead of each message |
|
|
365
|
-
| `list_agents` / `list_tasks` / `list_agent_profiles` | supervisor | who's running, what's queued, which profiles exist |
|
|
365
|
+
| `list_agents` / `list_tasks` / `list_agent_profiles` | supervisor | who's running, what's queued, which profiles exist; `list_tasks(full=true)` also shows each task's brief and `done_when` |
|
|
366
366
|
| `list_repos` | supervisor | the repos attached to the session (brindle Pro); `repo=<alias>` on `assign`/`handoff` puts a worker there |
|
|
367
367
|
| `cancel_task` | supervisor | cancel a queued task (and its dependents) to re-plan |
|
|
368
|
+
| `requeue` | supervisor | queue a cancelled task again with the same brief, files and profile; `depends_on` replaces its dependencies |
|
|
368
369
|
| `workspace_diff` | supervisor | a worker branch's changes against its base |
|
|
369
370
|
| `request_review` / `submit_review` | supervisor / reviewer | start a reviewer on a branch / record its verdict |
|
|
370
371
|
| `merge_workspace` / `remove_workspace` | supervisor | merge through the gates / delete the worktree |
|
|
@@ -407,8 +408,10 @@ count as unknown.
|
|
|
407
408
|
`brindle cost` prices the last 30 days of history at each model's list price
|
|
408
409
|
(per million tokens: input, output, cache write, cache read), copied from
|
|
409
410
|
Anthropic's and OpenAI's pricing pages and dated; brindle warns when they're
|
|
410
|
-
more than 90 days old. Local models cost $0
|
|
411
|
-
|
|
411
|
+
more than 90 days old. Local models cost $0, and usage on a model known to be
|
|
412
|
+
free reads "Free" (its tokens still count; a mix reads "$1.20 (+40k tokens
|
|
413
|
+
free)"). A model brindle has no price for is shown as unpriced tokens, never
|
|
414
|
+
guessed. Add or correct prices under
|
|
412
415
|
`pricing` in `~/.brindle/config.json` or `.brindle/config.json`:
|
|
413
416
|
|
|
414
417
|
```json
|
|
@@ -508,7 +511,7 @@ Autopilot, merge gates and cleanup:
|
|
|
508
511
|
| `protected_paths` | `[]` | paths or globs (`"migrations/"`, `"*.lock"`) whose merge conflicts brindle never hands to a worker to resolve: the branch waits for you instead (see "Conflict-aware merging") |
|
|
509
512
|
| `pool_size` | `1` if `setup` is set, else `0` | pre-built worktrees (checked out, files copied, setup run) kept ready so a new worker doesn't wait on `setup`; `0` disables it |
|
|
510
513
|
| `add_dirs` | `[]` | directories outside the worktree that Claude Code agents may use (`--add-dir`; full tool access, see "Directories outside the workspace") |
|
|
511
|
-
| `local_models` | `
|
|
514
|
+
| `local_models` | `true` | When a native profile points at Ollama on this machine and it isn't running, `brindle` starts `ollama serve` in the background (with the context length the profiles need) and loads their models, then stops it when the last session closes. `false`: brindle uses a server that's already running and never starts or preloads one |
|
|
512
515
|
| `sidebar` | `"left"` | where the dashboard sits in each window: `"left"` of the chat, or `"bottom"` (full-width rows under it) |
|
|
513
516
|
| `delegation` | `"balanced"` | how readily the supervisor hands work to workers. `"conservative"` does most work in its own chat (fewest tokens), `"fast"` splits any multi-part request across parallel workers straight away (quickest, most tokens). `brindle delegation fast` saves it for every repo and session (in `~/.brindle/config.json`; `--repo` for this repo only) and tells a running supervisor |
|
|
514
517
|
| `delete_merged_branches` | `true` | removing a worktree (after a merge, `brindle rm`, `brindle prune`, session cleanup) also deletes its branch once every commit is in its base, so finished branches don't pile up. An unmerged branch is always kept; `false` keeps them all. If GitHub keeps merged PR branches, the first `brindle pr` in a repo offers to turn on its automatic deletion with your `gh` login (repo admins only) |
|
|
@@ -712,10 +715,11 @@ how to use them, and the next step to get the rest:
|
|
|
712
715
|
| Brindle-CI (fixes builds, issues into verified pull requests, validation) | Team | `brindle ci init` |
|
|
713
716
|
| org profile and rule-pack library, pinned so a repo can't loosen it | Team | `brindle account org profiles` |
|
|
714
717
|
| org budgets and protected paths | Team | `budget`, `protected_paths` in your org policy (`brindle account org policy`) |
|
|
718
|
+
| per-member budgets, pause, and admin messages (sidebar "Messages", the bottom bar, `brindle org messages`) | Team | `brindle org messages [--keep]` lists and marks read the notices your admins sent you; a pause refuses new workers and stops running ones |
|
|
715
719
|
| audit log, air-gap | Enterprise | `brindle audit verify`, `"airgap": true` |
|
|
716
720
|
| audit export | Enterprise | `"audit_export"` in `~/.brindle/config.json`, `brindle audit ship` |
|
|
717
721
|
| managed models | Enterprise | `provider_config`, `deny_personal_keys` in your org policy (see "Managed models" below) |
|
|
718
|
-
| wider CI, cost centers, managed rollout | Enterprise | `brindle ci init --host gitlab`, `brindle cost request`, and `min_version`, required packs and the kill switch in your org policy |
|
|
722
|
+
| wider CI, cost centers, managed rollout | Enterprise | `brindle ci init --host gitlab`, `brindle cost request`, and `min_version`, required packs and the kill switch in your org policy; admins can also shut down or throttle a member, role or org remotely (it arrives within about a minute) |
|
|
719
723
|
|
|
720
724
|
`brindle ci init` checks your plan before it changes anything: it refuses
|
|
721
725
|
without Brindle-CI on the org you're using, and on Pro it sets up only the fix
|
|
@@ -1518,9 +1522,9 @@ ollama pull qwen3-coder:30b
|
|
|
1518
1522
|
brindle doctor # "model qwen3-coder:30b ... is available"
|
|
1519
1523
|
```
|
|
1520
1524
|
|
|
1521
|
-
Run `ollama serve` yourself, or let brindle do it
|
|
1522
|
-
of GPU memory, so
|
|
1523
|
-
|
|
1525
|
+
Run `ollama serve` yourself, or let brindle do it, with no setup. A 30B model holds
|
|
1526
|
+
about 20 GB of GPU memory, so set `"local_models": false` if you don't want brindle
|
|
1527
|
+
to start or preload one. When `brindle` (or `brindle continue`)
|
|
1524
1528
|
starts and a native profile points at Ollama on this machine that isn't answering,
|
|
1525
1529
|
brindle starts it in the background with `OLLAMA_CONTEXT_LENGTH` set to the largest
|
|
1526
1530
|
`context_tokens` any profile asks of it plus room for the reply (40960 for the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "brindle"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.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"
|
|
@@ -49,7 +49,10 @@ audit = "brindle.pro.audit_chain:make"
|
|
|
49
49
|
audit_export = "brindle.pro.audit_export:make"
|
|
50
50
|
|
|
51
51
|
[dependency-groups]
|
|
52
|
-
dev = [
|
|
52
|
+
dev = [
|
|
53
|
+
"pytest>=8",
|
|
54
|
+
"pytest-xdist>=3.8.0",
|
|
55
|
+
]
|
|
53
56
|
|
|
54
57
|
[build-system]
|
|
55
58
|
requires = ["hatchling>=1.27"]
|
|
@@ -69,3 +72,4 @@ only-include = ["src", "README.md", "LICENSE", "SCHEDULE-A", "pyproject.toml"]
|
|
|
69
72
|
[tool.pytest.ini_options]
|
|
70
73
|
testpaths = ["tests"]
|
|
71
74
|
pythonpath = ["tests"]
|
|
75
|
+
markers = ["serial: run outside xdist (`pytest -m serial`); the parallel pass skips these"]
|
|
@@ -1358,11 +1358,16 @@ def report_result(db: DB, agent_id: str, result: str, forward: bool = True,
|
|
|
1358
1358
|
agent=agent, usage=u, branch=ws.branch, task=agent.task, result=result,
|
|
1359
1359
|
)
|
|
1360
1360
|
if u and u.total:
|
|
1361
|
+
free = False
|
|
1361
1362
|
try:
|
|
1362
|
-
|
|
1363
|
+
root = ws.repo_root if ws else None
|
|
1364
|
+
extra = pricing.repo_overrides(root)
|
|
1365
|
+
price = pricing.price_for(u.model, extra) or pricing.profile_price(agent.profile, root, extra)
|
|
1366
|
+
dollars = usage_mod.usage_cost(u, extra, fallback=price)
|
|
1367
|
+
free = price == pricing.FREE
|
|
1363
1368
|
except Exception: # noqa: BLE001 - a price is an extra too
|
|
1364
1369
|
dollars = None
|
|
1365
|
-
forwarded = f"{result}\n\n{usage_mod.summary_line(u, dollars)}"
|
|
1370
|
+
forwarded = f"{result}\n\n{usage_mod.summary_line(u, dollars, free)}"
|
|
1366
1371
|
else:
|
|
1367
1372
|
forwarded = result
|
|
1368
1373
|
if ws and agent.mode in ("handoff", "handoff_detached", "assign"):
|
|
@@ -1609,6 +1614,7 @@ def delegate(
|
|
|
1609
1614
|
start = base if git.branch_exists(caller_ws.repo_root, base) else "HEAD"
|
|
1610
1615
|
created = workspaces.create(
|
|
1611
1616
|
db, caller_ws.path, branch, base, fetch=False, start=start,
|
|
1617
|
+
reuse_registered=True,
|
|
1612
1618
|
)
|
|
1613
1619
|
if created.setup and not created.setup.ok:
|
|
1614
1620
|
raise AgentError(f"workspace setup failed:\n{created.setup.log}")
|
|
@@ -334,6 +334,30 @@ def sync_goals_file(db: DB, root_id: str) -> None:
|
|
|
334
334
|
log.exception("brindle: couldn't sync milestone status to goals.md")
|
|
335
335
|
|
|
336
336
|
|
|
337
|
+
def clear_finished_goal(db: DB, root_id: str) -> None:
|
|
338
|
+
"""The goal is reached: empty the goals.md it was loaded from (when that
|
|
339
|
+
file is still this goal's) and clear the session's goal, so the next
|
|
340
|
+
session doesn't reload a finished goal. Never raises: a file we can't
|
|
341
|
+
write must not fail the verdict."""
|
|
342
|
+
try:
|
|
343
|
+
ap = db.get_autopilot(root_id)
|
|
344
|
+
if not ap or not ap.goal:
|
|
345
|
+
return
|
|
346
|
+
if ap.goals_file:
|
|
347
|
+
path = Path(ap.goals_file)
|
|
348
|
+
try:
|
|
349
|
+
plan = parse_goals(path.read_bytes().decode("utf-8"))
|
|
350
|
+
except OSError:
|
|
351
|
+
plan = None
|
|
352
|
+
if plan is not None and plan.goal == ap.goal:
|
|
353
|
+
path.write_bytes(b"")
|
|
354
|
+
db.set_milestones(root_id, [])
|
|
355
|
+
db.update_autopilot(root_id, goal=None, detail=None, goals_file=None, goal_sha=None,
|
|
356
|
+
audit_sha=None, audit_ok=None, auditor_id=None, note=None)
|
|
357
|
+
except Exception: # noqa: BLE001
|
|
358
|
+
log.exception("brindle: couldn't clear the finished goal")
|
|
359
|
+
|
|
360
|
+
|
|
337
361
|
# -- sessions ----------------------------------------------------------------
|
|
338
362
|
|
|
339
363
|
|
|
@@ -729,6 +753,7 @@ def check_milestones(db: DB, root_id: str, ws: Workspace, position: int | None =
|
|
|
729
753
|
lines.append(f"\nMilestone {m.position} check output:\n{m.output}")
|
|
730
754
|
if done and audit is None:
|
|
731
755
|
lines.append("\nEvery milestone's check passes: the goal is reached. Tell the user.")
|
|
756
|
+
clear_finished_goal(db, root_id)
|
|
732
757
|
elif done:
|
|
733
758
|
lines.append("\n" + AUDIT_HOLD[audit].format(note=(ap.note if ap else None) or ""))
|
|
734
759
|
return "\n".join(lines)
|
|
@@ -853,7 +878,7 @@ def record_audit(db: DB, root_id: str, approved: bool, summary: str) -> str:
|
|
|
853
878
|
if approved:
|
|
854
879
|
db.update_autopilot(root_id, state="done", note=None, audit_ok=1, auditor_id=None)
|
|
855
880
|
db.bump_progress(root_id)
|
|
856
|
-
|
|
881
|
+
clear_finished_goal(db, root_id)
|
|
857
882
|
return (f"[brindle] Completion audit of {sha}: APPROVED. Every milestone passes and the "
|
|
858
883
|
f"auditor found the goal fully delivered: the goal is reached. Tell the user."
|
|
859
884
|
f"\n\n{summary}")
|
|
@@ -101,7 +101,8 @@ def org_limits(repo_root: str | None) -> Limits | None:
|
|
|
101
101
|
spent = p.spend_seat_usd
|
|
102
102
|
if spent is not None and p.spend_month != time.strftime("%Y-%m", time.gmtime()):
|
|
103
103
|
spent = None # last month's number
|
|
104
|
-
lim = Limits(
|
|
104
|
+
lim = Limits(task_usd=p.budget_task_usd, goal_usd=p.budget_goal_usd,
|
|
105
|
+
month_usd=p.budget_seat_month_usd, seat_spent_usd=spent)
|
|
105
106
|
return lim if lim.any() else None
|
|
106
107
|
|
|
107
108
|
|
|
@@ -125,6 +126,7 @@ def limits(cfg, repo_root: str | None = None) -> Limits | None:
|
|
|
125
126
|
org = Limits(month_usd=0.0, goal_usd=0.0)
|
|
126
127
|
if org is not None:
|
|
127
128
|
lim.org = True
|
|
129
|
+
lim.task_usd = _tighter(lim.task_usd, org.task_usd)
|
|
128
130
|
lim.goal_usd = _tighter(lim.goal_usd, org.goal_usd)
|
|
129
131
|
lim.month_usd = _tighter(lim.month_usd, org.month_usd)
|
|
130
132
|
lim.seat_spent_usd = org.seat_spent_usd
|
|
@@ -66,7 +66,7 @@ LOOPBACK = frozenset({"localhost", "127.0.0.1"})
|
|
|
66
66
|
HOST_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$")
|
|
67
67
|
# A base_url written plainly: scheme, host, optional port, optional plain path.
|
|
68
68
|
BASE_URL_RE = re.compile(r"^(?P<scheme>https?)://(?P<host>[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?"
|
|
69
|
-
r"(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*)(:[0-9]{1,5})?(/[A-Za-z0-9._~/-]*)
|
|
69
|
+
r"(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*)(:[0-9]{1,5})?(/[A-Za-z0-9._~/-]*)?\Z")
|
|
70
70
|
# Every known provider credential: what a check must not see, and what a
|
|
71
71
|
# repo-supplied native profile may not point at its own endpoint.
|
|
72
72
|
PROVIDER_KEYS = frozenset((*CLAUDE_API_KEYS, *CLAUDE_SUBSCRIPTION, *CODEX_API_KEYS,
|
|
@@ -1139,6 +1139,15 @@ def _run_session(plan: dict, run_token: str, *, repo: str, cwd: str, env: Mutabl
|
|
|
1139
1139
|
deadline = started + (plan["limits"]["timeout_min"] + TIMEOUT_MARGIN_MIN) * 60
|
|
1140
1140
|
final: str | None = None
|
|
1141
1141
|
event: dict = {"state": "working", "milestones": [], "usage": {}}
|
|
1142
|
+
spent_before = 0.0 # what earlier attempts (before an escalation) cost: the limit is for the whole run
|
|
1143
|
+
|
|
1144
|
+
def over_limit() -> bool:
|
|
1145
|
+
limit = plan.get("budget_usd")
|
|
1146
|
+
if limit is None or usage_usd(event["usage"], cwd) + spent_before < limit:
|
|
1147
|
+
return False
|
|
1148
|
+
say(f"the run has spent the org's {pricing.money(limit)} limit")
|
|
1149
|
+
return True
|
|
1150
|
+
|
|
1142
1151
|
while final is None:
|
|
1143
1152
|
sleep(plan["limits"]["heartbeat_s"])
|
|
1144
1153
|
event = session_event(db, root.id, adapter, cwd=cwd, base_sha=base_sha, branch=branch,
|
|
@@ -1151,6 +1160,8 @@ def _run_session(plan: dict, run_token: str, *, repo: str, cwd: str, env: Mutabl
|
|
|
1151
1160
|
log.warning("brindle ci: heartbeat failed (%s): %s", e.code, e)
|
|
1152
1161
|
if clock() > deadline:
|
|
1153
1162
|
final = "timeout"
|
|
1163
|
+
elif over_limit(): # the org's limit is checked here, so an unreachable server can't lift it
|
|
1164
|
+
final = "budget"
|
|
1154
1165
|
continue
|
|
1155
1166
|
action = answer.get("action")
|
|
1156
1167
|
if action == "continue":
|
|
@@ -1170,6 +1181,8 @@ def _run_session(plan: dict, run_token: str, *, repo: str, cwd: str, env: Mutabl
|
|
|
1170
1181
|
or new["branch"] != branch:
|
|
1171
1182
|
raise CIError("the escalation plan is for another run", code="bad_plan")
|
|
1172
1183
|
adapter.stop(db, root.id)
|
|
1184
|
+
spent_before += usage_usd(event["usage"], cwd)
|
|
1185
|
+
event = {**event, "usage": {}} # the new supervisor starts its own count
|
|
1173
1186
|
# An escalation may only tighten the org's limits, never drop or raise them.
|
|
1174
1187
|
paths = list(dict.fromkeys((plan.get("protected_paths") or []) + (new.get("protected_paths") or [])))
|
|
1175
1188
|
if paths:
|
|
@@ -1183,10 +1196,8 @@ def _run_session(plan: dict, run_token: str, *, repo: str, cwd: str, env: Mutabl
|
|
|
1183
1196
|
else:
|
|
1184
1197
|
raise CIError("the server sent an unknown action", code="bad_response")
|
|
1185
1198
|
# The org's dollar limit for the run (Team org_budgets), on top of the server's tokens.
|
|
1186
|
-
|
|
1187
|
-
if final is None and limit is not None and usage_usd(event["usage"], cwd) >= limit:
|
|
1199
|
+
if final is None and over_limit():
|
|
1188
1200
|
final = "budget"
|
|
1189
|
-
say(f"the run has spent the org's {pricing.money(limit)} limit")
|
|
1190
1201
|
adapter.stop(db, root.id)
|
|
1191
1202
|
event = session_event(db, root.id, adapter, cwd=cwd, base_sha=base_sha, branch=branch,
|
|
1192
1203
|
milestone_ids=[m["id"] for m in plan.get("milestones") or []])
|
|
@@ -92,6 +92,7 @@ def _after_detach(ws: Workspace) -> None:
|
|
|
92
92
|
if tmux.has_session(ws.tmux_session):
|
|
93
93
|
typer.echo("Detached; everything is still running. Run `brindle` here to reopen.")
|
|
94
94
|
return
|
|
95
|
+
agents._stop_local_models(DB()) # the Ollama brindle started, if nothing else uses it
|
|
95
96
|
typer.echo("brindle session paused: nothing is running, and all work is saved.")
|
|
96
97
|
typer.echo(" `brindle continue` picks it up where you left off; `brindle` starts fresh.")
|
|
97
98
|
if scratch.is_scratch(ws.path) and not scratch.transferred_to(ws.path):
|
|
@@ -385,12 +386,20 @@ def _say_autopilot(db: DB, root_id: str) -> None:
|
|
|
385
386
|
typer.echo(" `brindle autopilot off` hands the wheel back to you.")
|
|
386
387
|
|
|
387
388
|
|
|
389
|
+
def _live_sessions(db: DB, ws: Workspace) -> list:
|
|
390
|
+
"""The live sessions (interactive agents) in this checkout. An agent whose
|
|
391
|
+
recorded pane id a newer agent has since taken (tmux reuses ids) isn't
|
|
392
|
+
live: that pane is the newer agent's."""
|
|
393
|
+
owners = agents.pane_owners(db)
|
|
394
|
+
return [a for a in db.list_agents(ws.id)
|
|
395
|
+
if a.mode == "interactive" and a.status not in ("paused", "done")
|
|
396
|
+
and agents.is_alive(a) and agents.owns_pane(db, a, owners)]
|
|
397
|
+
|
|
398
|
+
|
|
388
399
|
def _running_session(db: DB, ws: Workspace):
|
|
389
400
|
"""The live session (its interactive agent) in this checkout, if any."""
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
return a
|
|
393
|
-
return None
|
|
401
|
+
live = _live_sessions(db, ws)
|
|
402
|
+
return live[0] if live else None
|
|
394
403
|
|
|
395
404
|
|
|
396
405
|
def _ask_about_running(running) -> str:
|
|
@@ -415,12 +424,17 @@ def _session_worktree(db: DB, ws: Workspace) -> Workspace:
|
|
|
415
424
|
def _pause_running(db: DB, ws: Workspace, stop_procs: bool = True) -> None:
|
|
416
425
|
"""At most one live session per checkout: pause any that's still running.
|
|
417
426
|
``stop_procs=False`` when a detached cull follows (see agents.pause)."""
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
427
|
+
from brindle import scratch
|
|
428
|
+
|
|
429
|
+
for a in _live_sessions(db, ws):
|
|
430
|
+
# A session starts here next: keep its local models loaded.
|
|
431
|
+
agents.pause(db, a.id, stop_procs=stop_procs, stop_local_models=False)
|
|
432
|
+
typer.echo(f"Paused the session that was still running here ({a.id}); "
|
|
433
|
+
f"`brindle continue {a.id}` brings it back.")
|
|
434
|
+
# Chats left running in scratch sessions whose work moved into this repo.
|
|
435
|
+
for aid in scratch.pause_transferred_to(db, ws.repo_root):
|
|
436
|
+
typer.echo(f"Paused scratch session chat {aid}: its work moved into this repo; "
|
|
437
|
+
f"`brindle continue {aid}` brings it back.")
|
|
424
438
|
|
|
425
439
|
|
|
426
440
|
def _describe(s) -> str:
|
|
@@ -1578,7 +1592,8 @@ def learning() -> None:
|
|
|
1578
1592
|
typer.echo(p.report())
|
|
1579
1593
|
|
|
1580
1594
|
|
|
1581
|
-
@app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True
|
|
1595
|
+
@app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True,
|
|
1596
|
+
"help_option_names": []}) # --help is the plugin's: it lists every subcommand
|
|
1582
1597
|
def account(ctx: typer.Context) -> None:
|
|
1583
1598
|
"""brindle Pro/Team: paid features, login, upgrade, billing, orgs. Bare `brindle account` shows what you have."""
|
|
1584
1599
|
from brindle import account as account_mod
|
|
@@ -1614,6 +1629,30 @@ def _audit_repo(repo: Optional[str]) -> str:
|
|
|
1614
1629
|
return os.path.abspath(start)
|
|
1615
1630
|
|
|
1616
1631
|
|
|
1632
|
+
org_app = typer.Typer(help="Your org's messages (brindle Pro Team).")
|
|
1633
|
+
app.add_typer(org_app, name="org")
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
@org_app.command("messages")
|
|
1637
|
+
def org_messages(
|
|
1638
|
+
keep: bool = typer.Option(False, "--keep", help="Don't mark the messages read."),
|
|
1639
|
+
) -> None:
|
|
1640
|
+
"""List the notices your org's admins sent you, and mark them read."""
|
|
1641
|
+
from brindle.pro import status
|
|
1642
|
+
|
|
1643
|
+
notes, fresh = status.fetch_messages()
|
|
1644
|
+
if not notes:
|
|
1645
|
+
typer.echo("No new messages." if fresh else "No messages (couldn't reach the org; "
|
|
1646
|
+
"these are the last ones seen).")
|
|
1647
|
+
return
|
|
1648
|
+
for n in notes:
|
|
1649
|
+
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(n["at"])) if n["at"] else ""
|
|
1650
|
+
who = n["from_role"] + (" (to everyone)" if n["scope"] == "org" else "")
|
|
1651
|
+
typer.echo(f"{when} {who}: {n['text']}".strip())
|
|
1652
|
+
if fresh and not keep:
|
|
1653
|
+
status.mark_read([n["id"] for n in notes])
|
|
1654
|
+
|
|
1655
|
+
|
|
1617
1656
|
@audit_app.command("verify")
|
|
1618
1657
|
def audit_verify(
|
|
1619
1658
|
repo: Optional[str] = typer.Option(None, "--repo", help="The repo whose log to verify (default: here)."),
|
|
@@ -1675,8 +1714,17 @@ def audit_ship(
|
|
|
1675
1714
|
if not exporter.entitled():
|
|
1676
1715
|
typer.echo("audit: your plan doesn't include audit export (brindle Enterprise)")
|
|
1677
1716
|
raise typer.Exit(2)
|
|
1717
|
+
from brindle import airgap
|
|
1718
|
+
|
|
1719
|
+
if airgap.enabled():
|
|
1720
|
+
typer.echo(f"audit: air-gap mode is on via {airgap.source()}: nothing is sent")
|
|
1721
|
+
raise typer.Exit(2)
|
|
1722
|
+
results = exporter.flush(force=force)
|
|
1723
|
+
if not results:
|
|
1724
|
+
typer.echo("audit: another brindle process is sending right now; run this again in a moment")
|
|
1725
|
+
raise typer.Exit(1)
|
|
1678
1726
|
failed = False
|
|
1679
|
-
for name, res in
|
|
1727
|
+
for name, res in results.items():
|
|
1680
1728
|
failed = failed or bool(res.error)
|
|
1681
1729
|
typer.echo(f"{name}: sent {res.sent}" + (f", dropped {res.dropped}" if res.dropped else "")
|
|
1682
1730
|
+ (f"; FAILED: {res.error}" if res.error else ""))
|
|
@@ -2204,11 +2252,16 @@ def ci_doctor(
|
|
|
2204
2252
|
repo: Optional[str] = typer.Option(None, "--repo", help="owner/name (default: GITHUB_REPOSITORY)."),
|
|
2205
2253
|
) -> None:
|
|
2206
2254
|
"""Which provider CLIs and credential names are here, and which providers CI may use on this repository."""
|
|
2207
|
-
from brindle import ci_client
|
|
2255
|
+
from brindle import ci_client, ci_hosts
|
|
2208
2256
|
|
|
2209
2257
|
def go():
|
|
2210
|
-
|
|
2211
|
-
|
|
2258
|
+
env = os.environ
|
|
2259
|
+
if ci_hosts.GitLabHost.detect(env) and not ci_hosts.GitHubHost.detect(env):
|
|
2260
|
+
# the job's own project and the host's owner rule, not `gh`
|
|
2261
|
+
full, org = env.get("CI_PROJECT_PATH") or repo, ci_hosts.GitLabHost.org_hint
|
|
2262
|
+
else:
|
|
2263
|
+
full, org = env.get("GITHUB_REPOSITORY") or repo, None
|
|
2264
|
+
typer.echo(ci_client.doctor(env, full, os.getcwd(), org=org))
|
|
2212
2265
|
|
|
2213
2266
|
_ci_call(go)
|
|
2214
2267
|
|
|
@@ -102,10 +102,11 @@ class RepoConfig:
|
|
|
102
102
|
services: list[dict] = field(default_factory=list)
|
|
103
103
|
# Start Ollama in the background when a native profile points at it on
|
|
104
104
|
# this machine and it isn't running, and preload the models (see
|
|
105
|
-
# brindle.native.serve).
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
|
|
105
|
+
# brindle.native.serve). On by default, and only ever does anything for a
|
|
106
|
+
# native profile pointing at Ollama on a loopback address; the server
|
|
107
|
+
# brindle started is stopped again when the last session closes. Set
|
|
108
|
+
# false to never start or preload one.
|
|
109
|
+
local_models: bool = True
|
|
109
110
|
delete_merged_branches: bool = True # removing a worktree deletes its branch once fully merged into its base
|
|
110
111
|
pr_footer: bool = True # `brindle pr` ends the PR body with one "built with brindle" line
|
|
111
112
|
sidebar: str = "left" # where the dashboard sits: "left" of the chat or "bottom"
|
|
@@ -41,22 +41,36 @@ class NotEntitled(Exception):
|
|
|
41
41
|
@dataclass
|
|
42
42
|
class Bucket:
|
|
43
43
|
dollars: float = 0.0
|
|
44
|
-
tokens: int = 0 # tokens priced into ``dollars``
|
|
44
|
+
tokens: int = 0 # tokens priced into ``dollars`` (not the free ones)
|
|
45
45
|
unknown: int = 0 # tokens with no known price
|
|
46
|
+
free: int = 0 # tokens on models known to be free (priced at exactly zero)
|
|
46
47
|
|
|
47
|
-
def add(self, dollars: float | None, tokens: int) -> None:
|
|
48
|
+
def add(self, dollars: float | None, tokens: int, free: bool = False) -> None:
|
|
48
49
|
if dollars is None:
|
|
49
50
|
self.unknown += tokens
|
|
51
|
+
elif free:
|
|
52
|
+
self.free += tokens
|
|
50
53
|
else:
|
|
51
54
|
self.dollars += dollars
|
|
52
55
|
self.tokens += tokens
|
|
53
56
|
|
|
57
|
+
@property
|
|
58
|
+
def all_tokens(self) -> int:
|
|
59
|
+
return self.tokens + self.free + self.unknown
|
|
60
|
+
|
|
54
61
|
def show(self) -> str:
|
|
55
|
-
|
|
62
|
+
if self.tokens:
|
|
63
|
+
text = pricing.money(self.dollars)
|
|
64
|
+
if self.free:
|
|
65
|
+
text += f" (+{format_tokens(self.free)} tokens free)"
|
|
66
|
+
if self.unknown:
|
|
67
|
+
text += f" (+{format_tokens(self.unknown)} tokens unpriced)"
|
|
68
|
+
return text
|
|
69
|
+
if self.free:
|
|
70
|
+
return "Free" + (f" (+{format_tokens(self.unknown)} tokens unpriced)" if self.unknown else "")
|
|
56
71
|
if self.unknown:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return text
|
|
72
|
+
return f"unknown ({format_tokens(self.unknown)} tokens unpriced)"
|
|
73
|
+
return pricing.money(self.dollars)
|
|
60
74
|
|
|
61
75
|
|
|
62
76
|
@dataclass
|
|
@@ -66,6 +80,7 @@ class Priced:
|
|
|
66
80
|
model: str | None
|
|
67
81
|
tokens: int
|
|
68
82
|
dollars: float | None # None: no price known
|
|
83
|
+
free: bool = False # priced at exactly zero (a local model)
|
|
69
84
|
|
|
70
85
|
|
|
71
86
|
class Pricer:
|
|
@@ -100,7 +115,7 @@ class Pricer:
|
|
|
100
115
|
if price is not None:
|
|
101
116
|
dollars = price.cost(int(d.get("input") or 0), int(d.get("output") or 0),
|
|
102
117
|
int(d.get("cache_creation") or 0), int(d.get("cache_read") or 0))
|
|
103
|
-
return Priced(row, model, total, dollars)
|
|
118
|
+
return Priced(row, model, total, dollars, price is not None and price == pricing.FREE)
|
|
104
119
|
|
|
105
120
|
|
|
106
121
|
def rows_since(db: DB, repo_root: str | None, since: float) -> list[HistoryEntry]:
|
|
@@ -129,13 +144,16 @@ class Summary:
|
|
|
129
144
|
by_model: dict[str, Bucket] = field(default_factory=dict)
|
|
130
145
|
|
|
131
146
|
|
|
147
|
+
def model_label(p: Priced) -> str:
|
|
148
|
+
return p.model or (f"profile {p.row.profile}" if p.row.profile else "unknown model")
|
|
149
|
+
|
|
150
|
+
|
|
132
151
|
def summary(db: DB, repo_root: str | None, now: float | None = None, days: int = DAYS) -> Summary:
|
|
133
152
|
now = time.time() if now is None else now
|
|
134
153
|
s = Summary(days)
|
|
135
154
|
for p in priced_rows(db, repo_root, now - days * 86400):
|
|
136
|
-
s.total.add(p.dollars, p.tokens)
|
|
137
|
-
|
|
138
|
-
s.by_model.setdefault(label, Bucket()).add(p.dollars, p.tokens)
|
|
155
|
+
s.total.add(p.dollars, p.tokens, p.free)
|
|
156
|
+
s.by_model.setdefault(model_label(p), Bucket()).add(p.dollars, p.tokens, p.free)
|
|
139
157
|
return s
|
|
140
158
|
|
|
141
159
|
|
|
@@ -157,7 +175,7 @@ def describe_summary(s: Summary, repo_root: str | None) -> str:
|
|
|
157
175
|
if not s.by_model:
|
|
158
176
|
lines.append(" no agent usage on record")
|
|
159
177
|
for label, b in sorted(s.by_model.items(), key=lambda kv: (-kv[1].dollars, kv[0])):
|
|
160
|
-
lines.append(f" {label:<28} {b.show():<24} {format_tokens(b.
|
|
178
|
+
lines.append(f" {label:<28} {b.show():<24} {format_tokens(b.all_tokens)} tokens")
|
|
161
179
|
lines += _footer()
|
|
162
180
|
lines.append("By day, profile and goal, cost per merged branch and review pass rates: "
|
|
163
181
|
"`brindle cost report` (brindle Pro).")
|
|
@@ -243,18 +261,18 @@ def report(db: DB, repo_root: str | None, now: float | None = None, days: int =
|
|
|
243
261
|
rep = Report(days)
|
|
244
262
|
goals: dict = {}
|
|
245
263
|
for p in priced_rows(db, repo_root, since):
|
|
246
|
-
rep.total.add(p.dollars, p.tokens)
|
|
264
|
+
rep.total.add(p.dollars, p.tokens, p.free)
|
|
247
265
|
day = time.strftime("%Y-%m-%d", time.localtime(p.row.ts))
|
|
248
|
-
rep.by_day.setdefault(day, Bucket()).add(p.dollars, p.tokens)
|
|
249
|
-
rep.by_profile.setdefault(p.row.profile or "?", Bucket()).add(p.dollars, p.tokens)
|
|
250
|
-
rep.by_goal.setdefault(_goal_of(db, p.row.agent_id, goals), Bucket()).add(p.dollars, p.tokens)
|
|
266
|
+
rep.by_day.setdefault(day, Bucket()).add(p.dollars, p.tokens, p.free)
|
|
267
|
+
rep.by_profile.setdefault(p.row.profile or "?", Bucket()).add(p.dollars, p.tokens, p.free)
|
|
268
|
+
rep.by_goal.setdefault(_goal_of(db, p.row.agent_id, goals), Bucket()).add(p.dollars, p.tokens, p.free)
|
|
251
269
|
rows = rows_since(db, repo_root, since)
|
|
252
270
|
merged = {(r.repo_root, r.branch) for r in rows if r.kind == "merge" and r.branch}
|
|
253
271
|
rep.merged = len(merged)
|
|
254
272
|
# every row of a merged branch counts toward it, back to before the window began
|
|
255
273
|
for p in priced_rows(db, repo_root, 0):
|
|
256
274
|
if (p.row.repo_root, p.row.branch) in merged:
|
|
257
|
-
rep.merged_spend.add(p.dollars, p.tokens)
|
|
275
|
+
rep.merged_spend.add(p.dollars, p.tokens, p.free)
|
|
258
276
|
worker_profile = {(r.repo_root, r.branch): r.profile for r in db.list_history(repo_root, "worker_result", ALL_ROWS)
|
|
259
277
|
if r.branch and r.profile} # newest first: keep the oldest (the worker's own)
|
|
260
278
|
for r in rows:
|
|
@@ -375,11 +393,17 @@ def describe_report(rep: Report, repo_root: str | None) -> str:
|
|
|
375
393
|
lines.append("")
|
|
376
394
|
per = rep.per_merged_branch
|
|
377
395
|
if per is None:
|
|
378
|
-
|
|
379
|
-
|
|
396
|
+
if rep.merged and rep.merged_spend.free and not rep.merged_spend.tokens:
|
|
397
|
+
lines.append(f"Merged branches: {rep.merged}, Free"
|
|
398
|
+
+ (f" (+{format_tokens(rep.merged_spend.unknown)} tokens unpriced)"
|
|
399
|
+
if rep.merged_spend.unknown else ""))
|
|
400
|
+
else:
|
|
401
|
+
lines.append(f"Merged branches: {rep.merged}, no priced spend on them" if rep.merged
|
|
402
|
+
else "Merged branches: none in this period")
|
|
380
403
|
else:
|
|
381
|
-
unpriced = (f" (+{format_tokens(rep.merged_spend.
|
|
382
|
-
|
|
404
|
+
unpriced = ((f" (+{format_tokens(rep.merged_spend.free)} tokens free)" if rep.merged_spend.free else "")
|
|
405
|
+
+ (f" (+{format_tokens(rep.merged_spend.unknown)} tokens unpriced)"
|
|
406
|
+
if rep.merged_spend.unknown else ""))
|
|
383
407
|
lines.append(f"Merged branches: {rep.merged}, {pricing.money(per)} each on average "
|
|
384
408
|
f"(every row on the branch: worker, reviews, merge){unpriced}")
|
|
385
409
|
lines.append("")
|