tycho-cli 0.0.4__tar.gz → 0.0.6__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.4 → tycho_cli-0.0.6}/.gitignore +1 -0
  2. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/PKG-INFO +1 -1
  3. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_update.py +93 -0
  4. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/__init__.py +1 -1
  5. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/cli.py +40 -0
  6. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/hook.py +33 -5
  7. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho-target.ps1.example +4 -2
  8. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/.github/workflows/ci.yml +0 -0
  9. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/.github/workflows/release.yml +0 -0
  10. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/.tycho.toml +0 -0
  11. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/CONTRIBUTING.md +0 -0
  12. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/LICENSE +0 -0
  13. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/README.md +0 -0
  14. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/docs/bundler-spike.md +0 -0
  15. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/docs/harness-support.md +0 -0
  16. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/docs/hooks.md +0 -0
  17. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/docs/transcript-structures.md +0 -0
  18. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/docs/tycho-web.md +0 -0
  19. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/packaging/homebrew/tycho.rb +0 -0
  20. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/packaging/npm/bin/tycho.js +0 -0
  21. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/packaging/npm/package.json +0 -0
  22. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/pyproject.toml +0 -0
  23. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/conftest.py +0 -0
  24. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/fixtures/codex_transcript_sample.jsonl +0 -0
  25. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/fixtures/cursor_stop_payload.json +0 -0
  26. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/fixtures/cursor_transcript_sample.jsonl +0 -0
  27. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/fixtures/eval/claude_masked_red_suite.jsonl +0 -0
  28. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/fixtures/eval/codex_masked_red_suite.jsonl +0 -0
  29. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/fixtures/eval/cursor_masked_red_suite.jsonl +0 -0
  30. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/fixtures/eval/opencode_masked_red_suite.json +0 -0
  31. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/fixtures/opencode_transcript_sample.json +0 -0
  32. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/fixtures/transcript_multiturn.jsonl +0 -0
  33. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/fixtures/transcript_sample.jsonl +0 -0
  34. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_count.py +0 -0
  35. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_doctor.py +0 -0
  36. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_e2e_install.py +0 -0
  37. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_eval.py +0 -0
  38. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_init_safety.py +0 -0
  39. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_m1.py +0 -0
  40. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_m2.py +0 -0
  41. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_m3.py +0 -0
  42. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_m4.py +0 -0
  43. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_m5.py +0 -0
  44. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_m6.py +0 -0
  45. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_relay.py +0 -0
  46. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_scope.py +0 -0
  47. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_smoke.py +0 -0
  48. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_status.py +0 -0
  49. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tests/test_turn_scope.py +0 -0
  50. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/__main__.py +0 -0
  51. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/astdiff.py +0 -0
  52. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/checks.py +0 -0
  53. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/config.py +0 -0
  54. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/doctor.py +0 -0
  55. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/events.py +0 -0
  56. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/fsstate.py +0 -0
  57. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/gitstate.py +0 -0
  58. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/harness.py +0 -0
  59. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/init.py +0 -0
  60. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/model.py +0 -0
  61. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/opencode.py +0 -0
  62. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/report.py +0 -0
  63. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/runlog.py +0 -0
  64. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/state.py +0 -0
  65. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/status.py +0 -0
  66. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/verify.py +0 -0
  67. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/tycho/version.py +0 -0
  68. {tycho_cli-0.0.4 → tycho_cli-0.0.6}/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.4
3
+ Version: 0.0.6
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,6 +151,7 @@ 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
156
  monkeypatch.setattr(cli, "_upgrade_command", lambda: ["echo", "upgrading"])
156
157
  ran = {}
