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.
- custos_code/__init__.py +6 -0
- custos_code/adapters/__init__.py +194 -0
- custos_code/adapters/claude_code.py +266 -0
- custos_code/adapters/codex.py +437 -0
- custos_code/adapters/copilot.py +158 -0
- custos_code/adapters/devin.py +172 -0
- custos_code/adapters/machine.py +379 -0
- custos_code/adapters/otel.py +210 -0
- custos_code/adapters/state.py +164 -0
- custos_code/claims.py +319 -0
- custos_code/cli.py +789 -0
- custos_code/compress.py +113 -0
- custos_code/cost.py +216 -0
- custos_code/demo_fixtures/__init__.py +1 -0
- custos_code/demo_fixtures/ok_tests_0.jsonl +8 -0
- custos_code/demo_fixtures/trap_echo_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_ghost_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_piped_0.jsonl +4 -0
- custos_code/feedback.py +93 -0
- custos_code/hooks.py +648 -0
- custos_code/judge.py +338 -0
- custos_code/ledger.py +93 -0
- custos_code/models.py +129 -0
- custos_code/parsers.py +408 -0
- custos_code/report.py +317 -0
- custos_code/rerun.py +424 -0
- custos_code/review.py +381 -0
- custos_code/rules.py +464 -0
- custos_code/scope.py +471 -0
- custos_code/verdicts.py +296 -0
- custos_code-0.0.1.dist-info/METADATA +138 -0
- custos_code-0.0.1.dist-info/RECORD +35 -0
- custos_code-0.0.1.dist-info/WHEEL +4 -0
- custos_code-0.0.1.dist-info/entry_points.txt +2 -0
- custos_code-0.0.1.dist-info/licenses/LICENSE +21 -0
custos_code/__init__.py
ADDED
|
@@ -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
|