From 603abc4cb3129be8bd23c89aa69f4f5522d1e5a3 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 23 Jun 2026 21:00:11 -0400 Subject: feat(inbox-zero): guard roam-inbox writes against live org-capture Editing the roam inbox on disk while Emacs has an indirect org-capture buffer cloned from it reverts the base buffer under the capture: the capture can't finalize with C-c C-c, and a freshly-typed item can be lost. inbox-zero Phase D edits that file, which Craig captures into constantly, so the collision recurs every session. I added a capture-guard helper that asks the running daemon whether any CAPTURE buffer's base buffer visits a given file (file-equal-p, so symlinks and path spelling don't matter), exiting non-zero with the names when so. No reachable Emacs or no capture means exit 0, so it never blocks a write that was safe. Phase D calls it before the pull, not only before the remove, because the ff-only pull also rewrites the file on disk and would wedge a capture the same way. On a collision an on-demand run stops and asks Craig to finalize or abort. The wrap-up sub-step skips the roam reconcile without blocking the wrap, since the items are already filed and the next run reclaims them. emacs.md gains the inverse of the reload rule: don't yank a file out from under the daemon's live buffers. --- .ai/scripts/capture-guard | 55 +++++++++++++++++++ .ai/scripts/tests/capture-guard.bats | 103 +++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100755 .ai/scripts/capture-guard create mode 100644 .ai/scripts/tests/capture-guard.bats (limited to '.ai/scripts') diff --git a/.ai/scripts/capture-guard b/.ai/scripts/capture-guard new file mode 100755 index 0000000..1958309 --- /dev/null +++ b/.ai/scripts/capture-guard @@ -0,0 +1,55 @@ +#!/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-zero +# 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. +# +# 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 + +TARGET="${1:-$HOME/org/roam/inbox.org}" + +# 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 + +# 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)) + ","))' + +bufs="$(emacsclient -e "$lisp" 2>/dev/null)" || exit 0 + +# emacsclient prints an elisp string with surrounding double quotes: +# "" for none, "CAPTURE-inbox.org,..." for matches. Strip them. +bufs="${bufs#\"}" +bufs="${bufs%\"}" + +if [ -n "$bufs" ]; then + echo "$bufs" + exit 1 +fi +exit 0 diff --git a/.ai/scripts/tests/capture-guard.bats b/.ai/scripts/tests/capture-guard.bats new file mode 100644 index 0000000..12ecb83 --- /dev/null +++ b/.ai/scripts/tests/capture-guard.bats @@ -0,0 +1,103 @@ +#!/usr/bin/env bats +# +# Tests for claude-templates/.ai/scripts/capture-guard — detects live +# org-capture buffers visiting a target file before a workflow edits that +# file on disk (the roam inbox, in inbox-zero Phase D). Editing the file +# underneath an indirect org-capture buffer wedges the capture (see emacs.md). +# +# Contract under test: +# capture-guard [TARGET_FILE] (default TARGET_FILE = ~/org/roam/inbox.org) +# exit 0 → safe to edit: emacsclient absent, daemon unreachable, or no +# capture buffer visits TARGET_FILE. +# exit 1 → a live capture buffer visits TARGET_FILE; its name(s) printed. +# +# Strategy: the emacsclient boundary is mocked with a PATH stub. The stub +# answers the reachability probe (`-e t`) per STUB_REACHABLE and returns a +# canned, real-emacsclient-shaped result (quoted string) for the buffer query +# per STUB_BUFS. The script's own quote-stripping and exit logic is the code +# under test; the file-equal-p precision is real-Emacs behavior we trust. + +SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/capture-guard" +BASH_BIN="$(command -v bash)" + +setup() { + TEST_DIR="$(mktemp -d -t capture-guard-bats.XXXXXX)" + STUB_DIR="$TEST_DIR/bin" + mkdir -p "$STUB_DIR" + + cat > "$STUB_DIR/emacsclient" <<'STUB' +#!/usr/bin/env bash +# Mock emacsclient. `-e t` is the reachability probe; anything else is the +# buffer query, answered with the real-emacsclient-shaped quoted string. +expr="$2" +if [ "$expr" = "t" ]; then + [ "${STUB_REACHABLE:-1}" = "1" ] && { echo t; exit 0; } + exit 1 +fi +printf '%s\n' "${STUB_BUFS:-\"\"}" +exit 0 +STUB + chmod +x "$STUB_DIR/emacsclient" + + EMPTY_DIR="$TEST_DIR/empty" + mkdir -p "$EMPTY_DIR" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# ---- Safe-to-edit (exit 0) cases ------------------------------------ + +@test "capture-guard: emacsclient absent is safe (exit 0, no output)" { + run env PATH="$EMPTY_DIR" "$BASH_BIN" "$SCRIPT" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "capture-guard: daemon unreachable is safe (exit 0)" { + run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=0 "$BASH_BIN" "$SCRIPT" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "capture-guard: reachable with no capture buffers is safe (exit 0)" { + run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='""' "$BASH_BIN" "$SCRIPT" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# ---- Blocked (exit 1) cases ----------------------------------------- + +@test "capture-guard: one live capture buffer blocks (exit 1, name printed)" { + run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='"CAPTURE-inbox.org"' \ + "$BASH_BIN" "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"CAPTURE-inbox.org"* ]] +} + +@test "capture-guard: multiple live capture buffers all reported (exit 1)" { + run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 \ + STUB_BUFS='"CAPTURE-inbox.org,CAPTURE-2-inbox.org"' \ + "$BASH_BIN" "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"CAPTURE-inbox.org"* ]] + [[ "$output" == *"CAPTURE-2-inbox.org"* ]] +} + +@test "capture-guard: blocked output does not contain stray surrounding quotes" { + run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='"CAPTURE-inbox.org"' \ + "$BASH_BIN" "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" != \"* ]] + [[ "$output" != *\" ]] +} + +# ---- Argument handling ---------------------------------------------- + +@test "capture-guard: accepts an explicit target-file argument" { + run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='""' \ + "$BASH_BIN" "$SCRIPT" "$TEST_DIR/some-other-inbox.org" + [ "$status" -eq 0 ] + [ -z "$output" ] +} -- cgit v1.2.3