Browse Source

feat(skills): Add mac-ops — comprehensive macOS workstation operations

Peer skill to windows-ops with the same conventions retuned for macOS. 12
diagnostic + repair scripts covering kernel panic triage, failing-drive
identification, launchd startup audit, drive dependency mapping, boot
performance, TCC permission audit (mac-unique), wake reason analysis,
Spotlight health, APFS storage pressure, and safe data recovery. 7 reference
docs catalog the long tail: storage event vocabulary, panic codes, startup
mechanisms with vendor patterns, recovery procedures, TCC mechanics,
launchd deep-dive (disable vs bootout vs unload), and SSH remote staging.

The 8-rung diagnostic ladder mirrors windows-ops but adds TCC as rung 7 —
the macOS-unique privacy permissions dimension that's the #1 cause of
"this app silently doesn't work" complaints.

Includes 68-test self-suite, --json NDJSON output, --redact opsec mode,
--quiet/--verbose, --help on every script. Dogfooded on a real Mac during
build; bugs found and fixed in real time (plutil error-handling, pmset log
format, spotlight system-volume filtering).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0xDarkMatter 2 weeks ago
parent
commit
02aa4e770b

+ 3 - 2
.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "claude-mods",
-  "version": "2.6.0",
-  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 76 skills, 2 commands, 6 rules, 4 hooks, 13 output styles, modern CLI tools",
+  "version": "2.7.0",
+  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 78 skills, 2 commands, 6 rules, 4 hooks, 13 output styles, modern CLI tools",
   "author": "0xDarkMatter",
   "repository": "https://github.com/0xDarkMatter/claude-mods",
   "license": "MIT",
@@ -79,6 +79,7 @@
       "skills/markitdown",
       "skills/mcp-ops",
       "skills/migrate-ops",
+      "skills/mac-ops",
       "skills/monitoring-ops",
       "skills/net-ops",
       "skills/nginx-ops",

+ 1 - 1
AGENTS.md

@@ -5,7 +5,7 @@
 This is **claude-mods** - a collection of custom extensions for Claude Code:
 - **23 expert agents** for specialized domains (React, Python, Go, Rust, AWS, git, etc.)
 - **2 commands** for session management (/sync, /save)
-- **76 skills** for CLI tools, patterns, workflows, and development tasks (incl. `net-ops` for network troubleshooting and `windows-ops` for Windows workstation diagnostics)
+- **78 skills** for CLI tools, patterns, workflows, and development tasks (incl. `net-ops` for network troubleshooting, `windows-ops` for Windows workstation diagnostics, `mac-ops` for macOS workstation diagnostics)
 - **13 output styles** for response personality (Vesper, Spartan, Mentor, Executive, Pair, Atlas, Coach, Harbour, Meridian, Noir, Roast, Sage, Scout)
 - **4 hooks** for pre-commit linting, post-edit formatting, dangerous command warnings, and pmail notifications
 - **Pigeon** inter-session messaging (`pigeon send/read/reply`) - SQLite-backed pmail at `~/.claude/pmail.db`

File diff suppressed because it is too large
+ 5 - 2
README.md


File diff suppressed because it is too large
+ 352 - 0
skills/mac-ops/SKILL.md


+ 0 - 0
skills/mac-ops/assets/.gitkeep


+ 245 - 0
skills/mac-ops/references/launchd-deep-dive.md

