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/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)