brindle 0.3.13__tar.gz → 0.3.14__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 (134) hide show
  1. {brindle-0.3.13 → brindle-0.3.14}/PKG-INFO +2 -2
  2. {brindle-0.3.13 → brindle-0.3.14}/README.md +1 -1
  3. {brindle-0.3.13 → brindle-0.3.14}/pyproject.toml +1 -1
  4. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/agents.py +6 -1
  5. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/cloud_credential.py +1 -1
  6. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/cull.py +20 -3
  7. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/mcp_server.py +4 -1
  8. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/native/permissions.py +115 -47
  9. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/permissions.py +4 -1
  10. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/profiles.py +49 -21
  11. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/providers.py +5 -2
  12. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/signin_pause.py +40 -20
  13. brindle-0.3.14/src/brindle/upgrade.py +83 -0
  14. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/watch.py +10 -3
  15. {brindle-0.3.13 → brindle-0.3.14}/.gitignore +0 -0
  16. {brindle-0.3.13 → brindle-0.3.14}/LICENSE +0 -0
  17. {brindle-0.3.13 → brindle-0.3.14}/SCHEDULE-A +0 -0
  18. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/__init__.py +0 -0
  19. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/__main__.py +0 -0
  20. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/account.py +0 -0
  21. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/airgap.py +0 -0
  22. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/antigravity.py +0 -0
  23. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/autopilot.py +0 -0
  24. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/budget.py +0 -0
  25. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/__init__.py +0 -0
  26. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  27. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/developer-codex.md +0 -0
  28. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/developer-fable.md +0 -0
  29. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/developer-gemini.md +0 -0
  30. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/developer-heavy.md +0 -0
  31. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/developer-light.md +0 -0
  32. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/developer-local.md +0 -0
  33. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/developer.md +0 -0
  34. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/qa.md +0 -0
  35. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
  36. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
  37. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/reviewer-local.md +0 -0
  38. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/reviewer.md +0 -0
  39. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/subagent.md +0 -0
  40. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/supervisor.md +0 -0
  41. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_rules/__init__.py +0 -0
  42. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_rules/security/backend.md +0 -0
  43. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  44. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_rules/tests/only.md +0 -0
  45. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/ci_adapters.py +0 -0
  46. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/ci_client.py +0 -0
  47. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/ci_federation.py +0 -0
  48. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/ci_hosts.py +0 -0
  49. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/cli.py +0 -0
  50. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/codemap.py +0 -0
  51. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/codemap_docs.py +0 -0
  52. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/codex_hook.py +0 -0
  53. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/company_identity.py +0 -0
  54. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/company_login.py +0 -0
  55. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/config.py +0 -0
  56. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/conflicts.py +0 -0
  57. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/cost.py +0 -0
  58. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/cost_estimate.py +0 -0
  59. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/db.py +0 -0
  60. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/demo.py +0 -0
  61. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/detect.py +0 -0
  62. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/doctor.py +0 -0
  63. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/escapes.py +0 -0
  64. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/events.py +0 -0
  65. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/gates.py +0 -0
  66. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/git.py +0 -0
  67. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/guardrails.py +0 -0
  68. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/history.py +0 -0
  69. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/identity_lock.py +0 -0
  70. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/inbox.py +0 -0
  71. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/keys_cmds.py +0 -0
  72. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/keystore.py +0 -0
  73. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/launch_health.py +0 -0
  74. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/learned_rules.py +0 -0
  75. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/learning.py +0 -0
  76. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/legacy.py +0 -0
  77. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/model_access.py +0 -0
  78. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/model_availability.py +0 -0
  79. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/native/__init__.py +0 -0
  80. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/native/client.py +0 -0
  81. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/native/loop.py +0 -0
  82. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/native/runner.py +0 -0
  83. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/native/serve.py +0 -0
  84. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/native/tools.py +0 -0
  85. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pane_auth.py +0 -0
  86. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pipeline.py +0 -0
  87. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/plugins.py +0 -0
  88. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/policy.py +0 -0
  89. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pool.py +0 -0
  90. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pricing.py +0 -0
  91. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/__init__.py +0 -0
  92. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/_files.py +0 -0
  93. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/account.py +0 -0
  94. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/audit_chain.py +0 -0
  95. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/audit_export.py +0 -0
  96. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/auth.py +0 -0
  97. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/brief_learning.py +0 -0
  98. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/cost_centers.py +0 -0
  99. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/credentials.py +0 -0
  100. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/features.py +0 -0
  101. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/keys.py +0 -0
  102. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/learning.py +0 -0
  103. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/license.py +0 -0
  104. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/loopback.py +0 -0
  105. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/managed_models.py +0 -0
  106. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/org_profiles.py +0 -0
  107. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/orgkey.py +0 -0
  108. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/rollout.py +0 -0
  109. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/route_health.py +0 -0
  110. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/settings_sync.py +0 -0
  111. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/status.py +0 -0
  112. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/team_events.py +0 -0
  113. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/team_policy.py +0 -0
  114. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/procs.py +0 -0
  115. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/quota.py +0 -0
  116. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/repo_cmds.py +0 -0
  117. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/repos.py +0 -0
  118. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/rewind.py +0 -0
  119. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/rule_checks.py +0 -0
  120. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/savings.py +0 -0
  121. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/scratch.py +0 -0
  122. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/secrets.py +0 -0
  123. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/self_serve.py +0 -0
  124. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/services.py +0 -0
  125. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/sessions.py +0 -0
  126. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/sidebar_follow.py +0 -0
  127. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/status_cache.py +0 -0
  128. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/tasks.py +0 -0
  129. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/tmux.py +0 -0
  130. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/updates.py +0 -0
  131. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/usage.py +0 -0
  132. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/view.py +0 -0
  133. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/watch_costs.py +0 -0
  134. {brindle-0.3.13 → brindle-0.3.14}/src/brindle/workspaces.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brindle
