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,205 @@
1
+ """Territory map: every locked path, grouped by repo, with its current holder and contention."""
2
+
3
+ import os
4
+
5
+ from raidlock.core import leases as L
6
+ from raidlock.core.errors import Precondition
7
+ from raidlock.core.paths import overlaps
8
+
9
+ from ..replay import data_of, fold, records
10
+
11
+ OPEN = ("todo", "doing", "review", "blocked")
12
+ EVENTS = ("lock_violation", "lock_denied")
13
+ EVENT_SCAN = 5000
14
+ MAX_HISTORY_WORK = 100000
15
+
16
+
17
+ def _relative(board, path):
18
+ if not path:
19
+ return ""
20
+ if not os.path.isabs(path):
21
+ return path.strip("/")
22
+ for root in (board.workspace, os.path.realpath(board.workspace)):
23
+ try:
24
+ rel = os.path.relpath(path, root)
25
+ except ValueError:
26
+ continue
27
+ if not rel.startswith(".."):
28
+ return rel.replace(os.sep, "/")
29
+ return ""
30
+
31
+
32
+ def lock_events(board, until=None):
33
+ """Recorded lock denials and violations: [{seq, ts, event, agent, card, paths}], newest EVENT_SCAN status
34
+ records only."""
35
+ out = []
36
+ source = records(board, until) if until is not None else board.transcript.read(kind="status", limit=EVENT_SCAN)
37
+ for r in source[-EVENT_SCAN:]:
38
+ if until is not None and r["seq"] > until:
39
+ continue
40
+ data = data_of(r)
41
+ if data.get("event") not in EVENTS:
42
+ continue
43
+ raw = data.get("paths") or ([data.get("path")] if data.get("path") else [])
44
+ paths = [p for p in (_relative(board, x) for x in raw) if p]
45
+ out.append(
46
+ {
47
+ "seq": r["seq"],
48
+ "ts": r.get("ts", ""),
49
+ "event": data["event"],
50
+ "agent": data.get("agent") or (r.get("from") or {}).get("agent", ""),
51
+ "card": r.get("taskId", "") or "",
52
+ "paths": paths,
53
+ }
54
+ )
55
+ return out
56
+
57
+
58
+ def _holders_now(board, cards):
59
+ out = []
60
+ for cid, lease in board.leases().items():
61
+ if not L.is_live(lease):
62
+ continue
63
+ card = cards.get(cid, {})
64
+ out.append(
65
+ {
66
+ "agent": lease.get("agent", ""),
67
+ "card": cid,
68
+ "lane": card.get("lane", ""),
69
+ "paths": lease.get("paths") or card.get("locks", []),
70
+ "expires": lease.get("expires"),
71
+ }
72
+ )
73
+ return out
74
+
75
+
76
+ def territory(board, at=None, state=None):
77
+ cards = {c["id"]: c for c in board.cards()}
78
+ if at is None:
79
+ holders = _holders_now(board, cards)
80
+ lanes = {cid: c["lane"] for cid, c in cards.items()}
81
+ else:
82
+ state = state if state is not None else fold(board, at, marks=False)
83
+ cards = {c["id"]: c for c in state["cards"]}
84
+ holders = [dict(h, expires=None) for h in state["holders"]]
85
+ lanes = {c["id"]: c["lane"] for c in state["cards"]}
86
+ events = lock_events(board, until=at)
87
+ if at is not None:
88
+ path_count = sum(len(c.get("locks", [])) for c in cards.values())
89
+ comparisons = sum(len(h["paths"]) for h in holders) + sum(len(e["paths"]) for e in events)
90
+ if path_count * max(1, comparisons) > MAX_HISTORY_WORK:
91
+ return {
92
+ "at": at,
93
+ "trust": "unverified history",
94
+ "approximate": True,
95
+ "incomplete_history": True,
96
+ "error": "history work budget exceeded",
97
+ "repos": [],
98
+ "agents": [],
99
+ "held": 0,
100
+ "contested": 0,
101
+ "events": [],
102
+ }
103
+ held_cards = {h["card"] for h in holders}
104
+ paths = {}
105
+
106
+ def entry(path):
107
+ e = paths.get(path)
108
+ if e is None:
109
+ head, _, rest = path.partition("/")
110
+ e = paths[path] = {
111
+ "path": path,
112
+ "metadata_source": "current metadata" if at is not None else "live",
113
+ "repo": head,
114
+ "rest": rest,
115
+ "holder": "",
116
+ "card": "",
117
+ "lane": "",
118
+ "expires": None,
119
+ "cards": [],
120
+ "waiting": [],
121
+ "denials": 0,
122
+ "violations": 0,
123
+ }
124
+ return e
125
+
126
+ for h in holders:
127
+ for p in h["paths"]:
128
+ e = entry(p)
129
+ e.update(holder=h["agent"], card=h["card"], lane=h["lane"], expires=h["expires"])
130
+ if at is not None:
131
+ e["metadata_source"] = "unverified history"
132
+ for cid, c in sorted(cards.items()):
133
+ lane = lanes.get(cid)
134
+ if lane not in OPEN:
135
+ continue
136
+ for p in c.get("locks", []):
137
+ e = entry(p)
138
+ if cid not in e["cards"]:
139
+ e["cards"].append(cid)
140
+ for e in paths.values():
141
+ for cid in e["cards"]:
142
+ if cid in held_cards or lanes.get(cid) not in ("todo", "blocked"):
143
+ continue
144
+ for h in holders:
145
+ clash = h["card"] != cid and any(overlaps(e["path"], p) for p in h["paths"])
146
+ if clash and cid not in e["waiting"]:
147
+ e["waiting"].append(cid)
148
+ for ev in events:
149
+ if any(overlaps(e["path"], p) for p in ev["paths"]):
150
+ e["violations" if ev["event"] == "lock_violation" else "denials"] += 1
151
+ e["contested"] = bool(e["waiting"] or e["denials"] or e["violations"])
152
+ repos = {}
153
+ for e in sorted(paths.values(), key=lambda x: x["path"]):
154
+ repos.setdefault(e["repo"], []).append(e)
155
+ agents = sorted({e["holder"] for e in paths.values() if e["holder"]})
156
+ return {
157
+ "at": at,
158
+ "trust": "unverified history" if at is not None else "live",
159
+ "approximate": at is not None,
160
+ "incomplete_history": bool(state and state["incomplete_history"]),
161
+ "repos": [
162
+ {
163
+ "name": name,
164
+ "paths": items,
165
+ "held": sum(1 for i in items if i["holder"]),
166
+ "contested": sum(1 for i in items if i["contested"]),
167
+ }
168
+ for name, items in sorted(repos.items())
169
+ ],
170
+ "agents": agents,
171
+ "held": sum(1 for e in paths.values() if e["holder"]),
172
+ "contested": sum(1 for e in paths.values() if e["contested"]),
173
+ "events": events[-50:],
174
+ }
175
+
176
+
177
+ def payload(board, query):
178
+ raw = (query.get("at") or [""])[0]
179
+ try:
180
+ at = int(raw) if raw else None
181
+ except ValueError:
182
+ raise Precondition("at must be an integer") from None
183
+ if at is not None and at < 0:
184
+ raise Precondition("at must be a nonnegative transcript sequence number")
185
+ return territory(board, at)
186
+
187
+
188
+ def _setup(parser):
189
+ parser.add_argument("--at", type=int, default=None, help="transcript sequence number (default: live leases)")
190
+
191
+
192
+ def _handle(ctx, args):
193
+ result = territory(ctx.board, args.at)
194
+ lines = []
195
+ for repo in result["repos"]:
196
+ lines.append(f"{repo['name']}: {repo['held']} held, {repo['contested']} contested")
197
+ for e in repo["paths"]:
198
+ who = f"{e['holder']} on {e['card']}" if e["holder"] else "free"
199
+ flag = " CONTESTED" if e["contested"] else ""
200
+ lines.append(f" {e['path']}: {who}{flag}")
201
+ return result, "\n".join(lines) or "No locked paths."
202
+
203
+
204
+ def register_cli(add_command):
205
+ add_command("map", "territory map: which agent holds which locked paths, and where they clash", _setup, _handle)
@@ -0,0 +1,15 @@
1
+ .view-head{display:flex;flex-wrap:wrap;gap:6px 12px;align-items:baseline}
2
+ .view-head p{margin:0;flex:1 1 220px}
3
+ .zones{display:flex;flex-direction:column;gap:12px}
4
+ .repo-zone{background:var(--panel);border:1px solid var(--edge);border-radius:14px;padding:8px 10px 10px;min-width:0}
5
+ .repo-zone h3{margin:0 0 8px;font-size:14px;color:var(--accent);overflow-wrap:anywhere}
6
+ .tiles{display:flex;flex-wrap:wrap;gap:6px}
7
+ .tile{flex:var(--w,1) 1 9rem;min-width:0;min-height:64px;display:flex;flex-direction:column;gap:3px;padding:6px 8px;border-radius:8px;border:1px solid var(--edge);border-left:6px solid var(--agc,var(--edge));background:color-mix(in srgb,var(--agc,var(--card2)) 22%,var(--card2));font-size:14px;overflow-wrap:anywhere}
8
+ .tile.free{background:var(--card2);border-left-color:var(--edge);color:var(--muted)}
9
+ .tile .p{color:var(--ink);font-size:14px}
10
+ .tile .who{color:var(--muted)}
11
+ .tile .lane-tag{align-self:flex-start;font-size:12px;border:1px solid var(--edge);border-radius:6px;padding:0 6px}
12
+ .tile .flag{align-self:flex-start;font-size:12px;color:var(--blocked);font-weight:bold}
13
+ .tile.contested{border-color:var(--blocked);background-image:repeating-linear-gradient(135deg,transparent 0 8px,color-mix(in srgb,var(--blocked) 18%,transparent) 8px 12px)}
14
+ #viewPane .sw.free-sw{background:var(--card2);border:1px solid var(--edge)}
15
+ #viewPane .sw.hatch{background:repeating-linear-gradient(135deg,var(--card2) 0 3px,var(--blocked) 3px 5px)}
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ (function () {
3
+ const views = window.RaidlockViews = window.RaidlockViews || [];
4
+ function tile(c, e) {
5
+ const held = !!e.holder;
6
+ const cls = ["tile", held ? c.agentClass(e.holder) : "free", e.contested ? "contested" : ""].filter(Boolean).join(" ");
7
+ const flags = [];
8
+ if (e.waiting.length) flags.push(c.h("span", {class: "flag", text: `${e.waiting.length} waiting: ${e.waiting.join(", ")}`}));
9
+ if (e.denials) flags.push(c.h("span", {class: "flag", text: `${e.denials} denied`}));
10
+ if (e.violations) flags.push(c.h("span", {class: "flag", text: `${e.violations} write ${e.violations === 1 ? "violation" : "violations"}`}));
11
+ const who = held ? `${e.holder} on ${e.card}` : (e.cards.length ? `free · ${e.cards.join(", ")}` : "free");
12
+ const label = `${e.path}${e.metadata_source ? " (" + e.metadata_source + ")" : ""}: ${who}${e.contested ? ", contested" : ""}`;
13
+ const el = c.h("div", {class: cls, "data-path": e.path, "data-holder": e.holder || "", title: label},
14
+ c.h("b", {class: "p", text: e.rest || e.path}),
15
+ c.h("span", {class: "who", text: who}),
16
+ e.lane && held ? c.h("span", {class: "lane-tag", text: e.lane}) : null,
17
+ flags);
18
+ el.classList.add("tile-weight-" + Math.min(10, 1 + e.cards.length + (held ? 1 : 0)));
19
+ return el;
20
+ }
21
+ function tiles(c, d) {
22
+ if (!(d.repos || []).length) return [c.h("div", {class: "empty", text: "No locked paths yet. A card's --locks paths show up here once the card exists."})];
23
+ return d.repos.map(r => c.h("div", {class: "repo-zone", "data-repo": r.name},
24
+ c.h("h3", null, r.name, c.h("span", {class: "cls", text: ` ${r.held} held · ${r.contested} contested · ${r.paths.length} ${r.paths.length === 1 ? "path" : "paths"}`})),
25
+ c.h("div", {class: "tiles"}, r.paths.map(e => tile(c, e)))));
26
+ }
27
+ function legend(c, d) {
28
+ return c.h("div", {class: "legend-row"},
29
+ (d.agents || []).map(a => c.h("span", {class: c.agentClass(a)}, c.h("i", {class: "sw"}), a)),
30
+ c.h("span", null, c.h("i", {class: "sw free-sw"}), "free"),
31
+ c.h("span", null, c.h("i", {class: "sw hatch"}), "contested: a waiting card overlaps a held path, or a lock denial or write violation was recorded on it"));
32
+ }
33
+ views.push({
34
+ id: "map",
35
+ endpoint: "raidmap",
36
+ label: {raid: "War map", plain: "Map"},
37
+ tiles, legend,
38
+ render(c, d, pane) {
39
+ const head = c.h("div", {class: "view-head"},
40
+ c.h("h2", null, c.raid ? "War map" : "Map", c.raid && c.raidSub ? c.raidSub("Map") : null),
41
+ c.h("p", {class: "cls", text: `Which agent holds which locked paths: ${d.held} held, ${d.contested} contested. Updates every few seconds.`}),
42
+ c.h("button", {type: "button", class: "tog", "data-go": "replay", text: c.raid ? "See how it changed in the Chronicle" : "See how it changed in Timeline"}));
43
+ head.querySelector("[data-go]").addEventListener("click", () => c.go("replay"));
44
+ c.setKids(pane, [head, legend(c, d), c.h("div", {class: "zones"}, tiles(c, d))], JSON.stringify([d, c.theme]));
45
+ }
46
+ });
47
+ })();
@@ -0,0 +1,258 @@
1
+ """Rebuild board state at any transcript sequence number by folding the recorded events in order."""
2
+
3
+ import math
4
+
5
+ from raidlock.core.errors import Precondition
6
+ from raidlock.core.transcript import KINDS
7
+
8
+ LANES = ("todo", "doing", "review", "blocked", "done")
9
+ HOLD = ("doing", "review", "blocked")
10
+ PAGE = 5000
11
+ MAX_RECORDS = 200000
12
+ MARKS = 4000
13
+ RECENT = 8
14
+
15
+
16
+ def data_of(record):
17
+ out = {}
18
+ parts = list(record.get("parts", []))
19
+ for artifact in record.get("artifacts", []):
20
+ parts.extend(artifact.get("parts", []))
21
+ for part in parts:
22
+ if part.get("kind") == "data" and isinstance(part.get("data"), dict):
23
+ out.update(part["data"])
24
+ return out
25
+
26
+
27
+ def text_of(record):
28
+ return "\n".join(p.get("text", "") for p in record.get("parts", []) if p.get("kind") == "text")
29
+
30
+
31
+ MAX_BYTES = 16 * 1024 * 1024
32
+
33
+
34
+ def history(board):
35
+ version = board.version()
36
+ cached = getattr(board, "_history_cache", None)
37
+ if cached and cached[0] == version:
38
+ return cached[1]
39
+ transcript = board.transcript
40
+ latest = transcript.index.query_transcript(transcript.ws, limit=1)
41
+ last = latest[-1]["seq"] if latest else 0
42
+ out, cursor, size = [], 0, 0
43
+ incomplete = False
44
+ while cursor < last:
45
+ rows = transcript.index.query_transcript(transcript.ws, since=cursor, limit=PAGE)
46
+ selected = []
47
+ for row in rows:
48
+ if len(out) + len(selected) >= MAX_RECORDS or size + row["length"] > MAX_BYTES:
49
+ incomplete = True
50
+ break
51
+ selected.append(row)
52
+ size += row["length"]
53
+ out.extend(transcript._load(selected))
54
+ if incomplete or not rows:
55
+ break
56
+ cursor = rows[-1]["seq"]
57
+ result = (out, last, incomplete)
58
+ board._history_cache = (version, result)
59
+ return result
60
+
61
+
62
+ def records(board, until=None):
63
+ every, _, _ = history(board)
64
+ return [r for r in every if until is None or r["seq"] <= until]
65
+
66
+
67
+ class State:
68
+ def __init__(self, titles):
69
+ self.titles = titles
70
+ self.cards = {}
71
+ self.locks = {}
72
+ self.non_core = False
73
+ self.events = {"lock_violation": 0, "lock_denied": 0, "stale_fence": 0}
74
+
75
+ def card(self, cid):
76
+ c = self.cards.get(cid)
77
+ if c is None:
78
+ c = self.cards[cid] = {
79
+ "id": cid,
80
+ "trust": "unverified history",
81
+ "title_source": "current metadata",
82
+ "lease_holder": "",
83
+ "title": self.titles.get(cid, cid),
84
+ "lane": "todo",
85
+ "owner": "",
86
+ "tokens": 0,
87
+ "usd_equiv": 0.0,
88
+ "notes": 0,
89
+ "since": "",
90
+ }
91
+ return c
92
+
93
+ def apply(self, r):
94
+ kind, task = r.get("kind"), r.get("taskId") or ""
95
+ if r.get("src") != "board" or kind not in KINDS:
96
+ self.non_core = True
97
+ return
98
+ sender = (r.get("from") or {}).get("agent", "")
99
+ text, data = text_of(r), data_of({"parts": r.get("parts", [])})
100
+ event = data.get("event")
101
+ if event in self.events:
102
+ self.events[event] += 1
103
+ if not task:
104
+ return
105
+ if kind == "status":
106
+ if text.startswith(f"created {task}:"):
107
+ c = self.card(task)
108
+ c["title"] = text.split(":", 1)[1].strip() or task
109
+ c["title_source"] = "unverified history"
110
+ if isinstance(data.get("locks"), list):
111
+ self.locks[task] = data["locks"]
112
+ c.update(lane="todo", owner="", lease_holder="", since=r.get("ts", ""))
113
+ elif (
114
+ data.get("a2a_state") == "working"
115
+ and "fence" in data
116
+ and (text.startswith("claimed by ") or "taken over from" in text)
117
+ ):
118
+ self.card(task).update(lane="doing", owner=sender, lease_holder=sender, since=r.get("ts", ""))
119
+ elif data.get("lane") in LANES and text.startswith(f"{task} -> {data['lane']}"):
120
+ c = self.card(task)
121
+ c.update(lane=data["lane"], since=r.get("ts", ""))
122
+ if data["lane"] in ("todo", "done"):
123
+ c["lease_holder"] = ""
124
+ if data["lane"] == "todo":
125
+ c["owner"] = ""
126
+ elif text == "lease released":
127
+ c = self.card(task)
128
+ c["lease_holder"] = ""
129
+ if c["lane"] == "doing":
130
+ c.update(lane="todo", owner="", lease_holder="", since=r.get("ts", ""))
131
+ elif data.get("a2a_state") == "submitted" and text.endswith("reopened"):
132
+ self.card(task).update(lane="todo", owner="", lease_holder="", since=r.get("ts", ""))
133
+ elif text == "lease expired; reaped to blocked":
134
+ self.card(task).update(lane="blocked", lease_holder="", since=r.get("ts", ""))
135
+ elif text == "lease resumed after expiry":
136
+ self.card(task)["lease_holder"] = sender
137
+ if (
138
+ (text.startswith(f"created {task}:") or text.startswith("claimed by ") or text == "locks updated")
139
+ and isinstance(data.get("locks"), list)
140
+ and all(isinstance(p, str) for p in data["locks"])
141
+ ):
142
+ self.locks[task] = data["locks"]
143
+ elif kind == "cost":
144
+ c = self.card(task)
145
+ c["tokens"] += int(data.get("tokens") or 0)
146
+ value = float(data.get("usd_equiv") or 0)
147
+ if math.isfinite(value):
148
+ c["usd_equiv"] = round(c["usd_equiv"] + value, 6)
149
+ elif kind == "note":
150
+ self.card(task)["notes"] += 1
151
+
152
+
153
+ def mark(r):
154
+ text = text_of(r) or ", ".join(a.get("name", "") for a in r.get("artifacts", []))
155
+ return {
156
+ "seq": r["seq"],
157
+ "trust": "unverified history",
158
+ "source": r.get("src", "unknown"),
159
+ "ts": r.get("ts", ""),
160
+ "kind": r.get("kind", ""),
161
+ "task": r.get("taskId", "") or "",
162
+ "agent": (r.get("from") or {}).get("agent", ""),
163
+ "text": text[:160],
164
+ }
165
+
166
+
167
+ def fold(board, at=None, marks=True):
168
+ """Board state right after transcript record `at` (default: the newest)."""
169
+ if at is not None and (not isinstance(at, int) or at < 0):
170
+ raise Precondition("at must be a nonnegative transcript sequence number")
171
+ current = {c["id"]: c for c in board.cards()}
172
+ every, latest, incomplete = history(board)
173
+ if not every:
174
+ return {
175
+ "at": 0,
176
+ "ts": "",
177
+ "first": 0,
178
+ "last": latest,
179
+ "incomplete_history": incomplete,
180
+ "trust": "unverified history",
181
+ "non_core_records": False,
182
+ "total": 0,
183
+ "marks": [],
184
+ "truncated": False,
185
+ "cards": [],
186
+ "counts": dict.fromkeys(LANES, 0),
187
+ "holders": [],
188
+ "recent": [],
189
+ "events": {},
190
+ "untracked": len(current),
191
+ }
192
+ last = every[-1]["seq"]
193
+ point = last if at is None else max(every[0]["seq"], min(at, last))
194
+ state = State({k: v.get("title", k) for k, v in current.items()})
195
+ upto = []
196
+ for r in every:
197
+ if r["seq"] > point:
198
+ break
199
+ state.apply(r)
200
+ upto.append(r)
201
+ lock_of = {k: v.get("locks", []) for k, v in current.items()}
202
+ cards = sorted(state.cards.values(), key=lambda c: (LANES.index(c["lane"]), c["id"]))
203
+ for c in cards:
204
+ c["locks"] = state.locks.get(c["id"], lock_of.get(c["id"], []))
205
+ c["locks_source"] = "unverified history" if c["id"] in state.locks else "current metadata"
206
+ holders = [
207
+ {"agent": c["lease_holder"], "card": c["id"], "lane": c["lane"], "paths": c["locks"]}
208
+ for c in cards
209
+ if c["lease_holder"] and c["id"] in state.locks
210
+ ]
211
+ shown = [mark(r) for r in every[-MARKS:]] if marks else []
212
+ return {
213
+ "at": point,
214
+ "ts": upto[-1].get("ts", "") if upto else "",
215
+ "first": every[0]["seq"],
216
+ "last": latest,
217
+ "incomplete_history": incomplete,
218
+ "trust": "unverified history",
219
+ "non_core_records": state.non_core,
220
+ "total": len(every),
221
+ "marks": shown,
222
+ "truncated": len(every) > MARKS,
223
+ "cards": cards,
224
+ "counts": {lane: sum(1 for c in cards if c["lane"] == lane) for lane in LANES},
225
+ "holders": holders,
226
+ "recent": [mark(r) for r in upto[-RECENT:]][::-1],
227
+ "events": dict(state.events),
228
+ "untracked": sum(1 for k in current if k not in state.cards),
229
+ }
230
+
231
+
232
+ def payload(board, query):
233
+ raw = (query.get("at") or [""])[0]
234
+ try:
235
+ at = int(raw) if raw else None
236
+ except ValueError:
237
+ raise Precondition("at must be an integer") from None
238
+ from ..raidmap import territory
239
+
240
+ state = fold(board, at, marks=(query.get("marks") or ["1"])[0] != "0")
241
+ return {**state, "territory": territory(board, state["at"], state=state)}
242
+
243
+
244
+ def _setup(parser):
245
+ parser.add_argument("--at", type=int, default=None, help="transcript sequence number (default: newest)")
246
+
247
+
248
+ def _handle(ctx, args):
249
+ result = fold(ctx.board, args.at, marks=False)
250
+ lines = [f"at seq {result['at']} of {result['first']}..{result['last']} ({result['ts'] or 'empty'})"]
251
+ lines.append(", ".join(f"{n} {lane}" for lane, n in result["counts"].items()))
252
+ for c in result["cards"]:
253
+ lines.append(f" {c['lane']:<8} {c['id']}" + (f" ({c['owner']})" if c["owner"] else ""))
254
+ return result, "\n".join(lines)
255
+
256
+
257
+ def register_cli(add_command):
258
+ add_command("replay", "board state rebuilt from the transcript at a point in time", _setup, _handle)
@@ -0,0 +1,13 @@
1
+ .rp-controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:8px 0}
2
+ .rp-controls input[type=range]{flex:1 1 200px;min-width:0;accent-color:var(--accent)}
3
+ .rp-out{display:block;font-size:14px;padding:6px 10px;border:1px solid var(--edge);border-radius:8px;background:var(--bubble);overflow-wrap:anywhere}
4
+ .rp-body h3{font-size:14px;color:var(--accent);margin:14px 0 6px}
5
+ .rp-sum{margin:8px 0}
6
+ .rp-lanes{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
7
+ .rp-lane{background:var(--panel);border:1px solid var(--edge);border-top:3px solid var(--lc,var(--edge));border-radius:10px;padding:6px 8px;min-width:0}
8
+ .rp-lane[data-lane=todo]{--lc:var(--todo)}.rp-lane[data-lane=doing]{--lc:var(--doing)}.rp-lane[data-lane=review]{--lc:var(--review)}.rp-lane[data-lane=blocked]{--lc:var(--blocked)}.rp-lane[data-lane=done]{--lc:var(--done)}
9
+ .rp-lane h3{margin:0 0 6px;font-size:14px;display:flex;justify-content:space-between;gap:6px;color:var(--ink)}
10
+ .rp-chips{display:flex;flex-wrap:wrap;gap:4px}
11
+ .rp-chip{font-size:12px;border:1px solid var(--edge);border-left:4px solid var(--agc,var(--edge));border-radius:6px;padding:1px 6px;overflow-wrap:anywhere;max-width:100%}
12
+ .rp-chip small{color:var(--muted)}
13
+ .rp-recent{margin:0;padding-left:0;list-style:none;font-size:14px;display:flex;flex-direction:column;gap:3px;overflow-wrap:anywhere}
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ (function () {
3
+ const views = window.RaidlockViews = window.RaidlockViews || [];
4
+ const LANES = [["todo", "To do"], ["doing", "In progress"], ["review", "Review"], ["blocked", "Blocked"], ["done", "Done"]];
5
+ const STEP_MS = 700;
6
+ let st = null;
7
+ let inFlight = false;
8
+
9
+ function stop() {
10
+ if (st && st.timer) { clearInterval(st.timer); st.timer = 0; }
11
+ if (st && st.play) { st.play.setAttribute("aria-pressed", "false"); st.play.textContent = "Play"; }
12
+ }
13
+ function markText(m) {
14
+ if (!m) return "No records yet. The timeline fills as agents claim, note and move cards.";
15
+ return `#${m.seq} · ${st.c.localTime(m.ts)} · ${m.agent || "?"}: ${m.text || m.kind}`;
16
+ }
17
+ function label() {
18
+ const m = st.marks[st.idx];
19
+ st.c.setKids(st.out, [markText(m)]);
20
+ st.out.title = m ? m.ts : "";
21
+ st.range.setAttribute("aria-valuetext", m ? `record ${m.seq}, ${st.c.localTime(m.ts)}` : "empty");
22
+ st.play.disabled = !st.marks.length;
23
+ st.range.disabled = !st.marks.length;
24
+ st.prev.disabled = st.idx <= 0;
25
+ st.next.disabled = st.idx >= st.marks.length - 1;
26
+ }
27
+ function lanes(c, d) {
28
+ return c.h("div", {class: "rp-lanes"}, LANES.map(([id, name]) => {
29
+ const cards = (d.cards || []).filter(x => x.lane === id);
30
+ return c.h("section", {class: "rp-lane", "data-lane": id, "aria-label": name},
31
+ c.h("h3", null, name, c.h("span", {class: "count", text: String((d.counts || {})[id] || 0)})),
32
+ c.h("div", {class: "rp-chips"}, cards.length ? cards.map(x => c.h("span", {class: "rp-chip" + (x.owner ? " " + c.agentClass(x.owner) : ""), "data-id": x.id, title: x.title},
33
+ x.id, x.owner ? c.h("small", {text: " " + x.owner}) : null)) : c.h("span", {class: "cls", text: "none"})));
34
+ }));
35
+ }
36
+ function show(c, d, map) {
37
+ st.cur = d;
38
+ const recent = (d.recent || []).map(m => c.h("li", {class: "k-" + m.kind}, c.h("b", {text: `#${m.seq} ${m.agent || "?"}`}), ` ${m.kind}${m.task ? " on " + m.task : ""}${m.source && m.source !== "board" ? " (" + m.source + ")" : ""}: `, m.text || ""));
39
+ const ev = d.events || {};
40
+ const notes = [];
41
+ if (d.untracked) notes.push(`${d.untracked} cards have no creation record in the transcript and appear once they are first mentioned.`);
42
+ if (d.truncated) notes.push(`Only the newest ${st.marks.length} records are on the slider; older slider entries are omitted.`);
43
+ if (d.incomplete_history) notes.push(`Incomplete history: reconstruction stops at ${d.at}; true latest sequence is ${d.last}.`);
44
+ if (d.non_core_records) notes.push("Non-core or unknown records were skipped.");
45
+ if (map && map.error) notes.push("Incomplete territory: " + map.error);
46
+ const mapDef = c.def("map");
47
+ c.setKids(st.body, [
48
+ c.h("p", {class: "cls rp-sum", text: `${d.total} records · lock denials ${ev.lock_denied || 0} · write violations ${ev.lock_violation || 0} so far`}),
49
+ lanes(c, d),
50
+ c.h("h3", {text: "Locks at this point"}),
51
+ map && mapDef ? mapDef.legend(c, map) : null,
52
+ map && mapDef ? c.h("div", {class: "zones"}, mapDef.tiles(c, map)) : null,
53
+ c.h("h3", {text: "Records up to this point"}),
54
+ recent.length ? c.h("ul", {class: "rp-recent"}, recent) : c.h("p", {class: "cls", text: "Nothing yet."}),
55
+ notes.length ? c.h("p", {class: "note-sm", text: notes.join(" ")}) : null]);
56
+ }
57
+ async function load(c) {
58
+ const m = st.marks[st.idx];
59
+ if (!m || inFlight) return;
60
+ inFlight = true;
61
+ const owner = st;
62
+ const token = ++st.token;
63
+ try {
64
+ const d = await c.api("replay", `?at=${m.seq}&marks=0`);
65
+ const map = d && d.territory;
66
+ if (st === owner && token === st.token && d) show(c, d, map);
67
+ } catch (_) {} finally {
68
+ inFlight = false;
69
+ if (st === owner && st.marks[st.idx] !== m) load(c);
70
+ }
71
+ }
72
+ function scrub(c, i, now) {
73
+ st.idx = Math.max(0, Math.min(st.marks.length - 1, i));
74
+ st.range.value = String(st.idx);
75
+ label();
76
+ clearTimeout(st.debounce);
77
+ if (now) load(c); else st.debounce = setTimeout(() => load(c), 120);
78
+ }
79
+ function build(c, d, pane) {
80
+ stop();
81
+ st = {ws: c.ws, marks: d.marks || [], idx: Math.max(0, (d.marks || []).length - 1), timer: 0, token: 0, debounce: 0};
82
+ const max = Math.max(0, st.marks.length - 1);
83
+ st.range = c.h("input", {type: "range", min: "0", max: String(max), step: "1", value: String(st.idx), id: "replayRange", "aria-label": "Position in the transcript"});
84
+ st.prev = c.h("button", {type: "button", class: "tog", text: "Previous", "aria-label": "Previous record"});
85
+ st.next = c.h("button", {type: "button", class: "tog", text: "Next", "aria-label": "Next record"});
86
+ st.play = c.h("button", {type: "button", class: "tog", "aria-pressed": "false", text: "Play"});
87
+ st.live = c.h("button", {type: "button", class: "tog", text: "Refresh"});
88
+ st.out = c.h("output", {class: "rp-out", for: "replayRange"});
89
+ st.body = c.h("div", {class: "rp-body"});
90
+ st.range.addEventListener("input", () => { stop(); scrub(st.c, +st.range.value, false); });
91
+ st.prev.addEventListener("click", () => { stop(); scrub(st.c, st.idx - 1, true); });
92
+ st.next.addEventListener("click", () => { stop(); scrub(st.c, st.idx + 1, true); });
93
+ st.live.addEventListener("click", () => { stop(); st.ws = null; st.c.reload(); });
94
+ st.play.addEventListener("click", () => {
95
+ if (st.timer) { stop(); return; }
96
+ if (st.idx >= st.marks.length - 1) scrub(st.c, 0, true);
97
+ st.play.setAttribute("aria-pressed", "true");
98
+ st.play.textContent = "Pause";
99
+ st.timer = setInterval(() => {
100
+ if (inFlight) return;
101
+ if (!st || st.idx >= st.marks.length - 1) { stop(); return; }
102
+ scrub(st.c, st.idx + 1, true);
103
+ }, STEP_MS);
104
+ });
105
+ pane.replaceChildren(
106
+ c.h("div", {class: "view-head"}, c.h("h2", null, c.raid ? "Chronicle" : "Timeline", c.raid && c.raidSub ? c.raidSub("Timeline") : null),
107
+ c.h("p", {class: "cls", text: "The board rebuilt from the transcript at any point. Transcript records are not signed, so treat this as unverified history; lock paths use today's repo layout."})),
108
+ c.h("div", {class: "rp-controls"}, st.prev, st.range, st.next, st.play, st.live),
109
+ st.out, st.body);
110
+ st.c = c;
111
+ label();
112
+ show(c, d, d.territory);
113
+ if (st.marks.length) load(c);
114
+ }
115
+ views.push({
116
+ id: "replay",
117
+ endpoint: "replay",
118
+ label: {raid: "Chronicle", plain: "Timeline"},
119
+ poll: false,
120
+ leave() { stop(); },
121
+ render(c, d, pane) {
122
+ if (c.fresh || !st || st.ws !== c.ws || !pane.contains(st.range)) { build(c, d, pane); return; }
123
+ st.c = c;
124
+ if (st.cur) load(c);
125
+ }
126
+ });
127
+ })();