brindle 0.5.1__tar.gz → 0.5.2__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 (197) hide show
  1. {brindle-0.5.1 → brindle-0.5.2}/PKG-INFO +1 -1
  2. {brindle-0.5.1 → brindle-0.5.2}/pyproject.toml +1 -1
  3. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/agents.py +5 -5
  4. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/autopilot.py +4 -4
  5. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/check_scope.py +10 -6
  6. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/cli.py +18 -12
  7. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/codex_hook.py +20 -2
  8. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/company_login.py +3 -1
  9. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/config.py +77 -14
  10. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/daemon/api-schema.json +20 -0
  11. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/daemon/routes_actions.py +61 -14
  12. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/daemon/routes_read.py +65 -18
  13. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/db.py +5 -2
  14. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/gates.py +47 -6
  15. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/git.py +7 -2
  16. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/keystore.py +24 -1
  17. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/mcp_server.py +1 -1
  18. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pipeline.py +9 -9
  19. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/policy.py +7 -4
  20. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/license.py +48 -0
  21. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/provider_homes.py +35 -0
  22. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/providers.py +25 -0
  23. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/providers_install.py +28 -0
  24. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/service/dto.py +3 -1
  25. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/service/routing.py +6 -3
  26. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/worker_models.py +30 -13
  27. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/workspaces.py +19 -10
  28. {brindle-0.5.1 → brindle-0.5.2}/.gitignore +0 -0
  29. {brindle-0.5.1 → brindle-0.5.2}/LICENSE +0 -0
  30. {brindle-0.5.1 → brindle-0.5.2}/README.md +0 -0
  31. {brindle-0.5.1 → brindle-0.5.2}/SCHEDULE-A +0 -0
  32. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/__init__.py +0 -0
  33. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/__main__.py +0 -0
  34. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/account.py +0 -0
  35. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/airgap.py +0 -0
  36. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/antigravity.py +0 -0
  37. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/assembler.py +0 -0
  38. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/assets/braid_dark.png +0 -0
  39. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/assets/braid_light.png +0 -0
  40. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/budget.py +0 -0
  41. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/__init__.py +0 -0
  42. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  43. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/coordinator.md +0 -0
  44. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/developer-codex.md +0 -0
  45. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/developer-fable.md +0 -0
  46. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/developer-gemini.md +0 -0
  47. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  48. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/developer-light.md +0 -0
  49. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/developer-local.md +0 -0
  50. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/developer-opencode.md +0 -0
  51. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/developer-sonnet.md +0 -0
  52. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/developer.md +0 -0
  53. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/qa.md +0 -0
  54. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
  55. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
  56. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/reviewer-local.md +0 -0
  57. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/reviewer-opencode.md +0 -0
  58. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/reviewer.md +0 -0
  59. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/subagent.md +0 -0
  60. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_agents/supervisor.md +0 -0
  61. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_rules/__init__.py +0 -0
  62. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_rules/security/backend.md +0 -0
  63. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  64. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/builtin_rules/tests/only.md +0 -0
  65. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/bus.py +0 -0
  66. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/ci_adapters.py +0 -0
  67. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/ci_client.py +0 -0
  68. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/ci_federation.py +0 -0
  69. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/ci_hosts.py +0 -0
  70. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/clone.py +0 -0
  71. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/cloud_credential.py +0 -0
  72. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/cloud_login.py +0 -0
  73. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/codemap.py +0 -0
  74. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/codemap_docs.py +0 -0
  75. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/company_identity.py +0 -0
  76. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/conflicts.py +0 -0
  77. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/coordinator.py +0 -0
  78. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/cost.py +0 -0
  79. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/cost_estimate.py +0 -0
  80. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/cull.py +0 -0
  81. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/daemon/__init__.py +0 -0
  82. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/daemon/app.py +0 -0
  83. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/daemon/auth.py +0 -0
  84. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/daemon/events.py +0 -0
  85. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/daemon/holds.py +0 -0
  86. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/daemon/lifecycle.py +0 -0
  87. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/daemon/peer.py +0 -0
  88. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/daemon/routes_org_permissions.py +0 -0
  89. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/daemon/schema.py +0 -0
  90. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/daemon/session_review.py +0 -0
  91. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/daemon/terminal.py +0 -0
  92. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/debuglog.py +0 -0
  93. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/demo.py +0 -0
  94. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/detect.py +0 -0
  95. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/doctor.py +0 -0
  96. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/entry.py +0 -0
  97. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/escapes.py +0 -0
  98. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/events.py +0 -0
  99. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/guardrails.py +0 -0
  100. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/history.py +0 -0
  101. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/identity_lock.py +0 -0
  102. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/inbox.py +0 -0
  103. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/kb.py +0 -0
  104. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/key_check.py +0 -0
  105. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/keys_cmds.py +0 -0
  106. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/knowledge.py +0 -0
  107. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/launch_health.py +0 -0
  108. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/learned_rules.py +0 -0
  109. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/learning.py +0 -0
  110. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/legacy.py +0 -0
  111. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/local_setup.py +0 -0
  112. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/login_pause.py +0 -0
  113. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/model_access.py +0 -0
  114. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/model_availability.py +0 -0
  115. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/native/__init__.py +0 -0
  116. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/native/catalog.py +0 -0
  117. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/native/client.py +0 -0
  118. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/native/loop.py +0 -0
  119. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/native/lsp.py +0 -0
  120. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/native/permissions.py +0 -0
  121. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/native/runner.py +0 -0
  122. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/native/serve.py +0 -0
  123. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/native/tools.py +0 -0
  124. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/opencode.py +0 -0
  125. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/org_permissions.py +0 -0
  126. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pane_auth.py +0 -0
  127. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/permission_asks.py +0 -0
  128. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/permission_grants.py +0 -0
  129. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/permission_groups.py +0 -0
  130. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/permission_routing.py +0 -0
  131. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/permissions.py +0 -0
  132. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/picker.py +0 -0
  133. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/plugins.py +0 -0
  134. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pool.py +0 -0
  135. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/preflight.py +0 -0
  136. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pricing.py +0 -0
  137. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/__init__.py +0 -0
  138. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/_files.py +0 -0
  139. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/account.py +0 -0
  140. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/audit_chain.py +0 -0
  141. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/audit_export.py +0 -0
  142. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/auth.py +0 -0
  143. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/brief_learning.py +0 -0
  144. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/cost_centers.py +0 -0
  145. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/credentials.py +0 -0
  146. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/features.py +0 -0
  147. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/keys.py +0 -0
  148. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/learning.py +0 -0
  149. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/loopback.py +0 -0
  150. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/managed_models.py +0 -0
  151. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/org_profiles.py +0 -0
  152. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/orgkey.py +0 -0
  153. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/rollout.py +0 -0
  154. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/route_health.py +0 -0
  155. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/settings_sync.py +0 -0
  156. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/status.py +0 -0
  157. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/team_events.py +0 -0
  158. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/pro/team_policy.py +0 -0
  159. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/procs.py +0 -0
  160. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/profiles.py +0 -0
  161. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/prompt_answer.py +0 -0
  162. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/quota.py +0 -0
  163. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/repo_cmds.py +0 -0
  164. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/repos.py +0 -0
  165. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/rewind.py +0 -0
  166. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/rule_checks.py +0 -0
  167. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/savings.py +0 -0
  168. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/scratch.py +0 -0
  169. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/secrets.py +0 -0
  170. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/self_serve.py +0 -0
  171. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/service/__init__.py +0 -0
  172. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/service/errors.py +0 -0
  173. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/service/inbox.py +0 -0
  174. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/service/permissions.py +0 -0
  175. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/service/sessions.py +0 -0
  176. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/services.py +0 -0
  177. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/sessions.py +0 -0
  178. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/sidebar_follow.py +0 -0
  179. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/sidebar_logo.py +0 -0
  180. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/signin_pause.py +0 -0
  181. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/status_cache.py +0 -0
  182. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/tasks.py +0 -0
  183. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/tmux.py +0 -0
  184. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/updates.py +0 -0
  185. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/upgrade.py +0 -0
  186. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/usage.py +0 -0
  187. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/view.py +0 -0
  188. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/wake.py +0 -0
  189. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/watch.py +0 -0
  190. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/watch_costs.py +0 -0
  191. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/web/__init__.py +0 -0
  192. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/web/chat.py +0 -0
  193. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/web/dashboard.py +0 -0
  194. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/web/diff.py +0 -0
  195. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/web/server.py +0 -0
  196. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/web/static/app.js +0 -0
  197. {brindle-0.5.1 → brindle-0.5.2}/src/brindle/web/static/index.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brindle
