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.
Files changed (112) hide show
  1. {brindle-0.1.0 → brindle-0.2.0}/PKG-INFO +15 -11
  2. {brindle-0.1.0 → brindle-0.2.0}/README.md +14 -10
  3. {brindle-0.1.0 → brindle-0.2.0}/pyproject.toml +6 -2
  4. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/agents.py +8 -2
  5. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/autopilot.py +26 -1
  6. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/budget.py +3 -1
  7. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/ci_adapters.py +1 -1
  8. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/ci_client.py +14 -3
  9. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/cli.py +68 -15
  10. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/config.py +5 -4
  11. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/cost.py +44 -20
  12. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/cost_estimate.py +12 -7
  13. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/cull.py +9 -2
  14. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/db.py +14 -0
  15. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/events.py +42 -1
  16. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/mcp_server.py +58 -7
  17. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pricing.py +2 -1
  18. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/account.py +14 -3
  19. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/rollout.py +39 -12
  20. brindle-0.2.0/src/brindle/pro/status.py +591 -0
  21. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/team_events.py +37 -3
  22. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/team_policy.py +42 -5
  23. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/profiles.py +9 -2
  24. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/savings.py +4 -2
  25. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/scratch.py +34 -2
  26. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/tasks.py +86 -3
  27. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/tmux.py +29 -3
  28. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/usage.py +12 -6
  29. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/watch.py +83 -9
  30. brindle-0.2.0/src/brindle/watch_costs.py +298 -0
  31. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/workspaces.py +58 -0
  32. {brindle-0.1.0 → brindle-0.2.0}/.gitignore +0 -0
  33. {brindle-0.1.0 → brindle-0.2.0}/LICENSE +0 -0
  34. {brindle-0.1.0 → brindle-0.2.0}/SCHEDULE-A +0 -0
  35. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/__init__.py +0 -0
  36. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/__main__.py +0 -0
  37. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/account.py +0 -0
  38. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/airgap.py +0 -0
  39. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/antigravity.py +0 -0
  40. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/__init__.py +0 -0
  41. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  42. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/developer-codex.md +0 -0
  43. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  44. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/developer-local.md +0 -0
  45. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/developer.md +0 -0
  46. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/qa.md +0 -0
  47. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
  48. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/reviewer-local.md +0 -0
  49. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/reviewer.md +0 -0
  50. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/subagent.md +0 -0
  51. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_agents/supervisor.md +0 -0
  52. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_rules/__init__.py +0 -0
  53. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_rules/security/backend.md +0 -0
  54. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  55. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/builtin_rules/tests/only.md +0 -0
  56. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/ci_federation.py +0 -0
  57. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/ci_hosts.py +0 -0
  58. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/codemap.py +0 -0
  59. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/codex_hook.py +0 -0
  60. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/conflicts.py +0 -0
  61. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/demo.py +0 -0
  62. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/detect.py +0 -0
  63. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/doctor.py +0 -0
  64. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/gates.py +0 -0
  65. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/git.py +0 -0
  66. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/guardrails.py +0 -0
  67. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/history.py +0 -0
  68. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/inbox.py +0 -0
  69. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/learned_rules.py +0 -0
  70. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/learning.py +0 -0
  71. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/legacy.py +0 -0
  72. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/native/__init__.py +0 -0
  73. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/native/client.py +0 -0
  74. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/native/loop.py +0 -0
  75. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/native/permissions.py +0 -0
  76. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/native/runner.py +0 -0
  77. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/native/serve.py +0 -0
  78. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/native/tools.py +0 -0
  79. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/permissions.py +0 -0
  80. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pipeline.py +0 -0
  81. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/plugins.py +0 -0
  82. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/policy.py +0 -0
  83. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pool.py +0 -0
  84. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/__init__.py +0 -0
  85. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/_files.py +0 -0
  86. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/audit_chain.py +0 -0
  87. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/audit_export.py +0 -0
  88. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/auth.py +0 -0
  89. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/cost_centers.py +0 -0
  90. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/credentials.py +0 -0
  91. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/features.py +0 -0
  92. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/keys.py +0 -0
  93. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/learning.py +0 -0
  94. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/license.py +0 -0
  95. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/loopback.py +0 -0
  96. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/managed_models.py +0 -0
  97. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/org_profiles.py +0 -0
  98. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/orgkey.py +0 -0
  99. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/pro/settings_sync.py +0 -0
  100. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/procs.py +0 -0
  101. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/providers.py +0 -0
  102. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/quota.py +0 -0
  103. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/repo_cmds.py +0 -0
  104. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/repos.py +0 -0
  105. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/rewind.py +0 -0
  106. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/rule_checks.py +0 -0
  107. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/secrets.py +0 -0
  108. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/services.py +0 -0
  109. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/sessions.py +0 -0
  110. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/sidebar_follow.py +0 -0
  111. {brindle-0.1.0 → brindle-0.2.0}/src/brindle/status_cache.py +0 -0
  112. {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.1.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.1.0`). A session started before an upgrade keeps
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. A model brindle has no price
441
- for is shown as unpriced tokens, never guessed. Add or correct prices under
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` | `false` | `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. Off, brindle uses a server that's already running and never starts or preloads one |
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: a 30B model holds about 20 GB
1552
- of GPU memory, so brindle doesn't start or preload one unless you set
1553
- `"local_models": true`. Then, when `brindle` (or `brindle continue`)
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.1.0`). A session started before an upgrade keeps
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. A model brindle has no price
411
- for is shown as unpriced tokens, never guessed. Add or correct prices under
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` | `false` | `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. Off, brindle uses a server that's already running and never starts or preloads one |
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: a 30B model holds about 20 GB
1522
- of GPU memory, so brindle doesn't start or preload one unless you set
1523
- `"local_models": true`. Then, when `brindle` (or `brindle continue`)
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.1.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 = ["pytest>=8"]
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
- dollars = usage_mod.usage_cost(u, pricing.repo_overrides(ws.repo_root if ws else None))
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
- sync_goals_file(db, root_id)
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(goal_usd=p.budget_goal_usd, month_usd=p.budget_seat_month_usd, seat_spent_usd=spent)
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
- limit = plan.get("budget_usd")
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
- for a in db.list_agents(ws.id):
391
- if a.mode == "interactive" and a.status not in ("paused", "done") and agents.is_alive(a):
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
- for a in db.list_agents(ws.id):
419
- if a.mode == "interactive" and a.status not in ("paused", "done") and agents.is_alive(a):
420
- # A session starts here next: keep its local models loaded.
421
- agents.pause(db, a.id, stop_procs=stop_procs, stop_local_models=False)
422
- typer.echo(f"Paused the session that was still running here ({a.id}); "
423
- f"`brindle continue {a.id}` brings it back.")
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 exporter.flush(force=force).items():
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
- full = os.environ.get("GITHUB_REPOSITORY") or repo
2211
- typer.echo(ci_client.doctor(os.environ, full, os.getcwd()))
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). Off by default: a 30B model holds ~20 GB of GPU
106
- # memory, so brindle uses a server that's already running and leaves
107
- # starting one to the person.
108
- local_models: bool = False
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
- text = pricing.money(self.dollars) if self.tokens or not self.unknown else "unknown"
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
- text += f" (+{format_tokens(self.unknown)} tokens unpriced)" if self.tokens else \
58
- f" ({format_tokens(self.unknown)} tokens unpriced)"
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
- label = p.model or (f"profile {p.row.profile}" if p.row.profile else "unknown model")
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.tokens + b.unknown)} tokens")
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
- lines.append(f"Merged branches: {rep.merged}, no priced spend on them" if rep.merged
379
- else "Merged branches: none in this period")
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.unknown)} tokens unpriced)"
382
- if rep.merged_spend.unknown else "")
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("")