brindle 0.4.2__tar.gz → 0.4.3__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.3}/PKG-INFO +2 -2
- {brindle-0.4.2 → brindle-0.4.3}/README.md +1 -1
- {brindle-0.4.2 → brindle-0.4.3}/pyproject.toml +1 -1
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/agents.py +32 -7
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/autopilot.py +94 -7
- brindle-0.4.3/src/brindle/builtin_agents/developer-sonnet.md +7 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/reviewer-codex.md +1 -1
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/reviewer-gemini.md +1 -1
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/reviewer-local.md +1 -1
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/reviewer-opencode.md +1 -1
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/reviewer.md +1 -1
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/codex_hook.py +52 -11
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/config.py +2 -2
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/db.py +40 -6
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/launch_health.py +34 -7
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/mcp_server.py +24 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/providers.py +3 -2
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/view.py +1 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/watch.py +2 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/worker_models.py +1 -1
- {brindle-0.4.2 → brindle-0.4.3}/.gitignore +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/LICENSE +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/SCHEDULE-A +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/__init__.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/__main__.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/account.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/airgap.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/antigravity.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/assembler.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/assets/braid_dark.png +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/assets/braid_light.png +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/budget.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/backend-auditor.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/coordinator.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer-codex.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer-fable.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer-gemini.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer-heavy.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer-light.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer-local.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer-opencode.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/developer.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/qa.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_rules/__init__.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_rules/security/backend.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/builtin_rules/tests/only.md +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/ci_adapters.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/ci_client.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/ci_federation.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/ci_hosts.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/cli.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/cloud_credential.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/cloud_login.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/codemap.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/codemap_docs.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/company_identity.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/company_login.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/conflicts.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/coordinator.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/cost.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/cost_estimate.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/cull.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/demo.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/detect.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/doctor.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/escapes.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/events.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/gates.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/git.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/guardrails.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/history.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/identity_lock.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/inbox.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/kb.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/key_check.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/keys_cmds.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/keystore.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/knowledge.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/learned_rules.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/learning.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/legacy.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/local_setup.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/login_pause.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/model_access.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/model_availability.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/__init__.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/catalog.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/client.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/loop.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/lsp.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/permissions.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/runner.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/serve.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/native/tools.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/opencode.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pane_auth.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/permissions.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/picker.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pipeline.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/plugins.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/policy.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pool.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pricing.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/_files.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/account.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/audit_export.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/auth.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/brief_learning.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/cost_centers.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/features.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/keys.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/learning.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/license.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/managed_models.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/org_profiles.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/rollout.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/route_health.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/settings_sync.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/status.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/team_events.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/pro/team_policy.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/procs.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/profiles.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/prompt_answer.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/quota.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/repos.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/rewind.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/rule_checks.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/savings.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/scratch.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/secrets.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/self_serve.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/services.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/sessions.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/sidebar_follow.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/sidebar_logo.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/signin_pause.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/status_cache.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/tasks.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/tmux.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/updates.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/upgrade.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/usage.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/wake.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/watch_costs.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/web/__init__.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/web/chat.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/web/dashboard.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/web/diff.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/web/server.py +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/web/static/app.js +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/src/brindle/web/static/index.html +0 -0
- {brindle-0.4.2 → brindle-0.4.3}/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.3
|
|
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
|
|
@@ -2914,7 +2930,9 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
|
|
|
2914
2930
|
"""Claude Code's or Codex's PermissionRequest hook: brindle's permission
|
|
2915
2931
|
policy (brindle.permissions) answers allow or deny, or None to let the CLI
|
|
2916
2932
|
prompt the person as usual (ask). Off unless the repo or user config
|
|
2917
|
-
sets ``permission_policy: "on"
|
|
2933
|
+
sets ``permission_policy: "on"``, except for a Codex reviewer, whose hook
|
|
2934
|
+
allows what its profile's allowed_tools cover and leaves the rest to
|
|
2935
|
+
Codex (#241). Never raises: any failure is ask."""
|
|
2918
2936
|
try:
|
|
2919
2937
|
from brindle import permissions
|
|
2920
2938
|
from brindle.config import load_repo_config
|
|
@@ -2924,7 +2942,11 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
|
|
|
2924
2942
|
if ws is None:
|
|
2925
2943
|
return None
|
|
2926
2944
|
cfg = load_repo_config(ws.repo_root)
|
|
2927
|
-
|
|
2945
|
+
# Policy off: only a Codex reviewer has the hook (codex_hook.launch_flags).
|
|
2946
|
+
# Nobody watches it, so it gets what its allowed_tools cover and
|
|
2947
|
+
# nothing else of brindle's: every other answer is Codex's own.
|
|
2948
|
+
review_only = cfg.permission_policy != "on"
|
|
2949
|
+
if review_only and not (provider == "codex" and agent.mode == "review"):
|
|
2928
2950
|
return None
|
|
2929
2951
|
profile_rules = profile_rules_for(agent.profile, ws.repo_root)
|
|
2930
2952
|
if provider == "codex":
|
|
@@ -2945,7 +2967,8 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
|
|
|
2945
2967
|
profile = None # deleted profile: no extra allowances
|
|
2946
2968
|
if profile is not None:
|
|
2947
2969
|
rules += permissions.profile_bash_allows(profile)
|
|
2948
|
-
decision = permissions.
|
|
2970
|
+
decision = permissions.Decision("ask", "not in the profile's allowed_tools") if review_only \
|
|
2971
|
+
else permissions.decide_all(reqs, checks=cfg.checks, rules=rules)
|
|
2949
2972
|
if decision.decision == "ask" and len(reqs) == 1:
|
|
2950
2973
|
# A deny on one part of a compound command (cd x && <denied>)
|
|
2951
2974
|
# wins before any part-by-part allow below.
|
|
@@ -2977,6 +3000,8 @@ def permission_request_decision(db: DB, agent: Agent, payload: dict,
|
|
|
2977
3000
|
"deny", f"{decision.reason.removesuffix('; asking the user')}, and this profile "
|
|
2978
3001
|
"never asks (permission_mode dontAsk). Don't retry it: do without it, or ask "
|
|
2979
3002
|
"your supervisor with the send_message tool (brindle MCP server)")
|
|
3003
|
+
if review_only and decision.decision != "allow":
|
|
3004
|
+
return None # as Codex would without brindle: its own prompt or refusal
|
|
2980
3005
|
summary = reqs[0].summary() if len(reqs) == 1 else (
|
|
2981
3006
|
f"{reqs[0].tool}: " + ", ".join(r.path or "?" for r in reqs))[:200]
|
|
2982
3007
|
_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
|
|
@@ -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
|
|
@@ -88,10 +89,11 @@ TRUSTED, MOVED, NEW, REMOVED = "trusted", "moved", "new", "removed"
|
|
|
88
89
|
|
|
89
90
|
|
|
90
91
|
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
|
|
92
|
+
"""Where brindle's hook stands with Codex: TRUSTED (this exact hook, and
|
|
93
|
+
Codex's config still says so), MOVED (trusted, but brindle's hook has
|
|
94
|
+
changed since: a reinstall, another BRINDLE_HOME, another timeout), NEW
|
|
95
|
+
(never trusted) or REMOVED (trusted once, then the trust was taken out of
|
|
96
|
+
Codex's config)."""
|
|
95
97
|
import tomllib
|
|
96
98
|
|
|
97
99
|
from brindle import permissions
|
|
@@ -111,7 +113,11 @@ def status(command: str | None = None) -> str:
|
|
|
111
113
|
entry = state.get(rec["key"]) if isinstance(state, dict) else None
|
|
112
114
|
if not (isinstance(entry, dict) and entry.get("trusted_hash") == rec["hash"]):
|
|
113
115
|
return REMOVED
|
|
114
|
-
|
|
116
|
+
# Codex's hash covers the whole definition, not just the command: a trust
|
|
117
|
+
# recorded for another timeout (another build of brindle) is for another
|
|
118
|
+
# hook, and so is one recorded before brindle kept the definition (it can't
|
|
119
|
+
# tell what that was for), so ensure() trusts it again once.
|
|
120
|
+
return TRUSTED if rec.get("definition") == config_flags(command)[1] else MOVED
|
|
115
121
|
|
|
116
122
|
|
|
117
123
|
def trusted(command: str | None = None) -> bool:
|
|
@@ -148,13 +154,22 @@ def policy_on(cwd: str | None) -> bool:
|
|
|
148
154
|
return False
|
|
149
155
|
|
|
150
156
|
|
|
151
|
-
def launch_flags(cwd: str | None) -> list[str]:
|
|
157
|
+
def launch_flags(cwd: str | None, review: bool = False) -> list[str]:
|
|
152
158
|
"""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.
|
|
159
|
+
repo's policy is on and the person trusted the hook; else none. A reviewer
|
|
160
|
+
gets it with the policy off too (nobody watches its prompts; the hook only
|
|
161
|
+
allows what its allowed_tools cover, #241), but only a hook trusted
|
|
162
|
+
already: with the policy off brindle never trusts a NEW one itself, and a
|
|
163
|
+
REMOVED trust stays removed."""
|
|
154
164
|
try:
|
|
155
165
|
if not policy_on(cwd):
|
|
156
|
-
|
|
157
|
-
|
|
166
|
+
if not review:
|
|
167
|
+
return []
|
|
168
|
+
command = hook_command()
|
|
169
|
+
if status(command) not in (TRUSTED, MOVED):
|
|
170
|
+
return []
|
|
171
|
+
else:
|
|
172
|
+
command = hook_command()
|
|
158
173
|
return config_flags(command) if ensure(command=command) == TRUSTED else []
|
|
159
174
|
except Exception: # noqa: BLE001 - never stop a launch over this
|
|
160
175
|
return []
|
|
@@ -178,6 +193,31 @@ def launch_warning(provider: str, cwd: str | None) -> str | None:
|
|
|
178
193
|
f"({why}), so Codex asks for approvals itself. Ask the user to run `{INSTALL}`")
|
|
179
194
|
|
|
180
195
|
|
|
196
|
+
UNTRUSTED = ("brindle couldn't trust its own Codex permission hook (new, or changed by an upgrade), "
|
|
197
|
+
"so Codex agents start without it and Codex asks for approvals itself. "
|
|
198
|
+
f"Ask the user to run `{INSTALL}`.")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def tell_untrusted(db, agent, cwd: str | None) -> bool:
|
|
202
|
+
"""Tell ``agent``'s supervisor, once, that brindle couldn't trust its hook,
|
|
203
|
+
so this Codex agent started without it: nobody may watch a reviewer's pane
|
|
204
|
+
to find out otherwise. A trust the person removed stays theirs to give
|
|
205
|
+
back (launch_warning, doctor), not news."""
|
|
206
|
+
if agent.provider != "codex" or not agent.parent_id or not policy_on(cwd) \
|
|
207
|
+
or status() not in (NEW, MOVED):
|
|
208
|
+
return False
|
|
209
|
+
if db.conn.execute("SELECT 1 FROM inbox WHERE agent_id=? AND instr(body, ?) > 0",
|
|
210
|
+
(agent.parent_id, UNTRUSTED)).fetchone():
|
|
211
|
+
return False
|
|
212
|
+
from brindle import agents
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
agents.send_message(db, agent.parent_id, UNTRUSTED)
|
|
216
|
+
except agents.AgentError:
|
|
217
|
+
db.enqueue(agent.parent_id, UNTRUSTED, None)
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
|
|
181
221
|
# -- asking Codex itself ---------------------------------------------------------------
|
|
182
222
|
|
|
183
223
|
|
|
@@ -290,5 +330,6 @@ def trust(binary: str, state: HookState) -> HookState:
|
|
|
290
330
|
if after.status != "trusted":
|
|
291
331
|
raise CodexHookError(f"Codex still reports the hook as {after.status}")
|
|
292
332
|
with permissions.editing() as store:
|
|
293
|
-
store.codex_hook = {"command": after.command, "key": after.key, "hash": after.hash
|
|
333
|
+
store.codex_hook = {"command": after.command, "key": after.key, "hash": after.hash,
|
|
334
|
+
"definition": config_flags(after.command)[1]}
|
|
294
335
|
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
|
}
|
|
@@ -107,7 +107,8 @@ CREATE TABLE IF NOT EXISTS autopilot (
|
|
|
107
107
|
audit_history TEXT, -- persistent audit notes and prior accepted decisions
|
|
108
108
|
question TEXT, -- the supervisor's open need_user question (held while note matches)
|
|
109
109
|
nudged_time REAL, -- when the last nudge was sent
|
|
110
|
-
proposal TEXT
|
|
110
|
+
proposal TEXT, -- JSON: checks awaiting the user's answer in brindle's dialog
|
|
111
|
+
external_wait TEXT -- JSON {pid, until, path, note}: an outside job
|
|
111
112
|
);
|
|
112
113
|
CREATE TABLE IF NOT EXISTS milestones (
|
|
113
114
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -607,6 +608,7 @@ class Autopilot:
|
|
|
607
608
|
question: str | None = None # the open need_user question the user hasn't answered
|
|
608
609
|
nudged_time: float | None = None # when the last nudge was sent; never cleared, the backoff ignores it once progress moves past nudged_at
|
|
609
610
|
proposal: str | None = None # JSON {checks, rules, at}: checks awaiting the user's answer
|
|
611
|
+
external_wait: str | None = None # JSON {pid, until, path, note}: an outside job
|
|
610
612
|
|
|
611
613
|
|
|
612
614
|
@dataclass
|
|
@@ -849,7 +851,7 @@ class DB:
|
|
|
849
851
|
self.conn.execute("ALTER TABLE autopilot ADD COLUMN usage_paused_ids TEXT")
|
|
850
852
|
for col, kind in (("goal_sha", "TEXT"), ("auditor_id", "TEXT"), ("audit_sha", "TEXT"),
|
|
851
853
|
("audit_ok", "INTEGER"), ("audit_history", "TEXT"), ("question", "TEXT"),
|
|
852
|
-
("nudged_time", "REAL"), ("proposal", "TEXT")):
|
|
854
|
+
("nudged_time", "REAL"), ("proposal", "TEXT"), ("external_wait", "TEXT")):
|
|
853
855
|
if col not in cols:
|
|
854
856
|
self.conn.execute(f"ALTER TABLE autopilot ADD COLUMN {col} {kind}")
|
|
855
857
|
cols = {r["name"] for r in self.conn.execute("PRAGMA table_info(milestones)")}
|
|
@@ -1166,15 +1168,17 @@ class DB:
|
|
|
1166
1168
|
with self.tx() as c:
|
|
1167
1169
|
c.execute(f"UPDATE agents SET {cols} WHERE id=?", (*fields.values(), agent_id))
|
|
1168
1170
|
|
|
1169
|
-
def claim_idle(self, agent_id: str) ->
|
|
1171
|
+
def claim_idle(self, agent_id: str) -> float | None:
|
|
1170
1172
|
"""Atomically flip idle -> processing. Only the caller that wins may type
|
|
1171
|
-
into the agent's terminal, so two senders never interleave keystrokes.
|
|
1173
|
+
into the agent's terminal, so two senders never interleave keystrokes.
|
|
1174
|
+
Return this claim's status_since for rollback, or None if already busy."""
|
|
1172
1175
|
with self.tx() as c:
|
|
1176
|
+
claimed_at = time.time()
|
|
1173
1177
|
cur = c.execute(
|
|
1174
1178
|
"UPDATE agents SET status='processing', status_since=? WHERE id=? AND status='idle'",
|
|
1175
|
-
(
|
|
1179
|
+
(claimed_at, agent_id),
|
|
1176
1180
|
)
|
|
1177
|
-
return cur.rowcount == 1
|
|
1181
|
+
return claimed_at if cur.rowcount == 1 else None
|
|
1178
1182
|
|
|
1179
1183
|
_STAMP = "status_since = CASE WHEN status = ? THEN status_since ELSE ? END"
|
|
1180
1184
|
|
|
@@ -1250,6 +1254,19 @@ class DB:
|
|
|
1250
1254
|
c.execute("UPDATE inbox SET delivered_at=? WHERE id=?", (time.time(), row["id"]))
|
|
1251
1255
|
return _load(Message, row)
|
|
1252
1256
|
|
|
1257
|
+
def retry_pending(self, message_id: int, agent_id: str, idle_since: float) -> None:
|
|
1258
|
+
"""Release the caller's failed delivery claim without creating a second message.
|
|
1259
|
+
|
|
1260
|
+
Restore idle only if this delivery still owns the processing status;
|
|
1261
|
+
a newer hook or pause must keep its status. Both releases are atomic.
|
|
1262
|
+
"""
|
|
1263
|
+
with self.tx() as c:
|
|
1264
|
+
c.execute("UPDATE inbox SET delivered_at=NULL WHERE id=? AND agent_id=?",
|
|
1265
|
+
(message_id, agent_id))
|
|
1266
|
+
c.execute("UPDATE agents SET status='idle', status_since=? "
|
|
1267
|
+
"WHERE id=? AND status='processing' AND status_since=?",
|
|
1268
|
+
(time.time(), agent_id, idle_since))
|
|
1269
|
+
|
|
1253
1270
|
def drop_pending(self, agent_id: str, sender_id: str) -> int:
|
|
1254
1271
|
with self.tx() as c:
|
|
1255
1272
|
cur = c.execute(
|
|
@@ -1432,6 +1449,23 @@ class DB:
|
|
|
1432
1449
|
with self.tx() as c:
|
|
1433
1450
|
c.execute(f"UPDATE autopilot SET {cols} WHERE root_id=?", (*fields.values(), root_id))
|
|
1434
1451
|
|
|
1452
|
+
def set_external_wait(self, root_id: str, wait: str) -> None:
|
|
1453
|
+
"""A repeated declaration must leave the existing wait intact."""
|
|
1454
|
+
with self.tx() as c:
|
|
1455
|
+
c.execute("UPDATE autopilot SET external_wait=? WHERE root_id=? AND external_wait IS NULL",
|
|
1456
|
+
(wait, root_id))
|
|
1457
|
+
|
|
1458
|
+
def finish_external_wait(self, root_id: str, wait: str, now: float, message: str) -> bool:
|
|
1459
|
+
"""Atomically replace a wait with its durable completion notice."""
|
|
1460
|
+
with self.tx() as c:
|
|
1461
|
+
changed = c.execute(
|
|
1462
|
+
"UPDATE autopilot SET external_wait=NULL, nudges=0, nudged_at=progress, nudged_time=? "
|
|
1463
|
+
"WHERE root_id=? AND external_wait=?", (now, root_id, wait)).rowcount
|
|
1464
|
+
if changed:
|
|
1465
|
+
c.execute("INSERT INTO inbox (agent_id, sender_id, body, created_at) VALUES (?,NULL,?,?)",
|
|
1466
|
+
(root_id, message, now))
|
|
1467
|
+
return bool(changed)
|
|
1468
|
+
|
|
1435
1469
|
def bump_progress(self, root_id: str) -> None:
|
|
1436
1470
|
with self.tx() as c:
|
|
1437
1471
|
c.execute("UPDATE autopilot SET progress=progress+1, nudges=0 WHERE root_id=?", (root_id,))
|