diff --git a/todo_hooks/README.md b/todo_hooks/README.md index f4dff57..f6929fb 100644 --- a/todo_hooks/README.md +++ b/todo_hooks/README.md @@ -20,7 +20,7 @@ Run as `scripts/todo_hooks.sh ` or `PYTHONPATH= python3 -m todo_ ## Server -`TODO_MCP_URL` (default `http://127.0.0.1:8735/mcp`), token from `TODO_MCP_TOKEN` else `~/.config/todo-mcp/client-token` (0600). Handshake: `initialize`, `notifications/initialized`, then `tools/call`, all with the `Mcp-Session-Id` the first reply returns. +`TODO_MCP_URL` (default `http://127.0.0.1:8735/mcp`), token from `TODO_MCP_TOKEN` else `~/.config/todo-mcp-client/token` (0600). Handshake: `initialize`, `notifications/initialized`, then `tools/call`, all with the `Mcp-Session-Id` the first reply returns. ## Wiring a repo diff --git a/todo_hooks/__main__.py b/todo_hooks/__main__.py index 53690d2..d8330f3 100644 --- a/todo_hooks/__main__.py +++ b/todo_hooks/__main__.py @@ -5,7 +5,7 @@ from __future__ import annotations import sys from typing import TYPE_CHECKING -from todo_hooks import closer, import_pr, sweep, trust_cmd +from todo_hooks import closer, import_pr, session_start, sweep, trust_cmd from todo_hooks.context import Context if TYPE_CHECKING: @@ -14,7 +14,9 @@ if TYPE_CHECKING: from todo_hooks.client import TaskClient -USAGE = "usage: python3 -m todo_hooks {closer|sweep|trust|import-pr} [args]" +USAGE = ( + "usage: python3 -m todo_hooks {closer|sweep|trust|import-pr|session-start} [args]" +) def main( @@ -27,13 +29,22 @@ def main( ) -> int: """Dispatch one subcommand; returns the process exit code.""" args = list(sys.argv[1:] if argv is None else argv) - if not args or args[0] not in {"closer", "sweep", "trust", "import-pr"}: + if not args or args[0] not in { + "closer", + "sweep", + "trust", + "import-pr", + "session-start", + }: print(USAGE) return 2 - ctx = Context.build(env, cwd, make_client) + budget = session_start.BUDGET_SECONDS if args[0] == "session-start" else None + ctx = Context.build(env, cwd, make_client, budget) command, rest = args[0], args[1:] if command == "closer": return closer.run(ctx) + if command == "session-start": + return session_start.run(ctx, sys.stdin.read() if stdin is None else stdin) if command == "sweep": return sweep.main(ctx, rest) if command == "trust": diff --git a/todo_hooks/client.py b/todo_hooks/client.py index 3277798..d3993dc 100644 --- a/todo_hooks/client.py +++ b/todo_hooks/client.py @@ -5,6 +5,7 @@ from __future__ import annotations import contextlib import http.client import json +import time from typing import TYPE_CHECKING, Any, Protocol, Self import urllib.error import urllib.request @@ -84,24 +85,35 @@ def _parse_reply(content_type: str, body: str) -> dict[str, Any]: class McpClient: """One lazily initialised MCP session; ``close`` it (or use ``with``).""" - def __init__(self, url: str, token: str, timeout: float = DEFAULT_TIMEOUT) -> None: + def __init__( + self, + url: str, + token: str, + timeout: float = DEFAULT_TIMEOUT, + budget: float | None = None, + ) -> None: if not url.startswith(("http://", "https://")): msg = f"unsupported MCP url: {url}" raise McpError(msg) self.url = url self._token = token self._timeout = timeout + self._deadline = None if budget is None else time.monotonic() + budget self._session: str | None = None self._ready = False self._next_id = 0 @classmethod def from_env( - cls, env: Mapping[str, str], token_file: Path, timeout: float = DEFAULT_TIMEOUT + cls, + env: Mapping[str, str], + token_file: Path, + timeout: float = DEFAULT_TIMEOUT, + budget: float | None = None, ) -> McpClient: """Build from ``TODO_MCP_URL`` and the token (env or file).""" url = env.get("TODO_MCP_URL") or DEFAULT_URL - return cls(url, load_token(env, token_file), timeout) + return cls(url, load_token(env, token_file), timeout, budget) def __enter__(self) -> Self: return self @@ -119,12 +131,18 @@ class McpClient: } if self._session: headers["Mcp-Session-Id"] = self._session + timeout = self._timeout + if self._deadline is not None: + timeout = min(timeout, self._deadline - time.monotonic()) + if timeout <= 0: + msg = "time budget exhausted" + raise McpUnavailable(msg) data = json.dumps(payload).encode() if payload is not None else None request = urllib.request.Request( self.url, data=data, headers=headers, method=method ) try: - with urllib.request.urlopen(request, timeout=self._timeout) as reply: + with urllib.request.urlopen(request, timeout=timeout) as reply: body = reply.read().decode("utf-8", "replace") return reply.headers.get("Content-Type", ""), body, reply.headers except urllib.error.HTTPError as err: diff --git a/todo_hooks/context.py b/todo_hooks/context.py index ab82d72..4e00e13 100644 --- a/todo_hooks/context.py +++ b/todo_hooks/context.py @@ -34,13 +34,14 @@ class Context: env: Mapping[str, str] | None = None, cwd: Path | None = None, make_client: Callable[[], TaskClient] | None = None, + budget: float | None = None, ) -> Context: """Real defaults: ``os.environ``, the cwd, ``~`` paths, a live client.""" use_env = dict(os.environ) if env is None else dict(env) paths = Paths.default(use_env) def live() -> TaskClient: - return McpClient.from_env(use_env, paths.token_file) + return McpClient.from_env(use_env, paths.token_file, budget=budget) return cls( env=use_env, diff --git a/todo_hooks/paths.py b/todo_hooks/paths.py index 5ca104d..51efaff 100644 --- a/todo_hooks/paths.py +++ b/todo_hooks/paths.py @@ -28,7 +28,7 @@ class Paths: return cls( token_file=Path( env.get("TODO_MCP_TOKEN_FILE") - or base / ".config" / "todo-mcp" / "client-token" + or base / ".config" / "todo-mcp-client" / "token" ), trust_file=Path( env.get("TODO_HOOKS_TRUST_FILE") diff --git a/todo_hooks/session_start.py b/todo_hooks/session_start.py new file mode 100644 index 0000000..a1c115d --- /dev/null +++ b/todo_hooks/session_start.py @@ -0,0 +1,57 @@ +"""SessionStart hook: print the current repo's open tasks, at most ten lines.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +from todo_hooks.gitrepo import expected_repo, origin_url, toplevel +from todo_hooks.notes import parse_list + +if TYPE_CHECKING: + from todo_hooks.context import Context + +BUDGET_SECONDS = 0.8 +MAX_LINES = 10 + + +def _cwd_from(ctx: Context, stdin: str) -> Path: + try: + data = json.loads(stdin) + except json.JSONDecodeError: + return ctx.cwd + cwd = data.get("cwd") if isinstance(data, dict) else None + return Path(cwd) if cwd else ctx.cwd + + +def summary(repo: str, listing: str) -> list[str]: + """Header plus task lines, truncated to MAX_LINES with a '+K more' tail.""" + rows = parse_list(listing) + if not rows: + return [] + out = [f"Open todo tasks for {repo} ({len(rows)}):"] + shown = rows if len(rows) < MAX_LINES else rows[: MAX_LINES - 2] + out += [f"- {row.id8} {row.title}" for row in shown] + if len(shown) < len(rows): + out.append(f'… +{len(rows) - len(shown)} more: list_tasks(repo="{repo}")') + return out + + +def run(ctx: Context, stdin: str) -> int: + """Always 0 and silent on any problem: a session must never wait on this.""" + try: + root = toplevel(_cwd_from(ctx, stdin)) + if root is None or origin_url(root) is None: + return 0 + repo = expected_repo(root) + client = ctx.make_client() + try: + listing = client.call("list_tasks", {"repo": repo, "status": "open"}) + finally: + client.close() + for row in summary(repo, listing): + ctx.out(row) + except Exception: # noqa: BLE001 + return 0 + return 0 diff --git a/todo_hooks/tests/test_client.py b/todo_hooks/tests/test_client.py index c58c89f..f7fdf46 100644 --- a/todo_hooks/tests/test_client.py +++ b/todo_hooks/tests/test_client.py @@ -196,3 +196,21 @@ def test_non_object_json_is_unparseable() -> None: def test_non_http_url_is_refused() -> None: with pytest.raises(McpError, match="unsupported"): McpClient("file:///etc/passwd", "t") + + +def test_exhausted_budget_fails_before_any_request(mcp: FakeMcp) -> None: + with pytest.raises(McpUnavailable, match="budget"): + McpClient(mcp.url, TOKEN, budget=0).call("list_tasks") + assert mcp.requests == [] + + +def test_budget_caps_each_request(mcp: FakeMcp) -> None: + mcp.delay = 1.0 + with pytest.raises(McpUnavailable): + McpClient(mcp.url, TOKEN, timeout=5, budget=0.3).call("list_tasks") + + +def test_from_env_passes_budget(tmp_path: Path) -> None: + file = tmp_path / "t" + file.write_text("tok") + assert McpClient.from_env({}, file, budget=0).call is not None diff --git a/todo_hooks/tests/test_notes_trust.py b/todo_hooks/tests/test_notes_trust.py index fd3b37e..d2fc45e 100644 --- a/todo_hooks/tests/test_notes_trust.py +++ b/todo_hooks/tests/test_notes_trust.py @@ -102,7 +102,7 @@ def test_paths_default_and_env_overrides(tmp_path: Path) -> None: capped.parent.mkdir(parents=True) capped.write_text("#!/bin/sh\n") base = Paths.default({}, tmp_path) - assert base.token_file == tmp_path / ".config/todo-mcp/client-token" + assert base.token_file == tmp_path / ".config/todo-mcp-client/token" assert base.trust_file == tmp_path / ".config/todo-hooks/trusted-checks" assert base.report_file == tmp_path / ".local/state/todo-drift/report.txt" assert base.src_root == tmp_path / "src" diff --git a/todo_hooks/tests/test_session_start.py b/todo_hooks/tests/test_session_start.py new file mode 100644 index 0000000..c5cbb76 --- /dev/null +++ b/todo_hooks/tests/test_session_start.py @@ -0,0 +1,140 @@ +"""Tests for the SessionStart task summary.""" + +from __future__ import annotations + +import json +import time +from typing import TYPE_CHECKING + +from todo_hooks import __main__ as cli +from todo_hooks.tests._fakes import TOKEN, FakeMcp, line + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + import pytest + +ORIGIN = "git@github.com:kuhyx/demo.git" + + +def _rows(count: int) -> str: + return "\n".join( + line(f"{i:08x}-x", "kuhyx/demo", f"# Task {i}") for i in range(count) + ) + + +def _env(mcp: FakeMcp) -> dict[str, str]: + return {"TODO_MCP_URL": mcp.url, "TODO_MCP_TOKEN": TOKEN} + + +def _stdin(cwd: Path) -> str: + return json.dumps({"cwd": str(cwd), "hook_event_name": "SessionStart"}) + + +def test_prints_header_and_tasks( + mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str] +) -> None: + root = make_repo("demo", origin=ORIGIN) + seen: list[dict[str, object]] = [] + mcp.tools["list_tasks"] = lambda a: seen.append(a) or _rows(2) + assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root)) == 0 + assert capsys.readouterr().out.splitlines() == [ + "Open todo tasks for kuhyx/demo (2):", + "- 00000000 Task 0", + "- 00000001 Task 1", + ] + assert seen == [{"repo": "kuhyx/demo", "status": "open"}] + + +def test_nine_tasks_fit_ten_lines( + mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str] +) -> None: + root = make_repo("demo", origin=ORIGIN) + mcp.tools["list_tasks"] = lambda _a: _rows(9) + cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root)) + assert len(capsys.readouterr().out.splitlines()) == 10 + + +def test_more_than_nine_is_truncated( + mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str] +) -> None: + root = make_repo("demo", origin=ORIGIN) + mcp.tools["list_tasks"] = lambda _a: _rows(15) + cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root)) + lines = capsys.readouterr().out.splitlines() + assert len(lines) == 10 + assert lines[0] == "Open todo tasks for kuhyx/demo (15):" + assert lines[-1] == '… +7 more: list_tasks(repo="kuhyx/demo")' + + +def test_no_tasks_prints_nothing( + mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str] +) -> None: + root = make_repo("demo", origin=ORIGIN) + mcp.tools["list_tasks"] = lambda _a: "no tasks match" + assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root)) == 0 + assert capsys.readouterr().out == "" + + +def test_cwd_fallback_when_stdin_is_not_json( + mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str] +) -> None: + root = make_repo("demo", origin=ORIGIN) + mcp.tools["list_tasks"] = lambda _a: _rows(1) + cli.main(["session-start"], env=_env(mcp), cwd=root, stdin="not json") + assert "kuhyx/demo" in capsys.readouterr().out + cli.main(["session-start"], env=_env(mcp), cwd=root, stdin="[1]") + assert "kuhyx/demo" in capsys.readouterr().out + cli.main(["session-start"], env=_env(mcp), cwd=root, stdin="{}") + assert "kuhyx/demo" in capsys.readouterr().out + + +def test_silent_without_git_or_origin( + mcp: FakeMcp, + make_repo: Callable[..., Path], + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + mcp.tools["list_tasks"] = lambda _a: _rows(1) + plain = tmp_path / "plain" + plain.mkdir() + assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(plain)) == 0 + local = make_repo("local") + assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(local)) == 0 + assert capsys.readouterr().out == "" + assert mcp.requests == [] + + +def test_silent_when_server_down_or_token_bad( + mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str] +) -> None: + root = make_repo("demo", origin=ORIGIN) + bad = {**_env(mcp), "TODO_MCP_TOKEN": "wrong"} + assert cli.main(["session-start"], env=bad, stdin=_stdin(root)) == 0 + dead = {**_env(mcp), "TODO_MCP_URL": "http://127.0.0.1:1/mcp"} + assert cli.main(["session-start"], env=dead, stdin=_stdin(root)) == 0 + no_token = {"TODO_MCP_URL": mcp.url, "TODO_MCP_TOKEN_FILE": "/nonexistent"} + assert cli.main(["session-start"], env=no_token, stdin=_stdin(root)) == 0 + assert capsys.readouterr().out == "" + + +def test_silent_on_unexpected_error( + mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str] +) -> None: + root = make_repo("demo", origin=ORIGIN) + mcp.mode = "garbage" + assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root)) == 0 + assert capsys.readouterr().out == "" + + +def test_slow_server_stays_within_budget( + mcp: FakeMcp, make_repo: Callable[..., Path], capsys: pytest.CaptureFixture[str] +) -> None: + root = make_repo("demo", origin=ORIGIN) + mcp.delay = 3.0 + mcp.tools["list_tasks"] = lambda _a: _rows(1) + start = time.monotonic() + assert cli.main(["session-start"], env=_env(mcp), stdin=_stdin(root)) == 0 + assert time.monotonic() - start < 1.6 + assert capsys.readouterr().out == ""