todo_hooks: session-start, canonical token path
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 12:23:10 +01:00
parent 6f0070b353
commit 1fb04ce59d
9 changed files with 257 additions and 12 deletions

View File

@ -20,7 +20,7 @@ Run as `scripts/todo_hooks.sh <command>` or `PYTHONPATH=<utils> 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

View File

@ -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":

View File

@ -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:

View File

@ -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,

View File

@ -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")

View File

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

View File

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

View File

@ -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"

View File

@ -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 == ""