aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-x.ai/scripts/inbox-send.py41
-rwxr-xr-x.ai/scripts/inbox-status1
-rw-r--r--.ai/scripts/tests/inbox-status.bats12
-rw-r--r--.ai/scripts/tests/test_inbox_send.py72
-rwxr-xr-xclaude-templates/.ai/scripts/inbox-send.py41
-rwxr-xr-xclaude-templates/.ai/scripts/inbox-status1
-rw-r--r--claude-templates/.ai/scripts/tests/inbox-status.bats12
-rw-r--r--claude-templates/.ai/scripts/tests/test_inbox_send.py72
8 files changed, 246 insertions, 6 deletions
diff --git a/.ai/scripts/inbox-send.py b/.ai/scripts/inbox-send.py
index 1ebb636..5787a84 100755
--- a/.ai/scripts/inbox-send.py
+++ b/.ai/scripts/inbox-send.py
@@ -31,6 +31,7 @@ import os
import re
import shutil
import sys
+import tempfile
from datetime import datetime
from pathlib import Path
@@ -48,7 +49,7 @@ def resolve_roots() -> list[Path]:
config = Path.home() / ".claude" / "inbox-roots.txt"
if config.is_file():
paths: list[Path] = []
- for line in config.read_text().splitlines():
+ for line in config.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
paths.append(Path(line).expanduser())
@@ -194,6 +195,39 @@ def uniquify(dest: Path) -> Path:
n += 1
+def _atomic_write(dest: Path, writer) -> None:
+ """Write to a temp file in dest's directory, then rename it into place.
+
+ dest is another project's inbox/, and a direct write truncates the target
+ on open, so any mid-write failure (a full disk, an encoding error, an
+ interrupted process) leaves a zero-byte .org there. inbox-status counts
+ that phantom as a pending handoff and blocks a turn in the receiving
+ project over a file with no content and no sender (2026-07-23). Writing to
+ a temp sibling and os.replace-ing means the inbox only ever sees a complete
+ file. os.replace is atomic within one filesystem, and the temp sits in the
+ same directory as dest, so it is.
+
+ `writer` receives the open temp path and fills it. On any failure the temp
+ is removed and the error re-raised, so a caught error never leaves debris.
+ """
+ fd, tmp = tempfile.mkstemp(
+ dir=dest.parent, prefix=".inbox-send-", suffix=dest.suffix
+ )
+ os.close(fd)
+ tmp_path = Path(tmp)
+ # mkstemp creates the temp 0600; give the delivered file the umask-default
+ # mode the old direct write produced, so inbox files stay readable as before.
+ umask = os.umask(0)
+ os.umask(umask)
+ os.chmod(tmp_path, 0o666 & ~umask)
+ try:
+ writer(tmp_path)
+ os.replace(tmp_path, dest)
+ except BaseException:
+ tmp_path.unlink(missing_ok=True)
+ raise
+
+
def send_text(
target_inbox: Path,
message: str,
@@ -209,7 +243,8 @@ def send_text(
raise ValueError(f"could not derive a slug from text: {message!r}")
filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}.org"
dest = uniquify(target_inbox / filename)
- dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT)))
+ body = build_text_org(message, source_name, now.strftime(TS_DOC_FMT))
+ _atomic_write(dest, lambda p: p.write_text(body, encoding="utf-8"))
return dest
@@ -229,7 +264,7 @@ def send_file(
ext = src_path.suffix
filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}"
dest = uniquify(target_inbox / filename)
- shutil.copy2(src_path, dest)
+ _atomic_write(dest, lambda p: shutil.copyfile(src_path, p))
return dest
diff --git a/.ai/scripts/inbox-status b/.ai/scripts/inbox-status
index b917144..17031af 100755
--- a/.ai/scripts/inbox-status
+++ b/.ai/scripts/inbox-status
@@ -35,6 +35,7 @@ mapfile -t pending < <(find inbox -maxdepth 1 -type f \
! -name '.gitkeep' \
! -name 'lint-followups.org' \
! -name 'PROCESSED-*' \
+ ! -name '.inbox-send-*' \
-printf '%f\n' 2>/dev/null | sort)
n=${#pending[@]}
diff --git a/.ai/scripts/tests/inbox-status.bats b/.ai/scripts/tests/inbox-status.bats
index bc8a734..27a497e 100644
--- a/.ai/scripts/tests/inbox-status.bats
+++ b/.ai/scripts/tests/inbox-status.bats
@@ -45,6 +45,18 @@ teardown() {
[[ "$output" == *"0 pending"* ]]
}
+@test "inbox-status: ignores an in-flight .inbox-send-* temp file" {
+ mkdir "$TMP/inbox"
+ # inbox-send writes to a .inbox-send-* temp then renames it into place;
+ # during that window the temp must not read as a pending handoff, or a
+ # concurrent boundary check blocks on a file that's about to become real.
+ touch "$TMP/inbox/.inbox-send-abc123.org"
+ cd "$TMP"
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"0 pending"* ]]
+}
+
@test "inbox-status: -q suppresses the per-item lines" {
mkdir "$TMP/inbox"
echo body > "$TMP/inbox/handoff.org"
diff --git a/.ai/scripts/tests/test_inbox_send.py b/.ai/scripts/tests/test_inbox_send.py
index f75d7a1..02af23c 100644
--- a/.ai/scripts/tests/test_inbox_send.py
+++ b/.ai/scripts/tests/test_inbox_send.py
@@ -476,3 +476,75 @@ class TestFilenameCollisions:
assert len(files) == 2
bodies = "".join(f.read_text() for f in files)
assert "message one" in bodies and "message two" in bodies
+
+
+class TestAtomicWrite:
+ """A send wrote straight to the destination path in another project's
+ inbox/, and write_text truncates on open, so any mid-write failure left a
+ zero-byte .org there. inbox-status counts that phantom as a pending
+ handoff, blocking a turn in the receiving project over a file with no
+ content (2026-07-23). The write must be atomic: the inbox sees a complete
+ file or nothing."""
+
+ def test_send_text_writes_utf8(self, tmp_path):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ # An em dash and an accented char — both non-ASCII.
+ dest = mod.send_text(inbox, "accent café and dash — here", "src", None, now)
+ # Reading as utf-8 must round-trip; a locale-encoded write would raise
+ # under a C locale, and reading back proves the bytes are utf-8.
+ assert "—" in dest.read_text(encoding="utf-8")
+
+ def test_send_text_no_partial_on_write_failure(self, tmp_path, monkeypatch):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ # Force the atomic finalize to fail after the temp file is written.
+ def boom(*a, **k):
+ raise OSError("disk full")
+ monkeypatch.setattr(mod.os, "replace", boom)
+ with pytest.raises(OSError):
+ mod.send_text(inbox, "a message that should never half-land", "src", None, now)
+ # No phantom, no leftover temp: the inbox is empty.
+ assert list(inbox.iterdir()) == []
+
+ def test_send_text_leaves_no_temp_on_success(self, tmp_path):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ dest = mod.send_text(inbox, "clean send", "src", None, now)
+ assert list(inbox.iterdir()) == [dest]
+
+ def test_send_file_no_partial_on_write_failure(self, tmp_path, monkeypatch):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ src = tmp_path / "note.org"
+ src.write_text("body")
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ def boom(*a, **k):
+ raise OSError("disk full")
+ monkeypatch.setattr(mod.os, "replace", boom)
+ with pytest.raises(OSError):
+ mod.send_file(inbox, src, "src", None, now)
+ assert list(inbox.iterdir()) == []
+
+ def test_send_file_leaves_no_temp_on_success(self, tmp_path):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ src = tmp_path / "note.org"
+ src.write_text("payload")
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ dest = mod.send_file(inbox, src, "src", None, now)
+ assert list(inbox.iterdir()) == [dest]
+ assert dest.read_text() == "payload"
diff --git a/claude-templates/.ai/scripts/inbox-send.py b/claude-templates/.ai/scripts/inbox-send.py
index 1ebb636..5787a84 100755
--- a/claude-templates/.ai/scripts/inbox-send.py
+++ b/claude-templates/.ai/scripts/inbox-send.py
@@ -31,6 +31,7 @@ import os
import re
import shutil
import sys
+import tempfile
from datetime import datetime
from pathlib import Path
@@ -48,7 +49,7 @@ def resolve_roots() -> list[Path]:
config = Path.home() / ".claude" / "inbox-roots.txt"
if config.is_file():
paths: list[Path] = []
- for line in config.read_text().splitlines():
+ for line in config.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
paths.append(Path(line).expanduser())
@@ -194,6 +195,39 @@ def uniquify(dest: Path) -> Path:
n += 1
+def _atomic_write(dest: Path, writer) -> None:
+ """Write to a temp file in dest's directory, then rename it into place.
+
+ dest is another project's inbox/, and a direct write truncates the target
+ on open, so any mid-write failure (a full disk, an encoding error, an
+ interrupted process) leaves a zero-byte .org there. inbox-status counts
+ that phantom as a pending handoff and blocks a turn in the receiving
+ project over a file with no content and no sender (2026-07-23). Writing to
+ a temp sibling and os.replace-ing means the inbox only ever sees a complete
+ file. os.replace is atomic within one filesystem, and the temp sits in the
+ same directory as dest, so it is.
+
+ `writer` receives the open temp path and fills it. On any failure the temp
+ is removed and the error re-raised, so a caught error never leaves debris.
+ """
+ fd, tmp = tempfile.mkstemp(
+ dir=dest.parent, prefix=".inbox-send-", suffix=dest.suffix
+ )
+ os.close(fd)
+ tmp_path = Path(tmp)
+ # mkstemp creates the temp 0600; give the delivered file the umask-default
+ # mode the old direct write produced, so inbox files stay readable as before.
+ umask = os.umask(0)
+ os.umask(umask)
+ os.chmod(tmp_path, 0o666 & ~umask)
+ try:
+ writer(tmp_path)
+ os.replace(tmp_path, dest)
+ except BaseException:
+ tmp_path.unlink(missing_ok=True)
+ raise
+
+
def send_text(
target_inbox: Path,
message: str,
@@ -209,7 +243,8 @@ def send_text(
raise ValueError(f"could not derive a slug from text: {message!r}")
filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}.org"
dest = uniquify(target_inbox / filename)
- dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT)))
+ body = build_text_org(message, source_name, now.strftime(TS_DOC_FMT))
+ _atomic_write(dest, lambda p: p.write_text(body, encoding="utf-8"))
return dest
@@ -229,7 +264,7 @@ def send_file(
ext = src_path.suffix
filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}"
dest = uniquify(target_inbox / filename)
- shutil.copy2(src_path, dest)
+ _atomic_write(dest, lambda p: shutil.copyfile(src_path, p))
return dest
diff --git a/claude-templates/.ai/scripts/inbox-status b/claude-templates/.ai/scripts/inbox-status
index b917144..17031af 100755
--- a/claude-templates/.ai/scripts/inbox-status
+++ b/claude-templates/.ai/scripts/inbox-status
@@ -35,6 +35,7 @@ mapfile -t pending < <(find inbox -maxdepth 1 -type f \
! -name '.gitkeep' \
! -name 'lint-followups.org' \
! -name 'PROCESSED-*' \
+ ! -name '.inbox-send-*' \
-printf '%f\n' 2>/dev/null | sort)
n=${#pending[@]}
diff --git a/claude-templates/.ai/scripts/tests/inbox-status.bats b/claude-templates/.ai/scripts/tests/inbox-status.bats
index bc8a734..27a497e 100644
--- a/claude-templates/.ai/scripts/tests/inbox-status.bats
+++ b/claude-templates/.ai/scripts/tests/inbox-status.bats
@@ -45,6 +45,18 @@ teardown() {
[[ "$output" == *"0 pending"* ]]
}
+@test "inbox-status: ignores an in-flight .inbox-send-* temp file" {
+ mkdir "$TMP/inbox"
+ # inbox-send writes to a .inbox-send-* temp then renames it into place;
+ # during that window the temp must not read as a pending handoff, or a
+ # concurrent boundary check blocks on a file that's about to become real.
+ touch "$TMP/inbox/.inbox-send-abc123.org"
+ cd "$TMP"
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"0 pending"* ]]
+}
+
@test "inbox-status: -q suppresses the per-item lines" {
mkdir "$TMP/inbox"
echo body > "$TMP/inbox/handoff.org"
diff --git a/claude-templates/.ai/scripts/tests/test_inbox_send.py b/claude-templates/.ai/scripts/tests/test_inbox_send.py
index f75d7a1..02af23c 100644
--- a/claude-templates/.ai/scripts/tests/test_inbox_send.py
+++ b/claude-templates/.ai/scripts/tests/test_inbox_send.py
@@ -476,3 +476,75 @@ class TestFilenameCollisions:
assert len(files) == 2
bodies = "".join(f.read_text() for f in files)
assert "message one" in bodies and "message two" in bodies
+
+
+class TestAtomicWrite:
+ """A send wrote straight to the destination path in another project's
+ inbox/, and write_text truncates on open, so any mid-write failure left a
+ zero-byte .org there. inbox-status counts that phantom as a pending
+ handoff, blocking a turn in the receiving project over a file with no
+ content (2026-07-23). The write must be atomic: the inbox sees a complete
+ file or nothing."""
+
+ def test_send_text_writes_utf8(self, tmp_path):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ # An em dash and an accented char — both non-ASCII.
+ dest = mod.send_text(inbox, "accent café and dash — here", "src", None, now)
+ # Reading as utf-8 must round-trip; a locale-encoded write would raise
+ # under a C locale, and reading back proves the bytes are utf-8.
+ assert "—" in dest.read_text(encoding="utf-8")
+
+ def test_send_text_no_partial_on_write_failure(self, tmp_path, monkeypatch):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ # Force the atomic finalize to fail after the temp file is written.
+ def boom(*a, **k):
+ raise OSError("disk full")
+ monkeypatch.setattr(mod.os, "replace", boom)
+ with pytest.raises(OSError):
+ mod.send_text(inbox, "a message that should never half-land", "src", None, now)
+ # No phantom, no leftover temp: the inbox is empty.
+ assert list(inbox.iterdir()) == []
+
+ def test_send_text_leaves_no_temp_on_success(self, tmp_path):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ dest = mod.send_text(inbox, "clean send", "src", None, now)
+ assert list(inbox.iterdir()) == [dest]
+
+ def test_send_file_no_partial_on_write_failure(self, tmp_path, monkeypatch):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ src = tmp_path / "note.org"
+ src.write_text("body")
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ def boom(*a, **k):
+ raise OSError("disk full")
+ monkeypatch.setattr(mod.os, "replace", boom)
+ with pytest.raises(OSError):
+ mod.send_file(inbox, src, "src", None, now)
+ assert list(inbox.iterdir()) == []
+
+ def test_send_file_leaves_no_temp_on_success(self, tmp_path):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ src = tmp_path / "note.org"
+ src.write_text("payload")
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ dest = mod.send_file(inbox, src, "src", None, now)
+ assert list(inbox.iterdir()) == [dest]
+ assert dest.read_text() == "payload"