3
- Version: 0.3.13
3
+ Version: 0.3.14
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.13`). A session started before an upgrade keeps
97
+ session shows it too (`brindle 0.3.14`). 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
@@ -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.13`). A session started before an upgrade keeps
66
+ session shows it too (`brindle 0.3.14`). 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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brindle"
3
- version = "0.3.13"
3
+ version = "0.3.14"
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"
@@ -2937,8 +2937,13 @@ def tell_parent_unreported(db: DB, agent: Agent) -> bool:
2937
2937
  where =f" (branch `{ws.branch}`, workspace {ws.id})" if ws else ""
2938
2938
  tool = "submit_review" if agent.mode == "review" else "report_result"
2939
2939
  who = "Reviewer" if agent.mode == "review" else "Worker"
2940
+ # A fix round's worker reported once already; the review's fixes are what's missing.
2941
+ what = (f"stopped in a review fix round without committing or calling {tool} again "
2942
+ "(it may have lost its brindle connection)"
2943
+ if agent.pipeline == "fixing" and agent.result is not None
2944
+ else f"stopped without calling {tool}")
2940
2945
  body = (
2941
- f"{who} {agent.id}{where} stopped without calling {tool}, and won't be reminded "
2946
+ f"{who} {agent.id}{where} {what}, and won't be reminded "
2942
2947
  f"again on its own. Check on it: workspace_diff to see what it did, send_message to "
2943
2948
  f"ask it to finish or report, or remove_workspace if the work is abandoned."
2944
2949
  )
@@ -231,7 +231,7 @@ def hook_argv(argv0: str | None = None, python: str | None = None) -> list[str]:
231
231
  return [raw]
232
232
  interpreter = python or sys.executable
233
233
  _vet(interpreter, "the Python interpreter")
234
- return [interpreter, "-m", "brindle"]
234
+ return [interpreter, "-P", "-m", "brindle"] # -P: not a brindle/ in the hook's cwd
235
235
 
236
236
 
237
237
  def hook_command(cloud: str, org_id: str, argv0: str | None = None, python: str | None = None) -> str:
