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,6 @@
1
+ """Custos Code: check a coding agent's final report against the log of what it actually did.
2
+
3
+ Read AGENTS.md before changing anything. Invariants live there. Every module below has a
4
+ docstring saying what it owns and what it must never do.
5
+ """
6
+ __version__ = "0.0.1"
@@ -0,0 +1,194 @@
1
+ """Adapters turn a source (harness transcript, hook payload, vendor export) into LedgerEvents.
2
+
3
+ Contract: every adapter is a function `parse(path_or_payload) -> tuple[Session, list[LedgerEvent], str | None]`
4
+ returning the session, the chained events, and the final report text if present.
5
+ Adapters must set flags.truncated / flags.piped / flags.sidechain honestly; downstream tiers rely on them.
6
+ Golden tests live in tests/golden/<adapter>/ : real input in, expected JSONL out.
7
+
8
+ `detect` picks the adapter from the file itself so `custos-code check <path>` needs no --agent flag.
9
+
10
+ `request_and_plan` (docs/SCOPE.md §6.3, issue #58) is scope's other input besides the ledger it
11
+ already gets: what was asked, and what the agent said it would do before doing it. It lives here,
12
+ not in `scope.py`, so scope stays agent-agnostic -- it takes a request and a plan, never a
13
+ harness-specific shape.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from collections.abc import Sequence
20
+ from typing import Protocol
21
+
22
+ from ..models import EventKind, LedgerEvent, Session
23
+ from . import claude_code, codex, copilot, devin, machine, otel
24
+
25
+ Source = str
26
+
27
+
28
+ class Adapter(Protocol):
29
+ def parse(self, path: str) -> tuple[Session, list[LedgerEvent], str | None]: ...
30
+
31
+
32
+ ADAPTERS: dict[Source, Adapter] = {
33
+ "claude_code": claude_code,
34
+ "codex": codex,
35
+ "devin": devin,
36
+ "machine": machine,
37
+ "copilot": copilot,
38
+ "otel": otel,
39
+ }
40
+
41
+ _CODEX_TYPES = frozenset(
42
+ {
43
+ "session_meta",
44
+ "turn_context",
45
+ "response_item",
46
+ "event_msg",
47
+ "compacted",
48
+ "thread_rolled_back",
49
+ }
50
+ )
51
+
52
+
53
+ def _bundle_source(obj: dict[str, object]) -> Source | None:
54
+ """Class-R bundles and OTLP payloads are one JSON object; tell them apart by their keys."""
55
+ if "resourceSpans" in obj or "scopeSpans" in obj:
56
+ return "otel"
57
+ raw = obj.get("session")
58
+ session: dict[str, object] = raw if isinstance(raw, dict) else {}
59
+ keys = set(obj) | {f"session.{k}" for k in session}
60
+ # Copilot first: both bundles carry `pull_request` and a `session.session_id`, and only
61
+ # Copilot carries a workspace or an attached tool log. Devin's tell is structured_output.
62
+ if "pull_request" in keys and (keys & {"log", "session.workspace", "session.agent_log"}):
63
+ return "copilot"
64
+ if keys & {"session.structured_output", "structured_output"}:
65
+ return "devin"
66
+ if keys & {"session.session_id", "session_id", "pull_request", "pull_requests"}:
67
+ return "devin"
68
+ return None
69
+
70
+
71
+ def _line_source(rec: dict[str, object]) -> Source | None:
72
+ if "sessionId" in rec or (rec.get("type") in ("assistant", "user") and "message" in rec):
73
+ return "claude_code"
74
+ if str(rec.get("type")) in _CODEX_TYPES:
75
+ return "codex"
76
+ if rec.get("recorder") in machine.RECORDER_NAMES:
77
+ return "machine"
78
+ if "traceId" in rec and "spanId" in rec:
79
+ return "otel"
80
+ return None
81
+
82
+
83
+ def detect(path: str) -> Source:
84
+ """Name the adapter for a file, by its shape. Raises ValueError when nothing matches."""
85
+ with open(path, encoding="utf-8", errors="ignore") as fh:
86
+ text = fh.read()
87
+ if not text.strip():
88
+ raise ValueError(f"{path} is empty")
89
+ try:
90
+ whole = json.loads(text)
91
+ except json.JSONDecodeError:
92
+ whole = None
93
+ if isinstance(whole, dict):
94
+ name = _bundle_source(whole)
95
+ if name:
96
+ return name
97
+ for line in text.splitlines():
98
+ if not line.strip():
99
+ continue
100
+ try:
101
+ rec = json.loads(line)
102
+ except json.JSONDecodeError:
103
+ continue
104
+ if isinstance(rec, dict):
105
+ name = _line_source(rec)
106
+ if name:
107
+ return name
108
+ raise ValueError(f"cannot tell which agent wrote {path}; pass --agent")
109
+
110
+
111
+ def parse(path: str, source: Source | None = None) -> tuple[Session, list[LedgerEvent], str | None]:
112
+ """Parse a transcript with the adapter named by `source`, or the one `detect` picks."""
113
+ name = source or detect(path)
114
+ adapter = ADAPTERS.get(name)
115
+ if adapter is None:
116
+ raise ValueError(f"unknown agent {name!r}; one of {', '.join(sorted(ADAPTERS))}")
117
+ return adapter.parse(path)
118
+
119
+
120
+ def _first_user_text(ledger: Sequence[LedgerEvent]) -> str:
121
+ """The earliest non-sidechain USER event's text -- the literal ask, verbatim.
122
+
123
+ Claude Code, Codex, and Devin (when it has a chat transcript) all write these already; nothing
124
+ new has to be recorded (SCOPE.md §6.3's whole premise).
125
+ """
126
+ for event in ledger:
127
+ if event.kind is EventKind.USER and not event.flags.sidechain and event.output:
128
+ return event.output
129
+ return ""
130
+
131
+
132
+ def _todo_plan(ledger: Sequence[LedgerEvent]) -> list[str]:
133
+ """Claude Code's `TodoWrite`: the most recent call's items, oldest first.
134
+
135
+ The latest call wins, not the first -- a plan is allowed to change, and the self-authored
136
+ contract SCOPE.md §3 describes is whatever the agent most recently committed to, not its first
137
+ draft.
138
+ """
139
+ latest: LedgerEvent | None = None
140
+ for event in ledger:
141
+ if event.kind is EventKind.CALL and event.tool == "TodoWrite" and not event.flags.sidechain:
142
+ latest = event
143
+ if latest is None:
144
+ return []
145
+ todos = (latest.input or {}).get("todos")
146
+ if not isinstance(todos, list):
147
+ return []
148
+ out = []
149
+ for item in todos:
150
+ if not isinstance(item, dict):
151
+ continue
152
+ text = item.get("content") or item.get("activeForm") or item.get("task")
153
+ if isinstance(text, str) and text.strip():
154
+ out.append(text.strip())
155
+ return out
156
+
157
+
158
+ def _stated_plan_before_first_call(ledger: Sequence[LedgerEvent]) -> list[str]:
159
+ """Fallback plan: the last thing the agent said before it touched a tool.
160
+
161
+ A message written before any evidence exists is a commitment it cannot later revise -- the
162
+ same "log it has no write path to" property SCOPE.md §3 grounds the plan in. Applies to any
163
+ adapter whose ledger has TEXT/CALL events in the shared shape (Claude Code, Codex today).
164
+ """
165
+ first_call_seq = next(
166
+ (e.seq for e in ledger if e.kind is EventKind.CALL and not e.flags.sidechain), None
167
+ )
168
+ last_text = ""
169
+ for event in ledger:
170
+ if first_call_seq is not None and event.seq >= first_call_seq:
171
+ break
172
+ if event.kind is EventKind.TEXT and not event.flags.sidechain and event.output:
173
+ last_text = event.output
174
+ return [last_text] if last_text.strip() else []
175
+
176
+
177
+ def request_and_plan(
178
+ session: Session, ledger: Sequence[LedgerEvent], report: str | None = None
179
+ ) -> tuple[str, list[str]]:
180
+ """The spec and the agent's self-authored plan, per docs/SCOPE.md §6.3.
181
+
182
+ Deliberately reads the ledger, not the harness: Claude Code and Codex both already write a
183
+ USER event for the request, so no per-agent branch is needed to find it. Class-R bundles
184
+ (Copilot, Devin without a chat transcript) have no live back-and-forth to draw either from --
185
+ there, the PR body (`report`, already the same text `custos-code check` extracts claims from)
186
+ doubles as the spec, exactly as SCOPE.md §6.3 says, and there is no separate plan to find.
187
+
188
+ A `TodoWrite` call is the strongest plan signal where one exists; otherwise the last thing said
189
+ before the first tool call stands in for it (still empty for a class-R bundle, which has no
190
+ "before the first call" boundary to speak of).
191
+ """
192
+ request = _first_user_text(ledger) or (report or "")
193
+ plan = _todo_plan(ledger) or _stated_plan_before_first_call(ledger)
194
+ return request, plan
@@ -0,0 +1,266 @@
1
+ """Claude Code session JSONL -> ledger (class F, post-hoc). See docs/ADAPTERS.md §2.
2
+
3
+ Observed on the transcripts on this machine (299 sessions surveyed):
4
+ - records of interest have `type` in {assistant, user} and `message.content` as a list of blocks;
5
+ every such record carries `timestamp`, `sessionId`, `cwd`, `gitBranch`, `uuid`, `parentUuid`,
6
+ `version`, and `isSidechain`.
7
+ - assistant blocks: `text`, `thinking`, `tool_use{id, name, input}`.
8
+ - user blocks: `tool_result{tool_use_id, content, is_error}`; the record also carries `toolUseResult`:
9
+ Bash -> {stdout, stderr, interrupted, isImage, noOutputExpected[, gitOperation]} (no exit code)
10
+ Edit -> {filePath, oldString, newString, replaceAll, originalFile, structuredPatch}
11
+ Write -> {filePath, content, originalFile, structuredPatch, type, userModified}
12
+ Read -> {file{filePath, ...}, type}
13
+ - the report for a turn is the last assistant `text` block before the next human user message;
14
+ the session report is the last one in the file.
15
+
16
+ Owner: Oliver.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import glob
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import re
25
+ from datetime import datetime
26
+ from typing import Any
27
+
28
+ from .. import parsers
29
+ from ..ledger import MAX_OUTPUT_BYTES, chain, redact
30
+ from ..models import EventFlags, EventKind, LedgerEvent, Session
31
+
32
+ # Pipe detection lives in `parsers.is_piped`, which every other adapter already used. The copy
33
+ # that lived here missed `> file` and `>> file`, so `pytest -q > results.txt` was recorded
34
+ # unfiltered and `rules._outcome` CONFIRMED the claim on exit status alone -- a run whose whole
35
+ # output went to a file and was never parsed. This adapter and the live hook were the only two
36
+ # users of the weaker list, which is the worst pair to have it in.
37
+ _PATH_RE = re.compile(r"(?<![\w-])((?:\.{0,2}/)?[\w.-]+(?:/[\w.-]+)+\.[A-Za-z0-9]{1,8})")
38
+
39
+
40
+ def _ts(rec: dict[str, Any]) -> datetime:
41
+ raw = rec.get("timestamp")
42
+ if isinstance(raw, str):
43
+ try:
44
+ return datetime.fromisoformat(raw.replace("Z", "+00:00"))
45
+ except ValueError:
46
+ pass
47
+ return datetime.fromtimestamp(0)
48
+
49
+
50
+ def _paths_from_input(tool: str, inp: dict[str, Any], cwd: str | None) -> list[str]:
51
+ out: list[str] = []
52
+ for key in ("file_path", "path", "notebook_path"):
53
+ v = inp.get(key)
54
+ if isinstance(v, str) and v:
55
+ out.append(v)
56
+ if tool == "Bash":
57
+ cmd = inp.get("command")
58
+ if isinstance(cmd, str):
59
+ out.extend(m.group(1) for m in _PATH_RE.finditer(cmd))
60
+ seen: set[str] = set()
61
+ res: list[str] = []
62
+ for p in out:
63
+ q = p if os.path.isabs(p) or not cwd else os.path.normpath(os.path.join(cwd, p))
64
+ if q not in seen:
65
+ seen.add(q)
66
+ res.append(q)
67
+ return res
68
+
69
+
70
+ def _result_text(block: dict[str, Any], tur: Any) -> str:
71
+ if isinstance(tur, dict):
72
+ so, se = tur.get("stdout"), tur.get("stderr")
73
+ if isinstance(so, str) or isinstance(se, str):
74
+ return (so or "") + (("\n" + se) if se else "")
75
+ if isinstance(tur.get("content"), str):
76
+ return str(tur["content"])
77
+ c = block.get("content")
78
+ if isinstance(c, str):
79
+ return c
80
+ if isinstance(c, list):
81
+ return "\n".join(str(x.get("text", "")) for x in c if isinstance(x, dict))
82
+ return ""
83
+
84
+
85
+ _EXIT_RE = re.compile(r"\AExit code (\d+)\b")
86
+
87
+
88
+ def _exit_code_from(block: dict[str, Any], text: str) -> int | None:
89
+ """The exit status, only when the transcript states it.
90
+
91
+ Claude Code does not record an exit code field -- but a failing Bash result is delivered as
92
+ `is_error` with content that begins `Exit code 1`, and 1055 results in a 40-session sample
93
+ carry it. The adapter hardcoded None, which left `review._corroborate` with one of its three
94
+ grounding paths dead by construction: no cited event could ever show a non-zero exit, so that
95
+ branch never fired on this adapter.
96
+
97
+ A success is NOT inferred. `is_error` being absent is the harness saying it did not flag the
98
+ call, which is not the same as the process returning 0, and this project does not get to
99
+ promote an absence into a witnessed fact -- that is the move it marks `unwitnessed`.
100
+ """
101
+ if not block.get("is_error"):
102
+ return None
103
+ m = _EXIT_RE.match(text.lstrip())
104
+ return int(m.group(1)) if m else None
105
+
106
+
107
+ def _patch_text(tur: Any) -> str:
108
+ """The lines an Edit/Write changed, from `structuredPatch`.
109
+
110
+ Without this the judge sees `The file ... has been updated successfully.` and nothing else, so
111
+ every claim about *what* an edit said is unverifiable by construction -- 1369 Edit calls across
112
+ 29 of 96 real sessions. The patch is already in the transcript; we were discarding it.
113
+
114
+ Redacted like any other recorded content, because a diff carries file contents.
115
+ """
116
+ if not isinstance(tur, dict):
117
+ return ""
118
+ hunks = tur.get("structuredPatch")
119
+ if not isinstance(hunks, list) or not hunks:
120
+ return ""
121
+ out: list[str] = []
122
+ for h in hunks:
123
+ if not isinstance(h, dict):
124
+ continue
125
+ out.append(f"@@ -{h.get('oldStart')},{h.get('oldLines')} +{h.get('newStart')},{h.get('newLines')} @@")
126
+ out += [str(ln) for ln in h.get("lines", []) if isinstance(ln, str)]
127
+ return redact("\n".join(out)) if out else ""
128
+
129
+
130
+ def _flags_from_result(tool: str | None, inp: dict[str, Any] | None, block: dict[str, Any], tur: Any, sidechain: bool) -> EventFlags:
131
+ f = EventFlags(sidechain=sidechain)
132
+ if bool(block.get("is_error")):
133
+ f.error = True
134
+ if isinstance(tur, dict) and bool(tur.get("interrupted")):
135
+ f.interrupted = True
136
+ if tool == "Bash" and inp and isinstance(inp.get("command"), str) and parsers.is_piped(inp["command"]):
137
+ f.piped = True
138
+ return f
139
+
140
+
141
+ def parse(path: str) -> tuple[Session, list[LedgerEvent], str | None]:
142
+ """Parse one Claude Code transcript into a chained ledger.
143
+
144
+ Returns the session, all events (main chain and sidechains, flagged), and the session's final
145
+ report text (last assistant text block on the main chain), or None if there is none.
146
+ """
147
+ calls: dict[str, tuple[str, dict[str, Any]]] = {}
148
+ events: list[LedgerEvent] = []
149
+ session_id = ""
150
+ cwd: str | None = None
151
+ branch: str | None = None
152
+ started: datetime | None = None
153
+ ended: datetime | None = None
154
+ report: str | None = None
155
+ seq = 0
156
+
157
+ with open(path, encoding="utf-8", errors="ignore") as fh:
158
+ for line in fh:
159
+ try:
160
+ rec = json.loads(line)
161
+ except json.JSONDecodeError:
162
+ continue
163
+ if rec.get("type") not in ("assistant", "user"):
164
+ continue
165
+ msg = rec.get("message") or {}
166
+ content = msg.get("content")
167
+ if not isinstance(content, list):
168
+ continue
169
+ session_id = session_id or str(rec.get("sessionId", ""))
170
+ cwd = cwd or rec.get("cwd")
171
+ branch = branch or rec.get("gitBranch")
172
+ ts = _ts(rec)
173
+ started = started or ts
174
+ ended = ts
175
+ sidechain = bool(rec.get("isSidechain"))
176
+ rec_cwd = rec.get("cwd") if isinstance(rec.get("cwd"), str) else cwd
177
+
178
+ for block in content:
179
+ if not isinstance(block, dict):
180
+ continue
181
+ kind = block.get("type")
182
+ if rec["type"] == "assistant" and kind == "tool_use":
183
+ tool = str(block.get("name", ""))
184
+ raw_inp = block.get("input")
185
+ inp: dict[str, Any] = dict(raw_inp) if isinstance(raw_inp, dict) else {}
186
+ calls[str(block.get("id"))] = (tool, inp)
187
+ events.append(LedgerEvent(
188
+ seq=seq, ts=ts, session_id=session_id, kind=EventKind.CALL, tool=tool,
189
+ input=redact(inp), paths=_paths_from_input(tool, inp, rec_cwd), cwd=rec_cwd,
190
+ flags=EventFlags(sidechain=sidechain),
191
+ ))
192
+ seq += 1
193
+ elif rec["type"] == "user" and kind == "tool_result":
194
+ call = calls.get(str(block.get("tool_use_id")))
195
+ rtool: str | None = call[0] if call else None
196
+ rinp: dict[str, Any] = call[1] if call else {}
197
+ tur = rec.get("toolUseResult")
198
+ full = _result_text(block, tur)
199
+ full = redact(full)
200
+ flags = _flags_from_result(rtool, rinp, block, tur, sidechain)
201
+ paths: list[str] = []
202
+ if isinstance(tur, dict):
203
+ fp: object = tur.get("filePath")
204
+ if fp is None and isinstance(tur.get("file"), dict):
205
+ fp = tur["file"].get("filePath")
206
+ if isinstance(fp, str):
207
+ paths.append(fp)
208
+ if (patch := _patch_text(tur)):
209
+ full = f"{full}\n{patch}" if full.strip() else patch
210
+ if len(full.encode()) > MAX_OUTPUT_BYTES:
211
+ flags.truncated = True
212
+ events.append(LedgerEvent(
213
+ seq=seq, ts=ts, session_id=session_id, kind=EventKind.RESULT, tool=rtool,
214
+ output=full.encode()[:MAX_OUTPUT_BYTES].decode(errors="ignore"),
215
+ output_hash=hashlib.sha256(full.encode()).hexdigest(),
216
+ exit_code=_exit_code_from(block, full), paths=paths, cwd=rec_cwd, flags=flags,
217
+ ))
218
+ seq += 1
219
+ elif rec["type"] == "assistant" and kind == "text":
220
+ text = str(block.get("text", ""))
221
+ if not text.strip():
222
+ continue
223
+ events.append(LedgerEvent(
224
+ seq=seq, ts=ts, session_id=session_id, kind=EventKind.TEXT, tool=None,
225
+ output=redact(text), cwd=rec_cwd, flags=EventFlags(sidechain=sidechain),
226
+ ))
227
+ seq += 1
228
+ if not sidechain:
229
+ report = text
230
+ elif rec["type"] == "user" and kind == "text":
231
+ events.append(LedgerEvent(
232
+ seq=seq, ts=ts, session_id=session_id, kind=EventKind.USER, tool=None,
233
+ output=redact(str(block.get("text", "")))[:MAX_OUTPUT_BYTES], cwd=rec_cwd,
234
+ flags=EventFlags(sidechain=sidechain),
235
+ ))
236
+ seq += 1
237
+
238
+ events = chain(events)
239
+ session = Session(
240
+ id=session_id or os.path.basename(path).removesuffix(".jsonl"), source="claude_code",
241
+ agent="claude-code", started=started, ended=ended, cwd=cwd, git_branch=branch,
242
+ n_events=len(events), ledger_root_hash=events[-1].hash if events else "",
243
+ )
244
+ return session, events, report
245
+
246
+
247
+ def find_last_session(projects_dir: str | None = None) -> str:
248
+ """Most recently modified transcript under ~/.claude/projects (for `custos-code check --last`)."""
249
+ root = projects_dir or os.path.expanduser("~/.claude/projects")
250
+ files = glob.glob(os.path.join(root, "*", "*.jsonl"))
251
+ if not files:
252
+ raise FileNotFoundError(f"no Claude Code transcripts under {root}")
253
+ return max(files, key=os.path.getmtime)
254
+
255
+
256
+ def find_sessions(projects_dir: str | None = None, limit: int | None = None) -> list[str]:
257
+ """Local transcripts, newest first. Powers `custos-code scan`.
258
+
259
+ The point of scanning real history rather than a fixture: a staged trap is only caught when the
260
+ agent takes the bait, and a careful agent simply does not. Real sessions contain the failures
261
+ that actually happen -- a sample reported as a total, a remembered test count, a "verified
262
+ working" that skipped the one command that failed.
263
+ """
264
+ root = projects_dir or os.path.expanduser("~/.claude/projects")
265
+ files = sorted(glob.glob(os.path.join(root, "*", "*.jsonl")), key=os.path.getmtime, reverse=True)
266
+ return files[:limit] if limit else files