diff options
Diffstat (limited to '.ai/scripts')
| -rwxr-xr-x | .ai/scripts/capture-guard | 74 | ||||
| -rw-r--r-- | .ai/scripts/tests/capture-guard.bats | 27 |
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" ] +} |
