From ea24fed4f13dccda1f3f9cc303fe099d6f0d3cdc Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Wed, 16 Sep 2026 18:20:17 -0700 Subject: [PATCH 1/2] Merge the two hook-freshness flags into one CATSTACK_HOOK_FRESHNESS now takes off, local, or fetch. local is the default and never touches the network; fetch replaces CATSTACK_HOOK_FRESHNESS_FETCH=1; off (and the old 0) silences the hook. The retired name and unknown values are named in the advisory instead of being dropped silently. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I960a699d2a6b4cef1b620fcac3e1f1e349d52af5 --- engine/hooks/hook-freshness/README.md | 15 ++++--- engine/hooks/hook-freshness/detect.py | 38 ++++++++++++++-- .../hooks/hook-freshness/tests/test_hooks.py | 45 ++++++++++++++++++- 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/engine/hooks/hook-freshness/README.md b/engine/hooks/hook-freshness/README.md index 8a2d4cb7..ba025dc7 100644 --- a/engine/hooks/hook-freshness/README.md +++ b/engine/hooks/hook-freshness/README.md @@ -11,9 +11,10 @@ with `CATSTACK_HOOKS_REPO`), reads `git branch --show-current` and turn's context when the checkout is off `main` or behind it. Once per session, keyed by transcript path. -Advisory only — never blocks. No network by default; set -`CATSTACK_HOOK_FRESHNESS_FETCH=1` to allow a 3-second `git fetch` first, so -the count is not itself stale. `CATSTACK_HOOK_FRESHNESS=0` silences it. +Advisory only — never blocks. `CATSTACK_HOOK_FRESHNESS` picks the mode: +`local` (the default) compares against the last `origin/main` you fetched, +`fetch` first runs a 3-second `git fetch` so the count is not itself stale, +and `off` silences it. Fails open on every error: no symlink, no git, a detached HEAD, a timeout. ## Files @@ -28,6 +29,10 @@ Fails open on every error: no symlink, no git, a detached HEAD, a timeout. | Var | Effect | |-----|--------| | `CATSTACK_HOOKS_REPO` | Use this checkout instead of resolving the symlink. | -| `CATSTACK_HOOK_FRESHNESS_FETCH=1` | Allow a short `git fetch origin main` first. | -| `CATSTACK_HOOK_FRESHNESS=0` | Silence the advisory. | +| `CATSTACK_HOOK_FRESHNESS=local` | Default. Count against the local `origin/main`, no network. | +| `CATSTACK_HOOK_FRESHNESS=fetch` | Run a short `git fetch origin main` first. | +| `CATSTACK_HOOK_FRESHNESS=off` | Silence the advisory (`0` also works). | + +`CATSTACK_HOOK_FRESHNESS_FETCH` is retired: the hook ignores it and says so +in its advisory. An unknown value falls back to `local` with a note. | `HOOK_FRESHNESS_STATE_DIR` | Once-per-session marker directory. | diff --git a/engine/hooks/hook-freshness/detect.py b/engine/hooks/hook-freshness/detect.py index 1b120b4d..f1a99513 100644 --- a/engine/hooks/hook-freshness/detect.py +++ b/engine/hooks/hook-freshness/detect.py @@ -7,7 +7,7 @@ from `origin/main`, and returns one advisory line for the turn. Advisory only: no block, no LLM, no network unless -CATSTACK_HOOK_FRESHNESS_FETCH=1. Fails open on every error. +CATSTACK_HOOK_FRESHNESS=fetch. Fails open on every error. """ from __future__ import annotations @@ -24,6 +24,11 @@ ANCHOR_LINK = os.path.join(os.path.expanduser("~"), ".claude", "hooks", "diu-stop") TRUNK = "origin/main" FETCH_TIMEOUT_SECS = 3 + +MODE_FLAG = "CATSTACK_HOOK_FRESHNESS" +RETIRED_FETCH_FLAG = "CATSTACK_HOOK_FRESHNESS_FETCH" +OFF_VALUES = frozenset({"off", "0", "false", "no"}) +LOCAL_VALUES = frozenset({"", "local", "1", "true", "yes", "on"}) GIT_TIMEOUT_SECS = 5 MESSAGE = ( @@ -62,11 +67,35 @@ def resolve_repo(env=None, realpath=os.path.realpath, isdir=os.path.isdir): return repo if isdir(os.path.join(repo, ".git")) else None +def freshness_mode(env): + """(mode, note). mode is off, local, or fetch; note names a value this + hook could not use, or None.""" + raw = env.get(MODE_FLAG, "").strip().lower() + notes = [] + if RETIRED_FETCH_FLAG in env: + notes.append( + f"hook-freshness: {RETIRED_FETCH_FLAG} is retired and ignored; " + f"set {MODE_FLAG}=fetch instead." + ) + if raw in OFF_VALUES: + mode = "off" + elif raw == "fetch": + mode = "fetch" + elif raw in LOCAL_VALUES: + mode = "local" + else: + mode = "local" + notes.append( + f"hook-freshness: {MODE_FLAG}={env.get(MODE_FLAG)} is not off, local, or fetch; using local." + ) + return mode, "\n".join(notes) or None + + def repo_state(repo, env=None, run=_run_git): """(branch, commits behind trunk) for the checkout, or (None, None).""" env = env if env is not None else os.environ try: - if env.get("CATSTACK_HOOK_FRESHNESS_FETCH") == "1": + if freshness_mode(env)[0] == "fetch": run(["fetch", "--quiet", "origin", "main"], repo, FETCH_TIMEOUT_SECS) branch = run(["branch", "--show-current"], repo) behind_raw = run(["rev-list", "--count", f"HEAD..{TRUNK}"], repo) @@ -216,13 +245,14 @@ def decide( ): """Advisory context for this prompt, or None. Once per session.""" env = env if env is not None else os.environ - if env.get("CATSTACK_HOOK_FRESHNESS") == "0": + mode, mode_note = freshness_mode(env) + if mode == "off": return None key = payload.get("transcript_path") or payload.get("transcriptPath") or "" if state and already_advised(key): return None missing, unreadable = unresolvable_hooks(settings_path=settings_path, load=load, exists=exists) - lines = [ln for ln in [unresolvable_advisory(missing, unreadable)] if ln] + lines = [ln for ln in [mode_note, unresolvable_advisory(missing, unreadable)] if ln] repo = resolve_repo(env=env) if repo: branch, behind = repo_state(repo, env=env, run=run) diff --git a/engine/hooks/hook-freshness/tests/test_hooks.py b/engine/hooks/hook-freshness/tests/test_hooks.py index a77d3492..bb294e40 100644 --- a/engine/hooks/hook-freshness/tests/test_hooks.py +++ b/engine/hooks/hook-freshness/tests/test_hooks.py @@ -133,6 +133,41 @@ def test_missing_settings_file_reports_unchecked_through_decide(self): def test_no_hit_when_disabled_by_env(self): self.assertIsNone(detect.decide({}, env={"CATSTACK_HOOK_FRESHNESS": "0"})) + def test_no_hit_when_set_to_off(self): + settings = {"hooks": {"Stop": [{"hooks": [{"command": "python3 /nope/missing.py"}]}]}} + + def run(value): + env = {"CATSTACK_HOOKS_REPO": "/nope/not/a/repo"} + if value is not None: + env["CATSTACK_HOOK_FRESHNESS"] = value + return detect.decide({}, env=env, state=False, load=lambda _p: settings) + + self.assertIsNotNone(run(None)) + self.assertIsNone(run("off")) + + def test_mode_values(self): + self.assertEqual(detect.freshness_mode({}), ("local", None)) + self.assertEqual(detect.freshness_mode({"CATSTACK_HOOK_FRESHNESS": " Fetch "}), ("fetch", None)) + self.assertEqual(detect.freshness_mode({"CATSTACK_HOOK_FRESHNESS": "local"}), ("local", None)) + self.assertEqual(detect.freshness_mode({"CATSTACK_HOOK_FRESHNESS": "false"})[0], "off") + mode, note = detect.freshness_mode({"CATSTACK_HOOK_FRESHNESS": "sometimes"}) + self.assertEqual(mode, "local") + self.assertIn("CATSTACK_HOOK_FRESHNESS=sometimes", note) + + def test_retired_fetch_flag_is_named_and_ignored(self): + mode, note = detect.freshness_mode({"CATSTACK_HOOK_FRESHNESS_FETCH": "1"}) + self.assertEqual(mode, "local") + self.assertIn("CATSTACK_HOOK_FRESHNESS_FETCH is retired", note) + with tempfile.TemporaryDirectory() as tmp: + line = detect.decide( + {}, + env={"CATSTACK_HOOKS_REPO": "/nope/not/a/repo", "CATSTACK_HOOK_FRESHNESS_FETCH": "1"}, + state=False, + settings_path=os.path.join(tmp, "settings.json"), + load=lambda _p: {"hooks": {}}, + ) + self.assertIn("CATSTACK_HOOK_FRESHNESS_FETCH is retired", line) + def test_fails_open_when_git_errors(self): with tempfile.TemporaryDirectory() as tmp: repo = os.path.join(tmp, "catstack") @@ -148,12 +183,20 @@ def test_no_fetch_unless_opted_in(self): detect.repo_state(repo, env={}, run=fake_git(record=calls)) self.assertNotIn("fetch", [c[0] for c in calls]) - def test_fetch_when_opted_in(self): + def test_retired_fetch_flag_does_not_fetch(self): calls = [] with tempfile.TemporaryDirectory() as tmp: repo = os.path.join(tmp, "catstack") os.makedirs(os.path.join(repo, ".git")) detect.repo_state(repo, env={"CATSTACK_HOOK_FRESHNESS_FETCH": "1"}, run=fake_git(record=calls)) + self.assertNotIn("fetch", [c[0] for c in calls]) + + def test_fetch_when_opted_in(self): + calls = [] + with tempfile.TemporaryDirectory() as tmp: + repo = os.path.join(tmp, "catstack") + os.makedirs(os.path.join(repo, ".git")) + detect.repo_state(repo, env={"CATSTACK_HOOK_FRESHNESS": "fetch"}, run=fake_git(record=calls)) self.assertIn("fetch", [c[0] for c in calls]) def test_fails_open_on_garbage_stdin(self): From c571574184991be2901dfee9698fabe9e3b45f81 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Wed, 16 Sep 2026 18:20:13 -0700 Subject: [PATCH 2/2] Give CATSTACK_CAT_MODE_DEFAULT three settings and retire CAT_MODE_AUTO_INVOKE CATSTACK_CAT_MODE_DEFAULT now takes off, decide, or on (1 and 0 still work). decide replaces CAT_MODE_AUTO_INVOKE=true: install.sh reads the flag through the shared flag lookup and materializes the auto-invoking cat-mode copy only for decide, removing a generated copy when switched away. install.sh warns when it still sees CAT_MODE_AUTO_INVOKE, and when the flag holds an unknown value. flags.py gains --value for flags with more than two settings. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Iafccb934fe56639b4366e1bcf3668fef29fa6e5c --- .env.example | 21 ++++----- docs/ecosystem.md | 2 +- engine/hooks/_flags/flags.py | 11 ++++- engine/hooks/_flags/tests/test_cli.py | 13 ++++++ engine/hooks/cat-mode-default/README.md | 22 +++++---- engine/hooks/cat-mode-default/detect.py | 11 +++-- .../tests/fixtures/fires_flag_word_on.json | 7 +++ .../tests/fixtures/silent_flag_decide.json | 7 +++ .../cat-mode-default/tests/test_hooks.py | 2 +- install.sh | 27 ++++++++--- scripts/repro/repro-cat-mode-all-harnesses.sh | 6 +-- tests/test_install.py | 46 ++++++++++++++++--- 12 files changed, 131 insertions(+), 44 deletions(-) create mode 100644 engine/hooks/cat-mode-default/tests/fixtures/fires_flag_word_on.json create mode 100644 engine/hooks/cat-mode-default/tests/fixtures/silent_flag_decide.json diff --git a/.env.example b/.env.example index 707d4e02..0ba807cb 100644 --- a/.env.example +++ b/.env.example @@ -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: diff --git a/docs/ecosystem.md b/docs/ecosystem.md index 48ecc6d5..f4538329 100644 --- a/docs/ecosystem.md +++ b/docs/ecosystem.md @@ -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`) | diff --git a/engine/hooks/_flags/flags.py b/engine/hooks/_flags/flags.py index bb8be426..4de7aef3 100644 --- a/engine/hooks/_flags/flags.py +++ b/engine/hooks/_flags/flags.py @@ -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 diff --git a/engine/hooks/_flags/tests/test_cli.py b/engine/hooks/_flags/tests/test_cli.py index 19a13f8e..f7fc5789 100644 --- a/engine/hooks/_flags/tests/test_cli.py +++ b/engine/hooks/_flags/tests/test_cli.py @@ -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( diff --git a/engine/hooks/cat-mode-default/README.md b/engine/hooks/cat-mode-default/README.md index d0710b15..dc571635 100644 --- a/engine/hooks/cat-mode-default/README.md +++ b/engine/hooks/cat-mode-default/README.md @@ -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: @@ -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 @@ -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. diff --git a/engine/hooks/cat-mode-default/detect.py b/engine/hooks/cat-mode-default/detect.py index e921529f..49fb2d5a 100644 --- a/engine/hooks/cat-mode-default/detect.py +++ b/engine/hooks/cat-mode-default/detect.py @@ -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 @@ -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." ) @@ -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." diff --git a/engine/hooks/cat-mode-default/tests/fixtures/fires_flag_word_on.json b/engine/hooks/cat-mode-default/tests/fixtures/fires_flag_word_on.json new file mode 100644 index 00000000..fa8698d8 --- /dev/null +++ b/engine/hooks/cat-mode-default/tests/fixtures/fires_flag_word_on.json @@ -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?"} +} diff --git a/engine/hooks/cat-mode-default/tests/fixtures/silent_flag_decide.json b/engine/hooks/cat-mode-default/tests/fixtures/silent_flag_decide.json new file mode 100644 index 00000000..07561a12 --- /dev/null +++ b/engine/hooks/cat-mode-default/tests/fixtures/silent_flag_decide.json @@ -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?"} +} diff --git a/engine/hooks/cat-mode-default/tests/test_hooks.py b/engine/hooks/cat-mode-default/tests/test_hooks.py index 97b0ff69..dbd86e5d 100644 --- a/engine/hooks/cat-mode-default/tests/test_hooks.py +++ b/engine/hooks/cat-mode-default/tests/test_hooks.py @@ -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: diff --git a/install.sh b/install.sh index a2f883dd..df239f8e 100755 --- a/install.sh +++ b/install.sh @@ -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 @@ -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 @@ -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 @@ -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. diff --git a/scripts/repro/repro-cat-mode-all-harnesses.sh b/scripts/repro/repro-cat-mode-all-harnesses.sh index 9184d6f6..b7cd1d7f 100755 --- a/scripts/repro/repro-cat-mode-all-harnesses.sh +++ b/scripts/repro/repro-cat-mode-all-harnesses.sh @@ -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" diff --git a/tests/test_install.py b/tests/test_install.py index 179368b3..b771ac0f 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -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")) @@ -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 @@ -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)) @@ -1283,7 +1283,7 @@ 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") @@ -1291,10 +1291,44 @@ def test_override_materializes_cat_mode_for_all_three_harnesses(self): 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")),