brindle 0.4.2__tar.gz → 0.4.4__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.4.2 → brindle-0.4.4}/PKG-INFO +2 -2
- {brindle-0.4.2 → brindle-0.4.4}/README.md +1 -1
- {brindle-0.4.2 → brindle-0.4.4}/pyproject.toml +1 -1
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/agents.py +63 -7
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/autopilot.py +94 -7
- brindle-0.4.4/src/brindle/builtin_agents/developer-sonnet.md +7 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/reviewer-codex.md +1 -1
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/reviewer-gemini.md +1 -1
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/reviewer-local.md +1 -1
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/reviewer-opencode.md +1 -1
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/reviewer.md +1 -1
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/cli.py +1 -4
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/codex_hook.py +63 -12
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/config.py +2 -2
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/db.py +40 -6
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/launch_health.py +34 -7
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/mcp_server.py +24 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/providers.py +3 -2
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/view.py +1 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/watch.py +2 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/worker_models.py +1 -1
- {brindle-0.4.2 → brindle-0.4.4}/.gitignore +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/LICENSE +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/SCHEDULE-A +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/__init__.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/__main__.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/account.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/airgap.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/antigravity.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/assembler.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/assets/braid_dark.png +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/assets/braid_light.png +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/budget.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/backend-auditor.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/coordinator.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/developer-codex.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/developer-fable.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/developer-gemini.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/developer-heavy.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/developer-light.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/developer-local.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/developer-opencode.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/developer.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/qa.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_rules/__init__.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_rules/security/backend.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/builtin_rules/tests/only.md +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/ci_adapters.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/ci_client.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/ci_federation.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/ci_hosts.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/cloud_credential.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/cloud_login.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/codemap.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/codemap_docs.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/company_identity.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/company_login.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/conflicts.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/coordinator.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/cost.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/cost_estimate.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/cull.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/demo.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/detect.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/doctor.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/escapes.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/events.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/gates.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/git.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/guardrails.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/history.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/identity_lock.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/inbox.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/kb.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/key_check.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/keys_cmds.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/keystore.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/knowledge.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/learned_rules.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/learning.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/legacy.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/local_setup.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/login_pause.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/model_access.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/model_availability.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/native/__init__.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/native/catalog.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/native/client.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/native/loop.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/native/lsp.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/native/permissions.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/native/runner.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/native/serve.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/native/tools.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/opencode.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pane_auth.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/permissions.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/picker.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pipeline.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/plugins.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/policy.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pool.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pricing.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/_files.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/account.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/audit_export.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/auth.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/brief_learning.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/cost_centers.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/features.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/keys.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/learning.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/license.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/managed_models.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/org_profiles.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/rollout.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/route_health.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/settings_sync.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/status.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/team_events.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/pro/team_policy.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/procs.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/profiles.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/prompt_answer.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/quota.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/repos.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/rewind.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/rule_checks.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/savings.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/scratch.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/secrets.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/self_serve.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/services.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/sessions.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/sidebar_follow.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/sidebar_logo.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/signin_pause.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/status_cache.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/tasks.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/tmux.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/updates.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/upgrade.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/usage.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/wake.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/watch_costs.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/web/__init__.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/web/chat.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/web/dashboard.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/web/diff.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/web/server.py +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/web/static/app.js +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/web/static/index.html +0 -0
- {brindle-0.4.2 → brindle-0.4.4}/src/brindle/workspaces.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: brindle
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
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
|
|
@@ -101,7 +101,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
101
101
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
102
102
|
|
|
103
103
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
104
|
-
session shows it too (`brindle 0.4.
|
|
104
|
+
session shows it too (`brindle 0.4.3`). A session started before an upgrade keeps
|
|
105
105
|
running the old code, and shows the old number, until you restart it.
|
|
106
106
|
|
|
107
107
|
## Quick start
|
|
@@ -70,7 +70,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
70
70
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
71
71
|
|
|
72
72
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
73
|
-
session shows it too (`brindle 0.4.
|
|
73
|
+
session shows it too (`brindle 0.4.3`). A session started before an upgrade keeps
|
|
74
74
|
running the old code, and shows the old number, until you restart it.
|
|
75
75
|
|
|
76
76
|
## Quick start
|
|
@@ -901,6 +901,13 @@ def _launch(db: DB, agent: Agent, ws: Workspace, *, prompt: str | None,
|
|
|
901
901
|
argv = provider.command(LaunchContext(agent.id, profile, prompt, resume=resume, cwd=ws.path,
|
|
902
902
|
mode=agent.mode, plan_first=bool(agent.plan_first),
|
|
903
903
|
mcp_recovery=mcp_recovery))
|
|
904
|
+
if provider.name == "codex":
|
|
905
|
+
from brindle import codex_hook
|
|
906
|
+
|
|
907
|
+
try:
|
|
908
|
+
codex_hook.tell_untrusted(db, agent, ws.path) # launched without the hook it couldn't trust
|
|
909
|
+
except Exception: # noqa: BLE001 - never stop a launch over this
|
|
910
|
+
pass
|
|
904
911
|
if prompt or db.pending_count(agent.id):
|
|
905
912
|
_prepare_launch_health(db, agent, ws, profile, m, argv_task=bool(prompt and provider.name != "shell"),
|
|
906
913
|
submitted_prompt=argv[-1] if prompt and provider.name == "codex" else (prompt or ""))
|
|
@@ -1801,7 +1808,10 @@ def flush(db: DB, agent_id: str) -> bool:
|
|
|
1801
1808
|
break
|
|
1802
1809
|
if delivered:
|
|
1803
1810
|
return True
|
|
1804
|
-
if db.pending_count(agent_id) == 0
|
|
1811
|
+
if db.pending_count(agent_id) == 0:
|
|
1812
|
+
return False
|
|
1813
|
+
claimed_at = db.claim_idle(agent_id)
|
|
1814
|
+
if claimed_at is None:
|
|
1805
1815
|
return False
|
|
1806
1816
|
if _paste_blocked(agent):
|
|
1807
1817
|
# Leave it queued: the next flush (a later message, or a resumed
|
|
@@ -1816,9 +1826,15 @@ def flush(db: DB, agent_id: str) -> bool:
|
|
|
1816
1826
|
assert agent is not None
|
|
1817
1827
|
from brindle import antigravity
|
|
1818
1828
|
warmup = agent.provider == "antigravity" and antigravity.WARMUP_END in msg.body
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1829
|
+
try:
|
|
1830
|
+
baseline = launch_health.before_delivery(db, agent, msg.body) if not warmup else ""
|
|
1831
|
+
tmux.paste(agent.tmux_window, msg.body, lead=message_lead(db, agent, msg.sender_id),
|
|
1832
|
+
server=server_of(agent))
|
|
1833
|
+
except BaseException:
|
|
1834
|
+
# pop_pending claimed this row before the external send. Release the
|
|
1835
|
+
# same row and our idle claim so the next flush can retry it once.
|
|
1836
|
+
db.retry_pending(msg.id, agent_id, claimed_at)
|
|
1837
|
+
raise
|
|
1822
1838
|
if not warmup:
|
|
1823
1839
|
launch_health.verify_delivery(db, agent, msg.body, baseline, message_lead(db, agent, msg.sender_id))
|
|
1824
1840
|
return True
|
|
@@ -2143,9 +2159,40 @@ def finish_review(db: DB, reviewer_id: str, ws_id: str) -> None:
|
|
|
2143
2159
|
|
|
2144
2160
|
learned_rules.refresh_later(ws.repo_root) # the finding just landed in the history
|
|
2145
2161
|
finally:
|
|
2162
|
+
# A merge removes the workspace but leaves the session this reviewer
|
|
2163
|
+
# runs in; once the reviewer is gone nobody else would end it.
|
|
2164
|
+
if ws and db.get_workspace(ws_id) is None:
|
|
2165
|
+
_closing_session(reviewer_id).write_text(ws.tmux_session)
|
|
2146
2166
|
close_later(reviewer_id)
|
|
2147
2167
|
|
|
2148
2168
|
|
|
2169
|
+
def _closing_session(agent_id: str):
|
|
2170
|
+
from brindle.config import brindle_home
|
|
2171
|
+
|
|
2172
|
+
d = brindle_home() / "closing"
|
|
2173
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
2174
|
+
return d / agent_id
|
|
2175
|
+
|
|
2176
|
+
|
|
2177
|
+
def close_removed(db: DB, agent_id: str) -> None:
|
|
2178
|
+
"""Kill an agent and, when its workspace was removed with the session left
|
|
2179
|
+
running (noted by ``finish_review``), that session too: only brindle's own,
|
|
2180
|
+
unattached session of a workspace that no longer exists."""
|
|
2181
|
+
note = _closing_session(agent_id)
|
|
2182
|
+
try:
|
|
2183
|
+
session = note.read_text().strip()
|
|
2184
|
+
note.unlink()
|
|
2185
|
+
except OSError:
|
|
2186
|
+
session = ""
|
|
2187
|
+
try:
|
|
2188
|
+
kill(db, agent_id)
|
|
2189
|
+
except AgentError:
|
|
2190
|
+
pass
|
|
2191
|
+
if session and db.workspace_by_tmux_session(session) is None \
|
|
2192
|
+
and not tmux.session_attached(session):
|
|
2193
|
+
tmux.kill_session(session)
|
|
2194
|
+
|
|
2195
|
+
|
|
2149
2196
|
def close_later(agent_id: str, delay: float = 5.0) -> None:
|
|
2150
2197
|
"""Stop an agent shortly, from a detached process: used by an agent's own
|
|
2151
2198
|
tool call, which must return before its CLI goes away."""
|
|
@@ -2914,7 +2961,9 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
|
|
|
2914
2961
|
"""Claude Code's or Codex's PermissionRequest hook: brindle's permission
|
|
2915
2962
|
policy (brindle.permissions) answers allow or deny, or None to let the CLI
|
|
2916
2963
|
prompt the person as usual (ask). Off unless the repo or user config
|
|
2917
|
-
sets ``permission_policy: "on"
|
|
2964
|
+
sets ``permission_policy: "on"``, except for a Codex reviewer, whose hook
|
|
2965
|
+
allows what its profile's allowed_tools cover and leaves the rest to
|
|
2966
|
+
Codex (#241). Never raises: any failure is ask."""
|
|
2918
2967
|
try:
|
|
2919
2968
|
from brindle import permissions
|
|
2920
2969
|
from brindle.config import load_repo_config
|
|
@@ -2924,7 +2973,11 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
|
|
|
2924
2973
|
if ws is None:
|
|
2925
2974
|
return None
|
|
2926
2975
|
cfg = load_repo_config(ws.repo_root)
|
|
2927
|
-
|
|
2976
|
+
# Policy off: only a Codex reviewer has the hook (codex_hook.launch_flags).
|
|
2977
|
+
# Nobody watches it, so it gets what its allowed_tools cover and
|
|
2978
|
+
# nothing else of brindle's: every other answer is Codex's own.
|
|
2979
|
+
review_only = cfg.permission_policy != "on"
|
|
2980
|
+
if review_only and not (provider == "codex" and agent.mode == "review"):
|
|
2928
2981
|
return None
|
|
2929
2982
|
profile_rules = profile_rules_for(agent.profile, ws.repo_root)
|
|
2930
2983
|
if provider == "codex":
|
|
@@ -2945,7 +2998,8 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
|
|
|
2945
2998
|
profile = None # deleted profile: no extra allowances
|
|
2946
2999
|
if profile is not None:
|
|
2947
3000
|
rules += permissions.profile_bash_allows(profile)
|
|
2948
|
-
decision = permissions.
|
|
3001
|
+
decision = permissions.Decision("ask", "not in the profile's allowed_tools") if review_only \
|
|
3002
|
+
else permissions.decide_all(reqs, checks=cfg.checks, rules=rules)
|
|
2949
3003
|
if decision.decision == "ask" and len(reqs) == 1:
|
|
2950
3004
|
# A deny on one part of a compound command (cd x && <denied>)
|
|
2951
3005
|
# wins before any part-by-part allow below.
|
|
@@ -2977,6 +3031,8 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
|
|
|
2977
3031
|
"deny", f"{decision.reason.removesuffix('; asking the user')}, and this profile "
|
|
2978
3032
|
"never asks (permission_mode dontAsk). Don't retry it: do without it, or ask "
|
|
2979
3033
|
"your supervisor with the send_message tool (brindle MCP server)")
|
|
3034
|
+
if review_only and decision.decision != "allow":
|
|
3035
|
+
return None # as Codex would without brindle: its own prompt or refusal
|
|
2980
3036
|
summary = reqs[0].summary() if len(reqs) == 1 else (
|
|
2981
3037
|
f"{reqs[0].tool}: " + ", ".join(r.path or "?" for r in reqs))[:200]
|
|
2982
3038
|
_record_permission(db, agent, ws, summary, f"{decision.decision}: {decision.reason}")
|
|
@@ -12,6 +12,7 @@ is running, its Stop hook tells it to keep going. It stops pushing when:
|
|
|
12
12
|
- the supervisor calls ``need_user`` (blocked on a decision only the user can
|
|
13
13
|
make), until the user next types something or, for its own question,
|
|
14
14
|
until the supervisor says it's answered,
|
|
15
|
+
- the supervisor calls ``wait_on`` for an outside job, until it ends,
|
|
15
16
|
- it has nudged MAX_NUDGES times in a row with no progress (stalled), or
|
|
16
17
|
- Claude usage is near its limit.
|
|
17
18
|
|
|
@@ -38,6 +39,7 @@ from __future__ import annotations
|
|
|
38
39
|
|
|
39
40
|
import json
|
|
40
41
|
import logging
|
|
42
|
+
import math
|
|
41
43
|
import os
|
|
42
44
|
import re
|
|
43
45
|
import subprocess
|
|
@@ -57,6 +59,7 @@ GOALS_DIR = "goals" # .brindle/goals/<session id>.md: each session's ow
|
|
|
57
59
|
MAX_NUDGES = 3
|
|
58
60
|
NUDGE_BACKOFF = 900.0 # seconds before the same reminder is repeated when nothing has changed
|
|
59
61
|
NUDGE_HEAD = "[brindle autopilot] The goal isn't reached yet, and no workers are running."
|
|
62
|
+
WAIT_ENDED_HEAD = "[brindle autopilot] The job you were waiting on has ended"
|
|
60
63
|
MAX_GOAL_CHARS = 4000 # Claude Code's limit for a /goal condition
|
|
61
64
|
OUTPUT_TAIL_LINES = 30
|
|
62
65
|
|
|
@@ -118,6 +121,12 @@ to be asked each step.
|
|
|
118
121
|
depend on it: call `need_user` with the question, ask it, keep working the
|
|
119
122
|
rest, and report the blocked item with it. Stop only when nothing else is
|
|
120
123
|
left; once the user answers, call `need_user(answered=true)`.
|
|
124
|
+
- If the next step waits on an outside job, call `wait_on(pid=..., note=...)`
|
|
125
|
+
or `wait_on(until=..., note=...)` (Unix timestamp in seconds); `path=...`
|
|
126
|
+
can watch for a completion file. Autopilot holds reminders and stall counting,
|
|
127
|
+
then wakes you once when the process exits, the time arrives, or the file
|
|
128
|
+
appears. If several are given, the first to finish ends the wait. An existing
|
|
129
|
+
wait is kept if you call again. Keep working on independent steps meanwhile.
|
|
121
130
|
- When a milestone passes, tell the user in one line.
|
|
122
131
|
- If a message says autopilot is off, stop driving: wait for the user's
|
|
123
132
|
instructions.
|
|
@@ -853,6 +862,8 @@ def profile_weight(name: str, cfg: RepoConfig | None = None, repo_root: str | No
|
|
|
853
862
|
m = effective_profile_model(p, repo_root).lower()
|
|
854
863
|
if any(h in m for h in ("fable", "opus", "o3", "deepseek-r1", "astra")):
|
|
855
864
|
return "heavy"
|
|
865
|
+
if "sonnet" in m:
|
|
866
|
+
return "medium"
|
|
856
867
|
if getattr(p, "effort", None) in ("high", "xhigh", "max"):
|
|
857
868
|
return "heavy"
|
|
858
869
|
if "-heavy" in name:
|
|
@@ -890,8 +901,9 @@ def choose_profile(db: DB, caller_id: str, repo_root: str, requested: str | None
|
|
|
890
901
|
weight: str | None = None, why: list[str] | None = None,
|
|
891
902
|
decision: dict | None = None) -> tuple[str, bool]:
|
|
892
903
|
"""The worker profile for a delegation, and whether learning chose it:
|
|
893
|
-
``requested`` if given, else
|
|
894
|
-
the caller's session, else the repo's
|
|
904
|
+
``requested`` if given, else (when no ``weight`` is given) the first
|
|
905
|
+
unverified milestone's profile in the caller's session, else the repo's
|
|
906
|
+
routing for ``weight`` (available
|
|
895
907
|
candidates only, the hosted learner choosing among them), else the hosted
|
|
896
908
|
learner's pick among ``learning_candidates``, else the repo's
|
|
897
909
|
``default_agent``. Raises AutopilotError if it doesn't exist. An
|
|
@@ -916,7 +928,10 @@ def choose_profile(db: DB, caller_id: str, repo_root: str, requested: str | None
|
|
|
916
928
|
routed: dict = {}
|
|
917
929
|
if not name:
|
|
918
930
|
pending = next((m for m in db.milestones(root_of(db, caller_id)) if m.status != "passed"), None)
|
|
919
|
-
if pending and pending.profile:
|
|
931
|
+
if pending and pending.profile and weight:
|
|
932
|
+
if why is not None:
|
|
933
|
+
why.append(f"weight {weight} overrides milestone profile {pending.profile.strip()}")
|
|
934
|
+
elif pending and pending.profile:
|
|
920
935
|
m_profile = pending.profile.strip()
|
|
921
936
|
if ceiling and weight_order[profile_weight(m_profile, cfg, repo_root)] > weight_order[ceiling]:
|
|
922
937
|
candidates = cfg.routing.get(ceiling, [cfg.default_agent])
|
|
@@ -1048,6 +1063,52 @@ def need_user(db: DB, root_id: str, question: str, ask: bool = False) -> None:
|
|
|
1048
1063
|
question=note if ask else None)
|
|
1049
1064
|
|
|
1050
1065
|
|
|
1066
|
+
def wait_on(db: DB, root_id: str, pid: int | None = None, until: float | None = None,
|
|
1067
|
+
note: str = "", path: str | None = None) -> None:
|
|
1068
|
+
"""Declare a session wait; any supplied completion condition ends it."""
|
|
1069
|
+
if pid is None and until is None and not path:
|
|
1070
|
+
raise AutopilotError("Give a pid, an until Unix timestamp, or a completion file path.")
|
|
1071
|
+
if pid is not None and not 0 < pid <= 2**31 - 1:
|
|
1072
|
+
raise AutopilotError("pid must be a positive process id.")
|
|
1073
|
+
if until is not None and (not math.isfinite(until) or until < 0):
|
|
1074
|
+
raise AutopilotError("until must be a finite Unix timestamp in seconds.")
|
|
1075
|
+
db.set_external_wait(root_id, json.dumps({"pid": pid, "until": until,
|
|
1076
|
+
"path": path or None, "note": note.strip()[:500]}))
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def wait_summary(ap: Autopilot) -> str | None:
|
|
1080
|
+
if not ap.external_wait:
|
|
1081
|
+
return None
|
|
1082
|
+
wait = json.loads(ap.external_wait)
|
|
1083
|
+
parts = []
|
|
1084
|
+
if wait["pid"] is not None:
|
|
1085
|
+
parts.append(f"pid {wait['pid']}")
|
|
1086
|
+
if wait["until"] is not None:
|
|
1087
|
+
parts.append(f"until {wait['until']:g}")
|
|
1088
|
+
if wait["path"]:
|
|
1089
|
+
parts.append(f"file {wait['path']}")
|
|
1090
|
+
return f"waiting on {wait['note'] or 'outside job'} ({', '.join(parts)})"
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def wait_ended(db: DB, ap: Autopilot, now: float | None = None) -> str | None:
|
|
1094
|
+
"""Queue one durable completion notice from either a Stop hook or sweep."""
|
|
1095
|
+
from brindle import agents, identity_lock, procs
|
|
1096
|
+
|
|
1097
|
+
if not ap.external_wait:
|
|
1098
|
+
return None
|
|
1099
|
+
now = time.time() if now is None else now
|
|
1100
|
+
wait = json.loads(ap.external_wait)
|
|
1101
|
+
ended = ((wait["until"] is not None and now >= wait["until"])
|
|
1102
|
+
or (wait["path"] is not None and Path(wait["path"]).exists())
|
|
1103
|
+
or (wait["pid"] is not None and not procs.alive(wait["pid"])))
|
|
1104
|
+
if not ended:
|
|
1105
|
+
return None
|
|
1106
|
+
identity_lock.ensure(agents.get(db, ap.root_id))
|
|
1107
|
+
note = f": {wait['note']}" if wait["note"] else ""
|
|
1108
|
+
message = f"{WAIT_ENDED_HEAD}{note}. Continue with the next step."
|
|
1109
|
+
return message if db.finish_external_wait(ap.root_id, ap.external_wait, now, message) else None
|
|
1110
|
+
|
|
1111
|
+
|
|
1051
1112
|
def held(ap: Autopilot) -> bool:
|
|
1052
1113
|
"""Autopilot waits on the supervisor's open question, and nothing has
|
|
1053
1114
|
blocked it since (that would have replaced the note)."""
|
|
@@ -1517,7 +1578,7 @@ def work_state(db: DB, root_id: str) -> str | None:
|
|
|
1517
1578
|
ap = db.get_autopilot(root_id)
|
|
1518
1579
|
if ap is not None and checking(ap):
|
|
1519
1580
|
return "checking"
|
|
1520
|
-
if (ap is not None and ap.state == "usage_paused") or login_pause.paused(db, root_id):
|
|
1581
|
+
if (ap is not None and (ap.state == "usage_paused" or ap.external_wait)) or login_pause.paused(db, root_id):
|
|
1521
1582
|
return "waiting"
|
|
1522
1583
|
live = active_workers(db, root_id)
|
|
1523
1584
|
if any(a.status != "waiting" for a in live):
|
|
@@ -1543,6 +1604,8 @@ def progress(db: DB, root_id: str, *, in_flight: bool = True) -> str:
|
|
|
1543
1604
|
"user's request (ask what we're building only if there is no request).")
|
|
1544
1605
|
done, total = counts(db, root_id)
|
|
1545
1606
|
lines = [f"{head}. Goal: {ap.goal}", f"{done} of {total} milestones verified."]
|
|
1607
|
+
if waiting := wait_summary(ap):
|
|
1608
|
+
lines.append(waiting)
|
|
1546
1609
|
ms = db.milestones(root_id)
|
|
1547
1610
|
w = work_state(db, root_id) if in_flight and any(m.status == "failed" for m in ms) else None
|
|
1548
1611
|
for m in ms:
|
|
@@ -1787,7 +1850,10 @@ def usage_resume(db: DB, root_id: str, now: float | None = None) -> str | None:
|
|
|
1787
1850
|
|
|
1788
1851
|
def usage_sweep(db: DB, now: float | None = None) -> list[str]:
|
|
1789
1852
|
"""One pass over every autopilot session: stop workers at the limit,
|
|
1790
|
-
resume them after the reset
|
|
1853
|
+
resume them after the reset, and wake completed outside waits.
|
|
1854
|
+
Called from the cull sweep."""
|
|
1855
|
+
from brindle import agents, identity_lock, login_pause, tmux
|
|
1856
|
+
|
|
1791
1857
|
done = []
|
|
1792
1858
|
for root in db.list_agents():
|
|
1793
1859
|
if root.mode != "interactive" or root.status in ("paused", "done"):
|
|
@@ -1800,6 +1866,20 @@ def usage_sweep(db: DB, now: float | None = None) -> list[str]:
|
|
|
1800
1866
|
cfg = load_repo_config(ws.repo_root) if ws else RepoConfig()
|
|
1801
1867
|
if over_limit(db, root.id, cfg):
|
|
1802
1868
|
done.append(f"usage limit reached: {root.id} is {db.get_autopilot(root.id).state}")
|
|
1869
|
+
elif ((ap.external_wait or db.outstanding(root.id, WAIT_ENDED_HEAD))
|
|
1870
|
+
and not login_pause.paused(db, root.id) and not proposing(ap)):
|
|
1871
|
+
try:
|
|
1872
|
+
message = wait_ended(db, ap, now)
|
|
1873
|
+
if db.outstanding(root.id, WAIT_ENDED_HEAD):
|
|
1874
|
+
identity_lock.ensure(root)
|
|
1875
|
+
if agents.is_alive(root):
|
|
1876
|
+
agents.flush(db, root.id)
|
|
1877
|
+
if message:
|
|
1878
|
+
done.append(message)
|
|
1879
|
+
except (agents.AgentError, tmux.TmuxError):
|
|
1880
|
+
# Keep the wait, or its already queued notice, for retry.
|
|
1881
|
+
# Other sessions and the cull sweep's login recovery must run.
|
|
1882
|
+
log.debug("outside wait delivery deferred for %s", root.id, exc_info=True)
|
|
1803
1883
|
elif ap.state == "usage_paused":
|
|
1804
1884
|
line = usage_resume(db, root.id, now)
|
|
1805
1885
|
if line:
|
|
@@ -1849,6 +1929,12 @@ def on_stop(db: DB, agent: Agent, payload: dict) -> dict | None:
|
|
|
1849
1929
|
|
|
1850
1930
|
if over_limit(db, agent.id, cfg):
|
|
1851
1931
|
return None # workers included: they'd keep burning usage
|
|
1932
|
+
if ap.external_wait:
|
|
1933
|
+
try:
|
|
1934
|
+
wait_ended(db, ap)
|
|
1935
|
+
except agents.AgentError:
|
|
1936
|
+
log.debug("outside wait completion deferred for %s", agent.id, exc_info=True)
|
|
1937
|
+
return None # the durable inbox notice wakes it, including after a failed delivery
|
|
1852
1938
|
if checking(ap):
|
|
1853
1939
|
return None # the check's result arrives as a message and wakes it up
|
|
1854
1940
|
working, stalled = split_workers(db, agent.id, screen=True)
|
|
@@ -1857,7 +1943,7 @@ def on_stop(db: DB, agent: Agent, payload: dict) -> dict | None:
|
|
|
1857
1943
|
if any(agents.runs_process(a) for a in working
|
|
1858
1944
|
if getattr(a, "plan_state", None) != "proposed"):
|
|
1859
1945
|
return None # their results arrive as messages and wake it up
|
|
1860
|
-
if db.outstanding(agent.id, NUDGE_HEAD):
|
|
1946
|
+
if db.outstanding(agent.id, NUDGE_HEAD) or db.outstanding(agent.id, WAIT_ENDED_HEAD):
|
|
1861
1947
|
return None # (#209) the last reminder is still queued or unread: never pile up another
|
|
1862
1948
|
now = time.time()
|
|
1863
1949
|
if (ap.nudged_at == ap.progress and ap.nudged_time is not None
|
|
@@ -1908,7 +1994,8 @@ def nudge(db: DB, ap: Autopilot, cfg: RepoConfig,
|
|
|
1908
1994
|
f"unverified milestone and assign workers (at most {cfg.max_agents or 'any number'} "
|
|
1909
1995
|
"at once), get finished branches reviewed and merged, and call check_milestone. "
|
|
1910
1996
|
"If you need a decision only the user can make, call need_user with the question, "
|
|
1911
|
-
"then ask it."
|
|
1997
|
+
"then ask it. If the next step waits on an outside job, call wait_on with its pid, "
|
|
1998
|
+
"an until Unix timestamp, or a completion file path, and a note."
|
|
1912
1999
|
)
|
|
1913
2000
|
|
|
1914
2001
|
|
|
@@ -3,7 +3,7 @@ name: reviewer-codex
|
|
|
3
3
|
description: Reviews a branch's changes for bugs and risks without editing code, using Codex for a second model's view
|
|
4
4
|
provider: codex
|
|
5
5
|
permission_mode: dontAsk # nobody watches a reviewer: refuse what allowed_tools doesn't cover, never prompt
|
|
6
|
-
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:*)
|
|
6
|
+
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(gh issue view:*), Bash(gh pr view:*), Bash(gh pr diff:*), 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:*)
|
|
7
7
|
---
|
|
8
8
|
You are a code reviewer running under brindle. Your prompt gives you the
|
|
9
9
|
worker's original task and its finish line, and, if the repo has checks
|
|
@@ -5,7 +5,7 @@ provider: antigravity
|
|
|
5
5
|
model: gemini-4-argon
|
|
6
6
|
effort: high
|
|
7
7
|
permission_mode: dontAsk # nobody watches a reviewer: refuse what allowed_tools doesn't cover, never prompt
|
|
8
|
-
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(git ls-tree:*), Bash(git rev-parse:*), Bash(git cat-file:*), Bash(git grep:*), Bash(git blame:*), 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(rg:*), Bash(wc:*), Bash(bq show:*), Bash(bq ls:*), Bash(bq query --use_legacy_sql=false --dry_run:*), Bash(bq query --dry_run:*)
|
|
8
|
+
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(gh issue view:*), Bash(gh pr view:*), Bash(gh pr diff:*), Bash(git ls-tree:*), Bash(git rev-parse:*), Bash(git cat-file:*), Bash(git grep:*), Bash(git blame:*), 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(rg:*), Bash(wc:*), Bash(bq show:*), Bash(bq ls:*), Bash(bq query --use_legacy_sql=false --dry_run:*), Bash(bq query --dry_run:*)
|
|
9
9
|
---
|
|
10
10
|
You are a code reviewer running under brindle. Your prompt gives you the
|
|
11
11
|
worker's original task and its finish line, and, if the repo has checks
|
|
@@ -8,7 +8,7 @@ model: qwen3-coder:30b
|
|
|
8
8
|
context_tokens: 32k
|
|
9
9
|
fallback: reviewer # hosted, when Ollama isn't running or the model isn't pulled
|
|
10
10
|
permission_mode: dontAsk # nobody watches a reviewer: refuse what allowed_tools doesn't cover
|
|
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
|
+
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(gh issue view:*), Bash(gh pr view:*), Bash(gh pr diff:*), 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:*)
|
|
12
12
|
---
|
|
13
13
|
You are a code reviewer running under brindle. Your prompt gives you the
|
|
14
14
|
worker's original task and its finish line, and, if the repo has checks
|
|
@@ -3,7 +3,7 @@ name: reviewer-opencode
|
|
|
3
3
|
description: Reviews a branch's changes for bugs and risks without editing code, using OpenCode for another model's view
|
|
4
4
|
provider: opencode
|
|
5
5
|
permission_mode: dontAsk # nobody watches a reviewer: refuse what allowed_tools doesn't cover, never prompt
|
|
6
|
-
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:*)
|
|
6
|
+
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(gh issue view:*), Bash(gh pr view:*), Bash(gh pr diff:*), 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:*)
|
|
7
7
|
---
|
|
8
8
|
You are a code reviewer running under brindle. Your prompt gives you the
|
|
9
9
|
worker's original task and its finish line, and, if the repo has checks
|
|
@@ -7,7 +7,7 @@ effort: medium
|
|
|
7
7
|
strict_mcp: true
|
|
8
8
|
setting_sources: project,local
|
|
9
9
|
permission_mode: dontAsk # nobody watches a reviewer: refuse what allowed_tools doesn't cover, never prompt
|
|
10
|
-
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(git ls-tree:*), Bash(git rev-parse:*), Bash(git cat-file:*), Bash(git grep:*), Bash(git blame:*), 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(rg:*), Bash(wc:*), Bash(bq show:*), Bash(bq ls:*), Bash(bq query --use_legacy_sql=false --dry_run:*), Bash(bq query --dry_run:*)
|
|
10
|
+
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(gh issue view:*), Bash(gh pr view:*), Bash(gh pr diff:*), Bash(git ls-tree:*), Bash(git rev-parse:*), Bash(git cat-file:*), Bash(git grep:*), Bash(git blame:*), 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(rg:*), Bash(wc:*), Bash(bq show:*), Bash(bq ls:*), Bash(bq query --use_legacy_sql=false --dry_run:*), Bash(bq query --dry_run:*)
|
|
11
11
|
---
|
|
12
12
|
You are a code reviewer running under brindle. Your prompt gives you the
|
|
13
13
|
worker's original task and its finish line, and, if the repo has checks
|
|
@@ -3080,10 +3080,7 @@ def quit_cmd(root_id: str) -> None:
|
|
|
3080
3080
|
@app.command("_close", hidden=True)
|
|
3081
3081
|
def close_cmd(agent_id: str, delay: float = typer.Option(0.0)) -> None:
|
|
3082
3082
|
time.sleep(delay)
|
|
3083
|
-
|
|
3084
|
-
agents.kill(_helper_db(), agent_id)
|
|
3085
|
-
except agents.AgentError:
|
|
3086
|
-
pass
|
|
3083
|
+
agents.close_removed(_helper_db(), agent_id)
|
|
3087
3084
|
|
|
3088
3085
|
|
|
3089
3086
|
@app.command("_finish-review", hidden=True)
|
|
@@ -15,7 +15,8 @@ and only brindle's own Codex agents get it. The key is then always
|
|
|
15
15
|
same for every agent (the hook finds its agent from ``BRINDLE_AGENT_ID``, which
|
|
16
16
|
Codex passes through), so one trust covers every worktree brindle ever creates.
|
|
17
17
|
A changed command (brindle installed somewhere else, a different BRINDLE_HOME)
|
|
18
|
-
is a new hook to Codex
|
|
18
|
+
or timeout (another build of brindle) is a new hook to Codex, so brindle
|
|
19
|
+
records the whole definition it trusted, not just the command.
|
|
19
20
|
|
|
20
21
|
brindle trusts its own hook the first time a Codex agent needs it (``ensure``),
|
|
21
22
|
and again after it moves, so there's no setup step; it says so once at
|
|
@@ -35,6 +36,7 @@ import json
|
|
|
35
36
|
import os
|
|
36
37
|
import queue
|
|
37
38
|
import shlex
|
|
39
|
+
import signal
|
|
38
40
|
import subprocess
|
|
39
41
|
import threading
|
|
40
42
|
import time
|
|
@@ -88,10 +90,11 @@ TRUSTED, MOVED, NEW, REMOVED = "trusted", "moved", "new", "removed"
|
|
|
88
90
|
|
|
89
91
|
|
|
90
92
|
def status(command: str | None = None) -> str:
|
|
91
|
-
"""Where brindle's hook stands with Codex: TRUSTED (this exact
|
|
92
|
-
Codex's config still says so), MOVED (trusted, but brindle's
|
|
93
|
-
changed since: a reinstall, another BRINDLE_HOME
|
|
94
|
-
REMOVED (trusted once, then the trust was taken out of
|
|
93
|
+
"""Where brindle's hook stands with Codex: TRUSTED (this exact hook, and
|
|
94
|
+
Codex's config still says so), MOVED (trusted, but brindle's hook has
|
|
95
|
+
changed since: a reinstall, another BRINDLE_HOME, another timeout), NEW
|
|
96
|
+
(never trusted) or REMOVED (trusted once, then the trust was taken out of
|
|
97
|
+
Codex's config)."""
|
|
95
98
|
import tomllib
|
|
96
99
|
|
|
97
100
|
from brindle import permissions
|
|
@@ -111,7 +114,11 @@ def status(command: str | None = None) -> str:
|
|
|
111
114
|
entry = state.get(rec["key"]) if isinstance(state, dict) else None
|
|
112
115
|
if not (isinstance(entry, dict) and entry.get("trusted_hash") == rec["hash"]):
|
|
113
116
|
return REMOVED
|
|
114
|
-
|
|
117
|
+
# Codex's hash covers the whole definition, not just the command: a trust
|
|
118
|
+
# recorded for another timeout (another build of brindle) is for another
|
|
119
|
+
# hook, and so is one recorded before brindle kept the definition (it can't
|
|
120
|
+
# tell what that was for), so ensure() trusts it again once.
|
|
121
|
+
return TRUSTED if rec.get("definition") == config_flags(command)[1] else MOVED
|
|
115
122
|
|
|
116
123
|
|
|
117
124
|
def trusted(command: str | None = None) -> bool:
|
|
@@ -148,13 +155,22 @@ def policy_on(cwd: str | None) -> bool:
|
|
|
148
155
|
return False
|
|
149
156
|
|
|
150
157
|
|
|
151
|
-
def launch_flags(cwd: str | None) -> list[str]:
|
|
158
|
+
def launch_flags(cwd: str | None, review: bool = False) -> list[str]:
|
|
152
159
|
"""The flags a Codex agent in ``cwd`` launches with: brindle's hook when the
|
|
153
|
-
repo's policy is on and the person trusted the hook; else none.
|
|
160
|
+
repo's policy is on and the person trusted the hook; else none. A reviewer
|
|
161
|
+
gets it with the policy off too (nobody watches its prompts; the hook only
|
|
162
|
+
allows what its allowed_tools cover, #241), but only a hook trusted
|
|
163
|
+
already: with the policy off brindle never trusts a NEW one itself, and a
|
|
164
|
+
REMOVED trust stays removed."""
|
|
154
165
|
try:
|
|
155
166
|
if not policy_on(cwd):
|
|
156
|
-
|
|
157
|
-
|
|
167
|
+
if not review:
|
|
168
|
+
return []
|
|
169
|
+
command = hook_command()
|
|
170
|
+
if status(command) not in (TRUSTED, MOVED):
|
|
171
|
+
return []
|
|
172
|
+
else:
|
|
173
|
+
command = hook_command()
|
|
158
174
|
return config_flags(command) if ensure(command=command) == TRUSTED else []
|
|
159
175
|
except Exception: # noqa: BLE001 - never stop a launch over this
|
|
160
176
|
return []
|
|
@@ -178,6 +194,31 @@ def launch_warning(provider: str, cwd: str | None) -> str | None:
|
|
|
178
194
|
f"({why}), so Codex asks for approvals itself. Ask the user to run `{INSTALL}`")
|
|
179
195
|
|
|
180
196
|
|
|
197
|
+
UNTRUSTED = ("brindle couldn't trust its own Codex permission hook (new, or changed by an upgrade), "
|
|
198
|
+
"so Codex agents start without it and Codex asks for approvals itself. "
|
|
199
|
+
f"Ask the user to run `{INSTALL}`.")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def tell_untrusted(db, agent, cwd: str | None) -> bool:
|
|
203
|
+
"""Tell ``agent``'s supervisor, once, that brindle couldn't trust its hook,
|
|
204
|
+
so this Codex agent started without it: nobody may watch a reviewer's pane
|
|
205
|
+
to find out otherwise. A trust the person removed stays theirs to give
|
|
206
|
+
back (launch_warning, doctor), not news."""
|
|
207
|
+
if agent.provider != "codex" or not agent.parent_id or not policy_on(cwd) \
|
|
208
|
+
or status() not in (NEW, MOVED):
|
|
209
|
+
return False
|
|
210
|
+
if db.conn.execute("SELECT 1 FROM inbox WHERE agent_id=? AND instr(body, ?) > 0",
|
|
211
|
+
(agent.parent_id, UNTRUSTED)).fetchone():
|
|
212
|
+
return False
|
|
213
|
+
from brindle import agents
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
agents.send_message(db, agent.parent_id, UNTRUSTED)
|
|
217
|
+
except agents.AgentError:
|
|
218
|
+
db.enqueue(agent.parent_id, UNTRUSTED, None)
|
|
219
|
+
return True
|
|
220
|
+
|
|
221
|
+
|
|
181
222
|
# -- asking Codex itself ---------------------------------------------------------------
|
|
182
223
|
|
|
183
224
|
|
|
@@ -188,7 +229,8 @@ class _AppServer:
|
|
|
188
229
|
self.timeout = timeout
|
|
189
230
|
try:
|
|
190
231
|
self.proc = subprocess.Popen([binary, "app-server", *flags], stdin=subprocess.PIPE,
|
|
191
|
-
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True
|
|
232
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
|
|
233
|
+
start_new_session=True)
|
|
192
234
|
except OSError as e:
|
|
193
235
|
raise CodexHookError(f"couldn't run {binary}: {e}") from e
|
|
194
236
|
self.lines: queue.Queue = queue.Queue()
|
|
@@ -239,6 +281,14 @@ class _AppServer:
|
|
|
239
281
|
return result
|
|
240
282
|
|
|
241
283
|
def close(self) -> None:
|
|
284
|
+
# The app-server leads its own process group (see __init__): kill the
|
|
285
|
+
# group so nothing it spawned outlives it.
|
|
286
|
+
pid = getattr(self.proc, "pid", None)
|
|
287
|
+
if pid:
|
|
288
|
+
try:
|
|
289
|
+
os.killpg(pid, signal.SIGKILL)
|
|
290
|
+
except OSError:
|
|
291
|
+
pass
|
|
242
292
|
try:
|
|
243
293
|
self.proc.kill()
|
|
244
294
|
except OSError:
|
|
@@ -290,5 +340,6 @@ def trust(binary: str, state: HookState) -> HookState:
|
|
|
290
340
|
if after.status != "trusted":
|
|
291
341
|
raise CodexHookError(f"Codex still reports the hook as {after.status}")
|
|
292
342
|
with permissions.editing() as store:
|
|
293
|
-
store.codex_hook = {"command": after.command, "key": after.key, "hash": after.hash
|
|
343
|
+
store.codex_hook = {"command": after.command, "key": after.key, "hash": after.hash,
|
|
344
|
+
"definition": config_flags(after.command)[1]}
|
|
294
345
|
return after
|
|
@@ -80,8 +80,8 @@ def normalize_weight(weight: str | None) -> str | None:
|
|
|
80
80
|
# Nothing here prefers another provider for its own sake: all-Claude, all-Codex
|
|
81
81
|
# or all-Gemini may well be best, and that's the learner's call.
|
|
82
82
|
DEFAULT_ROUTING = {
|
|
83
|
-
"light": ["developer-light", "developer", "developer-codex", "developer-local"],
|
|
84
|
-
"medium": ["developer", "developer-codex", "developer-heavy"],
|
|
83
|
+
"light": ["developer-sonnet", "developer-light", "developer", "developer-codex", "developer-local"],
|
|
84
|
+
"medium": ["developer-sonnet", "developer", "developer-codex", "developer-heavy"],
|
|
85
85
|
"heavy": ["developer-heavy", "developer-gemini", "developer", "developer-codex"],
|
|
86
86
|
"advanced": ["developer-fable", "developer-heavy", "developer-gemini"],
|
|
87
87
|
}
|