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
21 changes: 8 additions & 13 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,14 @@
# install.sh sources this file if it exists; nothing here is committed
# or installed on any other machine.

# cat-mode ships with disable-model-invocation:true (see
# corpus/skills/cat-mode/SKILL.md) so it never auto-triggers, only
# /cat-mode does. Set this to true to let cat-mode auto-invoke on this
# machine only: install.sh materializes a local SKILL.md with the flag
# flipped to false and symlinks everything else in the skill as usual.
CAT_MODE_AUTO_INVOKE=false

# cat-mode-default hook: apply cat-mode on every investigation/execution
# prompt without typing /cat-mode. Read from the process env, then
# $CATSTACK_ENV_FILE, then the current repo's .env, then ~/.catstack.env.
# Put this same line in ~/.catstack.env to turn it on for every repo.
# 0 (or absent) turns it off.
CATSTACK_CAT_MODE_DEFAULT=1
# When cat-mode applies. Read from the process env, then $CATSTACK_ENV_FILE,
# then the current repo's .env, then ~/.catstack.env. Put this same line in
# ~/.catstack.env to set it for every repo.
# off -- only when you type /cat-mode (also: absent, 0)
# decide -- install.sh installs a copy the model may pick on its own;
# re-run ./install.sh after switching to or from decide
# on -- the cat-mode-default hook applies it on every prompt (also: 1)
CATSTACK_CAT_MODE_DEFAULT=on

