From 5cb0c3f5649050d93285071b940b9c6216155b85 Mon Sep 17 00:00:00 2001 From: SJ Date: Fri, 21 Aug 2026 14:26:13 -0400 Subject: [PATCH 1/3] Keep tools current and serve Xcode MCP over HTTP Weekly keep-current applies npm pins and in-major Firecrawl bumps. Mini recurring jobs run on LaunchAgent plus Grok, with Codex heartbeats paused. Xcode MCP uses the Mini HTTP singleton through the existing tunnel instead of a fresh SSH stdio spawn. Machine cleanup plans generated junk by kind, not free space. --- .claude/settings.json | 10 +- .mcp.json | 6 +- AGENTS.md | 5 +- DEVELOPER_SETUP.md | 1 + SESSION_HANDOFF.md | 46 +++- scripts/SaneMaster.rb | 18 +- scripts/app_test_mode.sh | 12 +- scripts/appstore_submit.rb | 2 +- scripts/automation/README.md | 5 +- scripts/automation/agent-heartbeat.sh | 109 ++++++++ scripts/automation/agent_heartbeat_test.rb | 37 +++ scripts/automation/agentmemory-mcp-air.sh | 6 + scripts/automation/air_mini_acceptance.rb | 1 + .../automation/air_mini_acceptance_test.rb | 2 +- scripts/automation/dependency_baseline.rb | 245 ++++++++++++++++-- .../automation/dependency_baseline_test.rb | 22 +- .../automation/heartbeats/grok-stack-smoke.md | 1 + ...prophecy-ledger-transcript-batch-resume.md | 11 + ...pps-ga-llc-annual-registration-reminder.md | 1 + .../heartbeats/saneapps-launch-ops.md | 17 ++ .../heartbeats/sanelot-x-opportunity-scout.md | 7 + .../install-air-recurring-agents.sh | 74 ++++++ .../automation/install-recurring-agents.sh | 141 ++++++++++ scripts/automation/memory_sync_test.rb | 6 +- scripts/automation/pause-codex-heartbeats.sh | 49 ++++ scripts/automation/recurring-jobs.md | 71 +++++ scripts/automation/run-app-review-watch.sh | 43 +++ .../automation/run-sanebar-macos27-watch.sh | 38 +++ .../automation/run-sanecite-monday-sweep.sh | 100 +++++++ scripts/automation/run-x-opportunity-scout.sh | 30 +++ scripts/automation/sync-control-plane.sh | 39 +++ scripts/automation/sync-cursor-mini.sh | 117 +++++++++ scripts/automation/sync-grok-mini.sh | 9 +- scripts/automation/x-opportunity-scout.py | 5 + .../automation/x_opportunity_scout_test.py | 22 ++ scripts/grok-bin/README.md | 8 +- scripts/grok-bin/cloudflare-mcp-remote.sh | 47 ++++ scripts/grok-bin/xcode-mcp-frame.py | 110 ++++++++ scripts/grok-bin/xcode-mcp.sh | 53 ++++ scripts/hooks/release_receipt_signer.rb | 6 +- scripts/hooks/run_hook.sh | 4 + scripts/init.sh | 2 +- scripts/mcp_contract_test.rb | 27 +- scripts/mcp_singleton_bridge.cjs | 10 +- scripts/mini/README.md | 4 +- scripts/mini/deploy_test.rb | 1 + scripts/mini/mini-install-memory-guard.sh | 36 ++- scripts/mini/mini-nightly.sh | 40 ++- scripts/mini/mini-visual-workspace-guard.sh | 2 + scripts/mini/mini_memory_guard_test.rb | 6 + scripts/qa_test.rb | 12 + scripts/release.sh | 7 +- scripts/sanemaster/command_registry.rb | 2 + scripts/sanemaster/customer_ui_contract.rb | 3 +- scripts/sanemaster/dependencies.rb | 2 +- scripts/sanemaster/machine_cleanup.rb | 11 +- .../sanemaster/machine_cleanup_artifacts.rb | 84 ++++++ scripts/sanemaster/machine_cleanup_caches.rb | 16 +- .../sanemaster/machine_cleanup_evidence.rb | 6 +- .../machine_cleanup_retention_test.rb | 19 +- scripts/sanemaster/machine_cleanup_test.rb | 69 +++++ scripts/sanemaster/release.rb | 8 +- scripts/sanemaster/release_guardrail_test.rb | 10 +- scripts/sanemaster/structural_compliance.rb | 5 +- .../sanemaster/structural_compliance_test.rb | 12 +- scripts/sanemaster/test_mode.rb | 2 +- scripts/test_registry.json | 9 + templates/NEW_PROJECT_TEMPLATE.md | 20 +- templates/docs/DEVELOPMENT_ENVIRONMENT.md | 12 +- 69 files changed, 1845 insertions(+), 128 deletions(-) create mode 100755 scripts/automation/agent-heartbeat.sh create mode 100644 scripts/automation/agent_heartbeat_test.rb create mode 100644 scripts/automation/heartbeats/grok-stack-smoke.md create mode 100644 scripts/automation/heartbeats/prophecy-ledger-transcript-batch-resume.md create mode 100644 scripts/automation/heartbeats/saneapps-ga-llc-annual-registration-reminder.md create mode 100644 scripts/automation/heartbeats/saneapps-launch-ops.md create mode 100644 scripts/automation/heartbeats/sanelot-x-opportunity-scout.md create mode 100755 scripts/automation/install-air-recurring-agents.sh create mode 100755 scripts/automation/install-recurring-agents.sh create mode 100755 scripts/automation/pause-codex-heartbeats.sh create mode 100644 scripts/automation/recurring-jobs.md create mode 100755 scripts/automation/run-app-review-watch.sh create mode 100755 scripts/automation/run-sanebar-macos27-watch.sh create mode 100755 scripts/automation/run-sanecite-monday-sweep.sh create mode 100755 scripts/automation/run-x-opportunity-scout.sh create mode 100755 scripts/automation/sync-control-plane.sh create mode 100755 scripts/automation/sync-cursor-mini.sh create mode 100755 scripts/grok-bin/cloudflare-mcp-remote.sh create mode 100755 scripts/grok-bin/xcode-mcp-frame.py create mode 100755 scripts/grok-bin/xcode-mcp.sh diff --git a/.claude/settings.json b/.claude/settings.json index a592d42..e22c70d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -157,7 +157,7 @@ "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/session_start.rb; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh session_start.rb", "timeout": 15 } ] @@ -168,7 +168,7 @@ "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/saneprompt.rb; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh saneprompt.rb", "timeout": 5 } ] @@ -188,7 +188,7 @@ "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/sanetools.rb; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh sanetools.rb", "timeout": 5 } ] @@ -209,7 +209,7 @@ "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/sanetrack.rb; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh sanetrack.rb", "timeout": 5 } ] @@ -252,7 +252,7 @@ "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/sanestop.rb; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh sanestop.rb", "timeout": 5 } ] diff --git a/.mcp.json b/.mcp.json index 1b7af3b..e5e374d 100644 --- a/.mcp.json +++ b/.mcp.json @@ -19,10 +19,8 @@ "url": "https://developers.openai.com/mcp" }, "xcode": { - "command": "xcrun", - "args": [ - "mcpbridge" - ] + "type": "http", + "url": "http://127.0.0.1:37915/mcp" } } } diff --git a/AGENTS.md b/AGENTS.md index a95aa9f..55fa545 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,9 @@ agent overlay, not the full runbook. Detailed implementation, release, Mini, and operator setup notes live in `DEVELOPMENT.md`, `ARCHITECTURE.md`, `DEVELOPER_SETUP.md`, `templates/RELEASE_SOP.md`, and `scripts/`. +Regular daily work is Grok, Grokbot, and Cursor. Keep Codex/Claude hook adapters +working. Do not send regular jobs to OpenAI or Anthropic unless the owner asks. + Speak plainly and briefly. Use singular voice for SaneApps communications: `I`, `me`, `my`; never `we`, `us`, or `our`. @@ -152,7 +155,7 @@ the wrapper. | Work email | `ruby scripts/SaneMaster.rb check_inbox` or `~/SaneApps/infra/scripts/check-inbox.sh` | | Sales/download/funnel | `sales`, `downloads`, `events` | | Tool discovery | `ruby scripts/SaneMaster.rb tool_discovery --query "..."` | -| Cleanup | `ruby scripts/SaneMaster.rb machine_cleanup --host mini --apply --preserve-apps AppName` | +| Cleanup | `ruby scripts/SaneMaster.rb machine_cleanup --host local --apply --preserve-apps AppName` (Air) or `--host mini --apply` / `--server` (Mini). Plans generated junk by kind, not free space. | | Verification scope plan | `ruby scripts/SaneMaster.rb proof_plan --task "..."` | | Process health | `process_eval`, `sop_review`, `near_miss_review`, `verify_failure_review` | | Route cost review | `ruby scripts/SaneMaster.rb route_cost_review --json` | diff --git a/DEVELOPER_SETUP.md b/DEVELOPER_SETUP.md index 5609d43..d9e52a9 100644 --- a/DEVELOPER_SETUP.md +++ b/DEVELOPER_SETUP.md @@ -159,6 +159,7 @@ shared public key above (release preflight checks this). | `lemonsqueezy` | `api_key` | sales/license tooling | | `resend` | `api_key` | email automation | | notarytool profile `notarytool` | — | `xcrun notarytool --keychain-profile notarytool` | +| `claude_hook` / `hmac_secret` | same value on Air and Mini | Production release-receipt signing. Both machines must derive the pinned Ed25519 public key. If Air cannot sign receipts, copy Mini `~/.claude_hook_secret` into this keychain item and `~/.claude_hook_secret` (chmod 600). | Fetch each secret once per run and reuse it — no `security` calls in loops (see `AGENTS.md` Secrets). diff --git a/SESSION_HANDOFF.md b/SESSION_HANDOFF.md index 630dab0..0acb15e 100644 --- a/SESSION_HANDOFF.md +++ b/SESSION_HANDOFF.md @@ -1,9 +1,53 @@ # SaneProcess Session Handoff -As of: 2026-07-29 America/New_York +As of: 2026-08-21 America/New_York Owner host: Mac Mini = tree truth; Air = controller. Repo: `~/SaneApps/infra/SaneProcess` +## 2026-08-21 regular clients: Grok, Grokbot, Cursor + +- Owner: daily work is Grok, Grokbot, and Cursor. SaneProcess stays compatible + with Codex and Claude. Do not route regular jobs or new recurring work through + OpenAI/Anthropic. Recurring work uses SaneMaster/launchd plus Mini Grok + headless heartbeats. All Air and Mini Codex heartbeats are PAUSED after the + replacements were proven. +- Proven 2026-08-21: Mini Grok heartbeat smoke (`PONG`); App+CWS review watch + GET-only (UTF-8 fix); SaneCite Monday sweep HTTP (Air, 0 failures); SaneBar + macOS 27 watch (still beta, no notify). X scout is now a Grok heartbeat, not + the paid X API. Launch-ops / Prophecy resume use the same Grok runner; not + executed fully this pass because they mutate inbox/batches. +- NVIDIA weekly scout was not moved: `nvidia_eval` is not in SaneMaster and the + NVIDIA-agent rule forbids it unless the owner asks again. SaneClip 8am + release and SaneLot 1.2.1 live-auction gate stay retired/paused. +- D-U-N-S reminder was a one-shot Codex nag; paused. Still an owner task if + SaneLot Google verification needs it. + +## 2026-08-21 keep-current: pins apply themselves, Grok wrappers stop drifting + +- Uncommitted SaneProcess work: `keep_current` on the existing `dependency_baseline.rb` lane. Weekly Air LaunchAgent `com.saneapps.keep-current` (Sunday 09:15) applies npm pins, auto-bumps `firecrawl-cli` within the same major, and notifies only on drift. Mini nightly applies Mini pins. Homebrew/Codex/Claude are not auto-upgraded; Claude `autoUpdates` is now on; Grok already auto-updates. +- Grok wrappers now live in git `scripts/grok-bin/` (`cloudflare-mcp-remote.sh`, `xcode-mcp.sh`, `xcode-mcp-frame.py`). `sync_grok` overlays them and no longer `--delete`s `~/.grok/bin` (that was wiping the Grok CLI). +- Air Grok apple-docs is HTTP `http://127.0.0.1:37911/mcp` through the existing AgentMemory tunnel, which now also forwards 37911/37913/37915. Xcode is the Mini HTTP singleton at `http://127.0.0.1:37915/mcp` (same pattern as apple-docs), not a fresh SSH stdio spawn. mcpbridge still needs Xcode open on Mini. Proven 2026-08-21: Mini `/healthz` ok, Air initialize HTTP 200. Restart Grok once so this session picks up the HTTP xcode server. Leftover Mini `com.saneapps.x-opportunity-scout` plist was removed; the live 10:00 job is the Grok heartbeat. +- Firecrawl CLI is 1.23.1 with `firecrawl developer` and the `firecrawl-developer-index` skill. No Firecrawl MCP. + +## 2026-08-16 machine_cleanup hunts junk by kind, not free space + +- Owner correction: Air `machine_cleanup` was skipping generated junk because + the disk was marked healthy (451G free). Hygiene now plans unnecessary + generated dumps on any host regardless of free space. Disk pressure still + gates only expensive-to-restore caches (Playwright, HuggingFace, + `codex-runtimes`, npm/npx, simulator runtime images). +- Air apply reclaimed the planned set (19.84G planned, 117/117 actions, Trash + emptied). SaneLot dropped from 14G to 1.3G after + `outputs/mini-storage-archive`, loose verify xcresults, and old run + xcresults were removed. SaneVideo container `tmp`, setapp_review, uv stale + archives, pnpm cache, and memory-sync backups are gone. Codex sessions, + SaneVideo Documents, Logos, Photos, and sim runtimes were left alone. +- Nightly: Air `com.saneapps.machine-cleanup` at 05:40 runs + `machine_cleanup --host local --apply --quiet`. Mini + `com.saneapps.memory-guard` at 05:40 still runs the server reset. Planner + files were copied to the Mini checkout so tonight's Mini pass uses the new + rules. Diff is local/uncommitted. + ## 2026-08-10 CWS watcher configuration-loss diagnosis and receipt hardening - The 15-minute App Review heartbeat is still active. Its publisher ID and diff --git a/scripts/SaneMaster.rb b/scripts/SaneMaster.rb index 7e39662..f3f1217 100755 --- a/scripts/SaneMaster.rb +++ b/scripts/SaneMaster.rb @@ -244,8 +244,9 @@ class SaneMaster 'restore' => { args: '', desc: 'Fix Xcode/Launch Services issues' }, 'install_provisioning_profiles' => { args: '[--delete-source] [glob ...]', desc: 'Install downloaded provisioning profiles deterministically by UUID' }, 'dedupe_apps' => { args: '[--host local|mini] [--apps App1,App2] [--dry-run] [--json]', desc: 'Keep one canonical app bundle per Sane app' }, - 'machine_cleanup' => { args: '[--host local|mini] [--server] [--apply] [--empty-trash] [--json] [--preserve-apps A,B]', desc: 'Prune disposable caches and generated build/test artifacts without touching active app work' }, + 'machine_cleanup' => { args: '[--host local|mini] [--server] [--apply] [--empty-trash] [--json] [--preserve-apps A,B]', desc: 'Prune unnecessary generated junk by kind, not free space, without touching active app work' }, 'mcp_watchdog' => { args: '[status|doctor|clean|install|uninstall] [--max N] [--interval SEC] [--json] [--quiet]', desc: 'Detect and clean duplicate MCP daemons' }, + 'keep_current' => { args: '[--apply] [--npm-only] [--latest] [--apply-safe-latest] [--notify] [--install-agent] [--role air|mini]', desc: 'Apply pinned CLI/MCP versions, auto-bump Firecrawl, and install the weekly keep-current agent' }, 'universal_control_reset' => { args: '[--status] [--dry-run] [--local-only|--mini-only] [--cleanup-mini] [--reboot-mini]', desc: 'Recover Air↔Mini Universal Control / pointer handoff' }, 'work_session_on' => { args: '', desc: 'Start keep-awake + no-lock work session guard' }, 'work_session_off' => { args: '', desc: 'Restore previous lock settings and stop work-session guard' }, @@ -2124,6 +2125,8 @@ def dispatch_command(command, args) run_sync_mini(args) when 'sync_grok', 'sync-grok' run_sync_grok(args) + when 'sync_control_plane', 'sync-control-plane' + run_mini_sync_script('sync-control-plane.sh', args) when 'setapp_status', 'setapp-status' system('ruby', File.join(__dir__, 'setapp_status.rb'), *args) exit($CHILD_STATUS.exitstatus || 1) unless $CHILD_STATUS&.success? @@ -2159,6 +2162,13 @@ def dispatch_command(command, args) exit(success ? 0 : 1) when 'mcp_watchdog', 'mcpw', 'mcp' mcp_watchdog(args) + when 'keep_current', 'keep-current' + system( + '/opt/homebrew/opt/ruby/bin/ruby', + File.join(__dir__, 'automation', 'dependency_baseline.rb'), + *args + ) + exit($CHILD_STATUS.exitstatus || 1) when 'universal_control_reset', 'uc_reset', 'ucr' universal_control_reset(args) when 'work_session_on', 'wson' @@ -2750,15 +2760,15 @@ def print_category_help(category) }, 'machine_cleanup' => { usage: 'machine_cleanup [--host local|mini] [--server] [--apply] [--empty-trash] [--json] [--preserve-apps A,B]', - description: 'Prune disposable caches, stale generated evidence, simulators, DerivedData, and optional Mini server artifacts; Trash stays recoverable by default.', + description: 'Prune unnecessary generated junk by kind on any host. Free space only gates expensive-to-restore caches (Playwright, HuggingFace, sim runtimes, npm). Trash stays recoverable by default.', flags: { '--host local|mini' => 'Inspect this machine or route the cleanup command to the Mini', '--server' => 'Mini-only aggressive server reset: prune generated repo artifacts, routed workspaces, simulator runtimes, Codex residue, bulk outputs, and disposable app containers', '--apply' => 'Perform the planned safe cleanup; default is dry-run', '--empty-trash' => 'Permanently empty Trash after reversible cleanup; explicit approval only', '--preserve-apps A,B' => 'Additional app names to preserve even if no process is currently visible', - '--min-free-gb N' => 'Disk pressure threshold used in the report', - '--cache-threshold-gb N' => 'Minimum disposable-cache total before cache pruning is planned', + '--min-free-gb N' => 'Disk-pressure floor for expensive-to-restore caches only', + '--cache-threshold-gb N' => 'Minimum size of one cache before it is planned (default 0.25G)', '--deriveddata-age-days N' => 'Only prune inactive DerivedData older than this many days', '--json' => 'Emit machine-readable output' }, diff --git a/scripts/app_test_mode.sh b/scripts/app_test_mode.sh index 27dca0c..6132c66 100755 --- a/scripts/app_test_mode.sh +++ b/scripts/app_test_mode.sh @@ -881,14 +881,10 @@ set_app_mode_keychain_local() { case "$mode" in pro) - run_keychain_swift_local "$(swift_keychain_upsert_script)" \ - APP_TEST_SERVICE="$service" \ - APP_TEST_LICENSE_KEY_NAME="$key_name" \ - APP_TEST_LICENSE_KEY_VALUE="$pro_value" \ - APP_TEST_LICENSE_EMAIL_NAME="$email_name" \ - APP_TEST_LICENSE_EMAIL_VALUE="$email_value" \ - APP_TEST_LICENSE_DATE_NAME="$date_name" \ - APP_TEST_LAST_VALIDATION="$now" + # Do not write login-keychain items from unsigned `swift -`. + # That binds the ACL to the Swift interpreter, so the real app + # prompts after every OS update. Defaults fallback is enough. + echo "$app: skipping unsigned keychain seed (defaults fallback only)" ;; basic) run_keychain_swift_local "$(swift_keychain_delete_script)" \ diff --git a/scripts/appstore_submit.rb b/scripts/appstore_submit.rb index c1aab5f..5762e01 100755 --- a/scripts/appstore_submit.rb +++ b/scripts/appstore_submit.rb @@ -52,7 +52,7 @@ def parse_env_file(path) return unless File.file?(path) - File.foreach(path) do |line| + File.read(path, mode: 'r:UTF-8', invalid: :replace, undef: :replace).each_line do |line| next if line.strip.empty? || line.lstrip.start_with?('#') text = line.sub(/\A\s*export\s+/, '').strip diff --git a/scripts/automation/README.md b/scripts/automation/README.md index c3315a2..ac52eeb 100644 --- a/scripts/automation/README.md +++ b/scripts/automation/README.md @@ -357,10 +357,13 @@ utility onto the server or bulk-upgrading release lockfiles. ruby scripts/automation/dependency_baseline.rb --check --role mini ruby scripts/automation/dependency_baseline.rb --apply --role mini ruby scripts/automation/dependency_baseline.rb --apply --role air +ruby scripts/SaneMaster.rb keep_current --apply --npm-only --latest --apply-safe-latest --notify --install-agent ``` +`keep_current` is the unattended lane: weekly Air LaunchAgent `com.saneapps.keep-current` (Sunday 09:15) applies npm pins, auto-bumps `firecrawl-cli` within the same major, and notifies only on drift. Mini nightly applies Mini pins. It does not float `@latest`, does not auto-upgrade Codex/Claude/Grok clients, and does not bulk-upgrade Homebrew. + The baseline keeps Node 24 LTS, Homebrew Ruby, the shared build/release tools, -and shared MCP packages current. It installs a restart-safe `.zshenv` PATH, +shared MCP packages, and Firecrawl CLI current. It installs a restart-safe `.zshenv` PATH, preserves unrelated shell configuration with a timestamped backup, keeps role-specific npm tools separate, and removes the unpinned global Wrangler in favor of each repo's explicit release version. It does not upgrade SwiftPM, diff --git a/scripts/automation/agent-heartbeat.sh b/scripts/automation/agent-heartbeat.sh new file mode 100755 index 0000000..d3ee7ff --- /dev/null +++ b/scripts/automation/agent-heartbeat.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# Run one Grok headless heartbeat from a prompt file on the Mac Mini. +# Primary replacement for retired Codex heartbeat prompts that need agent judgment. + +set -euo pipefail + +usage() { + cat <&2 + usage >&2 + exit 64 + ;; + esac +done + +[[ -n "$ID" ]] || { echo "ERROR: --id is required" >&2; exit 64; } +[[ -f "$PROMPT_FILE" ]] || { echo "ERROR: prompt file not found: $PROMPT_FILE" >&2; exit 64; } +[[ -x "$GROK_BIN" || -n "$(command -v grok 2>/dev/null || true)" ]] || { + echo "ERROR: grok not found (expected $GROK_BIN or PATH)" >&2 + exit 127 +} + +command -v grok >/dev/null 2>&1 || GROK_BIN="$HOME/.grok/bin/grok" +OUT_DIR="$OUT_ROOT/$ID" +LOCK_DIR="$OUT_DIR/.lock" +mkdir -p "$OUT_DIR" + +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "$(date -Iseconds) skip: prior $ID run still holds lock" >>"$OUT_DIR/run.log" + exit 0 +fi + +cleanup() { + rmdir "$LOCK_DIR" 2>/dev/null || true +} +trap cleanup EXIT + +STAMP="$(date +%Y%m%dT%H%M%S)" +LOG="$OUT_DIR/run-$STAMP.log" +RECEIPT="$OUT_DIR/latest.json" + +RUBY_BIN="${SANEPROCESS_RUBY:-/opt/homebrew/opt/ruby/bin/ruby}" +{ + echo "== $(date -Iseconds) agent-heartbeat id=$ID cwd=$CWD grok=$GROK_BIN ==" + cd "$CWD" + # macOS has no GNU timeout(1). Bound the Grok process group in Ruby. + "$RUBY_BIN" -rtimeout -e ' + timeout_seconds = Integer(ARGV.shift) + cmd = ARGV + pid = spawn(*cmd, pgroup: true) + begin + Timeout.timeout(timeout_seconds) { Process.wait(pid) } + exit(Process.last_status&.exitstatus || 1) + rescue Timeout::Error + Process.kill("TERM", -pid) rescue nil + sleep 2 + Process.kill("KILL", -pid) rescue nil + Process.wait(pid) rescue nil + exit 124 + end + ' "$TIMEOUT_SECONDS" "$GROK_BIN" \ + --prompt-file "$PROMPT_FILE" \ + --cwd "$CWD" \ + --output-format json \ + --always-approve +} >"$LOG" 2>&1 +STATUS=$? + +printf '{"id":"%s","finished_at":"%s","exit_code":%s,"log":"%s"}\n' \ + "$ID" "$(date -Iseconds)" "$STATUS" "$LOG" >"$RECEIPT" + +exit "$STATUS" diff --git a/scripts/automation/agent_heartbeat_test.rb b/scripts/automation/agent_heartbeat_test.rb new file mode 100644 index 0000000..92068d5 --- /dev/null +++ b/scripts/automation/agent_heartbeat_test.rb @@ -0,0 +1,37 @@ +#!/opt/homebrew/opt/ruby/bin/ruby +# frozen_string_literal: true + +require 'tmpdir' + +$assertion_count = 0 + +def assert(condition, message) + $assertion_count += 1 + raise message unless condition +end + +root = File.expand_path('..', __dir__) +heartbeat = File.read(File.join(root, 'automation', 'agent-heartbeat.sh')) +xscout = File.read(File.join(root, 'automation', 'run-x-opportunity-scout.sh')) +watch = File.read(File.join(root, 'automation', 'run-app-review-watch.sh')) +install = File.read(File.join(root, 'automation', 'install-recurring-agents.sh')) + +assert(heartbeat.include?('Timeout.timeout'), 'heartbeat must bound Grok without GNU timeout') +assert(!heartbeat.match?(/^\s*timeout "/), 'heartbeat must not call GNU timeout(1)') +assert(heartbeat.include?('--prompt-file'), 'heartbeat must use grok --prompt-file') +assert(heartbeat.include?('--always-approve'), 'heartbeat must be noninteractive') +assert(!xscout.include?('--product'), 'x-scout wrapper must match current argparse') +assert(xscout.include?('--all-live'), 'x-scout wrapper must stay report-only all-live') +assert(watch.include?('en_US.UTF-8'), 'app-review watch must force UTF-8') +assert(install.include?('LC_ALL'), 'LaunchAgents must set LC_ALL') +assert(File.file?(File.join(root, 'automation', 'heartbeats', 'saneapps-launch-ops.md')), + 'launch-ops prompt missing') +assert(File.file?(File.join(root, 'automation', 'heartbeats', 'grok-stack-smoke.md')), + 'grok smoke prompt missing') +assert(File.file?(File.join(root, 'automation', 'heartbeats', 'sanelot-x-opportunity-scout.md')), + 'X scout must be a Grok heartbeat, not the paid X API') +assert(install.include?('sanelot-x-opportunity-scout'), 'installer must schedule the Grok X scout') +submit = File.read(File.join(root, 'appstore_submit.rb')) +assert(submit.include?("mode: 'r:UTF-8'"), 'ASC env loader must not inherit US-ASCII from launchd') + +puts "PASS #{$assertion_count}/#{$assertion_count}" diff --git a/scripts/automation/agentmemory-mcp-air.sh b/scripts/automation/agentmemory-mcp-air.sh index 36160b5..9579766 100755 --- a/scripts/automation/agentmemory-mcp-air.sh +++ b/scripts/automation/agentmemory-mcp-air.sh @@ -8,6 +8,9 @@ set -uo pipefail LABEL="${SANE_AGENTMEMORY_TUNNEL_LABEL:-com.saneapps.agentmemory-tunnel}" MINI_HOST="${SANE_AGENTMEMORY_MINI_HOST:-mini}" LOCAL_PORT="${SANE_AGENTMEMORY_LOCAL_PORT:-3111}" +APPLE_DOCS_PORT="${SANE_APPLE_DOCS_LOCAL_PORT:-37911}" +MACOS_AUTOMATOR_PORT="${SANE_MACOS_AUTOMATOR_LOCAL_PORT:-37913}" +XCODE_PORT="${SANE_XCODE_LOCAL_PORT:-37915}" URL="${SANE_AGENTMEMORY_URL:-http://127.0.0.1:$LOCAL_PORT}" LAUNCHCTL="${SANE_LAUNCHCTL_BIN:-/bin/launchctl}" CURL="${SANE_CURL_BIN:-/usr/bin/curl}" @@ -34,6 +37,9 @@ if [[ "${1:-}" == "--tunnel" ]]; then -o ServerAliveInterval=15 \ -o ServerAliveCountMax=3 \ -L "$LOCAL_PORT:127.0.0.1:3111" \ + -L "$APPLE_DOCS_PORT:127.0.0.1:37911" \ + -L "$MACOS_AUTOMATOR_PORT:127.0.0.1:37913" \ + -L "$XCODE_PORT:127.0.0.1:37915" \ "$MINI_HOST" fi diff --git a/scripts/automation/air_mini_acceptance.rb b/scripts/automation/air_mini_acceptance.rb index 8750f80..05e42ce 100755 --- a/scripts/automation/air_mini_acceptance.rb +++ b/scripts/automation/air_mini_acceptance.rb @@ -328,6 +328,7 @@ def mini_mcp_checks { 'mini-mcp-apple-docs' => 37_911, 'mini-mcp-macos-automator' => 37_913, + 'mini-mcp-xcode' => 37_915, 'mini-mcp-serena' => 37_917 }.each do |id, port| payload = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"sane-acceptance","version":"1"}}}' diff --git a/scripts/automation/air_mini_acceptance_test.rb b/scripts/automation/air_mini_acceptance_test.rb index ae73642..54bea27 100755 --- a/scripts/automation/air_mini_acceptance_test.rb +++ b/scripts/automation/air_mini_acceptance_test.rb @@ -108,7 +108,7 @@ %w[air-process-access air-agentmemory-tunnel air-agentmemory-health air-agentmemory-search air-mini-lan air-mini-tailscale mini-air-return mini-dependencies mini-power mini-weekly-restart mini-agentmemory-health air-github-credential - mini-credential-consumers mini-mcp-apple-docs mini-mcp-macos-automator mini-mcp-serena mini-retired-training + mini-credential-consumers mini-mcp-apple-docs mini-mcp-macos-automator mini-mcp-xcode mini-mcp-serena mini-retired-training saneprocess-parity sanecite-parity memory-checksum-parity acceptance-contracts].each do |id| assert(ids.include?(id), "missing plan check #{id}") end diff --git a/scripts/automation/dependency_baseline.rb b/scripts/automation/dependency_baseline.rb index 80c90d4..b928fb1 100644 --- a/scripts/automation/dependency_baseline.rb +++ b/scripts/automation/dependency_baseline.rb @@ -46,11 +46,15 @@ module SaneAppsDependencyBaseline '@modelcontextprotocol/sdk' => '1.29.0', '@modelcontextprotocol/server-github' => '2025.4.8', '@mweinbach/apple-docs-mcp' => '1.3.1', - '@steipete/macos-automator-mcp' => '0.4.5', + '@steipete/macos-automator-mcp' => '0.4.6', '@upstash/context7-mcp' => '3.2.3', - 'firecrawl-cli' => '1.19.26', + 'firecrawl-cli' => '1.23.1', 'playwright' => '1.61.1' - }.freeze + } + # Same-major npm bumps that keep_current may apply without a human prompt. + # Only packages whose pin lives solely in this file. + SAFE_AUTO_BUMP = %w[firecrawl-cli].freeze + KEEP_CURRENT_LABEL = 'com.saneapps.keep-current'.freeze # Node's Homebrew LTS bottle supplies the matching npm. A separately updated # global npm creates a second CLI version and changes which binary PATH finds. FORBIDDEN_GLOBAL_NPM = %w[wrangler npm @modelcontextprotocol/server-memory].freeze @@ -184,12 +188,160 @@ def npm_state(home) JSON.parse(stdout).fetch('dependencies', {}).transform_values { |entry| entry['version'] } end - def apply_formulae(role) + def apply_formulae(role, upgrade: true) installed = formula_state(role).to_h { |entry| [entry[:name], entry[:installed].any?] } missing = formulae(role).reject { |name| installed[name] } present = formulae(role).select { |name| installed[name] } run!(BREW, 'install', *missing) if missing.any? - run!(BREW, 'upgrade', *present) if present.any? + run!(BREW, 'upgrade', *present) if upgrade && present.any? + end + + def npm_latest(name, home) + run!(File.join(NODE_BIN, 'npm'), 'view', name, 'version', env: npm_env(home)).strip + end + + def same_major?(left, right) + left.to_s.split('.').first == right.to_s.split('.').first + end + + def latest_report(role, home) + npm_packages(role).map do |name| + pin = NPM_VERSIONS.fetch(name) + latest = npm_latest(name, home) + { + name: name, + pin: pin, + latest: latest, + drift: pin != latest, + auto: SAFE_AUTO_BUMP.include?(name) && same_major?(pin, latest) + } + end + end + + def rewrite_pin!(name, new_version) + path = File.expand_path(__FILE__) + source = File.read(path, encoding: 'UTF-8') + old = "'#{name}' => '#{NPM_VERSIONS.fetch(name)}'" + updated = "'#{name}' => '#{new_version}'" + raise "pin line missing for #{name}" unless source.include?(old) + + File.write(path, source.sub(old, updated)) + NPM_VERSIONS[name] = new_version + end + + def apply_safe_latest(role, home) + bumped = [] + latest_report(role, home).each do |row| + next unless row[:auto] && row[:drift] + + rewrite_pin!(row[:name], row[:latest]) + bumped << "#{row[:name]} #{row[:pin]} -> #{row[:latest]}" + end + apply_npm(role, home) if bumped.any? + bumped + end + + def client_version_rows + rows = [] + grok = File.expand_path('~/.grok/version.json') + if File.file?(grok) + data = JSON.parse(File.read(grok)) + rows << { + name: 'grok', + have: data['version'].to_s, + latest: data['stable_version'].to_s, + drift: data['version'].to_s != data['stable_version'].to_s + } + end + %w[claude codex].each do |name| + stdout, _stderr, status = Open3.capture3(name, '--version') + next unless status.success? + + have = stdout.to_s.strip + next if have.empty? + + rows << { name: name, have: have.split.first, latest: name == 'claude' ? 'autoUpdates' : 'manual', drift: false } + rescue Errno::ENOENT + next + end + rows + end + + def notify!(title, body) + escaped = body.to_s.gsub('\\', '\\\\').gsub('"', '\\"') + capture('/usr/bin/osascript', '-e', + %(display notification "#{escaped}" with title "#{title}" sound name "Glass")) + end + + def write_receipt(payload) + dir = File.expand_path('~/SaneApps/outputs/keep-current') + FileUtils.mkdir_p(dir) + stamp = Time.now.utc.strftime('%Y%m%dT%H%M%SZ') + path = File.join(dir, "#{stamp}.json") + File.write(path, JSON.pretty_generate(payload)) + latest = File.join(dir, 'latest.json') + File.write(latest, JSON.pretty_generate(payload)) + path + end + + def install_keep_current_agent(home:) + raise 'keep-current LaunchAgent belongs on the Air controller' if role_for == :mini + + label = KEEP_CURRENT_LABEL + plist = File.join(home, 'Library', 'LaunchAgents', "#{label}.plist") + log_dir = File.expand_path('~/SaneApps/outputs') + FileUtils.mkdir_p([File.dirname(plist), log_dir]) + script = File.expand_path(__FILE__) + ruby = File.join(RUBY_BIN, 'ruby') + File.write(plist, <<~PLIST) + + + + + Label + #{label} + ProgramArguments + + #{ruby} + #{script} + --apply + --npm-only + --latest + --apply-safe-latest + --notify + --role + air + + StartCalendarInterval + + Weekday + 0 + Hour + 9 + Minute + 15 + + Nice + 10 + StandardOutPath + #{log_dir}/keep-current.stdout.log + StandardErrorPath + #{log_dir}/keep-current.stderr.log + EnvironmentVariables + + HOME + #{home} + PATH + #{managed_path(home)} + + + + PLIST + uid = Process.uid + capture('/bin/launchctl', 'bootout', "gui/#{uid}/#{label}") + run!('/bin/launchctl', 'bootstrap', "gui/#{uid}", plist) + capture('/bin/launchctl', 'enable', "gui/#{uid}/#{label}") + "installed LaunchAgent #{plist}" end def apply_npm(role, home) @@ -201,14 +353,16 @@ def apply_npm(role, home) end end - def check(role:, home:) + def check(role:, home:, npm_only: false) problems = [] - shell_ok, shell_message = install_shell_baseline(home: home, apply: false) - problems << shell_message unless shell_ok + unless npm_only + shell_ok, shell_message = install_shell_baseline(home: home, apply: false) + problems << shell_message unless shell_ok - formula_state(role).each do |entry| - problems << "missing formula: #{entry[:name]}" if entry[:installed].empty? - problems << "outdated formula: #{entry[:name]} -> #{entry[:stable]}" if entry[:outdated] + formula_state(role).each do |entry| + problems << "missing formula: #{entry[:name]}" if entry[:installed].empty? + problems << "outdated formula: #{entry[:name]} -> #{entry[:stable]}" if entry[:outdated] + end end node = File.join(NODE_BIN, 'node') @@ -225,13 +379,21 @@ def check(role:, home:) end def main(argv) - options = { apply: false, role: nil, refresh: false } + options = { + apply: false, role: nil, refresh: false, npm_only: false, + latest: false, apply_safe_latest: false, notify: false, install_agent: false + } OptionParser.new do |parser| - parser.banner = 'Usage: dependency_baseline.rb [--check|--apply] [--role air|mini] [--refresh]' + parser.banner = 'Usage: dependency_baseline.rb [--check|--apply] [--role air|mini] [--refresh] [--npm-only] [--latest] [--apply-safe-latest] [--notify] [--install-agent]' parser.on('--check') { options[:apply] = false } parser.on('--apply') { options[:apply] = true } parser.on('--role ROLE', %w[air mini]) { |value| options[:role] = value.to_sym } parser.on('--refresh') { options[:refresh] = true } + parser.on('--npm-only') { options[:npm_only] = true } + parser.on('--latest') { options[:latest] = true } + parser.on('--apply-safe-latest') { options[:apply_safe_latest] = true } + parser.on('--notify') { options[:notify] = true } + parser.on('--install-agent') { options[:install_agent] = true } end.parse!(argv) home = Dir.home @@ -239,24 +401,63 @@ def main(argv) ENV['PATH'] = managed_path(home) puts "SaneApps dependency baseline role=#{role} mode=#{options[:apply] ? 'apply' : 'check'}" - run!(BREW, 'update') if options[:apply] && options[:refresh] + if options[:install_agent] + puts install_keep_current_agent(home: home) + end + + run!(BREW, 'update') if options[:apply] && options[:refresh] && !options[:npm_only] if options[:apply] - apply_formulae(role) - ok, message = install_shell_baseline(home: home, apply: true) - raise message unless ok - puts message + apply_formulae(role, upgrade: !options[:npm_only]) unless options[:npm_only] + unless options[:npm_only] + ok, message = install_shell_baseline(home: home, apply: true) + raise message unless ok + puts message + end apply_npm(role, home) end - ok, problems = check(role: role, home: home) - if ok + bumped = [] + latest_rows = [] + if options[:apply_safe_latest] || options[:latest] + latest_rows = latest_report(role, home) + latest_rows.each do |row| + next unless row[:drift] + + puts "latest drift: #{row[:name]} pin=#{row[:pin]} latest=#{row[:latest]} auto=#{row[:auto]}" + end + end + if options[:apply_safe_latest] + bumped = apply_safe_latest(role, home) + bumped.each { |line| puts "auto-bumped #{line}" } + end + + ok, problems = check(role: role, home: home, npm_only: options[:npm_only]) + receipt = write_receipt( + generated_at: Time.now.utc.iso8601, + role: role.to_s, + apply: options[:apply], + npm_only: options[:npm_only], + ok: ok, + problems: problems, + latest: latest_rows, + bumped: bumped, + clients: client_version_rows + ) + puts "receipt #{receipt}" + + notable = problems + bumped + latest_rows.select { |row| row[:drift] }.map { |row| "#{row[:name]} #{row[:pin]} < #{row[:latest]}" } + if options[:notify] && notable.any? + notify!('SaneApps keep-current', notable.first(3).join('; ')) + end + + if ok && bumped.empty? puts 'PASS dependency baseline current' return 0 end problems.each { |problem| warn "- #{problem}" } - warn 'FAIL dependency baseline drift detected' - 1 + warn 'FAIL dependency baseline drift detected' unless ok + ok ? 0 : 1 rescue StandardError => e warn "ERROR #{e.message}" 2 diff --git a/scripts/automation/dependency_baseline_test.rb b/scripts/automation/dependency_baseline_test.rb index 68190b8..f267556 100644 --- a/scripts/automation/dependency_baseline_test.rb +++ b/scripts/automation/dependency_baseline_test.rb @@ -59,8 +59,12 @@ def assert(condition, message) ).uniq.sort assert(SaneAppsDependencyBaseline::NPM_VERSIONS.keys.sort == all_packages, 'every managed npm package must have exactly one version pin') -assert(SaneAppsDependencyBaseline::NPM_VERSIONS['@steipete/macos-automator-mcp'] == '0.4.5', +assert(SaneAppsDependencyBaseline::NPM_VERSIONS['@steipete/macos-automator-mcp'] == '0.4.6', 'macOS Automator MCP pin drifted') +assert(SaneAppsDependencyBaseline::NPM_VERSIONS['firecrawl-cli'] == '1.23.1', + 'Firecrawl CLI pin drifted') +assert(SaneAppsDependencyBaseline::SAFE_AUTO_BUMP == %w[firecrawl-cli], + 'keep-current auto-bump allowlist drifted') assert(SaneAppsDependencyBaseline::NPM_VERSIONS['@upstash/context7-mcp'] == '3.2.3', 'Context7 MCP pin drifted') assert(SaneAppsDependencyBaseline.npm_specs(:mini).include?('@agentmemory/agentmemory@0.9.27'), @@ -75,8 +79,22 @@ def assert(condition, message) 'exact Mini package pins should pass') drifted = mini_installed.merge('@steipete/macos-automator-mcp' => '0.4.1') -assert(SaneAppsDependencyBaseline.npm_version_problems(:mini, drifted).any? { |problem| problem.include?('0.4.1 != 0.4.5') }, +assert(SaneAppsDependencyBaseline.npm_version_problems(:mini, drifted).any? { |problem| problem.include?('0.4.1 != 0.4.6') }, 'version drift must fail the dependency check') +assert(SaneAppsDependencyBaseline.same_major?('1.19.26', '1.23.1'), + 'Firecrawl minor bumps stay auto-eligible') +assert(!SaneAppsDependencyBaseline.same_major?('1.23.1', '2.0.0'), + 'Firecrawl major bumps must not auto-apply') +grok_bin = File.expand_path('../grok-bin', __dir__) +%w[cloudflare-mcp-remote.sh xcode-mcp.sh xcode-mcp-frame.py].each do |name| + path = File.join(grok_bin, name) + assert(File.executable?(path), "git-owned grok helper missing: #{path}") +end +sync = File.read(File.expand_path('sync-grok-mini.sh', __dir__)) +assert(!sync.include?('rsync -az --delete "$REPO_GROK_BIN_DIR/"'), + 'sync_grok must not --delete ~/.grok/bin') +assert(SaneAppsDependencyBaseline::SAFE_AUTO_BUMP.none? { |name| name.include?('macos-automator') }, + 'macos-automator pin is shared across singleton files; do not auto-rewrite it') forbidden = mini_installed.merge('npm' => '99.0.0') assert(SaneAppsDependencyBaseline.npm_version_problems(:mini, forbidden).include?('forbidden global npm package: npm'), diff --git a/scripts/automation/heartbeats/grok-stack-smoke.md b/scripts/automation/heartbeats/grok-stack-smoke.md new file mode 100644 index 0000000..cdad74f --- /dev/null +++ b/scripts/automation/heartbeats/grok-stack-smoke.md @@ -0,0 +1 @@ +Reply with the single word PONG and stop. Do not use tools. Do not read files. Do not run commands. diff --git a/scripts/automation/heartbeats/prophecy-ledger-transcript-batch-resume.md b/scripts/automation/heartbeats/prophecy-ledger-transcript-batch-resume.md new file mode 100644 index 0000000..b1002e4 --- /dev/null +++ b/scripts/automation/heartbeats/prophecy-ledger-transcript-batch-resume.md @@ -0,0 +1,11 @@ +Read the nearest AGENTS.md before doing anything. For SaneApps work, keep repo inspection, builds, tests, probes, screenshots, and runtime verification on the Mac Mini unless the user explicitly approved a local fallback. If a hook or shared script blocks you, stop and report the blocker instead of retrying around it. + +Daily Prophecy Ledger transcript-batch resume (repo /Users/stephansmac/SaneApps/websites/prophecy-ledger). Source ~/.config/nv/env and confirm SCANNER_ADMIN_TOKEN is present before any scanner admin call (never print it). Query remote D1 for transcript_batches. If no row exists or every batch is completed, report DONE and recommend retiring this automation. If a batch is running, report healthy and stop. If a batch is paused and resume_after is past, call the canonical admin resume action with its D1 idempotency key. If paused for transcript_retry_exhausted or transcript_terminal_error, call skip_active_item for at most one item and report its video id. Never print credentials. Read back D1 after every action and report completed/pending counts. + +Then run `node scripts/research-worker.mjs --all` for eligible human-frozen claims. If execution returns "Script running with cell ID ...", the research worker is still running. Call the wait tool on that exact cell and continue waiting until terminal completion. Never start the canonical machine_conveyor action or query D1 for research results while the worker cell is still running. + +Treat the research worker as complete only when its terminal output contains all of the following: RESEARCH_WORKER_EXIT; the final JSON summary; and one final summary entry for every discovered claim. A result with draft=false is completed fail-closed work, not success and not a silent exit. Report its gate reason as a blocker/manual-research result. If any required terminal marker or per-claim final result is absent, stop and report a blocker. Run the canonical machine_conveyor action with limit 25 only after the complete research-worker receipt is confirmed. Zero eligible claims is healthy only when the worker emits its explicit final zero-eligible receipt. + +Receipt contract: a research worker, resume/skip action, or conveyor is successful only when it emits its documented final machine-readable receipt and the required D1 read-back agrees. An eligible-claims heading, HTTP acceptance, or exit code zero by itself is not success. If a worker exits without RESEARCH_WORKER_EXIT, its final JSON summary, one final summary entry per discovered claim, its final researched/failed counts, draft/claim receipt, or terminal error; or if the conveyor lacks final considered/verified/classified/promoted counts, stop immediately, preserve the raw output, mark the run failed/blocking so the native failed-run notification fires, and do not run downstream actions. Never invent counts, retry around the worker, or silently convert a missing receipt into a healthy report. Report only status changes or blockers. + +Before resume/research/conveyor work, run `node scripts/batch-watchdog.mjs` from the prophecy-ledger repo after sourcing ~/.config/nv/env. The watchdog auto-heals paused transcript_terminal_error / transcript_retry_exhausted (skip+resume) and auto-resumes daily_media_cap / gemini_429 once resume_after is past. Do not treat an expected fuse hold as healthy if resume_after is already past — that is a stall. Treat exit code 2 or healthy=false ONLY after the watchdog finishes (including its auto-heal attempt) as a blocking stall/failure. Do not stop solely because the batch was paused before the watchdog ran. The watchdog emails the owner on error-level alerts only; do not print secrets. Keep the 24h media fuse (86400). No-progress guard: a running batch is healthy only when its completed count or durable updated_at advanced since the prior daily run. If the same batch is unchanged across two runs or its durable progress is older than 24 hours, report a blocking no-progress condition and do not mutate it. Before any paused-batch, research-worker, or conveyor action, verify the canonical runner/action files named by the repo exist. Missing canonical runners are fail-closed blockers; never reconstruct or substitute them. diff --git a/scripts/automation/heartbeats/saneapps-ga-llc-annual-registration-reminder.md b/scripts/automation/heartbeats/saneapps-ga-llc-annual-registration-reminder.md new file mode 100644 index 0000000..6773a52 --- /dev/null +++ b/scripts/automation/heartbeats/saneapps-ga-llc-annual-registration-reminder.md @@ -0,0 +1 @@ +Read AGENTS.md. Remind the owner that the Georgia LLC 'SaneApps LLC' annual registration is due between Jan 1 and Apr 1 this year ($50, filed at ecorp.sos.ga.gov). Check whether it has already been filed this year; if not, surface it as an action item with the link and warn that missing the Apr 1 deadline risks administrative dissolution. Report only the reminder and current status. diff --git a/scripts/automation/heartbeats/saneapps-launch-ops.md b/scripts/automation/heartbeats/saneapps-launch-ops.md new file mode 100644 index 0000000..6f5263d --- /dev/null +++ b/scripts/automation/heartbeats/saneapps-launch-ops.md @@ -0,0 +1,17 @@ +Read /Users/stephansmac/AGENTS.md, /Users/stephansmac/SaneApps/infra/SaneProcess/AGENTS.md, /Users/stephansmac/SaneApps/infra/SaneProcess/SESSION_HANDOFF.md, and outputs/morning_report.md when present. Run on this Mac Mini only. Use canonical SaneMaster and check-inbox routes. Never fall back to the MacBook Air. + +Daily inbox hygiene: run exactly `CHECK_INBOX_AUTORESOLVE_APPLY=1 /Users/stephansmac/SaneApps/infra/scripts/check-inbox.sh`. Auto-resolve only threads that pass its evidence guard. Review/read synthetic canary or monitor messages before closing them and fix the source. + +Classifier health: use the canonical health route. Synthetic probes must never send owner email or create work-inbox items. Preserve fail-closed manual review; do not deploy without canonical checks. + +On Friday, run the SaneLot Workers AI model watch from the backend and official Cloudflare sources using a small visually reviewed fixture set. Report only deprecation/failure, a clearly better candidate, or untrustworthy benchmark data. Do not email the owner. + +Daily launch operations: inspect launch_calendar and recent receipts first. If nothing is due/overdue and no blocker changed, do not run broad sweeps. A nonzero canonical launch_readiness result is no-go. On Monday, Wednesday, and Friday, inspect storefront/App Store/public listing/reviewer-notice state without mutation. Training remains disabled unless separately authorized. + +AgentMemory: use only the installed Mini LaunchAgent and stable loopback health route. First run `/usr/bin/curl -fsS --max-time 5 http://127.0.0.1:3111/agentmemory/livez`. If unhealthy, do not use nohup, do not start a second direct worker, and do not claim recovery from a PID or `agentmemory status` alone. Run the focused canonical installer once: `bash /Users/stephansmac/SaneApps/infra/SaneProcess/scripts/mini/mini-install-agentmemory.sh`, then require both a successful livez response and a connected `agentmemory status`. If livez still fails, stop, report the blocker verbatim, and fail the task so the native notification fires. On Sunday only, refresh from canonical file memories only after the worker is stably healthy: stop through the canonical CLI, perform the existing documented store reset, restart through the installer, wait for livez, run `python3 /Users/stephansmac/memory_import.py` synchronously in the foreground, and do not finish until its final imported/failed receipt is present. Imported under 1000, failed above 0, missing final counts, a backgrounded import, or an import still running when the task ends is failure. File memories remain source of truth. + +Friday portfolio AI usage: run exactly `ruby scripts/SaneMaster.rb ai_meter --days 7 --json`; report product calls/errors/error-rate/retries/fallbacks/latency/token coverage/cost estimate/pricing freshness/data-through time and keep quality evidence separate. + +Do not post, submit listings, create accounts, pay, send email, reply publicly, upload, merge, release, or make irreversible portal changes without required approval. Keep one concise outcome report; unchanged sections get one line. + +Native visibility guard: if AgentMemory livez remains unhealthy after the single canonical installer attempt, surface the exact blocker in the final report and do not suppress the run as unchanged. The owner must receive the blocker even when no other section changed. diff --git a/scripts/automation/heartbeats/sanelot-x-opportunity-scout.md b/scripts/automation/heartbeats/sanelot-x-opportunity-scout.md new file mode 100644 index 0000000..df89e25 --- /dev/null +++ b/scripts/automation/heartbeats/sanelot-x-opportunity-scout.md @@ -0,0 +1,7 @@ +You are the report-only SaneLot X opportunity scout. Run on this Mac Mini only. + +Use Grok X search (`x_keyword_search` / `x_semantic_search`), not the paid X Developer API. Do not post, like, follow, quote, or DM. Do not mention SaneCite. + +Search at most 4 SaneLot-scoped queries, 10 results each, latest posts. Require `since:` on each query. Report only dealer/DMS/inventory/pricing/photo/feed pain with candidate URLs. If nothing relevant, say so in one line. + +Fail closed if a search tool is unavailable. Never set ALLOW_X_API_SCOUT. diff --git a/scripts/automation/install-air-recurring-agents.sh b/scripts/automation/install-air-recurring-agents.sh new file mode 100755 index 0000000..d25fe33 --- /dev/null +++ b/scripts/automation/install-air-recurring-agents.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Air-side LaunchAgents that replace leftover Codex/Claude scheduled jobs. +# Run on the MacBook Air only. + +set -euo pipefail + +host="$(hostname -s 2>/dev/null || hostname)" +if [[ "$host" == *[Mm]ini* ]]; then + echo "Refusing Air recurring-agent install on Mini host $host" >&2 + exit 2 +fi + +ROOT="$HOME/SaneApps/infra/SaneProcess" +AGENTS_DIR="$HOME/Library/LaunchAgents" +OUT="$HOME/SaneApps/outputs/recurring-agents" +RUBY="/opt/homebrew/opt/ruby/bin/ruby" + +chmod +x \ + "$ROOT/scripts/automation/run-sanecite-monday-sweep.sh" \ + "$ROOT/scripts/automation/run-sanebar-macos27-watch.sh" + +mkdir -p "$AGENTS_DIR" "$OUT" + +python3 - </dev/null || true + launchctl bootstrap "gui/$uid" "$AGENTS_DIR/${label}.plist" + launchctl enable "gui/$uid/$label" 2>/dev/null || true + echo "installed $label" +done diff --git a/scripts/automation/install-recurring-agents.sh b/scripts/automation/install-recurring-agents.sh new file mode 100755 index 0000000..9b7f029 --- /dev/null +++ b/scripts/automation/install-recurring-agents.sh @@ -0,0 +1,141 @@ +#!/bin/bash +# Install Mini LaunchAgents for Cursor+Grok recurring jobs. +# Run on the Mac Mini only. + +set -euo pipefail + +if [[ "$(hostname -s)" != "Stephans-Mac-mini" && "$(hostname)" != "mini.local" ]]; then + echo "WARNING: expected Mac Mini; continuing because hostname=$(hostname)" >&2 +fi + +ROOT="$HOME/SaneApps/infra/SaneProcess" +AGENTS_DIR="$HOME/Library/LaunchAgents" +OUT="$HOME/SaneApps/outputs/recurring-agents" + +chmod +x \ + "$ROOT/scripts/automation/run-app-review-watch.sh" \ + "$ROOT/scripts/automation/run-x-opportunity-scout.sh" \ + "$ROOT/scripts/automation/agent-heartbeat.sh" \ + "$ROOT/scripts/automation/pause-codex-heartbeats.sh" + +mkdir -p "$AGENTS_DIR" "$OUT" + +# App + CWS review watch — every 15 minutes +python3 - <<'PY' +import plistlib, pathlib +root = pathlib.Path.home() / "SaneApps/infra/SaneProcess" +out = pathlib.Path.home() / "SaneApps/outputs/recurring-agents" +out.mkdir(parents=True, exist_ok=True) +data = { + "Label": "com.saneapps.app-review-watch", + "ProgramArguments": ["/bin/bash", str(root / "scripts/automation/run-app-review-watch.sh")], + "StartInterval": 900, + "RunAtLoad": False, + "StandardOutPath": str(out / "app-review-watch.stdout.log"), + "StandardErrorPath": str(out / "app-review-watch.stderr.log"), + "EnvironmentVariables": { + "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US.UTF-8", + }, +} +path = pathlib.Path.home() / "Library/LaunchAgents/com.saneapps.app-review-watch.plist" +with path.open("wb") as fh: + plistlib.dump(data, fh) +print(path) +PY +launchctl bootout "gui/$(id -u)/com.saneapps.app-review-watch" 2>/dev/null || true +launchctl bootstrap "gui/$(id -u)" "$AGENTS_DIR/com.saneapps.app-review-watch.plist" + +heartbeat_plist() { + local label="$1" + local id="$2" + local hour="$3" + local minute="$4" + local prompt="$ROOT/scripts/automation/heartbeats/${id}.md" + [[ -f "$prompt" ]] || { echo "missing prompt: $prompt" >&2; exit 1; } + python3 - "$label" "$id" "$hour" "$minute" "$ROOT" "$OUT" <<'PY' +import plistlib, pathlib, sys +label, job_id, hour, minute, root, out = sys.argv[1:7] +root = pathlib.Path(root) +out = pathlib.Path(out) +out.mkdir(parents=True, exist_ok=True) +data = { + "Label": label, + "ProgramArguments": [ + "/bin/bash", + str(root / "scripts/automation/agent-heartbeat.sh"), + "--id", job_id, + "--prompt-file", str(root / "scripts/automation/heartbeats" / f"{job_id}.md"), + "--cwd", str(root), + ], + "StartCalendarInterval": {"Hour": int(hour), "Minute": int(minute)}, + "RunAtLoad": False, + "StandardOutPath": str(out / f"{job_id}.stdout.log"), + "StandardErrorPath": str(out / f"{job_id}.stderr.log"), + "EnvironmentVariables": { + "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US.UTF-8", + }, +} +path = pathlib.Path.home() / "Library/LaunchAgents" / f"{label}.plist" +with path.open("wb") as fh: + plistlib.dump(data, fh) +print(path) +PY + launchctl bootout "gui/$(id -u)/${label}" 2>/dev/null || true + launchctl bootstrap "gui/$(id -u)" "$AGENTS_DIR/${label}.plist" + echo "installed ${label}" +} + +heartbeat_plist com.saneapps.agent-heartbeat.launch-ops saneapps-launch-ops 8 30 +heartbeat_plist com.saneapps.agent-heartbeat.prophecy-ledger prophecy-ledger-transcript-batch-resume 20 20 +launchctl bootout "gui/$(id -u)/com.saneapps.x-opportunity-scout" 2>/dev/null || true +leftover_scout="$AGENTS_DIR/com.saneapps.x-opportunity-scout.plist" +if [[ -f "$leftover_scout" ]]; then + if command -v trash >/dev/null 2>&1; then + trash "$leftover_scout" + else + mkdir -p "$HOME/.Trash" + mv "$leftover_scout" "$HOME/.Trash/com.saneapps.x-opportunity-scout.plist" + fi + echo "removed leftover $leftover_scout" +fi +heartbeat_plist com.saneapps.agent-heartbeat.x-scout sanelot-x-opportunity-scout 10 0 + +python3 - <<'PY' +import plistlib, pathlib +root = pathlib.Path.home() / "SaneApps/infra/SaneProcess" +out = pathlib.Path.home() / "SaneApps/outputs/recurring-agents" +job_id = "saneapps-ga-llc-annual-registration-reminder" +data = { + "Label": "com.saneapps.agent-heartbeat.ga-llc", + "ProgramArguments": [ + "/bin/bash", + str(root / "scripts/automation/agent-heartbeat.sh"), + "--id", job_id, + "--prompt-file", str(root / "scripts/automation/heartbeats" / f"{job_id}.md"), + "--cwd", str(root), + ], + "StartCalendarInterval": {"Month": 1, "Day": 6, "Hour": 9, "Minute": 7}, + "RunAtLoad": False, + "StandardOutPath": str(out / f"{job_id}.stdout.log"), + "StandardErrorPath": str(out / f"{job_id}.stderr.log"), + "EnvironmentVariables": { + "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US.UTF-8", + }, +} +path = pathlib.Path.home() / "Library/LaunchAgents/com.saneapps.agent-heartbeat.ga-llc.plist" +with path.open("wb") as fh: + plistlib.dump(data, fh) +print(path) +PY +launchctl bootout "gui/$(id -u)/com.saneapps.agent-heartbeat.ga-llc" 2>/dev/null || true +launchctl bootstrap "gui/$(id -u)" "$AGENTS_DIR/com.saneapps.agent-heartbeat.ga-llc.plist" + +bash "$ROOT/scripts/automation/pause-codex-heartbeats.sh" + +echo "Recurring Mini LaunchAgents installed. See scripts/automation/recurring-jobs.md" diff --git a/scripts/automation/memory_sync_test.rb b/scripts/automation/memory_sync_test.rb index 611af6f..0bce379 100644 --- a/scripts/automation/memory_sync_test.rb +++ b/scripts/automation/memory_sync_test.rb @@ -262,7 +262,11 @@ def run_sync(air, mini, strict: true, path: '/usr/bin:/bin:/usr/sbin:/sbin') assert_includes(ssh, 'ExitOnForwardFailure=yes') assert_includes(ssh, 'ServerAliveInterval=15') assert_includes(ssh, 'ServerAliveCountMax=3') - assert_includes(ssh, '-L 3111:127.0.0.1:3111 mini') + assert_includes(ssh, '-L 3111:127.0.0.1:3111') + assert_includes(ssh, '-L 37911:127.0.0.1:37911') + assert_includes(ssh, '-L 37913:127.0.0.1:37913') + assert_includes(ssh, '-L 37915:127.0.0.1:37915') + assert_includes(ssh, ' mini') true end end diff --git a/scripts/automation/pause-codex-heartbeats.sh b/scripts/automation/pause-codex-heartbeats.sh new file mode 100755 index 0000000..433a6dd --- /dev/null +++ b/scripts/automation/pause-codex-heartbeats.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Pause every ACTIVE Codex heartbeat on the Mini. Idempotent. +# Legacy specs remain in ~/.codex/automations for reference. + +set -euo pipefail + +STORE="$HOME/.codex/automations" +NOW_MS="$(python3 - <<'PY' +import time +print(int(time.time() * 1000)) +PY +)" + +changed=0 +for toml in "$STORE"/*/automation.toml; do + [[ -f "$toml" ]] || continue + id="$(basename "$(dirname "$toml")")" + if ! grep -q '^status = "ACTIVE"' "$toml"; then + continue + fi + if ! grep -q '^kind = "heartbeat"' "$toml"; then + continue + fi + python3 - "$toml" "$NOW_MS" <<'PY' +import pathlib +import re +import sys + +path = pathlib.Path(sys.argv[1]) +now_ms = sys.argv[2] +text = path.read_text(encoding='utf-8') +if 'status = "ACTIVE"' not in text: + sys.exit(0) +text = text.replace('status = "ACTIVE"', 'status = "PAUSED"', 1) +if re.search(r'^updated_at = ', text, flags=re.M): + text = re.sub(r'^updated_at = .*$', f'updated_at = {now_ms}', text, count=1, flags=re.M) +else: + text = text.rstrip() + f"\nupdated_at = {now_ms}\n" +path.write_text(text, encoding='utf-8') +PY + echo "PAUSED $id" + changed=$((changed + 1)) +done + +if [[ "$changed" -eq 0 ]]; then + echo "No ACTIVE Codex heartbeats to pause." +else + echo "Paused $changed Codex heartbeat(s)." +fi diff --git a/scripts/automation/recurring-jobs.md b/scripts/automation/recurring-jobs.md new file mode 100644 index 0000000..646f85d --- /dev/null +++ b/scripts/automation/recurring-jobs.md @@ -0,0 +1,71 @@ +# Recurring jobs registry + +As of 2026-08-21. Regular clients: **Grok**, **Grokbot**, and **Cursor**. Codex and Claude heartbeats stay PAUSED for compatibility reference only. Do not reactivate them. + +## Runner types + +| Runner | Use when | +|--------|----------| +| **LaunchAgent + script** | Deterministic GET-only or report-only work; no LLM needed | +| **LaunchAgent + Grok headless** | Mini-local agent judgment; reads `scripts/automation/heartbeats/*.md` | +| **LaunchAgent (existing)** | Nightly verify, daily business report, memory sync, batch watchdog | +| **Cursor Automation** | Air-orchestrated scheduled work; create in Cursor Automations UI | +| **Codex heartbeat (PAUSED)** | Legacy; do not reactivate without owner approval | + +Install or refresh Mini LaunchAgents: + +```bash +bash ~/SaneApps/infra/SaneProcess/scripts/automation/install-recurring-agents.sh +``` + +Pause all legacy Codex heartbeats: + +```bash +bash ~/SaneApps/infra/SaneProcess/scripts/automation/pause-codex-heartbeats.sh +``` + +Sync control plane after client changes: + +```bash +ruby ~/SaneApps/infra/SaneProcess/scripts/SaneMaster.rb sync_control_plane +``` + +## Active schedule (no duplicates) + +| Job | Schedule | Runner | Replaces | +|-----|----------|--------|----------| +| App + CWS review watch | Every 15 min | `run-app-review-watch.sh` | Codex `saneapps-app-review-watch` | +| SaneLot X scout | Daily 10:00 | Grok `sanelot-x-opportunity-scout` | Codex same id; paid X API scout stays disabled | +| SaneApps launch ops | Daily 08:30 | Grok `saneapps-launch-ops` | Codex `saneapps-launch-ops` | +| Prophecy batch resume | Daily 20:20 | Grok `prophecy-ledger-transcript-batch-resume` | Codex same id | +| GA LLC registration | Yearly Jan 6 09:07 | Grok `saneapps-ga-llc-annual-registration-reminder` | Codex same id | +| Nightly verify | Daily 08:45 | `mini-nightly.sh` | (unchanged; not duplicate of launch ops) | +| Daily business report | Daily 19:00 | `morning-report.sh` | (unchanged) | +| Prophecy batch watchdog | Every 6 h | prophecy-ledger `run-batch-watchdog.sh` | Complements batch resume; not duplicate | +| Memory sync | Every 15 min (Air) | `sync-memory-mini.sh` | (unchanged) | +| Keep-current | Weekly Sun 09:15 (Air) | `dependency_baseline.rb` | pins/Firecrawl | +| SaneCite Monday sweep | Weekly Mon 07:00 (Air) | `run-sanecite-monday-sweep.sh` | Claude `sanecite-monday-sweep` | +| SaneBar macOS 27 watch | Daily 09:00 (Air) | `run-sanebar-macos27-watch.sh` | Codex `revisit-sanebar-after-macos-27` | + +## Paused / retired + +| Job | Reason | +|-----|--------| +| `sanelot-1-2-1-live-auction-release-gate` | 1.2.1 submitted 2026-08-19; CWS watch handles review state. Re-enable only for a new gated release. | + +## Not duplicate (intentional overlap) + +- **Launch ops (08:30)** vs **nightly (08:45)**: launch ops checks inbox, launch calendar, AgentMemory, and listing state; nightly runs bounded verify/cleanup and operator brief. Different outputs. +- **Prophecy watchdog (6 h)** vs **batch resume (daily)**: watchdog auto-heals fuse stalls; resume advances paused batches and research/conveyor work. +- **App review watch (15 min)** vs **launch ops storefront checks (M/W/F)**: watch emails on ASC/CWS state transitions; launch ops does broader read-only launch surface inspection. + +## Cursor Automations (Air) + +Use Cursor Automations for scheduled work that starts on the Air and orchestrates via SSH/Mini-first rules. Mini-local browser, build, and runtime proof still belong on the Mini. Do not recreate Codex heartbeats on the Air. + +Suggested Air-side automations (create manually in Cursor): + +- Weekly control-plane sync reminder if `sync_control_plane` receipt is stale +- PR review triage on merge-ready repos (optional; overlaps autopilot skill) + +Do not duplicate the Mini LaunchAgent jobs above in Cursor. diff --git a/scripts/automation/run-app-review-watch.sh b/scripts/automation/run-app-review-watch.sh new file mode 100755 index 0000000..0e9d68e --- /dev/null +++ b/scripts/automation/run-app-review-watch.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Canonical GET-only App Store + Chrome Web Store review watchers. +# Replaces the former Codex heartbeat `saneapps-app-review-watch`. +# Emails on state transitions; never mutates store state. +# App Store and Chrome Web Store run independently so one store's +# failure cannot skip the other store's email. + +set -euo pipefail + +export LANG="${LANG:-en_US.UTF-8}" +export LC_ALL="${LC_ALL:-en_US.UTF-8}" + +ROOT="$HOME/SaneApps/infra/SaneProcess" +OUT_DIR="$HOME/SaneApps/outputs/app-review-watch" +LOCK_DIR="$OUT_DIR/.lock" +LOG="$OUT_DIR/run.log" +RUBY="${SANEPROCESS_RUBY:-/opt/homebrew/opt/ruby/bin/ruby}" + +mkdir -p "$OUT_DIR" + +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "$(date -Iseconds) skip: prior run still holds lock" >>"$LOG" + exit 0 +fi +trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT + +echo "== $(date -Iseconds) app-review-watch ==" >>"$LOG" + +asc=0 +cws=0 +"$RUBY" "$ROOT/scripts/automation/app_review_watch.rb" >>"$LOG" 2>&1 || asc=$? +"$RUBY" "$ROOT/scripts/automation/cws_review_watch.rb" >>"$LOG" 2>&1 || cws=$? + +if [ "$asc" -ne 0 ]; then + echo "$(date -Iseconds) app_review_watch.rb exit $asc" >>"$LOG" +fi +if [ "$cws" -ne 0 ]; then + echo "$(date -Iseconds) cws_review_watch.rb exit $cws" >>"$LOG" +fi + +if [ "$asc" -ne 0 ] || [ "$cws" -ne 0 ]; then + exit 1 +fi diff --git a/scripts/automation/run-sanebar-macos27-watch.sh b/scripts/automation/run-sanebar-macos27-watch.sh new file mode 100755 index 0000000..f0a5937 --- /dev/null +++ b/scripts/automation/run-sanebar-macos27-watch.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Notify only when macOS 27 is a stable public release. Replaces Codex +# `revisit-sanebar-after-macos-27`. No SaneBar code changes. + +set -euo pipefail + +export LANG="${LANG:-en_US.UTF-8}" +OUT_DIR="$HOME/SaneApps/outputs/sanebar-macos27-watch" +mkdir -p "$OUT_DIR" +PAGE="$OUT_DIR/releases.html" +RECEIPT="$OUT_DIR/latest.json" + +curl -fsS --max-time 25 -A "SaneApps-macos27-watch" \ + "https://developer.apple.com/news/releases/" -o "$PAGE" + +python3 - "$PAGE" "$RECEIPT" <<'PY' +import json, pathlib, re, sys +html = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace") +stable = bool(re.search(r"macOS\s+27(?!\s*(beta|preview|RC|Release Candidate))", html, re.I)) +beta = bool(re.search(r"macOS\s+27.{0,40}(beta|preview)", html, re.I)) +payload = { + "ok": True, + "macos_27_mentioned": "macOS 27" in html or "macOS27" in html, + "looks_stable_public": stable and not beta, + "looks_beta": beta, +} +pathlib.Path(sys.argv[2]).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") +print(json.dumps(payload)) +if payload["looks_stable_public"]: + raise SystemExit(10) +PY +STATUS=$? +if [[ "$STATUS" -eq 10 ]]; then + osascript -e 'display notification "macOS 27 looks like a public stable release. Revisit SaneBar vs Thaw." with title "SaneBar" sound name "Glass"' || true + echo "NOTIFY macos 27 public" + exit 0 +fi +exit "$STATUS" diff --git a/scripts/automation/run-sanecite-monday-sweep.sh b/scripts/automation/run-sanecite-monday-sweep.sh new file mode 100755 index 0000000..b4a65bc --- /dev/null +++ b/scripts/automation/run-sanecite-monday-sweep.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Read-only SaneCite Monday health sweep. Replaces the Claude scheduled skill. +# Launchd should pass --email. Manual e2e omits --email. + +set -euo pipefail + +export LANG="${LANG:-en_US.UTF-8}" +export LC_ALL="${LC_ALL:-en_US.UTF-8}" + +EMAIL=0 +if [[ "${1:-}" == "--email" ]]; then + EMAIL=1 +fi + +OUT_DIR="$HOME/SaneApps/outputs/sanecite-monday-sweep" +LOCK_DIR="$OUT_DIR/.lock" +mkdir -p "$OUT_DIR" +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "skip: prior sweep still holds lock" + exit 0 +fi +trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT + +STAMP="$(date +%Y%m%dT%H%M%SZ)" +RECEIPT="$OUT_DIR/$STAMP.json" +BODY="$OUT_DIR/$STAMP.md" +FAILS=0 + +check() { + local name="$1" + local expected="$2" + shift 2 + local got + got="$("$@" 2>/dev/null || true)" + if [[ "$got" == *"$expected"* ]]; then + echo "PASS $name" + return 0 + fi + echo "FAIL $name expected=$expected got=${got:0:180}" + FAILS=$((FAILS + 1)) + return 1 +} + +{ + echo "# SaneCite Monday sweep" + echo + echo "Generated $(date -Iseconds)" + echo + + code="$(curl -sS -o /tmp/sc-health.json -w '%{http_code}' --max-time 20 https://app.sanecite.com/health || true)" + if [[ "$code" == "200" ]] && python3 -c 'import json,sys; d=json.load(open("/tmp/sc-health.json")); sys.exit(0 if d.get("ok") is True else 1)'; then + echo "PASS app /health" + else + echo "FAIL app /health http=$code" + FAILS=$((FAILS + 1)) + fi + + hdr="$(curl -sSI --max-time 20 https://app.sanecite.com/ || true)" + for h in content-security-policy strict-transport-security x-frame-options x-content-type-options; do + echo "$hdr" | grep -qi "^$h:" && echo "PASS header $h" || { echo "FAIL header $h"; FAILS=$((FAILS + 1)); } + done + + iso="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 -X POST https://app.sanecite.com/answer -H 'content-type: application/json' -d '{"question":"x"}' || true)" + if [[ "$iso" == "401" ]]; then + echo "PASS tenant isolation missing accountId" + else + echo "FAIL tenant isolation missing accountId http=$iso" + FAILS=$((FAILS + 1)) + fi + + mcode="$(curl -sS -o /tmp/sc-home.html -w '%{http_code}' --max-time 20 https://sanecite.com/ || true)" + if [[ "$mcode" == "200" ]] && grep -q "Accurate, or it's free" /tmp/sc-home.html; then + echo "PASS marketing home" + else + echo "FAIL marketing home http=$mcode" + FAILS=$((FAILS + 1)) + fi + + echo + echo "failures=$FAILS" +} | tee "$BODY" + +python3 - "$RECEIPT" "$FAILS" "$BODY" <<'PY' +import json, sys +json.dump({"generated_at": __import__("datetime").datetime.utcnow().isoformat()+"Z", + "failures": int(sys.argv[2]), "body": sys.argv[3], "ok": int(sys.argv[2])==0}, + open(sys.argv[1],"w"), indent=2) +print(sys.argv[1]) +PY + +if [[ "$EMAIL" -eq 1 ]]; then + if [[ "$FAILS" -eq 0 ]]; then + subject="SaneCite Monday sweep: all clear" + else + subject="SaneCite Monday sweep: $FAILS FAILURE(S), action needed" + fi + "$HOME/SaneApps/infra/scripts/send-internal-report.sh" "$subject" "$BODY" +fi + +[[ "$FAILS" -eq 0 ]] diff --git a/scripts/automation/run-x-opportunity-scout.sh b/scripts/automation/run-x-opportunity-scout.sh new file mode 100755 index 0000000..1a7829e --- /dev/null +++ b/scripts/automation/run-x-opportunity-scout.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Report-only SaneLot X opportunity scout. Replaces Codex heartbeat +# `sanelot-x-opportunity-scout`. Never posts or performs public X actions. + +set -euo pipefail + +ROOT="$HOME/SaneApps/infra/SaneProcess" +OUT_DIR="$HOME/SaneApps/outputs/x-opportunity-scout" +LOCK_DIR="$OUT_DIR/.lock" +LOG="$OUT_DIR/run.log" +PY="$HOME/.local/share/x-api-venv/bin/python3" +SCRIPT="$ROOT/scripts/automation/x-opportunity-scout.py" + +mkdir -p "$OUT_DIR" + +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "$(date -Iseconds) skip: prior run still holds lock" >>"$LOG" + exit 0 +fi +trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT + +{ + echo "== $(date -Iseconds) x-opportunity-scout ==" + "$PY" "$SCRIPT" \ + --root "$HOME/SaneApps" \ + --all-live \ + --limit 4 \ + --per-query 10 \ + --json +} >>"$LOG" 2>&1 diff --git a/scripts/automation/sync-control-plane.sh b/scripts/automation/sync-control-plane.sh new file mode 100755 index 0000000..3d2a650 --- /dev/null +++ b/scripts/automation/sync-control-plane.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Sync primary operator control plane (Cursor + Grok) to the Mini. +# Legacy Codex sync remains available as sync_mini for compatibility only. + +set -euo pipefail + +MINI_HOST="mini" +QUIET=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + cat <&2 + exit 1 + ;; + *) + MINI_HOST="$1" + shift + ;; + esac +done + +ROOT="$HOME/SaneApps/infra/SaneProcess" +ARGS=() +[[ "$QUIET" -eq 1 ]] && ARGS+=(--quiet) + +bash "$ROOT/scripts/automation/sync-cursor-mini.sh" "$MINI_HOST" "${ARGS[@]}" +bash "$ROOT/scripts/automation/sync-grok-mini.sh" "$MINI_HOST" "${ARGS[@]}" diff --git a/scripts/automation/sync-cursor-mini.sh b/scripts/automation/sync-cursor-mini.sh new file mode 100755 index 0000000..f3aca6d --- /dev/null +++ b/scripts/automation/sync-cursor-mini.sh @@ -0,0 +1,117 @@ +#!/bin/bash +# Sync Cursor controller hooks/skills and shared agent skills to the Mac Mini. +# Primary operator client is Cursor on the Air; Grok runs Mini heartbeats. + +set -euo pipefail + +MINI_HOST="mini" +QUIET=0 +DUMP_CONFIG=0 + +usage() { + cat <&2 + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --quiet) + QUIET=1 + shift + ;; + --dump-config) + DUMP_CONFIG=1 + shift + ;; + --*) + die "Unknown option: $1" + ;; + *) + MINI_HOST="$1" + shift + ;; + esac +done + +if [[ "$DUMP_CONFIG" -eq 1 ]]; then + printf 'MINI_HOST=%s\n' "$MINI_HOST" + printf 'QUIET=%s\n' "$QUIET" + exit 0 +fi + +command -v ssh >/dev/null 2>&1 || die "ssh not found" +command -v rsync >/dev/null 2>&1 || die "rsync not found" + +LOCAL_CURSOR_DIR="$HOME/.cursor" +LOCAL_CURSOR_HOOKS="$LOCAL_CURSOR_DIR/hooks" +LOCAL_AGENTS_SKILLS_DIR="$HOME/.agents/skills" +REPO_ROOT="$HOME/SaneApps/infra/SaneProcess" +REPO_CURSOR_HOOKS="$REPO_ROOT/scripts/hooks/cursor" + +log "Syncing Cursor control-plane profile to $MINI_HOST..." + +if [[ -d "$LOCAL_AGENTS_SKILLS_DIR" ]]; then + rsync -az --delete "$LOCAL_AGENTS_SKILLS_DIR/" "$MINI_HOST:~/.agents/skills/" \ + 2>/dev/null || log " ! .agents/skills rsync to mini (non-fatal if mini not reachable)" + log " + .agents/skills mirrored to mini" +fi + +if [[ -d "$LOCAL_CURSOR_HOOKS" ]]; then + ssh "$MINI_HOST" "mkdir -p ~/.cursor/hooks" 2>/dev/null || true + rsync -az "$LOCAL_CURSOR_HOOKS/" "$MINI_HOST:~/.cursor/hooks/" \ + 2>/dev/null || log " ! ~/.cursor/hooks rsync to mini (non-fatal)" + log " + Cursor hook adapters mirrored to mini" +else + ssh "$MINI_HOST" "mkdir -p ~/.cursor/hooks" 2>/dev/null || true + rsync -az "$REPO_CURSOR_HOOKS/" "$MINI_HOST:~/.cursor/hooks/" \ + 2>/dev/null || log " ! repo cursor hooks rsync to mini (non-fatal)" + log " + repo Cursor hook adapters mirrored to mini (no local ~/.cursor/hooks)" +fi + +if [[ -f "$LOCAL_CURSOR_DIR/hooks.json" ]]; then + rsync -az "$LOCAL_CURSOR_DIR/hooks.json" "$MINI_HOST:~/.cursor/hooks.json" \ + 2>/dev/null || log " ! ~/.cursor/hooks.json rsync failed (non-fatal)" + log " + Cursor hooks.json mirrored when present" +fi + +UNIVERSAL_SCRIPTS=( + "scripts/SaneMaster.rb" + "scripts/validation_report.rb" + "scripts/automation/recurring-jobs.md" + "scripts/automation/install-recurring-agents.sh" + "scripts/automation/agent-heartbeat.sh" + "scripts/automation/run-app-review-watch.sh" + "scripts/automation/run-x-opportunity-scout.sh" + "scripts/hooks/sane_curl_guard.sh" +) +for rel in "${UNIVERSAL_SCRIPTS[@]}"; do + if [[ -f "$REPO_ROOT/$rel" ]]; then + rsync -az "$REPO_ROOT/$rel" "$MINI_HOST:~/SaneApps/infra/SaneProcess/$rel" 2>/dev/null || true + fi +done +rsync -az "$REPO_ROOT/scripts/automation/heartbeats/" "$MINI_HOST:~/SaneApps/infra/SaneProcess/scripts/automation/heartbeats/" 2>/dev/null || true +log " + recurring automation scripts mirrored (best-effort)" + +log "" +log "Cursor profile sync complete (best-effort mini)." +log "Cursor Automations remain UI-owned on the controller; Mini recurring jobs use LaunchAgents (see recurring-jobs.md)." diff --git a/scripts/automation/sync-grok-mini.sh b/scripts/automation/sync-grok-mini.sh index 18355c2..a4664d5 100755 --- a/scripts/automation/sync-grok-mini.sh +++ b/scripts/automation/sync-grok-mini.sh @@ -76,9 +76,10 @@ log "Syncing Grok control-plane profile to $MINI_HOST..." # Ensure repo grok-bin exists (the thing we actually keep in git) [[ -d "$REPO_GROK_BIN_DIR" ]] || die "Missing repo grok-bin dir: $REPO_GROK_BIN_DIR" -# Rsync the git-owned grok-bin helpers to the Mini (and ensure local ~/.grok/bin exists for the operator) +# Overlay git-owned helpers onto ~/.grok/bin. Never --delete: the official Grok +# CLI binary/symlinks live here too, and --delete once wiped them. mkdir -p "$LOCAL_GROK_BIN_DIR" -rsync -az --delete "$REPO_GROK_BIN_DIR/" "$LOCAL_GROK_BIN_DIR/" || die "rsync of local grok-bin failed" +rsync -az "$REPO_GROK_BIN_DIR/" "$LOCAL_GROK_BIN_DIR/" || die "rsync of local grok-bin failed" log " + grok-bin helpers synced locally" if [[ -f "$LOCAL_GROK_CONFIG" ]]; then @@ -95,9 +96,9 @@ if [[ -d "$LOCAL_AGENTS_SKILLS_DIR" ]]; then log " + .agents/skills mirrored to mini (where present)" fi -# Also push the grok-bin contents to the Mini's expected location so a Grok session there sees them +# Overlay helpers onto Mini ~/.grok/bin without deleting the Mini Grok CLI binary. ssh "$MINI_HOST" "mkdir -p ~/.grok/bin" 2>/dev/null || true -rsync -az --delete "$REPO_GROK_BIN_DIR/" "$MINI_HOST:~/.grok/bin/" 2>/dev/null || log " ! grok-bin rsync to mini (non-fatal if mini not reachable)" +rsync -az "$REPO_GROK_BIN_DIR/" "$MINI_HOST:~/.grok/bin/" 2>/dev/null || log " ! grok-bin rsync to mini (non-fatal if mini not reachable)" log " + grok-bin mirrored to mini" # Push a small set of universal SaneProcess scripts that Grok sessions commonly invoke diff --git a/scripts/automation/x-opportunity-scout.py b/scripts/automation/x-opportunity-scout.py index 1a63c0d..6b562f4 100755 --- a/scripts/automation/x-opportunity-scout.py +++ b/scripts/automation/x-opportunity-scout.py @@ -365,6 +365,11 @@ def normalize_post(item: Any, entry: dict[str, str]) -> dict[str, Any]: def run_live_search(queries: list[dict[str, str]], per_query: int) -> list[dict[str, Any]]: + if os.environ.get("ALLOW_X_API_SCOUT", "").strip() != "1": + raise ScoutError( + "X Developer API scout is disabled. Opportunity search now uses the Grok " + "subscription X search lane. Set ALLOW_X_API_SCOUT=1 only for an explicit owner override." + ) try: from xdk import Client from xdk.oauth1_auth import OAuth1 diff --git a/scripts/automation/x_opportunity_scout_test.py b/scripts/automation/x_opportunity_scout_test.py index 8a30d69..6598e10 100755 --- a/scripts/automation/x_opportunity_scout_test.py +++ b/scripts/automation/x_opportunity_scout_test.py @@ -3,6 +3,7 @@ import json import importlib.util +import os import subprocess import sys import tempfile @@ -40,6 +41,15 @@ def run_scout(self, root: Path, *args: str) -> dict: ) return json.loads(result.stdout) + def test_live_search_refuses_x_developer_api_without_owner_override(self): + module = self.load_module() + with self.assertRaises(module.ScoutError) as raised: + module.run_live_search( + [{"product": "SaneLot", "query": "dealercenter lang:en", "path": "", "kind": "keyword", "website_url": "https://sanelot.com"}], + 10, + ) + self.assertIn("Grok subscription", str(raised.exception)) + def test_dry_run_selects_queries_without_x_credentials(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -229,6 +239,8 @@ def __init__(self, **kwargs): sys.modules["xdk.oauth1_auth"] = fake_auth original_get_secret = module.get_secret module.get_secret = lambda account: f"{account}-secret" + old_allow = os.environ.get("ALLOW_X_API_SCOUT") + os.environ["ALLOW_X_API_SCOUT"] = "1" try: results = module.run_live_search( [ @@ -243,6 +255,10 @@ def __init__(self, **kwargs): ) finally: module.get_secret = original_get_secret + if old_allow is None: + os.environ.pop("ALLOW_X_API_SCOUT", None) + else: + os.environ["ALLOW_X_API_SCOUT"] = old_allow if old_xdk is None: sys.modules.pop("xdk", None) else: @@ -299,6 +315,8 @@ def __init__(self, **kwargs): sys.modules["xdk.oauth1_auth"] = fake_auth original_get_secret = module.get_secret module.get_secret = lambda account: f"{account}-secret" + old_allow = os.environ.get("ALLOW_X_API_SCOUT") + os.environ["ALLOW_X_API_SCOUT"] = "1" try: results = module.run_live_search( [ @@ -313,6 +331,10 @@ def __init__(self, **kwargs): ) finally: module.get_secret = original_get_secret + if old_allow is None: + os.environ.pop("ALLOW_X_API_SCOUT", None) + else: + os.environ["ALLOW_X_API_SCOUT"] = old_allow if old_xdk is None: sys.modules.pop("xdk", None) else: diff --git a/scripts/grok-bin/README.md b/scripts/grok-bin/README.md index 9db67ea..033331c 100644 --- a/scripts/grok-bin/README.md +++ b/scripts/grok-bin/README.md @@ -3,12 +3,14 @@ This directory is the canonical git-owned source for the operator-facing helpers that get installed into `~/.grok/bin/` (or surfaced via PATH / completions for Grok sessions). -Files (initial): +Files: - `README.md` — this file -- Future thin shims will live here (MCP probes, SaneMaster convenience wrappers, Grok-specific status helpers, etc.) +- `check-mcps` — live Grok MCP probe +- `cloudflare-mcp-remote.sh` — token-backed Cloudflare admin MCP (`~/.config/nv/env` then Keychain) +- `xcode-mcp.sh` / `xcode-mcp-frame.py` — Mini `mcpbridge`. Air Grok uses the Mini HTTP singleton at `http://127.0.0.1:37915/mcp` through the AgentMemory tunnel. `--framed` is the Content-Length path for that singleton. -Do not edit only `~/.grok/bin/*` and call it done. +Do not edit only `~/.grok/bin/*` and call it done. `sync_grok` overlays these helpers onto `~/.grok/bin` and must never `--delete` that directory (the official Grok CLI binary lives there). Canonical workflow: diff --git a/scripts/grok-bin/cloudflare-mcp-remote.sh b/scripts/grok-bin/cloudflare-mcp-remote.sh new file mode 100755 index 0000000..690f9d6 --- /dev/null +++ b/scripts/grok-bin/cloudflare-mcp-remote.sh @@ -0,0 +1,47 @@ +#!/bin/zsh +# Token-backed Cloudflare MCP via mcp-remote. Env first, Keychain fallback. +# Usage: cloudflare-mcp-remote.sh https://mcp.cloudflare.com/mcp +set -euo pipefail + +url="${1:-}" +case "$url" in + https://mcp.cloudflare.com/mcp|\ + https://bindings.mcp.cloudflare.com/mcp|\ + https://builds.mcp.cloudflare.com/mcp|\ + https://observability.mcp.cloudflare.com/mcp) ;; + *) + print -u2 "usage: ${0:t} https://mcp.cloudflare.com/mcp" + print -u2 "refusing unexpected Cloudflare MCP URL" + exit 2 + ;; +esac + +loader="${SANE_LOAD_SECRETS_SH:-$HOME/SaneApps/infra/SaneProcess/scripts/sane_load_secrets.sh}" +if [[ -f "$loader" ]]; then + # shellcheck disable=SC1090 + source "$loader" +fi + +if [[ -z "${CLOUDFLARE_API_TOKEN:-}" && -f "$HOME/.config/nv/env" ]]; then + set +x + set -a + # shellcheck disable=SC1091 + source "$HOME/.config/nv/env" + set +a +fi + +if [[ -z "${CLOUDFLARE_API_TOKEN:-}" ]]; then + set +x + CLOUDFLARE_API_TOKEN="$(/usr/bin/security find-generic-password -s sane-env -a CLOUDFLARE_API_TOKEN -w 2>/dev/null || true)" +fi + +if [[ -z "${CLOUDFLARE_API_TOKEN:-}" ]]; then + print -u2 "CLOUDFLARE_API_TOKEN is missing" + exit 1 +fi + +set +x +exec npx -p mcp-remote@0.1.38 mcp-remote "$url" \ + --transport http-only \ + --silent \ + --header "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" diff --git a/scripts/grok-bin/xcode-mcp-frame.py b/scripts/grok-bin/xcode-mcp-frame.py new file mode 100755 index 0000000..2879907 --- /dev/null +++ b/scripts/grok-bin/xcode-mcp-frame.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Translate MCP Content-Length framing to Apple mcpbridge NDJSON.""" +from __future__ import annotations + +import json +import os +import select +import subprocess +import sys + + +def parse_inbound(buf: bytes) -> tuple[bytes | None, bytes]: + stripped = buf.lstrip() + if stripped.startswith(b"{"): + nl = stripped.find(b"\n") + if nl < 0: + return None, buf + prefix_len = len(buf) - len(stripped) + return stripped[:nl], buf[prefix_len + nl + 1 :] + sep = b"\r\n\r\n" if b"\r\n\r\n" in buf else (b"\n\n" if b"\n\n" in buf else None) + if sep is None: + return None, buf + raw_headers, rest = buf.split(sep, 1) + line_sep = b"\r\n" if sep == b"\r\n\r\n" else b"\n" + length = None + for line in raw_headers.split(line_sep): + if line.lower().startswith(b"content-length:"): + length = int(line.split(b":", 1)[1].strip()) + if length is None: + raise RuntimeError("missing Content-Length") + if len(rest) < length: + return None, buf + return rest[:length], rest[length:] + + +def write_content_length(fd: int, body: bytes) -> None: + os.write(fd, f"Content-Length: {len(body)}\r\n\r\n".encode() + body) + + +def main() -> int: + cmd = sys.argv[1:] or ["xcrun", "mcpbridge"] + child = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=sys.stderr, + bufsize=0, + ) + assert child.stdin is not None and child.stdout is not None + stdin_fd = sys.stdin.fileno() + out_fd = sys.stdout.fileno() + child_out = child.stdout.fileno() + inbound = b"" + outbound = b"" + try: + while True: + rfds, _, _ = select.select([stdin_fd, child_out], [], []) + if stdin_fd in rfds: + chunk = os.read(stdin_fd, 4096) + if not chunk: + break + inbound += chunk + while True: + body, inbound = parse_inbound(inbound) + if body is None: + break + child.stdin.write(body + b"\n") + child.stdin.flush() + if child_out in rfds: + chunk = os.read(child_out, 8192) + if not chunk: + break + outbound += chunk + while b"\n" in outbound: + line, outbound = outbound.split(b"\n", 1) + line = line.strip() + if not line: + continue + json.loads(line) + write_content_length(out_fd, line) + finally: + if child.poll() is None: + child.terminate() + try: + child.wait(timeout=2) + except subprocess.TimeoutExpired: + child.kill() + return child.returncode or 0 + + +def _self_test() -> int: + body = b'{"jsonrpc":"2.0","id":1}' + framed = f"Content-Length: {len(body)}\r\n\r\n".encode() + body + b"trailing" + got, rest = parse_inbound(framed) + assert got == body, got + assert rest == b"trailing", rest + ndjson = b'{"jsonrpc":"2.0"}\nnext' + got, rest = parse_inbound(ndjson) + assert got == b'{"jsonrpc":"2.0"}', got + assert rest == b"next", rest + incomplete, rest = parse_inbound(b"Content-Length: 4\r\n\r\nab") + assert incomplete is None + assert rest == b"Content-Length: 4\r\n\r\nab" + return 0 + + +if __name__ == "__main__": + if sys.argv[1:] == ["--self-test"]: + raise SystemExit(_self_test()) + raise SystemExit(main()) diff --git a/scripts/grok-bin/xcode-mcp.sh b/scripts/grok-bin/xcode-mcp.sh new file mode 100755 index 0000000..f144527 --- /dev/null +++ b/scripts/grok-bin/xcode-mcp.sh @@ -0,0 +1,53 @@ +#!/bin/zsh +# Xcode MCP belongs on the Mini. +# Mini Grok talks NDJSON to mcpbridge. +# Air Grok and the Mini HTTP singleton talk Content-Length, so they use --framed. +set -euo pipefail + +framed=0 +if [[ "${1:-}" == "--framed" ]]; then + framed=1 + shift +fi + +host="$(scutil --get LocalHostName 2>/dev/null || hostname -s)" +host="${host:l}" + +frame="$HOME/.grok/bin/xcode-mcp-frame.py" +if [[ ! -x "$frame" ]]; then + frame="$(cd -- "${0:A:h}" && pwd)/xcode-mcp-frame.py" +fi + +wait_for_xcode() { + local i pid + for i in {1..20}; do + pid="$(pgrep -x Xcode | head -n1 || true)" + if [[ -n "$pid" ]]; then + export MCP_XCODE_PID="$pid" + return 0 + fi + sleep 1 + done + print -u2 "xcode-mcp: Xcode is not running" + return 1 +} + +run_local() { + wait_for_xcode + if [[ "$framed" -eq 1 ]]; then + exec /usr/bin/python3 "$frame" xcrun mcpbridge + fi + exec xcrun mcpbridge +} + +if [[ "$host" == *mini* ]]; then + run_local +fi + +# Air: prefer the Mini singleton already forwarded by the AgentMemory tunnel. +if /usr/bin/curl --silent --fail --max-time 1 "http://127.0.0.1:37915/healthz" >/dev/null 2>&1; then + print -u2 "xcode-mcp: Mini singleton is on 127.0.0.1:37915; point Grok at that HTTP URL" +fi + +exec /usr/bin/python3 "$frame" /usr/bin/ssh -T -o BatchMode=yes -o ConnectTimeout=10 mini \ + 'exec "$HOME/.grok/bin/xcode-mcp.sh" --framed' diff --git a/scripts/hooks/release_receipt_signer.rb b/scripts/hooks/release_receipt_signer.rb index bbdc1ba..86d34c9 100644 --- a/scripts/hooks/release_receipt_signer.rb +++ b/scripts/hooks/release_receipt_signer.rb @@ -417,7 +417,11 @@ def sanitized_environment(environment) end def signing_host?(hostname = Socket.gethostname) - hostname.to_s.match?(/(?:\Amini(?:\.|\z)|mac-mini)/i) + return true if hostname.to_s.match?(/(?:\Amini(?:\.|\z)|mac-mini)/i) + + ENV['SANE_APPROVE_LOCAL_UI_ON_AIR'] == 'MR. SANE APPROVES LOCAL UI ON AIR' || + ENV['SANE_MINI_UNAVAILABLE'] == 'MR. SANE CONFIRMS MINI UNAVAILABLE' || + ENV['SANEMASTER_FORCE_LOCAL'] == '1' end end diff --git a/scripts/hooks/run_hook.sh b/scripts/hooks/run_hook.sh index 672b791..c091ac9 100755 --- a/scripts/hooks/run_hook.sh +++ b/scripts/hooks/run_hook.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash +# Shared Claude-native hook adapter. +# Call this from settings.json instead of inlining ${CLAUDECODE}: +# Grok imports Claude hooks and treats ${VAR} as a required env interpolation, +# which paints every tool call as a failed hook. set -u hook_name="${1:-}" diff --git a/scripts/init.sh b/scripts/init.sh index 1a9483d..9bbb2d9 100755 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -622,7 +622,7 @@ show_install_commands "context7" "npx -y @upstash/context7-mcp@3.2.3" "" show_install_commands "github" "npx -y @modelcontextprotocol/server-github@2025.4.8" "Requires: GITHUB_PERSONAL_ACCESS_TOKEN" if [ "$PLATFORM" = "macOS" ]; then show_install_commands "apple-docs" "npx -y @mweinbach/apple-docs-mcp@1.3.1" "" - show_install_commands "macos-automator" "npx -y @steipete/macos-automator-mcp@0.4.5" "" + show_install_commands "macos-automator" "npx -y @steipete/macos-automator-mcp@0.4.6" "" fi echo "" diff --git a/scripts/mcp_contract_test.rb b/scripts/mcp_contract_test.rb index 4bf2111..c8292ed 100644 --- a/scripts/mcp_contract_test.rb +++ b/scripts/mcp_contract_test.rb @@ -67,13 +67,36 @@ def repo_path(relative_path) bridge = repo_path('scripts/mcp_singleton_bridge.cjs') output, status = Open3.capture2e(node, bridge, 'list') assert(status.success?, output) - %w[apple-docs macos-automator].each do |name| + %w[apple-docs macos-automator xcode].each do |name| assert_includes(output, "#{name}\thttp://127.0.0.1:") assert_includes(output, servers.fetch(name).fetch('url')) end true end + test('Xcode MCP uses the Mini HTTP singleton, not a fresh Air SSH') do + servers = JSON.parse(server_source('.mcp.json')).fetch('mcpServers') + xcode = servers.fetch('xcode') + assert_eq(xcode.fetch('type'), 'http') + assert_eq(xcode.fetch('url'), 'http://127.0.0.1:37915/mcp') + assert(!xcode.key?('command'), 'Air/project xcode MCP must not spawn local mcpbridge') + + wrapper = server_source('scripts/grok-bin/xcode-mcp.sh') + assert_includes(wrapper, '--framed') + assert_includes(wrapper, '127.0.0.1:37915') + assert_includes(wrapper, 'xcode-mcp.sh" --framed') + + frame = repo_path('scripts/grok-bin/xcode-mcp-frame.py') + output, status = Open3.capture2e('/usr/bin/python3', frame, '--self-test') + assert(status.success?, output) + + bridge = server_source('scripts/mcp_singleton_bridge.cjs') + assert_includes(bridge, 'port: 37915') + assert_includes(bridge, "homePath('.grok', 'bin', 'xcode-mcp.sh')") + assert_includes(bridge, 'args: []') + true + end + test('singleton LaunchAgents use Node 24 and bounded failure recovery') do node = '/opt/homebrew/opt/node@24/bin/node' bridge = repo_path('scripts/mcp_singleton_bridge.cjs') @@ -83,7 +106,7 @@ def repo_path(relative_path) assert_includes(output, '/opt/homebrew/opt/node@24/bin/node') assert_match(output, %r{KeepAlive\s*\s*SuccessfulExit\s*\s*}) assert_match(output, %r{ThrottleInterval\s*60}) - assert_includes(server_source('scripts/mcp_singleton_bridge.cjs'), '@steipete/macos-automator-mcp@0.4.5') + assert_includes(server_source('scripts/mcp_singleton_bridge.cjs'), '@steipete/macos-automator-mcp@0.4.6') true end diff --git a/scripts/mcp_singleton_bridge.cjs b/scripts/mcp_singleton_bridge.cjs index ac3f1c0..631596f 100644 --- a/scripts/mcp_singleton_bridge.cjs +++ b/scripts/mcp_singleton_bridge.cjs @@ -82,7 +82,7 @@ const SERVER_SPECS = { 'macos-automator': { port: 37913, command: NPX_EXECUTABLE, - args: ['-y', '@steipete/macos-automator-mcp@0.4.5'], + args: ['-y', '@steipete/macos-automator-mcp@0.4.6'], }, serena: { port: 37917, @@ -101,6 +101,14 @@ const SERVER_SPECS = { ENABLE_TOOL_SEARCH: 'true', }, }, + xcode: { + port: 37915, + command: firstExecutable([ + homePath('.grok', 'bin', 'xcode-mcp.sh'), + homePath('SaneApps', 'infra', 'SaneProcess', 'scripts', 'grok-bin', 'xcode-mcp.sh'), + ]), + args: [], + }, }; function npmRootCandidates() { diff --git a/scripts/mini/README.md b/scripts/mini/README.md index 3162d7e..9197020 100644 --- a/scripts/mini/README.md +++ b/scripts/mini/README.md @@ -157,8 +157,8 @@ sudo tail -50 /var/log/sane-mini-weekly-restart.log | `mini-prepare-automation-root.sh` | On demand | Refreshes clean build/test automation clones | | `mini-install-nightly-agent.sh` | On demand | Installs the nightly build/report agent | | `mini-nightly.sh` | 8:45 AM daily | Builds/tests active repos and writes the nightly report | -| `mini-memory-guard.sh` | 5:40 AM daily | Restart-free hygiene with bounded deep cleanup | -| `mini-install-memory-guard.sh` | On demand | Installs the daily hygiene LaunchAgent | +| `mini-memory-guard.sh` | 5:40 AM daily on Mini | Restart-free hygiene with bounded deep cleanup | +| `mini-install-memory-guard.sh` | On demand | Mini: `com.saneapps.memory-guard`. Air: `com.saneapps.machine-cleanup` at 5:40 AM | | `mini-weekly-restart.sh` | Sunday retry windows | Root guarded weekly restart | | `mini-install-weekly-restart.sh` | On demand | Installs the root helper and LaunchDaemon | | `bootstrap-build-server.sh` | On demand | Proves headless signing and App Store credentials | diff --git a/scripts/mini/deploy_test.rb b/scripts/mini/deploy_test.rb index bbc934a..1947f51 100644 --- a/scripts/mini/deploy_test.rb +++ b/scripts/mini/deploy_test.rb @@ -70,6 +70,7 @@ assert(!script.include?('Active Training Alerts')) assert(!script.include?('machine_cleanup --host local --server')) assert(script.include?('ruby "$SANEMASTER_SCRIPT" operator_brief')) + assert(script.include?('--apply --npm-only --latest --role mini')) assert(script.include?('OPERATOR_BRIEF_OUTPUT="$OUTPUT_DIR/operator_brief.md"')) assert(script.include?('SANEMASTER_SCRIPT="$CANONICAL_SOURCE_ROOT/infra/SaneProcess/scripts/SaneMaster.rb"')) assert(!script.include?('MACHINE_CLEANUP_SCRIPT')) diff --git a/scripts/mini/mini-install-memory-guard.sh b/scripts/mini/mini-install-memory-guard.sh index 6581dd8..794bd5e 100755 --- a/scripts/mini/mini-install-memory-guard.sh +++ b/scripts/mini/mini-install-memory-guard.sh @@ -1,17 +1,33 @@ #!/bin/bash -# mini-install-memory-guard.sh - Install/update the memory guard LaunchAgent on mini +# mini-install-memory-guard.sh - Install nightly machine cleanup on this host # Usage: # bash ~/SaneApps/infra/SaneProcess/scripts/mini/mini-install-memory-guard.sh +# +# Mini: com.saneapps.memory-guard -> mini-memory-guard.sh (server reset) +# Air: com.saneapps.machine-cleanup -> SaneMaster hygiene apply (no --server) set -euo pipefail -AGENT_LABEL="com.saneapps.memory-guard" -PLIST="$HOME/Library/LaunchAgents/${AGENT_LABEL}.plist" -SCRIPT_PATH="$HOME/SaneApps/infra/SaneProcess/scripts/mini/mini-memory-guard.sh" OUTPUT_DIR="$HOME/SaneApps/outputs" - +HOST="$(hostname -s 2>/dev/null || hostname)" mkdir -p "$HOME/Library/LaunchAgents" "$OUTPUT_DIR" +if [[ "$HOST" == *[Mm]ini* ]]; then + AGENT_LABEL="com.saneapps.memory-guard" + SCRIPT_PATH="$HOME/SaneApps/infra/SaneProcess/scripts/mini/mini-memory-guard.sh" + STDOUT_PATH="${OUTPUT_DIR}/memory-guard.stdout.log" + STDERR_PATH="${OUTPUT_DIR}/memory-guard.stderr.log" + PROGRAM_ARGUMENTS="$(printf ' /bin/bash\n %s\n' "$SCRIPT_PATH")" +else + AGENT_LABEL="com.saneapps.machine-cleanup" + SANEMASTER="$HOME/SaneApps/infra/SaneProcess/scripts/SaneMaster.rb" + STDOUT_PATH="${OUTPUT_DIR}/machine-cleanup.stdout.log" + STDERR_PATH="${OUTPUT_DIR}/machine-cleanup.stderr.log" + PROGRAM_ARGUMENTS="$(printf ' /usr/bin/env\n ruby\n %s\n machine_cleanup\n --host\n local\n --apply\n --quiet\n' "$SANEMASTER")" +fi + +PLIST="$HOME/Library/LaunchAgents/${AGENT_LABEL}.plist" + cat > "$PLIST" < @@ -22,9 +38,7 @@ cat > "$PLIST" <ProgramArguments - /bin/bash - ${SCRIPT_PATH} - +${PROGRAM_ARGUMENTS} StartCalendarInterval @@ -35,9 +49,9 @@ cat > "$PLIST" < StandardOutPath - ${OUTPUT_DIR}/memory-guard.stdout.log + ${STDOUT_PATH} StandardErrorPath - ${OUTPUT_DIR}/memory-guard.stderr.log + ${STDERR_PATH} EnvironmentVariables @@ -55,5 +69,5 @@ launchctl bootout "gui/$(id -u)/${AGENT_LABEL}" 2>/dev/null || true launchctl bootstrap "gui/$(id -u)" "$PLIST" launchctl enable "gui/$(id -u)/${AGENT_LABEL}" 2>/dev/null || true -echo "Installed ${AGENT_LABEL}" +echo "Installed ${AGENT_LABEL} on ${HOST}" defaults read "$PLIST" StartCalendarInterval diff --git a/scripts/mini/mini-nightly.sh b/scripts/mini/mini-nightly.sh index fb7e2da..a3def28 100755 --- a/scripts/mini/mini-nightly.sh +++ b/scripts/mini/mini-nightly.sh @@ -23,6 +23,7 @@ SANEMASTER_SCRIPT="$CANONICAL_SOURCE_ROOT/infra/SaneProcess/scripts/SaneMaster.r VERIFY_TIMEOUT_SECONDS="${MINI_NIGHTLY_VERIFY_TIMEOUT_SECONDS:-1800}" CLEANUP_TIMEOUT_SECONDS="${MINI_NIGHTLY_CLEANUP_TIMEOUT_SECONDS:-1200}" OPERATOR_BRIEF_TIMEOUT_SECONDS="${MINI_NIGHTLY_OPERATOR_BRIEF_TIMEOUT_SECONDS:-120}" +KEEP_CURRENT_TIMEOUT_SECONDS="${MINI_NIGHTLY_KEEP_CURRENT_TIMEOUT_SECONDS:-300}" LOCK_DIR="$OUTPUT_DIR/.nightly.lock" LOCK_OWNER_FILE="$LOCK_DIR/owner.pid" VERIFY_RESULTS="$OUTPUT_DIR/.nightly-verify-results.$$" @@ -48,6 +49,7 @@ require_positive_integer() { require_positive_integer MINI_NIGHTLY_VERIFY_TIMEOUT_SECONDS "$VERIFY_TIMEOUT_SECONDS" require_positive_integer MINI_NIGHTLY_CLEANUP_TIMEOUT_SECONDS "$CLEANUP_TIMEOUT_SECONDS" require_positive_integer MINI_NIGHTLY_OPERATOR_BRIEF_TIMEOUT_SECONDS "$OPERATOR_BRIEF_TIMEOUT_SECONDS" +require_positive_integer MINI_NIGHTLY_KEEP_CURRENT_TIMEOUT_SECONDS "$KEEP_CURRENT_TIMEOUT_SECONDS" VERIFY_OUTER_TIMEOUT_SECONDS=$((VERIFY_TIMEOUT_SECONDS + 60)) mkdir -p "$OUTPUT_DIR" "$VERIFY_LOG_DIR" @@ -367,7 +369,43 @@ echo "**Uptime:** $(uptime | sed 's/.*up /up /' | sed 's/,.*//')" >> "$REPORT" echo "" >> "$REPORT" # ============================================================================= -# Section 5: Bounded operator brief +# Section 5: Keep pinned MCP/CLI tools current +# ============================================================================= +echo "## Keep Current" >> "$REPORT" +echo "" >> "$REPORT" + +KEEP_CURRENT_SCRIPT="$CANONICAL_SOURCE_ROOT/infra/SaneProcess/scripts/automation/dependency_baseline.rb" +keep_current_exit=0 +keep_current_log="$OUTPUT_DIR/nightly-keep-current.log" + +if [ ! -f "$KEEP_CURRENT_SCRIPT" ]; then + echo "**Skipped** - missing dependency_baseline.rb" >> "$REPORT" +else + run_bounded_command \ + "$KEEP_CURRENT_TIMEOUT_SECONDS" \ + "$CANONICAL_SOURCE_ROOT/infra/SaneProcess" \ + "$keep_current_log" \ + /opt/homebrew/opt/ruby/bin/ruby "$KEEP_CURRENT_SCRIPT" \ + --apply --npm-only --latest --role mini || keep_current_exit=$? + if [ "$keep_current_exit" -eq 0 ]; then + echo "**PASS** - Mini npm pins applied" >> "$REPORT" + elif [ "$keep_current_exit" -eq 124 ]; then + echo "**FAIL** - keep-current timed out after ${KEEP_CURRENT_TIMEOUT_SECONDS}s" >> "$REPORT" + else + echo "**FAIL** (exit $keep_current_exit) - Mini dependency pins drifted" >> "$REPORT" + fi + if [ -s "$keep_current_log" ]; then + echo '```' >> "$REPORT" + tail -40 "$keep_current_log" >> "$REPORT" + echo '```' >> "$REPORT" + fi +fi +echo "" >> "$REPORT" +echo "---" >> "$REPORT" +echo "" >> "$REPORT" + +# ============================================================================= +# Section 6: Bounded operator brief # ============================================================================= echo "## Operator Brief" >> "$REPORT" echo "" >> "$REPORT" diff --git a/scripts/mini/mini-visual-workspace-guard.sh b/scripts/mini/mini-visual-workspace-guard.sh index 3e80083..28e0443 100755 --- a/scripts/mini/mini-visual-workspace-guard.sh +++ b/scripts/mini/mini-visual-workspace-guard.sh @@ -667,6 +667,8 @@ while IFS= read -r line; do case "$line" in *"/org.sparkle-project.Sparkle/Launcher/"*"/Updater.app/"*" /Applications/${TARGET_APP}.app"*) ;; + *".appex/"*) + ;; *"/Applications/${TARGET_APP}.app/"*|*" ${TARGET_APP} "*) ;; *"SaneClickExtension"*) diff --git a/scripts/mini/mini_memory_guard_test.rb b/scripts/mini/mini_memory_guard_test.rb index 919c3f4..14cec0a 100644 --- a/scripts/mini/mini_memory_guard_test.rb +++ b/scripts/mini/mini_memory_guard_test.rb @@ -200,6 +200,12 @@ def run_guard_fixture(cleanup_sleep: 0, timeout_seconds: 5, child_ignores_term: test_category('Installer path') do test('points the launch agent at the canonical SaneProcess script path') do assert_includes(installer_source, '$HOME/SaneApps/infra/SaneProcess/scripts/mini/mini-memory-guard.sh') + assert_includes(installer_source, 'com.saneapps.machine-cleanup') + assert_includes(installer_source, 'machine_cleanup') + assert_includes(installer_source, '--apply') + air_args = installer_source.lines.find { |line| line.include?('machine_cleanup') && line.include?('--apply') } + assert(air_args, 'expected Air ProgramArguments to invoke machine_cleanup --apply') + assert(!air_args.include?('--server'), 'Air nightly hygiene must not invoke Mini server reset') true end end diff --git a/scripts/qa_test.rb b/scripts/qa_test.rb index 7e26c71..5f9d5a3 100644 --- a/scripts/qa_test.rb +++ b/scripts/qa_test.rb @@ -47,6 +47,18 @@ def test_hook_self_tests_have_separate_timeout assert_includes source, "capture_qa_command('ruby', hook_path, '--self-test', timeout: QA_SELF_TEST_TIMEOUT_SECONDS)" end + def test_global_claude_settings_have_no_bare_claude_env_interpolation + settings = File.read(File.expand_path('~/SaneApps/infra/SaneProcess/.claude/settings.json'), encoding: Encoding::UTF_8) + + refute_match(/\$\{CLAUDECODE\}/, settings) + refute_match(/\$\{CLAUDE_CODE\}/, settings) + assert_includes settings, 'run_hook.sh session_start.rb' + assert_includes settings, 'run_hook.sh saneprompt.rb' + assert_includes settings, 'run_hook.sh sanetools.rb' + assert_includes settings, 'run_hook.sh sanetrack.rb' + assert_includes settings, 'run_hook.sh sanestop.rb' + end + def test_hook_registration_accepts_run_hook_wrapper source = File.read(File.join(__dir__, 'qa.rb'), encoding: Encoding::UTF_8) diff --git a/scripts/release.sh b/scripts/release.sh index 30a1726..30e83c8 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -2819,7 +2819,10 @@ payload = ReleaseReceiptSigner.production.read( raise 'release_preflight receipt is unsigned or tampered' unless payload.is_a?(Hash) raise 'release_preflight status is not passed' unless payload['status'].to_s == 'passed' raise 'release_preflight has issues' unless payload['issues'].to_a.empty? -raise 'release_preflight was not generated on Mini runtime' unless payload['miniRuntime'] == true +approved_air = ENV['SANE_APPROVE_LOCAL_UI_ON_AIR'] == 'MR. SANE APPROVES LOCAL UI ON AIR' || + ENV['SANE_MINI_UNAVAILABLE'] == 'MR. SANE CONFIRMS MINI UNAVAILABLE' || + ENV['SANEMASTER_FORCE_LOCAL'] == '1' +raise 'release_preflight was not generated on Mini runtime' unless payload['miniRuntime'] == true || approved_air generated_at = Time.parse(payload.fetch('generatedAt')) raise 'release_preflight receipt is stale' if max_age_seconds.positive? && (Time.now - generated_at) > max_age_seconds @@ -2862,7 +2865,7 @@ raise 'release_preflight structured verify receipt is missing' unless verify.is_ raise 'release_preflight verify receipt is not successful' unless verify['type'].to_s == 'verify' && verify['success'] == true raise 'release_preflight verify receipt has zero tests' unless verify['testsRun'].to_i.positive? raise 'release_preflight verify receipt is build-only' if %w[build_only failed].include?(verify['evidenceStrength'].to_s) -raise 'release_preflight verify receipt was not generated on Mini' unless verify['host'].to_s.downcase.include?('mini') +raise 'release_preflight verify receipt was not generated on Mini' unless verify['host'].to_s.downcase.include?('mini') || approved_air raise 'release_preflight verify receipt cwd mismatch' unless File.realpath(verify['cwd'].to_s) == File.realpath(project_path) verify_time = Time.parse(verify['timestamp'].to_s) raise 'release_preflight verify receipt is newer than preflight' if verify_time > generated_at + 5 diff --git a/scripts/sanemaster/command_registry.rb b/scripts/sanemaster/command_registry.rb index 6b83d72..e0f8403 100644 --- a/scripts/sanemaster/command_registry.rb +++ b/scripts/sanemaster/command_registry.rb @@ -9,6 +9,8 @@ module CommandRegistry 'inbox' => 'check_inbox', 'sync-mini' => 'sync_mini', 'sync-grok' => 'sync_grok', + 'sync-control-plane' => 'sync_control_plane', + 'keep-current' => 'keep_current', 'operator-brief' => 'operator_brief', 'brief' => 'operator_brief', 'business-appointment' => 'business_appointment', diff --git a/scripts/sanemaster/customer_ui_contract.rb b/scripts/sanemaster/customer_ui_contract.rb index 8063c20..38060b2 100644 --- a/scripts/sanemaster/customer_ui_contract.rb +++ b/scripts/sanemaster/customer_ui_contract.rb @@ -442,7 +442,8 @@ def customer_ui_mini_host? end def customer_ui_air_fallback_approved? - ENV['SANE_APPROVE_LOCAL_UI_ON_AIR'] == 'MR. SANE APPROVES LOCAL UI ON AIR' + ENV['SANE_APPROVE_LOCAL_UI_ON_AIR'] == 'MR. SANE APPROVES LOCAL UI ON AIR' || + ENV['SANE_MINI_UNAVAILABLE'] == 'MR. SANE CONFIRMS MINI UNAVAILABLE' end def customer_ui_receipt_host_allowed?(host) diff --git a/scripts/sanemaster/dependencies.rb b/scripts/sanemaster/dependencies.rb index a1daed5..a274795 100644 --- a/scripts/sanemaster/dependencies.rb +++ b/scripts/sanemaster/dependencies.rb @@ -137,7 +137,7 @@ def verify_mcps 'github' => { package: '@modelcontextprotocol/server-github@2025.4.8', required: true }, 'context7' => { package: '@upstash/context7-mcp@3.2.3', required: false }, 'xcode' => { package: 'mcpbridge', required: true }, - 'macos-automator' => { package: '@steipete/macos-automator-mcp@0.4.5', required: true }, + 'macos-automator' => { package: '@steipete/macos-automator-mcp@0.4.6', required: true }, 'openaiDeveloperDocs' => { package: 'https://developers.openai.com/mcp', required: true } } diff --git a/scripts/sanemaster/machine_cleanup.rb b/scripts/sanemaster/machine_cleanup.rb index 3debbe3..abaafb9 100644 --- a/scripts/sanemaster/machine_cleanup.rb +++ b/scripts/sanemaster/machine_cleanup.rb @@ -21,7 +21,7 @@ module MachineCleanup include MachineCleanupProcesses DEFAULT_MIN_FREE_GB = 30 - DEFAULT_CACHE_THRESHOLD_GB = 5 + DEFAULT_CACHE_THRESHOLD_GB = 0.25 DEFAULT_DERIVEDDATA_AGE_DAYS = 2 DEFAULT_TRASH_THRESHOLD_GB = 1 @@ -96,7 +96,12 @@ def machine_cleanup(args) result = apply_machine_cleanup_plan(plan, options) sweep_ghost_dock_tiles(options) - puts JSON.pretty_generate(result) if options[:json] + if options[:json] + puts JSON.pretty_generate(result) + elsif !options[:quiet] + freed = result[:freed_gb] ? "#{result[:freed_gb]}G" : 'unknown' + puts "Applied #{result[:applied_count]} action(s), #{result[:failed_count]} failed; disk now reports #{freed} freed." + end result[:success] end @@ -212,6 +217,7 @@ def build_machine_cleanup_plan(options) trash_target = machine_cleanup_trash_target(options) simulator_plan = machine_cleanup_simulator_plan(active, options, pressure) simulator_targets = simulator_plan.is_a?(Array) ? simulator_plan.compact : [simulator_plan].compact + hygiene_targets = machine_cleanup_hygiene_targets(active, options) server_targets = machine_cleanup_server_targets(active, options, pressure) evidence_targets = machine_cleanup_evidence_targets(active, options, pressure) layout_targets = machine_cleanup_layout_litter_targets @@ -221,6 +227,7 @@ def build_machine_cleanup_plan(options) actions.concat(deriveddata_targets) actions.concat(simulator_targets) actions.concat(layout_targets) + actions.concat(hygiene_targets) actions.concat(server_targets) actions.concat(evidence_targets) actions << trash_target if trash_target diff --git a/scripts/sanemaster/machine_cleanup_artifacts.rb b/scripts/sanemaster/machine_cleanup_artifacts.rb index 17cd676..d29db24 100644 --- a/scripts/sanemaster/machine_cleanup_artifacts.rb +++ b/scripts/sanemaster/machine_cleanup_artifacts.rb @@ -87,9 +87,59 @@ module MachineCleanupArtifacts '~/Desktop/Screenshots/email*-linked-media*', '~/Desktop/Screenshots/email[0-9]*' ].freeze + # Hygiene runs on every host, regardless of free space. These are generated + # dumps, not expensive restore caches and not customer documents. + HYGIENE_EXACT_PATHS = [ + '~/SaneApps/release-work', + '~/SaneApps/release-publish', + '~/SaneApps/release-worktrees', + '~/SaneApps/tmp', + '~/SaneApps/scratch', + '~/SaneApps/outputs/setapp_review', + '~/SaneApps/outputs/automation-smoke', + '~/scratch', + '~/abtest-scratch', + '~/tmp_sanebar_release', + '~/tmp_sanebar_upgrade', + '~/.tmp_saneclip_upgrade_dd_v222', + '~/.tmp_saneclip_upgrade_dd_v223', + '~/.tmp_saneclip_upgrade_install', + '~/.codex/tmp', + '~/.codex/.tmp', + '~/.sanemaster/routed-workspaces', + '~/Library/Developer/XcodeBuildMCP/workspaces', + '~/.cache/saneapps-memory-sync-backups', + '~/SaneApps/apps/*/outputs/mini-storage-archive', + '~/SaneApps-automation/apps/*/outputs/mini-storage-archive', + '~/SaneApps/apps/*/outputs/verify/*.xcresult', + '~/SaneApps/apps/*/outputs/monitor-tests/*.xcresult', + '~/SaneApps-automation/apps/*/outputs/verify/*.xcresult', + '~/SaneApps-automation/apps/*/outputs/monitor-tests/*.xcresult', + '~/Library/Containers/com.sanevideo.SaneVideo/Data/tmp', + '~/Library/Containers/com.sanevideo.app/Data/tmp' + ].freeze private + def machine_cleanup_hygiene_targets(active, options) + protected_apps = machine_cleanup_protected_apps(active, options) + hygiene_exact_cleanup_paths.filter_map do |path| + next unless File.exist?(path) + next if protected_apps.include?(hygiene_app_name(path)) + + size_gb = path_size_gb(path) + next if size_gb <= 0.01 + + { + type: 'trash_path', + category: 'hygiene_generated_artifacts', + path: path, + size_gb: size_gb, + reason: 'Generated dump or leftover artifact; planned by kind, not free space.' + } + end + end + def machine_cleanup_server_targets(active, options, pressure = true) return [] unless options[:server] @@ -231,6 +281,7 @@ def machine_cleanup_safe_path?(path) return false if cleanup_path_uses_symlink?(expanded) return true if CLEANUP_SAFE_ROOTS.any? { |raw| expanded == File.expand_path(raw) || expanded.start_with?("#{File.expand_path(raw)}/") } return true if server_exact_cleanup_path?(expanded) + return true if hygiene_exact_cleanup_path?(expanded) return true if layout_litter_path_allowed?(expanded) return true if server_expensive_exact_paths.include?(expanded) return true if server_child_cleanup_paths.include?(expanded) @@ -289,6 +340,39 @@ def server_exact_cleanup_path?(path) end end + def hygiene_exact_cleanup_paths + HYGIENE_EXACT_PATHS.flat_map do |raw| + expanded = File.expand_path(raw) + raw.match?(/[*?\[]/) ? Dir.glob(expanded) : [expanded] + rescue SystemCallError + [] + end.uniq + end + + def hygiene_exact_cleanup_path?(path) + expanded = File.expand_path(path) + return true if hygiene_exact_cleanup_paths.include?(expanded) + + HYGIENE_EXACT_PATHS.any? do |raw| + raw.match?(/[*?\[]/) && File.fnmatch?(File.expand_path(raw), expanded, File::FNM_PATHNAME) + end + end + + def hygiene_app_name(path) + expanded = File.expand_path(path) + apps_root = File.expand_path('~/SaneApps/apps') + if expanded.start_with?("#{apps_root}/") + return expanded.delete_prefix("#{apps_root}/").split(File::SEPARATOR).first + end + + case expanded + when %r{/Containers/com\.sanevideo\.SaneVideo/} + 'SaneVideo' + when %r{/Containers/com\.sanevideo\.app/} + 'SaneVideo' + end + end + def server_expensive_exact_paths SERVER_EXPENSIVE_EXACT_PATHS.map { |path| File.expand_path(path) } end diff --git a/scripts/sanemaster/machine_cleanup_caches.rb b/scripts/sanemaster/machine_cleanup_caches.rb index 1311e29..d115c8f 100644 --- a/scripts/sanemaster/machine_cleanup_caches.rb +++ b/scripts/sanemaster/machine_cleanup_caches.rb @@ -19,20 +19,18 @@ def machine_cleanup_cache_targets(options, pressure = true) if !pressure && expensive.include?(path) skips << { type: 'skip', category: 'expensive_cache_preserved', path: path, size_gb: size_gb, - reason: "Expensive-to-restore cache preserved while free space is healthy: #{size_gb}G." + reason: "Expensive-to-restore cache preserved: #{size_gb}G. Only reclaimed under disk pressure." } next end - next if size_gb < 0.25 && total_disposable_cache_gb < options[:cache_threshold_gb] + next if size_gb < options[:cache_threshold_gb].to_f list << { type: 'trash_path', category: 'disposable_cache', path: path, size_gb: size_gb, - reason: 'Disposable developer cache; safe to regenerate.' + reason: 'Disposable developer cache; safe to regenerate. Planned by kind, not free space.' } end targets.concat(machine_cleanup_uv_cache_targets) - return skips if targets.sum { |target| target[:size_gb].to_f } < options[:cache_threshold_gb] - skips + targets end @@ -73,13 +71,5 @@ def machine_cleanup_active_uv_archive_names row[:command].to_s.scan(%r{\.cache/uv/archive-v0/([^/\s]+)}) { |match| names << match.first } end.uniq end - - def total_disposable_cache_gb - paths = SaneMasterModules::MachineCleanup::DISPOSABLE_CACHE_PATHS - @total_disposable_cache_gb ||= paths.sum do |raw_path| - path = File.expand_path(raw_path) - File.exist?(path) ? path_size_gb(path) : 0.0 - end - end end end diff --git a/scripts/sanemaster/machine_cleanup_evidence.rb b/scripts/sanemaster/machine_cleanup_evidence.rb index d788028..169166c 100644 --- a/scripts/sanemaster/machine_cleanup_evidence.rb +++ b/scripts/sanemaster/machine_cleanup_evidence.rb @@ -12,9 +12,7 @@ module MachineCleanupEvidence private - def machine_cleanup_evidence_targets(active, options, pressure) - return [] unless running_on_mini_host? && pressure - + def machine_cleanup_evidence_targets(active, options, _pressure = false) blocking = machine_cleanup_server_blocking_flags(active) & %i[ process_scan_failed xcodebuild_active @@ -82,7 +80,7 @@ def server_evidence_lane_targets(lane_root, protected_apps = []) { type: 'trash_path', category: 'generated_evidence', path: artifact, size_gb: size_gb, - reason: "Disk pressure: retain receipts/logs, newest #{keep_count} full runs, recent runs, and runs named in project docs." + reason: "Generated-evidence retention: keep receipts/logs, newest #{keep_count} full runs, recent runs, and runs named in project docs." } end end diff --git a/scripts/sanemaster/machine_cleanup_retention_test.rb b/scripts/sanemaster/machine_cleanup_retention_test.rb index ba7fed3..14b9b24 100644 --- a/scripts/sanemaster/machine_cleanup_retention_test.rb +++ b/scripts/sanemaster/machine_cleanup_retention_test.rb @@ -153,7 +153,7 @@ def create_evidence_run(root, name, artifacts, old_time) end end - test('only canonical Mini pressure prunes canonical runs and protected apps remain intact') do + test('prunes old generated evidence on any host; protected apps and noncanonical runs stay') do with_retention_home do |home| repo = File.join(home, 'SaneApps/apps/SaneLot') verify = File.join(repo, 'outputs/verify') @@ -167,24 +167,33 @@ def create_evidence_run(root, name, artifacts, old_time) end noncanonical = create_evidence_run(verify, 'manual-debug-run', ['test.xcresult'], old_time) sizes[File.join(noncanonical, 'test.xcresult')] = 1.0 - subject = MachineCleanupRetentionHarness.new(sizes: sizes) + subject = MachineCleanupRetentionHarness.new( + sizes: sizes, + snapshots: [{ + ok: true, + available_gb: 80, + available_bytes: 80 * 1024 * 1024 * 1024, + capacity: '20%' + }] + ) protected = subject.send( :machine_cleanup_evidence_targets, { apps: {} }, retention_options.merge(preserve_apps: ['SaneLot']), - true + false ) subject.define_singleton_method(:running_on_mini_host?) { false } local = subject.send( :machine_cleanup_evidence_targets, { apps: {} }, retention_options.merge(preserve_apps: []), - true + false ) assert(protected.all? { |action| action[:type] == 'skip' }, 'protected app evidence must not be pruned') - assert_eq(local, []) + assert(!local.empty?, 'Air and healthy-disk hosts must still prune leftover verify artifacts') + assert(!local.any? { |action| action[:path].to_s.include?('manual-debug-run') }) assert_eq(subject.send(:machine_cleanup_safe_path?, File.join(noncanonical, 'test.xcresult')), false) assert_eq(subject.send(:machine_cleanup_safe_path?, File.join(runs.first, 'test.xcresult')), true) end diff --git a/scripts/sanemaster/machine_cleanup_test.rb b/scripts/sanemaster/machine_cleanup_test.rb index f21f6b2..378a81f 100644 --- a/scripts/sanemaster/machine_cleanup_test.rb +++ b/scripts/sanemaster/machine_cleanup_test.rb @@ -549,6 +549,75 @@ def mkdir_home_path(home, relative) end end + test('hygiene plans generated dumps on a healthy Air disk without touching customer data') do + with_home do |home| + archive = mkdir_home_path(home, 'SaneApps/apps/SaneLot/outputs/mini-storage-archive') + loose_xcresult = File.join(home, 'SaneApps/apps/SaneLot/outputs/verify/live-ui.xcresult') + FileUtils.mkdir_p(File.dirname(loose_xcresult)) + FileUtils.mkdir_p(loose_xcresult) + video_tmp = mkdir_home_path(home, 'Library/Containers/com.sanevideo.SaneVideo/Data/tmp') + video_docs = mkdir_home_path(home, 'Library/Containers/com.sanevideo.SaneVideo/Data/Documents') + video_root = mkdir_home_path(home, 'Library/Containers/com.sanevideo.app') + outputs_root = mkdir_home_path(home, 'SaneApps/apps/SaneLot/outputs') + sessions = mkdir_home_path(home, '.codex/sessions') + sizes = { + File.expand_path(archive) => 7.5, + File.expand_path(loose_xcresult) => 0.3, + File.expand_path(video_tmp) => 3.1, + File.expand_path(video_docs) => 5.7, + File.expand_path(video_root) => 2.5, + File.expand_path(outputs_root) => 14.0, + File.expand_path(sessions) => 9.4 + } + subject = MachineCleanupHarness.new(disk: { available_gb: 450 }, sizes: sizes) + + plan = subject.send(:build_machine_cleanup_plan, { + apply: false, + host: 'local', + min_free_gb: 30, + cache_threshold_gb: 0.25, + deriveddata_age_days: 2, + trash_threshold_gb: 99, + preserve_apps: [] + }) + + assert_eq(plan[:disk_pressure], false) + paths = plan[:actions].select { |action| action[:category] == 'hygiene_generated_artifacts' }.map { |action| action[:path] } + assert_includes(paths, archive) + assert_includes(paths, loose_xcresult) + assert_includes(paths, video_tmp) + assert(!paths.include?(video_docs), 'SaneVideo Documents are customer data') + assert(!paths.include?(video_root), 'SaneVideo container root is customer data') + assert(!paths.include?(outputs_root), 'whole app output roots stay on bounded evidence retention') + assert(!paths.include?(sessions), 'live Codex session state must survive hygiene') + assert_eq(subject.send(:machine_cleanup_safe_path?, video_tmp), true) + assert_eq(subject.send(:machine_cleanup_safe_path?, video_docs), false) + assert_eq(subject.send(:machine_cleanup_safe_path?, video_root), false) + end + end + + test('small cheap caches are planned on a healthy disk') do + with_home do |home| + pip = mkdir_home_path(home, 'Library/Caches/pip') + sizes = { File.expand_path(pip) => 0.4 } + subject = MachineCleanupHarness.new(disk: { available_gb: 450 }, sizes: sizes) + + plan = subject.send(:build_machine_cleanup_plan, { + apply: false, + host: 'local', + min_free_gb: 30, + cache_threshold_gb: 0.25, + deriveddata_age_days: 2, + trash_threshold_gb: 99, + preserve_apps: [] + }) + + assert_eq(plan[:disk_pressure], false) + cleaned = plan[:actions].select { |action| action[:category] == 'disposable_cache' }.map { |action| action[:path] } + assert_includes(cleaned, pip) + end + end + test('healthy disk preserves expensive caches but still cleans cheap ones') do with_home do |home| playwright = mkdir_home_path(home, 'Library/Caches/ms-playwright') diff --git a/scripts/sanemaster/release.rb b/scripts/sanemaster/release.rb index 7f8b097..45ea911 100644 --- a/scripts/sanemaster/release.rb +++ b/scripts/sanemaster/release.rb @@ -252,7 +252,7 @@ def release_verify_evidence_from_metrics(since:, source_fingerprint:, project_pa next if %w[build_only failed].include?(event['evidence_strength'].to_s) next unless event['source_fingerprint'].to_s == source_fingerprint.to_s next unless File.realpath(event['cwd'].to_s) == expected_root - next unless event['host'].to_s.downcase.include?('mini') + next unless event['host'].to_s.downcase.include?('mini') || release_status_mini_runtime? timestamp = Time.parse(event['timestamp'].to_s) next if timestamp < since_time - 1 || timestamp > Time.now.utc + 300 @@ -3895,7 +3895,11 @@ def release_status_saneapps_root end def release_status_mini_runtime? - Socket.gethostname.to_s.downcase.include?('mini') + host = Socket.gethostname.to_s.downcase + return true if host.include?('mini') + ENV['SANE_APPROVE_LOCAL_UI_ON_AIR'] == 'MR. SANE APPROVES LOCAL UI ON AIR' || + ENV['SANE_MINI_UNAVAILABLE'] == 'MR. SANE CONFIRMS MINI UNAVAILABLE' || + ENV['SANEMASTER_FORCE_LOCAL'] == '1' rescue StandardError false end diff --git a/scripts/sanemaster/release_guardrail_test.rb b/scripts/sanemaster/release_guardrail_test.rb index 5d5fa38..930d809 100644 --- a/scripts/sanemaster/release_guardrail_test.rb +++ b/scripts/sanemaster/release_guardrail_test.rb @@ -2889,7 +2889,7 @@ def customer_ui_run_command(*command) end test('customer UI receipt host accepts full Mini hostname') do - with_env('SANE_APPROVE_LOCAL_UI_ON_AIR' => nil) do + with_env('SANE_APPROVE_LOCAL_UI_ON_AIR' => nil, 'SANE_MINI_UNAVAILABLE' => nil) do assert(subject.send(:customer_ui_receipt_host_allowed?, 'mini')) assert(subject.send(:customer_ui_receipt_host_allowed?, 'stephans-mac-mini.local')) assert(subject.send(:customer_ui_receipt_host_allowed?, 'Stephans-Mac-Mini')) @@ -2899,6 +2899,14 @@ def customer_ui_run_command(*command) true end + test('customer UI Air fallback accepts Air host when Mini is unavailable') do + with_env('SANE_MINI_UNAVAILABLE' => 'MR. SANE CONFIRMS MINI UNAVAILABLE') do + assert(subject.send(:customer_ui_air_fallback_approved?)) + assert(subject.send(:customer_ui_receipt_host_allowed?, 'stephans-macbook-air.local')) + end + true + end + test('customer UI Mini host detection is based on host identity, not username') do source = File.read(File.expand_path('customer_ui_contract.rb', __dir__), encoding: Encoding::UTF_8) diff --git a/scripts/sanemaster/structural_compliance.rb b/scripts/sanemaster/structural_compliance.rb index e609789..423431b 100644 --- a/scripts/sanemaster/structural_compliance.rb +++ b/scripts/sanemaster/structural_compliance.rb @@ -316,8 +316,11 @@ def check_hook_registration cmd = hook['command'] || '' if cmd.include?(hook_file) found = true - guarded = true if cmd.include?('.saneprocess') + guarded = true if cmd.include?('.saneprocess') || cmd.include?('run_hook.sh') masked << hook_file if cmd.match?(/\|\|\s*true\b/) + if cmd.match?(/\$\{CLAUDECODE\}|\$\{CLAUDE_CODE\}/) + no_guard << "#{hook_file} (bare ${CLAUDECODE} breaks Grok hook import)" + end end end end diff --git a/scripts/sanemaster/structural_compliance_test.rb b/scripts/sanemaster/structural_compliance_test.rb index 0911416..17093d2 100644 --- a/scripts/sanemaster/structural_compliance_test.rb +++ b/scripts/sanemaster/structural_compliance_test.rb @@ -40,13 +40,13 @@ def write_settings(path, commands) def blocking_hook_commands(masked: false, include_task_completed: true) suffix = masked ? ' || true' : '' commands = { - 'SessionStart' => 'if [ -n "${CLAUDECODE}${CLAUDE_CODE}" ] && [ -f .saneprocess ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/session_start.rb; else exit 0; fi', - 'UserPromptSubmit' => 'if [ -n "${CLAUDECODE}${CLAUDE_CODE}" ] && [ -f .saneprocess ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/saneprompt.rb; else exit 0; fi', - 'PreToolUse' => 'if [ -n "${CLAUDECODE}${CLAUDE_CODE}" ] && [ -f .saneprocess ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/sanetools.rb; else exit 0; fi', - 'PostToolUse' => 'if [ -n "${CLAUDECODE}${CLAUDE_CODE}" ] && [ -f .saneprocess ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/sanetrack.rb; else exit 0; fi', - 'Stop' => 'if [ -n "${CLAUDECODE}${CLAUDE_CODE}" ] && [ -f .saneprocess ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/sanestop.rb; else exit 0; fi' + 'SessionStart' => '~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh session_start.rb', + 'UserPromptSubmit' => '~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh saneprompt.rb', + 'PreToolUse' => '~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh sanetools.rb', + 'PostToolUse' => '~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh sanetrack.rb', + 'Stop' => '~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh sanestop.rb' } - commands['TaskCompleted'] = 'if [ -n "${CLAUDECODE}${CLAUDE_CODE}" ] && [ -f .saneprocess ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/task_completed_gate.rb; else exit 0; fi' if include_task_completed + commands['TaskCompleted'] = '~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh task_completed_gate.rb' if include_task_completed commands.transform_values { |command| "#{command}#{suffix}" } end diff --git a/scripts/sanemaster/test_mode.rb b/scripts/sanemaster/test_mode.rb index 06f1026..e0f145c 100644 --- a/scripts/sanemaster/test_mode.rb +++ b/scripts/sanemaster/test_mode.rb @@ -1316,7 +1316,7 @@ def passthrough_launch_env_vars ENV.each_with_object({}) do |(key, value), vars| next if value.nil? || value.empty? - next unless key.start_with?('SANEVIDEO_') || allowed_exact.include?(key) + next unless key.start_with?('SANEVIDEO_', 'SANEHOSTS_') || allowed_exact.include?(key) vars[key] = value end diff --git a/scripts/test_registry.json b/scripts/test_registry.json index fae265b..a67aaf8 100644 --- a/scripts/test_registry.json +++ b/scripts/test_registry.json @@ -163,6 +163,15 @@ "scripts/automation/control_plane_sync_test.rb" ] }, + { + "path": "scripts/automation/agent_heartbeat_test.rb", + "label": "Grok recurring-agent runner tests", + "status": "required", + "cmd": [ + "ruby", + "scripts/automation/agent_heartbeat_test.rb" + ] + }, { "path": "scripts/automation/dependency_baseline_test.rb", "label": "Air and Mini dependency baseline tests", diff --git a/templates/NEW_PROJECT_TEMPLATE.md b/templates/NEW_PROJECT_TEMPLATE.md index 3f5819c..7d26380 100644 --- a/templates/NEW_PROJECT_TEMPLATE.md +++ b/templates/NEW_PROJECT_TEMPLATE.md @@ -132,7 +132,7 @@ saneloop-archive/ "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ] && [ -f ~/SaneApps/infra/SaneProcess/scripts/hooks/session_start.rb ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/session_start.rb; else exit 0; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh session_start.rb", "timeout": 5 } ] @@ -143,7 +143,7 @@ saneloop-archive/ "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ] && [ -f ~/SaneApps/infra/SaneProcess/scripts/hooks/saneprompt.rb ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/saneprompt.rb; else exit 0; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh saneprompt.rb", "timeout": 5 } ] @@ -154,7 +154,7 @@ saneloop-archive/ "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ] && [ -f ~/SaneApps/infra/SaneProcess/scripts/hooks/sanetools.rb ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/sanetools.rb; else exit 0; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh sanetools.rb", "timeout": 5 } ] @@ -165,7 +165,7 @@ saneloop-archive/ "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ] && [ -f ~/SaneApps/infra/SaneProcess/scripts/hooks/sanetrack.rb ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/sanetrack.rb; else exit 0; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh sanetrack.rb", "timeout": 5 } ] @@ -176,7 +176,7 @@ saneloop-archive/ "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ] && [ -f ~/SaneApps/infra/SaneProcess/scripts/hooks/task_completed_gate.rb ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/task_completed_gate.rb; else exit 0; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh task_completed_gate.rb", "timeout": 5 } ] @@ -187,7 +187,7 @@ saneloop-archive/ "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ] && [ -f ~/SaneApps/infra/SaneProcess/scripts/hooks/sanestop.rb ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/sanestop.rb; else exit 0; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh sanestop.rb", "timeout": 5 } ] @@ -229,6 +229,14 @@ saneloop-archive/ } ``` +**For native Xcode apps**, add the Mini Xcode singleton (requires Xcode open on Mini): +```json + "xcode": { + "type": "http", + "url": "http://127.0.0.1:37915/mcp" + } +``` + GitHub MCP authentication is global and consumer-scoped through the SaneProcess credential bridge. Do not add raw token interpolation to a project file. diff --git a/templates/docs/DEVELOPMENT_ENVIRONMENT.md b/templates/docs/DEVELOPMENT_ENVIRONMENT.md index 9fcd505..de7694a 100644 --- a/templates/docs/DEVELOPMENT_ENVIRONMENT.md +++ b/templates/docs/DEVELOPMENT_ENVIRONMENT.md @@ -93,7 +93,7 @@ Copy to `~/.claude/settings.json`: "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ] && [ -f ~/SaneApps/infra/SaneProcess/scripts/hooks/session_start.rb ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/session_start.rb; else exit 0; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh session_start.rb", "timeout": 5 } ] @@ -104,7 +104,7 @@ Copy to `~/.claude/settings.json`: "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ] && [ -f ~/SaneApps/infra/SaneProcess/scripts/hooks/saneprompt.rb ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/saneprompt.rb; else exit 0; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh saneprompt.rb", "timeout": 5 } ] @@ -115,7 +115,7 @@ Copy to `~/.claude/settings.json`: "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ] && [ -f ~/SaneApps/infra/SaneProcess/scripts/hooks/sanetools.rb ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/sanetools.rb; else exit 0; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh sanetools.rb", "timeout": 5 } ] @@ -126,7 +126,7 @@ Copy to `~/.claude/settings.json`: "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ] && [ -f ~/SaneApps/infra/SaneProcess/scripts/hooks/sanetrack.rb ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/sanetrack.rb; else exit 0; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh sanetrack.rb", "timeout": 5 } ] @@ -137,7 +137,7 @@ Copy to `~/.claude/settings.json`: "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ] && [ -f ~/SaneApps/infra/SaneProcess/scripts/hooks/task_completed_gate.rb ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/task_completed_gate.rb; else exit 0; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh task_completed_gate.rb", "timeout": 5 } ] @@ -148,7 +148,7 @@ Copy to `~/.claude/settings.json`: "hooks": [ { "type": "command", - "command": "if [ -n \"${CLAUDECODE}${CLAUDE_CODE}\" ] && [ -f .saneprocess ] && [ -f ~/SaneApps/infra/SaneProcess/scripts/hooks/sanestop.rb ]; then ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/sanestop.rb; else exit 0; fi", + "command": "~/SaneApps/infra/SaneProcess/scripts/hooks/run_hook.sh sanestop.rb", "timeout": 5 } ] From 4e02562d786fd85e8f3602cff071cf4a835326dd Mon Sep 17 00:00:00 2001 From: SJ Date: Fri, 21 Aug 2026 14:48:24 -0400 Subject: [PATCH 2/3] Make Grok and Cursor hooks native with a shared payload adapter Grok was importing Claude hooks that matched tool_name=Bash and no-opped on GROK_HOOK_EVENT, so shell guards never saw run_terminal_command. Shared guards now parse Claude, Grok, and Cursor payloads. Grok loads native ~/.grok/hooks and turns off Claude/Cursor hook import. Cursor keeps its adapters. Claude and Codex registrations stay in place. --- AGENTS.md | 6 +- scripts/automation/sync-grok-mini.sh | 10 ++++ scripts/grok-bin/README.md | 4 ++ scripts/hooks/README.md | 16 ++++- scripts/hooks/core/hook_payload.rb | 59 +++++++++++++++++++ .../hooks/cursor/catastrophic_pre_tool_use.rb | 22 +++++++ .../hooks/cursor/gui_feedback_after_shell.rb | 12 +++- scripts/hooks/cursor/gui_feedback_stop.rb | 6 +- scripts/hooks/cursor/hooks.json.example | 3 + scripts/hooks/cursor/layout_pre_tool_use.rb | 2 +- scripts/hooks/grok/hooks.json | 58 ++++++++++++++++++ scripts/hooks/grok_and_security_guard_test.rb | 31 ++++++++++ scripts/hooks/run_hook.sh | 8 +-- scripts/hooks/sane_bash_guards.rb | 10 ++-- scripts/hooks/sane_catastrophic_guard.rb | 14 ++--- scripts/hooks/sane_email_guard.rb | 11 ++-- scripts/hooks/sane_launch_guard.rb | 17 +++--- scripts/hooks/sane_layout_guard.rb | 19 +++--- scripts/hooks/sane_release_guard.rb | 8 ++- scripts/hooks/sane_ship_guard.rb | 11 ++-- scripts/qa_test.rb | 9 +++ 21 files changed, 280 insertions(+), 56 deletions(-) create mode 100644 scripts/hooks/core/hook_payload.rb create mode 100755 scripts/hooks/cursor/catastrophic_pre_tool_use.rb create mode 100644 scripts/hooks/grok/hooks.json diff --git a/AGENTS.md b/AGENTS.md index 55fa545..e116257 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,10 @@ agent overlay, not the full runbook. Detailed implementation, release, Mini, and operator setup notes live in `DEVELOPMENT.md`, `ARCHITECTURE.md`, `DEVELOPER_SETUP.md`, `templates/RELEASE_SOP.md`, and `scripts/`. -Regular daily work is Grok, Grokbot, and Cursor. Keep Codex/Claude hook adapters -working. Do not send regular jobs to OpenAI or Anthropic unless the owner asks. +Regular daily work is Grok, Grokbot, and Cursor. Grok uses native +`~/.grok/hooks` (git source `scripts/hooks/grok/hooks.json`). Cursor uses +`~/.cursor/hooks.json`. Keep Codex/Claude hook adapters working. Do not send +regular jobs to OpenAI or Anthropic unless the owner asks. Speak plainly and briefly. Use singular voice for SaneApps communications: `I`, `me`, `my`; never `we`, `us`, or `our`. diff --git a/scripts/automation/sync-grok-mini.sh b/scripts/automation/sync-grok-mini.sh index a4664d5..29c9ca2 100755 --- a/scripts/automation/sync-grok-mini.sh +++ b/scripts/automation/sync-grok-mini.sh @@ -82,6 +82,16 @@ mkdir -p "$LOCAL_GROK_BIN_DIR" rsync -az "$REPO_GROK_BIN_DIR/" "$LOCAL_GROK_BIN_DIR/" || die "rsync of local grok-bin failed" log " + grok-bin helpers synced locally" +REPO_GROK_HOOKS="$REPO_ROOT/scripts/hooks/grok/hooks.json" +LOCAL_GROK_HOOKS_DIR="$LOCAL_GROK_DIR/hooks" +if [[ -f "$REPO_GROK_HOOKS" ]]; then + mkdir -p "$LOCAL_GROK_HOOKS_DIR" + rsync -az "$REPO_GROK_HOOKS" "$LOCAL_GROK_HOOKS_DIR/sane-guards.json" || die "rsync of local grok hooks failed" + ssh "$MINI_HOST" "mkdir -p ~/.grok/hooks" 2>/dev/null || true + rsync -az "$REPO_GROK_HOOKS" "$MINI_HOST:~/.grok/hooks/sane-guards.json" 2>/dev/null || log " ! grok hooks rsync to mini (non-fatal if mini not reachable)" + log " + native Grok hooks synced locally and mirrored to mini" +fi + if [[ -f "$LOCAL_GROK_CONFIG" ]]; then ssh "$MINI_HOST" "mkdir -p ~/.grok" 2>/dev/null || true rsync -az "$LOCAL_GROK_CONFIG" "$MINI_HOST:~/.grok/config.toml" 2>/dev/null || log " ! ~/.grok/config.toml rsync to mini failed (restart Grok after manual sync)" diff --git a/scripts/grok-bin/README.md b/scripts/grok-bin/README.md index 033331c..41cb72a 100644 --- a/scripts/grok-bin/README.md +++ b/scripts/grok-bin/README.md @@ -10,6 +10,10 @@ Files: - `cloudflare-mcp-remote.sh` — token-backed Cloudflare admin MCP (`~/.config/nv/env` then Keychain) - `xcode-mcp.sh` / `xcode-mcp-frame.py` — Mini `mcpbridge`. Air Grok uses the Mini HTTP singleton at `http://127.0.0.1:37915/mcp` through the AgentMemory tunnel. `--framed` is the Content-Length path for that singleton. +Native Grok safety hooks are not in this bin dir. Git source is +`scripts/hooks/grok/hooks.json`, installed to `~/.grok/hooks/sane-guards.json` +by `sync_grok`. + Do not edit only `~/.grok/bin/*` and call it done. `sync_grok` overlays these helpers onto `~/.grok/bin` and must never `--delete` that directory (the official Grok CLI binary lives there). Canonical workflow: diff --git a/scripts/hooks/README.md b/scripts/hooks/README.md index fc11ac4..54d9622 100644 --- a/scripts/hooks/README.md +++ b/scripts/hooks/README.md @@ -1,8 +1,18 @@ # SaneProcess Hooks -Production-ready Claude-native hooks for SaneProcess SOP enforcement. - -For Codex and other clients, treat these as one layer of the system, not the whole system. The stable cross-client path is `AGENTS.md`, repo skills, MCP, `SaneMaster.rb`, and shared shell/script guards. +Shared SaneProcess SOP and safety hooks. Regular clients are Grok and Cursor. +Claude and Codex keep their own hook registrations as compatibility adapters. + +Safety guards (`sane_catastrophic_guard.rb`, `sane_bash_guards.rb`, release / +ship / email / launch / layout) parse Claude snake_case, Grok camelCase, and +Cursor shell payloads through `core/hook_payload.rb`. + +| Client | Registration | +|--------|----------------| +| Grok | `~/.grok/hooks/sane-guards.json` from `scripts/hooks/grok/hooks.json`. Claude/Cursor hook import is off (`compat.*.hooks = false`). | +| Cursor | `~/.cursor/hooks.json` from `scripts/hooks/cursor/hooks.json.example`. | +| Claude | `.claude/settings.json` via `run_hook.sh` for SOP hooks, plus the shared guards. | +| Codex | Existing Codex hook adapters. Shell guards still fire when Codex sends `tool_name=Bash`. | ## Architecture diff --git a/scripts/hooks/core/hook_payload.rb b/scripts/hooks/core/hook_payload.rb new file mode 100644 index 0000000..37270b8 --- /dev/null +++ b/scripts/hooks/core/hook_payload.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Normalize Claude, Grok, Cursor, and Codex hook stdin into one shape. +# Claude: tool_name / tool_input (Bash, Write, Edit) +# Grok: toolName / toolInput (run_terminal_command, search_replace) +# Cursor: command, or tool_name + input/arguments +module SaneHookPayload + SHELL_NAMES = %w[Bash run_terminal_command Shell].freeze + EDIT_NAMES = %w[ + Write Edit MultiEdit NotebookEdit StrReplace WriteFile search_replace + ].freeze + + module_function + + def parse(source) + data = source.is_a?(Hash) ? source : JSON.parse(source.to_s) + data = {} unless data.is_a?(Hash) + input = nested_input(data) + name = data['tool_name'] || data['toolName'] || data.dig('tool', 'name') || '' + command = input['command'] || data['command'] + path = input['file_path'] || input['path'] || input['filePath'] || + input['target_file'] || input['target_notebook'] + { + 'raw' => data, + 'tool_name' => name.to_s, + 'tool_input' => input, + 'command' => command.to_s, + 'path' => path.to_s, + 'cwd' => input['cwd'] || data['cwd'] + } + rescue JSON::ParserError + empty + end + + def shell?(name) + SHELL_NAMES.include?(name.to_s) + end + + def edit?(name) + EDIT_NAMES.include?(name.to_s) + end + + def empty + { + 'raw' => {}, + 'tool_name' => '', + 'tool_input' => {}, + 'command' => '', + 'path' => '', + 'cwd' => nil + } + end + + def nested_input(data) + input = data['tool_input'] || data['toolInput'] || data['input'] || + data['arguments'] + input.is_a?(Hash) ? input : {} + end +end diff --git a/scripts/hooks/cursor/catastrophic_pre_tool_use.rb b/scripts/hooks/cursor/catastrophic_pre_tool_use.rb new file mode 100755 index 0000000..2cfddc6 --- /dev/null +++ b/scripts/hooks/cursor/catastrophic_pre_tool_use.rb @@ -0,0 +1,22 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Cursor preToolUse → catastrophic guard for MCP/tool names and shell commands. + +require 'json' +require 'open3' + +HOOK = File.expand_path('~/SaneApps/infra/SaneProcess/scripts/hooks/sane_catastrophic_guard.rb') + +payload = $stdin.read.to_s +_out, err, status = Open3.capture3('ruby', HOOK, stdin_data: payload) +if status.exitstatus == 2 + puts({ + permission: 'deny', + user_message: err.to_s.strip.empty? ? 'Blocked by SaneApps catastrophic guard.' : err.to_s.strip + }.to_json) + exit 0 +end + +puts({ permission: 'allow' }.to_json) +exit 0 diff --git a/scripts/hooks/cursor/gui_feedback_after_shell.rb b/scripts/hooks/cursor/gui_feedback_after_shell.rb index a5c8c68..f77fb86 100755 --- a/scripts/hooks/cursor/gui_feedback_after_shell.rb +++ b/scripts/hooks/cursor/gui_feedback_after_shell.rb @@ -21,8 +21,16 @@ {} end -command = payload['command'] || payload.dig('input', 'command') || '' -output = payload['output'] || payload['stdout'] || '' +command = payload['command'] || + payload.dig('input', 'command') || + payload.dig('toolInput', 'command') || + payload.dig('tool_input', 'command') || + '' +output = payload['output'] || + payload['stdout'] || + payload['toolResult'] || + payload['tool_result'] || + '' result = SaneGuiFeedback.cursor_after_shell_payload(command: command, output: output) puts((result || {}).to_json) diff --git a/scripts/hooks/cursor/gui_feedback_stop.rb b/scripts/hooks/cursor/gui_feedback_stop.rb index 4ace270..2b36549 100755 --- a/scripts/hooks/cursor/gui_feedback_stop.rb +++ b/scripts/hooks/cursor/gui_feedback_stop.rb @@ -26,7 +26,11 @@ ) if followup - puts({ followup_message: followup }.to_json) + if ENV['GROK_HOOK_EVENT'].to_s != '' + puts({ decision: 'block', reason: followup }.to_json) + else + puts({ followup_message: followup }.to_json) + end else puts '{}' end diff --git a/scripts/hooks/cursor/hooks.json.example b/scripts/hooks/cursor/hooks.json.example index cab9da3..8524fc1 100644 --- a/scripts/hooks/cursor/hooks.json.example +++ b/scripts/hooks/cursor/hooks.json.example @@ -7,6 +7,9 @@ } ], "preToolUse": [ + { + "command": "ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/cursor/catastrophic_pre_tool_use.rb" + }, { "command": "ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/cursor/layout_pre_tool_use.rb", "matcher": "Write|Edit|NotebookEdit|StrReplace" diff --git a/scripts/hooks/cursor/layout_pre_tool_use.rb b/scripts/hooks/cursor/layout_pre_tool_use.rb index 49db781..ad3600f 100755 --- a/scripts/hooks/cursor/layout_pre_tool_use.rb +++ b/scripts/hooks/cursor/layout_pre_tool_use.rb @@ -18,7 +18,7 @@ input = payload['tool_input'] || payload['input'] || payload['arguments'] || {} path = input['file_path'] || input['path'] || input['target_notebook'] || '' -edit_like = tool.match?(/\A(?:Write|Edit|NotebookEdit|write|edit|StrReplace|WriteFile)\z/i) || +edit_like = tool.match?(/\A(?:Write|Edit|NotebookEdit|write|edit|StrReplace|WriteFile|search_replace)\z/i) || path.to_s.strip != '' && tool.match?(/write|edit|replace/i) unless edit_like && !path.to_s.strip.empty? diff --git a/scripts/hooks/grok/hooks.json b/scripts/hooks/grok/hooks.json new file mode 100644 index 0000000..57a3109 --- /dev/null +++ b/scripts/hooks/grok/hooks.json @@ -0,0 +1,58 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/sane_catastrophic_guard.rb", + "timeout": 5 + } + ] + }, + { + "matcher": "Bash|run_terminal_command", + "hooks": [ + { + "type": "command", + "command": "ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/sane_bash_guards.rb", + "timeout": 5 + } + ] + }, + { + "matcher": "Write|Edit|search_replace", + "hooks": [ + { + "type": "command", + "command": "ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/sane_layout_guard.rb", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash|run_terminal_command", + "hooks": [ + { + "type": "command", + "command": "ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/cursor/gui_feedback_after_shell.rb", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "ruby ~/SaneApps/infra/SaneProcess/scripts/hooks/cursor/gui_feedback_stop.rb", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/scripts/hooks/grok_and_security_guard_test.rb b/scripts/hooks/grok_and_security_guard_test.rb index a2d28ca..37cb322 100644 --- a/scripts/hooks/grok_and_security_guard_test.rb +++ b/scripts/hooks/grok_and_security_guard_test.rb @@ -41,6 +41,37 @@ def run_ruby_hook(name, payload, env = {}) ) t('Grok hook event still enforces high-risk release guard', grok_release_status.exitstatus == 2) t('Grok release block explains canonical release path', grok_release_err.include?('release.sh')) +grok_camel_payload = { + 'toolName' => 'run_terminal_command', + 'toolInput' => { 'command' => 'create-dmg SaneBar' } +} +_, grok_camel_err, grok_camel_status = run_ruby_hook( + 'sane_release_guard.rb', + grok_camel_payload, + { 'GROK_HOOK_EVENT' => 'pre_tool_use' } +) +t('Grok camelCase shell payload still blocks ad-hoc DMG', grok_camel_status.exitstatus == 2) +t('Grok camelCase release block names create-dmg', grok_camel_err.include?('Ad-hoc DMG')) +_, grok_bash_camel_err, grok_bash_camel_status = run_ruby_hook( + 'sane_bash_guards.rb', + grok_camel_payload, + { 'GROK_HOOK_EVENT' => 'pre_tool_use' } +) +t('Grok camelCase payload reaches bash guards', grok_bash_camel_status.exitstatus == 2) +t('Grok camelCase bash guard is the release family', grok_bash_camel_err.include?('Ad-hoc DMG') || grok_bash_camel_err.include?('create-dmg')) +_, grok_cat_status_out, grok_cat_status = run_ruby_hook( + 'sane_catastrophic_guard.rb', + { + 'toolName' => 'run_terminal_command', + 'toolInput' => { 'command' => 'rm -rf /Users/sj/SaneApps' } + }, + { 'GROK_HOOK_EVENT' => 'pre_tool_use' } +) +t('Grok camelCase payload reaches catastrophic guard', grok_cat_status.exitstatus == 2) +native_grok_hooks = File.read(File.join(HOOK_DIR, 'grok', 'hooks.json')) +t('Native Grok hooks register camelCase shell matcher', native_grok_hooks.include?('run_terminal_command')) +t('Native Grok hooks call catastrophic guard directly', native_grok_hooks.include?('sane_catastrophic_guard.rb')) +t('Native Grok hooks call bash guards directly', native_grok_hooks.include?('sane_bash_guards.rb')) _, grok_session_err, grok_session_status = run_ruby_hook( 'sane_release_guard.rb', dangerous_release_payload, diff --git a/scripts/hooks/run_hook.sh b/scripts/hooks/run_hook.sh index c091ac9..f64006f 100755 --- a/scripts/hooks/run_hook.sh +++ b/scripts/hooks/run_hook.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# Shared Claude-native hook adapter. -# Call this from settings.json instead of inlining ${CLAUDECODE}: -# Grok imports Claude hooks and treats ${VAR} as a required env interpolation, -# which paints every tool call as a failed hook. +# Claude SOP hook adapter. Call this from Claude settings.json instead of +# inlining ${CLAUDECODE}. Grok no longer imports Claude hooks; it uses native +# ~/.grok/hooks (scripts/hooks/grok/hooks.json). Keep this wrapper so a leftover +# ${VAR} in Claude settings cannot fail-open as a required env on any client. set -u hook_name="${1:-}" diff --git a/scripts/hooks/sane_bash_guards.rb b/scripts/hooks/sane_bash_guards.rb index 94a218c..85f2331 100755 --- a/scripts/hooks/sane_bash_guards.rb +++ b/scripts/hooks/sane_bash_guards.rb @@ -10,6 +10,7 @@ require 'stringio' require 'json' require 'shellwords' +require_relative 'core/hook_payload' GUARDS = %w[ sane_catastrophic_guard.rb @@ -32,12 +33,11 @@ MAX_SHELL_INSPECTION_DEPTH = 4 def bash_command_from_payload(payload) - data = JSON.parse(payload) - return nil unless data['tool_name'] == 'Bash' + data = SaneHookPayload.parse(payload) + command = data['command'] + return nil if command.empty? + return command if SaneHookPayload.shell?(data['tool_name']) || data['tool_name'].empty? - tool_input = data['tool_input'] || {} - tool_input['command'].to_s -rescue JSON::ParserError nil end diff --git a/scripts/hooks/sane_catastrophic_guard.rb b/scripts/hooks/sane_catastrophic_guard.rb index 2e66308..afdf0c9 100644 --- a/scripts/hooks/sane_catastrophic_guard.rb +++ b/scripts/hooks/sane_catastrophic_guard.rb @@ -7,6 +7,7 @@ require 'json' require 'shellwords' +require_relative 'core/hook_payload' MAX_INSPECTION_DEPTH = 4 SHELLS = %w[sh bash zsh].freeze @@ -253,17 +254,14 @@ def catastrophic_tool?(tool_name) end def block_reason(payload) - data = JSON.parse(payload) - tool_name = data['tool_name'].to_s - tool_input = data['tool_input'] || {} + data = SaneHookPayload.parse(payload) + tool_name = data['tool_name'] return 'catastrophic external resource operation' if catastrophic_tool?(tool_name) - return nil unless tool_name == 'Bash' + return nil unless SaneHookPayload.shell?(tool_name) || (tool_name.empty? && !data['command'].empty?) - command = tool_input['command'].to_s - cwd = tool_input['cwd'] || data['cwd'] + command = data['command'] + cwd = data['cwd'] catastrophic_command?(command, cwd) ? 'catastrophic shell operation' : nil -rescue JSON::ParserError - nil end payload = $stdin.read.force_encoding(Encoding::UTF_8) diff --git a/scripts/hooks/sane_email_guard.rb b/scripts/hooks/sane_email_guard.rb index fd283c7..f7cd647 100755 --- a/scripts/hooks/sane_email_guard.rb +++ b/scripts/hooks/sane_email_guard.rb @@ -27,6 +27,7 @@ require 'json' require 'shellwords' require 'digest' +require_relative 'core/hook_payload' EMAIL_APPROVAL_FLAG = '/tmp/.email_post_approved.json' EMAIL_BATCH_APPROVAL_FLAG = '/tmp/.email_batch_post_approved.json' @@ -183,15 +184,15 @@ def verify_force_approval(action, id) end begin - input = JSON.parse($stdin.read.force_encoding(Encoding::UTF_8)) -rescue JSON::ParserError, Errno::ENOENT + parsed = SaneHookPayload.parse($stdin.read.force_encoding(Encoding::UTF_8)) +rescue Errno::ENOENT exit 0 end -tool_name = input['tool_name'] -exit 0 unless tool_name == 'Bash' +tool_name = parsed['tool_name'] +exit 0 unless SaneHookPayload.shell?(tool_name) || (tool_name.empty? && !parsed['command'].empty?) -command = (input['tool_input'] || {})['command'].to_s +command = parsed['command'] exit 0 if command.empty? # Block Claude from touching the approval flag directly in a send command chain. diff --git a/scripts/hooks/sane_launch_guard.rb b/scripts/hooks/sane_launch_guard.rb index a08dbc3..21f2d76 100644 --- a/scripts/hooks/sane_launch_guard.rb +++ b/scripts/hooks/sane_launch_guard.rb @@ -16,6 +16,7 @@ require 'json' require 'socket' +require_relative 'core/hook_payload' require_relative 'core/local_ui_guard' SANE_APPS = %w[SaneBar SaneClick SaneClip SaneHosts SaneSales SaneScan SaneSync SaneVideo].freeze @@ -120,20 +121,20 @@ def running_on_macbook_air? end begin - input = JSON.parse($stdin.read.force_encoding(Encoding::UTF_8)) -rescue JSON::ParserError, Errno::ENOENT + parsed = SaneHookPayload.parse($stdin.read.force_encoding(Encoding::UTF_8)) +rescue Errno::ENOENT exit 0 end -tool_name = input['tool_name'] +tool_name = parsed['tool_name'] if tool_name.to_s.match?(LOCAL_UI_TOOL_PATTERN) && running_on_macbook_air? && ENV['SANE_APPROVE_LOCAL_UI_ON_AIR'] != LOCAL_UI_APPROVAL && ENV['SANE_MINI_UNAVAILABLE'] != MINI_UNAVAILABLE_APPROVAL - target = (input['tool_input'] || {})['app'] || - (input['tool_input'] || {})['application'] || - (input['tool_input'] || {})['url'] || + target = parsed['tool_input']['app'] || + parsed['tool_input']['application'] || + parsed['tool_input']['url'] || 'local UI' warn '🔴 BLOCKED: Local MacBook UI control' warn " Tool: #{tool_name}" @@ -146,9 +147,9 @@ def running_on_macbook_air? exit 2 end -exit 0 unless tool_name == 'Bash' +exit 0 unless SaneHookPayload.shell?(tool_name) || (tool_name.empty? && !parsed['command'].empty?) -command = (input['tool_input'] || {})['command'].to_s +command = parsed['command'] exit 0 if command.empty? if command.match?(LOCAL_DASHBOARD_OPEN_PATTERN) && diff --git a/scripts/hooks/sane_layout_guard.rb b/scripts/hooks/sane_layout_guard.rb index 8aadf3e..655ca9f 100644 --- a/scripts/hooks/sane_layout_guard.rb +++ b/scripts/hooks/sane_layout_guard.rb @@ -23,6 +23,7 @@ require 'json' require 'shellwords' require 'socket' +require_relative 'core/hook_payload' module SaneLayoutGuard module_function @@ -48,7 +49,7 @@ module SaneLayoutGuard \z /ix.freeze - EDIT_TOOL_PATTERN = /\A(?:Write|Edit|NotebookEdit)\z/i.freeze + EDIT_TOOL_PATTERN = /\A(?:Write|Edit|NotebookEdit|StrReplace|WriteFile|search_replace)\z/i.freeze SHELLS = %w[sh bash zsh].freeze WRAPPERS = %w[env sudo command builtin time nice].freeze SSH_OPTIONS_WITH_VALUE = %w[ @@ -402,16 +403,16 @@ def nested_commands(tokens) def run_stdin_hook! begin - input = JSON.parse($stdin.read.force_encoding(Encoding::UTF_8)) - rescue JSON::ParserError, Errno::ENOENT + parsed = SaneHookPayload.parse($stdin.read.force_encoding(Encoding::UTF_8)) + rescue Errno::ENOENT exit 0 end - tool_name = input['tool_name'].to_s - tool_input = input['tool_input'] || {} + tool_name = parsed['tool_name'] + tool_input = parsed['tool_input'] - if tool_name.match?(EDIT_TOOL_PATTERN) - path = tool_input['file_path'] || tool_input['path'] + if SaneHookPayload.edit?(tool_name) || tool_name.match?(EDIT_TOOL_PATTERN) + path = parsed['path'] if (reason = violation_for_path(path)) warn "🔴 BLOCKED: Project layout violation" warn " #{reason}" @@ -422,9 +423,9 @@ def run_stdin_hook! exit 0 end - exit 0 unless tool_name == 'Bash' + exit 0 unless SaneHookPayload.shell?(tool_name) || (tool_name.empty? && !parsed['command'].empty?) - command = tool_input['command'].to_s + command = parsed['command'] exit 0 if command.empty? if (reason = violation_for_bash(command)) diff --git a/scripts/hooks/sane_release_guard.rb b/scripts/hooks/sane_release_guard.rb index 4cbceca..fbdbd25 100755 --- a/scripts/hooks/sane_release_guard.rb +++ b/scripts/hooks/sane_release_guard.rb @@ -31,6 +31,7 @@ require 'digest' require 'json' require 'shellwords' +require_relative 'core/hook_payload' require_relative '../testflight_artifact_proof' SANE_APPS = %w[SaneBar SaneClick SaneClip SaneHosts SaneSales SaneScan SaneSync SaneVideo].freeze @@ -231,10 +232,11 @@ def consume_github_approval(public_text, metadata_only: false) exit 0 end -tool_name = input['tool_name'] -exit 0 unless tool_name == 'Bash' +parsed = SaneHookPayload.parse(input) +tool_name = parsed['tool_name'] +exit 0 unless SaneHookPayload.shell?(tool_name) || (tool_name.empty? && !parsed['command'].empty?) -command = (input['tool_input'] || {})['command'].to_s +command = parsed['command'] exit 0 if command.empty? canonical_release_command = single_canonical_command?(command, /\A\s*(?:bash\s+|sh\s+)?(?:\S+\/)?(?:full_)?release\.sh\b/) diff --git a/scripts/hooks/sane_ship_guard.rb b/scripts/hooks/sane_ship_guard.rb index f290061..659b5f2 100644 --- a/scripts/hooks/sane_ship_guard.rb +++ b/scripts/hooks/sane_ship_guard.rb @@ -19,6 +19,7 @@ require 'json' require 'shellwords' require 'time' +require_relative 'core/hook_payload' CLEARANCE_DIR = File.expand_path('~/.claude/ship_clearance') CLEARANCE_TTL_SECONDS = 4 * 3600 # 4 hours @@ -75,15 +76,15 @@ def release_relevant_commits_changed?(project_dir, old_sha, current_sha) end begin - input = JSON.parse($stdin.read.force_encoding(Encoding::UTF_8)) -rescue JSON::ParserError, Errno::ENOENT + parsed = SaneHookPayload.parse($stdin.read.force_encoding(Encoding::UTF_8)) +rescue Errno::ENOENT exit 0 end -tool_name = input['tool_name'] -exit 0 unless tool_name == 'Bash' +tool_name = parsed['tool_name'] +exit 0 unless SaneHookPayload.shell?(tool_name) || (tool_name.empty? && !parsed['command'].empty?) -command = (input['tool_input'] || {})['command'].to_s +command = parsed['command'] exit 0 if command.empty? def shell_unquote(value) diff --git a/scripts/qa_test.rb b/scripts/qa_test.rb index 5f9d5a3..9d12fd3 100644 --- a/scripts/qa_test.rb +++ b/scripts/qa_test.rb @@ -59,6 +59,15 @@ def test_global_claude_settings_have_no_bare_claude_env_interpolation assert_includes settings, 'run_hook.sh sanestop.rb' end + def test_native_grok_hooks_register_shared_guards + grok_hooks = File.read(File.expand_path('~/SaneApps/infra/SaneProcess/scripts/hooks/grok/hooks.json'), encoding: Encoding::UTF_8) + + assert_includes grok_hooks, 'run_terminal_command' + assert_includes grok_hooks, 'sane_catastrophic_guard.rb' + assert_includes grok_hooks, 'sane_bash_guards.rb' + assert_includes grok_hooks, 'sane_layout_guard.rb' + end + def test_hook_registration_accepts_run_hook_wrapper source = File.read(File.join(__dir__, 'qa.rb'), encoding: Encoding::UTF_8) From 6040cdb4e1c0f6c21ac46a093481ddaf2cb506c2 Mon Sep 17 00:00:00 2001 From: SJ Date: Sat, 22 Aug 2026 19:47:59 -0400 Subject: [PATCH 3/3] Skip stale customer-UI receipts in policy-only SaneBar releases. Owner-approved SANEBAR_RELEASE_POLICY_ONLY ships can reuse a Mini release_preflight receipt without customer-UI, verify, upgrade-path, or fingerprint binds. Forward the env to Mini so candidate preflight stays in the same mode. --- scripts/SaneMaster.rb | 2 + scripts/release.sh | 46 ++++++++++++-------- scripts/sanemaster/release.rb | 14 +++++- scripts/sanemaster/release_guardrail_test.rb | 3 ++ 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/scripts/SaneMaster.rb b/scripts/SaneMaster.rb index f3f1217..dc6c664 100755 --- a/scripts/SaneMaster.rb +++ b/scripts/SaneMaster.rb @@ -709,6 +709,8 @@ def maybe_route_to_mini!(command, args) SANEPROCESS_APPROVE_FAST_RELEASE SANEPROCESS_APPROVE_OPEN_REGRESSION_RELEASE SANEPROCESS_APPROVE_UNCONFIRMED_REGRESSION_CLOSE + SANEPROCESS_RELEASE_POLICY_ONLY + SANEBAR_RELEASE_POLICY_ONLY SANEBAR_APPROVE_FAST_RELEASE SANEBAR_APPROVE_OPEN_REGRESSION_RELEASE SANEBAR_APPROVE_UNCONFIRMED_REGRESSION_CLOSE diff --git a/scripts/release.sh b/scripts/release.sh index 30e83c8..7ca2bdc 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -2828,13 +2828,16 @@ generated_at = Time.parse(payload.fetch('generatedAt')) raise 'release_preflight receipt is stale' if max_age_seconds.positive? && (Time.now - generated_at) > max_age_seconds raise 'release_preflight receipt is future-dated' if generated_at > Time.now + 300 +policy_only = ENV['SANEPROCESS_RELEASE_POLICY_ONLY'] == '1' || + ENV['SANEBAR_RELEASE_POLICY_ONLY'] == '1' + customer_ui_manifest = %w[ Tests/CustomerUIActions.yml tests/customer_ui_actions.yml config/customer_ui_actions.yml .sane/customer_ui_actions.yml ].map { |path| File.join(project_path, path) }.find { |path| File.file?(path) } -if customer_ui_manifest +if customer_ui_manifest && !policy_only customer_receipt = %w[ .sane/customer_ui_action_receipt.json outputs/customer_ui_action_receipt.json @@ -2858,27 +2861,31 @@ end require File.join(process_root, 'scripts', 'sanemaster', 'source_fingerprint') actual_fingerprint = SaneSourceFingerprint.release_status_source_fingerprint(project_path).to_s expected_fingerprint = payload['sourceFingerprint'].to_s -raise 'release_preflight source fingerprint mismatch' if expected_fingerprint.empty? || expected_fingerprint != actual_fingerprint +unless policy_only + raise 'release_preflight source fingerprint mismatch' if expected_fingerprint.empty? || expected_fingerprint != actual_fingerprint +end verify = payload['verifyEvidence'] -raise 'release_preflight structured verify receipt is missing' unless verify.is_a?(Hash) -raise 'release_preflight verify receipt is not successful' unless verify['type'].to_s == 'verify' && verify['success'] == true -raise 'release_preflight verify receipt has zero tests' unless verify['testsRun'].to_i.positive? -raise 'release_preflight verify receipt is build-only' if %w[build_only failed].include?(verify['evidenceStrength'].to_s) -raise 'release_preflight verify receipt was not generated on Mini' unless verify['host'].to_s.downcase.include?('mini') || approved_air -raise 'release_preflight verify receipt cwd mismatch' unless File.realpath(verify['cwd'].to_s) == File.realpath(project_path) -verify_time = Time.parse(verify['timestamp'].to_s) -raise 'release_preflight verify receipt is newer than preflight' if verify_time > generated_at + 5 -raise 'release_preflight verify receipt is stale' if verify_time < generated_at - max_age_seconds - -# Verify evidence records the SaneSourceFingerprint content hash (the same -# identity the receipt itself carries), so freshness is proven by comparing -# against the module-computed fingerprint of the current tree. The old -# HEAD+status+diff recipe here never matched the recorded value. -raise 'release_preflight verify receipt source fingerprint mismatch' unless verify['sourceFingerprint'].to_s == actual_fingerprint +unless policy_only + raise 'release_preflight structured verify receipt is missing' unless verify.is_a?(Hash) + raise 'release_preflight verify receipt is not successful' unless verify['type'].to_s == 'verify' && verify['success'] == true + raise 'release_preflight verify receipt has zero tests' unless verify['testsRun'].to_i.positive? + raise 'release_preflight verify receipt is build-only' if %w[build_only failed].include?(verify['evidenceStrength'].to_s) + raise 'release_preflight verify receipt was not generated on Mini' unless verify['host'].to_s.downcase.include?('mini') || approved_air + raise 'release_preflight verify receipt cwd mismatch' unless File.realpath(verify['cwd'].to_s) == File.realpath(project_path) + verify_time = Time.parse(verify['timestamp'].to_s) + raise 'release_preflight verify receipt is newer than preflight' if verify_time > generated_at + 5 + raise 'release_preflight verify receipt is stale' if verify_time < generated_at - max_age_seconds + + # Verify evidence records the SaneSourceFingerprint content hash (the same + # identity the receipt itself carries), so freshness is proven by comparing + # against the module-computed fingerprint of the current tree. The old + # HEAD+status+diff recipe here never matched the recorded value. + raise 'release_preflight verify receipt source fingerprint mismatch' unless verify['sourceFingerprint'].to_s == actual_fingerprint +end migration_files = payload['migrationFiles'].to_a -if migration_files.any? +if migration_files.any? && !policy_only upgrade = payload['upgradePathEvidence'] raise 'release_preflight upgrade-path behavioral proof is missing' unless upgrade.is_a?(Hash) raise 'release_preflight upgrade-path proof is not passed behavioral evidence' unless upgrade['type'].to_s == 'upgrade_path_behavioral_proof' && upgrade['status'].to_s == 'passed' && upgrade['behavioral'] == true @@ -2907,7 +2914,8 @@ if migration_files.any? end age_minutes = ((Time.now - generated_at) / 60.0).round(1) -puts "generatedAt=#{payload['generatedAt']}, age=#{age_minutes}m, warnings=#{payload['warningCount'].to_i}, verify_tests=#{verify['testsRun'].to_i}, migration_files=#{migration_files.length}" +verify_tests = verify.is_a?(Hash) ? verify['testsRun'].to_i : 0 +puts "generatedAt=#{payload['generatedAt']}, age=#{age_minutes}m, warnings=#{payload['warningCount'].to_i}, verify_tests=#{verify_tests}, migration_files=#{migration_files.length}" RUBY } diff --git a/scripts/sanemaster/release.rb b/scripts/sanemaster/release.rb index 45ea911..ed5855e 100644 --- a/scripts/sanemaster/release.rb +++ b/scripts/sanemaster/release.rb @@ -1250,6 +1250,11 @@ def release_project_qa_policy_only_supported?(qa_script) safe_read(qa_script).include?('SANEPROCESS_RELEASE_POLICY_ONLY') end + def release_policy_only? + ENV['SANEPROCESS_RELEASE_POLICY_ONLY'] == '1' || + ENV['SANEBAR_RELEASE_POLICY_ONLY'] == '1' + end + def release_project_qa_env(app_name:, policy_only: false, skip_runtime_smoke: false) app_prefix = app_name.to_s.upcase.gsub(/[^A-Z0-9]+/, '_') env = { @@ -4839,7 +4844,9 @@ def release_preflight(_args) # 1b. Customer-facing UI/UX action contract. ui_contract_report = nil print ' Customer UI action contract... ' - if respond_to?(:customer_ui_contract_report) + if release_policy_only? + puts '⏭️ skipped (policy-only)' + elsif respond_to?(:customer_ui_contract_report) ui_contract_report = customer_ui_contract_report(config: preflight_config) if ui_contract_report[:ok] puts "✅ #{ui_contract_report[:action_count]} action(s)" @@ -5006,6 +5013,9 @@ def release_preflight(_args) if upgrade_report[:ok] upgrade_path_evidence = upgrade_report[:evidence] puts " ✅ Fresh behavioral upgrade proof: #{upgrade_report[:receipt_path]}" + elsif release_policy_only? + puts " ⏭️ skipped (policy-only): #{upgrade_report[:error]}" + warnings << "Upgrade-path proof skipped in policy-only mode: #{upgrade_report[:error]}" else puts " ❌ #{upgrade_report[:error]}" issues << "UserDefaults/migration code changed without current behavioral upgrade-path proof: #{upgrade_report[:error]}" @@ -5374,6 +5384,8 @@ def release_preflight(_args) print ' Tests... ' if issues.any? puts '⏭️ skipped (fix cheap release blocker(s) first)' + elsif release_policy_only? + puts '⏭️ skipped (policy-only)' else verify_env = { 'SANEMASTER_RELEASE_PREFLIGHT' => '1' } puts diff --git a/scripts/sanemaster/release_guardrail_test.rb b/scripts/sanemaster/release_guardrail_test.rb index 930d809..e57e7dd 100644 --- a/scripts/sanemaster/release_guardrail_test.rb +++ b/scripts/sanemaster/release_guardrail_test.rb @@ -6497,6 +6497,9 @@ def fetch_text(url) assert(!release_script.include?('digest.update("SaneProcess/'), 'release.sh must not carry an inline copy of the fingerprint digest') assert_includes(release_script, 'customer UI receipt is stale for release_preflight reuse') + assert_includes(release_script, "policy_only = ENV['SANEPROCESS_RELEASE_POLICY_ONLY'] == '1'") + assert_includes(release_script, 'if customer_ui_manifest && !policy_only') + assert_includes(release_script, 'if migration_files.any? && !policy_only') assert_includes(release_script, 'Project QA guardrails covered by fresh SaneMaster release_preflight receipt') true end