#!/usr/bin/env bash # capture-guard — detect live org-capture buffers visiting a target file # before a workflow edits that file on disk. # # Editing a file on disk while Emacs has an indirect org-capture buffer # cloned from it reverts the base buffer underneath the capture, wedging it: # the capture can no longer finalize cleanly with C-c C-c, and a freshly-typed # item can be lost or written back against post-edit content. inbox.org # roam mode Phase D edits ~/org/roam/inbox.org, the file Craig captures into constantly, # so it calls this guard first. See claude-rules/emacs.md. # # Usage: capture-guard [--wait[=SECONDS]] [TARGET_FILE] (default ~/org/roam/inbox.org) # # Single-shot (default): check once. # exit 0 — safe to edit: no Emacs, daemon unreachable, or no capture buffer # visits TARGET_FILE. # exit 1 — a live capture buffer visits TARGET_FILE; its name(s) printed to # stdout, comma-separated. # # --wait[=SECONDS]: poll until the capture clears or SECONDS elapse (default # 30), re-checking every ~10s. Org captures are usually transient — a few # seconds of mid-finalize state — so a short wait clears most false alarms # before a caller has to surface or skip. Same exit codes: exit 0 the moment # it's clear, exit 1 if still blocked at the deadline (last buffer list on # stdout). The common case (nothing capturing) returns instantly without # sleeping. # # Conservative by construction: any uncertainty (no Emacs, query failure) # resolves to "safe," so the guard never blocks a workflow that would have # been fine. It only stops the one case it can positively confirm. set -euo pipefail WAIT_TOTAL=0 case "${1:-}" in --wait) WAIT_TOTAL=30; shift ;; --wait=*) WAIT_TOTAL="${1#--wait=}"; shift ;; esac TARGET="${1:-$HOME/org/roam/inbox.org}" INTERVAL=10 # Names of capture buffers whose base buffer visits TARGET. file-equal-p # normalizes symlinks and ./.. so the match survives path spelling; it also # returns nil when TARGET doesn't exist, which collapses to "safe" below. lisp='(let ((target (expand-file-name "'"$TARGET"'"))) (mapconcat (function buffer-name) (seq-filter (lambda (b) (and (string-prefix-p "CAPTURE" (buffer-name b)) (let* ((base (or (buffer-base-buffer b) b)) (f (buffer-file-name base))) (and f (file-equal-p f target))))) (buffer-list)) ","))' LAST_BUFS="" # detect — return 0 (safe) or 1 (blocked, name(s) in LAST_BUFS). Any # uncertainty resolves to safe, matching the single-shot contract. detect() { command -v emacsclient >/dev/null 2>&1 || return 0 emacsclient -e t >/dev/null 2>&1 || return 0 local bufs bufs="$(emacsclient -e "$lisp" 2>/dev/null)" || return 0 bufs="${bufs#\"}" bufs="${bufs%\"}" if [ -n "$bufs" ]; then LAST_BUFS="$bufs" return 1 fi return 0 } # Poll loop. With WAIT_TOTAL=0 (single-shot) it checks once and falls straight # through to the exit-1 branch on a block, never sleeping. Each sleep is capped # to the remaining budget so a short --wait never overshoots its deadline. elapsed=0 while :; do if detect; then exit 0 fi if [ "$elapsed" -ge "$WAIT_TOTAL" ]; then echo "$LAST_BUFS" exit 1 fi remaining=$((WAIT_TOTAL - elapsed)) step=$((remaining < INTERVAL ? remaining : INTERVAL)) sleep "$step" elapsed=$((elapsed + step)) done