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 months 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
+ 3 - 2
README.md


+ 352 - 0
skills/mac-ops/SKILL.md

@@ -0,0 +1,352 @@
+---
+name: mac-ops
+description: "Comprehensive macOS workstation operations — diagnose kernel panics, identify failing drives, audit launchd startup items, decode wake reasons, triage TCC permission denials, manage APFS snapshots, recover from no-boot. Use for: Mac is slow, slow bootup, won't boot, kernel panic, kernel_task hot, mds_stores CPU, photoanalysisd, cloudd, login loop, gray screen, sleep wake failure, drive failing, IO errors, APFS snapshots eating space, Time Machine local snapshots, Spotlight indexing, launchd, LaunchAgent, LaunchDaemon, login items, TCC permissions, Full Disk Access, Screen Recording denied, Gatekeeper, quarantine, com.apple.quarantine, app is damaged, helper tool, /Library/PrivilegedHelperTools, pmset, wake reasons, dark wake, sysdiagnose, panic.ips, DiagnosticReports, configuration profile, MDM profile, remote diagnostics over SSH."
+license: MIT
+allowed-tools: "Read Write Bash"
+metadata:
+  author: claude-mods
+  related-skills: windows-ops, net-ops, debug-ops, perf-ops
+---
+
+# mac-ops
+
+## Helps with
+
+Slow Mac that used to be fast — bloat accumulation across the four startup mechanisms (Login Items, `~/Library/LaunchAgents`, `/Library/LaunchAgents`, `/Library/LaunchDaemons`). The same machine still boots fast once those are inventoried and trimmed.
+
+Failing drives that nobody's spotted yet. macOS doesn't shout the way Windows does — IO errors live in `log show --predicate 'subsystem == "com.apple.iokit"'` and APFS surfaces them via `AppleAPFSContainerScheme` / `AppleNVMe*` provider messages. Healthy SSDs produce zero of these per month; dozens means active failure even when "About This Mac → Storage" still shows green.
+
+Kernel panics with no obvious cause. The `.panic` / `.ips` files in `/Library/Logs/DiagnosticReports/` carry the panic string, kernel call stack, and (critically) the loaded kext list. A panic mentioning a third-party kext (`com.eltima.ProductX`, `com.paragon.NTFS`, anti-virus drivers) tells a completely different story than a panic in core Apple code (`AppleIntelKBL Graphics`, `IOPlatformPluginUtil`).
+
+"My Mac is slow" diagnosed by chasing the wrong symptom. Activity Monitor shows what's running NOW; `log show` shows what failed at boot, what's been panicking, and what storage / power events preceded each freeze. Always audit before treating.
+
+Apps that "don't work right" but aren't crashing — usually a **TCC** (Transparency, Consent, Control) denial nobody explicitly clicked No to. Screen Recording, Accessibility, Full Disk Access, Camera, Microphone, Contacts, Calendars, Reminders, Photos, Automation — each has its own permission grant. Reading the TCC databases tells you exactly what's been denied and when.
+
+"Macintosh HD is full but I deleted everything" — APFS local Time Machine snapshots plus purgeable space breakdowns. `tmutil listlocalsnapshots /` and `diskutil apfs list` reveal the actual space accounting that Finder hides.
+
+Mac waking up at 3am for no apparent reason. `pmset -g log` records every wake with a reason string (`UserActivity`, `BT.HID`, `EHC0`, `RTC`, `Maintenance`). The pattern across a week tells you whether it's the keyboard, a Bluetooth peer, a kext, or scheduled maintenance.
+
+`mds_stores` / `mdworker_shared` / `photoanalysisd` / `cloudd` / `bird` chewing CPU. Each has a specific cause (Spotlight reindex on a new volume, Photos analyzing faces, iCloud Drive metadata sync) and a specific remedy (per-volume mdutil control, throttling, or waiting it out informedly).
+
+Login loops, gray screen at boot, "kernel" hangs in `loginwindow`. The boot-sequence layers (EFI → bootloader → kernel → launchd → loginwindow → WindowServer → shell) each fail differently; this skill packages the recoveryOS / single-user / verbose-boot patterns.
+
+"Is it safe to eject this disk?" — `lsof +D /Volumes/X`, `mdutil -s`, Time Machine target check, Photos library location, helper-tool security-scoped bookmarks. The wrong answer corrupts the volume; the right answer is a one-line verdict.
+
+Cloning data off a failing drive without finishing it off. `ditto` with `--rsrc` for HFS+ metadata, `rsync --partial --inplace --no-whole-file --append-verify` for resumable transfers. NEVER `fsck_apfs -y` a failing drive — verify-only first (`fsck_apfs -n`), and prefer reading from an APFS snapshot.
+
+Remote macOS diagnostics across the network — SSH (universal on macOS 13+), `kickstart` to enable ARD without a UI, staging the skill folder via `scp -r`.
+
+Apple Silicon vs Intel reality — most diagnostic surface is identical. Where it isn't (Secure Enclave vs T2, panic provenance, boot recovery modes), the differences are flagged explicitly.
+
+## The Universal Insight
+
+**macOS tells you what's wrong if you ask the right log in the right way.** Most users (and most tutorials) reach for Activity Monitor or "About This Mac". The actual diagnostic signal lives in `log show` (the unified logging system), in `/Library/Logs/DiagnosticReports/`, in `pmset -g log`, in the TCC databases, and in `launchctl print`. This skill packages the queries that turn noise into a verdict.
+
+The most common diagnostic failure: treating symptoms in isolation. "Slow boot" → disable login items. "Kernel panic" → reinstall macOS. "Random freezes" → reset SMC/NVRAM. These are reasonable last resorts, but the data to identify the *actual* cause is sitting in the unified log untouched. Always audit before treating.
+
+## The Diagnostic Ladder
+
+Walk down the layers in order. Each rung has a binary outcome:
+
+```
+1. Hardware health     — pmset, SMC errors, thermal events, Secure Enclave
+2. Storage health      — APFS state, IO errors, snapshot bloat
+3. Panic record        — DiagnosticReports/*.{panic,ips} + kext provenance
+4. Pre-panic timeline  — log show last 10 minutes before each panic
+5. Startup inventory   — Login Items + LaunchAgents + LaunchDaemons + profiles
+6. Resource pressure   — top CPU/mem, mds_stores, photoanalysisd, cloudd
+7. Permissions / TCC   — what app is denied what (the macOS-unique rung)
+8. Verdict             — what's failing, what to do
+```
+
+The most interesting failures cluster at rungs 2 (storage), 5 (startup bloat), and 7 (TCC denials). The least interesting (but most-treated) is rung 6.
+
+## Workflow
+
+### 1. Run the comprehensive audit
+
+```bash
+scripts/health-audit.sh
+```
+
+Produces a verdict block: hardware events, storage health per volume, recent panics, top resource consumers, startup inventory, TCC denials. Scan for `[FAIL]` markers — that's where to drill.
+
+### 2. Drill into the failing layer
+
+| Symptom | Script |
+|---|---|
+| Storage errors flagged | `scripts/disk-health.sh -v /Volumes/X` (or `-d disk2`) — focused per-volume deep dive: APFS state, IO errors, snapshot bloat, verdict |
+| Recent panic | `scripts/panic-triage.sh` (latest by default) or `-f /Library/Logs/DiagnosticReports/Kernel_*.panic` — kext + pre-panic timeline |
+| "Is it safe to eject volume X?" | `scripts/drive-dependencies.sh -v /Volumes/X` — open files, Spotlight index, TM target, Photos lib, helper-tool bookmarks |
+| "Why is boot taking so long?" | `scripts/boot-perf.sh` — per-boot durations from log show, with slow-component flags |
+| App can't see screen/mic/files | `scripts/tcc-audit.sh -a <bundle-id-or-name>` — what TCC has granted, what's been denied recently |
+| Mac waking at night | `scripts/wake-reasons.sh` — pmset log breakdown by reason class |
+| Spotlight broken / mds CPU spike | `scripts/spotlight-status.sh` — index state per volume, common fixes |
+| Storage "full" but disk usage doesn't add up | `scripts/storage-pressure.sh` — APFS snapshots, local Time Machine, purgeable bytes |
+
+### 3. Apply the minimum reversible fix
+
+| Action | Script |
+|---|---|
+| Disable startup item by name | `scripts/safe-disable-startup.sh -n <pattern>` — works across Login Items + LaunchAgents (no sudo for user-scope) |
+| List current state of all startup entries | `scripts/safe-disable-startup.sh --list` |
+| Re-enable previously disabled | `scripts/safe-disable-startup.sh -n <pattern> --enable` |
+| Disable system-scope daemon (admin) | `sudo launchctl disable system/<label>` then `sudo launchctl bootout system/<label>` |
+| Reset TCC for a specific service+bundle | `tccutil reset <Service> <bundle-id>` (per-service, not global) |
+| Safe clone from failing drive | `scripts/recover-clone.sh -s <source> -d <destination>` — rsync `--partial --inplace --no-whole-file` |
+
+All disables are reversible — Login Items via `osascript` System Events, LaunchAgents via `launchctl disable`. The inverse re-enables.
+
+## Storage Health & Failure Detection
+
+The highest-yield audit. Failing drives cause slow boots (kernel waits on probe timeouts), instability (IO retries cascade into kernel hangs), and panics (paging failures kill `WindowServer`). Three independent data sources to cross-reference:
+
+### IO error events
+
+```bash
+log show --last 30d --style compact \
+    --predicate 'subsystem == "com.apple.iokit" AND (eventMessage CONTAINS "I/O error" OR eventMessage CONTAINS "media error")' \
+    2>/dev/null | head -30
+```
+
+Healthy drives produce zero of these per month. Dozens = active failure regardless of what "About This Mac → Storage" claims.
+
+### APFS health
+
+```bash
+diskutil apfs list
+diskutil apfs verifyVolume /        # READ-ONLY — does not write
+```
+
+Look for `Verify failed` per-volume, container free-space mismatches, or snapshot trees growing without bound.
+
+### SMART status (via vendor / smartmontools)
+
+macOS's built-in SMART status (`diskutil info /dev/diskN`) reports only `Verified` or `Failing`. For real attributes, install `smartmontools` (`brew install smartmontools`) and use `smartctl -a /dev/diskN`. NVMe drives often return blank — fall back to vendor tools or the per-vendor utilities.
+
+### Disk → volume mapping
+
+```bash
+diskutil list
+diskutil info disk2s1
+```
+
+Cross-reference with `df -h` for mount point.
+
+## Boot Performance & Startup Management
+
+macOS has **four primary startup mechanisms**, each requiring different tooling. System Settings only shows one of them (Login Items). Full inventory in `references/startup-mechanisms.md`.
+
+| Mechanism | Where | How to inspect | How to disable |
+|---|---|---|---|
+| Login Items | System Settings → General → Login Items | `osascript` System Events | `osascript` (no sudo) |
+| User LaunchAgents | `~/Library/LaunchAgents/*.plist` | `launchctl print gui/$UID` | `launchctl disable gui/$UID/<label>` |
+| System LaunchAgents (per-user) | `/Library/LaunchAgents/*.plist` | `launchctl print gui/$UID` | `launchctl disable gui/$UID/<label>` (no sudo for current user) |
+| System LaunchDaemons | `/Library/LaunchDaemons/*.plist` | `sudo launchctl print system` | `sudo launchctl disable system/<label>` |
+| (Legacy) LoginHook | `com.apple.loginwindow` LoginHook key | `sudo defaults read com.apple.loginwindow LoginHook` | `sudo defaults delete com.apple.loginwindow LoginHook` |
+
+### `launchctl disable` vs `bootout`
+
+| Command | Effect |
+|---|---|
+| `launchctl disable <domain>/<label>` | Persistently disabled across reboots. **Reversible** with `enable`. |
+| `launchctl bootout <domain>/<label>` | Unloads the running service NOW. Comes back on next reboot if not also `disable`d. |
+| `launchctl unload <plist>` | Legacy form. Avoid in new scripts. |
+
+The right pair for "kill this daemon permanently": `disable` then `bootout`. The script `scripts/safe-disable-startup.sh` does both.
+
+### Boot duration measurement
+
+macOS records boot timing in the unified log. Approximate via:
+
+```bash
+log show --last 1h --style compact \
+    --predicate 'eventMessage CONTAINS "BOOT_TIME" OR eventMessage CONTAINS "loginwindow"' \
+    | head -50
+```
+
+Healthy Apple Silicon Mac: 10-20s to login screen. Intel Mac with spinning disk (vintage 2015 Mini): 25-45s. Failing storage: 60+s with stalls.
+
+## Panic Analysis & Diagnostic Reports
+
+### Where panics live
+
+```
+/Library/Logs/DiagnosticReports/*.panic          (legacy Intel + early Apple Silicon)
+/Library/Logs/DiagnosticReports/*.ips            (modern format, all panics on macOS 12+)
+~/Library/Logs/DiagnosticReports/                (per-user crashes, not panics)
+```
+
+`.ips` files are JSON. The `.panic` files are plain text but follow a strict structure.
+
+### Anatomy of a panic report
+
+```
+panic(cpu N caller 0x...): "Sleep wake failure in EFI"
+Loaded kexts:
+  com.apple.driver.AppleEFIRuntime           ...
+  com.eltima.ProductX                        2.1.7    <— third-party suspect
+  ...
+```
+
+The **kext list** is the most actionable signal. A panic that loaded only Apple kexts is harder to fix than one with a clear third-party suspect — kext-extraction-and-removal is the first move.
+
+### Common panic strings (full catalog in `references/panic-codes.md`)
+
+| String fragment | Likely cause |
+|---|---|
+| "Sleep wake failure" | Driver hung during S3/S4 transition (often USB, Bluetooth, GPU) |
+| "Unable to find driver" | Boot-time kext load failure — likely after macOS update |
+| "Unresponsive bootstrap subsystem" | `launchd` deadlock — usually third-party LaunchDaemon |
+| "WindowServer panic" | GPU driver or display kext fault |
+| "double_fault" / "page_fault" | Kernel-mode memory corruption — kext bug or RAM fault |
+| "panic_kthread" | Kernel watchdog timeout — driver in infinite loop |
+
+### Pre-panic timeline
+
+The panic record alone rarely tells you the cause. The **events in the 10 minutes before** are where the story is:
+
+```bash
+scripts/panic-triage.sh -t '2026-05-15 03:14:22' -m 10
+```
+
+Look for:
+- Disk arbitration errors before panic → storage failure cascade
+- `kernel` warnings naming a third-party kext before panic → driver hang
+- `powerd` / `assertion` messages before sleep panic → pmset assertion held by misbehaving app
+
+## TCC (Privacy Permissions) Audit
+
+A macOS-unique diagnostic layer. The TCC databases at:
+
+```
+/Library/Application Support/com.apple.TCC/TCC.db     (system, requires sudo)
+~/Library/Application Support/com.apple.TCC/TCC.db    (per-user)
+```
+
+…store every "Allow / Deny" grant ever made. Reading them tells you exactly which app has Screen Recording, Camera, Microphone, Full Disk Access, Accessibility, Automation, etc. — and which apps have been **denied** recently (the most common "this app doesn't work" cause).
+
+Full schema and reset procedures in `references/tcc-mechanics.md`. The `scripts/tcc-audit.sh` script wraps the common queries.
+
+## Common Failure Modes
+
+| Symptom | First check | Common cause |
+|---|---|---|
+| Slow boot, used to be fast | `startup-audit.sh` | Login Item / LaunchAgent bloat (Adobe CC, Docker, Setapp) |
+| Slow boot, getting worse | `disk-health.sh` | Failing SSD — APFS retries inflating boot time |
+| Random freezes + hard restart | `disk-health.sh` + `panic-triage.sh` | IO errors cascading into kernel hang |
+| Kernel panic on wake | `panic-triage.sh` (look for "Sleep wake failure") | Driver power-state bug (often USB, GPU, Bluetooth) |
+| App can't access screen/mic | `tcc-audit.sh -a <app>` | TCC denial, often from a recent system update |
+| "Macintosh HD almost full" | `storage-pressure.sh` | Local Time Machine snapshots + purgeable cache |
+| Mac wakes at 3am | `wake-reasons.sh` | Scheduled maintenance, BT keyboard tap, or kext bug |
+| `mds_stores` CPU 100% | `spotlight-status.sh` | Reindex on volume with no on-disk index store |
+| Login loop / gray screen | recoveryOS + safe boot + this skill's recovery docs | Bad LaunchAgent, corrupt Login Items, kext panic at boot |
+
+## Recovery Patterns
+
+### Failing-drive data recovery
+
+**Never `fsck_apfs -y` a failing drive** — `-y` answers Yes to repairs, which writes back. Use `fsck_apfs -n` (verify-only) first. Image first, repair the image second.
+
+```bash
+# Safe clone with no retries (skips bad sectors fast)
+rsync -avh --partial --inplace --no-whole-file --append-verify \
+    /Volumes/Failing/important/ /Volumes/Rescue/important/
+```
+
+For bit-level recovery, install `gddrescue` via Homebrew and use a map file so the operation is resumable. Documented in `references/recovery-patterns.md`.
+
+### Physically removing a failing drive
+
+If a drive is causing boot stalls or panics:
+
+1. Identify via `disk-health.sh`
+2. Verify nothing critical points at it (`drive-dependencies.sh -v /Volumes/X`)
+3. `diskutil unmount /Volumes/X` (or `diskutil eject /dev/diskN` for the whole device)
+4. Physically disconnect / power down before remount attempts
+
+## Voice & Output Style
+
+Output follows the claude-mods diagnostic convention:
+
+- `[PASS]` / `[FAIL]` / `[WARN]` / `[INFO]` prefixes for scan rows
+- Verdict block at the bottom with specific findings + recommended actions
+- Volume identifications include disk identifier, mount point, APFS role
+- Panic references include UTC timestamp, panic string, primary suspect kext
+- Panel rendering via `skills/_lib/term.sh` when a TTY is present; raw text otherwise
+- `--json` emits NDJSON for piping; `--redact` masks private addrs / hostnames / serial numbers
+
+## What This Skill Doesn't Cover
+
+- **Network diagnostics** → use `net-ops`
+- **Windows-side workstation issues** → use `windows-ops`
+- **Specific application performance profiling** → use `perf-ops`
+- **Source-code-level debugging** → use `debug-ops`
+- **iOS / iPadOS device issues** — different platform
+- **MDM authoring** (creating configuration profiles) — out of scope; we read them, not author them
+
+## Cross-References
+
+| When | Use |
+|---|---|
+| Triaging a remote Mac | `net-ops/ssh-bootstrap.sh` to land, then this skill's scripts |
+| Panic blames a network kext | Combine with `net-ops` for VPN/DNS interactions |
+| Same pattern on multiple Macs | Run `health-audit.sh --json` on each, diff outputs |
+| Suspect Windows + Mac in same household | `windows-ops` + `mac-ops`, same conventions |
+
+## References
+
+- `references/storage-events.md` — IO error patterns in unified log, APFS-specific event vocabulary, disk arbitration messages. Load when investigating volume errors or correlating IO failures to a specific device.
+
+- `references/panic-codes.md` — Common kernel panic strings, kext-extraction patterns, Apple Silicon vs Intel panic format differences. Load when decoding a non-trivial `.panic`/`.ips` file or matching a symptom to a likely cause.
+
+- `references/startup-mechanisms.md` — Deep dive on Login Items, LaunchAgents (user + system), LaunchDaemons, legacy LoginHook, configuration profile login items. Load when doing a full startup audit or hunting vendor-installed auto-launch hooks.
+
+- `references/recovery-patterns.md` — Failing-drive recovery (rsync, gddrescue), APFS snapshot rollback, target disk mode, recoveryOS, single-user mode on Apple Silicon vs Intel. Load when responding to "my drive is dying" or any destructive operation.
+
+- `references/remote-diagnostics.md` — SSH staging pattern (`scp -r mac-ops/ remote:`), `kickstart` for enabling ARD, sudo over SSH considerations. Load when troubleshooting "my parents' Mac across town".
+
+- `references/tcc-mechanics.md` — How TCC works under the hood. Both TCC.db locations, "service" string catalog (kTCCServiceScreenCapture, kTCCServiceAccessibility, etc.), grant types, when to use `tccutil reset` vs editing the DB, SIP interaction. Load when an app silently fails to access screen / mic / files.
+
+- `references/launchd-deep-dive.md` — launchd plist semantics, `RunAtLoad` vs `KeepAlive`, `ThrottleInterval`, why daemons fail to load, `disable` vs `bootout` vs `unload`, domain targets (system, user, gui), and Apple Silicon specifics (system extensions replacing kexts).
+
+## Worked example
+
+A user reports "my Mac wakes itself at 3am and is slow during the day." Running `scripts/health-audit.sh` produces a panel that follows the [Terminal Panel Design System](../../docs/TERMINAL-DESIGN.md):
+
+```
+╭── 🩺 mac-ops · health-audit ───────────────────────────────────── macks-mbp ───●
+│
+├── 3 volumes · 1 panic · 4 wakes/24h · 12 startup items
+│
+├── failing (4)
+│   ├── [panic] 2026-05-14 03:14    Sleep wake failure (com.kext.example.AcmeUSB)
+│   ├── [wake]  3 wakes from BT.HID    Bluetooth keyboard activity at night
+│   ├── [tcc]   Slack denied Screen Recording   (granted previously, lost after update)
+│   └── [start] 12 login items, 3 disabled, 2 unsigned
+│   │   ▲ remove AcmeUSB kext; pair BT keyboard to phone for night; re-grant Slack TCC
+│
+├── warn (2) · pass (9) · info (3)
+│
+╰── R refresh · D drill · ? help ─────────────────── ⬤ panic  • bt-wake  • tcc ───●
+```
+
+Three commands solve it: `panic-triage.sh` decodes the panic; `wake-reasons.sh` shows BT.HID is the dominant wake class; `tcc-audit.sh -a slack` confirms denied. The data was always there — this skill just asks for it correctly *and renders it like a proper instrument*.
+
+### Legacy / non-panel mode
+
+All scripts accept `--json` for NDJSON output (parses with `jq`) and `--redact` for opsec-clean diagnostic dumps. When stdout is not a TTY, panel chrome auto-disables and plain text emits.
+
+Full command sequence for the example:
+
+```bash
+scripts/health-audit.sh                            # diagnose
+scripts/panic-triage.sh                            # decode most recent panic
+scripts/wake-reasons.sh --since 7d                 # weekly wake pattern
+scripts/tcc-audit.sh -a Slack                      # check denied permissions
+scripts/safe-disable-startup.sh --list             # audit startup state
+scripts/safe-disable-startup.sh -n 'Adobe*'        # cull bloat
+sudo launchctl disable system/com.kext.example.AcmeUSB.daemon
+# (then reboot to confirm panic doesn't return)
+scripts/health-audit.sh                            # verify clean
+```

+ 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

Some files were not shown because too many files changed in this diff