diff --git a/README.md b/README.md index 4f5aaa4..1f0d0bb 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ The hook auto-registers in `settings.json` on your first `/tomato start`. Restar Tomato installs a global `PreToolUse` hook in `~/.claude/settings.json`. On every tool call, the hook checks a state file (`~/.tomato/state.json`) and either allows the call (exit 0) or blocks it (exit 2). -The hook is fast: ~8ms on the normal path (read JSON, check timer, allow). Phase transitions (work to rest, rest to work) take ~35ms and happen once every 25 minutes. +The hook is designed to be fast on the normal path (read JSON, check timer, allow). Phase transitions (work → rest, rest → work) happen once every 25 minutes and do a little more work. During rest, all tool calls are blocked — but `/tomato stop`, `/tomato resume`, and `/tomato status` still work. The hook whitelists its own CLI so you're never locked out. diff --git a/skills/tomato/bin/tomato-cli.py b/skills/tomato/bin/tomato-cli.py index 02d4ac0..64abed8 100755 --- a/skills/tomato/bin/tomato-cli.py +++ b/skills/tomato/bin/tomato-cli.py @@ -58,6 +58,15 @@ def load_config() -> Dict[str, Any]: config.update(user) except (json.JSONDecodeError, OSError) as exc: print(f"Warning: could not read config — {exc}", file=sys.stderr) + + for key, default in DEFAULT_CONFIG.items(): + val = config.get(key) + if isinstance(val, bool) or not isinstance(val, int) or val <= 0: + print( + f"Warning: config.json {key}={val!r} is invalid; using default {default}", + file=sys.stderr, + ) + config[key] = default return config diff --git a/skills/tomato/bin/tomato-hook.sh b/skills/tomato/bin/tomato-hook.sh index 141fef2..6208157 100755 --- a/skills/tomato/bin/tomato-hook.sh +++ b/skills/tomato/bin/tomato-hook.sh @@ -275,8 +275,11 @@ if [ "$PHASE" = "rest" ] && [ "$ELAPSED" -lt $((ACTIVE_REST_MINUTES * 60)) ]; th # Grace period: let the active session finish its current task if [ "$GRACE_SESSION_ID" != "null" ] && [ -n "$SESSION_ID" ] && \ [ "$SESSION_ID" = "$GRACE_SESSION_ID" ]; then - # Check hard cap first + # Check hard cap first. The `$NOW -ge $GRACE_*_AT` guards protect against + # backward clock skew: without them, a negative delta would evaluate as + # "within grace window" and bypass rest indefinitely. if [ "$GRACE_STARTED_AT" != "null" ] && \ + [ "$NOW" -ge "$GRACE_STARTED_AT" ] && \ [ $((NOW - GRACE_STARTED_AT)) -ge "$GRACE_MAX_SEC" ]; then # Hard cap reached — clear grace, fall through to block if acquire_lock; then @@ -285,6 +288,7 @@ if [ "$PHASE" = "rest" ] && [ "$ELAPSED" -lt $((ACTIVE_REST_MINUTES * 60)) ]; th release_lock fi elif [ "$GRACE_LAST_CALL_AT" != "null" ] && \ + [ "$NOW" -ge "$GRACE_LAST_CALL_AT" ] && \ [ $((NOW - GRACE_LAST_CALL_AT)) -lt "$GRACE_TIMEOUT_SEC" ]; then # Within grace window — allow and update timestamp if acquire_lock; then diff --git a/tests/test_cli.py b/tests/test_cli.py index 3f569f9..588ae16 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -228,14 +228,16 @@ def test_stats_empty(self, tmp_path: Path) -> None: def test_stats_with_data(self, tmp_path: Path) -> None: """Write history entries with 'timestamp' key -> stats shows correct cycles.""" + # Keep all timestamps within a 5-second window so the test never + # straddles midnight (stats filters by local-time "today"). now = int(time.time()) events = [ - {"event": "work_start", "timestamp": now - 3000, "cycle": 1}, - {"event": "work_end", "timestamp": now - 1500, "cycle": 1, "duration_sec": 1500}, - {"event": "rest_start", "timestamp": now - 1500, "cycle": 1, "rest_minutes": 5}, - {"event": "rest_end", "timestamp": now - 1200, "cycle": 1}, - {"event": "work_start", "timestamp": now - 1200, "cycle": 2}, - {"event": "work_end", "timestamp": now - 100, "cycle": 2, "duration_sec": 1100}, + {"event": "work_start", "timestamp": now - 5, "cycle": 1}, + {"event": "work_end", "timestamp": now - 4, "cycle": 1, "duration_sec": 1500}, + {"event": "rest_start", "timestamp": now - 3, "cycle": 1, "rest_minutes": 5}, + {"event": "rest_end", "timestamp": now - 2, "cycle": 1}, + {"event": "work_start", "timestamp": now - 1, "cycle": 2}, + {"event": "work_end", "timestamp": now, "cycle": 2, "duration_sec": 1100}, ] write_history(tmp_path, events) @@ -342,6 +344,26 @@ def test_no_args_shows_menu(self, tmp_path: Path) -> None: assert "Commands:" in result.stdout +class TestConfigValidation: + def test_invalid_config_falls_back_to_defaults(self, tmp_path: Path) -> None: + """Bad config values (negative, wrong type) fall back to defaults with a warning.""" + config_dir = tmp_path / ".tomato" + config_dir.mkdir() + (config_dir / "config.json").write_text(json.dumps({ + "work_minutes": -5, + "rest_minutes": "not an int", + "grace_max_sec": True, + })) + + result = run_cli("start", home=tmp_path) + assert result.returncode == 0 + assert "invalid" in result.stderr.lower() + + state = read_state(tmp_path) + assert state["work_minutes"] == 25 + assert state["rest_minutes"] == 5 + + # --------------------------------------------------------------------------- # Integration: timestamp key consistency # --------------------------------------------------------------------------- @@ -360,10 +382,11 @@ def test_start_history_uses_timestamp_key(self, tmp_path: Path) -> None: def test_stats_reads_timestamp_key(self, tmp_path: Path) -> None: """Write history with 'timestamp' key -> stats returns correct data (not zero).""" + # Keep timestamps within a small window so the test never straddles midnight. now = int(time.time()) events = [ - {"event": "work_start", "timestamp": now - 1600, "cycle": 1}, - {"event": "work_end", "timestamp": now - 100, "cycle": 1, "duration_sec": 1500}, + {"event": "work_start", "timestamp": now - 2, "cycle": 1}, + {"event": "work_end", "timestamp": now, "cycle": 1, "duration_sec": 1500}, ] write_history(tmp_path, events) diff --git a/uninstall.sh b/uninstall.sh index 118ca5e..06bec13 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -6,6 +6,11 @@ SKILL_DIR="$HOME/.claude/skills/$SKILL_NAME" TOMATO_DIR="$HOME/.tomato" SETTINGS_FILE="$HOME/.claude/settings.json" +PURGE=0 +if [ "${1:-}" = "--purge" ]; then + PURGE=1 +fi + # ---------- Remove hook from settings.json ---------- echo "Removing Tomato hook from Claude Code settings..." @@ -73,8 +78,12 @@ fi # ---------- Remove history ---------- if [ -d "$TOMATO_DIR" ]; then - rm -rf "$TOMATO_DIR" - echo " Removed $TOMATO_DIR/" + if [ "$PURGE" = "1" ]; then + rm -rf "$TOMATO_DIR" + echo " Removed $TOMATO_DIR/ (including focus history)" + else + echo " Preserved $TOMATO_DIR/ (focus history). Re-run with --purge to delete." + fi else echo " $TOMATO_DIR/ not found. Skipping." fi