brindle 0.2.0__tar.gz → 0.2.1__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 (117) hide show
  1. {brindle-0.2.0 → brindle-0.2.1}/.gitignore +1 -0
  2. {brindle-0.2.0 → brindle-0.2.1}/PKG-INFO +3 -2
  3. {brindle-0.2.0 → brindle-0.2.1}/README.md +2 -1
  4. {brindle-0.2.0 → brindle-0.2.1}/pyproject.toml +1 -1
  5. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/agents.py +142 -118
  6. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/autopilot.py +72 -14
  7. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/budget.py +46 -5
  8. brindle-0.2.1/src/brindle/builtin_agents/developer-codex.md +6 -0
  9. brindle-0.2.1/src/brindle/builtin_agents/developer-heavy.md +7 -0
  10. brindle-0.2.1/src/brindle/builtin_agents/developer-light.md +7 -0
  11. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/developer.md +4 -3
  12. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/reviewer-codex.md +4 -9
  13. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/reviewer-local.md +3 -6
  14. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/reviewer.md +4 -9
  15. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/cli.py +76 -9
  16. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/config.py +26 -2
  17. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/cost.py +137 -0
  18. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/db.py +45 -3
  19. brindle-0.2.1/src/brindle/escapes.py +176 -0
  20. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/gates.py +82 -20
  21. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/history.py +2 -1
  22. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/learning.py +31 -6
  23. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/mcp_server.py +47 -28
  24. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pipeline.py +41 -11
  25. brindle-0.2.1/src/brindle/pro/brief_learning.py +164 -0
  26. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/learning.py +71 -6
  27. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/team_policy.py +19 -2
  28. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/watch_costs.py +7 -2
  29. brindle-0.2.0/src/brindle/builtin_agents/developer-codex.md +0 -21
  30. brindle-0.2.0/src/brindle/builtin_agents/developer-heavy.md +0 -23
  31. {brindle-0.2.0 → brindle-0.2.1}/LICENSE +0 -0
  32. {brindle-0.2.0 → brindle-0.2.1}/SCHEDULE-A +0 -0
  33. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/__init__.py +0 -0
  34. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/__main__.py +0 -0
  35. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/account.py +0 -0
  36. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/airgap.py +0 -0
  37. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/antigravity.py +0 -0
  38. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/__init__.py +0 -0
  39. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/backend-auditor.md +0 -0
  40. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/developer-local.md +0 -0
  41. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/qa.md +0 -0
  42. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/subagent.md +0 -0
  43. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_agents/supervisor.md +0 -0
  44. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_rules/__init__.py +0 -0
  45. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_rules/security/backend.md +0 -0
  46. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
  47. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/builtin_rules/tests/only.md +0 -0
  48. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/ci_adapters.py +0 -0
  49. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/ci_client.py +0 -0
  50. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/ci_federation.py +0 -0
  51. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/ci_hosts.py +0 -0
  52. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/codemap.py +0 -0
  53. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/codex_hook.py +0 -0
  54. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/conflicts.py +0 -0
  55. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/cost_estimate.py +0 -0
  56. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/cull.py +0 -0
  57. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/demo.py +0 -0
  58. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/detect.py +0 -0
  59. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/doctor.py +0 -0
  60. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/events.py +0 -0
  61. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/git.py +0 -0
  62. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/guardrails.py +0 -0
  63. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/inbox.py +0 -0
  64. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/learned_rules.py +0 -0
  65. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/legacy.py +0 -0
  66. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/native/__init__.py +0 -0
  67. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/native/client.py +0 -0
  68. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/native/loop.py +0 -0
  69. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/native/permissions.py +0 -0
  70. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/native/runner.py +0 -0
  71. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/native/serve.py +0 -0
  72. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/native/tools.py +0 -0
  73. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/permissions.py +0 -0
  74. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/plugins.py +0 -0
  75. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/policy.py +0 -0
  76. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pool.py +0 -0
  77. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pricing.py +0 -0
  78. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/__init__.py +0 -0
  79. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/_files.py +0 -0
  80. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/account.py +0 -0
  81. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/audit_chain.py +0 -0
  82. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/audit_export.py +0 -0
  83. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/auth.py +0 -0
  84. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/cost_centers.py +0 -0
  85. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/credentials.py +0 -0
  86. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/features.py +0 -0
  87. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/keys.py +0 -0
  88. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/license.py +0 -0
  89. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/loopback.py +0 -0
  90. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/managed_models.py +0 -0
  91. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/org_profiles.py +0 -0
  92. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/orgkey.py +0 -0
  93. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/rollout.py +0 -0
  94. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/settings_sync.py +0 -0
  95. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/status.py +0 -0
  96. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/pro/team_events.py +0 -0
  97. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/procs.py +0 -0
  98. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/profiles.py +0 -0
  99. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/providers.py +0 -0
  100. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/quota.py +0 -0
  101. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/repo_cmds.py +0 -0
  102. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/repos.py +0 -0
  103. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/rewind.py +0 -0
  104. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/rule_checks.py +0 -0
  105. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/savings.py +0 -0
  106. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/scratch.py +0 -0
  107. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/secrets.py +0 -0
  108. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/services.py +0 -0
  109. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/sessions.py +0 -0
  110. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/sidebar_follow.py +0 -0
  111. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/status_cache.py +0 -0
  112. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/tasks.py +0 -0
  113. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/tmux.py +0 -0
  114. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/usage.py +0 -0
  115. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/view.py +0 -0
  116. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/watch.py +0 -0
  117. {brindle-0.2.0 → brindle-0.2.1}/src/brindle/workspaces.py +0 -0
