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/lore/vault.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from ..core import redact, store
|
|
6
|
+
from ..core.errors import Precondition
|
|
7
|
+
from ..core.lock import FileLock
|
|
8
|
+
from . import frontmatter as F
|
|
9
|
+
|
|
10
|
+
LESSONS = "lessons"
|
|
11
|
+
PENDING = ".pending"
|
|
12
|
+
HUBS = "hubs"
|
|
13
|
+
STATE = ".state.json"
|
|
14
|
+
TEXT_MAX = 200
|
|
15
|
+
LINKED_PATHS = 3
|
|
16
|
+
FIELDS = (
|
|
17
|
+
"type",
|
|
18
|
+
"id",
|
|
19
|
+
"created",
|
|
20
|
+
"workspace",
|
|
21
|
+
"workspace_uid",
|
|
22
|
+
"repo",
|
|
23
|
+
"paths",
|
|
24
|
+
"card",
|
|
25
|
+
"agent",
|
|
26
|
+
"provider",
|
|
27
|
+
"model",
|
|
28
|
+
"source",
|
|
29
|
+
"source_id",
|
|
30
|
+
"tags",
|
|
31
|
+
"hits",
|
|
32
|
+
"last_used",
|
|
33
|
+
"status",
|
|
34
|
+
"proposed_by",
|
|
35
|
+
"approved_by",
|
|
36
|
+
"approved_at",
|
|
37
|
+
)
|
|
38
|
+
HUB_KINDS = ("card", "agent", "repo", "path")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_id(value):
|
|
42
|
+
if not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{12}", value) is None:
|
|
43
|
+
raise Precondition("invalid lesson id: expected 12 lowercase hexadecimal characters")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_folder(folder):
|
|
48
|
+
if folder not in (LESSONS, PENDING):
|
|
49
|
+
raise Precondition("invalid lesson folder")
|
|
50
|
+
return folder
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def slug(text):
|
|
54
|
+
"""Lowercase [a-z0-9-] form for hub note names."""
|
|
55
|
+
return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", (text or "").lower())).strip("-") or "x"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def one_line(text, limit=TEXT_MAX):
|
|
59
|
+
text = " ".join(redact(text or "").replace("[[", "[").replace("]]", "]").split())
|
|
60
|
+
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def hubs_of(lesson):
|
|
64
|
+
"""Hub note names a lesson links to, by kind."""
|
|
65
|
+
out = {"card": [], "agent": [], "repo": [], "path": []}
|
|
66
|
+
if lesson.get("card"):
|
|
67
|
+
out["card"].append("card-" + slug(lesson["card"]))
|
|
68
|
+
if lesson.get("agent"):
|
|
69
|
+
out["agent"].append("agent-" + slug(lesson["agent"]))
|
|
70
|
+
if lesson.get("repo"):
|
|
71
|
+
out["repo"].append("repo-" + slug(lesson["repo"]))
|
|
72
|
+
for p in lesson.get("paths", [])[:LINKED_PATHS]:
|
|
73
|
+
out["path"].append("path-" + slug(p))
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def body(lesson):
|
|
78
|
+
h = hubs_of(lesson)
|
|
79
|
+
head = [lesson["source"]] + [f"[[{n}]]" for k in ("card", "agent", "repo") for n in h[k]]
|
|
80
|
+
lines = [lesson["text"], " · ".join(head)]
|
|
81
|
+
if h["path"]:
|
|
82
|
+
lines.append("paths: " + " ".join(f"[[{n}]]" for n in h["path"]))
|
|
83
|
+
return "\n".join(lines)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def render(lesson):
|
|
87
|
+
meta = {k: lesson.get(k, "") for k in FIELDS}
|
|
88
|
+
meta["type"] = "lesson"
|
|
89
|
+
meta["paths"] = list(lesson.get("paths", []))
|
|
90
|
+
meta["tags"] = list(lesson.get("tags", []))
|
|
91
|
+
meta["hits"] = int(lesson.get("hits", 0))
|
|
92
|
+
return F.dump(meta, body(lesson))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def from_note(text):
|
|
96
|
+
meta, rest = F.parse(text)
|
|
97
|
+
if meta.get("type") != "lesson":
|
|
98
|
+
return None
|
|
99
|
+
validate_id(meta.get("id"))
|
|
100
|
+
lesson = dict(meta)
|
|
101
|
+
lesson["text"] = rest.split("\n", 1)[0]
|
|
102
|
+
return lesson
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Vault:
|
|
106
|
+
"""An Obsidian-compatible folder: lessons/lesson-<id>.md plus generated hub notes under hubs/."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, root, lock=None):
|
|
109
|
+
self.root = root
|
|
110
|
+
self._lock = lock
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def for_board(cls, board):
|
|
114
|
+
return cls(os.path.join(board.dir, "lore"), lock=board.lock)
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def global_(cls, home):
|
|
118
|
+
root = os.path.join(home, "lore")
|
|
119
|
+
return cls(root, lock=lambda: FileLock(os.path.join(root, ".lock")))
|
|
120
|
+
|
|
121
|
+
@contextlib.contextmanager
|
|
122
|
+
def locked(self):
|
|
123
|
+
os.makedirs(self.root, exist_ok=True)
|
|
124
|
+
if self._lock is None:
|
|
125
|
+
yield
|
|
126
|
+
return
|
|
127
|
+
with self._lock():
|
|
128
|
+
yield
|
|
129
|
+
|
|
130
|
+
def _path(self, *parts):
|
|
131
|
+
path = os.path.abspath(os.path.join(self.root, *parts))
|
|
132
|
+
if os.path.commonpath([os.path.abspath(self.root), path]) != os.path.abspath(self.root):
|
|
133
|
+
raise Precondition("invalid vault path")
|
|
134
|
+
return path
|
|
135
|
+
|
|
136
|
+
def lesson_path(self, lesson_id, folder=LESSONS):
|
|
137
|
+
return self._path(validate_folder(folder), f"lesson-{validate_id(lesson_id)}.md")
|
|
138
|
+
|
|
139
|
+
def _note_path(self, rel):
|
|
140
|
+
parts = rel.split(os.sep)
|
|
141
|
+
if len(parts) != 2 or parts[0] not in (LESSONS, PENDING, HUBS) or parts[1] in (".", ".."):
|
|
142
|
+
raise Precondition("invalid vault path")
|
|
143
|
+
if parts[0] in (LESSONS, PENDING) and not re.fullmatch(r"lesson-[0-9a-f]{12}\.md", parts[1]):
|
|
144
|
+
raise Precondition("invalid lesson id")
|
|
145
|
+
return self._path(rel)
|
|
146
|
+
|
|
147
|
+
def _validate_note(self, rel, text):
|
|
148
|
+
folder, name = rel.split(os.sep)
|
|
149
|
+
if folder in (LESSONS, PENDING):
|
|
150
|
+
lesson = from_note(text)
|
|
151
|
+
if not lesson or name != f"lesson-{lesson['id']}.md":
|
|
152
|
+
raise Precondition("lesson id does not match filename")
|
|
153
|
+
|
|
154
|
+
def write(self, rel, text):
|
|
155
|
+
path = self._note_path(rel)
|
|
156
|
+
self._validate_note(rel, text)
|
|
157
|
+
store.atomic_write_text(path, text)
|
|
158
|
+
|
|
159
|
+
def has(self, lesson_id, folder=LESSONS):
|
|
160
|
+
return os.path.exists(self.lesson_path(lesson_id, folder))
|
|
161
|
+
|
|
162
|
+
def get(self, lesson_id, folder=LESSONS):
|
|
163
|
+
try:
|
|
164
|
+
with open(self.lesson_path(lesson_id, folder), encoding="utf-8") as f:
|
|
165
|
+
lesson = from_note(f.read())
|
|
166
|
+
if lesson and lesson["id"] != lesson_id:
|
|
167
|
+
raise Precondition("lesson id does not match filename")
|
|
168
|
+
return lesson
|
|
169
|
+
except (FileNotFoundError, F.FrontmatterError):
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
def put(self, lesson, folder=LESSONS):
|
|
173
|
+
self.lesson_path(lesson["id"], folder)
|
|
174
|
+
self.write(os.path.join(folder, f"lesson-{lesson['id']}.md"), render(lesson))
|
|
175
|
+
|
|
176
|
+
def drop(self, lesson_id, folder=LESSONS):
|
|
177
|
+
try:
|
|
178
|
+
os.remove(self.lesson_path(lesson_id, folder))
|
|
179
|
+
return True
|
|
180
|
+
except FileNotFoundError:
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
def pending(self):
|
|
184
|
+
"""Lessons waiting for lead approval. Kept in a dot folder, so Obsidian does not show them."""
|
|
185
|
+
return self.lessons(PENDING)
|
|
186
|
+
|
|
187
|
+
def lessons(self, folder=LESSONS):
|
|
188
|
+
validate_folder(folder)
|
|
189
|
+
try:
|
|
190
|
+
names = sorted(os.listdir(self._path(folder)))
|
|
191
|
+
except FileNotFoundError:
|
|
192
|
+
return []
|
|
193
|
+
out = []
|
|
194
|
+
for name in names:
|
|
195
|
+
if not re.fullmatch(r"lesson-[0-9a-f]{12}\.md", name):
|
|
196
|
+
continue
|
|
197
|
+
try:
|
|
198
|
+
lesson = self.get(name[7:-3], folder)
|
|
199
|
+
except (OSError, Precondition):
|
|
200
|
+
continue
|
|
201
|
+
if lesson:
|
|
202
|
+
out.append(lesson)
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
def state(self):
|
|
206
|
+
return store.read_json(self._path(STATE), {}) or {}
|
|
207
|
+
|
|
208
|
+
def save_state(self, data):
|
|
209
|
+
store.write_json(self._path(STATE), {"schema": store.SCHEMA, **data})
|
|
210
|
+
|
|
211
|
+
def rebuild_hubs(self, lessons=None):
|
|
212
|
+
"""Regenerate every hub note from the lessons and remove hubs nothing links to any more."""
|
|
213
|
+
lessons = [x for x in self.lessons() if x.get("status") != "retired"] if lessons is None else lessons
|
|
214
|
+
groups = {}
|
|
215
|
+
for lesson in lessons:
|
|
216
|
+
validate_id(lesson.get("id"))
|
|
217
|
+
for kind, names in hubs_of(lesson).items():
|
|
218
|
+
for name in names:
|
|
219
|
+
groups.setdefault((kind, name), []).append(lesson)
|
|
220
|
+
wanted = set()
|
|
221
|
+
for (kind, name), items in sorted(groups.items()):
|
|
222
|
+
rel = os.path.join(HUBS, f"{name}.md")
|
|
223
|
+
wanted.add(f"{name}.md")
|
|
224
|
+
lines = [f"# {name}", ""] + [
|
|
225
|
+
f"- [[lesson-{x['id']}]] {x['text']}"
|
|
226
|
+
for x in sorted(items, key=lambda x: (x.get("created", ""), x["id"]))
|
|
227
|
+
]
|
|
228
|
+
text = F.dump(
|
|
229
|
+
{"type": "hub", "hub": kind, "name": name, "lessons": len(items), "generated": True}, "\n".join(lines)
|
|
230
|
+
)
|
|
231
|
+
if self._read(rel) != text:
|
|
232
|
+
self.write(rel, text)
|
|
233
|
+
folder = self._path(HUBS)
|
|
234
|
+
if os.path.isdir(folder):
|
|
235
|
+
for n in os.listdir(folder):
|
|
236
|
+
if n.endswith(".md") and n not in wanted:
|
|
237
|
+
os.remove(os.path.join(folder, n))
|
|
238
|
+
return len(wanted)
|
|
239
|
+
|
|
240
|
+
def _read(self, rel):
|
|
241
|
+
path = self._note_path(rel)
|
|
242
|
+
try:
|
|
243
|
+
with open(path, encoding="utf-8") as f:
|
|
244
|
+
text = f.read()
|
|
245
|
+
except FileNotFoundError:
|
|
246
|
+
return None
|
|
247
|
+
self._validate_note(rel, text)
|
|
248
|
+
return text
|
|
249
|
+
|
|
250
|
+
def notes(self):
|
|
251
|
+
"""Every Markdown note in the vault as (relative path, text)."""
|
|
252
|
+
out = []
|
|
253
|
+
for base, dirs, files in os.walk(self.root):
|
|
254
|
+
dirs[:] = sorted(d for d in dirs if not d.startswith("."))
|
|
255
|
+
for n in sorted(files):
|
|
256
|
+
if n.endswith(".md"):
|
|
257
|
+
p = os.path.join(base, n)
|
|
258
|
+
rel = os.path.relpath(p, self.root)
|
|
259
|
+
try:
|
|
260
|
+
text = self._read(rel)
|
|
261
|
+
except (Precondition, F.FrontmatterError):
|
|
262
|
+
continue
|
|
263
|
+
out.append((rel, text))
|
|
264
|
+
return out
|
raidlock/mcp/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
def register_cli(add_command):
|
|
2
|
+
add_command("mcp", "serve this board to MCP clients over stdio", _setup, _handle)
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _setup(parser):
|
|
6
|
+
parser.add_argument(
|
|
7
|
+
"--workspace",
|
|
8
|
+
default=None,
|
|
9
|
+
help="workspace or .board folder to serve; MCP clients start servers in their own working folder",
|
|
10
|
+
)
|
|
11
|
+
return parser
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _handle(ctx, args):
|
|
15
|
+
from ..core import find_board_dir
|
|
16
|
+
from .server import run
|
|
17
|
+
|
|
18
|
+
path = args.workspace or args.board
|
|
19
|
+
board_path = find_board_dir(path) if path else None
|
|
20
|
+
return {"_exit": run(board_path=board_path)}, ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def register_tokens(register):
|
|
24
|
+
register("mcp tool schemas", _schema_tokens)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _schema_tokens(board):
|
|
28
|
+
import json
|
|
29
|
+
|
|
30
|
+
from ..core.tokens import estimate
|
|
31
|
+
from .tools import TOOLS
|
|
32
|
+
|
|
33
|
+
text = json.dumps(TOOLS, separators=(",", ":"))
|
|
34
|
+
return {"tokens": estimate(text), "detail": f"{len(TOOLS)} tools, {len(text)} chars (only when MCP is enabled)"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from urllib.parse import quote, unquote
|
|
3
|
+
|
|
4
|
+
from ..core.errors import Precondition
|
|
5
|
+
from ..core.leases import is_live
|
|
6
|
+
from ..core.transcript import _clean, redact
|
|
7
|
+
|
|
8
|
+
SCHEME = "raidlock://"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def uri(board, *parts):
|
|
12
|
+
return SCHEME + "/".join(quote(p, safe="") for p in (board.name, *parts))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def listing(board):
|
|
16
|
+
out = [
|
|
17
|
+
{
|
|
18
|
+
"uri": uri(board, "leases"),
|
|
19
|
+
"name": "leases",
|
|
20
|
+
"description": "Live leases on this board.",
|
|
21
|
+
"mimeType": "application/json",
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
for c in board.cards():
|
|
25
|
+
if c["lane"] != "done":
|
|
26
|
+
out.append(
|
|
27
|
+
{"uri": uri(board, "card", c["id"]), "name": c["id"], "title": c["title"], "mimeType": "text/markdown"}
|
|
28
|
+
)
|
|
29
|
+
return _clean(out)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def templates(board):
|
|
33
|
+
return [
|
|
34
|
+
{
|
|
35
|
+
"uriTemplate": SCHEME + quote(board.name, safe="") + "/card/{id}",
|
|
36
|
+
"name": "card",
|
|
37
|
+
"description": "A card's brief.",
|
|
38
|
+
"mimeType": "text/markdown",
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read(board, target):
|
|
44
|
+
if not isinstance(target, str) or not target.startswith(SCHEME):
|
|
45
|
+
raise Precondition("unknown resource", uri=target)
|
|
46
|
+
parts = [unquote(p) for p in target[len(SCHEME) :].split("/")]
|
|
47
|
+
if parts[0] != board.name:
|
|
48
|
+
raise Precondition(f"this server serves workspace {board.name}", uri=target)
|
|
49
|
+
if parts[1:] == ["leases"]:
|
|
50
|
+
return "application/json", json.dumps(leases(board), separators=(",", ":"), ensure_ascii=False)
|
|
51
|
+
if len(parts) == 3 and parts[1] == "card" and parts[2]:
|
|
52
|
+
return "text/markdown", brief(board, parts[2])
|
|
53
|
+
raise Precondition("unknown resource", uri=target)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def leases(board):
|
|
57
|
+
out = []
|
|
58
|
+
for cid, lease in sorted(board.leases().items()):
|
|
59
|
+
out.append(
|
|
60
|
+
{
|
|
61
|
+
"card": cid,
|
|
62
|
+
"agent": lease.get("agent", ""),
|
|
63
|
+
"paths": lease.get("paths", []),
|
|
64
|
+
"expires": lease.get("expires"),
|
|
65
|
+
"live": is_live(lease),
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
return _clean(out)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def brief(board, card_id):
|
|
72
|
+
return redact(board.brief(card_id)["text"])
|
raidlock/mcp/server.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .. import __version__
|
|
6
|
+
from ..core import db
|
|
7
|
+
from ..core.errors import RaidlockError
|
|
8
|
+
from ..core.transcript import _clean
|
|
9
|
+
from . import resources
|
|
10
|
+
from .tools import TOOLS, Session
|
|
11
|
+
|
|
12
|
+
MODERN = ("2026-07-28",)
|
|
13
|
+
LEGACY = ("2025-11-25", "2025-06-18", "2025-03-26")
|
|
14
|
+
META = "io.modelcontextprotocol/"
|
|
15
|
+
INFO = {"name": "raidlock", "version": __version__}
|
|
16
|
+
CAPABILITIES = {"tools": {}, "resources": {}}
|
|
17
|
+
INSTRUCTIONS = (
|
|
18
|
+
"raidlock board for this workspace. claimable, then claim a card, note progress, move it to review. "
|
|
19
|
+
"Edit only paths your card locks."
|
|
20
|
+
)
|
|
21
|
+
PARSE_ERROR, INVALID_REQUEST, NO_METHOD, INVALID_PARAMS, INTERNAL = -32700, -32600, -32601, -32602, -32603
|
|
22
|
+
UNSUPPORTED_VERSION = -32022
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RpcError(Exception):
|
|
26
|
+
def __init__(self, code, message, data=None):
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.code, self.message, self.data = code, message, data
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def compact(value):
|
|
32
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Server:
|
|
36
|
+
def __init__(self, board_path=None, env=None):
|
|
37
|
+
self.session = Session(board_path, env)
|
|
38
|
+
self.legacy = None
|
|
39
|
+
|
|
40
|
+
def handle(self, msg):
|
|
41
|
+
return _clean(self._handle(msg))
|
|
42
|
+
|
|
43
|
+
def _handle(self, msg):
|
|
44
|
+
if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0" or not isinstance(msg.get("method"), str):
|
|
45
|
+
if isinstance(msg, dict) and "method" not in msg and ("result" in msg or "error" in msg):
|
|
46
|
+
return None
|
|
47
|
+
return _error(msg.get("id") if isinstance(msg, dict) else None, INVALID_REQUEST, "invalid request")
|
|
48
|
+
if "id" not in msg:
|
|
49
|
+
return None
|
|
50
|
+
rid, method = msg["id"], msg["method"]
|
|
51
|
+
params = msg.get("params", {})
|
|
52
|
+
try:
|
|
53
|
+
if not isinstance(params, dict):
|
|
54
|
+
raise RpcError(INVALID_PARAMS, "params must be an object")
|
|
55
|
+
modern = self._era(method, params)
|
|
56
|
+
result = self.dispatch(method, params)
|
|
57
|
+
except RpcError as e:
|
|
58
|
+
return _error(rid, e.code, e.message, e.data)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
return _error(rid, INTERNAL, f"internal error: {e}")
|
|
61
|
+
result.setdefault("resultType", "complete")
|
|
62
|
+
if modern:
|
|
63
|
+
result.setdefault("_meta", {})[META + "serverInfo"] = INFO
|
|
64
|
+
return {"jsonrpc": "2.0", "id": rid, "result": result}
|
|
65
|
+
|
|
66
|
+
def _era(self, method, params):
|
|
67
|
+
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
|
|
68
|
+
version = meta.get(META + "protocolVersion")
|
|
69
|
+
if method in ("initialize", "ping") and version is None:
|
|
70
|
+
return False
|
|
71
|
+
if version is not None:
|
|
72
|
+
if version not in MODERN:
|
|
73
|
+
raise RpcError(
|
|
74
|
+
UNSUPPORTED_VERSION,
|
|
75
|
+
"Unsupported protocol version",
|
|
76
|
+
{"supported": list(MODERN), "requested": version},
|
|
77
|
+
)
|
|
78
|
+
if not isinstance(meta.get(META + "clientCapabilities"), dict):
|
|
79
|
+
raise RpcError(INVALID_PARAMS, f"missing _meta {META}clientCapabilities")
|
|
80
|
+
return True
|
|
81
|
+
if method == "server/discover" or self.legacy:
|
|
82
|
+
return method == "server/discover"
|
|
83
|
+
raise RpcError(
|
|
84
|
+
INVALID_PARAMS,
|
|
85
|
+
f"missing _meta {META}protocolVersion; send initialize first or use one of {', '.join(MODERN)}",
|
|
86
|
+
{"supported": list(MODERN)},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def dispatch(self, method, params):
|
|
90
|
+
if method == "initialize":
|
|
91
|
+
requested = params.get("protocolVersion")
|
|
92
|
+
self.legacy = requested if requested in LEGACY else LEGACY[0]
|
|
93
|
+
return {
|
|
94
|
+
"protocolVersion": self.legacy,
|
|
95
|
+
"capabilities": CAPABILITIES,
|
|
96
|
+
"serverInfo": INFO,
|
|
97
|
+
"instructions": INSTRUCTIONS,
|
|
98
|
+
}
|
|
99
|
+
if method == "ping":
|
|
100
|
+
return {}
|
|
101
|
+
if method == "server/discover":
|
|
102
|
+
return {
|
|
103
|
+
"supportedVersions": list(MODERN),
|
|
104
|
+
"capabilities": CAPABILITIES,
|
|
105
|
+
"_meta": {META + "serverInfo": INFO},
|
|
106
|
+
"instructions": INSTRUCTIONS,
|
|
107
|
+
"ttlMs": 3600000,
|
|
108
|
+
"cacheScope": "public",
|
|
109
|
+
}
|
|
110
|
+
if method == "tools/list":
|
|
111
|
+
return {"tools": TOOLS, "ttlMs": 3600000, "cacheScope": "public"}
|
|
112
|
+
if method == "tools/call":
|
|
113
|
+
return self.call_tool(params)
|
|
114
|
+
if method == "resources/list":
|
|
115
|
+
return {"resources": resources.listing(self._board()), "ttlMs": 0, "cacheScope": "private"}
|
|
116
|
+
if method == "resources/templates/list":
|
|
117
|
+
return {"resourceTemplates": resources.templates(self._board()), "ttlMs": 3600000, "cacheScope": "private"}
|
|
118
|
+
if method == "resources/read":
|
|
119
|
+
board = self._board()
|
|
120
|
+
try:
|
|
121
|
+
mime, text = resources.read(board, params.get("uri"))
|
|
122
|
+
except RaidlockError as e:
|
|
123
|
+
raise RpcError(INVALID_PARAMS, e.message, {"uri": params.get("uri")}) from e
|
|
124
|
+
return {
|
|
125
|
+
"contents": [{"uri": params["uri"], "mimeType": mime, "text": text}],
|
|
126
|
+
"ttlMs": 0,
|
|
127
|
+
"cacheScope": "private",
|
|
128
|
+
}
|
|
129
|
+
raise RpcError(NO_METHOD, f"method not found: {method}")
|
|
130
|
+
|
|
131
|
+
def _board(self):
|
|
132
|
+
try:
|
|
133
|
+
return self.session.board()
|
|
134
|
+
except RaidlockError as e:
|
|
135
|
+
raise RpcError(INVALID_PARAMS, e.message, e.to_dict()) from e
|
|
136
|
+
|
|
137
|
+
def call_tool(self, params):
|
|
138
|
+
name = params.get("name")
|
|
139
|
+
if not isinstance(name, str) or name not in {t["name"] for t in TOOLS}:
|
|
140
|
+
raise RpcError(INVALID_PARAMS, f"Unknown tool: {name}")
|
|
141
|
+
arguments = params.get("arguments", {})
|
|
142
|
+
if not isinstance(arguments, dict):
|
|
143
|
+
raise RpcError(INVALID_PARAMS, "arguments must be an object")
|
|
144
|
+
try:
|
|
145
|
+
out, is_error = self.session.call(name, arguments), False
|
|
146
|
+
except RaidlockError as e:
|
|
147
|
+
out, is_error = e.to_dict(), True
|
|
148
|
+
out.pop("ok", None)
|
|
149
|
+
return {"content": [{"type": "text", "text": compact(out)}], "isError": is_error}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _error(rid, code, message, data=None):
|
|
153
|
+
err = {"code": code, "message": message}
|
|
154
|
+
if data is not None:
|
|
155
|
+
err["data"] = data
|
|
156
|
+
return {"jsonrpc": "2.0", "id": rid, "error": err}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def serve(inp, out, board_path=None, env=None):
|
|
160
|
+
server = Server(board_path, env)
|
|
161
|
+
for raw in inp:
|
|
162
|
+
line = raw.decode("utf-8", "replace") if isinstance(raw, bytes) else raw
|
|
163
|
+
if not line.strip():
|
|
164
|
+
continue
|
|
165
|
+
try:
|
|
166
|
+
msg = json.loads(line)
|
|
167
|
+
except ValueError:
|
|
168
|
+
reply = _error(None, PARSE_ERROR, "parse error")
|
|
169
|
+
else:
|
|
170
|
+
with db.released():
|
|
171
|
+
reply = server.handle(msg)
|
|
172
|
+
if reply is not None:
|
|
173
|
+
data = compact(reply) + "\n"
|
|
174
|
+
out.write(data.encode("utf-8") if _binary(out) else data)
|
|
175
|
+
out.flush()
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _binary(stream):
|
|
180
|
+
return not isinstance(stream, io.TextIOBase)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def run(board_path=None, env=None):
|
|
184
|
+
return serve(sys.stdin.buffer, sys.stdout.buffer, board_path, env)
|