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.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/cli.py
ADDED
|
@@ -0,0 +1,2153 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import contextlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import sysconfig
|
|
10
|
+
import traceback
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .core import Board, Usage, clock, model, registry
|
|
14
|
+
from .core.db import Index
|
|
15
|
+
from .core.errors import IdentityError, Precondition, RaidlockError
|
|
16
|
+
from .core.hints import lead_cmd, lead_secret_src, quote_path
|
|
17
|
+
from .core.identity import detect_runtime, long_runtime, token_path
|
|
18
|
+
from .core.leases import describe, is_live
|
|
19
|
+
from .core.model import LANES
|
|
20
|
+
|
|
21
|
+
COMMANDS = {}
|
|
22
|
+
GROUPS = (
|
|
23
|
+
("Start", ("up", "quickstart", "doctor")),
|
|
24
|
+
("Plan", ("new", "plan")),
|
|
25
|
+
("Work", ("brief", "claim", "note", "move", "done")),
|
|
26
|
+
("Agents", ("delegate", "who", "lead")),
|
|
27
|
+
("Board", ("show", "ui")),
|
|
28
|
+
)
|
|
29
|
+
EXTENSION_MODULES = (
|
|
30
|
+
"raidlock.bench",
|
|
31
|
+
"raidlock.web.cli",
|
|
32
|
+
"raidlock.runners.cli",
|
|
33
|
+
"raidlock.routing",
|
|
34
|
+
"raidlock.resume",
|
|
35
|
+
"raidlock.duel",
|
|
36
|
+
"raidlock.resurrect",
|
|
37
|
+
"raidlock.approvals",
|
|
38
|
+
"raidlock.hooks",
|
|
39
|
+
"raidlock.mcp",
|
|
40
|
+
"raidlock.lore.cli",
|
|
41
|
+
"raidlock.scan",
|
|
42
|
+
"raidlock.pr",
|
|
43
|
+
"raidlock.prsync",
|
|
44
|
+
"raidlock.budgets",
|
|
45
|
+
"raidlock.limits",
|
|
46
|
+
"raidlock.notify",
|
|
47
|
+
"raidlock.snapshot",
|
|
48
|
+
"raidlock.guard",
|
|
49
|
+
"raidlock.infer",
|
|
50
|
+
"raidlock.foresight",
|
|
51
|
+
"raidlock.reputation",
|
|
52
|
+
"raidlock.recipes",
|
|
53
|
+
"raidlock.tui",
|
|
54
|
+
"raidlock.web.raidmap",
|
|
55
|
+
"raidlock.web.replay",
|
|
56
|
+
"raidlock.web.graph",
|
|
57
|
+
"raidlock.frames",
|
|
58
|
+
"raidlock.achievements",
|
|
59
|
+
"raidlock.treasury",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
RUNTIME_HELP = "claude-code (or claude), codex-cli (or codex), gemini-cli (or gemini), aider, shell, human"
|
|
64
|
+
FLAG_HELP = {
|
|
65
|
+
("*", "id"): "card id",
|
|
66
|
+
("*", "note"): "short note recorded on the card",
|
|
67
|
+
("*", "team"): "team name",
|
|
68
|
+
("*", "runtime"): "agent runtime: " + RUNTIME_HELP + " (default: detected)",
|
|
69
|
+
("*", "model"): "model id (default: detected)",
|
|
70
|
+
("*", "path"): "workspace folder (default: current folder)",
|
|
71
|
+
("*", "action"): "what to do",
|
|
72
|
+
("*", "interval"): "seconds between polls",
|
|
73
|
+
("*", "timeout"): "seconds before giving up",
|
|
74
|
+
("bench", "runs"): "samples per hook (more runs give steadier percentiles)",
|
|
75
|
+
("bench", "budget"): "JSON file of metric limits; exit 3 when one is exceeded",
|
|
76
|
+
("bench", "plugin_root"): "plugin checkout to measure (needed when installed from a wheel)",
|
|
77
|
+
("bench", "tokenizer"): "estimate (4 characters per token) or auto (a real tokenizer when installed)",
|
|
78
|
+
("brief", "id"): "card to brief a worker on",
|
|
79
|
+
("budget", "budget_action"): "show the budgets, or set one",
|
|
80
|
+
("check", "paths"): "comma-separated <repo>/<path> locks to test for conflicts",
|
|
81
|
+
("claim", "id"): "card to claim",
|
|
82
|
+
("claimable", "for_review"): "list cards waiting for review instead",
|
|
83
|
+
("config", "action"): "get a setting, or set one (lead)",
|
|
84
|
+
("config", "key"): "setting name, e.g. require_verify or require_review.count",
|
|
85
|
+
("config", "value"): "new value (set only)",
|
|
86
|
+
("cost", "id"): "card the usage belongs to (omit to show totals)",
|
|
87
|
+
("cost", "model"): "model id the usage was billed to",
|
|
88
|
+
("cost", "cache_write"): "cache-write tokens",
|
|
89
|
+
("cost", "billing"): "api, plan or unknown (default: the agent's billing)",
|
|
90
|
+
("cost", "duration_ms"): "run time in milliseconds",
|
|
91
|
+
("done", "id"): "card to close",
|
|
92
|
+
("edit", "id"): "card to change",
|
|
93
|
+
("foresight", "card"): "card whose locks to test against other open work",
|
|
94
|
+
("guard", "action"): "install, uninstall or check the git guard in this checkout",
|
|
95
|
+
("guard", "hook"): "which git hook to manage",
|
|
96
|
+
("heartbeat", "id"): "card whose lease to renew",
|
|
97
|
+
("heartbeat", "ttl"): "lease minutes (default: board ttl_minutes)",
|
|
98
|
+
("help", "topic"): "advanced, or a command name",
|
|
99
|
+
("inbox", "all"): "show read messages too",
|
|
100
|
+
("init", "path"): "workspace folder that holds your repos (default: current folder)",
|
|
101
|
+
("init", "model"): "the lead's model id (default: detected)",
|
|
102
|
+
("join", "team"): "team to join (cards on other teams are not claimable)",
|
|
103
|
+
("join", "role"): "worker claims cards, reviewer reviews them",
|
|
104
|
+
("join", "provider"): "provider: anthropic, openai, google, local or human (default: from the runtime)",
|
|
105
|
+
("join", "effort"): "reasoning effort the agent runs with, if any",
|
|
106
|
+
("join", "session"): "session id of the agent (default: from the runtime's environment)",
|
|
107
|
+
("lead", "action"): "status, heartbeat (renew), handover to another session, or takeover an expired lease",
|
|
108
|
+
("lock", "id"): "your card",
|
|
109
|
+
("lock", "paths"): "comma-separated <repo>/<path> locks to add",
|
|
110
|
+
("move", "id"): "card to move",
|
|
111
|
+
("move", "lane"): "target lane (doing is reached by claim, done by raid done)",
|
|
112
|
+
("msg", "text"): "message text",
|
|
113
|
+
("msg", "to"): "receiving agent",
|
|
114
|
+
("msg", "task"): "card the message is about",
|
|
115
|
+
("new", "title"): "card title",
|
|
116
|
+
("new", "id"): "card id (default: from the title)",
|
|
117
|
+
("new", "team"): "team whose members may claim it",
|
|
118
|
+
("new", "locks"): "comma-separated <repo>/<path> paths the claimer may edit",
|
|
119
|
+
("new", "depends"): "card ids that must be done first",
|
|
120
|
+
("new", "scope"): "what is in and out of scope",
|
|
121
|
+
("new", "verify"): "how the check is described to the worker",
|
|
122
|
+
("new", "verify_cmd"): "lead: command raid verify runs in a sandbox (needed for done when gates are on)",
|
|
123
|
+
("new", "output"): "what the worker hands back",
|
|
124
|
+
("new", "ticket"): "issue tracker key or URL",
|
|
125
|
+
("new", "reviewers"): "comma-separated agents who must review it",
|
|
126
|
+
("note", "id"): "your card",
|
|
127
|
+
("note", "text"): "what changed, or evidence",
|
|
128
|
+
("plan", "action"): "import",
|
|
129
|
+
("plan", "file"): "plan file (.json or .md), or recipe:<name>",
|
|
130
|
+
("plan", "team"): "team for items that name none",
|
|
131
|
+
("pr", "draft"): "open the pull request as a draft",
|
|
132
|
+
("pr-sync", "max_polls"): "stop after this many polls",
|
|
133
|
+
("quickstart", "path"): "workspace folder that holds your repos (default: current folder)",
|
|
134
|
+
("quickstart", "lead"): "lead name (default: <runtime>-lead)",
|
|
135
|
+
("quickstart", "runtime"): "the lead's runtime: " + RUNTIME_HELP + " (default: detected)",
|
|
136
|
+
("quickstart", "team"): "name of the default team",
|
|
137
|
+
("release", "id"): "card to give back",
|
|
138
|
+
("reopen", "id"): "done card to reopen",
|
|
139
|
+
("reputation", "by"): "group scores by agent, model or provider",
|
|
140
|
+
("resurrect", "model"): "model for the replacement agent",
|
|
141
|
+
("resurrect-policy", "action"): "show the policy, or set a key",
|
|
142
|
+
("resurrect-policy", "key"): "policy key to set",
|
|
143
|
+
("review", "id"): "card in review",
|
|
144
|
+
("review", "verdict"): "approve, changes (back to doing) or comment",
|
|
145
|
+
("routing", "routing_action"): "show the rules, set a key, or explain the route for a card",
|
|
146
|
+
("show", "id"): "card to show (omit for the board)",
|
|
147
|
+
("spend", "by"): "group spend by this field",
|
|
148
|
+
("spend", "until"): "UTC date or timestamp (exclusive)",
|
|
149
|
+
("suggest-locks", "text"): "card text to suggest locks for",
|
|
150
|
+
("suggest-locks", "repos"): "comma-separated repos to search (default: all)",
|
|
151
|
+
("suggest-locks", "limit"): "most suggestions to return",
|
|
152
|
+
("team", "action"): "add, rm or list teams (add and rm: lead)",
|
|
153
|
+
("team", "name"): "team name",
|
|
154
|
+
("team", "max"): "most cards the team may have in doing at once",
|
|
155
|
+
("team", "mission"): "one line on what the team works on",
|
|
156
|
+
("team", "repos"): "comma-separated repos the team works in",
|
|
157
|
+
("transcript", "task"): "only records for this card",
|
|
158
|
+
("transcript", "kind"): "only this record kind (msg, note, status, review, verify, handback, run)",
|
|
159
|
+
("up", "team"): "team to join as a worker",
|
|
160
|
+
("up", "role"): "role when joining as a worker",
|
|
161
|
+
("verify", "id"): "card to verify",
|
|
162
|
+
("verify", "timeout"): "seconds before the verify run is stopped",
|
|
163
|
+
("verify", "cwd"): "checkout to verify (default: the worker's recorded checkout, else the workspace)",
|
|
164
|
+
("wait", "id"): "card to wait on",
|
|
165
|
+
("workspaces", "rm"): "forget these registered workspaces",
|
|
166
|
+
}
|
|
167
|
+
LEAD = "raidlock --lead-secret <lead secret file>"
|
|
168
|
+
EXAMPLES = {
|
|
169
|
+
"up": ["raidlock up", "raidlock up --team core # in a worker session: join as a worker"],
|
|
170
|
+
"quickstart": ["raidlock quickstart", "raidlock quickstart --demo # also create a small git repo to try it on"],
|
|
171
|
+
"new": [
|
|
172
|
+
f'{LEAD} new "Validate cart" --id checkout --team core --locks acme-shop/src/checkout '
|
|
173
|
+
'--verify-cmd "python3 -m unittest"',
|
|
174
|
+
'raidlock new "Fix login" --suggest-locks',
|
|
175
|
+
],
|
|
176
|
+
"edit": [f'{LEAD} edit checkout --verify-cmd "python3 -m unittest"'],
|
|
177
|
+
"join": [
|
|
178
|
+
"raidlock join --agent worker-checkout --team core --runtime shell",
|
|
179
|
+
"raidlock join --agent codex-api --team core --runtime codex",
|
|
180
|
+
],
|
|
181
|
+
"claim": ["raidlock claim checkout", "raidlock claim checkout --take-over"],
|
|
182
|
+
"note": ['raidlock note checkout "validator added; 14 tests pass"'],
|
|
183
|
+
"move": ['raidlock move checkout review --note "tests: 14 passed"', "raidlock move checkout blocked"],
|
|
184
|
+
"verify": [f"{LEAD} verify checkout", f"{LEAD} verify checkout --cwd ~/demo/shop/acme-shop"],
|
|
185
|
+
"done": [f"{LEAD} verify checkout", f"{LEAD} done checkout"],
|
|
186
|
+
"review": [
|
|
187
|
+
'raidlock review checkout --verdict approve --note "read the diff; tests cover empty carts"',
|
|
188
|
+
f"{LEAD} review checkout --assign codex-reviewer",
|
|
189
|
+
],
|
|
190
|
+
"delegate": ["raidlock delegate checkout --to codex", "raidlock delegate checkout --to claude --as reviewer"],
|
|
191
|
+
"config": ["raidlock config get", f"{LEAD} config set require_verify false"],
|
|
192
|
+
"team": [f'{LEAD} team add core --max 3 --mission "checkout flow"', "raidlock team list"],
|
|
193
|
+
"show": ["raidlock show", "raidlock show checkout"],
|
|
194
|
+
"who": ["raidlock who", "raidlock who --brief"],
|
|
195
|
+
"brief": ["raidlock brief checkout --quiet"],
|
|
196
|
+
"msg": ['raidlock msg --to worker-checkout "please rebase on main" --task checkout'],
|
|
197
|
+
"plan": [f"{LEAD} plan import plan.md --team core"],
|
|
198
|
+
"release": ['raidlock release checkout --note "blocked on API key"'],
|
|
199
|
+
"reopen": [f"{LEAD} reopen checkout"],
|
|
200
|
+
"lead": [f"{LEAD} lead heartbeat", "raidlock lead status"],
|
|
201
|
+
"cost": ["raidlock cost checkout", "raidlock cost checkout --model gpt-6-astra --input 1200 --output 300"],
|
|
202
|
+
"spend": ["raidlock spend --by model --since 2026-10-01"],
|
|
203
|
+
"approvals": ["raidlock approvals", "raidlock approvals --kind review"],
|
|
204
|
+
"next": ["raidlock next", "raidlock next --provider codex"],
|
|
205
|
+
"duel": [f"{LEAD} duel checkout --vs claude,codex"],
|
|
206
|
+
"doctor": ["raidlock doctor"],
|
|
207
|
+
"ui": ["raidlock ui --open"],
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class UsageError(Precondition):
|
|
212
|
+
kind = "usage"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class Parser(argparse.ArgumentParser):
|
|
216
|
+
def __init__(self, *args, **kwargs):
|
|
217
|
+
kwargs.setdefault("allow_abbrev", False)
|
|
218
|
+
super().__init__(*args, **kwargs)
|
|
219
|
+
|
|
220
|
+
def error(self, message):
|
|
221
|
+
raise UsageError(message, hint=f"{self.prog} --help")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class Context:
|
|
225
|
+
def __init__(self, args):
|
|
226
|
+
self.args = args
|
|
227
|
+
self._board = None
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def board(self):
|
|
231
|
+
if self._board is None:
|
|
232
|
+
self._board = Board.open(self.args.board)
|
|
233
|
+
return self._board
|
|
234
|
+
|
|
235
|
+
def lead_secret(self):
|
|
236
|
+
from .core import lead as LD
|
|
237
|
+
|
|
238
|
+
if not hasattr(self, "_secret"):
|
|
239
|
+
src = getattr(self.args, "lead_secret", None)
|
|
240
|
+
self._secret = LD.read_presented(src, self.board.leadlease().store) if src else None
|
|
241
|
+
return self._secret
|
|
242
|
+
|
|
243
|
+
def caller(self):
|
|
244
|
+
a = self.args
|
|
245
|
+
return self.board.caller(
|
|
246
|
+
agent=a.agent, token=a.token, fence=a.fence, lead_secret=self.lead_secret(), cap=getattr(a, "cap", None)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def add_command(name, help_text, setup, handler, mutating=False, aliases=()):
|
|
251
|
+
"""Extension point: register a subcommand. `setup(parser)` adds arguments; `handler(ctx, args)` returns
|
|
252
|
+
(result_dict, human_text). `ctx.board` opens the board lazily; `ctx.caller()` resolves the agent."""
|
|
253
|
+
COMMANDS[name] = {"fn": handler, "help": help_text, "mutating": mutating, "aliases": tuple(aliases), "setup": setup}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def command(name, help_text, mutating=False, aliases=()):
|
|
257
|
+
def wrap(fn):
|
|
258
|
+
add_command(name, help_text, None, fn, mutating, aliases)
|
|
259
|
+
return fn
|
|
260
|
+
|
|
261
|
+
return wrap
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def options(name):
|
|
265
|
+
def wrap(fn):
|
|
266
|
+
COMMANDS[name]["setup"] = fn
|
|
267
|
+
return fn
|
|
268
|
+
|
|
269
|
+
return wrap
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _entry_points():
|
|
273
|
+
from importlib.metadata import entry_points
|
|
274
|
+
|
|
275
|
+
eps = entry_points()
|
|
276
|
+
return eps.select(group="raidlock.commands") if hasattr(eps, "select") else eps.get("raidlock.commands", [])
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def load_extensions(debug=False):
|
|
280
|
+
import importlib
|
|
281
|
+
|
|
282
|
+
for module in EXTENSION_MODULES:
|
|
283
|
+
try:
|
|
284
|
+
mod = importlib.import_module(module)
|
|
285
|
+
except ImportError:
|
|
286
|
+
continue
|
|
287
|
+
if hasattr(mod, "register_cli"):
|
|
288
|
+
mod.register_cli(add_command)
|
|
289
|
+
try:
|
|
290
|
+
group = list(_entry_points())
|
|
291
|
+
except Exception:
|
|
292
|
+
return
|
|
293
|
+
for ep in group:
|
|
294
|
+
try:
|
|
295
|
+
ep.load()(add_command)
|
|
296
|
+
except (ImportError, AttributeError) as e:
|
|
297
|
+
if debug:
|
|
298
|
+
print(f"raidlock: debug: extension {ep.name} ({ep.value}) not installed, skipped: {e}", file=sys.stderr)
|
|
299
|
+
except Exception as e:
|
|
300
|
+
print(f"raidlock: extension {ep.name} failed to load: {e}", file=sys.stderr)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def glyph(emoji, fallback):
|
|
304
|
+
enc = getattr(sys.stdout, "encoding", None) or "ascii"
|
|
305
|
+
try:
|
|
306
|
+
emoji.encode(enc)
|
|
307
|
+
return emoji
|
|
308
|
+
except (UnicodeEncodeError, LookupError):
|
|
309
|
+
return fallback
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def short(path):
|
|
313
|
+
home = os.path.expanduser("~")
|
|
314
|
+
return "~" + path[len(home) :] if path and path.startswith(home + os.sep) else path
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def hhmm(ts):
|
|
318
|
+
dt = clock.parse(ts) if ts else None
|
|
319
|
+
return dt.astimezone().strftime("%H:%M") if dt else "it leaves review/blocked"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
GLOBAL_DESTS = ("board", "json", "agent", "token", "fence", "fields", "debug", "lead_secret", "cap")
|
|
323
|
+
PROGS = ("raid", "raidlock")
|
|
324
|
+
_COMMAND_WORD = re.compile(r"(?<![\w./-])raidlock (?=[a-z-])")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def prog_name(argv0=None):
|
|
328
|
+
base = os.path.basename(argv0 if argv0 is not None else (sys.argv[0] if sys.argv else ""))
|
|
329
|
+
base = base[:-4] if base.lower().endswith(".exe") else base
|
|
330
|
+
return base if base in PROGS else "raidlock"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def rename(text, prog):
|
|
334
|
+
"""Rewrite command hints in `text` to the name the user invoked: the part of a line after Next:/next:, and lines
|
|
335
|
+
that start with a raidlock command."""
|
|
336
|
+
if prog == "raidlock" or not text:
|
|
337
|
+
return text
|
|
338
|
+
out = []
|
|
339
|
+
for line in text.split("\n"):
|
|
340
|
+
body = line.lstrip()
|
|
341
|
+
if body.startswith("raidlock "):
|
|
342
|
+
line = line[: len(line) - len(body)] + _COMMAND_WORD.sub(prog + " ", body)
|
|
343
|
+
else:
|
|
344
|
+
for marker in ("Next:", "next:", "Warning:"):
|
|
345
|
+
i = line.find(marker)
|
|
346
|
+
if i >= 0:
|
|
347
|
+
line = line[:i] + _COMMAND_WORD.sub(prog + " ", line[i:])
|
|
348
|
+
break
|
|
349
|
+
out.append(line)
|
|
350
|
+
return "\n".join(out)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _common_args(p):
|
|
354
|
+
p.add_argument("--board", default=None, help="workspace or .board folder (default: nearest .board)")
|
|
355
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
356
|
+
p.add_argument("--agent", default=None, help="agent name (default: RAIDLOCK_AGENT_ID or RAIDLOCK_AGENT)")
|
|
357
|
+
p.add_argument("--token", default=None, help="agent token (default: RAIDLOCK_TOKEN)")
|
|
358
|
+
p.add_argument("--fence", type=int, default=None, help="card fence (default: RAIDLOCK_FENCE)")
|
|
359
|
+
p.add_argument(
|
|
360
|
+
"--lead-secret",
|
|
361
|
+
default=None,
|
|
362
|
+
metavar="SRC",
|
|
363
|
+
help="present the lead secret: a 0600 file, - for stdin, or keychain",
|
|
364
|
+
)
|
|
365
|
+
p.add_argument("--cap", default=None, help="card-scoped capability (default: RAIDLOCK_CAP)")
|
|
366
|
+
p.add_argument("--fields", default=None, help="--json fields: all, or a comma list (default: minimal)")
|
|
367
|
+
p.add_argument("--debug", action="store_true", help="show tracebacks")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def common():
|
|
371
|
+
p = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
|
|
372
|
+
_common_args(p)
|
|
373
|
+
return p
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def command_name(word):
|
|
377
|
+
if word in COMMANDS:
|
|
378
|
+
return word
|
|
379
|
+
return next((n for n, s in COMMANDS.items() if word in s["aliases"]), None)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _examples(name, prog):
|
|
383
|
+
lines = EXAMPLES.get(name)
|
|
384
|
+
if not lines:
|
|
385
|
+
return None
|
|
386
|
+
return "examples:\n" + "\n".join(" " + rename(line, prog) for line in lines)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def command_parser(name, prog="raidlock"):
|
|
390
|
+
spec = COMMANDS[name]
|
|
391
|
+
p = Parser(
|
|
392
|
+
prog=f"{prog} {name}",
|
|
393
|
+
description=spec["help"],
|
|
394
|
+
epilog=_examples(name, prog),
|
|
395
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
396
|
+
)
|
|
397
|
+
p.set_defaults(_cmd=name)
|
|
398
|
+
if spec["setup"]:
|
|
399
|
+
spec["setup"](p)
|
|
400
|
+
_common_args(p.add_argument_group("common options"))
|
|
401
|
+
for action in p._actions:
|
|
402
|
+
if not action.help and action.dest != "help":
|
|
403
|
+
action.help = FLAG_HELP.get((name, action.dest)) or FLAG_HELP.get(("*", action.dest))
|
|
404
|
+
return p
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def parse(argv, prog="raidlock"):
|
|
408
|
+
"""Parse a command line. Global flags may appear anywhere; command flags and positionals may interleave."""
|
|
409
|
+
pre, rest = common().parse_known_args(argv)
|
|
410
|
+
if not rest or rest[0].startswith("-"):
|
|
411
|
+
raise UsageError(
|
|
412
|
+
"no command given" if not rest else f"unrecognized arguments: {' '.join(rest)}", hint=f"{prog} --help"
|
|
413
|
+
)
|
|
414
|
+
name = command_name(rest[0])
|
|
415
|
+
if name is None:
|
|
416
|
+
raise UsageError(f"unknown command {rest[0]}", hint=f"{prog} --help")
|
|
417
|
+
p = command_parser(name, prog)
|
|
418
|
+
try:
|
|
419
|
+
args = p.parse_intermixed_args(rest[1:])
|
|
420
|
+
except TypeError:
|
|
421
|
+
args = p.parse_args(rest[1:])
|
|
422
|
+
for dest in GLOBAL_DESTS:
|
|
423
|
+
if getattr(args, dest, None) in (None, False):
|
|
424
|
+
setattr(args, dest, getattr(pre, dest))
|
|
425
|
+
args.prog = prog
|
|
426
|
+
return args
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def everyday_help(prog="raidlock"):
|
|
430
|
+
lines = [f"{prog} {__version__}: a lease-and-review board for coding agents.", ""]
|
|
431
|
+
width = max(len(n) for n in COMMANDS)
|
|
432
|
+
for title, names in GROUPS:
|
|
433
|
+
present = [n for n in names if n in COMMANDS]
|
|
434
|
+
if present:
|
|
435
|
+
lines.append(f"{title}:")
|
|
436
|
+
lines += [f" {n:<{width}} {COMMANDS[n]['help']}" for n in present]
|
|
437
|
+
lines += [
|
|
438
|
+
"",
|
|
439
|
+
f"More commands: {prog} help advanced Command help: {prog} <command> --help",
|
|
440
|
+
"Every command takes --json for machines.",
|
|
441
|
+
]
|
|
442
|
+
return "\n".join(lines)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def advanced_help():
|
|
446
|
+
everyday = {n for _, names in GROUPS for n in names}
|
|
447
|
+
width = max(len(n) for n in COMMANDS)
|
|
448
|
+
rows = [f" {n:<{width}} {s['help']}" for n, s in sorted(COMMANDS.items()) if n not in everyday]
|
|
449
|
+
return (
|
|
450
|
+
"Advanced commands:\n" + "\n".join(rows) + "\n\nExit codes: 0 ok, 2 denied or conflict, 3 precondition, "
|
|
451
|
+
"4 timeout or stale lead fence, 5 identity or role. Usage errors also exit 3."
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
MINIMAL = {
|
|
456
|
+
"init": ("workspace", "lead", "token_file", "created"),
|
|
457
|
+
"quickstart": ("workspace", "created", "lead", "token_file", "card", "demo_repo"),
|
|
458
|
+
"up": ("action", "agent", "token", "token_file", "env_file", "status", "card", "lead", "workspace"),
|
|
459
|
+
"doctor": ("healthy", "problems", "warnings"),
|
|
460
|
+
"join": ("agent", "token", "role", "team", "display", "billing"),
|
|
461
|
+
"new": ("id", "created", "suggestions", "warnings"),
|
|
462
|
+
"edit": ("card", "verify_cmd", "verify"),
|
|
463
|
+
"claim": ("card", "agent", "fence", "expires", "paths", "cap"),
|
|
464
|
+
"heartbeat": ("card", "expires", "resumed"),
|
|
465
|
+
"note": ("card",),
|
|
466
|
+
"lock": ("card", "locks"),
|
|
467
|
+
"release": ("card", "lane", "released"),
|
|
468
|
+
"move": ("card", "lane", "a2a_state"),
|
|
469
|
+
"done": ("card", "lane", "a2a_state", "warnings"),
|
|
470
|
+
"reopen": ("card", "lane"),
|
|
471
|
+
"review": ("card", "verdict", "lane", "approvals", "reviewers", "warnings"),
|
|
472
|
+
"verify": ("card", "passed", "exit", "tail"),
|
|
473
|
+
"msg": ("id", "status", "dropped"),
|
|
474
|
+
"inbox": ("messages", "queued"),
|
|
475
|
+
"wait": ("card", "lane"),
|
|
476
|
+
"check": ("free", "conflicts", "allowed", "reason"),
|
|
477
|
+
"cost": ("card", "entry", "total", "rows"),
|
|
478
|
+
"brief": ("card", "text", "tokens", "over_cap"),
|
|
479
|
+
}
|
|
480
|
+
SLIM = {
|
|
481
|
+
"show": lambda r: (
|
|
482
|
+
{**r, "cards": [{k: c.get(k) for k in ("id", "title", "lane", "owner", "team")} for c in r["cards"]]}
|
|
483
|
+
if isinstance(r.get("cards"), list)
|
|
484
|
+
else r
|
|
485
|
+
),
|
|
486
|
+
"who": lambda r: {
|
|
487
|
+
"agents": {n: {k: a.get(k) for k in ("display", "status", "card")} for n, a in r["agents"].items()},
|
|
488
|
+
"lead_lease": r.get("lead_lease"),
|
|
489
|
+
},
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def select(name, result, fields):
|
|
494
|
+
if fields == "all":
|
|
495
|
+
return result
|
|
496
|
+
if fields:
|
|
497
|
+
keys = [f.strip() for f in fields.split(",") if f.strip()]
|
|
498
|
+
return {k: result[k] for k in keys if k in result}
|
|
499
|
+
if name in SLIM:
|
|
500
|
+
return SLIM[name](result)
|
|
501
|
+
if name in MINIMAL:
|
|
502
|
+
return {k: result[k] for k in MINIMAL[name] if k in result}
|
|
503
|
+
return result
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def emit(args, name, result, text, board_dir, mutating, prog="raidlock"):
|
|
507
|
+
if args.json:
|
|
508
|
+
out = select(name, result, args.fields)
|
|
509
|
+
if args.fields == "all" and board_dir:
|
|
510
|
+
out = {"board": board_dir, **out}
|
|
511
|
+
print(json.dumps({"ok": True, **out}, sort_keys=True, ensure_ascii=False, separators=(",", ":"), default=str))
|
|
512
|
+
return
|
|
513
|
+
if text and mutating and board_dir:
|
|
514
|
+
first, _, rest = text.partition("\n")
|
|
515
|
+
text = f"[{os.path.basename(os.path.dirname(board_dir))}] {first}" + (f"\n{rest}" if rest else "")
|
|
516
|
+
if text:
|
|
517
|
+
print(rename(text, prog))
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def main(argv=None, prog=None):
|
|
521
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
522
|
+
if argv[:1] == ["hook"]:
|
|
523
|
+
from .hooks import main as hook_main
|
|
524
|
+
|
|
525
|
+
return hook_main(argv[1:])
|
|
526
|
+
prog = prog or prog_name()
|
|
527
|
+
want_json, debug = "--json" in argv, "--debug" in argv
|
|
528
|
+
load_extensions(debug)
|
|
529
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
530
|
+
print(everyday_help(prog))
|
|
531
|
+
return 0
|
|
532
|
+
if argv[0] in ("--version", "-V"):
|
|
533
|
+
print(f"{prog} {__version__}")
|
|
534
|
+
return 0
|
|
535
|
+
try:
|
|
536
|
+
args = parse(argv, prog)
|
|
537
|
+
found = None
|
|
538
|
+
if args._cmd not in ("doctor", "migrate", "help", "version"):
|
|
539
|
+
from .core.board import find_board_dir
|
|
540
|
+
from .core.migrate import reject_legacy
|
|
541
|
+
|
|
542
|
+
try:
|
|
543
|
+
found = find_board_dir(args.board)
|
|
544
|
+
except Precondition:
|
|
545
|
+
found = None
|
|
546
|
+
if found:
|
|
547
|
+
reject_legacy(found)
|
|
548
|
+
spec = COMMANDS[args._cmd]
|
|
549
|
+
ctx = Context(args)
|
|
550
|
+
if (
|
|
551
|
+
found
|
|
552
|
+
and (
|
|
553
|
+
args.lead_secret
|
|
554
|
+
or os.environ.get("RAIDLOCK_LEAD_SECRET")
|
|
555
|
+
or os.environ.get("RAIDLOCK_LEAD_SECRET_FILE")
|
|
556
|
+
)
|
|
557
|
+
and not (args._cmd == "lead" and args.action == "takeover")
|
|
558
|
+
):
|
|
559
|
+
ctx.caller()
|
|
560
|
+
result, text = spec["fn"](ctx, args)
|
|
561
|
+
code = result.pop("_exit", 0)
|
|
562
|
+
board_dir = ctx._board.dir if ctx._board else None
|
|
563
|
+
emit(args, args._cmd, result, text, board_dir, spec["mutating"], prog)
|
|
564
|
+
return code
|
|
565
|
+
except RaidlockError as e:
|
|
566
|
+
_lead_identity_hint(e, locals().get("ctx"), argv)
|
|
567
|
+
if e.hint:
|
|
568
|
+
e.hint = _COMMAND_WORD.sub(prog + " ", e.hint) if prog != "raidlock" else e.hint
|
|
569
|
+
if want_json:
|
|
570
|
+
print(json.dumps(e.to_dict(), sort_keys=True, ensure_ascii=False, separators=(",", ":"), default=str))
|
|
571
|
+
else:
|
|
572
|
+
print(f"{prog}: {e.message}" + (f"\nNext: {e.hint}" if e.hint else ""), file=sys.stderr)
|
|
573
|
+
if debug:
|
|
574
|
+
traceback.print_exc()
|
|
575
|
+
return e.code
|
|
576
|
+
except KeyboardInterrupt:
|
|
577
|
+
return 130
|
|
578
|
+
except Exception as e:
|
|
579
|
+
if debug:
|
|
580
|
+
traceback.print_exc()
|
|
581
|
+
if want_json:
|
|
582
|
+
print(json.dumps({"ok": False, "error": "internal", "code": 1, "message": str(e)}))
|
|
583
|
+
else:
|
|
584
|
+
print(f"{prog}: internal error: {e}\nNext: rerun with --debug and report it", file=sys.stderr)
|
|
585
|
+
return 1
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
LEAD_COMMANDS = {
|
|
589
|
+
"new",
|
|
590
|
+
"edit",
|
|
591
|
+
"plan",
|
|
592
|
+
"verify",
|
|
593
|
+
"done",
|
|
594
|
+
"reopen",
|
|
595
|
+
"reap",
|
|
596
|
+
"team",
|
|
597
|
+
"config",
|
|
598
|
+
"lead",
|
|
599
|
+
"approvals",
|
|
600
|
+
"delegate",
|
|
601
|
+
"duel",
|
|
602
|
+
"resurrect",
|
|
603
|
+
"resurrect-policy",
|
|
604
|
+
"pr",
|
|
605
|
+
"pr-sync",
|
|
606
|
+
"routing",
|
|
607
|
+
"budget",
|
|
608
|
+
"lore",
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _lead_session_state(board):
|
|
613
|
+
"""(lead lease held, this call comes from the lead's session)."""
|
|
614
|
+
try:
|
|
615
|
+
from .core import lead as LD
|
|
616
|
+
|
|
617
|
+
status = board.lead_status()
|
|
618
|
+
sid = LD.session_of(board.env)[0]
|
|
619
|
+
except (RaidlockError, OSError):
|
|
620
|
+
return False, False
|
|
621
|
+
return bool(status.get("held")), bool(status.get("held")) and bool(sid) and sid == status.get("session")
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
STRICT_LEAD_COMMANDS = {"edit", "verify", "done", "reopen", "reap", "delegate", "duel", "resurrect"}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _lead_identity_hint(e, ctx, argv):
|
|
628
|
+
"""Point a caller without the lead secret at the exact lead command when the command needs the lead, or when the
|
|
629
|
+
call comes from the lead's own session; never point the lead at join."""
|
|
630
|
+
if not isinstance(e, IdentityError) or ctx is None or ctx._board is None:
|
|
631
|
+
return
|
|
632
|
+
if getattr(ctx.args, "lead_secret", None) or any(k.startswith("RAIDLOCK_LEAD_SECRET") for k in os.environ):
|
|
633
|
+
return
|
|
634
|
+
from .core.identity import LEAD_HINT
|
|
635
|
+
|
|
636
|
+
no_identity = e.message == "no agent identity"
|
|
637
|
+
lead_only = ctx.args._cmd in STRICT_LEAD_COMMANDS or e.hint == LEAD_HINT or e.message.startswith("only the lead")
|
|
638
|
+
if not (no_identity or lead_only):
|
|
639
|
+
return
|
|
640
|
+
board = ctx._board
|
|
641
|
+
held, in_lead_session = _lead_session_state(board)
|
|
642
|
+
if not held:
|
|
643
|
+
return
|
|
644
|
+
if not (in_lead_session or lead_only or ctx.args._cmd in LEAD_COMMANDS):
|
|
645
|
+
return
|
|
646
|
+
rest = " ".join(shlex.quote(x) for x in _without_identity(argv))
|
|
647
|
+
suffix = "" if in_lead_session or lead_only else " (a worker runs raidlock join instead)"
|
|
648
|
+
command = lead_cmd(board, rest) if in_lead_session else f"raidlock --lead-secret <lead secret file> {rest}"
|
|
649
|
+
e.hint = command + suffix
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
IDENTITY_FLAGS = ("--agent", "--token", "--cap", "--fence", "--lead-secret")
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _without_identity(argv):
|
|
656
|
+
"""The command line without worker identity flags, which the lead must not pass."""
|
|
657
|
+
out, skip = [], False
|
|
658
|
+
for word in argv:
|
|
659
|
+
if skip:
|
|
660
|
+
skip = False
|
|
661
|
+
continue
|
|
662
|
+
if word in IDENTITY_FLAGS:
|
|
663
|
+
skip = True
|
|
664
|
+
continue
|
|
665
|
+
if word.startswith(tuple(f + "=" for f in IDENTITY_FLAGS)):
|
|
666
|
+
continue
|
|
667
|
+
out.append(word)
|
|
668
|
+
return out
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _exports(**values):
|
|
672
|
+
return "\n".join(
|
|
673
|
+
f"export RAIDLOCK_{k.upper()}={shlex.quote(str(v))}" for k, v in values.items() if v not in (None, "")
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _interactive():
|
|
678
|
+
try:
|
|
679
|
+
return sys.stdin.isatty()
|
|
680
|
+
except (AttributeError, ValueError):
|
|
681
|
+
return False
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@command("help", "show help: raid help [advanced|<command>]")
|
|
685
|
+
def cmd_help(ctx, a):
|
|
686
|
+
if a.topic == "advanced":
|
|
687
|
+
return {"commands": sorted(COMMANDS)}, advanced_help()
|
|
688
|
+
name = command_name(a.topic) if a.topic else None
|
|
689
|
+
if name:
|
|
690
|
+
return {"command": name}, command_parser(name, a.prog).format_help()
|
|
691
|
+
return {"commands": sorted(COMMANDS)}, everyday_help(a.prog)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
@options("help")
|
|
695
|
+
def _help_opts(p):
|
|
696
|
+
p.add_argument("topic", nargs="?")
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _lead_file(board):
|
|
700
|
+
return token_path(board.home, board.uid, board.lead)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _lead_lines(board):
|
|
704
|
+
where = board.leadlease().store.using
|
|
705
|
+
src = lead_secret_src(board)
|
|
706
|
+
return [
|
|
707
|
+
f"The lead secret is in {src}" + (" (mode 0600)" if where == "file" else "") + "; it is never printed. "
|
|
708
|
+
"Lead commands present it, from this session only; never give it to an agent:",
|
|
709
|
+
" " + lead_cmd(board, "<command>"),
|
|
710
|
+
_exports(board=board.workspace, agent=board.lead),
|
|
711
|
+
]
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _init_text(board, token):
|
|
715
|
+
if not token:
|
|
716
|
+
return f"Board for {board.name} already exists at {short(board.dir)}.\nNext: raidlock show"
|
|
717
|
+
lines = (
|
|
718
|
+
[f"Created a board for {board.name}. You are the lead ({board.lead})."]
|
|
719
|
+
+ _lead_lines(board)
|
|
720
|
+
+ ["Next: " + lead_cmd(board, 'new "<task>" --locks <repo>/<path> --verify-cmd "<check>"')]
|
|
721
|
+
)
|
|
722
|
+
return "\n".join(lines + [f"Warning: {w}" for w in board.warnings()])
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
@command("init", "create a board in a workspace folder", mutating=True)
|
|
726
|
+
def cmd_init(ctx, a):
|
|
727
|
+
board, token = Board.init(
|
|
728
|
+
a.path,
|
|
729
|
+
lead=a.lead,
|
|
730
|
+
runtime=long_runtime(a.runtime) or detect_runtime(interactive=_interactive()),
|
|
731
|
+
model_id=a.model,
|
|
732
|
+
auth=not a.no_auth,
|
|
733
|
+
)
|
|
734
|
+
ctx._board = board
|
|
735
|
+
return {
|
|
736
|
+
"workspace": board.workspace,
|
|
737
|
+
"lead": board.lead,
|
|
738
|
+
"token_file": _lead_file(board),
|
|
739
|
+
"created": token is not None,
|
|
740
|
+
"warnings": board.warnings(),
|
|
741
|
+
}, _init_text(board, token)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
@options("init")
|
|
745
|
+
def _init_opts(p):
|
|
746
|
+
p.add_argument("path", nargs="?", default=".")
|
|
747
|
+
p.add_argument("--lead", default=None, help="lead name (default <runtime>-lead)")
|
|
748
|
+
p.add_argument("--runtime", default=None)
|
|
749
|
+
p.add_argument("--model", default=None)
|
|
750
|
+
p.add_argument("--no-auth", action="store_true", help="do not require agent tokens")
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
@command("up", "get going: create the board, join it, or show what you are on", mutating=True)
|
|
754
|
+
def cmd_up(ctx, a):
|
|
755
|
+
try:
|
|
756
|
+
board = ctx.board
|
|
757
|
+
except Precondition:
|
|
758
|
+
if a.board:
|
|
759
|
+
raise
|
|
760
|
+
board, token = Board.init(
|
|
761
|
+
".", runtime=long_runtime(a.runtime) or detect_runtime(interactive=_interactive()), model_id=a.model
|
|
762
|
+
)
|
|
763
|
+
ctx._board = board
|
|
764
|
+
return {
|
|
765
|
+
"action": "init",
|
|
766
|
+
"workspace": board.workspace,
|
|
767
|
+
"lead": board.lead,
|
|
768
|
+
"token_file": _lead_file(board),
|
|
769
|
+
}, _init_text(board, token)
|
|
770
|
+
caller = ctx.caller()
|
|
771
|
+
if a.join and caller.is_lead:
|
|
772
|
+
raise IdentityError(
|
|
773
|
+
"worker joins must not present a lead secret",
|
|
774
|
+
hint="remove --lead-secret and scrub RAIDLOCK_LEAD_* from the worker environment",
|
|
775
|
+
)
|
|
776
|
+
if not caller.name and not a.join and _lead_session_state(board)[1]:
|
|
777
|
+
return {"action": "lead", "lead": True, "agent": board.lead, "workspace": board.workspace}, (
|
|
778
|
+
f"This session holds the lead of {board.name}; up does not join it as a worker.\n"
|
|
779
|
+
"Next: " + lead_cmd(board, "up")
|
|
780
|
+
)
|
|
781
|
+
if a.join or not caller.name or caller.name not in board.agents():
|
|
782
|
+
if board.meta.get("migrated_from") and not a.join:
|
|
783
|
+
raise Precondition("migrated board has no identity; use lead takeover or raid up --join")
|
|
784
|
+
r = _no_lead_token(
|
|
785
|
+
board,
|
|
786
|
+
board.join(
|
|
787
|
+
caller,
|
|
788
|
+
agent=caller.name or None,
|
|
789
|
+
team=a.team or (board.card(a.card).get("team") if a.card else None),
|
|
790
|
+
role=a.role,
|
|
791
|
+
runtime=long_runtime(a.runtime) or detect_runtime(interactive=_interactive()),
|
|
792
|
+
model_id=a.model,
|
|
793
|
+
),
|
|
794
|
+
)
|
|
795
|
+
text = f"Joined {board.name} as {r['display']}."
|
|
796
|
+
if r["agent"] != board.lead:
|
|
797
|
+
from .core import identity, safeio
|
|
798
|
+
|
|
799
|
+
token = r.get("token") or identity.read_token(board.home, board.uid, r["agent"])
|
|
800
|
+
path = os.path.join(board.home, "agents", board.uid, r["agent"] + ".env")
|
|
801
|
+
safeio.write_text(
|
|
802
|
+
path,
|
|
803
|
+
"unset RAIDLOCK_CAP RAIDLOCK_FENCE\n"
|
|
804
|
+
+ _exports(board=board.dir, agent=r["agent"], agent_id=r["agent"], token=token)
|
|
805
|
+
+ "\n",
|
|
806
|
+
)
|
|
807
|
+
r["env_file"] = path
|
|
808
|
+
text += "\nsource " + shlex.quote(path)
|
|
809
|
+
return {"action": "join", **r}, text + "\nNext: raidlock claimable"
|
|
810
|
+
return {"action": "status", **_status(board, caller)}, _status_text(board, caller)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _no_lead_token(board, r):
|
|
814
|
+
if r.get("agent") == board.lead:
|
|
815
|
+
r = {k: v for k, v in r.items() if k != "token"}
|
|
816
|
+
r["token_file"] = _lead_file(board)
|
|
817
|
+
return r
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _status(board, caller):
|
|
821
|
+
me = board.who().get(caller.name, {})
|
|
822
|
+
return {
|
|
823
|
+
"agent": caller.name,
|
|
824
|
+
"status": me.get("status", "idle"),
|
|
825
|
+
"card": me.get("card", ""),
|
|
826
|
+
"lead": caller.is_lead,
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _status_text(board, caller):
|
|
831
|
+
s = _status(board, caller)
|
|
832
|
+
if s["card"]:
|
|
833
|
+
lease = board.leases().get(s["card"]) or {}
|
|
834
|
+
return (
|
|
835
|
+
f"You are {caller.name} on {board.name}: {s['status']} on {s['card']} "
|
|
836
|
+
f"(fence {lease.get('fence', '-')}, lock until {hhmm(lease.get('expires'))}).\n"
|
|
837
|
+
f'Next: raidlock note {s["card"]} "..."'
|
|
838
|
+
)
|
|
839
|
+
if caller.is_lead:
|
|
840
|
+
review = [c["id"] for c in board.cards() if c["lane"] == "review"]
|
|
841
|
+
nxt = _close_step(board, board.card(review[0])) if review else "raidlock show"
|
|
842
|
+
return f"You are the lead of {board.name}; {len(review)} card(s) in review.\nNext: {nxt}"
|
|
843
|
+
return f"You are {caller.name} on {board.name} with no card.\nNext: raidlock claimable"
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
@options("up")
|
|
847
|
+
def _up_opts(p):
|
|
848
|
+
p.add_argument("--join", action="store_true", help="explicitly join as a worker")
|
|
849
|
+
p.add_argument("--card", default=None, help="default the worker team from this card")
|
|
850
|
+
p.add_argument("--team", default=None)
|
|
851
|
+
p.add_argument("--role", default="worker", choices=["worker", "reviewer"])
|
|
852
|
+
p.add_argument("--runtime", default=None)
|
|
853
|
+
p.add_argument("--model", default=None)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _close_step(board, card):
|
|
857
|
+
if card.get("verify_cmd") or board.settings.get("require_verify"):
|
|
858
|
+
return lead_cmd(board, f"verify {card['id']}") + ", then " + lead_cmd(board, f"done {card['id']}")
|
|
859
|
+
return lead_cmd(board, f"done {card['id']}")
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
DEMO_REPO = "acme-shop"
|
|
863
|
+
DEMO_FILES = {
|
|
864
|
+
"src/__init__.py": "",
|
|
865
|
+
"src/checkout/__init__.py": "",
|
|
866
|
+
"src/checkout/cart.py": (
|
|
867
|
+
'def validate(cart):\n """Return the cart if it can be checked out."""\n return cart\n'
|
|
868
|
+
),
|
|
869
|
+
"tests/__init__.py": "",
|
|
870
|
+
"tests/checkout/__init__.py": "",
|
|
871
|
+
"tests/checkout/test_cart.py": (
|
|
872
|
+
"import unittest\n"
|
|
873
|
+
"\n"
|
|
874
|
+
"from src.checkout.cart import validate\n"
|
|
875
|
+
"\n"
|
|
876
|
+
"\n"
|
|
877
|
+
"class CartTest(unittest.TestCase):\n"
|
|
878
|
+
" def test_keeps_items(self):\n"
|
|
879
|
+
' self.assertEqual(validate(["book"]), ["book"])\n'
|
|
880
|
+
"\n"
|
|
881
|
+
" def test_rejects_empty_cart(self):\n"
|
|
882
|
+
" with self.assertRaises(ValueError):\n"
|
|
883
|
+
" validate([])\n"
|
|
884
|
+
),
|
|
885
|
+
".gitignore": "__pycache__/\n",
|
|
886
|
+
"README.md": "# acme-shop\n\nA tiny repo for trying raidlock. Run the tests with `python3 -m unittest`.\n",
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _demo_repo(workspace):
|
|
891
|
+
"""Create a small git repo with one failing test, so the getting-started guide has real work to verify."""
|
|
892
|
+
from .runners import workspace as W
|
|
893
|
+
|
|
894
|
+
root = os.path.join(workspace, DEMO_REPO)
|
|
895
|
+
if os.path.exists(root):
|
|
896
|
+
raise Precondition(f"{short(root)} already exists", hint="raidlock quickstart (without --demo)")
|
|
897
|
+
for rel, text in DEMO_FILES.items():
|
|
898
|
+
path = os.path.join(root, *rel.split("/"))
|
|
899
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
900
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
901
|
+
f.write(text)
|
|
902
|
+
ident = ["-c", "user.name=raidlock demo", "-c", "user.email=demo@raidlock.invalid", "-c", "commit.gpgsign=false"]
|
|
903
|
+
W.git(root, "init", "-q", "-b", "main")
|
|
904
|
+
W.git(root, "add", ".")
|
|
905
|
+
W.git(root, *ident, "commit", "-q", "--no-verify", "-m", "demo: cart without validation")
|
|
906
|
+
return root
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
@command("quickstart", "new board with a default team and an example card", mutating=True)
|
|
910
|
+
def cmd_quickstart(ctx, a):
|
|
911
|
+
if a.demo and os.path.isdir(os.path.join(a.path, ".board")):
|
|
912
|
+
raise Precondition(f"a board already exists in {short(os.path.abspath(a.path))}", hint="raidlock show")
|
|
913
|
+
board, token = Board.init(
|
|
914
|
+
a.path, lead=a.lead, runtime=long_runtime(a.runtime) or detect_runtime(interactive=_interactive())
|
|
915
|
+
)
|
|
916
|
+
ctx._board = board
|
|
917
|
+
if token is None:
|
|
918
|
+
return {
|
|
919
|
+
"workspace": board.workspace,
|
|
920
|
+
"created": False,
|
|
921
|
+
}, f"A board already exists at {short(board.dir)}.\nNext: raidlock show"
|
|
922
|
+
demo = _demo_repo(board.workspace) if a.demo else None
|
|
923
|
+
lead = board.caller(lead_secret=token)
|
|
924
|
+
board.add_team(lead, a.team, 2, "default team")
|
|
925
|
+
card = board.new_card(
|
|
926
|
+
lead,
|
|
927
|
+
"Example: replace with a real task",
|
|
928
|
+
card_id="example",
|
|
929
|
+
team=a.team,
|
|
930
|
+
locks=a.locks,
|
|
931
|
+
verify="describe how the lead checks this",
|
|
932
|
+
)
|
|
933
|
+
steps = [
|
|
934
|
+
f"Created {board.name} with team {a.team} and card {card['id']}. You are the lead ({board.lead}).",
|
|
935
|
+
*_lead_lines(board),
|
|
936
|
+
]
|
|
937
|
+
if demo:
|
|
938
|
+
steps.append(
|
|
939
|
+
f"Demo repo: {short(demo)} (a git repo whose empty-cart test fails until a worker fixes it). "
|
|
940
|
+
"Follow https://github.com/agi1clj/raidlock/blob/main/docs/guides/getting-started.md from here."
|
|
941
|
+
)
|
|
942
|
+
steps += [
|
|
943
|
+
"Then:",
|
|
944
|
+
" " + lead_cmd(board, f'new "<task>" --team {a.team} --locks <repo>/<path> --verify-cmd "<check>"'),
|
|
945
|
+
f" raidlock join --agent claude-worker-1 --team {a.team} --runtime claude # worker session; prints a token",
|
|
946
|
+
" raidlock claim <card> # as the worker, inside its checkout",
|
|
947
|
+
" raidlock move <card> review # worker hands back",
|
|
948
|
+
" " + lead_cmd(board, "verify <card>") + " # lead: signed receipt",
|
|
949
|
+
" " + lead_cmd(board, "done <card>"),
|
|
950
|
+
" raidlock ui # board in the browser (127.0.0.1)",
|
|
951
|
+
]
|
|
952
|
+
return {
|
|
953
|
+
"workspace": board.workspace,
|
|
954
|
+
"created": True,
|
|
955
|
+
"lead": board.lead,
|
|
956
|
+
"token_file": _lead_file(board),
|
|
957
|
+
"team": a.team,
|
|
958
|
+
"card": card["id"],
|
|
959
|
+
"demo_repo": demo,
|
|
960
|
+
}, "\n".join(steps)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
@options("quickstart")
|
|
964
|
+
def _qs_opts(p):
|
|
965
|
+
p.add_argument("path", nargs="?", default=".")
|
|
966
|
+
p.add_argument("--lead", default=None)
|
|
967
|
+
p.add_argument("--runtime", default=None)
|
|
968
|
+
p.add_argument("--team", default="core")
|
|
969
|
+
p.add_argument("--locks", default=None, help="locks for the example card")
|
|
970
|
+
p.add_argument("--demo", action="store_true", help=f"also create {DEMO_REPO}/, a small git repo to try raidlock on")
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
@command("team", "add, remove or list teams", mutating=True)
|
|
974
|
+
def cmd_team(ctx, a):
|
|
975
|
+
b = ctx.board
|
|
976
|
+
if a.action == "list":
|
|
977
|
+
teams = b.team_list()
|
|
978
|
+
lines = [
|
|
979
|
+
f"{n}: max {t['max']}, members {', '.join(t['members']) or '-'}"
|
|
980
|
+
+ (f" ({t['mission']})" if t.get("mission") else "")
|
|
981
|
+
for n, t in teams.items()
|
|
982
|
+
]
|
|
983
|
+
return {"teams": teams}, "\n".join(lines) or "No teams yet.\nNext: raidlock team add <name>"
|
|
984
|
+
if not a.name:
|
|
985
|
+
raise UsageError(f"team {a.action} needs a name", hint=f"raidlock team {a.action} <name>")
|
|
986
|
+
if a.action == "add":
|
|
987
|
+
t = b.add_team(ctx.caller(), a.name, a.max, a.mission or "", a.repos)
|
|
988
|
+
return t, f"Team {a.name} ready (max {t['max']} in doing). Next: raidlock join --team {a.name}"
|
|
989
|
+
return b.remove_team(ctx.caller(), a.name), f"Removed team {a.name}."
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
@options("team")
|
|
993
|
+
def _team_opts(p):
|
|
994
|
+
p.add_argument("action", choices=["add", "rm", "list"])
|
|
995
|
+
p.add_argument("name", nargs="?")
|
|
996
|
+
p.add_argument("--max", type=int, default=None)
|
|
997
|
+
p.add_argument("--mission", default=None)
|
|
998
|
+
p.add_argument("--repos", default=None)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
@command(
|
|
1002
|
+
"config",
|
|
1003
|
+
"get or set board settings: auth, ttl_minutes, hold_minutes, require_review, require_verify, "
|
|
1004
|
+
"require_card_for_lead_edits, board_first_subagents",
|
|
1005
|
+
mutating=True,
|
|
1006
|
+
)
|
|
1007
|
+
def cmd_config(ctx, a):
|
|
1008
|
+
b = ctx.board
|
|
1009
|
+
if a.action == "get":
|
|
1010
|
+
value = b.get_setting(a.key)
|
|
1011
|
+
if a.key:
|
|
1012
|
+
return {"key": a.key, "value": value}, json.dumps(value, sort_keys=True)
|
|
1013
|
+
return {"settings": value}, "\n".join(f"{k}: {json.dumps(v, sort_keys=True)}" for k, v in value.items())
|
|
1014
|
+
if a.key is None or a.value is None:
|
|
1015
|
+
raise UsageError("config set needs a key and a value", hint="raidlock config set require_review 1")
|
|
1016
|
+
settings = b.set_setting(ctx.caller(), a.key, a.value)
|
|
1017
|
+
return {"settings": settings}, f"Set {a.key} to {a.value}."
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
@options("config")
|
|
1021
|
+
def _config_opts(p):
|
|
1022
|
+
p.add_argument("action", nargs="?", default="get", choices=["get", "set"])
|
|
1023
|
+
p.add_argument("key", nargs="?")
|
|
1024
|
+
p.add_argument("value", nargs="?")
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
@command("join", "join the board as an agent; prints its token once", mutating=True)
|
|
1028
|
+
def cmd_join(ctx, a):
|
|
1029
|
+
board = ctx.board
|
|
1030
|
+
r = _no_lead_token(
|
|
1031
|
+
board,
|
|
1032
|
+
board.join(
|
|
1033
|
+
ctx.caller(),
|
|
1034
|
+
agent=a.agent,
|
|
1035
|
+
team=a.team,
|
|
1036
|
+
role=a.role,
|
|
1037
|
+
runtime=long_runtime(a.runtime) or detect_runtime(interactive=_interactive()),
|
|
1038
|
+
provider=a.provider,
|
|
1039
|
+
model_id=a.model,
|
|
1040
|
+
flag_model=a.runner_model,
|
|
1041
|
+
reported_model=a.reported_model,
|
|
1042
|
+
effort=a.effort,
|
|
1043
|
+
session=a.session or _env_session(),
|
|
1044
|
+
card=a.card,
|
|
1045
|
+
billing=a.billing,
|
|
1046
|
+
),
|
|
1047
|
+
)
|
|
1048
|
+
text = f"Joined as {r['display']}" + (f" on team {r['team']}" if r["team"] else "") + f" ({r['role']})."
|
|
1049
|
+
if r.get("token"):
|
|
1050
|
+
text += "\n" + _exports(agent=r["agent"], token=r["token"])
|
|
1051
|
+
elif r["agent"] != board.lead:
|
|
1052
|
+
from .core.identity import token_path
|
|
1053
|
+
|
|
1054
|
+
path = token_path(board.home, board.uid, r["agent"])
|
|
1055
|
+
r["credential_path"] = path
|
|
1056
|
+
text += f"\nExisting credential: {path}"
|
|
1057
|
+
nxt = "raidlock claimable" + (" --for-review" if r["role"] == "reviewer" else "")
|
|
1058
|
+
return r, text + (f" # next: {nxt}" if r.get("token") else f" Next: {nxt}")
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def _env_session():
|
|
1062
|
+
from .core.lead import SESSION_VARS
|
|
1063
|
+
|
|
1064
|
+
return next((os.environ[v].strip() for v in SESSION_VARS if os.environ.get(v, "").strip()), "")
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
@options("join")
|
|
1068
|
+
def _join_opts(p):
|
|
1069
|
+
p.add_argument("--team", default=None)
|
|
1070
|
+
p.add_argument("--role", default="worker", choices=["lead", "worker", "reviewer"])
|
|
1071
|
+
p.add_argument("--runtime", default=None, help=RUNTIME_HELP + " (default: detected)")
|
|
1072
|
+
p.add_argument("--provider", default=None)
|
|
1073
|
+
p.add_argument("--model", default=None, help="model id (default: detected)")
|
|
1074
|
+
p.add_argument("--runner-model", default=None, help="model a runner passed with -m")
|
|
1075
|
+
p.add_argument("--reported-model", default=None, help="model reported by the runtime")
|
|
1076
|
+
p.add_argument("--effort", default=None)
|
|
1077
|
+
p.add_argument("--session", default=None)
|
|
1078
|
+
p.add_argument("--card", default=None, help="card id used in a generated name")
|
|
1079
|
+
p.add_argument("--billing", choices=["api", "plan", "unknown"], default=None, help="default: detected")
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
@command("new", "create a card", mutating=True)
|
|
1083
|
+
def cmd_new(ctx, a):
|
|
1084
|
+
if a.suggest_locks:
|
|
1085
|
+
if a.locks is not None:
|
|
1086
|
+
raise Precondition("lock suggestions do not create a card; rerun with --locks without --suggest-locks")
|
|
1087
|
+
from .infer import suggest_locks
|
|
1088
|
+
|
|
1089
|
+
suggestions = suggest_locks(ctx.board, " ".join(filter(None, (a.title, a.scope))))
|
|
1090
|
+
text = "\n".join(f"{item['path']}: {item['reason']}" for item in suggestions) or "No lock suggestions."
|
|
1091
|
+
if suggestions:
|
|
1092
|
+
paths = ",".join(item["path"] for item in suggestions)
|
|
1093
|
+
text += (
|
|
1094
|
+
"\nNo card created. Review these paths, then rerun without --suggest-locks and add "
|
|
1095
|
+
f"--locks {shlex.quote(paths)}."
|
|
1096
|
+
)
|
|
1097
|
+
else:
|
|
1098
|
+
text += "\nNo card created."
|
|
1099
|
+
return {"suggestions": suggestions, "created": False}, text
|
|
1100
|
+
c = ctx.board.new_card(
|
|
1101
|
+
ctx.caller(),
|
|
1102
|
+
a.title,
|
|
1103
|
+
card_id=a.id,
|
|
1104
|
+
team=a.team or "",
|
|
1105
|
+
locks=a.locks,
|
|
1106
|
+
depends=a.depends,
|
|
1107
|
+
scope=a.scope or "",
|
|
1108
|
+
verify=a.verify or "",
|
|
1109
|
+
verify_cmd=a.verify_cmd or "",
|
|
1110
|
+
output=a.output or "",
|
|
1111
|
+
ticket=a.ticket or "",
|
|
1112
|
+
reviewers=a.reviewers,
|
|
1113
|
+
repo=a.repo,
|
|
1114
|
+
allow_protected=a.allow_protected,
|
|
1115
|
+
)
|
|
1116
|
+
locks = f"locks {', '.join(c['locks'])}" if c["locks"] else "no locks, so it reserves nothing"
|
|
1117
|
+
warnings = []
|
|
1118
|
+
if not c.get("verify_cmd") and ctx.board.settings.get("require_verify"):
|
|
1119
|
+
warnings.append(
|
|
1120
|
+
f"require_verify is on and {c['id']} has no verify command, so done will refuse it until one is set: "
|
|
1121
|
+
+ lead_cmd(ctx.board, f'edit {c["id"]} --verify-cmd "<check>"')
|
|
1122
|
+
)
|
|
1123
|
+
text = f"Created {c['id']} ({locks}). Next: raidlock claim {c['id']}"
|
|
1124
|
+
extra = {"warnings": warnings} if warnings else {}
|
|
1125
|
+
return {"id": c["id"], "card": c, **extra}, text + "".join(f"\nWarning: {w}" for w in warnings)
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
@command("edit", "lead: edit verification, repository, locks, dependencies or scope", mutating=True)
|
|
1129
|
+
def cmd_edit(ctx, a):
|
|
1130
|
+
r = ctx.board.edit_card(
|
|
1131
|
+
ctx.caller(),
|
|
1132
|
+
a.id,
|
|
1133
|
+
verify_cmd=a.verify_cmd,
|
|
1134
|
+
verify=a.verify,
|
|
1135
|
+
repo=a.repo,
|
|
1136
|
+
locks=a.locks,
|
|
1137
|
+
depends=a.depends,
|
|
1138
|
+
scope=a.scope,
|
|
1139
|
+
allow_protected=a.allow_protected,
|
|
1140
|
+
exclude_locks=a.exclude_locks,
|
|
1141
|
+
boss=None if a.boss is None else a.boss == "on",
|
|
1142
|
+
)
|
|
1143
|
+
if set(r) - {"card"} == {"boss"}:
|
|
1144
|
+
state = "is now a boss on the boss board" if r["boss"] else "is no longer a boss"
|
|
1145
|
+
return r, f"{a.id} {state}. Next: raidlock show {a.id}"
|
|
1146
|
+
if "verify_cmd" in r:
|
|
1147
|
+
what = f"verify command is now {r['verify_cmd']!r}" if r["verify_cmd"] else "verify command is cleared"
|
|
1148
|
+
else:
|
|
1149
|
+
what = "updated " + ", ".join(k for k in r if k != "card")
|
|
1150
|
+
nxt = lead_cmd(ctx.board, f"verify {a.id}") if r.get("verify_cmd") else "raidlock show " + a.id
|
|
1151
|
+
return r, f"Edited {a.id}: {what}; earlier receipts no longer count. Next: {nxt}"
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
@options("edit")
|
|
1155
|
+
def _edit_opts(p):
|
|
1156
|
+
p.add_argument("id")
|
|
1157
|
+
p.add_argument("--verify-cmd", default=None, help='command raid verify runs in a sandbox ("" clears it)')
|
|
1158
|
+
p.add_argument("--verify", default=None, help="how the check is described to the worker")
|
|
1159
|
+
p.add_argument("--allow-protected", default=None, help="lead-approved protected paths, comma-separated")
|
|
1160
|
+
p.add_argument("--repo", default=None, help="workspace repository for this card")
|
|
1161
|
+
p.add_argument("--locks", default=None, help="replace reserved paths; empty string clears them")
|
|
1162
|
+
p.add_argument("--exclude-locks", default=None, help="lead: reserve the directory except these file paths")
|
|
1163
|
+
p.add_argument("--depends", default=None, help="replace dependency card IDs")
|
|
1164
|
+
p.add_argument("--scope", default=None, help="replace the work description")
|
|
1165
|
+
p.add_argument("--boss", choices=("on", "off"), default=None, help="mark a large quest as a boss on the boss board")
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
@options("new")
|
|
1169
|
+
def _new_opts(p):
|
|
1170
|
+
p.add_argument("title")
|
|
1171
|
+
p.add_argument("--allow-protected", default=None, help="lead-approved protected paths, comma-separated")
|
|
1172
|
+
p.add_argument("--suggest-locks", action="store_true", help="preview lock suggestions without creating a card")
|
|
1173
|
+
for k in (
|
|
1174
|
+
"id",
|
|
1175
|
+
"team",
|
|
1176
|
+
"locks",
|
|
1177
|
+
"depends",
|
|
1178
|
+
"scope",
|
|
1179
|
+
"verify",
|
|
1180
|
+
"verify-cmd",
|
|
1181
|
+
"output",
|
|
1182
|
+
"ticket",
|
|
1183
|
+
"reviewers",
|
|
1184
|
+
"repo",
|
|
1185
|
+
):
|
|
1186
|
+
p.add_argument(f"--{k}", default=None, help="workspace repository" if k == "repo" else None)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
@command("plan", "import cards from a JSON or Markdown plan: raid plan import FILE", mutating=True)
|
|
1190
|
+
def cmd_plan(ctx, a):
|
|
1191
|
+
if a.file.startswith("recipe:"):
|
|
1192
|
+
from .recipes import parse_variables, read_plan
|
|
1193
|
+
|
|
1194
|
+
items = read_plan(a.file, a.team, parse_variables(a.var))
|
|
1195
|
+
else:
|
|
1196
|
+
if a.var:
|
|
1197
|
+
raise Precondition("--var applies only to recipe imports")
|
|
1198
|
+
items = _read_plan(a.file, a.team)
|
|
1199
|
+
cards = ctx.board.import_plan(ctx.caller(), items)
|
|
1200
|
+
ids = [c["id"] for c in cards]
|
|
1201
|
+
return {"cards": ids}, f"Imported {len(ids)} card(s): {', '.join(ids)}.\nNext: raidlock show"
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _read_plan(path, team):
|
|
1205
|
+
if os.path.isdir(path):
|
|
1206
|
+
path = os.path.join(path, "tasks.md")
|
|
1207
|
+
try:
|
|
1208
|
+
with open(path, encoding="utf-8") as f:
|
|
1209
|
+
text = f.read()
|
|
1210
|
+
except OSError as e:
|
|
1211
|
+
raise Precondition(
|
|
1212
|
+
f"cannot read {path}: {e.strerror}", hint="raidlock plan import <file.json|file.md>"
|
|
1213
|
+
) from None
|
|
1214
|
+
if path.endswith(".json"):
|
|
1215
|
+
try:
|
|
1216
|
+
data = json.loads(text)
|
|
1217
|
+
except ValueError as e:
|
|
1218
|
+
raise Precondition(f"{path} is not valid JSON: {e}") from None
|
|
1219
|
+
items = data.get("cards", []) if isinstance(data, dict) else data
|
|
1220
|
+
else:
|
|
1221
|
+
bullets = [line for line in text.splitlines() if line.lstrip().startswith(("- ", "* "))]
|
|
1222
|
+
tasks = [line for line in bullets if _MD_BOX.match(line)]
|
|
1223
|
+
items = [_md_item(line) for line in (tasks or bullets) if not _MD_DONE.match(line)]
|
|
1224
|
+
return [{"team": team, **i} if team and isinstance(i, dict) and not i.get("team") else i for i in items]
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
_MD_BOX = re.compile(r"^\s*[-*] \[[ xX]\]")
|
|
1228
|
+
_MD_DONE = re.compile(r"^\s*[-*] \[[xX]\]")
|
|
1229
|
+
_MD_TASK_NO = re.compile(r"^(T\d+|\d+(?:\.\d+)+\.?|\d+\.)\s+")
|
|
1230
|
+
_MD_MARKER = re.compile(r"^\[[A-Za-z0-9]{1,8}\]\s*")
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _md_item(line):
|
|
1234
|
+
text = line.lstrip()[2:].strip()
|
|
1235
|
+
if text.startswith("[ ]") or text.lower().startswith("[x]"):
|
|
1236
|
+
text = text[3:].strip()
|
|
1237
|
+
title, _, locks = text.partition(" | locks:")
|
|
1238
|
+
title = title.strip()
|
|
1239
|
+
ticket = ""
|
|
1240
|
+
number = _MD_TASK_NO.match(title)
|
|
1241
|
+
if number:
|
|
1242
|
+
ticket = number[1].rstrip(".")
|
|
1243
|
+
title = title[number.end() :]
|
|
1244
|
+
while _MD_MARKER.match(title):
|
|
1245
|
+
title = _MD_MARKER.sub("", title, count=1)
|
|
1246
|
+
item = {"title": title.strip(), "locks": locks.strip()}
|
|
1247
|
+
if ticket:
|
|
1248
|
+
item["ticket"] = ticket
|
|
1249
|
+
return item
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
@options("plan")
|
|
1253
|
+
def _plan_opts(p):
|
|
1254
|
+
p.add_argument("action", choices=["import"])
|
|
1255
|
+
p.add_argument("file")
|
|
1256
|
+
p.add_argument("--team", default=None)
|
|
1257
|
+
p.add_argument("--var", action="append", default=[], metavar="NAME=VALUE", help="variable for a recipe import")
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
@command("claimable", "cards you can claim, and why others are blocked")
|
|
1261
|
+
def cmd_claimable(ctx, a):
|
|
1262
|
+
m = ctx.board.claimable(agent=a.agent or ctx.caller().name or None, for_review=a.for_review)
|
|
1263
|
+
if not m:
|
|
1264
|
+
return {"cards": {}}, "Nothing waiting for review." if a.for_review else "Nothing open to claim."
|
|
1265
|
+
lines = []
|
|
1266
|
+
for cid, v in sorted(m.items(), key=lambda kv: (not kv[1]["ok"], kv[0])):
|
|
1267
|
+
if a.for_review:
|
|
1268
|
+
lines.append(f"review {cid}: {v['title']} (by {v['owner'] or '-'}, {len(v['approvals'])} approval(s))")
|
|
1269
|
+
elif v["ok"]:
|
|
1270
|
+
lines.append(
|
|
1271
|
+
f"ready {cid}: {v['title']}" + (f" (take over from {v['takeover']})" if v["takeover"] else "")
|
|
1272
|
+
)
|
|
1273
|
+
else:
|
|
1274
|
+
lines.append(f"not now {cid}: {v['title']} | {'; '.join(v['reasons'])}")
|
|
1275
|
+
return {"cards": m}, "\n".join(lines)
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
@options("claimable")
|
|
1279
|
+
def _claimable_opts(p):
|
|
1280
|
+
p.add_argument("--for-review", action="store_true")
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
@command("claim", "take a card: lock its paths and start work", mutating=True)
|
|
1284
|
+
def cmd_claim(ctx, a):
|
|
1285
|
+
r = ctx.board.claim(
|
|
1286
|
+
a.id,
|
|
1287
|
+
ctx.caller(),
|
|
1288
|
+
take_over=a.take_over,
|
|
1289
|
+
ttl_minutes=a.ttl,
|
|
1290
|
+
for_agent=a.for_agent,
|
|
1291
|
+
checkout=a.checkout,
|
|
1292
|
+
)
|
|
1293
|
+
paths = ", ".join(r["paths"])
|
|
1294
|
+
held = f"lock {paths} until {hhmm(r['expires'])}" if paths else f"no locks, lease until {hhmm(r['expires'])}"
|
|
1295
|
+
advice = "".join(f"\n# {warning}" for warning in r.get("warnings", []))
|
|
1296
|
+
cap = f"\nexport RAIDLOCK_CAP={r['cap']}" if r.get("cap") else ""
|
|
1297
|
+
return r, (
|
|
1298
|
+
f"Claimed {a.id}: {held}, fence {r['fence']}.{cap}\n"
|
|
1299
|
+
f'export RAIDLOCK_FENCE={r["fence"]} # next: raidlock note {a.id} "..."{advice}'
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
@options("claim")
|
|
1304
|
+
def _claim_opts(p):
|
|
1305
|
+
p.add_argument("id")
|
|
1306
|
+
p.add_argument("--take-over", action="store_true", help="take a card whose owner's lease expired")
|
|
1307
|
+
p.add_argument("--ttl", type=float, default=None, help="lease minutes (default: board ttl_minutes)")
|
|
1308
|
+
p.add_argument("--for", dest="for_agent", default=None, help="lead claims on behalf of an agent (runners)")
|
|
1309
|
+
p.add_argument(
|
|
1310
|
+
"--checkout", default=None, help="checkout recorded on the lease; omitted uses board repository resolution"
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
@command("heartbeat", "renew your lease on a card", mutating=True)
|
|
1315
|
+
def cmd_heartbeat(ctx, a):
|
|
1316
|
+
r = ctx.board.heartbeat(a.id, ctx.caller(), ttl_minutes=a.ttl)
|
|
1317
|
+
extra = " (resumed after it had expired)" if r["resumed"] else ""
|
|
1318
|
+
return r, f"Renewed {a.id} until {hhmm(r['expires'])}{extra}."
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
@options("heartbeat")
|
|
1322
|
+
def _hb_opts(p):
|
|
1323
|
+
p.add_argument("id")
|
|
1324
|
+
p.add_argument("--ttl", type=float, default=None)
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
@command("note", "add a progress note (renews your lease)", mutating=True)
|
|
1328
|
+
def cmd_note(ctx, a):
|
|
1329
|
+
return ctx.board.note(a.id, ctx.caller(), a.text), f"Noted on {a.id}."
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
@options("note")
|
|
1333
|
+
def _note_opts(p):
|
|
1334
|
+
p.add_argument("id")
|
|
1335
|
+
p.add_argument("text")
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
@command("lock", "add paths to a card's locks", mutating=True)
|
|
1339
|
+
def cmd_lock(ctx, a):
|
|
1340
|
+
r = ctx.board.lock_paths(a.id, ctx.caller(), a.replace or a.paths, replace=a.replace is not None)
|
|
1341
|
+
return r, f"{a.id} now locks {', '.join(r['locks'])}."
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
@options("lock")
|
|
1345
|
+
def _lock_opts(p):
|
|
1346
|
+
p.add_argument("id")
|
|
1347
|
+
p.add_argument("paths", nargs="?", default="")
|
|
1348
|
+
p.add_argument("--replace", default=None, help="lead: replace all locks with these paths")
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
@command("unlock", "lead: remove exact lock paths from a card", mutating=True)
|
|
1352
|
+
def cmd_unlock(ctx, a):
|
|
1353
|
+
r = ctx.board.lock_paths(a.id, ctx.caller(), a.paths, remove=True)
|
|
1354
|
+
return r, f"{a.id} now locks {', '.join(r['locks'])}."
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
@options("unlock")
|
|
1358
|
+
def _unlock_opts(p):
|
|
1359
|
+
p.add_argument("id")
|
|
1360
|
+
p.add_argument("paths", help="exact lock paths to remove (lead only)")
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
@command("release", "give a card back: drop its lease (doing goes back to todo)", mutating=True)
|
|
1364
|
+
def cmd_release(ctx, a):
|
|
1365
|
+
r = ctx.board.release(a.id, ctx.caller(), note=a.note)
|
|
1366
|
+
return r, f"Released {a.id}; it is in {r['lane']}."
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
@options("release")
|
|
1370
|
+
def _release_opts(p):
|
|
1371
|
+
p.add_argument("id")
|
|
1372
|
+
p.add_argument("--note", default=None)
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def _moved(card_id, lane):
|
|
1376
|
+
nxt = f" Next: the lead runs raidlock verify {card_id}, then done" if lane == "review" else ""
|
|
1377
|
+
return f"Moved {card_id} to {lane}.{nxt}"
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
@command("move", "move a card to review, blocked or todo (done: the lead uses raid done)", mutating=True)
|
|
1381
|
+
def cmd_move(ctx, a):
|
|
1382
|
+
r = ctx.board.move(a.id, a.lane, ctx.caller(), note=a.note)
|
|
1383
|
+
return r, _moved(a.id, a.lane)
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
@options("move")
|
|
1387
|
+
def _move_opts(p):
|
|
1388
|
+
p.add_argument("id")
|
|
1389
|
+
p.add_argument("lane", choices=list(LANES))
|
|
1390
|
+
p.add_argument("--note", default=None)
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _prune_runs(board, card_id):
|
|
1394
|
+
from .runners.runner import prune_runs
|
|
1395
|
+
|
|
1396
|
+
prune_runs(board, card_id)
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
@command("close", "lead: verify, record a reviewer approval, and close a card", mutating=True)
|
|
1400
|
+
def cmd_close(ctx, a):
|
|
1401
|
+
from .core.identity import read_token
|
|
1402
|
+
|
|
1403
|
+
board, caller = ctx.board, ctx.caller()
|
|
1404
|
+
if getattr(a, "evidence", None) is not None:
|
|
1405
|
+
result = board.close_with_evidence(a.id, caller, a.evidence, a.note)
|
|
1406
|
+
_prune_runs(board, a.id)
|
|
1407
|
+
return result, f"Closed {a.id} with evidence {a.evidence}."
|
|
1408
|
+
with board.lead_write(caller, "close a card"):
|
|
1409
|
+
card = board.card(a.id)
|
|
1410
|
+
agents = board.agents()
|
|
1411
|
+
reviewer = agents.get(a.reviewer, {})
|
|
1412
|
+
owner = agents.get(card.get("owner"), {})
|
|
1413
|
+
if not reviewer or not reviewer.get("provider") or not owner.get("provider"):
|
|
1414
|
+
raise Precondition("close requires registered owner and reviewer providers")
|
|
1415
|
+
if a.reviewer == card.get("owner") or reviewer["provider"] == owner["provider"]:
|
|
1416
|
+
raise Precondition("close requires a reviewer from another provider")
|
|
1417
|
+
token = read_token(board.home, board.uid, a.reviewer)
|
|
1418
|
+
if not token:
|
|
1419
|
+
raise Precondition("reviewer credential is missing; re-join the reviewer first")
|
|
1420
|
+
review_caller = board.caller(agent=a.reviewer, token=token, env={})
|
|
1421
|
+
board._auth(review_caller)
|
|
1422
|
+
if card["lane"] not in ("doing", "blocked", "review"):
|
|
1423
|
+
raise Precondition("close requires a claimed card")
|
|
1424
|
+
_verify_checkout(board, card, a.cwd)
|
|
1425
|
+
verified = board.verify(a.id, caller, timeout=a.timeout, cwd=a.cwd)
|
|
1426
|
+
if not verified["passed"]:
|
|
1427
|
+
return {**verified, "_exit": 2}, f"Verification failed for {a.id}: {verified['tail']}"
|
|
1428
|
+
if board.card(a.id)["lane"] != "review":
|
|
1429
|
+
board.move(a.id, "review", caller)
|
|
1430
|
+
board.assign_reviewers(a.id, caller, list(dict.fromkeys(card.get("reviewers", []) + [a.reviewer])))
|
|
1431
|
+
board.review(a.id, review_caller, "approve", note=a.note)
|
|
1432
|
+
result = board.move(a.id, "done", caller)
|
|
1433
|
+
_prune_runs(board, a.id)
|
|
1434
|
+
return result, f"Closed {a.id}; verified and approved by {a.reviewer}."
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
@options("close")
|
|
1438
|
+
def _close_opts(p):
|
|
1439
|
+
p.add_argument("id", help="card to verify and close")
|
|
1440
|
+
mode = p.add_mutually_exclusive_group(required=True)
|
|
1441
|
+
mode.add_argument("--reviewer", help="reviewer whose existing credential records approval")
|
|
1442
|
+
mode.add_argument("--evidence", help="https URL or commit SHA proving work delivered outside the board")
|
|
1443
|
+
p.add_argument("--cwd", "--checkout", default=None, help="clean checkout of the card branch")
|
|
1444
|
+
p.add_argument("--timeout", type=float, default=600, help="verification timeout in seconds")
|
|
1445
|
+
p.add_argument("--note", required=True, help="review evidence supporting the approval")
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
@command("cancel", "lead: cancel a card without verification or review gates", mutating=True)
|
|
1449
|
+
def cmd_cancel(ctx, a):
|
|
1450
|
+
result = ctx.board.cancel(a.id, ctx.caller(), a.reason)
|
|
1451
|
+
_prune_runs(ctx.board, a.id)
|
|
1452
|
+
return result, f"Cancelled {a.id}: {result['reason']}"
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
@options("cancel")
|
|
1456
|
+
def _cancel_opts(p):
|
|
1457
|
+
p.add_argument("id", help="card to cancel")
|
|
1458
|
+
p.add_argument("--reason", required=True, help="why the work will not be done")
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
@command("done", "lead: close a card after its verify and review gates pass", mutating=True)
|
|
1462
|
+
def cmd_done(ctx, a):
|
|
1463
|
+
before = ctx.board.card(a.id)
|
|
1464
|
+
r = ctx.board.move(a.id, "done", ctx.caller(), note=a.note)
|
|
1465
|
+
_prune_runs(ctx.board, a.id)
|
|
1466
|
+
warnings = []
|
|
1467
|
+
if before["lane"] == "todo":
|
|
1468
|
+
warnings.append(f"{a.id} was closed from todo: no worker claimed it or handed work back")
|
|
1469
|
+
if not model.last_verification(before) and not before.get("verify_cmd"):
|
|
1470
|
+
warnings.append(f"{a.id} has no verify command, so no receipt was checked")
|
|
1471
|
+
text = f"Done: {a.id}. Next: raidlock show" + "".join(f"\nWarning: {w}" for w in warnings)
|
|
1472
|
+
return {**r, **({"warnings": warnings} if warnings else {})}, text
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
@options("done")
|
|
1476
|
+
def _done_opts(p):
|
|
1477
|
+
p.add_argument("id")
|
|
1478
|
+
p.add_argument("--note", default=None)
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
@command("reopen", "lead: move a done card back to todo", mutating=True)
|
|
1482
|
+
def cmd_reopen(ctx, a):
|
|
1483
|
+
return ctx.board.reopen(a.id, ctx.caller(), note=a.note), f"Reopened {a.id}; it is in todo."
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
@options("reopen")
|
|
1487
|
+
def _reopen_opts(p):
|
|
1488
|
+
p.add_argument("id")
|
|
1489
|
+
p.add_argument("--note", default=None)
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
@command("review", "review a card (--verdict) or assign reviewers (--assign)", mutating=True)
|
|
1493
|
+
def cmd_review(ctx, a):
|
|
1494
|
+
if a.assign is not None:
|
|
1495
|
+
r = ctx.board.assign_reviewers(a.id, ctx.caller(), a.assign)
|
|
1496
|
+
return r, f"Reviewers of {a.id}: {', '.join(r['reviewers']) or 'none'}." + "".join(
|
|
1497
|
+
f"\nWarning: {w}" for w in r["warnings"]
|
|
1498
|
+
)
|
|
1499
|
+
if not a.verdict:
|
|
1500
|
+
raise UsageError("review needs --verdict or --assign", hint=f"raidlock review {a.id} --verdict approve")
|
|
1501
|
+
r = ctx.board.review(a.id, ctx.caller(), a.verdict, a.note or "")
|
|
1502
|
+
if r["lane"] == "doing":
|
|
1503
|
+
return r, f"Asked for changes on {a.id}; it is back in doing for its owner."
|
|
1504
|
+
return r, f"Recorded {a.verdict} on {a.id} ({len(r['approvals'])} approval(s))."
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
@options("review")
|
|
1508
|
+
def _review_opts(p):
|
|
1509
|
+
p.add_argument("id")
|
|
1510
|
+
p.add_argument("--by", dest="agent", default=None, help="reviewing agent (same as --agent)")
|
|
1511
|
+
p.add_argument("--verdict", choices=["approve", "changes", "comment"], default=None)
|
|
1512
|
+
p.add_argument("--note", default=None)
|
|
1513
|
+
p.add_argument("--assign", default=None, help="comma-separated reviewer agents")
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
@command("verify", "lead: run the card's verify command and record the result", mutating=True)
|
|
1517
|
+
def cmd_verify(ctx, a):
|
|
1518
|
+
board = ctx.board
|
|
1519
|
+
caller = ctx.caller()
|
|
1520
|
+
if caller.is_lead:
|
|
1521
|
+
_verify_checkout(board, board.card(a.id), a.cwd)
|
|
1522
|
+
r = board.verify(a.id, caller, timeout=a.timeout, cwd=a.cwd)
|
|
1523
|
+
secs = r["duration_ms"] / 1000
|
|
1524
|
+
if r["passed"]:
|
|
1525
|
+
return r, f"Verify passed for {a.id} ({secs:.1f} s). Next: " + lead_cmd(board, f"done {a.id}")
|
|
1526
|
+
return {**r, "_exit": 2}, (
|
|
1527
|
+
f"Verify failed for {a.id} (exit {r['exit']}, {secs:.1f} s).\n{r['tail']}".rstrip()
|
|
1528
|
+
+ "\nNext: fix the work, commit, then "
|
|
1529
|
+
+ lead_cmd(board, f"verify {a.id}")
|
|
1530
|
+
)
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def _verify_checkout(board, card, cwd):
|
|
1534
|
+
from .core import repos
|
|
1535
|
+
|
|
1536
|
+
if not card.get("verify_cmd"):
|
|
1537
|
+
return
|
|
1538
|
+
source = getattr(board, "_verification_source", None)
|
|
1539
|
+
if cwd:
|
|
1540
|
+
where = os.path.realpath(cwd)
|
|
1541
|
+
elif source is not None:
|
|
1542
|
+
where = os.path.realpath(source(card))
|
|
1543
|
+
else:
|
|
1544
|
+
return
|
|
1545
|
+
if repos.find_root(where):
|
|
1546
|
+
from .runners import workspace
|
|
1547
|
+
|
|
1548
|
+
try:
|
|
1549
|
+
workspace.head(where)
|
|
1550
|
+
return
|
|
1551
|
+
except RaidlockError:
|
|
1552
|
+
pass
|
|
1553
|
+
guess = "<repo checkout>"
|
|
1554
|
+
if card.get("locks"):
|
|
1555
|
+
try:
|
|
1556
|
+
from .runners import policy
|
|
1557
|
+
|
|
1558
|
+
guess = quote_path(policy.target(board.workspace, board.dir, card["locks"])["path"])
|
|
1559
|
+
except RaidlockError:
|
|
1560
|
+
pass
|
|
1561
|
+
raise Precondition(
|
|
1562
|
+
f"{short(where)} is not a git repository; verify runs a committed checkout of the card's repo",
|
|
1563
|
+
card=card["id"],
|
|
1564
|
+
hint=lead_cmd(board, f"verify {card['id']} --cwd {guess}"),
|
|
1565
|
+
)
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
@options("verify")
|
|
1569
|
+
def _verify_opts(p):
|
|
1570
|
+
p.add_argument("id")
|
|
1571
|
+
p.add_argument("--timeout", type=float, default=600)
|
|
1572
|
+
p.add_argument("--cwd", "--checkout", default=None, help="clean checkout of the card branch to verify")
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
@command("msg", "send a message to an agent", mutating=True)
|
|
1576
|
+
def cmd_msg(ctx, a):
|
|
1577
|
+
m = ctx.board.send(ctx.caller(), a.to, a.text, task=a.task, urgent=a.urgent)
|
|
1578
|
+
if m["dropped"]:
|
|
1579
|
+
return m, f"Not sent: the same message went to {a.to} less than a minute ago."
|
|
1580
|
+
if m["status"] == "queued":
|
|
1581
|
+
return m, f"Queued for {a.to}; delivered when it finishes its current card."
|
|
1582
|
+
return m, f"Sent to {a.to}."
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
@options("msg")
|
|
1586
|
+
def _msg_opts(p):
|
|
1587
|
+
p.add_argument("text")
|
|
1588
|
+
p.add_argument("--to", required=True)
|
|
1589
|
+
p.add_argument("--from", dest="agent", default=None, help="sender (same as --agent)")
|
|
1590
|
+
p.add_argument("--task", "--card", dest="task", default=None)
|
|
1591
|
+
p.add_argument("--urgent", action="store_true", help="lead only: deliver now")
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
@command("inbox", "read your messages", mutating=True)
|
|
1595
|
+
def cmd_inbox(ctx, a):
|
|
1596
|
+
r = ctx.board.inbox(ctx.caller(), show_all=a.all, wait=a.wait or 0.0, interval=a.interval)
|
|
1597
|
+
lines = [
|
|
1598
|
+
f"{m['sent']} {m['from']}{(' [' + m['task'] + ']') if m.get('task') else ''}"
|
|
1599
|
+
f"{(' (' + m['status'] + ')') if a.all else ''}: {m['text']}"
|
|
1600
|
+
for m in r["messages"]
|
|
1601
|
+
]
|
|
1602
|
+
if r["queued"] and not a.all:
|
|
1603
|
+
lines.append(f"{r['queued']} more queued until you finish {r['busy']}.")
|
|
1604
|
+
return r, "\n".join(lines) or "No messages."
|
|
1605
|
+
|
|
1606
|
+
|
|
1607
|
+
@options("inbox")
|
|
1608
|
+
def _inbox_opts(p):
|
|
1609
|
+
p.add_argument("--all", action="store_true")
|
|
1610
|
+
p.add_argument(
|
|
1611
|
+
"--wait",
|
|
1612
|
+
type=float,
|
|
1613
|
+
nargs="?",
|
|
1614
|
+
const=1800.0,
|
|
1615
|
+
default=None,
|
|
1616
|
+
help="block until a message arrives (seconds, default 1800); exit 4 on timeout",
|
|
1617
|
+
)
|
|
1618
|
+
p.add_argument("--interval", type=float, default=2.0)
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
@command("wait", "wait until a card leaves doing (exit 4 on timeout)")
|
|
1622
|
+
def cmd_wait(ctx, a):
|
|
1623
|
+
r = ctx.board.wait(a.id, timeout=a.timeout, interval=a.interval)
|
|
1624
|
+
return r, f"{a.id} is now in {r['lane']}."
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
@options("wait")
|
|
1628
|
+
def _wait_opts(p):
|
|
1629
|
+
p.add_argument("id")
|
|
1630
|
+
p.add_argument("--timeout", type=float, default=1800)
|
|
1631
|
+
p.add_argument("--interval", type=float, default=5.0)
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
@command("cost", "record a run's usage on a card, or show totals without an id", mutating=True)
|
|
1635
|
+
def cmd_cost(ctx, a):
|
|
1636
|
+
b = ctx.board
|
|
1637
|
+
if not a.id:
|
|
1638
|
+
t = b.spend(by="card")
|
|
1639
|
+
return t, _spend_text(t)
|
|
1640
|
+
usage = Usage(
|
|
1641
|
+
input=a.input,
|
|
1642
|
+
cached_input=a.cached_input,
|
|
1643
|
+
cache_write=a.cache_write,
|
|
1644
|
+
output=a.output,
|
|
1645
|
+
reasoning_output=a.reasoning_output,
|
|
1646
|
+
)
|
|
1647
|
+
r = b.record_cost(
|
|
1648
|
+
a.id,
|
|
1649
|
+
ctx.caller(),
|
|
1650
|
+
model_id=a.model,
|
|
1651
|
+
usage=usage,
|
|
1652
|
+
usd=a.usd,
|
|
1653
|
+
duration_ms=a.duration_ms,
|
|
1654
|
+
billing=a.billing,
|
|
1655
|
+
agent=a.for_agent,
|
|
1656
|
+
)
|
|
1657
|
+
e = r["entry"]
|
|
1658
|
+
return r, f"Recorded {e['tokens']:,} tokens ({_usd(e)}) on {a.id}."
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
def _usd(row):
|
|
1662
|
+
if row.get("billing") == "plan":
|
|
1663
|
+
return "plan" + (f", API-equivalent ${row['usd_equiv']:.4f}" if row.get("usd_equiv") else "")
|
|
1664
|
+
return f"${row['usd']:.4f}" if row.get("usd") is not None else "USD —"
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
def _spend_text(t):
|
|
1668
|
+
tot = t["total"]
|
|
1669
|
+
head = (
|
|
1670
|
+
f"Spend: ${tot['usd']:.4f} (API-equivalent ${tot['usd_equiv']:.4f}), {tot['tokens'] or 0:,} tokens, "
|
|
1671
|
+
f"{tot['runs']} run(s)."
|
|
1672
|
+
)
|
|
1673
|
+
rows = [f" {r['key'] or '-'}: ${r['usd']:.4f}, {r['tokens']:,} tokens" for r in t["rows"]]
|
|
1674
|
+
return "\n".join([head] + rows)
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
@options("cost")
|
|
1678
|
+
def _cost_opts(p):
|
|
1679
|
+
p.add_argument("id", nargs="?")
|
|
1680
|
+
p.add_argument("--model", default=None)
|
|
1681
|
+
p.add_argument("--input", type=int, default=0, help="uncached input tokens")
|
|
1682
|
+
p.add_argument("--cached-input", "--cached", dest="cached_input", type=int, default=0, help="cache-read tokens")
|
|
1683
|
+
p.add_argument("--cache-write", type=int, default=0)
|
|
1684
|
+
p.add_argument("--output", type=int, default=0, help="output tokens, reasoning included")
|
|
1685
|
+
p.add_argument("--reasoning-output", type=int, default=0, help="reasoning part of output (informational)")
|
|
1686
|
+
p.add_argument("--usd", type=float, default=None, help="runner-reported cost; wins over pricing")
|
|
1687
|
+
p.add_argument("--billing", choices=["api", "plan", "unknown"], default=None)
|
|
1688
|
+
p.add_argument("--for", dest="for_agent", default=None, help="lead records a run for another agent")
|
|
1689
|
+
p.add_argument("--duration-ms", type=int, default=0)
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
@command("spend", "spend by provider, model, agent, team, card, workspace, day or week")
|
|
1693
|
+
def cmd_spend(ctx, a):
|
|
1694
|
+
t = ctx.board.spend(by=a.by, since=a.since, until=a.until, everywhere=a.all)
|
|
1695
|
+
return t, _spend_text(t)
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
@options("spend")
|
|
1699
|
+
def _spend_opts(p):
|
|
1700
|
+
p.add_argument(
|
|
1701
|
+
"--by",
|
|
1702
|
+
default="provider",
|
|
1703
|
+
choices=["provider", "model", "agent", "team", "card", "billing", "workspace", "day", "week"],
|
|
1704
|
+
)
|
|
1705
|
+
p.add_argument("--since", default=None, help="UTC date or timestamp, e.g. 2026-10-01")
|
|
1706
|
+
p.add_argument("--until", default=None)
|
|
1707
|
+
p.add_argument("--all", action="store_true", help="every registered workspace")
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
@command("who", "who is on the board and what they are doing")
|
|
1711
|
+
def cmd_who(ctx, a):
|
|
1712
|
+
b = ctx.board
|
|
1713
|
+
who = b.who()
|
|
1714
|
+
icons = {
|
|
1715
|
+
"working": glyph("\U0001f525", "*"),
|
|
1716
|
+
"quiet": glyph("\U0001f525", "*"),
|
|
1717
|
+
"review": glyph("\U0001f989", "?"),
|
|
1718
|
+
"blocked": glyph("\U0001f578", "!"),
|
|
1719
|
+
"stalled": glyph("⌛", "~"),
|
|
1720
|
+
"idle": glyph("\U0001f37a", "-"),
|
|
1721
|
+
}
|
|
1722
|
+
lines = []
|
|
1723
|
+
for name, w in sorted(who.items()):
|
|
1724
|
+
card = f" on {w['card']}" if w["card"] else ""
|
|
1725
|
+
spent = f", spent ${w['spend_usd']:.2f}" if w["spend_usd"] else ""
|
|
1726
|
+
session = f", session {w['session']}" if w.get("session") else ""
|
|
1727
|
+
lines.append(f"{icons[w['status']]} {w.get('display') or name}: {w['label']}{card}{spent}{session}")
|
|
1728
|
+
leases = b.leases()
|
|
1729
|
+
lease = b.lead_status()
|
|
1730
|
+
if lease["held"]:
|
|
1731
|
+
state = "live" if lease["live"] else "expired"
|
|
1732
|
+
lines.append(
|
|
1733
|
+
f"lead session: {lease['session'] or '-'} (fence {lease['fence']}, {state} until "
|
|
1734
|
+
f"{hhmm(lease['expires'])}, secret in {lease['store']})"
|
|
1735
|
+
)
|
|
1736
|
+
if a.brief:
|
|
1737
|
+
lines = [
|
|
1738
|
+
"; ".join(f"{n} {w['status']}" + (f" {w['card']}" if w["card"] else "") for n, w in sorted(who.items()))
|
|
1739
|
+
]
|
|
1740
|
+
return {"agents": who, "leases": leases, "lead_lease": lease}, "\n".join(
|
|
1741
|
+
lines
|
|
1742
|
+
) or "No agents yet. Next: raidlock join"
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
@options("who")
|
|
1746
|
+
def _who_opts(p):
|
|
1747
|
+
p.add_argument("--brief", action="store_true", help="one line")
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
@command("lead", "inspect or transfer the lead session lease", mutating=True)
|
|
1751
|
+
def cmd_lead(ctx, a):
|
|
1752
|
+
b = ctx.board
|
|
1753
|
+
if a.action == "status":
|
|
1754
|
+
r = b.lead_status()
|
|
1755
|
+
if not r["held"]:
|
|
1756
|
+
return r, f"No lead lease on {b.name}. Next: raidlock lead takeover --lead-secret <file>"
|
|
1757
|
+
state = "live" if r["live"] else "EXPIRED"
|
|
1758
|
+
return r, (
|
|
1759
|
+
f"Lead {r['lead']}: session {r['session'] or '-'}, fence {r['fence']}, {state} until "
|
|
1760
|
+
f"{hhmm(r['expires'])}, secret in {r['store']}" + (" (handover pending)" if r.get("pending") else "")
|
|
1761
|
+
)
|
|
1762
|
+
if a.action == "heartbeat":
|
|
1763
|
+
r = b.lead_heartbeat(ctx.caller())
|
|
1764
|
+
return r, f"Lead lease renewed until {hhmm(r['expires'])} (fence {r['fence']})."
|
|
1765
|
+
if a.action == "handover":
|
|
1766
|
+
if not a.to:
|
|
1767
|
+
raise UsageError("lead handover needs --to <session>", hint="raidlock lead handover --to <session id>")
|
|
1768
|
+
r = b.lead_handover(ctx.caller(), a.to)
|
|
1769
|
+
return r, (
|
|
1770
|
+
f"Lead handed over to session {r['to']} at fence {r['fence']}; the old secret is retired.\n"
|
|
1771
|
+
"Next (in that session): raidlock --lead-secret "
|
|
1772
|
+
f"{short(r['secret_file']) if r['store'] == 'file' else 'keychain'} lead status"
|
|
1773
|
+
)
|
|
1774
|
+
r = b.lead_takeover(lead_secret=ctx.lead_secret())
|
|
1775
|
+
prev = r["previous"]
|
|
1776
|
+
print(
|
|
1777
|
+
f"WARNING: lead takeover: session {r['session']} replaces session {prev['session'] or '(none)'} "
|
|
1778
|
+
f"(lease expired {prev['expires'] or '-'}); logged in the transcript.",
|
|
1779
|
+
file=sys.stderr,
|
|
1780
|
+
)
|
|
1781
|
+
return r, f"You hold the lead lease now (fence {r['fence']}). The previous lead secret is retired."
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
@options("lead")
|
|
1785
|
+
def _lead_opts(p):
|
|
1786
|
+
p.add_argument("action", choices=["status", "heartbeat", "handover", "takeover"])
|
|
1787
|
+
p.add_argument("--to", default=None, help="session id that receives the lead (handover)")
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
@command("reap", "move cards whose doing lease expired to blocked", mutating=True)
|
|
1791
|
+
def cmd_reap(ctx, a):
|
|
1792
|
+
r = ctx.board.reap()
|
|
1793
|
+
return r, f"Reaped {len(r['reaped'])} card(s): {', '.join(r['reaped'])}." if r["reaped"] else "Nothing to reap."
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
@command("show", "the board, or one card with its notes")
|
|
1797
|
+
def cmd_show(ctx, a):
|
|
1798
|
+
b = ctx.board
|
|
1799
|
+
if a.id:
|
|
1800
|
+
c = b.card_view(b.card(a.id))
|
|
1801
|
+
lines = [
|
|
1802
|
+
f"{c['id']}: {c['title']}",
|
|
1803
|
+
f"{c['lane']} ({c['a2a_state']}), owner {c['owner'] or '-'}, team {c['team'] or '-'}, fence {c['fence']}",
|
|
1804
|
+
f"locks: {', '.join(c['locks']) or '-'}",
|
|
1805
|
+
]
|
|
1806
|
+
lines += [
|
|
1807
|
+
f"{k}: {c[k]}"
|
|
1808
|
+
for k in ("repo", "scope", "output", "verify", "verify_cmd", "ticket", "evidence")
|
|
1809
|
+
if c.get(k)
|
|
1810
|
+
]
|
|
1811
|
+
if c.get("lock_exclusions"):
|
|
1812
|
+
lines.append("excluded lock paths: " + ", ".join(c["lock_exclusions"]))
|
|
1813
|
+
if c.get("allow_protected"):
|
|
1814
|
+
lines.append("lead-approved protected paths: " + ", ".join(c["allow_protected"]))
|
|
1815
|
+
if c.get("pr"):
|
|
1816
|
+
lines.append("pr: " + _pr_badge(c) + (f" {c['pr']['url']}" if c["pr"].get("url") else ""))
|
|
1817
|
+
lines += [f"- {n['ts']} {n['by'] or '?'}: {n['text']}" for n in c["notes"]]
|
|
1818
|
+
return {"card": c}, "\n".join(lines)
|
|
1819
|
+
cards, leases = b.cards(), b.leases()
|
|
1820
|
+
if a.brief:
|
|
1821
|
+
lanes = [(lane, [c["id"] for c in cards if c["lane"] == lane]) for lane in LANES]
|
|
1822
|
+
text = " | ".join(
|
|
1823
|
+
f"{lane} {len(ids)}"
|
|
1824
|
+
+ (f": {', '.join(ids[:8])}" + (" ..." if len(ids) > 8 else "") if ids and lane != "done" else "")
|
|
1825
|
+
for lane, ids in lanes
|
|
1826
|
+
)
|
|
1827
|
+
return {"cards": cards, "version": b.version()}, text
|
|
1828
|
+
lock = glyph("\U0001f512", "lock")
|
|
1829
|
+
out = []
|
|
1830
|
+
for lane in LANES:
|
|
1831
|
+
rows = [c for c in cards if c["lane"] == lane]
|
|
1832
|
+
out.append(f"{lane} ({len(rows)})")
|
|
1833
|
+
if lane == "done" and not a.all:
|
|
1834
|
+
continue
|
|
1835
|
+
for c in rows:
|
|
1836
|
+
held = f"{lock} {hhmm(leases[c['id']]['expires'])}" if c["id"] in leases else ""
|
|
1837
|
+
extra = " ".join(
|
|
1838
|
+
x
|
|
1839
|
+
for x in (
|
|
1840
|
+
f"[{c['team']}]" if c["team"] else "",
|
|
1841
|
+
f"@{c['owner']}" if c["owner"] else "",
|
|
1842
|
+
held,
|
|
1843
|
+
f"({_pr_badge(c)})" if c.get("pr") else "",
|
|
1844
|
+
)
|
|
1845
|
+
if x
|
|
1846
|
+
)
|
|
1847
|
+
out.append(f" {c['id']}: {c['title']} {extra}".rstrip())
|
|
1848
|
+
agents = b.agents()
|
|
1849
|
+
return {"cards": [b.card_view(c, leases, agents) for c in cards], "version": b.version()}, "\n".join(out)
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
def _pr_badge(card):
|
|
1853
|
+
pr = card["pr"]
|
|
1854
|
+
state = str(pr.get("state") or "linked").lower()
|
|
1855
|
+
checks = (pr.get("checks") or {}).get("state", "none")
|
|
1856
|
+
return f"PR #{pr.get('number')} {state}" + (f" checks {checks}" if checks != "none" else "")
|
|
1857
|
+
|
|
1858
|
+
|
|
1859
|
+
@options("show")
|
|
1860
|
+
def _show_opts(p):
|
|
1861
|
+
p.add_argument("id", nargs="?")
|
|
1862
|
+
p.add_argument("--all", action="store_true", help="list done cards too")
|
|
1863
|
+
p.add_argument("--brief", action="store_true", help="one line per lane")
|
|
1864
|
+
|
|
1865
|
+
|
|
1866
|
+
@command("workspaces", "registered workspaces on this machine", aliases=("projects",))
|
|
1867
|
+
def cmd_workspaces(ctx, a):
|
|
1868
|
+
index = Index(registry.home())
|
|
1869
|
+
if a.rm:
|
|
1870
|
+
gone = registry.remove(index, a.rm)
|
|
1871
|
+
return {"removed": gone}, f"Removed {', '.join(gone) or 'nothing'}."
|
|
1872
|
+
rows = [_workspace_row(index, w) for w in registry.workspaces(index)]
|
|
1873
|
+
lines = [
|
|
1874
|
+
f"{r['name']}: {short(os.path.dirname(r['board']))}"
|
|
1875
|
+
+ (
|
|
1876
|
+
" (missing)"
|
|
1877
|
+
if r["missing"]
|
|
1878
|
+
else ", "
|
|
1879
|
+
+ ", ".join(f"{n} {k}" for k, n in r["lanes"].items() if n)
|
|
1880
|
+
+ f", {r['live_leases']} live lease(s)"
|
|
1881
|
+
)
|
|
1882
|
+
for r in rows
|
|
1883
|
+
]
|
|
1884
|
+
return {"workspaces": rows}, "\n".join(lines) or "No workspaces registered.\nNext: raidlock up"
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
def _workspace_row(index, w):
|
|
1888
|
+
lanes = {lane: 0 for lane in LANES}
|
|
1889
|
+
for c in index.card_rows(w["uid"]):
|
|
1890
|
+
lanes[c["lane"]] = lanes.get(c["lane"], 0) + 1
|
|
1891
|
+
live = sum(1 for lease in index.leases(w["uid"]).values() if is_live(lease))
|
|
1892
|
+
return {
|
|
1893
|
+
**w,
|
|
1894
|
+
"missing": not os.path.exists(os.path.join(w["board"], "board.json")),
|
|
1895
|
+
"lanes": lanes,
|
|
1896
|
+
"live_leases": live,
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
@options("workspaces")
|
|
1901
|
+
def _ws_opts(p):
|
|
1902
|
+
p.add_argument("--rm", nargs="+", default=None, metavar="NAME")
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
@command("check", "check paths for lock conflicts, or a file write (--write FILE)")
|
|
1906
|
+
def cmd_check(ctx, a):
|
|
1907
|
+
b = ctx.board
|
|
1908
|
+
if a.write:
|
|
1909
|
+
d = b.check_write(a.write, ctx.caller())
|
|
1910
|
+
return {**d, "_exit": 0 if d["allowed"] else 2}, ("Allowed: " if d["allowed"] else "Denied: ") + d[
|
|
1911
|
+
"reason"
|
|
1912
|
+
] + "."
|
|
1913
|
+
if not a.paths:
|
|
1914
|
+
raise UsageError("check needs paths or --write FILE", hint="raidlock check app/src")
|
|
1915
|
+
conflicts = b.check_paths(a.paths, card_id=a.id)
|
|
1916
|
+
if not conflicts:
|
|
1917
|
+
return {"free": True, "conflicts": []}, "Free."
|
|
1918
|
+
return {"free": False, "conflicts": conflicts, "_exit": 2}, "\n".join(describe(c) for c in conflicts)
|
|
1919
|
+
|
|
1920
|
+
|
|
1921
|
+
@options("check")
|
|
1922
|
+
def _check_opts(p):
|
|
1923
|
+
p.add_argument("paths", nargs="?")
|
|
1924
|
+
p.add_argument("--id", default=None, help="ignore this card's own lease")
|
|
1925
|
+
p.add_argument("--write", default=None, help="file to check for write permission")
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
@command("transcript", "the conversation log: newest records, filterable")
|
|
1929
|
+
def cmd_transcript(ctx, a):
|
|
1930
|
+
rows = ctx.board.transcript.read(task=a.task, kind=a.kind, agent=a.who, limit=a.tail)
|
|
1931
|
+
lines = []
|
|
1932
|
+
for r in rows:
|
|
1933
|
+
who = (r.get("from") or {}).get("agent", "?")
|
|
1934
|
+
text = " ".join(p.get("text", "") for p in r.get("parts", []) if p.get("kind") == "text")
|
|
1935
|
+
if r.get("src") == "v1-log" and not text.startswith("[untrusted v1 import]"):
|
|
1936
|
+
text = "[untrusted v1 import] " + text
|
|
1937
|
+
lines.append(
|
|
1938
|
+
f"{r['ts']} {r['kind']} {who}{(' -> ' + r['to']) if r.get('to') else ''}"
|
|
1939
|
+
f"{(' [' + r['taskId'] + ']') if r.get('taskId') else ''}: {text}"
|
|
1940
|
+
)
|
|
1941
|
+
return {"records": rows}, "\n".join(lines) or "No transcript yet."
|
|
1942
|
+
|
|
1943
|
+
|
|
1944
|
+
@options("transcript")
|
|
1945
|
+
def _transcript_opts(p):
|
|
1946
|
+
p.add_argument("--task", default=None)
|
|
1947
|
+
p.add_argument("--kind", default=None)
|
|
1948
|
+
p.add_argument("--who", default=None, help="records from or to this agent")
|
|
1949
|
+
p.add_argument("-n", "--tail", type=int, default=50, help="newest N records (0 = all)")
|
|
1950
|
+
|
|
1951
|
+
|
|
1952
|
+
@command("brief", "the card brief for an agent prompt, with its token estimate")
|
|
1953
|
+
def cmd_brief(ctx, a):
|
|
1954
|
+
r = ctx.board.brief(a.id, max_tokens=a.max_tokens)
|
|
1955
|
+
note = (
|
|
1956
|
+
f"~{r['tokens']} tokens"
|
|
1957
|
+
+ (f", {r['dropped']} line(s) cut" if r["dropped"] else "")
|
|
1958
|
+
+ (", over the cap" if r["over_cap"] else "")
|
|
1959
|
+
)
|
|
1960
|
+
return r, r["text"].rstrip("\n") + ("" if a.quiet else f"\n({note})")
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
@options("brief")
|
|
1964
|
+
def _brief_opts(p):
|
|
1965
|
+
p.add_argument("id")
|
|
1966
|
+
p.add_argument("--max-tokens", type=int, default=None, help="default: board setting brief_max_tokens")
|
|
1967
|
+
p.add_argument("--quiet", action="store_true", help="omit the token note (pipe straight into a prompt)")
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
@command("tokens", "estimated per-session token overhead of raidlock")
|
|
1971
|
+
def cmd_tokens(ctx, a):
|
|
1972
|
+
from .core import tokens as TK
|
|
1973
|
+
|
|
1974
|
+
try:
|
|
1975
|
+
board = ctx.board
|
|
1976
|
+
except Precondition:
|
|
1977
|
+
board = None
|
|
1978
|
+
r = TK.report(board)
|
|
1979
|
+
lines = [f"{i['name']}: ~{i['tokens']} ({i['detail']})" for i in r["items"]]
|
|
1980
|
+
return r, "\n".join(lines + [f"total: ~{r['total']} tokens per session ({r['method']})"])
|
|
1981
|
+
|
|
1982
|
+
|
|
1983
|
+
@command("version", "print the version")
|
|
1984
|
+
def cmd_version(ctx, a):
|
|
1985
|
+
return {"version": __version__}, f"raidlock {__version__}"
|
|
1986
|
+
|
|
1987
|
+
|
|
1988
|
+
def own_script(name="raid"):
|
|
1989
|
+
"""Path of this install's console script `name`, or None when it is not installed."""
|
|
1990
|
+
exe = name + (".exe" if os.name == "nt" else "")
|
|
1991
|
+
candidates = [os.path.join(sysconfig.get_path("scripts"), exe), os.path.join(os.path.dirname(sys.executable), exe)]
|
|
1992
|
+
if prog_name() == name and sys.argv and os.path.isabs(sys.argv[0]):
|
|
1993
|
+
candidates.insert(0, sys.argv[0])
|
|
1994
|
+
return next((c for c in candidates if os.path.isfile(c)), None)
|
|
1995
|
+
|
|
1996
|
+
|
|
1997
|
+
def _shadow(which, ours):
|
|
1998
|
+
if not which:
|
|
1999
|
+
return ""
|
|
2000
|
+
if ours:
|
|
2001
|
+
return "" if os.path.realpath(which) == os.path.realpath(ours) else which
|
|
2002
|
+
try:
|
|
2003
|
+
with open(which, "rb") as f:
|
|
2004
|
+
head = f.read(4096)
|
|
2005
|
+
except OSError:
|
|
2006
|
+
return which
|
|
2007
|
+
return "" if b"raidlock" in head else which
|
|
2008
|
+
|
|
2009
|
+
|
|
2010
|
+
def _home_writable(home):
|
|
2011
|
+
parent = home
|
|
2012
|
+
while not os.path.exists(parent):
|
|
2013
|
+
parent = os.path.dirname(parent)
|
|
2014
|
+
if not os.path.isdir(parent):
|
|
2015
|
+
return "not a directory"
|
|
2016
|
+
return "" if os.access(parent, os.W_OK) else "permission denied"
|
|
2017
|
+
|
|
2018
|
+
|
|
2019
|
+
@command("migrate", "explicitly upgrade a legacy board or restore its backup", mutating=True)
|
|
2020
|
+
def cmd_migrate(ctx, a):
|
|
2021
|
+
from .core import migrate
|
|
2022
|
+
from .core.board import find_board_dir
|
|
2023
|
+
|
|
2024
|
+
board_dir = find_board_dir(a.board)
|
|
2025
|
+
if a.rollback:
|
|
2026
|
+
choices = migrate.backups(board_dir)
|
|
2027
|
+
if not choices:
|
|
2028
|
+
raise Precondition("no recorded migration backup found")
|
|
2029
|
+
plan = f"Replace {board_dir} with backup {choices[0]}; changes since migration will be lost."
|
|
2030
|
+
else:
|
|
2031
|
+
migrate.preflight(board_dir, a.allow_auth_downgrade)
|
|
2032
|
+
if not migrate.needs_migration(board_dir):
|
|
2033
|
+
raise Precondition("no legacy v1 board found")
|
|
2034
|
+
plan = (
|
|
2035
|
+
f"Upgrade {board_dir} to schema 2: convert cards and inbox, rewrite agents and teams, "
|
|
2036
|
+
"import leases as inactive history (holders must claim again), create board.json and v1/. "
|
|
2037
|
+
"Save the complete original tree in a timestamped "
|
|
2038
|
+
"sibling .board.backup-* directory first (ignored in git workspaces)."
|
|
2039
|
+
)
|
|
2040
|
+
print(plan, file=sys.stderr)
|
|
2041
|
+
if not a.yes:
|
|
2042
|
+
if not _interactive():
|
|
2043
|
+
raise Precondition("migration requires --yes or interactive confirmation", hint="raid migrate --yes")
|
|
2044
|
+
if input("Continue? [y/N] ").strip().lower() not in ("y", "yes"):
|
|
2045
|
+
raise Precondition("migration cancelled; nothing changed")
|
|
2046
|
+
result = (
|
|
2047
|
+
migrate.rollback(board_dir, registry.home(), caller=ctx.caller(), board=ctx.board)
|
|
2048
|
+
if a.rollback
|
|
2049
|
+
else migrate.upgrade(board_dir, registry.home(), allow_auth_downgrade=a.allow_auth_downgrade, lead=a.lead)
|
|
2050
|
+
)
|
|
2051
|
+
message = f"{'Restored' if a.rollback else 'Migrated'} {board_dir}. Backup: {result['backup']}"
|
|
2052
|
+
if not a.rollback:
|
|
2053
|
+
message += f"\nLead: {result['lead']}\nSecret: {result['secret_file']}\nTakeover: {result['takeover']}"
|
|
2054
|
+
if result.get("dropped_untrusted_lore"):
|
|
2055
|
+
message += "\nDropped pre-existing untrusted lore; the original remains in the migration backup."
|
|
2056
|
+
for key, value in result["unmapped_settings"].items():
|
|
2057
|
+
message += f"\nNo v2 equivalent: {key} = {value!r}"
|
|
2058
|
+
return result, message
|
|
2059
|
+
|
|
2060
|
+
|
|
2061
|
+
@options("migrate")
|
|
2062
|
+
def _migrate_opts(p):
|
|
2063
|
+
p.add_argument("--lead", help="confirmed v1 lead when several lead-role agents exist")
|
|
2064
|
+
p.add_argument("--allow-auth-downgrade", action="store_true", help="explicitly accept imported auth=off")
|
|
2065
|
+
p.add_argument("--yes", action="store_true", help="confirm the printed changes")
|
|
2066
|
+
p.add_argument("--rollback", action="store_true", help="restore the recorded migration backup")
|
|
2067
|
+
|
|
2068
|
+
|
|
2069
|
+
def _tree_bytes(root):
|
|
2070
|
+
total = 0
|
|
2071
|
+
for base, _, files in os.walk(root):
|
|
2072
|
+
for name in files:
|
|
2073
|
+
with contextlib.suppress(OSError):
|
|
2074
|
+
total += os.lstat(os.path.join(base, name)).st_size
|
|
2075
|
+
return total
|
|
2076
|
+
|
|
2077
|
+
|
|
2078
|
+
@command("doctor", "check this install: Python, board, RAIDLOCK_HOME, database, PATH")
|
|
2079
|
+
def cmd_doctor(ctx, a):
|
|
2080
|
+
checks, problems, warnings = [], [], []
|
|
2081
|
+
|
|
2082
|
+
def check(name, ok, detail):
|
|
2083
|
+
checks.append({"check": name, "ok": ok, "detail": detail})
|
|
2084
|
+
if not ok:
|
|
2085
|
+
problems.append(f"{name}: {detail}")
|
|
2086
|
+
|
|
2087
|
+
py = ".".join(map(str, sys.version_info[:3]))
|
|
2088
|
+
check("python", sys.version_info >= (3, 11), py if sys.version_info >= (3, 11) else f"{py}; raidlock needs 3.11+")
|
|
2089
|
+
try:
|
|
2090
|
+
from .core import migrate, store
|
|
2091
|
+
from .core.board import find_board_dir
|
|
2092
|
+
|
|
2093
|
+
board_dir = find_board_dir(a.board)
|
|
2094
|
+
if migrate.needs_migration(board_dir):
|
|
2095
|
+
warnings.append("legacy v1 board found (not migrated); run raid migrate")
|
|
2096
|
+
check("board", True, "WARNING: " + warnings[-1])
|
|
2097
|
+
else:
|
|
2098
|
+
meta = store.read_json(os.path.join(board_dir, "board.json"))
|
|
2099
|
+
if meta is None:
|
|
2100
|
+
raise Precondition("not a raidlock board")
|
|
2101
|
+
store.check_schema(meta, os.path.join(board_dir, "board.json"))
|
|
2102
|
+
check("board", True, short(board_dir))
|
|
2103
|
+
except RaidlockError as e:
|
|
2104
|
+
explicit = a.board or os.environ.get("RAIDLOCK_BOARD")
|
|
2105
|
+
check("board", not explicit, f"none here ({e.message})" if not explicit else e.message)
|
|
2106
|
+
home = registry.home()
|
|
2107
|
+
err = _home_writable(home)
|
|
2108
|
+
check("home", not err, short(home) + (f" not writable: {err}" if err else " writable"))
|
|
2109
|
+
try:
|
|
2110
|
+
import sqlite3
|
|
2111
|
+
from pathlib import Path
|
|
2112
|
+
|
|
2113
|
+
path = Path(home) / "raidlock.db"
|
|
2114
|
+
if path.exists():
|
|
2115
|
+
import tempfile
|
|
2116
|
+
|
|
2117
|
+
with tempfile.TemporaryDirectory(prefix="raid-doctor-") as scratch:
|
|
2118
|
+
copy = Path(scratch) / path.name
|
|
2119
|
+
shutil.copyfile(path, copy)
|
|
2120
|
+
wal = Path(str(path) + "-wal")
|
|
2121
|
+
if wal.exists():
|
|
2122
|
+
shutil.copyfile(wal, str(copy) + "-wal")
|
|
2123
|
+
conn = sqlite3.connect(str(copy))
|
|
2124
|
+
try:
|
|
2125
|
+
n = conn.execute("SELECT count(*) FROM workspaces").fetchone()[0]
|
|
2126
|
+
state = conn.execute("PRAGMA quick_check").fetchone()[0]
|
|
2127
|
+
finally:
|
|
2128
|
+
conn.close()
|
|
2129
|
+
check("database", state == "ok", f"{state}, {n} workspace(s) registered")
|
|
2130
|
+
else:
|
|
2131
|
+
check("database", True, "not created yet")
|
|
2132
|
+
except Exception as e:
|
|
2133
|
+
check("database", False, f"cannot open the index in {short(home)}: {e}")
|
|
2134
|
+
used = sum(_tree_bytes(os.path.join(home, name)) for name in ("runs", "verify"))
|
|
2135
|
+
check("storage", True, f"{used / 1e6:.0f} MB in run clones and the verify cache; closed cards free theirs")
|
|
2136
|
+
which = shutil.which("raid")
|
|
2137
|
+
shadow = _shadow(which, own_script())
|
|
2138
|
+
check(
|
|
2139
|
+
"path",
|
|
2140
|
+
not shadow,
|
|
2141
|
+
f"another raid at {shadow} shadows this install; use raidlock instead or fix PATH"
|
|
2142
|
+
if shadow
|
|
2143
|
+
else (f"raid -> {short(which)}" if which else "raid not on PATH; raidlock works"),
|
|
2144
|
+
)
|
|
2145
|
+
lines = [f"{'ok ' if c['ok'] else 'FAIL'} {c['check']}: {c['detail']}" for c in checks]
|
|
2146
|
+
lines.append("Healthy." if not problems else f"{len(problems)} problem(s).")
|
|
2147
|
+
return {
|
|
2148
|
+
"healthy": not problems,
|
|
2149
|
+
"problems": problems,
|
|
2150
|
+
"warnings": warnings,
|
|
2151
|
+
"checks": checks,
|
|
2152
|
+
"_exit": 3 if problems else 0,
|
|
2153
|
+
}, "\n".join(lines)
|