@@ -9,3 +9,4 @@ graphify-out/
9
9
  # private, kept local for now
10
10
  docs/
11
11
  .brindle/config.local.json
12
+ .brindle/bench.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brindle
3
- Version: 0.2.0
3
+ Version: 0.2.1
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
@@ -89,7 +89,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
89
89
  brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
90
90
 
91
91
  `brindle --version` prints the installed version; the tmux status bar of every brindle
92
- session shows it too (`brindle 0.2.0`). A session started before an upgrade keeps
92
+ session shows it too (`brindle 0.2.1`). A session started before an upgrade keeps
93
93
  running the old code, and shows the old number, until you restart it.
94
94
 
95
95
  ## Quick start
@@ -360,6 +360,7 @@ your own status line prints, so what you see doesn't change.
360
360
  | `brindle cost requests` | Enterprise: your approval requests and their status; checks pending ones with the server (the cull pass also does, every few minutes). With cost centers, spend events also carry the repo (`owner/name`) so the org can total spend per center |
361
361
  | `brindle cost estimate [--tasks N] [--weight W] [--profile P] [--reviewer R]` | what the current goal (or N tasks) is likely to cost, as a range from this repo's history (median to p80 of past worker and review runs), plus a cheaper alternative with a different reviewer; says "low confidence" when there's too little history. Also added to `set_goal` and `assign` replies (brindle Pro) |
362
362
  | `brindle learning` | whether brindle Pro's hosted learning is on for this repo, and if not, why (nothing is learned on your machine) |
363
+ | `brindle learning seed` | Pro: sends this repo's finished routing history to the hosted learner once, so it starts with what this machine has seen. Only the usual coarse record fields leave (kind/size one-hots, counters, HMAC keys), never task text or paths. A second run sends nothing new; decisions already sent live are skipped |
363
364
  | `brindle account [login\|logout\|status\|upgrade\|portal\|org]` | paid features: bare `brindle account` shows what your plan has and how to get the rest (see "brindle Pro and Team" below) |
364
365
  | `brindle audit verify\|export\|pubkey\|ship\|prune` | the local tamper-evident audit log, and shipping it to your SIEM (brindle Enterprise; see "Audit log" below) |
365
366
  | `brindle watch [--all] [--once]` | the dashboard on its own (the same view as the sidebar): enter attaches, `p` peeks, `x` closes |
@@ -59,7 +59,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
59
59
  brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
60
60
 
61
61
  `brindle --version` prints the installed version; the tmux status bar of every brindle
62
- session shows it too (`brindle 0.2.0`). A session started before an upgrade keeps
62
+ session shows it too (`brindle 0.2.1`). A session started before an upgrade keeps
63
63
  running the old code, and shows the old number, until you restart it.
64
64
 
65
65
  ## Quick start
@@ -330,6 +330,7 @@ your own status line prints, so what you see doesn't change.
330
330
  | `brindle cost requests` | Enterprise: your approval requests and their status; checks pending ones with the server (the cull pass also does, every few minutes). With cost centers, spend events also carry the repo (`owner/name`) so the org can total spend per center |
331
331
  | `brindle cost estimate [--tasks N] [--weight W] [--profile P] [--reviewer R]` | what the current goal (or N tasks) is likely to cost, as a range from this repo's history (median to p80 of past worker and review runs), plus a cheaper alternative with a different reviewer; says "low confidence" when there's too little history. Also added to `set_goal` and `assign` replies (brindle Pro) |
332
332
  | `brindle learning` | whether brindle Pro's hosted learning is on for this repo, and if not, why (nothing is learned on your machine) |
333
+ | `brindle learning seed` | Pro: sends this repo's finished routing history to the hosted learner once, so it starts with what this machine has seen. Only the usual coarse record fields leave (kind/size one-hots, counters, HMAC keys), never task text or paths. A second run sends nothing new; decisions already sent live are skipped |
333
334
  | `brindle account [login\|logout\|status\|upgrade\|portal\|org]` | paid features: bare `brindle account` shows what your plan has and how to get the rest (see "brindle Pro and Team" below) |
334
335
  | `brindle audit verify\|export\|pubkey\|ship\|prune` | the local tamper-evident audit log, and shipping it to your SIEM (brindle Enterprise; see "Audit log" below) |
335
336
  | `brindle watch [--all] [--once]` | the dashboard on its own (the same view as the sidebar): enter attaches, `p` peeks, `x` closes |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brindle"
