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/state.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""Tycho's own repo-local state: what `init` wired up, and proof the hook still fires.
|
|
2
|
+
|
|
3
|
+
Two files under ``<repo>/.tycho/``, both entirely ours (no user content, ever):
|
|
4
|
+
|
|
5
|
+
- ``install.json`` — written by `tycho init`: schema version + what we wired, per harness.
|
|
6
|
+
- ``last-run.json`` — rewritten by the Stop hook on *every* invocation: the heartbeat.
|
|
7
|
+
|
|
8
|
+
Separate files on purpose: init and the hook write on different schedules from
|
|
9
|
+
different processes, so one shared file would mean read-modify-write races and a lost
|
|
10
|
+
update — exactly the kind of silent corruption TYCHO-6 just finished stamping out.
|
|
11
|
+
|
|
12
|
+
**Nothing here may raise into a caller.** The hook must never break the agent's Stop
|
|
13
|
+
(see hook.py), so a heartbeat we can't write is simply not written: a missing beat makes
|
|
14
|
+
`tycho doctor` say "unknown", which is honest, whereas an exception here would break the
|
|
15
|
+
one thing Tycho promises never to touch.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# Bump when the shape of what init installs changes such that an already-installed
|
|
26
|
+
# entry no longer satisfies it. `tycho doctor` compares this against the stamp in
|
|
27
|
+
# install.json and reports HOOK OUTDATED when they diverge; `tycho init` re-stamps.
|
|
28
|
+
SCHEMA = 1
|
|
29
|
+
|
|
30
|
+
_DIR = ".tycho"
|
|
31
|
+
_INSTALL = "install.json"
|
|
32
|
+
_LAST_RUN = "last-run.json"
|
|
33
|
+
|
|
34
|
+
# `.tycho.toml` is config.py's file, but it marks the same root as `.tycho/` and can exist
|
|
35
|
+
# without it (configured, never inited), so root_for() looks for either. Spelled out here
|
|
36
|
+
# rather than imported to keep state.py free of a config import it otherwise has no use for.
|
|
37
|
+
_CONFIG_MARKER = ".tycho.toml"
|
|
38
|
+
_GIT = ".git"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def root_for(repo: Path) -> Path:
|
|
42
|
+
"""Where this repo's Tycho state lives: `repo` itself, or the nearest ancestor holding it.
|
|
43
|
+
|
|
44
|
+
Every entry point hands us a cwd, and a cwd follows the user into subdirectories. Without
|
|
45
|
+
the walk, `<cwd>/.tycho` misses from anywhere but the repo root, and every reader here
|
|
46
|
+
reports that as "not installed" — the badge goes blank and `doctor` says unknown, both of
|
|
47
|
+
which read as "Tycho isn't here" rather than "you're one directory down" (TYCHO-79).
|
|
48
|
+
|
|
49
|
+
Stops at the git root: our state belongs to *this* repo, and an unrelated parent's `.tycho/`
|
|
50
|
+
is not ours to adopt. No marker anywhere means `repo` — so `init` still creates state where
|
|
51
|
+
it was run, and a first write lands exactly where it does today.
|
|
52
|
+
"""
|
|
53
|
+
for d in (repo, *repo.parents):
|
|
54
|
+
if (d / _DIR).is_dir() or (d / _CONFIG_MARKER).is_file():
|
|
55
|
+
return d
|
|
56
|
+
if (d / _GIT).exists(): # repo root reached (a dir, or a worktree/submodule's file)
|
|
57
|
+
break
|
|
58
|
+
return repo
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def dir_for(repo: Path) -> Path:
|
|
62
|
+
return root_for(repo) / _DIR
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_json(path: Path) -> dict | None:
|
|
66
|
+
"""Our own state, so unreadable or corrupt means "unknown" — never an error."""
|
|
67
|
+
try:
|
|
68
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
69
|
+
except (OSError, json.JSONDecodeError):
|
|
70
|
+
return None
|
|
71
|
+
return data if isinstance(data, dict) else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write_json(path: Path, data: dict) -> None:
|
|
75
|
+
"""Atomic, like every other write in this package: temp sibling, then rename."""
|
|
76
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
78
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
79
|
+
tmp.replace(path)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def write_install(repo: Path, harness: str, command: str) -> None:
|
|
83
|
+
"""Record that we wired `harness` to `command`. Merges — other harnesses survive."""
|
|
84
|
+
installed = dict(read_install(repo))
|
|
85
|
+
installed[harness] = {"command": command, "at": time.time()}
|
|
86
|
+
_write_json(dir_for(repo) / _INSTALL, {"schema": SCHEMA, "installed": installed})
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def drop_install(repo: Path, harness: str) -> None:
|
|
90
|
+
"""Forget one harness; drop the file (and dir) once there's nothing left to remember."""
|
|
91
|
+
path = dir_for(repo) / _INSTALL
|
|
92
|
+
installed = {k: v for k, v in read_install(repo).items() if k != harness}
|
|
93
|
+
if installed:
|
|
94
|
+
_write_json(path, {"schema": SCHEMA, "installed": installed})
|
|
95
|
+
return
|
|
96
|
+
path.unlink(missing_ok=True)
|
|
97
|
+
(dir_for(repo) / _LAST_RUN).unlink(missing_ok=True) # a heartbeat for nothing
|
|
98
|
+
try:
|
|
99
|
+
dir_for(repo).rmdir() # only if empty — never take a dir holding someone's file
|
|
100
|
+
except OSError:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read_install(repo: Path) -> dict:
|
|
105
|
+
"""{harness: {"command": str, "at": float}} for what we believe we installed."""
|
|
106
|
+
data = _read_json(dir_for(repo) / _INSTALL) or {}
|
|
107
|
+
installed = data.get("installed")
|
|
108
|
+
return installed if isinstance(installed, dict) else {}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def installed_schema(repo: Path) -> int | None:
|
|
112
|
+
"""The schema stamp on disk, or None if we've never installed here."""
|
|
113
|
+
data = _read_json(dir_for(repo) / _INSTALL)
|
|
114
|
+
return data.get("schema") if data else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def record_run(repo: Path, harness: str, verdict: str | None = None, pending: bool = False) -> None:
|
|
118
|
+
"""The heartbeat. Called on every hook invocation — a dead hook can't call this.
|
|
119
|
+
|
|
120
|
+
Recorded even when the hook finds nothing to verify: the question this answers is
|
|
121
|
+
"did the wiring fire?", not "was there a verdict?". Swallows everything, per the
|
|
122
|
+
module docstring.
|
|
123
|
+
|
|
124
|
+
The hook calls this at least twice: once on entry with ``pending=True`` (the beat must
|
|
125
|
+
land even if everything downstream fails — and the badge shows "verifying"), then again
|
|
126
|
+
at every terminal path — with a `verdict` when there is one, or plain (no verdict, no
|
|
127
|
+
pending) when there was nothing to verify. So the three shapes are distinct and drive
|
|
128
|
+
the badge colour (TYCHO-39/59): `pending` = mid-run (yellow), `verdict` = the result
|
|
129
|
+
(green/red), neither = fired-but-nothing-to-report (grey). Re-recording is what stops a
|
|
130
|
+
stale verdict — or a stuck "verifying" — outliving the run that produced it.
|
|
131
|
+
"""
|
|
132
|
+
beat: dict = {"at": time.time(), "harness": harness}
|
|
133
|
+
if verdict is not None:
|
|
134
|
+
beat["verdict"] = verdict
|
|
135
|
+
elif pending:
|
|
136
|
+
beat["pending"] = True
|
|
137
|
+
try:
|
|
138
|
+
_write_json(dir_for(repo) / _LAST_RUN, beat)
|
|
139
|
+
except OSError:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --- the catch record (TYCHO-62, replaces TYCHO-50's bare tally) -------------
|
|
144
|
+
#
|
|
145
|
+
# What Tycho caught — with the evidence, not just a number. Two files, same fail-open rule
|
|
146
|
+
# as everything here (a record we can't write is simply not written):
|
|
147
|
+
# repo <repo>/.tycho/catches.json : {tally, catches: [entry, … newest first]}
|
|
148
|
+
# machine <user_dir>/catches.json : {tally} — running total across every repo
|
|
149
|
+
# An entry is one adverse (FAILED/STALE) or intermediate (INDETERMINATE) run plus the
|
|
150
|
+
# checks that failed or couldn't pass. The last *verdict* is not stored here — that lives
|
|
151
|
+
# in last-run.json — so there is no transition dedup: every adverse/intermediate run is
|
|
152
|
+
# recorded (a standing failure re-reported each turn counts each turn).
|
|
153
|
+
|
|
154
|
+
_CATCHES = "catches.json"
|
|
155
|
+
_LEGACY_COUNTS = "counts.json" # pre-TYCHO-62 tally; migrated on first read/write, then dropped
|
|
156
|
+
_TALLIED = ("FAILED", "STALE", "INDETERMINATE") # verdicts that count as catches (with evidence)
|
|
157
|
+
_BLIND = ("INDETERMINATE", "UNSUPPORTED") # verdicts where Tycho had nothing to say (TYCHO-58)
|
|
158
|
+
_CATCH_LIST_CAP = 100 # the repo evidence trail keeps the most recent N; the tally stays exact
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def user_dir() -> Path:
|
|
162
|
+
"""Root of Tycho's *machine-level* state — outside every repo, by definition.
|
|
163
|
+
|
|
164
|
+
``TYCHO_HOME`` wins, then ``XDG_DATA_HOME``, then the ``~/.local/share`` default:
|
|
165
|
+
the same override chain the harness roots use (``harness.home``, TYCHO-14), spelled
|
|
166
|
+
out here because this root is an XDG data dir rather than a ``~/.<name>`` dotdir.
|
|
167
|
+
"""
|
|
168
|
+
override = os.environ.get("TYCHO_HOME")
|
|
169
|
+
if override:
|
|
170
|
+
return Path(override).expanduser()
|
|
171
|
+
data_home = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
|
172
|
+
return Path(data_home) / "tycho"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _count_of(data: dict, key: str) -> int:
|
|
176
|
+
"""A counter off disk, coerced. Anything else on that key means "no count yet"."""
|
|
177
|
+
value = data.get(key)
|
|
178
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _read_catches(path: Path) -> dict:
|
|
182
|
+
"""catches.json as {tally, catches}. Falls back to a legacy counts.json tally so the
|
|
183
|
+
numbers carry across the rename, until the next write re-homes them (TYCHO-62)."""
|
|
184
|
+
data = _read_json(path)
|
|
185
|
+
if data is not None:
|
|
186
|
+
return data
|
|
187
|
+
legacy = _read_json(path.with_name(_LEGACY_COUNTS))
|
|
188
|
+
if legacy:
|
|
189
|
+
return {"tally": {v: _count_of(legacy, v) for v in _TALLIED if _count_of(legacy, v)}}
|
|
190
|
+
return {}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _tally_of(data: dict) -> dict:
|
|
194
|
+
tally = data.get("tally")
|
|
195
|
+
return tally if isinstance(tally, dict) else {}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def record_catch(repo: Path, harness: str, verdict: str, results) -> None:
|
|
199
|
+
"""Record one verdict: bump the running tally (repo + machine) and, when the verdict is
|
|
200
|
+
adverse/intermediate, append it to the repo's evidence trail. *Every* verdict counts
|
|
201
|
+
toward the `runs` denominator (TYCHO-58) — VERIFIED/UNSUPPORTED are runs, not catches, so
|
|
202
|
+
they add no evidence entry. Never raises: this runs inside the Stop hook, so a record we
|
|
203
|
+
can't write is simply not written.
|
|
204
|
+
"""
|
|
205
|
+
entry = None
|
|
206
|
+
if verdict in _TALLIED:
|
|
207
|
+
# The evidence trail: what failed or couldn't pass. Every non-PASS check, with its
|
|
208
|
+
# status and the same evidence string the report shows.
|
|
209
|
+
trail = [
|
|
210
|
+
{"check": r.name, "status": r.status.name, "evidence": r.evidence}
|
|
211
|
+
for r in results if r.status.name != "PASS"
|
|
212
|
+
]
|
|
213
|
+
entry = {"at": time.time(), "harness": harness, "verdict": verdict, "checks": trail}
|
|
214
|
+
_bump(dir_for(repo) / _CATCHES, verdict, entry) # repo: tally (+ evidence if adverse)
|
|
215
|
+
_bump(user_dir() / _CATCHES, verdict, None) # machine: running tally only
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _bump(path: Path, verdict: str, entry: dict | None) -> None:
|
|
219
|
+
# read-modify-write with no lock — two repos catching something in the same
|
|
220
|
+
# instant can lose one machine-tally increment. It's a tally, not a ledger; add locking
|
|
221
|
+
# only if a slightly-low all-time number ever actually matters to someone.
|
|
222
|
+
try:
|
|
223
|
+
data = _read_catches(path)
|
|
224
|
+
tally = _tally_of(data)
|
|
225
|
+
tally["runs"] = _count_of(tally, "runs") + 1 # the denominator: every verdict (TYCHO-58)
|
|
226
|
+
tally[verdict] = _count_of(tally, verdict) + 1 # per-verdict, incl VERIFIED/UNSUPPORTED
|
|
227
|
+
out: dict = {"tally": tally}
|
|
228
|
+
if entry is not None:
|
|
229
|
+
prior = data.get("catches") if isinstance(data.get("catches"), list) else []
|
|
230
|
+
out["catches"] = [entry, *prior][:_CATCH_LIST_CAP] # newest first, bounded
|
|
231
|
+
_write_json(path, out)
|
|
232
|
+
path.with_name(_LEGACY_COUNTS).unlink(missing_ok=True) # migrated — drop the old file
|
|
233
|
+
except OSError:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def counts(repo: Path) -> dict:
|
|
238
|
+
"""{"FAILED": n, "STALE": n, "INDETERMINATE": n} — the running tally for `repo`."""
|
|
239
|
+
tally = _tally_of(_read_catches(dir_for(repo) / _CATCHES))
|
|
240
|
+
return {v: _count_of(tally, v) for v in _TALLIED}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def all_time_counts() -> dict:
|
|
244
|
+
"""The same running tally across every repo on this machine."""
|
|
245
|
+
tally = _tally_of(_read_catches(user_dir() / _CATCHES))
|
|
246
|
+
return {v: _count_of(tally, v) for v in _TALLIED}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def totals(repo: Path) -> dict:
|
|
250
|
+
"""{"runs": n, "blind": n} for `repo` (TYCHO-58): every verdict recorded — the denominator
|
|
251
|
+
the catch counts are read against — and how many of those were blind (INDETERMINATE or
|
|
252
|
+
UNSUPPORTED, i.e. Tycho had nothing to say). `runs` is 0 for a legacy tally migrated from
|
|
253
|
+
the pre-TYCHO-58 `counts.json`, which had no denominator; it fills in as new runs land."""
|
|
254
|
+
return _totals(_read_catches(dir_for(repo) / _CATCHES))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def all_time_totals() -> dict:
|
|
258
|
+
"""The same runs/blind totals across every repo on this machine."""
|
|
259
|
+
return _totals(_read_catches(user_dir() / _CATCHES))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _totals(data: dict) -> dict:
|
|
263
|
+
tally = _tally_of(data)
|
|
264
|
+
return {"runs": _count_of(tally, "runs"), "blind": sum(_count_of(tally, v) for v in _BLIND)}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def catches(repo: Path) -> list:
|
|
268
|
+
"""The evidence trail for `repo` — recent adverse/intermediate runs, newest first."""
|
|
269
|
+
trail = _read_catches(dir_for(repo) / _CATCHES).get("catches")
|
|
270
|
+
return trail if isinstance(trail, list) else []
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def last_run(repo: Path) -> dict | None:
|
|
274
|
+
"""The last recorded heartbeat, or None if the hook has never fired here."""
|
|
275
|
+
return _read_json(dir_for(repo) / _LAST_RUN)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# --- update check cache (TYCHO-53) ------------------------------------------
|
|
279
|
+
#
|
|
280
|
+
# Machine-wide (one check serves every repo): the newest version we saw, when we last
|
|
281
|
+
# looked, which version the user waved off, and how many times they've dismissed a notice.
|
|
282
|
+
# Fail-open like the rest — an unreadable/unwritable cache just means "check again".
|
|
283
|
+
|
|
284
|
+
_UPDATE = "update-check.json"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def read_update_cache() -> dict:
|
|
288
|
+
return _read_json(user_dir() / _UPDATE) or {}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def write_update_cache(**fields) -> None:
|
|
292
|
+
"""Merge `fields` into the update cache. Never raises."""
|
|
293
|
+
try:
|
|
294
|
+
data = read_update_cache()
|
|
295
|
+
data.update(fields)
|
|
296
|
+
_write_json(user_dir() / _UPDATE, data)
|
|
297
|
+
except OSError:
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def dismiss_update(version: str) -> None:
|
|
302
|
+
"""Record that the user waved off the notice for `version`, counting the dismissal."""
|
|
303
|
+
write_update_cache(dismissed_version=version, dismissed=_count_of(read_update_cache(), "dismissed") + 1)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def update_dismissed_count() -> int:
|
|
307
|
+
"""How many update notices the user has dismissed (record-keeping, TYCHO-53)."""
|
|
308
|
+
return _count_of(read_update_cache(), "dismissed")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# --- first-run offer bookkeeping (TYCHO-49) ---------------------------------
|
|
312
|
+
#
|
|
313
|
+
# Machine-level, keyed by repo path: which repos we've already made the "set up Tycho here?"
|
|
314
|
+
# offer in, so a declined offer is never re-nagged — and nothing is written into a repo the
|
|
315
|
+
# user said no to (the marker lives outside it).
|
|
316
|
+
|
|
317
|
+
_OFFERED = "offered.json"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def already_offered(repo: Path) -> bool:
|
|
321
|
+
data = _read_json(user_dir() / _OFFERED) or {}
|
|
322
|
+
return _key(repo) in (data.get("repos") if isinstance(data.get("repos"), list) else [])
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def mark_offered(repo: Path) -> None:
|
|
326
|
+
try:
|
|
327
|
+
data = _read_json(user_dir() / _OFFERED) or {}
|
|
328
|
+
repos = data.get("repos") if isinstance(data.get("repos"), list) else []
|
|
329
|
+
key = _key(repo)
|
|
330
|
+
if key not in repos:
|
|
331
|
+
repos.append(key)
|
|
332
|
+
_write_json(user_dir() / _OFFERED, {"repos": repos})
|
|
333
|
+
except OSError:
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _key(repo: Path) -> str:
|
|
338
|
+
try:
|
|
339
|
+
return str(repo.resolve())
|
|
340
|
+
except OSError:
|
|
341
|
+
return str(repo)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
_STATUS_OFF = "status-off"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def status_enabled(repo: Path) -> bool:
|
|
348
|
+
"""Whether the status-bar indicator should render here. Default on; a sentinel hides it.
|
|
349
|
+
|
|
350
|
+
Hiding the badge is not uninstalling: the Stop hook keeps verifying every turn, the
|
|
351
|
+
heartbeat keeps landing — only the passive indicator goes quiet (TYCHO-47).
|
|
352
|
+
"""
|
|
353
|
+
return not (dir_for(repo) / _STATUS_OFF).exists()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def set_status_enabled(repo: Path, enabled: bool) -> None:
|
|
357
|
+
"""Toggle the indicator on/off for `repo`. Never raises (same fail-open rule as above)."""
|
|
358
|
+
path = dir_for(repo) / _STATUS_OFF
|
|
359
|
+
try:
|
|
360
|
+
if enabled:
|
|
361
|
+
path.unlink(missing_ok=True)
|
|
362
|
+
else:
|
|
363
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
364
|
+
path.write_text("", encoding="utf-8")
|
|
365
|
+
except OSError:
|
|
366
|
+
pass
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# --- verdict relay to the agent (TYCHO-35, opt-in, default OFF) --------------
|
|
370
|
+
#
|
|
371
|
+
# Off by default: Tycho never feeds the agent its own context — and never spends the extra
|
|
372
|
+
# generations doing so — unless the user opts in. When ON, the Stop hook hands a non-VERIFIED
|
|
373
|
+
# verdict back to the Claude agent as additionalContext, which makes Claude Code continue the
|
|
374
|
+
# turn so the agent can address what Tycho caught, "until VERIFIED".
|
|
375
|
+
#
|
|
376
|
+
# The on/off flag lives in `.tycho.toml` (`[relay] enabled`, TYCHO-114) so it's hand-editable
|
|
377
|
+
# and versionable; these functions just delegate to `config`. Only the transient per-turn leash
|
|
378
|
+
# (`relay-streak`) stays a runtime file here — it's ephemeral state, not user configuration.
|
|
379
|
+
#
|
|
380
|
+
# That auto-continuation is *bounded* by the streak counter: one user turn can only be re-entered
|
|
381
|
+
# a fixed number of times (relay_max) before Tycho goes quiet and hands control back — so a
|
|
382
|
+
# verdict Tycho can never satisfy cannot cycle forever (the operator's explicit requirement).
|
|
383
|
+
# The streak resets on every real user prompt (UserPromptSubmit) and on a VERIFIED verdict.
|
|
384
|
+
|
|
385
|
+
_RELAY_STREAK = "relay-streak"
|
|
386
|
+
_RELAY_MAX_DEFAULT = 3
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def relay_max() -> int:
|
|
390
|
+
"""How many times the relay may auto-continue one user turn before going quiet.
|
|
391
|
+
|
|
392
|
+
Default 3; ``TYCHO_RELAY_MAX`` overrides it for a longer or shorter leash. Floored at 0
|
|
393
|
+
(0 = never force a continuation — the relay becomes a pure, single-shot notice). A junk
|
|
394
|
+
value falls back to the default rather than raising: this is read inside the Stop hook.
|
|
395
|
+
"""
|
|
396
|
+
try:
|
|
397
|
+
return max(0, int(os.environ.get("TYCHO_RELAY_MAX", _RELAY_MAX_DEFAULT)))
|
|
398
|
+
except (TypeError, ValueError):
|
|
399
|
+
return _RELAY_MAX_DEFAULT
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def relay_enabled(repo: Path) -> bool:
|
|
403
|
+
"""Whether the Stop hook should feed the verdict back to the agent here. Reads
|
|
404
|
+
`.tycho.toml` ([relay] enabled), default off. Lazy import of `config` avoids an import
|
|
405
|
+
cycle (config imports state for its path resolution)."""
|
|
406
|
+
from . import config
|
|
407
|
+
return config.load(repo).relay_enabled
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def set_relay_enabled(repo: Path, enabled: bool) -> None:
|
|
411
|
+
"""Toggle the verdict relay for `repo` in `.tycho.toml`. Resets the streak either way (a
|
|
412
|
+
toggle starts a fresh leash). Never raises — same fail-open rule as every write here."""
|
|
413
|
+
from . import config
|
|
414
|
+
try:
|
|
415
|
+
config.set_relay(repo, enabled)
|
|
416
|
+
except OSError:
|
|
417
|
+
pass
|
|
418
|
+
reset_relay_streak(repo)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def relay_streak(repo: Path) -> int:
|
|
422
|
+
"""How many times the current user turn has already been auto-continued by the relay.
|
|
423
|
+
Absent/unreadable/garbage counts as 0 — the honest floor for "nothing recorded yet"."""
|
|
424
|
+
try:
|
|
425
|
+
return max(0, int((dir_for(repo) / _RELAY_STREAK).read_text(encoding="utf-8").strip()))
|
|
426
|
+
except (OSError, ValueError):
|
|
427
|
+
return 0
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def bump_relay_streak(repo: Path) -> int:
|
|
431
|
+
"""Record one more relay auto-continuation; return the new count. Never raises."""
|
|
432
|
+
n = relay_streak(repo) + 1
|
|
433
|
+
try:
|
|
434
|
+
path = dir_for(repo) / _RELAY_STREAK
|
|
435
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
436
|
+
path.write_text(str(n), encoding="utf-8")
|
|
437
|
+
except OSError:
|
|
438
|
+
pass
|
|
439
|
+
return n
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def reset_relay_streak(repo: Path) -> None:
|
|
443
|
+
"""Clear the auto-continuation count — a fresh user prompt, a VERIFIED verdict, or a toggle."""
|
|
444
|
+
try:
|
|
445
|
+
(dir_for(repo) / _RELAY_STREAK).unlink(missing_ok=True)
|
|
446
|
+
except OSError:
|
|
447
|
+
pass
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
_STATUSLINE = "statusline.json"
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def write_statusline_wrap(repo: Path, command: str, origin: str) -> None:
|
|
454
|
+
"""Record a status command Tycho should compose with (TYCHO-47).
|
|
455
|
+
|
|
456
|
+
`origin` is "repo" (a foreign statusLine in this repo's own settings that we replaced —
|
|
457
|
+
restore it on uninstall) or "user" (a user-level line we never touched — it resurfaces
|
|
458
|
+
on its own when ours is removed). `tycho status` runs `command` and prepends its output.
|
|
459
|
+
"""
|
|
460
|
+
_write_json(dir_for(repo) / _STATUSLINE, {"command": command, "origin": origin})
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def read_statusline_wrap(repo: Path) -> dict | None:
|
|
464
|
+
"""The recorded compose target, or None. Our own state, so unreadable means "none"."""
|
|
465
|
+
return _read_json(dir_for(repo) / _STATUSLINE)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def clear_statusline_wrap(repo: Path) -> None:
|
|
469
|
+
(dir_for(repo) / _STATUSLINE).unlink(missing_ok=True)
|
tycho/status.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""`tycho status` — the one-line "am I on?" indicator, for a harness status bar (TYCHO-39).
|
|
2
|
+
|
|
3
|
+
`doctor` answers the same question, but only when asked. A verifier nobody can see is a
|
|
4
|
+
verifier that gets silently trusted while dead, so this is the passive form: the harness
|
|
5
|
+
renders it in the terminal on every draw, and the user never has to run a command to know
|
|
6
|
+
they're covered.
|
|
7
|
+
|
|
8
|
+
The contract it's written against (Claude Code 2.1.210, read from the shipped binary —
|
|
9
|
+
see `docs/harness-support.md`):
|
|
10
|
+
|
|
11
|
+
- the command is spawned with the status payload as JSON on **stdin**, and gets ~5s
|
|
12
|
+
before it's aborted;
|
|
13
|
+
- **stdout** is used only on **exit 0**, trimmed, blank lines dropped;
|
|
14
|
+
- ANSI colour is supported and rendered dimmed; stderr is swallowed to a debug log.
|
|
15
|
+
|
|
16
|
+
So this module reads *only* what the hook already wrote to disk (`state`), never verifies
|
|
17
|
+
inline, imports no engine, and fails open to empty output — an empty line renders as
|
|
18
|
+
nothing at all, which is the correct thing to show when we can't prove we're live.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from . import state
|
|
30
|
+
|
|
31
|
+
_RESET = "\033[0m"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _rgb(r: int, g: int, b: int) -> str:
|
|
35
|
+
return f"\033[38;2;{r};{g};{b}m"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Subdued, readable-on-black palette (TYCHO-94). Truecolor: the frost-blue mid-run beat is its
|
|
39
|
+
# exact shade, and the verdict colours are desaturated from pure ANSI — Claude Code renders the
|
|
40
|
+
# status line dark and dimmed, where full-saturation ANSI glares and is hard to read. Grey stays
|
|
41
|
+
# the terminal's own bright-black: the honest "no signal", deliberately left as-is.
|
|
42
|
+
_GREEN = _rgb(140, 198, 158) # VERIFIED — muted mint
|
|
43
|
+
_RED = _rgb(224, 138, 138) # FAILED / STALE — muted rose (both adverse)
|
|
44
|
+
_YELLOW = _rgb(216, 194, 130) # INDETERMINATE — muted amber (ran, couldn't conclude)
|
|
45
|
+
_FROST = _rgb(172, 213, 243) # verifying now — frost blue #ACD5F3 (transient, mid-run)
|
|
46
|
+
_GREY = "\033[90m" # no signal — never fired / UNSUPPORTED / nothing to verify
|
|
47
|
+
|
|
48
|
+
# Five honest states for the `[TYCHO]` badge (TYCHO-47/59/94). The text never changes; the
|
|
49
|
+
# colour carries the status:
|
|
50
|
+
# green = VERIFIED — proven good
|
|
51
|
+
# red = FAILED / STALE — adverse, something to look at
|
|
52
|
+
# frost = verifying (pending) — a run is in flight this turn
|
|
53
|
+
# yellow = INDETERMINATE — ran but couldn't conclude (attention, not alarm)
|
|
54
|
+
# grey = no signal — never fired, UNSUPPORTED, or a completed run with nothing to verify
|
|
55
|
+
# It lands on green or red once a verdict exists; frost is only the in-flight moment, yellow is
|
|
56
|
+
# inconclusive-but-noteworthy, and grey is the honest "nothing to say".
|
|
57
|
+
_VERDICT_COLOUR = {"VERIFIED": _GREEN, "FAILED": _RED, "STALE": _RED, "INDETERMINATE": _YELLOW}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def line(repo: Path) -> str:
|
|
61
|
+
"""The indicator for `repo`, or "" when there's nothing honest to show."""
|
|
62
|
+
if not state.read_install(repo):
|
|
63
|
+
return "" # not installed here — never clutter someone else's status bar
|
|
64
|
+
if os.environ.get("TYCHO_STATUS", "").strip().lower() in ("0", "off", "false", "no"):
|
|
65
|
+
return "" # a global override, for a session where you want it quiet everywhere
|
|
66
|
+
if not state.status_enabled(repo):
|
|
67
|
+
return "" # toggled off in this repo — the hook still runs, only the badge hides
|
|
68
|
+
beat = state.last_run(repo) or {}
|
|
69
|
+
if not isinstance(beat.get("at"), (int, float)):
|
|
70
|
+
return _paint(_GREY, "[TYCHO]") # installed, never fired here — bootup, no signal yet
|
|
71
|
+
verdict = beat.get("verdict")
|
|
72
|
+
if verdict is None:
|
|
73
|
+
# A run with no verdict: mid-run (pending) is frost blue "verifying"; a completed run
|
|
74
|
+
# that reached nothing to verify is grey — not a false "working forever".
|
|
75
|
+
return _paint(_FROST if beat.get("pending") else _GREY, "[TYCHO]")
|
|
76
|
+
return _paint(_VERDICT_COLOUR.get(verdict, _GREY), "[TYCHO]")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _paint(colour: str, text: str) -> str:
|
|
80
|
+
if os.environ.get("NO_COLOR"):
|
|
81
|
+
return text
|
|
82
|
+
return f"{colour}{text}{_RESET}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def repo_of(payload: dict) -> Path:
|
|
86
|
+
"""Which repo the bar is being drawn for.
|
|
87
|
+
|
|
88
|
+
`workspace.project_dir` is the project root; `cwd` follows the user around it. Prefer
|
|
89
|
+
the root — that's where `.tycho/` lives — and fall back to our own cwd for a human
|
|
90
|
+
running `tycho status` by hand.
|
|
91
|
+
"""
|
|
92
|
+
workspace = payload.get("workspace")
|
|
93
|
+
if isinstance(workspace, dict) and isinstance(workspace.get("project_dir"), str):
|
|
94
|
+
return Path(workspace["project_dir"])
|
|
95
|
+
cwd = payload.get("cwd")
|
|
96
|
+
return Path(cwd) if isinstance(cwd, str) else Path.cwd()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _stdin_text() -> str:
|
|
100
|
+
"""The harness's JSON on stdin as raw text — or "" when a human ran this in a terminal.
|
|
101
|
+
|
|
102
|
+
Raw, not parsed, because a *wrapped* status command (composition, TYCHO-47) needs the
|
|
103
|
+
exact same bytes forwarded to it. The isatty check keeps `tycho status` from hanging on
|
|
104
|
+
an interactive read: no payload is coming, and a status command that blocks is worse
|
|
105
|
+
than one that says nothing.
|
|
106
|
+
"""
|
|
107
|
+
if sys.stdin.isatty():
|
|
108
|
+
return ""
|
|
109
|
+
try:
|
|
110
|
+
return sys.stdin.read() or ""
|
|
111
|
+
except (OSError, ValueError):
|
|
112
|
+
return ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _parse(text: str) -> dict:
|
|
116
|
+
try:
|
|
117
|
+
payload = json.loads(text or "{}")
|
|
118
|
+
except (json.JSONDecodeError, ValueError):
|
|
119
|
+
return {}
|
|
120
|
+
return payload if isinstance(payload, dict) else {}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _wrapped_output(repo: Path, stdin_text: str) -> str:
|
|
124
|
+
"""Run the status command Tycho is composing with, and return its line (TYCHO-47).
|
|
125
|
+
|
|
126
|
+
Claude Code's statusLine holds one slot, so to coexist with another status line (a
|
|
127
|
+
third-party badge, a shell prompt) Tycho *becomes* the slot and runs the other command
|
|
128
|
+
itself, forwarding the same payload on stdin. Fail-open to "": a slow or broken
|
|
129
|
+
neighbour must never take Tycho's own segment — or the whole line — down with it.
|
|
130
|
+
"""
|
|
131
|
+
wrap = state.read_statusline_wrap(repo)
|
|
132
|
+
command = wrap.get("command") if isinstance(wrap, dict) else None
|
|
133
|
+
if not isinstance(command, str) or not command:
|
|
134
|
+
return ""
|
|
135
|
+
try:
|
|
136
|
+
proc = subprocess.run(
|
|
137
|
+
command, shell=True, input=stdin_text, capture_output=True,
|
|
138
|
+
text=True, timeout=3, encoding="utf-8", errors="replace",
|
|
139
|
+
)
|
|
140
|
+
except Exception:
|
|
141
|
+
return ""
|
|
142
|
+
return proc.stdout.strip() if proc.returncode == 0 else ""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main(off: bool = False, on: bool = False) -> int:
|
|
146
|
+
try:
|
|
147
|
+
# This line is UTF-8 (⬤, [TYCHO] colour codes). On Windows, stdout to a pipe
|
|
148
|
+
# defaults to cp1252, where writing it raises UnicodeEncodeError — the crash
|
|
149
|
+
# TYCHO-40 found in `doctor`. Say what encoding we speak instead of dying, and
|
|
150
|
+
# don't assume reconfigure exists (a wrapped stdout may not have it).
|
|
151
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
raw = _stdin_text()
|
|
155
|
+
repo = repo_of(_parse(raw))
|
|
156
|
+
if off or on:
|
|
157
|
+
# A human toggling from the terminal (TYCHO-47) — repo is our cwd, not a payload.
|
|
158
|
+
state.set_status_enabled(repo, enabled=on)
|
|
159
|
+
print(f"tycho: status indicator {'shown' if on else 'hidden'} for {repo}"
|
|
160
|
+
f"{'' if on else ' — the hook keeps verifying; run `tycho status --on` to show it'}")
|
|
161
|
+
return 0
|
|
162
|
+
try:
|
|
163
|
+
segments = [_wrapped_output(repo, raw), line(repo)]
|
|
164
|
+
text = " ".join(s for s in segments if s)
|
|
165
|
+
if text:
|
|
166
|
+
print(text)
|
|
167
|
+
except Exception:
|
|
168
|
+
# broad catch is the point — this draws in someone's terminal on every
|
|
169
|
+
# render. Empty output is the fail-open, and exit 0 keeps the harness quiet.
|
|
170
|
+
pass
|
|
171
|
+
return 0
|