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/verify.py ADDED
@@ -0,0 +1,202 @@
1
+ """The engine: reduce check results to a verdict.
2
+
3
+ M1 is a walking skeleton — `run_stub` returns a hard-coded result set so the CLI
4
+ renders a real block. The `gather -> run_checks` pipeline lands in M2/M3;
5
+ `verdict_of` is already the real reduction (it's pure and cheap to get right now).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Sequence
11
+ from dataclasses import replace
12
+ import os
13
+ from pathlib import Path, PurePosixPath
14
+
15
+ from . import checks as checks_mod
16
+ from . import config as config_mod
17
+ from . import events as events_mod
18
+ from . import fsstate, gitstate
19
+ from .config import Config
20
+ from .model import CheckResult, CheckStatus, FileEdit, FileState, GitSnapshot, Session, Verdict
21
+
22
+
23
+ def gather(
24
+ transcript: Path,
25
+ repo: Path,
26
+ config: Config | None = None,
27
+ since: str = "HEAD",
28
+ parse: Callable[[Path], tuple] | None = None,
29
+ turn_start: Callable[[Path], float] | None = None,
30
+ messages: Callable[[Path], tuple] | None = None,
31
+ ) -> Session:
32
+ """Read the transcript + config + file/git state into the immutable Session.
33
+
34
+ This is the only I/O boundary on the way in; everything downstream is pure.
35
+ ``parse`` selects the harness transcript reader (default: Claude Code).
36
+ ``turn_start`` locates the boundary of the turn under review (TYCHO-17); omit it
37
+ to scope the whole transcript, which is what a manual ``tycho verify`` audit wants.
38
+ """
39
+ events = (parse or events_mod.parse)(transcript)
40
+ msgs = (messages or events_mod.assistant_messages)(transcript)
41
+ # Harness transcripts record absolute file paths; git and scope globs speak
42
+ # repo-relative. Normalize once here so git_state / scope_drift compare like
43
+ # with like (an edit outside the repo stays absolute — scope_drift flags it).
44
+ edits = tuple(
45
+ _with_baseline(replace(fe, path=_relpath(fe.path, repo)), repo, since)
46
+ for fe in events_mod.file_edits(events)
47
+ )
48
+ files = {fe.path: _file_state(repo, fe.path) for fe in edits}
49
+ return Session(
50
+ events=events,
51
+ edits=edits,
52
+ repo=repo,
53
+ config=config if config is not None else config_mod.load(repo),
54
+ files=files,
55
+ git=_git_snapshot(repo, since),
56
+ has_tests=_has_tests(repo),
57
+ turn_start=turn_start(transcript) if turn_start else 0.0,
58
+ messages=msgs,
59
+ )
60
+
61
+
62
+ _IGNORED_TEST_DIRS = {".git", ".venv", "venv", "node_modules", "vendor", "target", "dist", "build"}
63
+ _TEST_DIRS = {"test", "tests", "spec", "specs", "__tests__"}
64
+ _SUFFIX_TEST_EXTENSIONS = {
65
+ ".c", ".cc", ".cpp", ".cs", ".dart", ".go", ".java", ".js", ".jsx",
66
+ ".kt", ".kts", ".m", ".mm", ".php", ".py", ".rb", ".swift", ".ts", ".tsx",
67
+ }
68
+
69
+
70
+ def _has_tests(repo: Path) -> bool:
71
+ """Recognize conventional test files without walking dependencies or build output."""
72
+ for root, dirs, files in os.walk(repo):
73
+ dirs[:] = [d for d in dirs if d not in _IGNORED_TEST_DIRS and not d.startswith(".")]
74
+ try:
75
+ in_test_dir = any(part.lower() in _TEST_DIRS for part in Path(root).relative_to(repo).parts)
76
+ except ValueError:
77
+ in_test_dir = False
78
+ for name in files:
79
+ stem, suffix = Path(name).stem, Path(name).suffix
80
+ if in_test_dir or name == "conftest.py" or stem.startswith("test_"):
81
+ return True
82
+ if stem.endswith(("_test", "_spec")) or ".test." in name or ".spec." in name:
83
+ return True
84
+ if suffix.lower() in _SUFFIX_TEST_EXTENSIONS and stem.endswith(("Test", "Tests", "Spec")):
85
+ return True
86
+ if suffix == ".rs" and name != "build.rs":
87
+ try:
88
+ text = (Path(root) / name).read_text(errors="ignore")
89
+ if any(marker in text for marker in ("#[test]", "#[rstest]", "proptest!")):
90
+ return True
91
+ except OSError:
92
+ pass
93
+ return False
94
+
95
+
96
+ def _with_baseline(fe: FileEdit, repo: Path, since: str) -> FileEdit:
97
+ """Recover the pre-edit baseline from git when the harness omitted it.
98
+
99
+ Claude Code sends ``originalFile: null`` on a file's *repeat* edits, which would leave
100
+ the AST tamper checks (`assertion_weakening`/`skip_mock_injection`) with no "before"
101
+ and silently UNSUPPORTED while still appearing to run (TYCHO-32). ``git show <since>:<path>``
102
+ is a truthful pre-session baseline for a tracked file, independent of any harness field.
103
+ Only for in-repo paths; an untracked path returns None and stays a genuine create.
104
+ """
105
+ if fe.original is not None or not checks_mod._is_in_repo(fe.path):
106
+ return fe
107
+ blob = gitstate.blob_at(repo, since, fe.path)
108
+ if blob is None:
109
+ return fe
110
+ return replace(fe, original=blob, kind="edit")
111
+
112
+
113
+ def _relpath(path: str, repo: Path) -> str:
114
+ """Make an absolute in-repo path repo-relative, always with forward slashes.
115
+
116
+ Git (`git diff --name-only`) and the `[scope]` globs both speak POSIX separators, so a
117
+ Windows backslash here would never reconcile against either — `git_state` would count
118
+ zero uncommitted and `scope_drift` would miss `src/**` (TYCHO-25). `as_posix()` equals
119
+ `str()` on *nix, so this is a no-op there.
120
+ """
121
+ # `is_absolute()` only knows the *host* flavor: on Windows a POSIX path like
122
+ # `/repo/old.md` (no drive) reads as relative and never normalizes, while a forward-
123
+ # slash-normalizing harness or WSL emits exactly that. Try the native flavor first,
124
+ # then POSIX, so an absolute path is recognized regardless of the OS it's read on.
125
+ p = Path(path)
126
+ if p.is_absolute():
127
+ try:
128
+ return p.relative_to(repo).as_posix()
129
+ except ValueError:
130
+ return path # edit outside the repo — keep absolute so scope_drift catches it
131
+ pp = PurePosixPath(path)
132
+ if pp.is_absolute():
133
+ try:
134
+ return pp.relative_to(PurePosixPath(repo.as_posix())).as_posix()
135
+ except ValueError:
136
+ return path
137
+ return path.replace("\\", "/")
138
+
139
+
140
+ def _file_state(repo: Path, rel_path: str) -> FileState:
141
+ abs_path = repo / rel_path
142
+ text = None
143
+ if fsstate.exists(abs_path):
144
+ try:
145
+ text = abs_path.read_text(encoding="utf-8")
146
+ except (OSError, UnicodeDecodeError):
147
+ text = None
148
+ return FileState(
149
+ path=rel_path,
150
+ exists=fsstate.exists(abs_path),
151
+ mtime=fsstate.mtime(abs_path),
152
+ current_text=text,
153
+ )
154
+
155
+
156
+ def _git_snapshot(repo: Path, since: str) -> GitSnapshot:
157
+ if not gitstate.is_repo(repo):
158
+ return GitSnapshot(is_repo=False, head_sha=None, changed_paths=())
159
+ return GitSnapshot(
160
+ is_repo=True,
161
+ head_sha=gitstate.head_sha(repo),
162
+ changed_paths=gitstate.diff_names(repo, since),
163
+ )
164
+
165
+
166
+ # file_state/git_state only establish that the edited files exist and are in the
167
+ # repo. That's true of essentially any session that touched a file, and stays true
168
+ # on every later Stop once the work is committed — so on their own they'd turn "I
169
+ # couldn't check anything" into a green VERIFIED. They corroborate a claim; they
170
+ # can't carry one.
171
+ _WEAK_CHECKS = frozenset({"file_state", "git_state"})
172
+
173
+
174
+ def verdict_of(results: Sequence[CheckResult]) -> Verdict:
175
+ """Reduce per-check statuses to one run verdict.
176
+
177
+ Any FAIL sinks the run; else any STALE; else one *substantive* PASS is enough
178
+ to VERIFY (an UNSUPPORTED/INDETERMINATE check does not sink an otherwise-clean
179
+ run, and a `_WEAK_CHECKS` pass alone is not enough to lift one). With nothing
180
+ conclusive: all-UNSUPPORTED -> UNSUPPORTED, otherwise INDETERMINATE (including
181
+ the empty case).
182
+ """
183
+ statuses = {r.status for r in results}
184
+ if CheckStatus.FAIL in statuses:
185
+ return Verdict.FAILED
186
+ if CheckStatus.STALE in statuses:
187
+ return Verdict.STALE
188
+ if any(r.status is CheckStatus.PASS and r.name not in _WEAK_CHECKS for r in results):
189
+ return Verdict.VERIFIED
190
+ if statuses and statuses <= {CheckStatus.UNSUPPORTED}:
191
+ return Verdict.UNSUPPORTED
192
+ return Verdict.INDETERMINATE
193
+
194
+
195
+ def run_checks(session: Session) -> list[CheckResult]:
196
+ """Run the enabled checks over the gathered snapshot (pure)."""
197
+ return checks_mod.run_checks(session)
198
+
199
+
200
+ def has_verifiable_activity(session: Session) -> bool:
201
+ """True when a Stop hook has edits or a recognized test/build command."""
202
+ return checks_mod.has_verifiable_activity(session)
tycho/version.py ADDED
@@ -0,0 +1,99 @@
1
+ """Is a newer Tycho published? A best-effort, offline-safe update check (TYCHO-53).
2
+
3
+ **Never runs in the Stop hook and never blocks.** The check is a single stdlib HTTPS GET to
4
+ the package index with a short timeout; anything that goes wrong — offline, slow, blocked,
5
+ unparseable, opted out — returns None and the caller says nothing. The result is cached
6
+ machine-wide so the network is hit at most once a day, and a dismissal counter records how
7
+ often the user waved the notice away.
8
+
9
+ Surfaced only on the manual/less-frequent paths (`tycho doctor`, `tycho init`, the SessionStart
10
+ agent-bootup hook) — the status bar reads the cache only, never the network.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import time
18
+ import urllib.request
19
+
20
+ from . import __version__, state
21
+
22
+ # PyPI distribution name. `tycho` is taken on PyPI by an unrelated project, so we publish and
23
+ # check as `tycho-cli` (TYCHO-73). The import package and CLI command stay `tycho`. (Set to
24
+ # None to make the check inert — it queries no index and never notifies.)
25
+ _DIST_NAME: str | None = "tycho-cli"
26
+ _CHECK_TTL = 86400 # re-hit the index at most once a day
27
+ _TIMEOUT = 2.0 # seconds — a slow index must never make a command drag
28
+ _OPT_OUT_ENV = "TYCHO_NO_UPDATE_CHECK"
29
+
30
+
31
+ def _index_url() -> str | None:
32
+ return f"https://pypi.org/pypi/{_DIST_NAME}/json" if _DIST_NAME else None
33
+
34
+
35
+ def _env_opted_out() -> bool:
36
+ return os.environ.get(_OPT_OUT_ENV, "").strip().lower() in ("1", "true", "yes", "on")
37
+
38
+
39
+ def _fetch() -> str | None:
40
+ """The latest version from PyPI's JSON API, or None on any failure (or no name yet)."""
41
+ url = _index_url()
42
+ if not url:
43
+ return None # distribution name unset — see _DIST_NAME
44
+ try:
45
+ req = urllib.request.Request(
46
+ url,
47
+ headers={"Accept": "application/json", "User-Agent": f"tycho/{__version__}"},
48
+ )
49
+ with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: # noqa: S310 — https literal
50
+ info = json.loads(resp.read().decode("utf-8")).get("info") or {}
51
+ version = info.get("version")
52
+ return version if isinstance(version, str) and version else None
53
+ except Exception:
54
+ return None # offline, slow, blocked, unparseable — say nothing
55
+
56
+
57
+ def refresh() -> str | None:
58
+ """Newest published version, hitting the network only if the cache is stale (≤ once/day)
59
+ and the user hasn't opted out. Cached machine-wide; never raises."""
60
+ if _env_opted_out():
61
+ return None
62
+ cache = state.read_update_cache()
63
+ if cache.get("latest") and (time.time() - cache.get("checked_at", 0)) < _CHECK_TTL:
64
+ return cache["latest"]
65
+ got = _fetch()
66
+ if got:
67
+ state.write_update_cache(latest=got, checked_at=time.time())
68
+ return got
69
+ return cache.get("latest")
70
+
71
+
72
+ def _tuple(v: str) -> tuple[int, ...]:
73
+ """Numeric release tuple: "0.1.0" -> (0, 1, 0). Non-numeric suffixes are dropped — fine
74
+ for the 0.0.x-alpha → 0.1.0 scheme (no pre-release tags in play)."""
75
+ return tuple(int("".join(c for c in part if c.isdigit()) or 0) for part in v.split("."))
76
+
77
+
78
+ def is_newer(candidate: str, than: str) -> bool:
79
+ return _tuple(candidate) > _tuple(than)
80
+
81
+
82
+ def notice(refresh_first: bool = False) -> str | None:
83
+ """One line if a newer version exists and the user hasn't opted out or dismissed it — else
84
+ None. `refresh_first=True` permits the network check (doctor/init/bootup); the default reads
85
+ only the cache, which is what the status bar must use. Never raises."""
86
+ if _env_opted_out():
87
+ return None
88
+ try:
89
+ newest = refresh() if refresh_first else state.read_update_cache().get("latest")
90
+ if not isinstance(newest, str) or not is_newer(newest, __version__):
91
+ return None
92
+ if state.read_update_cache().get("dismissed_version") == newest:
93
+ return None # they waved this exact version off
94
+ return (
95
+ f"A newer Tycho is available: {newest} (you have {__version__}). "
96
+ f"Run `tycho update`; dismiss with `tycho update --skip` or set {_OPT_OUT_ENV}=1."
97
+ )
98
+ except Exception:
99
+ return None
@@ -0,0 +1,379 @@
1
+ Metadata-Version: 2.4
2
+ Name: tycho-cli
3
+ Version: 0.0.1
4
+ Summary: Tycho — a free, offline, open-source local verifier that proves an AI agent actually did what it claims, from git, the filesystem, process exit codes, and the harness event stream. No account, no LLM in the trust path.
5
+ Project-URL: Homepage, https://swail.dev/tycho
6
+ Project-URL: Repository, https://github.com/swail-labs/tycho
7
+ Project-URL: Issues, https://github.com/swail-labs/tycho/issues
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Keywords: agents,ai,claude-code,codex,cursor,opencode,verification
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Classifier: Topic :: Software Development :: Testing
19
+ Requires-Python: >=3.11
20
+ Provides-Extra: dev
21
+ Requires-Dist: build>=1; extra == 'dev'
22
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Requires-Dist: ruff>=0.6; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # Tycho
28
+
29
+ A free, offline, open-source **local verifier** that proves an AI coding agent actually did what it
30
+ claims — from git, the filesystem, process exit codes, and the harness event stream. When an agent
31
+ says "done," Tycho turns some ✅s into `FAILED` / `STALE` / `INDETERMINATE` with per-check evidence,
32
+ *before* that ✅ reaches you.
33
+
34
+ - **No account, no network, no LLM in the trust path.** Only code renders a verdict.
35
+ - **Stdlib only**, Python ≥ 3.11. Nothing to audit, trivial install.
36
+ - **Claude Code, Cursor, Codex, and OpenCode** supported (auto-detected).
37
+ - **Linux, macOS, and Windows** — see [Platforms](#platforms).
38
+
39
+ ## Platforms
40
+
41
+ | OS | Status | Verified by |
42
+ |----|--------|-------------|
43
+ | Linux | Supported | CI: `ubuntu-latest`, Python 3.11 / 3.12 / 3.13 — full suite, `ruff`, and a wheel + sdist install into a clean venv |
44
+ | Windows | Supported | CI: `windows-latest`, Python 3.12 — same suite, plus the `.exe` console-script hook under cp1252/backslash |
45
+ | macOS | Supported | Development baseline; not gated in CI |
46
+
47
+ **WSL2 is Linux** (a real Linux kernel), so it's covered by the Linux row. Install and run Tycho
48
+ *inside* the distro — launch your agent from the WSL shell — rather than driving `wsl.exe` from a
49
+ Windows-hosted agent, so Tycho's Stop hook and the commands it verifies share one environment.
50
+
51
+ ## Install
52
+
53
+ Tycho is one stdlib-only CLI. The PyPI package is **`tycho-cli`** (the name `tycho` is taken by
54
+ an unrelated project); it installs the **`tycho`** command. Install it however you manage tools:
55
+
56
+ ```sh
57
+ uv tool install tycho-cli # uv (recommended)
58
+ pipx install tycho-cli # or pipx
59
+ ```
60
+
61
+ Both drop the `tycho` command on your PATH in its own isolated environment. Other paths:
62
+
63
+ ```sh
64
+ uvx --from tycho-cli tycho <command> # run once, no install (uv)
65
+ uv add tycho-cli # add to a project's dependencies
66
+ pip install --user tycho-cli # plain pip
67
+ ```
68
+
69
+ Prefer an isolated *tool* install (`uv tool install` / `pipx`) over a bare `pip install`, which
70
+ drops the CLI into whatever environment you happen to be in.
71
+
72
+ > **Not on PyPI yet.** The first release is still landing, so the commands above 404 for now.
73
+ > Until then, install from a source checkout — same isolated `tycho` command, no index needed:
74
+ >
75
+ > ```sh
76
+ > uv tool install . # from a clone of this repo
77
+ > uvx --from . tycho <command> # or run once, no install
78
+ > ```
79
+
80
+ Then wire it into your repo (or let Tycho offer to on first run):
81
+
82
+ ```sh
83
+ tycho init
84
+ ```
85
+
86
+ ## Quickstart
87
+
88
+ ```sh
89
+ tycho init # install the completion hook into the harnesses you actually have
90
+ # (asks per harness; idempotent, self-healing, repo-local only)
91
+ tycho init --yes # skip the prompts (scripts and CI)
92
+ ```
93
+
94
+ Tycho only touches harnesses it finds — Claude Code, Cursor, Codex, or OpenCode — and only inside this
95
+ repo. It merges with hooks you already have, backs up anything it changes, and refuses to touch a config
96
+ it can't parse rather than risk it.
97
+
98
+ On Claude Code it also adds a status-bar badge and a set of `/tycho` slash commands, so you can see it's
99
+ on and drive it without leaving the session — see [In Claude Code](#in-claude-code) below. On the other
100
+ three harnesses, `tycho status` prints that same badge for a shell prompt or tmux status line — see
101
+ [The badge in any shell](#the-badge-in-any-shell).
102
+
103
+ That's it. The next time an agent finishes a turn, Tycho verifies it automatically and prints the
104
+ verdict. To run it by hand:
105
+
106
+ ```sh
107
+ tycho verify # auto-discovers the most-recently-used session and verifies it
108
+ tycho verify --harness cursor # force a harness (default: whichever ran most recently)
109
+ tycho verify --session <path> # verify a specific transcript
110
+ tycho verify --claim "added rate limiting, tests pass" # echo the claim above the verdict
111
+ tycho help # what Tycho is, whether it's live here, and every command
112
+ tycho status # the one-line status-bar indicator (what Claude Code renders)
113
+ tycho count # how many problems Tycho has caught — in this repo, and all-time
114
+ tycho run -- pytest -q # run a command so its true exit code is seen (see "tycho run" below)
115
+ tycho scope list # show/edit which files the agent may edit (see "Scope" below)
116
+ tycho relay # let the agent see its verdict & work till VERIFIED (opt-in; see below)
117
+ tycho --version # print the version
118
+ tycho --help # full usage (also `tycho verify --help`)
119
+ ```
120
+
121
+ ```
122
+ 🔍 Tycho: FAILED
123
+ ✗ test_freshness — auth_test.py passed 14:22:01, auth.py edited 14:22:47 (46s later)
124
+ ✗ assertion_weakening — 2 assertions removed from test_login_expiry
125
+ ✓ command_execution — pytest ran, exit 0
126
+ • scope_drift — no [scope] set — run `tycho scope add '<glob>'` to bound edits (zero-config)
127
+ ```
128
+
129
+ `tycho verify` exits non-zero on an adverse finding so you can gate in `pre-push`/CI. The completion
130
+ hook never blocks — it annotates; you decide.
131
+
132
+ | Exit | Meaning |
133
+ |---|---|
134
+ | `0` | `VERIFIED` / `UNSUPPORTED` / `INDETERMINATE` — nothing adverse found |
135
+ | `1` | `FAILED` — a check proved the claim wrong |
136
+ | `2` | usage error |
137
+ | `3` | `STALE` — edits landed after the last passing test run |
138
+ | `4` | Tycho could not complete (unreadable transcript/config) |
139
+
140
+ `FAILED` and `STALE` are separate codes so you can pick which one blocks: gate on `1` alone, or on
141
+ both.
142
+
143
+ ## `tycho run` — when a test run is hidden from Tycho
144
+
145
+ Tycho recognises a test/build run from the command in the transcript. It reads *through* the common
146
+ ways a runner is wrapped — a pipe (`pytest | tail`), a variable interpreter (`"$PY" -m pytest`), and
147
+ nested shells (`wsl.exe -d Ubuntu -- bash -c 'pytest'`, `ssh host 'pytest'`) — automatically, with
148
+ nothing to remember. **WSL runs verify on their own; you don't need `tycho run` for them.**
149
+
150
+ The one case it can't see is when the runner's name isn't in the command at all — a Makefile target
151
+ (`make test`), a shell alias, or a `./run-tests.sh` that calls pytest inside. For those, prefix it:
152
+
153
+ ```sh
154
+ tycho run -- make test
155
+ ```
156
+
157
+ `tycho run` execs the command, forwards its output and exit code **unchanged**, and lets Tycho read
158
+ the runner's true status. It's a thin, safe wrapper — never use it as a habit for WSL or piped runs,
159
+ only where the runner is genuinely hidden behind indirection.
160
+
161
+ ## Scope — bounding what the agent may edit
162
+
163
+ Tycho is **zero-config**: with no `.tycho.toml`, `scope_drift` reports `UNSUPPORTED` and every other
164
+ check runs exactly as normal. Nothing assumes the file exists.
165
+
166
+ Opt in when you want to bound *where* the agent edits. `tycho init` drops a starter `.tycho.toml`
167
+ (empty, so behaviour is unchanged until you add a glob), and you manage the allowlist with:
168
+
169
+ ```
170
+ tycho scope add 'src/**' 'tests/**' # allow these; an edit elsewhere FAILs scope_drift
171
+ tycho scope remove 'tests/**' # drop one or more
172
+ tycho scope set 'src/**' # replace the whole list
173
+ tycho scope list # show the current bounds
174
+
175
+ tycho scope set '**' # allow the whole tree…
176
+ tycho scope add --exclude 'LICENSE' # …but carve this back out (exclude wins)
177
+ ```
178
+
179
+ `set`, `add`, and `remove` each take **one or more** globs — quote them so your shell keeps them
180
+ literal. The bound is an **explicit, deterministic** declaration: Tycho stores exactly what you wrote
181
+ and never infers scope from the prompt. Set it once as a standing bound, or change it whenever the task
182
+ changes. In Claude Code the same four are `/tycho-scope-list`, `/tycho-scope-set`, `/tycho-scope-add`,
183
+ and `/tycho-scope-remove`.
184
+
185
+ **Excluding paths.** Add `--exclude` to `set`/`add`/`remove` to edit a **denylist** instead of the
186
+ allowlist: a path that matches `exclude` FAILs `scope_drift` even if it also matches `include` — exclude
187
+ wins. This lets you allow a broad area and carve specific files back out (e.g. `include = ["**"]`,
188
+ `exclude = ["LICENSE"]` allows the whole repo but that one file). An empty exclude is a pure allowlist,
189
+ exactly as before; exclude has no effect while `include` is empty (scope stays zero-config `UNSUPPORTED`).
190
+
191
+ ## In Claude Code
192
+
193
+ `tycho init` wires Claude Code up with a status badge and slash commands, so you can drive Tycho without
194
+ leaving the session. Everything here also runs as a plain `tycho <command>` in your terminal — the slash
195
+ commands are just the in-editor shortcut.
196
+
197
+ **Slash commands.** Type `/` and each shows with its own description:
198
+
199
+ | Command | Does |
200
+ |---|---|
201
+ | `/tycho <args>` | run any Tycho subcommand, e.g. `/tycho verify --claim "added rate limiting"` |
202
+ | `/tycho-verify` | verify the latest session and render a verdict |
203
+ | `/tycho-status` | the one-line badge (what the status bar renders) |
204
+ | `/tycho-doctor` | full diagnostics: is Tycho installed, current, and firing? |
205
+ | `/tycho-help` | what Tycho is, whether it's live here, and every command |
206
+ | `/tycho-hide` · `/tycho-show` | hide / show the status badge |
207
+ | `/tycho-relay` · `-on` · `-off` | show / turn on / turn off the verdict relay (see [below](#let-the-agent-see-its-own-verdict--the-relay)) |
208
+ | `/tycho-scope-list` · `-set` · `-add` · `-remove` | show or edit the files the agent may edit (each `set`/`add`/`remove` takes one or more globs) |
209
+
210
+ **Status badge.** A `[TYCHO]` indicator in the status bar, coloured by the last run:
211
+
212
+ | Color | Meaning |
213
+ |---|---|
214
+ | 🟢 green | last run `VERIFIED` |
215
+ | 🔴 red | last run `FAILED` or `STALE` — something to look at |
216
+ | 🔵 blue | verifying right now — a run is in flight this turn |
217
+ | 🟡 yellow | an `INDETERMINATE` run (ran, couldn't conclude) |
218
+ | ⚪ grey | nothing to say yet — never fired here, `UNSUPPORTED`, or a turn with nothing to verify |
219
+
220
+ It settles on green or red once a real verdict exists; blue is only the in-flight moment,
221
+ yellow is the inconclusive-but-noteworthy run, and grey is the honest "no signal" — including a
222
+ fresh install that hasn't fired yet. The colors are muted on purpose, to stay readable on a dark
223
+ terminal.
224
+
225
+ `tycho verify` updates the badge too, so a manual verify that comes back `VERIFIED` turns it green. If you
226
+ already run a status line — a third-party badge, a shell prompt — Tycho **composes** with it instead of
227
+ replacing it: it takes Claude Code's single `statusLine` slot, runs your existing command too, and renders
228
+ both (`[OTHER] [TYCHO]`). Nothing you had is lost, and `tycho uninstall` restores it.
229
+
230
+ **Toggle the badge.** `/tycho-hide` (or `tycho status --off`) hides only Tycho's segment — the hook keeps
231
+ verifying every turn; `/tycho-show` (or `tycho status --on`) brings it back. `TYCHO_STATUS=off` hides it
232
+ everywhere for a session.
233
+
234
+ ## Let the agent see its own verdict — the relay
235
+
236
+ By default Tycho's verdict on Claude Code is **human-only**: it renders to your terminal and never enters
237
+ the model's context, so the agent can't see when its own turn came back `FAILED` or `STALE`. That's the
238
+ safe default — **Tycho free never spends your context or tokens unless you ask it to.**
239
+
240
+ Turn on the **verdict relay** and Tycho feeds a non-`VERIFIED` verdict back to the agent as it finishes a
241
+ turn, so the agent keeps working **until the verdict is `VERIFIED`** — catching a false "done" the moment
242
+ it happens instead of waiting for you to paste the verdict back in. `tycho init` asks once (default **no**)
243
+ when a repo has no `.tycho.toml` yet; if you already have one, it leaves your setting alone and just points
244
+ you at the toggle. Flip it any time:
245
+
246
+ ```sh
247
+ tycho relay # show the current setting
248
+ tycho relay --on # feed non-VERIFIED verdicts back to the agent (works until VERIFIED)
249
+ tycho relay --off # back to human-only (the default)
250
+ ```
251
+
252
+ In Claude Code the same three are `/tycho-relay`, `/tycho-relay-on`, and `/tycho-relay-off`; bare
253
+ `/tycho-relay` shows the current status and how to toggle. The setting is a hand-editable, per-repo key in
254
+ `.tycho.toml` (`[relay] enabled`), Claude-only, and never blocks — the Stop hook still exits 0.
255
+
256
+ **It is bounded — no infinite loops.** Each user turn can be auto-continued at most **3 times** before
257
+ Tycho goes quiet and hands control back to you, so a verdict the agent can't satisfy converges on a hard
258
+ stop, not an endless cycle. A fresh prompt from you resets the leash. Change the ceiling with
259
+ `TYCHO_RELAY_MAX` (e.g. `TYCHO_RELAY_MAX=5`; `0` disables the auto-continue entirely, leaving the verdict as
260
+ a one-shot note the agent sees but isn't pushed to act on).
261
+
262
+ **Estimated extra token usage.** With the relay **off** (default), **zero** — nothing reaches the model.
263
+ With it **on**, each turn that ends non-`VERIFIED` costs:
264
+
265
+ - the injected verdict itself — small and fixed, roughly **120–200 tokens** per re-check (the verdict lines
266
+ plus a one-paragraph guard), and
267
+ - **up to `TYCHO_RELAY_MAX` extra agent turns** (default 3) — the real cost, since each is a full generation
268
+ in which the agent reads context and works. Budget on the order of *your agent's normal per-turn token
269
+ use × up to 3* for a turn Tycho keeps re-checking; a turn that's already `VERIFIED` adds **nothing**.
270
+
271
+ So the relay trades tokens for the agent catching its own mistakes without you in the loop. Leave it off for
272
+ the cheapest, quietest default; turn it on when you'd rather the agent fix a bad turn before it reaches you.
273
+
274
+ ## The badge in any shell
275
+
276
+ Only the *wiring* is Claude-only. `statusLine` is the one harness setting that means "run this command and
277
+ render its stdout", so `tycho init` has nowhere to hang the badge on Cursor, Codex, or OpenCode. The badge
278
+ itself doesn't care: `tycho status` reads `.tycho/` off disk and prints one line, whichever agent wrote the
279
+ state. Call it from anything that renders a command:
280
+
281
+ ```sh
282
+ PS1='$(tycho status) '$PS1 # bash / zsh prompt
283
+ set -g status-right '#(cd #{pane_current_path} && tycho status)' # tmux (in tmux.conf)
284
+ ```
285
+
286
+ ```toml
287
+ [custom.tycho] # starship (in starship.toml)
288
+ command = "tycho status"
289
+ when = true
290
+ ```
291
+
292
+ No stdin needed — with no JSON on stdin it resolves the repo from the current directory, walking up to
293
+ find `.tycho/`, so the badge stays put as you `cd` around the tree. It prints nothing in repos where
294
+ Tycho isn't installed, so it stays out of every other prompt you have. `tycho status --off` / `--on` and
295
+ `TYCHO_STATUS=off` work here exactly as they do in Claude Code.
296
+
297
+ ## Is the hook still firing?
298
+
299
+ A silently dead hook is the worst failure a verifier has — silence looks exactly like "everything
300
+ passed." `tycho doctor` checks, without editing anything, that Tycho's entry is still in each harness's
301
+ config, that the command it would run resolves to a real executable, and when the hook last fired:
302
+
303
+ ```sh
304
+ tycho doctor # exits 0 when healthy, 5 when installed-but-not-working
305
+ ```
306
+
307
+ Repair is always the same self-healing `tycho init`. `tycho verify` runs the config half of this check
308
+ too, warning on stderr if the hook is broken. See [`docs/hooks.md`](docs/hooks.md#is-it-still-working-tycho-doctor).
309
+
310
+ ## Uninstall
311
+
312
+ ```sh
313
+ tycho uninstall # remove Tycho's hooks from every harness
314
+ tycho uninstall --harness codex # just one
315
+ tycho uninstall --purge # hooks, plus this repo's .tycho/ state and .tycho.toml config
316
+ ```
317
+
318
+ Only Tycho's own entries come out — your other hooks, and any unrelated settings in the same file,
319
+ are left exactly as they were. It's idempotent, so running it twice is safe.
320
+
321
+ By default uninstall leaves two repo-local files behind: `.tycho/` (the catch tally and evidence trail)
322
+ and `.tycho.toml` (your scope/checks config). Add **`--purge`** to delete those too — an explicit opt-in,
323
+ never the default, since it drops your catch history. It stays repo-local; the machine-wide all-time tally
324
+ under `~/.local/share/tycho` is never touched.
325
+
326
+ Removing the hooks and removing the package are two separate steps: uninstall first (otherwise the
327
+ harness is left calling a command that no longer exists), then remove the package the same way you
328
+ installed it — `pipx uninstall tycho`, `pip uninstall tycho`, or deleting the standalone binary.
329
+
330
+ ## The checks
331
+
332
+ `command_execution` · `test_freshness` · `test_provenance` · `assertion_weakening` ·
333
+ `skip_mock_injection` · `file_state` · `git_state` · `scope_drift` · `tool_call_provenance`. A check
334
+ that can't run returns `UNSUPPORTED`/`INDETERMINATE` with a reason — Tycho degrades honestly rather
335
+ than guess.
336
+
337
+ `tool_call_provenance` catches an agent that *claims* a tool action it never took. It currently
338
+ recognizes two families of claim — web search/fetch and issue-tracker actions (Jira/Linear). Broader
339
+ tool-call coverage — calendar, email, file/drive, code execution, and other connectors — is **not yet
340
+ supported and is in development**; until then a claim it can't classify is left `UNSUPPORTED`, never
341
+ guessed.
342
+
343
+ This is a **scope limitation, not a finished feature** — treat its result as advisory for now. Two
344
+ consequences follow from how narrowly it's currently drawn, both of which we'll address as the family
345
+ table is widened:
346
+
347
+ - **Missed claims ("no tool-action claims recognized").** The claim-recognition patterns are narrower
348
+ than real phrasing, so a turn that genuinely performed a supported action can still read as *no claim
349
+ recognized* — e.g. issue-tracker verbs like "parented", "linked", "added N links", or "created ticket
350
+ X" that fall outside the pattern set. That's a recall gap, not a statement that no tools ran.
351
+ - **Coarse (potentially false-positive) matches.** Within a supported family the match is **broad —
352
+ family-presence only, with no content-correlation.** A claim is considered backed if *some* tool call
353
+ of that family occurred; it does not yet verify that the specific claimed action (that ticket, that
354
+ URL) is the one the tool actually touched. So a claim could be marked backed by an unrelated call in
355
+ the same family.
356
+
357
+ Both are deliberate: the check is tuned to **never emit a false FAIL** today, at the cost of these gaps.
358
+ Tightening the phrasing coverage and adding safe content-correlation is planned, not shipped — so for
359
+ now, read `tool_call_provenance` as a hint, and confirm from the transcript when it matters.
360
+
361
+ ## How it works
362
+
363
+ The engine is pure and harness-agnostic: `gather → check → verdict` over an immutable snapshot, all I/O
364
+ at the edges. The four harnesses differ only in transcript format, repo field, and output channel —
365
+ isolated in one adapter. See [`docs/hooks.md`](docs/hooks.md) for the multi-harness design.
366
+
367
+ Tycho looks for each agent's sessions under the usual `~/.claude`, `~/.cursor`, `~/.codex`, and
368
+ `~/.local/share/opencode`. If yours live elsewhere, set `TYCHO_CLAUDE_HOME`, `TYCHO_CURSOR_HOME`,
369
+ `TYCHO_CODEX_HOME`, or `TYCHO_OPENCODE_HOME` to the directory that holds them — each agent's own
370
+ variable (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_DATA_HOME`) is honored too. See
371
+ [`docs/hooks.md`](docs/hooks.md#overriding-where-an-agents-data-lives).
372
+
373
+ Tycho's own machine-level state — just the all-time tally behind `tycho count` — lives under
374
+ `~/.local/share/tycho`; set `TYCHO_HOME` (or `XDG_DATA_HOME`) to move it. Everything else Tycho
375
+ knows is per-repo, in `<repo>/.tycho/`.
376
+
377
+ ## License
378
+
379
+ Apache-2.0.