raidlock 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. raidlock/__init__.py +11 -0
  2. raidlock/__main__.py +5 -0
  3. raidlock/_python.py +58 -0
  4. raidlock/achievements.py +156 -0
  5. raidlock/approvals.py +237 -0
  6. raidlock/bench.py +286 -0
  7. raidlock/bridge/__init__.py +186 -0
  8. raidlock/budgets.py +178 -0
  9. raidlock/cli.py +2153 -0
  10. raidlock/commands_install.py +114 -0
  11. raidlock/core/__init__.py +26 -0
  12. raidlock/core/advisories.py +16 -0
  13. raidlock/core/billing.py +54 -0
  14. raidlock/core/board.py +2251 -0
  15. raidlock/core/brief.py +111 -0
  16. raidlock/core/clock.py +40 -0
  17. raidlock/core/cost.py +198 -0
  18. raidlock/core/db.py +608 -0
  19. raidlock/core/errors.py +52 -0
  20. raidlock/core/exe.py +6 -0
  21. raidlock/core/hints.py +32 -0
  22. raidlock/core/identity.py +285 -0
  23. raidlock/core/lead.py +758 -0
  24. raidlock/core/lead_env.py +23 -0
  25. raidlock/core/lead_hook.py +42 -0
  26. raidlock/core/leases.py +19 -0
  27. raidlock/core/lock.py +99 -0
  28. raidlock/core/messages.py +98 -0
  29. raidlock/core/migrate.py +579 -0
  30. raidlock/core/model.py +126 -0
  31. raidlock/core/models.py +109 -0
  32. raidlock/core/paths.py +61 -0
  33. raidlock/core/portable.py +53 -0
  34. raidlock/core/receipts.py +73 -0
  35. raidlock/core/registry.py +39 -0
  36. raidlock/core/repos.py +250 -0
  37. raidlock/core/safeio.py +201 -0
  38. raidlock/core/status.py +52 -0
  39. raidlock/core/store.py +107 -0
  40. raidlock/core/tokens.py +77 -0
  41. raidlock/core/transcript.py +208 -0
  42. raidlock/duel.py +371 -0
  43. raidlock/foresight.py +191 -0
  44. raidlock/frames.py +162 -0
  45. raidlock/gitguard.py +753 -0
  46. raidlock/guard.py +239 -0
  47. raidlock/hooks.py +1526 -0
  48. raidlock/infer.py +205 -0
  49. raidlock/limits.py +685 -0
  50. raidlock/lore/__init__.py +22 -0
  51. raidlock/lore/approval.py +145 -0
  52. raidlock/lore/capture.py +146 -0
  53. raidlock/lore/cli.py +109 -0
  54. raidlock/lore/distill.py +17 -0
  55. raidlock/lore/frontmatter.py +56 -0
  56. raidlock/lore/inject.py +127 -0
  57. raidlock/lore/vault.py +264 -0
  58. raidlock/mcp/__init__.py +34 -0
  59. raidlock/mcp/resources.py +72 -0
  60. raidlock/mcp/server.py +184 -0
  61. raidlock/mcp/tools.py +178 -0
  62. raidlock/notify.py +144 -0
  63. raidlock/party.py +239 -0
  64. raidlock/pr.py +575 -0
  65. raidlock/prsync.py +318 -0
  66. raidlock/recipes/README.md +34 -0
  67. raidlock/recipes/__init__.py +105 -0
  68. raidlock/recipes/docs-refresh.md +15 -0
  69. raidlock/recipes/multi-repo-refactor.md +15 -0
  70. raidlock/recipes/security-audit.md +27 -0
  71. raidlock/recipes/upgrade-deps.md +15 -0
  72. raidlock/reputation.py +194 -0
  73. raidlock/resume.py +329 -0
  74. raidlock/resurrect.py +419 -0
  75. raidlock/routing.py +419 -0
  76. raidlock/runners/__init__.py +16 -0
  77. raidlock/runners/base.py +341 -0
  78. raidlock/runners/brief.py +176 -0
  79. raidlock/runners/claude_code.py +121 -0
  80. raidlock/runners/cli.py +110 -0
  81. raidlock/runners/codex.py +114 -0
  82. raidlock/runners/gemini.py +64 -0
  83. raidlock/runners/isolation.py +391 -0
  84. raidlock/runners/policy.py +123 -0
  85. raidlock/runners/runner.py +921 -0
  86. raidlock/runners/shell.py +35 -0
  87. raidlock/runners/verifycache.py +93 -0
  88. raidlock/runners/workspace.py +376 -0
  89. raidlock/scan.py +357 -0
  90. raidlock/snapshot.py +151 -0
  91. raidlock/treasury.py +210 -0
  92. raidlock/tui.py +223 -0
  93. raidlock/web/__init__.py +5 -0
  94. raidlock/web/achievements/view.css +19 -0
  95. raidlock/web/achievements/view.js +58 -0
  96. raidlock/web/bosses/view.css +19 -0
  97. raidlock/web/bosses/view.js +46 -0
  98. raidlock/web/cli.py +90 -0
  99. raidlock/web/data.py +687 -0
  100. raidlock/web/export.py +153 -0
  101. raidlock/web/frames/view.css +25 -0
  102. raidlock/web/frames/view.js +63 -0
  103. raidlock/web/graph/__init__.py +142 -0
  104. raidlock/web/graph/view.css +33 -0
  105. raidlock/web/graph/view.js +136 -0
  106. raidlock/web/raidmap/__init__.py +205 -0
  107. raidlock/web/raidmap/view.css +15 -0
  108. raidlock/web/raidmap/view.js +47 -0
  109. raidlock/web/replay/__init__.py +258 -0
  110. raidlock/web/replay/view.css +13 -0
  111. raidlock/web/replay/view.js +127 -0
  112. raidlock/web/server.py +292 -0
  113. raidlock/web/static/app.css +851 -0
  114. raidlock/web/static/app.js +2102 -0
  115. raidlock/web/static/fonts/OFL.txt +93 -0
  116. raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
  117. raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
  118. raidlock/web/static/icon-32.png +0 -0
  119. raidlock/web/static/icon-raid.svg +1 -0
  120. raidlock/web/static/icon.svg +1 -0
  121. raidlock/web/static/index.html +122 -0
  122. raidlock-0.1.0.dist-info/METADATA +186 -0
  123. raidlock-0.1.0.dist-info/RECORD +126 -0
  124. raidlock-0.1.0.dist-info/WHEEL +4 -0
  125. raidlock-0.1.0.dist-info/entry_points.txt +34 -0
  126. raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,341 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import shlex
