tycho-cli 0.0.5__tar.gz → 0.0.7__tar.gz

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.
Files changed (68) hide show
  1. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/.gitignore +1 -0
  2. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/PKG-INFO +1 -1
  3. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_update.py +106 -3
  4. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/__init__.py +1 -1
  5. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/cli.py +55 -8
  6. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/hook.py +33 -5
  7. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho-target.ps1.example +4 -2
  8. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/.github/workflows/ci.yml +0 -0
  9. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/.github/workflows/release.yml +0 -0
  10. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/.tycho.toml +0 -0
  11. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/CONTRIBUTING.md +0 -0
  12. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/LICENSE +0 -0
  13. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/README.md +0 -0
  14. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/docs/bundler-spike.md +0 -0
  15. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/docs/harness-support.md +0 -0
  16. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/docs/hooks.md +0 -0
  17. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/docs/transcript-structures.md +0 -0
  18. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/docs/tycho-web.md +0 -0
  19. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/packaging/homebrew/tycho.rb +0 -0
  20. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/packaging/npm/bin/tycho.js +0 -0
  21. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/packaging/npm/package.json +0 -0
  22. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/pyproject.toml +0 -0
  23. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/conftest.py +0 -0
  24. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/codex_transcript_sample.jsonl +0 -0
  25. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/cursor_stop_payload.json +0 -0
  26. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/cursor_transcript_sample.jsonl +0 -0
  27. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/eval/claude_masked_red_suite.jsonl +0 -0
  28. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/eval/codex_masked_red_suite.jsonl +0 -0
  29. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/eval/cursor_masked_red_suite.jsonl +0 -0
  30. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/eval/opencode_masked_red_suite.json +0 -0
  31. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/opencode_transcript_sample.json +0 -0
  32. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/transcript_multiturn.jsonl +0 -0
  33. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/transcript_sample.jsonl +0 -0
  34. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_count.py +0 -0
  35. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_doctor.py +0 -0
  36. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_e2e_install.py +0 -0
  37. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_eval.py +0 -0
  38. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_init_safety.py +0 -0
  39. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_m1.py +0 -0
  40. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_m2.py +0 -0
  41. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_m3.py +0 -0
  42. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_m4.py +0 -0
  43. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_m5.py +0 -0
  44. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_m6.py +0 -0
  45. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_relay.py +0 -0
  46. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_scope.py +0 -0
  47. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_smoke.py +0 -0
  48. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_status.py +0 -0
  49. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_turn_scope.py +0 -0
  50. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/__main__.py +0 -0
  51. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/astdiff.py +0 -0
  52. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/checks.py +0 -0
  53. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/config.py +0 -0
  54. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/doctor.py +0 -0
  55. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/events.py +0 -0
  56. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/fsstate.py +0 -0
  57. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/gitstate.py +0 -0
  58. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/harness.py +0 -0
  59. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/init.py +0 -0
  60. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/model.py +0 -0
  61. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/opencode.py +0 -0
  62. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/report.py +0 -0
  63. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/runlog.py +0 -0
  64. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/state.py +0 -0
  65. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/status.py +0 -0
  66. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/verify.py +0 -0
  67. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/version.py +0 -0
  68. {tycho_cli-0.0.5 → tycho_cli-0.0.7}/uv.lock +0 -0
@@ -151,6 +151,7 @@ activemq-data/
151
151
  .env
152
152
  .envrc
153
153
  .venv
154
+ .venv-release
154
155
  env/
155
156
  venv/
156
157
  ENV/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tycho-cli
3
- Version: 0.0.5
3
+ Version: 0.0.7
4
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
5
  Project-URL: Homepage, https://swail.dev/tycho
6
6
  Project-URL: Repository, https://github.com/swail-labs/tycho
@@ -151,8 +151,9 @@ def test_update_offline_is_reported_not_fatal(_online, monkeypatch, capsys):
151
151
  def test_update_prints_the_upgrade_command_then_runs_it(_online, monkeypatch, capsys):
152
152
  import subprocess
153
153
 
