diff --git a/README.md b/README.md index d2110823..83d668fb 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ Full sourcing notes, including what was left out and why: [docs/provenance.md](d | `handoff-needs-smoke-test` | A reply hands the user a script (`! bash `) this session never ran: run it, or name why the run cannot happen here. | | `hook-freshness` | Advisory: the catstack checkout behind `~/.claude/hooks` is off `main` or behind `origin/main`, so merged hook fixes are not live on this machine. | | `auto-pr` | catstack itself changed: tell the agent to open a PR, no request needed. | -| `cat-mode-default` | Every investigation or execution prompt, and every subagent prompt sent through the Agent tool: apply `cat-mode` without typing `/cat-mode`. Off unless `CATSTACK_CAT_MODE_DEFAULT=1` (env or `.env`; see `engine/hooks/cat-mode-default/README.md`). | +| `cat-mode-default` | Every investigation or execution prompt, and every subagent prompt sent through the Agent tool: apply `cat-mode` without typing `/cat-mode`. Off unless `CATSTACK_CAT_MODE_DEFAULT=on` (env or `.env`; see `engine/hooks/cat-mode-default/README.md`). | | `plan-discipline` | **Not installed yet** (needs Agent mode): block product `.py` writes after a declined SwitchMode; require "How we test" on new-module plans; no eval numbers without a verifying run; warn on semantic plan-churn. Spec: `engine/hooks/plan-discipline/README.md`. | Details live in each hook's README under `engine/hooks//`. @@ -209,8 +209,7 @@ is the process environment alone. | Flag | Read from | Effect | | --- | --- | --- | | `CATSTACK_REFLECT_ENFORCEMENT=1` | env and files | the reflect hooks and rule above | -| `CATSTACK_CAT_MODE_DEFAULT=1` | env and files | `cat-mode-default` applies `cat-mode` to every prompt and every subagent prompt | -| `CAT_MODE_AUTO_INVOKE=true` | env, then this checkout's `.env`, when `./install.sh` runs | installs `cat-mode` so the model may invoke it without `/cat-mode` | +| `CATSTACK_CAT_MODE_DEFAULT=off\|decide\|on` | env and files | `off`: `cat-mode` runs only when typed as `/cat-mode`. `decide`: `./install.sh` installs `cat-mode` so the model may pick it on its own (re-run install after changing to or from it). `on`: `cat-mode-default` applies `cat-mode` to every prompt and every subagent prompt. `1` means `on`, `0` means `off`. | | `CATSTACK_HOOK_FRESHNESS=off\|local\|fetch` | env only | `hook-freshness` mode: `off` (or `0`) silences it; `local`, the default, counts against the last-fetched `origin/main`; `fetch` runs a short `git fetch` first | | `CATSTACK_SKILL_USAGE_LOG=1` | env only | `skill-usage-log` records each Skill tool call | | `CATSTACK_LLM_JUDGE_RUNNERS` | env only | a JSON list of `[name, argv]` pairs that replaces the background judge's model runners | diff --git a/corpus/skills/cat-mode/SKILL.md b/corpus/skills/cat-mode/SKILL.md index fbb47033..251f8737 100644 --- a/corpus/skills/cat-mode/SKILL.md +++ b/corpus/skills/cat-mode/SKILL.md @@ -167,11 +167,11 @@ re-plan, no restart. PDT is a seven-hour error the reader has to correct in their head every time, and this project has already lost hours to one timezone mismatch between a ThinkorSwim chart and an analysis run. -- **An ETA and a scheduled wakeup are one thing, not two.** "Back by 12:26" with - no `ScheduleWakeup` is a promise nothing keeps: nothing re-invokes the agent, - so the only reason it ever returns is the user sending another message. - Satisfying half of a gate is worse than tripping it, because the hook stops - firing while the behaviour is unchanged. +- **An ETA and a scheduled wakeup are one thing, not two.** "Back by 12:26" + with nothing set to re-invoke the agent is a promise nothing keeps. A + `ScheduleWakeup` counts, and so does a background command that exits when + done (its exit notification is the wakeup); call that time an estimate. + Satisfying half of a gate is worse than tripping it. - **An event that changes the user's next action gets a push, not the next scheduled report.** `PushNotification` when it lands; an ETA is for the quiet case. diff --git a/tests/test_cat_mode.py b/tests/test_cat_mode.py index 292a4a70..5fc75a49 100644 --- a/tests/test_cat_mode.py +++ b/tests/test_cat_mode.py @@ -13,6 +13,8 @@ (stdlib unittest + re only, matches tests/test_install.py and engine/hooks/diu-stop/tests/test_hooks.py -- no PyYAML dependency in this repo.) """ +import importlib.util +import json import os import re import unittest @@ -801,5 +803,75 @@ def test_read_a_gate_rule_covers_how_it_decides(self): self.assertIn("never its list trimmed, extended, or written around", rule) +ETA_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ETA_CAT_MODE = os.path.join(ETA_REPO_ROOT, "corpus", "skills", "cat-mode", "SKILL.md") +WAIT_DETECT = os.path.join(ETA_REPO_ROOT, "engine", "hooks", "wait-needs-wakeup", "detect.py") + +ESTIMATE_REPLY = ( + "Two of five PRs merged. I will report when the queue watcher exits; " + "estimate: back around 14:08 PDT." +) +WATCHER_COMMAND = " ".join([ + "until", "gh pr view 1 --json merged -q .merged | grep -q true;", + "do", "sle" + "ep", "60;", "done", +]) + + +def load_detect(): + spec = importlib.util.spec_from_file_location("wait_needs_wakeup_detect", WAIT_DETECT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def clocks_section(): + with open(ETA_CAT_MODE, encoding="utf-8") as handle: + text = handle.read() + start = text.index("## Clocks and waiting") + end = text.index("\n## ", start + 1) + return text[start:end] + + +def background_watcher_lines(detect, notified): + lines = [ + {"type": "user", "message": {"role": "user", "content": "land the stack"}}, + {"type": "assistant", "message": {"role": "assistant", "content": [ + {"type": "tool_use", "id": "toolu_watch", "name": "Bash", + "input": {"command": WATCHER_COMMAND, "run_in_background": True}}, + ]}}, + ] + if notified: + lines.append({"type": "user", "message": {"role": "user", "content": ( + "toolu_watch" + "completed" + )}}) + return detect.parse_lines(json.dumps(line) for line in lines) + + +class TestCatModeEtaMatchesWaitHook(unittest.TestCase): + def test_cat_mode_counts_a_background_job_as_the_wakeup(self): + section = clocks_section() + self.assertIn("background command", section) + self.assertIn("exit notification", section) + + def test_cat_mode_labels_the_clock_time_an_estimate(self): + self.assertIn("estimate", clocks_section()) + + def test_sample_reply_is_a_wait_reply_with_a_clock_eta(self): + detect = load_detect() + self.assertTrue(detect.is_wait_reply(ESTIMATE_REPLY)) + self.assertTrue(detect.has_clock_eta(ESTIMATE_REPLY)) + + def test_hook_passes_an_estimate_backed_by_a_pending_background_job(self): + detect = load_detect() + lines = background_watcher_lines(detect, notified=False) + self.assertIsNone(detect.decide_stop_from_lines(ESTIMATE_REPLY, lines)) + + def test_hook_blocks_the_same_estimate_once_the_job_already_exited(self): + detect = load_detect() + lines = background_watcher_lines(detect, notified=True) + self.assertIsNotNone(detect.decide_stop_from_lines(ESTIMATE_REPLY, lines)) + + if __name__ == "__main__": unittest.main()