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.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/__init__.py
ADDED
raidlock/__main__.py
ADDED
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)
|
raidlock/achievements.py
ADDED
|
@@ -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)
|