brindle 0.3.22__tar.gz → 0.3.23__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.23}/PKG-INFO +10 -3
  2. {brindle-0.3.22 → brindle-0.3.23}/README.md +9 -2
  3. {brindle-0.3.22 → brindle-0.3.23}/pyproject.toml +1 -1
  4. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/agents.py +64 -42
  5. brindle-0.3.23/src/brindle/assets/braid_dark.png +0 -0
  6. brindle-0.3.23/src/brindle/assets/braid_light.png +0 -0
  7. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/autopilot.py +111 -4
  8. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/developer-local.md +1 -0
  9. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/reviewer-local.md +1 -0
  10. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/config.py +56 -8
  11. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/cull.py +48 -20
  12. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/db.py +7 -2
  13. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/gates.py +3 -1
  14. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/git.py +23 -0
  15. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/mcp_server.py +43 -1
  16. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/native/client.py +22 -2
  17. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/native/runner.py +44 -2
  18. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/permissions.py +16 -1
  19. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pipeline.py +66 -4
  20. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pool.py +26 -14
  21. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/status.py +18 -3
  22. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/procs.py +18 -0
  23. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/profiles.py +42 -2
  24. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/providers.py +21 -3
  25. brindle-0.3.23/src/brindle/sidebar_logo.py +240 -0
  26. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/tmux.py +19 -2
  27. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/view.py +7 -4
  28. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/watch.py +95 -64
  29. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/workspaces.py +14 -3
  30. {brindle-0.3.22 → brindle-0.3.23}/.gitignore +0 -0
  31. {brindle-0.3.22 → brindle-0.3.23}/LICENSE +0 -0
  32. {brindle-0.3.22 → brindle-0.3.23}/SCHEDULE-A +0 -0
  33. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/__init__.py +0 -0
  34. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/__main__.py +0 -0
  35. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/account.py +0 -0
  36. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/airgap.py +0 -0
  37. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/antigravity.py +0 -0
  38. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/budget.py +0 -0
  39. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/__init__.py +0 -0
  40. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  41. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/developer-codex.md +0 -0
  42. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/developer-fable.md +0 -0
  43. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/developer-gemini.md +0 -0
  44. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  45. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/developer-light.md +0 -0
  46. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/developer.md +0 -0
  47. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/qa.md +0 -0
  48. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
  49. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
  50. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/reviewer.md +0 -0
  51. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/subagent.md +0 -0
  52. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_agents/supervisor.md +0 -0
  53. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_rules/__init__.py +0 -0
  54. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_rules/security/backend.md +0 -0
  55. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  56. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/builtin_rules/tests/only.md +0 -0
  57. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/ci_adapters.py +0 -0
  58. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/ci_client.py +0 -0
  59. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/ci_federation.py +0 -0
  60. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/ci_hosts.py +0 -0
  61. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/cli.py +0 -0
  62. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/cloud_credential.py +0 -0
  63. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/cloud_login.py +0 -0
  64. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/codemap.py +0 -0
  65. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/codemap_docs.py +0 -0
  66. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/codex_hook.py +0 -0
  67. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/company_identity.py +0 -0
  68. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/company_login.py +0 -0
  69. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/conflicts.py +0 -0
  70. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/cost.py +0 -0
  71. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/cost_estimate.py +0 -0
  72. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/demo.py +0 -0
  73. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/detect.py +0 -0
  74. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/doctor.py +0 -0
  75. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/escapes.py +0 -0
  76. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/events.py +0 -0
  77. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/guardrails.py +0 -0
  78. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/history.py +0 -0
  79. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/identity_lock.py +0 -0
  80. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/inbox.py +0 -0
  81. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/key_check.py +0 -0
  82. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/keys_cmds.py +0 -0
  83. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/keystore.py +0 -0
  84. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/launch_health.py +0 -0
  85. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/learned_rules.py +0 -0
  86. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/learning.py +0 -0
  87. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/legacy.py +0 -0
  88. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/local_setup.py +0 -0
  89. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/login_pause.py +0 -0
  90. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/model_access.py +0 -0
  91. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/model_availability.py +0 -0
  92. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/native/__init__.py +0 -0
  93. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/native/loop.py +0 -0
  94. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/native/permissions.py +0 -0
  95. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/native/serve.py +0 -0
  96. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/native/tools.py +0 -0
  97. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pane_auth.py +0 -0
  98. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/picker.py +0 -0
  99. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/plugins.py +0 -0
  100. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/policy.py +0 -0
  101. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pricing.py +0 -0
  102. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/__init__.py +0 -0
  103. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/_files.py +0 -0
  104. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/account.py +0 -0
  105. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/audit_chain.py +0 -0
  106. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/audit_export.py +0 -0
  107. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/auth.py +0 -0
  108. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/brief_learning.py +0 -0
  109. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/cost_centers.py +0 -0
  110. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/credentials.py +0 -0
  111. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/features.py +0 -0
  112. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/keys.py +0 -0
  113. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/learning.py +0 -0
  114. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/license.py +0 -0
  115. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/loopback.py +0 -0
  116. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/managed_models.py +0 -0
  117. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/org_profiles.py +0 -0
  118. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/orgkey.py +0 -0
  119. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/rollout.py +0 -0
  120. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/route_health.py +0 -0
  121. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/settings_sync.py +0 -0
  122. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/team_events.py +0 -0
  123. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/pro/team_policy.py +0 -0
  124. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/prompt_answer.py +0 -0
  125. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/quota.py +0 -0
  126. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/repo_cmds.py +0 -0
  127. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/repos.py +0 -0
  128. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/rewind.py +0 -0
  129. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/rule_checks.py +0 -0
  130. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/savings.py +0 -0
  131. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/scratch.py +0 -0
  132. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/secrets.py +0 -0
  133. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/self_serve.py +0 -0
  134. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/services.py +0 -0
  135. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/sessions.py +0 -0
  136. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/sidebar_follow.py +0 -0
  137. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/signin_pause.py +0 -0
  138. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/status_cache.py +0 -0
  139. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/tasks.py +0 -0
  140. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/updates.py +0 -0
  141. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/upgrade.py +0 -0
  142. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/usage.py +0 -0
  143. {brindle-0.3.22 → brindle-0.3.23}/src/brindle/watch_costs.py +0 -0
  144. {brindle-0.3.22 → brindle-0.3.23}/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.23
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.23`). 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.23`). 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.23"
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.
@@ -1462,7 +1465,9 @@ def one_line(db: DB, message_id: int, body: str, sender_id: str | None) -> str:
1462
1465
  who = f"{sender.profile} {sender_id}" if sender else sender_id
