From 5ed9db7871d663e9205c78d7ab0ad9f1f0c95039 Mon Sep 17 00:00:00 2001 From: "B.D. Murphy" <161863146+bdmurphy73@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:09:52 -0700 Subject: [PATCH] Add weekly launchd commit reminder (com.umbraaudio.commitcheck) Wires the commit-cadence policy to an actual reminder instead of a script nobody runs. - scripts/commit_check_notify.py: runs scripts/commit_check.sh (piped to bash -s so the script is never opened on the external volume), logs to logs/commit_check.log and posts a macOS notification via osascript when the tree is dirty. Defaults to --any (week's work) ; --age-only restores the >threshold behaviour; --no-notify for testing. - scripts/commit_check.sh: new --any mode (warn on any uncommitted change, ignoring age) for the weekly job. - LaunchAgent fires Sunday 18:00; launcher ~/.umbraaudio/umbra_commit_check.sh mirrors umbraweb_login.sh (cd project, export PATH, exec .venv/bin/python). The venv python is the only interpreter approved for the external volume, so a shell never reads a project file. - Verified under launchd: detects dirty tree, notification sent, exit 1; clean tree exits 0. Probe artifacts removed. --- AGENTS.md | 14 ++- README.md | 4 +- scripts/commit_check.sh | 13 ++- scripts/commit_check_notify.py | 155 +++++++++++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 scripts/commit_check_notify.py diff --git a/AGENTS.md b/AGENTS.md index ce35786..ad4eab8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,18 @@ for days. - Mechanical nudge: **`scripts/commit_check.sh`** exits non-zero (1) when the tree has been dirty longer than a week — it measures the *oldest* uncommitted edit, ignores submodule - working-tree dirt, and prints a reminders block. Options: `--days N`, `--quiet`. Run it by - hand (`scripts/commit_check.sh`) or wire it to launchd/cron for a weekly reminder. + working-tree dirt, and prints a reminder block. Options: `--days N`, `--any` (warn on any + dirt, ignoring age), `--quiet`, `--help`. Run it by hand or via the reminder below. +- Weekly reminder (installed): LaunchAgent **`com.umbraaudio.commitcheck`** fires **Sunday + 18:00** and posts a macOS notification when the tree is dirty. Plist loaded from + `/Users/worstauthor/Library/LaunchAgents/com.umbraaudio.commitcheck.plist` and mirrored to + `/Volumes/Dock/worstauthor/Library/LaunchAgents/` (login-scanned). It runs + `/bin/zsh /Users/worstauthor/.umbraaudio/umbra_commit_check.sh` → `cd` project → + `exec .venv/bin/python scripts/commit_check_notify.py`. As with the web server, **a shell + never opens an external-volume file**: the venv python reads the repo, pipes + `scripts/commit_check.sh` to `bash -s` (so the script itself is never opened on the volume), + and shells `osascript` for the notification. Logs to `logs/commit_check.log`; exit 1 = dirty + (deliberate). Test with `launchctl kickstart -k gui/$(id -u)/com.umbraaudio.commitcheck`. ## System & Deployment Notes - **macOS 26.6.2, home relocated**: user `worstauthor`'s home dir is diff --git a/README.md b/README.md index 0285994..1651da7 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,13 @@ pytest tests/ -v > month of changes to `umbraaudio/web/routes.py`. See `AGENTS.md` → *Commit Cadence*. `scripts/commit_check.sh` enforces the nudge: it exits non-zero once the tree has been dirty -longer than a week. +longer than a week. The LaunchAgent `com.umbraaudio.commitcheck` runs it every **Sunday at +18:00** and posts a macOS notification when there is uncommitted work. ```bash scripts/commit_check.sh # warn if dirty > 7 days scripts/commit_check.sh --days 3 # tighter threshold +scripts/commit_check.sh --any # warn on any uncommitted change ``` ## Rebuilding from source diff --git a/scripts/commit_check.sh b/scripts/commit_check.sh index 3d67216..c0dc224 100755 --- a/scripts/commit_check.sh +++ b/scripts/commit_check.sh @@ -12,7 +12,7 @@ # by hand, or wire it to launchd/cron for a weekly reminder. # # Usage: -# scripts/commit_check.sh [--days N] [--quiet] +# scripts/commit_check.sh [--days N] [--any] [--quiet] # # Exit codes: # 0 clean, or uncommitted work is within the threshold @@ -22,16 +22,19 @@ set -u DAYS=7 QUIET=0 +ANY=0 usage() { cat <<'USAGE' commit_check.sh — warn when uncommitted work is older than the threshold. Usage: - scripts/commit_check.sh [--days N] [--quiet] + scripts/commit_check.sh [--days N] [--any] [--quiet] Options: --days N Threshold in days (default: 7). + --any Warn on ANY uncommitted change, ignoring the age threshold + (used by the weekly Sunday reminder job). --quiet, -q Print nothing when the tree is clean. -h, --help Show this help. @@ -51,6 +54,7 @@ while [ "$#" -gt 0 ]; do if [ "$#" -ge 2 ]; then shift 2; else shift; fi ;; --days=*) DAYS="${1#*=}"; shift ;; + --any) ANY=1; shift ;; --quiet|-q) QUIET=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "commit_check.sh: unknown argument: $1" >&2; usage >&2; exit 2 ;; @@ -126,7 +130,7 @@ else NEWEST_DAYS="" fi -if [ "$AGE_DAYS" -le "$DAYS" ]; then +if [ "$ANY" -eq 0 ] && [ "$AGE_DAYS" -le "$DAYS" ]; then if [ "$QUIET" -eq 0 ]; then echo "commit_check: $CHANGED uncommitted path(s); dirty ${AGE_DAYS}d (threshold ${DAYS}d) - OK." fi @@ -136,6 +140,9 @@ fi { echo "commit_check: COMMIT REMINDER" echo " Working tree: $CHANGED uncommitted path(s)" + if [ "$ANY" -eq 1 ]; then + echo " Trigger: any uncommitted change (--any)" + fi echo " Dirty for: ${AGE_DAYS} day(s) ($AGE_SOURCE)" if [ -n "$NEWEST_DAYS" ]; then echo " Last edit: ${NEWEST_DAYS} day(s) ago" diff --git a/scripts/commit_check_notify.py b/scripts/commit_check_notify.py new file mode 100644 index 0000000..3084ef5 --- /dev/null +++ b/scripts/commit_check_notify.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Weekly commit reminder for the UmbraAudio working tree. + +Invoked by ``~/.umbraaudio/umbra_commit_check.sh`` from the launchd job +``com.umbraaudio.commitcheck``. Under macOS's external-volume TCC policy only +the project's venv python may read ``/Volumes/1TB_APL_Storage``, so this file +(and ``commit_check.sh``, which it pipes to bash) is opened by python, never by +a shell. + +The actual test lives in ``scripts/commit_check.sh`` — this module only +schedules it, records the outcome in ``logs/commit_check.log`` and raises a +macOS notification when the tree is dirty. +""" + +from __future__ import annotations + +import argparse +import logging +import os +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +CHECK_SCRIPT = REPO_ROOT / "scripts" / "commit_check.sh" +LOG_PATH = REPO_ROOT / "logs" / "commit_check.log" +SAFE_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + +LOGGER = logging.getLogger("umbraaudio.commit_check") + + +def _configure_logging() -> None: + """Log to stderr and to logs/commit_check.log (best effort).""" + LOGGER.setLevel(logging.INFO) + formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s") + + stream = logging.StreamHandler(sys.stdout) + stream.setFormatter(formatter) + LOGGER.addHandler(stream) + + try: + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + file_handler = logging.FileHandler(LOG_PATH) + file_handler.setFormatter(formatter) + LOGGER.addHandler(file_handler) + except OSError: # pragma: no cover - logging must never break the reminder + LOGGER.warning("cannot write %s", LOG_PATH, exc_info=True) + + +def _applescript_string(value: str) -> str: + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def send_notification(title: str, subtitle: str, message: str) -> bool: + """Post a macOS notification; returns False when osascript is unavailable.""" + parts = [ + f"display notification {_applescript_string(message)}", + f"with title {_applescript_string(title)}", + ] + if subtitle: + parts.append(f"subtitle {_applescript_string(subtitle)}") + try: + result = subprocess.run( + ["/usr/bin/osascript", "-e", " ".join(parts)], + capture_output=True, + timeout=20, + ) + except (OSError, subprocess.TimeoutExpired): + LOGGER.warning("could not run osascript", exc_info=True) + return False + if result.returncode != 0: + LOGGER.warning( + "osascript exited %s: %s", + result.returncode, + result.stderr.decode(errors="replace").strip(), + ) + return False + return True + + +def run_check(days: int, any_dirty: bool) -> subprocess.CompletedProcess[bytes]: + """Run scripts/commit_check.sh via ``bash -s`` (stdin avoids the volume).""" + args = ["/bin/bash", "-s", "--"] + if any_dirty: + args.append("--any") + args += ["--days", str(days)] + env = dict(os.environ) + env["PATH"] = SAFE_PATH + return subprocess.run( + args, + input=CHECK_SCRIPT.read_bytes(), + cwd=str(REPO_ROOT), + capture_output=True, + env=env, + ) + + +def main(argv: list[str] | None = None) -> int: + """Run the reminder; returns the check's exit code (0 clean, 1 dirty, 2 error).""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--days", type=int, default=7, help="age threshold in days") + parser.add_argument( + "--age-only", + action="store_true", + help="only remind when the tree has been dirty longer than --days", + ) + parser.add_argument( + "--no-notify", + action="store_true", + help="log the result but do not post a notification", + ) + args = parser.parse_args(argv) + + any_dirty = not args.age_only + + _configure_logging() + LOGGER.info( + "commit check starting (repo=%s, days=%d, any=%s)", + REPO_ROOT, + args.days, + any_dirty, + ) + + try: + result = run_check(args.days, any_dirty) + except OSError: + LOGGER.exception("could not execute scripts/commit_check.sh") + return 2 + + output = result.stderr.decode(errors="replace").strip() + if result.returncode == 0: + LOGGER.info("clean / within threshold") + return 0 + if result.returncode == 2: + LOGGER.error("commit_check.sh failed (rc=2): %s", output) + return 2 + + LOGGER.warning("uncommitted work detected:\n%s", output) + if not args.no_notify: + dirty_line = next( + (line.strip() for line in output.splitlines() + if line.strip().lower().startswith("dirty for")), + "", + ) + sent = send_notification( + title="UmbraAudio: commit reminder", + subtitle="Uncommitted work", + message=dirty_line or "The working tree is dirty - commit your work.", + ) + LOGGER.info("notification %s", "sent" if sent else "not sent") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())