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/__init__.py +9 -0
- tycho/__main__.py +12 -0
- tycho/astdiff.py +102 -0
- tycho/checks.py +681 -0
- tycho/cli.py +516 -0
- tycho/config.py +177 -0
- tycho/doctor.py +304 -0
- tycho/events.py +393 -0
- tycho/fsstate.py +30 -0
- tycho/gitstate.py +46 -0
- tycho/harness.py +303 -0
- tycho/hook.py +212 -0
- tycho/init.py +1018 -0
- tycho/model.py +147 -0
- tycho/opencode.py +154 -0
- tycho/report.py +29 -0
- tycho/runlog.py +54 -0
- tycho/state.py +469 -0
- tycho/status.py +171 -0
- tycho/verify.py +202 -0
- tycho/version.py +99 -0
- tycho_cli-0.0.1.dist-info/METADATA +379 -0
- tycho_cli-0.0.1.dist-info/RECORD +26 -0
- tycho_cli-0.0.1.dist-info/WHEEL +4 -0
- tycho_cli-0.0.1.dist-info/entry_points.txt +2 -0
- tycho_cli-0.0.1.dist-info/licenses/LICENSE +201 -0
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
|