@martinffx/atelier 3.6.0 → 3.8.0
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.
- package/.codex-plugin/plugin.json +4 -2
- package/CHANGELOG.md +19 -0
- package/README.md +25 -20
- package/agents/keymaker.md +25 -17
- package/dist/atelier.js +1626 -940
- package/package.json +10 -11
- package/skills/atelier-orchestrator/SKILL.md +19 -2
- package/skills/atelier-setup/SKILL.md +10 -1
- package/skills/atelier-setup/references/issue-tracker.md +1 -1
- package/skills/code-retro/SKILL.md +146 -0
- package/skills/code-retro/agents/openai.yaml +5 -0
- package/skills/code-retro/references/report.md +35 -0
- package/skills/code-retro/references/transcripts.md +122 -0
- package/skills/code-retro/scripts/extract_sessions.py +800 -0
- package/skills/code-retro/scripts/test_extract_sessions.py +919 -0
- package/skills/code-subagents/SKILL.md +7 -4
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract the human-signal slice of agent session logs for one git branch.
|
|
3
|
+
|
|
4
|
+
Read-only on every log. Standard library only. Emits human turns, tool denials,
|
|
5
|
+
interrupts, question answers and a little nearby assistant text, each with its source
|
|
6
|
+
file and line. Ordinary tool output is left out. Log content is untrusted data: this
|
|
7
|
+
script never evaluates or follows it. Claude, Codex and OpenCode logs are read.
|
|
8
|
+
|
|
9
|
+
Verify mode (--verify <range-file> --line <n>) reads a quote on stdin and prints only
|
|
10
|
+
"match" or "no match", so a quote can be checked without reading the log.
|
|
11
|
+
|
|
12
|
+
See ../references/transcripts.md for the format facts this relies on.
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import glob
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import sqlite3
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
from collections import Counter
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from datetime import datetime, timedelta, timezone
|
|
27
|
+
|
|
28
|
+
HUMAN_MAX = 1200
|
|
29
|
+
ASSISTANT_MAX = 300
|
|
30
|
+
ANSWER_MAX = 500
|
|
31
|
+
TARGET_MAX = 120
|
|
32
|
+
DENIAL_MARKER = re.compile(r"the user said:\s*\n?", re.IGNORECASE)
|
|
33
|
+
DENIAL_BOILERPLATE = "The user doesn't want to proceed"
|
|
34
|
+
# origin.kind values known to be written by something other than the human
|
|
35
|
+
NON_HUMAN_KINDS = {"task-notification", "peer", "coordinator"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def trunc(text, limit, tail=False):
|
|
39
|
+
text = (text or "").strip()
|
|
40
|
+
if len(text) <= limit:
|
|
41
|
+
return text
|
|
42
|
+
extra = len(text) - limit
|
|
43
|
+
if tail:
|
|
44
|
+
return "[%d more chars]..." % extra + text[-limit:]
|
|
45
|
+
return text[:limit] + "...[%d more chars]" % extra
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def list_worktrees(repo):
|
|
49
|
+
"""[(path, branch or None)] for the repo's git worktrees, main one first (empty if git
|
|
50
|
+
is unavailable). A detached worktree has no branch."""
|
|
51
|
+
try:
|
|
52
|
+
out = subprocess.run(["git", "-C", repo, "worktree", "list", "--porcelain"],
|
|
53
|
+
capture_output=True, text=True, timeout=10).stdout
|
|
54
|
+
except (OSError, subprocess.SubprocessError):
|
|
55
|
+
return []
|
|
56
|
+
found = []
|
|
57
|
+
for line in out.splitlines():
|
|
58
|
+
if line.startswith("worktree "):
|
|
59
|
+
found.append([line[len("worktree "):], None])
|
|
60
|
+
elif line.startswith("branch ") and found:
|
|
61
|
+
found[-1][1] = line[len("branch "):].replace("refs/heads/", "", 1)
|
|
62
|
+
return [tuple(w) for w in found]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def under(path, roots):
|
|
66
|
+
return any(path == r or path.startswith(r.rstrip("/") + "/") for r in roots)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def encode_project_dir(path):
|
|
70
|
+
return re.sub(r"[^A-Za-z0-9]", "-", path)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def claude_project_dirs(root, roots):
|
|
74
|
+
"""Project dirs for the repo, its in-repo worktrees, and any listed worktree."""
|
|
75
|
+
if not os.path.isdir(root):
|
|
76
|
+
return []
|
|
77
|
+
enc = [encode_project_dir(p) for p in roots]
|
|
78
|
+
found = []
|
|
79
|
+
for name in sorted(os.listdir(root)):
|
|
80
|
+
full = os.path.join(root, name)
|
|
81
|
+
if not os.path.isdir(full):
|
|
82
|
+
continue
|
|
83
|
+
if any(name == e or name.startswith(e + "--") for e in enc):
|
|
84
|
+
found.append(full)
|
|
85
|
+
return found
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _json_obj(raw):
|
|
89
|
+
"""The JSON object in `raw`, or None for bad JSON and non-object values."""
|
|
90
|
+
try:
|
|
91
|
+
value = json.loads(raw)
|
|
92
|
+
except (TypeError, ValueError, RecursionError):
|
|
93
|
+
return None
|
|
94
|
+
return value if isinstance(value, dict) else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _clean(value):
|
|
98
|
+
return re.sub(r"[^\w.-]", "?", str(value))[:30]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _printable(text):
|
|
102
|
+
return re.sub(r"[\x00-\x1f\x7f-\x9f\u2028\u2029]", "?", text)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _warn_count(warnings, found, template):
|
|
106
|
+
"""Add a counted warning. `found` is a number (template takes the count) or a Counter
|
|
107
|
+
of names (template takes the count and the sorted names)."""
|
|
108
|
+
if isinstance(found, Counter):
|
|
109
|
+
if found:
|
|
110
|
+
warnings.append(template % (sum(found.values()), ", ".join(sorted(found))))
|
|
111
|
+
elif found:
|
|
112
|
+
warnings.append(template % found)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def read_jsonl(path):
|
|
116
|
+
"""Rows by line. Bad JSON and non-object rows keep their slot as None."""
|
|
117
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
118
|
+
return [_json_obj(line) for line in fh]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def short_target(inp):
|
|
122
|
+
if not isinstance(inp, dict):
|
|
123
|
+
return ""
|
|
124
|
+
for key in ("command", "file_path", "path", "filePath", "pattern", "description"):
|
|
125
|
+
if isinstance(inp.get(key), str):
|
|
126
|
+
return trunc(inp[key], TARGET_MAX)
|
|
127
|
+
return ""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Collector:
|
|
131
|
+
"""Builds records in line order, adding nearby assistant text before each event."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, fname):
|
|
134
|
+
self.fname = fname
|
|
135
|
+
self.records = []
|
|
136
|
+
self.pending_assistant = None
|
|
137
|
+
|
|
138
|
+
def assistant(self, line, text):
|
|
139
|
+
if text and text.strip():
|
|
140
|
+
self.pending_assistant = (line, text)
|
|
141
|
+
|
|
142
|
+
def event(self, kind, line, text, lead_in=False, **extra):
|
|
143
|
+
if self.pending_assistant:
|
|
144
|
+
aline, atext = self.pending_assistant
|
|
145
|
+
self.pending_assistant = None
|
|
146
|
+
self._add("assistant", aline, trunc(atext, ASSISTANT_MAX, tail=True), lead_in)
|
|
147
|
+
self._add(kind, line, text, lead_in, **extra)
|
|
148
|
+
|
|
149
|
+
def _add(self, kind, line, text, lead_in, **extra):
|
|
150
|
+
rec = {"kind": kind, "file": self.fname, "line": line, "text": text}
|
|
151
|
+
rec.update({k: v for k, v in extra.items() if v not in (None, "")})
|
|
152
|
+
if lead_in:
|
|
153
|
+
rec["lead_in"] = True
|
|
154
|
+
self.records.append(rec)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------- Claude
|
|
158
|
+
|
|
159
|
+
def _blocks(row):
|
|
160
|
+
msg = row.get("message")
|
|
161
|
+
content = msg.get("content") if isinstance(msg, dict) else None
|
|
162
|
+
if isinstance(content, str):
|
|
163
|
+
return [{"type": "text", "text": content}]
|
|
164
|
+
return [b for b in content if isinstance(b, dict)] if isinstance(content, list) else []
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _text_of(blocks):
|
|
168
|
+
return "\n".join(b.get("text", "") for b in blocks if b.get("type") == "text"
|
|
169
|
+
and isinstance(b.get("text"), str))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _branch(row):
|
|
173
|
+
b = row.get("gitBranch") if row else None
|
|
174
|
+
return b if isinstance(b, str) and b else None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def claude_ranges(rows, branch, base):
|
|
178
|
+
"""Return [(start, end, lead_in_start, borders_head)] using the per-line gitBranch."""
|
|
179
|
+
segs = [] # [branch, first_line]
|
|
180
|
+
for i, row in enumerate(rows, 1):
|
|
181
|
+
if not row or row.get("isSidechain"):
|
|
182
|
+
continue
|
|
183
|
+
b = _branch(row)
|
|
184
|
+
if b and (not segs or segs[-1][0] != b):
|
|
185
|
+
segs.append([b, i])
|
|
186
|
+
out = []
|
|
187
|
+
for n, (b, first) in enumerate(segs):
|
|
188
|
+
if b != branch:
|
|
189
|
+
continue
|
|
190
|
+
end = segs[n + 1][1] - 1 if n + 1 < len(segs) else len(rows)
|
|
191
|
+
lead = segs[n - 1][1] if n > 0 and segs[n - 1][0] == base else None
|
|
192
|
+
near = [segs[k][0] for k in (n - 1, n + 1) if 0 <= k < len(segs)]
|
|
193
|
+
out.append((first, end, lead, "HEAD" in near))
|
|
194
|
+
return out
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _claude_attachment(col, row, i, lead):
|
|
198
|
+
"""Human prompts queued while the agent was busy arrive as attachment rows."""
|
|
199
|
+
att = row.get("attachment")
|
|
200
|
+
if not isinstance(att, dict) or att.get("type") != "queued_command":
|
|
201
|
+
return
|
|
202
|
+
origin = att.get("origin")
|
|
203
|
+
if not isinstance(origin, dict) or origin.get("kind") != "human":
|
|
204
|
+
return
|
|
205
|
+
prompt = att.get("prompt")
|
|
206
|
+
if isinstance(prompt, str):
|
|
207
|
+
text = prompt
|
|
208
|
+
elif isinstance(prompt, list):
|
|
209
|
+
text = _text_of([b for b in prompt if isinstance(b, dict)])
|
|
210
|
+
else:
|
|
211
|
+
text = ""
|
|
212
|
+
text = text.strip()
|
|
213
|
+
if text and not text.startswith("<task-notification"):
|
|
214
|
+
col.event("human", i, trunc(text, HUMAN_MAX), lead)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _claude_records(rows, fname, start, end, lead_start, roots, stats):
|
|
218
|
+
names = {}
|
|
219
|
+
for row in rows:
|
|
220
|
+
if row and row.get("type") == "assistant":
|
|
221
|
+
for b in _blocks(row):
|
|
222
|
+
if b.get("type") == "tool_use" and isinstance(b.get("id"), str):
|
|
223
|
+
names[b["id"]] = (b.get("name"), b.get("input"))
|
|
224
|
+
col = Collector(fname)
|
|
225
|
+
lo = lead_start or start
|
|
226
|
+
for i in range(lo, end + 1):
|
|
227
|
+
row = rows[i - 1]
|
|
228
|
+
if not row or row.get("isSidechain"):
|
|
229
|
+
continue
|
|
230
|
+
cwd = row.get("cwd")
|
|
231
|
+
if isinstance(cwd, str) and cwd and not under(cwd, roots):
|
|
232
|
+
stats["cwd_skipped"] += 1
|
|
233
|
+
continue
|
|
234
|
+
lead = i < start
|
|
235
|
+
rtype = row.get("type")
|
|
236
|
+
if rtype == "assistant":
|
|
237
|
+
col.assistant(i, _text_of(_blocks(row)))
|
|
238
|
+
continue
|
|
239
|
+
if rtype == "attachment":
|
|
240
|
+
_claude_attachment(col, row, i, lead)
|
|
241
|
+
continue
|
|
242
|
+
if rtype != "user":
|
|
243
|
+
continue
|
|
244
|
+
blocks = _blocks(row)
|
|
245
|
+
results = [b for b in blocks if b.get("type") == "tool_result"]
|
|
246
|
+
if results:
|
|
247
|
+
_claude_result(col, row, results[0], names, i, lead)
|
|
248
|
+
continue
|
|
249
|
+
if row.get("isMeta") or row.get("isCompactSummary"):
|
|
250
|
+
continue
|
|
251
|
+
text = _text_of(blocks).strip()
|
|
252
|
+
if not text:
|
|
253
|
+
origin = row.get("origin")
|
|
254
|
+
if not isinstance(origin, dict) or origin.get("kind") == "human":
|
|
255
|
+
stats["no_text"] += 1
|
|
256
|
+
continue
|
|
257
|
+
if text.startswith("[Request interrupted by user"):
|
|
258
|
+
col.event("interrupt", i, text, lead)
|
|
259
|
+
continue
|
|
260
|
+
origin = row.get("origin")
|
|
261
|
+
if isinstance(origin, dict):
|
|
262
|
+
kind = origin.get("kind")
|
|
263
|
+
if kind != "human":
|
|
264
|
+
if kind not in NON_HUMAN_KINDS:
|
|
265
|
+
stats["unknown_kinds"][_clean(kind)] += 1
|
|
266
|
+
continue
|
|
267
|
+
elif text.startswith("<"):
|
|
268
|
+
continue
|
|
269
|
+
col.event("human", i, trunc(text, HUMAN_MAX), lead)
|
|
270
|
+
return col.records
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _claude_result(col, row, block, names, i, lead):
|
|
274
|
+
tid = block.get("tool_use_id")
|
|
275
|
+
name, inp = names.get(tid, (None, None)) if isinstance(tid, str) else (None, None)
|
|
276
|
+
tur = row.get("toolUseResult")
|
|
277
|
+
content = block.get("content")
|
|
278
|
+
if not isinstance(content, str):
|
|
279
|
+
content = _text_of([b for b in content if isinstance(b, dict)]) if isinstance(content, list) else ""
|
|
280
|
+
denied = row.get("toolDenialKind") == "user-rejected" or tur == "User rejected tool use"
|
|
281
|
+
if denied:
|
|
282
|
+
m = DENIAL_MARKER.search(content)
|
|
283
|
+
if m:
|
|
284
|
+
text = content[m.end():].strip()
|
|
285
|
+
elif content.startswith(DENIAL_BOILERPLATE):
|
|
286
|
+
text = ""
|
|
287
|
+
else:
|
|
288
|
+
text = content
|
|
289
|
+
col.event("denial", i, trunc(text, HUMAN_MAX), lead, tool=name, target=short_target(inp))
|
|
290
|
+
elif name == "AskUserQuestion" and isinstance(tur, dict):
|
|
291
|
+
parts = []
|
|
292
|
+
notes = tur.get("annotations") if isinstance(tur.get("annotations"), dict) else {}
|
|
293
|
+
answers = tur.get("answers") if isinstance(tur.get("answers"), dict) else {}
|
|
294
|
+
for q, a in answers.items():
|
|
295
|
+
piece = "%s => %s" % (trunc(str(q), 120), a)
|
|
296
|
+
note = notes[q].get("notes") if isinstance(notes.get(q), dict) else None
|
|
297
|
+
if note:
|
|
298
|
+
piece += " (note: %s)" % note
|
|
299
|
+
parts.append(piece)
|
|
300
|
+
col.event("answer", i, trunc("; ".join(parts), ANSWER_MAX), lead)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def extract_claude(root, roots, branch, base):
|
|
304
|
+
warnings = []
|
|
305
|
+
dirs = claude_project_dirs(root, roots)
|
|
306
|
+
stats = {"cwd_skipped": 0, "unknown_kinds": Counter(), "no_text": 0}
|
|
307
|
+
ranges, scanned, skipped = [], 0, 0
|
|
308
|
+
head_borders = head_only = no_branch = 0
|
|
309
|
+
for d in dirs:
|
|
310
|
+
skipped += len(glob.glob(os.path.join(d, "*", "subagents", "*.jsonl")))
|
|
311
|
+
for path in sorted(glob.glob(os.path.join(d, "*.jsonl"))):
|
|
312
|
+
scanned += 1
|
|
313
|
+
try:
|
|
314
|
+
rows = read_jsonl(path)
|
|
315
|
+
branches = {_branch(r) for r in rows if r and not r.get("isSidechain")} - {None}
|
|
316
|
+
if any(rows) and not branches:
|
|
317
|
+
no_branch += 1
|
|
318
|
+
elif branches == {"HEAD"}:
|
|
319
|
+
head_only += 1
|
|
320
|
+
found = []
|
|
321
|
+
for start, end, lead, head in claude_ranges(rows, branch, base):
|
|
322
|
+
ts = rows[start - 1].get("timestamp")
|
|
323
|
+
head_borders += 1 if head else 0
|
|
324
|
+
found.append({"file": path, "start": start, "end": end, "lead_in_start": lead,
|
|
325
|
+
"started": ts if isinstance(ts, str) else "",
|
|
326
|
+
"records": _claude_records(rows, os.path.basename(path), start,
|
|
327
|
+
end, lead, roots, stats)})
|
|
328
|
+
except Exception as exc: # one bad file must not hide the others
|
|
329
|
+
warnings.append("%s could not be read (%s)" % (os.path.basename(path),
|
|
330
|
+
type(exc).__name__))
|
|
331
|
+
continue
|
|
332
|
+
ranges += found
|
|
333
|
+
_warn_count(warnings, head_borders, "%d branch range(s) border rows whose gitBranch is HEAD; "
|
|
334
|
+
"sessions from a detached HEAD may be missing")
|
|
335
|
+
_warn_count(warnings, head_only, "%d log file(s) hold only gitBranch HEAD rows and cannot be "
|
|
336
|
+
"matched to the branch")
|
|
337
|
+
_warn_count(warnings, no_branch, "%d log file(s) have rows but no gitBranch and cannot be "
|
|
338
|
+
"matched to the branch")
|
|
339
|
+
_warn_count(warnings, stats["unknown_kinds"], "%d user row(s) have an unknown origin.kind "
|
|
340
|
+
"(%s) and were not treated as human")
|
|
341
|
+
_warn_count(warnings, stats["no_text"], "%d user row(s) had no readable text")
|
|
342
|
+
_warn_count(warnings, stats["cwd_skipped"], "%d row(s) skipped because their cwd is outside "
|
|
343
|
+
"the repo and its worktrees")
|
|
344
|
+
ranges.sort(key=lambda r: (r["started"], r["file"], r["start"]))
|
|
345
|
+
notes = ["%d subagent log(s) skipped" % skipped] if skipped else []
|
|
346
|
+
return {"ranges": ranges, "project_dirs": len(dirs), "files_scanned": scanned,
|
|
347
|
+
"notes": notes, "warnings": warnings}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---------------------------------------------------------------- Codex
|
|
351
|
+
|
|
352
|
+
def _codex_text(payload):
|
|
353
|
+
content = payload.get("content")
|
|
354
|
+
if not isinstance(content, list):
|
|
355
|
+
return ""
|
|
356
|
+
return "\n".join(b.get("text", "") for b in content
|
|
357
|
+
if isinstance(b, dict) and isinstance(b.get("text"), str))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _codex_records(rows, fname):
|
|
361
|
+
names = {}
|
|
362
|
+
for row in rows:
|
|
363
|
+
p = (row or {}).get("payload")
|
|
364
|
+
if isinstance(p, dict) and p.get("type") == "function_call" and isinstance(p.get("call_id"), str):
|
|
365
|
+
names[p["call_id"]] = p.get("name")
|
|
366
|
+
col = Collector(fname)
|
|
367
|
+
for i, row in enumerate(rows, 1):
|
|
368
|
+
p = (row or {}).get("payload")
|
|
369
|
+
if not isinstance(p, dict):
|
|
370
|
+
continue
|
|
371
|
+
t = p.get("type")
|
|
372
|
+
if row.get("type") == "response_item" and t == "message":
|
|
373
|
+
text = _codex_text(p).strip()
|
|
374
|
+
if p.get("role") == "assistant":
|
|
375
|
+
col.assistant(i, text)
|
|
376
|
+
elif p.get("role") == "user" and text and not text.startswith(("<", "# AGENTS.md")):
|
|
377
|
+
col.event("human", i, trunc(text, HUMAN_MAX))
|
|
378
|
+
elif row.get("type") == "event_msg" and t == "turn_aborted":
|
|
379
|
+
col.event("interrupt", i, str(p.get("reason") or "interrupted"))
|
|
380
|
+
elif (t == "function_call_output" and isinstance(p.get("call_id"), str)
|
|
381
|
+
and names.get(p["call_id"]) == "request_user_input"):
|
|
382
|
+
col.event("answer", i, trunc(str(p.get("output")), ANSWER_MAX))
|
|
383
|
+
return col.records
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def extract_codex(root, roots, branch):
|
|
387
|
+
warnings = []
|
|
388
|
+
ranges, scanned, bad_header = [], 0, 0
|
|
389
|
+
for path in sorted(glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True)):
|
|
390
|
+
try:
|
|
391
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
392
|
+
first = json.loads(fh.readline())
|
|
393
|
+
except OSError as exc:
|
|
394
|
+
warnings.append("%s could not be read (%s)" % (os.path.basename(path), type(exc).__name__))
|
|
395
|
+
continue
|
|
396
|
+
except (ValueError, RecursionError):
|
|
397
|
+
first = None
|
|
398
|
+
meta = first.get("payload") if isinstance(first, dict) and first.get("type") == "session_meta" else None
|
|
399
|
+
if not isinstance(meta, dict):
|
|
400
|
+
bad_header += 1
|
|
401
|
+
continue
|
|
402
|
+
scanned += 1
|
|
403
|
+
git = meta.get("git") if isinstance(meta.get("git"), dict) else {}
|
|
404
|
+
if (git.get("branch") != branch or isinstance(meta.get("source"), dict)
|
|
405
|
+
or meta.get("thread_source") == "automation"
|
|
406
|
+
or not under(str(meta.get("cwd") or ""), roots)):
|
|
407
|
+
continue
|
|
408
|
+
try:
|
|
409
|
+
rows = read_jsonl(path)
|
|
410
|
+
records = _codex_records(rows, os.path.basename(path))
|
|
411
|
+
except Exception as exc: # one bad file must not hide the others
|
|
412
|
+
warnings.append("%s could not be read (%s)" % (os.path.basename(path), type(exc).__name__))
|
|
413
|
+
continue
|
|
414
|
+
ts = meta.get("timestamp")
|
|
415
|
+
ranges.append({"file": path, "start": 1, "end": len(rows), "lead_in_start": None,
|
|
416
|
+
"started": ts if isinstance(ts, str) else "", "records": records})
|
|
417
|
+
_warn_count(warnings, bad_header, "%d .jsonl file(s) skipped for an unrecognised header")
|
|
418
|
+
ranges.sort(key=lambda r: (r["started"], r["file"]))
|
|
419
|
+
return {"ranges": ranges, "project_dirs": 0, "files_scanned": scanned,
|
|
420
|
+
"notes": ["the branch match is approximate (the branch is recorded once, not per "
|
|
421
|
+
"turn).", "Codex tool denials are not extracted"],
|
|
422
|
+
"warnings": warnings}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# ---------------------------------------------------------------- OpenCode
|
|
426
|
+
|
|
427
|
+
OC_TYPES = {"user", "assistant", "synthetic", "system", "compaction", "idle",
|
|
428
|
+
"agent-switched", "model-switched"}
|
|
429
|
+
DISMISSED = "The user dismissed"
|
|
430
|
+
RULE_REFUSAL = "The user has specified a rule which prevents"
|
|
431
|
+
OC_ERRORS = {"tool.execution", "tool.interrupted", "aborted", "unknown"}
|
|
432
|
+
EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
433
|
+
DATE_ONLY = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _parse_time(text, end_of_day):
|
|
437
|
+
"""Epoch ms from an ISO date or datetime. A trailing Z is accepted (Python 3.9 rejects it)
|
|
438
|
+
and a missing offset means UTC. A date-only value is the start of that day, or its end."""
|
|
439
|
+
text = text.strip()
|
|
440
|
+
# accept git's %ai form too: a space for the T, a space before the offset, +HHMM
|
|
441
|
+
text = re.sub(r"^(\d{4}-\d{2}-\d{2}) (?=\d)", r"\1T", text)
|
|
442
|
+
text = re.sub(r"\s+([+-]\d{2}:?\d{2})$", r"\1", text)
|
|
443
|
+
text = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", text)
|
|
444
|
+
try:
|
|
445
|
+
dt = datetime.fromisoformat(text[:-1] + "+00:00" if text.endswith(("Z", "z")) else text)
|
|
446
|
+
except ValueError:
|
|
447
|
+
raise argparse.ArgumentTypeError("not an ISO date or datetime: %r" % text[:40])
|
|
448
|
+
if dt.tzinfo is None:
|
|
449
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
450
|
+
ms = (dt - EPOCH) // timedelta(milliseconds=1)
|
|
451
|
+
if end_of_day and DATE_ONLY.match(text):
|
|
452
|
+
ms += 24 * 3600 * 1000 - 1
|
|
453
|
+
return ms
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def parse_since(text):
|
|
457
|
+
return _parse_time(text, False)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def parse_until(text):
|
|
461
|
+
return _parse_time(text, True)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def iso_ms(ms):
|
|
465
|
+
try:
|
|
466
|
+
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
467
|
+
except (OverflowError, OSError, ValueError):
|
|
468
|
+
return ""
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _is_int(v):
|
|
472
|
+
return isinstance(v, int) and not isinstance(v, bool)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _oc_answers(answers):
|
|
476
|
+
parts = []
|
|
477
|
+
for a in answers:
|
|
478
|
+
parts.append(", ".join(str(x) for x in a) if isinstance(a, list) else str(a))
|
|
479
|
+
return "; ".join(parts)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _oc_assistant(col, seq, data, stats):
|
|
483
|
+
content = data.get("content")
|
|
484
|
+
interrupted = False
|
|
485
|
+
for item in content if isinstance(content, list) else []:
|
|
486
|
+
if not isinstance(item, dict):
|
|
487
|
+
continue
|
|
488
|
+
if item.get("type") == "text" and isinstance(item.get("text"), str):
|
|
489
|
+
col.assistant(seq, item["text"])
|
|
490
|
+
elif item.get("type") == "tool" and isinstance(item.get("state"), dict):
|
|
491
|
+
state = item["state"]
|
|
492
|
+
name = item.get("name") if isinstance(item.get("name"), str) else None
|
|
493
|
+
meta = state.get("metadata")
|
|
494
|
+
answers = meta.get("answers") if isinstance(meta, dict) else None
|
|
495
|
+
if name == "question" and isinstance(answers, list):
|
|
496
|
+
col.event("answer", seq, trunc(_oc_answers(answers), ANSWER_MAX))
|
|
497
|
+
err = state.get("error")
|
|
498
|
+
if not isinstance(err, dict):
|
|
499
|
+
continue
|
|
500
|
+
etype, msg = err.get("type"), err.get("message")
|
|
501
|
+
msg = msg if isinstance(msg, str) else ""
|
|
502
|
+
if etype == "tool.execution":
|
|
503
|
+
if name == "question" and msg.startswith(DISMISSED):
|
|
504
|
+
col.event("answer", seq, "(dismissed)")
|
|
505
|
+
elif msg.startswith(RULE_REFUSAL):
|
|
506
|
+
# a configured permission rule refused the call, not a live human choice
|
|
507
|
+
col.event("denial", seq, trunc(msg, HUMAN_MAX), tool=name, rule=True,
|
|
508
|
+
target=short_target(state.get("input")))
|
|
509
|
+
# All 185 matches seen in real logs were permission or question rejections
|
|
510
|
+
# (for example PermissionRejectedError, QuestionRejectedError).
|
|
511
|
+
elif "reject" in msg.lower():
|
|
512
|
+
col.event("denial", seq, trunc(msg, HUMAN_MAX), tool=name,
|
|
513
|
+
target=short_target(state.get("input")))
|
|
514
|
+
elif etype in ("tool.interrupted", "aborted"):
|
|
515
|
+
if not interrupted:
|
|
516
|
+
col.event("interrupt", seq, etype)
|
|
517
|
+
interrupted = True
|
|
518
|
+
elif etype not in OC_ERRORS:
|
|
519
|
+
stats["unknown_errors"][_clean(etype)] += 1
|
|
520
|
+
err = data.get("error")
|
|
521
|
+
if isinstance(err, dict):
|
|
522
|
+
if err.get("type") == "aborted":
|
|
523
|
+
if not interrupted:
|
|
524
|
+
col.event("interrupt", seq, "aborted")
|
|
525
|
+
elif err.get("type") not in OC_ERRORS and err.get("type") is not None:
|
|
526
|
+
stats["unknown_errors"][_clean(err.get("type"))] += 1
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _opencode_records(rows, fname, stats):
|
|
530
|
+
col = Collector(fname)
|
|
531
|
+
for seq, mtype, raw in rows:
|
|
532
|
+
if not _is_int(seq):
|
|
533
|
+
continue
|
|
534
|
+
if mtype not in OC_TYPES:
|
|
535
|
+
stats["unknown_types"][_clean(mtype)] += 1
|
|
536
|
+
continue
|
|
537
|
+
data = _json_obj(raw)
|
|
538
|
+
if mtype == "user":
|
|
539
|
+
text = data.get("text") if data else None
|
|
540
|
+
if isinstance(text, str) and text.strip():
|
|
541
|
+
col.event("human", seq, trunc(text, HUMAN_MAX))
|
|
542
|
+
else:
|
|
543
|
+
stats["no_text"] += 1
|
|
544
|
+
continue
|
|
545
|
+
if data is not None and mtype == "assistant":
|
|
546
|
+
_oc_assistant(col, seq, data, stats)
|
|
547
|
+
return col.records
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
# review-decision: OpenCode sessions are matched by window overlap and every message in them is read — sessions are short-lived in practice, so out-of-window messages are rare. Reconsider if resumed long-running sessions become common or a retro reports Corrections from outside the branch's window.
|
|
551
|
+
def _in_window(created, updated, since, until):
|
|
552
|
+
if not _is_int(created):
|
|
553
|
+
return True # cannot be placed in the window; run() warns about the missing start
|
|
554
|
+
updated = updated if _is_int(updated) else created
|
|
555
|
+
return (until is None or created <= until) and (since is None or updated >= since)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _opencode_uri(path):
|
|
559
|
+
"""Read-only URI. With no side files, immutable=1 stops SQLite creating -wal/-shm files
|
|
560
|
+
when it opens a WAL-mode database."""
|
|
561
|
+
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
|
|
562
|
+
if not (os.path.exists(path + "-wal") or os.path.exists(path + "-shm")):
|
|
563
|
+
uri += "&immutable=1"
|
|
564
|
+
return uri
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _opencode_session(conn, path, sid, directory, created):
|
|
568
|
+
rows = conn.execute("SELECT seq, type, data FROM session_message "
|
|
569
|
+
"WHERE session_id = ? ORDER BY seq", (sid,)).fetchall()
|
|
570
|
+
seqs = [r[0] for r in rows if _is_int(r[0])]
|
|
571
|
+
stats = {"unknown_types": Counter(), "unknown_errors": Counter(), "no_text": 0}
|
|
572
|
+
records = _opencode_records(rows, "%s#%s" % (os.path.basename(path), sid), stats)
|
|
573
|
+
return {"file": "%s#%s" % (path, sid), "start": min(seqs, default=0),
|
|
574
|
+
"end": max(seqs, default=0), "lead_in_start": None, "dir": directory,
|
|
575
|
+
"started": iso_ms(created) if _is_int(created) else "", "records": records}, stats
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _opencode_db(path, roots, skip, since, until, stats):
|
|
579
|
+
"""(ranges, subagents skipped), or None when the database has no session_v2 table.
|
|
580
|
+
A session that cannot be read is counted in stats["failed"], not fatal."""
|
|
581
|
+
conn = sqlite3.connect(_opencode_uri(path), uri=True)
|
|
582
|
+
try:
|
|
583
|
+
conn.text_factory = lambda b: b.decode("utf-8", "replace")
|
|
584
|
+
found = conn.execute("SELECT name FROM sqlite_master WHERE type = ? AND name = ?",
|
|
585
|
+
("table", "session_v2")).fetchone()
|
|
586
|
+
if not found:
|
|
587
|
+
return None
|
|
588
|
+
sessions = conn.execute("SELECT id, parent_id, directory, time_created, time_updated "
|
|
589
|
+
"FROM session_v2").fetchall()
|
|
590
|
+
ranges, subagents = [], 0
|
|
591
|
+
for sid, parent, directory, created, updated in sessions:
|
|
592
|
+
if (not isinstance(sid, str) or not isinstance(directory, str)
|
|
593
|
+
or not under(directory, roots) or under(directory, skip)
|
|
594
|
+
or not _in_window(created, updated, since, until)):
|
|
595
|
+
continue
|
|
596
|
+
if parent:
|
|
597
|
+
subagents += 1
|
|
598
|
+
continue
|
|
599
|
+
try:
|
|
600
|
+
rng, got = _opencode_session(conn, path, sid, directory, created)
|
|
601
|
+
except Exception as exc: # one bad session must not hide the others
|
|
602
|
+
stats["failed"][type(exc).__name__] += 1
|
|
603
|
+
continue
|
|
604
|
+
for key, counter in got.items():
|
|
605
|
+
stats[key] += counter
|
|
606
|
+
ranges.append(rng)
|
|
607
|
+
return ranges, subagents
|
|
608
|
+
finally:
|
|
609
|
+
conn.close()
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def extract_opencode(root, roots, skip, since=None, until=None):
|
|
613
|
+
"""`skip` holds the directories of linked worktrees checked out on another branch."""
|
|
614
|
+
warnings = []
|
|
615
|
+
stats = {"unknown_types": Counter(), "unknown_errors": Counter(), "no_text": 0,
|
|
616
|
+
"failed": Counter()}
|
|
617
|
+
ranges, scanned, subagents = [], 0, 0
|
|
618
|
+
for path in sorted(glob.glob(os.path.join(root, "*.db"))):
|
|
619
|
+
name = os.path.basename(path)
|
|
620
|
+
if os.path.exists(path + "-wal") and not os.path.exists(path + "-shm"):
|
|
621
|
+
warnings.append("%s has a WAL file but no -shm file and was skipped" % name)
|
|
622
|
+
continue
|
|
623
|
+
try:
|
|
624
|
+
got = _opencode_db(path, roots, skip, since, until, stats)
|
|
625
|
+
except Exception as exc: # one bad database must not hide the others
|
|
626
|
+
warnings.append("%s could not be read (%s)" % (name, type(exc).__name__))
|
|
627
|
+
continue
|
|
628
|
+
if got is None:
|
|
629
|
+
warnings.append("%s has no session_v2 table and was skipped" % name)
|
|
630
|
+
continue
|
|
631
|
+
scanned += 1
|
|
632
|
+
ranges += got[0]
|
|
633
|
+
subagents += got[1]
|
|
634
|
+
if since is None and until is None:
|
|
635
|
+
warnings.append("no --since/--until given, so every top-level session in the repo "
|
|
636
|
+
"directories matched")
|
|
637
|
+
_warn_count(warnings, stats["unknown_types"],
|
|
638
|
+
"%d message(s) have an unknown type (%s) and were skipped")
|
|
639
|
+
_warn_count(warnings, stats["unknown_errors"],
|
|
640
|
+
"%d error(s) have an unknown type (%s) and were not read")
|
|
641
|
+
_warn_count(warnings, stats["no_text"], "%d user row(s) had no readable text")
|
|
642
|
+
_warn_count(warnings, stats["failed"], "%d session(s) could not be read (%s)")
|
|
643
|
+
ranges.sort(key=lambda r: (r["started"], r["file"]))
|
|
644
|
+
notes = ["the branch match is approximate (OpenCode records no branch; sessions are matched "
|
|
645
|
+
"by directory and time window)."]
|
|
646
|
+
if subagents:
|
|
647
|
+
notes.append("%d subagent session(s) skipped" % subagents)
|
|
648
|
+
return {"ranges": ranges, "project_dirs": 0, "files_scanned": scanned,
|
|
649
|
+
"notes": notes, "warnings": warnings}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
# ---------------------------------------------------------------- CLI
|
|
653
|
+
|
|
654
|
+
DEFAULT_ROOTS = {"claude": "~/.claude/projects", "codex": "~/.codex/sessions",
|
|
655
|
+
"opencode": "~/.local/share/opencode"}
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def describe(r):
|
|
659
|
+
where = "%s:%d-%d" % (r["file"], r["start"], r["end"])
|
|
660
|
+
if r["lead_in_start"]:
|
|
661
|
+
where += " (lead-in from line %d)" % r["lead_in_start"]
|
|
662
|
+
if r.get("dir"):
|
|
663
|
+
where += " (dir: %s)" % r["dir"][:200]
|
|
664
|
+
return _printable(where)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _norm(s):
|
|
668
|
+
return " ".join(s.split())
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def verify(range_file, line, quote):
|
|
672
|
+
"""True if the quote is a substring of the text of the human-signal record at source
|
|
673
|
+
line `line`. Assistant records are context and never match. Raises OSError if the range
|
|
674
|
+
file cannot be read."""
|
|
675
|
+
q = _norm(quote)
|
|
676
|
+
if not q:
|
|
677
|
+
return False
|
|
678
|
+
with open(range_file, encoding="utf-8", errors="replace") as fh:
|
|
679
|
+
for raw in fh:
|
|
680
|
+
rec = _json_obj(raw)
|
|
681
|
+
if (rec and rec.get("line") == line and rec.get("kind") != "assistant"
|
|
682
|
+
and isinstance(rec.get("text"), str) and q in _norm(rec["text"])):
|
|
683
|
+
return True
|
|
684
|
+
return False
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _write_ranges(out, useful):
|
|
688
|
+
files, total = [], 0
|
|
689
|
+
for n, r in enumerate(useful, 1):
|
|
690
|
+
path = os.path.join(out, "range-%03d.jsonl" % n)
|
|
691
|
+
body = "".join(json.dumps(rec, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
692
|
+
for rec in r["records"])
|
|
693
|
+
data = body.encode("utf-8", "backslashreplace")
|
|
694
|
+
with open(path, "wb") as fh:
|
|
695
|
+
fh.write(data)
|
|
696
|
+
total += len(data)
|
|
697
|
+
files.append(path)
|
|
698
|
+
return files, total
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def run(args):
|
|
702
|
+
repo = os.path.abspath(os.path.expanduser(args.repo))
|
|
703
|
+
root = os.path.expanduser(args.projects_dir or DEFAULT_ROOTS[args.harness])
|
|
704
|
+
worktrees = list_worktrees(repo)
|
|
705
|
+
roots = [repo] + [path for path, _ in worktrees]
|
|
706
|
+
if args.harness == "claude":
|
|
707
|
+
res = extract_claude(root, roots, args.branch, args.base)
|
|
708
|
+
elif args.harness == "opencode":
|
|
709
|
+
# linked worktrees (not the first, main one) on a different branch are not this branch's
|
|
710
|
+
other = [path for n, (path, br) in enumerate(worktrees) if n and br and br != args.branch]
|
|
711
|
+
res = extract_opencode(root, roots, other, args.since, args.until)
|
|
712
|
+
else:
|
|
713
|
+
res = extract_codex(root, roots, args.branch)
|
|
714
|
+
warnings = [] if os.path.isdir(root) else ["log root %s does not exist" % root]
|
|
715
|
+
warnings += res["warnings"]
|
|
716
|
+
starts = sorted(r["started"] for r in res["ranges"] if r["started"])
|
|
717
|
+
_warn_count(warnings, sum(1 for r in res["ranges"] if not r["started"]),
|
|
718
|
+
"%d range(s) have no timestamp, so range start is unknown")
|
|
719
|
+
|
|
720
|
+
useful = [r for r in res["ranges"] if r["records"]]
|
|
721
|
+
lines = []
|
|
722
|
+
if not res["ranges"]:
|
|
723
|
+
lines.append("RESULT: no sessions found")
|
|
724
|
+
lines.append("harness: %s | repo: %s | branch: %s | log root: %s" % (
|
|
725
|
+
args.harness, repo, args.branch, root))
|
|
726
|
+
lines.append("scanned %d log files in %d project dirs; none matched the branch."
|
|
727
|
+
% (res["files_scanned"], res["project_dirs"]))
|
|
728
|
+
lines.append("range start: none")
|
|
729
|
+
lines += ["note: " + n for n in res["notes"]]
|
|
730
|
+
lines += ["warning: " + w for w in warnings]
|
|
731
|
+
lines.append("Fall back to git, PR and review evidence only.")
|
|
732
|
+
print("\n".join(lines))
|
|
733
|
+
return 0
|
|
734
|
+
|
|
735
|
+
out = os.path.abspath(tempfile.mkdtemp(prefix="code-retro-"))
|
|
736
|
+
try: # on any failure the caller gets no output: line, so remove what was written
|
|
737
|
+
files, total = _write_ranges(out, useful)
|
|
738
|
+
lines.append("RESULT: sessions found")
|
|
739
|
+
lines.append("harness: %s | repo: %s | branch: %s" % (args.harness, repo, args.branch))
|
|
740
|
+
lines.append("output: %s (%d bytes in %d range files)" % (out, total, len(useful)))
|
|
741
|
+
for path, r in zip(files, useful):
|
|
742
|
+
lines.append(" %s %s records=%d" % (path, describe(r), len(r["records"])))
|
|
743
|
+
for r in res["ranges"]:
|
|
744
|
+
if not r["records"]:
|
|
745
|
+
lines.append(" (no human-signal records) %s" % describe(r))
|
|
746
|
+
lines.append("range start: earliest %s | latest %s" % (
|
|
747
|
+
(starts[0], starts[-1]) if starts else ("unknown", "unknown")))
|
|
748
|
+
cov = "coverage: %d log files scanned" % res["files_scanned"]
|
|
749
|
+
if res["project_dirs"]:
|
|
750
|
+
cov += " in %d project dirs" % res["project_dirs"]
|
|
751
|
+
lines.append(cov + "; %d matched ranges" % len(res["ranges"]))
|
|
752
|
+
lines += ["note: " + n for n in res["notes"]]
|
|
753
|
+
lines += ["warning: " + w for w in warnings]
|
|
754
|
+
print("\n".join(lines))
|
|
755
|
+
except BaseException:
|
|
756
|
+
shutil.rmtree(out, ignore_errors=True)
|
|
757
|
+
raise
|
|
758
|
+
return 0
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def main(argv=None):
|
|
762
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
763
|
+
ap.add_argument("--repo", help="path to the repository")
|
|
764
|
+
ap.add_argument("--branch")
|
|
765
|
+
ap.add_argument("--base", default="main", help="base branch whose contiguous lead-in is included (Claude)")
|
|
766
|
+
ap.add_argument("--harness", choices=sorted(DEFAULT_ROOTS), default="claude")
|
|
767
|
+
ap.add_argument("--projects-dir", help="override the log root (for fixtures)")
|
|
768
|
+
ap.add_argument("--since", type=parse_since, metavar="WHEN",
|
|
769
|
+
help="OpenCode: only sessions active at or after this ISO date or datetime")
|
|
770
|
+
ap.add_argument("--until", type=parse_until, metavar="WHEN",
|
|
771
|
+
help="OpenCode: only sessions started at or before this; a date means "
|
|
772
|
+
"the end of that day")
|
|
773
|
+
ap.add_argument("--verify", metavar="RANGE_FILE",
|
|
774
|
+
help="verify mode: read a quote on stdin, print match or no match")
|
|
775
|
+
ap.add_argument("--line", type=int, help="with --verify: the cited source line")
|
|
776
|
+
args = ap.parse_args(argv)
|
|
777
|
+
|
|
778
|
+
if args.verify:
|
|
779
|
+
if args.line is None:
|
|
780
|
+
ap.error("--verify needs --line")
|
|
781
|
+
try:
|
|
782
|
+
found = verify(args.verify, args.line, sys.stdin.read())
|
|
783
|
+
except OSError:
|
|
784
|
+
print("error: cannot read range file")
|
|
785
|
+
return 1
|
|
786
|
+
print("match" if found else "no match")
|
|
787
|
+
return 0
|
|
788
|
+
if not (args.repo and args.branch):
|
|
789
|
+
ap.error("--repo and --branch are required")
|
|
790
|
+
try:
|
|
791
|
+
return run(args)
|
|
792
|
+
except Exception as exc: # always give the caller a RESULT line
|
|
793
|
+
print("RESULT: extractor error")
|
|
794
|
+
print("error: %s" % type(exc).__name__)
|
|
795
|
+
print("Do not open the logs. Fall back to git, PR and review evidence only.")
|
|
796
|
+
return 1
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
if __name__ == "__main__":
|
|
800
|
+
sys.exit(main())
|