3
- Version: 0.5.1
3
+ Version: 0.5.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brindle"
3
- version = "0.5.1"
3
+ version = "0.5.2"
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"
@@ -180,7 +180,7 @@ def worker_guidance(ws: Workspace, report_only: bool = False) -> str:
180
180
  from brindle.config import load_repo_config
181
181
 
182
182
  try:
183
- checks = load_repo_config(ws.repo_root).checks
183
+ checks = load_repo_config(ws.repo_root, branch=ws.base_branch).checks
184
184
  except ValueError:
185
185
  checks = []
186
186
  parts = (codemap.guidance(ws), "" if report_only else test_guidance(checks),
@@ -2318,7 +2318,7 @@ def warm_checks(ws: Workspace) -> None:
2318
2318
  from brindle.providers import brindle_invocation
2319
2319
 
2320
2320
  try:
2321
- if not load_repo_config(ws.repo_root).checks or git.dirty_files(ws.path):
2321
+ if not load_repo_config(ws.repo_root, branch=ws.base_branch).checks or git.dirty_files(ws.path):
2322
2322
  return
2323
2323
  except (ValueError, git.GitError):
2324
2324
  return
@@ -2906,7 +2906,7 @@ def start_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | Non
2906
2906
  from brindle.providers import brindle_invocation
2907
2907
 
2908
2908
  _review_worktree_present(ws)
2909
- cfg = cfg or load_repo_config(ws.repo_root)
2909
+ cfg = cfg or workspaces.config_for(db, ws)
2910
2910
  if not cfg.checks:
2911
2911
  return request_review(db, caller, ws, profile, focus, cfg, second=second, slot=slot)
2912
2912
  _review_kill_switch(ws)
@@ -3026,7 +3026,7 @@ def review_after_checks(db: DB, caller_id: str | None, ws: Workspace, profile: s
3026
3026
  from brindle import gates, pipeline
3027
3027
  from brindle.config import load_repo_config
3028
3028
 
3029
- cfg = cfg or load_repo_config(ws.repo_root)
3029
+ cfg = cfg or workspaces.config_for(db, ws)
3030
3030
  slot = _review_claim_slot(profile, slot)
3031
3031
  profile = _review_profile(db, ws, profile, cfg)
3032
3032
  with _review_request_run(ws.id, profile, request_id), pipeline.review_run(ws.id):
@@ -3166,7 +3166,7 @@ def _request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str |
3166
3166
 
3167
3167
  _review_worktree_present(ws)
3168
3168
  _review_kill_switch(ws)
3169
- cfg = cfg or load_repo_config(ws.repo_root)
3169
+ cfg = cfg or workspaces.config_for(db, ws)
3170
3170
  slot = _review_claim_slot(profile, slot)
3171
3171
  profile = _review_profile(db, ws, profile, cfg)
3172
3172
  if second and not slot:
@@ -47,7 +47,7 @@ import time
47
47
  from dataclasses import dataclass
48
48
  from pathlib import Path
49
49
 
50
- from brindle.config import CONFIG_DIR, WEIGHTS, RepoConfig, config_root, load_repo_config
50
+ from brindle.config import CONFIG_DIR, WEIGHTS, RepoConfig, config_root, load_repo_config, workspace_config
51
51
  from brindle import bus, escapes, knowledge
52
52
  from brindle.db import DB, Agent, Autopilot, Milestone, Workspace, check_state
53
53
  from brindle.profiles import load_profile
@@ -1310,7 +1310,7 @@ def check_milestones(db: DB, root_id: str, ws: Workspace, position: int | None =
1310
1310
  ap = db.get_autopilot(root_id)
1311
1311
  if ap and ap.state == "done":
1312
1312
  return progress(db, root_id, in_flight=False)
1313
- cfg = cfg or load_repo_config(ws.repo_root)
1313
+ cfg = cfg or workspace_config(ws, session=True)
1314
1314
  ms = db.milestones(root_id)
1315
1315
  if not ms:
1316
1316
  raise AutopilotError("no milestones yet: record the goal with set_goal first")
@@ -1363,7 +1363,7 @@ def check_milestones(db: DB, root_id: str, ws: Workspace, position: int | None =
1363
1363
  # Cached by commit when the checkout is clean, so a check that
1364
1364
  # already passed at this HEAD (as a merge gate, say) isn't re-run.
1365
1365
  try:
1366
- ok, out = gates.run_checked(db, checkout, m.check_cmd, workspaces.workspace_env(checkout),
1366
+ ok, out = gates.run_checked(db, checkout, m.check_cmd, workspaces.workspace_env(checkout, repo_cfg),
1367
1367
  repo_cfg.check_timeout)
1368
1368
  except Exception as e:
1369
1369
  db.record_check(m.id, False, f"$ {m.check_cmd}\n(crashed: {e})", sha,
@@ -1569,7 +1569,7 @@ def start_audit(db: DB, root_id: str, ws: Workspace, cfg: RepoConfig | None = No
1569
1569
  if ap.audit_sha == head:
1570
1570
  return None
1571
1571
  agents.close(db, running.id)
1572
- cfg = cfg or load_repo_config(ws.repo_root)
1572
+ cfg = cfg or workspace_config(ws, session=True)
1573
1573
  profile = agents.default_review_profile(cfg, db.get_agent(root_id), db, ws.repo_root, ap.goal)
1574
1574
  auditor = agents.spawn(db, ws, profile, prompt=audit_prompt(db, ap, head), parent_id=root_id,
1575
1575
  mode="review", background_setup=True)
@@ -14,7 +14,9 @@ left its base decide what runs:
14
14
  - one matches ``INFER_FULL_WHEN`` (conftest, pytest and project config, a
15
15
  lockfile) or is under ``.brindle/``, or the scope can't be worked out: the
16
16
  command runs in full;
17
- - otherwise the command runs on the test files the branch touched plus the
17
+ - otherwise changed files that aren't ``.py`` are ignored (pytest can't be
18
+ affected by them), and if none is left the check is skipped; the command
19
+ runs on the test files the branch touched plus the
18
20
  tests ``graphify affected`` reaches from each changed symbol. A changed file
19
21
  the graph can't map to a test runs the full suite instead.
20
22
 
@@ -61,10 +63,9 @@ _VALUE_OPTIONS = {"-n", "-m", "-k", "-p", "-o", "-c", "--maxfail", "--tb", "--du
61
63
  "--deselect", "--dist", "--cov", "--timeout", "--junitxml", "-W", "--basetemp"}
62
64
  INFER_FULL_WHEN = ["**/conftest.py", "**/pyproject.toml", "**/setup.cfg", "**/pytest.ini", "**/tox.ini",
63
65
  "**/*.lock", "**/*lock.json", "**/requirements*.txt"]
64
- NOTICE = ("brindle can now run only the tests a branch's changes reach for a plain pytest check (`{cmd}`), "
65
- "using the code graph; it will be the default next release. Try it now with "
66
- '`"scoped_checks": "infer"` in .brindle/config.json (or ~/.brindle/config.json), or keep the '
67
- 'current behaviour with `"scoped_checks": "off"`.')
66
+ NOTICE = ("brindle now runs only the tests a worker branch's changes reach for a plain pytest check (`{cmd}`), "
67
+ "using the code graph. To always run it in full, set "
68
+ '`"scoped_checks": "off"` in .brindle/config.json (or ~/.brindle/config.json).')
68
69
 
69
70
 
70
71
  def pytest_selectors(cmd: str) -> bool | None:
@@ -99,7 +100,7 @@ def inferred(cmd: str, mode: str | None) -> CheckScope | None:
99
100
  """The scope ``scoped_checks: "infer"`` gives ``cmd``: pytest on the
100
101
  affected tests when it is a plain pytest command that picks no tests
101
102
  itself (appending targets would not narrow it), else None."""
102
- if mode == "infer" and pytest_selectors(cmd) is False:
103
+ if mode in (None, "infer") and pytest_selectors(cmd) is False:
103
104
  return CheckScope(INFER_FULL_WHEN)
104
105
  return None
105
106
 
@@ -176,6 +177,9 @@ def command(ws: Workspace, cmd: str, scope: CheckScope | None, diff: tuple[str,
176
177
  return None
177
178
  if any(matches(f, ALWAYS_FULL) or matches(f, scope.full_when) for f in changed):
178
179
  return cmd
180
+ changed = [f for f in changed if f.endswith(".py")] # pytest can't be affected by the rest
181
+ if not changed:
182
+ return None
179
183
  tests = pytest_targets(ws, base, changed)
180
184
  return f"{cmd} {_quoted(tests)} || [ $? -eq 5 ]" if tests else cmd # 5: none of them selected
181
185
 
@@ -2333,11 +2333,10 @@ def open_cmd(
2333
2333
  @app.command()
2334
2334
  def setup(workspace: Optional[str] = typer.Argument(None)) -> None:
2335
2335
  """Re-run setup commands in a workspace."""
2336
- from brindle.config import load_repo_config
2337
-
2338
- ws = _ws(DB(), workspace)
2339
- cmds = load_repo_config(ws.repo_root).setup
2340
- res = workspaces.run_commands(cmds, ws.path, workspaces.workspace_env(ws))
2336
+ db = DB()
2337
+ ws = _ws(db, workspace)
2338
+ cfg = workspaces.config_for(db, ws)
2339
+ res = workspaces.run_commands(cfg.setup, ws.path, workspaces.workspace_env(ws, cfg))
2341
2340
  typer.echo(res.log or "(no setup commands)")
2342
2341
  raise typer.Exit(0 if res.ok else 1)
2343
2342
 
@@ -2407,8 +2406,15 @@ def pr(
2407
2406
  draft: bool = typer.Option(False, "--draft"),
2408
2407
  ) -> None:
2409
2408
  """Push and open a pull request against the base branch."""
2410
- ws = _ws(DB(), workspace)
2411
- url = _run(workspaces.pull_request, ws, title, draft)
2409
+ from brindle import gates
2410
+
2411
+ db = DB()
2412
+ ws = _ws(db, workspace)
2413
+ def open_checked() -> str:
2414
+ with gates.pr_checked(db, ws, lambda text: typer.echo(text, err=True)) as sha:
2415
+ return workspaces.pull_request(ws, title, draft, sha=sha)
2416
+
2417
+ url = _run(open_checked)
2412
2418
  typer.echo(url)
2413
2419
  _offer_delete_on_merge(ws.repo_root)
2414
2420
 
@@ -2453,12 +2459,12 @@ def services_cmd(
2453
2459
  ) -> None:
2454
2460
  """Per-worktree Docker services (brindle Pro): list, start or stop a workspace's."""
2455
2461
  from brindle import services as services_mod
2456
- from brindle.config import load_repo_config
2457
2462
 
2458
2463
  if action not in ("ls", "up", "down"):
2459
2464
  _fail("action must be ls, up or down")
2460
- ws = _ws(DB(), workspace)
2461
- cfg = load_repo_config(ws.repo_root)
2465
+ db = DB()
2466
+ ws = _ws(db, workspace)
2467
+ cfg = workspaces.config_for(db, ws)
2462
2468
  if not cfg.services:
2463
2469
  _fail('no services configured; add a "services" list to .brindle/config.json')
2464
2470
  if action == "up":
@@ -3100,7 +3106,7 @@ def review_after_checks_cmd(workspace_id: str, caller: Optional[str] = typer.Opt
3100
3106
  if ws is None:
3101
3107
  return
3102
3108
  try:
3103
- agents.review_after_checks(db, caller, ws, profile, focus, load_repo_config(ws.repo_root),
3109
+ agents.review_after_checks(db, caller, ws, profile, focus, workspaces.config_for(db, ws),
3104
3110
  request_id=request_id, second=second, slot=slot)
3105
3111
  except (agents.AgentError, ValueError) as e:
3106
3112
  from brindle import pipeline
@@ -3170,7 +3176,7 @@ def warm_checks_cmd(workspace_id: str) -> None:
3170
3176
  try:
3171
3177
  # A new commit makes this run's result useless (the review and the
3172
3178
  # gate want the new head), so give up and free the check slot.
3173
- gates.check_summary(db, ws, load_repo_config(ws.repo_root),
3179
+ gates.check_summary(db, ws, workspaces.config_for(db, ws),
3174
3180
  cancel=lambda: gates.head(ws) != sha)
3175
3181
  except gates.Abandoned:
3176
3182
  pass
@@ -73,6 +73,20 @@ def _home_key(home: Path) -> str:
73
73
  return os.path.realpath(home)
74
74
 
75
75
 
76
+ def _runnable_script(script: str) -> bool:
77
+ if not os.access(script, os.X_OK):
78
+ return False
79
+ try:
80
+ with open(script, "rb") as f:
81
+ first = f.readline(4096)
82
+ except OSError:
83
+ return False
84
+ if first.startswith(b"#!"):
85
+ interpreter = first[2:].split(maxsplit=1)
86
+ return bool(interpreter) and os.access(os.fsdecode(interpreter[0]), os.X_OK)
87
+ return True
88
+
89
+
76
90
  def _stable_invocation() -> list[str]:
77
91
  """brindle_invocation, with the interpreter named the same however brindle
78
92
  was started (a venv's ``python`` and ``python3`` are the same program,
@@ -87,8 +101,12 @@ def _stable_invocation() -> list[str]:
87
101
  # replaces the other's and the next launch of either stops at "Hooks need review".
88
102
  script = os.path.join(os.path.dirname(exe), "brindle")
89
103
  if len(argv) == 1:
90
- return [os.path.realpath(exe)]
91
- if argv[1:] == ["-P", "-m", "brindle"] and os.access(script, os.X_OK):
104
+ if _runnable_script(exe):
105
+ return [os.path.realpath(exe)]
106
+ # Older bundled engines carry a console script with a CI-only shebang.
107
+ exe = os.path.join(os.path.dirname(os.path.realpath(exe)), "python3")
108
+ argv = [exe, "-P", "-m", "brindle"]
109
+ if argv[1:] == ["-P", "-m", "brindle"] and _runnable_script(script):
92
110
  return [os.path.realpath(script)]
93
111
  plain = os.path.join(os.path.dirname(exe), "python")
94
112
  try:
@@ -24,6 +24,7 @@ from pathlib import Path
24
24
  from typing import Callable
25
25
 
26
26
  from brindle import cloud_credential, config
27
+ from brindle.pro.license import request_cached
27
28
 
28
29
  log = logging.getLogger(__name__)
29
30
 
@@ -575,6 +576,7 @@ def route_in_use(org_id: str, setup) -> bool:
575
576
  return isinstance(got, dict) and got.get("org") == org_id
576
577
 
577
578
 
579
+ @request_cached
578
580
  def org_setup(repo_root: str | None = None, store=None, client=None, max_age: float | None = None):
579
581
  """(org id, AgentSetup) for the signed-in org; personal policies supply
580
582
  only federated Claude setup. None when there is no applicable setup;
@@ -583,8 +585,8 @@ def org_setup(repo_root: str | None = None, store=None, client=None, max_age: fl
583
585
  from brindle.pro import credentials, license, team_policy
584
586
 
585
587
  try:
586
- store = store or credentials.default_store()
587
588
  ent = license.current(store=store, client=client, max_age=max_age)
589
+ store = store or credentials.default_store()
588
590
  personal = team_policy.FEATURE not in ent.features
589
591
  if personal and (ent.policy_version <= 0 or (store.load() or {}).get("org_id")
590
592
  or ent.plan in ("team", "enterprise")):
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import json
12
12
  import os
13
+ import subprocess
13
14
  import sys
14
15
  import tempfile
15
16
  import threading
@@ -106,7 +107,7 @@ class RepoConfig:
106
107
  # Autopilot and merge gates.
107
108
  autopilot: bool = True # `brindle` starts the supervisor with autopilot on
108
109
  checks: list[str] = field(default_factory=list) # must pass in a branch before it merges
109
- scoped_checks: str | None = None # "off" or "infer"; None: not chosen (acts as "off" this release, see brindle.check_scope.inferred)
110
+ scoped_checks: str | None = None # "off" or "infer"; None: not chosen (acts as "infer", see brindle.check_scope.inferred)
110
111
  review: bool | None = None # require a reviewer's approval (None: only under autopilot)
111
112
  reviewer: str = "reviewer" # agent profile that reviews branches
112
113
  review_profile: str | None = None # force request_review's profile (skips its automatic cross-model pick)
@@ -244,11 +245,11 @@ def _merge_commands(shared: list[str], local: object) -> list[str]:
244
245
  return shared
245
246
 
246
247
 
247
- def _read_json(path: Path) -> dict:
248
- if not path.is_file():
248
+ def _read_json(path: Path, text: str | None = None) -> dict:
249
+ if text is None and not path.is_file():
249
250
  return {}
250
251
  try:
251
- data = json.loads(path.read_text(encoding="utf-8"))
252
+ data = json.loads(path.read_text(encoding="utf-8") if text is None else text)
252
253
  except json.JSONDecodeError as e:
253
254
  raise ValueError(f"{path}: invalid JSON ({e})") from e
254
255
  if not isinstance(data, dict):
@@ -256,6 +257,30 @@ def _read_json(path: Path) -> dict:
256
257
  return data
257
258
 
258
259
 
260
+ def _read_shared(root: Path, branch: str | None) -> dict:
261
+ """The shared config as committed on ``branch`` (the branch a session's
262
+ workers are cut from and merge into), not whatever the checkout at
263
+ ``root`` has checked out; the working tree's file when there's no
264
+ branch or it isn't committed there."""
265
+ path = root / CONFIG_DIR / CONFIG_FILE
266
+ if branch:
267
+ try:
268
+ proc = subprocess.run(["git", "show", f"{branch}:{CONFIG_DIR}/{CONFIG_FILE}"], cwd=root,
269
+ capture_output=True, text=True)
270
+ except OSError:
271
+ proc = None
272
+ if proc is not None and proc.returncode == 0:
273
+ return _read_json(Path(f"{branch}:{CONFIG_DIR}/{CONFIG_FILE}"), proc.stdout)
274
+ return _read_json(path)
275
+
276
+
277
+ def workspace_config(ws, *, session: bool = False) -> RepoConfig:
278
+ """``load_repo_config`` for workspace ``ws``, reading the shared file
279
+ from its base branch (see ``_read_shared``). A session's own workspace
280
+ (``session``) reads its own branch; its workers' base is that branch."""
281
+ return load_repo_config(ws.repo_root, branch=ws.branch if session and ws.kind == "worktree" else ws.base_branch)
282
+
283
+
259
284
  def config_root(path: str | Path) -> Path:
260
285
  """The directory whose ``.brindle`` applies to ``path``: ``path`` itself when
261
286
  it has one, else (for a linked git worktree, where the git-ignored config
@@ -342,10 +367,14 @@ def kind_overrides(found: object) -> dict[str, dict[str, str]]:
342
367
  return out
343
368
 
344
369
 
345
- def load_repo_config(repo_root: str | Path) -> RepoConfig:
370
+ def load_repo_config(repo_root: str | Path, *, user_only: bool = False, branch: str | None = None) -> RepoConfig:
371
+ """The settings for ``repo_root``: its local file over its shared one over
372
+ the person's ~/.brindle/config.json. ``user_only``: the person's file
373
+ alone, as every repo starts from it (the app's Agents tab). ``branch``:
374
+ read the shared file as committed there (see ``_read_shared``)."""
346
375
  base = config_root(repo_root) / CONFIG_DIR
347
- shared = _read_json(base / CONFIG_FILE)
348
- local = _read_json(base / LOCAL_CONFIG_FILE)
376
+ shared = {} if user_only else _read_shared(base.parent, branch)
377
+ local = {} if user_only else _read_json(base / LOCAL_CONFIG_FILE)
349
378
  user = user_settings()
350
379
 
351
380
  cfg = RepoConfig()
@@ -402,7 +431,8 @@ def load_repo_config(repo_root: str | Path) -> RepoConfig:
402
431
  cfg.routing_overrides.setdefault(kind, {}).update(fields)
403
432
  from brindle import worker_models
404
433
 
405
- worker_models.apply(cfg, user, str(repo_root)) # the models picked on the picker's workers screen
434
+ # The models picked on the picker's workers screen (the person's own profiles, for user_only).
435
+ worker_models.apply(cfg, user, str(repo_root), user_library=user_only)
406
436
  for source in (shared, local): # per weight, so a repo can override one and keep the rest
407
437
  second = source.get("second_review")
408
438
  if isinstance(second, dict):
@@ -520,15 +550,33 @@ def _sync_setting(key: str) -> None:
520
550
  pass
521
551
 
522
552
 
553
+ _REPO_WRITE = threading.Lock()
554
+
555
+
556
+ def _change_json(path: Path, change) -> None:
557
+ """Read ``path``, apply ``change`` to its dict and replace the file, one at a
558
+ time (daemon actions run in threads) through a unique temp file in the same
559
+ directory, so a reader never sees half a file and no write is lost."""
560
+ with _REPO_WRITE:
561
+ data = _read_json(path)
562
+ change(data)
563
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
564
+ try:
565
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
566
+ f.write(json.dumps(data, indent=2) + "\n")
567
+ os.replace(tmp, path)
568
+ except BaseException:
569
+ Path(tmp).unlink(missing_ok=True)
570
+ raise
571
+
572
+
523
573
  def set_local(repo_root: str | Path, key: str, value: object) -> Path:
524
574
  """Set one key in ``.brindle/config.local.json`` (gitignored: this person's
525
575
  own settings; other keys are kept)."""
526
576
  base = config_root(repo_root) / CONFIG_DIR
527
577
  base.mkdir(parents=True, exist_ok=True)
528
578
  path = base / LOCAL_CONFIG_FILE
529
- data = _read_json(path)
530
- data[key] = value
531
- path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
579
+ _change_json(path, lambda data: data.__setitem__(key, value))
532
580
  ignore = base / ".gitignore"
533
581
  if not ignore.exists():
534
582
  ignore.write_text(f"{LOCAL_CONFIG_FILE}\n", encoding="utf-8")
@@ -540,9 +588,24 @@ def set_repo(repo_root: str | Path, key: str, value: object) -> Path:
540
588
  everyone's settings; other keys are kept)."""
541
589
  path = scope_paths(repo_root)["repo"]
542
590
  path.parent.mkdir(parents=True, exist_ok=True)
543
- data = _read_json(path)
544
- data[key] = value
545
- path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
591
+ _change_json(path, lambda data: data.__setitem__(key, value))
592
+ return path
593
+
594
+
595
+ def unset_local(repo_root: str | Path, key: str) -> Path:
596
+ """Remove one key from ``.brindle/config.local.json`` (other keys are kept),
597
+ so the repo's shared file or the person's own value reads through again."""
598
+ path = scope_paths(repo_root)["local"]
599
+ if path.exists():
600
+ _change_json(path, lambda data: data.pop(key, None))
601
+ return path
602
+
603
+
604
+ def unset_repo(repo_root: str | Path, key: str) -> Path:
605
+ """Remove one key from the repo's committed ``.brindle/config.json``."""
606
+ path = scope_paths(repo_root)["repo"]
607
+ if path.exists():
608
+ _change_json(path, lambda data: data.pop(key, None))
546
609
  return path
547
610
 
548
611
 
@@ -5487,6 +5487,18 @@
5487
5487
  "title": "Mode",
5488
5488
  "type": "string"
5489
5489
  },
5490
+ "name": {
5491
+ "anyOf": [
5492
+ {
5493
+ "type": "string"
5494
+ },
5495
+ {
5496
+ "type": "null"
5497
+ }
5498
+ ],
5499
+ "default": null,
5500
+ "title": "Name"
5501
+ },
5490
5502
  "profile": {
5491
5503
  "title": "Profile",
5492
5504
  "type": "string"
@@ -6136,6 +6148,10 @@
6136
6148
  "method": "POST",
6137
6149
  "path": "/v1/providers/{provider}/login"
6138
6150
  },
6151
+ {
6152
+ "method": "POST",
6153
+ "path": "/v1/providers/{provider}/logout"
6154
+ },
6139
6155
  {
6140
6156
  "method": "GET",
6141
6157
  "path": "/v1/providers/{provider}/preflight"
@@ -6228,6 +6244,10 @@
6228
6244
  "method": "POST",
6229
6245
  "path": "/v1/sessions/{root}/pause"
6230
6246
  },
6247
+ {
6248
+ "method": "POST",
6249
+ "path": "/v1/sessions/{root}/rename"
6250
+ },
6231
6251
  {
6232
6252
  "method": "POST",
6233
6253
  "path": "/v1/sessions/{root}/transfer"
@@ -36,7 +36,7 @@ from starlette.requests import Request
36
36
  from starlette.responses import JSONResponse
37
37
  from starlette.routing import Route
38
38
 
39
- from brindle import agents, autopilot, bus, git, identity_lock, knowledge, pipeline, preflight, scratch, tmux, workspaces
39
+ from brindle import agents, autopilot, bus, gates, git, identity_lock, knowledge, pipeline, preflight, scratch, tmux, workspaces
40
40
  from brindle import sessions as sessions_mod
41
41
  from brindle.daemon import session_review as review
42
42
  from brindle.daemon import terminal
@@ -227,6 +227,7 @@ def start_session(db: DB, params: dict, body: Body) -> Any:
227
227
  sign-in or first-run screen (see brindle.preflight). Only opening a
228
228
  session that is running now launches nothing, so only that isn't checked."""
229
229
  repo, choice, session_id = _get(body, "repo", required=True), _get(body, "choice"), _get(body, "session_id")
230
+ name = _session_name(_get(body, "name")) # checked with the rest, before anything launches
230
231
  opening = (choice == "o" and not _get(body, "branch") and not _get(body, "worktree")
231
232
  and any(s.root.id == session_id
232
233
  for s in sessions_mod.live(db, workspaces.adopt_root(db, repo).repo_root)))
@@ -242,6 +243,9 @@ def start_session(db: DB, params: dict, body: Body) -> Any:
242
243
  if isinstance(got, svc.Opened):
243
244
  return {"opened": SessionDTO.from_agent(got.root).to_json()}
244
245
  if isinstance(got, svc.Started):
246
+ got.agent.name = name
247
+ if name:
248
+ db.update_agent(got.agent.id, name=name)
245
249
  return {**got.to_json(), "notes": _notes(got.notes)}
246
250
  return got
247
251
 
@@ -267,6 +271,19 @@ def close_session(db: DB, params: dict, body: Body) -> Any:
267
271
  return {"closed": [a.id for a, _ in closed], "stopped": [a.id for a, was in closed if was]}
268
272
 
269
273
 
274
+ def _session_name(raw: str | None) -> str | None:
275
+ """A session's name as kept: whitespace collapsed, at most 80 characters, None when empty."""
276
+ return " ".join((raw or "").split())[:80] or None
277
+
278
+
279
+ def rename_session(db: DB, params: dict, body: Body) -> Any:
280
+ """``name``: the session's new name; empty clears it (listed by its goal again)."""
281
+ root, _ = _root(db, params["root"])
282
+ name = _session_name(_get(body, "name"))
283
+ db.update_agent(root.id, name=name)
284
+ return {"id": root.id, "name": name}
285
+
286
+
270
287
  def handover_session(db: DB, params: dict, body: Body) -> Any:
271
288
  root, ws = _root(db, params["root"])
272
289
  dest = workspaces.checkout_for_target(db, ws.path, _get(body, "to", required=True))
@@ -414,7 +431,7 @@ def review_workspace(db: DB, params: dict, body: Body) -> Any:
414
431
  raise ServiceError("invalid", "only a worker's workspace (its own branch and worktree) can be reviewed")
415
432
  # With checks, a detached helper runs them first; review.submitted ends it.
416
433
  reviewer = agents.start_review(db, None, ws, _get(body, "profile"), _get(body, "focus"),
417
- load_repo_config(ws.repo_root))
434
+ load_repo_config(ws.repo_root, branch=ws.base_branch))
418
435
  return {"reviewer": reviewer.id if reviewer else None, "checks_running": reviewer is None}
419
436
 
420
437
 
@@ -741,8 +758,8 @@ def open_session_pr(db: DB, params: dict, body: Body, auto: bool = False) -> Any
741
758
  def work(db: DB, job: Job) -> Any:
742
759
  try:
743
760
  identity_lock.ensure(root) # again: the person signed in may have changed since
744
- head = git.out(["rev-parse", "HEAD"], ws.path)
745
- url = workspaces.open_pull_request(ws, title, draft, body=text)
761
+ with gates.pr_checked(db, ws, lambda t: bus.publish(db, "job.progress", job=job.id, text=t)) as head:
762
+ url = workspaces.open_pull_request(ws, title, draft, body=text, sha=head)
746
763
  if url is None and auto:
747
764
  raise workspaces.WorkspaceError("pushed, but gh couldn't open the PR")
748
765
  if url is None: # the person opens it from the compare page: no PR to record yet
@@ -935,17 +952,26 @@ def _fits(value: Any, default: Any) -> bool:
935
952
  def patch_config(db: DB, params: dict, body: Body) -> Any:
936
953
  """``values`` into ~/.brindle/config.json (scope "user") or the repo's
937
954
  .brindle/config.local.json (scope "repo"). Only the repo config's own
938
- keys, each with a value of its default's type (or null)."""
955
+ keys, each with a value of its default's type (or null). Scope "shared"
956
+ is the repo's committed .brindle/config.json. ``unset`` (scope "repo" or
957
+ "shared") lists keys to remove from that file, clearing a project's
958
+ override."""
939
959
  from brindle import config
940
960
 
941
961
  scope = _get(body, "scope", default="user")
942
- if scope not in ("user", "repo"):
943
- raise ServiceError("invalid", "scope must be user or repo")
944
- repo = _get(body, "repo", required=scope == "repo")
945
- values = _get(body, "values", dict, required=True)
962
+ if scope not in ("user", "repo", "shared"):
963
+ raise ServiceError("invalid", "scope must be user, repo or shared")
964
+ repo = _get(body, "repo", required=scope != "user")
965
+ values = _get(body, "values", dict, default={})
966
+ unset = _get(body, "unset", list, default=[])
967
+ if unset and scope == "user":
968
+ raise ServiceError("invalid", "unset needs scope repo or shared")
969
+ if not values and not unset:
970
+ raise ServiceError("invalid", "values is required")
971
+ path = None
946
972
  defaults = config.RepoConfig()
947
973
  known = {f.name for f in dataclasses.fields(config.RepoConfig)}
948
- if unknown := sorted(set(values) - known):
974
+ if unknown := sorted((set(values) | set(unset)) - known):
949
975
  raise ServiceError("invalid", f"unknown setting(s): {', '.join(unknown)}")
950
976
  for key, value in values.items():
951
977
  if not _fits(value, getattr(defaults, key)):
@@ -953,9 +979,13 @@ def patch_config(db: DB, params: dict, body: Body) -> Any:
953
979
  for key, value in values.items():
954
980
  if scope == "user":
955
981
  path = config.set_user(key, value)
982
+ elif scope == "shared":
983
+ path = config.set_repo(repo, key, value)
956
984
  else:
957
985
  path = config.set_local(repo, key, value)
958
- return {"updated": sorted(values), "path": str(path)}
986
+ for key in unset:
987
+ path = config.unset_repo(repo, key) if scope == "shared" else config.unset_local(repo, key)
988
+ return {"updated": sorted({*values, *unset}), "path": str(path)}
959
989
 
960
990
 
961
991
  # -- knowledge sources (brindle kb add / sync / remove) -----------------------------
@@ -1093,7 +1123,8 @@ def put_routing(db: DB, params: dict, body: Body) -> Any:
1093
1123
  lock_changes: dict[str, bool] = {}
1094
1124
  overrides: dict[str, dict[str, str | None]] = {} # kind -> effort / instructions (None clears)
1095
1125
  extra: dict[str, Any] = {} # supervisor and reviewer settings the kinds ask for
1096
- current = config.load_repo_config(repo).routing
1126
+ # A user-scope change builds on the person's own lists and library, never this repo's.
1127
+ current = config.load_repo_config(repo, user_only=scope == "user").routing
1097
1128
  for kind, spec in kinds.items():
1098
1129
  if kind not in config.ROUTING_KINDS or not isinstance(spec, dict):
1099
1130
  raise ServiceError("invalid", f"kind must be one of {', '.join(config.ROUTING_KINDS)}, not {kind!r}")
@@ -1131,7 +1162,7 @@ def put_routing(db: DB, params: dict, body: Body) -> Any:
1131
1162
  extra["supervisor_profile"] = model
1132
1163
  if fallbacks is not None:
1133
1164
  extra["supervisor_fallbacks"] = list(dict.fromkeys(fallbacks))
1134
- by_name = {p.name: p for p in profiles.list_profiles(repo)}
1165
+ by_name = {p.name: p for p in profiles.list_profiles(None if scope == "user" else repo)}
1135
1166
  known = set(by_name)
1136
1167
  named = [reviewer] if reviewer is not None else []
1137
1168
  for value in extra.values(): # supervisor and reviewer choices and fallbacks are profiles too
@@ -1146,7 +1177,7 @@ def put_routing(db: DB, params: dict, body: Body) -> Any:
1146
1177
  raise ServiceError("invalid", f"no such profile(s): {', '.join(unknown)}")
1147
1178
  if excluded is not None and (bad := sorted(set(map(str, excluded)) - set(providers.PROVIDERS))):
1148
1179
  raise ServiceError("invalid", f"no such provider(s): {', '.join(bad)}")
1149
- policy = RoutingPolicy.load(repo)
1180
+ policy = RoutingPolicy.load(repo, user_library=scope == "user")
1150
1181
  if policy.applies and policy.access.error:
1151
1182
  raise ServiceError("forbidden", f"routing can't be changed now: {policy.access.error}", 403)
1152
1183
  for name in dict.fromkeys(named):
@@ -1694,6 +1725,20 @@ def turn_off_provider(db: DB, params: dict, body: Body) -> Any:
1694
1725
  return providers_install.turn_off(_known_provider(params))
1695
1726
 
1696
1727
 
1728
+ def provider_logout(db: DB, params: dict, body: Body) -> Any:
1729
+ """Sign brindle's own copy of the provider out (Claude Code, Codex only);
1730
+ the person's own CLI login is not touched. Answers the provider's row."""
1731
+ from brindle import providers_install
1732
+
1733
+ provider = _known_provider(params)
1734
+ if provider not in providers_install.SIGN_OUT:
1735
+ raise ServiceError("invalid", f"sign out is offered for {', '.join(providers_install.SIGN_OUT)}")
1736
+ try:
1737
+ return providers_install.sign_out(provider)
1738
+ except providers_install.InstallError as e:
1739
+ raise ServiceError("logout_failed", str(e), 422) from None
1740
+
1741
+
1697
1742
  def terminal_input(db: DB, params: dict, body: Body) -> Any:
1698
1743
  """Type ``text``, one line the person typed or pasted in the app (a
1699
1744
  sign-in's code), into the action terminal ``id``, then Enter unless
@@ -1922,6 +1967,7 @@ ROUTES = [
1922
1967
  _post("/sessions/{root}/continue", continue_session),
1923
1968
  _post("/sessions/{root}/pause", pause_session),
1924
1969
  _post("/sessions/{root}/close", close_session),
1970
+ _post("/sessions/{root}/rename", rename_session),
1925
1971
  _post("/sessions/{root}/handover", handover_session),
1926
1972
  _post("/sessions/{root}/transfer", transfer_session),
1927
1973
  # GET on the file path reads (routes_read); agent callers are refused by the guard.
@@ -1977,6 +2023,7 @@ ROUTES = [
1977
2023
  # Like every route, refused to agent callers by the guard.
1978
2024
  _post("/providers/{provider}/login", provider_login),
1979
2025
  _post("/providers/{provider}/preflight", profile_preflight),
2026
+ _post("/providers/{provider}/logout", provider_logout),
1980
2027
  _post("/providers/{provider}/trust", provider_trust),
1981
2028
  _post("/providers/{provider}/consent", provider_consent),
1982
2029
  _post("/providers/{provider}/install", install_provider),