aboutsummaryrefslogtreecommitdiff
path: root/claude-templates
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-23 21:00:11 -0400
committerCraig Jennings <c@cjennings.net>2026-06-23 21:00:11 -0400
commit603abc4cb3129be8bd23c89aa69f4f5522d1e5a3 (patch)
tree356c6fc1343e4564d63b91b269aa8f65c1a40236 /claude-templates
parentd961c783d18c6178751b338ef1d8dd6a72db9f20 (diff)
downloadrulesets-603abc4cb3129be8bd23c89aa69f4f5522d1e5a3.tar.gz
rulesets-603abc4cb3129be8bd23c89aa69f4f5522d1e5a3.zip
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.
Diffstat (limited to 'claude-templates')
-rwxr-xr-xclaude-templates/.ai/scripts/capture-guard55
-rw-r--r--claude-templates/.ai/scripts/tests/capture-guard.bats103
-rw-r--r--claude-templates/.ai/workflows/inbox-zero.org11
3 files changed, 166 insertions, 3 deletions
diff --git a/claude-templates/.ai/scripts/capture-guard b/claude-templates/.ai/scripts/capture-guard
new file mode 100755
index 0000000..1958309
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/capture-guard.bats b/claude-templates/.ai/scripts/tests/capture-guard.bats
new file mode 100644
index 0000000..12ecb83
--- /dev/null
+++ b/claude-templates/.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" ]
+}
diff --git a/claude-templates/.ai/workflows/inbox-zero.org b/claude-templates/.ai/workflows/inbox-zero.org
index 4da27bd..5979ec0 100644
--- a/claude-templates/.ai/workflows/inbox-zero.org
+++ b/claude-templates/.ai/workflows/inbox-zero.org
@@ -71,9 +71,14 @@ Apply =process-inbox.org='s discipline against the project's =todo.org=; don't r
The roam inbox lives in a git repo (=~/org/roam=, auto-synced by the =roam-sync= timer). Edit it carefully:
-1. *Pull first* (=git -C ~/org/roam pull --ff-only=). If it can't fast-forward (dirty tree, divergence), surface and stop. Don't auto-stash, auto-merge, or force. Resolve before removing items.
-2. *Remove only the claimed items.* Never touch foreign or unowned items.
-3. *Commit the roam repo as its own commit* (separate from any project wrap commit): =chore(inbox): route <project> tasks to <project>/todo.org=. Push, or leave for the =roam-sync= timer. Surface a blocked push; don't force.
+1. *Guard against a live org-capture session* — before any read-modify-write of the roam inbox, run =.ai/scripts/capture-guard "$HOME/org/roam/inbox.org"=. This runs first because the pull in step 2 *also* rewrites the file on disk, and a fast-forward landing underneath a live capture wedges it just as a hand edit would.
+ - *Exit 0* → no live capture (or no reachable Emacs). Proceed.
+ - *Exit 1* → an indirect org-capture buffer is cloned from the roam inbox (the script prints the offending buffer name). Editing or fast-forwarding the file underneath it would leave the capture pointing at stale state and unable to finalize with =C-c C-c= (see =emacs.md=). Behavior depends on the caller:
+ - *On-demand / interactive run* → stop and surface: "You have a live org-capture session open against the roam inbox (=<buffer>=) — finalize it (=C-c C-c=) or abort it (=C-c C-k=) and I'll continue." Re-run the guard and resume once it returns clean.
+ - *Wrap-up sub-step* → don't block the wrap. Skip the roam reconcile for this run and surface one line: "Skipped roam-inbox reconcile — a live org-capture is open against it; claimed items stay and get caught next run." The items were already filed into =todo.org= in Phase C, so the next inbox-zero run's Phase C status-check drops the duplicates and its Phase D removes them from the roam inbox — the skip self-heals, it doesn't lose anything.
+2. *Pull first* (=git -C ~/org/roam pull --ff-only=). If it can't fast-forward (dirty tree, divergence), surface and stop. Don't auto-stash, auto-merge, or force. Resolve before removing items.
+3. *Remove only the claimed items.* Never touch foreign or unowned items.
+4. *Commit the roam repo as its own commit* (separate from any project wrap commit): =chore(inbox): route <project> tasks to <project>/todo.org=. Push, or leave for the =roam-sync= timer. Surface a blocked push; don't force.
* Phase E — Surface