1463
1466
  else:
1464
1467
  who = "brindle"
1465
- head = first_line(body, 300)
1468
+ # A stuck-worker notice (cull.note_stuck) carries the prompt it waits on in
1469
+ # its first line, which has to arrive whole.
1470
+ head = first_line(body, 1500 if "has been waiting on a prompt for" in body[:200] else 300)
1466
1471
  if not sender_id:
1467
1472
  head = head.removeprefix("[brindle] ") # the sender tag already says it
1468
1473
  return (f"[{who}] {head} "
@@ -1982,28 +1987,30 @@ def submit_review(db: DB, caller_id: str, approved: bool, summary: str) -> str:
1982
1987
  close_later(caller.id)
1983
1988
  return "Audit recorded and sent to the supervisor. You're done."
1984
1989
  # 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."
1990
+ # whatever the verdict set off is done.
1988
1991
  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."
1992
+ return "Your review was already recorded. You're done."
1993
+ # Recorded without the branch's merge lock: a merge holds it while the
1994
+ # repo's checks run, for minutes, and a verdict waiting on it left the
1995
+ # reviewer's call hanging until its CLI gave up on it, never recorded.
1996
+ # Acting on the verdict (finish_review) takes the lock, apart from the
1997
+ # reviewer, and finds whatever a merge in progress did: a second
1998
+ # reviewer's verdict for a branch that merged meanwhile is dropped there.
1999
+ gone = "The branch is already merged or removed. You're done."
2000
+ if db.get_workspace(ws.id) is None or not os.path.isdir(ws.path):
2001
+ close_later(caller_id)
2002
+ return gone
2003
+ try:
2000
2004
  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))
