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
@@ -0,0 +1,579 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import re
5
+ import shlex
6
+ import uuid
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ from . import clock, model, safeio, store
11
+ from . import paths as P
12
+ from .errors import Precondition
13
+ from .portable import remove_tree
14
+ from .transcript import _clean
15
+
16
+ V1_FIELDS = (
17
+ "id",
18
+ "title",
19
+ "lane",
20
+ "owner",
21
+ "team",
22
+ "ticket",
23
+ "scope",
24
+ "locks",
25
+ "depends",
26
+ "verify",
27
+ "updated",
28
+ "model",
29
+ "tokens",
30
+ "duration_ms",
31
+ "cost_usd",
32
+ )
33
+ _NOTE = re.compile(r"^- (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z) (.*)$")
34
+ _BY = re.compile(r"^([A-Za-z0-9_.-]+): (.*)$")
35
+ V1_FILES = ("agents.json", "teams.json", "leases.json", "LOG.md", "settings.json", "config.json")
36
+
37
+
38
+ def needs_migration(board_dir):
39
+ if os.path.exists(os.path.join(board_dir, "board.json")):
40
+ return False
41
+ cards = os.path.join(board_dir, "cards")
42
+ has_cards = os.path.isdir(cards) and any(n.endswith(".md") for n in os.listdir(cards))
43
+ return (
44
+ has_cards
45
+ or any(os.path.exists(os.path.join(board_dir, f)) for f in V1_FILES)
46
+ or os.path.isdir(os.path.join(board_dir, "v1"))
47
+ )
48
+
49
+
50
+ def reject_legacy(board_dir):
51
+ if needs_migration(board_dir):
52
+ raise Precondition(
53
+ "legacy v1 board found (not migrated); run raid migrate to upgrade",
54
+ board=str(board_dir),
55
+ hint="raid migrate --board " + str(board_dir),
56
+ )
57
+
58
+
59
+ def _write_json(path, data):
60
+ safeio.write_text(path, json.dumps(data, indent=2, sort_keys=True) + "\n")
61
+
62
+
63
+ def _write_doc(path, key, data):
64
+ _write_json(path, {"schema": store.SCHEMA, key: data})
65
+
66
+
67
+ def _write_jsonl(path, rows):
68
+ safeio.write_text(path, "".join(json.dumps(_clean(row)) + "\n" for row in rows))
69
+
70
+
71
+ def _state_path(board_dir, home):
72
+ if Path(os.path.abspath(home)).is_relative_to(Path(os.path.abspath(board_dir))):
73
+ raise Precondition("migration home must be outside the untrusted board")
74
+ key = hashlib.sha256(os.path.abspath(board_dir).encode()).hexdigest()
75
+ return safeio.check(Path(home) / "migrations" / (key + ".json"))
76
+
77
+
78
+ def _state(board_dir, home):
79
+ path = _state_path(board_dir, home)
80
+ state = json.loads(safeio.read(path)) if path.exists() else {}
81
+ if state and state.get("board_identity") != _board_identity(board_dir):
82
+ raise Precondition("migration state belongs to a different board")
83
+ return state
84
+
85
+
86
+ def _board_identity(board_dir):
87
+ info = safeio.check(board_dir).stat()
88
+ return [info.st_dev, info.st_ino]
89
+
90
+
91
+ def _manifest(root):
92
+ root = safeio.check(root, tree=True)
93
+ return {
94
+ str(p.relative_to(root)): None if p.is_dir() else hashlib.sha256(safeio.read(p)).hexdigest()
95
+ for p in sorted(root.rglob("*"))
96
+ }
97
+
98
+
99
+ def backups(board_dir, home=None):
100
+ from . import registry
101
+
102
+ state = _state(board_dir, home or registry.home())
103
+ backup = state.get("backup")
104
+ return [Path(backup)] if backup else []
105
+
106
+
107
+ def _read_json(path, default=None):
108
+ return json.loads(safeio.read(path)) if Path(path).exists() else default
109
+
110
+
111
+ def _reject_existing_outputs(board_dir, home):
112
+ if _state(board_dir, home):
113
+ return
114
+ root = Path(board_dir)
115
+ if list((root / "cards").glob("*.json")) or list((root / "transcript").glob("*")):
116
+ raise Precondition("untrusted board contains v2 output without trusted migration state")
117
+
118
+
119
+ def preflight(board_dir, allow_auth_downgrade=False):
120
+ root = safeio.check(board_dir, tree=True)
121
+ source = root / "v1" if (root / "v1").is_dir() else root
122
+ settings = _settings(source)[0]
123
+ if settings["auth"] == "off" and not allow_auth_downgrade:
124
+ raise Precondition("authentication downgrade requires --allow-auth-downgrade")
125
+ seen = set()
126
+ for path in (source / "cards").glob("*.md"):
127
+ meta, body = _read_v1_card(path)
128
+ cid = model.card_id(meta["id"])
129
+ if cid in seen:
130
+ raise Precondition(f"duplicate card id: {cid}")
131
+ seen.add(cid)
132
+ for cid in _split(meta["depends"]):
133
+ model.card_id(cid)
134
+ if meta["owner"]:
135
+ model.agent_name(meta["owner"])
136
+ for note in _notes(body):
137
+ if note["by"]:
138
+ model.agent_name(note["by"])
139
+ for name, agent in _read_json(source / "agents.json", {}).items():
140
+ model.agent_name(name)
141
+ if agent.get("card"):
142
+ model.card_id(agent["card"])
143
+ for cid, lease in _read_json(source / "leases.json", {}).items():
144
+ model.card_id(cid)
145
+ if lease.get("agent"):
146
+ model.agent_name(lease["agent"])
147
+ for path in (source / "inbox").glob("*.jsonl"):
148
+ model.agent_name(path.stem)
149
+ for line in safeio.read(path).decode().splitlines():
150
+ if not line.strip():
151
+ continue
152
+ row = json.loads(line)
153
+ for key in ("from", "to", "agent"):
154
+ if row.get(key) and row[key] != "all":
155
+ model.agent_name(row[key])
156
+ for key in ("card", "task"):
157
+ if row.get(key):
158
+ model.card_id(row[key])
159
+ return source
160
+
161
+
162
+ def upgrade(board_dir, home, allow_auth_downgrade=False, lead=None):
163
+ from . import identity
164
+ from .db import Index
165
+
166
+ preflight(board_dir, allow_auth_downgrade)
167
+ lead = _lead(_agents(_read_json(os.path.join(board_dir, "agents.json"), {})), lead)
168
+ safeio.check(home, tree=True)
169
+ _reject_existing_outputs(board_dir, home)
170
+ _state_path(board_dir, home)
171
+ safeio.check(Path(board_dir).parent / ".gitignore")
172
+ if not needs_migration(board_dir):
173
+ raise Precondition("no legacy v1 board found")
174
+ state = _state(board_dir, home)
175
+ if state.get("backup"):
176
+ backup = state["backup"]
177
+ if _manifest(backup) != state.get("manifest"):
178
+ raise Precondition("migration backup integrity check failed")
179
+ else:
180
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
181
+ backup = str(Path(board_dir).absolute()) + ".backup-" + stamp
182
+ safeio.copytree(board_dir, backup + ".partial")
183
+ safeio.replace(backup + ".partial", backup)
184
+ state = {
185
+ "uid": state.get("uid") or uuid.uuid4().hex,
186
+ "backup": backup,
187
+ "manifest": _manifest(backup),
188
+ "board_identity": _board_identity(board_dir),
189
+ }
190
+ _write_json(_state_path(board_dir, home), state)
191
+ workspace = Path(board_dir).resolve().parent
192
+ if any((parent / ".git").exists() for parent in (workspace, *workspace.parents)):
193
+ ignore = workspace / ".gitignore"
194
+ content = safeio.read(ignore).decode() if ignore.exists() else ""
195
+ pattern = "/" + Path(board_dir).name + ".backup-*"
196
+ if pattern not in content.splitlines():
197
+ safeio.write_text(
198
+ str(ignore), content + ("\n" if content and not content.endswith("\n") else "") + pattern + "\n"
199
+ )
200
+ result = migrate(board_dir, Index(home), allow_auth_downgrade=allow_auth_downgrade, lead=lead)
201
+ meta = _read_json(os.path.join(board_dir, "board.json"))
202
+ from .lead import mint
203
+
204
+ secret_path = identity.token_path(home, meta["uid"], meta["lead"])
205
+ safeio.write_text(secret_path, mint(1) + "\n")
206
+ result["secret_file"] = secret_path
207
+ result["takeover"] = shlex.join(
208
+ [
209
+ "raid",
210
+ "--board",
211
+ str(Path(board_dir).resolve()),
212
+ "lead",
213
+ "takeover",
214
+ "--lead-secret",
215
+ identity.token_path(home, meta["uid"], meta["lead"]),
216
+ ]
217
+ )
218
+ result["backup"] = backup
219
+ return result
220
+
221
+
222
+ def rollback(board_dir, home, caller=None, board=None):
223
+ from .board import Board
224
+ from .lock import FileLock
225
+
226
+ board_dir = safeio.check(board_dir, tree=True)
227
+ if board and (Path(board.dir).resolve() != board_dir or Path(board.home).resolve() != Path(home).resolve()):
228
+ raise Precondition("rollback authorization belongs to a different board")
229
+ with FileLock(os.path.join(board_dir, ".lock")):
230
+ board = board or (Board.open(board_dir) if Path(board_dir, "board.json").exists() else None)
231
+ if board and caller is None:
232
+ raise Precondition("rollback requires current lead authorization")
233
+ return _rollback(board_dir, home, board, caller)
234
+
235
+
236
+ def _rollback(board_dir, home, board=None, caller=None):
237
+ from .db import Index, purge
238
+
239
+ safeio.check(board_dir, tree=True)
240
+ state = _state(board_dir, home)
241
+ choices = backups(board_dir, home)
242
+ if not choices:
243
+ raise Precondition("no recorded migration backup found")
244
+ backup = Path(state["backup"])
245
+ if _manifest(backup) != state.get("manifest"):
246
+ raise Precondition("migration backup integrity check failed")
247
+ if board:
248
+ board._require_lead(caller, "roll back migration")
249
+ uid = state.get("uid")
250
+ staging = str(board_dir) + ".restore-" + uuid.uuid4().hex
251
+ safeio.copytree(backup, staging)
252
+ if _manifest(staging) != state.get("manifest"):
253
+ remove_tree(staging)
254
+ raise Precondition("migration backup changed during restore")
255
+ root = Path(board_dir)
256
+ for child in root.iterdir():
257
+ if child.name == ".lock":
258
+ continue
259
+ if child.is_dir():
260
+ remove_tree(child)
261
+ else:
262
+ safeio.unlink(child)
263
+ for child in Path(staging).iterdir():
264
+ if child.name != ".lock":
265
+ safeio.replace(child, root / child.name)
266
+ remove_tree(staging)
267
+ if uid:
268
+ index = Index(home)
269
+ with index.write() as conn:
270
+ purge(conn, uid)
271
+ safeio.unlink(_state_path(board_dir, home))
272
+ return {"restored": str(board_dir), "backup": str(backup)}
273
+
274
+
275
+ def _backup(board_dir):
276
+ target = os.path.join(board_dir, "v1")
277
+ if os.path.isdir(target):
278
+ return target
279
+ staging = target + ".partial"
280
+ remove_tree(staging, ignore_errors=True)
281
+ safeio.mkdir(staging)
282
+ for name in V1_FILES:
283
+ src = os.path.join(board_dir, name)
284
+ if os.path.exists(src):
285
+ safeio.write_text(Path(staging, name), safeio.read(src))
286
+ for folder in ("cards", "inbox"):
287
+ src = os.path.join(board_dir, folder)
288
+ if os.path.isdir(src):
289
+ safeio.copytree(src, os.path.join(staging, folder))
290
+ safeio.replace(staging, target)
291
+ return target
292
+
293
+
294
+ def _read_v1_card(path):
295
+ text = safeio.read(path).decode("utf-8", errors="replace")
296
+ head, _, body = text.partition("\n---\n")
297
+ meta = {k: "" for k in V1_FIELDS}
298
+ for line in head.splitlines()[1:]:
299
+ k, _, v = line.partition(": ")
300
+ meta[k.strip()] = v.strip()
301
+ return meta, body
302
+
303
+
304
+ def _notes(body):
305
+ out = []
306
+ for line in body.splitlines():
307
+ m = _NOTE.match(line.strip())
308
+ if not m:
309
+ continue
310
+ by = _BY.match(m.group(2))
311
+ out.append(
312
+ {"ts": m.group(1), "by": by.group(1) if by else "", "model": "", "text": by.group(2) if by else m.group(2)}
313
+ )
314
+ return out
315
+
316
+
317
+ def _split(value):
318
+ return [x for x in re.split(r"[,\s]+", value or "") if x]
319
+
320
+
321
+ def _locks(value):
322
+ out = []
323
+ for raw in _split(value):
324
+ try:
325
+ out.append(P.canonical(raw))
326
+ except Precondition:
327
+ continue
328
+ return out
329
+
330
+
331
+ def _num(value, cast):
332
+ try:
333
+ return cast(value)
334
+ except (TypeError, ValueError):
335
+ return cast(0)
336
+
337
+
338
+ def _card(meta, body):
339
+ lane = meta["lane"] if meta["lane"] in ("todo", "doing", "review", "blocked", "done") else "todo"
340
+ card = {
341
+ "id": meta["id"],
342
+ "title": meta["title"],
343
+ "lane": lane,
344
+ "owner": meta["owner"],
345
+ "team": meta["team"],
346
+ "ticket": meta["ticket"],
347
+ "scope": meta["scope"],
348
+ "output": "",
349
+ "description": "".join(line for line in body.splitlines(keepends=True) if not _NOTE.match(line.strip())),
350
+ "verify": meta["verify"],
351
+ "verify_cmd": "",
352
+ "locks": _locks(meta["locks"]),
353
+ "depends": _split(meta["depends"]),
354
+ "reviewers": [],
355
+ "fence": 0,
356
+ "released": False,
357
+ "created": meta["updated"] or clock.iso(),
358
+ "updated": meta["updated"] or clock.iso(),
359
+ "notes": _notes(body),
360
+ "reviews": [],
361
+ "verifications": [],
362
+ "costs": [],
363
+ }
364
+ if meta.get("cost_usd") or meta.get("tokens"):
365
+ card["costs"].append(
366
+ {
367
+ "ts": card["updated"],
368
+ "by": meta["owner"],
369
+ "model": meta.get("model", ""),
370
+ "usage": {"input": 0, "cached": 0, "cache_write": 0, "output": 0},
371
+ "tokens": _num(meta.get("tokens"), int),
372
+ "usd": _num(meta.get("cost_usd"), float),
373
+ "usd_source": "migrated",
374
+ "duration_ms": _num(meta.get("duration_ms"), int),
375
+ }
376
+ )
377
+ return card
378
+
379
+
380
+ def _agents(data):
381
+ out = {}
382
+ for name, a in (data or {}).items():
383
+ out[name] = {
384
+ "team": a.get("team", ""),
385
+ "role": a.get("role", "worker"),
386
+ "joined": a.get("joined", ""),
387
+ "last_seen": a.get("last_seen", ""),
388
+ "card": a.get("card", ""),
389
+ "provider": "",
390
+ "runtime": "",
391
+ "model": "",
392
+ "model_version": "",
393
+ "effort": "",
394
+ "model_source": "unknown",
395
+ "session": "",
396
+ }
397
+ return out
398
+
399
+
400
+ def _teams(data):
401
+ return {
402
+ n: {"max": t.get("max", 2), "mission": t.get("mission", ""), "repos": t.get("repos", [])}
403
+ for n, t in (data or {}).items()
404
+ }
405
+
406
+
407
+ def _inbox(backup, board_dir):
408
+ src = os.path.join(backup, "inbox")
409
+ if not os.path.isdir(src):
410
+ return
411
+ for name in os.listdir(src):
412
+ if name.endswith(".jsonl"):
413
+ rows = [
414
+ json.loads(line) for line in safeio.read(os.path.join(src, name)).decode().splitlines() if line.strip()
415
+ ]
416
+ for r in rows:
417
+ r["task"] = r.pop("card", r.get("task", ""))
418
+ r.setdefault("urgent", False)
419
+ r.setdefault("model", "")
420
+ _write_jsonl(os.path.join(board_dir, "inbox", name), rows)
421
+
422
+
423
+ def migrate(board_dir, index, allow_auth_downgrade=False, lead=None):
424
+ """Migrate a team-board v1 `.board` in place. v1 files are copied to `.board/v1/` first; board.json is written
425
+ last."""
426
+ preflight(board_dir, allow_auth_downgrade)
427
+ home = os.path.dirname(index.path)
428
+ safeio.check(home, tree=True)
429
+ _reject_existing_outputs(board_dir, home)
430
+ state = _state(board_dir, home)
431
+ if not state:
432
+ state = {"uid": uuid.uuid4().hex, "board_identity": _board_identity(board_dir)}
433
+ _write_json(_state_path(board_dir, home), state)
434
+ provenance = Path(board_dir, "checkout-provenance.json")
435
+ if provenance.exists():
436
+ safeio.unlink(provenance)
437
+ lore = Path(board_dir, "lore")
438
+ dropped_lore = lore.exists()
439
+ if dropped_lore:
440
+ remove_tree(lore)
441
+ backup = _backup(board_dir)
442
+ settings, unmapped, explicit = _settings(backup)
443
+ uid = state["uid"]
444
+ cards = {}
445
+ cards_dir = os.path.join(backup, "cards")
446
+ for name in sorted(os.listdir(cards_dir)) if os.path.isdir(cards_dir) else []:
447
+ if name.endswith(".md"):
448
+ card = _card(*_read_v1_card(os.path.join(cards_dir, name)))
449
+ if card["id"]:
450
+ cards[card["id"]] = card
451
+ agents = _agents(_read_json(os.path.join(backup, "agents.json"), {}))
452
+ history = _read_json(os.path.join(backup, "leases.json"), {})
453
+ leases = len(history)
454
+ for cid, lease in history.items():
455
+ if cid in cards:
456
+ cards[cid]["imported_lease"] = {"active": False, "untrusted": True, "claimed": lease}
457
+ for existing in Path(board_dir, "cards").glob("*.json"):
458
+ safeio.unlink(existing)
459
+ for card in cards.values():
460
+ _write_doc(os.path.join(board_dir, "cards", card["id"] + ".json"), "card", card)
461
+ _write_doc(os.path.join(board_dir, "agents.json"), "agents", agents)
462
+ _write_doc(
463
+ os.path.join(board_dir, "teams.json"), "teams", _teams(_read_json(os.path.join(backup, "teams.json"), {}))
464
+ )
465
+ for stale in ("leases.json",):
466
+ if os.path.exists(os.path.join(board_dir, stale)):
467
+ safeio.unlink(os.path.join(board_dir, stale))
468
+ _inbox(backup, board_dir)
469
+ transcript_count = _transcript(backup, board_dir, index, uid)
470
+ lead = _lead(agents, lead)
471
+ _write_json(
472
+ os.path.join(board_dir, "board.json"),
473
+ {
474
+ "schema": store.SCHEMA,
475
+ "uid": uid,
476
+ "workspace": os.path.basename(os.path.dirname(os.path.abspath(board_dir))),
477
+ "created": clock.iso(),
478
+ "lead": lead,
479
+ "migrated_from": 1,
480
+ "settings": settings,
481
+ "explicit_settings": explicit,
482
+ },
483
+ )
484
+ for name in os.listdir(os.path.join(board_dir, "cards")) if os.path.isdir(os.path.join(board_dir, "cards")) else []:
485
+ if name.endswith(".md"):
486
+ safeio.unlink(os.path.join(board_dir, "cards", name))
487
+ return {
488
+ "cards": len(cards),
489
+ "agents": len(agents),
490
+ "leases": leases,
491
+ "backup": backup,
492
+ "lead": lead,
493
+ "unmapped_settings": unmapped,
494
+ "dropped_untrusted_lore": dropped_lore,
495
+ "transcript": transcript_count,
496
+ }
497
+
498
+
499
+ def _lead(agents, selected=None):
500
+ candidates = sorted(n for n, a in agents.items() if a.get("role") == "lead")
501
+ if selected:
502
+ if selected not in candidates and not (selected == "lead" and ("lead" in agents or not candidates)):
503
+ raise Precondition("--lead must name a v1 lead-role agent")
504
+ return selected
505
+ if "lead" in agents:
506
+ return "lead"
507
+ if len(candidates) > 1:
508
+ raise Precondition("multiple v1 leads; select the confirmed lead with --lead <name>")
509
+ return candidates[0] if candidates else "lead"
510
+
511
+
512
+ def _settings(backup):
513
+ from .board import DEFAULT_SETTINGS, _apply_setting, normalize_review_setting
514
+
515
+ settings = dict(DEFAULT_SETTINGS, require_review=normalize_review_setting(0))
516
+ unmapped, explicit = {}, []
517
+ aliases = {"ttl": "ttl_minutes", "default_ttl": "ttl_minutes", "default_ttl_minutes": "ttl_minutes"}
518
+ for filename in ("config.json", "settings.json"):
519
+ raw = _read_json(os.path.join(backup, filename), {})
520
+ for key, value in raw.get("settings", raw).items():
521
+ target = aliases.get(key, key)
522
+ try:
523
+ if target == "require_review" and isinstance(value, dict):
524
+ for subkey, item in value.items():
525
+ _apply_setting(settings, target + "." + subkey, item)
526
+ explicit.append(target + "." + subkey)
527
+ else:
528
+ _apply_setting(settings, target, value)
529
+ except (Precondition, TypeError, ValueError):
530
+ if target in DEFAULT_SETTINGS or target.startswith("require_review."):
531
+ raise Precondition(f"invalid migration setting {filename}:{key}") from None
532
+ unmapped[f"{filename}:{key}"] = value
533
+ else:
534
+ explicit.append(target)
535
+ return settings, unmapped, sorted(set(explicit))
536
+
537
+
538
+ def _transcript(backup, board_dir, index, uid):
539
+ from . import transcript as T
540
+
541
+ path = Path(backup) / "LOG.md"
542
+ for existing in Path(board_dir, "transcript").glob("*.jsonl"):
543
+ safeio.unlink(existing)
544
+ if not path.exists():
545
+ T.Transcript(board_dir, index, uid).reindex()
546
+ return 0
547
+ records = []
548
+ for number, line in enumerate(safeio.read(path).decode().splitlines()):
549
+ match = _NOTE.match(line)
550
+ ts, event = (match.group(1), match.group(2)) if match else ("", line)
551
+ words = event.split()
552
+ verb = words[0] if words else ""
553
+ card, actor = "", ""
554
+ if verb in {"new", "claim", "lock", "move", "release", "reap", "cost", "note", "heartbeat"}:
555
+ card = words[1].rstrip(":") if len(words) > 1 else ""
556
+ by = re.search(r"\bby ([A-Za-z0-9_.-]+)", event)
557
+ if by:
558
+ actor = by.group(1)
559
+ elif verb in {"join", "inbox"} and len(words) > 1:
560
+ actor = words[1]
561
+ elif verb == "msg":
562
+ message = re.match(r"msg \S+ (\S+) -> \S+(?: on (\S+))?", event)
563
+ if message:
564
+ actor, card = message.group(1), message.group(2) or ""
565
+ record = T.make(
566
+ "status",
567
+ {"agent": ""},
568
+ "[untrusted v1 import] " + line,
569
+ task=card,
570
+ context=uid,
571
+ src="v1-log",
572
+ message_id=f"v1-log:{uid}:{number}",
573
+ data={"claimed_action": verb, "claimed_actor": actor, "trust": "untrusted v1 import"},
574
+ )
575
+ record.update(ts=ts, trust="untrusted v1 import", claimed_action=verb, claimed_actor=actor, claimed_text=line)
576
+ records.append(record)
577
+ _write_jsonl(os.path.join(board_dir, "transcript", "000000.jsonl"), records)
578
+ T.Transcript(board_dir, index, uid).reindex()
579
+ return len(records)