3
- version = "0.2.0"
3
+ version = "0.2.1"
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"
@@ -47,6 +47,12 @@ WORKER_FOOTER = """
47
47
  ---
48
48
  You are running as a brindle worker (agent id {agent_id}) on branch `{branch}`.
49
49
  {guidance}
50
+ Before you finish, check your work against the task and its finish line, item
51
+ by item: each behaviour it asks for needs a test that would fail without your
52
+ change, and a requirement with no such test isn't done yet. If the task turns
53
+ out too big for one branch, finish a coherent part and say exactly what's left
54
+ (and how you'd split it) rather than leaving every part half done.
55
+
50
56
  When you have finished:
51
57
  1. Commit your work to this branch with a clear message (do not push or merge).
52
58
  2. Call the `report_result` tool from the `brindle` MCP server with a concise
@@ -72,6 +78,12 @@ Your shell may start in another directory, so begin every Bash command with
72
78
  `cd {path} && ` (or use `git -C {path}`), and give file tools absolute paths
73
79
  under that directory. Don't change files anywhere else, and don't switch branches.
74
80
  {guidance}
81
+ Before you finish, check your work against the task and its finish line, item
82
+ by item: each behaviour it asks for needs a test that would fail without your
83
+ change, and a requirement with no such test isn't done yet. If the task turns
84
+ out too big for one branch, finish a coherent part and say exactly what's left
85
+ (and how you'd split it) rather than leaving every part half done.
86
+
75
87
  When you have finished:
76
88
  1. Commit your work there on `{branch}` with a clear message (do not push or merge).
77
89
  2. Don't call any brindle tools. End with a concise summary: what you changed,
@@ -1697,38 +1709,19 @@ def _local_reviewer_available() -> bool:
1697
1709
  return bool(ok) and "is available" in detail
1698
1710
 
1699
1711
 
1700
- def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | None = None,
1701
- focus: str | None = None, cfg: RepoConfig | None = None) -> Agent:
1702
- """Start a reviewer in a worker's workspace right away. Its verdict is
1703
- recorded for the merge gate and forwarded to ``caller`` as a message.
1704
- ``profile`` picks the reviewer profile; None uses
1705
- ``default_review_profile``.
1706
-
1707
- Raises ``AgentError`` if the chosen profile (explicit or picked) doesn't
1708
- exist, or if it uses the codex provider but codex isn't on PATH -- rather
1709
- than spawning a reviewer doomed to fail in a dead pane.
1710
-
1711
- ``cfg.checks`` are NOT run here (that would block the caller on the full
1712
- suite): the caller runs them in the background and delivers a pass/fail
1713
- summary to the reviewer as a message once they finish, via
1714
- ``deliver_check_summary``. If the workspace already has a review at an
1715
- earlier commit, the reviewer is pointed at just what changed since then,
1716
- when that's a plain diff (see ``is_linear_since``)."""
1717
- from brindle import gates
1718
- from brindle.config import load_repo_config
1712
+ def _review_kill_switch(ws: Workspace) -> None:
1719
1713
  from brindle.pro import rollout
1720
1714
 
1721
1715
  why_not = rollout.kill_switch_reason(ws.repo_root) # Enterprise managed rollout
1722
1716
  if why_not:
1723
1717
  raise AgentError(f"No reviewer started: {why_not}")
1724
- cfg = cfg or load_repo_config(ws.repo_root)
1725
- sha = gates.head(ws)
1726
- for old in db.list_agents(ws.id):
1727
- if old.mode != "review" or old.result is not None or not is_alive(old):
1728
- continue
1729
- if old.review_sha == sha:
1730
- return old # one reviewer per workspace and commit
1731
- close(db, old.id) # a new commit replaces the reviewer of the old one
1718
+
1719
+
1720
+ def _review_profile(db: DB, ws: Workspace, profile: str | None, cfg: RepoConfig) -> str:
1721
+ """The reviewer profile to use, checked to be one that can run: raises
1722
+ ``AgentError`` if it (explicit or picked) doesn't exist, or uses the codex
1723
+ provider but codex isn't on PATH -- rather than spawning a reviewer doomed
1724
+ to fail in a dead pane."""
1732
1725
  worker = workspace_worker(db, ws)
1733
1726
  profile = profile or default_review_profile(cfg, worker, db, ws.repo_root,
1734
1727
  worker.task if worker else None)
@@ -1743,6 +1736,125 @@ def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | N
1743
1736
  f"reviewer profile {profile!r} can't run: {why}. "
1744
1737
  "Fix that, or set review_profile (or pass profile) to a different reviewer"
1745
1738
  )
