brindle 0.2.0__tar.gz → 0.2.1__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.2.0 → brindle-0.2.1}/.gitignore +1 -0
- {brindle-0.2.0 → brindle-0.2.1}/PKG-INFO +3 -2
- {brindle-0.2.0 → brindle-0.2.1}/README.md +2 -1
- {brindle-0.2.0 → brindle-0.2.1}/pyproject.toml +1 -1
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/agents.py +142 -118
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/autopilot.py +72 -14
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/budget.py +46 -5
- brindle-0.2.1/src/brindle/builtin_agents/developer-codex.md +6 -0
- brindle-0.2.1/src/brindle/builtin_agents/developer-heavy.md +7 -0
- brindle-0.2.1/src/brindle/builtin_agents/developer-light.md +7 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/developer.md +4 -3
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/reviewer-codex.md +4 -9
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/reviewer-local.md +3 -6
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/reviewer.md +4 -9
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/cli.py +76 -9
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/config.py +26 -2
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/cost.py +137 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/db.py +45 -3
- brindle-0.2.1/src/brindle/escapes.py +176 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/gates.py +82 -20
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/history.py +2 -1
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/learning.py +31 -6
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/mcp_server.py +47 -28
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pipeline.py +41 -11
- brindle-0.2.1/src/brindle/pro/brief_learning.py +164 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/learning.py +71 -6
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/team_policy.py +19 -2
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/watch_costs.py +7 -2
- brindle-0.2.0/src/brindle/builtin_agents/developer-codex.md +0 -21
- brindle-0.2.0/src/brindle/builtin_agents/developer-heavy.md +0 -23
- {brindle-0.2.0 → brindle-0.2.1}/LICENSE +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/SCHEDULE-A +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/__init__.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/__main__.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/account.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/airgap.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/antigravity.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/backend-auditor.md +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/developer-local.md +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/qa.md +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_rules/__init__.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_rules/security/backend.md +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_rules/tests/only.md +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/ci_adapters.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/ci_client.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/ci_federation.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/ci_hosts.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/codemap.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/codex_hook.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/conflicts.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/cost_estimate.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/cull.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/demo.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/detect.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/doctor.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/events.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/git.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/guardrails.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/inbox.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/learned_rules.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/legacy.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/native/__init__.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/native/client.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/native/loop.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/native/permissions.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/native/runner.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/native/serve.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/native/tools.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/permissions.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/plugins.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/policy.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pool.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pricing.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/_files.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/account.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/audit_export.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/auth.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/cost_centers.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/features.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/keys.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/license.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/managed_models.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/org_profiles.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/rollout.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/settings_sync.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/status.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/team_events.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/procs.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/profiles.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/providers.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/quota.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/repos.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/rewind.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/rule_checks.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/savings.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/scratch.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/secrets.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/services.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/sessions.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/sidebar_follow.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/status_cache.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/tasks.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/tmux.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/usage.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/view.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/watch.py +0 -0
- {brindle-0.2.0 → brindle-0.2.1}/src/brindle/workspaces.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: brindle
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Run coding agents in parallel, each on its own git branch, with a supervisor that delegates, reviews, and merges
|
|
5
5
|
Project-URL: Homepage, https://pawdelta.com/brindle/
|
|
6
6
|
Project-URL: Documentation, https://pawdelta.com/brindle/docs/recommended-use
|
|
@@ -89,7 +89,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
89
89
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
90
90
|
|
|
91
91
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
92
|
-
session shows it too (`brindle 0.2.
|
|
92
|
+
session shows it too (`brindle 0.2.1`). 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
|
|
@@ -360,6 +360,7 @@ your own status line prints, so what you see doesn't change.
|
|
|
360
360
|
| `brindle cost requests` | Enterprise: your approval requests and their status; checks pending ones with the server (the cull pass also does, every few minutes). With cost centers, spend events also carry the repo (`owner/name`) so the org can total spend per center |
|
|
361
361
|
| `brindle cost estimate [--tasks N] [--weight W] [--profile P] [--reviewer R]` | what the current goal (or N tasks) is likely to cost, as a range from this repo's history (median to p80 of past worker and review runs), plus a cheaper alternative with a different reviewer; says "low confidence" when there's too little history. Also added to `set_goal` and `assign` replies (brindle Pro) |
|
|
362
362
|
| `brindle learning` | whether brindle Pro's hosted learning is on for this repo, and if not, why (nothing is learned on your machine) |
|
|
363
|
+
| `brindle learning seed` | Pro: sends this repo's finished routing history to the hosted learner once, so it starts with what this machine has seen. Only the usual coarse record fields leave (kind/size one-hots, counters, HMAC keys), never task text or paths. A second run sends nothing new; decisions already sent live are skipped |
|
|
363
364
|
| `brindle account [login\|logout\|status\|upgrade\|portal\|org]` | paid features: bare `brindle account` shows what your plan has and how to get the rest (see "brindle Pro and Team" below) |
|
|
364
365
|
| `brindle audit verify\|export\|pubkey\|ship\|prune` | the local tamper-evident audit log, and shipping it to your SIEM (brindle Enterprise; see "Audit log" below) |
|
|
365
366
|
| `brindle watch [--all] [--once]` | the dashboard on its own (the same view as the sidebar): enter attaches, `p` peeks, `x` closes |
|
|
@@ -59,7 +59,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
59
59
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
60
60
|
|
|
61
61
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
62
|
-
session shows it too (`brindle 0.2.
|
|
62
|
+
session shows it too (`brindle 0.2.1`). 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
|
|
@@ -330,6 +330,7 @@ your own status line prints, so what you see doesn't change.
|
|
|
330
330
|
| `brindle cost requests` | Enterprise: your approval requests and their status; checks pending ones with the server (the cull pass also does, every few minutes). With cost centers, spend events also carry the repo (`owner/name`) so the org can total spend per center |
|
|
331
331
|
| `brindle cost estimate [--tasks N] [--weight W] [--profile P] [--reviewer R]` | what the current goal (or N tasks) is likely to cost, as a range from this repo's history (median to p80 of past worker and review runs), plus a cheaper alternative with a different reviewer; says "low confidence" when there's too little history. Also added to `set_goal` and `assign` replies (brindle Pro) |
|
|
332
332
|
| `brindle learning` | whether brindle Pro's hosted learning is on for this repo, and if not, why (nothing is learned on your machine) |
|
|
333
|
+
| `brindle learning seed` | Pro: sends this repo's finished routing history to the hosted learner once, so it starts with what this machine has seen. Only the usual coarse record fields leave (kind/size one-hots, counters, HMAC keys), never task text or paths. A second run sends nothing new; decisions already sent live are skipped |
|
|
333
334
|
| `brindle account [login\|logout\|status\|upgrade\|portal\|org]` | paid features: bare `brindle account` shows what your plan has and how to get the rest (see "brindle Pro and Team" below) |
|
|
334
335
|
| `brindle audit verify\|export\|pubkey\|ship\|prune` | the local tamper-evident audit log, and shipping it to your SIEM (brindle Enterprise; see "Audit log" below) |
|
|
335
336
|
| `brindle watch [--all] [--once]` | the dashboard on its own (the same view as the sidebar): enter attaches, `p` peeks, `x` closes |
|
|
@@ -47,6 +47,12 @@ WORKER_FOOTER = """
|
|
|
47
47
|
---
|
|
48
48
|
You are running as a brindle worker (agent id {agent_id}) on branch `{branch}`.
|
|
49
49
|
{guidance}
|
|
50
|
+
Before you finish, check your work against the task and its finish line, item
|
|
51
|
+
by item: each behaviour it asks for needs a test that would fail without your
|
|
52
|
+
change, and a requirement with no such test isn't done yet. If the task turns
|
|
53
|
+
out too big for one branch, finish a coherent part and say exactly what's left
|
|
54
|
+
(and how you'd split it) rather than leaving every part half done.
|
|
55
|
+
|
|
50
56
|
When you have finished:
|
|
51
57
|
1. Commit your work to this branch with a clear message (do not push or merge).
|
|
52
58
|
2. Call the `report_result` tool from the `brindle` MCP server with a concise
|
|
@@ -72,6 +78,12 @@ Your shell may start in another directory, so begin every Bash command with
|
|
|
72
78
|
`cd {path} && ` (or use `git -C {path}`), and give file tools absolute paths
|
|
73
79
|
under that directory. Don't change files anywhere else, and don't switch branches.
|
|
74
80
|
{guidance}
|
|
81
|
+
Before you finish, check your work against the task and its finish line, item
|
|
82
|
+
by item: each behaviour it asks for needs a test that would fail without your
|
|
83
|
+
change, and a requirement with no such test isn't done yet. If the task turns
|
|
84
|
+
out too big for one branch, finish a coherent part and say exactly what's left
|
|
85
|
+
(and how you'd split it) rather than leaving every part half done.
|
|
86
|
+
|
|
75
87
|
When you have finished:
|
|
76
88
|
1. Commit your work there on `{branch}` with a clear message (do not push or merge).
|
|
77
89
|
2. Don't call any brindle tools. End with a concise summary: what you changed,
|
|
@@ -1697,38 +1709,19 @@ def _local_reviewer_available() -> bool:
|
|
|
1697
1709
|
return bool(ok) and "is available" in detail
|
|
1698
1710
|
|
|
1699
1711
|
|
|
1700
|
-
def
|
|
1701
|
-
focus: str | None = None, cfg: RepoConfig | None = None) -> Agent:
|
|
1702
|
-
"""Start a reviewer in a worker's workspace right away. Its verdict is
|
|
1703
|
-
recorded for the merge gate and forwarded to ``caller`` as a message.
|
|
1704
|
-
``profile`` picks the reviewer profile; None uses
|
|
1705
|
-
``default_review_profile``.
|
|
1706
|
-
|
|
1707
|
-
Raises ``AgentError`` if the chosen profile (explicit or picked) doesn't
|
|
1708
|
-
exist, or if it uses the codex provider but codex isn't on PATH -- rather
|
|
1709
|
-
than spawning a reviewer doomed to fail in a dead pane.
|
|
1710
|
-
|
|
1711
|
-
``cfg.checks`` are NOT run here (that would block the caller on the full
|
|
1712
|
-
suite): the caller runs them in the background and delivers a pass/fail
|
|
1713
|
-
summary to the reviewer as a message once they finish, via
|
|
1714
|
-
``deliver_check_summary``. If the workspace already has a review at an
|
|
1715
|
-
earlier commit, the reviewer is pointed at just what changed since then,
|
|
1716
|
-
when that's a plain diff (see ``is_linear_since``)."""
|
|
1717
|
-
from brindle import gates
|
|
1718
|
-
from brindle.config import load_repo_config
|
|
1712
|
+
def _review_kill_switch(ws: Workspace) -> None:
|
|
1719
1713
|
from brindle.pro import rollout
|
|
1720
1714
|
|
|
1721
1715
|
why_not = rollout.kill_switch_reason(ws.repo_root) # Enterprise managed rollout
|
|
1722
1716
|
if why_not:
|
|
1723
1717
|
raise AgentError(f"No reviewer started: {why_not}")
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
close(db, old.id) # a new commit replaces the reviewer of the old one
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def _review_profile(db: DB, ws: Workspace, profile: str | None, cfg: RepoConfig) -> str:
|
|
1721
|
+
"""The reviewer profile to use, checked to be one that can run: raises
|
|
1722
|
+
``AgentError`` if it (explicit or picked) doesn't exist, or uses the codex
|
|
1723
|
+
provider but codex isn't on PATH -- rather than spawning a reviewer doomed
|
|
1724
|
+
to fail in a dead pane."""
|
|
1732
1725
|
worker = workspace_worker(db, ws)
|
|
1733
1726
|
profile = profile or default_review_profile(cfg, worker, db, ws.repo_root,
|
|
1734
1727
|
worker.task if worker else None)
|
|
@@ -1743,6 +1736,125 @@ def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | N
|
|
|
1743
1736
|
f"reviewer profile {profile!r} can't run: {why}. "
|
|
1744
1737
|
"Fix that, or set review_profile (or pass profile) to a different reviewer"
|
|
1745
1738
|
)
|
|
1739
|
+
return profile
|
|
1740
|
+
|
|
1741
|
+
|
|
1742
|
+
def _live_reviewer(db: DB, ws: Workspace, sha: str, profile: str | None = None) -> Agent | None:
|
|
1743
|
+
"""The reviewer still at work on ``ws`` at ``sha``, if any. With
|
|
1744
|
+
``profile``, only one running that profile (a second review by another
|
|
1745
|
+
profile is not a duplicate)."""
|
|
1746
|
+
for a in db.list_agents(ws.id):
|
|
1747
|
+
if a.mode == "review" and a.result is None and is_alive(a) and a.review_sha == sha \
|
|
1748
|
+
and (profile is None or a.profile == profile):
|
|
1749
|
+
return a
|
|
1750
|
+
return None
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
def start_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | None = None,
|
|
1754
|
+
focus: str | None = None, cfg: RepoConfig | None = None) -> Agent | None:
|
|
1755
|
+
"""Have ``ws`` reviewed. Without checks configured the reviewer starts
|
|
1756
|
+
right away and is returned. With checks, nothing starts yet: a detached
|
|
1757
|
+
``_review-after-checks`` process runs them and starts the reviewer with
|
|
1758
|
+
their results in its prompt (see ``review_after_checks``), so this returns
|
|
1759
|
+
at once with None, however long the suite takes. Raises ``AgentError``
|
|
1760
|
+
for a reviewer that couldn't start whatever the checks say (the kill
|
|
1761
|
+
switch, an unusable profile); a failure after that reaches ``caller``
|
|
1762
|
+
as a message."""
|
|
1763
|
+
from brindle import gates
|
|
1764
|
+
from brindle.config import load_repo_config
|
|
1765
|
+
from brindle.providers import brindle_invocation
|
|
1766
|
+
|
|
1767
|
+
cfg = cfg or load_repo_config(ws.repo_root)
|
|
1768
|
+
if not cfg.checks:
|
|
1769
|
+
return request_review(db, caller, ws, profile, focus, cfg)
|
|
1770
|
+
_review_kill_switch(ws)
|
|
1771
|
+
_review_profile(db, ws, profile, cfg)
|
|
1772
|
+
existing = _live_reviewer(db, ws, gates.head(ws), profile)
|
|
1773
|
+
if existing:
|
|
1774
|
+
return existing # one reviewer per workspace and commit
|
|
1775
|
+
argv = [*brindle_invocation(), "_review-after-checks", ws.id]
|
|
1776
|
+
if caller:
|
|
1777
|
+
argv += ["--caller", caller.id]
|
|
1778
|
+
if profile:
|
|
1779
|
+
argv += ["--profile", profile]
|
|
1780
|
+
if focus:
|
|
1781
|
+
argv += ["--focus", focus]
|
|
1782
|
+
_detach(argv)
|
|
1783
|
+
return None
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
def expected_check_wait(db: DB, ws: Workspace, cfg: RepoConfig) -> float | None:
|
|
1787
|
+
"""Seconds the repo's checks usually take (the sum of each one's last
|
|
1788
|
+
passing duration), or None when any of them has never passed."""
|
|
1789
|
+
total = 0.0
|
|
1790
|
+
for cmd in cfg.checks:
|
|
1791
|
+
took = db.check_duration(ws.repo_root, cmd)
|
|
1792
|
+
if took is None:
|
|
1793
|
+
return None
|
|
1794
|
+
total += took
|
|
1795
|
+
return total if cfg.checks else None
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
def review_after_checks(db: DB, caller_id: str | None, ws: Workspace, profile: str | None = None,
|
|
1799
|
+
focus: str | None = None, cfg: RepoConfig | None = None) -> Agent | None:
|
|
1800
|
+
"""Run ``cfg.checks`` for ``ws`` (through ``gates.check_summary``: its
|
|
1801
|
+
lock and the by-sha cache, so a result a worker's report already warmed
|
|
1802
|
+
is instant), then start the reviewer with the results in its prompt.
|
|
1803
|
+
Meant to run from a detached process, which outlives the call that
|
|
1804
|
+
started it. Gives up quietly when the branch moves on (the new commit
|
|
1805
|
+
gets its own review), the workspace is gone, or a reviewer is already at
|
|
1806
|
+
work on this commit. Raises ``AgentError`` when the reviewer can't
|
|
1807
|
+
start."""
|
|
1808
|
+
from brindle import gates
|
|
1809
|
+
from brindle.config import load_repo_config
|
|
1810
|
+
|
|
1811
|
+
cfg = cfg or load_repo_config(ws.repo_root)
|
|
1812
|
+
sha = gates.head(ws)
|
|
1813
|
+
if _live_reviewer(db, ws, sha, profile):
|
|
1814
|
+
return None
|
|
1815
|
+
try:
|
|
1816
|
+
summary = gates.check_summary(db, ws, cfg, cancel=lambda: gates.head(ws) != sha)
|
|
1817
|
+
except gates.Abandoned:
|
|
1818
|
+
return None
|
|
1819
|
+
except Exception as e: # noqa: BLE001 - the review goes ahead, saying so
|
|
1820
|
+
summary = f"(running the checks crashed: {e})"
|
|
1821
|
+
if db.get_workspace(ws.id) is None or gates.head(ws) != sha:
|
|
1822
|
+
return None
|
|
1823
|
+
caller = db.get_agent(caller_id) if caller_id else None
|
|
1824
|
+
text = f"Checks for {ws.branch} at {sha[:8]}:\n\n{summary}" if summary else None
|
|
1825
|
+
return request_review(db, caller, ws, profile, focus, cfg, check_summary=text)
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | None = None,
|
|
1829
|
+
focus: str | None = None, cfg: RepoConfig | None = None,
|
|
1830
|
+
check_summary: str | None = None) -> Agent:
|
|
1831
|
+
"""Start a reviewer in a worker's workspace right away. Its verdict is
|
|
1832
|
+
recorded for the merge gate and forwarded to ``caller`` as a message.
|
|
1833
|
+
``profile`` picks the reviewer profile; None uses
|
|
1834
|
+
``default_review_profile`` (``AgentError`` if it can't run).
|
|
1835
|
+
|
|
1836
|
+
``cfg.checks`` are NOT run here (that would block the caller on the full
|
|
1837
|
+
suite): ``start_review`` runs them first, in the background, and passes
|
|
1838
|
+
their results as ``check_summary`` for the reviewer's prompt. If the
|
|
1839
|
+
workspace already has a review at an earlier commit, the reviewer is
|
|
1840
|
+
pointed at just what changed since then, when that's a plain diff (see
|
|
1841
|
+
``is_linear_since``)."""
|
|
1842
|
+
from brindle import gates
|
|
1843
|
+
from brindle.config import load_repo_config
|
|
1844
|
+
|
|
1845
|
+
_review_kill_switch(ws)
|
|
1846
|
+
cfg = cfg or load_repo_config(ws.repo_root)
|
|
1847
|
+
sha = gates.head(ws)
|
|
1848
|
+
for old in db.list_agents(ws.id):
|
|
1849
|
+
if old.mode != "review" or old.result is not None or not is_alive(old):
|
|
1850
|
+
continue
|
|
1851
|
+
if old.review_sha == sha:
|
|
1852
|
+
if profile and old.profile != profile:
|
|
1853
|
+
continue # another profile's review of this commit (a second review): keep both
|
|
1854
|
+
return old # one reviewer per workspace and commit
|
|
1855
|
+
close(db, old.id) # a new commit replaces the reviewer of the old one
|
|
1856
|
+
worker = workspace_worker(db, ws)
|
|
1857
|
+
profile = _review_profile(db, ws, profile, cfg)
|
|
1746
1858
|
|
|
1747
1859
|
base = ws.base_branch or "the base branch"
|
|
1748
1860
|
task = (f"Review the changes on branch `{ws.branch}` (workspace {ws.id}) against `{base}`: "
|
|
@@ -1755,16 +1867,9 @@ def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | N
|
|
|
1755
1867
|
if worker and worker.done_when:
|
|
1756
1868
|
task += f"\n\nIts finish line: {worker.done_when.strip()}"
|
|
1757
1869
|
|
|
1758
|
-
if
|
|
1759
|
-
task += ("\n\nThe repo's checks
|
|
1760
|
-
"
|
|
1761
|
-
"message. Review the diff meanwhile, and don't call submit_review until you've "
|
|
1762
|
-
"received it. If about 10 minutes pass after you've finished reviewing with no "
|
|
1763
|
-
"such message, submit anyway and say plainly in your summary that the checks "
|
|
1764
|
-
"were still running when you submitted, so your verdict rests on the diff "
|
|
1765
|
-
"alone: the merge gate runs them before anything merges, and a failure that "
|
|
1766
|
-
"arrives after your verdict goes to the supervisor. Don't run the whole suite "
|
|
1767
|
-
"yourself to compensate.")
|
|
1870
|
+
if check_summary:
|
|
1871
|
+
task += (f"\n\nThe repo's checks have already run on this commit:\n\n{check_summary}\n\n"
|
|
1872
|
+
"Take these results into account; don't run the whole suite yourself.")
|
|
1768
1873
|
|
|
1769
1874
|
prev = db.last_review(ws.id)
|
|
1770
1875
|
if prev and prev.sha != sha:
|
|
@@ -1784,87 +1889,6 @@ def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | N
|
|
|
1784
1889
|
mode="review", background_setup=True, review_sha=sha)
|
|
1785
1890
|
|
|
1786
1891
|
|
|
1787
|
-
def deliver_check_summary(db: DB, reviewer_id: str, ws: Workspace, cfg: RepoConfig) -> None:
|
|
1788
|
-
"""Run ``cfg.checks`` for ``ws`` and deliver a pass/fail summary to the
|
|
1789
|
-
reviewer's inbox: delivered right away if it's idle, or handed over at its
|
|
1790
|
-
next Stop, exactly like any other queued message (see ``send_message``).
|
|
1791
|
-
Meant to run from a detached process started by request_review, so it
|
|
1792
|
-
outlives the MCP server call that kicked it off. Always delivers
|
|
1793
|
-
something, even if a check crashes, since the reviewer was told to wait
|
|
1794
|
-
for this before approving. If the reviewer is no longer there to receive
|
|
1795
|
-
it by the time the checks finish, a failing summary isn't dropped: see
|
|
1796
|
-
``_late_check_summary``."""
|
|
1797
|
-
from brindle import gates
|
|
1798
|
-
|
|
1799
|
-
reviewer = db.get_agent(reviewer_id)
|
|
1800
|
-
if reviewer is None:
|
|
1801
|
-
return
|
|
1802
|
-
supervisor_id = reviewer.parent_id
|
|
1803
|
-
sha = gates.head(ws) # label with the commit the checks ran on
|
|
1804
|
-
try:
|
|
1805
|
-
summary = gates.check_summary(db, ws, cfg)
|
|
1806
|
-
failed = gates.summary_failed(summary)
|
|
1807
|
-
except Exception as e:
|
|
1808
|
-
summary, failed = f"(running the checks crashed: {e})", True
|
|
1809
|
-
|
|
1810
|
-
reviewer = db.get_agent(reviewer_id)
|
|
1811
|
-
if reviewer is None or reviewer.result is not None or reviewer.status == "done":
|
|
1812
|
-
# It submitted its review, was closed, or was removed while the checks ran.
|
|
1813
|
-
if failed:
|
|
1814
|
-
_late_check_summary(db, ws, sha, summary, reviewer_id, supervisor_id)
|
|
1815
|
-
return
|
|
1816
|
-
|
|
1817
|
-
text = (f"Checks for {ws.branch} at {sha[:8]}:\n\n{summary}" if summary
|
|
1818
|
-
else "No checks are configured for this repo.")
|
|
1819
|
-
db.enqueue(reviewer_id, text, None)
|
|
1820
|
-
flush(db, reviewer_id)
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
def _late_check_summary(db: DB, ws: Workspace, sha: str, summary: str, reviewer_id: str,
|
|
1824
|
-
supervisor_id: str | None) -> None:
|
|
1825
|
-
"""The checks for ``ws`` at ``sha`` finished after its reviewer was done,
|
|
1826
|
-
and failed (or crashed), so the verdict was given without them (a late
|
|
1827
|
-
pass changes nothing). The failure must reach someone who can act on it:
|
|
1828
|
-
the supervisor when the branch was approved (or has no verdict at this
|
|
1829
|
-
commit), the worker when changes were requested anyway (the supervisor
|
|
1830
|
-
if it's gone). Dropped
|
|
1831
|
-
only when there's nothing left to act on: the workspace was removed, the
|
|
1832
|
-
branch merged at this commit (its worktree may be going away under the
|
|
1833
|
-
run), or the branch has new commits, which get their own run."""
|
|
1834
|
-
from brindle import gates
|
|
1835
|
-
|
|
1836
|
-
try:
|
|
1837
|
-
if db.get_workspace(ws.id) is None or db.merged_sha(ws.id) == sha or gates.head(ws) != sha:
|
|
1838
|
-
return
|
|
1839
|
-
except git.GitError:
|
|
1840
|
-
return
|
|
1841
|
-
worker = workspace_worker(db, ws)
|
|
1842
|
-
supervisor_id = supervisor_id or (worker.parent_id if worker else None)
|
|
1843
|
-
review = db.latest_review(ws.id, sha)
|
|
1844
|
-
checks = f"Checks for {ws.branch} at {sha[:8]}:\n\n{summary}"
|
|
1845
|
-
if review is not None and not review.approved and worker and is_alive(worker):
|
|
1846
|
-
try:
|
|
1847
|
-
send_message(db, worker.id,
|
|
1848
|
-
"The repo's checks finished after the review of your branch, and "
|
|
1849
|
-
f"failed. Fix this along with the review's findings.\n\n{checks}")
|
|
1850
|
-
return
|
|
1851
|
-
except (AgentError, tmux.TmuxError):
|
|
1852
|
-
pass # the supervisor hears instead
|
|
1853
|
-
if supervisor_id is None or db.get_agent(supervisor_id) is None:
|
|
1854
|
-
return
|
|
1855
|
-
if review is not None and review.approved:
|
|
1856
|
-
lead = (f"[brindle] The checks on `{ws.branch}` (workspace {ws.id}) FAILED, and the result "
|
|
1857
|
-
f"arrived after reviewer {reviewer_id} had approved it: the approval rests on "
|
|
1858
|
-
"the diff alone. The merge gate runs the checks again, so the branch won't "
|
|
1859
|
-
"merge while they fail. Send this to the worker to fix, then have it reviewed "
|
|
1860
|
-
"again.")
|
|
1861
|
-
else:
|
|
1862
|
-
lead = (f"[brindle] The checks on `{ws.branch}` (workspace {ws.id}) FAILED, and the result "
|
|
1863
|
-
f"arrived after reviewer {reviewer_id} had finished, so the review didn't "
|
|
1864
|
-
"take it into account. Send this to the worker to fix.")
|
|
1865
|
-
gates.tell(db, supervisor_id, f"{lead}\n\n{checks}")
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
1892
|
# -- hook entry point --------------------------------------------------------
|
|
1869
1893
|
|
|
1870
1894
|
|
|
@@ -41,6 +41,7 @@ from dataclasses import dataclass
|
|
|
41
41
|
from pathlib import Path
|
|
42
42
|
|
|
43
43
|
from brindle.config import CONFIG_DIR, WEIGHTS, RepoConfig, config_root, load_repo_config
|
|
44
|
+
from brindle import escapes
|
|
44
45
|
from brindle.db import DB, Agent, Autopilot, Milestone, Workspace
|
|
45
46
|
from brindle.profiles import load_profile
|
|
46
47
|
|
|
@@ -67,7 +68,9 @@ to be asked each step.
|
|
|
67
68
|
- The goal: if none is set yet, ask the user what we're building. Turn the
|
|
68
69
|
answer into a goal with 2-6 milestones, each with a `check` command brindle can
|
|
69
70
|
run from the root of this checkout that exits 0 only when that milestone is
|
|
70
|
-
done (e.g. `uv run pytest tests/test_settings.py -q`).
|
|
71
|
+
done (e.g. `uv run pytest tests/test_settings.py -q`). A check must test what
|
|
72
|
+
the milestone promises, not just that the suite passes: name the tests that
|
|
73
|
+
cover its acceptance criteria. Record it with
|
|
71
74
|
`set_goal`, tell the user the plan in a few lines, then start. If
|
|
72
75
|
`.brindle/goals.md` exists, brindle has already loaded it: call `get_progress`.
|
|
73
76
|
- Work on the first unverified milestone. Split it into independent tasks and
|
|
@@ -90,7 +93,8 @@ to be asked each step.
|
|
|
90
93
|
- After merging, call `check_milestone`. Only brindle's check marks a milestone
|
|
91
94
|
done: never claim one is done yourself. When every milestone passes, brindle
|
|
92
95
|
audits the work against the goal; the goal is reached only when that audit
|
|
93
|
-
approves. Treat any gaps it finds as unfinished work
|
|
96
|
+
approves. Treat any gaps it finds as unfinished work, and tighten (with
|
|
97
|
+
`set_goal`) the check that let each gap through.
|
|
94
98
|
- Keep going until every milestone passes. If you stop early, brindle will ask
|
|
95
99
|
you to continue. Stop only when you are blocked on something only the user
|
|
96
100
|
can decide: call `need_user` with the question, then ask it.
|
|
@@ -501,7 +505,8 @@ def _route_by_weight(db: DB, cfg: RepoConfig, repo_root: str, weight: str, task:
|
|
|
501
505
|
decision["baseline"] = remaining[0]
|
|
502
506
|
why = f"weight {weight} -> {name}"
|
|
503
507
|
picked = f"learning picked it: {reason}" if reason else "learning picked it"
|
|
504
|
-
|
|
508
|
+
kept = learning.kept_note(cfg, repo_root)
|
|
509
|
+
detail = [picked if pick else (f"learning kept it: {kept}" if kept else ""), *skipped]
|
|
505
510
|
detail = [d for d in detail if d]
|
|
506
511
|
if detail:
|
|
507
512
|
why += f" ({', '.join(detail)})"
|
|
@@ -601,18 +606,63 @@ def tail(text: str, lines: int = OUTPUT_TAIL_LINES) -> str:
|
|
|
601
606
|
return "\n".join(out[-lines:])
|
|
602
607
|
|
|
603
608
|
|
|
604
|
-
|
|
605
|
-
"""
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
609
|
+
class CheckCancelled(Exception):
|
|
610
|
+
"""``cancel()`` turned true while a check command was running."""
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
KILL_WAIT = 5.0 # seconds to wait for a killed check's pipes to close
|
|
614
|
+
CANCEL_POLL = 1.0 # seconds between looks at ``cancel`` while a check runs
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def run_check(cmd: str, cwd: str, env: dict[str, str], timeout: int,
|
|
618
|
+
cancel=None) -> tuple[bool, str]:
|
|
619
|
+
"""Run one check command. Returns (passed, the tail of its output).
|
|
620
|
+
``cancel``, if given, is polled while the command runs; when it turns
|
|
621
|
+
true the command is killed and ``CheckCancelled`` is raised."""
|
|
622
|
+
if cancel is None:
|
|
623
|
+
try:
|
|
624
|
+
proc = subprocess.run(
|
|
625
|
+
cmd, shell=True, cwd=cwd, env={**os.environ, **env}, capture_output=True,
|
|
626
|
+
text=True, timeout=timeout, stdin=subprocess.DEVNULL,
|
|
627
|
+
)
|
|
628
|
+
except subprocess.TimeoutExpired:
|
|
629
|
+
return False, f"$ {cmd}\n(timed out after {timeout}s)"
|
|
630
|
+
stdout, stderr, returncode = proc.stdout, proc.stderr, proc.returncode
|
|
631
|
+
else:
|
|
632
|
+
proc = subprocess.Popen(
|
|
633
|
+
cmd, shell=True, cwd=cwd, env={**os.environ, **env}, stdout=subprocess.PIPE,
|
|
634
|
+
stderr=subprocess.PIPE, text=True, stdin=subprocess.DEVNULL, start_new_session=True,
|
|
610
635
|
)
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
636
|
+
deadline = time.monotonic() + timeout
|
|
637
|
+
while True:
|
|
638
|
+
try:
|
|
639
|
+
stdout, stderr = proc.communicate(timeout=CANCEL_POLL)
|
|
640
|
+
break
|
|
641
|
+
except subprocess.TimeoutExpired:
|
|
642
|
+
pass
|
|
643
|
+
timed_out = time.monotonic() >= deadline
|
|
644
|
+
if timed_out or cancel():
|
|
645
|
+
_kill_group(proc)
|
|
646
|
+
try:
|
|
647
|
+
proc.communicate(timeout=KILL_WAIT)
|
|
648
|
+
except subprocess.TimeoutExpired:
|
|
649
|
+
pass # a grandchild still holds the pipes; don't hang on it
|
|
650
|
+
if timed_out:
|
|
651
|
+
return False, f"$ {cmd}\n(timed out after {timeout}s)"
|
|
652
|
+
raise CheckCancelled(cmd)
|
|
653
|
+
returncode = proc.returncode
|
|
654
|
+
output = tail((stdout or "") + (stderr or ""))
|
|
655
|
+
status = "" if returncode == 0 else f"(exit {returncode})"
|
|
656
|
+
return returncode == 0, "\n".join(p for p in (f"$ {cmd}", output, status) if p)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _kill_group(proc: subprocess.Popen) -> None:
|
|
660
|
+
import signal
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
664
|
+
except (ProcessLookupError, PermissionError):
|
|
665
|
+
proc.kill()
|
|
616
666
|
|
|
617
667
|
|
|
618
668
|
def checking(ap: Autopilot, timeout: float = 900.0) -> bool:
|
|
@@ -709,6 +759,11 @@ def check_milestones(db: DB, root_id: str, ws: Workspace, position: int | None =
|
|
|
709
759
|
# Not if it already passed at this very commit: a flaky check
|
|
710
760
|
# flipping back isn't progress.
|
|
711
761
|
newly_passed |= ok and m.status != "passed" and (sha is None or m.passed_sha != sha)
|
|
762
|
+
if not ok and m.status == "passed" and sha and m.checked_sha:
|
|
763
|
+
# Passed at the commit before, fails now: whatever merged in
|
|
764
|
+
# between got past its reviewer.
|
|
765
|
+
escapes.milestone_regressed(db, checkout.repo_root, checkout.path, m.title,
|
|
766
|
+
m.checked_sha, sha)
|
|
712
767
|
db.record_check(m.id, ok, out, sha)
|
|
713
768
|
|
|
714
769
|
run(chosen)
|
|
@@ -884,6 +939,9 @@ def record_audit(db: DB, root_id: str, approved: bool, summary: str) -> str:
|
|
|
884
939
|
f"\n\n{summary}")
|
|
885
940
|
db.update_autopilot(root_id, state="running", note=summary.strip()[:4000], audit_ok=0,
|
|
886
941
|
auditor_id=None)
|
|
942
|
+
audit_ws = db.get_workspace(root.workspace_id) if root else None
|
|
943
|
+
if audit_ws is not None:
|
|
944
|
+
escapes.audit_gaps(db, audit_ws.repo_root, summary)
|
|
887
945
|
return (f"[brindle] Completion audit of {sha}: GAPS FOUND. The goal isn't reached yet, even "
|
|
888
946
|
"though every milestone's check passes. Close each gap below (and where a check let "
|
|
889
947
|
"a gap through, tighten it with set_goal), then call check_milestone to re-audit."
|
|
@@ -25,7 +25,7 @@ from __future__ import annotations
|
|
|
25
25
|
import logging
|
|
26
26
|
import statistics
|
|
27
27
|
import time
|
|
28
|
-
from dataclasses import dataclass
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
29
|
|
|
30
30
|
from brindle import pricing
|
|
31
31
|
from brindle.db import DB
|
|
@@ -47,10 +47,34 @@ class Limits:
|
|
|
47
47
|
stop: bool = False # close a worker that reaches task_usd (default: only warn)
|
|
48
48
|
seat_spent_usd: float | None = None # this month's spend as the org counts it (org budgets)
|
|
49
49
|
org: bool = False # an org budget is part of these limits: it can't be skipped for want of a price
|
|
50
|
+
hide_dollars: bool = False # the org hides this person's org spend and limits: words, not dollars
|
|
51
|
+
|
|
52
|
+
org_caps: dict = field(default_factory=dict) # the org's own value per limit key, before a repo tightened it
|
|
50
53
|
|
|
51
54
|
def any(self) -> bool:
|
|
52
55
|
return any(getattr(self, k) is not None for k in KEYS)
|
|
53
56
|
|
|
57
|
+
def hides(self, key: str) -> bool:
|
|
58
|
+
"""Whether ``key``'s dollars are the org's to hide. A stricter personal
|
|
59
|
+
(repo) limit is the person's own: it stays in dollars."""
|
|
60
|
+
if not self.hide_dollars:
|
|
61
|
+
return False
|
|
62
|
+
cap, mine = self.org_caps.get(key), getattr(self, key)
|
|
63
|
+
return cap is not None and (mine is None or mine >= cap)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
AT_LIMIT, CLOSE, WITHIN = "at limit", "getting close", "within limit"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def status_word(spent: float, limit: float | None) -> str:
|
|
70
|
+
""""within limit", "getting close" (at ``WARN_AT`` of ``limit`` or more) or
|
|
71
|
+
"at limit" (what is shown instead of org dollars when the org hides them)."""
|
|
72
|
+
if limit is None:
|
|
73
|
+
return WITHIN
|
|
74
|
+
if limit <= 0 or spent >= limit:
|
|
75
|
+
return AT_LIMIT
|
|
76
|
+
return CLOSE if spent >= limit * WARN_AT else WITHIN
|
|
77
|
+
|
|
54
78
|
|
|
55
79
|
def parse(raw: object) -> Limits:
|
|
56
80
|
"""``Limits`` from a config value; anything malformed or non-positive is ignored."""
|
|
@@ -102,7 +126,8 @@ def org_limits(repo_root: str | None) -> Limits | None:
|
|
|
102
126
|
if spent is not None and p.spend_month != time.strftime("%Y-%m", time.gmtime()):
|
|
103
127
|
spent = None # last month's number
|
|
104
128
|
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
|
|
129
|
+
month_usd=p.budget_seat_month_usd, seat_spent_usd=spent,
|
|
130
|
+
hide_dollars=p.visibility.get("own_dollars") is False)
|
|
106
131
|
return lim if lim.any() else None
|
|
107
132
|
|
|
108
133
|
|
|
@@ -130,8 +155,13 @@ def limits(cfg, repo_root: str | None = None) -> Limits | None:
|
|
|
130
155
|
lim.goal_usd = _tighter(lim.goal_usd, org.goal_usd)
|
|
131
156
|
lim.month_usd = _tighter(lim.month_usd, org.month_usd)
|
|
132
157
|
lim.seat_spent_usd = org.seat_spent_usd
|
|
158
|
+
lim.hide_dollars = org.hide_dollars
|
|
159
|
+
lim.org_caps = {k: getattr(org, k) for k in KEYS}
|
|
133
160
|
if lim.month_usd is not None:
|
|
134
|
-
|
|
161
|
+
approved = _approved_month() # an admin's cost-center approval (Enterprise)
|
|
162
|
+
lim.month_usd += approved
|
|
163
|
+
if lim.org_caps.get("month_usd") is not None:
|
|
164
|
+
lim.org_caps["month_usd"] += approved
|
|
135
165
|
return lim if lim.any() else None
|
|
136
166
|
|
|
137
167
|
|
|
@@ -266,18 +296,25 @@ class Gate:
|
|
|
266
296
|
return f"{profile} has no known price, so it can't be shown to fit the org's budget"
|
|
267
297
|
return None
|
|
268
298
|
m = pricing.money
|
|
299
|
+
# the org hides its dollars from this person: say it in words (a personal limit stays in dollars)
|
|
269
300
|
if lim.task_usd is not None and est > lim.task_usd:
|
|
301
|
+
if lim.hides("task_usd"):
|
|
302
|
+
return f"a {profile} task would go over the task budget (at limit)"
|
|
270
303
|
return f"a {profile} task costs about {m(est)}, over the {m(lim.task_usd)} task budget"
|
|
271
304
|
if lim.goal_usd is not None:
|
|
272
305
|
spent, cap = self.goal_spend(), self.goal_limit()
|
|
273
306
|
if spent is None:
|
|
274
307
|
return f"the goal's spend can't be totalled, so {profile} can't be shown to fit its budget"
|
|
275
308
|
if spent + est > cap:
|
|
309
|
+
if lim.hides("goal_usd"):
|
|
310
|
+
return f"{profile} would take the goal over its budget (at limit)"
|
|
276
311
|
return (f"{profile} at about {m(est)} would take the goal to {m(spent + est)}, "
|
|
277
312
|
f"over its {m(cap)} budget")
|
|
278
313
|
if lim.month_usd is not None:
|
|
279
314
|
spent = self.month_spend()
|
|
280
315
|
if spent + est > lim.month_usd:
|
|
316
|
+
if lim.hides("month_usd"):
|
|
317
|
+
return f"{profile} would take this month over the budget (at limit)"
|
|
281
318
|
return (f"{profile} at about {m(est)} would take this month to {m(spent + est)}, "
|
|
282
319
|
f"over the {m(lim.month_usd)} budget")
|
|
283
320
|
return None
|
|
@@ -326,7 +363,9 @@ def sweep(db: DB, now: float | None = None) -> list[str]:
|
|
|
326
363
|
m = pricing.money
|
|
327
364
|
try:
|
|
328
365
|
if spent >= lim.task_usd:
|
|
329
|
-
note = (f"[brindle budget] worker {agent.id}
|
|
366
|
+
note = (f"[brindle budget] worker {agent.id} is at limit on its task budget."
|
|
367
|
+
if lim.hides("task_usd") else
|
|
368
|
+
f"[brindle budget] worker {agent.id} has spent about {m(spent)}, "
|
|
330
369
|
f"its {m(lim.task_usd)} task budget.")
|
|
331
370
|
if lim.stop:
|
|
332
371
|
try:
|
|
@@ -344,7 +383,9 @@ def sweep(db: DB, now: float | None = None) -> list[str]:
|
|
|
344
383
|
done.append(note)
|
|
345
384
|
elif spent >= lim.task_usd * WARN_AT and not r.budget_state:
|
|
346
385
|
db.set_budget_state(agent.id, "warned")
|
|
347
|
-
note = (f"[brindle budget] worker {agent.id}
|
|
386
|
+
note = (f"[brindle budget] worker {agent.id} is getting close to its task budget."
|
|
387
|
+
if lim.hides("task_usd") else
|
|
388
|
+
f"[brindle budget] worker {agent.id} has spent about {m(spent)}, over "
|
|
348
389
|
f"{round(WARN_AT * 100)}% of its {m(lim.task_usd)} task budget.")
|
|
349
390
|
_tell(db, agent, note)
|
|
350
391
|
done.append(note)
|