@@ -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: ["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
@@ -248,3 +266,78 @@ def test_session_start_never_raises_and_prints_nothing_on_error(monkeypatch, cap
248
266
  _stdin(monkeypatch)
249
267
  assert hook.session_start() == 0 # a bootup hook must never break the session
250
268
  assert capsys.readouterr().out == ""
269
+
270
+
271
+ # --- Stop-hook update notice (TYCHO-116) -------------------------------------
272
+ #
273
+ # The Stop hook appends a human-only "newer Tycho available" line to the verdict the user is
274
+ # already reading — cache-only (no network on the hot path), never in the model-facing
275
+ # additionalContext, and suppressed where there's no human-only channel (Cursor).
276
+
277
+ from pathlib import Path as _Path # noqa: E402
278
+
279
+ _CLAUDE_FIXTURE = _Path(__file__).parent / "fixtures" / "transcript_sample.jsonl"
280
+
281
+
282
+ def _claude_stop(repo) -> str:
283
+ import json
284
+ return json.dumps({"cwd": str(repo), "transcript_path": str(_CLAUDE_FIXTURE.resolve())})
285
+
286
+
287
+ def test_stop_hook_appends_update_line_to_human_output(_online, tmp_path):
288
+ from tycho import hook
289
+ state.write_update_cache(latest="9.9.9", checked_at=time.time())
290
+ out = hook.run(_claude_stop(tmp_path))
291
+ assert out is not None and "9.9.9" in out["systemMessage"] # verdict + update line, together
292
+
293
+
294
+ def test_stop_hook_update_line_is_never_model_facing(_online, tmp_path):
295
+ # With the relay on there IS an additionalContext (model-facing) copy — the update line must
296
+ # ride only the human systemMessage, never that (TYCHO-35: don't tell the model to self-update).
297
+ from tycho import hook
298
+ state.set_relay_enabled(tmp_path, True)
299
+ state.write_update_cache(latest="9.9.9", checked_at=time.time())
300
+ out = hook.run(_claude_stop(tmp_path))
301
+ assert "9.9.9" in out["systemMessage"]
302
+ assert "9.9.9" not in out["hookSpecificOutput"]["additionalContext"]
303
+
304
+
305
+ def test_stop_hook_silent_when_up_to_date(_online, tmp_path):
306
+ from tycho import hook
307
+ state.write_update_cache(latest=tycho.__version__, checked_at=time.time())
308
+ out = hook.run(_claude_stop(tmp_path))
309
+ assert "newer Tycho" not in out["systemMessage"]
310
+
311
+
312
+ def test_stop_hook_respects_dismissal(_online, tmp_path):
313
+ from tycho import hook
314
+ state.write_update_cache(latest="9.9.9", checked_at=time.time())
315
+ state.dismiss_update("9.9.9") # `tycho update --skip` waved this version off
316
+ out = hook.run(_claude_stop(tmp_path))
317
+ assert "9.9.9" not in out["systemMessage"]
318
+
319
+
320
+ def test_stop_hook_silent_when_opted_out(tmp_path):
321
+ # conftest sets TYCHO_NO_UPDATE_CHECK=1; even a fresh cache with a newer version stays silent.
322
+ from tycho import hook
323
+ state.write_update_cache(latest="9.9.9", checked_at=time.time())
324
+ out = hook.run(_claude_stop(tmp_path))
325
+ assert out is not None and "9.9.9" not in out["systemMessage"]
326
+
327
+
328
+ def test_stop_hook_update_notice_never_hits_the_network(_online, monkeypatch, tmp_path):
329
+ from tycho import hook
330
+ monkeypatch.setattr(version_mod, "_fetch", lambda: pytest.fail("Stop path must not hit the network"))
331
+ state.write_update_cache(latest="9.9.9", checked_at=time.time())
332
+ out = hook.run(_claude_stop(tmp_path))
333
+ assert "9.9.9" in out["systemMessage"] # served from the cache only
334
+
335
+
336
+ def test_stop_hook_suffix_suppressed_without_a_human_channel(_online):
337
+ # Cursor: format_output is model-facing and notice_output is None, so a notice would reach the
338
+ # model — suppress it, exactly as the bootup notice does (TYCHO-35/72).
339
+ from types import SimpleNamespace
340
+
341
+ from tycho import hook
342
+ state.write_update_cache(latest="9.9.9", checked_at=time.time())
343
+ 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.4"
9
+ __version__ = "0.0.6"
@@ -407,6 +407,20 @@ def _update(skip: bool) -> int:
407
407
  print(f"tycho {__version__} is up to date.")
408
408
  return ExitCode.OK
409
409
  cmd = _upgrade_command()
410
+ if sys.platform == "win32":
411
+ # A running .exe can't have its own launcher shim replaced on Windows — an in-process
412
+ # `uv tool upgrade` fails to copy `…\.local\bin\tycho.exe` with os error 32 ("being used
413
+ # by another process"). So defer: a detached child waits for THIS process to exit (which
414
+ # releases the lock), then upgrades against a free shim. POSIX replaces a running exe fine.
415
+ try:
416
+ _spawn_deferred_upgrade(cmd)
417
+ print(f"Updating tycho {__version__} → {newest}. The upgrade runs once this process "
418
+ f"exits; re-open your shell and run `tycho --version` to confirm.")
419
+ return ExitCode.OK
420
+ except Exception as exc:
421
+ print(f"tycho: couldn't schedule the upgrade ({type(exc).__name__}). Run it yourself:\n {' '.join(cmd)}",
422
+ file=sys.stderr)
423
+ return ExitCode.OK
410
424
  print(f"Updating tycho {__version__} → {newest}: {' '.join(cmd)}")
411
425
  try:
412
426
  import subprocess
@@ -418,6 +432,32 @@ def _update(skip: bool) -> int:
418
432
  return ExitCode.OK
419
433
 
420
434
 
435
+ def _spawn_deferred_upgrade(cmd: Sequence[str]) -> None:
436
+ """Windows only: run `cmd` in a detached process that first waits for us to exit, so the
437
+ running tycho.exe's shim is unlocked before the upgrade copies over it (TYCHO-108 follow-up).
438
+
439
+ Waits on THIS PID (deterministic, not a fixed sleep) with a 30s ceiling, then upgrades. Fully
440
+ detached — no console window, survives our exit — so `tycho update` returns immediately and the
441
+ swap happens a beat later against a released lock."""
442
+ import os
443
+ import subprocess
444
+
445
+ ppid = os.getpid()
446
+ call = "& " + " ".join("'" + str(a).replace("'", "''") + "'" for a in cmd) # PS call-operator
447
+ script = (
448
+ "$ErrorActionPreference='SilentlyContinue';"
449
+ f"Wait-Process -Id {ppid} -Timeout 30;"
450
+ f"{call}"
451
+ )
452
+ # CREATE_NO_WINDOW (no console flash) + CREATE_NEW_PROCESS_GROUP (survives our exit, ignores
453
+ # our console signals). NOT DETACHED_PROCESS — Windows rejects it alongside CREATE_NO_WINDOW.
454
+ flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
455
+ subprocess.Popen(
456
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
457
+ creationflags=flags, close_fds=True,
458
+ )
459
+
460
+
421
461
  def _install(lines: Sequence[str]) -> int:
422
462
  """Print init/uninstall status lines; exit non-zero if we refused to touch a file.
423
463
 
@@ -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