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/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """raidlock: a local-first lease-and-review board for coding agents from any provider."""
2
+
3
+ import sys
4
+
5
+ __version__ = "0.1.0"
6
+ MINIMUM_PYTHON = (3, 11)
7
+
8
+ if sys.version_info < MINIMUM_PYTHON:
9
+ from ._python import refuse
10
+
11
+ refuse(MINIMUM_PYTHON)
raidlock/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
raidlock/_python.py ADDED
@@ -0,0 +1,58 @@
1
+ """Refuse to run on a Python older than raidlock supports, with one clear line.
2
+
3
+ This module must stay importable by old interpreters: plain syntax and the standard library only. A hook guard that
4
+ runs inside a raidlock workspace denies the tool call instead of failing open."""
5
+
6
+ import json
7
+ import os
8
+ import sys
9
+
10
+ GUARDS = ("pre-write", "pre-read", "pre-bash", "pre-urgent")
11
+
12
+
13
+ def message(minimum):
14
+ have = ".".join(str(n) for n in sys.version_info[:3])
15
+ need = ".".join(str(n) for n in minimum)
16
+ return (
17
+ "raidlock needs Python {need} or newer, but {exe} is Python {have}. Install raidlock with "
18
+ "`uv tool install raidlock` or `pipx install raidlock`, or set RAIDLOCK_PYTHON to a Python {need}+ "
19
+ "interpreter."
20
+ ).format(need=need, exe=sys.executable or "python", have=have)
21
+
22
+
23
+ def board_above(start):
24
+ cur = os.path.abspath(start or os.getcwd())
25
+ while True:
26
+ if os.path.isfile(os.path.join(cur, ".board", "board.json")):
27
+ return True
28
+ parent = os.path.dirname(cur)
29
+ if parent == cur:
30
+ return False
31
+ cur = parent
32
+
33
+
34
+ def hook_input():
35
+ try:
36
+ data = json.loads(sys.stdin.read() or "{}")
37
+ except (OSError, ValueError):
38
+ return {}
39
+ return data if isinstance(data, dict) else {}
40
+
41
+
42
+ def refuse(minimum):
43
+ text = message(minimum)
44
+ if any(arg in GUARDS for arg in sys.argv[1:]) and not sys.stdin.isatty():
45
+ data = hook_input()
46
+ if board_above(data.get("cwd")):
47
+ out = {
48
+ "hookSpecificOutput": {
49
+ "hookEventName": "PreToolUse",
50
+ "permissionDecision": "deny",
51
+ "permissionDecisionReason": "raidlock: " + text,
52
+ }
53
+ }
54
+ sys.stdout.write(json.dumps(out, separators=(",", ":")))
55
+ sys.stdout.flush()
56
+ raise SystemExit(0)
57
+ sys.stderr.write(text + "\n")
58
+ raise SystemExit(1)
@@ -0,0 +1,156 @@
1
+ """Achievements earned from recorded board events. The web UI shows them unless the viewer turns them off.
2
+
3
+ Every rule reads the transcript and cards; nothing is stored and nothing is granted. Names and art are original to
4
+ raidlock.
5
+ """
6
+
7
+ import datetime
8
+
9
+ from .core import clock, receipts
10
+ from .web.replay import data_of, history, records, text_of
11
+
12
+ CLEAN_CARDS = 50
13
+ SWIFT_MINUTES = 30
14
+ PEACE_DAYS = 7
15
+ TITLES = {
16
+ "clean-hands": "the Unbroken",
17
+ "first-seal": "the Sealbearer",
18
+ "second-opinion": "the Wise",
19
+ "clean-run": "the Flawless",
20
+ "swift-blade": "the Swift",
21
+ "night-watch": "of the Night Watch",
22
+ "peacekeeper": "the Peacekeeper",
23
+ }
24
+ CATALOG = (
25
+ ("first-light", "First Light", "A card reaches done for the first time.", "sun"),
26
+ ("first-seal", "Wax Seal", "The first verify receipt that passes and checks out.", "seal"),
27
+ ("second-opinion", "Second Opinion", "A review from a different provider than the card's owner.", "eyes"),
28
+ ("second-draft", "Second Draft", "A card that got changes requested and was then approved.", "quill"),
29
+ ("many-voices", "Many Voices", "Cards finished by agents from two or more providers.", "chord"),
30
+ (
31
+ "clean-hands",
32
+ "Clean Hands",
33
+ f"One agent finishes {CLEAN_CARDS} cards with zero recorded lock violations.",
34
+ "hands",
35
+ ),
36
+ ("clean-run", "Clean Run", "A card closes on its first verify run.", "rune"),
37
+ ("peacekeeper", "Peacekeeper", f"{PEACE_DAYS} days of finished cards with no lock violation.", "shield"),
38
+ ("swift-blade", "Swift Blade", f"A card goes from claim to done within {SWIFT_MINUTES} minutes.", "blade"),
39
+ ("night-watch", "Night Watch", "A card is finished between midnight and 4 a.m. local time.", "moon"),
40
+ )
41
+
42
+
43
+ def evaluate(board):
44
+ agents = board.agents()
45
+ cards = {c["id"]: c for c in board.cards()}
46
+
47
+ def provider(name):
48
+ return (agents.get(name) or {}).get("provider", "") or ""
49
+
50
+ owners = {}
51
+ earned = {}
52
+ violations = {}
53
+ done_by = {}
54
+ changed = set()
55
+ providers_done = {}
56
+ first_verify = {}
57
+ claimed_at = {}
58
+ since_peace = None
59
+
60
+ def win(key, r, by, detail):
61
+ if key not in earned:
62
+ earned[key] = {"at": r.get("ts", ""), "seq": r.get("seq", 0), "by": by, "detail": detail}
63
+
64
+ for r in records(board):
65
+ kind, task = r.get("kind"), r.get("taskId", "") or ""
66
+ sender = (r.get("from") or {}).get("agent", "")
67
+ data = data_of(r)
68
+ when = clock.parse(r.get("ts", ""))
69
+ if since_peace is None and when:
70
+ since_peace = when
71
+ if kind == "status" and data.get("a2a_state") == "working" and "fence" in data:
72
+ owners[task] = sender
73
+ if when:
74
+ claimed_at[task] = when
75
+ if data.get("event") == "lock_violation":
76
+ who = data.get("agent") or sender
77
+ violations[who] = violations.get(who, 0) + 1
78
+ since_peace = when or since_peace
79
+ owner = owners.get(task) or cards.get(task, {}).get("owner", "")
80
+ if kind == "status" and data.get("lane") == "done" and task:
81
+ win("first-light", r, owner, task)
82
+ if first_verify.get(task) is True:
83
+ win("clean-run", r, owner, task)
84
+ start = claimed_at.get(task)
85
+ if when and start and when - start <= datetime.timedelta(minutes=SWIFT_MINUTES):
86
+ win("swift-blade", r, owner, task)
87
+ if when and when.astimezone().hour < 4:
88
+ win("night-watch", r, owner, task)
89
+ if when and since_peace and when - since_peace >= datetime.timedelta(days=PEACE_DAYS):
90
+ win("peacekeeper", r, owner, f"{PEACE_DAYS} days without a lock violation")
91
+ if owner:
92
+ done_by[owner] = done_by.get(owner, 0) + 1
93
+ if done_by[owner] >= CLEAN_CARDS and not violations.get(owner):
94
+ win("clean-hands", r, owner, f"{done_by[owner]} cards")
95
+ if provider(owner):
96
+ providers_done.setdefault(provider(owner), owner)
97
+ if len(providers_done) >= 2:
98
+ win("many-voices", r, owner, ", ".join(sorted(providers_done)))
99
+ if kind == "verify" and task and task not in first_verify:
100
+ first_verify[task] = bool(data.get("passed"))
101
+ if kind == "verify" and data.get("passed") and task in cards and receipts.valid(board, cards[task], data):
102
+ win("first-seal", r, owner, task)
103
+ if kind == "review" and data.get("verdict") in ("approve", "changes") and task:
104
+ if owner and provider(sender) and provider(owner) and provider(sender) != provider(owner):
105
+ win("second-opinion", r, sender, f"{provider(sender)} reviewed {provider(owner)} on {task}")
106
+ if data["verdict"] == "changes":
107
+ changed.add(task)
108
+ elif task in changed:
109
+ win("second-draft", r, owner, task)
110
+ if kind == "status" and text_of(r).endswith("reopened"):
111
+ changed.discard(task)
112
+ clean = [n for n, d in done_by.items() if not violations.get(n)]
113
+ best = max([done_by[n] for n in clean] + [0])
114
+ out = []
115
+ for key, name, desc, art in CATALOG:
116
+ row = {"id": key, "name": name, "description": desc, "art": art, "earned": key in earned}
117
+ row.update(earned.get(key, {"at": "", "seq": 0, "by": "", "detail": ""}))
118
+ if key == "clean-hands":
119
+ row["progress"] = {"value": min(best, CLEAN_CARDS), "goal": CLEAN_CARDS}
120
+ elif key == "many-voices":
121
+ row["progress"] = {"value": min(len(providers_done), 2), "goal": 2}
122
+ out.append(row)
123
+ titles = {}
124
+ for key, title in TITLES.items():
125
+ who = earned.get(key, {}).get("by")
126
+ if who and who not in titles:
127
+ titles[who] = title
128
+ return {
129
+ "titles": titles,
130
+ "incomplete_history": history(board)[2],
131
+ "last": history(board)[1],
132
+ "achievements": out,
133
+ "earned": sum(1 for a in out if a["earned"]),
134
+ "total": len(out),
135
+ }
136
+
137
+
138
+ def payload(board, query):
139
+ return evaluate(board)
140
+
141
+
142
+ def _handle(ctx, args):
143
+ result = evaluate(ctx.board)
144
+ lines = [f"{result['earned']}/{result['total']} earned"]
145
+ for a in result["achievements"]:
146
+ mark = "x" if a["earned"] else " "
147
+ extra = f" by {a['by']} ({a['detail']})" if a["earned"] and a["by"] else ""
148
+ prog = a.get("progress")
149
+ if not a["earned"] and prog:
150
+ extra = f" ({prog['value']}/{prog['goal']})"
151
+ lines.append(f"[{mark}] {a['name']}: {a['description']}{extra}")
152
+ return result, "\n".join(lines)
153
+
154
+
155
+ def register_cli(add_command):
156
+ add_command("achievements", "achievements earned from recorded board events", None, _handle)
raidlock/approvals.py ADDED
@@ -0,0 +1,237 @@
1
+ """Approval inbox: one list of everything waiting for a human, each item with copy-ready commands.
2
+
3
+ Items are derived from board state on every read, so an item resolves itself when the state that raised it changes:
4
+ a card that passes its done gate is ready to close, a done card whose runner patch still applies is a pending
5
+ apply, a review card short of approvals or a receipt is a pending review, and blocked cards, undecided duels,
6
+ stuck resurrections and pending lessons are lead decisions. Reading the inbox never changes the board."""
7
+
8
+ import contextvars
9
+ import os
10
+ import re
11
+
12
+ from . import duel
13
+ from .core.errors import Precondition, RaidlockError
14
+ from .core.hints import lead_cmd, lead_secret_src, quote_path, short_path
15
+ from .runners import policy
16
+ from .runners import workspace as W
17
+ from .runners.runner import latest_patch
18
+
19
+ KINDS = ("close", "apply", "review", "duel", "decision", "lore")
20
+ APPLY_CHECKS = 10
21
+ TEXT_CAP = 300
22
+
23
+
24
+ _short = short_path
25
+ _q = quote_path
26
+
27
+
28
+ WEB_LEAD_SRC = '"$LEAD_SECRET_FILE"'
29
+ _LEAD_SRC = contextvars.ContextVar("lead_secret_src", default=None)
30
+
31
+
32
+ def web_lead_src(board):
33
+ """What the web board shows after --lead-secret: `keychain` or the real secret file path when it is under the
34
+ home folder (shown as ~/...), otherwise the LEAD_SECRET_FILE shell variable the getting-started guide sets up."""
35
+ src = lead_secret_src(board)
36
+ return src if src == "keychain" or src.startswith("~/") else WEB_LEAD_SRC
37
+
38
+
39
+ def _lead(board, rest):
40
+ secret = _LEAD_SRC.get()
41
+ return f"raid --lead-secret {secret} {rest}" if secret else lead_cmd(board, rest, prog="raid")
42
+
43
+
44
+ def _clip(text):
45
+ text = " ".join(str(text or "").split())
46
+ return text if len(text) <= TEXT_CAP else text[: TEXT_CAP - 3] + "..."
47
+
48
+
49
+ def _item(kind, card, reason, commands, **extra):
50
+ cid = card["id"] if card else extra.pop("ref", "")
51
+ return {
52
+ "id": f"{kind}:{cid}",
53
+ "kind": kind,
54
+ "card": cid if card else "",
55
+ "title": _clip(card.get("title", "")) if card else _clip(extra.pop("title", "")),
56
+ "reason": _clip(reason),
57
+ "since": (card or {}).get("updated", "") or extra.pop("since", ""),
58
+ "commands": commands,
59
+ **extra,
60
+ }
61
+
62
+
63
+ def _review_items(board, card):
64
+ cid = card["id"]
65
+ try:
66
+ board._done_gate(card)
67
+ except Precondition as e:
68
+ commands = []
69
+ if "verification" in e.message or "verify" in e.message:
70
+ if card.get("verify_cmd"):
71
+ commands.append(_lead(board, f"verify {_q(cid)}"))
72
+ else:
73
+ commands.append(_lead(board, f'edit {_q(cid)} --verify-cmd "<check>"'))
74
+ if "review" in e.message:
75
+ commands += [
76
+ _lead(board, f"delegate {_q(cid)} --to <runtime> --as reviewer"),
77
+ f"raid review {_q(cid)} --verdict approve # as a reviewer agent",
78
+ ]
79
+ commands.append(f"raid show {_q(cid)}")
80
+ return [_item("review", card, e.message, commands)]
81
+ except RaidlockError as e:
82
+ return [_item("review", card, e.message, [f"raid show {_q(cid)}"])]
83
+ reason = "passes the done gate"
84
+ approved = board.approvals(card)
85
+ if approved:
86
+ reason += "; approved by " + ", ".join(approved)
87
+ return [_item("close", card, reason, [_lead(board, f"done {_q(cid)}")])]
88
+
89
+
90
+ def _apply_item(board, card):
91
+ folder, _ = latest_patch(board, card["id"])
92
+ if not folder:
93
+ return None
94
+ patch = os.path.join(folder, "changes.patch")
95
+ try:
96
+ target = policy.target(board.workspace, board.dir, card["locks"])
97
+ W.git(target["path"], "apply", "--check", patch)
98
+ except (RaidlockError, OSError):
99
+ return None
100
+ repo = os.path.relpath(target["path"], board.workspace)
101
+ return _item(
102
+ "apply",
103
+ card,
104
+ f"done; its runner patch is not in {repo} yet",
105
+ [f"git -C {_q(repo)} apply --index {_q(patch)}", f"git -C {_q(repo)} diff --cached --stat"],
106
+ patch=_short(patch),
107
+ )
108
+
109
+
110
+ def _blocked_item(board, card):
111
+ cid = card["id"]
112
+ last = next((n["text"] for n in reversed(card.get("notes", []))), "")
113
+ commands = [
114
+ f"raid show {_q(cid)}",
115
+ _lead(board, f"resume {_q(cid)}"),
116
+ _lead(board, f"resurrect {_q(cid)} --dry-run"),
117
+ ]
118
+ if card.get("owner"):
119
+ commands.append(f"raid claim {_q(cid)} --take-over # as another worker")
120
+ last = last[len("-> blocked") :].strip(" ()") if last.startswith("-> blocked") else last
121
+ return _item("decision", card, f"blocked: {last}" if last else "blocked", commands)
122
+
123
+
124
+ def _duel_item(board, card):
125
+ record = duel.load(board, card["id"])
126
+ if not record or record.get("status") != "decide":
127
+ return None
128
+ verified = [s for s in record.get("sides", []) if duel.passing(board, card["id"], s)]
129
+ commands = [_lead(board, f"duel {_q(card['id'])}")]
130
+ commands += [_lead(board, f"duel {_q(card['id'])} --winner {_q(s['label'])}") for s in verified]
131
+ reason = (
132
+ "duel finished; verified: " + ", ".join(f"{s['label']} ({s['runtime']})" for s in verified)
133
+ if verified
134
+ else "duel finished; no contestant has a passing signed receipt"
135
+ )
136
+ return _item("duel", card, reason, commands)
137
+
138
+
139
+ def _lore_items(board):
140
+ try:
141
+ from .lore import Vault
142
+
143
+ rows = Vault.for_board(board).pending()
144
+ except (ImportError, RaidlockError, OSError):
145
+ return []
146
+ return [
147
+ _item(
148
+ "lore",
149
+ None,
150
+ "lesson waiting for approval",
151
+ [_lead(board, f"lore approve {_q(x['id'])}"), _lead(board, f"lore reject {_q(x['id'])}")],
152
+ ref=x["id"],
153
+ title=x.get("text", ""),
154
+ since=x.get("created", ""),
155
+ )
156
+ for x in rows
157
+ if isinstance(x.get("id"), str) and re.fullmatch(r"[a-f0-9]{12}", x["id"])
158
+ ]
159
+
160
+
161
+ def collect(board, check_apply=True, secret=None):
162
+ """All pending items, most actionable first. Read-only. Lead commands carry the lead secret file's path, or
163
+ `secret` (the web board passes web_lead_src)."""
164
+ token = _LEAD_SRC.set(secret)
165
+ try:
166
+ return _collect(board, check_apply)
167
+ finally:
168
+ _LEAD_SRC.reset(token)
169
+
170
+
171
+ def _collect(board, check_apply):
172
+ cards = board.cards()
173
+ items = []
174
+ duels = {}
175
+ for card in cards:
176
+ try:
177
+ found = _duel_item(board, card)
178
+ except RaidlockError:
179
+ found = None
180
+ if found:
181
+ duels[card["id"]] = found
182
+ for card in cards:
183
+ if card["lane"] == "review":
184
+ items += _review_items(board, card)
185
+ elif card["lane"] == "blocked" and card["id"] not in duels:
186
+ items.append(_blocked_item(board, card))
187
+ if check_apply:
188
+ done = sorted(
189
+ (c for c in cards if c["lane"] == "done" and not c.get("evidence")),
190
+ key=lambda c: c.get("updated", ""),
191
+ reverse=True,
192
+ )
193
+ for card in done[:APPLY_CHECKS]:
194
+ found = _apply_item(board, card)
195
+ if found:
196
+ items.append(found)
197
+ items += list(duels.values())
198
+ items += _lore_items(board)
199
+ order = {k: i for i, k in enumerate(KINDS)}
200
+ items.sort(key=lambda x: (order[x["kind"]], x["since"], x["id"]))
201
+ counts = {k: sum(1 for x in items if x["kind"] == k) for k in KINDS}
202
+ return {"workspace": board.name, "items": items, "counts": counts, "total": len(items)}
203
+
204
+
205
+ def render(result):
206
+ if not result["items"]:
207
+ return "Nothing needs you right now."
208
+ urgent = sum(x["kind"] != "lore" for x in result["items"])
209
+ lines = [f"{urgent} item(s) need you in {result['workspace']}:"]
210
+ lore_heading = False
211
+ for x in result["items"]:
212
+ if x["kind"] == "lore" and not lore_heading:
213
+ lines.append("Lore inbox (low priority):")
214
+ lore_heading = True
215
+ label = f"{x['card']}: {x['title']}" if x["card"] else x["title"]
216
+ lines.append(f"[{x['kind']}] {label}")
217
+ lines.append(f" {x['reason']}")
218
+ lines += [f" $ {c}" for c in x["commands"]]
219
+ lines.append("Commands with --lead-secret run only in the lead session; never give the secret to an agent.")
220
+ return "\n".join(lines)
221
+
222
+
223
+ def _setup(p):
224
+ p.add_argument("--kind", default=None, choices=KINDS, help="only this kind of item")
225
+ p.add_argument("--no-apply-check", action="store_true", help="skip the git apply checks for done cards")
226
+
227
+
228
+ def _handler(ctx, a):
229
+ r = collect(ctx.board, check_apply=not a.no_apply_check)
230
+ if a.kind:
231
+ r = {**r, "items": [x for x in r["items"] if x["kind"] == a.kind]}
232
+ r["total"] = len(r["items"])
233
+ return r, render(r)
234
+
235
+
236
+ def register_cli(add_command):
237
+ add_command("approvals", "everything waiting for a human, with copy-ready commands", _setup, _handler)