Skip to content
Closed
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
15 changes: 10 additions & 5 deletions engine/hooks/hook-freshness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. |
38 changes: 34 additions & 4 deletions engine/hooks/hook-freshness/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 = (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 44 additions & 1 deletion engine/hooks/hook-freshness/tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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):
Expand Down
Loading
Loading