@@ -266,19 +266,26 @@ def note_idle_unreported(db: DB, now: float, panes: dict[str, bool]) -> list[str
266
266
  A worker whose provider has no hook for a turn starting (Codex) stays
267
267
  'idle' from its first finished turn on, through any later turns, so it
268
268
  also needs a pane that has printed nothing for that long, and the spell
269
- starts at its last output."""
269
+ starts at its last output.
270
+
271
+ A worker in a pipeline fix round already has a result from its first
272
+ report, so it counts as unreported while it hasn't reported again since
273
+ the review that asked for fixes (committing doesn't count), and its spell
274
+ starts no earlier than that review."""
270
275
  from brindle.providers import get_provider
271
276
 
272
277
  done = []
273
278
  owners = agents.pane_owners(db, panes)
274
279
  for a in db.list_agents():
280
+ fix_round = _fix_round_since(db, a) if a.result is not None else None
275
281
  # Only these are ever told (a synchronous handoff's caller is waiting).
276
- if (a.mode not in agents.FORWARDING_MODES or not a.parent_id or a.result is not None
282
+ if (a.mode not in agents.FORWARDING_MODES or not a.parent_id
283
+ or (a.result is not None and fix_round is None)
277
284
  or a.dismissed_at is not None or a.status != "idle"
278
285
  or not agents.runs_process(a) or not agents.same_server(a, panes)
279
286
  or not agents.is_alive(a, panes) or not agents.owns_pane(db, a, owners)):
280
287
  continue
281
- since = a.status_since or a.created_at
288
+ since = max(a.status_since or a.created_at, fix_round or 0)
282
289
  provider = get_provider(a.provider)
283
290
  if not (provider.uses_hooks and provider.announces_turns):
284
291
  last = tmux.window_activity(a.tmux_window, server=agents.server_of(a))
@@ -295,6 +302,16 @@ def note_idle_unreported(db: DB, now: float, panes: dict[str, bool]) -> list[str
295
302
  return done
296
303
 
297
304
 
305
+ def _fix_round_since(db: DB, a: Agent) -> float | None:
306
+ """When the review that put a worker's branch in a fix round was given,
307
+ if the worker is still in that round, whether or not it has committed
308
+ since; None otherwise. Reporting again moves the branch on to 'reviewing'."""
309
+ if a.pipeline != "fixing":
310
+ return None
311
+ review = db.last_review(a.workspace_id)
312
+ return review.created_at if review is not None else None
313
+
314
+
298
315
  # How long a worker no hook reports on (Codex) may show nothing new before its
299
316
  # supervisor is told. Such a CLI can sit on a startup error forever: signed in
300
317
  # without a plan that includes it, say (issue #42).
@@ -10,7 +10,7 @@ import time
10
10
 
11
11
  from mcp.server.mcpserver import MCPServer
12
12
 
13
- from brindle import agents, autopilot, codex_hook, cost_estimate, git, history, pipeline, policy, quota, repos, savings, sessions, tasks, workspaces
13
+ from brindle import agents, autopilot, codex_hook, cost_estimate, git, history, pipeline, policy, quota, repos, savings, sessions, tasks, upgrade, workspaces
14
14
  from brindle.pro import brief_learning
15
15
  from brindle.config import load_repo_config
16
16
  from brindle.db import DB, Agent, Workspace
@@ -36,6 +36,9 @@ mcp = MCPServer(
36
36
  "`list_repos` shows them, and `repo=<alias>` on handoff/assign (and on the workspace "
37
37
  "tools) puts a worker in that repo, with that repo's own checks, review and merge."
38
38
  ),
39
+ # Loads all of brindle up front, so an upgrade under this long-lived server
40
+ # leaves it serving the version it started with (see upgrade.py).
41
+ lifespan=upgrade.preloaded,
39
42
  )
40
43
 
41
44
 
@@ -98,75 +98,140 @@ def uncovered_part(specs: list[str | None], command: str, cd_root: str | None =
98
98
  Neither is what split_commands can't split (an unclosed quote, a
99
99
  heredoc, a redirection that writes a file): it counts as uncovered. With
100
100
  ``cd_root``, a ``cd`` whose target is inside that directory is covered
101
- too (``cd sub && pytest`` from a worker's own worktree)."""
101
+ too (``cd sub && pytest`` from a worker's own worktree), but only before
102
+ any other command: the target is checked now and entered later, and what
103
+ runs first (``git checkout``, a build) may swap a directory on the way
104
+ for a symlink out. The residual risk is a process running alongside
105
+ (something this agent backgrounded earlier) doing that swap between this
106
+ check and the cd; _cd_inside refuses symlinks so it at least has to
107
+ replace a directory, not just retarget a link."""
102
108
  command = command.strip()
103
109
  if not command or "$(" in command or "`" in command:
104
110
  return command or "(empty)"
105
- parts = split_commands(command)
106
- if not parts:
111
+ parsed = _simple_commands(command)
112
+ if not parsed:
107
113
  return command
108
- cwd = cd_root
109
- for part in parts:
110
- if cd_root and _cd_inside(part, cwd, cd_root) is not None:
111
- cwd = _cd_inside(part, cwd, cd_root)
112
- continue
113
- if not any(_covers(spec, part) for spec in specs if spec):
114
- return part
114
+ # Where the shell may be: a cd can fail, or be skipped after a failure,
115
+ # so every later cd must stay inside from each place it may start.
116
+ reached = {cd_root} if cd_root else set() # anywhere, once a list stops
117
+ here = set(reached) # where this command starts
118
+ before = ";"
119
+ ran = False # a command other than cd has
120
+ for words, after in parsed:
121
+ if cd_root and words[0] == "cd":
122
+ # In a pipe or backgrounded, it changes a subshell's directory;
123
+ # after ||, whether it ran says nothing about what runs next.
124
+ if ran or before in ("||", "|", "|&") or after in ("|", "|&", "&"):
125
+ return _joined(words)
126
+ landed = {_cd_inside(words, cwd, cd_root) for cwd in here}
127
+ if None in landed or len(reached | landed) > 32:
128
+ return _joined(words)
129
+ reached |= landed
130
+ if after == "&&": # what follows runs only if this cd worked
131
+ here = landed
132
+ elif not any(_covers(spec, words) for spec in specs if spec):
133
+ return _joined(words)
134
+ else:
135
+ ran = True
136
+ if after != "&&":
137
+ here = set(reached)
138
+ before = after
115
139
  return None
116
140
 
117
141
 
118
- def _cd_inside(part: str, cwd: str | None, root: str) -> str | None:
119
- """The directory ``part`` (a ``cd`` command) would land in, if that is
120
- ``root`` or below it; None for any other command or target."""
121
- tokens = part.split()
122
- if len(tokens) != 2 or tokens[0] != "cd" or tokens[1].startswith("-") or "$" in tokens[1]:
123
- return None # a variable expands at run time to who knows where
142
+ def _cd_inside(words: list[str], cwd: str, root: str) -> str | None:
143
+ """Where ``cd`` with ``words`` lands from ``cwd`` (the logical path, as
144
+ $PWD has it), if that is ``root`` or below it; None for any other target.
145
+ No ``-`` option, ``~`` (``~-`` is $OLDPWD, ``~1`` the dir stack) or glob,
146
+ and the same place whether the shell resolves ``..`` logically (bash's
147
+ default: ``link/..`` is back where it started) or physically. Below
148
+ ``root`` it must be an existing directory reached through no symlink:
149
+ one, even pointing inside, can be retargeted before the cd runs."""
124
150
  import os
125
151
 
126
- # realpath on both sides: a symlink inside the worktree may point out of
127
- # it, and the worktree itself may sit under one (/tmp on macOS).
128
- target = os.path.realpath(os.path.join(cwd or root, os.path.expanduser(tokens[1])))
129
- real_root = os.path.realpath(root)
130
- return target if target == real_root or target.startswith(real_root + os.sep) else None
152
+ target = words[1] if len(words) == 2 else ""
153
+ if not target or target.startswith(("-", "~")) or any(c in target for c in "*?[{"):
154
+ return None
155
+ root = os.path.normpath(root)
156
+ logical = os.path.normpath(os.path.join(cwd, target))
157
+ if logical != root and not logical.startswith(root.rstrip(os.sep) + os.sep):
158
+ return None
159
+ path = root # which itself may sit under a symlink (/tmp on macOS)
160
+ for part in os.path.relpath(logical, root).split(os.sep):
161
+ path = os.path.join(path, part)
162
+ if os.path.islink(path):
163
+ return None
164
+ if not os.path.isdir(logical):
165
+ return None
166
+ # The way there may pass through one (``link/..``), which cd -L skips
167
+ # and cd -P follows.
168
+ physical = os.path.realpath(os.path.join(os.path.realpath(cwd), target))
169
+ return logical if os.path.realpath(logical) == physical else None
131
170
 
132
171
 
133
- def _covers(spec: str, part: str) -> bool:
172
+ def _covers(spec: str, words: list[str]) -> bool:
173
+ """Word by word, so a quoted ``'git status'`` (one word, a command of
174
+ that name) or ``python "-m pytest"`` isn't ``git status`` or ``python -m
175
+ pytest``."""
134
176
  if spec.endswith(":*"):
135
- prefix = spec[:-2].strip()
136
- return part == prefix or part.startswith(prefix + " ")
137
- return part == spec.strip()
177
+ prefix = spec[:-2].split()
178
+ return bool(prefix) and words[:len(prefix)] == prefix
179
+ return words == spec.split()
180
+
181
+
182
+ def _joined(words: list[str]) -> str:
183
+ """``words`` as one line for a person or a deny rule to read: split at
184
+ any whitespace, even inside a word, so an empty or spaced word (``task
185
+ test "" secret``) can't slip past an exact deny on ``task test secret``."""
186
+ return " ".join(" ".join(words).split())
138
187
 
139
188
 
140
189
  def split_commands(command: str) -> list[str]:
141
190
  """The simple commands joined by a control operator (``&&``, ``||``,
142
191
  ``;``, ``;;``, ``|``, ``|&``, ``&`` or a newline), each re-joined from
143
- its words (so spacing and quoting don't matter). A redirection is dropped
144
- when it's harmless: output to /dev/null, an fd dup (``2>&1``), input from
145
- a file. Anything else gives an empty list, which nothing matches: an
146
- unclosed quote, other output redirection, a heredoc (``<<``), process
147
- substitution (``<(...)``), a subshell or any other operator."""
192
+ its words (so spacing and quoting don't matter; see _joined), or an
193
+ empty list for what _simple_commands can't read."""
194
+ return [_joined(words) for words, _ in _simple_commands(command) or []]
195
+
196
+
197
+ def _simple_commands(command: str) -> list[tuple[list[str], str]] | None:
198
+ """Each simple command's words (quotes removed) with the control operator
199
+ after it ("" for the last). A redirection is dropped when it's harmless:
200
+ output to /dev/null, an fd dup (``2>&1``), input from a file. Anything
201
+ else gives None: an unclosed quote, other output redirection, a heredoc
202
+ (``<<``), process substitution (``<(...)``), a subshell or any other
203
+ operator, a command with no words (``; < x``, ``a ; ; b``) or a list
204
+ left open (``git status |``)."""
148
205
  tokens = _tokens(command)
149
206
  if tokens is None:
150
- return []
151
- parts, current = [], []
207
+ return None
208
+ out: list[tuple[list[str], str]] = []
209
+ words: list[str] = []
210
+ redirected = False
152
211
  i = 0
153
212
  while i < len(tokens):
154
213
  is_op, text = tokens[i]
155
214
  if not is_op:
156
- current.append(text)
215
+ words.append(text)
157
216
  elif text in _SEPARATORS:
158
- if current:
159
- parts.append(" ".join(current))
160
- current = []
217
+ if words:
218
+ out.append((words, text))
219
+ words, redirected = [], False
220
+ elif redirected or text != "\n":
221
+ return None
222
+ # else a blank line, or a newline continuing a && or |
161
223
  else:
162
224
  target = tokens[i + 1] if i + 1 < len(tokens) else (True, "")
163
225
  if target[0] or not _harmless_redirect(text, target[1]):
164
- return []
226
+ return None
227
+ redirected = True
165
228
  i += 1
166
229
  i += 1
167
- if current:
168
- parts.append(" ".join(current))
169
- return parts
230
+ if words:
231
+ out.append((words, ""))
232
+ elif redirected or not out or out[-1][1] in ("&&", "||", "|", "|&"):
233
+ return None
234
+ return out
170
235
 
171
236
 
172
237
  def _harmless_redirect(op: str, target: str) -> bool:
@@ -195,9 +260,10 @@ def _tokens(command: str) -> list[tuple[bool, str]] | None:
195
260
  if c in " \t\n" or c in _OPERATOR_CHARS:
196
261
  j = i + 1
197
262
  if c in _OPERATOR_CHARS:
198
- while j < n and command[j] in _OPERATOR_CHARS:
199
- j += 1
200
- op = command[i:j]
263
+ # \<newline> goes before the shell reads operators: &\<newline>> is &>
264
+ while j < n and (command[j] in _OPERATOR_CHARS or command.startswith("\\\n", j)):
265
+ j += 1 if command[j] in _OPERATOR_CHARS else 2
266
+ op = command[i:j].replace("\\\n", "")
201
267
  if word is not None:
202
268
  text = "".join(word)
203
269
  fd = op[0] in "<>" and not quoted and text.isascii() and text.isdigit()
@@ -246,12 +312,14 @@ def _tokens(command: str) -> list[tuple[bool, str]] | None:
246
312
 
247
313
 
248
314
  def _expands(command: str, i: int) -> bool:
249
- """Whether the ``$`` at ``i`` starts quoting or an expansion with its own
250
- parsing (``$'``, ``$"``, ``${``, ``$(``, ``$[``), once the shell has
251
- deleted the line continuations (\\<newline>) after it."""
315
+ """Whether the ``$`` at ``i`` starts quoting or any expansion (``$'``,
316
+ ``$"``, ``${``, ``$(``, ``$[``, ``$VAR``, ``$_``, ``$1``, ``$@``...),
317
+ once the shell has deleted the line continuations (\\<newline>) after
318
+ it: what a parameter holds is only known at run time (``$unset`` is no
319
+ word at all)."""
252
320
  if command[i] != "$":
253
321
  return False
254
322
  k = i + 1
255
323
  while command.startswith("\\\n", k):
256
324
  k += 2
257
- return k < len(command) and command[k] in "'\"{(["
325
+ return k < len(command) and (command[k] in "'\"{([_@*#?$!-" or command[k].isalnum())
@@ -850,7 +850,10 @@ def deny_in_parts(req: Request, *, checks: list[str] | None = None,
850
850
  return None
851
851
  denies = [rule for rule in rules if rule.decision == "deny"]
852
852
  for words in _segments(req.command):
853
- decision = decide(replace(req, command=" ".join(words)), checks=checks, rules=denies)
853
+ # Split even inside a word, as split_commands does: an empty or
854
+ # spaced word (task test "" secret) mustn't dodge an exact deny.
855
+ command = " ".join(" ".join(words).split())
856
+ decision = decide(replace(req, command=command), checks=checks, rules=denies)
854
857
  if decision.decision == "deny":
855
858
  return decision
856
859
  return None
@@ -40,6 +40,47 @@ from brindle.config import CONFIG_DIR, brindle_home, user_profiles_dir
40
40
 
41
41
  log = logging.getLogger(__name__)
42
42
 
43
+ _BUILTIN_PACKAGES = ("brindle.builtin_agents", "brindle.builtin_rules")
44
+ # The built-in profiles and rule packs as freeze_builtins() read them, by
45
+ # package and then name (``security/backend``), for a long-lived process that
46
+ # must not see an upgrade replace those files under it. None: read the files.
47
+ _frozen: dict[str, dict[str, str]] | None = None
48
+
49
+
50
+ def _builtin_files(package: str):
51
+ """(name, file) for each ``.md`` file under ``package``, the name being its
52
+ path below it without ``.md``."""
53
+ def walk(entry, prefix: str):
54
+ for child in entry.iterdir():
55
+ if child.is_dir():
56
+ yield from walk(child, f"{prefix}{child.name}/")
57
+ elif child.name.endswith(".md"):
58
+ yield f"{prefix}{child.name[:-3]}", child
59
+
60
+ return walk(resources.files(package), "")
61
+
62
+
63
+ def freeze_builtins() -> None:
64
+ """Read every built-in profile and rule pack into memory, and serve them
65
+ from there from now on (see brindle.upgrade)."""
66
+ global _frozen
67
+ _frozen = {package: {name: f.read_text(encoding="utf-8") for name, f in _builtin_files(package)}
68
+ for package in _BUILTIN_PACKAGES}
69
+
70
+
71
+ def _builtin_text(package: str, name: str) -> str | None:
72
+ if _frozen is not None:
73
+ return _frozen[package].get(name)
74
+ *dirs, last = name.split("/")
75
+ f = resources.files(package).joinpath(*dirs, f"{last}.md")
76
+ return f.read_text(encoding="utf-8") if f.is_file() else None
77
+
78
+
79
+ def _builtin_names(package: str) -> list[str]:
80
+ if _frozen is not None:
81
+ return list(_frozen[package])
82
+ return [name for name, _ in _builtin_files(package)]
83
+
43
84
 
44
85
  class ProfileError(ValueError):
45
86
  """A profile file that can't be used as written: an ``extends`` cycle, or
@@ -389,9 +430,9 @@ def _read(name: str, repo_root: str | None) -> tuple[str, str] | None:
389
430
  return f.read_text(encoding="utf-8"), str(f)
390
431
  if org is not None:
391
432
  return org.text, f"org profile {name}"
392
- builtin = resources.files("brindle.builtin_agents").joinpath(f"{name}.md")
393
- if builtin.is_file():
394
- return builtin.read_text(encoding="utf-8"), f"built-in profile {name}"
433
+ builtin = _builtin_text("brindle.builtin_agents", name)
434
+ if builtin is not None:
435
+ return builtin, f"built-in profile {name}"
395
436
  return None
396
437
 
397
438
 
@@ -483,9 +524,9 @@ def _read_pack(name: str, repo_root: str | None) -> tuple[str, str] | None:
483
524
  return f.read_text(encoding="utf-8"), str(f)
484
525
  if org is not None:
485
526
  return org.text, f"org rule pack {name}"
486
- builtin = resources.files("brindle.builtin_rules").joinpath(*parts[:-1], f"{parts[-1]}.md")
487
- if builtin.is_file():
488
- return builtin.read_text(encoding="utf-8"), f"built-in rule pack {name}"
527
+ builtin = _builtin_text("brindle.builtin_rules", name)
528
+ if builtin is not None:
529
+ return builtin, f"built-in rule pack {name}"
489
530
  return None
490
531
 
491
532
 
@@ -561,17 +602,7 @@ def _required_packs(names: list[str]) -> list[RulePack]:
561
602
 
562
603
  def list_rule_packs(repo_root: str | None = None) -> list[str]:
563
604
  """Every rule pack name that would load, the nearest definition winning."""
564
- names: set[str] = set()
565
- builtin = resources.files("brindle.builtin_rules")
566
-
567
- def walk(entry, prefix: str) -> None:
568
- for child in entry.iterdir():
569
- if child.is_dir():
570
- walk(child, f"{prefix}{child.name}/")
571
- elif child.name.endswith(".md"):
572
- names.add(f"{prefix}{child.name[:-3]}")
573
-
574
- walk(builtin, "")
605
+ names = set(_builtin_names("brindle.builtin_rules"))
575
606
  names.update(_org_names("pack"))
576
607
  for d in _rule_dirs(repo_root):
577
608
  if d.is_dir():
@@ -625,10 +656,7 @@ def profile_rules_for(name: str, repo_root: str | None = None):
625
656
  def profile_names(repo_root: str | None = None) -> list[str]:
626
657
  """Every profile file name (without ``.md``) in the lookup order's
627
658
  directories and the built-ins, each once."""
628
- names: set[str] = set()
629
- for entry in resources.files("brindle.builtin_agents").iterdir():
630
- if entry.name.endswith(".md"):
631
- names.add(entry.name[:-3])
659
+ names = set(_builtin_names("brindle.builtin_agents"))
632
660
  names.update(_org_names("profile"))
633
661
  for d in _search_dirs(repo_root):
634
662
  if d.is_dir():
@@ -36,14 +36,17 @@ def brindle_invocation() -> list[str]:
36
36
  """argv that re-enters this same brindle install, independent of PATH.
37
37
  A brindle run from a worktree's venv (developing brindle with brindle)
38
38
  would write that venv's python into hooks that outlive the worktree, so
39
- the installed ``brindle`` is used instead when there is one."""
39
+ the installed ``brindle`` is used instead when there is one. ``-P``: these
40
+ run from inside worktrees (hooks, a re-exec'd sidebar), and ``-m`` would
41
+ otherwise put the cwd first on sys.path, so a ``brindle/`` there would be
42
+ imported instead of the install."""
40
43
  if _in_worktree(sys.executable):
41
44
  # Past every worktree's own bin (``uv run`` puts it first on PATH).
42
45
  for directory in os.environ.get("PATH", "").split(os.pathsep):
43
46
  installed = shutil.which("brindle", path=directory) if directory else None
44
47
  if installed and not _in_worktree(installed):
45
48
  return [installed]
46
- return [sys.executable, "-m", "brindle"]
49
+ return [sys.executable, "-P", "-m", "brindle"]
47
50
 
48
51
 
49
52
  def mcp_server_spec(agent_id: str) -> dict:
@@ -25,7 +25,8 @@ status``). Antigravity and gateway routes can't be verified and are skipped.
25
25
  account.
26
26
 
27
27
  State is ``$BRINDLE_HOME/signin-pause.json`` (0600): which agents were paused
28
- and why, and the identity each launched under. Identities are ids (Claude org,
28
+ and why, the identity each launched under, and the warnings already given (so
29
+ another sweeping process doesn't repeat one). Identities are ids (Claude org,
29
30
  AWS account and role, GCP project, Azure subscription), never tokens.
30
31
  """
31
32
 
@@ -37,7 +38,7 @@ import os
37
38
  import time
38
39
  from dataclasses import dataclass
39
40
 
40
- from brindle import agents, company_identity, config
41
+ from brindle import agents, company_identity, company_login, config
41
42
 
42
43
  log = logging.getLogger(__name__)
43
44
 
@@ -45,19 +46,18 @@ INTERVAL = 30.0
45
46
  STATE_FILE = "signin-pause.json"
46
47
  NAMES = {"claude": "Claude", "codex": "Codex"}
47
48
  CLOUD_ROUTES = ("bedrock", "vertex", "foundry")
49
+ ROUTED = CLOUD_ROUTES + ("gateway",) # routes whose env, not the person's sign-in, picks the account
48
50
  # The reason of an agent paused with no identity to come back under: never auto-resumed.
49
51
  NO_IDENTITY = "paused at sign-out; resume it yourself once you've checked the account"
50
52
 
51
53
  _last = 0.0
52
54
  _cloud: dict[str, tuple[tuple, "Check"]] = {} # route -> (fingerprint, determined answer)
53
- _warned: set[tuple[str, str]] = set()
54
55
 
55
56
 
56
57
  def reset() -> None:
57
58
  global _last
58
59
  _last = 0.0
59
60
  _cloud.clear()
60
- _warned.clear()
61
61
 
62
62
 
63
63
  @dataclass
@@ -81,7 +81,7 @@ def _load() -> dict:
81
81
  except (OSError, ValueError):
82
82
  got = {}
83
83
  got = got if isinstance(got, dict) else {}
84
- for key in ("paused", "identity", "different"):
84
+ for key in ("paused", "identity", "different", "warned"):
85
85
  if not isinstance(got.get(key), dict):
86
86
  got[key] = {}
87
87
  return got
@@ -116,10 +116,7 @@ def check(provider: str, cache: bool = False) -> tuple[Check, bool] | None:
116
116
  return _from(company_identity.codex_identity(cache)), False
117
117
  if provider != "claude":
118
118
  return None
119
- got = company_identity._setup()
120
- org_id, setup = got if got else (None, None)
121
- claude = setup.claude if setup else None
122
- enforce = bool(setup and setup.enforce)
119
+ org_id, claude, enforce = _claude_setup(company_identity._setup())
123
120
  route = claude.route if claude is not None else None
124
121
  if route == "gateway":
125
122
  return None
@@ -138,6 +135,20 @@ def check(provider: str, cache: bool = False) -> tuple[Check, bool] | None:
138
135
  return res, enforce
139
136
 
140
137
 
138
+ def _claude_setup(got) -> tuple[str | None, object, bool]:
139
+ """(org id, the ``ClaudeSetup`` Claude's panes run under, whether the policy
140
+ enforces) for ``got``, an (org id, ``AgentSetup``) or None. A cloud or gateway
141
+ route whose env no pane gets (``company_login.route_in_use``: not stored by
142
+ company login, not enforced) leaves Claude on the person's own sign-in, which
143
+ is what's checked then. A login route has no env: its policy always applies."""
144
+ org_id, setup = got if got else (None, None)
145
+ claude = setup.claude if setup else None
146
+ if (claude is not None and claude.route in ROUTED
147
+ and not company_login.route_in_use(org_id, setup)):
148
+ claude = None
149
+ return org_id, claude, bool(setup and setup.enforce)
150
+
151
+
141
152
  def _from(ident) -> Check:
142
153
  if not ident.determined:
143
154
  return Check("undetermined", line=ident.line)
@@ -225,20 +236,31 @@ def run(db) -> list[str]:
225
236
  return done
226
237
 
227
238
 
228
- def _warn_once(db, provider: str, key: str, text: str, agent_list) -> list[str]:
229
- if (provider, key) in _warned:
239
+ def _warn_once(db, state, provider: str, key: str, text: str, agent_list) -> list[str]:
240
+ """Tell once per spell of the same state. Kept in the state file, since every
241
+ sweeping process (each pane's watcher, the CLI) starts with no memory of it."""
242
+ warned = state["warned"].get(provider)
243
+ if not isinstance(warned, list):
244
+ warned = state["warned"][provider] = []
245
+ if key in warned:
230
246
  return []
231
- _warned.add((provider, key))
247
+ warned.append(key)
232
248
  _tell(db, agent_list, text)
233
249
  return [f"warned: {text}"]
234
250
 
235
251
 
252
+ def _unwarn(state, provider: str, prefix: str) -> None:
253
+ warned = state["warned"].get(provider)
254
+ if isinstance(warned, list):
255
+ warned[:] = [k for k in warned if not (isinstance(k, str) and k.startswith(prefix))]
256
+
257
+
236
258
  def _handle(db, state, provider, running, res: Check, enforce: bool) -> list[str]:
237
259
  name = NAMES[provider]
238
260
  if res.kind == "undetermined":
239
- return _warn_once(db, provider, "undetermined",
261
+ return _warn_once(db, state, provider, "undetermined",
240
262
  f"{name}'s sign-in can't be verified ({res.line}); agents keep running.", running)
241
- _warned.discard((provider, "undetermined"))
263
+ _unwarn(state, provider, "undetermined")
242
264
  if res.kind == "signed_out":
243
265
  return _pause(db, state, provider, running, f"{name} signed out",
244
266
  f"{name} is signed out. Run `brindle login` or `brindle doctor --fix` "
@@ -253,10 +275,9 @@ def _handle(db, state, provider, running, res: Check, enforce: bool) -> list[str
253
275
  "enforces it. Fix it and they resume on their own.")
254
276
  out: list[str] = []
255
277
  if res.problem:
256
- out += _warn_once(db, provider, "mismatch:" + res.problem, f"{res.problem}.", running)
278
+ out += _warn_once(db, state, provider, "mismatch:" + res.problem, f"{res.problem}.", running)
257
279
  else:
258
- for k in [k for k in _warned if k[0] == provider and k[1].startswith("mismatch:")]:
259
- _warned.discard(k)
280
+ _unwarn(state, provider, "mismatch:")
260
281
  return out + _resume(db, state, provider, res)
261
282
 
262
283
 
@@ -264,11 +285,10 @@ def _expected() -> str | None:
264
285
  """The identity the org's agent_setup expects Claude to run as (or the
265
286
  saved self-serve setup's); None when there's nothing to check against."""
266
287
  try:
267
- got = company_identity._setup() or company_identity._local_setup()
288
+ claude = _claude_setup(company_identity._setup() or company_identity._local_setup())[1]
268
289
  except Exception: # noqa: BLE001 - an unreadable setup means nothing expected
269
290
  return None
270
- setup = got[1] if got else None
271
- return company_identity.expected_ident(setup.claude if setup else None)
291
+ return company_identity.expected_ident(claude)
272
292
 
273
293
 
274
294
  def _pause(db, state, provider, running, reason: str, advice: str) -> list[str]: