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/config.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Load and manage `.tycho.toml`. Zero-config default just works (no scope check, no disables).
|
|
2
|
+
|
|
3
|
+
`tomllib` reads TOML but the stdlib has no writer, so the mutating helpers (`ensure`,
|
|
4
|
+
`set_scope`/`add_scope`/`remove_scope`) re-render the file from a small canonical template.
|
|
5
|
+
That keeps Tycho stdlib-only and preserves the built-in explanatory comments, at the cost
|
|
6
|
+
of not preserving a user's own hand-added comments or unknown tables — the file is Tycho's
|
|
7
|
+
to manage, and `tycho scope` says so. The keys Tycho's schema defines (`scope.include`,
|
|
8
|
+
`scope.exclude`, `checks.disable`, `relay.enabled`) always round-trip.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import tomllib
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import state
|
|
18
|
+
|
|
19
|
+
CONFIG_NAME = ".tycho.toml"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Config:
|
|
24
|
+
scope_include: tuple[str, ...] = ()
|
|
25
|
+
disabled_checks: tuple[str, ...] = ()
|
|
26
|
+
scope_exclude: tuple[str, ...] = ()
|
|
27
|
+
relay_enabled: bool = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def path(repo: Path) -> Path:
|
|
31
|
+
"""The config for `repo` — resolved the same way as `.tycho/`, its sibling, so that a
|
|
32
|
+
command run from a subdirectory reads the repo's real scope instead of defaulting to
|
|
33
|
+
"no scope set" (TYCHO-79)."""
|
|
34
|
+
return state.root_for(repo) / CONFIG_NAME
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load(repo: Path) -> Config:
|
|
38
|
+
"""Read the repo's `.tycho.toml`; missing file -> defaults (never a false positive)."""
|
|
39
|
+
p = path(repo)
|
|
40
|
+
if not p.is_file():
|
|
41
|
+
return Config()
|
|
42
|
+
data = tomllib.loads(p.read_text(encoding="utf-8"))
|
|
43
|
+
scope = data.get("scope", {})
|
|
44
|
+
disabled = data.get("checks", {}).get("disable", [])
|
|
45
|
+
relay = data.get("relay", {}).get("enabled", False)
|
|
46
|
+
return Config(
|
|
47
|
+
scope_include=tuple(scope.get("include", [])),
|
|
48
|
+
disabled_checks=tuple(disabled),
|
|
49
|
+
scope_exclude=tuple(scope.get("exclude", [])),
|
|
50
|
+
relay_enabled=bool(relay),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --- writing (stdlib-only, canonical render) --------------------------------
|
|
55
|
+
|
|
56
|
+
def _toml_str(s: str) -> str:
|
|
57
|
+
"""One glob as a TOML basic string. Globs use `/ * ? [ ]` — none need escaping; only
|
|
58
|
+
a backslash or double-quote would, so handle just those."""
|
|
59
|
+
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _toml_array(items: tuple[str, ...]) -> str:
|
|
63
|
+
return "[" + ", ".join(_toml_str(i) for i in items) + "]"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def render(
|
|
67
|
+
scope_include: tuple[str, ...] = (),
|
|
68
|
+
disabled: tuple[str, ...] = (),
|
|
69
|
+
scope_exclude: tuple[str, ...] = (),
|
|
70
|
+
relay_enabled: bool = False,
|
|
71
|
+
) -> str:
|
|
72
|
+
"""The canonical `.tycho.toml`, with the current values filled in."""
|
|
73
|
+
return (
|
|
74
|
+
"# .tycho.toml — Tycho configuration. Managed by `tycho scope` / `tycho relay`; also safe\n"
|
|
75
|
+
"# to hand-edit. Tycho never assumes this file exists: with an empty `include` below,\n"
|
|
76
|
+
"# `scope_drift` stays UNSUPPORTED and every other check runs as it does with no file.\n"
|
|
77
|
+
"\n"
|
|
78
|
+
"[scope]\n"
|
|
79
|
+
"# Directories/files the agent is allowed to edit, as globs with POSIX separators\n"
|
|
80
|
+
'# (e.g. "src/**", "tests/**"). An edit outside these FAILs scope_drift. This is an\n'
|
|
81
|
+
"# explicit, deterministic bound — never inferred from the prompt.\n"
|
|
82
|
+
"# Manage with: tycho scope add|remove|set <glob>... (or /tycho-scope-* in Claude Code)\n"
|
|
83
|
+
f"include = {_toml_array(scope_include)}\n"
|
|
84
|
+
"# Paths to carve back OUT of `include`, same glob syntax. An edit matching any of these\n"
|
|
85
|
+
'# FAILs scope_drift even if it also matches `include` — exclude wins (e.g. include "**",\n'
|
|
86
|
+
'# exclude "LICENSE" allows the whole tree but that one file). Empty = pure allowlist.\n'
|
|
87
|
+
"# Manage with: tycho scope add|remove|set --exclude <glob>...\n"
|
|
88
|
+
f"exclude = {_toml_array(scope_exclude)}\n"
|
|
89
|
+
"\n"
|
|
90
|
+
"[checks]\n"
|
|
91
|
+
"# Check names to disable entirely (rarely needed), e.g. \"command_execution\".\n"
|
|
92
|
+
f"disable = {_toml_array(disabled)}\n"
|
|
93
|
+
"\n"
|
|
94
|
+
"[relay]\n"
|
|
95
|
+
"# Feed a non-VERIFIED verdict back to the agent so it keeps working until VERIFIED\n"
|
|
96
|
+
"# (Claude Code only, bounded by TYCHO_RELAY_MAX). Off by default — verdicts stay\n"
|
|
97
|
+
"# human-only. Toggle: tycho relay --on|--off (or /tycho-relay-on / -off in Claude Code).\n"
|
|
98
|
+
f"enabled = {'true' if relay_enabled else 'false'}\n"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _write(repo: Path, text: str) -> None:
|
|
103
|
+
"""Atomic write so an interrupted run can't truncate the file."""
|
|
104
|
+
p = path(repo)
|
|
105
|
+
tmp = p.with_name(p.name + ".tmp")
|
|
106
|
+
tmp.write_text(text, encoding="utf-8")
|
|
107
|
+
tmp.replace(p)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def ensure(repo: Path) -> bool:
|
|
111
|
+
"""Create `.tycho.toml` with the template if it's absent. Never clobber an existing
|
|
112
|
+
one (TYCHO-6). Returns True if it created the file."""
|
|
113
|
+
if path(repo).exists():
|
|
114
|
+
return False
|
|
115
|
+
_write(repo, render())
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _save(repo: Path, include: tuple[str, ...], exclude: tuple[str, ...]) -> None:
|
|
120
|
+
"""Rewrite the file with these include/exclude lists, preserving disabled checks + relay."""
|
|
121
|
+
cur = load(repo)
|
|
122
|
+
_write(repo, render(tuple(include), cur.disabled_checks, tuple(exclude), cur.relay_enabled))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def set_relay(repo: Path, enabled: bool) -> None:
|
|
126
|
+
"""Write the verdict-relay flag to `.tycho.toml`, preserving scope + disabled checks. Creates
|
|
127
|
+
the file if absent, so the setting is always visible and hand-editable (TYCHO-114)."""
|
|
128
|
+
cur = load(repo)
|
|
129
|
+
_write(repo, render(cur.scope_include, cur.disabled_checks, cur.scope_exclude, enabled))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def set_scope(repo: Path, globs: list[str]) -> tuple[str, ...]:
|
|
133
|
+
"""Replace the include list wholesale (dedup, keep order)."""
|
|
134
|
+
new = tuple(dict.fromkeys(globs))
|
|
135
|
+
_save(repo, new, load(repo).scope_exclude)
|
|
136
|
+
return new
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def add_scope(repo: Path, globs: list[str]) -> tuple[str, ...]:
|
|
140
|
+
"""Append include globs not already present."""
|
|
141
|
+
cur = load(repo)
|
|
142
|
+
new = tuple(dict.fromkeys((*cur.scope_include, *globs)))
|
|
143
|
+
_save(repo, new, cur.scope_exclude)
|
|
144
|
+
return new
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def remove_scope(repo: Path, globs: list[str]) -> tuple[str, ...]:
|
|
148
|
+
"""Drop every listed include glob (exact match)."""
|
|
149
|
+
cur = load(repo)
|
|
150
|
+
drop = set(globs)
|
|
151
|
+
new = tuple(g for g in cur.scope_include if g not in drop)
|
|
152
|
+
_save(repo, new, cur.scope_exclude)
|
|
153
|
+
return new
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def set_exclude(repo: Path, globs: list[str]) -> tuple[str, ...]:
|
|
157
|
+
"""Replace the exclude list wholesale (dedup, keep order)."""
|
|
158
|
+
new = tuple(dict.fromkeys(globs))
|
|
159
|
+
_save(repo, load(repo).scope_include, new)
|
|
160
|
+
return new
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def add_exclude(repo: Path, globs: list[str]) -> tuple[str, ...]:
|
|
164
|
+
"""Append exclude globs not already present."""
|
|
165
|
+
cur = load(repo)
|
|
166
|
+
new = tuple(dict.fromkeys((*cur.scope_exclude, *globs)))
|
|
167
|
+
_save(repo, cur.scope_include, new)
|
|
168
|
+
return new
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def remove_exclude(repo: Path, globs: list[str]) -> tuple[str, ...]:
|
|
172
|
+
"""Drop every listed exclude glob (exact match)."""
|
|
173
|
+
cur = load(repo)
|
|
174
|
+
drop = set(globs)
|
|
175
|
+
new = tuple(g for g in cur.scope_exclude if g not in drop)
|
|
176
|
+
_save(repo, cur.scope_include, new)
|
|
177
|
+
return new
|
tycho/doctor.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""`tycho doctor` — is Tycho actually wired, current, and still firing here?
|
|
2
|
+
|
|
3
|
+
A silently dead hook is the worst failure a verifier has. Every other bug is loud: a
|
|
4
|
+
wrong verdict argues with you. A dead hook says nothing at all, and silence is exactly
|
|
5
|
+
what "everything passed" looks like — you believe you're covered while nothing runs.
|
|
6
|
+
(TYCHO-3, the OpenCode export truncation, was this class of failure.)
|
|
7
|
+
|
|
8
|
+
**The hard part is that a dead hook cannot report its own death.** Nothing Tycho does at
|
|
9
|
+
Stop time can help, because a hook that doesn't fire runs no code. So liveness can only
|
|
10
|
+
come from something the hook *already did*: `state.record_run` writes a heartbeat on
|
|
11
|
+
every invocation, and `doctor` — a manual command, run by a human who is present — reads
|
|
12
|
+
it back. That's the whole trick, and its limits are worth stating plainly:
|
|
13
|
+
|
|
14
|
+
- A heartbeat proves the hook fired at least once, at that time. Proof of life, never
|
|
15
|
+
proof of current health.
|
|
16
|
+
- **No heartbeat is not proof of death.** A fresh install hasn't fired yet; neither has
|
|
17
|
+
a repo you haven't run an agent in. Reporting BROKEN there would be crying wolf, and a
|
|
18
|
+
diagnostic that cries wolf gets ignored exactly when it's finally right.
|
|
19
|
+
- Nothing here polls or runs in the background — Tycho stays a thing that runs when
|
|
20
|
+
called. The cost is that a hook which died five minutes ago goes undiagnosed until
|
|
21
|
+
someone asks. That's the honest trade, documented rather than hidden.
|
|
22
|
+
|
|
23
|
+
So doctor reports what it can prove from disk and says "unknown" where it can't. It
|
|
24
|
+
never edits anything: diagnosis and repair stay separate, and `tycho init` is already
|
|
25
|
+
the repair (it's self-healing).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import shlex
|
|
32
|
+
import shutil
|
|
33
|
+
import subprocess
|
|
34
|
+
import time
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from . import harness as harness_mod
|
|
39
|
+
from . import init as init_mod
|
|
40
|
+
from . import state
|
|
41
|
+
from . import version as version_mod
|
|
42
|
+
|
|
43
|
+
# Severities. BROKEN and OUTDATED are the two that mean "Tycho is not doing its job";
|
|
44
|
+
# the rest is context for whoever is reading.
|
|
45
|
+
OK = "OK"
|
|
46
|
+
BROKEN = "HOOK BROKEN"
|
|
47
|
+
OUTDATED = "HOOK OUTDATED"
|
|
48
|
+
INFO = "INFO"
|
|
49
|
+
# Advisory only: the harness moved past the version Tycho's output contract was verified
|
|
50
|
+
# against. "Re-verify", not "broken" — a bump usually changes nothing, so it never sinks
|
|
51
|
+
# the verdict (not in _ADVERSE). But louder than a bare INFO bullet, because it's the one
|
|
52
|
+
# thing doctor structurally can't check for itself: whether the harness still reads our
|
|
53
|
+
# output (TYCHO-34).
|
|
54
|
+
DRIFT = "HARNESS DRIFT"
|
|
55
|
+
|
|
56
|
+
_ADVERSE = (BROKEN, OUTDATED)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Finding:
|
|
61
|
+
"""One diagnosis.
|
|
62
|
+
|
|
63
|
+
`fix` is mandatory for adverse levels: a complaint without a remedy is noise, and
|
|
64
|
+
the user is reading this *because* something is off.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
level: str
|
|
68
|
+
text: str
|
|
69
|
+
fix: str = ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def hook_health(repo: Path) -> list[Finding]:
|
|
73
|
+
"""Only the adverse wiring findings: is what's installed runnable, and current?
|
|
74
|
+
|
|
75
|
+
No discovery, no heartbeat — cheap enough to run on *every* manual command. That's
|
|
76
|
+
the point: a broken hook has to be surfaced by the command people actually run, not
|
|
77
|
+
only by the one they'd run if they already suspected something was wrong.
|
|
78
|
+
"""
|
|
79
|
+
findings: list[Finding] = []
|
|
80
|
+
_wired_harnesses(repo, findings)
|
|
81
|
+
return adverse(findings)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _wired_harnesses(repo: Path, findings: list[Finding]) -> list[str]:
|
|
85
|
+
recorded = state.read_install(repo)
|
|
86
|
+
wired = [n for n in init_mod.HARNESSES if _is_wired(repo, n, recorded, findings)]
|
|
87
|
+
schema = _schema_finding(repo)
|
|
88
|
+
if schema is not None:
|
|
89
|
+
findings.append(schema)
|
|
90
|
+
return wired
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def diagnose(repo: Path) -> list[Finding]:
|
|
94
|
+
"""Everything we can establish about this repo's wiring, without touching it."""
|
|
95
|
+
findings: list[Finding] = []
|
|
96
|
+
# A new version is worth surfacing where someone is already looking; doctor is manual,
|
|
97
|
+
# so the (once-a-day, fail-open) network check is fine here (TYCHO-53).
|
|
98
|
+
note = version_mod.notice(refresh_first=True)
|
|
99
|
+
if note:
|
|
100
|
+
findings.append(Finding(INFO, note, "run `tycho update`"))
|
|
101
|
+
wired = _wired_harnesses(repo, findings)
|
|
102
|
+
|
|
103
|
+
if not wired:
|
|
104
|
+
findings.append(Finding(
|
|
105
|
+
INFO, "no Tycho hook is installed in this repo", "run `tycho init` to install one"
|
|
106
|
+
))
|
|
107
|
+
return findings
|
|
108
|
+
|
|
109
|
+
findings.append(_heartbeat_finding(repo, wired))
|
|
110
|
+
findings.append(_transcript_finding(repo))
|
|
111
|
+
findings.extend(_harness_drift(wired))
|
|
112
|
+
return findings
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def adverse(findings: list[Finding]) -> list[Finding]:
|
|
116
|
+
"""The findings that mean Tycho isn't doing its job."""
|
|
117
|
+
return [f for f in findings if f.level in _ADVERSE]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def liveness(repo: Path) -> str:
|
|
121
|
+
"""One line answering "is it on here?" — the payoff line of `tycho help` (TYCHO-38).
|
|
122
|
+
|
|
123
|
+
The same reads `diagnose` does, collapsed to a sentence and never raised: help is the
|
|
124
|
+
command a confused user reaches for, so it degrades to an honest "unknown" rather than
|
|
125
|
+
dying on a repo where nothing is installed. `tycho doctor` stays the full answer.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
findings: list[Finding] = []
|
|
129
|
+
wired = _wired_harnesses(repo, findings)
|
|
130
|
+
if not wired:
|
|
131
|
+
return "NOT installed here — run `tycho init`"
|
|
132
|
+
broken = adverse(findings)
|
|
133
|
+
if broken:
|
|
134
|
+
return f"installed, but not working — {broken[0].text}"
|
|
135
|
+
return f"installed ({', '.join(wired)}) — {_heartbeat_finding(repo, wired).text}"
|
|
136
|
+
except Exception as exc: # a status line must never be the reason `tycho help` fails
|
|
137
|
+
return f"status unknown ({type(exc).__name__}) — run `tycho doctor`"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _is_wired(repo: Path, name: str, recorded: dict, findings: list[Finding]) -> bool:
|
|
141
|
+
"""Diagnose one harness against its *own config* — the only thing that decides
|
|
142
|
+
whether Tycho fires. install.json is a claim; the harness's config is the truth."""
|
|
143
|
+
try:
|
|
144
|
+
command = init_mod.installed_command(repo, name)
|
|
145
|
+
except init_mod.ConfigRefused as exc:
|
|
146
|
+
findings.append(Finding(BROKEN, f"{name}: {exc}", "fix that file, then re-run `tycho init`"))
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
if command is None:
|
|
150
|
+
if name in recorded:
|
|
151
|
+
# We installed it and it's gone: an upgrade rewrote the config, someone
|
|
152
|
+
# hand-edited it, or a teammate's settings landed on top. Loud, because this
|
|
153
|
+
# is precisely the case where the user believes they're covered and isn't.
|
|
154
|
+
findings.append(Finding(
|
|
155
|
+
BROKEN,
|
|
156
|
+
f"{name}: Tycho installed a hook here, but it's gone from {init_mod.config_path(repo, name)}",
|
|
157
|
+
"run `tycho init` to reinstall it",
|
|
158
|
+
))
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
if not _resolves(command):
|
|
162
|
+
# The entry is there, so the harness dutifully runs it — into a missing
|
|
163
|
+
# interpreter. The config looks installed and nothing has ever run: the exact
|
|
164
|
+
# silent death this command exists to surface.
|
|
165
|
+
findings.append(Finding(
|
|
166
|
+
BROKEN,
|
|
167
|
+
f"{name}: hook command doesn't resolve to a runnable program — {command}",
|
|
168
|
+
"run `tycho init` to rewrite it for this environment",
|
|
169
|
+
))
|
|
170
|
+
return True # wired (the entry exists), just not working
|
|
171
|
+
|
|
172
|
+
# Deliberately *not* compared against `init.hook_command()`. That returns a console
|
|
173
|
+
# script or `<python> -m` depending on whether the venv happens to be on PATH right
|
|
174
|
+
# now, so the same healthy install reads as two different "current" answers — and
|
|
175
|
+
# flagging a hook that resolves and runs would be crying wolf (see module docstring).
|
|
176
|
+
# A command that resolves and is one of ours does the job; that's the bar. A stale
|
|
177
|
+
# path to a deleted venv doesn't resolve, and is caught above.
|
|
178
|
+
findings.append(Finding(OK, f"{name}: hook installed and runnable — {command}"))
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _resolves(command: str) -> bool:
|
|
183
|
+
"""Would the host shell find something to execute here?
|
|
184
|
+
|
|
185
|
+
The hook fires without a venv, so this is the question that actually matters — and
|
|
186
|
+
the one a config-shape check can't answer. Split the way the *host* shell would
|
|
187
|
+
(Windows keeps backslashes; POSIX treats them as escapes), then: a path must exist
|
|
188
|
+
on disk, a bare name must be on PATH. The executable bit is a POSIX concept — on
|
|
189
|
+
Windows, existence (with ``which`` honouring PATHEXT) is the runnable test.
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
argv = shlex.split(command, posix=(os.name != "nt"))
|
|
193
|
+
except ValueError:
|
|
194
|
+
return False
|
|
195
|
+
if not argv:
|
|
196
|
+
return False
|
|
197
|
+
program = argv[0].strip('"')
|
|
198
|
+
is_path = bool(os.path.dirname(program)) or (os.name == "nt" and ":" in program)
|
|
199
|
+
if is_path:
|
|
200
|
+
if os.name == "nt":
|
|
201
|
+
return os.path.isfile(program) or shutil.which(program) is not None
|
|
202
|
+
return os.path.isfile(program) and os.access(program, os.X_OK)
|
|
203
|
+
return shutil.which(program) is not None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _schema_finding(repo: Path) -> Finding | None:
|
|
207
|
+
stamped = state.installed_schema(repo)
|
|
208
|
+
if stamped is None:
|
|
209
|
+
return None # installed before we kept state, or state was cleaned — not a fault
|
|
210
|
+
if stamped != state.SCHEMA:
|
|
211
|
+
return Finding(
|
|
212
|
+
OUTDATED,
|
|
213
|
+
f"installed hook config is schema v{stamped}; this Tycho speaks v{state.SCHEMA}",
|
|
214
|
+
"run `tycho init` to rewrite it",
|
|
215
|
+
)
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _heartbeat_finding(repo: Path, wired: list[str]) -> Finding:
|
|
220
|
+
"""The liveness answer, with the hedging the module docstring argues for."""
|
|
221
|
+
beat = state.last_run(repo)
|
|
222
|
+
at = beat.get("at") if beat else None
|
|
223
|
+
if not isinstance(at, (int, float)):
|
|
224
|
+
return Finding(
|
|
225
|
+
INFO,
|
|
226
|
+
"the hook has not run here yet — no heartbeat recorded",
|
|
227
|
+
f"finish a turn in {'/'.join(wired)}, then re-run `tycho doctor`",
|
|
228
|
+
)
|
|
229
|
+
age = max(0.0, time.time() - at)
|
|
230
|
+
return Finding(OK, f"hook last fired {_ago(age)} (via {beat.get('harness', '?')})")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _ago(seconds: float) -> str:
|
|
234
|
+
for size, unit in ((86400, "d"), (3600, "h"), (60, "m")):
|
|
235
|
+
if seconds >= size:
|
|
236
|
+
return f"{int(seconds // size)}{unit} ago"
|
|
237
|
+
return "just now"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _transcript_finding(repo: Path) -> Finding:
|
|
241
|
+
"""A wired, runnable hook still verifies nothing without a readable session."""
|
|
242
|
+
path, harness = harness_mod.discover(repo)
|
|
243
|
+
if path is None:
|
|
244
|
+
return Finding(INFO, "no agent session found for this repo yet — nothing to verify")
|
|
245
|
+
try:
|
|
246
|
+
return Finding(OK, f"most recent session: {harness.name} ({path})")
|
|
247
|
+
finally:
|
|
248
|
+
if harness.name == "opencode":
|
|
249
|
+
path.unlink(missing_ok=True) # a rebuilt temp file — discovery hands it over
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _harness_drift(wired: list[str]) -> list[Finding]:
|
|
253
|
+
"""Has a wired harness moved past the version its hook contract was verified against?
|
|
254
|
+
|
|
255
|
+
Offline `--version` probe per harness. Doctor proves the hook *fires*; it can't prove
|
|
256
|
+
the harness still *reads* our output — version drift is the best available proxy for
|
|
257
|
+
that blind spot. Fails open in both directions: a harness with no pinned contract, a
|
|
258
|
+
missing/unparseable `--version`, or a matching version all stay silent — only a real
|
|
259
|
+
mismatch speaks, and it says "re-verify", not "broken" (TYCHO-34).
|
|
260
|
+
"""
|
|
261
|
+
findings: list[Finding] = []
|
|
262
|
+
for name in wired:
|
|
263
|
+
pinned = harness_mod.VERIFIED_AGAINST.get(name)
|
|
264
|
+
if not pinned:
|
|
265
|
+
continue
|
|
266
|
+
installed = _probe_version(pinned["probe"])
|
|
267
|
+
if installed is None or pinned["version"] in installed:
|
|
268
|
+
continue
|
|
269
|
+
findings.append(Finding(
|
|
270
|
+
DRIFT,
|
|
271
|
+
f"{name}: hook contract verified against {pinned['version']}, you have {installed}",
|
|
272
|
+
"re-verify Tycho's output fields against this version — see docs/harness-support.md",
|
|
273
|
+
))
|
|
274
|
+
return findings
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _probe_version(probe: tuple[str, ...]) -> str | None:
|
|
278
|
+
"""The harness's own `--version`, first line — or None if it can't be read.
|
|
279
|
+
|
|
280
|
+
Never raises: a missing binary, a timeout, or a non-zero exit is "can't tell", which
|
|
281
|
+
doctor treats as silence. Same fail-open rule as the Stop hook."""
|
|
282
|
+
try:
|
|
283
|
+
proc = subprocess.run(probe, capture_output=True, text=True, timeout=5)
|
|
284
|
+
except (OSError, subprocess.SubprocessError):
|
|
285
|
+
return None
|
|
286
|
+
lines = (proc.stdout or proc.stderr or "").strip().splitlines()
|
|
287
|
+
return lines[0].strip() if lines else None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def healthy(findings: list[Finding]) -> bool:
|
|
291
|
+
return not adverse(findings)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def render(findings: list[Finding]) -> str:
|
|
295
|
+
lines = ["tycho doctor"]
|
|
296
|
+
for f in findings:
|
|
297
|
+
mark = {OK: "✓", INFO: "•", DRIFT: "⚠"}.get(f.level, "✗")
|
|
298
|
+
label = "" if f.level in (OK, INFO, DRIFT) else f"{f.level}: "
|
|
299
|
+
lines.append(f" {mark} {label}{f.text}")
|
|
300
|
+
if f.fix:
|
|
301
|
+
lines.append(f" → {f.fix}")
|
|
302
|
+
lines.append("")
|
|
303
|
+
lines.append(" healthy" if healthy(findings) else " NOT healthy — Tycho is not verifying this repo")
|
|
304
|
+
return "\n".join(lines)
|