Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
178 changes: 178 additions & 0 deletions hooks/flag_ui_change.py
Original file line number Diff line number Diff line change
@@ -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
``<project>/.cyclaudes/pending-ui/<session_id>.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
``<project_dir>/.cyclaudes/pending-ui/<session_id>.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())
15 changes: 15 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python \"${CLAUDE_PLUGIN_ROOT}/hooks/flag_ui_change.py\""
}
]
}
]
}
}
9 changes: 8 additions & 1 deletion planning/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<session_id>.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
Expand Down
137 changes: 137 additions & 0 deletions tests/test_flag_ui_change.py
Original file line number Diff line number Diff line change
@@ -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/<session_id>.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()
Loading