2005
+ except git.GitError: # removed just now
2006
+ close_later(caller_id)
2007
+ return gone
2008
+ db.add_review(ws.id, sha, caller.id, approved, summary)
2009
+ if approved:
2010
+ db.bump_progress(autopilot.root_of(db, caller.id))
2011
+ pipeline.note_review(db, ws, approved, reviewer=caller)
2012
+ verdict = "APPROVED" if approved else "CHANGES REQUESTED"
2013
+ db.set_result(caller.id, _review_text(ws, sha, approved, summary))
2007
2014
  finish_review_later(caller.id, ws.id)
2008
2015
  return f"Review recorded ({verdict}); brindle takes it from here. You're done."
2009
2016
 
@@ -2273,14 +2280,12 @@ def default_review_profile(cfg: RepoConfig, worker: Agent | None, db: DB | None
2273
2280
  def _local_reviewer_available() -> bool:
2274
2281
  """Whether the built-in ``reviewer-local`` profile's model is served
2275
2282
  right now. Any failure counts as not available."""
2276
- try:
2277
- from brindle.native import runner
2283
+ from brindle.native import runner
2278
2284
 
2279
- endpoint = runner.endpoint_for(load_profile("reviewer-local"))
2280
- ok, detail = runner.probe(endpoint, timeout=1.0)
2285
+ try:
2286
+ return runner.served(load_profile("reviewer-local"))
2281
2287
  except Exception:
2282
2288
  return False
2283
- return bool(ok) and "is available" in detail
2284
2289
 
2285
2290
 
2286
2291
  def _review_kill_switch(ws: Workspace) -> None:
@@ -2397,30 +2402,45 @@ def expected_check_wait(db: DB, ws: Workspace, cfg: RepoConfig) -> float | None:
2397
2402
  return total if cfg.checks else None
2398
2403
 
2399
2404
 
2405
+ MOVED_RETRIES = 2 # check runs a branch that keeps moving under them gets again (review_after_checks)
2406
+
2407
+
2400
2408
  def review_after_checks(db: DB, caller_id: str | None, ws: Workspace, profile: str | None = None,
2401
2409
  focus: str | None = None, cfg: RepoConfig | None = None) -> Agent | None:
2402
2410
  """Run ``cfg.checks`` for ``ws`` (through ``gates.check_summary``: its
2403
2411
  lock and the by-sha cache, so a result a worker's report already warmed
2404
2412
  is instant), then start the reviewer with the results in its prompt.
2405
2413
  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
2414
+ started it. Gives up quietly when the workspace is gone, a reviewer is
2415
+ already at work on this commit, or the branch moves on to a commit a new
2416
+ report hands to a run of its own. A piped branch that moves on without
2417
+ a new report (its worker committed again, or merged its moved base in)
2418
+ is checked again on its new head, up to ``MOVED_RETRIES`` times, and then
2419
+ goes to the supervisor. Raises ``AgentError`` when the reviewer can't
2409
2420
  start."""
2410
- from brindle import gates
2421
+ from brindle import gates, pipeline
2411
2422
  from brindle.config import load_repo_config
2412
2423
 
2413
2424
  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:
2425
+ since = pipeline.review_claim(db, ws)
2426
+ for _attempt in range(MOVED_RETRIES + 1):
2427
+ sha = gates.head(ws)
2428
+ if _live_reviewer(db, ws, sha, profile):
2429
+ return None
2430
+ try:
2431
+ summary = gates.check_summary(db, ws, cfg, cancel=lambda sha=sha: gates.head(ws) != sha)
2432
+ except gates.Abandoned:
2433
+ summary = None
2434
+ except Exception as e: # noqa: BLE001 - the review goes ahead, saying so
2435
+ summary = f"(running the checks crashed: {e})"
2436
+ if db.get_workspace(ws.id) is None:
2437
+ return None
2438
+ if gates.head(ws) == sha:
2439
+ break
2440
+ if not pipeline.still_claimed(db, ws, since):
2441
+ return None
2442
+ else:
2443
+ pipeline.on_moved_during_checks(db, ws)
2424
2444
  return None
2425
2445
  caller = db.get_agent(caller_id) if caller_id else None
2426
2446
  text = f"Checks for {ws.branch} at {sha[:8]}:\n\n{summary}" if summary else None
@@ -3322,7 +3342,9 @@ def tell_parent_unreported(db: DB, agent: Agent) -> bool:
3322
3342
  if agent.mode == "review" and ws is not None and os.path.isdir(ws.path):
3323
3343
  from brindle import gates
3324
3344
 
3325
- if db.latest_review(ws.id, gates.head(ws)):
3345
+ # Its own verdict only: another reviewer's (a second review's) says
3346
+ # nothing about whether this one's call got through.
3347
+ if any(r.reviewer_id == agent.id for r in db.reviews_at(ws.id, gates.head(ws))):
3326
3348
  return False # its verdict for the current commit is already recorded
3327
3349
  if agent.unreported_noted:
3328
3350
  return False # the supervisor was told about this stop; cleared once it resumes
@@ -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,6 +1818,8 @@ 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)
@@ -1856,12 +1859,16 @@ def on_stop(db: DB, agent: Agent, payload: dict) -> dict | None:
1856
1859
  # One reminder per step: never counts toward stalling.
