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/model.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Immutable data carriers + enums for the verifier. No I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Verdict(StrEnum):
|
|
12
|
+
"""The run-level answer Tycho renders."""
|
|
13
|
+
|
|
14
|
+
VERIFIED = "VERIFIED"
|
|
15
|
+
FAILED = "FAILED"
|
|
16
|
+
STALE = "STALE"
|
|
17
|
+
UNSUPPORTED = "UNSUPPORTED"
|
|
18
|
+
INDETERMINATE = "INDETERMINATE"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CheckStatus(StrEnum):
|
|
22
|
+
"""The per-check outcome. Reduces to a Verdict in verify.verdict_of."""
|
|
23
|
+
|
|
24
|
+
PASS = "PASS"
|
|
25
|
+
FAIL = "FAIL"
|
|
26
|
+
STALE = "STALE"
|
|
27
|
+
UNSUPPORTED = "UNSUPPORTED"
|
|
28
|
+
INDETERMINATE = "INDETERMINATE"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class CheckResult:
|
|
33
|
+
"""One check's outcome plus the human-readable evidence for it."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
status: CheckStatus
|
|
37
|
+
evidence: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Event:
|
|
42
|
+
"""One normalized tool invocation from the harness transcript.
|
|
43
|
+
|
|
44
|
+
`ts` is the completion time (result timestamp, falling back to invocation).
|
|
45
|
+
`is_error` is the harness's failure signal (Bash: non-zero exit or denied);
|
|
46
|
+
None means no result was captured. `result` holds the structured
|
|
47
|
+
toolUseResult (Bash: stdout/stderr; Edit/Write: filePath/originalFile/…),
|
|
48
|
+
or {} when absent or non-structured.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
ts: float
|
|
52
|
+
tool: str
|
|
53
|
+
input: dict = field(default_factory=dict)
|
|
54
|
+
is_error: bool | None = None
|
|
55
|
+
result: dict = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Message:
|
|
60
|
+
"""One assistant natural-language message (the agent's prose, not a tool call).
|
|
61
|
+
|
|
62
|
+
Carried so `tool_call_provenance` can check the agent's *claims* ("I created the
|
|
63
|
+
ticket", "I searched the web") against the tool `Event`s that actually happened.
|
|
64
|
+
Only assistant text is modeled — user prose and tool payloads live elsewhere.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
ts: float
|
|
68
|
+
text: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class FileEdit:
|
|
73
|
+
"""A file the agent created or edited this session.
|
|
74
|
+
|
|
75
|
+
`original` is the file's full content *before* the edit (None for a new
|
|
76
|
+
file) — enough for the AST checks without touching git.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
path: str
|
|
80
|
+
ts: float
|
|
81
|
+
original: str | None
|
|
82
|
+
kind: str # "create" | "edit"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class FileState:
|
|
87
|
+
"""Working-tree state of one file, read once in gather() so checks stay pure.
|
|
88
|
+
|
|
89
|
+
`current_text` is the on-disk content — the "after" side for the AST checks.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
path: str
|
|
93
|
+
exists: bool
|
|
94
|
+
mtime: float | None
|
|
95
|
+
current_text: str | None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class GitSnapshot:
|
|
100
|
+
"""Repo state read once in gather()."""
|
|
101
|
+
|
|
102
|
+
is_repo: bool
|
|
103
|
+
head_sha: str | None
|
|
104
|
+
changed_paths: tuple[str, ...]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class Session:
|
|
109
|
+
"""The gathered input snapshot the pure checks run against.
|
|
110
|
+
|
|
111
|
+
Carries *both* scopes, because the checks genuinely need both (TYCHO-17):
|
|
112
|
+
"did this turn's edits land?" is a question about `turn_edits`, while "is a
|
|
113
|
+
source stale against the last green run?" is a question about the whole
|
|
114
|
+
session — a file edited three turns ago and never retested really is stale.
|
|
115
|
+
So `turn_start` narrows a *view*; it never narrows `edits`/`events`.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
events: tuple[Event, ...]
|
|
119
|
+
edits: tuple[FileEdit, ...]
|
|
120
|
+
repo: Path
|
|
121
|
+
config: "Config" # noqa: F821 — forward ref to tycho.config.Config, avoids import cycle
|
|
122
|
+
files: Mapping[str, FileState] = field(default_factory=dict)
|
|
123
|
+
git: GitSnapshot = field(default_factory=lambda: GitSnapshot(False, None, ()))
|
|
124
|
+
has_tests: bool = True
|
|
125
|
+
# Assistant prose, for tool_call_provenance. Empty for harnesses whose reader doesn't
|
|
126
|
+
# supply it (the check then degrades to UNSUPPORTED there, never a false verdict).
|
|
127
|
+
messages: tuple[Message, ...] = ()
|
|
128
|
+
# Epoch at which the turn under review began. 0.0 means "the whole transcript is
|
|
129
|
+
# the turn" — the honest default for `tycho verify` (a manual whole-session audit)
|
|
130
|
+
# and for harnesses whose readers already hand us a single turn (Codex) or that
|
|
131
|
+
# don't timestamp events at all (Cursor).
|
|
132
|
+
turn_start: float = 0.0
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def turn_edits(self) -> tuple[FileEdit, ...]:
|
|
136
|
+
"""The edits made by the turn under review (all of them when turn_start is 0.0)."""
|
|
137
|
+
return tuple(fe for fe in self.edits if fe.ts >= self.turn_start)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def turn_events(self) -> tuple[Event, ...]:
|
|
141
|
+
"""The events of the turn under review (all of them when turn_start is 0.0)."""
|
|
142
|
+
return tuple(e for e in self.events if e.ts >= self.turn_start)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def turn_messages(self) -> tuple[Message, ...]:
|
|
146
|
+
"""The assistant prose of the turn under review (all when turn_start is 0.0)."""
|
|
147
|
+
return tuple(m for m in self.messages if m.ts >= self.turn_start)
|
tycho/opencode.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Read OpenCode sessions straight from its SQLite store (``opencode.db``).
|
|
2
|
+
|
|
3
|
+
OpenCode persists every session in ``~/.local/share/opencode/opencode.db``
|
|
4
|
+
(tables ``session`` / ``message`` / ``part``). Earlier Tycho shelled out to
|
|
5
|
+
``opencode export <id>`` and captured stdout — but that command truncates at a
|
|
6
|
+
fixed 128 KB whenever its stdout is a pipe (a Bun non-blocking-write quirk), so
|
|
7
|
+
every non-trivial session came back as invalid JSON and the verdict was
|
|
8
|
+
silently dropped.
|
|
9
|
+
|
|
10
|
+
Reading the DB directly is both correct and *standard*: it's the same "read the
|
|
11
|
+
harness's own on-disk store" shape the Claude/Cursor/Codex adapters already use.
|
|
12
|
+
We reconstruct the exact ``{info, messages:[{parts:[…]}]}`` JSON that
|
|
13
|
+
``events.parse_opencode`` already consumes, so normalization is unchanged and the
|
|
14
|
+
OpenCode transcript flows through the identical ``parse → gather → checks``
|
|
15
|
+
pipeline as every other harness.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sqlite3
|
|
23
|
+
import tempfile
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def db_path() -> Path:
|
|
28
|
+
"""Location of OpenCode's SQLite store.
|
|
29
|
+
|
|
30
|
+
``TYCHO_OPENCODE_HOME`` (the dir holding ``opencode.db``) wins, then
|
|
31
|
+
OpenCode's own ``XDG_DATA_HOME``, then the ``~/.local/share`` default. Same
|
|
32
|
+
override chain as every other harness (``harness.home``) — spelled out here
|
|
33
|
+
because OpenCode's root is an XDG data dir, not a ``~/.<name>`` dotdir.
|
|
34
|
+
"""
|
|
35
|
+
override = os.environ.get("TYCHO_OPENCODE_HOME")
|
|
36
|
+
if override:
|
|
37
|
+
return Path(override).expanduser() / "opencode.db"
|
|
38
|
+
data_home = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
|
39
|
+
return Path(data_home) / "opencode" / "opencode.db"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _connect(db: Path) -> sqlite3.Connection:
|
|
43
|
+
"""Open the DB read-only — never write to the agent's live store."""
|
|
44
|
+
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=5)
|
|
45
|
+
conn.row_factory = sqlite3.Row
|
|
46
|
+
return conn
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def latest_session(cwd: Path, db: Path | None = None) -> str | None:
|
|
50
|
+
"""Id of the most-recently-updated session rooted at ``cwd`` (or None).
|
|
51
|
+
|
|
52
|
+
This gives OpenCode the same on-disk discovery every other harness has:
|
|
53
|
+
``tycho verify`` can find the newest session for a directory with no help
|
|
54
|
+
from the harness.
|
|
55
|
+
"""
|
|
56
|
+
db = db or db_path()
|
|
57
|
+
if not db.is_file():
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
with _connect(db) as conn:
|
|
61
|
+
row = conn.execute(
|
|
62
|
+
"SELECT id FROM session WHERE directory = ? ORDER BY time_updated DESC LIMIT 1",
|
|
63
|
+
(str(cwd),),
|
|
64
|
+
).fetchone()
|
|
65
|
+
except sqlite3.Error:
|
|
66
|
+
return None
|
|
67
|
+
return row["id"] if row else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _json_object(raw: object) -> dict | None:
|
|
71
|
+
"""Parse a DB ``data`` column into a dict, or None if it isn't one (external data)."""
|
|
72
|
+
try:
|
|
73
|
+
value = json.loads(raw) # type: ignore[arg-type]
|
|
74
|
+
except (json.JSONDecodeError, TypeError):
|
|
75
|
+
return None
|
|
76
|
+
return value if isinstance(value, dict) else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def session_json(session_id: str, cwd: Path, db: Path | None = None) -> dict | None:
|
|
80
|
+
"""Reconstruct one session's export-shaped JSON from the DB (tool parts only).
|
|
81
|
+
|
|
82
|
+
Each ``part.data`` row is already the same object ``opencode export`` nests
|
|
83
|
+
under ``messages[].parts[]`` — carrying ``state.time`` and
|
|
84
|
+
``state.metadata.exit`` — so freshness/provenance/command_execution keep
|
|
85
|
+
working. We drop non-tool parts (text/reasoning/step); ``parse_opencode``
|
|
86
|
+
ignores them anyway.
|
|
87
|
+
|
|
88
|
+
One message in, one message out — parts grouped by ``part.message_id``. This used
|
|
89
|
+
to flatten every part into a single synthesized ``{"role": "assistant"}`` message
|
|
90
|
+
and never read the ``message`` table at all, which discarded the user messages
|
|
91
|
+
entirely. That is why OpenCode had no derivable turn boundary (TYCHO-21): the data
|
|
92
|
+
was in the DB the whole time, Tycho dropped it before the reader ever saw it. A user
|
|
93
|
+
message carries no tool parts, so it lands here with ``parts: []`` — kept, because
|
|
94
|
+
its timestamp is exactly what ``events.turn_start_opencode`` anchors on.
|
|
95
|
+
|
|
96
|
+
``info`` keeps only ``role`` and ``time`` from ``message.data``, not the whole blob:
|
|
97
|
+
a user message's ``data`` also carries ``summary.diffs`` (full patches) and nothing
|
|
98
|
+
downstream reads them — no reason to copy that through a temp file on every Stop.
|
|
99
|
+
"""
|
|
100
|
+
db = db or db_path()
|
|
101
|
+
if not db.is_file():
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
with _connect(db) as conn:
|
|
105
|
+
info = conn.execute(
|
|
106
|
+
"SELECT id, directory, version FROM session WHERE id = ?", (session_id,)
|
|
107
|
+
).fetchone()
|
|
108
|
+
messages = conn.execute(
|
|
109
|
+
"SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created ASC",
|
|
110
|
+
(session_id,),
|
|
111
|
+
).fetchall()
|
|
112
|
+
rows = conn.execute(
|
|
113
|
+
"SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created ASC",
|
|
114
|
+
(session_id,),
|
|
115
|
+
).fetchall()
|
|
116
|
+
except sqlite3.Error:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
parts_by_message: dict[str, list[dict]] = {}
|
|
120
|
+
for row in rows:
|
|
121
|
+
part = _json_object(row["data"])
|
|
122
|
+
if part is not None and part.get("type") == "tool":
|
|
123
|
+
parts_by_message.setdefault(row["message_id"], []).append(part)
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
"info": {
|
|
127
|
+
"id": session_id,
|
|
128
|
+
"directory": info["directory"] if info else str(cwd),
|
|
129
|
+
"version": info["version"] if info else "",
|
|
130
|
+
},
|
|
131
|
+
"messages": [
|
|
132
|
+
{
|
|
133
|
+
"info": {k: v for k, v in data.items() if k in ("role", "time")},
|
|
134
|
+
"parts": parts_by_message.get(row["id"], []),
|
|
135
|
+
}
|
|
136
|
+
for row in messages
|
|
137
|
+
if (data := _json_object(row["data"])) is not None
|
|
138
|
+
],
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def materialize(session_id: str, cwd: Path, db: Path | None = None) -> Path | None:
|
|
143
|
+
"""Write a session's reconstructed JSON to a temp file; return its path.
|
|
144
|
+
|
|
145
|
+
The caller hands this path to the standard engine (and unlinks it after),
|
|
146
|
+
so OpenCode reuses ``events.parse_opencode`` with zero special-casing.
|
|
147
|
+
"""
|
|
148
|
+
data = session_json(session_id, cwd, db)
|
|
149
|
+
if data is None:
|
|
150
|
+
return None
|
|
151
|
+
fd, name = tempfile.mkstemp(suffix=".json", prefix="tycho-opencode-")
|
|
152
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
153
|
+
json.dump(data, fh)
|
|
154
|
+
return Path(name)
|
tycho/report.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Render a verdict + its check results as the terminal block."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from .model import CheckResult, CheckStatus, Verdict
|
|
8
|
+
|
|
9
|
+
_MARK = {
|
|
10
|
+
CheckStatus.PASS: "✓",
|
|
11
|
+
CheckStatus.FAIL: "✗",
|
|
12
|
+
CheckStatus.STALE: "✗",
|
|
13
|
+
CheckStatus.UNSUPPORTED: "•",
|
|
14
|
+
CheckStatus.INDETERMINATE: "?",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render(verdict: Verdict, results: Sequence[CheckResult], claim: str | None = None) -> str:
|
|
19
|
+
"""One header line + one line per check (mark, name, evidence).
|
|
20
|
+
|
|
21
|
+
``claim`` (from ``tycho verify --claim``) is echoed verbatim above the verdict so
|
|
22
|
+
the human can compare what was *said* against what was *proven*. Tycho does not
|
|
23
|
+
parse or semantically match the claim — structured claim matching is a Pro concern.
|
|
24
|
+
"""
|
|
25
|
+
lines = [f"🔍 Tycho: {verdict}"]
|
|
26
|
+
if claim:
|
|
27
|
+
lines.insert(0, f' claim: "{claim}"')
|
|
28
|
+
lines.extend(f" {_MARK[r.status]} {r.name} — {r.evidence}" for r in results)
|
|
29
|
+
return "\n".join(lines)
|
tycho/runlog.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Read a test runner's own verdict out of its terminal output. Stdlib `re`, no I/O.
|
|
2
|
+
|
|
3
|
+
The exit code is the contract Tycho prefers: universal, unambiguous, and impossible for a
|
|
4
|
+
runner to get wrong. This module is the fallback for when that contract isn't available —
|
|
5
|
+
the shell masked the status (`pytest; echo done`), or the harness never recorded one. In
|
|
6
|
+
those cases the runner's own summary line is the best remaining evidence, and it *is*
|
|
7
|
+
evidence: the runner reporting on itself, not us inferring from silence.
|
|
8
|
+
|
|
9
|
+
Kept out of `checks.py` (which owns *which commands are runners*) and out of `events.py`
|
|
10
|
+
(which owns *what a harness transcript looks like*) because both need this and neither
|
|
11
|
+
should import the other. Codex has read its exit status out of this text since the adapter
|
|
12
|
+
landed; this is that knowledge, in one place, for every caller.
|
|
13
|
+
|
|
14
|
+
Failure is checked before success on purpose: a red summary names both ("1 failed, 76
|
|
15
|
+
passed in 1.07s"), so asking "did anything fail?" first is the only order that can't read
|
|
16
|
+
a red run as green.
|
|
17
|
+
|
|
18
|
+
**`.pytest_cache` looks like a better source than this and is not — don't reach for it.**
|
|
19
|
+
It is the obvious idea (filesystem truth, immune to truncation and to harnesses that keep
|
|
20
|
+
no output), and it is wrong: `v/cache/lastfailed` is not a record of the last run. pytest
|
|
21
|
+
seeds it from the previous value and only *removes* an entry when that test runs and
|
|
22
|
+
passes, so an entry for a test that was deselected, renamed, or deleted persists forever.
|
|
23
|
+
Measured on this repo: `lastfailed` named a test that no longer exists, and survived a
|
|
24
|
+
fully green 316-test run untouched — the file isn't even rewritten when nothing changed,
|
|
25
|
+
so its mtime can't date the run either. A check trusting it would report FAILED on a green
|
|
26
|
+
repo, which is the one failure this program must never have.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import re
|
|
32
|
+
|
|
33
|
+
# "1 failed", "3 errors", "=== ERRORS ===". The [1-9] guard matters: a runner that prints
|
|
34
|
+
# "0 failed" is reporting success, and a bare `\d+` would read it as the exact opposite.
|
|
35
|
+
_FAILURE = re.compile(r"\b[1-9]\d* (?:failed|errors?)\b|=+ ERRORS =+")
|
|
36
|
+
|
|
37
|
+
# "77 passed in 0.79s" — the count and the duration together. Requiring the duration is
|
|
38
|
+
# what keeps a stray "5 passed" in someone's prose from counting as a run that happened.
|
|
39
|
+
_SUCCESS = re.compile(r"\b\d+ passed\b.*?\bin \d+(?:\.\d+)?s\b", re.DOTALL)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def outcome(text: str) -> bool | None:
|
|
43
|
+
"""True = the runner reported failures, False = it reported success, None = can't tell.
|
|
44
|
+
|
|
45
|
+
None is the common case and the honest one: no recognized summary means no verdict,
|
|
46
|
+
never an assumed pass. Mirrors `Event.is_error` so callers can treat the two alike.
|
|
47
|
+
"""
|
|
48
|
+
if not text:
|
|
49
|
+
return None
|
|
50
|
+
if _FAILURE.search(text):
|
|
51
|
+
return True
|
|
52
|
+
if _SUCCESS.search(text):
|
|
53
|
+
return False
|
|
54
|
+
return None
|