# Reflect enforcement: the scope-lock, reflect-on-thrash, wrong-check-reflect
# and verdict-flip-watch hooks, plus the always-on "same complaint type twice:
Expand Down
2 changes: 1 addition & 1 deletion docs/ecosystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,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 every prompt and on 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=on`) |
| `demo-freeze` | hook |
| `explicit-failures` | hook (advisory; always on) |
| `text-match-decision-warn` | hook (advisory; PreToolUse on file edits for Claude, Cursor, and Codex; warns when added code decides by matching error/log text, tool or agent output, or plan/task prose, and logs each warning next to the metrics runner's `runs.jsonl`) |
Expand Down
11 changes: 10 additions & 1 deletion engine/hooks/_flags/flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,17 +233,26 @@ def main(argv: list[str] | None = None, environ: dict | None = None, stdout=None
"""Print `on`, `off`, or `unchecked` for one key, for callers that are not
Python. install.sh reads it to pick which always-on rules to install.
`unchecked` means a candidate file could not be read: the note goes to
stderr and the caller treats the flag as off, like the hooks do."""
stderr and the caller treats the flag as off, like the hooks do.

`--value` prints the raw value instead, lowercased and trimmed, for a flag
with more than two settings. Unset prints an empty line; unreadable still
prints `unchecked`."""
import argparse

parser = argparse.ArgumentParser(description="Look up one catstack flag.")
parser.add_argument("key")
parser.add_argument("--cwd", default=None, help="where to start looking for a repo .env")
parser.add_argument("--value", action="store_true", help="print the raw value, not on/off")
args = parser.parse_args(argv)
found = resolve_flag(args.key, dict(os.environ if environ is None else environ), args.cwd)
note = found.unreadable_note(args.key)
if note:
(stderr or sys.stderr).write(note + "\n")
if args.value:
raw = "unchecked" if found.value is None and note else (found.value or "").strip().lower()
(stdout or sys.stdout).write(raw + "\n")
return 0
state = "on" if found.on else ("unchecked" if note else "off")
(stdout or sys.stdout).write(state + "\n")
return 0
Expand Down
13 changes: 13 additions & 0 deletions engine/hooks/_flags/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ def test_unreadable_file_prints_unchecked_and_names_the_file(self):
self.assertEqual((code, state), (0, "unchecked"))
self.assertIn(os.path.join(self.repo, ".env"), err)

def test_value_prints_the_raw_setting(self):
self.environ["CATSTACK_CAT_MODE_DEFAULT"] = " Decide "
self.assertEqual(
self.run_main("CATSTACK_CAT_MODE_DEFAULT", "--value", "--cwd", self.repo), (0, "decide", "")
)

def test_value_unset_prints_empty(self):
self.assertEqual(self.run_main(KEY, "--value", "--cwd", self.repo), (0, "", ""))

def test_value_unreadable_prints_unchecked(self):
os.makedirs(os.path.join(self.repo, ".env"))
self.assertEqual(self.run_main(KEY, "--value", "--cwd", self.repo)[:2], (0, "unchecked"))

def test_runs_as_a_script(self):
env = {**os.environ, **self.environ, KEY: "on"}
result = subprocess.run(
Expand Down
22 changes: 14 additions & 8 deletions engine/hooks/cat-mode-default/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@ prompt without flipping that frontmatter flag.

## Turning it on

Set `CATSTACK_CAT_MODE_DEFAULT=1`. The hook reads it from the process
Set `CATSTACK_CAT_MODE_DEFAULT=on` (`1` also works). The flag has three
settings:

| Value | What happens |
| --- | --- |
| `off` (or unset, `0`) | `cat-mode` runs only when you type `/cat-mode`. |
| `decide` | This hook stays quiet. `install.sh` installs a copy of `cat-mode` the model may pick on its own each turn. Re-run `install.sh` after switching to or from `decide`. |
| `on` (or `1`) | This hook tells the model to use `cat-mode` on every prompt. |

The hook reads it from the process
environment first. If it is not set there, it searches `.env` files in this
order and the first file that defines the key wins:

Expand All @@ -21,12 +30,12 @@ order and the first file that defines the key wins:
For "on in every repo", add this line to `~/.catstack.env`:

```
CATSTACK_CAT_MODE_DEFAULT=1
CATSTACK_CAT_MODE_DEFAULT=on
```

Files are parsed as plain `KEY=VALUE` lines (`export` prefix and quotes are
tolerated). They are never sourced, and no other key is read or printed.
`0`, `false`, `no`, `off`, or an absent key means off.
Only `on`, `1`, `true`, and `yes` fire this hook; anything else keeps it quiet.

## When it fires

Expand Down Expand Up @@ -62,8 +71,5 @@ second copy). Same flag resolution as the prompt hook.
environment, optional `.env` content, and payload.
- `tests/fixtures/agent_*.json`: the same for the Agent-tool companion.

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 means the context is injected unless the prompt already contains a
typed `/cat-mode`.
`decide` replaces the retired `CAT_MODE_AUTO_INVOKE=true`. `install.sh` warns
when it still finds that name and ignores it.
11 changes: 7 additions & 4 deletions engine/hooks/cat-mode-default/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

Two questions, both pure functions over the payload and environment:

1. Is the flag on? `CATSTACK_CAT_MODE_DEFAULT` is read from the process
1. Is the flag `on`? `CATSTACK_CAT_MODE_DEFAULT` takes `off`, `decide`, or
`on` (`1`/`true`/`yes` also mean `on`). Only `on` fires this hook;
`decide` is handled by install.sh, which lets the model pick cat-mode
itself. The value is read from the process
environment first. If it is not set there, a `.env` file is searched in
this order and the first file that defines the key wins:
a. the file named by `$CATSTACK_ENV_FILE`, if that variable is set
Expand Down Expand Up @@ -133,9 +136,9 @@ def installed_skill_path(home: str | None = None) -> str | None:

def context_text(skill_path: str | None) -> str:
if skill_path is None:
return f"cat-mode default is on ({FLAG}=1) but cat-mode is not installed: run install.sh."
return f"cat-mode default is on ({FLAG}=on) but cat-mode is not installed: run install.sh."
return (
f"cat-mode default is on ({FLAG}=1): read and apply {skill_path} for this turn "
f"cat-mode default is on ({FLAG}=on): read and apply {skill_path} for this turn "
"-- investigation and execution follow the user's conventions."
)

Expand Down Expand Up @@ -164,7 +167,7 @@ def mentions_cat_mode(prompt: str) -> bool:

def agent_prefix_line(skill_path: str | None) -> str:
if skill_path is None:
return f"cat-mode default is on ({FLAG}=1) but cat-mode is not installed: run install.sh."
return f"cat-mode default is on ({FLAG}=on) but cat-mode is not installed: run install.sh."
return f"cat-mode default is on: read and apply {skill_path} before starting."


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"why": "the word on turns the default on, same as 1",
"expect": "fires",
"environ": {"CATSTACK_CAT_MODE_DEFAULT": "on"},
"env_file": null,
"payload": {"hook_event_name": "UserPromptSubmit", "prompt": "why isnt the 72,000,000 transaction recorded in my sheet?"}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"why": "decide leaves the choice to the model through the installed skill, so the hook stays quiet",
"expect": "silent",
"environ": {"CATSTACK_CAT_MODE_DEFAULT": "decide"},
"env_file": null,
"payload": {"hook_event_name": "UserPromptSubmit", "prompt": "why isnt the 72,000,000 transaction recorded in my sheet?"}
}
2 changes: 1 addition & 1 deletion engine/hooks/cat-mode-default/tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def test_fires_on_investigation_with_env_flag(self) -> None:
fixture, context = self.run_fixture("fires_env_flag_investigation.json")
self.assertEqual(fixture["payload"]["prompt"], REAL_PROMPT)
self.assertIsNotNone(context)
self.assertIn("CATSTACK_CAT_MODE_DEFAULT=1", context)
self.assertIn("CATSTACK_CAT_MODE_DEFAULT=on", context)
self.assertIn(self.box.skill_path, context)

def test_fires_on_dotenv_file_only(self) -> None:
Expand Down
27 changes: 20 additions & 7 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,6 @@ warn_if_installing_from_worktree() {

warn_if_installing_from_worktree

if [ -z "${CAT_MODE_AUTO_INVOKE:-}" ] && [ -f "$REPO_DIR/.env" ]; then
CAT_MODE_AUTO_INVOKE="$(grep -m1 '^CAT_MODE_AUTO_INVOKE=' "$REPO_DIR/.env" | cut -d= -f2-)"
fi
CAT_MODE_AUTO_INVOKE="${CAT_MODE_AUTO_INVOKE:-false}"

FORCE=0
ENGINE_ONLY=0
WITH_SESSION_MINE=0
Expand All @@ -63,6 +58,20 @@ for arg in "$@"; do
esac
done

CAT_MODE_DEFAULT="$(python3 "$REPO_DIR/engine/hooks/_flags/flags.py" CATSTACK_CAT_MODE_DEFAULT --value --cwd "$REPO_DIR")"
case "$CAT_MODE_DEFAULT" in
1|true|yes|on) CAT_MODE_DEFAULT=on ;;
""|0|false|no|off) CAT_MODE_DEFAULT=off ;;
decide) ;;
*)
echo "install.sh: WARNING — CATSTACK_CAT_MODE_DEFAULT=$CAT_MODE_DEFAULT is not off, decide, or on; treating it as off."
CAT_MODE_DEFAULT=off
;;
esac
if [ -n "${CAT_MODE_AUTO_INVOKE:-}" ] || { [ -f "$REPO_DIR/.env" ] && grep -q '^CAT_MODE_AUTO_INVOKE=' "$REPO_DIR/.env"; }; then
echo "install.sh: WARNING — CAT_MODE_AUTO_INVOKE is retired and ignored. Use CATSTACK_CAT_MODE_DEFAULT=decide instead."
fi

# Skills written against one agent's specific mechanics (a tool name, a
# transcript path convention) that would be actively wrong to install
# elsewhere verbatim. Everything not listed here is agent-agnostic prose and
Expand Down Expand Up @@ -118,7 +127,11 @@ link_cat_mode() {
local skill_root="$1" skills_dir="$2"
local src="$skill_root/cat-mode" target="$skills_dir/cat-mode"

if [ "$CAT_MODE_AUTO_INVOKE" != "true" ]; then
if [ "$CAT_MODE_DEFAULT" != "decide" ]; then
if [ -f "$target/.catstack-generated" ] && [ ! -L "$target" ]; then
echo "remove cat-mode (generated decide copy; CATSTACK_CAT_MODE_DEFAULT=$CAT_MODE_DEFAULT)"
rm -rf "$target"
fi
link_item "cat-mode" "$src" "$target"
return
fi
Expand Down Expand Up @@ -147,7 +160,7 @@ link_cat_mode() {
link_item "cat-mode/$name" "$entry" "$target/$name"
fi
done
echo "local cat-mode (CAT_MODE_AUTO_INVOKE=true — SKILL.md materialized with disable-model-invocation:false, rest still symlinked)"
echo "local cat-mode (CATSTACK_CAT_MODE_DEFAULT=decide — SKILL.md materialized with disable-model-invocation:false, rest still symlinked)"
}

# Skills live under engine/skills, corpus/skills, and product/skills.
Expand Down
6 changes: 3 additions & 3 deletions scripts/repro/repro-cat-mode-all-harnesses.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@ for agent_dir in .claude .cursor .codex; do
fi
done
if [ "$installed" != 3 ]; then
echo "[FAIL] before change: CAT_MODE_AUTO_INVOKE=true did not install cat-mode for all three harnesses"
echo "[FAIL] before change: CATSTACK_CAT_MODE_DEFAULT=decide did not install cat-mode for all three harnesses"
else
echo "[PASS] before change: baseline installed cat-mode for all three harnesses"
exit 1
fi

HOME="$after_home" CAT_MODE_AUTO_INVOKE=true bash "$repo_dir/install.sh" >/dev/null
HOME="$after_home" CATSTACK_CAT_MODE_DEFAULT=decide bash "$repo_dir/install.sh" >/dev/null
for agent_dir in .claude .cursor .codex; do
target="$after_home/$agent_dir/skills/cat-mode"
test -d "$target"
test ! -L "$target"
grep -q '^disable-model-invocation: false$' "$target/SKILL.md"
done
echo "[PASS] after change: CAT_MODE_AUTO_INVOKE=true installed auto-invoking cat-mode for Claude, Cursor, and Codex"
echo "[PASS] after change: CATSTACK_CAT_MODE_DEFAULT=decide installed auto-invoking cat-mode for Claude, Cursor, and Codex"
46 changes: 40 additions & 6 deletions tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,7 @@ def test_claude_md_switches_between_engine_and_full_targets(self):
target = os.path.join(self.fake_home, ".claude", "CLAUDE.md")
self.assertEqual(os.readlink(target), os.path.join(REPO_ROOT, "engine", "CLAUDE.core.md"))

result = run_install(self.fake_home, extra_env={"CAT_MODE_AUTO_INVOKE": "false"})
result = run_install(self.fake_home, extra_env={"CATSTACK_CAT_MODE_DEFAULT": "off"})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(os.readlink(target), os.path.join(REPO_ROOT, "CLAUDE.md"))

Expand Down Expand Up @@ -1243,7 +1243,7 @@ def frontmatter_disable_model_invocation(skill_md_path):
return match.group(1) if match else None


class TestCatModeAutoInvokeOverride(unittest.TestCase):
class TestCatModeDefaultInstall(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.fake_home = self.tmp.name
Expand All @@ -1263,7 +1263,7 @@ def test_default_is_a_plain_symlink_with_the_committed_flag(self):
)

def test_override_materializes_skill_md_but_keeps_other_files_symlinked(self):
result = run_install(self.fake_home, extra_env={"CAT_MODE_AUTO_INVOKE": "true"})
result = run_install(self.fake_home, extra_env={"CATSTACK_CAT_MODE_DEFAULT": "decide"})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(os.path.islink(self.cat_mode_target))
self.assertTrue(os.path.isdir(self.cat_mode_target))
Expand All @@ -1283,18 +1283,52 @@ def test_override_materializes_skill_md_but_keeps_other_files_symlinked(self):
self.assertTrue(os.path.islink(linked), f"{name} should still be a live symlink")

def test_override_materializes_cat_mode_for_all_three_harnesses(self):
result = run_install(self.fake_home, extra_env={"CAT_MODE_AUTO_INVOKE": "true"})
result = run_install(self.fake_home, extra_env={"CATSTACK_CAT_MODE_DEFAULT": "decide"})
self.assertEqual(result.returncode, 0, result.stderr)
for agent_dir in (".claude", ".cursor", ".codex"):
target = os.path.join(self.fake_home, agent_dir, "skills", "cat-mode")
self.assertTrue(os.path.isdir(target), target)
self.assertFalse(os.path.islink(target), target)
self.assertEqual(frontmatter_disable_model_invocation(os.path.join(target, "SKILL.md")), "false")

def test_on_keeps_the_plain_symlink(self):
result = run_install(self.fake_home, extra_env={"CATSTACK_CAT_MODE_DEFAULT": "on"})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(os.path.islink(self.cat_mode_target))

def test_decide_is_read_from_home_env_file(self):
with open(os.path.join(self.fake_home, ".catstack.env"), "w", encoding="utf-8") as handle:
handle.write("CATSTACK_CAT_MODE_DEFAULT=decide\n")
result = run_install(self.fake_home)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(os.path.islink(self.cat_mode_target))
self.assertEqual(
frontmatter_disable_model_invocation(os.path.join(self.cat_mode_target, "SKILL.md")),
"false",
)

def test_switching_back_to_on_restores_the_symlink(self):
run_install(self.fake_home, extra_env={"CATSTACK_CAT_MODE_DEFAULT": "decide"})
result = run_install(self.fake_home, extra_env={"CATSTACK_CAT_MODE_DEFAULT": "on"})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(os.path.islink(self.cat_mode_target), result.stdout)

def test_retired_auto_invoke_is_named_and_ignored(self):
result = run_install(self.fake_home, extra_env={"CAT_MODE_AUTO_INVOKE": "true"})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("CAT_MODE_AUTO_INVOKE is retired", result.stdout + result.stderr)
self.assertTrue(os.path.islink(self.cat_mode_target))

def test_unknown_value_is_named_and_treated_as_off(self):
result = run_install(self.fake_home, extra_env={"CATSTACK_CAT_MODE_DEFAULT": "maybe"})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("CATSTACK_CAT_MODE_DEFAULT=maybe", result.stdout + result.stderr)
self.assertTrue(os.path.islink(self.cat_mode_target))

def test_rerun_with_override_stays_idempotent(self):
first = run_install(self.fake_home, extra_env={"CAT_MODE_AUTO_INVOKE": "true"})
first = run_install(self.fake_home, extra_env={"CATSTACK_CAT_MODE_DEFAULT": "decide"})
self.assertEqual(first.returncode, 0, first.stderr)
second = run_install(self.fake_home, extra_env={"CAT_MODE_AUTO_INVOKE": "true"})
second = run_install(self.fake_home, extra_env={"CATSTACK_CAT_MODE_DEFAULT": "decide"})
self.assertEqual(second.returncode, 0, second.stderr)
self.assertEqual(
frontmatter_disable_model_invocation(os.path.join(self.cat_mode_target, "SKILL.md")),
Expand Down
Loading