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