aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts
diff options
context:
space:
mode:
Diffstat (limited to '.ai/scripts')
-rwxr-xr-x.ai/scripts/capture-guard74
-rw-r--r--.ai/scripts/tests/capture-guard.bats27
2 files changed, 82 insertions, 19 deletions
diff --git a/.ai/scripts/capture-guard b/.ai/scripts/capture-guard
index 52f4e06..6c01f2f 100755
--- a/.ai/scripts/capture-guard
+++ b/.ai/scripts/capture-guard
@@ -9,11 +9,21 @@
# 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 [TARGET_FILE] (default ~/org/roam/inbox.org)
-# 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.
+# 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
@@ -21,11 +31,14 @@
set -euo pipefail
-TARGET="${1:-$HOME/org/roam/inbox.org}"
+WAIT_TOTAL=0
+case "${1:-}" in
+ --wait) WAIT_TOTAL=30; shift ;;
+ --wait=*) WAIT_TOTAL="${1#--wait=}"; shift ;;
+esac
-# No Emacs to collide with → nothing to guard against.
-command -v emacsclient >/dev/null 2>&1 || exit 0
-emacsclient -e t >/dev/null 2>&1 || exit 0
+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
@@ -41,15 +54,38 @@ lisp='(let ((target (expand-file-name "'"$TARGET"'")))
(buffer-list))
","))'
-bufs="$(emacsclient -e "$lisp" 2>/dev/null)" || exit 0
+LAST_BUFS=""
-# emacsclient prints an elisp string with surrounding double quotes:
-# "" for none, "CAPTURE-inbox.org,..." for matches. Strip them.
-bufs="${bufs#\"}"
-bufs="${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
+}
-if [ -n "$bufs" ]; then
- echo "$bufs"
- exit 1
-fi
-exit 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
diff --git a/.ai/scripts/tests/capture-guard.bats b/.ai/scripts/tests/capture-guard.bats
index 2bfee61..31632a4 100644
--- a/.ai/scripts/tests/capture-guard.bats
+++ b/.ai/scripts/tests/capture-guard.bats
@@ -101,3 +101,30 @@ teardown() {
[ "$status" -eq 0 ]
[ -z "$output" ]
}
+
+# ---- --wait poll mode -----------------------------------------------
+
+@test "capture-guard --wait: returns 0 instantly when already safe (no sleep)" {
+ SECONDS=0
+ run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='""' \
+ "$BASH_BIN" "$SCRIPT" --wait
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+ [ "$SECONDS" -lt 2 ] # didn't poll-sleep
+}
+
+@test "capture-guard --wait=1: times out to exit 1 when persistently blocked" {
+ # Stub always reports the buffer, so it never clears — the short budget
+ # forces a timeout. Capped sleep keeps this near 1s.
+ run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='"CAPTURE-inbox.org"' \
+ "$BASH_BIN" "$SCRIPT" --wait=1
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"CAPTURE-inbox.org"* ]]
+}
+
+@test "capture-guard --wait=N accepts a target after the flag" {
+ run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='""' \
+ "$BASH_BIN" "$SCRIPT" --wait=1 "$TEST_DIR/some-other-inbox.org"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}