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.
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/.gitignore +1 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/PKG-INFO +1 -1
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_update.py +106 -3
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/__init__.py +1 -1
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/cli.py +55 -8
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/hook.py +33 -5
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho-target.ps1.example +4 -2
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/.github/workflows/ci.yml +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/.github/workflows/release.yml +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/.tycho.toml +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/CONTRIBUTING.md +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/LICENSE +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/README.md +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/docs/bundler-spike.md +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/docs/harness-support.md +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/docs/hooks.md +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/docs/transcript-structures.md +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/docs/tycho-web.md +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/packaging/homebrew/tycho.rb +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/packaging/npm/bin/tycho.js +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/packaging/npm/package.json +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/pyproject.toml +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/conftest.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/codex_transcript_sample.jsonl +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/cursor_stop_payload.json +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/cursor_transcript_sample.jsonl +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/eval/claude_masked_red_suite.jsonl +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/eval/codex_masked_red_suite.jsonl +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/eval/cursor_masked_red_suite.jsonl +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/eval/opencode_masked_red_suite.json +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/opencode_transcript_sample.json +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/transcript_multiturn.jsonl +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/fixtures/transcript_sample.jsonl +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_count.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_doctor.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_e2e_install.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_eval.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_init_safety.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_m1.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_m2.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_m3.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_m4.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_m5.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_m6.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_relay.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_scope.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_smoke.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_status.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tests/test_turn_scope.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/__main__.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/astdiff.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/checks.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/config.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/doctor.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/events.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/fsstate.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/gitstate.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/harness.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/init.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/model.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/opencode.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/report.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/runlog.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/state.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/status.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/verify.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/tycho/version.py +0 -0
- {tycho_cli-0.0.5 → tycho_cli-0.0.7}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tycho-cli
|
|
3
|
-
Version: 0.0.
|
|
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
|
|
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)) == ""
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
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 $
|
|
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
|
|
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
|