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.
- {brindle-0.3.13 → brindle-0.3.14}/PKG-INFO +2 -2
- {brindle-0.3.13 → brindle-0.3.14}/README.md +1 -1
- {brindle-0.3.13 → brindle-0.3.14}/pyproject.toml +1 -1
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/agents.py +6 -1
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/cloud_credential.py +1 -1
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/cull.py +20 -3
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/mcp_server.py +4 -1
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/native/permissions.py +115 -47
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/permissions.py +4 -1
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/profiles.py +49 -21
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/providers.py +5 -2
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/signin_pause.py +40 -20
- brindle-0.3.14/src/brindle/upgrade.py +83 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/watch.py +10 -3
- {brindle-0.3.13 → brindle-0.3.14}/.gitignore +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/LICENSE +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/SCHEDULE-A +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/__init__.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/__main__.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/account.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/airgap.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/antigravity.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/autopilot.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/budget.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/backend-auditor.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/developer-codex.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/developer-fable.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/developer-gemini.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/developer-heavy.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/developer-light.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/developer-local.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/developer.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/qa.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/reviewer-local.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/reviewer.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_rules/__init__.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_rules/security/backend.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/builtin_rules/tests/only.md +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/ci_adapters.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/ci_client.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/ci_federation.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/ci_hosts.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/cli.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/codemap.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/codemap_docs.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/codex_hook.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/company_identity.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/company_login.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/config.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/conflicts.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/cost.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/cost_estimate.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/db.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/demo.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/detect.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/doctor.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/escapes.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/events.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/gates.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/git.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/guardrails.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/history.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/identity_lock.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/inbox.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/keys_cmds.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/keystore.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/launch_health.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/learned_rules.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/learning.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/legacy.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/model_access.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/model_availability.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/native/__init__.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/native/client.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/native/loop.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/native/runner.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/native/serve.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/native/tools.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pane_auth.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pipeline.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/plugins.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/policy.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pool.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pricing.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/_files.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/account.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/audit_export.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/auth.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/brief_learning.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/cost_centers.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/features.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/keys.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/learning.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/license.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/managed_models.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/org_profiles.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/rollout.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/route_health.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/settings_sync.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/status.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/team_events.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/pro/team_policy.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/procs.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/quota.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/repos.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/rewind.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/rule_checks.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/savings.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/scratch.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/secrets.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/self_serve.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/services.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/sessions.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/sidebar_follow.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/status_cache.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/tasks.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/tmux.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/updates.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/usage.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/view.py +0 -0
- {brindle-0.3.13 → brindle-0.3.14}/src/brindle/watch_costs.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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}
|
|
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
|
|
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
|
-
|
|
106
|
-
if not
|
|
111
|
+
parsed = _simple_commands(command)
|
|
112
|
+
if not parsed:
|
|
107
113
|
return command
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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(
|
|
119
|
-
"""
|
|
120
|
-
``root`` or below it; None for any other
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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,
|
|
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].
|
|
136
|
-
return
|
|
137
|
-
return
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
+
words.append(text)
|
|
157
216
|
elif text in _SEPARATORS:
|
|
158
|
-
if
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
250
|
-
|
|
251
|
-
deleted the line continuations (\\<newline>) after
|
|
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
|
-
|
|
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 =
|
|
393
|
-
if builtin
|
|
394
|
-
return builtin
|
|
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 =
|
|
487
|
-
if builtin
|
|
488
|
-
return builtin
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]:
|