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/reputation.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from raidlock.core import Precondition, receipts
|
|
2
|
+
from raidlock.core.status import agent_status
|
|
3
|
+
|
|
4
|
+
REAPED = ("lease expired; reaped to blocked", "lease expired; reaped in blocked")
|
|
5
|
+
WEIGHTS = {"verify": 30, "review": 20, "rework": 5, "lock": 10, "stall": 5}
|
|
6
|
+
_COUNTS = (
|
|
7
|
+
"verify_passed",
|
|
8
|
+
"verify_failed",
|
|
9
|
+
"review_approve",
|
|
10
|
+
"review_changes",
|
|
11
|
+
"rework",
|
|
12
|
+
"lock_violations",
|
|
13
|
+
"lock_denials",
|
|
14
|
+
"stalls",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def reputation(board, by="agent"):
|
|
19
|
+
"""Return deterministic trust rows, with raw inputs, for UI, routing and CLI.
|
|
20
|
+
|
|
21
|
+
Score = clamp(50 + verify_weight * (2 * verify_pass_rate - 1)
|
|
22
|
+
+ review_weight * (2 * approval_rate - 1) - rework_weight * rework
|
|
23
|
+
- lock_weight * (lock_violations + lock_denials) - stall_weight * stalls,
|
|
24
|
+
0, 100), rounded to two decimals. Missing rates contribute zero; an
|
|
25
|
+
unobserved agent starts at 50. WEIGHTS contains all weights. Grouped rates
|
|
26
|
+
use pooled counts, never averages of agent scores. All recorded verdicts
|
|
27
|
+
count, including superseded reviews; comment verdicts do not count.
|
|
28
|
+
|
|
29
|
+
Transcript claim events establish historical ownership and model identity.
|
|
30
|
+
Verification belongs to the task owner, not the lead running the command;
|
|
31
|
+
reviews belong to their recipient, not their author. A changes verdict with
|
|
32
|
+
a held lease counts as rework. Card histories fill in only tasks without
|
|
33
|
+
corresponding transcript events, using the current owner when history is
|
|
34
|
+
unavailable. Unknown models/providers are grouped as "unknown".
|
|
35
|
+
|
|
36
|
+
Lock evidence must be a structured transcript data part with event equal
|
|
37
|
+
to "lock_violation", "lock_denied" or "stale_fence"; its agent defaults
|
|
38
|
+
to the sender. Core check_write denials are recorded as lock violations;
|
|
39
|
+
stale owner-command fences count as denials. Absent events do not prove
|
|
40
|
+
absence of violations. Stalls count recorded lease reaps plus current
|
|
41
|
+
stalled tasks, once per episode. Core reap records currently identify
|
|
42
|
+
expiry by their exact text. No board state is changed by this function.
|
|
43
|
+
"""
|
|
44
|
+
if by not in ("agent", "model", "provider"):
|
|
45
|
+
raise Precondition("reputation grouping must be agent, model or provider")
|
|
46
|
+
agents = board.agents()
|
|
47
|
+
cards = {card["id"]: card for card in board.cards() if card["lane"] != "cancelled"}
|
|
48
|
+
groups = {}
|
|
49
|
+
identities = {name: {"agent": name, **record} for name, record in agents.items()}
|
|
50
|
+
owners, held, stalled = {}, {}, set()
|
|
51
|
+
seen = set()
|
|
52
|
+
verified = set()
|
|
53
|
+
|
|
54
|
+
def identity(name):
|
|
55
|
+
return identities.get(name, {"agent": name})
|
|
56
|
+
|
|
57
|
+
def bucket(person):
|
|
58
|
+
key = person.get(by) or "unknown"
|
|
59
|
+
return groups.setdefault(key, dict.fromkeys(_COUNTS, 0))
|
|
60
|
+
|
|
61
|
+
def add(person, field):
|
|
62
|
+
if person.get("agent"):
|
|
63
|
+
bucket(person)[field] += 1
|
|
64
|
+
|
|
65
|
+
for person in identities.values():
|
|
66
|
+
bucket(person)
|
|
67
|
+
for record in board.transcript.read():
|
|
68
|
+
sender = record.get("from") or {}
|
|
69
|
+
name = sender.get("agent", "")
|
|
70
|
+
if name:
|
|
71
|
+
identities[name] = {**identity(name), **sender}
|
|
72
|
+
task = record.get("taskId", "")
|
|
73
|
+
if task and task not in cards:
|
|
74
|
+
continue
|
|
75
|
+
kind = record.get("kind")
|
|
76
|
+
data = _data(record)
|
|
77
|
+
text = "\n".join(p.get("text", "") for p in record.get("parts", []) if p.get("kind") == "text")
|
|
78
|
+
if kind == "status" and data.get("a2a_state") == "working" and "fence" in data:
|
|
79
|
+
owners[task] = dict(identity(name))
|
|
80
|
+
held[task] = True
|
|
81
|
+
stalled.discard(task)
|
|
82
|
+
owner = owners.get(task) or identity(cards.get(task, {}).get("owner", ""))
|
|
83
|
+
if (
|
|
84
|
+
kind == "verify"
|
|
85
|
+
and receipts.valid(board, cards.get(task, {"id": task}), data)
|
|
86
|
+
and data["signature"] not in verified
|
|
87
|
+
):
|
|
88
|
+
seen.add((task, kind))
|
|
89
|
+
verified.add(data["signature"])
|
|
90
|
+
add(owner, "verify_passed" if data["passed"] else "verify_failed")
|
|
91
|
+
if kind == "review" and data.get("verdict") in ("approve", "changes"):
|
|
92
|
+
seen.add((task, kind))
|
|
93
|
+
recipient = record.get("to") or owner.get("agent", "")
|
|
94
|
+
person = owner if recipient == owner.get("agent") else identity(recipient)
|
|
95
|
+
add(person, "review_" + data["verdict"])
|
|
96
|
+
if data["verdict"] == "changes" and held.get(task, True):
|
|
97
|
+
add(person, "rework")
|
|
98
|
+
if kind == "status" and text == "lease resumed after expiry":
|
|
99
|
+
stalled.discard(task)
|
|
100
|
+
held[task] = True
|
|
101
|
+
if kind == "status" and text in REAPED:
|
|
102
|
+
if task not in stalled:
|
|
103
|
+
add(owner, "stalls")
|
|
104
|
+
stalled.add(task)
|
|
105
|
+
held[task] = False
|
|
106
|
+
if kind == "status" and (
|
|
107
|
+
data.get("lane") in ("todo", "done") or text == "lease released" or data.get("a2a_state") == "submitted"
|
|
108
|
+
):
|
|
109
|
+
held[task] = False
|
|
110
|
+
event = data.get("event")
|
|
111
|
+
if event in ("lock_violation", "lock_denied", "stale_fence"):
|
|
112
|
+
add(identity(data.get("agent") or name), "lock_violations" if event == "lock_violation" else "lock_denials")
|
|
113
|
+
for task, card in cards.items():
|
|
114
|
+
owner = identity(card.get("owner", ""))
|
|
115
|
+
if (task, "verify") not in seen:
|
|
116
|
+
for result in card.get("verifications", []):
|
|
117
|
+
if receipts.valid(board, card, result) and result["signature"] not in verified:
|
|
118
|
+
verified.add(result["signature"])
|
|
119
|
+
add(owner, "verify_passed" if result["passed"] else "verify_failed")
|
|
120
|
+
if (task, "review") not in seen:
|
|
121
|
+
for result in card.get("reviews", []):
|
|
122
|
+
if result.get("verdict") in ("approve", "changes"):
|
|
123
|
+
add(owner, "review_" + result["verdict"])
|
|
124
|
+
if result["verdict"] == "changes":
|
|
125
|
+
add(owner, "rework")
|
|
126
|
+
leases = board.leases()
|
|
127
|
+
for task, card in cards.items():
|
|
128
|
+
name = card.get("owner", "")
|
|
129
|
+
status = agent_status(name, [card], leases)
|
|
130
|
+
if status["status"] == "stalled" and task not in stalled:
|
|
131
|
+
historical = owners.get(task)
|
|
132
|
+
add(historical if historical and historical.get("agent") == name else identity(name), "stalls")
|
|
133
|
+
rows = []
|
|
134
|
+
for key, inputs in sorted(groups.items()):
|
|
135
|
+
verifies = inputs["verify_passed"] + inputs["verify_failed"]
|
|
136
|
+
reviews = inputs["review_approve"] + inputs["review_changes"]
|
|
137
|
+
inputs["verify_pass_rate"] = inputs["verify_passed"] / verifies if verifies else None
|
|
138
|
+
inputs["approval_rate"] = inputs["review_approve"] / reviews if reviews else None
|
|
139
|
+
contributions = {
|
|
140
|
+
"verify": WEIGHTS["verify"] * (2 * inputs["verify_pass_rate"] - 1) if verifies else 0,
|
|
141
|
+
"review": WEIGHTS["review"] * (2 * inputs["approval_rate"] - 1) if reviews else 0,
|
|
142
|
+
"rework": -WEIGHTS["rework"] * inputs["rework"],
|
|
143
|
+
"lock": -WEIGHTS["lock"] * (inputs["lock_violations"] + inputs["lock_denials"]),
|
|
144
|
+
"stall": -WEIGHTS["stall"] * inputs["stalls"],
|
|
145
|
+
}
|
|
146
|
+
rows.append(
|
|
147
|
+
{
|
|
148
|
+
"key": key,
|
|
149
|
+
"score": round(max(0, min(100, 50 + sum(contributions.values()))), 2),
|
|
150
|
+
"inputs": inputs,
|
|
151
|
+
"contributions": contributions,
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
return {
|
|
155
|
+
"by": by,
|
|
156
|
+
"rows": rows,
|
|
157
|
+
"weights": dict(WEIGHTS),
|
|
158
|
+
"base_score": 50,
|
|
159
|
+
"formula": "round(clamp(base_score + sum(contributions), 0, 100), 2)",
|
|
160
|
+
"limitations": [
|
|
161
|
+
"Lock counts cover recorded structured events only.",
|
|
162
|
+
"Card-only histories use current ownership; rework uses changes verdicts.",
|
|
163
|
+
"Transcript evidence takes precedence per card and event type; missing logs can undercount.",
|
|
164
|
+
"Scores summarize recorded evidence; routing integration is not implemented here.",
|
|
165
|
+
],
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _data(record):
|
|
170
|
+
data = {}
|
|
171
|
+
parts = list(record.get("parts", []))
|
|
172
|
+
for artifact in record.get("artifacts", []):
|
|
173
|
+
parts.extend(artifact.get("parts", []))
|
|
174
|
+
for part in parts:
|
|
175
|
+
if part.get("kind") == "data" and isinstance(part.get("data"), dict):
|
|
176
|
+
data.update(part["data"])
|
|
177
|
+
return data
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _setup(parser):
|
|
181
|
+
parser.add_argument("--by", choices=("agent", "model", "provider"), default="agent")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _handle(ctx, args):
|
|
185
|
+
result = reputation(ctx.board, args.by)
|
|
186
|
+
lines = []
|
|
187
|
+
for row in result["rows"]:
|
|
188
|
+
inputs = ", ".join(f"{key}={value}" for key, value in row["inputs"].items())
|
|
189
|
+
lines.append(f"{row['key']}: {row['score']:.2f}/100 ({inputs})")
|
|
190
|
+
return result, "\n".join(lines) or "No reputation data."
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def register_cli(add_command):
|
|
194
|
+
add_command("reputation", "trust scores and their recorded inputs", _setup, _handle, mutating=False)
|
raidlock/resume.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Resume packets: everything a fresh agent needs to continue a card a dead or expired worker left behind.
|
|
2
|
+
|
|
3
|
+
A packet holds the card brief, the latest notes, the work so far as a validated patch, the open questions of the
|
|
4
|
+
last handback and the transcript tail, all redacted and capped by a token budget. Work is read from the newest
|
|
5
|
+
runner clone (tracked and untracked files) or from the checkout recorded on the lease, hashed into a lead-owned
|
|
6
|
+
object store; the worker's .git is never trusted. A patch that leaves the card's locks, touches a denied path or
|
|
7
|
+
trips the secret scan is reported as rejected and never offered for reuse."""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
|
|
12
|
+
from .core import clock, store
|
|
13
|
+
from .core import identity as I
|
|
14
|
+
from .core.brief import UNTRUSTED_RULE, quote_data
|
|
15
|
+
from .core.errors import Denied, Precondition, RaidlockError
|
|
16
|
+
from .core.tokens import estimate
|
|
17
|
+
from .core.transcript import redact
|
|
18
|
+
from .runners import policy
|
|
19
|
+
from .runners import workspace as W
|
|
20
|
+
from .runners.base import Redactor
|
|
21
|
+
from .runners.runner import run_numbers, runs_dir
|
|
22
|
+
from .scan import _parse, scan_patch
|
|
23
|
+
|
|
24
|
+
HANDOFF_PERCENT = 80
|
|
25
|
+
NOTES = 8
|
|
26
|
+
TRANSCRIPT = 20
|
|
27
|
+
PATHS_SHOWN = 50
|
|
28
|
+
DEFAULT_FACTOR = 3
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resume_dir(board, card_id):
|
|
32
|
+
return os.path.join(board.home, "resume", board.name, card_id)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _authorize(board, caller, card):
|
|
36
|
+
if caller.is_lead:
|
|
37
|
+
board._auth(caller)
|
|
38
|
+
return "lead"
|
|
39
|
+
if card.get("owner") and caller.name == card["owner"]:
|
|
40
|
+
return board._owner_or_lead(card, caller)
|
|
41
|
+
board._auth(caller, card["id"])
|
|
42
|
+
raise Denied(f"only the lead or the owner of {card['id']} reads its resume packet", card=card["id"])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_fill(card_id, fill, cont):
|
|
46
|
+
"""Refuse to continue at or past the handoff mark. Returns None when no fill was given."""
|
|
47
|
+
if fill is None:
|
|
48
|
+
return None
|
|
49
|
+
if isinstance(fill, bool) or not isinstance(fill, (int, float)) or not 0 <= fill <= 100:
|
|
50
|
+
raise Precondition("context fill must be a percentage from 0 to 100")
|
|
51
|
+
handoff = fill >= HANDOFF_PERCENT
|
|
52
|
+
if handoff and cont:
|
|
53
|
+
raise Precondition(
|
|
54
|
+
f"context fill {fill:g}% reached the {HANDOFF_PERCENT}% handoff mark: stop and hand {card_id} to a "
|
|
55
|
+
"fresh agent with its resume packet",
|
|
56
|
+
card=card_id,
|
|
57
|
+
hint=f"raidlock resume {card_id}",
|
|
58
|
+
)
|
|
59
|
+
return handoff
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _latest_run(board, card_id):
|
|
63
|
+
for n in reversed(run_numbers(board, card_id)):
|
|
64
|
+
folder = os.path.join(runs_dir(board, card_id), str(n))
|
|
65
|
+
if os.path.exists(os.path.join(folder, "discarded.json")):
|
|
66
|
+
continue
|
|
67
|
+
info = store.read_json(os.path.join(folder, "run.json"), default=None)
|
|
68
|
+
if not isinstance(info, dict):
|
|
69
|
+
info = store.read_json(os.path.join(folder, "started.json"), default=None)
|
|
70
|
+
if not isinstance(info, dict) or info.get("role") != "worker":
|
|
71
|
+
continue
|
|
72
|
+
return folder, info
|
|
73
|
+
return None, {}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _source(board, card):
|
|
77
|
+
"""Where the unfinished work lives: (kind, repo, base, worktree, run_dir, info)."""
|
|
78
|
+
target = policy.target(board.workspace, board.dir, card["locks"])
|
|
79
|
+
folder, info = _latest_run(board, card["id"])
|
|
80
|
+
if folder:
|
|
81
|
+
clone = os.path.join(folder, "repo")
|
|
82
|
+
if os.path.exists(os.path.join(folder, "changes.patch")):
|
|
83
|
+
return "accepted", target, info.get("base") or "", None, folder, info
|
|
84
|
+
if os.path.exists(os.path.join(folder, "rejected.patch")) or (info.get("check") or {}).get("scan"):
|
|
85
|
+
return "rejected", target, info.get("base") or "", None, folder, info
|
|
86
|
+
if os.path.isdir(clone):
|
|
87
|
+
return "clone", target, info.get("base") or "", clone, folder, info
|
|
88
|
+
lease = board.leases().get(card["id"]) or {}
|
|
89
|
+
checkout = (lease.get("checkout") or {}).get("path") or card.get("verification_checkout") or ""
|
|
90
|
+
if checkout and os.path.isdir(checkout):
|
|
91
|
+
try:
|
|
92
|
+
base = W.head(checkout)
|
|
93
|
+
except RaidlockError:
|
|
94
|
+
base = ""
|
|
95
|
+
return "checkout", target, base, checkout, None, {}
|
|
96
|
+
return "none", target, "", None, folder, info
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _audit(target, base, worktree, out_dir, redactor):
|
|
100
|
+
"""Hash the worktree against BASE into a lead-owned store; return (status, paths, patch path, problems)."""
|
|
101
|
+
if not base:
|
|
102
|
+
return "unknown", [], "", ["the base commit of the interrupted work is unknown"]
|
|
103
|
+
store_dir = os.path.join(out_dir, "lead.git")
|
|
104
|
+
staging = os.path.join(out_dir, "pending.patch")
|
|
105
|
+
try:
|
|
106
|
+
try:
|
|
107
|
+
paths, tree = W.audit(target["path"], base, worktree, store_dir)
|
|
108
|
+
except RaidlockError as e:
|
|
109
|
+
return "unknown", [], "", [redactor.text("could not read the interrupted work: " + e.message)]
|
|
110
|
+
if not paths:
|
|
111
|
+
return "empty", [], "", []
|
|
112
|
+
W.write_patch(store_dir, base, tree, staging)
|
|
113
|
+
with open(staging, "rb") as f:
|
|
114
|
+
data = f.read()
|
|
115
|
+
return _publish(data, target, out_dir, redactor)
|
|
116
|
+
finally:
|
|
117
|
+
shutil.rmtree(store_dir, ignore_errors=True)
|
|
118
|
+
if os.path.exists(staging):
|
|
119
|
+
os.remove(staging)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_patch(data, rels, ci, redactor):
|
|
123
|
+
paths = sorted(_parse(data.decode("utf-8", "surrogateescape"))[0])
|
|
124
|
+
found = policy.check(paths, rels, ci)
|
|
125
|
+
from .scan import patch_links
|
|
126
|
+
|
|
127
|
+
problems = []
|
|
128
|
+
bad_links = policy.check_links(patch_links(data.decode("utf-8", "surrogateescape")), rels, ci)
|
|
129
|
+
if bad_links:
|
|
130
|
+
problems.append("unsafe symlink targets: " + ", ".join(bad_links))
|
|
131
|
+
if found["outside"]:
|
|
132
|
+
problems.append("outside the card's locks: " + ", ".join(found["outside"][:20]))
|
|
133
|
+
if found["denied"]:
|
|
134
|
+
problems.append("denied paths: " + ", ".join(found["denied"][:20]))
|
|
135
|
+
if any(secret.encode() in data for secret in redactor.secrets):
|
|
136
|
+
problems.append("secret scan findings: known credential")
|
|
137
|
+
findings = scan_patch(data, rels=rels, ci=ci)
|
|
138
|
+
if findings:
|
|
139
|
+
problems.append("secret scan findings: " + ", ".join(sorted({item["rule"] for item in findings})))
|
|
140
|
+
return paths, problems
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _publish(data, target, out_dir, redactor):
|
|
144
|
+
paths, problems = validate_patch(data, target["rels"], target["ci"], redactor)
|
|
145
|
+
if problems:
|
|
146
|
+
return "rejected", paths, "", problems
|
|
147
|
+
patch = os.path.join(out_dir, "changes.patch")
|
|
148
|
+
with open(patch, "xb") as f:
|
|
149
|
+
os.chmod(patch, 0o600)
|
|
150
|
+
f.write(data)
|
|
151
|
+
return "accepted", paths, patch, []
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _secrets(board, caller, card):
|
|
155
|
+
found = [caller.lead_secret] if caller.lead_secret else []
|
|
156
|
+
folder = os.path.join(board.home, "tokens", board.uid)
|
|
157
|
+
names = [p[:-6] for p in os.listdir(folder) if p.endswith(".token")] if os.path.isdir(folder) else []
|
|
158
|
+
for name in names:
|
|
159
|
+
token = I.read_token(board.home, board.uid, name)
|
|
160
|
+
if token:
|
|
161
|
+
found.append(token)
|
|
162
|
+
for current in board.cards():
|
|
163
|
+
for fence in range(1, int(current.get("fence") or 0) + 1):
|
|
164
|
+
found.append(I.make_cap(token, name, current["id"], fence))
|
|
165
|
+
return found
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _fit(sections, cap):
|
|
169
|
+
"""Keep sections in priority order, trimming the first one that overflows and dropping the rest."""
|
|
170
|
+
out, used, kept, dropped = [], 0, [], []
|
|
171
|
+
for name, text in sections:
|
|
172
|
+
if not text.strip():
|
|
173
|
+
continue
|
|
174
|
+
cost = estimate(text + "\n")
|
|
175
|
+
if used + cost <= cap:
|
|
176
|
+
out.append(text)
|
|
177
|
+
used += cost
|
|
178
|
+
kept.append(name)
|
|
179
|
+
continue
|
|
180
|
+
room = (cap - used) * 4 - 40
|
|
181
|
+
if room > 200:
|
|
182
|
+
lines, size = [], 0
|
|
183
|
+
for line in text.splitlines():
|
|
184
|
+
if size + len(line) + 1 > room:
|
|
185
|
+
break
|
|
186
|
+
lines.append(line)
|
|
187
|
+
size += len(line) + 1
|
|
188
|
+
lines.append(f"[{name} cut to fit the {cap}-token budget]")
|
|
189
|
+
clipped = "\n".join(lines)
|
|
190
|
+
out.append(clipped)
|
|
191
|
+
used += estimate(clipped + "\n")
|
|
192
|
+
kept.append(name)
|
|
193
|
+
else:
|
|
194
|
+
dropped.append(name)
|
|
195
|
+
return "\n".join(out) + "\n", kept, dropped
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def build(board, caller, card_id, max_tokens=None, context_fill=None, cont=False):
|
|
199
|
+
with board.lock():
|
|
200
|
+
return _build(board, caller, card_id, max_tokens, context_fill, cont)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _build(board, caller, card_id, max_tokens=None, context_fill=None, cont=False):
|
|
204
|
+
"""Build, save and return a resume packet for `card_id`. Lead or owner (with its fence) only."""
|
|
205
|
+
card = board.card(card_id)
|
|
206
|
+
_authorize(board, caller, card)
|
|
207
|
+
handoff = check_fill(card_id, context_fill, cont)
|
|
208
|
+
if cont:
|
|
209
|
+
return {"card": card_id, "continue": True, "context_fill": context_fill, "handoff_at": HANDOFF_PERCENT}
|
|
210
|
+
cap = int(max_tokens or board.settings["brief_max_tokens"] * DEFAULT_FACTOR)
|
|
211
|
+
if cap < 200:
|
|
212
|
+
raise Precondition("a resume packet needs a budget of at least 200 tokens")
|
|
213
|
+
redactor = Redactor.for_env(os.environ, _secrets(board, caller, card))
|
|
214
|
+
numbers = (
|
|
215
|
+
[int(n) for n in os.listdir(resume_dir(board, card_id)) if n.isdigit()]
|
|
216
|
+
if os.path.isdir(resume_dir(board, card_id))
|
|
217
|
+
else []
|
|
218
|
+
)
|
|
219
|
+
out_dir = os.path.join(resume_dir(board, card_id), str(max(numbers, default=0) + 1))
|
|
220
|
+
os.makedirs(out_dir, mode=0o700)
|
|
221
|
+
kind, target, base, worktree, folder, info = _source(board, card)
|
|
222
|
+
problems, paths, patch = [], [], ""
|
|
223
|
+
if kind == "accepted":
|
|
224
|
+
with open(os.path.join(folder, "changes.patch"), "rb") as f:
|
|
225
|
+
data = f.read()
|
|
226
|
+
status, paths, patch, problems = _publish(data, target, out_dir, redactor)
|
|
227
|
+
elif kind == "rejected":
|
|
228
|
+
status, paths = "rejected", list(info.get("paths") or [])
|
|
229
|
+
problems = ["the last runner patch was rejected: " + str(info.get("reason") or "unsafe changes")[:300]]
|
|
230
|
+
elif kind in ("clone", "checkout"):
|
|
231
|
+
status, paths, patch, problems = _audit(target, base, worktree, out_dir, redactor)
|
|
232
|
+
else:
|
|
233
|
+
status = "none"
|
|
234
|
+
handback = store.read_json(os.path.join(folder, "handback.json"), default={}) if folder else {}
|
|
235
|
+
brief = board.brief(card_id, max_tokens=max(150, cap // 3))["text"]
|
|
236
|
+
header = [
|
|
237
|
+
f"# Resume packet: {card_id}",
|
|
238
|
+
f"lane {card['lane']} | previous owner {card.get('owner') or '-'} | base {base[:12] or 'unknown'}",
|
|
239
|
+
f"work so far: {status}" + (f", {len(paths)} path(s)" if paths else ""),
|
|
240
|
+
]
|
|
241
|
+
header += [f"- {p}" for p in paths[:PATHS_SHOWN]]
|
|
242
|
+
header += [f"problem: {p}" for p in problems]
|
|
243
|
+
notes = [f"- {n.get('ts', '')} {n.get('by', '')}: {n.get('text', '')}" for n in card.get("notes", [])[-NOTES:]]
|
|
244
|
+
remaining = [f"- {q}" for q in (handback or {}).get("open_questions") or []]
|
|
245
|
+
if card.get("verify_cmd") or card.get("verify"):
|
|
246
|
+
remaining.append(f"- checks: {card.get('verify_cmd') or card.get('verify')}")
|
|
247
|
+
diff = ""
|
|
248
|
+
if patch:
|
|
249
|
+
with open(patch, encoding="utf-8", errors="replace") as f:
|
|
250
|
+
diff = f.read()
|
|
251
|
+
tail = []
|
|
252
|
+
for rec in board.transcript.read(task=card_id, limit=TRANSCRIPT):
|
|
253
|
+
text = " ".join(p.get("text", "") for p in rec.get("parts", []) if p.get("kind") == "text")
|
|
254
|
+
who = (rec.get("from") or {}).get("agent", "")
|
|
255
|
+
tail.append(f"- {rec.get('ts', '')} {rec.get('kind', '')} {who}: {' '.join(text.split())[:300]}")
|
|
256
|
+
sections = [
|
|
257
|
+
("header", "\n".join(header)),
|
|
258
|
+
("brief", "## Card brief\n" + brief),
|
|
259
|
+
("notes", "## Latest notes\n" + "\n".join(notes) if notes else ""),
|
|
260
|
+
("remaining", "## Remaining work and checks\n" + "\n".join(remaining) if remaining else ""),
|
|
261
|
+
("diff", "## Work so far (validated patch)\n" + diff if diff else ""),
|
|
262
|
+
("transcript", "## Transcript tail\n" + "\n".join(tail) if tail else ""),
|
|
263
|
+
]
|
|
264
|
+
sections = [
|
|
265
|
+
(name, "\n".join(line if i == 0 else quote_data(line) for i, line in enumerate(text.splitlines())))
|
|
266
|
+
for name, text in sections
|
|
267
|
+
]
|
|
268
|
+
sections = [(name, redactor.text(redact(text))) for name, text in sections]
|
|
269
|
+
sections[0] = ("header", sections[0][1] + "\n" + UNTRUSTED_RULE)
|
|
270
|
+
text, kept, dropped = _fit(sections, cap)
|
|
271
|
+
packet = {
|
|
272
|
+
"card": card_id,
|
|
273
|
+
"created": clock.iso(),
|
|
274
|
+
"by": caller.name,
|
|
275
|
+
"status": status,
|
|
276
|
+
"source": kind,
|
|
277
|
+
"base": base,
|
|
278
|
+
"paths": paths,
|
|
279
|
+
"problems": problems,
|
|
280
|
+
"patch": patch,
|
|
281
|
+
"run_dir": folder or "",
|
|
282
|
+
"tokens": estimate(text),
|
|
283
|
+
"max_tokens": cap,
|
|
284
|
+
"sections": kept,
|
|
285
|
+
"dropped": dropped,
|
|
286
|
+
"handoff": bool(handoff),
|
|
287
|
+
"dir": out_dir,
|
|
288
|
+
}
|
|
289
|
+
store.atomic_write_text(os.path.join(out_dir, "packet.md"), text)
|
|
290
|
+
store.write_json(os.path.join(out_dir, "packet.json"), packet)
|
|
291
|
+
with board.lock():
|
|
292
|
+
board._record(
|
|
293
|
+
"status",
|
|
294
|
+
caller.name,
|
|
295
|
+
f"resume packet for {card_id}: {status}, ~{packet['tokens']} tokens",
|
|
296
|
+
task=card_id,
|
|
297
|
+
data={"event": "resume_packet", "status": status, "tokens": packet["tokens"], "paths": len(paths)},
|
|
298
|
+
)
|
|
299
|
+
return {**packet, "text": text}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _setup(p):
|
|
303
|
+
p.add_argument("card", help="card id")
|
|
304
|
+
p.add_argument("--max-tokens", type=int, default=None, help="packet budget (default: 3x brief_max_tokens)")
|
|
305
|
+
p.add_argument("--context-fill", type=float, default=None, help="the asking agent's context fill in percent")
|
|
306
|
+
p.add_argument(
|
|
307
|
+
"--continue",
|
|
308
|
+
dest="cont",
|
|
309
|
+
action="store_true",
|
|
310
|
+
help="ask whether to keep going; refused at or past the 80%% handoff mark",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _short(path):
|
|
315
|
+
home = os.path.expanduser("~")
|
|
316
|
+
return "~" + path[len(home) :] if path and path.startswith(home + os.sep) else path
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _handler(ctx, a):
|
|
320
|
+
r = build(ctx.board, ctx.caller(), a.card, max_tokens=a.max_tokens, context_fill=a.context_fill, cont=a.cont)
|
|
321
|
+
if a.cont:
|
|
322
|
+
return r, f"Keep going on {a.card}; hand off at {HANDOFF_PERCENT}% context fill."
|
|
323
|
+
lines = [r["text"].rstrip("\n"), ""]
|
|
324
|
+
lines.append(f"(~{r['tokens']}/{r['max_tokens']} tokens; work {r['status']}; packet {_short(r['dir'])}/packet.md)")
|
|
325
|
+
return r, "\n".join(lines)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def register_cli(add_command):
|
|
329
|
+
add_command("resume", "build a resume packet for a card a dead or expired worker left", _setup, _handler)
|