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/cli.py
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""`tycho` command-line entry point.
|
|
2
|
+
|
|
3
|
+
Exit codes are part of the public contract — a team gating in pre-push/CI depends on
|
|
4
|
+
them. See ``ExitCode``: only an adverse finding is non-zero, and FAILED (1) is kept
|
|
5
|
+
distinct from STALE (3) so a gate can choose which one blocks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from enum import IntEnum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from . import harness as harness_mod
|
|
18
|
+
from . import verify as engine
|
|
19
|
+
from .model import CheckResult, CheckStatus, Verdict
|
|
20
|
+
from .report import render
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExitCode(IntEnum):
|
|
24
|
+
"""Stable process exit codes. Don't renumber — CI gates depend on these."""
|
|
25
|
+
|
|
26
|
+
OK = 0 # VERIFIED / UNSUPPORTED / INDETERMINATE — nothing adverse found
|
|
27
|
+
FAILED = 1 # a check proved the claim wrong
|
|
28
|
+
USAGE = 2 # bad invocation (argparse's own convention)
|
|
29
|
+
STALE = 3 # edits landed after the last passing test run
|
|
30
|
+
INTERNAL = 4 # Tycho itself could not complete (bad transcript/config/git)
|
|
31
|
+
UNHEALTHY = 5 # `doctor`: Tycho is installed here but not working (broken/outdated)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_VERDICT_EXIT = {Verdict.FAILED: ExitCode.FAILED, Verdict.STALE: ExitCode.STALE}
|
|
35
|
+
|
|
36
|
+
# One line per command, defined once: argparse's `-h` and `tycho help` both render these,
|
|
37
|
+
# and a help screen that disagrees with `-h` is worse than no help screen (TYCHO-38).
|
|
38
|
+
_COMMANDS = {
|
|
39
|
+
"verify": "verify what the agent claimed and render a verdict",
|
|
40
|
+
"hook": "Stop-hook entrypoint: read hook JSON on stdin, verify, print",
|
|
41
|
+
"init": "install Tycho's hook into this repo's detected harnesses",
|
|
42
|
+
"doctor": "check that Tycho's hooks are installed, current, and firing",
|
|
43
|
+
"uninstall": "remove Tycho's hooks (leaves your other hooks alone)",
|
|
44
|
+
"status": "one line for a harness status bar: is Tycho live here, and the last verdict",
|
|
45
|
+
"count": "how many problems Tycho has caught — in this repo, and all-time",
|
|
46
|
+
"run": "run a command so its true exit code is seen even when wrapped/piped: tycho run -- pytest",
|
|
47
|
+
"scope": "show or edit which files the agent may edit (the scope_drift allowlist)",
|
|
48
|
+
"relay": "let the agent see its own verdict and keep working until VERIFIED (Claude, bounded, off by default)",
|
|
49
|
+
"update": "check for and install a newer Tycho",
|
|
50
|
+
"help": "what Tycho is, whether it's live here, and every command",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_ABOUT = """\
|
|
54
|
+
Tycho proves an agent did what it claimed. It runs as a Stop hook: when a turn ends it
|
|
55
|
+
reads git, the filesystem, exit codes, and the harness's own event stream, then prints a
|
|
56
|
+
verdict — VERIFIED, FAILED, or STALE. Entirely offline and stdlib-only: no account, no
|
|
57
|
+
network, no LLM in the trust path. It never blocks your agent; only `tycho verify` exits
|
|
58
|
+
non-zero, so CI can gate on it."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _force_utf8() -> None:
|
|
62
|
+
"""Keep Windows' legacy console (cp1252) from crashing on our status glyphs.
|
|
63
|
+
|
|
64
|
+
`doctor`/`verify` print ✓/✗/•/→; a default Windows console can't encode them and
|
|
65
|
+
`print` raises UnicodeEncodeError — a traceback where the whole point is a verdict
|
|
66
|
+
that fails open. Reconfigure to UTF-8 (renders on any modern terminal), with
|
|
67
|
+
errors=replace so even a stream that can't do UTF-8 degrades instead of raising.
|
|
68
|
+
Fail-open: a stream without `reconfigure()` (older, or already wrapped) is left be.
|
|
69
|
+
"""
|
|
70
|
+
for stream in (sys.stdout, sys.stderr):
|
|
71
|
+
try:
|
|
72
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
73
|
+
except Exception:
|
|
74
|
+
# broad catch is correct — this is best-effort cosmetic setup and
|
|
75
|
+
# must never be why a command fails (a stream with no/File reconfigure, or a
|
|
76
|
+
# hostile one that raises). Fail open and let the command run.
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
81
|
+
_force_utf8()
|
|
82
|
+
parser = argparse.ArgumentParser(
|
|
83
|
+
prog="tycho",
|
|
84
|
+
description="Verify what an agent claims it did.",
|
|
85
|
+
epilog="run `tycho help` for what Tycho is and whether it's live in this repo",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument("--version", action="version", version=f"tycho {__version__}")
|
|
88
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
89
|
+
|
|
90
|
+
v = sub.add_parser("verify", help=_COMMANDS["verify"])
|
|
91
|
+
v.add_argument("--session", type=Path, help="path to a harness transcript to verify")
|
|
92
|
+
v.add_argument(
|
|
93
|
+
"--harness",
|
|
94
|
+
choices=("claude", "cursor", "codex", "opencode"),
|
|
95
|
+
help="which harness to verify (default: whichever ran most recently)",
|
|
96
|
+
)
|
|
97
|
+
v.add_argument("--since", help="git ref to diff from (manual mode), e.g. HEAD~1")
|
|
98
|
+
v.add_argument("--claim", help="free-text claim being checked (shown in the report)")
|
|
99
|
+
|
|
100
|
+
sub.add_parser("hook", help=_COMMANDS["hook"])
|
|
101
|
+
sub.add_parser("session-start", help="SessionStart-hook entrypoint (internal): update notice at agent bootup")
|
|
102
|
+
sub.add_parser("prompt-submit", help="UserPromptSubmit-hook entrypoint (internal): mark a run in flight for the badge")
|
|
103
|
+
i = sub.add_parser("init", help=_COMMANDS["init"])
|
|
104
|
+
i.add_argument(
|
|
105
|
+
"--harness",
|
|
106
|
+
choices=("claude", "cursor", "codex", "opencode"),
|
|
107
|
+
help="install only this harness, detected or not (default: every one detected here)",
|
|
108
|
+
)
|
|
109
|
+
i.add_argument("--yes", action="store_true", help="skip the prompts (for scripts and CI)")
|
|
110
|
+
sub.add_parser("doctor", help=_COMMANDS["doctor"])
|
|
111
|
+
u = sub.add_parser("uninstall", help=_COMMANDS["uninstall"])
|
|
112
|
+
u.add_argument(
|
|
113
|
+
"--harness",
|
|
114
|
+
choices=("claude", "cursor", "codex", "opencode"),
|
|
115
|
+
help="only uninstall this harness (default: all)",
|
|
116
|
+
)
|
|
117
|
+
u.add_argument(
|
|
118
|
+
"--purge",
|
|
119
|
+
action="store_true",
|
|
120
|
+
help="also delete repo-local Tycho state (.tycho/) and config (.tycho.toml)",
|
|
121
|
+
)
|
|
122
|
+
s = sub.add_parser("status", help=_COMMANDS["status"])
|
|
123
|
+
toggle = s.add_mutually_exclusive_group()
|
|
124
|
+
toggle.add_argument("--off", action="store_true", help="hide the indicator in this repo (the hook keeps verifying)")
|
|
125
|
+
toggle.add_argument("--on", action="store_true", help="show the indicator again")
|
|
126
|
+
sub.add_parser("count", help=_COMMANDS["count"])
|
|
127
|
+
r = sub.add_parser("run", help=_COMMANDS["run"])
|
|
128
|
+
r.add_argument(
|
|
129
|
+
"cmd",
|
|
130
|
+
nargs=argparse.REMAINDER,
|
|
131
|
+
help="the command to run, e.g. tycho run -- pytest -q (the -- is optional)",
|
|
132
|
+
)
|
|
133
|
+
sc = sub.add_parser("scope", help=_COMMANDS["scope"])
|
|
134
|
+
sc.add_argument("action", choices=("list", "set", "add", "remove"), help="list, or set/add/remove globs")
|
|
135
|
+
sc.add_argument(
|
|
136
|
+
"paths", nargs="*", metavar="GLOB",
|
|
137
|
+
help="one or more include globs — quote them so the shell keeps them literal, e.g. 'src/**' 'tests/**'",
|
|
138
|
+
)
|
|
139
|
+
# nargs="+" (a value-taking option), not store_true: a bare flag interspersed between the
|
|
140
|
+
# `action` and `paths` positionals splits them into two groups, which argparse < 3.12 can't
|
|
141
|
+
# backfill (it drops the trailing glob). Carrying the globs on --exclude sidesteps that.
|
|
142
|
+
sc.add_argument(
|
|
143
|
+
"--exclude", nargs="+", metavar="GLOB",
|
|
144
|
+
help="operate on the exclude list (paths carved OUT of include) instead, e.g. --exclude 'LICENSE'",
|
|
145
|
+
)
|
|
146
|
+
rl = sub.add_parser("relay", help=_COMMANDS["relay"])
|
|
147
|
+
rl_toggle = rl.add_mutually_exclusive_group()
|
|
148
|
+
rl_toggle.add_argument(
|
|
149
|
+
"--on", action="store_true",
|
|
150
|
+
help="feed a non-VERIFIED verdict back to the agent so it keeps working until VERIFIED (bounded)",
|
|
151
|
+
)
|
|
152
|
+
rl_toggle.add_argument(
|
|
153
|
+
"--off", action="store_true", help="stop feeding the verdict to the agent (the default)",
|
|
154
|
+
)
|
|
155
|
+
up = sub.add_parser("update", help=_COMMANDS["update"])
|
|
156
|
+
up.add_argument("--skip", action="store_true", help="dismiss the current update notice (records the dismissal)")
|
|
157
|
+
sub.add_parser("help", help=_COMMANDS["help"])
|
|
158
|
+
|
|
159
|
+
args = parser.parse_args(argv)
|
|
160
|
+
if args.command == "help":
|
|
161
|
+
return _help(Path.cwd())
|
|
162
|
+
if args.command == "count":
|
|
163
|
+
return _count(Path.cwd())
|
|
164
|
+
if args.command == "run":
|
|
165
|
+
return _run(args.cmd)
|
|
166
|
+
if args.command == "scope":
|
|
167
|
+
return _scope(Path.cwd(), args.action, args.paths, args.exclude)
|
|
168
|
+
if args.command == "update":
|
|
169
|
+
return _update(skip=args.skip)
|
|
170
|
+
if args.command == "relay":
|
|
171
|
+
return _relay(Path.cwd(), on=args.on, off=args.off)
|
|
172
|
+
if args.command == "status":
|
|
173
|
+
from . import status
|
|
174
|
+
|
|
175
|
+
return status.main(off=args.off, on=args.on)
|
|
176
|
+
if args.command == "hook":
|
|
177
|
+
from . import hook
|
|
178
|
+
|
|
179
|
+
return hook.main()
|
|
180
|
+
if args.command == "session-start":
|
|
181
|
+
from . import hook
|
|
182
|
+
|
|
183
|
+
return hook.session_start()
|
|
184
|
+
if args.command == "prompt-submit":
|
|
185
|
+
from . import hook
|
|
186
|
+
|
|
187
|
+
return hook.prompt_submit()
|
|
188
|
+
if args.command == "init":
|
|
189
|
+
from . import init as init_mod
|
|
190
|
+
|
|
191
|
+
rc = _install(init_mod.init(Path.cwd(), only=args.harness, assume_yes=args.yes))
|
|
192
|
+
_print_update_notice() # tell them if a newer Tycho exists (TYCHO-53)
|
|
193
|
+
return rc
|
|
194
|
+
if args.command == "doctor":
|
|
195
|
+
from . import doctor
|
|
196
|
+
|
|
197
|
+
_offer_first_run(Path.cwd())
|
|
198
|
+
findings = doctor.diagnose(Path.cwd())
|
|
199
|
+
print(doctor.render(findings))
|
|
200
|
+
return ExitCode.OK if doctor.healthy(findings) else ExitCode.UNHEALTHY
|
|
201
|
+
if args.command == "uninstall":
|
|
202
|
+
from . import init as init_mod
|
|
203
|
+
|
|
204
|
+
return _install(init_mod.uninstall(Path.cwd(), only=args.harness, purge=args.purge))
|
|
205
|
+
if args.command == "verify":
|
|
206
|
+
return _verify(args)
|
|
207
|
+
parser.error(f"unknown command: {args.command}") # argparse exits; unreachable below
|
|
208
|
+
return ExitCode.USAGE
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _run(argv: list[str]) -> int:
|
|
212
|
+
"""`tycho run [--] <cmd>` — the opt-in escape hatch (TYCHO-90). Exec <cmd> directly and
|
|
213
|
+
forward its output and *exit code* unchanged, so `command_execution` sees the runner's
|
|
214
|
+
true status no matter how the command is wrapped, piped, or aliased — the cases static
|
|
215
|
+
parsing of the shell string can't reach. Tycho only wraps; it never alters the child's
|
|
216
|
+
exit code the caller sees, so this is safe to prefix onto anything.
|
|
217
|
+
"""
|
|
218
|
+
import subprocess
|
|
219
|
+
|
|
220
|
+
cmd = argv[1:] if argv and argv[0] == "--" else argv
|
|
221
|
+
if not cmd:
|
|
222
|
+
print("tycho run: give a command, e.g. tycho run -- pytest -q", file=sys.stderr)
|
|
223
|
+
return ExitCode.USAGE
|
|
224
|
+
try:
|
|
225
|
+
return subprocess.call(cmd) # inherits stdio; returns the child's real exit code
|
|
226
|
+
except FileNotFoundError:
|
|
227
|
+
print(f"tycho run: command not found: {cmd[0]}", file=sys.stderr)
|
|
228
|
+
return ExitCode.USAGE
|
|
229
|
+
except KeyboardInterrupt:
|
|
230
|
+
return 130 # conventional 128+SIGINT, matching what a bare run would return
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _help(cwd: Path) -> int:
|
|
234
|
+
"""One screen: what Tycho is, whether it's live *here*, and every command (TYCHO-38).
|
|
235
|
+
|
|
236
|
+
The liveness line is the reason this exists. `-h` lists subcommands but can't answer
|
|
237
|
+
the question people actually have — is it on? — and nobody discovers `doctor` until
|
|
238
|
+
they already suspect it isn't.
|
|
239
|
+
"""
|
|
240
|
+
from . import doctor
|
|
241
|
+
|
|
242
|
+
print(f"tycho {__version__} — verify what an agent claims it did.")
|
|
243
|
+
print(f"\n{_ABOUT}\n")
|
|
244
|
+
print(f"Status here: {doctor.liveness(cwd)}\n")
|
|
245
|
+
print("Commands:")
|
|
246
|
+
for name, text in _COMMANDS.items():
|
|
247
|
+
print(f" {name:<10} {text}")
|
|
248
|
+
print("\n `tycho doctor` for full diagnostics · https://github.com/swail-labs/tycho")
|
|
249
|
+
return ExitCode.OK
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _count(cwd: Path) -> int:
|
|
253
|
+
"""`tycho count` — the running tally of what Tycho caught (TYCHO-50/62).
|
|
254
|
+
|
|
255
|
+
Reads only what the hook already wrote (`state.catches.json`), like `status`: no engine,
|
|
256
|
+
no verification. "Caught" is the adverse tally (FAILED + STALE); INDETERMINATE is shown
|
|
257
|
+
apart, because a blind spot isn't a save.
|
|
258
|
+
"""
|
|
259
|
+
from . import state
|
|
260
|
+
|
|
261
|
+
here = _caught(state.counts(cwd), state.totals(cwd))
|
|
262
|
+
everywhere = _caught(state.all_time_counts(), state.all_time_totals())
|
|
263
|
+
print(f"this repo: {here} · all-time: {everywhere}")
|
|
264
|
+
return ExitCode.OK
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _caught(counts: dict, totals: dict) -> str:
|
|
268
|
+
""""12 caught (9 FAILED, 3 STALE) of 274 runs, 41 blind (15%)" — the catches read against
|
|
269
|
+
their denominator (TYCHO-58). The breakdown and the blind clause each drop when zero; the
|
|
270
|
+
whole denominator drops for a legacy tally with no run count yet (runs == 0), falling back
|
|
271
|
+
to the bare "N caught"."""
|
|
272
|
+
caught = counts["FAILED"] + counts["STALE"]
|
|
273
|
+
breakdown = ", ".join(f"{counts[v]} {v}" for v in ("FAILED", "STALE") if counts[v])
|
|
274
|
+
text = f"{caught} caught ({breakdown})" if caught else "0 caught"
|
|
275
|
+
runs = totals["runs"]
|
|
276
|
+
if not runs: # legacy tally with no denominator, or a genuinely quiet repo
|
|
277
|
+
return text
|
|
278
|
+
blind = totals["blind"] # INDETERMINATE + UNSUPPORTED — runs Tycho couldn't speak to
|
|
279
|
+
rate = f", {blind} blind ({round(100 * blind / runs)}%)" if blind else ""
|
|
280
|
+
return f"{text} of {runs} run{'' if runs == 1 else 's'}{rate}"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _scope(cwd: Path, action: str, paths: list[str], exclude_globs: list[str] | None = None) -> int:
|
|
284
|
+
"""`tycho scope list|set|add|remove [--exclude GLOB...]` — read or edit the scope_drift
|
|
285
|
+
bounds in `.tycho.toml`. Positional globs edit the include allowlist; `--exclude GLOB...`
|
|
286
|
+
edits the exclude denylist instead (paths carved back out of include — exclude wins). Zero-
|
|
287
|
+
config stays intact: an empty include means scope_drift is UNSUPPORTED. Globs are stored
|
|
288
|
+
verbatim (an explicit, deterministic bound), so quote them at the shell to keep them literal."""
|
|
289
|
+
from . import config as config_mod
|
|
290
|
+
|
|
291
|
+
exclude = exclude_globs is not None # --exclude carries its own globs, so its presence is the mode
|
|
292
|
+
globs = exclude_globs if exclude else paths
|
|
293
|
+
if action != "list":
|
|
294
|
+
if not globs:
|
|
295
|
+
flag = " --exclude" if exclude else ""
|
|
296
|
+
print(
|
|
297
|
+
f"tycho scope {action}{flag}: give at least one glob, "
|
|
298
|
+
f"e.g. tycho scope {action}{flag} 'src/**' 'tests/**'",
|
|
299
|
+
file=sys.stderr,
|
|
300
|
+
)
|
|
301
|
+
return ExitCode.USAGE
|
|
302
|
+
include_fns = {"set": config_mod.set_scope, "add": config_mod.add_scope, "remove": config_mod.remove_scope}
|
|
303
|
+
exclude_fns = {"set": config_mod.set_exclude, "add": config_mod.add_exclude, "remove": config_mod.remove_exclude}
|
|
304
|
+
(exclude_fns if exclude else include_fns)[action](cwd, globs)
|
|
305
|
+
|
|
306
|
+
cfg = config_mod.load(cwd)
|
|
307
|
+
if cfg.scope_include:
|
|
308
|
+
print(f"scope ({config_mod.CONFIG_NAME}) — the agent may edit:")
|
|
309
|
+
for g in cfg.scope_include:
|
|
310
|
+
print(f" {g}")
|
|
311
|
+
if cfg.scope_exclude:
|
|
312
|
+
print(" …except (exclude wins):")
|
|
313
|
+
for g in cfg.scope_exclude:
|
|
314
|
+
print(f" !{g}")
|
|
315
|
+
print("edits outside these FAIL scope_drift.")
|
|
316
|
+
else:
|
|
317
|
+
print("scope: none set — every path is in scope, so scope_drift stays UNSUPPORTED (zero-config).")
|
|
318
|
+
print("set bounds with: tycho scope add 'src/**' 'tests/**'")
|
|
319
|
+
if cfg.scope_exclude: # exclude without include does nothing — be honest about it
|
|
320
|
+
print(f"note: exclude is set but ignored while include is empty: {', '.join(cfg.scope_exclude)}")
|
|
321
|
+
return ExitCode.OK
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _relay(cwd: Path, on: bool, off: bool) -> int:
|
|
325
|
+
"""`tycho relay [--on|--off]` — the opt-in verdict relay (TYCHO-35). Off by default.
|
|
326
|
+
|
|
327
|
+
With it on, the Stop hook feeds a non-VERIFIED verdict back to the Claude agent as context,
|
|
328
|
+
so the agent keeps working until VERIFIED — bounded by ``TYCHO_RELAY_MAX`` (default 3) so it
|
|
329
|
+
can't loop forever. Bare ``tycho relay`` reports the current setting without changing it.
|
|
330
|
+
"""
|
|
331
|
+
from . import state
|
|
332
|
+
|
|
333
|
+
repo = state.root_for(cwd)
|
|
334
|
+
if on or off:
|
|
335
|
+
state.set_relay_enabled(repo, enabled=on)
|
|
336
|
+
enabled = state.relay_enabled(repo)
|
|
337
|
+
if not (on or off):
|
|
338
|
+
print(f"tycho: verdict relay is {'ON' if enabled else 'OFF'} for {repo}"
|
|
339
|
+
f"{'' if enabled else ' — verdicts stay human-only (no agent context used)'}.")
|
|
340
|
+
print(" toggle: `tycho relay --on` | `--off` · in Claude Code: /tycho-relay-on | "
|
|
341
|
+
"/tycho-relay-off · stored in .tycho.toml [relay].")
|
|
342
|
+
elif enabled:
|
|
343
|
+
print(f"tycho: verdict relay ON for {repo} — the agent now sees a non-VERIFIED verdict and "
|
|
344
|
+
f"keeps working until VERIFIED, up to {state.relay_max()} automatic re-checks per turn. "
|
|
345
|
+
f"This spends extra tokens; turn it back off with `tycho relay --off`.")
|
|
346
|
+
else:
|
|
347
|
+
print(f"tycho: verdict relay OFF for {repo} — verdicts stay human-only, no agent context used.")
|
|
348
|
+
return ExitCode.OK
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _print_update_notice() -> None:
|
|
352
|
+
"""Print the 'newer version available' line, if any. Never the reason a command fails."""
|
|
353
|
+
try:
|
|
354
|
+
from . import version as version_mod
|
|
355
|
+
|
|
356
|
+
note = version_mod.notice(refresh_first=True)
|
|
357
|
+
if note:
|
|
358
|
+
print(note, file=sys.stderr)
|
|
359
|
+
except Exception:
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _upgrade_command() -> list[str]:
|
|
364
|
+
"""The upgrade command for however Tycho was installed — best-effort from the install
|
|
365
|
+
path. Falls back to pip, which works for a plain `pip install` (TYCHO-10).
|
|
366
|
+
|
|
367
|
+
Names the **distribution** (`tycho-cli`), not the import/command name `tycho`: the bare
|
|
368
|
+
`tycho` on PyPI is an unrelated project, so `pip install --upgrade tycho` would pull that,
|
|
369
|
+
and `pipx/uv upgrade tycho` wouldn't find the tool (installed as `tycho-cli`) — TYCHO-96.
|
|
370
|
+
Single-sourced from the same constant the update check queries, so the two can't drift.
|
|
371
|
+
"""
|
|
372
|
+
from . import version as version_mod
|
|
373
|
+
|
|
374
|
+
pkg = version_mod._DIST_NAME or "tycho-cli"
|
|
375
|
+
prefix = sys.prefix.replace("\\", "/").lower()
|
|
376
|
+
if "pipx" in prefix:
|
|
377
|
+
return ["pipx", "upgrade", pkg]
|
|
378
|
+
if "/uv/tools/" in prefix or "/uv/" in prefix:
|
|
379
|
+
return ["uv", "tool", "upgrade", pkg]
|
|
380
|
+
return [sys.executable, "-m", "pip", "install", "--upgrade", pkg]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _update(skip: bool) -> int:
|
|
384
|
+
"""`tycho update` — check the index and upgrade in place, or `--skip` to dismiss the
|
|
385
|
+
notice for this version. Offline/failure is reported, never fatal (TYCHO-10/53)."""
|
|
386
|
+
from . import state
|
|
387
|
+
from . import version as version_mod
|
|
388
|
+
|
|
389
|
+
newest = version_mod.refresh()
|
|
390
|
+
behind = bool(newest) and version_mod.is_newer(newest, __version__)
|
|
391
|
+
if skip:
|
|
392
|
+
if behind:
|
|
393
|
+
state.dismiss_update(newest)
|
|
394
|
+
print(f"tycho: dismissed the update to {newest} "
|
|
395
|
+
f"(you've dismissed {state.update_dismissed_count()} so far). "
|
|
396
|
+
f"`tycho update` still upgrades when you're ready.")
|
|
397
|
+
else:
|
|
398
|
+
print(f"tycho {__version__}: nothing to skip — up to date (or the index is unreachable).")
|
|
399
|
+
return ExitCode.OK
|
|
400
|
+
if newest is None:
|
|
401
|
+
print(f"tycho {__version__}: couldn't reach the package index (offline?). Try again later.")
|
|
402
|
+
return ExitCode.OK
|
|
403
|
+
if not behind:
|
|
404
|
+
print(f"tycho {__version__} is up to date.")
|
|
405
|
+
return ExitCode.OK
|
|
406
|
+
cmd = _upgrade_command()
|
|
407
|
+
print(f"Updating tycho {__version__} → {newest}: {' '.join(cmd)}")
|
|
408
|
+
try:
|
|
409
|
+
import subprocess
|
|
410
|
+
|
|
411
|
+
return subprocess.run(cmd).returncode or ExitCode.OK
|
|
412
|
+
except Exception as exc:
|
|
413
|
+
print(f"tycho: couldn't run the upgrade ({type(exc).__name__}). Run it yourself:\n {' '.join(cmd)}",
|
|
414
|
+
file=sys.stderr)
|
|
415
|
+
return ExitCode.OK
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _install(lines: Sequence[str]) -> int:
|
|
419
|
+
"""Print init/uninstall status lines; exit non-zero if we refused to touch a file.
|
|
420
|
+
|
|
421
|
+
A refusal is an unfinished install, not a warning — `tycho init --yes` in a
|
|
422
|
+
provisioning script has to fail loudly rather than leave the repo unhooked.
|
|
423
|
+
"""
|
|
424
|
+
from .init import REFUSED
|
|
425
|
+
|
|
426
|
+
for line in lines:
|
|
427
|
+
print(line)
|
|
428
|
+
return ExitCode.INTERNAL if any(REFUSED in line for line in lines) else ExitCode.OK
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _warn_if_hook_broken(cwd: Path) -> None:
|
|
432
|
+
"""Say so, loudly, if Tycho is installed here but wouldn't actually fire.
|
|
433
|
+
|
|
434
|
+
A manual `tycho verify` is often the only moment a human looks at Tycho — and the
|
|
435
|
+
verdict it prints would look identical whether the Stop hook has been running all
|
|
436
|
+
week or has been dead since the venv moved. Stderr, so it can't pollute a piped
|
|
437
|
+
report, and never fatal: a broken hook isn't a failed claim (TYCHO-8).
|
|
438
|
+
"""
|
|
439
|
+
try:
|
|
440
|
+
from . import doctor
|
|
441
|
+
|
|
442
|
+
for f in doctor.hook_health(cwd):
|
|
443
|
+
print(f"tycho: {f.level} — {f.text}", file=sys.stderr)
|
|
444
|
+
if f.fix:
|
|
445
|
+
print(f" → {f.fix}", file=sys.stderr)
|
|
446
|
+
except Exception:
|
|
447
|
+
pass # a diagnostic must never be the reason a verify fails
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _offer_first_run(cwd: Path) -> None:
|
|
451
|
+
"""First-run 'set up Tycho here?' offer, printed for the manual commands. Never fatal."""
|
|
452
|
+
try:
|
|
453
|
+
from . import init as init_mod
|
|
454
|
+
|
|
455
|
+
for line in init_mod.offer_first_run(cwd):
|
|
456
|
+
print(line)
|
|
457
|
+
except Exception:
|
|
458
|
+
pass
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _verify(args: argparse.Namespace) -> int:
|
|
462
|
+
from . import state
|
|
463
|
+
|
|
464
|
+
# The repo root, not wherever the user happens to stand (TYCHO-79). Everything below is
|
|
465
|
+
# keyed to it: harnesses store transcripts under the *project* path, so discovery from a
|
|
466
|
+
# subdirectory finds no session and verify goes INDETERMINATE on a session that exists.
|
|
467
|
+
cwd = state.root_for(Path.cwd())
|
|
468
|
+
_offer_first_run(cwd)
|
|
469
|
+
_warn_if_hook_broken(cwd)
|
|
470
|
+
if args.session:
|
|
471
|
+
harness = harness_mod.BY_NAME.get(args.harness or "claude", harness_mod.CLAUDE)
|
|
472
|
+
transcript = args.session
|
|
473
|
+
else:
|
|
474
|
+
transcript, harness = harness_mod.discover(cwd, only=args.harness)
|
|
475
|
+
if transcript is None:
|
|
476
|
+
note = CheckResult(
|
|
477
|
+
"session",
|
|
478
|
+
CheckStatus.INDETERMINATE,
|
|
479
|
+
"no recent session found for this directory — pass --session <path>",
|
|
480
|
+
)
|
|
481
|
+
print(render(Verdict.INDETERMINATE, [note], claim=args.claim))
|
|
482
|
+
return ExitCode.OK
|
|
483
|
+
print(f"tycho: verifying {harness.name} session {transcript}")
|
|
484
|
+
|
|
485
|
+
try:
|
|
486
|
+
try:
|
|
487
|
+
session = engine.gather(
|
|
488
|
+
transcript, cwd, since=args.since or "HEAD",
|
|
489
|
+
parse=harness.parse, messages=harness.messages,
|
|
490
|
+
)
|
|
491
|
+
finally:
|
|
492
|
+
# OpenCode's transcript is a rebuilt temp file — discovery owns cleanup.
|
|
493
|
+
if not args.session and harness.name == "opencode":
|
|
494
|
+
transcript.unlink(missing_ok=True)
|
|
495
|
+
except Exception as exc:
|
|
496
|
+
# A verifier that dies on a corrupt transcript/config must say so plainly —
|
|
497
|
+
# a traceback is not a verdict. (The Stop hook stays silent; this is the
|
|
498
|
+
# manual path, where the human asked and deserves an answer.)
|
|
499
|
+
print(f"tycho: could not verify {transcript} — {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
500
|
+
return ExitCode.INTERNAL
|
|
501
|
+
|
|
502
|
+
results = engine.run_checks(session)
|
|
503
|
+
verdict = engine.verdict_of(results)
|
|
504
|
+
# A manual verify is a real verification event — record it so the status bar reflects
|
|
505
|
+
# it (a green [TYCHO] after `tycho verify` → VERIFIED), same channel the hook writes.
|
|
506
|
+
try:
|
|
507
|
+
state.record_run(cwd, harness.name, verdict=verdict.name)
|
|
508
|
+
state.record_catch(cwd, harness.name, verdict.name, results) # evidence trail (TYCHO-62)
|
|
509
|
+
except Exception:
|
|
510
|
+
pass # a status-bar convenience must never be why verify fails
|
|
511
|
+
print(render(verdict, results, claim=args.claim))
|
|
512
|
+
return _VERDICT_EXIT.get(verdict, ExitCode.OK)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
if __name__ == "__main__":
|
|
516
|
+
raise SystemExit(main())
|