aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/capture-guard
blob: 6c01f2f775945508e6a1a8e3933dfd313d6db960 (plain)
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