1739
+ return profile
1740
+
1741
+
1742
+ def _live_reviewer(db: DB, ws: Workspace, sha: str, profile: str | None = None) -> Agent | None:
1743
+ """The reviewer still at work on ``ws`` at ``sha``, if any. With
1744
+ ``profile``, only one running that profile (a second review by another
1745
+ profile is not a duplicate)."""
1746
+ for a in db.list_agents(ws.id):
1747
+ if a.mode == "review" and a.result is None and is_alive(a) and a.review_sha == sha \
1748
+ and (profile is None or a.profile == profile):
1749
+ return a
1750
+ return None
1751
+
1752
+
1753
+ def start_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | None = None,
1754
+ focus: str | None = None, cfg: RepoConfig | None = None) -> Agent | None:
1755
+ """Have ``ws`` reviewed. Without checks configured the reviewer starts
1756
+ right away and is returned. With checks, nothing starts yet: a detached
1757
+ ``_review-after-checks`` process runs them and starts the reviewer with
1758
+ their results in its prompt (see ``review_after_checks``), so this returns
1759
+ at once with None, however long the suite takes. Raises ``AgentError``
1760
+ for a reviewer that couldn't start whatever the checks say (the kill
1761
+ switch, an unusable profile); a failure after that reaches ``caller``
1762
+ as a message."""
1763
+ from brindle import gates
1764
+ from brindle.config import load_repo_config
1765
+ from brindle.providers import brindle_invocation
1766
+
1767
+ cfg = cfg or load_repo_config(ws.repo_root)
1768
+ if not cfg.checks:
1769
+ return request_review(db, caller, ws, profile, focus, cfg)
1770
+ _review_kill_switch(ws)
1771
+ _review_profile(db, ws, profile, cfg)
1772
+ existing = _live_reviewer(db, ws, gates.head(ws), profile)
1773
+ if existing:
1774
+ return existing # one reviewer per workspace and commit
1775
+ argv = [*brindle_invocation(), "_review-after-checks", ws.id]
1776
+ if caller:
1777
+ argv += ["--caller", caller.id]
1778
+ if profile:
1779
+ argv += ["--profile", profile]
1780
+ if focus:
1781
+ argv += ["--focus", focus]
1782
+ _detach(argv)
1783
+ return None
1784
+
1785
+
1786
+ def expected_check_wait(db: DB, ws: Workspace, cfg: RepoConfig) -> float | None:
1787
+ """Seconds the repo's checks usually take (the sum of each one's last
1788
+ passing duration), or None when any of them has never passed."""
1789
+ total = 0.0
1790
+ for cmd in cfg.checks:
1791
+ took = db.check_duration(ws.repo_root, cmd)
1792
+ if took is None:
1793
+ return None
1794
+ total += took
1795
+ return total if cfg.checks else None
1796
+
1797
+
1798
+ def review_after_checks(db: DB, caller_id: str | None, ws: Workspace, profile: str | None = None,
1799
+ focus: str | None = None, cfg: RepoConfig | None = None) -> Agent | None:
1800
+ """Run ``cfg.checks`` for ``ws`` (through ``gates.check_summary``: its
1801
+ lock and the by-sha cache, so a result a worker's report already warmed
1802
+ is instant), then start the reviewer with the results in its prompt.
1803
+ Meant to run from a detached process, which outlives the call that
1804
+ started it. Gives up quietly when the branch moves on (the new commit
1805
+ gets its own review), the workspace is gone, or a reviewer is already at
1806
+ work on this commit. Raises ``AgentError`` when the reviewer can't
1807
+ start."""
1808
+ from brindle import gates
1809
+ from brindle.config import load_repo_config
1810
+
1811
+ cfg = cfg or load_repo_config(ws.repo_root)
1812
+ sha = gates.head(ws)
1813
+ if _live_reviewer(db, ws, sha, profile):
1814
+ return None
1815
+ try:
1816
+ summary = gates.check_summary(db, ws, cfg, cancel=lambda: gates.head(ws) != sha)
1817
+ except gates.Abandoned:
1818
+ return None
1819
+ except Exception as e: # noqa: BLE001 - the review goes ahead, saying so
1820
+ summary = f"(running the checks crashed: {e})"
1821
+ if db.get_workspace(ws.id) is None or gates.head(ws) != sha:
1822
+ return None
1823
+ caller = db.get_agent(caller_id) if caller_id else None
1824
+ text = f"Checks for {ws.branch} at {sha[:8]}:\n\n{summary}" if summary else None
1825
+ return request_review(db, caller, ws, profile, focus, cfg, check_summary=text)
1826
+
1827
+
1828
+ def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | None = None,
1829
+ focus: str | None = None, cfg: RepoConfig | None = None,
1830
+ check_summary: str | None = None) -> Agent:
1831
+ """Start a reviewer in a worker's workspace right away. Its verdict is
1832
+ recorded for the merge gate and forwarded to ``caller`` as a message.
1833
+ ``profile`` picks the reviewer profile; None uses
1834
+ ``default_review_profile`` (``AgentError`` if it can't run).
1835
+
1836
+ ``cfg.checks`` are NOT run here (that would block the caller on the full
1837
+ suite): ``start_review`` runs them first, in the background, and passes
1838
+ their results as ``check_summary`` for the reviewer's prompt. If the
1839
+ workspace already has a review at an earlier commit, the reviewer is
1840
+ pointed at just what changed since then, when that's a plain diff (see
1841
+ ``is_linear_since``)."""
1842
+ from brindle import gates
1843
+ from brindle.config import load_repo_config
1844
+
1845
+ _review_kill_switch(ws)
1846
+ cfg = cfg or load_repo_config(ws.repo_root)
1847
+ sha = gates.head(ws)
1848
+ for old in db.list_agents(ws.id):
1849
+ if old.mode != "review" or old.result is not None or not is_alive(old):
1850
+ continue
1851
+ if old.review_sha == sha:
1852
+ if profile and old.profile != profile:
1853
+ continue # another profile's review of this commit (a second review): keep both
1854
+ return old # one reviewer per workspace and commit
1855
+ close(db, old.id) # a new commit replaces the reviewer of the old one
1856
+ worker = workspace_worker(db, ws)
1857
+ profile = _review_profile(db, ws, profile, cfg)
1746
1858
 
1747
1859
  base = ws.base_branch or "the base branch"
