diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-23 21:00:11 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-23 21:00:11 -0400 |
| commit | 603abc4cb3129be8bd23c89aa69f4f5522d1e5a3 (patch) | |
| tree | 356c6fc1343e4564d63b91b269aa8f65c1a40236 | |
| parent | d961c783d18c6178751b338ef1d8dd6a72db9f20 (diff) | |
| download | rulesets-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.
| -rwxr-xr-x | .ai/scripts/capture-guard | 55 | ||||
| -rw-r--r-- | .ai/scripts/tests/capture-guard.bats | 103 | ||||
| -rw-r--r-- | .ai/workflows/inbox-zero.org | 11 | ||||
| -rw-r--r-- | claude-rules/emacs.md | 6 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/capture-guard | 55 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/capture-guard.bats | 103 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/inbox-zero.org | 11 | ||||
| -rw-r--r-- | docs/design/2026-06-22-inbox-zero-capture-hardening.org | 39 |
8 files changed, 377 insertions, 6 deletions
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" ] +} diff --git a/.ai/workflows/inbox-zero.org b/.ai/workflows/inbox-zero.org index 4da27bd..5979ec0 100644 --- a/.ai/workflows/inbox-zero.org +++ b/.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 diff --git a/claude-rules/emacs.md b/claude-rules/emacs.md index 702b40e..907a981 100644 --- a/claude-rules/emacs.md +++ b/claude-rules/emacs.md @@ -27,3 +27,9 @@ This re-evaluates the file and redefines its `defun`s live. For straight functio 3. Verify: for visual changes, screenshot and read it (the `screenshot.py` tool under `.ai/scripts/` can capture an app off-screen on a headless output); for behavior, eval or exercise it. This replaces the quit → relaunch → re-find-and-load-files cycle for most edits. A real restart stays the gold standard for a guaranteed-clean state — anything touching `:config`, load order, or when in doubt. + +## Don't edit on disk a file the daemon is capturing into + +The reload caveats above are about pushing changes *into* the daemon. The inverse hazard: a tool that edits a file *on disk* while the daemon has an indirect buffer cloned from it. org-capture works through such a buffer, and a disk write (a hand edit, a `git pull` that fast-forwards the file, a `sed`/Write) reverts the base buffer underneath the capture. The capture is left on stale state, can no longer finalize with `C-c C-c`, and a freshly-typed item can be lost or written back against post-edit content. Orphaned `CAPTURE-*` buffers piling up as Craig retries is the visible symptom. + +The roam inbox (`~/org/roam/inbox.org`) is the live case — Craig captures into it constantly, and inbox-zero's Phase D edits it. Before a disk write to a file the daemon may be capturing into, check first: `.ai/scripts/capture-guard <file>` exits non-zero (and names the buffer) when a live capture is cloned from `<file>`, and exits 0 — safe — when there's no capture or no reachable Emacs. Same principle as the reload rule, one layer out: leave the daemon's live buffers authoritative rather than yanking the file from under them. 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 diff --git a/docs/design/2026-06-22-inbox-zero-capture-hardening.org b/docs/design/2026-06-22-inbox-zero-capture-hardening.org new file mode 100644 index 0000000..69acf94 --- /dev/null +++ b/docs/design/2026-06-22-inbox-zero-capture-hardening.org @@ -0,0 +1,39 @@ +#+TITLE: inbox-zero Phase D wedges live org-capture sessions on the roam inbox + +* The bug + +Phase D of =inbox-zero.org= removes claimed items from =~/org/roam/inbox.org= by editing the file on disk (Edit / sed / Write). That collides with any live org-capture session Craig has open against the same file. + +org-capture works through an *indirect buffer* cloned from the target file. When the inbox-zero disk write lands and Emacs reverts the main =inbox.org= buffer underneath, the indirect capture buffers are left pointing at stale state and wedge — they can no longer finalize cleanly with =C-c C-c=. The visible symptom is org-capture failing, and one or more orphaned =CAPTURE-*inbox.org= / =CAPTURE-N-inbox.org= buffers piling up as Craig retries. + +Hit live on 2026-06-22 during a home-session inbox-zero: I filed three home items, wrote =inbox.org= on disk, and Craig's open capture wedged, leaving two orphaned =CAPTURE-inbox.org= buffers. No data was lost that time (the orphaned buffers held only existing file content, not a freshly-typed item), but that was luck — had he typed an item into the capture before it wedged, finalizing the stale buffer afterward would have written it back against the post-edit file and could have clobbered the routing or a foreign item. + +* Why it's worth fixing in the canonical + +=inbox-zero.org= is a rulesets-owned synced workflow that runs in every project (startup nudge, wrap-up sub-step, on demand), and the roam inbox is the single shared file all of them edit. Craig edits that same file live in Emacs and captures into it constantly. So the collision window recurs in every project, every session — not a home-only quirk. A local fix in home gets reverted by the next template sync, so the durable fix has to land in rulesets. + +* Proposed fix (recommended: guard before the disk write) + +Add a pre-edit guard to Phase D, before removing any claimed items: + +1. If Emacs is reachable (=emacsclient -e t= succeeds), check for live capture buffers targeting the roam inbox: + + #+begin_src bash + emacsclient -e '(mapcar #(quote buffer-name) + (seq-filter (lambda (b) (string-match-p "CAPTURE.*inbox" (buffer-name b))) + (buffer-list)))' 2>/dev/null + #+end_src + +2. If any =CAPTURE-*inbox*= buffer exists, *stop before editing* and surface it: "You have a live org-capture session open against the roam inbox — finalize (=C-c C-c=) or abort (=C-c C-k=) it before I route items, otherwise the edit will wedge the capture." Resume Phase D only once it's clear. This mirrors the existing pull-before-edit / surface-and-stop discipline already in Phase D. + +3. Independently, when Emacs has =inbox.org= open and *unmodified* (the common case, no live capture), the disk edit is benign — Emacs reverts a clean buffer without complaint. Optionally trigger an explicit =revert-buffer= via emacsclient afterward so the buffer is immediately consistent rather than lazily on next focus. + +* Alternative (heavier): do the removal through Emacs when it's running + +Instead of editing on disk, when Emacs is reachable, perform the claimed-item removal inside the running daemon (find the buffer, delete the items, save), and fall back to the disk edit only when Emacs isn't running. This keeps Emacs's buffer authoritative and sidesteps the disk/buffer divergence entirely. It's more code and more failure surface for arbitrary item removal, so I'd lean on the guard above unless you want the stronger guarantee. + +* Note for whoever builds it + +The =emacs.md= rule already covers "don't make Craig restart Emacs; push changes into the running daemon." This is the same principle one layer out: don't edit a file *on disk* that the running daemon is actively editing/capturing into. Worth a line in =emacs.md= too, or at least a cross-reference from inbox-zero Phase D. + +Origin: home, 2026-06-22. |
