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/harness.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Per-harness adapters for the Stop hook.
|
|
2
|
+
|
|
3
|
+
The engine is harness-agnostic — it runs on a normalized ``Session``. Only three
|
|
4
|
+
things differ between harnesses, and all three are documented and stable:
|
|
5
|
+
|
|
6
|
+
1. where the transcript lives (a ``transcript_path`` in the Stop payload),
|
|
7
|
+
2. the repo root in that payload (``cwd`` vs ``workspace_roots``),
|
|
8
|
+
3. the JSON shape that surfaces a message to the human *without* blocking.
|
|
9
|
+
|
|
10
|
+
Each harness hands us a ``transcript_path`` to JSONL, but the schemas differ.
|
|
11
|
+
The adapter selects the matching pinned parser and output channel.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import events
|
|
23
|
+
from . import opencode as opencode_mod
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Harness:
|
|
28
|
+
"""How one harness reads its transcript, names the repo root, and formats output."""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
parse: Callable[[Path], tuple]
|
|
32
|
+
repo_root: Callable[[dict], Path]
|
|
33
|
+
format_output: Callable[[str], dict]
|
|
34
|
+
discover: Callable[[Path], Path | None] # newest transcript for a repo, or None
|
|
35
|
+
# Materialize the transcript to verify from a hook payload. File harnesses
|
|
36
|
+
# read the path the harness handed us; OpenCode has no file, so it rebuilds
|
|
37
|
+
# the transcript from opencode.db by session id (a temp file the caller
|
|
38
|
+
# unlinks). Returns None when there's nothing to verify.
|
|
39
|
+
transcript_of: Callable[[dict], Path | None]
|
|
40
|
+
# Epoch at which the turn under review began, so the Stop reports *this turn's*
|
|
41
|
+
# work rather than the whole session's (TYCHO-17). Where the boundary lives is
|
|
42
|
+
# harness knowledge, so it belongs here; what to do with it is the engine's.
|
|
43
|
+
# Defaulting to 0.0 means "the whole transcript is the turn" — the honest answer
|
|
44
|
+
# for a harness whose reader already yields one turn, or that can't timestamp.
|
|
45
|
+
turn_start: Callable[[Path], float] = lambda _: 0.0
|
|
46
|
+
# Assistant prose reader, for tool_call_provenance. Defaults to none: a harness whose
|
|
47
|
+
# transcript we don't (yet) mine for prose supplies no messages, and the check degrades
|
|
48
|
+
# to UNSUPPORTED there rather than guessing. Only Claude Code wires a real reader in v1.
|
|
49
|
+
messages: Callable[[Path], tuple] = lambda _: ()
|
|
50
|
+
# How to surface the bootup update-notice (TYCHO-72). This is a *human-only* channel and
|
|
51
|
+
# is deliberately NOT `format_output`: on Cursor `format_output` is model-facing
|
|
52
|
+
# (`followup_message`), and a notice that reaches the model could commission it to go
|
|
53
|
+
# update Tycho (the TYCHO-35 rule). None — the default — means "no user-facing bootup
|
|
54
|
+
# channel", so the notice is suppressed there rather than risk a model-facing one. Only
|
|
55
|
+
# Claude/Codex (`systemMessage`) and OpenCode (`message`, toasted by the plugin) have one.
|
|
56
|
+
notice_output: Callable[[str], dict] | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _payload_transcript(payload: dict) -> Path | None:
|
|
60
|
+
"""File harnesses (Claude/Cursor/Codex) hand us the transcript path directly."""
|
|
61
|
+
path = payload.get("transcript_path")
|
|
62
|
+
return Path(path) if path else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _cwd_root(payload: dict) -> Path:
|
|
66
|
+
return Path(payload.get("cwd") or os.getcwd())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _cursor_root(payload: dict) -> Path:
|
|
70
|
+
roots = payload.get("workspace_roots") or []
|
|
71
|
+
return Path(roots[0]) if roots else Path(os.getcwd())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --- data roots -------------------------------------------------------------
|
|
75
|
+
#
|
|
76
|
+
# Each harness keeps its sessions under a root that defaults to a dotdir in
|
|
77
|
+
# $HOME. That default is only a default: dotfiles get relocated, machines get
|
|
78
|
+
# shared, and Windows puts this somewhere else entirely (TYCHO-15). So every
|
|
79
|
+
# root is overridable — Tycho's own env var first, then the harness's native
|
|
80
|
+
# one (the variable the agent itself honors), then the historical default.
|
|
81
|
+
|
|
82
|
+
_NATIVE_HOME_ENV = {
|
|
83
|
+
"claude": "CLAUDE_CONFIG_DIR",
|
|
84
|
+
"codex": "CODEX_HOME",
|
|
85
|
+
"cursor": None, # no documented override
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def home(name: str) -> Path:
|
|
90
|
+
"""Root of ``name``'s on-disk data — the dir holding ``projects/``/``sessions/``."""
|
|
91
|
+
for var in (f"TYCHO_{name.upper()}_HOME", _NATIVE_HOME_ENV.get(name)):
|
|
92
|
+
value = os.environ.get(var) if var else None
|
|
93
|
+
if value:
|
|
94
|
+
return Path(value).expanduser()
|
|
95
|
+
return Path.home() / f".{name}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --- manual discovery: find a repo's newest transcript on disk --------------
|
|
99
|
+
#
|
|
100
|
+
# Claude and Cursor key transcripts by the (start-time) cwd, encoded by turning every
|
|
101
|
+
# path separator and space into "-". Claude keeps the leading dash from the absolute
|
|
102
|
+
# path; Cursor strips it. Verified against real dirs under ~/.claude and ~/.cursor —
|
|
103
|
+
# including on Windows, where str(cwd) carries a drive colon and backslashes:
|
|
104
|
+
# `C:\Users\me\Swail Labs\tycho` -> `C--Users-me-Swail-Labs-tycho` (TYCHO-24). The POSIX
|
|
105
|
+
# set ("/", ".") is a subset of this, so *nix encoding is unchanged.
|
|
106
|
+
_DIR_SEP_CHARS = str.maketrans({c: "-" for c in "\\/:. "})
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _encode(cwd: Path) -> str:
|
|
110
|
+
return str(cwd).translate(_DIR_SEP_CHARS)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _newest(paths) -> Path | None:
|
|
114
|
+
files = [p for p in paths if p.is_file()]
|
|
115
|
+
return max(files, key=lambda p: p.stat().st_mtime, default=None)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _claude_discover(cwd: Path) -> Path | None:
|
|
119
|
+
root = home("claude") / "projects" / _encode(cwd)
|
|
120
|
+
return _newest(root.glob("*.jsonl"))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _cursor_discover(cwd: Path) -> Path | None:
|
|
124
|
+
root = home("cursor") / "projects" / _encode(cwd).lstrip("-") / "agent-transcripts"
|
|
125
|
+
return _newest(root.glob("*/*.jsonl"))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _opencode_root(payload: dict) -> Path:
|
|
129
|
+
return Path(payload.get("directory") or payload.get("cwd") or os.getcwd())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _opencode_transcript(payload: dict) -> Path | None:
|
|
133
|
+
"""Rebuild the session named in the plugin payload from opencode.db."""
|
|
134
|
+
session_id = payload.get("sessionID") or payload.get("session_id")
|
|
135
|
+
if not session_id:
|
|
136
|
+
return None
|
|
137
|
+
return opencode_mod.materialize(session_id, _opencode_root(payload))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _opencode_discover(cwd: Path) -> Path | None:
|
|
141
|
+
"""Materialize the newest opencode.db session for ``cwd`` (or None)."""
|
|
142
|
+
session_id = opencode_mod.latest_session(cwd)
|
|
143
|
+
return opencode_mod.materialize(session_id, cwd) if session_id else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _codex_discover(cwd: Path) -> Path | None:
|
|
147
|
+
root = home("codex") / "sessions"
|
|
148
|
+
for path in sorted(root.glob("**/*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
149
|
+
try:
|
|
150
|
+
first = next(line for line in path.read_text(encoding="utf-8").splitlines() if line)
|
|
151
|
+
payload = json.loads(first).get("payload", {})
|
|
152
|
+
except (OSError, StopIteration, ValueError):
|
|
153
|
+
continue
|
|
154
|
+
if payload.get("cwd") == str(cwd):
|
|
155
|
+
return path
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _cursor_output(text: str) -> dict:
|
|
160
|
+
"""Wrap the verdict so Cursor *shows* it instead of acting on it.
|
|
161
|
+
|
|
162
|
+
`followup_message` is the only field Cursor's stop hook reads, and Cursor queues
|
|
163
|
+
it as a UserMessageAction — the model sees it and, left unqualified, would treat
|
|
164
|
+
a FAILED verdict as a fresh instruction and start fixing things. Tycho reports;
|
|
165
|
+
it doesn't commission work. So the message carries its own stop condition: relay
|
|
166
|
+
verbatim, then end the turn. This is a prompt, not an API guarantee — Cursor's
|
|
167
|
+
loop_count/loop_limit remains the hard backstop, and exit 0 still never blocks.
|
|
168
|
+
"""
|
|
169
|
+
return {"followup_message": f"{text}\n\n{_CURSOR_RELAY}"}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Addressed to the model, not the human — kept one line so it can't bury the verdict.
|
|
173
|
+
_CURSOR_RELAY = (
|
|
174
|
+
"[Tycho] The above is an automated verification result, not a request. "
|
|
175
|
+
"Show it to the user verbatim and end your turn now — do not edit files, "
|
|
176
|
+
"run commands, or act on it unless the user asks."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
CLAUDE = Harness(
|
|
181
|
+
name="claude",
|
|
182
|
+
parse=events.parse,
|
|
183
|
+
repo_root=_cwd_root,
|
|
184
|
+
# Claude renders `systemMessage` to the human; exit 0 never blocks the Stop.
|
|
185
|
+
format_output=lambda text: {"systemMessage": text},
|
|
186
|
+
# Bootup notice rides the same human-only field — verified against 2.1.212 that
|
|
187
|
+
# SessionStart's `systemMessage` renders to the user, not the model (TYCHO-72 step 0).
|
|
188
|
+
notice_output=lambda text: {"systemMessage": text},
|
|
189
|
+
discover=_claude_discover,
|
|
190
|
+
transcript_of=_payload_transcript,
|
|
191
|
+
turn_start=events.turn_start,
|
|
192
|
+
messages=events.assistant_messages,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
CURSOR = Harness(
|
|
196
|
+
name="cursor",
|
|
197
|
+
parse=events.parse_cursor,
|
|
198
|
+
repo_root=_cursor_root,
|
|
199
|
+
# `followup_message` is the ONLY field Cursor's stop hook reads — pinned to
|
|
200
|
+
# cursor-agent 2026.07.09-a3815c0, which validates exactly one output key
|
|
201
|
+
# ("followup_message must be a string if provided") and forwards it as
|
|
202
|
+
# StopRequestResponse.followupMessage. The `user_message` sent here previously
|
|
203
|
+
# was never read on stop (that field exists only on permission-deny steps), so
|
|
204
|
+
# the verdict silently reached nobody. See tests/fixtures/cursor_stop_payload.json.
|
|
205
|
+
#
|
|
206
|
+
# Unlike Claude's `systemMessage`, this re-enters the model loop: Cursor queues it
|
|
207
|
+
# as a UserMessageAction. That is the only channel Cursor offers — there is no
|
|
208
|
+
# human-only field — so _cursor_output tells the model to relay and stop.
|
|
209
|
+
format_output=_cursor_output,
|
|
210
|
+
discover=_cursor_discover,
|
|
211
|
+
transcript_of=_payload_transcript,
|
|
212
|
+
# turn_start stays 0.0: parse_cursor gives every Event ts=0.0 (the transcript
|
|
213
|
+
# carries no timestamps), so there is nothing to scope *by* — and the sample
|
|
214
|
+
# transcript is one turn end-to-end (a `<user_query>` through `turn_ended`).
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
CODEX = Harness(
|
|
218
|
+
name="codex",
|
|
219
|
+
parse=events.parse_codex,
|
|
220
|
+
repo_root=_cwd_root,
|
|
221
|
+
format_output=lambda text: {"systemMessage": text},
|
|
222
|
+
# Codex's SessionStart output wire schema clones Claude's — `systemMessage` is the
|
|
223
|
+
# human-facing field (docs/harness-support.md). Render-path still ⚠️ pending a live
|
|
224
|
+
# capture (TYCHO-111); worst case the toast is silent, never model-facing.
|
|
225
|
+
notice_output=lambda text: {"systemMessage": text},
|
|
226
|
+
discover=_codex_discover,
|
|
227
|
+
transcript_of=_payload_transcript,
|
|
228
|
+
# parse_codex returns every turn (TYCHO-20); turn_start_codex anchors the boundary on
|
|
229
|
+
# the latest turn's task_started, so the engine narrows exactly like Claude. Codex is
|
|
230
|
+
# the only harness with an explicit turn_id, which is why it can do this precisely.
|
|
231
|
+
turn_start=events.turn_start_codex,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
OPENCODE = Harness(
|
|
235
|
+
name="opencode",
|
|
236
|
+
parse=events.parse_opencode,
|
|
237
|
+
repo_root=_opencode_root,
|
|
238
|
+
format_output=lambda text: {"message": text},
|
|
239
|
+
# The plugin reads `.message` and toasts it via client.tui.showToast — a genuinely
|
|
240
|
+
# user-facing sink. The bootup notice reuses the same shape on the session.created
|
|
241
|
+
# branch (TYCHO-72).
|
|
242
|
+
notice_output=lambda text: {"message": text},
|
|
243
|
+
# OpenCode has no transcript file — the reader rebuilds it from opencode.db,
|
|
244
|
+
# so discovery and the live plugin both use the same DB-backed materializer.
|
|
245
|
+
discover=_opencode_discover,
|
|
246
|
+
transcript_of=_opencode_transcript,
|
|
247
|
+
# turn_start_opencode anchors on the last user message, which is what opens an
|
|
248
|
+
# OpenCode turn. The boundary looked underivable while session_json flattened every
|
|
249
|
+
# part into one synthesized assistant message and dropped the user rows (TYCHO-21);
|
|
250
|
+
# the DB carries a per-message `time.created` and always did.
|
|
251
|
+
turn_start=events.turn_start_opencode,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
ALL = (CLAUDE, CURSOR, CODEX, OPENCODE)
|
|
255
|
+
BY_NAME = {h.name: h for h in ALL}
|
|
256
|
+
|
|
257
|
+
# The harness version each adapter's hook contract was last verified against, and the
|
|
258
|
+
# local `--version` command to read the installed one. This is the machine copy of the
|
|
259
|
+
# "Verified against" row in docs/harness-support.md — keep the two in step (a test pins
|
|
260
|
+
# them together). `probe` stays offline (a local subprocess), so the zero-dep/offline
|
|
261
|
+
# invariant holds; drift is advisory ("re-verify"), never a break (TYCHO-34). OpenCode
|
|
262
|
+
# has no CLI version contract (it's read from opencode.db), so it isn't probed.
|
|
263
|
+
VERIFIED_AGAINST = {
|
|
264
|
+
"claude": {"version": "2.1.210", "probe": ("claude", "--version")},
|
|
265
|
+
"cursor": {"version": "2026.07.09-a3815c0", "probe": ("cursor-agent", "--version")},
|
|
266
|
+
"codex": {"version": "0.144.4", "probe": ("codex", "--version")},
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def detect(payload: dict) -> Harness:
|
|
271
|
+
"""Pick the harness from the Stop payload shape.
|
|
272
|
+
|
|
273
|
+
Cursor's payload carries ``workspace_roots``/``cursor_version``; Claude's
|
|
274
|
+
does not. Default to Claude — it's the harness with a pinned fixture.
|
|
275
|
+
"""
|
|
276
|
+
if payload.get("harness") == "opencode" or payload.get("sessionID"):
|
|
277
|
+
return OPENCODE
|
|
278
|
+
if payload.get("workspace_roots") is not None or payload.get("cursor_version"):
|
|
279
|
+
return CURSOR
|
|
280
|
+
if payload.get("hook_event_name") == "Stop" and payload.get("turn_id"):
|
|
281
|
+
return CODEX
|
|
282
|
+
return CLAUDE
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def discover(cwd: Path, only: str | None = None) -> tuple[Path | None, Harness | None]:
|
|
286
|
+
"""Find the most-recently-used transcript for ``cwd`` across harnesses.
|
|
287
|
+
|
|
288
|
+
Cursor transcripts carry no internal timestamps, so file mtime is the only
|
|
289
|
+
signal all harnesses share — and it's exactly "most recently used". With
|
|
290
|
+
``only`` set (a harness name), restrict discovery to that harness.
|
|
291
|
+
Returns (path, harness), or (None, None) when nothing is found.
|
|
292
|
+
"""
|
|
293
|
+
candidates = []
|
|
294
|
+
for harness in ALL:
|
|
295
|
+
if only and harness.name != only:
|
|
296
|
+
continue
|
|
297
|
+
path = harness.discover(cwd)
|
|
298
|
+
if path is not None:
|
|
299
|
+
candidates.append((path.stat().st_mtime, path, harness))
|
|
300
|
+
if not candidates:
|
|
301
|
+
return None, None
|
|
302
|
+
_, path, harness = max(candidates, key=lambda c: c[0])
|
|
303
|
+
return path, harness
|
tycho/hook.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""The Stop-hook entrypoint: stdin JSON → verify → print, never block.
|
|
2
|
+
|
|
3
|
+
Wired by ``tycho init`` (next milestone) into the harness's Stop hook. Reads the
|
|
4
|
+
hook payload on stdin, locates the transcript + repo, runs the engine, and emits
|
|
5
|
+
the verdict as the harness's JSON output.
|
|
6
|
+
|
|
7
|
+
A verifier must never break the agent: any bad input, missing transcript, or
|
|
8
|
+
internal error exits 0 with no output. Unlike ``tycho verify`` (which exits 1 on
|
|
9
|
+
FAILED so CI can gate), the *hook* always exits 0 — it annotates the Stop, it
|
|
10
|
+
never blocks it. That is a design invariant: Tycho never blocks.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import harness as harness_mod
|
|
20
|
+
from . import state
|
|
21
|
+
from . import verify as engine
|
|
22
|
+
from .report import render
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run(stdin_text: str) -> dict | None:
|
|
26
|
+
"""Pure-ish core: hook payload text → output dict (or None for "say nothing").
|
|
27
|
+
|
|
28
|
+
Split from stdin/stdout so it's testable without patching the process.
|
|
29
|
+
Returns None whenever there is nothing to report or anything goes wrong.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
payload = json.loads(stdin_text)
|
|
33
|
+
except (json.JSONDecodeError, TypeError):
|
|
34
|
+
return None
|
|
35
|
+
if not isinstance(payload, dict):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
harness = harness_mod.detect(payload)
|
|
39
|
+
repo = harness.repo_root(payload)
|
|
40
|
+
# Heartbeat, up front on every path: it answers "did the wiring fire?" — the basis of
|
|
41
|
+
# `tycho doctor` (TYCHO-8) — so it must land even when there's nothing to verify.
|
|
42
|
+
# `pending=True` shows the badge "verifying"; every path below re-beats a terminal state
|
|
43
|
+
# so it never sticks (TYCHO-59). Never raises.
|
|
44
|
+
state.record_run(repo, harness.name, pending=True)
|
|
45
|
+
|
|
46
|
+
transcript = harness.transcript_of(payload)
|
|
47
|
+
if not transcript:
|
|
48
|
+
state.record_run(repo, harness.name) # nothing to verify — clear "verifying" (grey)
|
|
49
|
+
return None # transcripts disabled (Cursor) or absent
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
session = engine.gather(
|
|
53
|
+
Path(transcript), repo, parse=harness.parse, turn_start=harness.turn_start,
|
|
54
|
+
messages=harness.messages,
|
|
55
|
+
)
|
|
56
|
+
if not engine.has_verifiable_activity(session):
|
|
57
|
+
state.record_run(repo, harness.name) # fired, nothing to report — clear pending (grey)
|
|
58
|
+
return None
|
|
59
|
+
results = engine.run_checks(session)
|
|
60
|
+
verdict = engine.verdict_of(results)
|
|
61
|
+
# Re-beat with the verdict, for `tycho status` to render passively (TYCHO-39).
|
|
62
|
+
# Only here, where a verdict exists: the entry beat above already proved liveness,
|
|
63
|
+
# and claiming a verdict we never reached would be the one lie Tycho can't tell.
|
|
64
|
+
state.record_run(repo, harness.name, verdict=verdict.name)
|
|
65
|
+
# And log it to the catch record with its evidence trail, if adverse/intermediate
|
|
66
|
+
# (TYCHO-62). No-op for VERIFIED/UNSUPPORTED; never raises.
|
|
67
|
+
state.record_catch(repo, harness.name, verdict.name, results)
|
|
68
|
+
except Exception:
|
|
69
|
+
# broad catch is the correct behavior here — fail open, never
|
|
70
|
+
# break the agent's Stop over an unreadable transcript or git hiccup. Clear the
|
|
71
|
+
# pending beat so the badge doesn't sit on "verifying" forever.
|
|
72
|
+
state.record_run(repo, harness.name)
|
|
73
|
+
return None
|
|
74
|
+
finally:
|
|
75
|
+
# OpenCode's transcript is a rebuilt temp file — clean it up either way.
|
|
76
|
+
if harness.name == "opencode":
|
|
77
|
+
Path(transcript).unlink(missing_ok=True)
|
|
78
|
+
report = render(verdict, results)
|
|
79
|
+
relayed = _relay_output(repo, harness, verdict, report)
|
|
80
|
+
return relayed if relayed is not None else harness.format_output(report)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- verdict relay (TYCHO-35, opt-in, default OFF) --------------------------
|
|
84
|
+
#
|
|
85
|
+
# When the operator turns the relay on, feed a non-VERIFIED verdict back to the *Claude* agent
|
|
86
|
+
# as Stop-hook additionalContext. Verified against Claude Code 2.1.212 (binary disassembly, see
|
|
87
|
+
# docs/harness-support.md): a Stop hook's `hookSpecificOutput.additionalContext` is injected as a
|
|
88
|
+
# `hook_additional_context` message AND makes Claude Code continue the turn (preventContinuation
|
|
89
|
+
# stays false — it is not `decision:"block"`, so the "never blocks" invariant holds: exit stays 0
|
|
90
|
+
# and nothing is halted). That continuation is what lets the agent keep working "until VERIFIED".
|
|
91
|
+
#
|
|
92
|
+
# It is *bounded* by state.relay_streak: at most relay_max() auto-continuations per user turn,
|
|
93
|
+
# after which Tycho goes quiet and the turn ends normally — an unsatisfiable verdict can't cycle
|
|
94
|
+
# forever. Off by default and Claude-only, so every other harness and the un-opted-in path emit
|
|
95
|
+
# exactly the human-only output they always did (no agent context used, no extra generations).
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _relay_output(repo: Path, harness, verdict, report: str) -> dict | None:
|
|
99
|
+
"""The relay output dict, or None to fall through to the normal human-only output.
|
|
100
|
+
|
|
101
|
+
Returns None (unchanged behaviour) unless the relay is enabled here, the harness is Claude,
|
|
102
|
+
and the verdict is worth acting on. VERIFIED clears the streak and ends the turn (nothing to
|
|
103
|
+
fix); once the streak reaches relay_max() the relay goes quiet and hands control back.
|
|
104
|
+
"""
|
|
105
|
+
if harness.name != "claude" or not state.relay_enabled(repo):
|
|
106
|
+
return None
|
|
107
|
+
if verdict.name == "VERIFIED": # proven good — end the turn, reset the leash
|
|
108
|
+
state.reset_relay_streak(repo)
|
|
109
|
+
return None
|
|
110
|
+
if state.relay_streak(repo) >= state.relay_max():
|
|
111
|
+
# Leash spent for this user turn — go quiet and let the turn end. Deliberately do NOT
|
|
112
|
+
# reset here: resetting would re-arm the relay on the very next Stop and oscillate
|
|
113
|
+
# (inject N, rest 1, inject N…) instead of stopping. The streak stays maxed until a real
|
|
114
|
+
# user prompt (prompt_submit) resets it — that is what scopes the bound to one user turn.
|
|
115
|
+
return None
|
|
116
|
+
attempt = state.bump_relay_streak(repo)
|
|
117
|
+
guard = _relay_guard(attempt, state.relay_max())
|
|
118
|
+
# systemMessage keeps the human seeing the verdict, now with a pointer to manage/turn off the
|
|
119
|
+
# relay (TYCHO-114); additionalContext is the model-facing copy that also drives the continuation.
|
|
120
|
+
manage = "[TYCHO] Relay is on — the agent keeps working until VERIFIED. Manage or turn it off: `tycho relay` (/tycho-relay)."
|
|
121
|
+
return {
|
|
122
|
+
"systemMessage": f"{report}\n\n{manage}",
|
|
123
|
+
"hookSpecificOutput": {
|
|
124
|
+
"hookEventName": "Stop",
|
|
125
|
+
"additionalContext": f"{report}\n\n{guard}",
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _relay_guard(attempt: int, cap: int) -> str:
|
|
131
|
+
"""The instruction appended to the model-facing verdict. Frames it as a report, points at
|
|
132
|
+
fixing the root cause, and — crucially — offers the escape hatch of stopping to tell the
|
|
133
|
+
user, so a verdict the agent can't satisfy converges on a conversation rather than a loop.
|
|
134
|
+
Names the attempt count so the model knows the leash is finite."""
|
|
135
|
+
if attempt >= cap:
|
|
136
|
+
tail = (" This is the final automatic re-check — after this the turn ends and control "
|
|
137
|
+
"returns to the user regardless of the verdict.")
|
|
138
|
+
else:
|
|
139
|
+
tail = f" Automatic re-check {attempt} of {cap}."
|
|
140
|
+
return (
|
|
141
|
+
"[TYCHO] The above is an automated verification of the turn you just finished — a report, "
|
|
142
|
+
"not a new instruction from the user. If a check FAILED or is STALE, fix the underlying "
|
|
143
|
+
"cause and finish so the next check can confirm it. If you believe the verdict is wrong, "
|
|
144
|
+
"or the work is genuinely out of scope, stop and say so to the user instead of continuing. "
|
|
145
|
+
"Do not start unrelated work. The user can manage or turn off this relay with `tycho relay` "
|
|
146
|
+
"(/tycho-relay in Claude Code)." + tail
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def session_start() -> int:
|
|
151
|
+
"""SessionStart / bootup hook: surface a *user-facing* update notice at agent bootup
|
|
152
|
+
(TYCHO-53, generalized to Codex/OpenCode in TYCHO-72). The output shape is the harness's
|
|
153
|
+
`notice_output` — a human-only channel (`systemMessage` on Claude/Codex, `message` toasted
|
|
154
|
+
by the OpenCode plugin). A harness with no such channel (Cursor: `notice_output is None`)
|
|
155
|
+
gets nothing, so the notice can never reach the model and commission a self-update
|
|
156
|
+
(TYCHO-35's rule). We parse the payload only to pick that shape; the update check itself is
|
|
157
|
+
machine-global, not per-repo.
|
|
158
|
+
|
|
159
|
+
Same invariants as the Stop hook: never raises, always exit 0, prints nothing when
|
|
160
|
+
offline / opted out / already current. A bootup hook must never break the session.
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
payload = json.loads(sys.stdin.read() or "{}")
|
|
164
|
+
harness = harness_mod.detect(payload) if isinstance(payload, dict) else harness_mod.CLAUDE
|
|
165
|
+
# Codex's SessionStart payload isn't Stop-shaped so `detect` reads it as Claude — which
|
|
166
|
+
# is harmless here: both emit `systemMessage`, so the notice shape is identical either way.
|
|
167
|
+
if harness.notice_output is None:
|
|
168
|
+
return 0 # no user-facing bootup channel on this harness (e.g. Cursor)
|
|
169
|
+
from . import version as version_mod
|
|
170
|
+
|
|
171
|
+
note = version_mod.notice(refresh_first=True)
|
|
172
|
+
if note:
|
|
173
|
+
print(json.dumps(harness.notice_output(f"Tycho: {note}")))
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def prompt_submit() -> int:
|
|
180
|
+
"""UserPromptSubmit hook (Claude Code): the user just submitted a prompt, so a turn is
|
|
181
|
+
starting. Record a `pending` beat so the status badge shows frost-blue "verifying" for the
|
|
182
|
+
whole run (TYCHO-94); the Stop hook clears it to the verdict when the turn ends.
|
|
183
|
+
|
|
184
|
+
Prints nothing — a UserPromptSubmit hook's stdout is injected into the agent's context, and
|
|
185
|
+
Tycho must never put words in the user's prompt. Same invariants as every hook: never
|
|
186
|
+
raises, always exit 0, touches only the heartbeat.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
payload = json.loads(sys.stdin.read() or "{}")
|
|
190
|
+
if isinstance(payload, dict):
|
|
191
|
+
harness = harness_mod.detect(payload)
|
|
192
|
+
repo = harness.repo_root(payload)
|
|
193
|
+
state.record_run(repo, harness.name, pending=True)
|
|
194
|
+
# A real user prompt opens a fresh turn, so the verdict-relay leash resets here: the
|
|
195
|
+
# bound (TYCHO-35) counts auto-continuations *within* one user turn, not across them.
|
|
196
|
+
# Auto-continuations don't re-fire UserPromptSubmit, so this only ever runs for the
|
|
197
|
+
# human's own prompts — exactly the boundary the bound is scoped to.
|
|
198
|
+
state.reset_relay_streak(repo)
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main() -> int:
|
|
205
|
+
output = run(sys.stdin.read())
|
|
206
|
+
if output is not None:
|
|
207
|
+
print(json.dumps(output))
|
|
208
|
+
return 0 # the hook annotates; it never blocks the Stop.
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
raise SystemExit(main())
|