raidlock 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. raidlock/__init__.py +11 -0
  2. raidlock/__main__.py +5 -0
  3. raidlock/_python.py +58 -0
  4. raidlock/achievements.py +156 -0
  5. raidlock/approvals.py +237 -0
  6. raidlock/bench.py +286 -0
  7. raidlock/bridge/__init__.py +186 -0
  8. raidlock/budgets.py +178 -0
  9. raidlock/cli.py +2153 -0
  10. raidlock/commands_install.py +114 -0
  11. raidlock/core/__init__.py +26 -0
  12. raidlock/core/advisories.py +16 -0
  13. raidlock/core/billing.py +54 -0
  14. raidlock/core/board.py +2251 -0
  15. raidlock/core/brief.py +111 -0
  16. raidlock/core/clock.py +40 -0
  17. raidlock/core/cost.py +198 -0
  18. raidlock/core/db.py +608 -0
  19. raidlock/core/errors.py +52 -0
  20. raidlock/core/exe.py +6 -0
  21. raidlock/core/hints.py +32 -0
  22. raidlock/core/identity.py +285 -0
  23. raidlock/core/lead.py +758 -0
  24. raidlock/core/lead_env.py +23 -0
  25. raidlock/core/lead_hook.py +42 -0
  26. raidlock/core/leases.py +19 -0
  27. raidlock/core/lock.py +99 -0
  28. raidlock/core/messages.py +98 -0
  29. raidlock/core/migrate.py +579 -0
  30. raidlock/core/model.py +126 -0
  31. raidlock/core/models.py +109 -0
  32. raidlock/core/paths.py +61 -0
  33. raidlock/core/portable.py +53 -0
  34. raidlock/core/receipts.py +73 -0
  35. raidlock/core/registry.py +39 -0
  36. raidlock/core/repos.py +250 -0
  37. raidlock/core/safeio.py +201 -0
  38. raidlock/core/status.py +52 -0
  39. raidlock/core/store.py +107 -0
  40. raidlock/core/tokens.py +77 -0
  41. raidlock/core/transcript.py +208 -0
  42. raidlock/duel.py +371 -0
  43. raidlock/foresight.py +191 -0
  44. raidlock/frames.py +162 -0
  45. raidlock/gitguard.py +753 -0
  46. raidlock/guard.py +239 -0
  47. raidlock/hooks.py +1526 -0
  48. raidlock/infer.py +205 -0
  49. raidlock/limits.py +685 -0
  50. raidlock/lore/__init__.py +22 -0
  51. raidlock/lore/approval.py +145 -0
  52. raidlock/lore/capture.py +146 -0
  53. raidlock/lore/cli.py +109 -0
  54. raidlock/lore/distill.py +17 -0
  55. raidlock/lore/frontmatter.py +56 -0
  56. raidlock/lore/inject.py +127 -0
  57. raidlock/lore/vault.py +264 -0
  58. raidlock/mcp/__init__.py +34 -0
  59. raidlock/mcp/resources.py +72 -0
  60. raidlock/mcp/server.py +184 -0
  61. raidlock/mcp/tools.py +178 -0
  62. raidlock/notify.py +144 -0
  63. raidlock/party.py +239 -0
  64. raidlock/pr.py +575 -0
  65. raidlock/prsync.py +318 -0
  66. raidlock/recipes/README.md +34 -0
  67. raidlock/recipes/__init__.py +105 -0
  68. raidlock/recipes/docs-refresh.md +15 -0
  69. raidlock/recipes/multi-repo-refactor.md +15 -0
  70. raidlock/recipes/security-audit.md +27 -0
  71. raidlock/recipes/upgrade-deps.md +15 -0
  72. raidlock/reputation.py +194 -0
  73. raidlock/resume.py +329 -0
  74. raidlock/resurrect.py +419 -0
  75. raidlock/routing.py +419 -0
  76. raidlock/runners/__init__.py +16 -0
  77. raidlock/runners/base.py +341 -0
  78. raidlock/runners/brief.py +176 -0
  79. raidlock/runners/claude_code.py +121 -0
  80. raidlock/runners/cli.py +110 -0
  81. raidlock/runners/codex.py +114 -0
  82. raidlock/runners/gemini.py +64 -0
  83. raidlock/runners/isolation.py +391 -0
  84. raidlock/runners/policy.py +123 -0
  85. raidlock/runners/runner.py +921 -0
  86. raidlock/runners/shell.py +35 -0
  87. raidlock/runners/verifycache.py +93 -0
  88. raidlock/runners/workspace.py +376 -0
  89. raidlock/scan.py +357 -0
  90. raidlock/snapshot.py +151 -0
  91. raidlock/treasury.py +210 -0
  92. raidlock/tui.py +223 -0
  93. raidlock/web/__init__.py +5 -0
  94. raidlock/web/achievements/view.css +19 -0
  95. raidlock/web/achievements/view.js +58 -0
  96. raidlock/web/bosses/view.css +19 -0
  97. raidlock/web/bosses/view.js +46 -0
  98. raidlock/web/cli.py +90 -0
  99. raidlock/web/data.py +687 -0
  100. raidlock/web/export.py +153 -0
  101. raidlock/web/frames/view.css +25 -0
  102. raidlock/web/frames/view.js +63 -0
  103. raidlock/web/graph/__init__.py +142 -0
  104. raidlock/web/graph/view.css +33 -0
  105. raidlock/web/graph/view.js +136 -0
  106. raidlock/web/raidmap/__init__.py +205 -0
  107. raidlock/web/raidmap/view.css +15 -0
  108. raidlock/web/raidmap/view.js +47 -0
  109. raidlock/web/replay/__init__.py +258 -0
  110. raidlock/web/replay/view.css +13 -0
  111. raidlock/web/replay/view.js +127 -0
  112. raidlock/web/server.py +292 -0
  113. raidlock/web/static/app.css +851 -0
  114. raidlock/web/static/app.js +2102 -0
  115. raidlock/web/static/fonts/OFL.txt +93 -0
  116. raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
  117. raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
  118. raidlock/web/static/icon-32.png +0 -0
  119. raidlock/web/static/icon-raid.svg +1 -0
  120. raidlock/web/static/icon.svg +1 -0
  121. raidlock/web/static/index.html +122 -0
  122. raidlock-0.1.0.dist-info/METADATA +186 -0
  123. raidlock-0.1.0.dist-info/RECORD +126 -0
  124. raidlock-0.1.0.dist-info/WHEEL +4 -0
  125. raidlock-0.1.0.dist-info/entry_points.txt +34 -0
  126. raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/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
@@ -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)