Skip to content
2 changes: 1 addition & 1 deletion docs/ecosystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
24 changes: 12 additions & 12 deletions engine/hooks/cat-mode-default/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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`
Expand All @@ -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`.
2 changes: 1 addition & 1 deletion engine/hooks/cat-mode-default/claude_prompt_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 3 additions & 35 deletions engine/hooks/cat-mode-default/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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*$")

Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -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"}
Expand Down
47 changes: 22 additions & 25 deletions engine/hooks/cat-mode-default/tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"))
Expand Down
Loading