1748
1860
  task = (f"Review the changes on branch `{ws.branch}` (workspace {ws.id}) against `{base}`: "
@@ -1755,16 +1867,9 @@ def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | N
1755
1867
  if worker and worker.done_when:
1756
1868
  task += f"\n\nIts finish line: {worker.done_when.strip()}"
1757
1869
 
1758
- if cfg and cfg.checks:
1759
- task += ("\n\nThe repo's checks are still running (or queued behind other branches' "
1760
- "runs): you don't have their results yet. A pass/fail summary will arrive as a "
1761
- "message. Review the diff meanwhile, and don't call submit_review until you've "
1762
- "received it. If about 10 minutes pass after you've finished reviewing with no "
1763
- "such message, submit anyway and say plainly in your summary that the checks "
1764
- "were still running when you submitted, so your verdict rests on the diff "
1765
- "alone: the merge gate runs them before anything merges, and a failure that "
1766
- "arrives after your verdict goes to the supervisor. Don't run the whole suite "
1767
- "yourself to compensate.")
1870
+ if check_summary:
1871
+ task += (f"\n\nThe repo's checks have already run on this commit:\n\n{check_summary}\n\n"
1872
+ "Take these results into account; don't run the whole suite yourself.")
1768
1873
 
1769
1874
  prev = db.last_review(ws.id)
1770
1875
  if prev and prev.sha != sha:
@@ -1784,87 +1889,6 @@ def request_review(db: DB, caller: Agent | None, ws: Workspace, profile: str | N
1784
1889
  mode="review", background_setup=True, review_sha=sha)
1785
1890
 
1786
1891
 
1787
- def deliver_check_summary(db: DB, reviewer_id: str, ws: Workspace, cfg: RepoConfig) -> None:
1788
- """Run ``cfg.checks`` for ``ws`` and deliver a pass/fail summary to the
1789
- reviewer's inbox: delivered right away if it's idle, or handed over at its
1790
- next Stop, exactly like any other queued message (see ``send_message``).
1791
- Meant to run from a detached process started by request_review, so it
1792
- outlives the MCP server call that kicked it off. Always delivers
1793
- something, even if a check crashes, since the reviewer was told to wait
1794
- for this before approving. If the reviewer is no longer there to receive
1795
- it by the time the checks finish, a failing summary isn't dropped: see
1796
- ``_late_check_summary``."""
1797
- from brindle import gates
1798
-
1799
- reviewer = db.get_agent(reviewer_id)
1800
- if reviewer is None:
1801
- return
1802
- supervisor_id = reviewer.parent_id
1803
- sha = gates.head(ws) # label with the commit the checks ran on
1804
- try:
1805
- summary = gates.check_summary(db, ws, cfg)
1806
- failed = gates.summary_failed(summary)
1807
- except Exception as e:
1808
- summary, failed = f"(running the checks crashed: {e})", True
1809
-
1810
- reviewer = db.get_agent(reviewer_id)
1811
- if reviewer is None or reviewer.result is not None or reviewer.status == "done":
1812
- # It submitted its review, was closed, or was removed while the checks ran.
1813
- if failed:
1814
- _late_check_summary(db, ws, sha, summary, reviewer_id, supervisor_id)
1815
- return
1816
-
1817
- text = (f"Checks for {ws.branch} at {sha[:8]}:\n\n{summary}" if summary
1818
- else "No checks are configured for this repo.")
1819
- db.enqueue(reviewer_id, text, None)
1820
- flush(db, reviewer_id)
1821
-
1822
-
1823
- def _late_check_summary(db: DB, ws: Workspace, sha: str, summary: str, reviewer_id: str,
1824
- supervisor_id: str | None) -> None:
1825
- """The checks for ``ws`` at ``sha`` finished after its reviewer was done,
1826
- and failed (or crashed), so the verdict was given without them (a late
1827
- pass changes nothing). The failure must reach someone who can act on it:
1828
- the supervisor when the branch was approved (or has no verdict at this
1829
- commit), the worker when changes were requested anyway (the supervisor
1830
- if it's gone). Dropped
1831
- only when there's nothing left to act on: the workspace was removed, the
1832
- branch merged at this commit (its worktree may be going away under the
1833
- run), or the branch has new commits, which get their own run."""
1834
- from brindle import gates
1835
-
1836
- try:
1837
- if db.get_workspace(ws.id) is None or db.merged_sha(ws.id) == sha or gates.head(ws) != sha:
1838
- return
1839
- except git.GitError:
1840
- return
1841
- worker = workspace_worker(db, ws)
1842
- supervisor_id = supervisor_id or (worker.parent_id if worker else None)
1843
- review = db.latest_review(ws.id, sha)
1844
- checks = f"Checks for {ws.branch} at {sha[:8]}:\n\n{summary}"
1845
- if review is not None and not review.approved and worker and is_alive(worker):
1846
- try:
1847
- send_message(db, worker.id,
1848
- "The repo's checks finished after the review of your branch, and "
1849
- f"failed. Fix this along with the review's findings.\n\n{checks}")
1850
- return
1851
- except (AgentError, tmux.TmuxError):
1852
- pass # the supervisor hears instead
1853
- if supervisor_id is None or db.get_agent(supervisor_id) is None:
1854
- return
1855
- if review is not None and review.approved:
1856
- lead = (f"[brindle] The checks on `{ws.branch}` (workspace {ws.id}) FAILED, and the result "
1857
- f"arrived after reviewer {reviewer_id} had approved it: the approval rests on "
1858
- "the diff alone. The merge gate runs the checks again, so the branch won't "
1859
- "merge while they fail. Send this to the worker to fix, then have it reviewed "
1860
- "again.")
1861
- else:
1862
- lead = (f"[brindle] The checks on `{ws.branch}` (workspace {ws.id}) FAILED, and the result "
1863
- f"arrived after reviewer {reviewer_id} had finished, so the review didn't "
1864
- "take it into account. Send this to the worker to fix.")
1865
- gates.tell(db, supervisor_id, f"{lead}\n\n{checks}")
1866
-
1867
-
1868
1892
  # -- hook entry point --------------------------------------------------------
1869
1893
 
1870
1894
 
@@ -41,6 +41,7 @@ from dataclasses import dataclass
41
41
  from pathlib import Path
42
42
 
43
43
  from brindle.config import CONFIG_DIR, WEIGHTS, RepoConfig, config_root, load_repo_config
44
+ from brindle import escapes
44
45
  from brindle.db import DB, Agent, Autopilot, Milestone, Workspace
45
46
  from brindle.profiles import load_profile
46
47
 
@@ -67,7 +68,9 @@ to be asked each step.
67
68
  - The goal: if none is set yet, ask the user what we're building. Turn the
68
69
  answer into a goal with 2-6 milestones, each with a `check` command brindle can
69
70
  run from the root of this checkout that exits 0 only when that milestone is
70
- done (e.g. `uv run pytest tests/test_settings.py -q`). Record it with
71
+ done (e.g. `uv run pytest tests/test_settings.py -q`). A check must test what
72
+ the milestone promises, not just that the suite passes: name the tests that
73
+ cover its acceptance criteria. Record it with
71
74
  `set_goal`, tell the user the plan in a few lines, then start. If
72
75
  `.brindle/goals.md` exists, brindle has already loaded it: call `get_progress`.
73
76
  - Work on the first unverified milestone. Split it into independent tasks and
@@ -90,7 +93,8 @@ to be asked each step.
90
93
  - After merging, call `check_milestone`. Only brindle's check marks a milestone
91
94
  done: never claim one is done yourself. When every milestone passes, brindle
92
95
  audits the work against the goal; the goal is reached only when that audit
93
- approves. Treat any gaps it finds as unfinished work.
96
+ approves. Treat any gaps it finds as unfinished work, and tighten (with
97
+ `set_goal`) the check that let each gap through.
94
98
  - Keep going until every milestone passes. If you stop early, brindle will ask
95
99
  you to continue. Stop only when you are blocked on something only the user
96
100
  can decide: call `need_user` with the question, then ask it.
@@ -501,7 +505,8 @@ def _route_by_weight(db: DB, cfg: RepoConfig, repo_root: str, weight: str, task:
501
505
  decision["baseline"] = remaining[0]
502
506
  why = f"weight {weight} -> {name}"
503
507
  picked = f"learning picked it: {reason}" if reason else "learning picked it"
504
- detail = [picked if pick else "", *skipped]
508
+ kept = learning.kept_note(cfg, repo_root)
509
+ detail = [picked if pick else (f"learning kept it: {kept}" if kept else ""), *skipped]
505
510
  detail = [d for d in detail if d]
506
511
  if detail:
507
512
  why += f" ({', '.join(detail)})"
@@ -601,18 +606,63 @@ def tail(text: str, lines: int = OUTPUT_TAIL_LINES) -> str:
601
606
  return "\n".join(out[-lines:])
602
607
 
603
608
 
604
- def run_check(cmd: str, cwd: str, env: dict[str, str], timeout: int) -> tuple[bool, str]:
605
- """Run one check command. Returns (passed, the tail of its output)."""
606
- try:
607
- proc = subprocess.run(
608
- cmd, shell=True, cwd=cwd, env={**os.environ, **env}, capture_output=True,
609
- text=True, timeout=timeout, stdin=subprocess.DEVNULL,
609
+ class CheckCancelled(Exception):
610
+ """``cancel()`` turned true while a check command was running."""
611
+
612
+
613
+ KILL_WAIT = 5.0 # seconds to wait for a killed check's pipes to close
614
+ CANCEL_POLL = 1.0 # seconds between looks at ``cancel`` while a check runs
615
+
616
+
617
+ def run_check(cmd: str, cwd: str, env: dict[str, str], timeout: int,
618
+ cancel=None) -> tuple[bool, str]:
619
+ """Run one check command. Returns (passed, the tail of its output).
620
+ ``cancel``, if given, is polled while the command runs; when it turns
621
+ true the command is killed and ``CheckCancelled`` is raised."""
622
+ if cancel is None:
623
+ try:
624
+ proc = subprocess.run(
625
+ cmd, shell=True, cwd=cwd, env={**os.environ, **env}, capture_output=True,
626
+ text=True, timeout=timeout, stdin=subprocess.DEVNULL,
627
+ )
628
+ except subprocess.TimeoutExpired:
629
+ return False, f"$ {cmd}\n(timed out after {timeout}s)"
630
+ stdout, stderr, returncode = proc.stdout, proc.stderr, proc.returncode
631
+ else:
632
+ proc = subprocess.Popen(
633
+ cmd, shell=True, cwd=cwd, env={**os.environ, **env}, stdout=subprocess.PIPE,
634
+ stderr=subprocess.PIPE, text=True, stdin=subprocess.DEVNULL, start_new_session=True,
610
635
  )
611
- except subprocess.TimeoutExpired:
612
- return False, f"$ {cmd}\n(timed out after {timeout}s)"
613
- output = tail((proc.stdout or "") + (proc.stderr or ""))
614
- status = "" if proc.returncode == 0 else f"(exit {proc.returncode})"
615
- return proc.returncode == 0, "\n".join(p for p in (f"$ {cmd}", output, status) if p)
636
+ deadline = time.monotonic() + timeout
637
+ while True:
638
+ try:
639
+ stdout, stderr = proc.communicate(timeout=CANCEL_POLL)
640
+ break
641
+ except subprocess.TimeoutExpired:
642
+ pass
643
+ timed_out = time.monotonic() >= deadline
644
+ if timed_out or cancel():
645
+ _kill_group(proc)
646
+ try:
647
+ proc.communicate(timeout=KILL_WAIT)
648
+ except subprocess.TimeoutExpired:
649
+ pass # a grandchild still holds the pipes; don't hang on it
650
+ if timed_out:
651
+ return False, f"$ {cmd}\n(timed out after {timeout}s)"
652
+ raise CheckCancelled(cmd)
653
+ returncode = proc.returncode
654
+ output = tail((stdout or "") + (stderr or ""))
655
+ status = "" if returncode == 0 else f"(exit {returncode})"
656
+ return returncode == 0, "\n".join(p for p in (f"$ {cmd}", output, status) if p)
657
+
658
+
659
+ def _kill_group(proc: subprocess.Popen) -> None:
660
+ import signal
661
+
662
+ try:
663
+ os.killpg(proc.pid, signal.SIGKILL)
664
+ except (ProcessLookupError, PermissionError):
665
+ proc.kill()
616
666
 
617
667
 
618
668
  def checking(ap: Autopilot, timeout: float = 900.0) -> bool:
@@ -709,6 +759,11 @@ def check_milestones(db: DB, root_id: str, ws: Workspace, position: int | None =
709
759
  # Not if it already passed at this very commit: a flaky check
710
760
  # flipping back isn't progress.
711
761
  newly_passed |= ok and m.status != "passed" and (sha is None or m.passed_sha != sha)
762
+ if not ok and m.status == "passed" and sha and m.checked_sha:
763
+ # Passed at the commit before, fails now: whatever merged in
764
+ # between got past its reviewer.
765
+ escapes.milestone_regressed(db, checkout.repo_root, checkout.path, m.title,
766
+ m.checked_sha, sha)
712
767
  db.record_check(m.id, ok, out, sha)
713
768
 
714
769
  run(chosen)
@@ -884,6 +939,9 @@ def record_audit(db: DB, root_id: str, approved: bool, summary: str) -> str:
884
939
  f"\n\n{summary}")
885
940
  db.update_autopilot(root_id, state="running", note=summary.strip()[:4000], audit_ok=0,
886
941
  auditor_id=None)
942
+ audit_ws = db.get_workspace(root.workspace_id) if root else None
943
+ if audit_ws is not None:
944
+ escapes.audit_gaps(db, audit_ws.repo_root, summary)
887
945
  return (f"[brindle] Completion audit of {sha}: GAPS FOUND. The goal isn't reached yet, even "
888
946
  "though every milestone's check passes. Close each gap below (and where a check let "
889
947
  "a gap through, tighten it with set_goal), then call check_milestone to re-audit."
@@ -25,7 +25,7 @@ from __future__ import annotations
25
25
  import logging
26
26
  import statistics
27
27
  import time
28
- from dataclasses import dataclass
28
+ from dataclasses import dataclass, field
29
29
 
30
30
  from brindle import pricing
31
31
  from brindle.db import DB
@@ -47,10 +47,34 @@ class Limits:
47
47
  stop: bool = False # close a worker that reaches task_usd (default: only warn)
48
48
  seat_spent_usd: float | None = None # this month's spend as the org counts it (org budgets)
49
49
  org: bool = False # an org budget is part of these limits: it can't be skipped for want of a price
50
+ hide_dollars: bool = False # the org hides this person's org spend and limits: words, not dollars
51
+
52
+ org_caps: dict = field(default_factory=dict) # the org's own value per limit key, before a repo tightened it
50
53
 
51
54
  def any(self) -> bool:
52
55
  return any(getattr(self, k) is not None for k in KEYS)
53
56
 
57
+ def hides(self, key: str) -> bool:
58
+ """Whether ``key``'s dollars are the org's to hide. A stricter personal
59
+ (repo) limit is the person's own: it stays in dollars."""
60
+ if not self.hide_dollars:
61
+ return False
62
+ cap, mine = self.org_caps.get(key), getattr(self, key)
63
+ return cap is not None and (mine is None or mine >= cap)
64
+
65
+
66
+ AT_LIMIT, CLOSE, WITHIN = "at limit", "getting close", "within limit"
67
+
68
+
69
+ def status_word(spent: float, limit: float | None) -> str:
70
+ """"within limit", "getting close" (at ``WARN_AT`` of ``limit`` or more) or
71
+ "at limit" (what is shown instead of org dollars when the org hides them)."""
72
+ if limit is None:
73
+ return WITHIN
74
+ if limit <= 0 or spent >= limit:
75
+ return AT_LIMIT
76
+ return CLOSE if spent >= limit * WARN_AT else WITHIN
77
+
54
78
 
55
79
  def parse(raw: object) -> Limits:
56
80
  """``Limits`` from a config value; anything malformed or non-positive is ignored."""
@@ -102,7 +126,8 @@ def org_limits(repo_root: str | None) -> Limits | None:
102
126
  if spent is not None and p.spend_month != time.strftime("%Y-%m", time.gmtime()):
103
127
  spent = None # last month's number
104
128
  lim = Limits(task_usd=p.budget_task_usd, goal_usd=p.budget_goal_usd,
105
- month_usd=p.budget_seat_month_usd, seat_spent_usd=spent)
129
+ month_usd=p.budget_seat_month_usd, seat_spent_usd=spent,
130
+ hide_dollars=p.visibility.get("own_dollars") is False)
106
131
  return lim if lim.any() else None
107
132
 
108
133
 
@@ -130,8 +155,13 @@ def limits(cfg, repo_root: str | None = None) -> Limits | None:
130
155
  lim.goal_usd = _tighter(lim.goal_usd, org.goal_usd)
131
156
  lim.month_usd = _tighter(lim.month_usd, org.month_usd)
132
157
  lim.seat_spent_usd = org.seat_spent_usd
158
+ lim.hide_dollars = org.hide_dollars
159
+ lim.org_caps = {k: getattr(org, k) for k in KEYS}
133
160
  if lim.month_usd is not None:
134
- lim.month_usd += _approved_month() # an admin's cost-center approval (Enterprise)
161
+ approved = _approved_month() # an admin's cost-center approval (Enterprise)
162
+ lim.month_usd += approved
163
+ if lim.org_caps.get("month_usd") is not None:
164
+ lim.org_caps["month_usd"] += approved
135
165
  return lim if lim.any() else None
136
166
 
137
167
 
@@ -266,18 +296,25 @@ class Gate:
266
296
  return f"{profile} has no known price, so it can't be shown to fit the org's budget"
267
297
  return None
268
298
  m = pricing.money
299
+ # the org hides its dollars from this person: say it in words (a personal limit stays in dollars)
269
300
  if lim.task_usd is not None and est > lim.task_usd:
301
+ if lim.hides("task_usd"):
302
+ return f"a {profile} task would go over the task budget (at limit)"
270
303
  return f"a {profile} task costs about {m(est)}, over the {m(lim.task_usd)} task budget"
271
304
  if lim.goal_usd is not None:
272
305
  spent, cap = self.goal_spend(), self.goal_limit()
273
306
  if spent is None:
274
307
  return f"the goal's spend can't be totalled, so {profile} can't be shown to fit its budget"
275
308
  if spent + est > cap:
309
+ if lim.hides("goal_usd"):
310
+ return f"{profile} would take the goal over its budget (at limit)"
276
311
  return (f"{profile} at about {m(est)} would take the goal to {m(spent + est)}, "
277
312
  f"over its {m(cap)} budget")
278
313
  if lim.month_usd is not None:
279
314
  spent = self.month_spend()
280
315
  if spent + est > lim.month_usd:
316
+ if lim.hides("month_usd"):
317
+ return f"{profile} would take this month over the budget (at limit)"
281
318
  return (f"{profile} at about {m(est)} would take this month to {m(spent + est)}, "
282
319
  f"over the {m(lim.month_usd)} budget")
283
320
  return None
@@ -326,7 +363,9 @@ def sweep(db: DB, now: float | None = None) -> list[str]:
326
363
  m = pricing.money
327
364
  try:
328
365
  if spent >= lim.task_usd:
329
- note = (f"[brindle budget] worker {agent.id} has spent about {m(spent)}, "
366
+ note = (f"[brindle budget] worker {agent.id} is at limit on its task budget."
367
+ if lim.hides("task_usd") else
368
+ f"[brindle budget] worker {agent.id} has spent about {m(spent)}, "
330
369
  f"its {m(lim.task_usd)} task budget.")
331
370
  if lim.stop:
332
371
  try:
@@ -344,7 +383,9 @@ def sweep(db: DB, now: float | None = None) -> list[str]:
344
383
  done.append(note)
345
384
  elif spent >= lim.task_usd * WARN_AT and not r.budget_state:
346
385
  db.set_budget_state(agent.id, "warned")
347
- note = (f"[brindle budget] worker {agent.id} has spent about {m(spent)}, over "
386
+ note = (f"[brindle budget] worker {agent.id} is getting close to its task budget."
387
+ if lim.hides("task_usd") else
388
+ f"[brindle budget] worker {agent.id} has spent about {m(spent)}, over "
348
389
  f"{round(WARN_AT * 100)}% of its {m(lim.task_usd)} task budget.")
349
390
  _tell(db, agent, note)
350
391
  done.append(note)
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: developer-codex
3
+ description: Implements a well-scoped coding task on its own branch, using Codex (the medium tier of weight routing)
4
+ extends: developer
5
+ provider: codex
6
+ ---
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: developer-heavy
3
+ description: Implements a design-heavy or subtle task on its own branch, on Fable with high effort (the heavy tier of weight routing)
4
+ extends: developer
5
+ model: claude-fable-5-1
6
+ effort: high
7
+ ---
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: developer-light
3
+ description: Implements a small, well-specified task on its own branch, on Sonnet with low effort (the baseline of the light tier of weight routing)
4
+ extends: developer
5
+ model: sonnet
6
+ effort: low
7
+ ---