brindle 0.3.22__tar.gz → 0.3.24__tar.gz

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