1857
1860
  db.update_autopilot(agent.id, nudges=0, nudged_at=ap.progress)
1858
1861
  return {"decision": "block", "reason": nudge(db, ap, cfg, working, stalled)}
1862
+ now = time.time()
1863
+ if (ap.nudged_at == ap.progress and ap.nudged_time is not None
1864
+ and now - ap.nudged_time < NUDGE_BACKOFF):
1865
+ return None # (#177) already reminded since the last step: it's legitimately waiting
1859
1866
  nudges = ap.nudges if ap.nudged_at == ap.progress else 0
1860
1867
  if nudges >= MAX_NUDGES:
1861
1868
  db.update_autopilot(agent.id, state="stalled", nudges=0,
1862
1869
  note=f"no progress after {MAX_NUDGES} reminders to keep going")
1863
1870
  return None
1864
- db.update_autopilot(agent.id, nudges=nudges + 1, nudged_at=ap.progress)
1871
+ db.update_autopilot(agent.id, nudges=nudges + 1, nudged_at=ap.progress, nudged_time=now)
1865
1872
  return {"decision": "block", "reason": nudge(db, ap, cfg, working, stalled)}
1866
1873
 
1867
1874
 
@@ -1911,12 +1918,112 @@ def nudge(db: DB, ap: Autopilot, cfg: RepoConfig,
1911
1918
 
1912
1919
 
1913
1920
  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.
1921
+ branch before it merges. Before the first `assign`, call `add_checks` with
1922
+ the check command you propose{suggestion}. brindle itself asks the user to
1923
+ approve it (and to allow its commands in .claude/settings.local.json) and
1924
+ writes what they accept; don't ask them yourself. Then commit
1925
+ .brindle/config.json.
1917
1926
  """
1918
1927
 
1919
1928
 
1929
+ PROPOSAL_TTL = 30 * 60 # seconds a check proposal may hold autopilot (see proposing)
1930
+
1931
+
1932
+ SAFE_ALLOW = re.compile(r"[A-Za-z0-9_./ =,@+-]+") # no wildcard, quote, $, &, redirect or newline
1933
+ # Programs whose only job is checking code. Exact, case-sensitive matches: anything
1934
+ # else (wrappers, shells, package runners, interpreters) is refused.
1935
+ CHECK_PROGRAMS = {"pytest", "ruff", "mypy", "shellcheck", "eslint", "tsc",
1936
+ "flake8", "pyright", "golangci-lint"}
1937
+ # Toolchains run code through many subcommands (go run, cargo install, ...): only these.
1938
+ CHECK_SUBCOMMANDS = {"go": {"test", "vet"}, "cargo": {"test", "check", "clippy", "fmt"}}
1939
+ # Flags allowed on those subcommands; any other flag can name a program to run (-exec, -toolexec, --config ...).
1940
+ HARMLESS_FLAGS = {"-v", "-q", "-race", "--check", "--all-targets", "--workspace"}
1941
+ CHECK_MODULES = {"pytest", "mypy", "ruff", "unittest"} # the only `python -m` targets allowed
1942
+
1943
+
1944
+ def _runs_arbitrary(c: str) -> bool:
1945
+ """True unless the command runs a known check program. ``NAME=VALUE`` prefixes
1946
+ and paths are refused; ``uv run`` is looked through to the program it runs."""
1947
+ argv = c.split()
1948
+ if argv[:1] == ["uv"] and argv[1:2] == ["run"]:
1949
+ argv = argv[2:]
1950
+ if not argv or "=" in argv[0] or argv[0] not in {*CHECK_PROGRAMS, *CHECK_SUBCOMMANDS, "python", "python3"}:
1951
+ return True
1952
+ if argv[0] in CHECK_SUBCOMMANDS:
1953
+ return (argv[1:2] == [] or argv[1] not in CHECK_SUBCOMMANDS[argv[0]]
1954
+ or any(a.startswith("-") and a not in HARMLESS_FLAGS for a in argv[2:]))
1955
+ if argv[0] in CHECK_PROGRAMS:
1956
+ return False
1957
+ return argv[1:2] != ["-m"] or argv[2:3] == [] or argv[2] not in CHECK_MODULES
1958
+
1959
+
1960
+ def allow_rules(checks: list[str]) -> tuple[list[str], str | None]:
1961
+ """One narrow Bash allow rule per command in ``checks``, or none and why."""
1962
+ commands = [c.strip() for check in checks for c in re.split(r"&&|\|\||;|\|", check)]
1963
+ unsafe = [c for c in commands if c and (
1964
+ not SAFE_ALLOW.fullmatch(c) or _runs_arbitrary(c))]
1965
+ if unsafe:
1966
+ return [], (f"{unsafe[0]!r} has a wildcard, substitution, redirect or a binary "
1967
+ "that runs arbitrary code")
1968
+ return list(dict.fromkeys(f"Bash({c})" for c in commands if c)), None
1969
+
1970
+
1971
+ def propose_checks(db: DB, root_id: str, checks: list[str]) -> tuple[str, list[str]]:
1972
+ """Record ``checks`` as awaiting the user's answer in brindle's dialog (no
1973
+ nudge meanwhile, see on_stop). Returns the dialog's text and the allow
1974
+ rules it offers."""
1975
+ rules, refusal = allow_rules(checks)
1976
+ db.update_autopilot(root_id, proposal=json.dumps(
1977
+ {"checks": checks, "rules": rules, "at": time.time()}))
1978
+ text = ("brindle: run these checks on every branch before it merges "
1979
+ "(saved to .brindle/config.json)?\n" + "\n".join(f" {c}" for c in checks))
1980
+ if rules:
1981
+ text += ("\n\nAlso allow their commands without a permission prompt "
1982
+ "(.claude/settings.local.json):\n" + "\n".join(f" {r}" for r in rules))
1983
+ else:
1984
+ text += f"\n\nbrindle won't offer to allow their commands: {refusal}."
1985
+ return text, rules
1986
+
1987
+
1988
+ def proposing(ap: Autopilot) -> bool:
1989
+ """A check proposal is waiting on the user. One older than PROPOSAL_TTL
1990
+ is a dialog whose server died: it no longer holds autopilot."""
1991
+ try:
1992
+ at = float(json.loads(ap.proposal or "{}").get("at") or 0)
1993
+ except (ValueError, TypeError, AttributeError):
1994
+ return False
1995
+ return time.time() - at < PROPOSAL_TTL
1996
+
1997
+
1998
+ def add_checks(repo_root: str, checks: list[str], allow: bool = False) -> str:
1999
+ """Add approved ``checks`` to .brindle/config.json and, with ``allow``, one
2000
+ narrow Bash allow rule per command in them to .claude/settings.local.json."""
2001
+ from brindle.config import CONFIG_FILE, _read_json
2002
+
2003
+ root = Path(config_root(repo_root))
2004
+ path = root / CONFIG_DIR / CONFIG_FILE
2005
+ data = _read_json(path)
2006
+ data["checks"] = [*(c for c in data.get("checks") or [] if c not in checks), *checks]
2007
+ path.parent.mkdir(parents=True, exist_ok=True)
2008
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
2009
+ text = f"Added {len(checks)} check(s) to {path}."
2010
+ if not allow:
2011
+ return text
2012
+ rules, refusal = allow_rules(checks)
2013
+ if refusal:
2014
+ return f"{text} Did not allow any commands: {refusal}. The user can add rules themselves."
2015
+ settings_path = root / ".claude" / "settings.local.json"
2016
+ settings = _read_json(settings_path)
2017
+ perms = settings.setdefault("permissions", {})
2018
+ allowed = perms.setdefault("allow", [])
2019
+ for rule in rules:
2020
+ if rule not in allowed:
2021
+ allowed.append(rule)
2022
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
2023
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
2024
+ return f"{text} Allowed their commands in {settings_path}."
2025
+
2026
+
1920
2027
  def guide(cfg: RepoConfig, repo_root: str | None = None) -> str:
1921
2028
  text = GUIDE.format(max_agents=cfg.max_agents or "any number of")
1922
2029
  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
  ---
@@ -30,8 +30,22 @@ def db_path() -> Path:
30
30
  return brindle_home() / "brindle.db"
31
31
 
32
32
 
33
+ WORKTREES_DIR = "worktrees"
34
+
35
+
33
36
  def worktrees_dir() -> Path:
34
- return brindle_home() / "worktrees"
37
+ """``~/.brindle/worktrees``, where worktrees went before they moved under
38
+ each repo (see ``worktree_root``). Existing ones there keep working."""
39
+ return brindle_home() / WORKTREES_DIR
40
+
41
+
42
+ def worktree_root(repo_root: str | Path, cfg: RepoConfig | None = None) -> Path:
43
+ """Where ``repo_root``'s new worktrees go: ``<repo>/.brindle/worktrees``,
44
+ or the repo's ``worktree_root`` (``~`` expanded; relative to the repo)."""
45
+ configured = (cfg or load_repo_config(repo_root)).worktree_root
46
+ if not configured:
47
+ return Path(repo_root) / CONFIG_DIR / WORKTREES_DIR
48
+ return Path(repo_root) / os.path.expanduser(configured)
35
49
 
36
50
 
37
51
  def user_profiles_dir() -> Path:
@@ -80,6 +94,7 @@ class RepoConfig:
80
94
  copy: list[str] = field(default_factory=list)
81
95
  base_branch: str | None = None
82
96
  branch_prefix: str = ""
97
+ worktree_root: str | None = None # where new worktrees go (None: <repo>/.brindle/worktrees; see worktree_root())
83
98
  default_agent: str = "developer"
84
99
  fetch: bool = True
85
100
  auto_update: bool = False # upgrade the PyPI tool at session start
@@ -130,6 +145,9 @@ class RepoConfig:
130
145
  # already fast). 0 disables the pool.
131
146
  pool_size: int | None = None
132
147
  add_dirs: list[str] = field(default_factory=list)
148
+ # Directories outside the worktree that workers may read (not write), e.g. a
149
+ # gitignored results/ folder in the main checkout.
150
+ worker_extra_read_dirs: list[str] = field(default_factory=list)
133
151
  # Per-worktree Docker services (brindle Pro): [{"name", "preset"?, "image"?, "port"?, "env"?}]
134
152
  # -- see brindle.services.
135
153
  services: list[dict] = field(default_factory=list)
@@ -199,13 +217,22 @@ def config_root(path: str | Path) -> Path:
199
217
  path = Path(path)
200
218
  if (path / CONFIG_DIR).is_dir():
201
219
  return path
220
+ main = linked_main(path)
221
+ return main if main is not None and (main / CONFIG_DIR).is_dir() else path
222
+
223
+
224
+ def linked_main(path: str | Path) -> Path | None:
225
+ """The main checkout of the linked worktree at ``path``, or None when
226
+ ``path`` isn't one. A linked worktree's ``.git`` is a file saying
227
+ ``gitdir: <main>/.git/worktrees/<name>``, and that folder's ``commondir``
228
+ names ``<main>/.git`` (relative to it), so no git process runs."""
229
+ path = Path(path)
202
230
  try:
203
- gitdir = Path((path / ".git").read_text(encoding="utf-8").split("gitdir:", 1)[1].strip())
231
+ gitdir = path / (path / ".git").read_text(encoding="utf-8").split("gitdir:", 1)[1].strip()
204
232
  common = (gitdir / (gitdir / "commondir").read_text(encoding="utf-8").strip()).resolve()
205
- except (OSError, IndexError): # not a linked worktree: nothing to discover
206
- return path
207
- main = common.parent
208
- return main if common.name == ".git" and (main / CONFIG_DIR).is_dir() else path
233
+ except (OSError, IndexError): # no .git file, or not a worktree's gitdir
234
+ return None
235
+ return common.parent if common.name == ".git" else None
209
236
 
210
237
 
211
238
  def _default_permission_policy() -> str:
@@ -219,6 +246,25 @@ def _default_permission_policy() -> str:
219
246
  return "off"
220
247
 
221
248
 
249
+ CHECK_RAM_GB = 2 # RAM budgeted per concurrent check run
250
+ CHECK_CONCURRENCY_CAP = 6
251
+
252
+
253
+ def _memory_gb() -> float:
254
+ try:
255
+ return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / 2**30
256
+ except (ValueError, OSError):
257
+ return 0.0
258
+
259
+
260
+ def host_check_concurrency() -> int:
261
+ """Check runs at once when the repo doesn't say: half the logical cores and
262
+ RAM for ``CHECK_RAM_GB`` per run, never below 2 or above the cap (#169)."""
263
+ cores = os.cpu_count() or 1
264
+ by_ram = int(_memory_gb() // CHECK_RAM_GB)
265
+ return max(2, min(cores // 2, by_ram, CHECK_CONCURRENCY_CAP))
266
+
267
+
222
268
  def load_repo_config(repo_root: str | Path) -> RepoConfig:
223
269
  base = config_root(repo_root) / CONFIG_DIR
224
270
  shared = _read_json(base / CONFIG_FILE)
@@ -226,10 +272,10 @@ def load_repo_config(repo_root: str | Path) -> RepoConfig:
226
272
  user = user_settings()
227
273
 
228
274
  cfg = RepoConfig()
229
- for key in ("setup", "teardown", "copy", "checks", "add_dirs", "protected_paths"):
275
+ for key in ("setup", "teardown", "copy", "checks", "add_dirs", "worker_extra_read_dirs", "protected_paths"):
230
276
  merged = _merge_commands(list(shared.get(key, [])), local.get(key))
231
277
  setattr(cfg, key, merged)
232
- for key in ("base_branch", "branch_prefix", "default_agent", "fetch", "auto_update", "autopilot", "review",
278
+ for key in ("base_branch", "branch_prefix", "worktree_root", "default_agent", "fetch", "auto_update", "autopilot", "review",
233
279
  "reviewer", "review_profile", "pre_commit", "max_agents", "check_timeout",
234
280
  "check_concurrency",
235
281
  "usage_limit", "pool_size", "graphify", "stale_after", "pipeline",
@@ -248,6 +294,8 @@ def load_repo_config(repo_root: str | Path) -> RepoConfig:
248
294
  if isinstance(rule, str) and rule.strip() and rule.strip() not in rules:
249
295
  rules.append(rule.strip())
250
296
  cfg.rules = rules
297
+ if not any("check_concurrency" in source for source in (local, shared, user)):
298
+ cfg.check_concurrency = host_check_concurrency()
251
299
  if not any("permission_policy" in source for source in (local, shared, user)):
252
300
  cfg.permission_policy = _default_permission_policy()
253
301
  services = local["services"] if "services" in local else shared.get("services")