@@ -0,0 +1,245 @@
+# launchd Deep Dive
+
+Load this when designing, debugging, or disabling a launchd service. Covers plist semantics, domain targets, the `disable` vs `bootout` vs `unload` distinction, and the Apple Silicon system extension story.
+
+## Contents
+
+1. [What launchd is](#what-launchd-is)
+2. [Plist locations](#plist-locations)
+3. [Plist key reference](#plist-key-reference)
+4. [Domain targets](#domain-targets) — system / user / gui
+5. [disable vs bootout vs unload](#disable-vs-bootout-vs-unload)
+6. [Common semantics](#common-semantics) — RunAtLoad, KeepAlive, ThrottleInterval
+7. [Why daemons fail to load](#why-daemons-fail-to-load)
+8. [System extensions vs kexts](#system-extensions-vs-kexts) — Apple Silicon story
+9. [Diagnostic commands](#diagnostic-commands)
+
+## What launchd is
+
+`launchd` is macOS's init system AND its services manager — PID 1. It replaces `init`, `cron`, `at`, `xinetd`, `inetd`, and various startup hooks. Everything that runs as a background process on macOS — Apple's daemons, third-party agents, helper tools — is started, monitored, and (when necessary) restarted by launchd.
+
+A "launchd job" is described by a property list (plist). The plist names the job (Label), tells launchd what to run (ProgramArguments), when to run it (RunAtLoad, KeepAlive, StartCalendarInterval, WatchPaths), and how to handle failures (ThrottleInterval, ExitTimeOut).
+
+## Plist locations
+
+| Path | Scope | Loaded as |
+|---|---|---|
+| `~/Library/LaunchAgents/*.plist` | Current user only | gui/$UID |
+| `/Library/LaunchAgents/*.plist` | Any logged-in user | gui/$UID per user |
+| `/Library/LaunchDaemons/*.plist` | System-wide, runs as specified UID (usually root) | system |
+| `/System/Library/LaunchAgents/*.plist` | Apple's per-user agents | gui/$UID (read-only) |
+| `/System/Library/LaunchDaemons/*.plist` | Apple's daemons | system (read-only) |
+
+**Agent vs Daemon:**
+- Agent runs in user context, has access to the GUI, dies when the user logs out
+- Daemon runs system-wide, no GUI, survives logout
+
+The most common third-party startup item is a LaunchAgent in `/Library/LaunchAgents/` — system-installed (admin needed to write there) but runs per-logged-in-user.
+
+## Plist key reference
+
+Essential keys:
+
+| Key | Type | Purpose |
+|---|---|---|
+| `Label` | string | Unique identifier (reverse-DNS by convention, e.g. `com.example.MyDaemon`) |
+| `ProgramArguments` | array | argv to exec — `[interpreter, arg1, arg2...]` |
+| `Program` | string | (alternative) single binary path; rarely used now |
+| `RunAtLoad` | bool | Run once immediately when the job is loaded |
+| `KeepAlive` | bool or dict | Restart the process if it exits (see below for dict form) |
+| `ThrottleInterval` | int | Minimum seconds between restarts (default 10) |
+| `StartCalendarInterval` | dict | Cron-style schedule (Minute, Hour, Day, Weekday, Month) |
+| `StartInterval` | int | Run every N seconds |
+| `WatchPaths` | array | Run when any of these paths changes |
+| `QueueDirectories` | array | Run when any of these dirs becomes non-empty |
+| `StandardOutPath` | string | Redirect stdout to this file |
+| `StandardErrorPath` | string | Redirect stderr to this file |
+| `EnvironmentVariables` | dict | Env vars for the launched process |
+| `UserName` | string | UID to run as (daemons only) |
+| `GroupName` | string | GID to run as |
+| `WorkingDirectory` | string | cwd |
+| `Disabled` | bool | Initial disabled state (rarely used — prefer `launchctl disable`) |
+| `LimitLoadToSessionType` | string | "Aqua" (logged-in user), "Background", "LoginWindow", "System" |
+| `MachServices` | dict | Mach service names this process publishes |
+| `Sockets` | dict | Sockets to set up before the program runs |
+| `LaunchOnlyOnce` | bool | Once loaded, never re-run |
+
+`KeepAlive` as a dict (more nuanced):
+
+```xml
+<key>KeepAlive</key>
+<dict>
+    <key>SuccessfulExit</key><false/>     <!-- only restart on failure -->
+    <key>NetworkState</key><true/>         <!-- only run when network is up -->
+    <key>PathState</key>                   <!-- only run while paths exist -->
+    <dict>
+        <key>/usr/local/bin/foo</key><true/>
+    </dict>
+    <key>Crashed</key><true/>              <!-- only restart if crashed -->
+</dict>
+```
+
+## Domain targets
+
+`launchctl` operations take a **domain/label** pair. The domain determines which launchd instance hosts the job.
+
+| Domain | Form | What it covers |
+|---|---|---|
+| `system` | `system` | Root-level daemons (`/Library/LaunchDaemons/`, `/System/Library/LaunchDaemons/`) |
+| `user/<UID>` | `user/501` | A specific user's background tasks (no GUI) |
+| `gui/<UID>` | `gui/501` | A specific user's GUI session (most LaunchAgents live here) |
+| `pid/<PID>` | `pid/12345` | A single process's environment |
+
+Most operations on user agents target `gui/$UID` because that's where Aqua-session agents run.
+
+## disable vs bootout vs unload
+
+The three commands look interchangeable but aren't. Choose based on intent.
+
+### `launchctl disable <domain>/<label>`
+
+**Effect:** Marks the job as disabled. The mark persists across reboots. The job will not be loaded next time launchd starts.
+
+**Does NOT:** Stop the currently running process.
+
+**Reversible:** Yes — `launchctl enable <domain>/<label>`.
+
+**Use when:** You want to permanently stop a service from auto-starting.
+
+```bash
+launchctl disable gui/$UID/com.example.helper
+```
+
+### `launchctl bootout <domain>/<label>`
+
+**Effect:** Unloads the currently running job. Stops the process. The job will come back on next reboot UNLESS also `disable`d.
+
+**Reversible:** Implicit — next reboot reloads.
+
+**Use when:** You want to kill the running daemon right now but allow it to come back later.
+
+```bash
+launchctl bootout gui/$UID/com.example.helper
+```
+
+### `launchctl unload <plist-path>`
+
+**Legacy form** of `bootout`. Takes a path instead of a domain/label. Still works on most macOS versions but deprecated; prefer `bootout`.
+
+### The right combo for "make this stop forever"
+
+```bash
+launchctl disable gui/$UID/com.example.helper        # don't reload on next boot
+launchctl bootout gui/$UID/com.example.helper         # kill the running process
+```
+
+For system daemons:
+
+```bash
+sudo launchctl disable system/com.example.daemon
+sudo launchctl bootout system/com.example.daemon
+```
+
+## Common semantics
+
+### `RunAtLoad=true` + `KeepAlive=false`
+
+Run once at load (typically at user login or system boot). If the process exits, don't restart.
+
+### `RunAtLoad=true` + `KeepAlive=true`
+
+Run at load, restart whenever it exits — "always running" service.
+
+### `RunAtLoad=false` + `StartCalendarInterval`
+
+Don't run at load. Run on a schedule. Equivalent to cron.
+
+### `RunAtLoad=false` + `WatchPaths`
+
+Don't run at load. Run when a specific path is written to. Used for "watch this file for changes".
+
+### `ThrottleInterval`
+
+Minimum seconds between restarts. Default 10. If a job crashes faster than this, launchd will throttle it ("service throttled by N seconds" in the log). High throttling = the daemon is crash-looping.
+
+## Why daemons fail to load
+
+In rough order of frequency:
+
+1. **Plist syntax error** — `plutil -lint /path/to/plist` validates structure
+2. **Wrong file ownership** — system daemons must be owned `root:wheel` with mode `644`; LaunchAgents owned by the user (or root)
+3. **Wrong permissions** — `chmod 644` on the plist itself
+4. **Program path missing** — the binary doesn't exist or isn't executable
+5. **Label collision** — another job with the same Label is already loaded
+6. **Code signature mismatch** — Apple Silicon enforces signing; ad-hoc signed binaries may need `spctl --add`
+7. **TCC denial** — the program needs a TCC permission to work; without it, it crash-loops
+8. **Sandbox violation** — sandbox profile denies a syscall the program needs
+9. **Missing dependency** — a service it requires hasn't been declared
+10. **Throttled** — was crashing too fast; launchd backed off
+
+Check load errors:
+
+```bash
+launchctl print gui/$UID/com.example.helper          # detailed state
+launchctl print-disabled gui/$UID | grep example     # is it disabled?
+log show --predicate 'process == "launchd"' --last 1h --style compact | grep example
+```
+
+## System extensions vs kexts
+
+On Apple Silicon, kernel extensions (kexts) are deprecated. Most kernel-level integrations have moved to **System Extensions** — daemons in `/Library/SystemExtensions/` that run in user-mode but have privileged kernel APIs available via XPC.
+
+Key differences:
+
+| Property | Kext | System Extension |
+|---|---|---|
+| Lives in | `/Library/Extensions` | `/Library/SystemExtensions/<UUID>/<name>.systemextension` |
+| Loads via | kextd | `sysextd` |
+| Requires reboot | Often | Usually not |
+| Apple Silicon | Limited (deprecated) | Fully supported |
+| Signing | Notarized + user approved | Notarized + user approved + Family-specific entitlements |
+
+Inventory:
+
+```bash
+systemextensionsctl list
+```
+
+Disable via the system extension's app removing it, or:
+
+```bash
+systemextensionsctl uninstall <team-id> <bundle-id>
+```
+
+## Diagnostic commands
+
+```bash
+# Print all loaded jobs in user domain
+launchctl print gui/$UID | head -40
+
+# Print all loaded jobs in system domain
+sudo launchctl print system | head -40
+
+# Specific job's state
+launchctl print gui/$UID/com.example.helper
+
+# What's currently disabled?
+launchctl print-disabled gui/$UID
+sudo launchctl print-disabled system
+
+# Validate a plist
+plutil -lint /Library/LaunchAgents/com.example.helper.plist
+
+# Convert plist to readable format
+plutil -convert xml1 -o - /Library/LaunchAgents/com.example.helper.plist
+
+# Watch launchd's log for a specific job
+log stream --predicate 'process == "launchd" AND eventMessage CONTAINS "com.example"'
+```
+
+## Cross-references
+
+- `scripts/startup-audit.sh` — inventory all launchd jobs
+- `scripts/safe-disable-startup.sh` — disable + bootout in one step, reversible
+- For Windows equivalent (Services + Scheduled Tasks + Run keys), see `windows-ops/references/startup-mechanisms.md`
+- For TCC interaction with daemons, see `tcc-mechanics.md`

+ 181 - 0
skills/mac-ops/references/panic-codes.md

@@ -0,0 +1,181 @@
+# macOS Kernel Panic Codes
+
+Load this when decoding a kernel panic report from `/Library/Logs/DiagnosticReports/`. macOS doesn't use numeric bugcheck codes the way Windows does — instead, panics carry **strings**. The string + the loaded kext list together identify the cause.
+
+## Contents
+
+1. [Panic file formats](#panic-file-formats)
+2. [Anatomy of a panic report](#anatomy-of-a-panic-report)
+3. [Common panic strings](#common-panic-strings)
+4. [Kext provenance — Apple vs third-party](#kext-provenance--apple-vs-third-party)
+5. [Apple Silicon panic specifics](#apple-silicon-panic-specifics)
+6. [Pre-panic correlation](#pre-panic-correlation)
+7. [When there's no panic report](#when-theres-no-panic-report)
+
+## Panic file formats
+
+| Era | Path | Format |
+|---|---|---|
+| macOS 10.x → 11 | `/Library/Logs/DiagnosticReports/*.panic` | Plain text |
+| macOS 12+ | `/Library/Logs/DiagnosticReports/Kernel_*.ips` | JSON header + plain-text body |
+| User-mode crashes | `~/Library/Logs/DiagnosticReports/*.ips` | App crashes (not kernel panics) |
+
+`.ips` files have a JSON metadata header on the first line, then the panic body below. To extract the body:
+
+```bash
+tail -n +2 /Library/Logs/DiagnosticReports/Kernel-2026-05-15-031422.ips
+```
+
+## Anatomy of a panic report
+
+A typical panic report contains:
+
+```
+panic(cpu N caller 0x...): "<panic string>"@<source-file>:<line>
+Backtrace (CPU N), Frame : Return Address
+0xffffffaeb01: 0xffff80019c... addr2line: __ZN16IOPlatformPlugin...
+...
+Mac OS version: 23F79
+Kernel version: Darwin Kernel Version 23.5.0...
+Kernel UUID:    ABC...
+iBoot version:  iBoot-10151.121.1
+secure boot?:   YES
+roots installed: 0
+Paniclog version: 14
+
+Loaded kexts:
+  com.apple.driver.AppleEFIRuntime    1
+  com.apple.iokit.IOACPIFamily        1.5
+  com.example.product.kext            2.1.7      <— third-party suspect
+  ...
+```
+
+The **panic string** identifies the failure class. The **call stack** points at the kext / function. The **kext list** narrows the suspect.
+
+## Common panic strings
+
+### Storage / IO
+
+| String fragment | Likely cause | First action |
+|---|---|---|
+| `"Sleep wake failure in EFI"` | Driver hung crossing sleep/wake | Check USB / BT / GPU driver versions; remove third-party kext |
+| `"unresponsive bootstrap subsystem"` | launchd deadlock at boot | Boot safe mode; audit `/Library/LaunchDaemons/` |
+| `"VFS error mounting volume"` | Filesystem mount failed | Boot recoveryOS, run `diskutil verifyVolume` |
+| `"APFS reaper: ... corruption"` | APFS metadata corruption | Image first; do NOT `fsck_apfs -y` |
+| `"IOPlatformPanicAction"` | Hardware-initiated panic (often thermal / power) | Check `pmset -g log` for power events around panic time |
+
+### Memory / pagefault
+
+| Fragment | Cause |
+|---|---|
+| `"Kernel trap at ... page_fault"` | Kernel-mode memory access fault — driver bug or RAM fault |
+| `"double_fault"` | Kernel handler itself crashed during fault handling — very serious |
+| `"general_protection"` | Kernel touched invalid memory region |
+| `"Kernel data abort"` (Apple Silicon) | Memory access violation in kernel/kext |
+
+### Driver / kext
+
+| Fragment | Cause |
+|---|---|
+| `"WindowServer panic"` or `"AGXFirmwareKernExt"` | GPU driver fault. Try external display, alternative GPU mode |
+| `"Bluetooth panic"` or `"IOBluetoothFamily"` | BT stack issue — unpair recent devices |
+| `"AppleACPIPlatform"` | ACPI / firmware interaction — rare but tied to motherboard |
+| `"AppleAHCIPort"` / `"AppleNVMeFamily"` | Storage controller. Check disk-health |
+| `"IOAudioFamily"` / `"AppleHDA"` | Audio driver. Often triggered by external audio interface |
+| `"IOUSBHostFamily"` | USB driver fault — unplug recent USB devices |
+| `"IOFireWireFamily"` | FireWire (legacy) — rare on modern Macs |
+
+### Sleep / power
+
+| Fragment | Cause |
+|---|---|
+| `"Sleep wake failure"` | Driver crossing power state. Look at backtrace for kext name |
+| `"Wake transition timed out"` | Specific driver took too long to wake |
+| `"smc panic"` (rare) | SMC firmware issue. Reset SMC (Intel only — Apple Silicon doesn't have user-resetable SMC) |
+
+### Watchdog / hang
+
+| Fragment | Cause |
+|---|---|
+| `"panic_kthread"` | Kernel watchdog timeout — a driver was in infinite loop |
+| `"Hard hang on cpu N"` | Specific CPU stuck — possibly hardware |
+
+## Kext provenance — Apple vs third-party
+
+The most important triage: is the panic in an Apple kext, or a third-party kext?
+
+| Prefix | Origin | Diagnostic value |
+|---|---|---|
+| `com.apple.*` | Apple-shipped | Harder to fix — likely a bug. Check for OS updates. |
+| `com.<vendor>.*` (Adobe, Paragon, Eltima, ESET, etc.) | Third-party | **Primary suspect.** Try removing/updating the kext. |
+
+Common third-party kexts that show up in panics:
+
+| Kext label | Vendor | Reason |
+|---|---|---|
+| `com.eltima.ProductX` | Eltima Software | USB virtualization, often crashes |
+| `com.paragon-software.fs.kext.ntfs` | Paragon NTFS | Filesystem driver |
+| `com.eset.kext.esets-eset_ctl` | ESET | Anti-virus / firewall |
+| `com.kaspersky.kext.*` | Kaspersky | AV |
+| `com.driver.AcmeUSB` | Misc. drivers | Various |
+| `com.intel.driver.EnergyDriver` | Intel (Boot Camp era) | Power management |
+
+If you see ANY `com.<thirdparty>` kext in the panic kext list, that's your starting point — especially if it appears in the call stack itself, not just the loaded-kext inventory.
+
+Note: many vendors now ship **System Extensions** instead of kexts (especially on Apple Silicon). System extensions show up differently — see `references/launchd-deep-dive.md`.
+
+## Apple Silicon panic specifics
+
+Apple Silicon panics use a slightly different format and include more hardware context. Key differences:
+
+- Panic file naming: `Kernel-YYYY-MM-DD-HHMMSS.panic` and `.ips`
+- Backtrace addresses are ARM64
+- "secure boot?: YES" line at the bottom (vs Intel's variable response)
+- Some panic strings differ — e.g. `"Kernel data abort"` (ARM64) vs `"page_fault"` (x86)
+- Apple Silicon has **no** SMC-resettable; recoveryOS handles equivalent reset
+- T2 chip references absent on Apple Silicon (T2 was Intel-era; Apple Silicon has equivalent in SoC)
+
+## Pre-panic correlation
+
+The panic record is the symptom. The **events in the 10 minutes before** are usually the cause. Use:
+
+```bash
+# Replace TIME with the panic timestamp
+log show --start '2026-05-15 03:04:22' --end '2026-05-15 03:14:22' --style compact \
+    --predicate '(subsystem == "com.apple.kernel" OR subsystem == "com.apple.iokit") AND (messageType == "Error" OR messageType == "Fault")'
+```
+
+What to look for:
+
+- **storage**: IOATAFamily / AppleNVMeFamily errors → IO error cascade
+- **driver hang**: repeated identical kernel messages from a single kext
+- **assertion held**: a process kept the system from sleeping → eventually hung during forced sleep
+- **silence**: no events for >60s before panic → total system freeze (often hardware or kernel deadlock)
+
+The `scripts/panic-triage.sh` script automates this window query.
+
+## When there's no panic report
+
+Two common reasons:
+
+1. **Hard power loss** — kernel didn't get to write the dump. Symptom: Mac restarts unexpectedly, nothing in `/Library/Logs/DiagnosticReports/`. Check `pmset -g log` for "Standby" → sudden "Wake" without preceding "Sleep".
+2. **Disk too full** — kernel couldn't allocate space for the panic file. Free up space; ensure root volume has at least a few GB free.
+
+For Apple Silicon: the **system reset record** (which IS preserved across hard power loss) is queryable via:
+
+```bash
+log show --predicate 'eventMessage CONTAINS "previous shutdown cause"' --last 30d | head
+```
+
+Negative values indicate unclean shutdown:
+- `-3` = hard power loss
+- `-20` = no associated cause / unexpected
+- `-128` = thermal shutdown
+- `5` = clean shutdown initiated by user
+
+## Cross-references
+
+- `scripts/panic-triage.sh` — automated panic decode + pre-panic timeline
+- For storage-induced panics, see `storage-events.md`
+- For Windows BugCheck equivalents, see `windows-ops/references/bugcheck-codes.md`
+- For recovery from no-boot post-panic, see `recovery-patterns.md`

+ 251 - 0
skills/mac-ops/references/recovery-patterns.md

@@ -0,0 +1,251 @@
+# macOS Recovery Patterns
+
+Load this when responding to "my drive is dying", filesystem corruption, boot configuration damage, or any destructive operation. These procedures have to be right the first time — getting them wrong destroys data.
+
+## Contents
+
+1. [Cardinal rules](#cardinal-rules) — never bend
+2. [Failing-drive data recovery](#failing-drive-data-recovery)
+3. [APFS verification + repair](#apfs-verification--repair)
+4. [Snapshot rollback](#snapshot-rollback)
+5. [Target disk mode / share disk mode](#target-disk-mode--share-disk-mode)
+6. [Boot recovery (recoveryOS, safe mode, single-user)](#boot-recovery)
+7. [Drive removal procedures](#drive-removal-procedures)
+8. [Reinstalling macOS without losing data](#reinstalling-macos-without-losing-data)
+
+## Cardinal rules
+
+These never bend:
+
+1. **Image first, repair second.** Priority is getting data OFF a failing drive before doing anything that writes TO it.
+2. **Never `fsck_apfs -y`.** The `-y` flag answers Yes to repairs, which writes back. Read-only verify (`fsck_apfs -n` or `diskutil verifyVolume`) is fine; anything that writes is not.
+3. **Never `diskutil eraseDisk` or `format`** on a drive you want data from.
+4. **Don't trust `diskutil info` SMART "Verified"** when `log show` is full of IO errors. The log is the truth.
+5. **Don't pound a failing drive with retries.** Default rsync retries each error; we use `--partial --inplace --no-whole-file --append-verify --ignore-errors` to skip past unreadable sectors fast.
+6. **APFS Snapshots are read-only and free** — use them aggressively before destructive operations. `tmutil localsnapshot /` makes one in under a second.
+
+## Failing-drive data recovery
+
+### Strategy 1: rsync (default — handles most failing drives)
+
+```bash
+# Resumable, skips errors, preserves metadata
+rsync -avh --partial --inplace --no-whole-file --append-verify --ignore-errors \
+    --info=progress2 \
+    /Volumes/Failing/important/ /Volumes/Rescue/important/
+```
+
+- `--partial` — keep partially-transferred files (allows resume)
+- `--inplace` — write directly to destination (don't double-buffer)
+- `--no-whole-file` — block-level transfer (skip already-copied portions)
+- `--append-verify` — when resuming, verify the existing portion first
+- `--ignore-errors` — keep going on individual file failures
+
+Exit codes 23-24 indicate "some files failed" — expected on a failing drive. Run again later to retry just the failures.
+
+### Strategy 2: ditto (when metadata matters)
+
+```bash
+ditto --rsrc --extattr /Volumes/Failing /Volumes/Rescue
+```
+
+macOS-native, preserves resource forks, xattrs, ACLs, and Finder metadata. Use for:
+- Final Cut Pro libraries (`.fcpbundle`)
+- Logic Pro projects
+- Photos libraries
+- Apps that depend on resource forks
+
+`ditto` does NOT have a resume mode like rsync, but it's a single-pass syscall-level copy that's fast on healthy data.
+
+### Strategy 3: ddrescue (last resort, bit-level)
+
+For drives with many bad sectors or filesystem corruption so severe rsync can't traverse the tree:
+
+```bash
+brew install gddrescue
+ddrescue -n --idirect /dev/disk2 /Volumes/Rescue/disk2.img /Volumes/Rescue/disk2.map
+```
+
+`-n` = no scraping (skip retries for now)
+`--idirect` = bypass OS cache, talk directly to device
+
+The `.map` file records what's been recovered, allowing resume. After the first pass:
+
+```bash
+# Second pass: scrape bad areas more aggressively
+ddrescue -r3 /dev/disk2 /Volumes/Rescue/disk2.img /Volumes/Rescue/disk2.map
+```
+
+Once you have the image, mount it (`hdiutil attach /Volumes/Rescue/disk2.img`) and extract files from the read-only mount.
+
+## APFS verification + repair
+
+### Verify (always safe)
+
+```bash
+diskutil verifyVolume /                # Verify boot volume (read-only)
+diskutil verifyDisk disk2              # Verify whole disk
+fsck_apfs -n /dev/disk2s1              # Lowest-level verify (no writes)
+```
+
+### Repair (destructive — image first!)
+
+```bash
+diskutil repairVolume /Volumes/Foo     # Writes to disk — only on healthy storage
+fsck_apfs -y /dev/disk2s1              # Forbidden on failing drives
+```
+
+`fsck_apfs -y` requires the volume to be **unmounted**. The system volume can be unmounted from recoveryOS only.
+
+### When `repairVolume` is appropriate
+
+Run it when:
+- Volume verifies as faulty AND
+- The underlying disk has zero SMART errors AND
+- The unified log has zero IO errors AND
+- You have a backup OR you've imaged the drive first
+
+If any of those preconditions fails, **image first**.
+
+## Snapshot rollback
+
+APFS snapshots are read-only filesystem states. Two flavors:
+
+### Local Time Machine snapshots
+
+Created automatically when TM is enabled. Roll the boot volume back to a specific snapshot:
+
+```bash
+# List snapshots
+tmutil listlocalsnapshots /
+
+# Roll back (Apple Silicon: requires recoveryOS for boot volume)
+# Intel + non-boot volumes:
+diskutil apfs revert disk2s1 -toSnapshot com.apple.TimeMachine.2026-05-16-120000.local
+```
+
+### Manual snapshots
+
+Take a snapshot before any risky operation:
+
+```bash
+tmutil localsnapshot /
+# Confirms with "Created local snapshot with date: <name>"
+```
+
+If the risky operation goes wrong, revert as above.
+
+### Removing old snapshots
+
+Time Machine local snapshots can consume substantial purgeable space. macOS auto-deletes them under disk pressure, but you can force:
+
+```bash
+tmutil deletelocalsnapshots <name>     # specific snapshot
+tmutil thinlocalsnapshots /            # all eligible
+```
+
+## Target disk mode / share disk mode
+
+Mount one Mac's drives onto another Mac to recover data:
+
+### Apple Silicon (macOS 11+): Share Disk
+
+1. Boot the patient Mac into recoveryOS (hold power button)
+2. Utilities → Share Disk
+3. Connect via USB-C / Thunderbolt to the helper Mac
+4. The patient drive appears on the helper
+
+### Intel: Target Disk Mode
+
+1. Boot the patient Mac while holding `T`
+2. Connect via Thunderbolt or FireWire
+3. Patient drive appears on the helper
+
+Either method gives you read-write access to the patient's drives without booting macOS on the patient.
+
+## Boot recovery
+
+### recoveryOS
+
+Where most repair work happens:
+
+- **Apple Silicon**: hold power button until "Loading startup options" appears, then "Options"
+- **Intel**: hold `Cmd-R` at boot
+
+From recoveryOS you get:
+- Disk Utility (verify / repair / partition)
+- Reinstall macOS
+- Restore from Time Machine
+- Terminal (with limited commands — `fsck_apfs`, `diskutil`, `nvram`)
+
+### Safe boot
+
+Boots with minimal kexts, no third-party LaunchAgents, runs filesystem check.
+
+- **Apple Silicon**: hold power, choose volume while holding Shift
+- **Intel**: hold Shift at boot
+
+### Single-user mode (Intel only; not on Apple Silicon)
+
+```
+Boot with Cmd-S
+```
+
+Drops to a root shell before launchd starts. Almost never needed these days; Apple Silicon doesn't support it.
+
+### Verbose boot
+
+Shows kernel/launchd messages instead of the Apple logo:
+
+```bash
+sudo nvram boot-args="-v"             # persistent until cleared
+sudo nvram -d boot-args                # clear
+```
+
+## Drive removal procedures
+
+In order of safety / reversibility:
+
+1. **Software unmount**
+   ```bash
+   diskutil unmount /Volumes/Foo            # one volume
+   diskutil eject /dev/disk2                # whole device (all volumes)
+   ```
+
+2. **Set offline** (won't remount until enabled)
+   ```bash
+   diskutil disableMount /dev/disk2s1
+   ```
+
+3. **Physical disconnect** (external) — only after step 1 succeeds
+
+4. **BIOS / firmware disable** (internal) — boot recoveryOS, then physically disconnect
+
+5. **Destruction** — for data on a failed drive you're disposing of: see "Cryptographic erase" below
+
+### Cryptographic erase (FileVault)
+
+If FileVault was on, erasing the volume effectively destroys data:
+
+```bash
+diskutil apfs eraseVolume APFS Untitled /Volumes/Foo
+```
+
+The previous encryption key is gone, making prior data unrecoverable without it.
+
+## Reinstalling macOS without losing data
+
+Reinstalling macOS over an existing install does NOT delete user data, but **always have a backup**.
+
+1. Boot to recoveryOS
+2. Reinstall macOS → choose existing volume
+3. Wait (45-90 min)
+
+This restores the OS files only. `/Users/` is untouched. Applications need re-checking — some app helper plists may need re-registration.
+
+## Cross-references
+
+- For storage event interpretation, see `storage-events.md`
+- For volume dependency checks before eject, see `scripts/drive-dependencies.sh`
+- For safe clone execution, see `scripts/recover-clone.sh`
+- For Windows equivalents (BCD repair, bootrec), see `windows-ops/references/recovery-patterns.md`

+ 212 - 0
skills/mac-ops/references/remote-diagnostics.md

@@ -0,0 +1,212 @@
+# Remote macOS Diagnostics
+
+Load this when running mac-ops against a Mac you can't sit in front of — a server, a colleague's machine across town, a family member's iMac. Unlike Windows (which has WinRM, PSRemoting, WS-Man, and the double-hop problem), macOS remote management is **SSH all the way down** plus a few macOS-specific bits.
+
+## Contents
+
+1. [SSH baseline](#ssh-baseline)
+2. [Enabling Remote Login from a UI-less context](#enabling-remote-login-from-a-ui-less-context)
+3. [Staging the skill on the target](#staging-the-skill-on-the-target)
+4. [sudo over SSH](#sudo-over-ssh)
+5. [Apple Remote Desktop (ARD) — `kickstart`](#apple-remote-desktop-ard--kickstart)
+6. [Screen Sharing (VNC)](#screen-sharing-vnc)
+7. [Common failure modes](#common-failure-modes)
+8. [Authentication strategies](#authentication-strategies)
+
+## SSH baseline
+
+macOS 13+ ships OpenSSH server out of the box. Enable from:
+
+- **GUI:** System Settings → General → Sharing → toggle "Remote Login"
+- **CLI (admin):** `sudo systemsetup -setremotelogin on`
+- **Verify:** `sudo systemsetup -getremotelogin`
+
+Connect:
+
+```bash
+ssh <user>@<host>
+```
+
+Default port 22. Listens on all interfaces by default. To restrict, edit `/etc/ssh/sshd_config`.
+
+## Enabling Remote Login from a UI-less context
+
+If you can't reach System Settings (e.g., headless setup or you're already remote via another channel):
+
+```bash
+sudo systemsetup -setremotelogin on
+```
+
+`systemsetup` is sandbox-restricted on macOS 12+. If it errors:
+
+```bash
+sudo launchctl load -w /System/Library/LaunchDaemons/ssh.plist     # macOS 11 and earlier
+sudo launchctl bootstrap system /System/Library/LaunchDaemons/ssh.plist   # macOS 12+
+```
+
+To restrict to specific users (`/etc/ssh/sshd_config`):
+
+```
+AllowUsers admin remoteuser
+AllowGroups admin remoteoperators
+```
+
+Reload sshd:
+
+```bash
+sudo launchctl kickstart -k system/com.openssh.sshd
+```
+
+## Staging the skill on the target
+
+The pattern: copy the skill folder to the target, then invoke per-script over SSH.
+
+```bash
+# Stage
+scp -r ~/.claude/skills/mac-ops <user>@<host>:~/mac-ops-staging
+
+# Run a probe
+ssh <user>@<host> 'bash ~/mac-ops-staging/scripts/health-audit.sh --json --redact'
+```
+
+Or run a single script via stdin without staging:
+
+```bash
+ssh <user>@<host> 'bash -s' < ~/.claude/skills/mac-ops/scripts/health-audit.sh -- --json --redact
+```
+
+The `--` separates ssh's bash invocation from the script's own args.
+
+### Tarball + ship pattern (when scp+stdin won't work)
+
+```bash
+# Local: bundle the skill
+tar czf /tmp/mac-ops.tar.gz -C ~/.claude/skills mac-ops
+
+# Send + extract + run
+scp /tmp/mac-ops.tar.gz <user>@<host>:/tmp/
+ssh <user>@<host> 'cd /tmp && tar xzf mac-ops.tar.gz && bash mac-ops/scripts/health-audit.sh --json'
+```
+
+## sudo over SSH
+
+Some diagnostic scripts need sudo (system TCC.db, full LaunchDaemon inspection). Two options:
+
+### Option A: NOPASSWD entry (for trusted automation only)
+
+Add to `/etc/sudoers.d/mac-ops` on the target:
+
+```
+remoteuser ALL=(ALL) NOPASSWD: /usr/sbin/sysdiagnose, /usr/bin/log, /usr/sbin/diskutil, /usr/bin/launchctl
+```
+
+Restrict the command list — never grant blanket NOPASSWD for all of ALL.
+
+### Option B: TTY-allocated SSH (for interactive runs)
+
+```bash
+ssh -t <user>@<host> 'sudo bash ~/mac-ops-staging/scripts/health-audit.sh'
+```
+
+`-t` forces a pseudo-terminal so sudo can prompt for the password. You type it on the local terminal; SSH proxies the prompt.
+
+### Option C: stdin-passed password (avoid in real automation)
+
+```bash
+echo 'password' | ssh <user>@<host> 'sudo -S bash ~/script.sh'
+```
+
+Visible in process listings and shell history. Use only for one-off testing on machines you control.
+
+## Apple Remote Desktop (ARD) — `kickstart`
+
+ARD provides screen sharing, file transfer, and remote commands. It's enabled via the `kickstart` utility:
+
+```bash
+# Enable ARD service for all users
+sudo /System/Library/CoreServices/RemoteManagement/ARDAgent.app/Contents/Resources/kickstart \
+    -activate -configure -access -on -restart -agent -privs -all
+
+# Restrict to one user
+sudo /System/Library/CoreServices/RemoteManagement/ARDAgent.app/Contents/Resources/kickstart \
+    -activate -configure -access -on -users <username> -privs -all
+
+# Disable
+sudo /System/Library/CoreServices/RemoteManagement/ARDAgent.app/Contents/Resources/kickstart \
+    -deactivate -configure -access -off
+```
+
+ARD listens on TCP 3283 + 5900. Combine with the macOS firewall to restrict source IPs.
+
+## Screen Sharing (VNC)
+
+macOS's Screen Sharing is VNC-compatible:
+
+- **Enable:** System Settings → General → Sharing → Screen Sharing
+- **CLI:** `sudo systemsetup -getremotescreensharing` (read-only on recent macOS)
+
+Default port 5900. Use only over SSH tunnel or VPN — VNC is unencrypted.
+
+SSH-tunnel pattern:
+
+```bash
+ssh -L 5900:localhost:5900 <user>@<host>
+# Then connect a VNC client to localhost:5900
+```
+
+## Common failure modes
+
+### "Permission denied (publickey)"
+
+Server doesn't accept your key. Options:
+
+- Add your key: `ssh-copy-id <user>@<host>`
+- Use password auth: `ssh -o PreferredAuthentications=password <user>@<host>` (requires `PasswordAuthentication yes` in sshd_config)
+
+### "Connection refused"
+
+`sshd` not running. SSH into the target via a different channel (Apple Remote Desktop, physical access) and:
+
+```bash
+sudo launchctl kickstart -k system/com.openssh.sshd
+```
+
+### "Too many authentication failures"
+
+Local SSH agent is trying multiple keys. Force a specific key:
+
+```bash
+ssh -i ~/.ssh/specific_key -o IdentitiesOnly=yes <user>@<host>
+```
+
+### "Network is unreachable" / "host not found"
+
+DNS or routing problem. Use `net-ops/scripts/reverse-probe.sh` from another machine to confirm reachability.
+
+### "sudo: a terminal is required"
+
+Pass `-t` to ssh:
+
+```bash
+ssh -t <user>@<host> 'sudo command'
+```
+
+### "Operation not permitted" inside SSH session
+
+The remote shell may lack Full Disk Access. Grant FDA to `/usr/libexec/sshd-keygen-wrapper` or to the user's shell binary in System Settings → Privacy & Security → Full Disk Access.
+
+## Authentication strategies
+
+| Scenario | Strategy |
+|---|---|
+| Your own personal Mac | SSH key auth + Touch ID for sudo (`auth sufficient pam_tid.so` in `/etc/pam.d/sudo_local`) |
+| Family member's Mac, occasional check-ins | SSH key auth + sudoers.d NOPASSWD entry for the diagnostic commands |
+| Corporate Mac under MDM | Usually managed via MDM-issued cert. SSH may be disabled or restricted by profile. Coordinate with IT. |
+| Server Mac in a datacenter | SSH key auth + dedicated `mac-ops` user with sudoers.d entry for diagnostic commands only |
+| One-off colleague's Mac | `ssh-copy-id` once, then run the staging pattern. Remove your key when done. |
+
+## Cross-references
+
+- `net-ops/scripts/ssh-bootstrap.sh` — initial SSH connection helper with key + password fallback
+- `net-ops/scripts/reverse-probe.sh` — probe a remote host's reachability
+- For Windows equivalent (PSRemoting, WS-Man, double-hop), see `windows-ops/references/remote-diagnostics.md`

+ 297 - 0
skills/mac-ops/references/startup-mechanisms.md

@@ -0,0 +1,297 @@
+# macOS Startup Mechanisms
+
+Load this when doing a full startup audit, hunting auto-launch hooks across multiple mechanisms, or implementing disable-without-sudo for user-scope items.
+
+macOS has **four primary** startup mechanisms plus a handful of less-common ones. System Settings → General → Login Items shows only the first one. The rest are invisible to most users.
+
+## Contents
+
+1. [The five mechanisms](#the-five-mechanisms)
+2. [Login Items (System Settings)](#login-items-system-settings)
+3. [User LaunchAgents](#user-launchagents)
+4. [System LaunchAgents](#system-launchagents)
+5. [System LaunchDaemons](#system-launchdaemons)
+6. [Legacy LoginHook](#legacy-loginhook)
+7. [Configuration profiles](#configuration-profiles)
+8. [Vendor patterns](#vendor-patterns)
+9. [Disable strategies](#disable-strategies)
+10. [Order of execution](#order-of-execution)
+
+## The five mechanisms
+
+| # | Mechanism | Scope | User-visible | Admin needed to write |
+|---|---|---|---|---|
+| 1 | Login Items | Per-user, on login | Yes (System Settings) | No |
+| 2 | User LaunchAgents | Per-user, on login | No | No |
+| 3 | System LaunchAgents | Per-user, on login (any user) | No | Yes |
+| 4 | System LaunchDaemons | System-wide, on boot | No | Yes |
+| 5 | Legacy LoginHook | Per-user, on login (single script) | No | Yes |
+
+Modern macOS has effectively retired LoginHook in favor of LaunchAgents but it's still honored when present.
+
+## Login Items (System Settings)
+
+**Path:** Stored in `~/Library/Application Support/com.apple.backgroundtaskmanagementagent/backgrounditems.btm` (binary plist — opaque).
+
+**Inspect:** AppleScript via System Events:
+
+```applescript
+tell application "System Events"
+    name of every login item
+end tell
+```
+
+```bash
+osascript -e 'tell application "System Events" to name of every login item'
+```
+
+**Disable:**
+
+```bash
+osascript -e 'tell application "System Events" to delete login item "AppName"'
+```
+
+No sudo needed; this is per-user.
+
+**Vendor patterns to look for:**
+- "Adobe Creative Cloud" — added by most Adobe app installers
+- "Microsoft AutoUpdate" — Office installs
+- "Setapp" — if user uses Setapp app subscription
+- "Granola", "Notion", "Slack", "Dropbox" — common productivity apps
+
+## User LaunchAgents
+
+**Path:** `~/Library/LaunchAgents/*.plist`
+
+**Loaded by:** `launchd` in the user's GUI session (`gui/$UID`) at login.
+
+**Inspect:**
+
+```bash
+ls ~/Library/LaunchAgents
+# For a specific agent:
+plutil -p ~/Library/LaunchAgents/com.example.helper.plist
+```
+
+**Disable (no sudo):**
+
+```bash
+launchctl disable gui/$UID/com.example.helper        # persistent
+launchctl bootout gui/$UID/com.example.helper        # kill now
+```
+
+**Common offenders:**
+- `com.google.GoogleUpdater.wake` — Google's update agent (runs every few hours)
+- `com.google.keystone.agent` — Older Google updater
+- `com.adobe.ccxprocess` — Adobe CC helper
+- `com.valvesoftware.steamclean` — Steam cleanup
+- `com.docker.helper` — Docker Desktop user-side helper
+
+## System LaunchAgents
+
+**Path:** `/Library/LaunchAgents/*.plist`
+
+**Loaded by:** Same as user LaunchAgents (`gui/$UID`) at login — but plists live in the system path so admin is needed to install them. They still run **per-user**.
+
+**Inspect / disable:** Same as user LaunchAgents:
+
+```bash
+launchctl disable gui/$UID/com.example.system-agent     # no sudo needed
+                                                         # for disable, even
+                                                         # though plist is in
+                                                         # /Library/LaunchAgents
+```
+
+This is the key insight: even though writing to `/Library/LaunchAgents/` requires admin, **disabling** an existing agent for your own session does not. The disable state is per-user.
+
+**Common offenders:**
+- `com.adobe.AdobeCreativeCloud` — Adobe CC
+- `com.eset.esets_gui` — ESET tray app
+- `us.zoom.updater.login.check` — Zoom updater
+- `com.microsoft.update.agent` — Microsoft AutoUpdate
+
+## System LaunchDaemons
+
+**Path:** `/Library/LaunchDaemons/*.plist`
+
+**Loaded by:** `launchd` system instance (`system`) at boot. Run as the UID specified in the plist (often `root`).
+
+**Inspect:**
+
+```bash
+ls /Library/LaunchDaemons
+sudo launchctl print system | head -40
+```
+
+**Disable (requires sudo):**
+
+```bash
+sudo launchctl disable system/com.example.daemon
+sudo launchctl bootout system/com.example.daemon
+```
+
+**Common offenders:**
+- `com.docker.socket`, `com.docker.vmnetd` — Docker
+- `com.adobe.acc.installer.v2` — Adobe Creative Cloud installer
+- `com.microsoft.autoupdate.helper` — MS AutoUpdate
+- `us.zoom.ZoomDaemon` — Zoom
+- `com.cloudflare.1dot1dot1dot1.macos.warp.daemon` — Cloudflare WARP
+- `com.google.GoogleUpdater.wake.system` — Google updater
+
+## Legacy LoginHook
+
+A single executable that runs on every login. Pre-LaunchAgent era.
+
+**Inspect:**
+
+```bash
+sudo defaults read com.apple.loginwindow LoginHook
+sudo defaults read com.apple.loginwindow LogoutHook
+```
+
+**Remove:**
+
+```bash
+sudo defaults delete com.apple.loginwindow LoginHook
+sudo defaults delete com.apple.loginwindow LogoutHook
+```
+
+LoginHook is rarely used today — if you find one, it likely originates from old enterprise scripts or mac-vintage admin tooling. Replace with a proper LaunchAgent.
+
+## Configuration profiles
+
+MDM-managed Macs may have configuration profiles that add:
+
+- Login items
+- Network filters (DNS, proxies)
+- LaunchDaemons / LaunchAgents
+- TCC grants
+
+**Inspect:**
+
+```bash
+profiles list -type configuration                  # user-scope
+sudo profiles list -type configuration             # all profiles
+sudo profiles show -type configuration             # full payloads
+```
+
+Profile-managed items can **override** user choices and may re-apply automatically. Removing profile-managed items requires either:
+
+1. The profile's removal password (set by the MDM admin)
+2. MDM disenrollment
+
+Coordinate with IT before removing managed items.
+
+## Vendor patterns
+
+A startup audit usually finds the same handful of vendors leaking auto-start hooks across multiple mechanisms:
+
+### Adobe Creative Cloud
+
+Installs items in:
+- Login Items (Adobe Creative Cloud)
+- User LaunchAgent (`com.adobe.ccxprocess`)
+- System LaunchAgent (`com.adobe.AdobeCreativeCloud`)
+- System LaunchDaemon (`com.adobe.acc.installer.v2`)
+- Privileged helper (`/Library/PrivilegedHelperTools/com.adobe.acc.installer.v2`)
+
+To fully stop Adobe auto-launch, **disable all five**. Killing one doesn't stop the others.
+
+### Microsoft Office
+
+Installs:
+- Login Items (Microsoft Defender if installed)
+- System LaunchAgent (`com.microsoft.update.agent`)
+- System LaunchDaemon (`com.microsoft.autoupdate.helper`)
+- Privileged helper (`com.microsoft.autoupdate.helper`)
+
+### Docker Desktop
+
+Installs:
+- Login Items (Docker.app)
+- User LaunchAgent (`com.docker.helper`)
+- System LaunchDaemons (`com.docker.socket`, `com.docker.vmnetd`)
+- Privileged helpers (`com.docker.socket`, `com.docker.vmnetd`)
+
+### Google Drive / Chrome
+
+- Login Items (Google Drive)
+- User LaunchAgent (`com.google.GoogleUpdater.wake`)
+- User LaunchAgent (`com.google.keystone.agent` — legacy)
+- System LaunchDaemon (`com.google.GoogleUpdater.wake.system`)
+
+### Zoom
+
+- System LaunchAgent (`us.zoom.updater.login.check`, `us.zoom.updater`)
+- System LaunchDaemon (`us.zoom.ZoomDaemon`)
+- Privileged helper (`us.zoom.ZoomDaemon`)
+
+### Cisco AnyConnect / Secure Client
+
+- System LaunchAgent (`com.cisco.anyconnect.gui`)
+- System LaunchDaemon (`com.cisco.anyconnect.vpnagentd`)
+- Multiple kexts / system extensions
+- Configuration profile (often)
+
+Cisco is notable for installing across nearly every mechanism plus its own system extension.
+
+## Disable strategies
+
+### Strategy 1: System Settings (UI)
+
+Quickest for Login Items. System Settings → General → Login Items. Toggle off, or click `-` to remove.
+
+### Strategy 2: `safe-disable-startup.sh` (this skill)
+
+Handles all four mechanisms (Login Items + 3 launchd tiers) in one command:
+
+```bash
+scripts/safe-disable-startup.sh -n 'com.adobe.*'
+scripts/safe-disable-startup.sh -n 'com.adobe.*' --apply
+```
+
+Default is dry-run. `--apply` performs the disable. `--enable` reverses.
+
+### Strategy 3: Direct `launchctl`
+
+For surgical control:
+
+```bash
+# User agent (no sudo)
+launchctl disable gui/$UID/com.example.helper
+launchctl bootout gui/$UID/com.example.helper
+
+# System daemon (sudo)
+sudo launchctl disable system/com.example.daemon
+sudo launchctl bootout system/com.example.daemon
+```
+
+### Strategy 4: Delete the plist (irreversible)
+
+Don't do this. Disabling preserves the file for future re-enable; deleting requires reinstall.
+
+## Order of execution
+
+Roughly:
+
+```
+EFI → boot.efi → kernel → launchd (PID 1)
+                              │
+                              ├── system domain LaunchDaemons load (/Library/LaunchDaemons + /System/...)
+                              │
+                              └── loginwindow → user enters credentials → gui/$UID domain starts
+                                                                            │
+                                                                            ├── gui/$UID LaunchAgents load
+                                                                            │
+                                                                            └── Login Items fire
+```
+
+Login Items run **after** LaunchAgents — so a LaunchAgent failing won't be visible at the login UI, but a Login Item failing might be.
+
+## Cross-references
+
+- `scripts/startup-audit.sh` — full inventory across all mechanisms
+- `scripts/safe-disable-startup.sh` — reversible disable
+- For Windows equivalents (Run keys, Services, Scheduled Tasks, Startup folder), see `windows-ops/references/startup-mechanisms.md`
+- For launchd plist semantics, see `launchd-deep-dive.md`
+- For configuration profile inspection, see `tcc-mechanics.md` (profiles also gate TCC)

+ 137 - 0
skills/mac-ops/references/storage-events.md

@@ -0,0 +1,137 @@
+# macOS Storage Events Catalog
+
+Load this when investigating disk errors, IO failures, or correlating storage problems to a specific device. Unlike Windows (which has stable numeric event IDs), macOS surfaces storage signal through the **unified logging system** (`log show`) and the **AppleSystemPolicy** / **IOKit** subsystems. Event vocabulary is freer-form, so we match by substrings.
+
+## Contents
+
+1. [Where storage signal lives](#where-storage-signal-lives)
+2. [Critical message fragments](#critical-message-fragments) — what to grep for
+3. [IOKit subsystem events](#iokit-subsystem-events)
+4. [APFS-specific events](#apfs-specific-events)
+5. [`disk arbitration` messages](#disk-arbitration-messages)
+6. [Query recipes](#query-recipes) — `log show` patterns
+7. [Severity triage](#severity-triage) — count thresholds
+
+## Where storage signal lives
+
+| Source | Tool | Notes |
+|---|---|---|
+| Unified log | `log show` | All recent storage signal; can filter by subsystem |
+| Per-device counters | `diskutil info /dev/diskN` | Reliability counters where exposed |
+| SMART | `diskutil info` reports Verified/Failing only; `smartctl -a` (smartmontools) for attributes | NVMe often blank — check with vendor utility |
+| Disk arbitration daemon | `log show --predicate 'process == "diskarbitrationd"'` | Mount/unmount events, eject failures |
+| APFS | `diskutil apfs list` and `diskutil verifyVolume` | Read-only verify is safe even on failing disks |
+| fsck | `fsck_apfs -n /dev/diskN` (verify-only — never `-y`) | NEVER `-y` on a failing drive |
+
+## Critical message fragments
+
+Substrings to grep for in the unified log. The presence of any of these is a **strong signal**:
+
+| Fragment | Significance |
+|---|---|
+| `I/O error` | Read or write failed at IOKit layer |
+| `media error` | Disk media (sector / NAND) reported uncorrectable failure |
+| `device timeout` | Drive didn't respond within driver's timeout window |
+| `NVMe Controller is unresponsive` | NVMe controller hang — drive becoming unreachable |
+| `AppleAHCIPort` + `error` | SATA controller-level error |
+| `failed to mount` | filesystem-level mount failure |
+| `Failed to set up disk` | early-boot disk setup failure |
+| `Detected stale snapshot` | APFS snapshot tree corruption |
+| `corrupt b-tree` | APFS metadata corruption — serious |
+| `APFS_MODULE_RESET` | APFS driver had to reset internal state |
+| `EXC_RESOURCE` + `MEMORY` related to mds | Spotlight indexer crashed under memory pressure |
+
+## IOKit subsystem events
+
+```bash
+log show --last 30d --style compact \
+    --predicate 'subsystem == "com.apple.iokit" AND messageType == "Error"' \
+    2>/dev/null | head -50
+```
+
+Most failing-drive evidence appears here. Look at the `sender` (kext name) — `AppleNVMeFamily`, `AppleAHCIPort`, `IOSCSITargetDevice` identify which protocol layer is reporting.
+
+## APFS-specific events
+
+```bash
+log show --last 30d --style compact \
+    --predicate 'eventMessage CONTAINS "apfs" AND (messageType == "Error" OR messageType == "Fault")'
+```
+
+APFS error categories worth knowing:
+
+| Pattern | Cause |
+|---|---|
+| `apfs_log_op_warn_or_err` | Logged operation hit an error condition |
+| `apfs_volume_mounted: error` | Volume failed to mount — usually corruption or hardware |
+| `apfs_jhash_lookup_object: object not found` | B-tree corruption — run verifyVolume |
+| `apfs_snap_metadata_create_with_xid` errors | Snapshot creation failed — usually disk-full or hardware |
+
+## `disk arbitration` messages
+
+```bash
+log show --last 7d --style compact --predicate 'process == "diskarbitrationd"'
+```
+
+Useful for:
+- **Eject failures**: who's holding the volume open
+- **Surprise removal**: USB / Thunderbolt drives yanked
+- **Repeated mount failures**: filesystem flaky or disk failing during mount
+
+## Query recipes
+
+### Last 30 days of storage errors with sample messages
+
+```bash
+log show --last 30d --style compact \
+    --predicate '(subsystem == "com.apple.iokit" OR subsystem == "com.apple.kernel") AND (eventMessage CONTAINS[c] "I/O error" OR eventMessage CONTAINS[c] "media error" OR eventMessage CONTAINS[c] "device timeout")'
+```
+
+### Per-day error count (visualize as histogram)
+
+```bash
+log show --last 30d --style syslog \
+    --predicate 'eventMessage CONTAINS[c] "I/O error"' 2>/dev/null \
+    | awk '{print $1, $2}' | sort | uniq -c | tail -30
+```
+
+### Correlate IO errors to a specific physical disk
+
+```bash
+log show --last 7d --style compact \
+    --predicate 'eventMessage CONTAINS "diskN"'   # replace N
+```
+
+### Surface APFS corruption indicators
+
+```bash
+log show --last 30d --style compact \
+    --predicate 'eventMessage CONTAINS[c] "corrupt" OR eventMessage CONTAINS[c] "b-tree" OR eventMessage CONTAINS[c] "checksum"'
+```
+
+## Severity triage
+
+Counts that suggest action. Always cross-reference with SMART status and `diskutil verifyVolume`.
+
+| Event class | Healthy SSD (30d) | Healthy HDD (30d) | Worrying | Active failure |
+|---|---|---|---|---|
+| IO error | 0 | 0-1 | 5+ | 20+ |
+| Media error | 0 | 0 | any | 5+ |
+| Device timeout | 0 | 0-1 | 3+ | 10+ |
+| APFS Error/Fault | 0-2 | 0-2 | 5+ | 15+ |
+| diskarbitrationd eject failures | 0 | 0 | depends | repeated on same volume |
+
+**HDDs** produce more noise than SSDs even when healthy — spinning disks have inherent retry behavior. Adjust thresholds upward for HDD media.
+
+## Cardinal rules
+
+1. **Image first, repair second.** A drive throwing errors is one write away from worse. Get data off it before any repair.
+2. **Never `fsck_apfs -y`** on a failing drive — `-y` answers Yes to repairs, which writes back. Use `-n` (no-op verify) only.
+3. **Don't trust SMART "Verified"** when the log is screaming. macOS's `diskutil info` SMART status reports only Pass/Fail at a high level; the unified log is the truth.
+4. **Don't pound a failing drive with retries.** Use `rsync --partial --inplace --no-whole-file --append-verify --ignore-errors` to skip past unreadable sectors fast.
+
+## Cross-references
+
+- For Windows equivalent vocabulary, see `windows-ops/references/storage-events.md` (`disk` provider events 7/52/153/154, `storahci` 129)
+- For recovery workflow, see `recovery-patterns.md`
+- For volume dependency mapping before eject, see `scripts/drive-dependencies.sh`

+ 234 - 0
skills/mac-ops/references/tcc-mechanics.md

@@ -0,0 +1,234 @@
+# TCC (Transparency, Consent, Control) Mechanics
+
+Load this when an app silently fails to access screen recording, microphone, camera, files, Accessibility, or another app. TCC is macOS's privacy permissions database — every Allow/Deny grant ever made lives in `TCC.db` and silently controls what apps can do.
+
+## Contents
+
+1. [What TCC is](#what-tcc-is)
+2. [Database locations](#database-locations)
+3. [Service catalog](#service-catalog) — every kTCCService* string
+4. [Schema](#schema) — the `access` table
+5. [auth_value semantics](#auth_value-semantics)
+6. [Reading TCC.db](#reading-tccdb)
+7. [Resetting grants](#resetting-grants) — `tccutil`
+8. [The Full Disk Access requirement](#the-full-disk-access-requirement)
+9. [SIP and TCC](#sip-and-tcc)
+10. [Common failure modes](#common-failure-modes)
+
+## What TCC is
+
+TCC is the framework macOS uses for **per-app, per-resource** privacy controls. When an app tries to read your contacts, record the screen, listen on the mic, or send keystrokes to another app, the request goes through TCC. TCC either:
+
+1. Looks up an existing grant → silently allow or deny
+2. Has no grant → show a system prompt, record the user's answer
+
+Once recorded, the grant persists across reboots until the user revokes it (System Settings → Privacy & Security) or `tccutil reset` is run.
+
+The "silent denial" mode is the diagnostic pain: an app that previously worked stops working, the user remembers no prompt, and TCC quietly returns "not permitted" to the app's APIs. The app reports "feature unavailable" without explaining why.
+
+## Database locations
+
+```
+~/Library/Application Support/com.apple.TCC/TCC.db    User-scope grants (per-user)
+/Library/Application Support/com.apple.TCC/TCC.db     System-scope grants (machine-wide)
+```
+
+Both are SQLite databases. Both are protected by SIP/Full Disk Access — your terminal needs FDA to read them.
+
+To grant FDA to your terminal:
+1. System Settings → Privacy & Security → Full Disk Access → +
+2. Choose `/Applications/Utilities/Terminal.app` (or your terminal of choice)
+3. Restart the terminal session
+
+## Service catalog
+
+Every grant is for a (service, client) pair. The service string starts with `kTCCService`. Common ones:
+
+| Service string | What it gates | User-facing name |
+|---|---|---|
+| `kTCCServiceScreenCapture` | Screen recording, screenshots | Screen Recording |
+| `kTCCServiceMicrophone` | Audio input | Microphone |
+| `kTCCServiceCamera` | Video input | Camera |
+| `kTCCServiceAccessibility` | Synthetic input events, control other apps | Accessibility |
+| `kTCCServiceSystemPolicyAllFiles` | Read all files (Time Machine, backup apps) | Full Disk Access |
+| `kTCCServicePostEvent` | Generate synthetic input events | (part of Accessibility) |
+| `kTCCServiceListenEvent` | Listen to global input events | Input Monitoring |
+| `kTCCServiceAppleEvents` | Control another app via AppleScript | Automation |
+| `kTCCServicePhotos` | Photos library access | Photos |
+| `kTCCServiceContactsFull` | Read all contacts | Contacts (full) |
+| `kTCCServiceContactsLimited` | Limited contacts access | Contacts (limited) |
+| `kTCCServiceCalendar` | Calendar events | Calendars |
+| `kTCCServiceReminders` | Reminders | Reminders |
+| `kTCCServiceMotion` | Motion / fitness data | Motion & Fitness |
+| `kTCCServiceMediaLibrary` | Apple Music library | Apple Music |
+| `kTCCServiceSpeechRecognition` | On-device speech recognition | Speech Recognition |
+| `kTCCServiceLocation` | Geolocation | Location Services (separate UI) |
+| `kTCCServiceSystemPolicyDesktopFolder` | Desktop folder | Files & Folders → Desktop |
+| `kTCCServiceSystemPolicyDocumentsFolder` | Documents folder | Files & Folders → Documents |
+| `kTCCServiceSystemPolicyDownloadsFolder` | Downloads folder | Files & Folders → Downloads |
+| `kTCCServiceSystemPolicyRemovableVolumes` | External volumes | Files & Folders → Removable Volumes |
+| `kTCCServiceSystemPolicyNetworkVolumes` | Network mounts | Files & Folders → Network Volumes |
+| `kTCCServiceFileProviderDomain` | File provider extensions | (none — system) |
+| `kTCCServiceUbiquity` | iCloud Drive sync | (managed by iCloud) |
+| `kTCCServiceDeveloperTool` | Run unsigned binaries | Developer Tools |
+| `kTCCServicePrototype3Rights` | Future feature placeholder | (not user-facing) |
+
+## Schema
+
+The `access` table (simplified):
+
+```sql
+CREATE TABLE access (
+    service TEXT NOT NULL,           -- kTCCService* string
+    client TEXT NOT NULL,            -- bundle ID or path
+    client_type INTEGER NOT NULL,    -- 0=bundle ID, 1=absolute path
+    auth_value INTEGER NOT NULL,     -- 0=deny, 1=unknown, 2=allow, 3=limited
+    auth_reason INTEGER NOT NULL,    -- why was this set (user prompt, MDM, etc.)
+    auth_version INTEGER NOT NULL,
+    csreq BLOB,                       -- code signature requirement
+    policy_id INTEGER,
+    indirect_object_identifier_type INTEGER,
+    indirect_object_identifier TEXT,
+    indirect_object_code_identity BLOB,
+    flags INTEGER,
+    last_modified INTEGER NOT NULL,  -- unix epoch
+    pid INTEGER,
+    pid_version INTEGER,
+    boot_uuid TEXT,
+    last_reminded INTEGER
+    -- newer macOS versions add columns; the above is the stable core
+);
+```
+
+`auth_reason` values worth knowing:
+
+- `0` = not set
+- `1` = error (something went wrong, default-deny)
+- `2` = user denied at prompt
+- `3` = user consent
+- `4` = system set
+- `5` = service policy (the deny was structural, not user)
+- `6` = MDM policy
+
+## auth_value semantics
+
+| Value | Meaning |
+|---|---|
+| `0` | **Denied**. App requests fail silently with permission error. |
+| `1` | Unknown / not yet asked. Next request triggers a prompt. |
+| `2` | **Allowed**. App requests succeed. |
+| `3` | **Limited**. Used for partial Photos access (specific albums) and similar. |
+
+## Reading TCC.db
+
+```bash
+# Allowed grants on this user
+sqlite3 "$HOME/Library/Application Support/com.apple.TCC/TCC.db" \
+  "SELECT service, client, datetime(last_modified, 'unixepoch') FROM access WHERE auth_value = 2"
+
+# Denials (the diagnostic gold mine)
+sqlite3 "$HOME/Library/Application Support/com.apple.TCC/TCC.db" \
+  "SELECT service, client, datetime(last_modified, 'unixepoch') FROM access WHERE auth_value = 0"
+
+# A specific app's grants
+sqlite3 "$HOME/Library/Application Support/com.apple.TCC/TCC.db" \
+  "SELECT service, auth_value FROM access WHERE client = 'com.tinyspeck.slackmacgap'"
+```
+
+System TCC.db needs sudo and FDA:
+
+```bash
+sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db \
+  "SELECT service, client, auth_value FROM access"
+```
+
+## Resetting grants
+
+`tccutil` resets a (service, client) pair back to "Unknown" — the next request prompts the user again. This is the **correct fix** for "Slack lost Screen Recording after macOS update":
+
+```bash
+# Reset by service + bundle ID
+tccutil reset ScreenCapture com.tinyspeck.slackmacgap
+
+# Reset ALL services for a specific bundle ID (rare; use with care)
+tccutil reset All com.tinyspeck.slackmacgap
+
+# Reset ALL apps for a specific service (nuclear)
+tccutil reset ScreenCapture
+```
+
+Service-name shorthand for `tccutil` strips the `kTCCService` prefix:
+
+| Full service string | tccutil shorthand |
+|---|---|
+| `kTCCServiceScreenCapture` | `ScreenCapture` |
+| `kTCCServiceMicrophone` | `Microphone` |
+| `kTCCServiceCamera` | `Camera` |
+| `kTCCServiceAccessibility` | `Accessibility` |
+| `kTCCServiceSystemPolicyAllFiles` | `SystemPolicyAllFiles` |
+| `kTCCServiceAppleEvents` | `AppleEvents` |
+
+## The Full Disk Access requirement
+
+Many TCC operations require **the calling process** itself to have Full Disk Access. This is the bootstrap problem:
+
+- `cat ~/Library/Application\ Support/com.apple.TCC/TCC.db` → `Permission denied` unless your shell has FDA
+- A backup app trying to back up `~/Library/` needs FDA
+- A monitoring agent trying to read `~/Library/Logs/` may need FDA
+
+Two-step grant:
+1. System Settings → Privacy & Security → Full Disk Access
+2. Add the binary (e.g. `/Applications/Utilities/Terminal.app`)
+3. Quit and restart the app — grants only apply on new launch
+
+## SIP and TCC
+
+System Integrity Protection (SIP) protects TCC.db itself from tampering. With SIP enabled (default):
+
+- You cannot edit TCC.db directly even as root — the kernel will reject the write
+- `tccutil reset` is the only sanctioned way to clear grants
+- Some tools (security research, blue-team scripts) require SIP disabled to inspect/modify TCC. **Don't disable SIP** on a production Mac.
+
+SIP status:
+
+```bash
+csrutil status
+# "System Integrity Protection status: enabled."
+```
+
+## Common failure modes
+
+### "Slack can't record my screen" (the canonical case)
+
+1. macOS updated; TCC schema gained new rows or service moved
+2. Slack's screen-recording grant became Unknown or Denied
+3. Slack's UI shows the feature as "Unavailable"
+4. Fix:
+   - System Settings → Privacy & Security → Screen Recording → toggle Slack OFF, then ON
+   - Or: `tccutil reset ScreenCapture com.tinyspeck.slackmacgap` then re-open Slack
+
+### "Terminal can't read TCC.db"
+
+Your terminal lacks Full Disk Access. Grant it as above.
+
+### "Automation grant won't stick"
+
+`kTCCServiceAppleEvents` requires BOTH apps to have a grant — the controller AND the target. Adding only the controller is a common mistake.
+
+### "An app I uninstalled still appears in Privacy & Security"
+
+The TCC entry persists after the app is removed. Click `-` in the System Settings list to remove, or run `tccutil reset` on the bundle ID.
+
+### "Reset doesn't reprompt"
+
+`tccutil reset` sets to Unknown but the app needs to re-request. Quit and relaunch the app to trigger the re-prompt.
+
+### "MDM-managed grants"
+
+If the Mac is managed by MDM (configuration profile), some TCC grants are forced and cannot be revoked by the user. `auth_reason = 6` indicates an MDM-set grant. Removing the configuration profile (with admin authorization) is the only way to free the grant.
+
+## Cross-references
+
+- `scripts/tcc-audit.sh` — reads both TCC.dbs with filtering
+- For configuration profile inspection, see `scripts/startup-audit.sh` (Section 7)
+- For Windows equivalent (none — Windows handles permissions per-API, not centralized), see `windows-ops`

+ 183 - 0
skills/mac-ops/scripts/_lib/common.sh

@@ -0,0 +1,183 @@
+# mac-ops common helpers
+# Source from any script:  source "$(dirname "$0")/_lib/common.sh"
+#
+# Provides:
+#   - Semantic exit codes
+#   - log_pass / log_fail / log_warn / log_info — [TAG] message lines
+#   - JSON-mode emitters (mac_emit_check, mac_emit_section, mac_emit_summary)
+#   - Mode flags parsing: --json --redact --quiet
+#   - Reuses net-ops _lib/redact.sh patterns via the shared term.sh
+
+set -u
+
+# Semantic exit codes
+EXIT_OK=0
+EXIT_ERROR=1
+EXIT_USAGE=2
+EXIT_NOT_FOUND=3
+EXIT_VALIDATION=4
+EXIT_PRECONDITION=5
+EXIT_TIMEOUT=6
+EXIT_UNAVAILABLE=7
+
+# Mode flags (set by parse_common_flags)
+JSON_MODE="${JSON_MODE:-0}"
+REDACT="${REDACT:-0}"
+QUIET="${QUIET:-0}"
+VERBOSE="${VERBOSE:-0}"
+
+# Running counters
+PASS_COUNT=0
+FAIL_COUNT=0
+WARN_COUNT=0
+INFO_COUNT=0
+FIRST_FAIL=""
+CURRENT_SECTION=""
+
+parse_common_flags() {
+    for a in "$@"; do
+        case "$a" in
+            --json)         JSON_MODE=1 ;;
+            --redact)       REDACT=1 ;;
+            --quiet|-q)     QUIET=1 ;;
+            --verbose|-v)   VERBOSE=1 ;;
+        esac
+    done
+}
+
+# JSON-safe escaper for strings
+_json_escape() {
+    local s="$1"
+    s="${s//\\/\\\\}"
+    s="${s//\"/\\\"}"
+    s="${s//$'\n'/\\n}"
+    s="${s//$'\r'/\\r}"
+    s="${s//$'\t'/\\t}"
+    printf '%s' "$s"
+}
+
+# Section header — sets CURRENT_SECTION and prints a banner (or JSON record).
+section() {
+    CURRENT_SECTION="$1"
+    if [[ "$JSON_MODE" -eq 1 ]]; then
+        printf '{"type":"section","name":"%s"}\n' "$(_json_escape "$1")"
+    else
+        [[ "$QUIET" -eq 1 ]] || { echo; echo "=== $1 ==="; }
+    fi
+}
+
+# Check result emitters
+log_pass() {
+    PASS_COUNT=$((PASS_COUNT + 1))
+    if [[ "$JSON_MODE" -eq 1 ]]; then
+        printf '{"type":"check","section":"%s","label":"%s","status":"pass","detail":"%s"}\n' \
+            "$(_json_escape "$CURRENT_SECTION")" "$(_json_escape "$1")" "$(_json_escape "${2:-}")"
+    else
+        echo "[PASS] $1${2:+ :: $2}"
+    fi
+}
+
+log_fail() {
+    FAIL_COUNT=$((FAIL_COUNT + 1))
+    [[ -z "$FIRST_FAIL" ]] && FIRST_FAIL="[$CURRENT_SECTION] $1"
+    if [[ "$JSON_MODE" -eq 1 ]]; then
+        printf '{"type":"check","section":"%s","label":"%s","status":"fail","detail":"%s"}\n' \
+            "$(_json_escape "$CURRENT_SECTION")" "$(_json_escape "$1")" "$(_json_escape "${2:-}")"
+    else
+        echo "[FAIL] $1${2:+ :: $2}"
+    fi
+}
+
+log_warn() {
+    WARN_COUNT=$((WARN_COUNT + 1))
+    if [[ "$JSON_MODE" -eq 1 ]]; then
+        printf '{"type":"check","section":"%s","label":"%s","status":"warn","detail":"%s"}\n' \
+            "$(_json_escape "$CURRENT_SECTION")" "$(_json_escape "$1")" "$(_json_escape "${2:-}")"
+    else
+        echo "[WARN] $1${2:+ :: $2}"
+    fi
+}
+
+log_info() {
+    INFO_COUNT=$((INFO_COUNT + 1))
+    if [[ "$JSON_MODE" -eq 1 ]]; then
+        printf '{"type":"check","section":"%s","label":"%s","status":"info","detail":"%s"}\n' \
+            "$(_json_escape "$CURRENT_SECTION")" "$(_json_escape "$1")" "$(_json_escape "${2:-}")"
+    else
+        echo "[INFO] $1${2:+ :: $2}"
+    fi
+}
+
+# Free-form info text — text in default mode, suppressed in JSON
+note() {
+    [[ "$JSON_MODE" -eq 1 ]] && return 0
+    [[ "$QUIET" -eq 1 ]] && return 0
+    echo "$@"
+}
+
+emit_summary() {
+    if [[ "$JSON_MODE" -eq 1 ]]; then
+        printf '{"type":"summary","pass":%d,"fail":%d,"warn":%d,"info":%d,"first_fail":"%s"}\n' \
+            "$PASS_COUNT" "$FAIL_COUNT" "$WARN_COUNT" "$INFO_COUNT" "$(_json_escape "$FIRST_FAIL")"
+    else
+        echo
+        echo "=== SUMMARY ==="
+        echo "  PASS: $PASS_COUNT    FAIL: $FAIL_COUNT    WARN: $WARN_COUNT    INFO: $INFO_COUNT"
+        if [[ -n "$FIRST_FAIL" ]]; then
+            echo "  First failure: $FIRST_FAIL"
+        else
+            echo "  No failures."
+        fi
+    fi
+}
+
+# Redact filter: same regex set as net-ops's, plus macOS-specific patterns.
+# Preserves Tailscale's 100.100.100.100 and public DNS anchors.
+redact_filter() {
+    if [[ "$REDACT" -eq 0 ]]; then cat; return; fi
+    perl -pe '
+        s/100\.100\.100\.100/__TS_MAGIC__/g;
+        s/\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/10.X.X.X/g;
+        s/\b172\.(1[6-9]|2[0-9]|3[01])\.\d{1,3}\.\d{1,3}\b/172.X.X.X/g;
+        s/\b192\.168\.\d{1,3}\.\d{1,3}\b/192.168.X.X/g;
+        s/\b100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}\b/100.X.X.X/g;
+        s/\b169\.254\.\d{1,3}\.\d{1,3}\b/169.254.X.X/g;
+        s/\b[0-9a-fA-F]{2}([:-])[0-9a-fA-F]{2}\1[0-9a-fA-F]{2}\1[0-9a-fA-F]{2}\1[0-9a-fA-F]{2}\1[0-9a-fA-F]{2}\b/XX:XX:XX:XX:XX:XX/g;
+        s/\b[a-z0-9-]+\.ts\.net\b/REDACTED.ts.net/g;
+        # macOS specifics: hostnames matching <name>.local or <name>.lan
+        s/\b([a-zA-Z0-9-]+)\.local\b/HOSTNAME.local/g;
+        # macOS serial numbers (12-char base32-ish, only when prefixed by Serial)
+        s/Serial(?:Number)?[:= ]+\K[A-Z0-9]{10,14}/REDACTED/g;
+        # UUIDs (long volume / device identifiers)
+        s/\b[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\b/UUID-REDACTED/gi;
+        s/__TS_MAGIC__/100.100.100.100/g;
+    '
+}
+
+# Self-reinvoke filter — same pattern as net-ops to handle --redact + --json
+# compose. Strips --redact from inner argv to prevent infinite recursion.
+maybe_filter_self() {
+    [[ "$REDACT" -eq 1 ]] || [[ "$JSON_MODE" -eq 1 ]] || return 0
+    [[ "${_MACOPS_FILTERED:-0}" -eq 1 ]] && return 0
+    export _MACOPS_FILTERED=1
+    local cleaned_args=()
+    for a in "$@"; do [[ "$a" != "--redact" ]] && cleaned_args+=("$a"); done
+    if [[ "$JSON_MODE" -eq 1 ]] && [[ "$REDACT" -eq 1 ]]; then
+        "$0" ${cleaned_args[@]+"${cleaned_args[@]}"} | grep '^{' | redact_filter
+    elif [[ "$JSON_MODE" -eq 1 ]]; then
+        "$0" ${cleaned_args[@]+"${cleaned_args[@]}"} | grep '^{'
+    else
+        "$0" ${cleaned_args[@]+"${cleaned_args[@]}"} | redact_filter
+    fi
+    exit "${PIPESTATUS[0]}"
+}
+
+# Convenience: macOS major version (12, 13, 14, 15, 26...)
+macos_major() {
+    sw_vers -productVersion 2>/dev/null | awk -F. '{print $1}'
+}
+
+# Convenience: am I on Apple Silicon?
+is_apple_silicon() {
+    [[ "$(uname -m)" == "arm64" ]]
+}

+ 141 - 0
skills/mac-ops/scripts/boot-perf.sh

@@ -0,0 +1,141 @@
+#!/usr/bin/env bash
+# mac-ops :: boot-perf.sh
+# Measure boot duration and identify slow startup components.
+# macOS records boot events in the unified log; we extract the markers.
+
+set -u
+
+DAYS=7
+SHOW_N=10
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        --days) DAYS="$2"; shift 2 ;;
+        --show) SHOW_N="$2"; shift 2 ;;
+        --help|-h)
+            cat <<EOF
+Usage: $0 [options]
+
+  --days N       How many days of boot history to scan (default: 7)
+  --show N       How many recent boots to show (default: 10)
+  --json, --redact, --quiet, --verbose
+
+Healthy:
+  Apple Silicon Mac to login:   10-20s
+  Intel Mac (SSD):              20-35s
+  Intel Mac (HDD, vintage):     45-90s
+  Failing storage:              60s+ with stalls
+EOF
+            exit 0 ;;
+        *) shift ;;
+    esac
+done
+
+source "$(dirname "$0")/_lib/common.sh"
+parse_common_flags "$@"
+maybe_filter_self "$@"
+
+# ----------------------------------------------------------------------------
+section "1. RECENT BOOT TIMES"
+# ----------------------------------------------------------------------------
+# Approach: find each "kernel boot" marker, then compute time until loginwindow
+# completes its initial setup. The unified log has BOOT_TIME / "kernel boot"
+# markers as well as loginwindow setup messages.
+note "  Scanning unified log for last ${DAYS}d of boot events..."
+
+# macOS marks the kernel boot start with "boot complete" + "boot session" + "first user event"
+# We grep for kernel-version + UUID lines that mark a fresh boot.
+boots_raw=$(log show --last "${DAYS}d" --style compact --predicate \
+    'process == "kernel" AND (eventMessage CONTAINS "Darwin Kernel Version" OR eventMessage CONTAINS "boot args")' \
+    2>/dev/null | head -100)
+
+if [[ -z "$boots_raw" ]]; then
+    log_info "Boot events" "no boot markers found in window — try a wider --days N"
+else
+    # Each fresh boot logs "Darwin Kernel Version" once; count them
+    boot_count=$(echo "$boots_raw" | grep -c "Darwin Kernel Version" || echo 0)
+    log_info "Boots in last ${DAYS}d" "${boot_count:-0}"
+    note "  Recent boot markers (most recent ${SHOW_N}):"
+    echo "$boots_raw" | grep "Darwin Kernel Version" | tail -"$SHOW_N" | awk '{print $1, $2}' | sed 's/^/    /'
+fi
+
+# ----------------------------------------------------------------------------
+section "2. CURRENT BOOT DURATION ESTIMATE"
+# ----------------------------------------------------------------------------
+# Find the most recent boot marker (Darwin Kernel Version line)
+boot_start_line=$(log show --last "${DAYS}d" --style compact --predicate \
+    'process == "kernel" AND eventMessage CONTAINS "Darwin Kernel Version"' \
+    2>/dev/null | tail -1)
+boot_start_ts=$(echo "$boot_start_line" | awk '{print $1, $2}')
+
+if [[ -z "$boot_start_ts" ]]; then
+    log_warn "Boot start timestamp" "could not extract"
+else
+    note "  Boot start:  $boot_start_ts"
+    # Find first WindowServer / loginwindow ready event AFTER boot
+    if loginwindow_evt=$(log show --start "$boot_start_ts" --style compact 2>/dev/null \
+            | grep -E "(loginwindow.*started|WindowServer.*started|opendirectoryd started)" \
+            | head -3); then
+        note "  Earliest user-space events after boot:"
+        echo "$loginwindow_evt" | sed 's/^/    /'
+    fi
+
+    # Attempt to compute seconds from boot to loginwindow
+    first_user_event=$(echo "$loginwindow_evt" | head -1 | awk '{print $1, $2}')
+    if [[ -n "$first_user_event" ]] && command -v gdate >/dev/null 2>&1; then
+        b=$(gdate -d "$boot_start_ts" +%s 2>/dev/null)
+        f=$(gdate -d "$first_user_event" +%s 2>/dev/null)
+        if [[ -n "$b" ]] && [[ -n "$f" ]]; then
+            diff=$((f - b))
+            log_info "Boot duration (boot → user-space)" "${diff}s"
+        fi
+    else
+        log_info "Boot duration calc" "install coreutils (brew install coreutils) for gdate-based timing"
+    fi
+fi
+
+# ----------------------------------------------------------------------------
+section "3. SLOW LAUNCH AGENTS"
+# ----------------------------------------------------------------------------
+# Find agents that took long to start. Narrow to launchd messages specifically;
+# avoid matching Wi-Fi/airportd "throttled=0" noise.
+slow_events=$(log show --last "${DAYS}d" --style compact --predicate \
+    'process == "launchd" AND (eventMessage CONTAINS "took longer than" OR eventMessage CONTAINS "throttled by" OR eventMessage CONTAINS "exited with abnormal code")' \
+    2>/dev/null | head -20)
+
+if [[ -z "$slow_events" ]]; then
+    log_pass "Slow launchd events" "none found"
+else
+    n=$(echo "$slow_events" | wc -l | tr -d ' ')
+    log_warn "Slow launchd events" "$n events — see below"
+    echo "$slow_events" | head -10 | sed 's/^/    /'
+fi
+
+# ----------------------------------------------------------------------------
+section "4. LOGINWINDOW DELAYS"
+# ----------------------------------------------------------------------------
+# loginwindow logs assertions about slow login items
+loginwindow_delays=$(log show --last "${DAYS}d" --style compact \
+    --predicate 'process == "loginwindow"' 2>/dev/null \
+    | grep -iE "(delay|slow|timed out|waited)" \
+    | head -10)
+
+if [[ -n "$loginwindow_delays" ]]; then
+    log_warn "loginwindow delay messages" "see below"
+    echo "$loginwindow_delays" | sed 's/^/    /'
+else
+    log_pass "loginwindow delays" "none reported"
+fi
+
+# ----------------------------------------------------------------------------
+section "5. SAFE-BOOT / VERBOSE-BOOT INDICATORS"
+# ----------------------------------------------------------------------------
+# nvram for boot args
+boot_args=$(nvram boot-args 2>/dev/null | awk '{print $2}')
+if [[ "$boot_args" == *"-v"* ]] || [[ "$boot_args" == *"-x"* ]]; then
+    log_warn "NVRAM boot-args" "$boot_args — non-default boot mode"
+else
+    log_pass "NVRAM boot-args" "default (${boot_args:-empty})"
+fi
+
+# ----------------------------------------------------------------------------
+emit_summary

+ 224 - 0
skills/mac-ops/scripts/disk-health.sh

@@ -0,0 +1,224 @@
+#!/usr/bin/env bash
+# mac-ops :: disk-health.sh
+# Per-disk / per-volume deep dive. Maps APFS containers, surfaces IO errors
+# from the unified log, reports SMART status (where macOS exposes it), and
+# checks snapshot bloat.
+#
+# Usage:
+#   scripts/disk-health.sh                            # all disks
+#   scripts/disk-health.sh -d disk2                   # by /dev/diskN
+#   scripts/disk-health.sh -v /Volumes/Foo            # by mount point
+#   scripts/disk-health.sh -v /                       # boot volume
+
+set -u
+
+TARGET_DEV=""
+TARGET_VOL=""
+DAYS=30
+
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        -d|--disk) TARGET_DEV="$2"; shift 2 ;;
+        -v|--volume) TARGET_VOL="$2"; shift 2 ;;
+        --days) DAYS="$2"; shift 2 ;;
+        --help|-h)
+            cat <<EOF
+Usage: $0 [options]
+
+  -d, --disk diskN          Inspect specific device (e.g. disk2)
+  -v, --volume PATH         Inspect by mount point (e.g. / or /Volumes/X)
+  --days N                  Log lookback window (default: 30)
+  --json, --redact, --quiet, --verbose   Standard flags
+
+Output sections:
+  1. Device summary (model, size, bus, SMART status)
+  2. APFS container + volume layout (if APFS)
+  3. IO errors via unified log (last --days)
+  4. Snapshot bloat (Time Machine local snapshots)
+  5. Free space / purgeable space breakdown
+  6. Mount + fsck verification status
+EOF
+            exit 0 ;;
+        *) shift ;;
+    esac
+done
+
+source "$(dirname "$0")/_lib/common.sh"
+parse_common_flags "$@"
+maybe_filter_self "$@"
+
+# Resolve target → /dev/diskN
+resolve_target() {
+    if [[ -n "$TARGET_DEV" ]]; then
+        echo "${TARGET_DEV#/dev/}"
+        return
+    fi
+    if [[ -n "$TARGET_VOL" ]]; then
+        diskutil info "$TARGET_VOL" 2>/dev/null | awk -F': *' '/Device Identifier/{print $2; exit}'
+        return
+    fi
+    # No target — return empty (we'll iterate all)
+    echo ""
+}
+
+disk_id=$(resolve_target)
+
+# ----------------------------------------------------------------------------
+section "1. DEVICE SUMMARY"
+# ----------------------------------------------------------------------------
+if [[ -n "$disk_id" ]]; then
+    targets=("$disk_id")
+else
+    # All physical disks (not partitions / synthesized)
+    mapfile -t targets < <(diskutil list 2>/dev/null | awk '/^\/dev\/disk[0-9]+ /{gsub("/dev/",""); print $1}' | sort -u | head -20)
+fi
+
+for d in "${targets[@]}"; do
+    [[ -z "$d" ]] && continue
+    info=$(diskutil info "$d" 2>/dev/null)
+    [[ -z "$info" ]] && { log_warn "diskutil info $d" "no data"; continue; }
+
+    model=$(echo "$info" | awk -F': *' '/Device \/ Media Name/{print $2; exit}')
+    bus=$(echo "$info" | awk -F': *' '/Protocol/{print $2; exit}')
+    size=$(echo "$info" | awk -F': *' '/Disk Size/{print $2; exit}')
+    smart=$(echo "$info" | awk -F': *' '/SMART Status/{print $2; exit}')
+    internal=$(echo "$info" | awk -F': *' '/Device Location/{print $2; exit}')
+
+    note "  /dev/$d"
+    note "    Model:     ${model:-(unknown)}"
+    note "    Bus:       ${bus:-?}    Location: ${internal:-?}"
+    note "    Size:      ${size:-?}"
+
+    case "$smart" in
+        Verified)
+            log_pass "/dev/$d SMART status" "Verified" ;;
+        Failing|Failed)
+            log_fail "/dev/$d SMART status" "$smart — back up immediately, do not write to drive" ;;
+        "Not Supported"|"")
+            log_info "/dev/$d SMART status" "${smart:-(not exposed; macOS limitation for many NVMe drives)}" ;;
+        *)
+            log_warn "/dev/$d SMART status" "$smart" ;;
+    esac
+done
+
+# ----------------------------------------------------------------------------
+section "2. APFS CONTAINERS + VOLUMES"
+# ----------------------------------------------------------------------------
+if [[ -n "$disk_id" ]]; then
+    diskutil apfs list "$disk_id" 2>/dev/null | sed 's/^/  /' | head -60
+else
+    diskutil apfs list 2>/dev/null | sed 's/^/  /' | head -80
+fi
+
+# Volumes per target (with free space)
+note ""
+note "  Mounted APFS volumes:"
+df -h | awk 'NR==1 || /\/dev\/disk.* apfs|\/dev\/disk.*\/Volumes/{print "    " $0}' | head -12
+
+# ----------------------------------------------------------------------------
+section "3. IO ERRORS (unified log, last ${DAYS}d)"
+# ----------------------------------------------------------------------------
+io_lines=$(log show --last "${DAYS}d" --style compact \
+    --predicate '(subsystem == "com.apple.iokit" OR subsystem == "com.apple.kernel") AND (eventMessage CONTAINS[c] "I/O error" OR eventMessage CONTAINS[c] "media error" OR eventMessage CONTAINS[c] "MEDIA_ERROR" OR eventMessage CONTAINS[c] "device timeout")' \
+    2>/dev/null)
+io_count=$(echo "$io_lines" | grep -c . || echo 0)
+
+if [[ "$io_count" -gt 50 ]]; then
+    log_fail "IO errors in log" "$io_count events — active failure"
+    note "  Sample (first 5):"
+    echo "$io_lines" | head -5 | sed 's/^/    /'
+elif [[ "$io_count" -gt 5 ]]; then
+    log_warn "IO errors in log" "$io_count events"
+    note "  Sample (first 3):"
+    echo "$io_lines" | head -3 | sed 's/^/    /'
+elif [[ "$io_count" -gt 0 ]]; then
+    log_info "IO errors in log" "$io_count events (occasional events normal)"
+else
+    log_pass "IO errors in log" "0"
+fi
+
+# APFS-specific corruption signal
+apfs_errors=$(log show --last "${DAYS}d" --style compact \
+    --predicate 'eventMessage CONTAINS "apfs" AND (messageType == "Error" OR messageType == "Fault")' \
+    2>/dev/null | wc -l | tr -d ' ')
+if [[ "$apfs_errors" -gt 10 ]]; then
+    log_warn "APFS error/fault events" "$apfs_errors"
+else
+    log_pass "APFS error/fault events" "$apfs_errors"
+fi
+
+# ----------------------------------------------------------------------------
+section "4. APFS SNAPSHOT BLOAT"
+# ----------------------------------------------------------------------------
+# Per-volume snapshot count
+mount | awk '/apfs/{print $3}' | while read -r mnt; do
+    [[ -z "$mnt" ]] && continue
+    snap_count=$(tmutil listlocalsnapshots "$mnt" 2>/dev/null | grep -c "com.apple" || echo 0)
+    if [[ "$snap_count" -gt 20 ]]; then
+        log_warn "Snapshots on $mnt" "$snap_count — purgeable space tied up"
+    elif [[ "$snap_count" -gt 0 ]]; then
+        log_info "Snapshots on $mnt" "$snap_count"
+    else
+        log_pass "Snapshots on $mnt" "0"
+    fi
+done
+
+# ----------------------------------------------------------------------------
+section "5. FREE SPACE / PURGEABLE BREAKDOWN"
+# ----------------------------------------------------------------------------
+if [[ -n "$TARGET_VOL" ]]; then
+    volumes=("$TARGET_VOL")
+else
+    mapfile -t volumes < <(mount | awk '/apfs/{print $3}' | head -6)
+fi
+
+for v in "${volumes[@]}"; do
+    [[ -d "$v" ]] || continue
+    df_line=$(df -h "$v" 2>/dev/null | tail -1)
+    free_pct=$(echo "$df_line" | awk '{gsub("%","",$5); print 100-$5}')
+    free_gb=$(echo "$df_line" | awk '{print $4}')
+    note "  $v: ${free_gb} free (${free_pct}%)"
+    if [[ "$free_pct" -lt 10 ]]; then
+        log_warn "Free space on $v" "${free_pct}% — low"
+    else
+        log_pass "Free space on $v" "${free_pct}%"
+    fi
+    # Purgeable space from APFS (requires diskutil apfs)
+    purgeable=$(diskutil apfs list 2>/dev/null | awk -v vol="$v" '
+        $0 ~ vol {found=1}
+        found && /Capacity In Use/{print $NF; found=0; exit}
+    ')
+done
+
+# ----------------------------------------------------------------------------
+section "6. VOLUME VERIFICATION (read-only)"
+# ----------------------------------------------------------------------------
+# Only verify the target if we have one; iterating all volumes is slow + noisy.
+if [[ -n "$TARGET_VOL" ]]; then
+    verify_target="$TARGET_VOL"
+elif [[ -n "$disk_id" ]]; then
+    verify_target="$disk_id"
+else
+    verify_target=""
+fi
+
+if [[ -n "$verify_target" ]]; then
+    note "  Running: diskutil verifyVolume $verify_target (read-only)"
+    if diskutil verifyVolume "$verify_target" 2>&1 | grep -q "appears to be OK"; then
+        log_pass "verifyVolume $verify_target" "OK"
+    else
+        log_warn "verifyVolume $verify_target" "did not return clean (may need sudo or already in use)"
+    fi
+else
+    note "  (skipped — pass -v or -d to verify a specific target)"
+fi
+
+emit_summary
+
+if [[ "$JSON_MODE" -eq 0 ]]; then
+    echo
+    note "  Drilldowns:"
+    note "    drive-dependencies.sh -v <mount>   # check what references a volume"
+    note "    storage-pressure.sh                # snapshot bloat detail"
+    note "    recover-clone.sh                   # safely image data off a failing drive"
+fi

+ 206 - 0
skills/mac-ops/scripts/drive-dependencies.sh

@@ -0,0 +1,206 @@
+#!/usr/bin/env bash
+# mac-ops :: drive-dependencies.sh
+# "Is it safe to eject this volume?" — find every reference to a volume
+# before you yank the cable / unmount / destroy a snapshot.
+#
+# Checks:
+#   - Open files via lsof
+#   - Spotlight index state
+#   - Time Machine destination
+#   - Photos / Music / TV library locations
+#   - Helper-tool security-scoped bookmarks (best-effort)
+#   - Symlinks pointing into the volume from common locations
+#   - Background processes with cwd inside the volume
+
+set -u
+
+TARGET=""
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        -v|--volume) TARGET="$2"; shift 2 ;;
+        --help|-h)
+            cat <<EOF
+Usage: $0 -v <mount-point> [options]
+
+  -v, --volume PATH        Volume to check (e.g. /Volumes/Backup, /)
+  --json, --redact, --quiet, --verbose   Standard flags
+
+Verdict: "safe to eject" requires PASS on every check. Any FAIL/WARN means
+something would break or lose state on disconnect.
+EOF
+            exit 0 ;;
+        *) shift ;;
+    esac
+done
+
+if [[ -z "$TARGET" ]]; then
+    echo "Error: -v <mount-point> required (e.g. -v /Volumes/Backup)" >&2
+    exit 2
+fi
+
+if [[ ! -d "$TARGET" ]]; then
+    echo "Error: $TARGET is not a directory / not mounted" >&2
+    exit 3
+fi
+
+source "$(dirname "$0")/_lib/common.sh"
+parse_common_flags "$@"
+maybe_filter_self "$@"
+
+note "  Volume: $TARGET"
+
+# ----------------------------------------------------------------------------
+section "1. OPEN FILES (lsof)"
+# ----------------------------------------------------------------------------
+# `lsof +D` is recursive and VERY slow on large volumes (especially $HOME).
+# Use `lsof` without +D and grep by mount point — much faster, equivalent
+# accuracy for "is anything open under this path".
+target_real=$(cd "$TARGET" 2>/dev/null && pwd -P || echo "$TARGET")
+open_lines=$(lsof -F n 2>/dev/null | grep "^n${target_real}/" 2>/dev/null || true)
+open_count=$(printf '%s\n' "$open_lines" | grep -c . 2>/dev/null || echo 0)
+if [[ "$open_count" -gt 0 ]]; then
+    log_fail "Open file handles" "$open_count — unmount will fail or corrupt"
+    note "  Top processes holding files (sample):"
+    # lsof -F format is column-based; use plain lsof for the process listing
+    lsof 2>/dev/null | awk -v t="$target_real" '$NF ~ "^"t"/"{print $1, $2}' | sort -u | head -10 | sed 's/^/    /'
+else
+    log_pass "Open file handles" "0"
+fi
+
+# ----------------------------------------------------------------------------
+section "2. PROCESSES WITH CWD INSIDE VOLUME"
+# ----------------------------------------------------------------------------
+# Use lsof -c with -d cwd for current working directories
+cwd_procs=$(lsof -d cwd 2>/dev/null | awk -v t="$TARGET" '$NF ~ t {print $1, $2}' | sort -u)
+if [[ -n "$cwd_procs" ]]; then
+    cwd_count=$(echo "$cwd_procs" | wc -l | tr -d ' ')
+    log_warn "Processes with cwd inside" "$cwd_count"
+    echo "$cwd_procs" | head -5 | sed 's/^/    /'
+else
+    log_pass "Processes with cwd inside" "0"
+fi
+
+# ----------------------------------------------------------------------------
+section "3. SPOTLIGHT INDEX STATE"
+# ----------------------------------------------------------------------------
+spotlight_status=$(mdutil -s "$TARGET" 2>/dev/null | tail -1)
+note "  $spotlight_status"
+case "$spotlight_status" in
+    *"Indexing enabled"*) log_warn "Spotlight indexing" "enabled on this volume — eject may corrupt index" ;;
+    *"Indexing disabled"*) log_pass "Spotlight indexing" "disabled" ;;
+    *) log_info "Spotlight indexing" "$spotlight_status" ;;
+esac
+
+# ----------------------------------------------------------------------------
+section "4. TIME MACHINE DESTINATION CHECK"
+# ----------------------------------------------------------------------------
+tm_dest=$(tmutil destinationinfo 2>/dev/null | awk -F': *' '/Mount Point/{print $2}')
+if [[ "$tm_dest" == "$TARGET" ]] || [[ "$TARGET" == "$tm_dest"/* ]]; then
+    log_fail "Time Machine destination" "this volume IS the TM target — eject will fail current/next backup"
+elif [[ -n "$tm_dest" ]]; then
+    log_pass "Time Machine destination" "different volume ($tm_dest)"
+else
+    log_pass "Time Machine destination" "none configured"
+fi
+
+# Recent TM activity touching this volume
+tm_active=$(tmutil currentphase 2>/dev/null)
+if [[ "$tm_active" != "BackupNotRunning" ]] && [[ -n "$tm_active" ]]; then
+    log_warn "Time Machine current phase" "$tm_active — wait before eject"
+fi
+
+# ----------------------------------------------------------------------------
+section "5. MEDIA LIBRARY LOCATIONS"
+# ----------------------------------------------------------------------------
+# Photos library
+photos_lib=$(defaults read com.apple.Photos UserLibrarySelectionMethod 2>/dev/null || true)
+# Best-effort: check common Photos library paths under this volume
+photos_libs=$(find "$TARGET" -maxdepth 3 -name "Photos Library.photoslibrary" -type d 2>/dev/null | head -3)
+if [[ -n "$photos_libs" ]]; then
+    log_warn "Photos library detected on volume" "$(echo "$photos_libs" | head -1)"
+fi
+
+# Music library
+music_libs=$(find "$TARGET" -maxdepth 3 -name "*.musiclibrary" -type d 2>/dev/null | head -3)
+if [[ -n "$music_libs" ]]; then
+    log_warn "Music library detected on volume" "$(echo "$music_libs" | head -1)"
+fi
+
+# Final Cut / Logic / iMovie libraries
+fcp_libs=$(find "$TARGET" -maxdepth 3 \( -name "*.fcpbundle" -o -name "*.logicx" -o -name "*.imovielibrary" \) -type d 2>/dev/null | head -3)
+if [[ -n "$fcp_libs" ]]; then
+    log_warn "Pro app library detected on volume" "$(echo "$fcp_libs" | head -1)"
+fi
+
+# ----------------------------------------------------------------------------
+section "6. SYMLINKS POINTING INTO VOLUME"
+# ----------------------------------------------------------------------------
+# Common places where symlinks land
+declare -a check_dirs=(
+    "$HOME/Documents"
+    "$HOME/Desktop"
+    "$HOME/Movies"
+    "$HOME/Music"
+    "$HOME/Pictures"
+    "$HOME/Library/Mobile Documents"
+)
+symlink_count=0
+for d in "${check_dirs[@]}"; do
+    [[ -d "$d" ]] || continue
+    found=$(find "$d" -maxdepth 2 -type l 2>/dev/null | while read -r link; do
+        dest=$(readlink "$link")
+        [[ "$dest" == "$TARGET"/* ]] && echo "$link -> $dest"
+    done)
+    if [[ -n "$found" ]]; then
+        n=$(echo "$found" | wc -l | tr -d ' ')
+        symlink_count=$((symlink_count + n))
+        echo "$found" | head -3 | sed 's/^/    /'
+    fi
+done
+
+if [[ "$symlink_count" -gt 0 ]]; then
+    log_warn "Symlinks pointing into volume" "$symlink_count — they'll dangle on eject"
+else
+    log_pass "Symlinks pointing into volume" "0"
+fi
+
+# ----------------------------------------------------------------------------
+section "7. PRIVILEGED HELPER / LAUNCH ITEMS REFERENCING VOLUME"
+# ----------------------------------------------------------------------------
+# Grep launchd plists for paths inside the target
+helper_refs=0
+for d in "$HOME/Library/LaunchAgents" /Library/LaunchAgents /Library/LaunchDaemons; do
+    [[ -d "$d" ]] || continue
+    matches=$(grep -l "$TARGET" "$d"/*.plist 2>/dev/null || true)
+    if [[ -n "$matches" ]]; then
+        helper_refs=$((helper_refs + $(echo "$matches" | wc -l | tr -d ' ')))
+        echo "$matches" | head -3 | sed 's|^|    |'
+    fi
+done
+
+if [[ "$helper_refs" -gt 0 ]]; then
+    log_warn "Launchd plists referencing volume" "$helper_refs — daemons will fail on eject"
+else
+    log_pass "Launchd plists referencing volume" "0"
+fi
+
+# ----------------------------------------------------------------------------
+section "8. APP BOOKMARKS / RECENTS"
+# ----------------------------------------------------------------------------
+# Sandboxed apps store security-scoped bookmarks; we can't decode them without
+# the app, but we can list which apps have recents pointing at this volume.
+note "  (App security-scoped bookmarks aren't directly inspectable — this is informational)"
+
+# ----------------------------------------------------------------------------
+emit_summary
+
+if [[ "$JSON_MODE" -eq 0 ]]; then
+    echo
+    if [[ "$FAIL_COUNT" -eq 0 ]] && [[ "$WARN_COUNT" -eq 0 ]]; then
+        echo "  ✓ Safe to eject $TARGET — no system references detected."
+    elif [[ "$FAIL_COUNT" -gt 0 ]]; then
+        echo "  ✗ NOT safe to eject $TARGET — eject will fail or break the items above."
+    else
+        echo "  ⚠ Ejecting will work, but the items above will dangle or stop working until remount."
+    fi
+fi

+ 252 - 0
skills/mac-ops/scripts/health-audit.sh

@@ -0,0 +1,252 @@
+#!/usr/bin/env bash
+# mac-ops :: health-audit.sh
+# Comprehensive macOS workstation audit — the orchestrator.
+# Walks the 8-rung diagnostic ladder and emits a verdict.
+#
+# Usage:
+#   scripts/health-audit.sh [--json] [--redact] [--quiet] [--verbose] [--days N]
+#
+# Stdout = data (text by default, NDJSON when --json).
+# Stderr = section banners (suppressed with --quiet).
+
+set -u
+
+DAYS=30
+
+for arg in "$@"; do
+    case "$arg" in
+        --days) shift; DAYS="${1:-30}" ;;
+        --days=*) DAYS="${arg#--days=}" ;;
+        --help|-h)
+            cat <<EOF
+Usage: $0 [options]
+
+  --days N         Days back to scan logs (default: 30)
+  --json           Emit NDJSON for piping to jq
+  --redact         Mask private IPs, MACs, UUIDs, hostnames
+  --quiet|-q       Suppress section banners
+  --verbose|-v     Include extra detail (e.g. per-volume APFS dump)
+
+Exit codes (reflect whether the audit RAN, not what it found):
+  0  audit completed (findings in output)
+  1  general error
+  2  usage error
+  5  precondition missing
+EOF
+            exit 0 ;;
+    esac
+done
+
+source "$(dirname "$0")/_lib/common.sh"
+parse_common_flags "$@"
+maybe_filter_self "$@"
+
+# ----------------------------------------------------------------------------
+section "1. HARDWARE HEALTH"
+# ----------------------------------------------------------------------------
+# Thermal events, low-battery shutdown, SMC errors
+thermal_events=$(log show --last "${DAYS}d" --style compact \
+    --predicate 'subsystem == "com.apple.thermalmonitord"' 2>/dev/null | wc -l | tr -d ' ')
+if [[ "$thermal_events" -gt 100 ]]; then
+    log_warn "Thermal monitor events ($DAYS days)" "$thermal_events — possible sustained throttling"
+else
+    log_pass "Thermal monitor events ($DAYS days)" "$thermal_events"
+fi
+
+# Unclean shutdowns (power assertions + kernel)
+unclean=$(log show --last "${DAYS}d" --style compact \
+    --predicate 'eventMessage CONTAINS[c] "previous shutdown cause" AND eventMessage CONTAINS "-"' 2>/dev/null \
+    | grep -cE "previous shutdown cause:\s*-[0-9]+" || true)
+if [[ "$unclean" -gt 2 ]]; then
+    log_warn "Unclean shutdowns ($DAYS days)" "$unclean recorded"
+elif [[ "$unclean" -gt 0 ]]; then
+    log_info "Unclean shutdowns ($DAYS days)" "$unclean"
+else
+    log_pass "Unclean shutdowns ($DAYS days)" "0"
+fi
+
+# Battery condition (laptops only)
+if pmset -g batt 2>/dev/null | grep -q "InternalBattery"; then
+    cycles=$(system_profiler SPPowerDataType 2>/dev/null | awk '/Cycle Count/{print $3; exit}')
+    condition=$(system_profiler SPPowerDataType 2>/dev/null | awk -F': ' '/Condition/{print $2; exit}')
+    if [[ "$condition" == "Normal" ]]; then
+        log_pass "Battery condition" "$condition (cycles=$cycles)"
+    else
+        log_warn "Battery condition" "$condition (cycles=$cycles)"
+    fi
+fi
+
+# ----------------------------------------------------------------------------
+section "2. STORAGE HEALTH"
+# ----------------------------------------------------------------------------
+# IO errors via unified log
+io_errors=$(log show --last "${DAYS}d" --style compact \
+    --predicate '(subsystem == "com.apple.iokit" OR subsystem == "com.apple.kernel") AND (eventMessage CONTAINS[c] "I/O error" OR eventMessage CONTAINS[c] "media error" OR eventMessage CONTAINS[c] "media is not present")' \
+    2>/dev/null | wc -l | tr -d ' ')
+if [[ "$io_errors" -gt 20 ]]; then
+    log_fail "IO errors via log ($DAYS days)" "$io_errors — investigate per-volume with disk-health.sh"
+elif [[ "$io_errors" -gt 0 ]]; then
+    log_warn "IO errors via log ($DAYS days)" "$io_errors"
+else
+    log_pass "IO errors via log ($DAYS days)" "0"
+fi
+
+# APFS verify per mounted APFS volume (read-only — safe)
+note "  APFS volumes:"
+while read -r line; do
+    [[ -z "$line" ]] && continue
+    disk=$(echo "$line" | awk '{print $1}')
+    mount=$(echo "$line" | awk '{print $NF}')
+    # diskutil apfs verifyVolume is read-only; skip noisy ones we can't auth for
+    if diskutil apfs verifyVolume "$disk" 2>&1 | grep -q "successfully verified"; then
+        log_pass "APFS volume $disk" "$mount — verified"
+    else
+        # Probably needs privileges or is in use; soft-pass with info
+        log_info "APFS volume $disk" "$mount — verify skipped (may need sudo or volume in use)"
+    fi
+done < <(diskutil list 2>/dev/null | awk '/Apple_APFS_Container/{print $NF, $NF}' | sort -u)
+
+# APFS snapshot bloat — local Time Machine snapshots
+snap_count=$(tmutil listlocalsnapshots / 2>/dev/null | wc -l | tr -d ' ')
+if [[ "$snap_count" -gt 10 ]]; then
+    log_warn "Local Time Machine snapshots on /" "$snap_count — may eat purgeable space"
+else
+    log_pass "Local Time Machine snapshots on /" "$snap_count"
+fi
+
+# Free space on root volume
+root_free_pct=$(df -h / | awk 'NR==2{gsub("%","",$5); print 100-$5}')
+if [[ "$root_free_pct" -lt 5 ]]; then
+    log_fail "Free space on /" "${root_free_pct}% — critical"
+elif [[ "$root_free_pct" -lt 15 ]]; then
+    log_warn "Free space on /" "${root_free_pct}%"
+else
+    log_pass "Free space on /" "${root_free_pct}%"
+fi
+
+# ----------------------------------------------------------------------------
+section "3. PANIC RECORDS"
+# ----------------------------------------------------------------------------
+panic_dir="/Library/Logs/DiagnosticReports"
+panics_recent=$(find "$panic_dir" -maxdepth 1 \( -name "*.panic" -o -name "Kernel*.ips" \) \
+    -mtime "-${DAYS}" 2>/dev/null | wc -l | tr -d ' ')
+if [[ "$panics_recent" -gt 0 ]]; then
+    log_fail "Kernel panics ($DAYS days)" "$panics_recent — drill with panic-triage.sh"
+    note "  Most recent:"
+    find "$panic_dir" -maxdepth 1 \( -name "*.panic" -o -name "Kernel*.ips" \) -mtime "-${DAYS}" 2>/dev/null \
+        | head -3 | sed 's|.*/|    |'
+else
+    log_pass "Kernel panics ($DAYS days)" "0"
+fi
+
+# User app crashes (informational — they don't crash the system but indicate flaky software)
+user_crashes=$(find ~/Library/Logs/DiagnosticReports -maxdepth 1 -name "*.ips" -mtime "-7" 2>/dev/null | wc -l | tr -d ' ')
+if [[ "$user_crashes" -gt 20 ]]; then
+    log_warn "User-space app crashes (7 days)" "$user_crashes — frequent crashes"
+else
+    log_info "User-space app crashes (7 days)" "$user_crashes"
+fi
+
+# ----------------------------------------------------------------------------
+section "4. STARTUP INVENTORY"
+# ----------------------------------------------------------------------------
+# Login Items (visible in System Settings)
+login_items=$(osascript -e 'tell application "System Events" to count of login items' 2>/dev/null || echo 0)
+log_info "Login Items (System Settings)" "$login_items"
+
+# User LaunchAgents
+user_agents=$(find "$HOME/Library/LaunchAgents" -maxdepth 1 -name "*.plist" 2>/dev/null | wc -l | tr -d ' ')
+log_info "User LaunchAgents (~/Library/LaunchAgents)" "$user_agents"
+
+# System LaunchAgents
+sys_agents=$(find "/Library/LaunchAgents" -maxdepth 1 -name "*.plist" 2>/dev/null | wc -l | tr -d ' ')
+log_info "System LaunchAgents (/Library/LaunchAgents)" "$sys_agents"
+
+# System LaunchDaemons
+sys_daemons=$(find "/Library/LaunchDaemons" -maxdepth 1 -name "*.plist" 2>/dev/null | wc -l | tr -d ' ')
+log_info "System LaunchDaemons (/Library/LaunchDaemons)" "$sys_daemons"
+
+# Privileged helper tools (often orphaned after app uninstall)
+helpers=$(find "/Library/PrivilegedHelperTools" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ')
+if [[ "$helpers" -gt 5 ]]; then
+    log_warn "Privileged helper tools" "$helpers — some may be orphans from uninstalled apps"
+else
+    log_info "Privileged helper tools" "$helpers"
+fi
+
+total_startup=$((login_items + user_agents + sys_agents + sys_daemons))
+note "  Total startup items: $total_startup (drill with startup-audit.sh)"
+
+# ----------------------------------------------------------------------------
+section "5. RESOURCE PRESSURE (snapshot)"
+# ----------------------------------------------------------------------------
+# Top 5 CPU consumers — `-o command` includes full path/args; we keep it short with cut
+note "  Top 5 by CPU%:"
+ps -ArcS -o pcpu,pid,command 2>/dev/null | head -6 | tail -5 | \
+    awk '{pcpu=$1; pid=$2; $1=""; $2=""; sub(/^  /,""); printf "    %5s%% [%6s] %s\n", pcpu, pid, $0}' | \
+    cut -c1-100
+
+# Notable noisy processes
+for proc in mds_stores mdworker_shared photoanalysisd cloudd bird WindowServer; do
+    if cpu=$(ps -ArcS -o pcpu,comm 2>/dev/null | awk -v p="$proc" '$2==p{print $1; exit}'); then
+        if [[ -n "$cpu" ]]; then
+            cpu_int=${cpu%.*}
+            if [[ "${cpu_int:-0}" -gt 50 ]]; then
+                log_warn "$proc CPU" "${cpu}% — sustained spike?"
+            else
+                log_info "$proc CPU" "${cpu}%"
+            fi
+        fi
+    fi
+done
+
+# ----------------------------------------------------------------------------
+section "6. WAKE PATTERN (last 24h)"
+# ----------------------------------------------------------------------------
+wake_count=$(pmset -g log 2>/dev/null | grep -cE "Wake from" | head -1)
+wake_count="${wake_count:-0}"
+if [[ "$wake_count" -gt 50 ]]; then
+    log_warn "Wakes in pmset log (full history)" "$wake_count — drill with wake-reasons.sh"
+else
+    log_info "Wakes in pmset log (full history)" "$wake_count"
+fi
+
+# ----------------------------------------------------------------------------
+section "7. TCC (PERMISSIONS)"
+# ----------------------------------------------------------------------------
+# Read-only check — does the user TCC.db exist? How many entries?
+user_tcc="$HOME/Library/Application Support/com.apple.TCC/TCC.db"
+if [[ -r "$user_tcc" ]]; then
+    grants=$(sqlite3 "$user_tcc" "SELECT COUNT(*) FROM access WHERE auth_value > 0" 2>/dev/null || echo "?")
+    denied=$(sqlite3 "$user_tcc" "SELECT COUNT(*) FROM access WHERE auth_value = 0" 2>/dev/null || echo "?")
+    log_info "User TCC grants (allowed)" "$grants"
+    if [[ "$denied" != "?" ]] && [[ "$denied" -gt 0 ]]; then
+        log_warn "User TCC grants (denied)" "$denied — drill with tcc-audit.sh"
+    else
+        log_pass "User TCC denials" "0"
+    fi
+else
+    log_info "User TCC.db" "not readable (run tcc-audit.sh for details)"
+fi
+
+# ----------------------------------------------------------------------------
+section "8. SYSTEM INFO"
+# ----------------------------------------------------------------------------
+note "  macOS:      $(sw_vers -productVersion) ($(sw_vers -buildVersion))"
+note "  Arch:       $(uname -m)"
+note "  Uptime:     $(uptime | awk -F'up ' '{split($2,a,","); print a[1]}')"
+note "  Hostname:   $(scutil --get LocalHostName 2>/dev/null || hostname)"
+
+# ----------------------------------------------------------------------------
+emit_summary
+
+if [[ "$JSON_MODE" -eq 0 ]] && [[ -n "$FIRST_FAIL" ]]; then
+    case "$FIRST_FAIL" in
+        *"PANIC"*)     echo "  Next: scripts/panic-triage.sh  # decode the most recent panic" ;;
+        *"STORAGE"*)   echo "  Next: scripts/disk-health.sh -v /  # drill into rung 2" ;;
+        *"STARTUP"*)   echo "  Next: scripts/startup-audit.sh  # inventory and cull bloat" ;;
+        *"TCC"*)       echo "  Next: scripts/tcc-audit.sh  # see which app/service is denied" ;;
+        *"WAKE"*)      echo "  Next: scripts/wake-reasons.sh  # break down wake causes" ;;
+        *) echo "  Next: re-run with --verbose, then check references/" ;;
+    esac
+fi

+ 152 - 0
skills/mac-ops/scripts/panic-triage.sh

@@ -0,0 +1,152 @@
+#!/usr/bin/env bash
+# mac-ops :: panic-triage.sh
+# Decode the most recent kernel panic (or one specified by path/time).
+# Emits panic string, suspect kext, and the pre-panic timeline window.
+#
+# Usage:
+#   scripts/panic-triage.sh                              # most recent panic
+#   scripts/panic-triage.sh -f <path>                    # specific report file
+#   scripts/panic-triage.sh -t '2026-05-14 03:14:22'     # by timestamp (UTC)
+#   scripts/panic-triage.sh -m 15                        # widen pre-panic window to 15 min
+
+set -u
+
+PANIC_FILE=""
+PANIC_TIME=""
+WINDOW_MIN=10
+
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        -f|--file) PANIC_FILE="$2"; shift 2 ;;
+        -t|--time) PANIC_TIME="$2"; shift 2 ;;
+        -m|--minutes) WINDOW_MIN="$2"; shift 2 ;;
+        --help|-h)
+            cat <<EOF
+Usage: $0 [options]
+
+  -f, --file PATH        Specific .panic or Kernel*.ips file to decode
+  -t, --time 'YYYY-MM-DD HH:MM:SS'   Timestamp anchor for pre-panic window
+  -m, --minutes N        Pre-panic window in minutes (default: 10)
+  --json, --redact, --quiet, --verbose   Standard flags
+
+Exit codes:
+  0 success
+  3 no panic reports found
+EOF
+            exit 0 ;;
+        *) shift ;;
+    esac
+done
+
+source "$(dirname "$0")/_lib/common.sh"
+parse_common_flags "$@"
+maybe_filter_self "$@"
+
+panic_dir="/Library/Logs/DiagnosticReports"
+
+# ----------------------------------------------------------------------------
+section "1. PANIC REPORT SELECTION"
+# ----------------------------------------------------------------------------
+if [[ -z "$PANIC_FILE" ]]; then
+    # Find newest panic report
+    PANIC_FILE=$(find "$panic_dir" -maxdepth 1 \( -name "*.panic" -o -name "Kernel*.ips" \) 2>/dev/null \
+                | xargs ls -t 2>/dev/null | head -1)
+fi
+
+if [[ -z "$PANIC_FILE" ]] || [[ ! -f "$PANIC_FILE" ]]; then
+    log_info "Panic reports" "none found in $panic_dir"
+    emit_summary
+    exit "$EXIT_NOT_FOUND"
+fi
+
+log_pass "Panic report selected" "$PANIC_FILE"
+panic_mtime=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$PANIC_FILE" 2>/dev/null)
+note "  Last modified: $panic_mtime"
+
+# ----------------------------------------------------------------------------
+section "2. PANIC STRING + KEXT EXTRACTION"
+# ----------------------------------------------------------------------------
+if [[ "$PANIC_FILE" == *.ips ]]; then
+    # .ips files are JSON-with-extras. The first line is a JSON header,
+    # the rest of the file is structured but not strict JSON.
+    panic_string=$(head -200 "$PANIC_FILE" | grep -m1 "panic(" | head -1)
+    # Extract the bundleID of the panicking kext (best-effort)
+    suspect_kext=$(grep -m1 -oE '"bundleID":"[^"]+"' "$PANIC_FILE" | head -1 | sed 's/.*"://; s/"//g')
+else
+    panic_string=$(grep -m1 "^panic(" "$PANIC_FILE")
+    # In old .panic format the "Kernel Extensions in backtrace" line lists suspects
+    suspect_kext=$(awk '/Kernel Extensions in backtrace:/{getline; print; exit}' "$PANIC_FILE" | awk -F'[()]' '{print $2}')
+fi
+
+if [[ -n "$panic_string" ]]; then
+    log_pass "Panic string extracted"
+    note "  $panic_string"
+fi
+
+if [[ -n "$suspect_kext" ]]; then
+    case "$suspect_kext" in
+        com.apple.*) log_warn "Suspect kext" "$suspect_kext (Apple — harder to fix; check macOS update)" ;;
+        *)            log_fail "Suspect kext" "$suspect_kext (third-party — primary suspect)" ;;
+    esac
+else
+    log_info "Suspect kext" "could not extract from report — check report manually"
+fi
+
+# Match panic string against the common-causes catalog
+note "  Pattern match (quick lookup; see references/panic-codes.md for full catalog):"
+case "$panic_string" in
+    *"Sleep wake failure"*)
+        note "    → Driver power-state bug. Often USB / Bluetooth / GPU. Check kext list around panic." ;;
+    *"Unresponsive bootstrap subsystem"*)
+        note "    → launchd deadlock. Usually a third-party LaunchDaemon. Audit /Library/LaunchDaemons/." ;;
+    *"WindowServer"*)
+        note "    → GPU driver / display kext fault. Try disabling external display, alternative GPU mode." ;;
+    *"double_fault"*|*"page_fault"*)
+        note "    → Kernel-mode memory corruption. Bad RAM or buggy kext. Run memtest from recoveryOS." ;;
+    *"panic_kthread"*)
+        note "    → Kernel watchdog timeout. A driver hung in infinite loop. Examine pre-panic kext activity." ;;
+    *"Unable to find driver"*)
+        note "    → Boot-time kext failed to load. Often after macOS update. Try safe-boot." ;;
+    *)
+        note "    → No quick-pattern match. See references/panic-codes.md." ;;
+esac
+
+# ----------------------------------------------------------------------------
+section "3. PRE-PANIC TIMELINE"
+# ----------------------------------------------------------------------------
+if [[ -z "$PANIC_TIME" ]]; then
+    PANIC_TIME=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$PANIC_FILE" 2>/dev/null)
+fi
+note "  Anchor: $PANIC_TIME  (window: ${WINDOW_MIN} min before)"
+
+# Convert anchor to epoch, compute start
+if anchor_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$PANIC_TIME" "+%s" 2>/dev/null); then
+    start_epoch=$((anchor_epoch - WINDOW_MIN * 60))
+    start_str=$(date -r "$start_epoch" "+%Y-%m-%d %H:%M:%S")
+    note "  Searching unified log from $start_str to $PANIC_TIME ..."
+
+    # Filter the noisy stuff out; surface kernel + kext + IO + power events
+    log show --start "$start_str" --end "$PANIC_TIME" --style compact \
+        --predicate '(subsystem == "com.apple.kernel" OR subsystem == "com.apple.iokit" OR processImagePath CONTAINS "kernel" OR senderImagePath CONTAINS ".kext") AND (messageType == "Default" OR messageType == "Error" OR messageType == "Fault")' \
+        2>/dev/null | tail -50 | sed 's/^/    /'
+
+    log_info "Pre-panic events captured" "${WINDOW_MIN} min window"
+else
+    log_warn "Pre-panic timeline" "could not parse panic timestamp; pass -t explicitly"
+fi
+
+# ----------------------------------------------------------------------------
+section "4. CONTEXT: RECENT PANICS"
+# ----------------------------------------------------------------------------
+recent_panics=$(find "$panic_dir" -maxdepth 1 \( -name "*.panic" -o -name "Kernel*.ips" \) \
+    -mtime -30 2>/dev/null | wc -l | tr -d ' ')
+log_info "Panics in last 30 days" "$recent_panics"
+
+if [[ "$recent_panics" -gt 1 ]]; then
+    note "  Recent panic files:"
+    find "$panic_dir" -maxdepth 1 \( -name "*.panic" -o -name "Kernel*.ips" \) -mtime -30 2>/dev/null \
+        | xargs ls -lt 2>/dev/null | head -5 | awk '{print "    "$NF" — "$6" "$7" "$8}'
+fi
+
+# ----------------------------------------------------------------------------
+emit_summary

+ 175 - 0
skills/mac-ops/scripts/recover-clone.sh

@@ -0,0 +1,175 @@
+#!/usr/bin/env bash
+# mac-ops :: recover-clone.sh
+# Safely image data off a failing drive using rsync with no retries.
+#
+# Cardinal rules (enforced):
+#   1. NEVER write to the source. Read-only operations only.
+#   2. NEVER use -y or --force on fsck against a failing drive.
+#   3. Default mode is DRY RUN — show what would be copied.
+#
+# Strategies (in order of safety):
+#   --strategy=rsync     Default. Resumable, skips errors, partial files OK.
+#   --strategy=ditto     macOS native. Preserves resource forks & xattrs.
+#   --strategy=ddrescue  Bit-level. Requires brew install gddrescue.
+
+set -u
+
+SOURCE=""
+DEST=""
+STRATEGY="rsync"
+APPLY=0
+EXCLUDES=()
+
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        -s|--source) SOURCE="$2"; shift 2 ;;
+        -d|--destination) DEST="$2"; shift 2 ;;
+        --strategy) STRATEGY="$2"; shift 2 ;;
+        --exclude) EXCLUDES+=("$2"); shift 2 ;;
+        --apply) APPLY=1; shift ;;
+        --help|-h)
+            cat <<EOF
+Usage: $0 -s <source> -d <destination> [options]
+
+  -s, --source PATH        Source path (file or directory on failing drive)
+  -d, --destination PATH   Destination path (healthy drive)
+  --strategy NAME          rsync (default) | ditto | ddrescue
+  --exclude PATTERN        Add exclusion (can repeat)
+  --apply                  Actually perform the clone (default: dry-run)
+  --json, --redact, --quiet, --verbose
+
+Examples:
+  $0 -s /Volumes/Failing/work -d /Volumes/Rescue/work
+  $0 -s ~/Documents -d /Volumes/Backup/Documents --apply
+  $0 -s /Volumes/Failing -d /Volumes/Rescue --strategy=ditto --apply
+
+Strategy reference:
+  rsync     Best general-purpose. --partial --inplace --no-whole-file
+            --append-verify. Skips errors, resumable.
+  ditto     macOS-native. Preserves metadata, xattrs, ACLs, resource forks.
+            Use when source has Pro app libraries (Final Cut etc).
+  ddrescue  For drives with many bad sectors. Bit-level, resumable via map
+            file. Requires: brew install gddrescue.
+EOF
+            exit 0 ;;
+        *) shift ;;
+    esac
+done
+
+if [[ -z "$SOURCE" ]] || [[ -z "$DEST" ]]; then
+    echo "Error: -s and -d required" >&2
+    exit 2
+fi
+
+if [[ ! -e "$SOURCE" ]]; then
+    echo "Error: source does not exist: $SOURCE" >&2
+    exit 3
+fi
+
+source "$(dirname "$0")/_lib/common.sh"
+parse_common_flags "$@"
+maybe_filter_self "$@"
+
+note "  Source:      $SOURCE"
+note "  Destination: $DEST"
+note "  Strategy:    $STRATEGY"
+note "  Mode:        $([[ "$APPLY" -eq 1 ]] && echo APPLY || echo DRY-RUN)"
+
+# ----------------------------------------------------------------------------
+section "1. PREFLIGHT"
+# ----------------------------------------------------------------------------
+# Source size (read-only)
+src_size=$(du -sh "$SOURCE" 2>/dev/null | awk '{print $1}')
+log_info "Source size (du)" "${src_size:-?}"
+
+# Destination free space
+dest_parent=$(dirname "$DEST")
+[[ -d "$dest_parent" ]] || { log_fail "Destination parent dir" "$dest_parent does not exist"; exit 3; }
+dest_free=$(df -h "$dest_parent" | awk 'NR==2{print $4}')
+log_info "Destination free space" "$dest_free"
+
+# Sanity: source and dest on different volumes?
+src_vol=$(df "$SOURCE" 2>/dev/null | awk 'NR==2{print $1}')
+dest_vol=$(df "$dest_parent" 2>/dev/null | awk 'NR==2{print $1}')
+if [[ "$src_vol" == "$dest_vol" ]]; then
+    log_warn "Source/dest volume" "same volume — defeats purpose of cloning off failing drive"
+else
+    log_pass "Source/dest volume" "different volumes"
+fi
+
+# Strategy availability check
+case "$STRATEGY" in
+    rsync)
+        command -v rsync >/dev/null || { log_fail "rsync" "not installed"; exit 5; }
+        log_pass "rsync available" "$(rsync --version | head -1)"
+        ;;
+    ditto)
+        command -v ditto >/dev/null || { log_fail "ditto" "not installed (built-in on macOS — shouldn't happen)"; exit 5; }
+        log_pass "ditto available"
+        ;;
+    ddrescue)
+        if ! command -v ddrescue >/dev/null; then
+            log_fail "ddrescue" "not installed — run: brew install gddrescue"
+            exit 5
+        fi
+        log_pass "ddrescue available"
+        ;;
+    *)
+        log_fail "Strategy" "unknown: $STRATEGY"; exit 2 ;;
+esac
+
+# ----------------------------------------------------------------------------
+section "2. BUILD COMMAND"
+# ----------------------------------------------------------------------------
+case "$STRATEGY" in
+    rsync)
+        cmd=(rsync -avh
+             --partial --inplace --no-whole-file --append-verify
+             --no-perms --no-owner --no-group
+             --human-readable --info=progress2,stats2
+             --ignore-errors)
+        for e in ${EXCLUDES[@]+"${EXCLUDES[@]}"}; do cmd+=("--exclude=$e"); done
+        cmd+=("$SOURCE/" "$DEST/")
+        ;;
+    ditto)
+        cmd=(ditto --rsrc --extattr "$SOURCE" "$DEST")
+        ;;
+    ddrescue)
+        # ddrescue needs a map file for resumability
+        mapfile="${DEST}.ddrescue.map"
+        cmd=(ddrescue -n --idirect "$SOURCE" "$DEST" "$mapfile")
+        note "  ddrescue map file: $mapfile"
+        ;;
+esac
+
+note "  Command:"
+note "    ${cmd[*]}"
+
+# ----------------------------------------------------------------------------
+section "3. EXECUTE"
+# ----------------------------------------------------------------------------
+if [[ "$APPLY" -eq 0 ]]; then
+    note "  (dry-run — pass --apply to actually clone)"
+    if [[ "$STRATEGY" == "rsync" ]]; then
+        # rsync has its own --dry-run that previews actions
+        rsync --dry-run -ah "$SOURCE/" "$DEST/" 2>&1 | tail -10 | sed 's/^/    /'
+    fi
+    emit_summary
+    exit 0
+fi
+
+# Apply mode
+mkdir -p "$DEST" || { log_fail "mkdir $DEST" "failed"; exit 1; }
+log_info "Starting clone" "$STRATEGY"
+"${cmd[@]}"
+rc=$?
+if [[ "$rc" -eq 0 ]]; then
+    log_pass "Clone finished" "exit 0"
+elif [[ "$rc" -le 24 ]] && [[ "$STRATEGY" == "rsync" ]]; then
+    # rsync 23-24 = partial transfer (some files failed); acceptable for failing drive
+    log_warn "Clone finished with rsync exit $rc" "some files unreadable — expected on failing drive"
+else
+    log_fail "Clone exit code" "$rc"
+fi
+
+emit_summary

+ 199 - 0
skills/mac-ops/scripts/safe-disable-startup.sh

@@ -0,0 +1,199 @@
+#!/usr/bin/env bash
+# mac-ops :: safe-disable-startup.sh
+# Disable a startup item by name pattern. Reversible.
+#
+# Mechanisms handled (no sudo for user-scope):
+#   - Login Items                (via osascript / System Events)
+#   - User LaunchAgents          (launchctl disable gui/$UID/<label>)
+#   - System LaunchAgents        (launchctl disable gui/$UID/<label>)
+#
+# Mechanisms handled (sudo required):
+#   - System LaunchDaemons       (sudo launchctl disable system/<label>)
+#
+# Default mode is DRY RUN. Pass --apply to actually disable.
+# Use --enable to reverse a prior disable.
+
+set -u
+
+NAME_PATTERN=""
+APPLY=0
+ENABLE=0
+LIST_ONLY=0
+
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        -n|--name) NAME_PATTERN="$2"; shift 2 ;;
+        --list)    LIST_ONLY=1; shift ;;
+        --apply)   APPLY=1; shift ;;
+        --enable)  ENABLE=1; APPLY=1; shift ;;
+        --help|-h)
+            cat <<EOF
+Usage: $0 [options]
+
+  -n, --name PATTERN     Glob pattern matching the entry label / name
+  --list                 List all currently-disabled launchd entries
+  --apply                Actually perform the disable (default: dry-run)
+  --enable               Re-enable a previously disabled item (implies --apply)
+
+  --json, --redact, --quiet, --verbose   Standard flags
+
+Examples:
+  $0 --list                              # show current disable state
+  $0 -n 'com.adobe.*'                    # dry-run: what would be disabled?
+  $0 -n 'com.adobe.*' --apply            # disable Adobe agents
+  $0 -n 'com.adobe.*' --enable           # re-enable
+  $0 -n 'Adobe Updater' --apply          # also matches Login Item by name
+
+Note: System LaunchDaemons (/Library/LaunchDaemons) require sudo and operate
+on system/<label> instead of gui/\$UID/<label>. The script asks for sudo only
+when needed.
+EOF
+            exit 0 ;;
+        *) shift ;;
+    esac
+done
+
+source "$(dirname "$0")/_lib/common.sh"
+parse_common_flags "$@"
+maybe_filter_self "$@"
+
+# ----------------------------------------------------------------------------
+# --list mode: show all disabled launchctl entries
+# ----------------------------------------------------------------------------
+if [[ "$LIST_ONLY" -eq 1 ]]; then
+    section "DISABLED LAUNCHD ENTRIES (user domain)"
+    if launchctl print-disabled "gui/$UID" 2>/dev/null | grep -E "=> (true|disabled)$" | sed 's/^/  /'; then
+        :
+    else
+        note "  (no user-domain disables, or print-disabled requires newer macOS)"
+    fi
+    section "DISABLED LAUNCHD ENTRIES (system domain — sudo)"
+    if sudo -n launchctl print-disabled system 2>/dev/null | grep -E "=> (true|disabled)$" | sed 's/^/  /'; then
+        :
+    else
+        note "  (system domain requires sudo, or no entries disabled)"
+    fi
+    emit_summary
+    exit 0
+fi
+
+if [[ -z "$NAME_PATTERN" ]]; then
+    echo "Error: -n PATTERN required (or --list)" >&2
+    exit "$EXIT_USAGE"
+fi
+
+# ----------------------------------------------------------------------------
+section "1. SEARCH MATCHES"
+# ----------------------------------------------------------------------------
+
+# Find matching LaunchAgent plists (user + system)
+user_matches=()
+sys_agent_matches=()
+sys_daemon_matches=()
+
+for p in "$HOME/Library/LaunchAgents"/*.plist /Library/LaunchAgents/*.plist; do
+    [[ -f "$p" ]] || continue
+    label=$(/usr/libexec/PlistBuddy -c "Print :Label" "$p" 2>/dev/null || basename "$p" .plist)
+    # Match by label OR filename
+    if [[ "$label" == $NAME_PATTERN ]] || [[ "$(basename "$p" .plist)" == $NAME_PATTERN ]]; then
+        case "$p" in
+            "$HOME"/*) user_matches+=("$label|$p") ;;
+            *)         sys_agent_matches+=("$label|$p") ;;
+        esac
+    fi
+done
+
+for p in /Library/LaunchDaemons/*.plist; do
+    [[ -f "$p" ]] || continue
+    label=$(/usr/libexec/PlistBuddy -c "Print :Label" "$p" 2>/dev/null || basename "$p" .plist)
+    if [[ "$label" == $NAME_PATTERN ]] || [[ "$(basename "$p" .plist)" == $NAME_PATTERN ]]; then
+        sys_daemon_matches+=("$label|$p")
+    fi
+done
+
+# Find matching Login Items (by name only; AppleScript glob match)
+login_item_matches=()
+if items=$(osascript <<APPLESCRIPT 2>/dev/null
+tell application "System Events"
+    set output to ""
+    repeat with li in (every login item)
+        set itemName to name of li
+        if itemName is like "$NAME_PATTERN" then
+            set output to output & itemName & linefeed
+        end if
+    end repeat
+    return output
+end tell
+APPLESCRIPT
+); then
+    while IFS= read -r name; do
+        [[ -n "$name" ]] && login_item_matches+=("$name")
+    done <<< "$items"
+fi
+
+total_matches=$(( ${#user_matches[@]} + ${#sys_agent_matches[@]} + ${#sys_daemon_matches[@]} + ${#login_item_matches[@]} ))
+
+if [[ "$total_matches" -eq 0 ]]; then
+    log_warn "Matches for '$NAME_PATTERN'" "0 — nothing to do"
+    emit_summary
+    exit "$EXIT_NOT_FOUND"
+fi
+
+log_pass "Matches for '$NAME_PATTERN'" "$total_matches"
+[[ ${#user_matches[@]} -gt 0 ]]       && note "  User LaunchAgents:"      && printf "    %s\n" "${user_matches[@]%|*}"
+[[ ${#sys_agent_matches[@]} -gt 0 ]]  && note "  System LaunchAgents:"    && printf "    %s\n" "${sys_agent_matches[@]%|*}"
+[[ ${#sys_daemon_matches[@]} -gt 0 ]] && note "  System LaunchDaemons:"   && printf "    %s\n" "${sys_daemon_matches[@]%|*}"
+[[ ${#login_item_matches[@]} -gt 0 ]] && note "  Login Items:"            && printf "    %s\n" "${login_item_matches[@]}"
+
+# ----------------------------------------------------------------------------
+if [[ "$APPLY" -eq 0 ]]; then
+    section "2. DRY RUN — would $([[ "$ENABLE" -eq 1 ]] && echo enable || echo disable)"
+    note "  Pass --apply to perform the action."
+    emit_summary
+    exit 0
+fi
+# ----------------------------------------------------------------------------
+
+verb=$([[ "$ENABLE" -eq 1 ]] && echo enable || echo disable)
+section "2. APPLY — ${verb}"
+
+# launchctl verb selection
+lctl_verb=$([[ "$ENABLE" -eq 1 ]] && echo enable || echo disable)
+
+# Disable user agents (no sudo)
+for entry in ${user_matches[@]+"${user_matches[@]}"} ${sys_agent_matches[@]+"${sys_agent_matches[@]}"}; do
+    label="${entry%|*}"
+    if launchctl "$lctl_verb" "gui/$UID/$label" 2>/dev/null; then
+        log_pass "launchctl $lctl_verb gui/$UID/$label"
+    else
+        log_warn "launchctl $lctl_verb gui/$UID/$label" "may already be in target state"
+    fi
+done
+
+# Disable system daemons (sudo)
+if [[ ${#sys_daemon_matches[@]} -gt 0 ]]; then
+    note "  System daemons require sudo:"
+    for entry in "${sys_daemon_matches[@]}"; do
+        label="${entry%|*}"
+        if sudo launchctl "$lctl_verb" "system/$label" 2>/dev/null; then
+            log_pass "sudo launchctl $lctl_verb system/$label"
+        else
+            log_warn "sudo launchctl $lctl_verb system/$label" "may need sudo or already in state"
+        fi
+    done
+fi
+
+# Login Items (via osascript)
+for name in ${login_item_matches[@]+"${login_item_matches[@]}"}; do
+    if [[ "$ENABLE" -eq 1 ]]; then
+        log_warn "Login Item '$name'" "re-enable requires manual re-add (System Settings → Login Items)"
+    else
+        if osascript -e "tell application \"System Events\" to delete login item \"$name\"" 2>/dev/null; then
+            log_pass "Removed Login Item" "$name"
+        else
+            log_warn "Login Item '$name'" "removal failed (TCC may be blocking System Events)"
+        fi
+    fi
+done
+
+emit_summary

+ 132 - 0
skills/mac-ops/scripts/spotlight-status.sh

@@ -0,0 +1,132 @@
+#!/usr/bin/env bash
+# mac-ops :: spotlight-status.sh
+# Spotlight (mds) health: indexing state per volume, daemon CPU/IO,
+# common reindex/repair operations.
+
+set -u
+
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        --help|-h)
+            cat <<EOF
+Usage: $0 [options]
+
+  --json, --redact, --quiet, --verbose
+
+Common Spotlight fixes (in order of severity):
+  1. Wait — initial indexing on a new volume can take hours
+  2. mdutil -E /Volumes/X         Erase + rebuild index for a volume
+  3. mdutil -i off /Volumes/X     Disable Spotlight on a volume entirely
+  4. (Reboot — clears mds daemon state)
+EOF
+            exit 0 ;;
+        *) shift ;;
+    esac
+done
+
+source "$(dirname "$0")/_lib/common.sh"
+parse_common_flags "$@"
+maybe_filter_self "$@"
+
+# ----------------------------------------------------------------------------
+section "1. MDS / MDWORKER PROCESS HEALTH"
+# ----------------------------------------------------------------------------
+note "  Top mds-family processes by CPU:"
+ps -ArcS -o pcpu,rss,pid,comm 2>/dev/null | awk '/mds|mdworker|mdsync/' | head -10 | \
+    awk '{printf "    %5s%% RSS=%-8s PID=%s  %s\n", $1, $2, $3, $4}'
+
+# Specifically check mds_stores — the kernel-side indexer doing the heavy lifting
+mds_cpu=$(ps -ArcS -o pcpu,comm 2>/dev/null | awk '$2=="mds_stores"{print $1; exit}')
+mds_cpu="${mds_cpu:-0}"
+mds_int=${mds_cpu%.*}
+if [[ "${mds_int:-0}" -gt 80 ]]; then
+    log_warn "mds_stores CPU" "${mds_cpu}% — heavy indexing in progress"
+elif [[ "${mds_int:-0}" -gt 30 ]]; then
+    log_info "mds_stores CPU" "${mds_cpu}% — moderate indexing"
+else
+    log_pass "mds_stores CPU" "${mds_cpu}%"
+fi
+
+# ----------------------------------------------------------------------------
+section "2. INDEX STATUS PER VOLUME"
+# ----------------------------------------------------------------------------
+mount | awk '/apfs/{print $3}' | while read -r vol; do
+    [[ -z "$vol" ]] && continue
+    # Skip system-managed read-only volumes — they never have indexes
+    case "$vol" in
+        /System/Volumes/VM|/System/Volumes/xarts|/System/Volumes/Hardware|/System/Volumes/iSCPreboot|/System/Volumes/Update*) continue ;;
+    esac
+    status=$(mdutil -s "$vol" 2>/dev/null | tail -1)
+    case "$status" in
+        *"Indexing enabled"*)
+            log_pass "$vol indexing" "enabled"
+            ;;
+        *"Indexing disabled"*)
+            log_info "$vol indexing" "disabled (Spotlight will not search this volume)"
+            ;;
+        *"No index"*|*"not registered"*)
+            log_warn "$vol indexing" "no index store on disk — search empty until rebuild"
+            ;;
+        *"unknown"*)
+            log_info "$vol indexing" "system volume (no user index)"
+            ;;
+        *)
+            log_info "$vol indexing" "$status"
+            ;;
+    esac
+done
+
+# ----------------------------------------------------------------------------
+section "3. INDEX STORE SIZES"
+# ----------------------------------------------------------------------------
+note "  On-disk Spotlight index size per volume:"
+mount | awk '/apfs/{print $3}' | while read -r vol; do
+    [[ -z "$vol" ]] && continue
+    spot_dir="$vol/.Spotlight-V100"
+    if [[ -d "$spot_dir" ]]; then
+        size=$(du -sh "$spot_dir" 2>/dev/null | awk '{print $1}')
+        printf "    %-30s %s\n" "$vol" "${size:-?}"
+    fi
+done
+
+# ----------------------------------------------------------------------------
+section "4. RECENT MDS LOG ACTIVITY"
+# ----------------------------------------------------------------------------
+mds_errors=$(log show --last 24h --style compact \
+    --predicate 'process == "mds" OR process == "mds_stores" OR process == "mdworker_shared"' \
+    2>/dev/null | grep -iE "(error|fault|crash)" | head -10)
+
+if [[ -n "$mds_errors" ]]; then
+    log_warn "mds errors (24h)" "see below"
+    echo "$mds_errors" | sed 's/^/    /'
+else
+    log_pass "mds errors (24h)" "none"
+fi
+
+# ----------------------------------------------------------------------------
+section "5. INDEX PERMANENT EXCLUSIONS"
+# ----------------------------------------------------------------------------
+exclusions="$HOME/Library/Preferences/com.apple.spotlight.plist"
+if [[ -f "$exclusions" ]]; then
+    note "  Per-user Spotlight preferences exist."
+fi
+sys_exclusions="/.Spotlight-V100"
+if [[ -d "$sys_exclusions" ]]; then
+    note "  Boot volume index dir present at /.Spotlight-V100"
+fi
+
+note ""
+note "  To exclude a path from Spotlight (per-user):"
+note "    System Settings → Spotlight → Search Privacy → +"
+
+# ----------------------------------------------------------------------------
+emit_summary
+
+if [[ "$JSON_MODE" -eq 0 ]]; then
+    echo
+    note "  Common operations:"
+    note "    mdutil -s /Volumes/X       Status per volume"
+    note "    sudo mdutil -E /Volumes/X  Erase + rebuild index (heavy operation)"
+    note "    sudo mdutil -i off /       Disable Spotlight on boot volume (drastic)"
+    note "    sudo mdutil -i on /        Re-enable"
+fi

+ 202 - 0
skills/mac-ops/scripts/startup-audit.sh

@@ -0,0 +1,202 @@
+#!/usr/bin/env bash
+# mac-ops :: startup-audit.sh
+# Inventory every auto-start mechanism on this Mac.
+#
+# Covers:
+#   - System Settings → Login Items (user-visible)
+#   - User LaunchAgents     ~/Library/LaunchAgents
+#   - System LaunchAgents   /Library/LaunchAgents
+#   - System LaunchDaemons  /Library/LaunchDaemons
+#   - Apple LaunchAgents    /System/Library/LaunchAgents (system-managed, usually skip)
+#   - Privileged helpers    /Library/PrivilegedHelperTools
+#   - Legacy LoginHook      `sudo defaults read com.apple.loginwindow LoginHook`
+
+set -u
+
+for arg in "$@"; do
+    case "$arg" in
+        --help|-h)
+            cat <<EOF
+Usage: $0 [options]
+
+  --json           Emit NDJSON
+  --redact         Mask private addrs / hostnames
+  --verbose        Include /System/Library/LaunchAgents (Apple's own — usually noise)
+  --quiet          Suppress section banners
+
+Reports total counts per mechanism + per-entry detail. To DISABLE an entry,
+use scripts/safe-disable-startup.sh.
+EOF
+            exit 0 ;;
+    esac
+done
+
+source "$(dirname "$0")/_lib/common.sh"
+parse_common_flags "$@"
+maybe_filter_self "$@"
+
+# ----------------------------------------------------------------------------
+section "1. LOGIN ITEMS (System Settings → General → Login Items)"
+# ----------------------------------------------------------------------------
+if items=$(osascript <<'APPLESCRIPT' 2>/dev/null
+tell application "System Events"
+    set output to ""
+    repeat with li in (every login item)
+        set output to output & (name of li) & "|" & (path of li) & "|" & (hidden of li) & linefeed
+    end repeat
+    return output
+end tell
+APPLESCRIPT
+); then
+    if [[ -z "$items" ]]; then
+        log_pass "Login Items count" "0"
+    else
+        count=$(echo "$items" | grep -c '|' || echo 0)
+        log_info "Login Items count" "$count"
+        note "  Items (name | path | hidden):"
+        echo "$items" | sed 's/^/    /'
+    fi
+else
+    log_warn "Login Items" "could not query System Events (TCC may be denying Automation)"
+fi
+
+# ----------------------------------------------------------------------------
+section "2. USER LAUNCHAGENTS  (~/Library/LaunchAgents)"
+# ----------------------------------------------------------------------------
+agents_dir="$HOME/Library/LaunchAgents"
+if [[ -d "$agents_dir" ]]; then
+    count=$(find "$agents_dir" -maxdepth 1 -name "*.plist" 2>/dev/null | wc -l | tr -d ' ')
+    log_info "User LaunchAgents count" "$count"
+    if [[ "$count" -gt 0 ]]; then
+        note "  Plists (label · RunAtLoad · KeepAlive · path):"
+        for p in "$agents_dir"/*.plist; do
+            [[ -f "$p" ]] || continue
+            # Try PlistBuddy first; fall back to plutil; fall back to filename
+            label=$(/usr/libexec/PlistBuddy -c "Print :Label" "$p" 2>/dev/null) || label=""
+            [[ -z "$label" ]] && { label=$(plutil -extract Label raw -o - "$p" 2>/dev/null) || label=""; }
+            [[ -z "$label" ]] && label="$(basename "$p" .plist) (label unread)"
+            run_at_load=$(plutil -extract RunAtLoad raw -o - "$p" 2>/dev/null) || run_at_load=""
+            [[ -z "$run_at_load" ]] && run_at_load="no"
+            keep_alive=$(plutil -extract KeepAlive raw -o - "$p" 2>/dev/null) || keep_alive=""
+            [[ -z "$keep_alive" ]] && keep_alive="no"
+            prog=$(plutil -extract ProgramArguments.0 raw -o - "$p" 2>/dev/null) || prog=""
+            if [[ -z "$prog" ]]; then
+                prog=$(plutil -extract Program raw -o - "$p" 2>/dev/null) || prog=""
+            fi
+            [[ -z "$prog" ]] && prog="(no Program/ProgramArguments)"
+            printf "    %-45s · RunAtLoad=%s · KeepAlive=%s\n      %s\n" "$label" "$run_at_load" "$keep_alive" "$prog"
+        done
+    fi
+else
+    log_info "User LaunchAgents directory" "absent"
+fi
+
+# ----------------------------------------------------------------------------
+section "3. SYSTEM LAUNCHAGENTS  (/Library/LaunchAgents)"
+# ----------------------------------------------------------------------------
+sys_agents_dir="/Library/LaunchAgents"
+if [[ -d "$sys_agents_dir" ]]; then
+    count=$(find "$sys_agents_dir" -maxdepth 1 -name "*.plist" 2>/dev/null | wc -l | tr -d ' ')
+    log_info "System LaunchAgents count" "$count"
+    if [[ "$count" -gt 0 ]]; then
+        note "  Plists (label · vendor-pattern hint):"
+        for p in "$sys_agents_dir"/*.plist; do
+            [[ -f "$p" ]] || continue
+            label=$(/usr/libexec/PlistBuddy -c "Print :Label" "$p" 2>/dev/null) || label=""
+            [[ -z "$label" ]] && { label=$(plutil -extract Label raw -o - "$p" 2>/dev/null) || label=""; }
+            [[ -z "$label" ]] && label="$(basename "$p" .plist) (label unread)"
+            hint=""
+            case "$label" in
+                com.adobe.*)        hint="Adobe (Creative Cloud helpers)" ;;
+                com.docker.*)       hint="Docker Desktop" ;;
+                com.microsoft.*)    hint="Microsoft (Office / Edge / Teams)" ;;
+                com.google.*)       hint="Google (Chrome / Drive)" ;;
+                com.dropbox.*)      hint="Dropbox" ;;
+                com.cisco.*)        hint="Cisco (AnyConnect / WebEx)" ;;
+                com.paragon-*)      hint="Paragon (NTFS / ExtFS)" ;;
+                org.openvpn.*)      hint="OpenVPN / Tunnelblick" ;;
+                com.tailscale.*)    hint="Tailscale" ;;
+                io.nextdns.*)       hint="NextDNS" ;;
+                ch.protonvpn.*)     hint="Proton VPN" ;;
+            esac
+            printf "    %-50s%s\n" "$label" "${hint:+— $hint}"
+        done
+    fi
+fi
+
+# ----------------------------------------------------------------------------
+section "4. SYSTEM LAUNCHDAEMONS  (/Library/LaunchDaemons)"
+# ----------------------------------------------------------------------------
+daemons_dir="/Library/LaunchDaemons"
+if [[ -d "$daemons_dir" ]]; then
+    count=$(find "$daemons_dir" -maxdepth 1 -name "*.plist" 2>/dev/null | wc -l | tr -d ' ')
+    log_info "System LaunchDaemons count" "$count"
+    if [[ "$count" -gt 0 ]]; then
+        note "  Plists (label):"
+        for p in "$daemons_dir"/*.plist; do
+            [[ -f "$p" ]] || continue
+            label=$(/usr/libexec/PlistBuddy -c "Print :Label" "$p" 2>/dev/null) || label=""
+            [[ -z "$label" ]] && { label=$(plutil -extract Label raw -o - "$p" 2>/dev/null) || label=""; }
+            [[ -z "$label" ]] && label="$(basename "$p" .plist) (label unread)"
+            printf "    %s\n" "$label"
+        done
+    fi
+fi
+
+# ----------------------------------------------------------------------------
+section "5. PRIVILEGED HELPER TOOLS"
+# ----------------------------------------------------------------------------
+helpers_dir="/Library/PrivilegedHelperTools"
+if [[ -d "$helpers_dir" ]]; then
+    count=$(find "$helpers_dir" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ')
+    if [[ "$count" -gt 5 ]]; then
+        log_warn "Privileged helper tools" "$count — may include orphans from uninstalled apps"
+    else
+        log_info "Privileged helper tools" "$count"
+    fi
+    if [[ "$count" -gt 0 ]]; then
+        note "  Helpers:"
+        find "$helpers_dir" -maxdepth 1 -type f 2>/dev/null | sed 's/^/    /'
+    fi
+fi
+
+# ----------------------------------------------------------------------------
+section "6. LEGACY LoginHook (rarely used these days)"
+# ----------------------------------------------------------------------------
+if hook=$(sudo -n defaults read com.apple.loginwindow LoginHook 2>/dev/null); then
+    log_warn "LoginHook present" "$hook"
+else
+    log_pass "LoginHook" "none (or sudo declined)"
+fi
+
+# ----------------------------------------------------------------------------
+section "7. CONFIGURATION PROFILES (may add login items / restrictions)"
+# ----------------------------------------------------------------------------
+if profile_count=$(profiles list -type configuration 2>/dev/null | grep -c "attribute:"); then
+    profile_count="${profile_count:-0}"
+    if [[ "$profile_count" -gt 0 ]]; then
+        log_info "Configuration profiles (user)" "$profile_count"
+        note "  Run 'sudo profiles list -type configuration' for system-wide profile list."
+    else
+        log_pass "Configuration profiles (user)" "0"
+    fi
+fi
+
+# ----------------------------------------------------------------------------
+section "8. /System/Library/LaunchAgents  (Apple's own — usually noise)"
+# ----------------------------------------------------------------------------
+if [[ "$VERBOSE" -eq 1 ]]; then
+    apple_agents=$(find /System/Library/LaunchAgents -maxdepth 1 -name "*.plist" 2>/dev/null | wc -l | tr -d ' ')
+    log_info "Apple-managed LaunchAgents" "$apple_agents (system-protected; informational only)"
+else
+    note "  (skipped — pass --verbose to include Apple-managed agents)"
+fi
+
+# ----------------------------------------------------------------------------
+emit_summary
+
+if [[ "$JSON_MODE" -eq 0 ]]; then
+    echo
+    note "  To DISABLE an entry: scripts/safe-disable-startup.sh -n <pattern>"
+    note "  To RE-ENABLE:        scripts/safe-disable-startup.sh -n <pattern> --enable"
+fi

+ 165 - 0
skills/mac-ops/scripts/storage-pressure.sh

@@ -0,0 +1,165 @@
+#!/usr/bin/env bash
+# mac-ops :: storage-pressure.sh
+# "Disk is full but I deleted everything" — explain macOS's purgeable space
+# accounting and surface the actual consumers (APFS snapshots, local Time
+# Machine backups, Spotlight index, iCloud cached files, etc).
+
+set -u
+
+VOL="/"
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        -v|--volume) VOL="$2"; shift 2 ;;
+        --help|-h)
+            cat <<EOF
+Usage: $0 [options]
+
+  -v, --volume PATH      Volume to analyze (default: /)
+  --json, --redact, --quiet, --verbose
+
+Why "About This Mac → Storage" doesn't match du:
+  - APFS local Time Machine snapshots: data deleted but retained for TM
+  - iCloud cached files: shown as "Purgeable" — frees automatically under pressure
+  - Spotlight index: ~.Spotlight-V100 hidden dir
+  - Cached files in ~/Library/Caches, /var/folders
+  - Sleepimage, swap files (in dynamic_pager dirs)
+
+Common reclaims:
+  tmutil thinlocalsnapshots /             # remove eligible TM snapshots
+  tmutil deletelocalsnapshots <name>      # specific snapshot
+  diskutil apfs deleteSnapshot diskNsM <name>
+EOF
+            exit 0 ;;
+        *) shift ;;
+    esac
+done
+
+source "$(dirname "$0")/_lib/common.sh"
+parse_common_flags "$@"
+maybe_filter_self "$@"
+
+if [[ ! -d "$VOL" ]]; then
+    echo "Error: $VOL is not a directory" >&2
+    exit 3
+fi
+
+note "  Volume: $VOL"
+
+# ----------------------------------------------------------------------------
+section "1. df vs APFS reality"
+# ----------------------------------------------------------------------------
+df -h "$VOL" 2>/dev/null | head -2 | sed 's/^/  /'
+
+# diskutil info gives the APFS-aware view including snapshot space
+note ""
+note "  diskutil info (APFS-aware):"
+disk_id=$(diskutil info "$VOL" 2>/dev/null | awk -F': *' '/Device Identifier/{print $2; exit}')
+if [[ -n "$disk_id" ]]; then
+    diskutil info "$disk_id" 2>/dev/null | grep -E "Allocation Block Size|Container Total Space|Container Free Space|Volume Used Space|Volume Free Space|APFS Snapshot|Capacity In Use" | sed 's/^/  /'
+fi
+
+# ----------------------------------------------------------------------------
+section "2. APFS SNAPSHOTS"
+# ----------------------------------------------------------------------------
+snap_count=$(tmutil listlocalsnapshots "$VOL" 2>/dev/null | grep -c "com.apple" || echo 0)
+if [[ "$snap_count" -gt 0 ]]; then
+    log_info "Local Time Machine snapshots" "$snap_count"
+    note "  Recent (last 10):"
+    tmutil listlocalsnapshots "$VOL" 2>/dev/null | tail -10 | sed 's/^/    /'
+
+    # Calculate approximate space held by snapshots
+    if [[ -n "$disk_id" ]]; then
+        snap_space=$(diskutil apfs list 2>/dev/null | awk -v d="$disk_id" '
+            $0 ~ d {found=1}
+            found && /Snapshot/ {print; if (++n >= 5) exit}
+        ' | head -8)
+        if [[ -n "$snap_space" ]]; then
+            note ""
+            note "  Snapshot space (from diskutil apfs list):"
+            echo "$snap_space" | sed 's/^/    /'
+        fi
+    fi
+
+    if [[ "$snap_count" -gt 20 ]]; then
+        log_warn "Snapshot count" "$snap_count — consider 'tmutil thinlocalsnapshots $VOL'"
+    fi
+else
+    log_pass "Local Time Machine snapshots" "0"
+fi
+
+# ----------------------------------------------------------------------------
+section "3. iCLOUD CACHED FILES"
+# ----------------------------------------------------------------------------
+icloud_dir="$HOME/Library/Mobile Documents"
+if [[ -d "$icloud_dir" ]]; then
+    icloud_size=$(du -sh "$icloud_dir" 2>/dev/null | awk '{print $1}')
+    log_info "iCloud Drive cache size" "${icloud_size:-?}"
+    note "  These are typically marked 'Purgeable' — macOS evicts under pressure."
+fi
+
+# ----------------------------------------------------------------------------
+section "4. CACHE / TEMPORARY DIRECTORIES"
+# ----------------------------------------------------------------------------
+note "  User caches:"
+for d in "$HOME/Library/Caches" "$HOME/Library/Application Support/Caches"; do
+    if [[ -d "$d" ]]; then
+        size=$(du -sh "$d" 2>/dev/null | awk '{print $1}')
+        printf "    %s = %s\n" "$d" "${size:-?}"
+    fi
+done
+
+note ""
+note "  System caches:"
+for d in /Library/Caches /var/folders /private/var/log; do
+    if [[ -d "$d" ]]; then
+        size=$(sudo -n du -sh "$d" 2>/dev/null | awk '{print $1}')
+        if [[ -z "$size" ]]; then
+            # No sudo — try without
+            size=$(du -sh "$d" 2>/dev/null | awk '{print $1}')
+        fi
+        printf "    %s = %s\n" "$d" "${size:-?}"
+    fi
+done
+
+# ----------------------------------------------------------------------------
+section "5. SLEEPIMAGE + SWAP"
+# ----------------------------------------------------------------------------
+if [[ -f /private/var/vm/sleepimage ]]; then
+    size=$(ls -lh /private/var/vm/sleepimage 2>/dev/null | awk '{print $5}')
+    log_info "Sleep image" "${size:-?} — equals RAM size; safe to ignore"
+fi
+
+swap_files=$(ls /private/var/vm/swapfile* 2>/dev/null | wc -l | tr -d ' ')
+if [[ "$swap_files" -gt 0 ]]; then
+    swap_total=$(ls -lh /private/var/vm/swapfile* 2>/dev/null | awk '{sum+=$5}END{print sum/1024/1024" GB"}')
+    log_info "Swap files" "$swap_files files (~$swap_total) — grows under memory pressure"
+fi
+
+# ----------------------------------------------------------------------------
+section "6. SPOTLIGHT INDEX SIZE"
+# ----------------------------------------------------------------------------
+spot_dir="$VOL/.Spotlight-V100"
+if [[ -d "$spot_dir" ]]; then
+    spot_size=$(sudo -n du -sh "$spot_dir" 2>/dev/null | awk '{print $1}')
+    [[ -z "$spot_size" ]] && spot_size="(needs sudo to size)"
+    log_info "Spotlight index size" "$spot_size"
+fi
+
+# ----------------------------------------------------------------------------
+section "7. TOP 10 LARGEST DIRECTORIES IN ~ (heuristic)"
+# ----------------------------------------------------------------------------
+note "  This walks ~ — may take a moment on large home dirs."
+du -sh "$HOME"/* 2>/dev/null | sort -rh | head -10 | sed 's/^/    /'
+
+# ----------------------------------------------------------------------------
+emit_summary
+
+if [[ "$JSON_MODE" -eq 0 ]]; then
+    echo
+    note "  Reclaim playbook:"
+    note "    tmutil thinlocalsnapshots $VOL              # trim eligible local TM snapshots"
+    note "    rm -rf ~/Library/Caches/*                   # clear per-user caches"
+    note "    docker system prune -a                       # Docker images/volumes"
+    note "    brew cleanup -s                              # Homebrew cached downloads"
+    note "    sudo periodic daily weekly monthly           # rotate system logs"
+fi

+ 214 - 0
skills/mac-ops/scripts/tcc-audit.sh

@@ -0,0 +1,214 @@
+#!/usr/bin/env bash
+# mac-ops :: tcc-audit.sh
+# Read the TCC (Transparency, Consent, Control) databases to surface which
+# apps have which permissions, what's been denied, and where to fix it.
+#
+# TCC databases:
+#   ~/Library/Application Support/com.apple.TCC/TCC.db    (user-scope)
+#   /Library/Application Support/com.apple.TCC/TCC.db     (system-scope, requires sudo)
+#
+# The user DB is readable in some macOS releases under SIP/FDA assumptions;
+# this script gracefully degrades when access is denied.
+
+set -u
+
+APP_FILTER=""
+SERVICE_FILTER=""
+SHOW_DENIED_ONLY=0
+
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        -a|--app) APP_FILTER="$2"; shift 2 ;;
+        -s|--service) SERVICE_FILTER="$2"; shift 2 ;;
+        --denied) SHOW_DENIED_ONLY=1; shift ;;
+        --help|-h)
+            cat <<EOF
+Usage: $0 [options]
+
+  -a, --app PATTERN          Filter by bundle ID or name (e.g. -a slack, -a com.slack.*)
+  -s, --service PATTERN      Filter by TCC service (e.g. -s ScreenCapture, -s Camera)
+  --denied                   Show only denied grants (the most common "broken" cause)
+
+  --json, --redact, --quiet, --verbose
+
+Examples:
+  $0                                    # all grants on this user
+  $0 --denied                           # what apps were denied something
+  $0 -a Slack                           # Slack's permission state
+  $0 -s ScreenCapture                   # who has Screen Recording
+
+Service catalog (most common):
+  kTCCServiceScreenCapture       Screen Recording
+  kTCCServiceMicrophone          Microphone
+  kTCCServiceCamera              Camera
+  kTCCServiceAccessibility       Accessibility (control your Mac)
+  kTCCServiceSystemPolicyAllFiles Full Disk Access
+  kTCCServicePostEvent           Synthetic input events
+  kTCCServiceListenEvent         Input event listening
+  kTCCServiceAppleEvents         Automation (controlling other apps)
+  kTCCServicePhotos              Photos library
+  kTCCServiceContactsFull        Contacts
+  kTCCServiceCalendar            Calendars
+  kTCCServiceReminders           Reminders
+
+If a script-controlled app has lost permission, the typical fix is:
+  System Settings → Privacy & Security → <Service> → toggle the app off, then on
+or:
+  tccutil reset <Service> <bundle-id>     (resets to "Ask again" — re-prompts user)
+
+Read references/tcc-mechanics.md for the deep dive.
+EOF
+            exit 0 ;;
+        *) shift ;;
+    esac
+done
+
+source "$(dirname "$0")/_lib/common.sh"
+parse_common_flags "$@"
+maybe_filter_self "$@"
+
+user_tcc="$HOME/Library/Application Support/com.apple.TCC/TCC.db"
+sys_tcc="/Library/Application Support/com.apple.TCC/TCC.db"
+
+# ----------------------------------------------------------------------------
+section "1. TCC.db ACCESSIBILITY CHECK"
+# ----------------------------------------------------------------------------
+if [[ -r "$user_tcc" ]]; then
+    log_pass "User TCC.db readable" "$user_tcc"
+    user_readable=1
+else
+    log_warn "User TCC.db readable" "no (this terminal needs Full Disk Access)"
+    user_readable=0
+fi
+
+if [[ -r "$sys_tcc" ]]; then
+    log_pass "System TCC.db readable" "$sys_tcc"
+    sys_readable=1
+elif sudo -n true 2>/dev/null; then
+    if sudo -n test -r "$sys_tcc"; then
+        log_info "System TCC.db" "readable via sudo (cached credential)"
+        sys_readable=1
+    else
+        log_info "System TCC.db" "would need sudo"
+        sys_readable=0
+    fi
+else
+    log_info "System TCC.db" "requires sudo (skipped)"
+    sys_readable=0
+fi
+
+if [[ "$user_readable" -eq 0 ]] && [[ "$sys_readable" -eq 0 ]]; then
+    note ""
+    note "  Neither TCC.db is readable from this terminal."
+    note "  To grant Full Disk Access to your terminal:"
+    note "    System Settings → Privacy & Security → Full Disk Access → +"
+    note "    Add: /Applications/Utilities/Terminal.app (or your terminal app)"
+    note "    Then restart the terminal session."
+    emit_summary
+    exit 0
+fi
+
+# ----------------------------------------------------------------------------
+section "2. PERMISSION GRANTS"
+# ----------------------------------------------------------------------------
+# auth_value semantics:
+#   0 = Denied
+#   1 = Unknown
+#   2 = Allowed
+#   3 = Limited (e.g. partial Photos access)
+# The 'service' column is kTCC* string; 'client' is bundle ID; 'client_type' is 0=bundle, 1=path
+# Modern TCC.db schemas have additional columns; we select defensively.
+
+build_filter() {
+    local where="1=1"
+    [[ -n "$APP_FILTER" ]] && where="$where AND (client LIKE '%${APP_FILTER//\'/}%' COLLATE NOCASE)"
+    [[ -n "$SERVICE_FILTER" ]] && where="$where AND (service LIKE '%${SERVICE_FILTER//\'/}%' COLLATE NOCASE)"
+    [[ "$SHOW_DENIED_ONLY" -eq 1 ]] && where="$where AND auth_value = 0"
+    echo "$where"
+}
+
+query_tcc() {
+    local db="$1"
+    local where
+    where=$(build_filter)
+    sqlite3 -separator '|' "$db" \
+        "SELECT service, client, auth_value, datetime(last_modified, 'unixepoch') FROM access WHERE $where ORDER BY auth_value, service, client" \
+        2>/dev/null
+}
+
+if [[ "$user_readable" -eq 1 ]]; then
+    note "  --- User-scope (per-user permission grants) ---"
+    rows=$(query_tcc "$user_tcc")
+    if [[ -z "$rows" ]]; then
+        log_pass "User TCC grants matching filter" "0 rows"
+    else
+        count=$(echo "$rows" | wc -l | tr -d ' ')
+        log_info "User TCC grants" "$count rows"
+        note "  service                      | client                                              | auth | last modified"
+        note "  -----------------------------|----------------------------------------------------|------|------------------------"
+        echo "$rows" | head -50 | awk -F'|' '{
+            svc = substr($1, 1, 28)
+            cli = substr($2, 1, 50)
+            auth = $3
+            ts = $4
+            label = (auth == 0 ? "DENY" : (auth == 2 ? "ALLOW" : (auth == 3 ? "LIM" : "?")))
+            printf "  %-28s | %-50s | %-4s | %s\n", svc, cli, label, ts
+        }'
+        denied=$(echo "$rows" | awk -F'|' '$3 == 0' | wc -l | tr -d ' ')
+        if [[ "$denied" -gt 0 ]]; then
+            log_warn "User TCC denials" "$denied — see DENY rows above"
+        fi
+    fi
+fi
+
+if [[ "$sys_readable" -eq 1 ]]; then
+    note ""
+    note "  --- System-scope (machine-wide grants, e.g. Full Disk Access) ---"
+    if [[ -r "$sys_tcc" ]]; then
+        rows=$(query_tcc "$sys_tcc")
+    else
+        rows=$(sudo sqlite3 -separator '|' "$sys_tcc" \
+            "SELECT service, client, auth_value, datetime(last_modified, 'unixepoch') FROM access WHERE $(build_filter) ORDER BY auth_value, service, client" 2>/dev/null)
+    fi
+    if [[ -z "$rows" ]]; then
+        log_pass "System TCC grants matching filter" "0 rows"
+    else
+        count=$(echo "$rows" | wc -l | tr -d ' ')
+        log_info "System TCC grants" "$count rows"
+        echo "$rows" | head -30 | awk -F'|' '{
+            svc = substr($1, 1, 28)
+            cli = substr($2, 1, 50)
+            auth = $3
+            ts = $4
+            label = (auth == 0 ? "DENY" : (auth == 2 ? "ALLOW" : (auth == 3 ? "LIM" : "?")))
+            printf "  %-28s | %-50s | %-4s | %s\n", svc, cli, label, ts
+        }'
+    fi
+fi
+
+# ----------------------------------------------------------------------------
+section "3. RECENT TCC PROMPTS"
+# ----------------------------------------------------------------------------
+# Look for tccd / system extension prompt activity in the last 7 days
+prompts=$(log show --last 7d --style compact \
+    --predicate 'process == "tccd"' 2>/dev/null \
+    | grep -iE "(prompt|denied|auth)" | head -10)
+
+if [[ -n "$prompts" ]]; then
+    log_info "Recent tccd activity (7d)" "see below"
+    echo "$prompts" | sed 's/^/    /'
+else
+    log_pass "Recent tccd activity" "quiet"
+fi
+
+# ----------------------------------------------------------------------------
+emit_summary
+
+if [[ "$JSON_MODE" -eq 0 ]]; then
+    echo
+    note "  Fix a denied grant:"
+    note "    1) System Settings → Privacy & Security → <Service> → toggle app off then on"
+    note "    2) Or: tccutil reset <ServiceShortName> <bundle-id>"
+    note "       e.g.  tccutil reset ScreenCapture com.tinyspeck.slackmacgap"
+    note "  See references/tcc-mechanics.md for the full service catalog."
+fi

+ 164 - 0
skills/mac-ops/scripts/wake-reasons.sh

@@ -0,0 +1,164 @@
+#!/usr/bin/env bash
+# mac-ops :: wake-reasons.sh
+# Why does this Mac wake up? Breakdown of pmset -g log wake events by cause.
+#
+# Common wake reason classes:
+#   UserActivity / EHCx       — user touched the keyboard / trackpad / a peripheral
+#   BT.HID                    — Bluetooth keyboard/mouse activity
+#   RTC / SMC                 — scheduled wake (Power Nap, Time Machine, calendar)
+#   PWRB                      — power button pressed
+#   USB.lid / Notifier        — lid open or wake-via-USB device
+#   Maintenance               — system maintenance wake (dark wake)
+#   Network                   — Wake-on-LAN / Bluetooth proximity
+
+set -u
+
+SINCE_DAYS=7
+TOP_N=15
+
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        --since)
+            # Accept '7d' or '24h' or just N (days)
+            v="$2"; shift 2
+            case "$v" in
+                *d) SINCE_DAYS="${v%d}" ;;
+                *h) SINCE_DAYS=$(( ${v%h} / 24 )); [[ "$SINCE_DAYS" -lt 1 ]] && SINCE_DAYS=1 ;;
+                *)  SINCE_DAYS="$v" ;;
+            esac
+            ;;
+        --top) TOP_N="$2"; shift 2 ;;
+        --help|-h)
+            cat <<EOF
+Usage: $0 [options]
+
+  --since 7d|24h|N         Lookback window (default: 7d)
+  --top N                  Show top N wake reasons (default: 15)
+  --json, --redact, --quiet, --verbose
+
+Wake reason quick reference:
+  UserActivity    Display/keyboard/trackpad — user-driven, expected
+  BT.HID          Bluetooth keyboard/mouse activity (often phantom at night)
+  RTC             Real-time clock — scheduled wake (Power Nap, calendar)
+  PWRB            Power button — manual wake
+  USB.lid         Lid open
+  Maintenance     Background maintenance (dark wake)
+  Network         WoL or Bluetooth proximity peer
+
+Heavy BT.HID wakes overnight usually mean a Bluetooth keyboard is "tapping" the
+display awake — easy fix is to disable "Wake for Bluetooth" or unpair the device.
+
+Heavy RTC wakes can mean Power Nap is enabled with too much background work.
+EOF
+            exit 0 ;;
+        *) shift ;;
+    esac
+done
+
+source "$(dirname "$0")/_lib/common.sh"
+parse_common_flags "$@"
+maybe_filter_self "$@"
+
+# ----------------------------------------------------------------------------
+section "1. WAKE PATTERN OVERVIEW"
+# ----------------------------------------------------------------------------
+note "  Lookback: ${SINCE_DAYS}d (pmset log retains roughly 7-14 days)"
+
+# pmset -g log format on modern macOS:
+#   "2026-05-10 02:40:55 +1000 DarkWake  DarkWake from Deep Idle [CDNPB] : due to NUB.SPMI0Sw3IRQ nub-spmi-a0.0x59 ... rtc/Maintenance ..."
+# Wake reasons appear after "due to" and end at "Using" or end-of-line.
+# Categories: rtc/Maintenance, rtc/SleepService, SMC.OutboxNotEmpty, NUB.SPMI*, etc.
+since_epoch=$(($(date +%s) - SINCE_DAYS * 86400))
+since_str=$(date -r "$since_epoch" "+%Y-%m-%d")
+
+raw=$(pmset -g log 2>/dev/null | awk -v since="$since_str" '
+    $1 >= since && ($0 ~ /DarkWake/ || $0 ~ /[[:space:]]Wake[[:space:]]/) {print}
+')
+
+wake_count=$(echo "$raw" | grep -c . || echo 0)
+if [[ "$wake_count" -eq 0 ]]; then
+    log_info "Wakes (since $since_str)" "0 — Mac hasn't slept, or pmset log was cleared"
+    emit_summary
+    exit 0
+fi
+
+log_info "Total wake events (since $since_str)" "$wake_count"
+
+# ----------------------------------------------------------------------------
+section "2. WAKE REASONS BY CLASS"
+# ----------------------------------------------------------------------------
+note "  Wake-cause class | count | pct"
+note "  -----------------|-------|----"
+
+# Extract the bit after "due to" up to "Using" — these are the cause tokens.
+# Then classify by first significant token.
+reasons_raw=$(echo "$raw" | sed -nE 's/.*due to (.*) Using.*/\1/p; s/.*due to (.*)/\1/p' \
+    | awk '
+        {
+            # Each line is a series of tokens. The most informative is usually the last
+            # one before category-style "rtc/X" or "wifi/" or similar slash-form.
+            for (i=1; i<=NF; i++) {
+                if ($i ~ /\//) { print $i; next }
+            }
+            print $1  # fallback to first token
+        }
+    ')
+
+echo "$reasons_raw" | sort | uniq -c | sort -rn | head -"$TOP_N" | \
+while read -r count reason; do
+    pct=$(( count * 100 / (wake_count > 0 ? wake_count : 1) ))
+    class="?"
+    case "$reason" in
+        rtc/Maintenance*|rtc/Power*)  class="rtc scheduled" ;;
+        rtc/SleepService*)            class="push-svc wake" ;;
+        rtc/*)                        class="rtc" ;;
+        SMC.OutboxNotEmpty*|smc/*)    class="hardware (SMC)" ;;
+        NUB.SPMI*|nub-spmi*)          class="USB/peripheral" ;;
+        wifibt/*|wlan/*)              class="wifi/bluetooth" ;;
+        EHC*|HID*|UserActivity)       class="user input" ;;
+        BT*)                          class="bluetooth peer" ;;
+        PWRB*|PowerButton*)           class="power button" ;;
+        Maintenance*)                 class="maintenance" ;;
+        Network*|WoL*)                class="network" ;;
+        *)                            class="other" ;;
+    esac
+    printf "  %-18s | %5d | %3d%%  (%s)\n" "$class" "$count" "$pct" "$reason"
+done
+
+# ----------------------------------------------------------------------------
+section "3. DARK WAKES (background maintenance)"
+# ----------------------------------------------------------------------------
+# pmset log line format: "DATE TIME TZ DarkWake \tDarkWake from ..."
+# The literal "DarkWake" appears in column 4 (after date/time/tz) AND in the message
+dark_wakes=$(echo "$raw" | awk '$4=="DarkWake"' | wc -l | tr -d ' ')
+log_info "Dark wakes" "$dark_wakes"
+
+if [[ "$dark_wakes" -gt 50 ]]; then
+    log_warn "Dark wake count" "$dark_wakes — frequent background wakes drain battery"
+    note "  Common causes:"
+    note "    • Power Nap enabled (System Settings → Battery → Options)"
+    note "    • Backup destinations (Time Machine, Backblaze) running"
+    note "    • Calendar / Contacts / iCloud sync"
+fi
+
+# ----------------------------------------------------------------------------
+section "4. WAKE TIMING (last 24h)"
+# ----------------------------------------------------------------------------
+yesterday=$(date -v-1d "+%Y-%m-%d")
+note "  Wakes since $yesterday:"
+echo "$raw" | awk -v y="$yesterday" '$1 >= y {print "    "$1, $2, $0}' | grep -oE "[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9:]+ .* Wake reason: [A-Za-z0-9_.]+" | tail -20
+
+# ----------------------------------------------------------------------------
+section "5. ASSERTIONS HOLDING SYSTEM AWAKE"
+# ----------------------------------------------------------------------------
+note "  Current pmset assertions (who's preventing sleep right now):"
+pmset -g assertions 2>/dev/null | grep -E "(IDLE|PreventUserIdleSystemSleep|PreventSystemSleep|PreventDisplay)" | head -10 | sed 's/^/    /'
+
+# ----------------------------------------------------------------------------
+section "6. SLEEP/WAKE PREFERENCES"
+# ----------------------------------------------------------------------------
+note "  pmset -g (custom settings):"
+pmset -g custom 2>/dev/null | head -25 | sed 's/^/    /'
+
+# ----------------------------------------------------------------------------
+emit_summary

+ 203 - 0
skills/mac-ops/tests/run.sh

@@ -0,0 +1,203 @@
+#!/usr/bin/env bash
+# mac-ops :: tests/run.sh
+# Lightweight self-tests. Run from repo root:
+#   bash skills/mac-ops/tests/run.sh
+#
+# Validates structural and output invariants WITHOUT trying to simulate
+# broken macOS state. Catches regressions in:
+#  - bash syntax / unbound vars / set -u trips
+#  - section headers + ordering
+#  - --json producing parseable NDJSON
+#  - --redact masking private addrs / tailnet names
+#  - --help working for every script
+#  - summary block format
+
+set -u
+
+PASS=0
+FAIL=0
+FAILED_TESTS=()
+
+assert() {
+    local name="$1"; shift
+    if "$@"; then
+        PASS=$((PASS+1))
+        printf "  [PASS] %s\n" "$name"
+    else
+        FAIL=$((FAIL+1))
+        FAILED_TESTS+=("$name")
+        printf "  [FAIL] %s\n" "$name"
+    fi
+}
+
+contains() { local hay="$1" needle="$2"; [[ "$hay" == *"$needle"* ]]; }
+not_contains() { local hay="$1" needle="$2"; [[ "$hay" != *"$needle"* ]]; }
+
+# Skip on non-macOS
+if [[ "$(uname -s)" != "Darwin" ]]; then
+    echo "Skipping: mac-ops tests only run on macOS (this is $(uname -s))"
+    exit 0
+fi
+
+here="$(cd "$(dirname "$0")" && pwd)"
+root="$(cd "$here/.." && pwd)"
+
+echo "=== mac-ops self-tests ==="
+echo "Root: $root"
+
+# ----------------------------------------------------------------------------
+echo
+echo "--- Script parse + permissions ---"
+# ----------------------------------------------------------------------------
+
+for f in "$root"/scripts/*.sh; do
+    name=$(basename "$f")
+    # bash -n parses without executing
+    if bash -n "$f" 2>/dev/null; then
+        assert "parse: $name" true
+    else
+        assert "parse: $name" false
+    fi
+    # executable bit set
+    if [[ -x "$f" ]]; then
+        assert "executable: $name" true
+    else
+        assert "executable: $name" false
+    fi
+done
+
+# ----------------------------------------------------------------------------
+echo
+echo "--- --help works for every script ---"
+# ----------------------------------------------------------------------------
+
+for f in "$root"/scripts/*.sh; do
+    name=$(basename "$f")
+    out=$(bash "$f" --help 2>&1)
+    assert "--help: $name returns usage" contains "$out" "Usage:"
+done
+
+# ----------------------------------------------------------------------------
+echo
+echo "--- health-audit structural ---"
+# ----------------------------------------------------------------------------
+
+audit_out=$(bash "$root/scripts/health-audit.sh" --days 1 --quiet 2>&1)
+assert "health-audit emits SUMMARY block" contains "$audit_out" "=== SUMMARY ==="
+assert "health-audit shows PASS counts" contains "$audit_out" "PASS:"
+assert "health-audit runs without unbound vars" not_contains "$audit_out" "unbound variable"
+
+# ----------------------------------------------------------------------------
+echo
+echo "--- --json produces pure NDJSON ---"
+# ----------------------------------------------------------------------------
+
+# Capture stdout only — JSON contract is "stdout = NDJSON, stderr may have noise"
+json_out=$(bash "$root/scripts/health-audit.sh" --days 1 --json 2>/dev/null)
+json_lines=$(echo "$json_out" | grep -c '^{' | tr -d '\n ')
+non_json=$(echo "$json_out" | grep -v '^{' | grep -c . | tr -d '\n ')
+assert "--json: at least one JSON record" bash -c "[[ \"$json_lines\" -ge 1 ]]"
+assert "--json: stdout is pure NDJSON (no non-JSON)" bash -c "[[ \"$non_json\" -eq 0 ]]"
+assert "--json: includes summary record" contains "$json_out" '"type":"summary"'
+
+# ----------------------------------------------------------------------------
+echo
+echo "--- --redact masks private addrs ---"
+# ----------------------------------------------------------------------------
+
+# Use startup-audit since it lists Adobe-style paths under /Users/...
+redact_out=$(bash "$root/scripts/startup-audit.sh" --redact --quiet 2>&1)
+# Should NOT contain raw 192.168.x.x or .ts.net hostnames
+leaks=$(echo "$redact_out" | grep -E '\b192\.168\.[0-9]+\.[0-9]+\b' | grep -v '192.168.X.X')
+assert "--redact: no 192.168.* leak in startup-audit" bash -c "[[ -z \"$leaks\" ]]"
+
+# ----------------------------------------------------------------------------
+echo
+echo "--- startup-audit produces clean output ---"
+# ----------------------------------------------------------------------------
+
+startup_out=$(bash "$root/scripts/startup-audit.sh" --quiet 2>&1)
+# Plutil errors should be filtered (we use || val="" pattern)
+assert "startup-audit: no plutil 'Could not extract'" not_contains "$startup_out" "Could not extract value"
+
+# ----------------------------------------------------------------------------
+echo
+echo "--- safe-disable-startup --list works ---"
+# ----------------------------------------------------------------------------
+
+list_out=$(bash "$root/scripts/safe-disable-startup.sh" --list 2>&1)
+assert "--list returns SUMMARY" contains "$list_out" "SUMMARY"
+
+# ----------------------------------------------------------------------------
+echo
+echo "--- panic-triage handles 'no panics' gracefully ---"
+# ----------------------------------------------------------------------------
+
+# Most dev Macs have no recent panics. Verify the script doesn't error.
+panic_out=$(bash "$root/scripts/panic-triage.sh" --quiet 2>&1 || true)
+assert "panic-triage runs without crashing" contains "$panic_out" "PANIC REPORT"
+
+# ----------------------------------------------------------------------------
+echo
+echo "--- tcc-audit gracefully handles permission denial ---"
+# ----------------------------------------------------------------------------
+
+tcc_out=$(bash "$root/scripts/tcc-audit.sh" --quiet 2>&1)
+# Should exit cleanly even without TCC.db read access
+assert "tcc-audit reaches SUMMARY (or handles no-access path)" bash -c "echo '$tcc_out' | grep -qE 'SUMMARY|TCC.db readable'"
+
+# ----------------------------------------------------------------------------
+echo
+echo "--- wake-reasons parses real pmset log ---"
+# ----------------------------------------------------------------------------
+
+wake_out=$(bash "$root/scripts/wake-reasons.sh" --since 1d --quiet 2>&1)
+assert "wake-reasons reaches SUMMARY" contains "$wake_out" "SUMMARY"
+
+# ----------------------------------------------------------------------------
+echo
+echo "--- spotlight-status filters system volumes ---"
+# ----------------------------------------------------------------------------
+
+spot_out=$(bash "$root/scripts/spotlight-status.sh" --quiet 2>/dev/null)
+# Should NOT have "Error: unknown indexing state" leak from system vols
+err_leaks=$(echo "$spot_out" | grep -c "unknown indexing state" | tr -d '\n ')
+assert "spotlight-status: system-vol error filtering" bash -c "[[ \"${err_leaks:-0}\" -le 0 ]]"
+
+# ----------------------------------------------------------------------------
+echo
+echo "--- All 12 scripts present ---"
+# ----------------------------------------------------------------------------
+
+expected_scripts=(
+    health-audit.sh panic-triage.sh startup-audit.sh safe-disable-startup.sh
+    disk-health.sh drive-dependencies.sh boot-perf.sh recover-clone.sh
+    tcc-audit.sh wake-reasons.sh spotlight-status.sh storage-pressure.sh
+)
+for s in "${expected_scripts[@]}"; do
+    assert "script exists: $s" test -f "$root/scripts/$s"
+done
+
+# ----------------------------------------------------------------------------
+echo
+echo "--- All 7 reference docs present ---"
+# ----------------------------------------------------------------------------
+
+expected_refs=(
+    storage-events.md recovery-patterns.md tcc-mechanics.md
+    launchd-deep-dive.md panic-codes.md startup-mechanisms.md
+    remote-diagnostics.md
+)
+for r in "${expected_refs[@]}"; do
+    assert "reference exists: $r" test -f "$root/references/$r"
+done
+
+# ----------------------------------------------------------------------------
+echo
+echo "=== TOTAL: $PASS pass, $FAIL fail ==="
+if [[ "$FAIL" -gt 0 ]]; then
+    echo "Failed tests:"
+    for t in "${FAILED_TESTS[@]}"; do echo "  - $t"; done
+    exit 1
+fi
+exit 0