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/core/repos.py ADDED
@@ -0,0 +1,250 @@
1
+ import os
2
+ import re
3
+ from urllib.parse import urlsplit
4
+
5
+ from . import paths as P
6
+ from . import store
7
+
8
+ _SCP = re.compile(r"^(?:[^@/]+@)?([^:/]+):(?!//)(.+)$")
9
+ _REMOTE = re.compile(r'^\s*\[remote\s+"([^"]+)"\]\s*$')
10
+ _URL = re.compile(r"^\s*url\s*=\s*(.+?)\s*$")
11
+ _SECTION = re.compile(r"^\s*\[")
12
+
13
+
14
+ def normalize_remote(url):
15
+ """Normalize a git remote URL to `host/owner/name`, lowercase, without scheme, user, port or `.git`."""
16
+ text = url.strip().replace("\\", "/")
17
+ text = re.sub(r"(\.git)?/*$", "", text)
18
+ if "://" in text:
19
+ parts = urlsplit(text)
20
+ if parts.scheme == "file":
21
+ return "path:" + parts.path.rstrip("/")
22
+ host = (parts.hostname or "").lower()
23
+ return (host + "/" + parts.path.strip("/")).lower()
24
+ m = _SCP.match(text)
25
+ if m and not os.path.isabs(text):
26
+ return (m.group(1) + "/" + m.group(2).strip("/")).lower()
27
+ return "path:" + os.path.realpath(text).replace("\\", "/")
28
+
29
+
30
+ def git_dir(repo):
31
+ dotgit = os.path.join(repo, ".git")
32
+ if os.path.isdir(dotgit):
33
+ return dotgit
34
+ if os.path.isfile(dotgit):
35
+ try:
36
+ with open(dotgit, encoding="utf-8") as f:
37
+ line = f.read().strip()
38
+ except OSError:
39
+ return None
40
+ if line.startswith("gitdir:"):
41
+ target = line[7:].strip()
42
+ return os.path.normpath(os.path.join(repo, target))
43
+ return None
44
+
45
+
46
+ def common_dir(gdir):
47
+ try:
48
+ with open(os.path.join(gdir, "commondir"), encoding="utf-8") as f:
49
+ return os.path.normpath(os.path.join(gdir, f.read().strip()))
50
+ except OSError:
51
+ return gdir
52
+
53
+
54
+ def remote_url(repo):
55
+ gdir = git_dir(repo)
56
+ if not gdir:
57
+ return None
58
+ remotes = _read_remotes(os.path.join(common_dir(gdir), "config"))
59
+ return remotes.get("origin") or (next(iter(remotes.values())) if remotes else None)
60
+
61
+
62
+ def _read_remotes(config):
63
+ remotes, current = {}, None
64
+ try:
65
+ with open(config, encoding="utf-8") as f:
66
+ lines = f.readlines()
67
+ except OSError:
68
+ return remotes
69
+ for line in lines:
70
+ m = _REMOTE.match(line)
71
+ if m:
72
+ current = m.group(1)
73
+ continue
74
+ if _SECTION.match(line):
75
+ current = None
76
+ continue
77
+ u = _URL.match(line)
78
+ if current and u and current not in remotes:
79
+ remotes[current] = u.group(1)
80
+ return remotes
81
+
82
+
83
+ def identity(repo):
84
+ """Repo identity: normalized remote URL, else `path:<absolute path>`."""
85
+ url = remote_url(repo)
86
+ if url:
87
+ return normalize_remote(url)
88
+ return "path:" + os.path.realpath(repo).replace("\\", "/")
89
+
90
+
91
+ def find_root(path):
92
+ cur = os.path.realpath(path)
93
+ while True:
94
+ if git_dir(cur):
95
+ return cur
96
+ parent = os.path.dirname(cur)
97
+ if parent == cur:
98
+ return None
99
+ cur = parent
100
+
101
+
102
+ def checkout_info(path):
103
+ """Describe the checkout containing `path`: {path, kind: clone|worktree, branch}, or None."""
104
+ root = find_root(path)
105
+ if not root:
106
+ return None
107
+ gdir = git_dir(root)
108
+ kind = "worktree" if os.path.isfile(os.path.join(root, ".git")) and common_dir(gdir) != gdir else "clone"
109
+ return {"path": root, "kind": kind, "branch": _branch(gdir)}
110
+
111
+
112
+ def _branch(gdir):
113
+ try:
114
+ with open(os.path.join(gdir, "HEAD"), encoding="utf-8") as f:
115
+ head = f.read().strip()
116
+ except OSError:
117
+ return ""
118
+ if head.startswith("ref:"):
119
+ return head.split("refs/heads/", 1)[-1]
120
+ return head[:12]
121
+
122
+
123
+ def case_insensitive(folder):
124
+ probe = os.path.join(folder, ".board")
125
+ if not os.path.exists(probe):
126
+ probe = folder
127
+ head, tail = os.path.split(probe.rstrip(os.sep))
128
+ swapped = os.path.join(head, tail.swapcase())
129
+ if swapped == probe or not os.path.exists(swapped):
130
+ return False
131
+ try:
132
+ return os.path.samefile(probe, swapped)
133
+ except OSError:
134
+ return False
135
+
136
+
137
+ def discover(workspace, declared=None):
138
+ """Map repo name -> absolute path: the workspace itself, git repos at depth 1-2, plus declared repos."""
139
+ workspace = os.path.realpath(workspace)
140
+ found = {}
141
+ if git_dir(workspace):
142
+ found[os.path.basename(workspace)] = workspace
143
+ for name in _children(workspace):
144
+ full = os.path.join(workspace, name)
145
+ if git_dir(full):
146
+ found[name] = full
147
+ continue
148
+ for sub in _children(full):
149
+ if git_dir(os.path.join(full, sub)):
150
+ found[name + "/" + sub] = os.path.join(full, sub)
151
+ for name, rel in (declared or {}).items():
152
+ found[P.canonical(name)] = os.path.realpath(os.path.join(workspace, os.path.expanduser(rel)))
153
+ return found
154
+
155
+
156
+ def _children(folder):
157
+ try:
158
+ names = sorted(os.listdir(folder))
159
+ except OSError:
160
+ return []
161
+ return [n for n in names if not n.startswith(".") and os.path.isdir(os.path.join(folder, n))]
162
+
163
+
164
+ def declared_repos(board_dir):
165
+ data = store.read_json(os.path.join(board_dir, "workspace.json"), {})
166
+ store.check_schema(data, "workspace.json")
167
+ return data.get("repos", {})
168
+
169
+
170
+ class Resolver:
171
+ """Resolve `<repo>/<path>` lock paths of one workspace to repo identities."""
172
+
173
+ def __init__(self, workspace, board_dir):
174
+ self.workspace = os.path.realpath(workspace)
175
+ self.ci = case_insensitive(self.workspace)
176
+ self.repos = discover(self.workspace, declared_repos(board_dir))
177
+ self._ids = {}
178
+
179
+ def _identity(self, name):
180
+ if name not in self._ids:
181
+ self._ids[name] = identity(self.repos[name])
182
+ return self._ids[name]
183
+
184
+ def _match(self, parts):
185
+ names = sorted(self.repos, key=lambda n: -len(P.split(n)))
186
+ for name in names:
187
+ want = P.split(name)
188
+ have = parts[: len(want)]
189
+ if want == have or (self.ci and [w.casefold() for w in want] == [h.casefold() for h in have]):
190
+ return name, len(want)
191
+ root = next((n for n, p in self.repos.items() if p == self.workspace), None)
192
+ if root:
193
+ return root, 0
194
+ if len(self.repos) == 1:
195
+ return next(iter(self.repos)), 0
196
+ return None, 1
197
+
198
+ def resolve(self, raw):
199
+ """Return {path, id, rel, ci} for a lock path."""
200
+ path = P.canonical(raw)
201
+ parts = P.split(path)
202
+ name, used = self._match(parts)
203
+ if name:
204
+ ident = self._identity(name)
205
+ else:
206
+ ident = "path:" + os.path.realpath(os.path.join(self.workspace, parts[0])).replace("\\", "/")
207
+ rel = "/".join(parts[used:])
208
+ if self.ci:
209
+ ident = ident.casefold() if ident.startswith("path:") else ident
210
+ rel = rel.casefold()
211
+ return {"path": path, "id": ident, "rel": rel, "ci": self.ci}
212
+
213
+
214
+ def locate(abs_path):
215
+ """Map an absolute file path to {id, rel, ci, root} through its checkout's git remote, or None."""
216
+ root = find_root(os.path.dirname(abs_path) if not os.path.isdir(abs_path) else abs_path)
217
+ if not root:
218
+ return None
219
+ real = os.path.realpath(abs_path)
220
+ rel = os.path.relpath(real, root).replace("\\", "/")
221
+ rel = "" if rel == "." else rel
222
+ ci = case_insensitive(root)
223
+ ident = identity(root)
224
+ if ci:
225
+ rel = rel.casefold()
226
+ ident = ident.casefold() if ident.startswith("path:") else ident
227
+ return {"id": ident, "rel": rel, "ci": ci, "root": root}
228
+
229
+
230
+ def keys_overlap(a, b):
231
+ if a["id"] != b["id"]:
232
+ return False
233
+ fold = a.get("ci") or b.get("ci")
234
+ if any(P.contains(path, b["rel"], fold) for path in a.get("exclude", [])):
235
+ return False
236
+ if any(P.contains(path, a["rel"], fold) for path in b.get("exclude", [])):
237
+ return False
238
+ if not a["rel"] or not b["rel"]:
239
+ return True
240
+ return P.overlaps(a["rel"], b["rel"], fold)
241
+
242
+
243
+ def key_contains(outer, inner):
244
+ if outer["id"] != inner["id"]:
245
+ return False
246
+ if any(P.overlaps(path, inner["rel"], outer.get("ci") or inner.get("ci")) for path in outer.get("exclude", [])):
247
+ return False
248
+ if not outer["rel"]:
249
+ return True
250
+ return P.contains(outer["rel"], inner["rel"], outer.get("ci") or inner.get("ci"))
@@ -0,0 +1,201 @@
1
+ import contextlib
2
+ import errno
3
+ import os
4
+ import secrets
5
+ import stat
6
+ from pathlib import Path
7
+
8
+ from . import portable
9
+ from .errors import Precondition
10
+
11
+ _DIR_FD = os.open in os.supports_dir_fd and os.mkdir in os.supports_dir_fd
12
+
13
+
14
+ def _system_link(component):
15
+ aliases = {Path("/", name): Path("/private", name) for name in ("var", "tmp", "etc")}
16
+ if component not in aliases:
17
+ return False
18
+ try:
19
+ link = component.lstat()
20
+ parent = component.parent.lstat()
21
+ except OSError:
22
+ return False
23
+ return (
24
+ stat.S_ISLNK(link.st_mode)
25
+ and os.readlink(component) in (str(aliases[component]), str(aliases[component]).lstrip("/"))
26
+ and link.st_uid == 0
27
+ and parent.st_uid == 0
28
+ and not parent.st_mode & (stat.S_IWGRP | stat.S_IWOTH)
29
+ and component.parent == Path(component.anchor)
30
+ )
31
+
32
+
33
+ def check(path, tree=False):
34
+ path = Path(os.path.abspath(path))
35
+ if len(path.parts) > 1:
36
+ top = Path(path.anchor, path.parts[1])
37
+ if _system_link(top):
38
+ path = Path("/private", top.name, *path.parts[2:])
39
+ for component in (*reversed(path.parents), path):
40
+ try:
41
+ mode = component.lstat().st_mode
42
+ except FileNotFoundError:
43
+ continue
44
+ if portable._redirected(component) or not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)):
45
+ raise Precondition(f"unsafe filesystem entry: {component}")
46
+ if component != path and not stat.S_ISDIR(mode):
47
+ raise Precondition(f"not a directory: {component}")
48
+ if tree and path.is_dir():
49
+ for child in path.iterdir():
50
+ check(child, tree=True)
51
+ return path
52
+
53
+
54
+ def _directory(path, create=False):
55
+ path = check(path)
56
+ nofollow = getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
57
+ flags = os.O_RDONLY | nofollow
58
+ search = (getattr(os, "O_SEARCH", 0) | nofollow) if hasattr(os, "O_SEARCH") else flags
59
+ parts = path.parts[1:]
60
+ parent = os.open(path.anchor, search if parts else flags)
61
+ try:
62
+ for index, part in enumerate(parts):
63
+ if create:
64
+ with contextlib.suppress(FileExistsError):
65
+ os.mkdir(part, mode=0o700, dir_fd=parent)
66
+ if index < len(parts) - 1:
67
+ fd = os.open(part, search, dir_fd=parent)
68
+ else:
69
+ try:
70
+ fd = os.open(part, flags, dir_fd=parent)
71
+ except PermissionError:
72
+ if search == flags:
73
+ raise
74
+ fd = os.open(part, search, dir_fd=parent)
75
+ os.close(parent)
76
+ parent = fd
77
+ return parent
78
+ except BaseException:
79
+ os.close(parent)
80
+ raise
81
+
82
+
83
+ def read(path):
84
+ path = check(path)
85
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
86
+ if not _DIR_FD:
87
+ fd = portable.open_nofollow(path, flags)
88
+ with os.fdopen(fd, "rb") as stream:
89
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
90
+ raise Precondition(f"not a regular file: {path}")
91
+ check(path)
92
+ return stream.read()
93
+ parent = _directory(path.parent)
94
+ try:
95
+ fd = os.open(path.name, flags, dir_fd=parent)
96
+ with os.fdopen(fd, "rb") as stream:
97
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
98
+ raise Precondition(f"not a regular file: {path}")
99
+ return stream.read()
100
+ finally:
101
+ os.close(parent)
102
+
103
+
104
+ def mkdir(path):
105
+ path = check(path)
106
+ if not _DIR_FD:
107
+ os.mkdir(path, mode=0o700)
108
+ check(path)
109
+ return
110
+ parent = _directory(path.parent)
111
+ try:
112
+ os.mkdir(path.name, mode=0o700, dir_fd=parent)
113
+ finally:
114
+ os.close(parent)
115
+
116
+
117
+ def copytree(source, target):
118
+ source, target = check(source, tree=True), check(target)
119
+ mkdir(target)
120
+ for child in source.iterdir():
121
+ dest = target / child.name
122
+ if child.is_dir():
123
+ copytree(child, dest)
124
+ else:
125
+ write_text(dest, read(child))
126
+
127
+
128
+ def write_text(path, text, mode=0o600):
129
+ path = check(path)
130
+ if not _DIR_FD:
131
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
132
+ check(path)
133
+ temporary = path.parent / (".migration-" + secrets.token_hex(16))
134
+ try:
135
+ fd = portable.open_nofollow(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
136
+ with os.fdopen(fd, "wb") as stream:
137
+ stream.write(text.encode("utf-8") if isinstance(text, str) else text)
138
+ stream.flush()
139
+ os.fsync(stream.fileno())
140
+ check(path)
141
+ check(temporary)
142
+ os.replace(temporary, path)
143
+ finally:
144
+ with contextlib.suppress(FileNotFoundError):
145
+ unlink(temporary)
146
+ return
147
+ parent = _directory(path.parent, create=True)
148
+ temporary = ".migration-" + secrets.token_hex(16)
149
+ try:
150
+ try:
151
+ existing = os.stat(path.name, dir_fd=parent, follow_symlinks=False)
152
+ except FileNotFoundError:
153
+ pass
154
+ else:
155
+ if not stat.S_ISREG(existing.st_mode):
156
+ raise Precondition(f"not a regular file: {path}")
157
+ fd = os.open(
158
+ temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), mode, dir_fd=parent
159
+ )
160
+ with os.fdopen(fd, "wb") as stream:
161
+ stream.write(text.encode("utf-8") if isinstance(text, str) else text)
162
+ stream.flush()
163
+ os.fsync(stream.fileno())
164
+ os.replace(temporary, path.name, src_dir_fd=parent, dst_dir_fd=parent)
165
+ try:
166
+ os.fsync(parent)
167
+ except OSError as exc:
168
+ if exc.errno not in (errno.EBADF, errno.EPERM):
169
+ raise
170
+ finally:
171
+ with contextlib.suppress(FileNotFoundError):
172
+ os.unlink(temporary, dir_fd=parent)
173
+ os.close(parent)
174
+
175
+
176
+ def replace(source, target):
177
+ source, target = check(source), check(target)
178
+ if not _DIR_FD:
179
+ os.replace(source, target)
180
+ return
181
+ source_parent = _directory(source.parent)
182
+ try:
183
+ target_parent = _directory(target.parent)
184
+ try:
185
+ os.replace(source.name, target.name, src_dir_fd=source_parent, dst_dir_fd=target_parent)
186
+ finally:
187
+ os.close(target_parent)
188
+ finally:
189
+ os.close(source_parent)
190
+
191
+
192
+ def unlink(path):
193
+ path = check(path)
194
+ if not _DIR_FD:
195
+ os.unlink(path)
196
+ return
197
+ parent = _directory(path.parent)
198
+ try:
199
+ os.unlink(path.name, dir_fd=parent)
200
+ finally:
201
+ os.close(parent)
@@ -0,0 +1,52 @@
1
+ from . import clock
2
+
3
+ WORKING_SECONDS = 15 * 60
4
+ STALLED_SECONDS = 45 * 60
5
+ LABELS = {
6
+ "working": "Working",
7
+ "quiet": "Working (quiet)",
8
+ "review": "Waiting for review",
9
+ "blocked": "Blocked",
10
+ "stalled": "Stalled",
11
+ "idle": "Idle",
12
+ }
13
+ ORDER = ("working", "quiet", "blocked", "stalled", "review", "idle")
14
+
15
+
16
+ def _doing_status(lease, at):
17
+ if not lease or not lease.get("expires") or (clock.parse(lease["expires"]) or at) <= at:
18
+ return "stalled"
19
+ age = clock.age_seconds(lease.get("renewed", ""), at)
20
+ if age is None or age > STALLED_SECONDS:
21
+ return "stalled"
22
+ return "quiet" if age > WORKING_SECONDS else "working"
23
+
24
+
25
+ def agent_status(name, cards, leases, at=None):
26
+ """Derived status of one agent from its cards and its own lease heartbeat (never card.updated)."""
27
+ at = at or clock.now()
28
+ mine = [c for c in cards if c.get("owner") == name]
29
+ doing = next((c for c in mine if c["lane"] == "doing"), None)
30
+ if doing:
31
+ lease = leases.get(doing["id"])
32
+ return {"status": _doing_status(lease, at), "card": doing["id"], "heartbeat": (lease or {}).get("renewed", "")}
33
+ for lane, status in (("blocked", "blocked"), ("review", "review")):
34
+ card = next((c for c in mine if c["lane"] == lane), None)
35
+ if card:
36
+ return {
37
+ "status": status,
38
+ "card": card["id"],
39
+ "heartbeat": (leases.get(card["id"]) or {}).get("renewed", ""),
40
+ }
41
+ return {"status": "idle", "card": "", "heartbeat": ""}
42
+
43
+
44
+ def statuses(agents, cards, leases, at=None):
45
+ """{agent: {status, label, card, heartbeat}} for every joined agent and card owner."""
46
+ names = set(agents) | {c["owner"] for c in cards if c.get("owner")}
47
+ out = {}
48
+ for name in sorted(names):
49
+ s = agent_status(name, cards, leases, at)
50
+ s["label"] = LABELS[s["status"]]
51
+ out[name] = s
52
+ return out
raidlock/core/store.py ADDED
@@ -0,0 +1,107 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+
5
+ from .errors import Precondition
6
+
7
+ SCHEMA = 2
8
+
9
+
10
+ def atomic_write_text(path, text):
11
+ folder = os.path.dirname(os.path.abspath(path))
12
+ os.makedirs(folder, exist_ok=True)
13
+ fd, tmp = tempfile.mkstemp(prefix="." + os.path.basename(path) + ".", suffix=".tmp", dir=folder)
14
+ try:
15
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
16
+ f.write(text)
17
+ f.flush()
18
+ os.fsync(f.fileno())
19
+ os.replace(tmp, path)
20
+ except BaseException:
21
+ if os.path.exists(tmp):
22
+ os.unlink(tmp)
23
+ raise
24
+ _fsync_dir(folder)
25
+
26
+
27
+ def _fsync_dir(folder):
28
+ if os.name != "posix":
29
+ return
30
+ try:
31
+ fd = os.open(folder, os.O_RDONLY)
32
+ except OSError:
33
+ return
34
+ try:
35
+ os.fsync(fd)
36
+ except OSError:
37
+ pass
38
+ finally:
39
+ os.close(fd)
40
+
41
+
42
+ def write_json(path, data):
43
+ atomic_write_text(path, json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False) + "\n")
44
+
45
+
46
+ def read_json(path, default=None):
47
+ try:
48
+ with open(path, encoding="utf-8") as f:
49
+ return json.load(f)
50
+ except FileNotFoundError:
51
+ return default
52
+ except ValueError as e:
53
+ raise Precondition(f"corrupt JSON in {path}: {e}", path=path) from e
54
+
55
+
56
+ def check_schema(data, path):
57
+ found = data.get("schema", 1) if isinstance(data, dict) else 1
58
+ if not isinstance(found, int) or found > SCHEMA:
59
+ raise Precondition(
60
+ f"{path} has schema {found}; this raidlock understands up to {SCHEMA}; upgrade raidlock",
61
+ path=path,
62
+ schema=found,
63
+ )
64
+ return found
65
+
66
+
67
+ def read_doc(path, key, default=None):
68
+ data = read_json(path)
69
+ if data is None:
70
+ return {} if default is None else default
71
+ check_schema(data, path)
72
+ return data.get(key, {} if default is None else default)
73
+
74
+
75
+ def write_doc(path, key, value, **extra):
76
+ write_json(path, {"schema": SCHEMA, key: value, **extra})
77
+
78
+
79
+ def append_line(path, line, sync=True):
80
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
81
+ with open(path, "a", encoding="utf-8", newline="\n") as f:
82
+ f.write(line.rstrip("\n") + "\n")
83
+ f.flush()
84
+ if sync:
85
+ os.fsync(f.fileno())
86
+
87
+
88
+ def read_lines(path):
89
+ try:
90
+ with open(path, encoding="utf-8") as f:
91
+ return [line.rstrip("\n") for line in f if line.strip()]
92
+ except FileNotFoundError:
93
+ return []
94
+
95
+
96
+ def read_jsonl(path):
97
+ out = []
98
+ for line in read_lines(path):
99
+ try:
100
+ out.append(json.loads(line))
101
+ except ValueError:
102
+ continue
103
+ return out
104
+
105
+
106
+ def write_jsonl(path, rows):
107
+ atomic_write_text(path, "".join(json.dumps(r, sort_keys=True, ensure_ascii=False) + "\n" for r in rows))
@@ -0,0 +1,77 @@
1
+ import importlib
2
+ import math
3
+
4
+ CONTRIBUTORS = {}
5
+ EXTENSION_MODULES = ("raidlock.hooks", "raidlock.mcp", "raidlock.web", "raidlock.runners", "raidlock.plugin")
6
+ SAMPLE_OUTPUTS = (
7
+ "[ws] Claimed fix-login: lock app/auth until 14:05, fence 7.",
8
+ 'export RAIDLOCK_FENCE=7 # next: raidlock note fix-login "..."',
9
+ "[ws] Noted on fix-login.",
10
+ "[ws] Renewed fix-login until 14:50.",
11
+ "[ws] Moved fix-login to review. Next: the lead runs raidlock done fix-login",
12
+ "No messages.",
13
+ )
14
+
15
+
16
+ def estimate(text):
17
+ """Token estimate: ceil(characters / 4)."""
18
+ return math.ceil(len(text or "") / 4)
19
+
20
+
21
+ def register(name, fn):
22
+ """Register an overhead contributor. `fn(board)` returns {"tokens": int, "detail": str} or a text to estimate.
23
+ Edge components (skill, hooks, MCP schemas, UI) call this from `register_tokens(register)`."""
24
+ CONTRIBUTORS[name] = fn
25
+
26
+
27
+ def _load_extensions():
28
+ for module in EXTENSION_MODULES:
29
+ try:
30
+ mod = importlib.import_module(module)
31
+ except ImportError:
32
+ continue
33
+ if hasattr(mod, "register_tokens"):
34
+ mod.register_tokens(register)
35
+ try:
36
+ from importlib.metadata import entry_points
37
+
38
+ eps = entry_points()
39
+ group = eps.select(group="raidlock.tokens") if hasattr(eps, "select") else eps.get("raidlock.tokens", [])
40
+ for ep in group:
41
+ ep.load()(register)
42
+ except Exception:
43
+ return
44
+
45
+
46
+ def _core_outputs(board):
47
+ text = "\n".join(SAMPLE_OUTPUTS)
48
+ return {"tokens": estimate(text), "detail": f"{len(SAMPLE_OUTPUTS)} typical command outputs per session"}
49
+
50
+
51
+ def _core_brief(board):
52
+ if board is None:
53
+ return {"tokens": 0, "detail": "no board"}
54
+ cards = [c for c in board.cards() if c["lane"] in ("todo", "doing", "blocked")]
55
+ if not cards:
56
+ return {"tokens": 0, "detail": "no open cards"}
57
+ sizes = sorted(board.brief(c["id"])["tokens"] for c in cards)
58
+ return {"tokens": sizes[len(sizes) // 2], "detail": f"median card brief over {len(cards)} open card(s)"}
59
+
60
+
61
+ register("cli outputs", _core_outputs)
62
+ register("card brief", _core_brief)
63
+
64
+
65
+ def report(board=None):
66
+ """Estimated per-session token overhead of raidlock, by contributor."""
67
+ _load_extensions()
68
+ items = []
69
+ for name, fn in sorted(CONTRIBUTORS.items()):
70
+ try:
71
+ value = fn(board)
72
+ except Exception as e:
73
+ value = {"tokens": 0, "detail": f"error: {e}"}
74
+ if isinstance(value, str):
75
+ value = {"tokens": estimate(value), "detail": f"{len(value)} chars"}
76
+ items.append({"name": name, "tokens": int(value.get("tokens", 0)), "detail": value.get("detail", "")})
77
+ return {"items": items, "total": sum(i["tokens"] for i in items), "method": "ceil(chars / 4)"}