raidlock 0.1.0__py3-none-any.whl

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 (126) hide show
  1. raidlock/__init__.py +11 -0
  2. raidlock/__main__.py +5 -0
  3. raidlock/_python.py +58 -0
  4. raidlock/achievements.py +156 -0
  5. raidlock/approvals.py +237 -0
  6. raidlock/bench.py +286 -0
  7. raidlock/bridge/__init__.py +186 -0
  8. raidlock/budgets.py +178 -0
  9. raidlock/cli.py +2153 -0
  10. raidlock/commands_install.py +114 -0
  11. raidlock/core/__init__.py +26 -0
  12. raidlock/core/advisories.py +16 -0
  13. raidlock/core/billing.py +54 -0
  14. raidlock/core/board.py +2251 -0
  15. raidlock/core/brief.py +111 -0
  16. raidlock/core/clock.py +40 -0
  17. raidlock/core/cost.py +198 -0
  18. raidlock/core/db.py +608 -0
  19. raidlock/core/errors.py +52 -0
  20. raidlock/core/exe.py +6 -0
  21. raidlock/core/hints.py +32 -0
  22. raidlock/core/identity.py +285 -0
  23. raidlock/core/lead.py +758 -0
  24. raidlock/core/lead_env.py +23 -0
  25. raidlock/core/lead_hook.py +42 -0
  26. raidlock/core/leases.py +19 -0
  27. raidlock/core/lock.py +99 -0
  28. raidlock/core/messages.py +98 -0
  29. raidlock/core/migrate.py +579 -0
  30. raidlock/core/model.py +126 -0
  31. raidlock/core/models.py +109 -0
  32. raidlock/core/paths.py +61 -0
  33. raidlock/core/portable.py +53 -0
  34. raidlock/core/receipts.py +73 -0
  35. raidlock/core/registry.py +39 -0
  36. raidlock/core/repos.py +250 -0
  37. raidlock/core/safeio.py +201 -0
  38. raidlock/core/status.py +52 -0
  39. raidlock/core/store.py +107 -0
  40. raidlock/core/tokens.py +77 -0
  41. raidlock/core/transcript.py +208 -0
  42. raidlock/duel.py +371 -0
  43. raidlock/foresight.py +191 -0
  44. raidlock/frames.py +162 -0
  45. raidlock/gitguard.py +753 -0
  46. raidlock/guard.py +239 -0
  47. raidlock/hooks.py +1526 -0
  48. raidlock/infer.py +205 -0
  49. raidlock/limits.py +685 -0
  50. raidlock/lore/__init__.py +22 -0
  51. raidlock/lore/approval.py +145 -0
  52. raidlock/lore/capture.py +146 -0
  53. raidlock/lore/cli.py +109 -0
  54. raidlock/lore/distill.py +17 -0
  55. raidlock/lore/frontmatter.py +56 -0
  56. raidlock/lore/inject.py +127 -0
  57. raidlock/lore/vault.py +264 -0
  58. raidlock/mcp/__init__.py +34 -0
  59. raidlock/mcp/resources.py +72 -0
  60. raidlock/mcp/server.py +184 -0
  61. raidlock/mcp/tools.py +178 -0
  62. raidlock/notify.py +144 -0
  63. raidlock/party.py +239 -0
  64. raidlock/pr.py +575 -0
  65. raidlock/prsync.py +318 -0
  66. raidlock/recipes/README.md +34 -0
  67. raidlock/recipes/__init__.py +105 -0
  68. raidlock/recipes/docs-refresh.md +15 -0
  69. raidlock/recipes/multi-repo-refactor.md +15 -0
  70. raidlock/recipes/security-audit.md +27 -0
  71. raidlock/recipes/upgrade-deps.md +15 -0
  72. raidlock/reputation.py +194 -0
  73. raidlock/resume.py +329 -0
  74. raidlock/resurrect.py +419 -0
  75. raidlock/routing.py +419 -0
  76. raidlock/runners/__init__.py +16 -0
  77. raidlock/runners/base.py +341 -0
  78. raidlock/runners/brief.py +176 -0
  79. raidlock/runners/claude_code.py +121 -0
  80. raidlock/runners/cli.py +110 -0
  81. raidlock/runners/codex.py +114 -0
  82. raidlock/runners/gemini.py +64 -0
  83. raidlock/runners/isolation.py +391 -0
  84. raidlock/runners/policy.py +123 -0
  85. raidlock/runners/runner.py +921 -0
  86. raidlock/runners/shell.py +35 -0
  87. raidlock/runners/verifycache.py +93 -0
  88. raidlock/runners/workspace.py +376 -0
  89. raidlock/scan.py +357 -0
  90. raidlock/snapshot.py +151 -0
  91. raidlock/treasury.py +210 -0
  92. raidlock/tui.py +223 -0
  93. raidlock/web/__init__.py +5 -0
  94. raidlock/web/achievements/view.css +19 -0
  95. raidlock/web/achievements/view.js +58 -0
  96. raidlock/web/bosses/view.css +19 -0
  97. raidlock/web/bosses/view.js +46 -0
  98. raidlock/web/cli.py +90 -0
  99. raidlock/web/data.py +687 -0
  100. raidlock/web/export.py +153 -0
  101. raidlock/web/frames/view.css +25 -0
  102. raidlock/web/frames/view.js +63 -0
  103. raidlock/web/graph/__init__.py +142 -0
  104. raidlock/web/graph/view.css +33 -0
  105. raidlock/web/graph/view.js +136 -0
  106. raidlock/web/raidmap/__init__.py +205 -0
  107. raidlock/web/raidmap/view.css +15 -0
  108. raidlock/web/raidmap/view.js +47 -0
  109. raidlock/web/replay/__init__.py +258 -0
  110. raidlock/web/replay/view.css +13 -0
  111. raidlock/web/replay/view.js +127 -0
  112. raidlock/web/server.py +292 -0
  113. raidlock/web/static/app.css +851 -0
  114. raidlock/web/static/app.js +2102 -0
  115. raidlock/web/static/fonts/OFL.txt +93 -0
  116. raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
  117. raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
  118. raidlock/web/static/icon-32.png +0 -0
  119. raidlock/web/static/icon-raid.svg +1 -0
  120. raidlock/web/static/icon.svg +1 -0
  121. raidlock/web/static/index.html +122 -0
  122. raidlock-0.1.0.dist-info/METADATA +186 -0
  123. raidlock-0.1.0.dist-info/RECORD +126 -0
  124. raidlock-0.1.0.dist-info/WHEEL +4 -0
  125. raidlock-0.1.0.dist-info/entry_points.txt +34 -0
  126. raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/routing.py ADDED
