tycho-cli 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.
tycho/events.py ADDED
@@ -0,0 +1,393 @@
1
+ """Parse a Claude Code transcript (JSONL) into normalized Events + FileEdits.
2
+
3
+ Transcript schema (verified against a real session):
4
+ - each line is a JSON entry with a top-level `timestamp` and a `message.content`
5
+ list of blocks;
6
+ - an assistant `tool_use` block carries `id`, `name`, `input`;
7
+ - a user `tool_result` block carries `tool_use_id`, `is_error`, and the entry
8
+ carries the structured `toolUseResult` (Bash: stdout/stderr; Edit/Write:
9
+ filePath/originalFile/type/…).
10
+ Bash results have no numeric exit code — `is_error` is the failure signal.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+
20
+ from . import runlog
21
+ from .model import Event, FileEdit, Message
22
+
23
+ _EDIT_TOOLS = frozenset({"Edit", "Write", "MultiEdit"})
24
+
25
+
26
+ def _entries(transcript: Path):
27
+ """Yield parsed JSONL entries, skipping blank/malformed lines (external data)."""
28
+ for line in Path(transcript).read_text(encoding="utf-8").splitlines():
29
+ line = line.strip()
30
+ if not line:
31
+ continue
32
+ try:
33
+ yield json.loads(line)
34
+ except json.JSONDecodeError:
35
+ continue
36
+
37
+
38
+ def parse(transcript: Path) -> tuple[Event, ...]:
39
+ """Read a Claude Code JSONL transcript → Events sorted by completion time."""
40
+ uses: dict[str, tuple[float, str, dict]] = {}
41
+ results: dict[str, tuple[float, bool, dict]] = {}
42
+
43
+ for entry in _entries(transcript):
44
+ ts = _epoch(entry.get("timestamp"))
45
+ for block in _blocks(entry):
46
+ kind = block.get("type")
47
+ if kind == "tool_use" and block.get("id"):
48
+ uses[block["id"]] = (ts, block.get("name", ""), block.get("input") or {})
49
+ elif kind == "tool_result" and block.get("tool_use_id"):
50
+ tur = entry.get("toolUseResult")
51
+ results[block["tool_use_id"]] = (
52
+ ts,
53
+ bool(block.get("is_error")),
54
+ tur if isinstance(tur, dict) else {},
55
+ )
56
+
57
+ events = []
58
+ for uid, (use_ts, name, inp) in uses.items():
59
+ result = results.get(uid)
60
+ if result is None:
61
+ events.append(Event(ts=use_ts, tool=name, input=inp, is_error=None, result={}))
62
+ else:
63
+ res_ts, is_error, res = result
64
+ events.append(
65
+ Event(ts=res_ts or use_ts, tool=name, input=inp, is_error=is_error, result=res)
66
+ )
67
+ return tuple(sorted(events, key=lambda e: e.ts))
68
+
69
+
70
+ def assistant_messages(transcript: Path) -> tuple[Message, ...]:
71
+ """The assistant's natural-language `text` blocks — the agent's prose claims, for
72
+ `tool_call_provenance`. Skips tool_use/tool_result blocks and harness meta; a message
73
+ with no real text is dropped. Same JSONL schema as `parse` (verified fixture)."""
74
+ out = []
75
+ for entry in _entries(transcript):
76
+ if entry.get("type") != "assistant" or entry.get("isMeta"):
77
+ continue
78
+ ts = _epoch(entry.get("timestamp"))
79
+ for block in _blocks(entry):
80
+ if block.get("type") == "text":
81
+ text = block.get("text")
82
+ if isinstance(text, str) and text.strip():
83
+ out.append(Message(ts=ts, text=text))
84
+ return tuple(out)
85
+
86
+
87
+ def turn_start(transcript: Path) -> float:
88
+ """Epoch at which the transcript's last turn began — its final user message.
89
+
90
+ A Claude turn is `user prose -> assistant work -> assistant stop_reason=end_turn`,
91
+ and the Stop hook fires at the end of the last one. We anchor on the *user* side
92
+ rather than counting `end_turn` markers: on real sessions one turn routinely emits
93
+ two adjacent `end_turn` assistant messages (a contentful one preceded by an empty
94
+ one), so a marker count over-counts turns and would place the boundary inside the
95
+ turn it's meant to open. User messages have no such duplication — on the session
96
+ this was verified against, 15 `end_turn` markers coalesce to exactly the 11 user
97
+ messages, alternating cleanly.
98
+
99
+ Real user prose arrives as a plain `message.content` string; a content *list* is
100
+ how Claude delivers `tool_result` blocks back to itself, so a list only counts as
101
+ a turn start when it carries a genuine `text` block (a pasted image + prompt).
102
+ Returns 0.0 when no user message is found — "the whole transcript is the turn",
103
+ which is both the single-turn case and the honest fallback.
104
+ """
105
+ starts = [_epoch(e.get("timestamp")) for e in _entries(transcript) if _is_user_prose(e)]
106
+ return max(starts, default=0.0)
107
+
108
+
109
+ def _is_user_prose(entry: dict) -> bool:
110
+ """True for a real user message — not a tool_result carrier, not harness meta."""
111
+ if entry.get("type") != "user" or entry.get("isMeta"):
112
+ return False
113
+ content = (entry.get("message") or {}).get("content")
114
+ if isinstance(content, str):
115
+ return bool(content.strip())
116
+ return any(b.get("type") == "text" for b in _blocks(entry))
117
+
118
+
119
+ def parse_cursor(transcript: Path) -> tuple[Event, ...]:
120
+ """Read a Cursor agent transcript → Events.
121
+
122
+ Cursor's Stop transcript is thinner than Claude's: each ``tool_use`` block
123
+ carries only ``name`` + ``input`` — no ids, no timestamps, and no
124
+ ``tool_result`` blocks at all. So Events get ts=0.0, is_error=None,
125
+ result={}, and checks that need timings or exit codes (test_freshness,
126
+ command_execution) degrade to UNSUPPORTED honestly for Cursor sessions.
127
+ Verified against a real transcript at ``tests/fixtures/cursor_transcript_sample.jsonl``.
128
+ """
129
+ events = []
130
+ for entry in _entries(transcript):
131
+ for block in _blocks(entry):
132
+ if block.get("type") == "tool_use" and block.get("name"):
133
+ tool = {"Shell": "Bash", "StrReplace": "Edit"}.get(block["name"], block["name"])
134
+ events.append(Event(ts=0.0, tool=tool, input=block.get("input") or {}))
135
+ return tuple(events)
136
+
137
+
138
+ def parse_codex(transcript: Path) -> tuple[Event, ...]:
139
+ """Read *every* turn from a Codex rollout JSONL transcript.
140
+
141
+ Returns all turns' events, not just the latest, so the session-scoped checks can
142
+ reason across turns (freshness/provenance, and the AST checks that diff the earliest
143
+ original against disk); the Stop narrows to the turn under review via
144
+ ``Harness.turn_start`` (``turn_start_codex``), exactly as Claude does. (TYCHO-20 —
145
+ this used to filter to the latest ``turn_id`` in the reader, which left those checks
146
+ blind to earlier turns and made Codex's scope disagree with every other reader.)
147
+ """
148
+ calls: dict[str, tuple[float, str]] = {}
149
+ results: dict[str, tuple[bool | None, str]] = {} # call_id -> (is_error, output text)
150
+ events = []
151
+ for entry in _entries(transcript):
152
+ payload = entry.get("payload") or {}
153
+ ts = _epoch(entry.get("timestamp"))
154
+ if entry.get("type") == "response_item" and payload.get("type") == "custom_tool_call":
155
+ command = _codex_command(payload.get("input"))
156
+ if command:
157
+ calls[payload.get("call_id", "")] = (ts, command)
158
+ elif entry.get("type") == "response_item" and payload.get("type") == "custom_tool_call_output":
159
+ output = payload.get("output")
160
+ results[payload.get("call_id", "")] = (_codex_is_error(output), _codex_output_text(output))
161
+ elif entry.get("type") == "event_msg" and payload.get("type") == "patch_apply_end":
162
+ if not payload.get("success"):
163
+ continue
164
+ for path, change in (payload.get("changes") or {}).items():
165
+ kind = change.get("type", "update")
166
+ events.append(
167
+ Event(
168
+ ts=ts,
169
+ tool="Write",
170
+ input={"path": path},
171
+ is_error=False,
172
+ result={"type": "create" if kind == "add" else "edit"},
173
+ )
174
+ )
175
+ events.extend(
176
+ Event(
177
+ ts=ts,
178
+ tool="Bash",
179
+ input={"command": command},
180
+ is_error=results.get(call_id, (None, ""))[0],
181
+ # Keep the runner's own words, not just the verdict we distilled from them.
182
+ # `is_error` alone is useless the moment the shell masks the status: the engine
183
+ # then has to re-read the output, and it can only read what the reader kept
184
+ # (TYCHO-60). Codex is the one harness whose transcript carries this text.
185
+ result={"stdout": out} if (out := results.get(call_id, (None, ""))[1]) else {},
186
+ )
187
+ for call_id, (ts, command) in calls.items()
188
+ )
189
+ return tuple(sorted(events, key=lambda e: e.ts))
190
+
191
+
192
+ def turn_start_codex(transcript: Path) -> float:
193
+ """Epoch of the latest Codex turn's ``task_started`` — the boundary its Stop reviews.
194
+
195
+ ``parse_codex`` now returns every turn (TYCHO-20), so the engine narrows to the turn
196
+ under review via ``turn_start`` just like Claude. We anchor on the latest turn that
197
+ actually has tool calls or edits: Codex emits empty trailing turns (compaction
198
+ follow-ups), and anchoring on one of those would push the boundary past all real work
199
+ and blank out ``turn_edits``. 0.0 when there is no such turn — the honest "the whole
200
+ transcript is the turn" fallback.
201
+ """
202
+ entries = list(_entries(transcript))
203
+ turn_ids = [
204
+ e.get("payload", {}).get("turn_id")
205
+ for e in entries
206
+ if e.get("type") == "event_msg" and e.get("payload", {}).get("type") == "task_started"
207
+ ]
208
+ turn_id = _codex_latest_turn_with_events(entries, turn_ids)
209
+ if turn_id is None:
210
+ return 0.0
211
+ for e in entries:
212
+ p = e.get("payload") or {}
213
+ if e.get("type") == "event_msg" and p.get("type") == "task_started" and p.get("turn_id") == turn_id:
214
+ return _epoch(e.get("timestamp"))
215
+ return 0.0
216
+
217
+
218
+ def _opencode_data(transcript: Path) -> dict:
219
+ """Load an OpenCode session JSON (``{info, messages:[{info, parts:[…]}]}``)."""
220
+ text = Path(transcript).read_text(encoding="utf-8")
221
+ return json.loads(text[text.index("{"):])
222
+
223
+
224
+ def parse_opencode(transcript: Path) -> tuple[Event, ...]:
225
+ """Read an OpenCode session JSON (``{info, messages:[{parts:[…]}]}``).
226
+
227
+ Tycho rebuilds this shape from ``opencode.db`` (see ``opencode.py``); it's
228
+ identical to what ``opencode export`` emits, so this reader is unchanged.
229
+ """
230
+ data = _opencode_data(transcript)
231
+ events = []
232
+ for message in data.get("messages", []):
233
+ for part in message.get("parts", []):
234
+ if part.get("type") != "tool":
235
+ continue
236
+ state = part.get("state") or {}
237
+ timing = state.get("time") or {}
238
+ metadata = state.get("metadata") or {}
239
+ status = state.get("status")
240
+ exit_code = metadata.get("exit")
241
+ is_error = (
242
+ exit_code != 0 if isinstance(exit_code, int)
243
+ else True if status == "error"
244
+ else None
245
+ )
246
+ tool = {"bash": "Bash", "edit": "Edit", "write": "Write"}.get(
247
+ part.get("tool"), part.get("tool", "")
248
+ )
249
+ events.append(Event(
250
+ ts=(timing.get("end") or timing.get("start") or 0) / 1000,
251
+ tool=tool,
252
+ input=state.get("input") or {},
253
+ is_error=is_error,
254
+ result={"type": "edit"} if tool in _EDIT_TOOLS else {},
255
+ ))
256
+ return tuple(sorted(events, key=lambda e: e.ts))
257
+
258
+
259
+ def turn_start_opencode(transcript: Path) -> float:
260
+ """Epoch of the OpenCode session's last user message — the boundary its Stop reviews.
261
+
262
+ An OpenCode turn runs `user message -> assistant messages/tool parts`, so a user
263
+ message *starts* the next turn; the last one opens the turn the Stop fires on. Snitch
264
+ reads the same store and treats a user line the same way (`reader_opencode.go`).
265
+
266
+ OpenCode timestamps in ms, but ``parse_opencode`` divides by 1000 to give ``Event.ts``
267
+ in seconds — so the boundary is scaled to match. A ms boundary against second-scale
268
+ events would sit ~1000x in the future and silently blank out ``turn_edits``, which is
269
+ the quiet failure this scoping exists to prevent.
270
+
271
+ Returns 0.0 when no user message carries a timestamp — the same honest "the whole
272
+ transcript is the turn" fallback as the other readers.
273
+ """
274
+ starts = [
275
+ created
276
+ for message in _opencode_data(transcript).get("messages", [])
277
+ if (info := message.get("info") or {}).get("role") == "user"
278
+ and isinstance(created := (info.get("time") or {}).get("created"), (int, float))
279
+ ]
280
+ return max(starts, default=0) / 1000
281
+
282
+
283
+ def _codex_latest_turn_with_events(entries: list[dict], turn_ids: list[str]) -> str | None:
284
+ """Find the most recent turn that actually has tool calls or edits."""
285
+ for tid in reversed(turn_ids):
286
+ for e in entries:
287
+ p = e.get("payload") or {}
288
+ tagged = p.get("turn_id") or (p.get("internal_chat_message_metadata_passthrough") or {}).get("turn_id")
289
+ if tagged != tid:
290
+ continue
291
+ if e.get("type") == "response_item" and p.get("type") in ("custom_tool_call",):
292
+ return tid
293
+ if e.get("type") == "event_msg" and p.get("type") == "patch_apply_end":
294
+ return tid
295
+ return None
296
+
297
+
298
+ def _codex_command(value: object) -> str | None:
299
+ if not isinstance(value, str):
300
+ return None
301
+ start = value.find('{cmd:"')
302
+ if start < 0:
303
+ return None
304
+ start += 6
305
+ for delim in ('","workdir"', '",', '"}', '")'):
306
+ end = value.find(delim, start)
307
+ if end >= 0:
308
+ return value[start:end]
309
+ if value.endswith('"') and start < len(value) - 1:
310
+ return value[start:-1]
311
+ return None
312
+
313
+
314
+ def _codex_output_text(value: object) -> str:
315
+ """Flatten a Codex tool-call output into the text the command actually printed.
316
+
317
+ Codex nests it (`[{"type": "input_text", "text": "77 passed in 0.79s"}]`), so pull the
318
+ `text` leaves out and leave the scaffolding behind. Normalizing it into `result` is
319
+ what lets the harness-agnostic engine re-read a runner's verdict when the shell masked
320
+ the exit status — the reader is the only layer allowed to know this shape.
321
+ """
322
+ if isinstance(value, str):
323
+ return value
324
+ if isinstance(value, dict):
325
+ if isinstance(text := value.get("text"), str):
326
+ return text
327
+ return "\n".join(t for v in value.values() if (t := _codex_output_text(v)))
328
+ if isinstance(value, list):
329
+ return "\n".join(t for item in value if (t := _codex_output_text(item)))
330
+ return ""
331
+
332
+
333
+ def _codex_is_error(value: object) -> bool | None:
334
+ if isinstance(value, dict):
335
+ if isinstance(value.get("exit_code"), int):
336
+ return value["exit_code"] != 0
337
+ return next((r for item in value.values() if (r := _codex_is_error(item)) is not None), None)
338
+ if isinstance(value, list):
339
+ return next((r for item in value if (r := _codex_is_error(item)) is not None), None)
340
+ if isinstance(value, str):
341
+ match = re.search(r'"exit_code"\s*:\s*(\d+)', value)
342
+ if match:
343
+ return int(match.group(1)) != 0
344
+ # Current Codex rollouts omit the exec exit code, so fall back to the runner's own
345
+ # summary — the same reading `checks` does for a shell-masked status, shared via
346
+ # `runlog` so one runner's output format is described in exactly one place.
347
+ # ("Script completed" is deliberately not success: it appears for failed commands.)
348
+ return runlog.outcome(value)
349
+ return None
350
+
351
+
352
+ def file_edits(events: tuple[Event, ...]) -> tuple[FileEdit, ...]:
353
+ """Project the *successful* Edit/Write events into FileEdits (path, ts, before-content).
354
+
355
+ Reads Claude's ``file_path``/``filePath`` or Cursor's ``path`` input key.
356
+
357
+ A failed call is not evidence of an edit: one denied by a PreToolUse hook, blocked, or
358
+ errored never touched the disk, and — having no ``toolUseResult`` — would land here as a
359
+ phantom ``kind=create, original=None`` row (TYCHO-33). ``is_error is None`` means *no
360
+ status was recorded*, not failure, so those are still counted; Cursor never records one
361
+ and would otherwise report zero edits.
362
+ """
363
+ out = []
364
+ for e in events:
365
+ if e.tool not in _EDIT_TOOLS or e.is_error:
366
+ continue
367
+ path = (
368
+ e.input.get("file_path")
369
+ or e.input.get("filePath")
370
+ or e.input.get("path")
371
+ or e.result.get("filePath")
372
+ )
373
+ if not path:
374
+ continue
375
+ original = e.result.get("originalFile")
376
+ kind = e.result.get("type") or ("edit" if original is not None else "create")
377
+ out.append(FileEdit(path=path, ts=e.ts, original=original, kind=kind))
378
+ return tuple(out)
379
+
380
+
381
+ def _blocks(entry: dict) -> list:
382
+ message = entry.get("message")
383
+ content = message.get("content") if isinstance(message, dict) else None
384
+ return content if isinstance(content, list) else []
385
+
386
+
387
+ def _epoch(stamp: str | None) -> float:
388
+ if not stamp:
389
+ return 0.0
390
+ try:
391
+ return datetime.fromisoformat(stamp.replace("Z", "+00:00")).timestamp()
392
+ except (ValueError, TypeError):
393
+ return 0.0
tycho/fsstate.py ADDED
@@ -0,0 +1,30 @@
1
+ """Filesystem reader: existence, content hash, mtime. Read-only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+
8
+ _CHUNK = 65536
9
+
10
+
11
+ def exists(path: Path | str) -> bool:
12
+ return Path(path).is_file()
13
+
14
+
15
+ def sha256(path: Path | str) -> str | None:
16
+ """Hex digest of the file, or None if it isn't a readable file."""
17
+ p = Path(path)
18
+ if not p.is_file():
19
+ return None
20
+ h = hashlib.sha256()
21
+ with p.open("rb") as f:
22
+ while chunk := f.read(_CHUNK):
23
+ h.update(chunk)
24
+ return h.hexdigest()
25
+
26
+
27
+ def mtime(path: Path | str) -> float | None:
28
+ """Modification time (epoch seconds), or None if the file is absent."""
29
+ p = Path(path)
30
+ return p.stat().st_mtime if p.is_file() else None
tycho/gitstate.py ADDED
@@ -0,0 +1,46 @@
1
+ """Read-only git reader via subprocess. Never writes to the repo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+
9
+ def _git(repo: Path, *args: str) -> tuple[int, str]:
10
+ """Run a git command in `repo`; return (returncode, stdout). Never raises on
11
+ non-zero — callers decide what a failure means."""
12
+ proc = subprocess.run(
13
+ ["git", "-C", str(repo), *args],
14
+ capture_output=True,
15
+ text=True,
16
+ )
17
+ return proc.returncode, proc.stdout
18
+
19
+
20
+ def is_repo(repo: Path) -> bool:
21
+ code, _ = _git(repo, "rev-parse", "--git-dir")
22
+ return code == 0
23
+
24
+
25
+ def head_sha(repo: Path) -> str | None:
26
+ code, out = _git(repo, "rev-parse", "HEAD")
27
+ return out.strip() if code == 0 else None
28
+
29
+
30
+ def commit_exists(repo: Path, ref: str) -> bool:
31
+ code, _ = _git(repo, "cat-file", "-e", f"{ref}^{{commit}}")
32
+ return code == 0
33
+
34
+
35
+ def diff_names(repo: Path, since: str) -> tuple[str, ...]:
36
+ """Paths changed between `since` and the working tree (staged + unstaged)."""
37
+ code, out = _git(repo, "diff", "--name-only", since)
38
+ if code != 0:
39
+ return ()
40
+ return tuple(line for line in out.splitlines() if line)
41
+
42
+
43
+ def blob_at(repo: Path, ref: str, path: str) -> str | None:
44
+ """File contents at a ref (e.g. `HEAD`), or None if absent there."""
45
+ code, out = _git(repo, "show", f"{ref}:{path}")
46
+ return out if code == 0 else None