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/mcp/tools.py ADDED
@@ -0,0 +1,178 @@
1
+ import os
2
+
3
+ from ..core import Board, repos
4
+ from ..core.errors import IdentityError, Precondition
5
+ from ..core.transcript import _clean
6
+
7
+ ID = {"type": "string"}
8
+ FENCE = {"type": "integer"}
9
+
10
+
11
+ def _schema(required, **props):
12
+ return {"type": "object", "properties": props, "required": list(required)}
13
+
14
+
15
+ TOOLS = [
16
+ {
17
+ "name": "claimable",
18
+ "description": "Cards you can claim now, and why others are blocked.",
19
+ "inputSchema": _schema(()),
20
+ },
21
+ {
22
+ "name": "claim",
23
+ "description": "Take a card: lock its paths. Returns the fence.",
24
+ "inputSchema": _schema(("id",), id=ID),
25
+ },
26
+ {
27
+ "name": "heartbeat",
28
+ "description": "Renew your lease on a card.",
29
+ "inputSchema": _schema(("id",), id=ID, fence=FENCE),
30
+ },
31
+ {
32
+ "name": "note",
33
+ "description": "Add a one-line progress note; renews the lease.",
34
+ "inputSchema": _schema(("id", "text"), id=ID, text={"type": "string"}, fence=FENCE),
35
+ },
36
+ {
37
+ "name": "lock",
38
+ "description": "Add lock paths (<repo>/<path>, comma-separated) to your card.",
39
+ "inputSchema": _schema(("id", "paths"), id=ID, paths={"type": "string"}, fence=FENCE),
40
+ },
41
+ {
42
+ "name": "move",
43
+ "description": "Move your card to review or blocked. Only the lead closes cards.",
44
+ "inputSchema": _schema(
45
+ ("id", "lane"), id=ID, lane={"enum": ["review", "blocked"]}, note={"type": "string"}, fence=FENCE
46
+ ),
47
+ },
48
+ {
49
+ "name": "msg",
50
+ "description": "Message an agent; card ties it to a task.",
51
+ "inputSchema": _schema(("to", "text"), to={"type": "string"}, text={"type": "string"}, card=ID),
52
+ },
53
+ {"name": "inbox", "description": "Read your new messages.", "inputSchema": _schema(())},
54
+ ]
55
+
56
+
57
+ def _annotations(name):
58
+ if name == "claimable":
59
+ return {"readOnlyHint": True, "openWorldHint": False}
60
+ return {
61
+ "readOnlyHint": False,
62
+ "destructiveHint": False,
63
+ "idempotentHint": name == "heartbeat",
64
+ "openWorldHint": False,
65
+ }
66
+
67
+
68
+ for tool in TOOLS:
69
+ tool["annotations"] = _annotations(tool["name"])
70
+ SPECS = {t["name"]: t["inputSchema"] for t in TOOLS}
71
+ LEAD_REFUSAL = "the MCP server never acts as the lead; the lead uses the raidlock CLI"
72
+
73
+
74
+ class Session:
75
+ def __init__(self, board_path=None, env=None):
76
+ base = dict(os.environ if env is None else env)
77
+ base.pop("RAIDLOCK_ROLE", None)
78
+ self.env = base
79
+ self.board_path = board_path
80
+ self.fences = {}
81
+
82
+ def board(self):
83
+ return Board.open(self.board_path, env=self.env)
84
+
85
+ def caller(self, board, card=None, fence=None):
86
+ if fence is None and card:
87
+ fence = self.fences.get(card)
88
+ c = board.caller(fence=fence, env=self.env)
89
+ if c.is_lead or (c.name and c.name == board.lead):
90
+ raise IdentityError(LEAD_REFUSAL, agent=c.name, hint="set RAIDLOCK_AGENT and RAIDLOCK_TOKEN to a worker")
91
+ return c
92
+
93
+ def call(self, name, args):
94
+ check_args(name, args)
95
+ board = self.board()
96
+ return _clean(getattr(self, "t_" + name)(board, args))
97
+
98
+ def t_claimable(self, b, a):
99
+ c = self.caller(b)
100
+ ready, waiting = [], []
101
+ for cid, v in sorted(b.claimable(agent=c.name or None).items()):
102
+ if v["ok"]:
103
+ ready.append({"id": cid, "title": v["title"], **({"takeover": v["takeover"]} if v["takeover"] else {})})
104
+ else:
105
+ waiting.append({"id": cid, "why": "; ".join(v["reasons"])})
106
+ return {"ready": ready, "blocked": waiting}
107
+
108
+ def t_claim(self, b, a):
109
+ r = b.claim(a["id"], self.caller(b), checkout=os.getcwd() if repos.find_root(os.getcwd()) else None)
110
+ self.fences[a["id"]] = r["fence"]
111
+ return {"card": r["card"], "fence": r["fence"], "expires": r["expires"], "paths": r["paths"]}
112
+
113
+ def t_heartbeat(self, b, a):
114
+ r = b.heartbeat(a["id"], self.caller(b, a["id"], a.get("fence")))
115
+ return {"card": r["card"], "expires": r["expires"], **({"resumed": True} if r.get("resumed") else {})}
116
+
117
+ def t_note(self, b, a):
118
+ b.note(a["id"], self.caller(b, a["id"], a.get("fence")), a["text"])
119
+ return {"card": a["id"], "noted": True}
120
+
121
+ def t_lock(self, b, a):
122
+ r = b.lock_paths(a["id"], self.caller(b, a["id"], a.get("fence")), a["paths"])
123
+ return {"card": r["card"], "locks": r["locks"]}
124
+
125
+ def t_move(self, b, a):
126
+ if a["lane"] not in ("review", "blocked"):
127
+ raise Precondition("move takes review or blocked", lane=a["lane"])
128
+ r = b.move(a["id"], a["lane"], self.caller(b, a["id"], a.get("fence")), note=a.get("note"))
129
+ return {"card": r["card"], "lane": r["lane"]}
130
+
131
+ def t_msg(self, b, a):
132
+ m = b.send(self.caller(b), a["to"], a["text"], task=a.get("card") or None)
133
+ out = {"to": a["to"], "status": m.get("status", "")}
134
+ if m.get("dropped"):
135
+ out["dropped"] = True
136
+ return out
137
+
138
+ def t_inbox(self, b, a):
139
+ r = b.inbox(self.caller(b))
140
+ msgs = [
141
+ {
142
+ k: v
143
+ for k, v in (
144
+ ("from", m.get("from")),
145
+ ("card", m.get("task")),
146
+ ("sent", m.get("sent")),
147
+ ("text", m.get("text")),
148
+ )
149
+ if v
150
+ }
151
+ for m in r["messages"]
152
+ ]
153
+ out = {"messages": msgs}
154
+ if r.get("queued"):
155
+ out["queued"] = r["queued"]
156
+ return out
157
+
158
+
159
+ def check_args(name, args):
160
+ spec = SPECS[name]
161
+ if not isinstance(args, dict):
162
+ raise Precondition("arguments must be an object")
163
+ extra = sorted(set(args) - set(spec["properties"]))
164
+ if extra:
165
+ raise Precondition(f"unknown argument(s): {', '.join(extra)}", tool=name)
166
+ for key in spec["required"]:
167
+ if key not in args:
168
+ raise Precondition(f"missing argument: {key}", tool=name)
169
+ for key, value in args.items():
170
+ prop = spec["properties"][key]
171
+ if "enum" in prop:
172
+ if value not in prop["enum"]:
173
+ raise Precondition(f"{key} must be one of {', '.join(prop['enum'])}", tool=name)
174
+ elif prop["type"] == "integer":
175
+ if isinstance(value, bool) or not isinstance(value, int):
176
+ raise Precondition(f"{key} must be an integer", tool=name)
177
+ elif not isinstance(value, str) or not value.strip():
178
+ raise Precondition(f"{key} must be a non-empty string", tool=name)
raidlock/notify.py ADDED
@@ -0,0 +1,144 @@
1
+ import base64
2
+ import datetime
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ import time
7
+ from xml.sax.saxutils import escape
8
+
9
+ from .core import db
10
+
11
+
12
+ def send_notification(title, body):
13
+ if sys.platform == "darwin":
14
+ executable = shutil.which("terminal-notifier")
15
+ if executable:
16
+ command = [executable, "-title", title, "-message", body]
17
+ else:
18
+ executable = shutil.which("osascript")
19
+ if not executable:
20
+ return False
21
+ script = "on run argv\ndisplay notification (item 2 of argv) with title (item 1 of argv)\nend run"
22
+ command = [executable, "-e", script, title, body]
23
+ elif sys.platform == "win32":
24
+ executable = shutil.which("powershell") or shutil.which("pwsh")
25
+ if not executable:
26
+ return False
27
+ xml = (
28
+ '<toast><visual><binding template="ToastGeneric"><text>'
29
+ + escape(title)
30
+ + "</text><text>"
31
+ + escape(body)
32
+ + "</text></binding></visual></toast>"
33
+ )
34
+ payload = base64.b64encode(xml.encode("utf-8")).decode("ascii")
35
+ script = (
36
+ "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, "
37
+ "ContentType = WindowsRuntime] > $null; "
38
+ "[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, "
39
+ "ContentType = WindowsRuntime] > $null; "
40
+ "$xml = New-Object Windows.Data.Xml.Dom.XmlDocument; "
41
+ f'$xml.LoadXml([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("{payload}"))); '
42
+ "$toast = [Windows.UI.Notifications.ToastNotification]::new($xml); "
43
+ '[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Raidlock").Show($toast)'
44
+ )
45
+ encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii")
46
+ command = [executable, "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]
47
+ elif sys.platform.startswith("linux"):
48
+ executable = shutil.which("notify-send")
49
+ if not executable:
50
+ return False
51
+ command = [executable, "--", title, body]
52
+ else:
53
+ return False
54
+ try:
55
+ return subprocess.run(command, capture_output=True, timeout=10, check=False).returncode == 0
56
+ except (OSError, subprocess.SubprocessError):
57
+ return False
58
+
59
+
60
+ def _parse_time(value):
61
+ try:
62
+ parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
63
+ return parsed.replace(tzinfo=datetime.UTC) if parsed.tzinfo is None else parsed
64
+ except (AttributeError, TypeError, ValueError):
65
+ return None
66
+
67
+
68
+ class Watcher:
69
+ def __init__(self, board, notify=None, debounce=60):
70
+ self.board = board
71
+ self.notify = notify or send_notification
72
+ self.debounce = debounce
73
+ self.version = None
74
+ self.cursor = 0
75
+ self.cards = []
76
+ self.leases = {}
77
+ self.active = set()
78
+ self.sent = {}
79
+
80
+ def poll(self, now=None, monotonic=None):
81
+ now = now or datetime.datetime.now(datetime.UTC)
82
+ tick = time.monotonic() if monotonic is None else monotonic
83
+ version = self.board.version()
84
+ if version != self.version:
85
+ self.cards = self.board.cards()
86
+ self.leases = self.board.leases()
87
+ self.version = version
88
+ events = []
89
+ active = set()
90
+ for card in self.cards:
91
+ cid = card["id"]
92
+ if card["lane"] == "review":
93
+ key = ("review", cid)
94
+ active.add(key)
95
+ events.append((key, "Card entered review", f"{cid}: {card['title']}"))
96
+ for cid, lease in self.leases.items():
97
+ expires = _parse_time(lease.get("expires"))
98
+ if expires and 0 < (expires - now).total_seconds() < 300:
99
+ key = ("lease", cid, lease.get("fence"), lease.get("expires"))
100
+ active.add(key)
101
+ events.append((key, "Lease expires soon", f"{cid}: {lease.get('agent', '')} (under 5 minutes)"))
102
+ for name, status in self.board.statuses().items():
103
+ if status["status"] == "stalled":
104
+ key = ("stalled", name, status.get("card", ""))
105
+ active.add(key)
106
+ events.append((key, "Agent stalled", f"{name}: {status.get('card', '')}"))
107
+ for record in self.board.transcript.read(since=self.cursor):
108
+ self.cursor = max(self.cursor, record["seq"])
109
+ if record.get("kind") == "msg" and record.get("to") == self.board.lead:
110
+ key = ("message", record.get("messageId", record["seq"]))
111
+ body = "\n".join(p.get("text", "") for p in record.get("parts", []) if p.get("kind") == "text")
112
+ events.append((key, "Message to lead", body))
113
+ delivered = 0
114
+ for key, title, body in events:
115
+ if key in self.active or tick - self.sent.get(key, float("-inf")) < self.debounce:
116
+ continue
117
+ self.sent[key] = tick
118
+ delivered += bool(self.notify(f"Raidlock: {title}", body))
119
+ self.active = active
120
+ self.sent = {key: value for key, value in self.sent.items() if tick - value < self.debounce}
121
+ return delivered
122
+
123
+
124
+ def _setup(parser):
125
+ parser.add_argument("--once", action="store_true", help="Check current events once and exit")
126
+
127
+
128
+ def _handle(ctx, args):
129
+ watcher = Watcher(ctx.board)
130
+ count = 0
131
+ try:
132
+ while True:
133
+ count += watcher.poll()
134
+ if args.once:
135
+ break
136
+ db.close_all()
137
+ time.sleep(2)
138
+ except KeyboardInterrupt:
139
+ pass
140
+ return {"notifications": count}, ""
141
+
142
+
143
+ def register_cli(add_command):
144
+ add_command("notify", "watch the board for desktop notifications", _setup, _handle)
raidlock/party.py ADDED
@@ -0,0 +1,239 @@
1
+ """Raid-theme party roles, boss board and loot, derived from recorded card activity on every read. Display only:
2
+ nothing is stored and nothing is granted. Item names are original to raidlock.
3
+
4
+ Tank counts leases held now on blocked cards plus claimed cards that were blocked or that other cards depend on. Healer
5
+ counts reviews and passing verify runs on other agents' cards plus moving someone else's card out of blocked. DPS counts
6
+ finished cards the agent owns and leases held now on cards that are not blocked. The highest score wins; ties go to DPS,
7
+ then Tank, then Healer. Agents with no activity get no role. Cancelled cards never count.
8
+
9
+ A boss is a card with BOSS_DEPENDENTS or more transitive dependents, or one the lead marked with edit --boss on. Its HP
10
+ is the share of the boss and its dependents still open; it is slain when all of them are done. Every done card drops one
11
+ item for its owner; the rarity comes from dependents, changes-requested reviews, a first-try verify pass and recorded
12
+ run time, and the name from a hash of the card id, so the same card always drops the same item.
13
+ """
14
+
15
+ import datetime
16
+ import hashlib
17
+
18
+ from .core import clock
19
+ from .web.replay import data_of, history
20
+
21
+ ROLES = ("dps", "tank", "healer")
22
+ BOSS_DEPENDENTS = 3
23
+ RARITIES = ("Common", "Uncommon", "Rare", "Epic", "Legendary")
24
+ ADJECTIVES = (
25
+ ("Worn", "Patched", "Sturdy", "Plain"),
26
+ ("Polished", "Tempered", "Keen", "Lucky"),
27
+ ("Runed", "Moonlit", "Gilded", "Whispering"),
28
+ ("Stormbound", "Emberforged", "Starlit", "Thornwoven"),
29
+ ("Sunforged", "Everflame", "Worldroot", "Firstlight"),
30
+ )
31
+ NOUNS = (
32
+ "Lantern",
33
+ "Whetstone",
34
+ "Cloak",
35
+ "Compass",
36
+ "Quill",
37
+ "Buckler",
38
+ "Satchel",
39
+ "Ring",
40
+ "Spyglass",
41
+ "Hammer",
42
+ "Amulet",
43
+ "Boots",
44
+ )
45
+
46
+
47
+ def live(cards):
48
+ return [c for c in cards if c.get("lane") != "cancelled"]
49
+
50
+
51
+ def dependents(cards):
52
+ ids = {c["id"] for c in cards}
53
+ back = {cid: set() for cid in ids}
54
+ for c in cards:
55
+ for dep in c.get("depends", []):
56
+ if dep in back and dep != c["id"]:
57
+ back[dep].add(c["id"])
58
+ out = {}
59
+ for cid in sorted(ids):
60
+ seen, stack = set(), sorted(back[cid])
61
+ while stack:
62
+ nxt = stack.pop()
63
+ if nxt == cid or nxt in seen:
64
+ continue
65
+ seen.add(nxt)
66
+ stack.extend(sorted(back[nxt]))
67
+ out[cid] = seen
68
+ return out
69
+
70
+
71
+ def _claimers(card):
72
+ out = set()
73
+ for n in card.get("notes", []):
74
+ words = str(n.get("text", "")).split()
75
+ if len(words) >= 3 and words[:2] == ["claimed", "by"]:
76
+ out.add(words[2])
77
+ return out
78
+
79
+
80
+ def _unblockers(card):
81
+ out, lane = [], ""
82
+ for n in card.get("notes", []):
83
+ text = str(n.get("text", ""))
84
+ if not text.startswith("-> "):
85
+ continue
86
+ nxt = text[3:].split(" ", 1)[0]
87
+ if lane == "blocked" and nxt != "blocked":
88
+ out.append(n.get("by", ""))
89
+ lane = nxt
90
+ return out
91
+
92
+
93
+ def roles(board):
94
+ cards = live(board.cards())
95
+ names = sorted(board.agents())
96
+ score = {n: dict.fromkeys(ROLES, 0) for n in names}
97
+ deps = dependents(cards)
98
+
99
+ def add(name, role):
100
+ if name in score:
101
+ score[name][role] += 1
102
+
103
+ lanes = {c["id"]: c.get("lane") for c in cards}
104
+ for lease in sorted(board.leases().values(), key=lambda x: x.get("card", "")):
105
+ add(lease.get("agent", ""), "tank" if lanes.get(lease.get("card")) == "blocked" else "dps")
106
+ for c in cards:
107
+ owner = c.get("owner", "")
108
+ hard = deps.get(c["id"]) or any(str(n.get("text", "")).startswith("-> blocked") for n in c.get("notes", []))
109
+ if hard:
110
+ for name in _claimers(c):
111
+ add(name, "tank")
112
+ if c.get("lane") == "done" and owner:
113
+ add(owner, "dps")
114
+ for r in c.get("reviews", []):
115
+ if r.get("verdict") in ("approve", "changes") and r.get("by") != owner:
116
+ add(r.get("by", ""), "healer")
117
+ for v in c.get("verifications", []):
118
+ if v.get("passed") and v.get("by") != owner:
119
+ add(v.get("by", ""), "healer")
120
+ for name in _unblockers(c):
121
+ if name != owner:
122
+ add(name, "healer")
123
+ out = {}
124
+ for name in names:
125
+ s = score[name]
126
+ best = max(ROLES, key=lambda r: (s[r], -ROLES.index(r)))
127
+ out[name] = {"role": best if s[best] else "", **s}
128
+ return out
129
+
130
+
131
+ def _done_at(board):
132
+ out = {}
133
+ for r in history(board)[0]:
134
+ if r.get("kind") == "status" and data_of(r).get("lane") == "done" and r.get("taskId"):
135
+ out[r["taskId"]] = r.get("ts", "")
136
+ return out
137
+
138
+
139
+ def bosses(cards, deps, done_at):
140
+ by_id = {c["id"]: c for c in cards}
141
+ out = []
142
+ for cid in sorted(by_id):
143
+ under = deps.get(cid, set())
144
+ if len(under) < BOSS_DEPENDENTS and not by_id[cid].get("boss"):
145
+ continue
146
+ group = [cid, *sorted(under)]
147
+ still = [x for x in group if by_id[x].get("lane") != "done"]
148
+ slain = not still
149
+ out.append(
150
+ {
151
+ "id": cid,
152
+ "title": by_id[cid].get("title", cid),
153
+ "dependents": len(under),
154
+ "total": len(group),
155
+ "open": len(still),
156
+ "hp": round(100 * len(still) / len(group)),
157
+ "slain": slain,
158
+ "slain_at": max(done_at.get(x) or by_id[x].get("updated", "") for x in group) if slain else "",
159
+ "final": by_id[max(group, key=lambda x: done_at.get(x) or by_id[x].get("updated", ""))].get("owner", "")
160
+ if slain
161
+ else "",
162
+ }
163
+ )
164
+ alive = sorted((b for b in out if not b["slain"]), key=lambda b: (-b["hp"], b["id"]))
165
+ dead = sorted((b for b in out if b["slain"]), key=lambda b: (b["slain_at"], b["id"]), reverse=True)
166
+ return alive + dead
167
+
168
+
169
+ def tier(card, dependents):
170
+ changes = sum(1 for r in card.get("reviews", []) if r.get("verdict") == "changes")
171
+ runs = card.get("verifications") or []
172
+ first_pass = bool(runs) and bool(runs[0].get("passed"))
173
+ hours = sum(int(r.get("duration_ms") or 0) for r in card.get("costs", [])) / 3600000.0
174
+ score = min(dependents, 4) + min(changes, 2) + int(first_pass) + (2 if hours >= 4 else 1 if hours >= 1 else 0)
175
+ return min(4, (score + 1) // 2)
176
+
177
+
178
+ def item_name(card_id, rank):
179
+ digest = hashlib.sha256(f"{card_id}\n{rank}".encode()).digest()
180
+ words = ADJECTIVES[rank]
181
+ return f"{words[digest[0] % len(words)]} {NOUNS[digest[1] % len(NOUNS)]}"
182
+
183
+
184
+ def loot(cards, deps, done_at):
185
+ bags = {}
186
+ for c in cards:
187
+ if c.get("lane") != "done" or not c.get("owner"):
188
+ continue
189
+ rank = tier(c, len(deps.get(c["id"], ())))
190
+ bags.setdefault(c["owner"], []).append(
191
+ {
192
+ "card": c["id"],
193
+ "title": c.get("title", c["id"]),
194
+ "item": item_name(c["id"], rank),
195
+ "rarity": RARITIES[rank],
196
+ "tier": rank,
197
+ "at": done_at.get(c["id"]) or c.get("updated", ""),
198
+ }
199
+ )
200
+ out = []
201
+ for name, items in sorted(bags.items()):
202
+ out.append({"agent": name, "items": sorted(items, key=lambda i: (i["at"], i["card"]))})
203
+ return out
204
+
205
+
206
+ def payload(board, query):
207
+ cards = live(board.cards())
208
+ deps = dependents(cards)
209
+ done_at = _done_at(board)
210
+ return {
211
+ "bosses": bosses(cards, deps, done_at),
212
+ "bags": loot(cards, deps, done_at),
213
+ "boss_dependents": BOSS_DEPENDENTS,
214
+ }
215
+
216
+
217
+ def slain_bosses(cards):
218
+ cards = live(cards)
219
+ return [
220
+ {k: b[k] for k in ("id", "title", "total", "final", "slain_at")}
221
+ for b in bosses(cards, dependents(cards), {})
222
+ if b["slain"]
223
+ ]
224
+
225
+
226
+ def streak(cards, today=None):
227
+ days = set()
228
+ for c in cards:
229
+ when = clock.parse(c.get("updated", "")) if c.get("lane") == "done" else None
230
+ if when:
231
+ days.add(when.astimezone().date())
232
+ day = today or datetime.datetime.now().astimezone().date()
233
+ if day not in days:
234
+ day -= datetime.timedelta(days=1)
235
+ count = 0
236
+ while day in days:
237
+ count += 1
238
+ day -= datetime.timedelta(days=1)
239
+ return count