@@ -0,0 +1,419 @@
1
+ """Provider routing: `raid next` suggests the next claimable card and the provider and model to run it on.
2
+
3
+ Rules live in `.board/routing.json` and are written only by the lead. Routing reads plan headroom from
4
+ `raidlock.limits`; it never claims, delegates or changes a card, so every board gate still applies."""
5
+
6
+ import copy
7
+ import fnmatch
8
+ import json
9
+ import re
10
+
11
+ from . import limits as LM
12
+ from .core import paths as P
13
+ from .core import store
14
+ from .core.errors import Precondition
15
+ from .core.hints import lead_cmd, lead_secret_src
16
+ from .core.identity import RUNTIME_SHORT, short_runtime
17
+ from .core.model import card_id, line_text
18
+
19
+ PROVIDERS = ("claude", "codex", "gemini", "shell")
20
+ QUOTAS = ("plan", "none")
21
+ FILE = "routing.json"
22
+ RULE_KEYS = ("name", "label", "path", "team", "provider", "model", "fallback")
23
+ MODEL_ID = re.compile(r"^[A-Za-z0-9._:/@-]{1,100}$")
24
+ LABEL = re.compile(r"^[a-z0-9][a-z0-9_-]{0,39}$")
25
+ WEEKLY_MINUTES = 10080
26
+ FIVE_HOUR_MINUTES = 300
27
+ DEFAULTS = {
28
+ "rules": [],
29
+ "labels": {},
30
+ "default": {"provider": "", "model": "", "fallback": []},
31
+ "weekly_max": 90,
32
+ "five_hour_max": 95,
33
+ "quota": {"shell": "none"},
34
+ }
35
+
36
+
37
+ def config(board):
38
+ """The routing config merged over the defaults."""
39
+ found = store.read_doc(board._p(FILE), "routing")
40
+ out = copy.deepcopy(DEFAULTS)
41
+ if not isinstance(found, dict):
42
+ raise Precondition(f"corrupt {FILE}: expected an object")
43
+ if not all(isinstance(found.get(k) or {}, dict) for k in ("quota", "labels")):
44
+ raise Precondition(f"corrupt {FILE}: quota and labels must be objects")
45
+ for key in ("rules", "default", "weekly_max", "five_hour_max"):
46
+ if key in found:
47
+ out[key] = validate(key, found[key])
48
+ for name, value in (found.get("quota") or {}).items():
49
+ out["quota"][name] = validate("quota." + name, value)
50
+ for cid, value in (found.get("labels") or {}).items():
51
+ out["labels"][cid] = validate("label." + cid, value)
52
+ return out
53
+
54
+
55
+ def _provider(value, key):
56
+ value = short_runtime(value) if isinstance(value, str) else value
57
+ if value not in PROVIDERS:
58
+ raise Precondition(f"{key} must be one of {', '.join(PROVIDERS)}", key=key)
59
+ return value
60
+
61
+
62
+ def _providers(value, key):
63
+ if not isinstance(value, list):
64
+ raise Precondition(f"{key} must be a list of providers", key=key)
65
+ out = []
66
+ for item in value:
67
+ item = _provider(item, key)
68
+ if item not in out:
69
+ out.append(item)
70
+ return out
71
+
72
+
73
+ def _model(value, key):
74
+ if value in (None, ""):
75
+ return ""
76
+ if not isinstance(value, str) or not MODEL_ID.match(value):
77
+ raise Precondition(f"{key} is not a valid model id", key=key)
78
+ return value
79
+
80
+
81
+ def _percent(value, key):
82
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 < value <= 100:
83
+ raise Precondition(f"{key} must be a number in (0, 100]", key=key)
84
+ return value
85
+
86
+
87
+ def _labels(value, key):
88
+ if not isinstance(value, list):
89
+ raise Precondition(f"{key} must be a list of labels", key=key)
90
+ for item in value:
91
+ if not isinstance(item, str) or not LABEL.match(item):
92
+ raise Precondition(f"{key}: invalid label {item!r}; use [a-z0-9_-], max 40", key=key)
93
+ return sorted(set(value))
94
+
95
+
96
+ def check_rule(rule, index=0):
97
+ """Validate one rule: a match (label, path or team), a provider, an optional model and fallbacks."""
98
+ key = f"rules[{index}]"
99
+ if not isinstance(rule, dict) or set(rule) - set(RULE_KEYS):
100
+ raise Precondition(f"{key} must be an object with keys from {', '.join(RULE_KEYS)}", key=key)
101
+ if not any(rule.get(k) for k in ("label", "path", "team")):
102
+ raise Precondition(f"{key} needs label, path or team to match", key=key)
103
+ out = {"name": line_text(rule.get("name") or f"rule-{index + 1}", key + ".name", 80)}
104
+ if rule.get("label"):
105
+ out["label"] = _labels([rule["label"]], key + ".label")[0]
106
+ if rule.get("path"):
107
+ raw = str(rule["path"]).strip()
108
+ P.canonical(re.sub(r"[*?\[\]]", "x", raw))
109
+ out["path"] = raw.rstrip("/")
110
+ if rule.get("team"):
111
+ out["team"] = line_text(rule["team"], key + ".team", 40)
112
+ out["provider"] = _provider(rule.get("provider"), key + ".provider")
113
+ out["model"] = _model(rule.get("model"), key + ".model")
114
+ out["fallback"] = [p for p in _providers(rule.get("fallback") or [], key + ".fallback") if p != out["provider"]]
115
+ return out
116
+
117
+
118
+ def validate(key, value):
119
+ """Validate one config key and return the value to store; None clears it."""
120
+ if key == "rules":
121
+ if value is None:
122
+ return []
123
+ if not isinstance(value, list):
124
+ raise Precondition("rules must be a list")
125
+ return [check_rule(rule, i) for i, rule in enumerate(value)]
126
+ if key == "default":
127
+ if value is None:
128
+ return copy.deepcopy(DEFAULTS["default"])
129
+ if not isinstance(value, dict) or set(value) - {"provider", "model", "fallback"}:
130
+ raise Precondition("default must be an object with provider, model and fallback")
131
+ provider = _provider(value["provider"], "default.provider") if value.get("provider") else ""
132
+ return {
133
+ "provider": provider,
134
+ "model": _model(value.get("model"), "default.model"),
135
+ "fallback": _providers(value.get("fallback") or [], "default.fallback"),
136
+ }
137
+ if key in ("weekly_max", "five_hour_max"):
138
+ return DEFAULTS[key] if value is None else _percent(value, key)
139
+ if key.startswith("quota."):
140
+ _provider(key[len("quota.") :], key)
141
+ if value is not None and value not in QUOTAS:
142
+ raise Precondition(f"{key} must be one of {', '.join(QUOTAS)}", key=key)
143
+ return value
144
+ if key.startswith("label."):
145
+ card_id(key[len("label.") :])
146
+ return None if value is None else _labels(value, key)
147
+ raise Precondition(
148
+ f"unknown routing key {key}; known: rules, default, weekly_max, five_hour_max, quota.<provider>, label.<card>",
149
+ key=key,
150
+ )
151
+
152
+
153
+ def set_config(board, caller, key, value):
154
+ """Lead-only: validate and store one routing key under the board lock."""
155
+ value = validate(key, value)
156
+ if key.startswith("label."):
157
+ board.card(key[len("label.") :])
158
+ with board.lead_write(caller, f"change routing {key}"):
159
+ cfg = config(board)
160
+ if key.startswith("quota."):
161
+ cfg["quota"] = dict(cfg.get("quota") or {})
162
+ if value is None:
163
+ cfg["quota"].pop(key[len("quota.") :], None)
164
+ else:
165
+ cfg["quota"][key[len("quota.") :]] = value
166
+ elif key.startswith("label."):
167
+ cfg["labels"] = dict(cfg.get("labels") or {})
168
+ if value:
169
+ cfg["labels"][key[len("label.") :]] = value
170
+ else:
171
+ cfg["labels"].pop(key[len("label.") :], None)
172
+ else:
173
+ cfg[key] = value
174
+ store.write_doc(board._p(FILE), "routing", cfg)
175
+ board.index.bump(board.uid)
176
+ return {"key": key, "value": value, "routing": cfg}
177
+
178
+
179
+ def labels_of(cfg, card):
180
+ return list((cfg.get("labels") or {}).get(card["id"], []))
181
+
182
+
183
+ def _path_match(pattern, lock):
184
+ if any(ch in pattern for ch in "*?["):
185
+ return fnmatch.fnmatchcase(lock, pattern) or fnmatch.fnmatchcase(lock + "/", pattern)
186
+ return P.overlaps(pattern, lock)
187
+
188
+
189
+ def matches(rule, card, cfg):
190
+ if rule.get("label") and rule["label"] not in labels_of(cfg, card):
191
+ return False
192
+ if rule.get("team") and rule["team"] != card.get("team"):
193
+ return False
194
+ return not (rule.get("path") and not any(_path_match(rule["path"], lock) for lock in card.get("locks", [])))
195
+
196
+
197
+ def rule_for(cfg, card):
198
+ return next((r for r in cfg.get("rules") or [] if matches(r, card, cfg)), None)
199
+
200
+
201
+ def _window_kind(window):
202
+ minutes = window.get("window_minutes") or 0
203
+ if minutes >= WEEKLY_MINUTES:
204
+ return "weekly"
205
+ if 0 < minutes <= FIVE_HOUR_MINUTES:
206
+ return "5h"
207
+ return "other"
208
+
209
+
210
+ def headroom(provider, cfg, collected):
211
+ """{provider, ok, left, reason, exhausted, retry_after}. Unknown or stale limits count as no headroom."""
212
+ if (cfg.get("quota") or {}).get(provider) == "none":
213
+ return {"provider": provider, "ok": True, "left": 100.0, "reason": "no plan quota", "exhausted": False}
214
+ found = next((p for p in (collected or {}).get("providers", []) if p.get("provider") == provider), None)
215
+ status = (found or {}).get("status", "unavailable")
216
+ windows = (found or {}).get("windows") or []
217
+ base = {"provider": provider, "ok": False, "left": 0.0, "exhausted": False}
218
+ if status != "ok" or not windows:
219
+ detail = status if status != "ok" else "no windows"
220
+ return {**base, "reason": f"{provider} headroom unknown ({detail}); treated as no headroom"}
221
+ resets = [w.get("resets_in_seconds") for w in windows if isinstance(w.get("resets_in_seconds"), int)]
222
+ if found.get("limit_reached"):
223
+ return {
224
+ **base,
225
+ "exhausted": True,
226
+ "retry_after": max(resets) if resets else None,
227
+ "reason": f"{provider} quota exhausted ({found['limit_reached']})",
228
+ }
229
+ five = None
230
+ for w in windows:
231
+ kind, used = _window_kind(w), w.get("used_percent")
232
+ if not isinstance(used, (int, float)):
233
+ continue
234
+ if kind == "weekly" and used >= cfg["weekly_max"]:
235
+ return {
236
+ **base,
237
+ "exhausted": used >= 100,
238
+ "retry_after": w.get("resets_in_seconds"),
239
+ "reason": f"{provider} weekly window {used:g}% used (no new work at {cfg['weekly_max']:g}% or more)",
240
+ }
241
+ if kind == "5h":
242
+ five = used
243
+ if used >= cfg["five_hour_max"]:
244
+ return {
245
+ **base,
246
+ "retry_after": w.get("resets_in_seconds"),
247
+ "reason": f"{provider} 5h window {used:g}% used; wait for the reset",
248
+ }
249
+ peak = max((w["used_percent"] for w in windows if isinstance(w.get("used_percent"), (int, float))), default=0)
250
+ left = 100.0 - (five if five is not None else peak)
251
+ return {**base, "ok": True, "left": left, "reason": f"{provider} has {left:g}% 5h headroom"}
252
+
253
+
254
+ def route(board, card, provider=None, exclude=(), collected=None, cfg=None):
255
+ """Pick a provider and model for `card`. Returns {provider, model, rule, reason, checked}; provider is "" when
256
+ nothing has headroom. An explicit `provider` is checked but never swapped."""
257
+ cfg = cfg or config(board)
258
+ collected = LM.collect() if collected is None else collected
259
+ rule = rule_for(cfg, card)
260
+ base = rule or cfg.get("default") or {}
261
+ if provider:
262
+ candidates = [_provider(provider, "provider")]
263
+ ranked = False
264
+ elif base.get("provider"):
265
+ candidates = [base["provider"], *base.get("fallback", [])]
266
+ ranked = False
267
+ else:
268
+ candidates = list(base.get("fallback") or []) or [p for p in PROVIDERS if p != "shell"]
269
+ ranked = True
270
+ candidates = [p for i, p in enumerate(candidates) if p not in exclude and p not in candidates[:i]]
271
+ checked = [headroom(p, cfg, collected) for p in candidates]
272
+ usable = [c for c in checked if c["ok"]]
273
+ if ranked:
274
+ usable.sort(key=lambda c: -c["left"])
275
+ pick = usable[0] if usable else None
276
+ model = ""
277
+ if pick and pick["provider"] == base.get("provider"):
278
+ model = base.get("model", "")
279
+ return {
280
+ "card": card["id"],
281
+ "provider": pick["provider"] if pick else "",
282
+ "model": model,
283
+ "rule": (rule or {}).get("name", "default"),
284
+ "labels": labels_of(cfg, card),
285
+ "reason": pick["reason"] if pick else "; ".join(c["reason"] for c in checked) or "no candidate provider",
286
+ "checked": checked,
287
+ "fallback": bool(pick and checked and pick["provider"] != checked[0]["provider"]),
288
+ }
289
+
290
+
291
+ def allowed_for(cfg, card, provider):
292
+ """True when the card's rule (if any) lets `provider` run it."""
293
+ rule = rule_for(cfg, card)
294
+ return rule is None or provider in [rule["provider"], *rule.get("fallback", [])]
295
+
296
+
297
+ def caller_provider(board, caller):
298
+ record = board.agents().get(caller.name, {}) if caller.name and not caller.is_lead else {}
299
+ short = RUNTIME_SHORT.get(record.get("runtime", ""), "")
300
+ return short if short in PROVIDERS else ""
301
+
302
+
303
+ def suggest(board, caller, provider=None, for_review=False, collected=None):
304
+ """The next card for the caller with its route. Read-only: no claim, no lease, no card change."""
305
+ cfg = config(board)
306
+ collected = LM.collect() if collected is None else collected
307
+ agent = caller.name if caller.name and not caller.is_lead else None
308
+ provider = provider or caller_provider(board, caller)
309
+ if provider:
310
+ _provider(provider, "provider")
311
+ room = headroom(provider, cfg, collected)
312
+ if not room["ok"]:
313
+ raise Precondition(
314
+ f"no new work routed to {provider}: {room['reason']}",
315
+ provider=provider,
316
+ retry_after=room.get("retry_after"),
317
+ hint=_headroom_hint(board) if "headroom unknown" in room["reason"] else "raidlock limits",
318
+ )
319
+ queue = board.claimable(agent=agent, for_review=for_review)
320
+ cards = {c["id"]: c for c in board.cards()}
321
+ open_ids = [cid for cid, v in queue.items() if v["ok"] and cid in cards]
322
+ open_ids.sort(key=lambda cid: (cards[cid]["lane"] != "todo", cards[cid].get("created", ""), cid))
323
+ skipped = []
324
+ agents = board.agents()
325
+ for cid in open_ids:
326
+ card = cards[cid]
327
+ exclude = ()
328
+ if for_review:
329
+ owner_short = RUNTIME_SHORT.get(agents.get(card.get("owner"), {}).get("runtime", ""), "")
330
+ exclude = (owner_short,) if owner_short in PROVIDERS and owner_short != "shell" else ()
331
+ if provider and provider in exclude:
332
+ skipped.append(f"{cid}: review needs a provider other than {provider}")
333
+ continue
334
+ elif provider and not allowed_for(cfg, card, provider):
335
+ skipped.append(f"{cid}: rule {rule_for(cfg, card)['name']} routes it elsewhere")
336
+ continue
337
+ r = route(board, card, provider=provider, exclude=exclude, collected=collected, cfg=cfg)
338
+ if not r["provider"]:
339
+ skipped.append(f"{cid}: {r['reason']}")
340
+ continue
341
+ return {
342
+ **r,
343
+ "title": card["title"],
344
+ "lane": card["lane"],
345
+ "command": _command(r, agent, for_review, board if caller.is_lead else None),
346
+ }
347
+ reason = "; ".join(skipped[:5]) if skipped else ("nothing waiting for review" if for_review else "nothing open")
348
+ unknown = any("headroom unknown" in item for item in skipped)
349
+ raise Precondition(f"no card to route: {reason}", hint=_headroom_hint(board) if unknown else "raidlock claimable")
350
+
351
+
352
+ def _headroom_hint(board):
353
+ return (
354
+ "raidlock limits shows what is known; run raidlock limits --install-statusline once to record Claude Code "
355
+ "plan usage, or "
356
+ "mark a provider as having no plan quota: " + lead_cmd(board, "routing set quota.<provider> '\"none\"'")
357
+ )
358
+
359
+
360
+ def _command(r, agent, for_review, lead_board=None):
361
+ if agent:
362
+ return f"raidlock review {r['card']} --verdict approve" if for_review else f"raidlock claim {r['card']}"
363
+ parts = ["raidlock", "delegate", r["card"], "--to", r["provider"]]
364
+ if lead_board is not None:
365
+ parts[1:1] = ["--lead-secret", lead_secret_src(lead_board)]
366
+ if for_review:
367
+ parts += ["--as", "reviewer"]
368
+ if r.get("model"):
369
+ parts += ["--model", r["model"]]
370
+ return " ".join(parts)
371
+
372
+
373
+ def _next_setup(p):
374
+ p.add_argument(
375
+ "--provider", default=None, type=short_runtime, choices=PROVIDERS, help="route only to this provider"
376
+ )
377
+ p.add_argument("--for-review", action="store_true", help="suggest a card waiting for review instead")
378
+
379
+
380
+ def _next_handler(ctx, a):
381
+ r = suggest(ctx.board, ctx.caller(), provider=a.provider, for_review=a.for_review)
382
+ model = f" model {r['model']}" if r["model"] else ""
383
+ via = " (fallback)" if r["fallback"] else ""
384
+ text = f"next: {r['card']}: {r['title']}\nroute: {r['provider']}{model}{via}, rule {r['rule']}; {r['reason']}"
385
+ return r, text + f"\nNext: {r['command']}"
386
+
387
+
388
+ def _routing_setup(p):
389
+ p.add_argument("routing_action", nargs="?", default="show", choices=("show", "set", "explain"))
390
+ p.add_argument("key", nargs="?", help="config key for set, card id for explain")
391
+ p.add_argument("value", nargs="?", help="JSON value for set; null clears")
392
+
393
+
394
+ def _routing_handler(ctx, a):
395
+ if a.routing_action == "set":
396
+ if a.key is None or a.value is None:
397
+ raise Precondition("routing set needs a key and a JSON value")
398
+ try:
399
+ value = json.loads(a.value)
400
+ except ValueError:
401
+ raise Precondition("routing value must be valid JSON") from None
402
+ r = set_config(ctx.board, ctx.caller(), a.key, value)
403
+ return r, f"routing {a.key} set"
404
+ if a.routing_action == "explain":
405
+ if not a.key:
406
+ raise Precondition("routing explain needs a card id")
407
+ r = route(ctx.board, ctx.board.card(a.key))
408
+ lines = [f"{a.key}: rule {r['rule']} -> {r['provider'] or 'no provider'}"]
409
+ lines += [f" {c['provider']}: {'ok' if c['ok'] else 'no'}, {c['reason']}" for c in r["checked"]]
410
+ return r, "\n".join(lines)
411
+ cfg = config(ctx.board)
412
+ return {"routing": cfg}, json.dumps(cfg, indent=2, sort_keys=True)
413
+
414
+
415
+ def register_cli(add_command):
416
+ add_command("next", "suggest the next card for you and the provider to run it on", _next_setup, _next_handler)
417
+ add_command(
418
+ "routing", "show, set or explain provider routing rules", _routing_setup, _routing_handler, mutating=True
419
+ )
@@ -0,0 +1,16 @@
1
+ """Runners: delegate a card to an external agent CLI in an isolated clone, enforce its locks, hand it back."""
2
+
3
+ SAMPLE = (
4
+ "codex-worker-api handed api to review (3 file(s) changed, verify passed). "
5
+ "Patch: ~/ws/.board/runs/api/changes.patch\nNext: raidlock show api"
6
+ )
7
+
8
+
9
+ def register_tokens(register):
10
+ register(
11
+ "delegate output",
12
+ lambda board: {
13
+ "tokens": -(-len(SAMPLE) // 4),
14
+ "detail": "one line per delegate run; prompts and events stay on disk",
15
+ },
16
+ )