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/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())