aboutsummaryrefslogtreecommitdiff
path: root/.ai
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-24 05:36:29 -0400
committerCraig Jennings <c@cjennings.net>2026-06-24 05:36:29 -0400
commit1eaec823a9174bb74596a5b008917cdddfc21e6d (patch)
tree9929109ffda3090f22d8cf26bc2a0145141386c4 /.ai
parent558624ebdba99e00ffa232421a8d65dd89052eef (diff)
downloadrulesets-1eaec823a9174bb74596a5b008917cdddfc21e6d.tar.gz
rulesets-1eaec823a9174bb74596a5b008917cdddfc21e6d.zip
feat(inbox): poll-and-retry the capture-guard instead of bouncing
When a roam edit hits a live org-capture, the guard used to bounce the caller right away (surface to the user, or skip the cycle) even though the capture is usually a few seconds of mid-finalize that clears on its own. capture-guard gets a --wait poll mode: it re-checks every ~10s up to a budget (default 30s, each sleep capped so a short --wait never overshoots), returns the instant the capture clears, and reports blocked only at the deadline. The no-capture common case still returns instantly without sleeping. Roam mode now uses --wait on every write, and the per-caller fallback fires only after the wait: an interactive run surfaces, the auto /loop defers to the next cycle (the loop cadence is the retry), wrap-up skips and self-heals. Surfaced live this session: a transient capture blocked a roam reconcile and had cleared a minute later. Covered by three new bats cases (instant-when-safe, timeout-when-blocked, target-after-flag). Claude-Session: https://claude.ai/code/session_017PtX1nt1rtYVATuzmzBS4f
Diffstat (limited to '.ai')
-rwxr-xr-x.ai/scripts/capture-guard74
-rw-r--r--.ai/scripts/tests/capture-guard.bats27
-rw-r--r--.ai/workflows/inbox.org19
3 files changed, 96 insertions, 24 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" ]
+}
diff --git a/.ai/workflows/inbox.org b/.ai/workflows/inbox.org
index 67f0dde..c442d17 100644
--- a/.ai/workflows/inbox.org
+++ b/.ai/workflows/inbox.org
@@ -137,11 +137,20 @@ Cross-project boundary: never act on a file under another project's =.ai/= scope
* Core §5 — Capture-guard before a roam write
-Before *any* read-modify-write of =~/org/roam/inbox.org=, run =.ai/scripts/capture-guard "$HOME/org/roam/inbox.org"=. This runs first because the Phase D edit rewrites the file on disk, and editing underneath a live capture wedges it just as a stray hand edit would.
+Before *any* read-modify-write of =~/org/roam/inbox.org=, run the capture-guard. This runs first because the Phase D edit rewrites the file on disk, and editing underneath a live capture wedges it just as a stray 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 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:
+*Wait-and-retry, not bounce.* Use the poll mode so a *transient* capture clears itself instead of immediately kicking the work back to the caller:
+
+#+begin_src bash
+.ai/scripts/capture-guard --wait "$HOME/org/roam/inbox.org"
+#+end_src
+
+An org capture is usually only a few seconds of mid-finalize state, so =--wait= (default 30s, re-checking every ~10s) returns the instant it clears and reports blocked only if it's *still* open at the deadline. The common case — nothing capturing — returns instantly without sleeping. This is the fix for the guard bouncing a caller over a capture that would have cleared on its own a moment later. (The bare single-shot form — no =--wait= — stays available for a caller that genuinely must not block.)
+
+- *Exit 0* → no live capture, or it cleared during the wait (or no reachable Emacs). Proceed with the edit.
+- *Exit 1* → an indirect org-capture buffer is *still* cloned from the roam inbox after the wait (the script prints the offending buffer name). Editing underneath it would leave the capture pointing at stale state and unable to finalize with =C-c C-c= (see =emacs.md=). Only now does the per-caller fallback fire:
- *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.
+ - *Auto inbox zero (=/loop=) cycle* → don't surface or wait further; defer the roam reconcile to the next cycle, which is itself the retry at loop cadence. The items were already filed in Phase C, so the next cycle's Phase C status-check drops the duplicates and its Phase D removes them. Note one line: "roam reconcile deferred — a capture is still open; next cycle catches it."
- *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 roam mode Phase C, so the next roam run's Phase C status-check drops the duplicates and its Phase D removes them — the skip self-heals.
* Core §6 — Priority-scheme check
@@ -397,7 +406,7 @@ Apply the core disposition discipline against the project's =todo.org=; don't re
The roam inbox lives in a git repo (=~/org/roam=, auto-synced every 15 minutes by the =roam-sync= timer). Craig captures into it constantly, so its working tree is dirty most of the time — which is exactly why this mode never runs =git pull= itself. A pull on a dirty tree fails, and that would block triage on nearly every run. Instead, edit the file and hand the git work to =roam-sync=, which already commits-first-then-rebases and so handles the dirty tree correctly.
-1. *Guard against a live org-capture session* — run the capture-guard (core §5) before the edit. On exit 1 the caller-specific behavior (interactive stop-and-surface vs wrap-up skip-and-self-heal) is in core §5.
+1. *Guard against a live org-capture session* — run the capture-guard in poll mode (=capture-guard --wait=, core §5) before the edit, so a transient capture clears itself rather than bouncing the run. On a still-blocked exit 1 the caller-specific fallback (interactive stop-and-surface / auto-loop defer-to-next-cycle / wrap-up skip-and-self-heal) is in core §5.
2. *Remove the claimed items and the empty entries* from the working-tree file. Never touch foreign or unowned (titled) items. Empty entries (Phase B's =empty= bucket) are removed on every triage regardless of who would own a titled version, since an aborted capture belongs to nobody. The claimed-item removal and the empty sweep happen in the same edit.
3. *Hand the commit + push to =roam-sync=.* Don't =git pull=, =git commit=, or =git push= here. Trigger the sync so the edit lands promptly rather than waiting up to 15 minutes for the next timer tick:
@@ -446,7 +455,7 @@ A recurring, *interactive* roam check. Trigger phrase: "auto inbox zero" (match
** Per cycle
-1. Run roam mode's scan (Phase A local check + Phase B roam scan), read-only — no =git pull=. The capture-guard (core §5) still gates any write, and the rare write hands its git to =roam-sync= (roam Phase D).
+1. Run roam mode's scan (Phase A local check + Phase B roam scan), read-only — no =git pull=. The capture-guard still gates any write: use =capture-guard --wait= (core §5) so a transient capture clears itself; if it's still open after the wait, *defer this cycle's roam reconcile to the next cycle* rather than surfacing — the loop cadence is the retry, and the filed items get swept next time. The rare write hands its git to =roam-sync= (roam Phase D).
2. *Nothing found* → no inbox summary. One acknowledgement line: =ran at HH:MM, nothing found=. Nothing else. The acknowledge-only-on-empty rule keeps a quiet inbox quiet.
3. *Items found* → summarize the found items, file them as tasks (roam Phase C), and *append them to a displayed queue* — the harness task list, via =TaskCreate= — so the queue accumulates across cycles. Then ask: "run this batch next?"
- *Yes* → launch into implementing the found items, each through the normal disposition ladder (core §3) + verify flow.