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