1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
|
#!/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
|