custos-code 0.0.1__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.
@@ -0,0 +1,437 @@
1
+ """Codex rollout JSONL -> ledger (class F; see docs/ADAPTERS.md §3).
2
+
3
+ File: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<session_id>.jsonl (CLI and Codex Desktop).
4
+ Observed on a real rollout on this machine:
5
+ session_meta{id,cwd,originator,cli_version} -> Session (key codex:<id>)
6
+ turn_context{turn_id,cwd,sandbox_policy} -> turn boundary
7
+ function_call{name=exec_command,arguments{cmd,workdir},call_id} -> CALL tool=Bash
8
+ exec_command_end{call_id,command[argv],cwd,exit_code,aggregated_output,duration,status,parsed_cmd}
9
+ -> RESULT with argv, exit_code, full output
10
+ function_call_output{call_id,output "... Process exited with code N ... Original token count: T"}
11
+ -> flags.truncated only
12
+ custom_tool_call{name=apply_patch,input "*** Begin Patch"} -> CALL tool=Edit, paths from Add/Update/Delete File
13
+ patch_apply_end{call_id,success,changes{path:{type,content}}} -> RESULT per path
14
+ task_complete{turn_id,last_agent_message} -> TEXT (the report for the turn)
15
+ turn_aborted / compacted / thread_rolled_back -> integrity flags
16
+
17
+ Owner: Ananya.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import glob
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import re
27
+ from datetime import datetime
28
+ from typing import Any
29
+
30
+ from ..ledger import MAX_OUTPUT_BYTES, chain, redact
31
+ from ..models import EventFlags, EventKind, LedgerEvent, Session
32
+ from ..parsers import is_piped
33
+
34
+ _PATH_RE = re.compile(r"(?<![\w-])((?:\.{0,2}/)?[\w.-]+(?:/[\w.-]+)+\.[A-Za-z0-9]{1,8})")
35
+ _PATCH_PATH_RE = re.compile(r"^\*\*\* (Add|Update|Delete) File: (.+)$", re.MULTILINE)
36
+ _EXIT_RE = re.compile(r"Process exited with code (-?\d+)")
37
+ _TOKEN_COUNT_RE = re.compile(r"Original token count: (\d+)")
38
+
39
+ # payload.type values that carry no evidence and no report
40
+ _IGNORED = frozenset(
41
+ {"token_count", "agent_reasoning", "agent_reasoning_delta", "agent_message_delta"}
42
+ )
43
+ # payload.type values meaning the record before this point may be summarized or discarded
44
+ _INTEGRITY = frozenset({"compacted", "thread_rolled_back"})
45
+
46
+
47
+ def _ts(rec: dict[str, Any], payload: dict[str, Any], fallback: datetime) -> datetime:
48
+ for raw in (rec.get("timestamp"), payload.get("timestamp")):
49
+ if isinstance(raw, str):
50
+ try:
51
+ return datetime.fromisoformat(raw.replace("Z", "+00:00"))
52
+ except ValueError:
53
+ continue
54
+ return fallback
55
+
56
+
57
+ def _abs_paths(paths: list[str], cwd: str | None) -> list[str]:
58
+ seen: set[str] = set()
59
+ out: list[str] = []
60
+ for p in paths:
61
+ q = p if os.path.isabs(p) or not cwd else os.path.normpath(os.path.join(cwd, p))
62
+ if q not in seen:
63
+ seen.add(q)
64
+ out.append(q)
65
+ return out
66
+
67
+
68
+ def _command_text(value: object) -> str:
69
+ """exec_command carries either a shell string or an argv list; the ledger stores one string."""
70
+ if isinstance(value, str):
71
+ return value
72
+ if isinstance(value, list):
73
+ return " ".join(str(part) for part in value)
74
+ return ""
75
+
76
+
77
+ def _arguments(raw: object) -> dict[str, Any]:
78
+ if isinstance(raw, dict):
79
+ return dict(raw)
80
+ if isinstance(raw, str):
81
+ try:
82
+ parsed = json.loads(raw)
83
+ except json.JSONDecodeError:
84
+ return {}
85
+ if isinstance(parsed, dict):
86
+ return parsed
87
+ return {}
88
+
89
+
90
+ def _str(value: object) -> str:
91
+ return value if isinstance(value, str) else ""
92
+
93
+
94
+ def _obj(value: object) -> dict[str, Any]:
95
+ return dict(value) if isinstance(value, dict) else {}
96
+
97
+
98
+ def _duration_ms(value: object) -> int | None:
99
+ if isinstance(value, dict):
100
+ secs, nanos = value.get("secs"), value.get("nanos")
101
+ if isinstance(secs, int | float) or isinstance(nanos, int | float):
102
+ s = float(secs) if isinstance(secs, int | float) else 0.0
103
+ n = float(nanos) if isinstance(nanos, int | float) else 0.0
104
+ return int(s * 1000 + n / 1_000_000)
105
+ if isinstance(value, int | float):
106
+ return int(float(value) * 1000)
107
+ return None
108
+
109
+
110
+ def _patch_paths(patch: str) -> list[str]:
111
+ return [m.group(2).strip() for m in _PATCH_PATH_RE.finditer(patch)]
112
+
113
+
114
+ class _Builder:
115
+ """Accumulates ledger events while walking one rollout file."""
116
+
117
+ def __init__(self) -> None:
118
+ self.events: list[LedgerEvent] = []
119
+ self.seq = 0
120
+ self.session_id = ""
121
+ self.cwd: str | None = None
122
+ self.agent = "codex"
123
+ self.model: str | None = None
124
+ self.started: datetime | None = None
125
+ self.ended: datetime = datetime.fromtimestamp(0)
126
+ self.report: str | None = None
127
+ self.calls: dict[str, tuple[str, dict[str, Any]]] = {}
128
+ self.max_output_tokens: dict[str, int] = {}
129
+ self.results: dict[str, int] = {} # call_id -> index into self.events
130
+ self.integrity_events = 0
131
+
132
+ def add(self, **fields: Any) -> LedgerEvent:
133
+ event = LedgerEvent(seq=self.seq, session_id=self.session_id, **fields)
134
+ self.events.append(event)
135
+ self.seq += 1
136
+ return event
137
+
138
+ def add_result(self, call_id: str | None, **fields: Any) -> LedgerEvent:
139
+ event = self.add(kind=EventKind.RESULT, **fields)
140
+ if call_id:
141
+ self.results[call_id] = len(self.events) - 1
142
+ return event
143
+
144
+ def result_for(self, call_id: object) -> LedgerEvent | None:
145
+ if isinstance(call_id, str) and call_id in self.results:
146
+ return self.events[self.results[call_id]]
147
+ return None
148
+
149
+ def store_output(self, event: LedgerEvent, text: str) -> None:
150
+ full = redact(text)
151
+ raw = full.encode()
152
+ if len(raw) > MAX_OUTPUT_BYTES:
153
+ event.flags.truncated = True
154
+ event.output = raw[:MAX_OUTPUT_BYTES].decode(errors="ignore")
155
+ event.output_hash = hashlib.sha256(raw).hexdigest()
156
+
157
+
158
+ def _on_session_meta(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
159
+ b.session_id = str(p.get("id") or b.session_id)
160
+ if isinstance(p.get("cwd"), str):
161
+ b.cwd = p["cwd"]
162
+ if isinstance(p.get("originator"), str) and p["originator"]:
163
+ b.agent = p["originator"]
164
+ if isinstance(p.get("model"), str):
165
+ b.model = p["model"]
166
+
167
+
168
+ def _on_turn_context(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
169
+ cwd = p["cwd"] if isinstance(p.get("cwd"), str) else b.cwd
170
+ b.add(
171
+ kind=EventKind.META,
172
+ ts=ts,
173
+ tool=None,
174
+ cwd=cwd,
175
+ input=redact(
176
+ {
177
+ "turn_id": str(p.get("turn_id", "")),
178
+ "sandbox_policy": json.dumps(p.get("sandbox_policy"), sort_keys=True)
179
+ if p.get("sandbox_policy") is not None
180
+ else "",
181
+ "approval_policy": str(p.get("approval_policy", "")),
182
+ }
183
+ ),
184
+ )
185
+
186
+
187
+ def _on_function_call(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
188
+ name = str(p.get("name", ""))
189
+ args = _arguments(p.get("arguments"))
190
+ call_id = str(p.get("call_id", ""))
191
+ if name in ("exec_command", "shell", "local_shell"):
192
+ command = _command_text(args.get("cmd") or args.get("command"))
193
+ cwd = args["workdir"] if isinstance(args.get("workdir"), str) else b.cwd
194
+ inp: dict[str, Any] = {"command": command}
195
+ if isinstance(args.get("workdir"), str):
196
+ inp["workdir"] = args["workdir"]
197
+ tool = "Bash"
198
+ paths = _abs_paths([m.group(1) for m in _PATH_RE.finditer(command)], cwd)
199
+ else:
200
+ cwd = b.cwd
201
+ inp = args
202
+ tool = name or "unknown"
203
+ paths = _abs_paths(
204
+ [v for k, v in args.items() if k in ("path", "file_path") and isinstance(v, str)], cwd
205
+ )
206
+ if isinstance(args.get("max_output_tokens"), int):
207
+ b.max_output_tokens[call_id] = args["max_output_tokens"]
208
+ b.calls[call_id] = (tool, inp)
209
+ b.add(kind=EventKind.CALL, ts=ts, tool=tool, input=redact(inp), paths=paths, cwd=cwd)
210
+
211
+
212
+ def _on_custom_tool_call(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
213
+ name = str(p.get("name", ""))
214
+ call_id = str(p.get("call_id", ""))
215
+ raw = p.get("input")
216
+ patch = raw if isinstance(raw, str) else json.dumps(raw, sort_keys=True)
217
+ tool = "Edit" if name == "apply_patch" else (name or "unknown")
218
+ paths = _abs_paths(_patch_paths(patch), b.cwd)
219
+ inp: dict[str, Any] = {"patch": patch} if tool == "Edit" else {"input": patch}
220
+ b.calls[call_id] = (tool, inp)
221
+ b.add(kind=EventKind.CALL, ts=ts, tool=tool, input=redact(inp), paths=paths, cwd=b.cwd)
222
+
223
+
224
+ def _on_exec_command_end(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
225
+ call_id = str(p.get("call_id", ""))
226
+ _, call_input = b.calls.get(call_id, ("Bash", {}))
227
+ argv = _command_text(p.get("command"))
228
+ command = argv or str(call_input.get("command", ""))
229
+ cwd = p["cwd"] if isinstance(p.get("cwd"), str) else b.cwd
230
+ exit_code = p.get("exit_code") if isinstance(p.get("exit_code"), int) else None
231
+ output = _str(p.get("aggregated_output"))
232
+ if not output:
233
+ stdout, stderr = _str(p.get("stdout")), _str(p.get("stderr"))
234
+ output = stdout + (("\n" + stderr) if stderr else "")
235
+ flags = EventFlags(piped=is_piped(command) if command else False)
236
+ event = b.add_result(
237
+ call_id,
238
+ ts=ts,
239
+ tool="Bash",
240
+ exit_code=exit_code,
241
+ cwd=cwd,
242
+ duration_ms=_duration_ms(p.get("duration")),
243
+ flags=flags,
244
+ paths=_abs_paths([m.group(1) for m in _PATH_RE.finditer(command)], cwd),
245
+ )
246
+ b.store_output(event, output)
247
+
248
+
249
+ def _on_patch_apply_end(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
250
+ call_id = str(p.get("call_id", ""))
251
+ changes = _obj(p.get("changes"))
252
+ stdout, stderr = _str(p.get("stdout")), _str(p.get("stderr"))
253
+ success = p.get("success")
254
+ event = b.add_result(
255
+ call_id,
256
+ ts=ts,
257
+ tool="Edit",
258
+ cwd=b.cwd,
259
+ paths=_abs_paths([str(k) for k in changes], b.cwd),
260
+ flags=EventFlags(error=success is False),
261
+ )
262
+ b.store_output(event, stdout + (("\n" + stderr) if stderr else ""))
263
+
264
+
265
+ def _on_function_call_output(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
266
+ """The model-facing view of a result: used only for truncation and a missing exit code."""
267
+ call_id = p.get("call_id")
268
+ raw = p.get("output")
269
+ text = (
270
+ raw if isinstance(raw, str) else json.dumps(raw, sort_keys=True) if raw is not None else ""
271
+ )
272
+ event = b.result_for(call_id)
273
+ if event is None:
274
+ tool = b.calls.get(str(call_id), ("Bash", {}))[0]
275
+ event = b.add_result(str(call_id) if call_id else None, ts=ts, tool=tool, cwd=b.cwd)
276
+ b.store_output(event, text)
277
+ if event.exit_code is None:
278
+ m = _EXIT_RE.search(text)
279
+ if m:
280
+ event.exit_code = int(m.group(1))
281
+ m = _TOKEN_COUNT_RE.search(text)
282
+ budget = b.max_output_tokens.get(str(call_id))
283
+ if m and budget is not None and int(m.group(1)) > budget:
284
+ event.flags.truncated = True
285
+
286
+
287
+ def _on_custom_tool_call_output(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
288
+ """apply_patch's secondary result: exit code and duration for an already-recorded RESULT."""
289
+ call_id = p.get("call_id")
290
+ payload = _arguments(p.get("output"))
291
+ meta = _obj(payload.get("metadata"))
292
+ event = b.result_for(call_id)
293
+ if event is None:
294
+ tool = b.calls.get(str(call_id), ("Edit", {}))[0]
295
+ event = b.add_result(str(call_id) if call_id else None, ts=ts, tool=tool, cwd=b.cwd)
296
+ b.store_output(event, _str(payload.get("output")))
297
+ if event.exit_code is None and isinstance(meta.get("exit_code"), int):
298
+ event.exit_code = meta["exit_code"]
299
+ if event.duration_ms is None:
300
+ event.duration_ms = _duration_ms(meta.get("duration_seconds"))
301
+
302
+
303
+ def _on_web_search_call(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
304
+ query = _str(p.get("query"))
305
+ call_id = str(p.get("call_id", ""))
306
+ b.calls[call_id] = ("WebSearch", {"query": query})
307
+ b.add(kind=EventKind.CALL, ts=ts, tool="WebSearch", input=redact({"query": query}), cwd=b.cwd)
308
+
309
+
310
+ def _on_web_search_end(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
311
+ query = _str(p.get("query"))
312
+ event = b.add_result(
313
+ str(p.get("call_id")) if p.get("call_id") else None, ts=ts, tool="WebSearch", cwd=b.cwd
314
+ )
315
+ b.store_output(event, query)
316
+
317
+
318
+ def _on_agent_message(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
319
+ text = p.get("message")
320
+ if isinstance(text, str) and text.strip():
321
+ event = b.add(kind=EventKind.TEXT, ts=ts, tool=None, cwd=b.cwd)
322
+ b.store_output(event, text)
323
+
324
+
325
+ def _on_user_message(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
326
+ text = p.get("message")
327
+ if isinstance(text, str) and text.strip():
328
+ event = b.add(kind=EventKind.USER, ts=ts, tool=None, cwd=b.cwd)
329
+ b.store_output(event, text)
330
+
331
+
332
+ def _on_task_complete(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
333
+ text = p.get("last_agent_message")
334
+ if isinstance(text, str) and text.strip():
335
+ event = b.add(kind=EventKind.TEXT, ts=ts, tool=None, cwd=b.cwd)
336
+ b.store_output(event, text)
337
+ b.report = text
338
+
339
+
340
+ def _on_turn_aborted(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
341
+ """An aborted turn has no report; anything the agent said mid-turn is not one."""
342
+ b.report = None
343
+ b.add(
344
+ kind=EventKind.META,
345
+ ts=ts,
346
+ tool=None,
347
+ cwd=b.cwd,
348
+ input=redact({"event": "turn_aborted", "reason": str(p.get("reason", ""))}),
349
+ )
350
+
351
+
352
+ def _on_integrity(b: _Builder, p: dict[str, Any], ts: datetime) -> None:
353
+ b.integrity_events += 1
354
+ b.add(
355
+ kind=EventKind.META,
356
+ ts=ts,
357
+ tool=None,
358
+ cwd=b.cwd,
359
+ input=redact(
360
+ {"event": str(p.get("type", "")), "note": "history before this point may be incomplete"}
361
+ ),
362
+ )
363
+
364
+
365
+ _HANDLERS = {
366
+ "session_meta": _on_session_meta,
367
+ "turn_context": _on_turn_context,
368
+ "function_call": _on_function_call,
369
+ "custom_tool_call": _on_custom_tool_call,
370
+ "function_call_output": _on_function_call_output,
371
+ "custom_tool_call_output": _on_custom_tool_call_output,
372
+ "exec_command_end": _on_exec_command_end,
373
+ "patch_apply_end": _on_patch_apply_end,
374
+ "web_search_call": _on_web_search_call,
375
+ "web_search_end": _on_web_search_end,
376
+ "agent_message": _on_agent_message,
377
+ "user_message": _on_user_message,
378
+ "task_complete": _on_task_complete,
379
+ "turn_aborted": _on_turn_aborted,
380
+ }
381
+
382
+
383
+ def parse(path: str) -> tuple[Session, list[LedgerEvent], str | None]:
384
+ """Parse one Codex rollout into a chained ledger.
385
+
386
+ Returns the session, the chained events, and the report for the last completed turn
387
+ (`task_complete.last_agent_message`), or None when the last turn was aborted or never finished.
388
+ """
389
+ b = _Builder()
390
+ with open(path, encoding="utf-8", errors="ignore") as fh:
391
+ for line in fh:
392
+ try:
393
+ rec = json.loads(line)
394
+ except json.JSONDecodeError:
395
+ continue
396
+ if not isinstance(rec, dict):
397
+ continue
398
+ payload = _obj(rec.get("payload"))
399
+ kind = str(payload.get("type") or rec.get("type") or "")
400
+ if kind in _IGNORED:
401
+ continue
402
+ ts = _ts(rec, payload, b.ended)
403
+ b.started = b.started or ts
404
+ b.ended = ts
405
+ body = payload or rec
406
+ if kind in _INTEGRITY:
407
+ _on_integrity(b, {**body, "type": kind}, ts)
408
+ continue
409
+ handler = _HANDLERS.get(kind)
410
+ if handler is not None:
411
+ handler(b, body, ts)
412
+
413
+ events = chain(b.events)
414
+ session = Session(
415
+ id=b.session_id or os.path.basename(path).removesuffix(".jsonl"),
416
+ source="codex",
417
+ agent=b.agent,
418
+ model=b.model,
419
+ started=b.started,
420
+ ended=b.ended if b.events else None,
421
+ cwd=b.cwd,
422
+ n_events=len(events),
423
+ ledger_root_hash=events[-1].hash if events else "",
424
+ integrity_score=1.0
425
+ if not b.integrity_events
426
+ else max(0.0, 1.0 - 0.25 * b.integrity_events),
427
+ )
428
+ return session, events, b.report
429
+
430
+
431
+ def find_last_session(sessions_dir: str | None = None) -> str:
432
+ """Most recently modified rollout under ~/.codex/sessions (for `custos-code check --last`)."""
433
+ root = sessions_dir or os.path.expanduser("~/.codex/sessions")
434
+ files = glob.glob(os.path.join(root, "**", "rollout-*.jsonl"), recursive=True)
435
+ if not files:
436
+ raise FileNotFoundError(f"no Codex rollouts under {root}")
437
+ return max(files, key=os.path.getmtime)
@@ -0,0 +1,158 @@
1
+ """GitHub Copilot coding agent (class R): the agent runs on GitHub, the report is the PR body.
2
+
3
+ Copilot's session logs are linked from the commits it pushes and are downloadable as the Actions
4
+ job log for the `Copilot` workflow run; the machine-readable session export is still
5
+ NEEDS-DECISION(ananya): A3, so this adapter takes a bundle and treats the log as optional.
6
+
7
+ Bundle (JSON, one object):
8
+ {"pull_request": {"number","title","body","html_url","head"},
9
+ "session": {"id","agent","model","started_at","completed_at","workspace"},
10
+ "log": [ {"type":"tool_call","name":"bash","arguments":{...},"id":...},
11
+ {"type":"tool_result","tool_call_id":...,"exit_code":0,"output":"..."} ],
12
+ "commits": [...], "checks": [...]} # same shapes as the Devin bundle
13
+
14
+ `commits`/`checks` reuse the Devin Path C builders: class R evidence is class R evidence whoever
15
+ opened the PR. Without `log`, shell claims come out `unrecorded` (the record is known-incomplete),
16
+ never `unwitnessed`.
17
+
18
+ Owner: Ananya.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ from datetime import UTC, datetime
26
+ from typing import Any
27
+
28
+ from ..ledger import chain, redact
29
+ from ..models import EventFlags, EventKind, LedgerEvent, Session
30
+ from ..parsers import is_piped
31
+ from .state import Builder, as_dict, as_list, as_text, as_ts, checks, commits, no_tool_log
32
+
33
+ _SHELL_TOOLS = frozenset({"bash", "shell", "run", "terminal", "execute_bash"})
34
+ _EDIT_TOOLS = frozenset(
35
+ {"str_replace_editor", "create", "edit", "write", "apply_patch", "edit_file"}
36
+ )
37
+
38
+
39
+ def _tool_name(name: str) -> str:
40
+ low = name.lower()
41
+ if low in _SHELL_TOOLS:
42
+ return "Bash"
43
+ if low in _EDIT_TOOLS:
44
+ return "Edit"
45
+ if low in ("read", "view", "open"):
46
+ return "Read"
47
+ return name or "Tool"
48
+
49
+
50
+ def _log(b: Builder, entries: list[Any], fallback: datetime) -> datetime:
51
+ calls: dict[str, tuple[str, dict[str, Any]]] = {}
52
+ ts = fallback
53
+ for raw in entries:
54
+ entry = as_dict(raw)
55
+ ts = as_ts(entry.get("timestamp") or entry.get("ts"), ts)
56
+ etype = str(entry.get("type", ""))
57
+ if etype in ("tool_call", "function_call"):
58
+ name = _tool_name(str(entry.get("name", "")))
59
+ args = as_dict(entry.get("arguments") or entry.get("input"))
60
+ command = str(args.get("command") or args.get("cmd") or "")
61
+ path = str(args.get("path") or args.get("file_path") or "")
62
+ call_id = str(entry.get("id") or entry.get("tool_call_id") or f"call-{b.seq}")
63
+ calls[call_id] = (name, args)
64
+ b.add(
65
+ kind=EventKind.CALL,
66
+ ts=ts,
67
+ tool=name,
68
+ input=redact(dict(args)),
69
+ paths=[path] if path else [],
70
+ flags=EventFlags(piped=bool(command) and is_piped(command)),
71
+ )
72
+ elif etype in ("tool_result", "function_call_output"):
73
+ call_id = str(entry.get("tool_call_id") or entry.get("id") or "")
74
+ name, args = calls.get(call_id, ("Tool", {}))
75
+ command = str(args.get("command") or args.get("cmd") or "")
76
+ path = str(args.get("path") or args.get("file_path") or "")
77
+ exit_code = entry.get("exit_code")
78
+ event = b.add(
79
+ kind=EventKind.RESULT,
80
+ ts=ts,
81
+ tool=name,
82
+ paths=[path] if path else [],
83
+ exit_code=int(exit_code) if isinstance(exit_code, int) else None,
84
+ flags=EventFlags(
85
+ piped=bool(command) and is_piped(command),
86
+ error=bool(entry.get("is_error")) or bool(exit_code),
87
+ ),
88
+ )
89
+ b.store_output(event, as_text(entry.get("output") or entry.get("content")))
90
+ elif etype in ("assistant_message", "message", "text"):
91
+ event = b.add(kind=EventKind.TEXT, ts=ts)
92
+ b.store_output(event, as_text(entry.get("content") or entry.get("message")))
93
+ return ts
94
+
95
+
96
+ def parse(path: str) -> tuple[Session, list[LedgerEvent], str | None]:
97
+ with open(path, encoding="utf-8") as fh:
98
+ bundle = json.load(fh)
99
+ if not isinstance(bundle, dict):
100
+ raise ValueError(f"{path}: expected a Copilot bundle object")
101
+ session = as_dict(bundle.get("session"))
102
+ pr = as_dict(bundle.get("pull_request"))
103
+ git = as_dict(bundle.get("git")) or {"commits": as_list(bundle.get("commits"))}
104
+ entries = as_list(bundle.get("log"))
105
+
106
+ session_id = str(session.get("id") or f"copilot-pr-{pr.get('number', 'unknown')}")
107
+ cwd = str(session.get("workspace") or git.get("root") or "") or None
108
+ started = as_ts(
109
+ session.get("started_at") or pr.get("created_at"), datetime.fromtimestamp(0, tz=UTC)
110
+ )
111
+
112
+ b = Builder(session_id, cwd)
113
+ b.add(
114
+ kind=EventKind.META,
115
+ ts=started,
116
+ input=redact(
117
+ {
118
+ "event": "session_meta",
119
+ "class": "R",
120
+ "pull_request": str(pr.get("html_url") or pr.get("url") or ""),
121
+ "log_present": bool(entries),
122
+ }
123
+ ),
124
+ )
125
+ if not entries:
126
+ no_tool_log(b, started, "Copilot session log not attached; evidence is git + CI only")
127
+ ts = _log(b, entries, started)
128
+ ts = commits(b, git, ts)
129
+ ts = checks(b, as_list(bundle.get("checks")), ts)
130
+
131
+ events = chain(b.events)
132
+ body = as_text(pr.get("body"))
133
+ report = f"## Pull request {pr.get('html_url') or ''}\n{body}".strip() if body else None
134
+ meta = Session(
135
+ id=session_id,
136
+ source="copilot",
137
+ agent=str(session.get("agent") or "copilot-coding-agent"),
138
+ model=str(session.get("model")) if session.get("model") else None,
139
+ started=started,
140
+ ended=as_ts(session.get("completed_at"), ts),
141
+ cwd=cwd,
142
+ git_branch=str(git.get("branch") or pr.get("head") or "") or None,
143
+ n_events=len(events),
144
+ ledger_root_hash=events[-1].hash if events else "",
145
+ # the bundle is assembled by the caller from the GitHub API, not signed by a harness:
146
+ # class R cannot reach 1.0 until the log's provenance is checkable (NEEDS-DECISION: A3)
147
+ integrity_score=0.8 if entries else 0.5,
148
+ )
149
+ return meta, events, report
150
+
151
+
152
+ def find_last_session(sessions_dir: str | None = None) -> str:
153
+ root = sessions_dir or os.path.expanduser("~/.custos-code/copilot")
154
+ bundles = [os.path.join(root, f) for f in os.listdir(root)] if os.path.isdir(root) else []
155
+ files = [p for p in bundles if p.endswith(".json") and os.path.isfile(p)]
156
+ if not files:
157
+ raise FileNotFoundError(f"no Copilot bundles under {root}")
158
+ return max(files, key=os.path.getmtime)