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/treasury.py ADDED
@@ -0,0 +1,210 @@
1
+ """Treasury forecast: burn rate, a forecast to the end of the calendar month (UTC) and budget alerts.
2
+
3
+ Spend is measured the way budget checks measure it: paid USD, plus the API-equivalent of plan usage unless
4
+ `budget.count_plan_usage` is false. The forecast adds the trailing seven-day daily rate for the rest of the month.
5
+ Alerts fire at ALERT_PCT of a cap, when a cap is reached, and when the forecast passes the monthly cap.
6
+ """
7
+
8
+ import datetime
9
+ import math
10
+
11
+ from . import budgets
12
+ from .core import RaidlockError, clock
13
+
14
+ ALERT_PCT = 80
15
+ TRAIL_DAYS = 7
16
+ HISTORY_DAYS = 14
17
+
18
+
19
+ def _period(now):
20
+ start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
21
+ end = (start + datetime.timedelta(days=32)).replace(day=1)
22
+ return start, end
23
+
24
+
25
+ def _measure(rows, count_plan):
26
+ for r in rows:
27
+ for key in ("usd", "usd_equiv"):
28
+ value = r.get(key)
29
+ if value is not None and (not math.isfinite(float(value)) or float(value) < 0):
30
+ raise ValueError("invalid-data: nonfinite or negative money")
31
+ paid = sum(float(r.get("usd") or 0) for r in rows)
32
+ plan = sum(float(r.get("usd_equiv") or 0) for r in rows if r.get("key") == "plan")
33
+ equiv = sum(float(r.get("usd_equiv") or 0) for r in rows)
34
+ total = paid + (plan if count_plan else 0)
35
+ if not math.isfinite(total) or not math.isfinite(equiv):
36
+ raise ValueError("invalid-data: money aggregation overflow")
37
+ return round(total, 6), round(equiv, 6)
38
+
39
+
40
+ def _spent(board, since, until, count_plan):
41
+ rows = board.spend(by="billing", since=clock.iso(since), until=clock.iso(until))["rows"]
42
+ return _measure(rows, count_plan)
43
+
44
+
45
+ def _settings(board):
46
+ try:
47
+ return budgets._settings(board)
48
+ except (RaidlockError, ValueError, TypeError):
49
+ return {}
50
+
51
+
52
+ def _alert(workspace, level, kind, text):
53
+ return {"workspace": workspace, "level": level, "kind": kind, "text": text}
54
+
55
+
56
+ def forecast(board, now=None):
57
+ try:
58
+ return _forecast(board, now)
59
+ except (RaidlockError, ValueError, TypeError, OverflowError):
60
+ return {"workspace": board.name, "error": "invalid-data", "alerts": [], "by_day": []}
61
+
62
+
63
+ def _forecast(board, now=None):
64
+ """Forecast for one workspace."""
65
+ now = now or clock.now()
66
+ start, end = _period(now)
67
+ settings = _settings(board)
68
+ count_plan = settings.get(budgets.COUNT_PLAN)
69
+ count_plan = True if count_plan is None else count_plan
70
+ day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
71
+ until = now + datetime.timedelta(seconds=1)
72
+ month, month_equiv = _spent(board, start, until, count_plan)
73
+ today, _ = _spent(board, day_start, until, count_plan)
74
+ trail, _ = _spent(board, now - datetime.timedelta(days=TRAIL_DAYS), until, count_plan)
75
+ elapsed = max((now - start).total_seconds() / 86400.0, 1 / 24)
76
+ remaining = max((end - now).total_seconds() / 86400.0, 0.0)
77
+ rate = trail / TRAIL_DAYS
78
+ projected = round(month + rate * remaining, 6)
79
+ history_from = clock.iso(day_start - datetime.timedelta(days=HISTORY_DAYS - 1))
80
+ by_day = {r["key"]: r for r in board.spend(by="day", since=history_from)["rows"]}
81
+ days = []
82
+ for i in range(HISTORY_DAYS):
83
+ key = (day_start - datetime.timedelta(days=HISTORY_DAYS - 1 - i)).strftime("%Y-%m-%d")
84
+ row = by_day.get(key) or {}
85
+ days.append({"day": key, "usd": round(float(row.get("usd") or 0), 6), "usd_equiv": row.get("usd_equiv") or 0})
86
+ daily_cap = settings.get(budgets.DAILY)
87
+ monthly_cap = settings.get(budgets.MONTHLY)
88
+ name = board.name
89
+ alerts = []
90
+ if daily_cap is not None:
91
+ if today >= daily_cap:
92
+ alerts.append(
93
+ _alert(name, "critical", "daily", f"today ${today:.2f} reached the daily cap ${daily_cap:.2f}")
94
+ )
95
+ elif daily_cap and today >= daily_cap * ALERT_PCT / 100:
96
+ alerts.append(
97
+ _alert(name, "warning", "daily", f"today ${today:.2f} is over {ALERT_PCT}% of ${daily_cap:.2f}")
98
+ )
99
+ if monthly_cap is not None:
100
+ if month >= monthly_cap:
101
+ alerts.append(
102
+ _alert(name, "critical", "monthly", f"month ${month:.2f} reached the monthly cap ${monthly_cap:.2f}")
103
+ )
104
+ elif projected > monthly_cap:
105
+ alerts.append(
106
+ _alert(
107
+ name,
108
+ "warning",
109
+ "forecast",
110
+ f"on track for ${projected:.2f} by {end.strftime('%Y-%m-%d')}, over the ${monthly_cap:.2f} cap",
111
+ )
112
+ )
113
+ elif monthly_cap and month >= monthly_cap * ALERT_PCT / 100:
114
+ alerts.append(
115
+ _alert(name, "warning", "monthly", f"month ${month:.2f} is over {ALERT_PCT}% of ${monthly_cap:.2f}")
116
+ )
117
+ return {
118
+ "workspace": name,
119
+ "now": clock.iso(now),
120
+ "period": {
121
+ "start": clock.iso(start),
122
+ "end": clock.iso(end),
123
+ "days_elapsed": round(elapsed, 3),
124
+ "days_left": round(remaining, 3),
125
+ },
126
+ "count_plan": count_plan,
127
+ "month": month,
128
+ "month_api_equiv": month_equiv,
129
+ "today": today,
130
+ "burn": {
131
+ "per_day_month": round(month / elapsed, 6),
132
+ "per_day_trailing": round(rate, 6),
133
+ "trail_days": TRAIL_DAYS,
134
+ },
135
+ "forecast": projected,
136
+ "caps": {"daily": daily_cap, "monthly": monthly_cap},
137
+ "by_day": days,
138
+ "alerts": alerts,
139
+ }
140
+
141
+
142
+ def combine(items, now=None):
143
+ """Sum per-workspace forecasts into one view across workspaces."""
144
+ now = now or clock.now()
145
+ if any(f.get("error") for f in items):
146
+ return {"workspace": "*", "error": "invalid-data", "alerts": [], "by_day": []}
147
+ start, end = _period(now)
148
+ days = {}
149
+ for f in items:
150
+ for d in f["by_day"]:
151
+ row = days.setdefault(d["day"], {"day": d["day"], "usd": 0.0, "usd_equiv": 0.0})
152
+ row["usd"] = round(row["usd"] + d["usd"], 6)
153
+ row["usd_equiv"] = round(row["usd_equiv"] + float(d["usd_equiv"] or 0), 6)
154
+
155
+ def total(key):
156
+ return round(sum(f[key] for f in items), 6)
157
+
158
+ return {
159
+ "workspace": "*",
160
+ "now": clock.iso(now),
161
+ "period": {"start": clock.iso(start), "end": clock.iso(end)},
162
+ "month": total("month"),
163
+ "month_api_equiv": total("month_api_equiv"),
164
+ "today": total("today"),
165
+ "burn": {
166
+ "per_day_month": round(sum(f["burn"]["per_day_month"] for f in items), 6),
167
+ "per_day_trailing": round(sum(f["burn"]["per_day_trailing"] for f in items), 6),
168
+ "trail_days": TRAIL_DAYS,
169
+ },
170
+ "forecast": total("forecast"),
171
+ "by_day": [days[k] for k in sorted(days)],
172
+ "alerts": [a for f in items for a in f["alerts"]],
173
+ "workspaces": [{k: f[k] for k in ("workspace", "month", "today", "forecast", "caps", "alerts")} for f in items],
174
+ }
175
+
176
+
177
+ def everywhere(now=None):
178
+ """Forecasts for every registered workspace, combined."""
179
+ from .core import Board, registry
180
+ from .core.db import Index
181
+
182
+ home = registry.home()
183
+ items = []
184
+ for w in registry.workspaces(Index(home)):
185
+ try:
186
+ items.append(forecast(Board.open(w["board"], home=home), now))
187
+ except (RaidlockError, OSError, ValueError):
188
+ continue
189
+ return combine(items, now)
190
+
191
+
192
+ def _setup(parser):
193
+ parser.add_argument("--all", action="store_true", help="every registered workspace, with alerts per workspace")
194
+
195
+
196
+ def _handle(ctx, args):
197
+ f = everywhere() if args.all else forecast(ctx.board)
198
+ if f.get("error"):
199
+ return f, "invalid-data: money could not be aggregated"
200
+ lines = [
201
+ f"{f['workspace']}: ${f['month']:.2f} this month, ${f['today']:.2f} today",
202
+ f"burn ${f['burn']['per_day_trailing']:.2f}/day over {TRAIL_DAYS} days; "
203
+ f"forecast ${f['forecast']:.2f} by {f['period']['end'][:10]}",
204
+ ]
205
+ lines += [f"{a['level'].upper()} {a['workspace']}: {a['text']}" for a in f["alerts"]]
206
+ return f, "\n".join(lines)
207
+
208
+
209
+ def register_cli(add_command):
210
+ add_command("treasury", "burn rate, month-end forecast and budget alerts", _setup, _handle)
raidlock/tui.py ADDED
@@ -0,0 +1,223 @@
1
+ """Live terminal board for supported Linux/macOS hosts; native Windows is unverified."""
2
+
3
+ import argparse
4
+ import math
5
+ import os
6
+ import selectors
7
+ import sys
8
+ import time
9
+ from contextlib import contextmanager
10
+ from datetime import datetime
11
+
12
+ from raidlock.core import Board, Precondition, db
13
+
14
+ LANES = ("todo", "doing", "review", "blocked", "done", "cancelled")
15
+ STATUSES = {
16
+ "working": "Working",
17
+ "quiet": "Working",
18
+ "review": "Waiting",
19
+ "blocked": "Blocked",
20
+ "stalled": "Stalled",
21
+ "idle": "Idle",
22
+ }
23
+
24
+
25
+ def _text(value):
26
+ return "".join(c if c.isprintable() else " " for c in str(value))
27
+
28
+
29
+ def _countdown(lease, now):
30
+ if not lease:
31
+ return "-"
32
+ if lease.get("expires") is None:
33
+ return "held"
34
+ expires = datetime.fromisoformat(lease["expires"].replace("Z", "+00:00"))
35
+ seconds = max(0, math.ceil((expires - now).total_seconds()))
36
+ if not seconds:
37
+ return "expired"
38
+ minutes, seconds = divmod(seconds, 60)
39
+ return f"{minutes:02d}:{seconds:02d}"
40
+
41
+
42
+ def render(snapshot, color=False):
43
+ now = datetime.fromisoformat(snapshot["now"].replace("Z", "+00:00"))
44
+ color = color and "NO_COLOR" not in os.environ
45
+
46
+ def heading(text):
47
+ return f"\033[1;36m{text}\033[0m" if color else text
48
+
49
+ total = snapshot["costs"]["workspace"]
50
+ lines = [heading(f"Raidlock | {_text(snapshot['workspace'])} | Spend ${total['usd']:.4f}")]
51
+ if total.get("unpriced_runs"):
52
+ lines.append(f"Unpriced runs: {total['unpriced_runs']}")
53
+ for lane in LANES:
54
+ cards = sorted((c for c in snapshot["cards"] if c["lane"] == lane), key=lambda c: c["id"])
55
+ if lane == "cancelled" and not cards:
56
+ continue
57
+ lines.extend(["", heading(f"{lane.upper()} ({len(cards)})")])
58
+ for card in cards:
59
+ title = _text(card["title"])
60
+ title = title if len(title) <= 44 else title[:41] + "..."
61
+ lease = snapshot.get("leases", {}).get(card["id"], card.get("lease"))
62
+ lines.append(
63
+ f" {_text(card['id'])} | {title} | {_text(card.get('owner') or '-')} | lock {_countdown(lease, now)}"
64
+ )
65
+ if not cards:
66
+ lines.append(" (empty)")
67
+ agents = snapshot["agents"]
68
+ lines.extend(["", heading(f"AGENTS ({len(agents)})")])
69
+ for name, agent in sorted(agents.items()):
70
+ status = STATUSES.get(agent.get("status"), "Idle")
71
+ lines.append(
72
+ f" {_text(name)} | {status} | [{_text(agent.get('model') or 'unknown')}] | "
73
+ f"{_text(agent.get('card') or '-')}"
74
+ )
75
+ if not agents:
76
+ lines.append(" (none)")
77
+ lines.extend(["", "q / Ctrl-C to exit | countdowns sampled on board changes"])
78
+ return "\n".join(lines) + "\n"
79
+
80
+
81
+ @contextmanager
82
+ def _terminal(output):
83
+ enabled = output.isatty()
84
+ restore = None
85
+ if enabled and os.name == "nt":
86
+ import ctypes
87
+ import msvcrt
88
+
89
+ kernel = ctypes.windll.kernel32
90
+ handle = ctypes.c_void_p(msvcrt.get_osfhandle(output.fileno()))
91
+ mode = ctypes.c_ulong()
92
+ enabled = bool(kernel.GetConsoleMode(handle, ctypes.byref(mode)))
93
+ if enabled:
94
+ enabled = bool(kernel.SetConsoleMode(handle, mode.value | 4))
95
+ if enabled:
96
+
97
+ def restore():
98
+ return kernel.SetConsoleMode(handle, mode.value)
99
+
100
+ try:
101
+ if enabled:
102
+ output.write("\033[?25l")
103
+ yield enabled
104
+ finally:
105
+ if enabled:
106
+ output.write("\033[0m\033[?25h")
107
+ output.flush()
108
+ if restore:
109
+ restore()
110
+
111
+
112
+ @contextmanager
113
+ def _keyboard(stream):
114
+ saved = None
115
+ if os.name != "nt" and stream.isatty():
116
+ import termios
117
+ import tty
118
+
119
+ saved = termios.tcgetattr(stream.fileno())
120
+ tty.setcbreak(stream.fileno())
121
+ try:
122
+ yield
123
+ finally:
124
+ if saved is not None:
125
+ termios.tcsetattr(stream.fileno(), termios.TCSADRAIN, saved)
126
+
127
+
128
+ def _quit(stream, delay):
129
+ deadline = time.monotonic() + delay
130
+ while True:
131
+ remaining = deadline - time.monotonic()
132
+ if remaining <= 0:
133
+ return False
134
+ if os.name == "nt":
135
+ import msvcrt
136
+
137
+ if stream.isatty() and msvcrt.kbhit() and msvcrt.getwch() in ("q", "Q", "\x03"):
138
+ return True
139
+ time.sleep(min(0.05, remaining))
140
+ else:
141
+ try:
142
+ with selectors.DefaultSelector() as selector:
143
+ selector.register(stream, selectors.EVENT_READ)
144
+ ready = selector.select(remaining)
145
+ except (ValueError, OSError):
146
+ time.sleep(remaining)
147
+ return False
148
+ if not ready:
149
+ return False
150
+ char = os.read(stream.fileno(), 1)
151
+ if char in (b"q", b"Q", b"\x03"):
152
+ return True
153
+ if not char:
154
+ time.sleep(remaining)
155
+ return False
156
+
157
+
158
+ def watch(board, interval=3, output=None, stdin=None):
159
+ if not math.isfinite(interval) or interval <= 0:
160
+ raise Precondition("interval must be a positive finite number")
161
+ output = sys.stdout if output is None else output
162
+ stdin = sys.stdin if stdin is None else stdin
163
+ version = None
164
+ try:
165
+ with _terminal(output) as ansi, _keyboard(stdin):
166
+ while True:
167
+ current = board.version()["workspace"]
168
+ if current != version:
169
+ snapshot = board.snapshot(transcript_limit=0, messages_limit=0)
170
+ output.write(("\033[H\033[2J" if ansi else "") + render(snapshot, color=ansi))
171
+ output.flush()
172
+ version = snapshot["version"]["workspace"]
173
+ db.close_all()
174
+ if _quit(stdin, interval):
175
+ break
176
+ except KeyboardInterrupt:
177
+ pass
178
+ return 0
179
+
180
+
181
+ def _open(workspace=None, path=None):
182
+ return Board.open(workspace if workspace is not None else path)
183
+
184
+
185
+ def _interval(value):
186
+ try:
187
+ number = float(value)
188
+ except ValueError:
189
+ raise argparse.ArgumentTypeError("interval must be a positive finite number") from None
190
+ if not math.isfinite(number) or number <= 0:
191
+ raise argparse.ArgumentTypeError("interval must be a positive finite number")
192
+ return number
193
+
194
+
195
+ def _setup(parser):
196
+ parser.add_argument("--workspace", metavar="NAME", help="workspace folder name or path")
197
+ parser.add_argument("--interval", type=_interval, default=3)
198
+
199
+
200
+ def _command(ctx, args):
201
+ if args.json:
202
+ raise Precondition("watch does not support --json")
203
+ board = _open(args.workspace, args.board) if args.workspace else ctx.board
204
+ return {"_exit": watch(board, args.interval)}, ""
205
+
206
+
207
+ def register_cli(add_command):
208
+ add_command("watch", "live terminal board", _setup, _command)
209
+
210
+
211
+ def main(argv=None):
212
+ from raidlock.cli import add_command
213
+ from raidlock.cli import main as cli_main
214
+
215
+ register_cli(add_command)
216
+ args = list(sys.argv[1:] if argv is None else argv)
217
+ if not args or args[0] != "watch":
218
+ args.insert(0, "watch")
219
+ return cli_main(args)
220
+
221
+
222
+ if __name__ == "__main__":
223
+ sys.exit(main())
@@ -0,0 +1,5 @@
1
+ """Read-only local board UI and CLI entrypoint."""
2
+
3
+ from .cli import register_cli
4
+
5
+ __all__ = ["register_cli"]
@@ -0,0 +1,19 @@
1
+ .achs{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));gap:10px}
2
+ .ach{display:flex;gap:10px;align-items:flex-start;background:var(--panel);border:1px solid var(--edge);border-radius:12px;padding:10px;min-width:0}
3
+ .ach.earned{border-color:var(--accent2)}
4
+ .ach-body{display:flex;flex-direction:column;gap:3px;min-width:0;font-size:14px;overflow-wrap:anywhere}
5
+ .ach-body b{font-size:14px;color:var(--accent)}
6
+ .ach-body small{color:var(--muted)}
7
+ .ach-art{flex:none}
8
+ .ach-art .ring{fill:var(--pill);stroke:var(--edge);stroke-width:2}
9
+ .ach-art .fillc{fill:var(--muted)}
10
+ .ach-art .strokec{stroke:var(--muted);stroke-width:2.5;fill:none;stroke-linecap:round}
11
+ .ach-art .cut{stroke:var(--pill);stroke-width:3;fill:none;stroke-linecap:round;stroke-linejoin:round}
12
+ .ach.earned .ach-art .ring{stroke:var(--accent2)}
13
+ .ach.earned .ach-art .fillc{fill:var(--accent)}
14
+ .ach.earned .ach-art .strokec{stroke:var(--accent)}
15
+ .ach.locked .ach-body b{color:var(--muted)}
16
+ .ach-meter{height:8px;border-radius:4px;background:var(--pill);border:1px solid var(--edge);overflow:hidden}
17
+ .ach-meter i{display:block;height:100%;background:var(--accent)}
18
+
19
+ .titles{margin:0 0 14px}.titles h3{margin:0 0 6px;font-size:15px;color:var(--accent)}.titles ul{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px 14px}.titles li{font-size:14px}.titles b{color:var(--ink)}
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ (function () {
3
+ const views = window.RaidlockViews = window.RaidlockViews || [];
4
+
5
+ const ART = {
6
+ sun: s => [s("circle", {cx: 24, cy: 24, r: 9, class: "fillc"}),
7
+ ...[0, 45, 90, 135, 180, 225, 270, 315].map(a => s("line", {x1: 24, y1: 6, x2: 24, y2: 11, transform: `rotate(${a} 24 24)`, class: "strokec"}))],
8
+ seal: s => [s("path", {class: "fillc", d: "M24 6l4 3 5-1 2 5 5 2-1 5 3 4-3 4 1 5-5 2-2 5-5-1-4 3-4-3-5 1-2-5-5-2 1-5-3-4 3-4-1-5 5-2 2-5 5 1z"}),
9
+ s("path", {class: "cut", d: "M17 24l5 5 9-10"})],
10
+ eyes: s => [s("ellipse", {cx: 15, cy: 24, rx: 9, ry: 6, class: "strokec"}), s("ellipse", {cx: 33, cy: 24, rx: 9, ry: 6, class: "strokec"}),
11
+ s("circle", {cx: 16, cy: 24, r: 3, class: "fillc"}), s("circle", {cx: 34, cy: 24, r: 3, class: "fillc"})],
12
+ quill: s => [s("path", {class: "fillc", d: "M38 8C24 10 16 20 13 34l3 1c3-8 8-14 15-18-5 5-9 10-11 17 11-4 17-14 18-26z"}),
13
+ s("line", {x1: 13, y1: 34, x2: 10, y2: 41, class: "strokec"})],
14
+ chord: s => [10, 24, 38].flatMap((x, i) => [s("line", {x1: x, y1: 10, x2: x, y2: 34, class: "strokec"}), s("circle", {cx: x - 3, cy: 36 - i * 6, r: 4, class: "fillc"})]),
15
+ hands: s => [s("path", {class: "fillc", d: "M10 38V20a2 2 0 014 0v8V14a2 2 0 014 0v14V12a2 2 0 014 0v16-12a2 2 0 014 0v18c0 6-4 10-10 10h-1c-3 0-5-2-7-4z"}),
16
+ s("path", {class: "strokec", d: "M30 30l4-4a2 2 0 013 3l-6 7"})],
17
+ rune: s => [s("circle", {cx: 24, cy: 24, r: 15, class: "strokec"}), s("path", {class: "strokec", d: "M16 24l6 6 11-13"})],
18
+ shield: s => [s("path", {class: "fillc", d: "M24 6l15 5v11c0 10-7 17-15 20C16 39 9 32 9 22V11z"}), s("path", {class: "strokec", d: "M24 14v20M17 22h14"})],
19
+ blade: s => [s("path", {class: "fillc", d: "M34 6l8 0 0 8-18 18-8-8z"}), s("path", {class: "strokec", d: "M14 26l8 8M10 38l6-6"})],
20
+ moon: s => [s("path", {class: "fillc", d: "M30 8a16 16 0 1 0 10 26A13 13 0 0 1 30 8z"}), s("circle", {cx: 36, cy: 14, r: 2, class: "fillc"})]
21
+ };
22
+
23
+ function art(c, a) {
24
+ const draw = ART[a.art] || ART.sun;
25
+ return c.svg("svg", {viewBox: "0 0 48 48", width: "48", height: "48", class: "ach-art", "aria-hidden": "true"},
26
+ c.svg("circle", {cx: 24, cy: 24, r: 22, class: "ring"}), draw(c.svg));
27
+ }
28
+
29
+ function badge(c, a) {
30
+ const p = a.progress;
31
+ const status = a.earned ? `Earned${a.by ? " by " + a.by : ""}${a.at ? ", " + c.ago(a.at) + " ago" : ""}${a.detail ? " · " + a.detail : ""}`
32
+ : p ? `Locked · ${p.value}/${p.goal}` : "Locked";
33
+ let meter = null;
34
+ if (!a.earned && p) {
35
+ const fill = c.h("i");
36
+ fill.classList.add("width-" + Math.round(Math.max(0, Math.min(100, 100 * p.value / Math.max(1, p.goal)))));
37
+ meter = c.h("div", {class: "ach-meter", role: "meter", "aria-label": `${a.name} progress`, "aria-valuemin": "0", "aria-valuemax": String(p.goal), "aria-valuenow": String(p.value)}, fill);
38
+ }
39
+ return c.h("div", {class: "ach " + (a.earned ? "earned" : "locked"), "data-id": a.id},
40
+ art(c, a), c.h("div", {class: "ach-body"}, c.h("b", {text: a.name}), c.h("span", {text: a.description}), meter, c.h("small", {text: status})));
41
+ }
42
+
43
+ views.push({
44
+ id: "achievements",
45
+ endpoint: "achievements",
46
+ label: {raid: "Feats", plain: "Achievements"},
47
+ optIn: "achievements",
48
+ render(c, d, pane) {
49
+ const titles = Object.entries(d.titles || {});
50
+ c.setKids(pane, [
51
+ c.h("div", {class: "view-head"}, c.h("h2", null, c.raid ? "Feats" : "Achievements", c.raid && c.raidSub ? c.raidSub("Achievements") : null),
52
+ c.h("p", {class: "cls", text: `${d.incomplete_history ? "Incomplete history (latest sequence " + d.last + "). " : ""}${d.earned} of ${d.total} earned, from recorded events only. Turn them off in Settings.`})),
53
+ titles.length ? c.h("section", {class: "titles", "aria-label": "Titles earned"}, c.h("h3", {text: "Titles earned"}),
54
+ c.h("ul", null, titles.map(([who, title]) => c.h("li", null, c.h("b", {text: who}), " " + title)))) : null,
55
+ c.h("div", {class: "achs"}, (d.achievements || []).map(a => badge(c, a)))], JSON.stringify([d, c.theme, Math.floor(Date.now() / 60000)]));
56
+ }
57
+ });
58
+ })();
@@ -0,0 +1,19 @@
1
+ .bosses,.bags,.drops{list-style:none;margin:0;padding:0}
2
+ .bosses,.bags{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,280px),1fr));gap:10px}
3
+ .boss,.bag{background:var(--panel);border:1px solid var(--edge);border-radius:12px;padding:10px;min-width:0;display:flex;flex-direction:column;gap:6px;overflow-wrap:anywhere;font-size:14px}
4
+ .boss{border-left:5px solid var(--blocked)}
5
+ .boss.slain{border-left-color:var(--done)}
6
+ .boss-head{display:flex;flex-direction:column;gap:2px;min-width:0}
7
+ .boss-head b{font-size:14px;color:var(--ink)}
8
+ .boss.slain .boss-head b{text-decoration:line-through}
9
+ .boss-hp{height:12px;border-radius:6px;background:var(--pill);border:1px solid var(--edge);overflow:hidden}
10
+ .boss-hp i{display:block;height:100%;background:var(--blocked);transition:width .6s ease-out}
11
+ .boss-note{margin:0;font-size:14px;color:var(--muted)}
12
+ .loot-head{margin:16px 0 8px;font-size:15px;color:var(--accent)}
13
+ .drops{display:flex;flex-direction:column;gap:6px}
14
+ .drop{display:flex;flex-direction:column;gap:1px;padding:4px 8px;border-left:4px solid var(--muted);background:var(--pill);border-radius:6px}
15
+ .drop-name{font-weight:700;color:var(--ink)}
16
+ .drop.r1{border-left-color:var(--done)}
17
+ .drop.r2{border-left-color:var(--todo)}
18
+ .drop.r3{border-left-color:var(--review)}
19
+ .drop.r4{border-left-color:var(--accent)}
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ (function () {
3
+ const views = window.RaidlockViews = window.RaidlockViews || [];
4
+ const BAG_CAP = 12;
5
+
6
+ function boss(c, b) {
7
+ const fill = c.h("i");
8
+ fill.classList.add("width-" + Math.max(0, Math.min(100, b.hp)));
9
+ const left = b.slain ? "slain" : `${b.open} of ${b.total} quests still open`;
10
+ return c.h("li", {class: "boss" + (b.slain ? " slain" : ""), "data-id": b.id},
11
+ c.h("div", {class: "boss-head"}, c.h("b", {text: b.title}), c.h("span", {class: "cls", text: `${b.id} · ${b.dependents} quests wait on it`})),
12
+ c.h("div", {class: "boss-hp", role: "meter", "aria-label": `${b.title} HP`, "aria-valuemin": "0", "aria-valuemax": "100",
13
+ "aria-valuenow": String(b.hp), "aria-valuetext": `${b.hp}%, ${left}`}, fill),
14
+ b.slain ? c.h("p", {class: "boss-note slain-at"}, "Slain ", b.slain_at) : c.h("p", {class: "boss-note", text: `HP ${b.hp}% · ${left}`}));
15
+ }
16
+
17
+ function bag(c, g) {
18
+ return c.h("li", {class: "bag", "data-agent": g.agent}, c.h("b", {text: g.agent}),
19
+ c.h("ul", {class: "drops"}, g.items.map(i => c.h("li", {class: `drop r${i.tier}`, "data-card": i.card},
20
+ c.h("span", {class: "drop-name", text: i.item}), c.h("span", {class: "cls", text: `${i.rarity} · ${i.title}`})))));
21
+ }
22
+
23
+ views.push({
24
+ id: "bosses",
25
+ endpoint: "bosses",
26
+ label: {raid: "Boss board", plain: "Big quests"},
27
+ raidOnly: true,
28
+ render(c, d, pane) {
29
+ const bosses = d.bosses || [], bags = d.bags || [];
30
+ const drops = bags.reduce((n, g) => n + g.items.length, 0);
31
+ const best = g => Math.max(...g.items.map(i => i.tier));
32
+ const ranked = bags.slice().sort((a, b) => best(b) - best(a) || b.items.length - a.items.length || a.agent.localeCompare(b.agent));
33
+ c.setKids(pane, [
34
+ c.h("div", {class: "view-head"}, c.h("h2", null, "Boss board", c.raidSub ? c.raidSub("Big quests and loot") : null),
35
+ c.h("p", {class: "cls", text: `A quest that ${d.boss_dependents} or more others wait on is a boss. Its HP is the share of it and its dependents still open. Every finished quest drops one item for the agent who closed it.`})),
36
+ bosses.length ? c.h("ul", {class: "bosses"}, bosses.map(b => boss(c, b)))
37
+ : c.h("p", {class: "empty", text: `No bosses roam this realm yet. A quest becomes a boss once ${d.boss_dependents} others wait on it, or when the raid leader names it one.`}),
38
+ c.h("h3", {class: "loot-head", text: `Loot bags · ${drops} ${drops === 1 ? "drop" : "drops"}`}),
39
+ bags.length ? c.h("ul", {class: "bags"}, ranked.slice(0, BAG_CAP).map(g => bag(c, g)))
40
+ : c.h("p", {class: "empty", text: "The bags are empty. Turn in a quest to earn the first drop."}),
41
+ ranked.length > BAG_CAP ? c.h("details", {class: "more-bags"}, c.h("summary", {text: `Show all ${ranked.length} bags`}),
42
+ c.h("ul", {class: "bags"}, ranked.slice(BAG_CAP).map(g => bag(c, g)))) : null],
43
+ JSON.stringify([d, c.theme]));
44
+ }
45
+ });
46
+ })();
raidlock/web/cli.py ADDED
@@ -0,0 +1,90 @@
1
+ import os
2
+ import webbrowser
3
+
4
+ from raidlock.core import find_board_dir
5
+ from raidlock.core.errors import Precondition
6
+
7
+ HOSTS = ("127.0.0.1", "0.0.0.0")
8
+
9
+
10
+ def check_host(host, env=None):
11
+ env = os.environ if env is None else env
12
+ if host != HOSTS[0] and env.get("RAIDLOCK_SANDBOX") != "container":
13
+ raise Precondition(
14
+ f"--host {host} is allowed only inside the raidlock container image",
15
+ hint="raidlock ui (binds 127.0.0.1)",
16
+ )
17
+ return host
18
+
19
+
20
+ def setup(parser):
21
+ parser.add_argument("--port", type=int, default=8765, help="port on 127.0.0.1 (default 8765)")
22
+ parser.add_argument(
23
+ "--host",
24
+ choices=HOSTS,
25
+ default=HOSTS[0],
26
+ help="bind address; 0.0.0.0 only inside the raidlock container image",
27
+ )
28
+ parser.add_argument("--only", action="store_true", help="show only this board, not every registered workspace")
29
+ parser.add_argument("--open", action="store_true", help="open the page in your browser")
30
+
31
+
32
+ def handler(ctx, args):
33
+ from .server import make_server
34
+
35
+ host = check_host(getattr(args, "host", "127.0.0.1"))
36
+ here = None
37
+ try:
38
+ here = find_board_dir(args.board)
39
+ except Precondition:
40
+ if args.board or args.only:
41
+ raise
42
+ if here:
43
+ from raidlock.core.migrate import reject_legacy
44
+
45
+ reject_legacy(here)
46
+ single = here if (args.only or args.board) else None
47
+ try:
48
+ srv = make_server(port=args.port, single=single, extra=None if single else here, host=host)
49
+ except OSError as e:
50
+ raise Precondition(
51
+ f"port {args.port} is busy ({e.strerror})", hint=f"raidlock ui --port {args.port + 1}"
52
+ ) from e
53
+ if here:
54
+ caller = ctx.caller()
55
+ if caller.is_lead:
56
+ import time
57
+
58
+ previous = srv.service_actions
59
+ renewed = [time.monotonic()]
60
+
61
+ def heartbeat():
62
+ previous()
63
+ if time.monotonic() - renewed[0] >= 30:
64
+ from raidlock.core.errors import RaidlockError
65
+
66
+ try:
67
+ ctx.board.lead_heartbeat(caller)
68
+ except RaidlockError as exc:
69
+ print(f"raidlock ui: lead heartbeat stopped: {exc}", flush=True)
70
+ srv.service_actions = previous
71
+ renewed[0] = time.monotonic()
72
+
73
+ srv.service_actions = heartbeat
74
+ port = srv.server_address[1]
75
+ url = f"http://127.0.0.1:{port}/"
76
+ where = os.path.basename(os.path.dirname(single)) if single else "every registered workspace"
77
+ print(f"raidlock ui: {where} at {url}\nNext: open {url} (Ctrl+C stops it)", flush=True)
78
+ if args.open:
79
+ webbrowser.open(url)
80
+ try:
81
+ srv.serve_forever()
82
+ except KeyboardInterrupt:
83
+ pass
84
+ finally:
85
+ srv.server_close()
86
+ return {"url": url}, ""
87
+
88
+
89
+ def register_cli(add_command):
90
+ add_command("ui", "open the live board in your browser", setup, handler)