diff --git a/docs/ecosystem.md b/docs/ecosystem.md index 13380747..dea86601 100644 --- a/docs/ecosystem.md +++ b/docs/ecosystem.md @@ -69,7 +69,7 @@ again. | `bug-complaint-leak` | hook | | `publish-act-guard` | hook | | `categorical-scope-guard` | hook (PreToolUse on `Bash`; blocks a status-narrowed mutation when the live turn said all/every/each) | -| `cat-mode-default` | hook (UserPromptSubmit + PreToolUse on `Agent`; applies `cat-mode` on work turns and subagent prompts when `CATSTACK_CAT_MODE_DEFAULT=1`) | +| `cat-mode-default` | hook (UserPromptSubmit + PreToolUse on `Agent`; applies `cat-mode` on every prompt and on subagent prompts when `CATSTACK_CAT_MODE_DEFAULT=1`) | | `demo-freeze` | hook | | `explicit-failures` | hook (advisory; always on) | | `external-claim-gate` | hook (PreToolUse on `Bash`; blocks a gh issue/comment/release/api write whose body claims a cause or fix with no evidence; blocks as UNCHECKED when the body cannot be read) | diff --git a/engine/hooks/_runner/README.md b/engine/hooks/_runner/README.md new file mode 100644 index 00000000..92f705de --- /dev/null +++ b/engine/hooks/_runner/README.md @@ -0,0 +1,48 @@ +# Hook runner + +Usage: + +```sh +python3 engine/hooks/_runner/run.py [--timeout SECONDS] / [args...] +``` + +The runner reads stdin, runs the hook script in a subprocess with that stdin, +passes through the hook's stdout, stderr, and exit code, then appends one JSONL +metrics row. + +Rows are written to `~/.cache/catstack-hook-metrics/runs.jsonl` by default. Set +`CATSTACK_HOOK_METRICS_DIR` to write `runs.jsonl` under a different directory. + +Each row contains: + +- `ts`: UTC timestamp for the recorded run. +- `harness`: `claude`, `cursor`, `codex`, or `unknown`, inferred from the hooks path. +- `hook`: the first path segment from `/`. +- `script`: the rest of the hook script path after `hook`. +- `event`: `hook_event_name` from JSON stdin, or `null`. +- `session_id`: `session_id` from JSON stdin, falling back to `conversation_id`, or `null`. +- `outcome`: classified result for the run. +- `exit_code`: hook process exit code recorded by the runner. +- `duration_ms`: elapsed runner time in milliseconds. +- `stdout_bytes`: number of stdout bytes emitted by the hook. +- `stderr_tail`: final 500 decoded stderr characters, with invalid UTF-8 replaced. + +Outcome precedence is: + +1. `timed_out` when the runner timeout kills the hook. +2. `blocked` when `exit_code` is `2`. +3. `crashed` when `exit_code` is any other nonzero value. +4. `caught_error` when any stderr line starts with `catstack-hook-error `. +5. `blocked` when stdout is a JSON object with `decision: "block"`, `continue: false`, + `hookSpecificOutput.permissionDecision: "deny"`, or `permission: "deny"`. +6. `spoke` when stdout has non-whitespace bytes. +7. `silent` otherwise. + +If a metrics row cannot be written, the runner appends one stderr line after the +hook stderr: + +```text +catstack-hook-metrics: could not write row to : +``` + +Nothing calls this runner until install wiring lands. diff --git a/engine/hooks/_runner/outcome.py b/engine/hooks/_runner/outcome.py new file mode 100644 index 00000000..f4e7fed8 --- /dev/null +++ b/engine/hooks/_runner/outcome.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import json + +OUTCOMES = { + "timed_out", + "crashed", + "blocked", + "caught_error", + "spoke", + "silent", +} + + +def _stderr_has_hook_error(stderr: bytes) -> bool: + return any(line.startswith(b"catstack-hook-error ") for line in stderr.splitlines()) + + +def _stdout_blocks(stdout: bytes) -> bool: + try: + payload = json.loads(stdout.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return False + if not isinstance(payload, dict): + return False + if payload.get("decision") == "block": + return True + if payload.get("continue") is False: + return True + hook_output = payload.get("hookSpecificOutput") + if isinstance(hook_output, dict) and hook_output.get("permissionDecision") == "deny": + return True + return payload.get("permission") == "deny" + + +def classify(exit_code: int | None, stdout: bytes, stderr: bytes, timed_out: bool) -> str: + if timed_out: + return "timed_out" + if exit_code == 2: + return "blocked" + if exit_code not in (0, None): + return "crashed" + if _stderr_has_hook_error(stderr): + return "caught_error" + if _stdout_blocks(stdout): + return "blocked" + if stdout.strip(): + return "spoke" + return "silent" diff --git a/engine/hooks/_runner/run.py b/engine/hooks/_runner/run.py new file mode 100644 index 00000000..bcc2fd7a --- /dev/null +++ b/engine/hooks/_runner/run.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import argparse +import datetime +import json +import os +import subprocess +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from outcome import classify + + +def _hooks_root() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _harness(hooks_root: str) -> str: + for name in ("claude", "cursor", "codex"): + if f"/.{name}/" in hooks_root: + return name + return "unknown" + + +def _stdin_fields(stdin: bytes) -> tuple[str | None, str | None]: + try: + payload = json.loads(stdin.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None, None + if not isinstance(payload, dict): + return None, None + session_id = payload.get("session_id") + if session_id is None: + session_id = payload.get("conversation_id") + return payload.get("hook_event_name"), session_id + + +def _metrics_path() -> str: + root = os.environ.get("CATSTACK_HOOK_METRICS_DIR") + if not root: + root = os.path.expanduser(os.path.join("~", ".cache", "catstack-hook-metrics")) + return os.path.join(root, "runs.jsonl") + + +def _write_metrics(row: dict[str, object], path: str) -> bytes: + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(row, sort_keys=True, separators=(",", ":")) + "\n") + except OSError as exc: + return f"catstack-hook-metrics: could not write row to {path}: {exc}\n".encode() + return b"" + + +def _format_timeout(seconds: float) -> str: + if seconds == int(seconds): + return str(int(seconds)) + return str(seconds) + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--timeout", type=float) + parser.add_argument("hook_script") + parser.add_argument("args", nargs=argparse.REMAINDER) + return parser.parse_args(argv) + + +def _row( + hooks_root: str, + hook: str, + script: str, + stdin: bytes, + outcome: str, + exit_code: int | None, + started: float, + stdout: bytes, + stderr: bytes, +) -> dict[str, object]: + event, session_id = _stdin_fields(stdin) + return { + "ts": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "harness": _harness(hooks_root), + "hook": hook, + "script": script, + "event": event, + "session_id": session_id, + "outcome": outcome, + "exit_code": exit_code, + "duration_ms": int((time.monotonic() - started) * 1000), + "stdout_bytes": len(stdout), + "stderr_tail": stderr.decode("utf-8", errors="replace")[-500:], + } + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(sys.argv[1:] if argv is None else argv) + started = time.monotonic() + stdin = sys.stdin.buffer.read() + hooks_root = _hooks_root() + hook, script = args.hook_script.split("/", 1) if "/" in args.hook_script else (args.hook_script, "") + script_path = os.path.join(hooks_root, hook, script) + stdout = b"" + stderr = b"" + exit_code = 1 + timed_out = False + + if not script or not os.path.isfile(script_path): + stderr = f"catstack-hook-runner: no such hook script: {script_path}\n".encode() + else: + proc = subprocess.Popen( + [sys.executable, script_path, *args.args], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=os.getcwd(), + env=os.environ.copy(), + ) + try: + stdout, stderr = proc.communicate(stdin, timeout=args.timeout) + exit_code = proc.returncode + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + timed_out = True + stdout = b"" + stderr = ( + f"catstack-hook-runner: {args.hook_script} timed out after " + f"{_format_timeout(args.timeout)}s\n" + ).encode() + exit_code = 1 + + try: + outcome = classify(exit_code, stdout, stderr, timed_out) + row = _row(hooks_root, hook, script, stdin, outcome, exit_code, started, stdout, stderr) + metrics_error = _write_metrics(row, _metrics_path()) + except Exception as exc: + metrics_error = f"catstack-hook-metrics: could not record run: {type(exc).__name__}: {exc}\n".encode() + sys.stdout.buffer.write(stdout) + sys.stdout.buffer.flush() + sys.stderr.buffer.write(stderr) + sys.stderr.buffer.write(metrics_error) + sys.stderr.buffer.flush() + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/engine/hooks/_runner/tests/test_outcome.py b/engine/hooks/_runner/tests/test_outcome.py new file mode 100644 index 00000000..2eb68d97 --- /dev/null +++ b/engine/hooks/_runner/tests/test_outcome.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import os +import sys +import unittest + +RUNNER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, RUNNER_DIR) + +import outcome + + +class ClassifyOutcomes(unittest.TestCase): + def test_timed_out_wins(self): + self.assertEqual(outcome.classify(2, b'{"decision":"block"}', b"", True), "timed_out") + + def test_exit_two_blocks_before_crash(self): + self.assertEqual(outcome.classify(2, b"", b"", False), "blocked") + + def test_other_nonzero_exit_crashes(self): + self.assertEqual(outcome.classify(1, b'{"decision":"block"}', b"", False), "crashed") + + def test_stderr_hook_error_is_caught_error(self): + self.assertEqual(outcome.classify(0, b"", b"catstack-hook-error x\n", False), "caught_error") + + def test_stderr_hook_error_requires_line_start(self): + self.assertEqual(outcome.classify(0, b"", b"x catstack-hook-error y\n", False), "silent") + + def test_json_decision_block_blocks(self): + self.assertEqual(outcome.classify(0, b'{"decision":"block"}', b"", False), "blocked") + + def test_json_continue_false_blocks(self): + self.assertEqual(outcome.classify(0, b'{"continue":false}', b"", False), "blocked") + + def test_json_permission_decision_deny_blocks(self): + data = b'{"hookSpecificOutput":{"permissionDecision":"deny"}}' + self.assertEqual(outcome.classify(0, data, b"", False), "blocked") + + def test_json_permission_deny_blocks(self): + self.assertEqual(outcome.classify(0, b'{"permission":"deny"}', b"", False), "blocked") + + def test_non_json_stdout_speaks(self): + self.assertEqual(outcome.classify(0, b"{not json", b"", False), "spoke") + + def test_json_array_stdout_speaks(self): + self.assertEqual(outcome.classify(0, b"[1]", b"", False), "spoke") + + def test_whitespace_stdout_is_silent(self): + self.assertEqual(outcome.classify(0, b" \n\t", b"", False), "silent") + + def test_empty_stdout_is_silent(self): + self.assertEqual(outcome.classify(0, b"", b"", False), "silent") + + +if __name__ == "__main__": + unittest.main() diff --git a/engine/hooks/_runner/tests/test_run.py b/engine/hooks/_runner/tests/test_run.py new file mode 100644 index 00000000..cc5b2186 --- /dev/null +++ b/engine/hooks/_runner/tests/test_run.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +RUNNER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +class RunnerCLI(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.home = os.path.join(self.tmp.name, "home") + self.hooks_root = os.path.join(self.home, ".claude", "hooks") + self.runner_dir = os.path.join(self.hooks_root, "_runner") + self.fixture_dir = os.path.join(self.hooks_root, "fixture") + self.metrics_dir = os.path.join(self.tmp.name, "metrics") + os.makedirs(self.runner_dir) + os.makedirs(self.fixture_dir) + shutil.copy2(os.path.join(RUNNER_DIR, "run.py"), os.path.join(self.runner_dir, "run.py")) + shutil.copy2(os.path.join(RUNNER_DIR, "outcome.py"), os.path.join(self.runner_dir, "outcome.py")) + self._write_fixture("silent.py", "") + self._write_fixture("spoke.py", "import json\nprint(json.dumps({'hookSpecificOutput': {'additionalContext': 'hi'}}))\n") + self._write_fixture("block_exit2.py", "import sys\nsys.stderr.write('blocked\\n')\nsys.exit(2)\n") + self._write_fixture("block_json.py", "print('{\"decision\":\"block\",\"reason\":\"x\"}')\n") + self._write_fixture("crash.py", "raise RuntimeError('boom')\n") + self._write_fixture("slow.py", "import time\ntime.sleep(5)\n") + self._write_fixture("caught.py", "import sys\nsys.stderr.write('catstack-hook-error fixture: ValueError: x\\n')\n") + + def _write_fixture(self, name: str, body: str) -> None: + with open(os.path.join(self.fixture_dir, name), "w", encoding="utf-8") as handle: + handle.write(body) + + def _env(self, metrics_dir: str | None = None) -> dict[str, str]: + env = os.environ.copy() + env["CATSTACK_HOOK_METRICS_DIR"] = self.metrics_dir if metrics_dir is None else metrics_dir + return env + + def _stdin(self) -> bytes: + return json.dumps({"hook_event_name": "PromptSubmit", "session_id": "s1"}).encode() + + def _direct(self, script: str) -> subprocess.CompletedProcess[bytes]: + return subprocess.run( + [sys.executable, os.path.join(self.fixture_dir, script)], + input=self._stdin(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=self._env(), + ) + + def _runner(self, script: str, *args: str, metrics_dir: str | None = None) -> subprocess.CompletedProcess[bytes]: + return subprocess.run( + [sys.executable, os.path.join(self.runner_dir, "run.py"), *args, f"fixture/{script}"], + input=self._stdin(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=self._env(metrics_dir), + ) + + def _row(self) -> dict[str, object]: + with open(os.path.join(self.metrics_dir, "runs.jsonl"), encoding="utf-8") as handle: + rows = [json.loads(line) for line in handle] + self.assertEqual(len(rows), 1) + return rows[0] + + def test_recording_failure_still_forwards_hook_result(self) -> None: + with open(os.path.join(self.runner_dir, "outcome.py"), "w", encoding="utf-8") as handle: + handle.write("def classify(*args, **kwargs):\n raise RuntimeError('classify broke')\n") + direct = self._direct("spoke.py") + wrapped = self._runner("spoke.py") + self.assertEqual(wrapped.stdout, direct.stdout) + self.assertEqual(wrapped.returncode, direct.returncode) + self.assertIn(b"catstack-hook-metrics: could not record run: RuntimeError: classify broke", wrapped.stderr) + + def _assert_run_matches_direct(self, script: str, outcome: str) -> None: + direct = self._direct(script) + wrapped = self._runner(script) + self.assertEqual(wrapped.stdout, direct.stdout) + self.assertEqual(wrapped.stderr, direct.stderr) + self.assertEqual(wrapped.returncode, direct.returncode) + row = self._row() + self.assertEqual(row["outcome"], outcome) + self.assertEqual(row["harness"], "claude") + self.assertEqual(row["hook"], "fixture") + self.assertEqual(row["script"], script) + self.assertEqual(row["event"], "PromptSubmit") + self.assertEqual(row["session_id"], "s1") + self.assertEqual(row["exit_code"], direct.returncode) + self.assertEqual(row["stdout_bytes"], len(direct.stdout)) + + def test_silent_hook_stays_silent(self): + self._assert_run_matches_direct("silent.py", "silent") + + def test_spoke_hook_keeps_stdout_bytes(self): + self._assert_run_matches_direct("spoke.py", "spoke") + + def test_exit_two_hook_blocks(self): + self._assert_run_matches_direct("block_exit2.py", "blocked") + + def test_block_json_hook_blocks(self): + self._assert_run_matches_direct("block_json.py", "blocked") + + def test_crash_hook_crashes(self): + self._assert_run_matches_direct("crash.py", "crashed") + + def test_caught_error_hook_is_caught(self): + self._assert_run_matches_direct("caught.py", "caught_error") + + def test_slow_hook_times_out(self): + wrapped = self._runner("slow.py", "--timeout", "1") + self.assertEqual(wrapped.stdout, b"") + self.assertIn(b"catstack-hook-runner: fixture/slow.py timed out after 1s\n", wrapped.stderr) + self.assertEqual(wrapped.returncode, 1) + row = self._row() + self.assertEqual(row["outcome"], "timed_out") + self.assertEqual(row["harness"], "claude") + self.assertEqual(row["hook"], "fixture") + self.assertEqual(row["script"], "slow.py") + self.assertEqual(row["event"], "PromptSubmit") + self.assertEqual(row["exit_code"], 1) + + def test_missing_hook_script_records_crash(self): + wrapped = self._runner("missing.py") + self.assertEqual(wrapped.stdout, b"") + self.assertEqual(wrapped.returncode, 1) + self.assertIn(b"catstack-hook-runner: no such hook script:", wrapped.stderr) + row = self._row() + self.assertEqual(row["outcome"], "crashed") + self.assertEqual(row["hook"], "fixture") + self.assertEqual(row["script"], "missing.py") + self.assertEqual(row["exit_code"], 1) + + def test_metrics_write_failure_adds_one_stderr_line(self): + direct = self._direct("spoke.py") + metrics_file = os.path.join(self.tmp.name, "metrics-file") + with open(metrics_file, "w", encoding="utf-8") as handle: + handle.write("") + wrapped = self._runner("spoke.py", metrics_dir=metrics_file) + self.assertEqual(wrapped.stdout, direct.stdout) + self.assertEqual(wrapped.returncode, direct.returncode) + self.assertEqual(direct.stderr, b"") + self.assertIn(b"catstack-hook-metrics: could not write row", wrapped.stderr) + self.assertEqual(len([line for line in wrapped.stderr.splitlines() if line]), 1) + + def test_non_json_stdin_records_null_event_and_conversation_id_fallback(self): + wrapped = subprocess.run( + [sys.executable, os.path.join(self.runner_dir, "run.py"), "fixture/silent.py"], + input=b"not json", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=self._env(), + ) + self.assertEqual(wrapped.returncode, 0) + row = self._row() + self.assertIsNone(row["event"]) + self.assertIsNone(row["session_id"]) + + def test_conversation_id_records_session_id_when_session_id_absent(self): + wrapped = subprocess.run( + [sys.executable, os.path.join(self.runner_dir, "run.py"), "fixture/silent.py"], + input=json.dumps({"hook_event_name": "Stop", "conversation_id": "c1"}).encode(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=self._env(), + ) + self.assertEqual(wrapped.returncode, 0) + row = self._row() + self.assertEqual(row["event"], "Stop") + self.assertEqual(row["session_id"], "c1") + + +if __name__ == "__main__": + unittest.main() diff --git a/engine/hooks/cat-mode-default/README.md b/engine/hooks/cat-mode-default/README.md index aa0230f9..d0710b15 100644 --- a/engine/hooks/cat-mode-default/README.md +++ b/engine/hooks/cat-mode-default/README.md @@ -1,12 +1,12 @@ # cat-mode-default -Claude Code `UserPromptSubmit` hook. When the flag is on and the prompt is -an investigation or execution, it injects one line of context telling the -model to read and apply the installed `cat-mode` skill for that turn. +Claude Code `UserPromptSubmit` hook. When the flag is on, it injects one line +of context telling the model to read and apply the installed `cat-mode` skill +for that turn. `cat-mode` ships with `disable-model-invocation: true`, so on its own it -applies when typed as `/cat-mode`. This hook makes it the default for -work turns without flipping that frontmatter flag. +applies when typed as `/cat-mode`. This hook makes it the default for every +prompt without flipping that frontmatter flag. ## Turning it on @@ -30,11 +30,10 @@ tolerated). They are never sourced, and no other key is read or printed. ## When it fires -Prompt is treated as work when it is longer than a short phrase or carries a -work verb (why, how, fix, build, run, land, make, investigate, check, debug, -...). It stays silent for a bare slash command (`/clear`), a one-word -acknowledgement (`ok`, `thanks`), or any prompt that already contains -`/cat-mode`, so the skill is not applied twice in one turn. +With the flag on, every prompt gets the context unless it already contains a +typed `/cat-mode`, so the skill is not applied twice in one turn. Other slash +commands, acknowledgements, and short execution prompts all get the same +default context. Injected text names the installed file (`~/.claude/skills/cat-mode/SKILL.md`) so the model reads the real skill. If that file is missing the line says @@ -54,7 +53,7 @@ second copy). Same flag resolution as the prompt hook. ## Files -- `detect.py`: flag resolution, prompt classification, context text. +- `detect.py`: flag resolution, typed `/cat-mode` detection, context text. - `claude_prompt_submit.py`: the Claude entrypoint; fail-open, never denies. - `claude.prompt.hook.json`: settings fragment `install_claude_hook.py` merges. - `claude_pretooluse_agent.py` + `claude.agent.hook.json`: the `PreToolUse` @@ -66,4 +65,5 @@ second copy). Same flag resolution as the prompt hook. Related but different: `CAT_MODE_AUTO_INVOKE=true` in catstack's own `.env` makes `install.sh` materialize a cat-mode copy with model invocation enabled, which leaves the choice to the model each turn. This hook is deterministic: -flag on plus a work prompt means the context is injected. +flag on means the context is injected unless the prompt already contains a +typed `/cat-mode`. diff --git a/engine/hooks/cat-mode-default/claude_prompt_submit.py b/engine/hooks/cat-mode-default/claude_prompt_submit.py index f6da8f15..d94e6b1d 100644 --- a/engine/hooks/cat-mode-default/claude_prompt_submit.py +++ b/engine/hooks/cat-mode-default/claude_prompt_submit.py @@ -2,7 +2,7 @@ """Claude Code UserPromptSubmit: inject the cat-mode default context. Fail-open. No LLM. Never denies. Silent unless CATSTACK_CAT_MODE_DEFAULT -resolves to on and the prompt is an investigation or execution. +resolves to on and the prompt does not contain a typed /cat-mode. """ from __future__ import annotations diff --git a/engine/hooks/cat-mode-default/detect.py b/engine/hooks/cat-mode-default/detect.py index 397f4f43..e921529f 100644 --- a/engine/hooks/cat-mode-default/detect.py +++ b/engine/hooks/cat-mode-default/detect.py @@ -11,10 +11,8 @@ Files are parsed as plain `KEY=VALUE` lines. They are never sourced, and no key other than the flag is read back or printed. -2. Is the prompt an investigation or execution? A bare slash command, a - one-word acknowledgement ("ok", "thanks"), or a prompt that already - invokes /cat-mode is not. Anything longer than a short phrase, or that - carries a work verb (why, how, fix, build, run, ...), is. +2. Does the prompt already contain a typed /cat-mode? Every other prompt gets + the context when the flag is on. The text injected names the installed cat-mode SKILL.md so the model reads the real file rather than a summary. When the skill is not installed the @@ -31,19 +29,6 @@ SKILL_RELPATH = os.path.join(".claude", "skills", "cat-mode", "SKILL.md") TRUE_VALUES = frozenset({"1", "true", "yes", "on"}) -MIN_WORK_LENGTH = 12 -ACKS = frozenset({ - "ok", "okay", "k", "kk", "yes", "y", "yep", "yup", "no", "nope", "sure", - "thanks", "thank you", "thx", "ty", "cool", "great", "nice", "good", - "done", "go", "continue", "proceed", "got it", "lgtm", "np", "fine", -}) -WORK_VERBS = ( - "why", "how", "what", "where", "fix", "build", "run", "land", "make", - "investigate", "check", "debug", "test", "repro", "find", "explain", - "add", "remove", "delete", "refactor", "write", "implement", "deploy", - "ship", "merge", "open", "update", "review", "compare", "verify", -) -WORK_VERB_RE = re.compile(r"\b(?:" + "|".join(WORK_VERBS) + r")\b", re.IGNORECASE) CAT_MODE_COMMAND_RE = re.compile(r"(?:^|\s)/cat-mode\b") ENV_LINE_RE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$") @@ -140,23 +125,6 @@ def typed_cat_mode(prompt: str) -> bool: return bool(CAT_MODE_COMMAND_RE.search(prompt or "")) -def is_work_prompt(prompt: str) -> bool: - text = (prompt or "").strip() - if not text: - return False - if typed_cat_mode(text): - return False - tokens = text.split() - if text.startswith("/") and len(tokens) == 1: - return False - normalized = re.sub(r"[^a-z ]", "", text.lower()).strip() - if normalized in ACKS: - return False - if len(text) > MIN_WORK_LENGTH: - return True - return bool(WORK_VERB_RE.search(text)) - - def installed_skill_path(home: str | None = None) -> str | None: home_dir = home or os.path.expanduser("~") path = os.path.join(home_dir, SKILL_RELPATH) @@ -176,7 +144,7 @@ def decide(payload: dict, environ: dict | None = None, home: str | None = None) """Return the additionalContext to inject, or None to stay silent.""" env = os.environ if environ is None else environ prompt = extract_prompt_text(payload if isinstance(payload, dict) else {}) - if not is_work_prompt(prompt): + if typed_cat_mode(prompt): return None cwd = payload.get("cwd") if isinstance(payload, dict) else None if not flag_on(env, cwd or os.getcwd(), home): diff --git a/engine/hooks/cat-mode-default/tests/fixtures/silent_ack_ok.json b/engine/hooks/cat-mode-default/tests/fixtures/silent_ack_ok.json index 4641ae3b..983087d7 100644 --- a/engine/hooks/cat-mode-default/tests/fixtures/silent_ack_ok.json +++ b/engine/hooks/cat-mode-default/tests/fixtures/silent_ack_ok.json @@ -1,6 +1,6 @@ { - "why": "flag on, but the prompt is a one-word acknowledgement, not work", - "expect": "silent", + "why": "flag on, so the prompt receives the default even though it is a one-word acknowledgement", + "expect": "fires", "environ": {"CATSTACK_CAT_MODE_DEFAULT": "1"}, "env_file": null, "payload": {"hook_event_name": "UserPromptSubmit", "prompt": "ok"} diff --git a/engine/hooks/cat-mode-default/tests/test_hooks.py b/engine/hooks/cat-mode-default/tests/test_hooks.py index af7031ba..97b0ff69 100644 --- a/engine/hooks/cat-mode-default/tests/test_hooks.py +++ b/engine/hooks/cat-mode-default/tests/test_hooks.py @@ -130,21 +130,33 @@ def test_silent_when_flag_off(self) -> None: self.assertEqual(fixture["payload"]["prompt"], REAL_PROMPT) self.assertIsNone(context) - def test_silent_on_one_word_ack(self) -> None: + def test_fires_on_one_word_ack(self) -> None: _fixture, context = self.run_fixture("silent_ack_ok.json") - self.assertIsNone(context) + self.assertIsNotNone(context) def test_silent_when_user_typed_cat_mode(self) -> None: _fixture, context = self.run_fixture("silent_typed_cat_mode.json") self.assertIsNone(context) - def test_effectiveness_same_prompt_flag_on_vs_off(self) -> None: - payload = {"prompt": REAL_PROMPT, "cwd": self.box.cwd} - on = injected_context(run_entrypoint(payload, self.box.environ({detect.FLAG: "1"}), self.box.home)) - off = injected_context(run_entrypoint(payload, self.box.environ(), self.box.home)) - self.assertIsNotNone(on) - self.assertIn("read and apply", on) - self.assertIsNone(off) + def test_effectiveness_prompt_matrix(self) -> None: + prompts = ( + "ok", + "thanks", + "yes do it", + "go ahead", + "investigate why the build fails on main", + "/cat-mode fix this", + ) + for prompt in prompts: + payload = {"prompt": prompt, "cwd": self.box.cwd} + on = injected_context(run_entrypoint(payload, self.box.environ({detect.FLAG: "1"}), self.box.home)) + off = injected_context(run_entrypoint(payload, self.box.environ({detect.FLAG: "0"}), self.box.home)) + if prompt == "/cat-mode fix this": + self.assertIsNone(on, prompt) + else: + self.assertIsNotNone(on, prompt) + self.assertIn("read and apply", on) + self.assertIsNone(off, prompt) class FlagResolutionCase(unittest.TestCase): @@ -219,22 +231,7 @@ def test_true_values_turn_on(self) -> None: self.assertTrue(detect.flag_on(self.box.environ({detect.FLAG: value}), self.box.cwd, self.box.home), value) -class PromptClassificationCase(unittest.TestCase): - def test_fires_on_work_prompts(self) -> None: - for prompt in ( - REAL_PROMPT, - "fix it", - "run tests", - "how do I land this stack", - "/loop 5m check the PR queue and repair failures", - "Investigate the flaky e2e on main", - ): - self.assertTrue(detect.is_work_prompt(prompt), prompt) - - def test_silent_on_acks_and_bare_commands(self) -> None: - for prompt in ("ok", "OK!", "yes", "thanks", "Thank you.", "/clear", "/cat-mode", "", " "): - self.assertFalse(detect.is_work_prompt(prompt), prompt) - +class PromptCommandCase(unittest.TestCase): def test_silent_when_cat_mode_typed_anywhere(self) -> None: self.assertTrue(detect.typed_cat_mode("/cat-mode fix it")) self.assertTrue(detect.typed_cat_mode("please /cat-mode fix it"))