#!/usr/bin/env bash # live-sync.sh — heartbeat committer for machine-written paths in the command # center checkout. Refs #11 (see omp-docs/tools/live-sync.md). # # The checkout IS the deployment; the harness, auto-learn, and desktop GUIs # write tracked files continuously through symlinks. This script commits ONLY # the allowlisted machine-live + desktop-live tiers (AGENTS.md § Live-sync # tiers), merges the trunk, pushes, and reaps stale session worktrees. Task # dirt outside the allowlist is NEVER committed — only surfaced. # # Black-box contract (tests/live-sync.bats pins it): # input git state of $LIVE_SYNC_REPO # output exit 0 = quiet heartbeat (synced, no-op, or reaped) # exit 1 = alert raised (branch guard, hook refusal, merge conflict, # fetch/push failure); alert goes to stderr + notify sink # Env overrides (test seams): # LIVE_SYNC_REPO target repo (default $HOME/.omp) # LIVE_SYNC_NOTIFY alert command, message on stdin # LIVE_SYNC_NTFY_TOPIC_FILE ntfy topic file, mode 600 (default # ~/.config/ntfy/live-sync; absent/empty = no push) # LIVE_SYNC_AGE_DAYS wt/wt-* reap threshold (default 7) set -euo pipefail REPO="${LIVE_SYNC_REPO:-$HOME/.omp}" AGE_DAYS="${LIVE_SYNC_AGE_DAYS:-7}" TOPIC_FILE="${LIVE_SYNC_NTFY_TOPIC_FILE:-$HOME/.config/ntfy/live-sync}" STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}" # Allowlist tiers. Human prose (AGENTS.md, omp-docs, scripts, plugins, # agent/skills) is deliberately absent: branch -> PR territory, never swept. LIVE_PATHS=( agent/memories agent/managed-skills agent/config.yml agent/models.yml dotfiles ) notify() { local msg="live-sync: $1" stamp now stamp=$(cat "$STATE_DIR/live-sync.alert" 2>/dev/null || echo 0) now=$(date +%s) if (( now - stamp >= 3600 )); then date +%s > "$STATE_DIR/live-sync.alert" if [[ -n "${LIVE_SYNC_NOTIFY:-}" ]]; then printf '%s' "$msg" | $LIVE_SYNC_NOTIFY || true elif [[ -s "$TOPIC_FILE" ]]; then curl -fsS -m 10 -d "$msg" "https://ntfy.sh/$(<"$TOPIC_FILE")" >/dev/null 2>&1 || true fi fi printf '%s\n' "$msg" >&2 } cd "$REPO" 2>/dev/null || exit 0 exec 9>"$STATE_DIR/live-sync.lock" flock -n 9 || exit 0 # another heartbeat owns the repo git rev-parse --verify -q HEAD >/dev/null || exit 0 branch=$(git symbolic-ref --quiet --short HEAD || true) if [[ "$branch" != main ]]; then notify "on branch '$branch', not main — refusing to sync, nothing changed" exit 1 fi # 1. stage only allowlisted paths that actually hold dirt (pathspecs that # match nothing would make git add fatal, so check per-path) for p in "${LIVE_PATHS[@]}"; do if [[ -n $(git status --porcelain -- "$p") ]]; then git add -A -- "$p" fi done if ! git diff --cached --quiet; then # 2. commit WITH hooks armed — the secret gate stays pointed at the diff; # a refusal here is the system working, never bypassed if ! git commit -q -m "live: heartbeat sync $(date -Is) (Refs #11)"; then notify "commit rejected by hook/secret gate — live changes left STAGED for a human" exit 1 fi # 3. integrate trunk and publish; never fight the remote if git remote get-url origin >/dev/null 2>&1; then if ! git fetch -q origin; then notify "fetch failed — commit is safe locally, will retry next heartbeat" exit 1 fi if git rev-parse --verify -q origin/main >/dev/null \ && ! git merge -q --no-edit origin/main; then git merge --abort || true notify "merge conflict with origin/main — local commit kept; needs you at the keyboard" exit 1 fi if ! git push -q origin main; then notify "push failed — commit is safe locally, will retry next heartbeat" exit 1 fi fi fi # 4. reap session worktrees older than AGE_DAYS. Dirty worktrees are left # alone (git refuses), and so are any with a live process cwd inside. in_use() { local p for p in /proc/[0-9]*/cwd; do if [[ "$(readlink "$p" 2>/dev/null || true)" == "$1"* ]]; then return 0 fi done return 1 } git worktree prune reap_path="" reap_ref="" reap_flush() { if [[ "$reap_path" == "$REPO/wt/wt-"* && -d "$reap_path" ]] \ && [[ -z $(find "$reap_path" -mtime "-$AGE_DAYS" -print -quit) ]] \ && ! in_use "$reap_path"; then git worktree remove -- "$reap_path" 2>/dev/null && git worktree prune || true if [[ -n "$reap_ref" ]]; then git branch -q -d "${reap_ref#refs/heads/}" 2>/dev/null || true fi fi reap_path="" reap_ref="" } while IFS= read -r line; do case "$line" in "worktree "*) reap_flush; reap_path="${line#worktree }" ;; "branch "*) reap_ref="${line#branch }" ;; esac done < <(git worktree list --porcelain) reap_flush