5
+ import signal
6
+ import subprocess
7
+ import threading
8
+ import time
9
+
10
+ from ..core import Usage
11
+ from ..core.portable import open_nofollow
12
+ from ..core.transcript import REDACTED, redact
13
+
14
+ BASE_ENV = (
15
+ "PATH",
16
+ "HOME",
17
+ "USER",
18
+ "LOGNAME",
19
+ "SHELL",
20
+ "LANG",
21
+ "LC_ALL",
22
+ "LC_CTYPE",
23
+ "TERM",
24
+ "TMPDIR",
25
+ "TEMP",
26
+ "TMP",
27
+ "SYSTEMROOT",
28
+ "COMSPEC",
29
+ "PATHEXT",
30
+ "APPDATA",
31
+ "LOCALAPPDATA",
32
+ "USERPROFILE",
33
+ "SSL_CERT_FILE",
34
+ "SSL_CERT_DIR",
35
+ "NODE_EXTRA_CA_CERTS",
36
+ "REQUESTS_CA_BUNDLE",
37
+ "HTTPS_PROXY",
38
+ "HTTP_PROXY",
39
+ "NO_PROXY",
40
+ "https_proxy",
41
+ "http_proxy",
42
+ "no_proxy",
43
+ )
44
+ SUMMARY_CAP = 500
45
+ SECRET_NAME = re.compile(r"(?i)(token|secret|password|passwd|api[_-]?key|access[_-]?key|credential|session[_-]?key)")
46
+ MIN_SECRET = 8
47
+
48
+
49
+ class Redactor:
50
+ """Masks the given literal secrets, then the common key and token patterns, in strings and JSON values."""
51
+
52
+ def __init__(self, secrets=()):
53
+ self.secrets = sorted(
54
+ {s for s in secrets if isinstance(s, str) and len(s.strip()) >= MIN_SECRET}, key=len, reverse=True
55
+ )
56
+
57
+ @classmethod
58
+ def for_env(cls, env, extra=()):
59
+ values = [v for k, v in (env or {}).items() if SECRET_NAME.search(k)]
60
+ return cls(list(values) + list(extra))
61
+
62
+ def text(self, value):
63
+ if not isinstance(value, str) or not value:
64
+ return value
65
+ for secret in self.secrets:
66
+ value = value.replace(secret, REDACTED)
67
+ return redact(value)
68
+
69
+ def stream(self, mask=True):
70
+ """A separate PEM-block state for each output stream."""
71
+ private = False
72
+
73
+ def clean(line):
74
+ nonlocal private
75
+ begin = re.search(r"-----BEGIN [A-Z ]*PRIVATE KEY-----", line)
76
+ end = re.search(r"-----END [A-Z ]*PRIVATE KEY-----", line)
77
+ if private:
78
+ if end:
79
+ private = False
80
+ return REDACTED + self.text(line[end.end() :])
81
+ return REDACTED + ("\n" if line.endswith("\n") else "")
82
+ if begin and not end:
83
+ private = True
84
+ return self.text(line) if mask or begin else line
85
+
86
+ return clean
87
+
88
+ def __call__(self, value):
89
+ if isinstance(value, str):
90
+ return self.text(value)
91
+ if isinstance(value, list):
92
+ return [self(v) for v in value]
93
+ if isinstance(value, tuple):
94
+ return tuple(self(v) for v in value)
95
+ if isinstance(value, dict):
96
+ return {
97
+ self(k) if isinstance(k, str) else k: REDACTED
98
+ if isinstance(k, str) and SECRET_NAME.search(k) and isinstance(v, str)
99
+ else self(v)
100
+ for k, v in value.items()
101
+ }
102
+ return value
103
+
104
+
105
+ class Adapter:
106
+ """One external runtime. Subclasses set the identity fields and implement `command` and `parse`."""
107
+
108
+ prompt_stdin = True
109
+ name = ""
110
+ runtime = ""
111
+ provider = ""
112
+ binary = ""
113
+ env_keys = ()
114
+ api_keys = ()
115
+ sandboxes = ("read-only", "workspace-write")
116
+
117
+ def executable(self, env=None):
118
+ env = os.environ if env is None else env
119
+ override = env.get(f"RAIDLOCK_{self.name.upper()}_BIN", "")
120
+ return shlex.split(override) if override else [self.binary]
121
+
122
+ def child_env(self, env=None):
123
+ env = os.environ if env is None else env
124
+ keep = BASE_ENV + tuple(self.env_keys) + tuple(self.api_keys)
125
+ out = {k: env[k] for k in keep if k in env}
126
+ out.update(NO_COLOR="1", CI="1")
127
+ return out
128
+
129
+ def prepare(self, run):
130
+ return {}
131
+
132
+ def command(self, run):
133
+ raise NotImplementedError
134
+
135
+ def parse(self, obj):
136
+ return []
137
+
138
+ def final(self, run, collected):
139
+ return collected.structured if collected.structured is not None else collected.final
140
+
141
+
142
+ def short(text, limit=SUMMARY_CAP):
143
+ text = " ".join(str(text or "").split())
144
+ return text if len(text) <= limit else text[: limit - 3] + "..."
145
+
146
+
147
+ class Collector:
148
+ """Accumulates normalized events of one run."""
149
+
150
+ def __init__(self):
151
+ self.messages = []
152
+ self.commands = []
153
+ self.usage = Usage()
154
+ self.usd = None
155
+ self.final = ""
156
+ self.structured = None
157
+ self.error = ""
158
+ self.model = ""
159
+ self.billing = ""
160
+ self.files = []
161
+ self.events = 0
162
+
163
+ def add(self, event):
164
+ kind = event.get("kind")
165
+ self.events += 1
166
+ if kind == "message" and event.get("text"):
167
+ self.messages.append(event["text"])
168
+ self.final = event["text"]
169
+ elif kind == "command":
170
+ self.commands.append({"command": event.get("command", ""), "exit": event.get("exit")})
171
+ elif kind == "usage":
172
+ u = event.get("usage") or Usage()
173
+ self.usage = Usage(**{k: getattr(self.usage, k) + getattr(u, k) for k in self.usage.as_dict()})
174
+ if event.get("usd") is not None:
175
+ self.usd = (self.usd or 0.0) + float(event["usd"])
176
+ elif kind == "result":
177
+ if event.get("text"):
178
+ self.final = event["text"]
179
+ if event.get("structured") is not None:
180
+ self.structured = event["structured"]
181
+ if event.get("error"):
182
+ self.error = event["error"]
183
+ elif kind == "error":
184
+ self.error = event.get("text") or "error"
185
+ elif kind == "model":
186
+ self.model = event.get("model") or self.model
187
+ self.billing = event.get("billing") or self.billing
188
+ elif kind == "files":
189
+ self.files.extend(event.get("paths", []))
190
+
191
+ def summary(self):
192
+ return {
193
+ "messages": len(self.messages),
194
+ "commands": len(self.commands),
195
+ "usage": self.usage.as_dict(),
196
+ "usd": self.usd,
197
+ "model": self.model,
198
+ "error": self.error,
199
+ "events": self.events,
200
+ }
201
+
202
+
203
+ def decode(line):
204
+ text = line.rstrip("\r\n")
205
+ if not text.strip():
206
+ return None
207
+ try:
208
+ obj = json.loads(text)
209
+ except ValueError:
210
+ return {"type": "raw", "text": text}
211
+ return obj if isinstance(obj, dict) else {"type": "raw", "text": text}
212
+
213
+
214
+ class Process:
215
+ """Runs one adapter command: prompt on stdin, redacted events to disk, parsed events to `on_event`."""
216
+
217
+ def __init__(self, argv, cwd, env, prompt, events_path, stderr_path, timeout, redactor=None, raw=False):
218
+ self.argv, self.cwd, self.env, self.prompt = argv, cwd, env, prompt
219
+ self.events_path, self.stderr_path, self.timeout = events_path, stderr_path, timeout
220
+ self.redactor = redactor or Redactor()
221
+ self.raw = raw
222
+ self.proc = None
223
+ self.killed = ""
224
+ self._lock = threading.Lock()
225
+
226
+ def stop(self, reason):
227
+ with self._lock:
228
+ if self.killed or self.proc is None or self.proc.poll() is not None:
229
+ return
230
+ self.killed = reason
231
+ _terminate(self.proc)
232
+
233
+ def _raw_path(self, path):
234
+ root, ext = os.path.splitext(path)
235
+ return root + ".raw" + ext
236
+
237
+ def _open_raw(self, path, mode):
238
+ if not self.raw:
239
+ return None
240
+ fd = open_nofollow(
241
+ self._raw_path(path), os.O_WRONLY | os.O_CREAT | (os.O_APPEND if mode == "a" else os.O_TRUNC), 0o600
242
+ )
243
+ return os.fdopen(fd, mode, encoding="utf-8")
244
+
245
+ def _drain_stderr(self, stream, err, raw):
246
+ clean = self.redactor.stream()
247
+ for line in stream:
248
+ err.write(clean(line))
249
+ err.flush()
250
+ if raw:
251
+ raw.write(line)
252
+ raw.flush()
253
+
254
+ def run(self, adapter, on_event):
255
+ posix = os.name == "posix"
256
+ raw_events = self._open_raw(self.events_path, "a")
257
+ raw_err = self._open_raw(self.stderr_path, "w")
258
+ try:
259
+ return self._run(adapter, on_event, posix, raw_events, raw_err)
260
+ finally:
261
+ for f in (raw_events, raw_err):
262
+ if f:
263
+ f.close()
264
+
265
+ def _run(self, adapter, on_event, posix, raw_events, raw_err):
266
+ with (
267
+ open(self.events_path, "a", encoding="utf-8") as events,
268
+ open(self.stderr_path, "w", encoding="utf-8") as err,
269
+ ):
270
+ try:
271
+ self.proc = subprocess.Popen(
272
+ self.argv,
273
+ cwd=self.cwd,
274
+ env=self.env,
275
+ stdin=subprocess.PIPE if adapter.prompt_stdin else subprocess.DEVNULL,
276
+ stdout=subprocess.PIPE,
277
+ stderr=subprocess.PIPE,
278
+ text=True,
279
+ encoding="utf-8",
280
+ errors="replace",
281
+ start_new_session=posix,
282
+ )
283
+ except OSError as e:
284
+ return None, f"could not start {self.argv[0]}: {e}"
285
+ drain = threading.Thread(target=self._drain_stderr, args=(self.proc.stderr, err, raw_err), daemon=True)
286
+ drain.start()
287
+ timer = threading.Timer(self.timeout, self.stop, args=(f"timed out after {self.timeout:g}s",))
288
+ timer.daemon = True
289
+ timer.start()
290
+ try:
291
+ try:
292
+ if adapter.prompt_stdin:
293
+ self.proc.stdin.write(self.prompt)
294
+ self.proc.stdin.close()
295
+ except (BrokenPipeError, OSError):
296
+ pass
297
+ clean = self.redactor.stream(mask=False)
298
+ for line in self.proc.stdout:
299
+ obj = decode(line)
300
+ if obj is None:
301
+ continue
302
+ if raw_events:
303
+ raw_events.write(json.dumps(obj, ensure_ascii=False) + "\n")
304
+ raw_events.flush()
305
+ normalized = decode(clean(line))
306
+ if normalized is None:
307
+ continue
308
+ obj = self.redactor(normalized)
309
+ events.write(json.dumps(obj, ensure_ascii=False) + "\n")
310
+ events.flush()
311
+ for event in adapter.parse(normalized):
312
+ on_event(self.redactor(event))
313
+ code = self.proc.wait(timeout=self.timeout)
314
+ except BaseException:
315
+ self.stop("interrupted")
316
+ raise
317
+ finally:
318
+ timer.cancel()
319
+ drain.join(timeout=10)
320
+ return code, self.killed
321
+
322
+
323
+ def _terminate(proc):
324
+ try:
325
+ if os.name == "posix":
326
+ os.killpg(proc.pid, signal.SIGTERM)
327
+ else:
328
+ proc.terminate()
329
+ except (ProcessLookupError, PermissionError, OSError):
330
+ return
331
+ deadline = time.monotonic() + 5
332
+ while proc.poll() is None and time.monotonic() < deadline:
333
+ time.sleep(0.05)
334
+ if proc.poll() is None:
335
+ try:
336
+ if os.name == "posix":
337
+ os.killpg(proc.pid, signal.SIGKILL)
338
+ else:
339
+ proc.kill()
340
+ except (ProcessLookupError, PermissionError, OSError):
341
+ pass
@@ -0,0 +1,176 @@
1
+ import json
2
+ import re
3
+
4
+ from ..core import redact
5
+ from ..core.brief import UNTRUSTED_HEADER, UNTRUSTED_RULE, quote_data
6
+ from ..lore.inject import HEADER as LORE_HEADER
7
+
8
+ HANDBACK_CAP = 4096
9
+ ITEM_CAP = 400
10
+ LIST_CAP = 20
11
+ STATUSES = ("done", "partial", "blocked")
12
+ VERDICTS = ("approve", "changes", "comment")
13
+
14
+ WORKER_CONTRACT = """# Worker contract (raidlock)
15
+
16
+ You are a worker agent. The raidlock runner delegated one card to you.
17
+ The current directory is a private clone of the repository. Nobody else works in it.
18
+
19
+ - Change only files under the allowed paths listed at the end. Lock paths in the card are workspace paths;
20
+ the allowed paths are the same locks relative to this clone. A change anywhere else fails the run.
21
+ - Never touch .github/workflows/, .claude/, .board/, *.pem, .env* or anything named secrets.
22
+ - Do not commit, push, merge, apply infrastructure, change settings or permissions, or read secrets.
23
+ The runner collects your uncommitted changes as a patch and does all board work (notes, moves) for you.
24
+ - Assume there is no network. Do not install packages. The runner runs the card's verify command after you finish.
25
+ - Keep the final answer short: cite paths and commands, never paste file contents.
26
+ - End with only the handback JSON object:
27
+ {"status": "done|partial|blocked", "evidence": ["..."], "paths_changed": ["..."], "open_questions": ["..."]}
28
+ """
29
+
30
+ REVIEWER_CONTRACT = """# Reviewer contract (raidlock)
31
+
32
+ You are a reviewer agent. The current directory is a private, read-only clone of the repository.
33
+ The changes under review are the difference between {base} and HEAD: run `git diff {base} HEAD` and read the files.
34
+
35
+ - Do not change files. Do not commit, push, merge, or read secrets.
36
+ - Check correctness first, then the card's acceptance criteria, then risk.
37
+ - Keep the final answer short: cite paths and lines, never paste file contents.
38
+ - End with only the review JSON object:
39
+ {{"verdict": "approve|changes|comment", "summary": "...", "findings": ["path:line problem"]}}
40
+ """
41
+
42
+ HANDBACK_SCHEMA = {
43
+ "type": "object",
44
+ "additionalProperties": False,
45
+ "required": ["status", "evidence", "paths_changed", "open_questions"],
46
+ "properties": {
47
+ "status": {"type": "string", "enum": list(STATUSES)},
48
+ "evidence": {"type": "array", "items": {"type": "string"}},
49
+ "paths_changed": {"type": "array", "items": {"type": "string"}},
50
+ "open_questions": {"type": "array", "items": {"type": "string"}},
51
+ },
52
+ }
53
+
54
+ REVIEW_SCHEMA = {
55
+ "type": "object",
56
+ "additionalProperties": False,
57
+ "required": ["verdict", "summary", "findings"],
58
+ "properties": {
59
+ "verdict": {"type": "string", "enum": list(VERDICTS)},
60
+ "summary": {"type": "string"},
61
+ "findings": {"type": "array", "items": {"type": "string"}},
62
+ },
63
+ }
64
+
65
+
66
+ def schema(role):
67
+ return REVIEW_SCHEMA if role == "reviewer" else HANDBACK_SCHEMA
68
+
69
+
70
+ def render(card_brief, role, rels, base):
71
+ """Prompt text: the stable contract first (cacheable), then the core card brief without its board-command loop
72
+ (the runner does the board work), then the allowed paths as they appear inside the clone."""
73
+ contract = REVIEWER_CONTRACT.format(base=base) if role == "reviewer" else WORKER_CONTRACT
74
+ allowed = ", ".join(r or "(the whole repository)" for r in rels)
75
+ return redact(contract + "\n" + _quoted_brief(card_brief) + "\nAllowed paths in this clone: " + allowed + "\n")
76
+
77
+
78
+ def _quoted_brief(text):
79
+ return (
80
+ UNTRUSTED_HEADER
81
+ + "\n"
82
+ + UNTRUSTED_RULE
83
+ + "\n"
84
+ + "\n".join(quote_data(line) for line in _without_loop(text).splitlines())
85
+ + "\n"
86
+ )
87
+
88
+
89
+ def _without_loop(text):
90
+ out, skip = [], False
91
+ for line in text.splitlines():
92
+ if line.startswith("## ") or line == LORE_HEADER:
93
+ skip = line.strip() == "## Loop"
94
+ if not skip:
95
+ out.append(line)
96
+ return "\n".join(out).rstrip() + "\n"
97
+
98
+
99
+ def _clip(text, limit):
100
+ text = " ".join(str(text).split())
101
+ return text if len(text) <= limit else text[: limit - 3] + "..."
102
+
103
+
104
+ def find_json(text):
105
+ """The last JSON object in `text` (whole text first, then fenced or brace-delimited candidates)."""
106
+ if isinstance(text, dict):
107
+ return text
108
+ if not text:
109
+ return None
110
+ text = text.strip()
111
+ try:
112
+ value = json.loads(text)
113
+ return value if isinstance(value, dict) else None
114
+ except ValueError:
115
+ pass
116
+ for match in reversed(list(re.finditer(r"\{", text))):
117
+ try:
118
+ value, _ = json.JSONDecoder().raw_decode(text[match.start() :])
119
+ except ValueError:
120
+ continue
121
+ if isinstance(value, dict):
122
+ return value
123
+ return None
124
+
125
+
126
+ def _strings(value):
127
+ if not isinstance(value, list):
128
+ value = [value] if value else []
129
+ return [_clip(redact(str(v)), ITEM_CAP) for v in value if str(v).strip()][:LIST_CAP]
130
+
131
+
132
+ def handback(raw, fallback_text=""):
133
+ """Normalize an agent handback to {status, evidence[], paths_changed[], open_questions[]}."""
134
+ data = find_json(raw) or {}
135
+ status = data.get("status") if data.get("status") in STATUSES else ""
136
+ out = {
137
+ "status": status or "partial",
138
+ "evidence": _strings(data.get("evidence")),
139
+ "paths_changed": _strings(data.get("paths_changed")),
140
+ "open_questions": _strings(data.get("open_questions")),
141
+ }
142
+ if not data and fallback_text:
143
+ out["evidence"] = [_clip(redact(fallback_text), ITEM_CAP)]
144
+ out["open_questions"] = ["the agent returned no handback JSON"]
145
+ return out
146
+
147
+
148
+ def review(raw, fallback_text=""):
149
+ """Normalize a review to {verdict, summary, findings[]}; anything unparseable is a comment."""
150
+ data = find_json(raw) or {}
151
+ verdict = data.get("verdict") if data.get("verdict") in VERDICTS else "comment"
152
+ summary = data.get("summary") or (fallback_text if not data else "") or "no summary"
153
+ return {
154
+ "verdict": verdict,
155
+ "summary": _clip(redact(str(summary)), 1500),
156
+ "findings": _strings(data.get("findings")),
157
+ }
158
+
159
+
160
+ def cap(obj, limit=HANDBACK_CAP):
161
+ """Trim list items (longest lists first) until the JSON encoding fits in `limit` bytes."""
162
+ obj = json.loads(json.dumps(obj))
163
+ while len(json.dumps(obj, ensure_ascii=False).encode("utf-8")) > limit:
164
+ lists = [(len(v), k) for k, v in obj.items() if isinstance(v, list) and v]
165
+ if not lists:
166
+ for k, v in obj.items():
167
+ if isinstance(v, str) and len(v) > 200:
168
+ obj[k] = v[: len(v) // 2] + "..."
169
+ break
170
+ else:
171
+ break
172
+ continue
173
+ _, key = max(lists)
174
+ obj[key].pop()
175
+ obj.setdefault("truncated", True)
176
+ return obj
@@ -0,0 +1,121 @@
1
+ import json
2
+ import re
3
+
4
+ from ..core import Usage
5
+ from ..core.errors import Precondition
6
+ from .base import Adapter, short
7
+
8
+ READ_TOOLS = (
9
+ "Read",
10
+ "Grep",
11
+ "Glob",
12
+ "Bash(git diff *)",
13
+ "Bash(git log *)",
14
+ "Bash(git show *)",
15
+ "Bash(git status *)",
16
+ "Bash(ls *)",
17
+ )
18
+ WRITE_TOOLS = READ_TOOLS + ("Edit(./**)", "Write(./**)", "NotebookEdit(./**)")
19
+ READ_NAMES = ("Read", "Grep", "Glob", "WebSearch", "WebFetch", "TodoWrite", "Bash")
20
+ WRITE_NAMES = ("Edit", "Write", "NotebookEdit")
21
+ _RULE = re.compile(r"^([A-Za-z]+)(?:\(([A-Za-z0-9 ._/*:@=+-]+)\))?$")
22
+
23
+
24
+ class ClaudeCode(Adapter):
25
+ name = "claude"
26
+ runtime = "claude-code"
27
+ provider = "anthropic"
28
+ binary = "claude"
29
+ env_keys = ("CLAUDE_CONFIG_DIR",)
30
+ api_keys = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")
31
+
32
+ def tools(self, sandbox, override=None):
33
+ if override:
34
+ return tuple(override)
35
+ return WRITE_TOOLS if sandbox == "workspace-write" else READ_TOOLS
36
+
37
+ def check_tools(self, value, sandbox):
38
+ """Validate a --tools override: known tool names, optional plain rule specs, Bash only with a command
39
+ prefix, write tools only in a workspace-write sandbox."""
40
+ items = [t.strip() for t in (value.split(",") if isinstance(value, str) else value) if t.strip()]
41
+ if not items:
42
+ raise Precondition("--tools is empty")
43
+ for item in items:
44
+ m = _RULE.match(item)
45
+ name = m.group(1) if m else ""
46
+ if name not in READ_NAMES + WRITE_NAMES:
47
+ raise Precondition(
48
+ f"--tools: {item!r} is not an allowed tool rule; allowed tools: "
49
+ + ", ".join(READ_NAMES + WRITE_NAMES),
50
+ tool=item,
51
+ )
52
+ if name == "Bash" and not m.group(2):
53
+ raise Precondition("--tools: Bash needs a command prefix, e.g. Bash(pytest *)", tool=item)
54
+ if name in WRITE_NAMES and sandbox != "workspace-write":
55
+ raise Precondition(f"--tools: {name} needs --sandbox workspace-write", tool=item)
56
+ return items
57
+
58
+ def command(self, run):
59
+ argv = self.executable(run.parent_env) + [
60
+ "-p",
61
+ "--output-format",
62
+ "stream-json",
63
+ "--verbose",
64
+ "--permission-mode",
65
+ "dontAsk",
66
+ "--safe-mode",
67
+ "--no-session-persistence",
68
+ "--setting-sources",
69
+ "",
70
+ "--strict-mcp-config",
71
+ "--allowedTools",
72
+ ",".join(self.tools(run.sandbox, getattr(run, "tools", None))),
73
+ "--json-schema",
74
+ json.dumps(run.schema, separators=(",", ":")),
75
+ ]
76
+ if run.model:
77
+ argv += ["--model", run.model]
78
+ return argv
79
+
80
+ def parse(self, obj):
81
+ kind = obj.get("type", "")
82
+ if kind == "system" and obj.get("subtype") == "init":
83
+ source = obj.get("apiKeySource")
84
+ billing = "" if source is None else ("plan" if source == "none" else "api")
85
+ return [{"kind": "model", "model": obj.get("model", ""), "billing": billing}]
86
+ if kind == "assistant":
87
+ out = []
88
+ for block in (obj.get("message") or {}).get("content") or []:
89
+ if block.get("type") == "text" and block.get("text"):
90
+ out.append({"kind": "message", "text": block["text"]})
91
+ elif block.get("type") == "tool_use" and block.get("name") != "StructuredOutput":
92
+ out.append({"kind": "command", "command": _tool(block), "exit": None})
93
+ return out
94
+ if kind == "result":
95
+ usage = {
96
+ "kind": "usage",
97
+ "usage": Usage.from_anthropic(obj.get("usage") or {}),
98
+ "usd": obj.get("total_cost_usd"),
99
+ }
100
+ error = ""
101
+ if obj.get("is_error") or (obj.get("subtype") and obj.get("subtype") != "success"):
102
+ error = short(obj.get("result") or obj.get("subtype") or "error", 300)
103
+ return [
104
+ usage,
105
+ {
106
+ "kind": "result",
107
+ "text": obj.get("result") or "",
108
+ "structured": obj.get("structured_output"),
109
+ "error": error,
110
+ },
111
+ ]
112
+ return []
113
+
114
+ def final(self, run, collected):
115
+ return collected.structured if collected.structured is not None else collected.final
116
+
117
+
118
+ def _tool(block):
119
+ data = block.get("input") or {}
120
+ detail = data.get("command") or data.get("file_path") or data.get("pattern") or data.get("path") or ""
121
+ return short(f"{block.get('name', 'tool')} {detail}".strip(), 300)