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