154
+ monkeypatch.setattr(cli.sys, "platform", "linux") # POSIX runs the upgrade in place
154
155
  _fetches(monkeypatch, "9.9.9")
155
- monkeypatch.setattr(cli, "_upgrade_command", lambda: ["echo", "upgrading"])
156
+ monkeypatch.setattr(cli, "_upgrade_command", lambda force=False: ["echo", "upgrading"])
156
157
  ran = {}
157
158
 
158
159
  class _Done:
@@ -165,6 +166,23 @@ def test_update_prints_the_upgrade_command_then_runs_it(_online, monkeypatch, ca
165
166
  assert ran["cmd"] == ["echo", "upgrading"]
166
167
 
167
168
 
169
+ def test_update_on_windows_defers_the_upgrade_past_process_exit(_online, monkeypatch, capsys):
170
+ # A running .exe can't have its own shim replaced on Windows, so the upgrade must be deferred
171
+ # to a detached child that waits for us to exit — never run synchronously (TYCHO-108 follow-up).
172
+ import subprocess
173
+
174
+ monkeypatch.setattr(cli.sys, "platform", "win32")
175
+ _fetches(monkeypatch, "9.9.9")
176
+ monkeypatch.setattr(cli, "_upgrade_command", lambda force=False: ["uv", "tool", "upgrade", "tycho-cli"])
177
+ monkeypatch.setattr(subprocess, "run", lambda *a, **k: pytest.fail("must not upgrade in-process on Windows"))
178
+ spawned = {}
179
+ monkeypatch.setattr(cli, "_spawn_deferred_upgrade", lambda cmd: spawned.setdefault("cmd", list(cmd)))
180
+ assert cli.main(["update"]) == cli.ExitCode.OK
181
+ out = capsys.readouterr().out
182
+ assert "9.9.9" in out and "once this process exits" in out
183
+ assert spawned["cmd"] == ["uv", "tool", "upgrade", "tycho-cli"]
184
+
185
+
168
186
  @pytest.mark.parametrize("prefix", ["/home/u/.local/pipx/venvs/tycho-cli",
169
187
  "/home/u/.local/share/uv/tools/tycho-cli",
170
188
  "/usr"]) # pipx, uv, plain-pip branches
@@ -174,8 +192,18 @@ def test_upgrade_command_names_the_distribution_not_the_taken_tycho(monkeypatch,
174
192
  # the installed tool (TYCHO-96).
175
193
  monkeypatch.setattr(cli.sys, "prefix", prefix)
176
194
  cmd = cli._upgrade_command()
177
- assert "tycho-cli" in cmd
178
- assert "tycho" not in cmd # the plain command name must never be the upgrade target
195
+ assert "tycho-cli" in " ".join(cmd)
196
+ assert "tycho" not in cmd # the bare command name must never be a standalone upgrade target
197
+
198
+
199
+ @pytest.mark.parametrize("prefix", ["/home/u/.local/pipx/venvs/tycho-cli",
200
+ "/home/u/.local/share/uv/tools/tycho-cli"]) # pip has no persisted pin
201
+ def test_force_crosses_a_version_pin_plain_respects_it(monkeypatch, prefix):
202
+ # A user who installed `tycho-cli==X` on purpose: plain `update` must stay within that pin
203
+ # (`upgrade`), and only `--force` reinstalls the latest across it (`install …@latest`/`--force`).
204
+ monkeypatch.setattr(cli.sys, "prefix", prefix)
205
+ assert "upgrade" in cli._upgrade_command(force=False)
206
+ assert "install" in cli._upgrade_command(force=True)
179
207
 
180
208
 
181
209
  def test_update_skip_dismisses_and_counts(_online, monkeypatch, capsys):
@@ -248,3 +276,78 @@ def test_session_start_never_raises_and_prints_nothing_on_error(monkeypatch, cap
248
276
  _stdin(monkeypatch)
249
277
  assert hook.session_start() == 0 # a bootup hook must never break the session
250
278
  assert capsys.readouterr().out == ""
279
+
280
+
281
+ # --- Stop-hook update notice (TYCHO-116) -------------------------------------
282
+ #
283
+ # The Stop hook appends a human-only "newer Tycho available" line to the verdict the user is
284
+ # already reading — cache-only (no network on the hot path), never in the model-facing
285
+ # additionalContext, and suppressed where there's no human-only channel (Cursor).
286
+
287
+ from pathlib import Path as _Path # noqa: E402
288
+
289
+ _CLAUDE_FIXTURE = _Path(__file__).parent / "fixtures" / "transcript_sample.jsonl"
290
+
291
+
292
+ def _claude_stop(repo) -> str:
293
+ import json
294
+ return json.dumps({"cwd": str(repo), "transcript_path": str(_CLAUDE_FIXTURE.resolve())})
295
+
296
+
297
+ def test_stop_hook_appends_update_line_to_human_output(_online, tmp_path):
298
+ from tycho import hook
299
+ state.write_update_cache(latest="9.9.9", checked_at=time.time())
300
+ out = hook.run(_claude_stop(tmp_path))
301
+ assert out is not None and "9.9.9" in out["systemMessage"] # verdict + update line, together
302
+
303
+
304
+ def test_stop_hook_update_line_is_never_model_facing(_online, tmp_path):
305
+ # With the relay on there IS an additionalContext (model-facing) copy — the update line must
306
+ # ride only the human systemMessage, never that (TYCHO-35: don't tell the model to self-update).
307
+ from tycho import hook
308
+ state.set_relay_enabled(tmp_path, True)
309
+ state.write_update_cache(latest="9.9.9", checked_at=time.time())
310
+ out = hook.run(_claude_stop(tmp_path))
311
+ assert "9.9.9" in out["systemMessage"]
312
+ assert "9.9.9" not in out["hookSpecificOutput"]["additionalContext"]
313
+
314
+
315
+ def test_stop_hook_silent_when_up_to_date(_online, tmp_path):
316
+ from tycho import hook
317
+ state.write_update_cache(latest=tycho.__version__, checked_at=time.time())
318
+ out = hook.run(_claude_stop(tmp_path))
319
+ assert "newer Tycho" not in out["systemMessage"]
320
+
321
+
322
+ def test_stop_hook_respects_dismissal(_online, tmp_path):
323
+ from tycho import hook
324
+ state.write_update_cache(latest="9.9.9", checked_at=time.time())
325
+ state.dismiss_update("9.9.9") # `tycho update --skip` waved this version off
326
+ out = hook.run(_claude_stop(tmp_path))
327
+ assert "9.9.9" not in out["systemMessage"]
328
+
329
+
330
+ def test_stop_hook_silent_when_opted_out(tmp_path):
331
+ # conftest sets TYCHO_NO_UPDATE_CHECK=1; even a fresh cache with a newer version stays silent.
332
+ from tycho import hook
333
+ state.write_update_cache(latest="9.9.9", checked_at=time.time())
334
+ out = hook.run(_claude_stop(tmp_path))
335
+ assert out is not None and "9.9.9" not in out["systemMessage"]
336
+
337
+
338
+ def test_stop_hook_update_notice_never_hits_the_network(_online, monkeypatch, tmp_path):
339
+ from tycho import hook
340
+ monkeypatch.setattr(version_mod, "_fetch", lambda: pytest.fail("Stop path must not hit the network"))
341
+ state.write_update_cache(latest="9.9.9", checked_at=time.time())
342
+ out = hook.run(_claude_stop(tmp_path))
343
+ assert "9.9.9" in out["systemMessage"] # served from the cache only
344
+
345
+
346
+ def test_stop_hook_suffix_suppressed_without_a_human_channel(_online):
347
+ # Cursor: format_output is model-facing and notice_output is None, so a notice would reach the
348
+ # model — suppress it, exactly as the bootup notice does (TYCHO-35/72).
349
+ from types import SimpleNamespace
350
+
351
+ from tycho import hook
352
+ state.write_update_cache(latest="9.9.9", checked_at=time.time())
353
+ assert hook._update_suffix(SimpleNamespace(notice_output=None)) == ""
@@ -6,4 +6,4 @@ trust path. Free, open source (Apache 2.0), offline, no account needed.
6
6
  """
7
7
 
8
8
  # Alpha. Bumped to 0.1.0 for the first public release (TYCHO-10).
9
- __version__ = "0.0.5"
9
+ __version__ = "0.0.7"
@@ -157,6 +157,7 @@ def main(argv: Sequence[str] | None = None) -> int:
157
157
  )
158
158
  up = sub.add_parser("update", help=_COMMANDS["update"])
159
159
  up.add_argument("--skip", action="store_true", help="dismiss the current update notice (records the dismissal)")
160
+ up.add_argument("--force", action="store_true", help="reinstall the latest even across a pinned version (uv/pipx)")
160
161
  sub.add_parser("help", help=_COMMANDS["help"])
161
162
 
162
163
  args = parser.parse_args(argv)
@@ -169,7 +170,7 @@ def main(argv: Sequence[str] | None = None) -> int:
169
170
  if args.command == "scope":
170
171
  return _scope(Path.cwd(), args.action, args.paths, args.exclude)
171
172
  if args.command == "update":
172
- return _update(skip=args.skip)
173
+ return _update(skip=args.skip, force=args.force)
173
174
  if args.command == "relay":
174
175
  return _relay(Path.cwd(), on=args.on, off=args.off)
175
176
  if args.command in ("statusline", "status"):
@@ -363,7 +364,7 @@ def _print_update_notice() -> None:
363
364
  pass
364
365
 
365
366
 
366
- def _upgrade_command() -> list[str]:
367
+ def _upgrade_command(force: bool = False) -> list[str]:
367
368
  """The upgrade command for however Tycho was installed — best-effort from the install
368
369
  path. Falls back to pip, which works for a plain `pip install` (TYCHO-10).
369
370
 
@@ -371,21 +372,26 @@ def _upgrade_command() -> list[str]:
371
372
  `tycho` on PyPI is an unrelated project, so `pip install --upgrade tycho` would pull that,
372
373
  and `pipx/uv upgrade tycho` wouldn't find the tool (installed as `tycho-cli`) — TYCHO-96.
373
374
  Single-sourced from the same constant the update check queries, so the two can't drift.
375
+
376
+ `force` reinstalls the latest even across a version pin set at install time (`uv tool
377
+ install tycho-cli==X` / `pipx install tycho-cli==X`). Without it, `uv tool upgrade` /
378
+ `pipx upgrade` only move within that pin — respecting a version the user chose on purpose.
374
379
  """
375
380
  from . import version as version_mod
376
381
 
377
382
  pkg = version_mod._DIST_NAME or "tycho-cli"
378
383
  prefix = sys.prefix.replace("\\", "/").lower()
379
384
  if "pipx" in prefix:
380
- return ["pipx", "upgrade", pkg]
385
+ return ["pipx", "install", "--force", pkg] if force else ["pipx", "upgrade", pkg]
381
386
  if "/uv/tools/" in prefix or "/uv/" in prefix:
382
- return ["uv", "tool", "upgrade", pkg]
387
+ return ["uv", "tool", "install", f"{pkg}@latest"] if force else ["uv", "tool", "upgrade", pkg]
383
388
  return [sys.executable, "-m", "pip", "install", "--upgrade", pkg]
384
389
 
385
390
 
386
- def _update(skip: bool) -> int:
391
+ def _update(skip: bool, force: bool = False) -> int:
387
392
  """`tycho update` — check the index and upgrade in place, or `--skip` to dismiss the
388
- notice for this version. Offline/failure is reported, never fatal (TYCHO-10/53)."""
393
+ notice for this version. `--force` reinstalls the latest across a pinned version (uv/pipx);
394
+ plain update respects the pin. Offline/failure is reported, never fatal (TYCHO-10/53)."""
389
395
  from . import state
390
396
  from . import version as version_mod
391
397
 
@@ -406,8 +412,23 @@ def _update(skip: bool) -> int:
406
412
  if not behind:
407
413
  print(f"tycho {__version__} is up to date.")
408
414
  return ExitCode.OK
409
- cmd = _upgrade_command()
410
- print(f"Updating tycho {__version__} {newest}: {' '.join(cmd)}")
415
+ cmd = _upgrade_command(force=force)
416
+ hint = "" if force else " (if it's pinned and doesn't move, `tycho update --force`)"
417
+ if sys.platform == "win32":
418
+ # A running .exe can't have its own launcher shim replaced on Windows — an in-process
419
+ # `uv tool upgrade` fails to copy `…\.local\bin\tycho.exe` with os error 32 ("being used
420
+ # by another process"). So defer: a detached child waits for THIS process to exit (which
421
+ # releases the lock), then upgrades against a free shim. POSIX replaces a running exe fine.
422
+ try:
423
+ _spawn_deferred_upgrade(cmd)
424
+ print(f"Updating tycho {__version__} → {newest}. The upgrade runs once this process "
425
+ f"exits; re-open your shell and run `tycho --version` to confirm.{hint}")
426
+ return ExitCode.OK
427
+ except Exception as exc:
428
+ print(f"tycho: couldn't schedule the upgrade ({type(exc).__name__}). Run it yourself:\n {' '.join(cmd)}",
429
+ file=sys.stderr)
430
+ return ExitCode.OK
431
+ print(f"Updating tycho {__version__} → {newest}: {' '.join(cmd)}{hint}")
411
432
  try:
412
433
  import subprocess
413
434
 
@@ -418,6 +439,32 @@ def _update(skip: bool) -> int:
418
439
  return ExitCode.OK
419
440
 
420
441
 
442
+ def _spawn_deferred_upgrade(cmd: Sequence[str]) -> None:
443
+ """Windows only: run `cmd` in a detached process that first waits for us to exit, so the
444
+ running tycho.exe's shim is unlocked before the upgrade copies over it (TYCHO-108 follow-up).
445
+
446
+ Waits on THIS PID (deterministic, not a fixed sleep) with a 30s ceiling, then upgrades. Fully
447
+ detached — no console window, survives our exit — so `tycho update` returns immediately and the
448
+ swap happens a beat later against a released lock."""
449
+ import os
450
+ import subprocess
451
+
452
+ ppid = os.getpid()
453
+ call = "& " + " ".join("'" + str(a).replace("'", "''") + "'" for a in cmd) # PS call-operator
454
+ script = (
455
+ "$ErrorActionPreference='SilentlyContinue';"
456
+ f"Wait-Process -Id {ppid} -Timeout 30;"
457
+ f"{call}"
458
+ )
459
+ # CREATE_NO_WINDOW (no console flash) + CREATE_NEW_PROCESS_GROUP (survives our exit, ignores
460
+ # our console signals). NOT DETACHED_PROCESS — Windows rejects it alongside CREATE_NO_WINDOW.
461
+ flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
462
+ subprocess.Popen(
463
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
464
+ creationflags=flags, close_fds=True,
465
+ )
466
+
467
+
421
468
  def _install(lines: Sequence[str]) -> int:
422
469
  """Print init/uninstall status lines; exit non-zero if we refused to touch a file.
423
470
 
@@ -76,8 +76,33 @@ def run(stdin_text: str) -> dict | None:
76
76
  if harness.name == "opencode":
77
77
  Path(transcript).unlink(missing_ok=True)
78
78
  report = render(verdict, results)
79
- relayed = _relay_output(repo, harness, verdict, report)
80
- return relayed if relayed is not None else harness.format_output(report)
79
+ # A human-only "newer Tycho available" line, appended to the verdict the user is already
80
+ # reading (TYCHO-116). Cache-only never a network call on the hot Stop path.
81
+ update = _update_suffix(harness)
82
+ relayed = _relay_output(repo, harness, verdict, report, update)
83
+ return relayed if relayed is not None else harness.format_output(report + update)
84
+
85
+
86
+ def _update_suffix(harness) -> str:
87
+ """The Stop-hook update notice as a human-only suffix (leading blank line), or "" (TYCHO-116).
88
+
89
+ Only on harnesses with a human-only Stop channel: there, `format_output` writes the same field
90
+ `notice_output` does (systemMessage / message), so appending here stays human-facing. On Cursor
91
+ `format_output` is model-facing (`followup_message`) and `notice_output is None`, so a notice
92
+ would reach the model (TYCHO-35's rule) — suppress it, exactly as the bootup notice does.
93
+
94
+ Cache-only (`refresh_first=False`): the Stop path must never hit the network — the once-a-day
95
+ fetch is the doctor/init/bootup job. Respects opt-out + dismissal via `version.notice`. Never
96
+ raises: an update line is never worth breaking a Stop over."""
97
+ if harness.notice_output is None:
98
+ return ""
99
+ try:
100
+ from . import version as version_mod
101
+
102
+ note = version_mod.notice(refresh_first=False)
103
+ return f"\n\n{note}" if note else ""
104
+ except Exception:
105
+ return ""
81
106
 
82
107
 
83
108
  # --- verdict relay (TYCHO-35, opt-in, default OFF) --------------------------
@@ -95,12 +120,15 @@ def run(stdin_text: str) -> dict | None:
95
120
  # exactly the human-only output they always did (no agent context used, no extra generations).
96
121
 
97
122
 
98
- def _relay_output(repo: Path, harness, verdict, report: str) -> dict | None:
123
+ def _relay_output(repo: Path, harness, verdict, report: str, update: str = "") -> dict | None:
99
124
  """The relay output dict, or None to fall through to the normal human-only output.
100
125
 
101
126
  Returns None (unchanged behaviour) unless the relay is enabled here, the harness is Claude,
102
127
  and the verdict is worth acting on. VERIFIED clears the streak and ends the turn (nothing to
103
128
  fix); once the streak reaches relay_max() the relay goes quiet and hands control back.
129
+
130
+ `update` is the human-only update line (TYCHO-116): it rides the human-facing `systemMessage`
131
+ only, never `additionalContext` — the model-facing copy must not be told to go update Tycho.
104
132
  """
105
133
  if harness.name != "claude" or not state.relay_enabled(repo):
106
134
  return None
@@ -119,10 +147,10 @@ def _relay_output(repo: Path, harness, verdict, report: str) -> dict | None:
119
147
  # relay (TYCHO-114); additionalContext is the model-facing copy that also drives the continuation.
120
148
  manage = "[TYCHO] Relay is on — the agent keeps working until VERIFIED. Manage or turn it off: `tycho relay` (/tycho-relay)."
121
149
  return {
122
- "systemMessage": f"{report}\n\n{manage}",
150
+ "systemMessage": f"{report}\n\n{manage}{update}", # update line is human-only
123
151
  "hookSpecificOutput": {
124
152
  "hookEventName": "Stop",
125
- "additionalContext": f"{report}\n\n{guard}",
153
+ "additionalContext": f"{report}\n\n{guard}", # model-facing — never the update line
126
154
  },
127
155
  }
128
156
 
@@ -1,9 +1,11 @@
1
- # Toggle tycho hooks/statusline between local editable venv and global uv-tool release.
1
+ # Toggle tycho hooks/statusline between the local editable venv (.venv, tracks live repo source)
2
+ # and the pinned release venv (.venv-release, a fixed non-editable install). Both live in the repo
3
+ # and are fully isolated — editing source or `tycho update`-ing one never touches the other.
2
4
  # Copy to tycho-target.ps1 (gitignored) and run from the repo root.
3
5
  # Usage: .\tycho-target.ps1 (flips) | .\tycho-target.ps1 local | .\tycho-target.ps1 release
4
6
  $settings = Join-Path $PSScriptRoot ".claude\settings.json"
5
7
  $local = (Join-Path $PSScriptRoot ".venv\Scripts\tycho.EXE").Replace("\", "/")
6
- $release = (Join-Path $HOME ".local\bin\tycho.exe").Replace("\", "/")
8
+ $release = (Join-Path $PSScriptRoot ".venv-release\Scripts\tycho.EXE").Replace("\", "/")
7
9
 
8
10
  $text = Get-Content $settings -Raw
9
11
  $isLocal = $text.Contains($local)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes