From ce48e73ac2872e14da6638fe88bae7dba63da640 Mon Sep 17 00:00:00 2001 From: Cameron Crow <157651944+CameronCrow@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:38:20 -0500 Subject: [PATCH] feat(phase3-A): PostToolUse relevance detector + session-state protocol Adds the cheap, deterministic half of the Phase 3 autonomous trigger: a PostToolUse hook (matcher Edit|Write) that matches tool_input.file_path against a per-repo UI-glob set and, on a match, records the de-duplicated repo-relative path in .cyclaudes/pending-ui/.json per the frozen schema in planning/PHASE_3.md, for the Stop hook (issue #32) to read. No-op on non-UI paths and never blocks the tool call. Closes #31 Co-Authored-By: Claude Opus 4.8 --- .gitignore | 5 +- hooks/flag_ui_change.py | 178 +++++++++++++++++++++++++++++++++++ hooks/hooks.json | 15 +++ planning/TODO.md | 9 +- tests/test_flag_ui_change.py | 137 +++++++++++++++++++++++++++ 5 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 hooks/flag_ui_change.py create mode 100644 hooks/hooks.json create mode 100644 tests/test_flag_ui_change.py diff --git a/.gitignore b/.gitignore index 2578b84..af349ee 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,10 @@ logs/ # temporary files *.tmp -# Claude +# Claude .claude/ # OPTIONAL: Remove '#' to ignore CLAUDE.md in the repository # CLAUDE.md + +# Cyclaudes Phase 3 session-scoped trigger state (planning/PHASE_3.md — FROZEN INTERFACE) +.cyclaudes/ diff --git a/hooks/flag_ui_change.py b/hooks/flag_ui_change.py new file mode 100644 index 0000000..8a38613 --- /dev/null +++ b/hooks/flag_ui_change.py @@ -0,0 +1,178 @@ +"""PostToolUse relevance detector — the cheap, deterministic half of the Phase 3 trigger. + +Fires on every ``Edit``/``Write`` tool call. Checks whether the touched file matches +this repo's UI globs and, if so, records it in session-scoped state under +``/.cyclaudes/pending-ui/.json`` for the Stop hook (Phase 3, +deliverable B, issue #32) to read. Free — no filesystem write at all — on the common +case of a non-UI change, which is the whole point: verifying every edit would be slow +enough that the trigger gets disabled. + +This module is import-safe: :func:`flag` is the entire implementation, and the +``__main__`` block below it is a thin stdin/stdout adapter, so tests call ``flag()`` +directly against a tmp project dir rather than spawning a subprocess. + +FROZEN INTERFACE (see ``planning/PHASE_3.md`` → Implementation design → +FROZEN INTERFACE) — do not change this shape without updating that section first; +the Stop hook (issue #32) is built against it in parallel:: + + {"session_id": "...", "ui_touched": ["relpath/one.tsx", "relpath/two.xaml"]} + +Per-repo glob override: drop a ``.cyclaudes/ui-globs.txt`` in the project root, one +glob pattern per line (``#`` comments and blank lines ignored). Its presence +*replaces* :data:`DEFAULT_UI_GLOBS` entirely — an override that wants to keep some of +the defaults must repeat them. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path +from typing import Any + +__all__ = ["DEFAULT_UI_GLOBS", "flag"] + +#: Default UI-glob set, matched against the repo-relative, forward-slash-normalized +#: path. Overridable per-repo via ``.cyclaudes/ui-globs.txt`` (see module docstring). +DEFAULT_UI_GLOBS = [ + "ui/**", + "**/*.tsx", + "**/*.jsx", + "**/*.xaml", + "**/*.css", + "frontend/**", +] + +_OVERRIDE_PATH = Path(".cyclaudes") / "ui-globs.txt" + + +def _load_globs(project_dir: Path) -> list[str]: + """The glob set in effect for *project_dir*: the override file if present, else the default.""" + override = project_dir / _OVERRIDE_PATH + if not override.is_file(): + return DEFAULT_UI_GLOBS + globs = [ + line.strip() + for line in override.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.strip().startswith("#") + ] + return globs or DEFAULT_UI_GLOBS + + +def _glob_to_regex(pattern: str) -> re.Pattern[str]: + """Compile *pattern* (posix-style, ``**`` allowed) to a regex matching a full relpath. + + :mod:`fnmatch` and :meth:`pathlib.PurePath.match` don't give ``**`` "any number + of path segments" semantics portably across the Python versions this project + supports, so this is a small hand-rolled translator: ``**/`` and ``**`` cross + directory boundaries; ``*`` and ``?`` stay within a single segment. + """ + pattern = pattern.replace("\\", "/") + parts: list[str] = [] + i, n = 0, len(pattern) + while i < n: + if pattern[i : i + 3] == "**/": + parts.append("(?:.*/)?") + i += 3 + elif pattern[i : i + 2] == "**": + parts.append(".*") + i += 2 + elif pattern[i] == "*": + parts.append("[^/]*") + i += 1 + elif pattern[i] == "?": + parts.append("[^/]") + i += 1 + else: + parts.append(re.escape(pattern[i])) + i += 1 + return re.compile("^" + "".join(parts) + "$") + + +def _matches_ui_glob(relpath: str, globs: list[str]) -> bool: + relpath = relpath.replace("\\", "/") + return any(_glob_to_regex(g).match(relpath) for g in globs) + + +def _repo_relative(file_path: str, project_dir: Path) -> str: + """*file_path* relative to *project_dir*, forward-slash-normalized. + + ``tool_input.file_path`` is normally absolute; a relative path (already + repo-relative) is passed through rather than re-resolved against the process's + current working directory, which may not be *project_dir*. + """ + path = Path(file_path) + if path.is_absolute(): + try: + relpath = os.path.relpath(path, project_dir) + except ValueError: + # Different drive on Windows — no relative path exists. Fall back to the + # absolute path so matching degrades sanely (misses, never crashes). + relpath = str(path) + else: + relpath = str(path) + return relpath.replace("\\", "/") + + +def flag(payload: dict[str, Any], project_dir: str | Path) -> None: + """Core relevance test. + + Reads *payload* — a ``PostToolUse`` hook stdin JSON — and, if + ``tool_input.file_path`` matches a UI glob for *project_dir*, appends its + repo-relative path (de-duplicated) to + ``/.cyclaudes/pending-ui/.json`` per the frozen schema. + Does nothing on a non-UI path or an incomplete payload. + + Never raises. A ``PostToolUse`` hook cannot block the tool call anyway, so the + only sane failure mode for a malformed payload is silence, not a crash the agent + has to work around. + """ + session_id = payload.get("session_id") + tool_input = payload.get("tool_input") or {} + file_path = tool_input.get("file_path") + if not session_id or not file_path: + return + + project_dir = Path(project_dir) + relpath = _repo_relative(file_path, project_dir) + if not _matches_ui_glob(relpath, _load_globs(project_dir)): + return + + state_dir = project_dir / ".cyclaudes" / "pending-ui" + state_dir.mkdir(parents=True, exist_ok=True) + state_path = state_dir / f"{session_id}.json" + + ui_touched: list[str] = [] + if state_path.is_file(): + try: + existing = json.loads(state_path.read_text(encoding="utf-8")) + ui_touched = list(existing.get("ui_touched", [])) + except (json.JSONDecodeError, AttributeError): + ui_touched = [] + + if relpath not in ui_touched: + ui_touched.append(relpath) + + state_path.write_text( + json.dumps({"session_id": session_id, "ui_touched": ui_touched}, indent=2), + encoding="utf-8", + ) + + +def _main() -> int: + """Stdin/stdout adapter Claude Code actually invokes. Always exits 0.""" + try: + payload = json.load(sys.stdin) + project_dir = payload.get("cwd") or os.getcwd() + flag(payload, project_dir) + except Exception: + # PostToolUse cannot block the tool call; a broken hook must stay silent + # rather than surface as a tool-call error the agent has to work around. + pass + return 0 + + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..977e8c4 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "python \"${CLAUDE_PLUGIN_ROOT}/hooks/flag_ui_change.py\"" + } + ] + } + ] + } +} diff --git a/planning/TODO.md b/planning/TODO.md index d079af6..b8b1875 100644 --- a/planning/TODO.md +++ b/planning/TODO.md @@ -104,7 +104,14 @@ Tightly coupled; best done by **one agent**, not fanned out. constrains everything else in this phase)* - [ ] Plugin packaging - [ ] Criteria capture at implement-time (post-conditions written before the change) -- [ ] Trigger + cheap relevance test (don't verify non-UI changes) +- [x] Trigger + cheap relevance test (don't verify non-UI changes) + (`hooks/flag_ui_change.py` + `hooks/hooks.json`: `PostToolUse` hook, matcher + `Edit|Write`, matches `tool_input.file_path` against a per-repo UI-glob set + (default `ui/**`, `**/*.tsx`, `**/*.jsx`, `**/*.xaml`, `**/*.css`, `frontend/**`; + overridable via `.cyclaudes/ui-globs.txt`) and appends the de-duplicated + repo-relative path to `.cyclaudes/pending-ui/.json` per the frozen + schema (`planning/PHASE_3.md`) the Stop hook (issue #32) reads. No-op on a + non-UI path; never blocks the tool call. `tests/test_flag_ui_change.py`) - [ ] Loop integration: pass → continue; fail → actionable diff + self-correct; abstain → escalate with specifics - [ ] Bounded retry — cap correct→verify cycles, escalate on exhaustion diff --git a/tests/test_flag_ui_change.py b/tests/test_flag_ui_change.py new file mode 100644 index 0000000..060025d --- /dev/null +++ b/tests/test_flag_ui_change.py @@ -0,0 +1,137 @@ +"""Tests for ``hooks/flag_ui_change.py`` — the PostToolUse relevance detector. + +Loaded by file path rather than package import: the hook script deliberately lives +outside the ``cyclaudes`` package (at ``hooks/flag_ui_change.py``) so the bare +``python ${CLAUDE_PLUGIN_ROOT}/hooks/flag_ui_change.py`` invocation Claude Code +actually runs needs no install step. See the module docstring there for the frozen +``pending-ui/.json`` schema this exercises. +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +HOOK_PATH = Path(__file__).resolve().parents[1] / "hooks" / "flag_ui_change.py" + + +def _load_hook(): + spec = importlib.util.spec_from_file_location("flag_ui_change", HOOK_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +flag_ui_change = _load_hook() +flag = flag_ui_change.flag + + +def _payload(session_id: str, file_path: str) -> dict: + """A minimal PostToolUse stdin payload shape, as Claude Code would send it.""" + return { + "session_id": session_id, + "tool_name": "Edit", + "tool_input": {"file_path": file_path}, + } + + +def _state_path(project_dir: Path, session_id: str) -> Path: + return project_dir / ".cyclaudes" / "pending-ui" / f"{session_id}.json" + + +def _touch(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("// placeholder", encoding="utf-8") + + +def test_ui_path_is_recorded_with_repo_relative_path(tmp_path): + file_path = tmp_path / "src" / "components" / "Button.tsx" + _touch(file_path) + + flag(_payload("sess-1", str(file_path)), tmp_path) + + state = json.loads(_state_path(tmp_path, "sess-1").read_text(encoding="utf-8")) + assert state == { + "session_id": "sess-1", + "ui_touched": ["src/components/Button.tsx"], + } + + +def test_non_ui_path_is_a_noop(tmp_path): + file_path = tmp_path / "src" / "cyclaudes" / "ui.py" + _touch(file_path) + + flag(_payload("sess-2", str(file_path)), tmp_path) + + assert not (tmp_path / ".cyclaudes").exists() + + +def test_dedup_same_file_twice_yields_one_entry(tmp_path): + file_path = tmp_path / "frontend" / "App.jsx" + _touch(file_path) + + flag(_payload("sess-3", str(file_path)), tmp_path) + flag(_payload("sess-3", str(file_path)), tmp_path) + + state = json.loads(_state_path(tmp_path, "sess-3").read_text(encoding="utf-8")) + assert state["ui_touched"] == ["frontend/App.jsx"] + + +def test_distinct_ui_files_accumulate(tmp_path): + one = tmp_path / "frontend" / "App.jsx" + two = tmp_path / "frontend" / "Nav.tsx" + _touch(one) + _touch(two) + + flag(_payload("sess-4", str(one)), tmp_path) + flag(_payload("sess-4", str(two)), tmp_path) + + state = json.loads(_state_path(tmp_path, "sess-4").read_text(encoding="utf-8")) + assert state["ui_touched"] == ["frontend/App.jsx", "frontend/Nav.tsx"] + + +def test_separate_sessions_do_not_cross_contaminate(tmp_path): + one = tmp_path / "frontend" / "App.jsx" + two = tmp_path / "frontend" / "Nav.tsx" + _touch(one) + _touch(two) + + flag(_payload("sess-a", str(one)), tmp_path) + flag(_payload("sess-b", str(two)), tmp_path) + + state_a = json.loads(_state_path(tmp_path, "sess-a").read_text(encoding="utf-8")) + state_b = json.loads(_state_path(tmp_path, "sess-b").read_text(encoding="utf-8")) + assert state_a["ui_touched"] == ["frontend/App.jsx"] + assert state_b["ui_touched"] == ["frontend/Nav.tsx"] + assert set(_state_path(tmp_path, "sess-a").parent.iterdir()) == { + _state_path(tmp_path, "sess-a"), + _state_path(tmp_path, "sess-b"), + } + + +def test_glob_override_changes_what_matches(tmp_path): + (tmp_path / ".cyclaudes").mkdir() + (tmp_path / ".cyclaudes" / "ui-globs.txt").write_text( + "# only python under widgets/ counts as UI in this repo\nwidgets/**/*.py\n", + encoding="utf-8", + ) + + no_longer_ui = tmp_path / "frontend" / "App.jsx" + now_ui = tmp_path / "widgets" / "panel" / "view.py" + _touch(no_longer_ui) + _touch(now_ui) + + flag(_payload("sess-5", str(no_longer_ui)), tmp_path) + flag(_payload("sess-5", str(now_ui)), tmp_path) + + state = json.loads(_state_path(tmp_path, "sess-5").read_text(encoding="utf-8")) + assert state["ui_touched"] == ["widgets/panel/view.py"] + + +def test_missing_session_id_or_file_path_is_a_noop(tmp_path): + flag({"tool_input": {"file_path": str(tmp_path / "a.tsx")}}, tmp_path) + flag({"session_id": "sess-6", "tool_input": {}}, tmp_path) + + assert not (tmp_path / ".cyclaudes").exists()