brindle 0.3.22__tar.gz → 0.3.24__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {brindle-0.3.22 → brindle-0.3.24}/PKG-INFO +10 -3
- {brindle-0.3.22 → brindle-0.3.24}/README.md +9 -2
- {brindle-0.3.22 → brindle-0.3.24}/pyproject.toml +1 -1
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/agents.py +100 -51
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/antigravity.py +3 -1
- brindle-0.3.24/src/brindle/assets/braid_dark.png +0 -0
- brindle-0.3.24/src/brindle/assets/braid_light.png +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/autopilot.py +115 -7
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/developer-local.md +1 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/reviewer-local.md +1 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/cli.py +43 -17
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/cloud_login.py +45 -7
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/company_identity.py +4 -1
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/company_login.py +26 -3
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/config.py +56 -8
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/cull.py +59 -23
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/db.py +14 -4
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/doctor.py +17 -6
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/gates.py +4 -2
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/git.py +23 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/mcp_server.py +48 -3
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/native/client.py +22 -2
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/native/runner.py +44 -2
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/permissions.py +16 -1
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/picker.py +14 -1
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pipeline.py +203 -20
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pool.py +26 -14
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/status.py +18 -3
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/procs.py +18 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/profiles.py +42 -2
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/prompt_answer.py +17 -2
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/providers.py +61 -7
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/sidebar_follow.py +11 -0
- brindle-0.3.24/src/brindle/sidebar_logo.py +250 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/signin_pause.py +8 -2
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/tasks.py +59 -12
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/tmux.py +52 -18
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/upgrade.py +20 -4
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/view.py +9 -5
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/watch.py +116 -65
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/workspaces.py +14 -3
- {brindle-0.3.22 → brindle-0.3.24}/.gitignore +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/LICENSE +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/SCHEDULE-A +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/__init__.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/__main__.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/account.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/airgap.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/budget.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/backend-auditor.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/developer-codex.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/developer-fable.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/developer-gemini.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/developer-heavy.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/developer-light.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/developer.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/qa.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/reviewer.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_rules/__init__.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_rules/security/backend.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/builtin_rules/tests/only.md +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/ci_adapters.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/ci_client.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/ci_federation.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/ci_hosts.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/cloud_credential.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/codemap.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/codemap_docs.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/codex_hook.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/conflicts.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/cost.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/cost_estimate.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/demo.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/detect.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/escapes.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/events.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/guardrails.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/history.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/identity_lock.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/inbox.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/key_check.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/keys_cmds.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/keystore.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/launch_health.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/learned_rules.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/learning.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/legacy.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/local_setup.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/login_pause.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/model_access.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/model_availability.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/native/__init__.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/native/loop.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/native/permissions.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/native/serve.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/native/tools.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pane_auth.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/plugins.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/policy.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pricing.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/_files.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/account.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/audit_export.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/auth.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/brief_learning.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/cost_centers.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/features.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/keys.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/learning.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/license.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/managed_models.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/org_profiles.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/rollout.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/route_health.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/settings_sync.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/team_events.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/pro/team_policy.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/quota.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/repos.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/rewind.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/rule_checks.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/savings.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/scratch.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/secrets.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/self_serve.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/services.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/sessions.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/status_cache.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/updates.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/usage.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/watch_costs.py +0 -0
- {brindle-0.3.22 → brindle-0.3.24}/src/brindle/worker_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: brindle
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.24
|
|
4
4
|
Summary: Run coding agents in parallel, each on its own git branch, with a supervisor that delegates, reviews, and merges
|
|
5
5
|
Project-URL: Homepage, https://pawdelta.com/brindle/
|
|
6
6
|
Project-URL: Documentation, https://pawdelta.com/brindle/docs/recommended-use
|
|
@@ -94,7 +94,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
94
94
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
95
95
|
|
|
96
96
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
97
|
-
session shows it too (`brindle 0.3.
|
|
97
|
+
session shows it too (`brindle 0.3.24`). A session started before an upgrade keeps
|
|
98
98
|
running the old code, and shows the old number, until you restart it.
|
|
99
99
|
|
|
100
100
|
## Quick start
|
|
@@ -562,7 +562,7 @@ Autopilot, merge gates and cleanup:
|
|
|
562
562
|
| `pre_commit` | `true` | run [pre-commit](https://pre-commit.com) over the branch, if the repo uses it |
|
|
563
563
|
| `max_agents` | `4` | workers running at once per session (`0`: no cap) |
|
|
564
564
|
| `check_timeout` | `900` | seconds each check may take |
|
|
565
|
-
| `check_concurrency` |
|
|
565
|
+
| `check_concurrency` | half the logical cores, at least 2, at most 6 and 2 GB of RAM per run | check runs at once on this machine, across branches; the rest wait their turn (`0`: no cap). A run far past its last duration is reported to the supervisor |
|
|
566
566
|
| `fable_escalation` | on, unless the worker runs on a cloud account (Bedrock, Vertex or Foundry) | retry a failed heavy task once on `developer-heavy` before handing it to you; `true` turns it on for a cloud account, `false` off |
|
|
567
567
|
| `usage_limit` | `90` | autopilot stops pushing on at this % of your Claude usage limit |
|
|
568
568
|
| `limit_cooldown_minutes` | `300` for Antigravity | how long a provider that hit its limit counts as unavailable |
|
|
@@ -1844,6 +1844,13 @@ in the same checkout keeps it running for the new one.
|
|
|
1844
1844
|
|
|
1845
1845
|
Then a supervisor can `assign` a task to `developer-local`, or the repo config
|
|
1846
1846
|
can make the free model the reviewer: `"review_profile": "reviewer-local"`.
|
|
1847
|
+
When the local model isn't served (Ollama isn't running, or the model isn't
|
|
1848
|
+
pulled), the worker starts as the profile's `fallback:` instead, and is recorded
|
|
1849
|
+
as that profile: `developer-light` (Haiku) for `developer-local`, `reviewer` for
|
|
1850
|
+
`reviewer-local`. A native worker on a hosted model that refuses a request
|
|
1851
|
+
retries it on `developer-local`'s model, when that is served on this machine or
|
|
1852
|
+
the private network, and keeps the rest of the conversation there; the transcript
|
|
1853
|
+
records which model gave each reply.
|
|
1847
1854
|
Other endpoints, same fields:
|
|
1848
1855
|
|
|
1849
1856
|
| Backend | `api` | `base_url` | Notes |
|
|
@@ -63,7 +63,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
63
63
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
64
64
|
|
|
65
65
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
66
|
-
session shows it too (`brindle 0.3.
|
|
66
|
+
session shows it too (`brindle 0.3.24`). A session started before an upgrade keeps
|
|
67
67
|
running the old code, and shows the old number, until you restart it.
|
|
68
68
|
|
|
69
69
|
## Quick start
|
|
@@ -531,7 +531,7 @@ Autopilot, merge gates and cleanup:
|
|
|
531
531
|
| `pre_commit` | `true` | run [pre-commit](https://pre-commit.com) over the branch, if the repo uses it |
|
|
532
532
|
| `max_agents` | `4` | workers running at once per session (`0`: no cap) |
|
|
533
533
|
| `check_timeout` | `900` | seconds each check may take |
|
|
534
|
-
| `check_concurrency` |
|
|
534
|
+
| `check_concurrency` | half the logical cores, at least 2, at most 6 and 2 GB of RAM per run | check runs at once on this machine, across branches; the rest wait their turn (`0`: no cap). A run far past its last duration is reported to the supervisor |
|
|
535
535
|
| `fable_escalation` | on, unless the worker runs on a cloud account (Bedrock, Vertex or Foundry) | retry a failed heavy task once on `developer-heavy` before handing it to you; `true` turns it on for a cloud account, `false` off |
|
|
536
536
|
| `usage_limit` | `90` | autopilot stops pushing on at this % of your Claude usage limit |
|
|
537
537
|
| `limit_cooldown_minutes` | `300` for Antigravity | how long a provider that hit its limit counts as unavailable |
|
|
@@ -1813,6 +1813,13 @@ in the same checkout keeps it running for the new one.
|
|
|
1813
1813
|
|
|
1814
1814
|
Then a supervisor can `assign` a task to `developer-local`, or the repo config
|
|
1815
1815
|
can make the free model the reviewer: `"review_profile": "reviewer-local"`.
|
|
1816
|
+
When the local model isn't served (Ollama isn't running, or the model isn't
|
|
1817
|
+
pulled), the worker starts as the profile's `fallback:` instead, and is recorded
|
|
1818
|
+
as that profile: `developer-light` (Haiku) for `developer-local`, `reviewer` for
|
|
1819
|
+
`reviewer-local`. A native worker on a hosted model that refuses a request
|
|
1820
|
+
retries it on `developer-local`'s model, when that is served on this machine or
|
|
1821
|
+
the private network, and keeps the rest of the conversation there; the transcript
|
|
1822
|
+
records which model gave each reply.
|
|
1816
1823
|
Other endpoints, same fields:
|
|
1817
1824
|
|
|
1818
1825
|
| Backend | `api` | `base_url` | Notes |
|
|
@@ -26,7 +26,7 @@ from brindle.config import RepoConfig
|
|
|
26
26
|
from brindle.db import DB, Agent, Workspace
|
|
27
27
|
from brindle.model_availability import ModelAvailabilityChecker, PROVISIONING_TIMEOUT, provisioning_context
|
|
28
28
|
from brindle.pro import route_health
|
|
29
|
-
from brindle.profiles import load_profile, missing_add_dirs
|
|
29
|
+
from brindle.profiles import load_profile, local_fallback, missing_add_dirs
|
|
30
30
|
from brindle.providers import LaunchContext, get_provider, signed_out, unusable
|
|
31
31
|
from brindle.sidebar_follow import (
|
|
32
32
|
SIDEBAR_COLUMNS,
|
|
@@ -336,6 +336,9 @@ def spawn(
|
|
|
336
336
|
|
|
337
337
|
identity_lock.ensure_workspace(db, ws) # no new agent in another identity's worktree
|
|
338
338
|
profile = load_profile(profile_name, ws.repo_root)
|
|
339
|
+
hosted = local_fallback(profile, ws.repo_root) # its local model isn't up
|
|
340
|
+
if hosted:
|
|
341
|
+
profile_name, profile = hosted, load_profile(hosted, ws.repo_root)
|
|
339
342
|
provider = get_provider(provider_name or profile.provider)
|
|
340
343
|
agent_id = new_id()
|
|
341
344
|
# Headless is a Claude Code mode; other CLIs ignore the profile field.
|
|
@@ -1355,6 +1358,24 @@ def is_alive(agent: Agent, panes: dict[str, bool] | None = None) -> bool:
|
|
|
1355
1358
|
return tmux.window_alive(agent.tmux_window, server=agent.tmux_server or None)
|
|
1356
1359
|
|
|
1357
1360
|
|
|
1361
|
+
LAUNCH_GRACE = 60.0 # seconds a just-spawned agent may go without a window before it counts as dead
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def alive_or_starting(agent: Agent) -> bool:
|
|
1365
|
+
"""``is_alive``, or still being launched: spawn records the agent before
|
|
1366
|
+
its window opens (the launch's checks can take seconds), so one with no
|
|
1367
|
+
window yet is starting, not dead, for LAUNCH_GRACE seconds. A launch known
|
|
1368
|
+
to have failed ends that: spawn deletes the row of one that raised, and
|
|
1369
|
+
launch_health marks one whose CLI died at startup "failed". For the
|
|
1370
|
+
checks that would replace the agent (start another reviewer, forget an
|
|
1371
|
+
auditor), not for ones that talk to its pane."""
|
|
1372
|
+
if is_alive(agent):
|
|
1373
|
+
return True
|
|
1374
|
+
return (runs_process(agent) and not agent.tmux_window and agent.result is None
|
|
1375
|
+
and agent.status not in ("paused", "done", "failed")
|
|
1376
|
+
and time.time() - agent.created_at < LAUNCH_GRACE)
|
|
1377
|
+
|
|
1378
|
+
|
|
1358
1379
|
def server_of(agent: Agent) -> str | None:
|
|
1359
1380
|
"""The tmux server ``agent``'s window is on (a private socket name, or
|
|
1360
1381
|
None for the default server), for the ``server`` argument of tmux calls
|
|
@@ -1462,7 +1483,9 @@ def one_line(db: DB, message_id: int, body: str, sender_id: str | None) -> str:
|
|
|
1462
1483
|
who = f"{sender.profile} {sender_id}" if sender else sender_id
|
|
1463
1484
|
else:
|
|
1464
1485
|
who = "brindle"
|
|
1465
|
-
|
|
1486
|
+
# A stuck-worker notice (cull.note_stuck) carries the prompt it waits on in
|
|
1487
|
+
# its first line, which has to arrive whole.
|
|
1488
|
+
head = first_line(body, 1500 if "has been waiting on a prompt for" in body[:200] else 300)
|
|
1466
1489
|
if not sender_id:
|
|
1467
1490
|
head = head.removeprefix("[brindle] ") # the sender tag already says it
|
|
1468
1491
|
return (f"[{who}] {head} "
|
|
@@ -1903,12 +1926,17 @@ def _detach(args: list[str]) -> None:
|
|
|
1903
1926
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
1904
1927
|
|
|
1905
1928
|
|
|
1929
|
+
# A shell that starts the command after it in the background and exits, so the
|
|
1930
|
+
# command is nobody's child (see _detach_orphan).
|
|
1931
|
+
ORPHANED = ["/bin/sh", "-c", '"$@" </dev/null >/dev/null 2>&1 &', "sh"]
|
|
1932
|
+
|
|
1933
|
+
|
|
1906
1934
|
def _detach_orphan(args: list[str]) -> None:
|
|
1907
1935
|
"""Start a brindle helper that outlives this process and its agent: a
|
|
1908
1936
|
shell starts it in the background and exits, so it is nobody's child.
|
|
1909
1937
|
Stopping an agent stops every process descended from its MCP server
|
|
1910
1938
|
(brindle.procs), a helper ``_detach`` started there included."""
|
|
1911
|
-
subprocess.run([
|
|
1939
|
+
subprocess.run([*ORPHANED, *args],
|
|
1912
1940
|
start_new_session=True, stdin=subprocess.DEVNULL,
|
|
1913
1941
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
|
1914
1942
|
|
|
@@ -1982,28 +2010,30 @@ def submit_review(db: DB, caller_id: str, approved: bool, summary: str) -> str:
|
|
|
1982
2010
|
close_later(caller.id)
|
|
1983
2011
|
return "Audit recorded and sent to the supervisor. You're done."
|
|
1984
2012
|
# Already recorded: closing this reviewer is its verdict's job, once
|
|
1985
|
-
# whatever the verdict set off is done.
|
|
1986
|
-
# that work (a merge running the checks) can hold for minutes.
|
|
1987
|
-
recorded = "Your review was already recorded. You're done."
|
|
2013
|
+
# whatever the verdict set off is done.
|
|
1988
2014
|
if caller.result is not None:
|
|
1989
|
-
return recorded
|
|
1990
|
-
#
|
|
1991
|
-
#
|
|
1992
|
-
#
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2015
|
+
return "Your review was already recorded. You're done."
|
|
2016
|
+
# Recorded without the branch's merge lock: a merge holds it while the
|
|
2017
|
+
# repo's checks run, for minutes, and a verdict waiting on it left the
|
|
2018
|
+
# reviewer's call hanging until its CLI gave up on it, never recorded.
|
|
2019
|
+
# Acting on the verdict (finish_review) takes the lock, apart from the
|
|
2020
|
+
# reviewer, and finds whatever a merge in progress did: a second
|
|
2021
|
+
# reviewer's verdict for a branch that merged meanwhile is dropped there.
|
|
2022
|
+
gone = "The branch is already merged or removed. You're done."
|
|
2023
|
+
if db.get_workspace(ws.id) is None or not os.path.isdir(ws.path):
|
|
2024
|
+
close_later(caller_id)
|
|
2025
|
+
return gone
|
|
2026
|
+
try:
|
|
2000
2027
|
sha = gates.head(ws)
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
db.
|
|
2028
|
+
except git.GitError: # removed just now
|
|
2029
|
+
close_later(caller_id)
|
|
2030
|
+
return gone
|
|
2031
|
+
db.add_review(ws.id, sha, caller.id, approved, summary)
|
|
2032
|
+
if approved:
|
|
2033
|
+
db.bump_progress(autopilot.root_of(db, caller.id))
|
|
2034
|
+
pipeline.note_review(db, ws, approved, reviewer=caller)
|
|
2035
|
+
verdict = "APPROVED" if approved else "CHANGES REQUESTED"
|
|
2036
|
+
db.set_result(caller.id, _review_text(ws, sha, approved, summary))
|
|
2007
2037
|
finish_review_later(caller.id, ws.id)
|
|
2008
2038
|
return f"Review recorded ({verdict}); brindle takes it from here. You're done."
|
|
2009
2039
|
|
|
@@ -2273,14 +2303,12 @@ def default_review_profile(cfg: RepoConfig, worker: Agent | None, db: DB | None
|
|
|
2273
2303
|
def _local_reviewer_available() -> bool:
|
|
2274
2304
|
"""Whether the built-in ``reviewer-local`` profile's model is served
|
|
2275
2305
|
right now. Any failure counts as not available."""
|
|
2276
|
-
|
|
2277
|
-
from brindle.native import runner
|
|
2306
|
+
from brindle.native import runner
|
|
2278
2307
|
|
|
2279
|
-
|
|
2280
|
-
|
|
2308
|
+
try:
|
|
2309
|
+
return runner.served(load_profile("reviewer-local"))
|
|
2281
2310
|
except Exception:
|
|
2282
2311
|
return False
|
|
2283
|
-
return bool(ok) and "is available" in detail
|
|
2284
2312
|
|
|
2285
2313
|
|
|
2286
2314
|
def _review_kill_switch(ws: Workspace) -> None:
|
|
@@ -2345,7 +2373,7 @@ def _live_reviewer(db: DB, ws: Workspace, sha: str, profile: str | None = None)
|
|
|
2345
2373
|
``profile``, only one running that profile (a second review by another
|
|
2346
2374
|
profile is not a duplicate)."""
|
|
2347
2375
|
for a in db.list_agents(ws.id):
|
|
2348
|
-
if a.mode == "review" and a.result is None and
|
|
2376
|
+
if a.mode == "review" and a.result is None and alive_or_starting(a) and a.review_sha == sha \
|
|
2349
2377
|
and (profile is None or a.profile == profile):
|
|
2350
2378
|
return a
|
|
2351
2379
|
return None
|
|
@@ -2381,7 +2409,9 @@ def start_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | Non
|
|
|
2381
2409
|
argv += ["--profile", profile]
|
|
2382
2410
|
if focus:
|
|
2383
2411
|
argv += ["--focus", focus]
|
|
2384
|
-
|
|
2412
|
+
# Nobody's child: the worker reporting here may be closed while the checks
|
|
2413
|
+
# still run, and stopping it would stop this run too, so no reviewer (#190).
|
|
2414
|
+
_detach([*ORPHANED, *argv])
|
|
2385
2415
|
return None
|
|
2386
2416
|
|
|
2387
2417
|
|
|
@@ -2397,34 +2427,51 @@ def expected_check_wait(db: DB, ws: Workspace, cfg: RepoConfig) -> float | None:
|
|
|
2397
2427
|
return total if cfg.checks else None
|
|
2398
2428
|
|
|
2399
2429
|
|
|
2430
|
+
MOVED_RETRIES = 2 # check runs a branch that keeps moving under them gets again (review_after_checks)
|
|
2431
|
+
|
|
2432
|
+
|
|
2400
2433
|
def review_after_checks(db: DB, caller_id: str | None, ws: Workspace, profile: str | None = None,
|
|
2401
2434
|
focus: str | None = None, cfg: RepoConfig | None = None) -> Agent | None:
|
|
2402
2435
|
"""Run ``cfg.checks`` for ``ws`` (through ``gates.check_summary``: its
|
|
2403
2436
|
lock and the by-sha cache, so a result a worker's report already warmed
|
|
2404
2437
|
is instant), then start the reviewer with the results in its prompt.
|
|
2405
2438
|
Meant to run from a detached process, which outlives the call that
|
|
2406
|
-
started it. Gives up quietly when the
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2439
|
+
started it. Gives up quietly when the workspace is gone, a reviewer is
|
|
2440
|
+
already at work on this commit, or the branch moves on to a commit a new
|
|
2441
|
+
report hands to a run of its own. A piped branch that moves on without
|
|
2442
|
+
a new report (its worker committed again, or merged its moved base in)
|
|
2443
|
+
is checked again on its new head, up to ``MOVED_RETRIES`` times, and then
|
|
2444
|
+
goes to the supervisor. Raises ``AgentError`` when the reviewer can't
|
|
2445
|
+
start. Holds ``pipeline.review_run`` throughout, so the sweep can tell
|
|
2446
|
+
this run at work (queued for a slot, say) from one that died."""
|
|
2447
|
+
from brindle import gates, pipeline
|
|
2411
2448
|
from brindle.config import load_repo_config
|
|
2412
2449
|
|
|
2413
2450
|
cfg = cfg or load_repo_config(ws.repo_root)
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2451
|
+
with pipeline.review_run(ws.id):
|
|
2452
|
+
since = pipeline.review_claim(db, ws)
|
|
2453
|
+
for _attempt in range(MOVED_RETRIES + 1):
|
|
2454
|
+
sha = gates.head(ws)
|
|
2455
|
+
if _live_reviewer(db, ws, sha, profile):
|
|
2456
|
+
return None
|
|
2457
|
+
try:
|
|
2458
|
+
summary = gates.check_summary(db, ws, cfg, cancel=lambda sha=sha: gates.head(ws) != sha)
|
|
2459
|
+
except gates.Abandoned:
|
|
2460
|
+
summary = None
|
|
2461
|
+
except Exception as e: # noqa: BLE001 - the review goes ahead, saying so
|
|
2462
|
+
summary = f"(running the checks crashed: {e})"
|
|
2463
|
+
if db.get_workspace(ws.id) is None:
|
|
2464
|
+
return None
|
|
2465
|
+
if gates.head(ws) == sha:
|
|
2466
|
+
break
|
|
2467
|
+
if not pipeline.still_claimed(db, ws, since):
|
|
2468
|
+
return None
|
|
2469
|
+
else:
|
|
2470
|
+
pipeline.on_moved_during_checks(db, ws)
|
|
2471
|
+
return None
|
|
2472
|
+
caller = db.get_agent(caller_id) if caller_id else None
|
|
2473
|
+
text = f"Checks for {ws.branch} at {sha[:8]}:\n\n{summary}" if summary else None
|
|
2474
|
+
return request_review(db, caller, ws, profile, focus, cfg, check_summary=text)
|
|
2428
2475
|
|
|
2429
2476
|
|
|
2430
2477
|
def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | None = None,
|
|
@@ -2449,7 +2496,7 @@ def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | N
|
|
|
2449
2496
|
cfg = cfg or load_repo_config(ws.repo_root)
|
|
2450
2497
|
sha = gates.head(ws)
|
|
2451
2498
|
for old in db.list_agents(ws.id):
|
|
2452
|
-
if old.mode != "review" or old.result is not None or not
|
|
2499
|
+
if old.mode != "review" or old.result is not None or not alive_or_starting(old):
|
|
2453
2500
|
continue
|
|
2454
2501
|
if old.unreported_noted:
|
|
2455
2502
|
continue # it stopped without a verdict: not the reviewer of anything
|
|
@@ -3322,7 +3369,9 @@ def tell_parent_unreported(db: DB, agent: Agent) -> bool:
|
|
|
3322
3369
|
if agent.mode == "review" and ws is not None and os.path.isdir(ws.path):
|
|
3323
3370
|
from brindle import gates
|
|
3324
3371
|
|
|
3325
|
-
|
|
3372
|
+
# Its own verdict only: another reviewer's (a second review's) says
|
|
3373
|
+
# nothing about whether this one's call got through.
|
|
3374
|
+
if any(r.reviewer_id == agent.id for r in db.reviews_at(ws.id, gates.head(ws))):
|
|
3326
3375
|
return False # its verdict for the current commit is already recorded
|
|
3327
3376
|
if agent.unreported_noted:
|
|
3328
3377
|
return False # the supervisor was told about this stop; cleared once it resumes
|
|
@@ -110,7 +110,9 @@ def binary() -> str:
|
|
|
110
110
|
explicit = os.environ.get("BRINDLE_AGY_BIN")
|
|
111
111
|
if explicit:
|
|
112
112
|
return explicit
|
|
113
|
-
|
|
113
|
+
from brindle.providers import which_cli
|
|
114
|
+
|
|
115
|
+
return which_cli("agy") or (BUNDLED if os.path.exists(BUNDLED) else "agy")
|
|
114
116
|
|
|
115
117
|
|
|
116
118
|
def tool_names() -> list[str]:
|
|
Binary file
|
|
Binary file
|
|
@@ -54,6 +54,7 @@ log = logging.getLogger(__name__)
|
|
|
54
54
|
GOALS_FILE = "goals.md"
|
|
55
55
|
GOALS_DIR = "goals" # .brindle/goals/<session id>.md: each session's own goal
|
|
56
56
|
MAX_NUDGES = 3
|
|
57
|
+
NUDGE_BACKOFF = 900.0 # seconds before the same reminder is repeated when nothing has changed
|
|
57
58
|
MAX_GOAL_CHARS = 4000 # Claude Code's limit for a /goal condition
|
|
58
59
|
OUTPUT_TAIL_LINES = 30
|
|
59
60
|
|
|
@@ -1817,12 +1818,15 @@ def on_stop(db: DB, agent: Agent, payload: dict) -> dict | None:
|
|
|
1817
1818
|
|
|
1818
1819
|
if login_pause.paused(db, agent.id):
|
|
1819
1820
|
return None # only the person can renew the login; a nudge would just fail
|
|
1821
|
+
if proposing(ap):
|
|
1822
|
+
return None # brindle's own dialog is asking the user to approve checks
|
|
1820
1823
|
if ap.state == "auditing":
|
|
1821
1824
|
if ap.auditor_id:
|
|
1822
1825
|
auditor = db.get_agent(ap.auditor_id)
|
|
1823
|
-
if auditor is None or auditor.status in ("paused", "done") or not agents.
|
|
1824
|
-
|
|
1825
|
-
#
|
|
1826
|
+
if auditor is None or auditor.status in ("paused", "done") or not agents.alive_or_starting(auditor) \
|
|
1827
|
+
or auditor.result is not None:
|
|
1828
|
+
# Auditor was spawned but is no longer running (dead or crashed without verdict;
|
|
1829
|
+
# one still starting isn't). Reset state to running so autopilot can recover.
|
|
1826
1830
|
db.update_autopilot(agent.id, state="running", auditor_id=None)
|
|
1827
1831
|
ap = db.get_autopilot(agent.id)
|
|
1828
1832
|
else:
|
|
@@ -1856,12 +1860,16 @@ def on_stop(db: DB, agent: Agent, payload: dict) -> dict | None:
|
|
|
1856
1860
|
# One reminder per step: never counts toward stalling.
|
|
1857
1861
|
db.update_autopilot(agent.id, nudges=0, nudged_at=ap.progress)
|
|
1858
1862
|
return {"decision": "block", "reason": nudge(db, ap, cfg, working, stalled)}
|
|
1863
|
+
now = time.time()
|
|
1864
|
+
if (ap.nudged_at == ap.progress and ap.nudged_time is not None
|
|
1865
|
+
and now - ap.nudged_time < NUDGE_BACKOFF):
|
|
1866
|
+
return None # (#177) already reminded since the last step: it's legitimately waiting
|
|
1859
1867
|
nudges = ap.nudges if ap.nudged_at == ap.progress else 0
|
|
1860
1868
|
if nudges >= MAX_NUDGES:
|
|
1861
1869
|
db.update_autopilot(agent.id, state="stalled", nudges=0,
|
|
1862
1870
|
note=f"no progress after {MAX_NUDGES} reminders to keep going")
|
|
1863
1871
|
return None
|
|
1864
|
-
db.update_autopilot(agent.id, nudges=nudges + 1, nudged_at=ap.progress)
|
|
1872
|
+
db.update_autopilot(agent.id, nudges=nudges + 1, nudged_at=ap.progress, nudged_time=now)
|
|
1865
1873
|
return {"decision": "block", "reason": nudge(db, ap, cfg, working, stalled)}
|
|
1866
1874
|
|
|
1867
1875
|
|
|
@@ -1911,12 +1919,112 @@ def nudge(db: DB, ap: Autopilot, cfg: RepoConfig,
|
|
|
1911
1919
|
|
|
1912
1920
|
|
|
1913
1921
|
NO_CHECKS = """- This repo has no `checks` in .brindle/config.json, so nothing verifies a
|
|
1914
|
-
branch before it merges. Before the first `assign`,
|
|
1915
|
-
|
|
1916
|
-
|
|
1922
|
+
branch before it merges. Before the first `assign`, call `add_checks` with
|
|
1923
|
+
the check command you propose{suggestion}. brindle itself asks the user to
|
|
1924
|
+
approve it (and to allow its commands in .claude/settings.local.json) and
|
|
1925
|
+
writes what they accept; don't ask them yourself. Then commit
|
|
1926
|
+
.brindle/config.json.
|
|
1917
1927
|
"""
|
|
1918
1928
|
|
|
1919
1929
|
|
|
1930
|
+
PROPOSAL_TTL = 30 * 60 # seconds a check proposal may hold autopilot (see proposing)
|
|
1931
|
+
|
|
1932
|
+
|
|
1933
|
+
SAFE_ALLOW = re.compile(r"[A-Za-z0-9_./ =,@+-]+") # no wildcard, quote, $, &, redirect or newline
|
|
1934
|
+
# Programs whose only job is checking code. Exact, case-sensitive matches: anything
|
|
1935
|
+
# else (wrappers, shells, package runners, interpreters) is refused.
|
|
1936
|
+
CHECK_PROGRAMS = {"pytest", "ruff", "mypy", "shellcheck", "eslint", "tsc",
|
|
1937
|
+
"flake8", "pyright", "golangci-lint"}
|
|
1938
|
+
# Toolchains run code through many subcommands (go run, cargo install, ...): only these.
|
|
1939
|
+
CHECK_SUBCOMMANDS = {"go": {"test", "vet"}, "cargo": {"test", "check", "clippy", "fmt"}}
|
|
1940
|
+
# Flags allowed on those subcommands; any other flag can name a program to run (-exec, -toolexec, --config ...).
|
|
1941
|
+
HARMLESS_FLAGS = {"-v", "-q", "-race", "--check", "--all-targets", "--workspace"}
|
|
1942
|
+
CHECK_MODULES = {"pytest", "mypy", "ruff", "unittest"} # the only `python -m` targets allowed
|
|
1943
|
+
|
|
1944
|
+
|
|
1945
|
+
def _runs_arbitrary(c: str) -> bool:
|
|
1946
|
+
"""True unless the command runs a known check program. ``NAME=VALUE`` prefixes
|
|
1947
|
+
and paths are refused; ``uv run`` is looked through to the program it runs."""
|
|
1948
|
+
argv = c.split()
|
|
1949
|
+
if argv[:1] == ["uv"] and argv[1:2] == ["run"]:
|
|
1950
|
+
argv = argv[2:]
|
|
1951
|
+
if not argv or "=" in argv[0] or argv[0] not in {*CHECK_PROGRAMS, *CHECK_SUBCOMMANDS, "python", "python3"}:
|
|
1952
|
+
return True
|
|
1953
|
+
if argv[0] in CHECK_SUBCOMMANDS:
|
|
1954
|
+
return (argv[1:2] == [] or argv[1] not in CHECK_SUBCOMMANDS[argv[0]]
|
|
1955
|
+
or any(a.startswith("-") and a not in HARMLESS_FLAGS for a in argv[2:]))
|
|
1956
|
+
if argv[0] in CHECK_PROGRAMS:
|
|
1957
|
+
return False
|
|
1958
|
+
return argv[1:2] != ["-m"] or argv[2:3] == [] or argv[2] not in CHECK_MODULES
|
|
1959
|
+
|
|
1960
|
+
|
|
1961
|
+
def allow_rules(checks: list[str]) -> tuple[list[str], str | None]:
|
|
1962
|
+
"""One narrow Bash allow rule per command in ``checks``, or none and why."""
|
|
1963
|
+
commands = [c.strip() for check in checks for c in re.split(r"&&|\|\||;|\|", check)]
|
|
1964
|
+
unsafe = [c for c in commands if c and (
|
|
1965
|
+
not SAFE_ALLOW.fullmatch(c) or _runs_arbitrary(c))]
|
|
1966
|
+
if unsafe:
|
|
1967
|
+
return [], (f"{unsafe[0]!r} has a wildcard, substitution, redirect or a binary "
|
|
1968
|
+
"that runs arbitrary code")
|
|
1969
|
+
return list(dict.fromkeys(f"Bash({c})" for c in commands if c)), None
|
|
1970
|
+
|
|
1971
|
+
|
|
1972
|
+
def propose_checks(db: DB, root_id: str, checks: list[str]) -> tuple[str, list[str]]:
|
|
1973
|
+
"""Record ``checks`` as awaiting the user's answer in brindle's dialog (no
|
|
1974
|
+
nudge meanwhile, see on_stop). Returns the dialog's text and the allow
|
|
1975
|
+
rules it offers."""
|
|
1976
|
+
rules, refusal = allow_rules(checks)
|
|
1977
|
+
db.update_autopilot(root_id, proposal=json.dumps(
|
|
1978
|
+
{"checks": checks, "rules": rules, "at": time.time()}))
|
|
1979
|
+
text = ("brindle: run these checks on every branch before it merges "
|
|
1980
|
+
"(saved to .brindle/config.json)?\n" + "\n".join(f" {c}" for c in checks))
|
|
1981
|
+
if rules:
|
|
1982
|
+
text += ("\n\nAlso allow their commands without a permission prompt "
|
|
1983
|
+
"(.claude/settings.local.json):\n" + "\n".join(f" {r}" for r in rules))
|
|
1984
|
+
else:
|
|
1985
|
+
text += f"\n\nbrindle won't offer to allow their commands: {refusal}."
|
|
1986
|
+
return text, rules
|
|
1987
|
+
|
|
1988
|
+
|
|
1989
|
+
def proposing(ap: Autopilot) -> bool:
|
|
1990
|
+
"""A check proposal is waiting on the user. One older than PROPOSAL_TTL
|
|
1991
|
+
is a dialog whose server died: it no longer holds autopilot."""
|
|
1992
|
+
try:
|
|
1993
|
+
at = float(json.loads(ap.proposal or "{}").get("at") or 0)
|
|
1994
|
+
except (ValueError, TypeError, AttributeError):
|
|
1995
|
+
return False
|
|
1996
|
+
return time.time() - at < PROPOSAL_TTL
|
|
1997
|
+
|
|
1998
|
+
|
|
1999
|
+
def add_checks(repo_root: str, checks: list[str], allow: bool = False) -> str:
|
|
2000
|
+
"""Add approved ``checks`` to .brindle/config.json and, with ``allow``, one
|
|
2001
|
+
narrow Bash allow rule per command in them to .claude/settings.local.json."""
|
|
2002
|
+
from brindle.config import CONFIG_FILE, _read_json
|
|
2003
|
+
|
|
2004
|
+
root = Path(config_root(repo_root))
|
|
2005
|
+
path = root / CONFIG_DIR / CONFIG_FILE
|
|
2006
|
+
data = _read_json(path)
|
|
2007
|
+
data["checks"] = [*(c for c in data.get("checks") or [] if c not in checks), *checks]
|
|
2008
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
2009
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
2010
|
+
text = f"Added {len(checks)} check(s) to {path}."
|
|
2011
|
+
if not allow:
|
|
2012
|
+
return text
|
|
2013
|
+
rules, refusal = allow_rules(checks)
|
|
2014
|
+
if refusal:
|
|
2015
|
+
return f"{text} Did not allow any commands: {refusal}. The user can add rules themselves."
|
|
2016
|
+
settings_path = root / ".claude" / "settings.local.json"
|
|
2017
|
+
settings = _read_json(settings_path)
|
|
2018
|
+
perms = settings.setdefault("permissions", {})
|
|
2019
|
+
allowed = perms.setdefault("allow", [])
|
|
2020
|
+
for rule in rules:
|
|
2021
|
+
if rule not in allowed:
|
|
2022
|
+
allowed.append(rule)
|
|
2023
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2024
|
+
settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
|
|
2025
|
+
return f"{text} Allowed their commands in {settings_path}."
|
|
2026
|
+
|
|
2027
|
+
|
|
1920
2028
|
def guide(cfg: RepoConfig, repo_root: str | None = None) -> str:
|
|
1921
2029
|
text = GUIDE.format(max_agents=cfg.max_agents or "any number of")
|
|
1922
2030
|
if repo_root and not cfg.checks:
|
|
@@ -6,6 +6,7 @@ api: openai # Ollama's OpenAI-compatible endpoint
|
|
|
6
6
|
base_url: http://localhost:11434/v1
|
|
7
7
|
model: qwen3-coder:30b # ollama pull qwen3-coder:30b (19 GB; needs ~24 GB of memory)
|
|
8
8
|
context_tokens: 32k # Ollama's default window is smaller: set OLLAMA_CONTEXT_LENGTH=40960 for `ollama serve`
|
|
9
|
+
fallback: developer-light # hosted Haiku, when Ollama isn't running or the model isn't pulled
|
|
9
10
|
permission_mode: acceptEdits # edits are free; commands need a rule below
|
|
10
11
|
allowed_tools: Bash(git add:*), Bash(git commit:*), Bash(git status:*), Bash(git ls-files:*), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git rev-parse:*), Bash(git branch:*), Bash(pytest:*), Bash(python -m pytest:*), Bash(python3 -m pytest:*), Bash(python -m unittest:*), Bash(python3 -m unittest:*), Bash(uv run:*), Bash(uv sync:*), Bash(npm test:*), Bash(npm run:*), Bash(npm ci:*), Bash(pnpm test:*), Bash(pnpm run:*), Bash(pnpm install:*), Bash(yarn test:*), Bash(yarn run:*), Bash(cargo build:*), Bash(cargo test:*), Bash(cargo check:*), Bash(cargo clippy:*), Bash(go build:*), Bash(go test:*), Bash(go vet:*), Bash(make:*), Bash(swift build:*), Bash(swift test:*), Bash(ls:*), Bash(pwd), Bash(cat:*), Bash(tail:*), Bash(head:*), Bash(grep:*), Bash(wc:*)
|
|
11
12
|
---
|
|
@@ -6,6 +6,7 @@ api: openai
|
|
|
6
6
|
base_url: http://localhost:11434/v1
|
|
7
7
|
model: qwen3-coder:30b
|
|
8
8
|
context_tokens: 32k
|
|
9
|
+
fallback: reviewer # hosted, when Ollama isn't running or the model isn't pulled
|
|
9
10
|
permission_mode: dontAsk # nobody watches a reviewer: refuse what allowed_tools doesn't cover
|
|
10
11
|
allowed_tools: Bash(git status:*), Bash(git ls-files:*), Bash(git stash list), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(git merge-base:*), Bash(pytest:*), Bash(python -m pytest:*), Bash(python3 -m pytest:*), Bash(python -m unittest:*), Bash(python3 -m unittest:*), Bash(uv run:*), Bash(npm test:*), Bash(npm run:*), Bash(pnpm test:*), Bash(pnpm run:*), Bash(yarn test:*), Bash(yarn run:*), Bash(cargo test:*), Bash(cargo check:*), Bash(cargo clippy:*), Bash(go test:*), Bash(go vet:*), Bash(make:*), Bash(swift test:*), Bash(ls:*), Bash(pwd), Bash(cat:*), Bash(tail:*), Bash(head:*), Bash(grep:*), Bash(wc:*)
|
|
11
12
|
---
|