aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-15 16:56:39 -0500
committerCraig Jennings <c@cjennings.net>2026-05-15 16:56:39 -0500
commit69c5e4ace81586c05dea6a9a3afd54dafa61a73b (patch)
tree6deab67c2a373736d5095ae9b8373c3df2add7a7 /.ai/scripts
downloadrulesets-69c5e4ace81586c05dea6a9a3afd54dafa61a73b.tar.gz
rulesets-69c5e4ace81586c05dea6a9a3afd54dafa61a73b.zip
Squashed 'claude-templates/' content from commit f116888
git-subtree-dir: claude-templates git-subtree-split: f1168885580e9197dcf57b57644eb576fdca2ab1
Diffstat (limited to '.ai/scripts')
-rw-r--r--.ai/scripts/cj-remove-block.py101
-rw-r--r--.ai/scripts/cj-scan.py162
-rwxr-xr-x.ai/scripts/cmail-action.py387
-rwxr-xr-x.ai/scripts/cross-agent-comms/cross-agent-discover230
-rw-r--r--.ai/scripts/cross-agent-comms/cross-agent-discover.md155
-rwxr-xr-x.ai/scripts/cross-agent-comms/cross-agent-halt134
-rw-r--r--.ai/scripts/cross-agent-comms/cross-agent-halt.md134
-rwxr-xr-x.ai/scripts/cross-agent-comms/cross-agent-recv250
-rw-r--r--.ai/scripts/cross-agent-comms/cross-agent-recv.md218
-rwxr-xr-x.ai/scripts/cross-agent-comms/cross-agent-resume145
-rw-r--r--.ai/scripts/cross-agent-comms/cross-agent-resume.md117
-rwxr-xr-x.ai/scripts/cross-agent-comms/cross-agent-send356
-rw-r--r--.ai/scripts/cross-agent-comms/cross-agent-send.md199
-rwxr-xr-x.ai/scripts/cross-agent-comms/cross-agent-status185
-rw-r--r--.ai/scripts/cross-agent-comms/cross-agent-status.md139
-rwxr-xr-x.ai/scripts/cross-agent-comms/cross-agent-watch106
-rw-r--r--.ai/scripts/cross-agent-comms/cross-agent-watch.md128
-rw-r--r--.ai/scripts/daily-prep-agenda.el142
-rw-r--r--.ai/scripts/eml-view-and-extract-attachments-readme.org47
-rw-r--r--.ai/scripts/eml-view-and-extract-attachments.py410
-rwxr-xr-x.ai/scripts/gmail-fetch-attachments.py180
-rw-r--r--.ai/scripts/inbox-send.py262
-rw-r--r--.ai/scripts/lint-org.el365
-rwxr-xr-x.ai/scripts/maildir-flag-manager.py351
-rw-r--r--.ai/scripts/tests/conftest.py77
-rw-r--r--.ai/scripts/tests/fixtures/duplicate-attachment-names.eml36
-rw-r--r--.ai/scripts/tests/fixtures/empty-body.eml16
-rw-r--r--.ai/scripts/tests/fixtures/html-only.eml20
-rw-r--r--.ai/scripts/tests/fixtures/multiple-received-headers.eml12
-rw-r--r--.ai/scripts/tests/fixtures/no-received-headers.eml9
-rw-r--r--.ai/scripts/tests/fixtures/plain-text.eml15
-rw-r--r--.ai/scripts/tests/fixtures/todo-sample.org37
-rw-r--r--.ai/scripts/tests/fixtures/with-attachment.eml27
-rw-r--r--.ai/scripts/tests/test-lint-org.el465
-rw-r--r--.ai/scripts/tests/test-todo-cleanup.el518
-rw-r--r--.ai/scripts/tests/test_cj_remove_block.py157
-rw-r--r--.ai/scripts/tests/test_cj_scan.py250
-rw-r--r--.ai/scripts/tests/test_cmail_action.py669
-rw-r--r--.ai/scripts/tests/test_cross_agent_discover.py204
-rw-r--r--.ai/scripts/tests/test_cross_agent_halt.py204
-rw-r--r--.ai/scripts/tests/test_cross_agent_recv.py176
-rw-r--r--.ai/scripts/tests/test_cross_agent_send.py210
-rw-r--r--.ai/scripts/tests/test_cross_agent_status.py165
-rw-r--r--.ai/scripts/tests/test_cross_agent_watch.py155
-rw-r--r--.ai/scripts/tests/test_extract_body.py96
-rw-r--r--.ai/scripts/tests/test_extract_metadata.py65
-rw-r--r--.ai/scripts/tests/test_generate_filenames.py157
-rw-r--r--.ai/scripts/tests/test_gmail_fetch_attachments.py420
-rw-r--r--.ai/scripts/tests/test_inbox_send.py329
-rw-r--r--.ai/scripts/tests/test_integration_stdout.py68
-rw-r--r--.ai/scripts/tests/test_maildir_flag_manager.py310
-rw-r--r--.ai/scripts/tests/test_parse_received_headers.py105
-rw-r--r--.ai/scripts/tests/test_process_eml.py162
-rw-r--r--.ai/scripts/tests/test_save_attachments.py97
-rw-r--r--.ai/scripts/todo-cleanup.el514
55 files changed, 10648 insertions, 0 deletions
diff --git a/.ai/scripts/cj-remove-block.py b/.ai/scripts/cj-remove-block.py
new file mode 100644
index 0000000..71c7b3d
--- /dev/null
+++ b/.ai/scripts/cj-remove-block.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+"""cj-remove-block — Remove a cj annotation block from an org file by line range.
+
+Idempotently deletes lines [start, end] (1-indexed, inclusive) from the file,
+but only after validating that those lines actually look like a cj annotation
+(either a `#+begin_src cj: ... #+end_src` fence pair or a single `cj:` line).
+The validation step is the point — it protects against accidentally trimming
+the wrong block when line numbers drift between a `cj-scan` call and a remove call.
+
+Usage:
+ cj-remove-block --file FILE.org --start N --end M
+
+Companion to the /respond-to-cj-comments skill and to cj-scan.py.
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+SRC_OPEN_RE = re.compile(r"^\s*#\+begin_src\s+cj:", re.IGNORECASE)
+SRC_CLOSE_RE = re.compile(r"^\s*#\+end_src\s*$", re.IGNORECASE)
+LEGACY_CJ_RE = re.compile(r"^\s*cj:\s")
+
+
+def looks_like_cj_range(lines: list[str], start: int, end: int) -> tuple[bool, str]:
+ """Return (ok, reason). Validates start..end (1-indexed, inclusive) is a cj range."""
+ if end < start:
+ return False, f"Range end ({end}) is before start ({start})"
+ if start < 1 or end > len(lines):
+ return False, (
+ f"Range {start}..{end} is out of bounds for a file with {len(lines)} lines"
+ )
+
+ first = lines[start - 1]
+ last = lines[end - 1]
+
+ if start == end:
+ # Single-line removal must look like legacy inline.
+ if LEGACY_CJ_RE.match(first):
+ return True, ""
+ return False, (
+ f"Line {start} does not look like a legacy inline cj: line "
+ f"(got: {first[:60]!r})"
+ )
+
+ # Multi-line removal must look like a source-block fence pair.
+ if not SRC_OPEN_RE.match(first):
+ return False, (
+ f"Line {start} does not look like a #+begin_src cj: opening fence "
+ f"(got: {first[:60]!r})"
+ )
+ if not SRC_CLOSE_RE.match(last):
+ return False, (
+ f"Line {end} does not look like a #+end_src closing fence "
+ f"(got: {last[:60]!r})"
+ )
+ return True, ""
+
+
+def remove_range(path: Path, start: int, end: int) -> None:
+ """Read path, validate range looks like cj content, remove the range, write back."""
+ text = path.read_text()
+ had_trailing_newline = text.endswith("\n")
+ lines = text.splitlines(keepends=False)
+
+ ok, reason = looks_like_cj_range(lines, start, end)
+ if not ok:
+ print(f"cj-remove-block: refusing to remove — {reason}", file=sys.stderr)
+ sys.exit(1)
+
+ new_lines = lines[: start - 1] + lines[end:]
+ new_text = "\n".join(new_lines)
+ if new_lines and had_trailing_newline:
+ new_text += "\n"
+ elif not new_lines and had_trailing_newline:
+ new_text = ""
+ path.write_text(new_text)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Remove a cj annotation block from an org file by line range.",
+ )
+ parser.add_argument("--file", required=True, type=Path, help="Path to the org file.")
+ parser.add_argument("--start", required=True, type=int, help="Start line (1-indexed, inclusive).")
+ parser.add_argument("--end", required=True, type=int, help="End line (1-indexed, inclusive).")
+ args = parser.parse_args()
+
+ if not args.file.is_file():
+ print(f"Not a file: {args.file}", file=sys.stderr)
+ return 2
+
+ remove_range(args.file, args.start, args.end)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/cj-scan.py b/.ai/scripts/cj-scan.py
new file mode 100644
index 0000000..54e2bf9
--- /dev/null
+++ b/.ai/scripts/cj-scan.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+"""cj-scan — Parse an org file for cj annotations and VERIFY-placement audit.
+
+Output: JSON to stdout with three top-level keys:
+- cj_blocks: every cj annotation found (source-block or legacy-inline form)
+- verify_tasks: every VERIFY heading with placement validity + suggested promotion target
+- unclosed_blocks: any source-block fence that opened but never closed
+
+Usage:
+ cj-scan FILE.org
+
+Companion to the /respond-to-cj-comments skill — the skill calls this script
+to get a single structured view of every cj annotation and every VERIFY
+placement violation in a single tool call, instead of stitching the picture
+together from multiple grep + Read round-trips.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+# VERIFY placement: top-level under a `*` section, or first-level child of a
+# `**` parent task. Anything else gets a promotion_target suggestion.
+VALID_VERIFY_DEPTHS = {2, 3}
+
+HEADING_RE = re.compile(r"^(\*+)\s+(.*)$")
+SRC_OPEN_RE = re.compile(r"^\s*#\+begin_src\s+cj:\s*(\S*)\s*$", re.IGNORECASE)
+SRC_CLOSE_RE = re.compile(r"^\s*#\+end_src\s*$", re.IGNORECASE)
+LEGACY_CJ_RE = re.compile(r"^\s*cj:\s*(.*)$")
+VERIFY_KEYWORD_RE = re.compile(r"^VERIFY(\s|\[|$)")
+
+
+@dataclass
+class HeadingFrame:
+ depth: int
+ heading: str
+
+
+def promotion_target(depth: int) -> int | None:
+ """Return the suggested target depth for a misplaced VERIFY, or None if valid."""
+ if depth in VALID_VERIFY_DEPTHS:
+ return None
+ if depth < 2:
+ return 2
+ return 3
+
+
+def is_verify_heading(heading_text: str) -> bool:
+ """True when heading text begins with the VERIFY keyword (optional priority cookie)."""
+ return bool(VERIFY_KEYWORD_RE.match(heading_text))
+
+
+def scan_file(path: Path) -> dict[str, object]:
+ """Scan an org file and return cj_blocks + verify_tasks + unclosed_blocks."""
+ cj_blocks: list[dict[str, object]] = []
+ verify_tasks: list[dict[str, object]] = []
+ unclosed_blocks: list[dict[str, object]] = []
+ heading_stack: list[HeadingFrame] = []
+
+ in_cj_block = False
+ block_start_line: int | None = None
+ block_label: str | None = None
+ block_body: list[str] = []
+
+ file_str = str(path)
+ lines = path.read_text().splitlines()
+
+ for lineno, line in enumerate(lines, start=1):
+ if in_cj_block:
+ if SRC_CLOSE_RE.match(line):
+ cj_blocks.append({
+ "file": file_str,
+ "form": "source-block",
+ "start_line": block_start_line,
+ "end_line": lineno,
+ "body": "\n".join(block_body),
+ "label": block_label,
+ "parent_heading_chain": [asdict(h) for h in heading_stack],
+ "parent_depth": heading_stack[-1].depth if heading_stack else 0,
+ })
+ in_cj_block = False
+ block_start_line = None
+ block_label = None
+ block_body = []
+ else:
+ block_body.append(line)
+ continue
+
+ m_heading = HEADING_RE.match(line)
+ if m_heading:
+ depth = len(m_heading.group(1))
+ heading_text = m_heading.group(2).strip()
+ # Pop frames at this depth or deeper before pushing the new one.
+ while heading_stack and heading_stack[-1].depth >= depth:
+ heading_stack.pop()
+ heading_stack.append(HeadingFrame(depth=depth, heading=heading_text))
+ if is_verify_heading(heading_text):
+ pt = promotion_target(depth)
+ verify_tasks.append({
+ "file": file_str,
+ "line": lineno,
+ "depth": depth,
+ "heading": heading_text,
+ "valid_depth": pt is None,
+ "promotion_target": pt,
+ })
+ continue
+
+ m_src_open = SRC_OPEN_RE.match(line)
+ if m_src_open:
+ in_cj_block = True
+ block_start_line = lineno
+ block_label = m_src_open.group(1) or None
+ block_body = []
+ continue
+
+ m_legacy = LEGACY_CJ_RE.match(line)
+ if m_legacy:
+ cj_blocks.append({
+ "file": file_str,
+ "form": "legacy-inline",
+ "start_line": lineno,
+ "end_line": lineno,
+ "body": m_legacy.group(1).strip(),
+ "parent_heading_chain": [asdict(h) for h in heading_stack],
+ "parent_depth": heading_stack[-1].depth if heading_stack else 0,
+ })
+
+ if in_cj_block:
+ unclosed_blocks.append({
+ "file": file_str,
+ "start_line": block_start_line,
+ "label": block_label,
+ })
+
+ return {
+ "cj_blocks": cj_blocks,
+ "verify_tasks": verify_tasks,
+ "unclosed_blocks": unclosed_blocks,
+ }
+
+
+def main() -> int:
+ if len(sys.argv) != 2:
+ print("Usage: cj-scan FILE.org", file=sys.stderr)
+ return 2
+ path = Path(sys.argv[1])
+ if not path.is_file():
+ print(f"Not a file: {path}", file=sys.stderr)
+ return 2
+ result = scan_file(path)
+ json.dump(result, sys.stdout, indent=2)
+ sys.stdout.write("\n")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/cmail-action.py b/.ai/scripts/cmail-action.py
new file mode 100755
index 0000000..10eb215
--- /dev/null
+++ b/.ai/scripts/cmail-action.py
@@ -0,0 +1,387 @@
+#!/usr/bin/env python3
+"""
+cmail-action — IMAP triage operations against Proton Mail Bridge.
+
+Mirrors the operations the Gmail MCP server provides for gmail/dmail
+(list-unread, read, mark-read, star, unstar, trash) so the
+process-unread-emails workflow can drive cmail end-to-end the same way.
+
+Connects to local Proton Bridge IMAP at 127.0.0.1:1143 with STARTTLS,
+using the Bridge-generated app password at ~/.config/.cmailpass and the
+Bridge self-signed certificate at ~/.config/protonbridge.pem. Cert CN
+is 127.0.0.1 but lacks a SubjectAltName, so hostname verification is
+disabled (connection is to localhost — verifying via the pinned cert
+is sufficient).
+
+IMAP -> Proton mapping:
+- \\Seen flag -> Read state
+- \\Flagged flag -> Starred label
+- MOVE to Trash -> Trash folder
+- COPY to label -> applies the label (Starred etc.)
+"""
+
+import argparse
+import email
+import imaplib
+import json
+import mimetypes
+import smtplib
+import ssl
+import sys
+from email.message import EmailMessage
+from email.policy import default as default_policy
+from pathlib import Path
+
+HOST = "127.0.0.1"
+PORT = 1143
+SMTP_PORT = 1025
+USER = "c@cjennings.net"
+PASS_FILE = Path.home() / ".config" / ".cmailpass"
+CERT_FILE = Path.home() / ".config" / "protonbridge.pem"
+
+INBOX = "INBOX"
+TRASH = "Trash"
+
+
+def connect():
+ if not PASS_FILE.is_file():
+ sys.exit(f"error: missing password file {PASS_FILE}")
+ if not CERT_FILE.is_file():
+ sys.exit(f"error: missing bridge cert {CERT_FILE}")
+ ctx = ssl.create_default_context(cafile=str(CERT_FILE))
+ ctx.check_hostname = False
+ try:
+ M = imaplib.IMAP4(HOST, PORT)
+ except OSError as e:
+ sys.exit(f"error: cannot reach Bridge at {HOST}:{PORT} ({e}). "
+ f"Is protonmail-bridge running? "
+ f"(systemctl --user status protonmail-bridge)")
+ M.starttls(ssl_context=ctx)
+ password = PASS_FILE.read_text().strip()
+ try:
+ M.login(USER, password)
+ except imaplib.IMAP4.error as e:
+ sys.exit(f"error: IMAP login failed for {USER}: {e}")
+ return M
+
+
+def _select(M, mailbox=INBOX, readonly=False):
+ typ, data = M.select(mailbox, readonly=readonly)
+ if typ != "OK":
+ sys.exit(f"error: cannot select {mailbox}: {data}")
+
+
+def _decode_header(value):
+ if value is None:
+ return ""
+ return str(value)
+
+
+def parse_fetch_metadata(meta):
+ """Parse FLAGS and RFC822.SIZE out of an IMAP FETCH metadata string.
+
+ Returns {"flags": str, "size": int | None}. Tolerates malformed input
+ (returns the defaults rather than raising).
+ """
+ result = {"flags": "", "size": None}
+ flags_idx = meta.find("FLAGS (")
+ if flags_idx != -1:
+ end = meta.find(")", flags_idx)
+ if end != -1:
+ result["flags"] = meta[flags_idx + 7:end]
+ # Tokenize with parens stripped so RFC822.SIZE matches whether or not
+ # it abuts an opening paren in the raw response (e.g. "(RFC822.SIZE 500)"
+ # would otherwise tokenize as "(RFC822.SIZE" and miss the equality check).
+ tokens = meta.replace("(", " ").replace(")", " ").split()
+ for i, p in enumerate(tokens):
+ if p == "RFC822.SIZE" and i + 1 < len(tokens):
+ try:
+ result["size"] = int(tokens[i + 1])
+ except ValueError:
+ pass
+ break
+ return result
+
+
+def extract_body(msg):
+ """Pick a printable body out of an email.message.EmailMessage.
+
+ Multipart: text/plain preferred, text/html fallback. Single-part:
+ returns content directly. Returns None if no body found.
+ """
+ if msg.is_multipart():
+ for part in msg.walk():
+ if part.get_content_type() == "text/plain":
+ return part.get_content()
+ for part in msg.walk():
+ if part.get_content_type() == "text/html":
+ return part.get_content()
+ return None
+ return msg.get_content()
+
+
+def build_message(from_addr, to_addr, subject, body, attachments=None):
+ """Construct an EmailMessage from the given fields and attachments.
+
+ attachments is a list of (filename, maintype, subtype, content_bytes)
+ tuples — typically the return value of load_attachment per file. Pure
+ function: no I/O, no SMTP.
+ """
+ msg = EmailMessage()
+ msg["From"] = from_addr
+ msg["To"] = to_addr
+ msg["Subject"] = subject
+ msg.set_content(body)
+ for filename, maintype, subtype, content in (attachments or []):
+ msg.add_attachment(content, maintype=maintype, subtype=subtype,
+ filename=filename)
+ return msg
+
+
+def load_attachment(path):
+ """Read a file and return (filename, maintype, subtype, content_bytes).
+
+ MIME type comes from mimetypes.guess_type; falls back to
+ application/octet-stream when guess returns None. Raises FileNotFoundError
+ for missing paths and IsADirectoryError for directories.
+ """
+ if not path.exists():
+ raise FileNotFoundError(f"attachment not found: {path}")
+ if path.is_dir():
+ raise IsADirectoryError(f"attachment path is a directory: {path}")
+ mime, _ = mimetypes.guess_type(path.name)
+ if mime is None:
+ maintype, subtype = "application", "octet-stream"
+ else:
+ maintype, subtype = mime.split("/", 1)
+ return (path.name, maintype, subtype, path.read_bytes())
+
+
+def smtp_connect():
+ """Connect to Proton Bridge's local SMTP submission endpoint.
+
+ Mirrors connect()'s pattern: STARTTLS against the pinned cert,
+ plaintext password from PASS_FILE. Skipped from unit tests for
+ the same reason connect() is — network + SSL + file I/O.
+ """
+ if not PASS_FILE.is_file():
+ sys.exit(f"error: missing password file {PASS_FILE}")
+ if not CERT_FILE.is_file():
+ sys.exit(f"error: missing bridge cert {CERT_FILE}")
+ ctx = ssl.create_default_context(cafile=str(CERT_FILE))
+ ctx.check_hostname = False
+ try:
+ smtp = smtplib.SMTP(HOST, SMTP_PORT)
+ except OSError as e:
+ sys.exit(f"error: cannot reach Bridge SMTP at {HOST}:{SMTP_PORT} ({e}). "
+ f"Is protonmail-bridge running?")
+ smtp.starttls(context=ctx)
+ password = PASS_FILE.read_text().strip()
+ try:
+ smtp.login(USER, password)
+ except smtplib.SMTPException as e:
+ sys.exit(f"error: SMTP login failed for {USER}: {e}")
+ return smtp
+
+
+def cmd_list_unread(args):
+ M = connect()
+ try:
+ _select(M, INBOX, readonly=True)
+ typ, data = M.uid("SEARCH", None, "UNSEEN")
+ if typ != "OK":
+ sys.exit(f"error: search failed: {data}")
+ uids = data[0].split() if data and data[0] else []
+ if args.limit and len(uids) > args.limit:
+ uids = uids[-args.limit:]
+ out = []
+ for uid in uids:
+ uid_s = uid.decode()
+ typ, data = M.uid(
+ "FETCH", uid,
+ "(BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)] "
+ "FLAGS RFC822.SIZE)"
+ )
+ if typ != "OK" or not data or not data[0]:
+ continue
+ # FLAGS / RFC822.SIZE may arrive in a non-tuple chunk after
+ # the BODY literal closes. Concatenate all chunks before
+ # parsing so the parser sees the full metadata.
+ hdr_raw = b""
+ meta_str = ""
+ for chunk in data:
+ if isinstance(chunk, tuple):
+ hdr_raw = chunk[1]
+ meta_str += chunk[0].decode("utf-8", errors="replace") + " "
+ elif isinstance(chunk, (bytes, bytearray)):
+ meta_str += chunk.decode("utf-8", errors="replace") + " "
+ parsed = parse_fetch_metadata(meta_str)
+ msg = email.message_from_bytes(hdr_raw, policy=default_policy)
+ out.append({
+ "uid": uid_s,
+ "from": _decode_header(msg.get("From")),
+ "to": _decode_header(msg.get("To")),
+ "subject": _decode_header(msg.get("Subject")),
+ "date": _decode_header(msg.get("Date")),
+ "flags": parsed["flags"],
+ "size": parsed["size"],
+ })
+ print(json.dumps(out, indent=2, ensure_ascii=False))
+ finally:
+ M.logout()
+
+
+def cmd_read(args):
+ M = connect()
+ try:
+ _select(M, INBOX, readonly=True)
+ typ, data = M.uid("FETCH", str(args.uid).encode(), "(RFC822)")
+ if typ != "OK" or not data or not data[0]:
+ sys.exit(f"error: uid {args.uid} not found in {INBOX}")
+ raw = data[0][1]
+ msg = email.message_from_bytes(raw, policy=default_policy)
+ for h in ("From", "To", "Cc", "Date", "Subject"):
+ v = msg.get(h)
+ if v:
+ print(f"{h}: {v}")
+ print()
+ body = extract_body(msg)
+ print(body if body is not None else "<no body>")
+ finally:
+ M.logout()
+
+
+def _store(uids, op, flags):
+ M = connect()
+ try:
+ _select(M, INBOX, readonly=False)
+ for uid in uids:
+ typ, data = M.uid("STORE", str(uid).encode(), op, flags)
+ if typ != "OK":
+ sys.exit(f"error: STORE {op} {flags} on uid {uid} failed: {data}")
+ print(f"ok: STORE {op} {flags} on {len(uids)} uid(s)")
+ finally:
+ M.logout()
+
+
+def cmd_mark_read(args):
+ _store(args.uids, "+FLAGS", r"(\Seen)")
+
+
+def cmd_mark_unread(args):
+ _store(args.uids, "-FLAGS", r"(\Seen)")
+
+
+def cmd_star(args):
+ # Workflow convention: starring also marks read (matches the Gmail flow).
+ _store(args.uids, "+FLAGS", r"(\Flagged \Seen)")
+
+
+def cmd_unstar(args):
+ _store(args.uids, "-FLAGS", r"(\Flagged)")
+
+
+def cmd_trash(args):
+ M = connect()
+ try:
+ _select(M, INBOX, readonly=False)
+ moved = 0
+ for uid in args.uids:
+ typ, data = M.uid("MOVE", str(uid).encode(), TRASH)
+ if typ != "OK":
+ # Fallback for servers without RFC 6851 MOVE.
+ typ2, data2 = M.uid("COPY", str(uid).encode(), TRASH)
+ if typ2 != "OK":
+ sys.exit(f"error: COPY uid {uid} -> {TRASH} failed: {data2}")
+ M.uid("STORE", str(uid).encode(), "+FLAGS", r"(\Deleted)")
+ moved += 1
+ M.expunge()
+ print(f"ok: moved {moved} uid(s) to {TRASH}")
+ finally:
+ M.logout()
+
+
+def cmd_send(args):
+ # Resolve attachments first so a missing file fails before SMTP opens.
+ attachments = [load_attachment(Path(p)) for p in (args.attach or [])]
+ if args.body is not None:
+ body = args.body
+ elif args.body_file is not None:
+ body = Path(args.body_file).read_text()
+ else:
+ body = sys.stdin.read()
+ msg = build_message(USER, args.to, args.subject, body, attachments)
+ smtp = smtp_connect()
+ try:
+ smtp.send_message(msg)
+ print(f"ok: sent to {args.to}")
+ finally:
+ smtp.quit()
+
+
+def cmd_folders(_args):
+ M = connect()
+ try:
+ typ, data = M.list()
+ if typ != "OK":
+ sys.exit(f"error: LIST failed: {data}")
+ for line in data:
+ print(line.decode("utf-8", errors="replace"))
+ finally:
+ M.logout()
+
+
+def main():
+ p = argparse.ArgumentParser(prog="cmail-action",
+ description="IMAP triage against Proton Bridge")
+ sp = p.add_subparsers(dest="cmd", required=True)
+
+ p_list = sp.add_parser("list-unread", help="list unread INBOX messages as JSON")
+ p_list.add_argument("--limit", type=int, default=50,
+ help="cap to N most recent (default 50)")
+ p_list.set_defaults(func=cmd_list_unread)
+
+ p_read = sp.add_parser("read", help="print headers + body of a UID")
+ p_read.add_argument("uid", type=int)
+ p_read.set_defaults(func=cmd_read)
+
+ p_mr = sp.add_parser("mark-read")
+ p_mr.add_argument("uids", nargs="+", type=int)
+ p_mr.set_defaults(func=cmd_mark_read)
+
+ p_mu = sp.add_parser("mark-unread")
+ p_mu.add_argument("uids", nargs="+", type=int)
+ p_mu.set_defaults(func=cmd_mark_unread)
+
+ p_s = sp.add_parser("star", help="star (sets \\Flagged + \\Seen)")
+ p_s.add_argument("uids", nargs="+", type=int)
+ p_s.set_defaults(func=cmd_star)
+
+ p_us = sp.add_parser("unstar")
+ p_us.add_argument("uids", nargs="+", type=int)
+ p_us.set_defaults(func=cmd_unstar)
+
+ p_t = sp.add_parser("trash", help="MOVE uid(s) to Trash")
+ p_t.add_argument("uids", nargs="+", type=int)
+ p_t.set_defaults(func=cmd_trash)
+
+ p_f = sp.add_parser("folders", help="list IMAP folders (debug)")
+ p_f.set_defaults(func=cmd_folders)
+
+ p_send = sp.add_parser("send", help="send an email via Bridge SMTP")
+ p_send.add_argument("--to", required=True, help="recipient address")
+ p_send.add_argument("--subject", required=True)
+ body_group = p_send.add_mutually_exclusive_group()
+ body_group.add_argument("--body", help="body text inline")
+ body_group.add_argument("--body-file", help="path to a file whose "
+ "contents become the body")
+ p_send.add_argument("--attach", action="append", default=[],
+ help="path to attach (repeatable)")
+ p_send.set_defaults(func=cmd_send)
+
+ args = p.parse_args()
+ args.func(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-discover b/.ai/scripts/cross-agent-comms/cross-agent-discover
new file mode 100755
index 0000000..152cf27
--- /dev/null
+++ b/.ai/scripts/cross-agent-comms/cross-agent-discover
@@ -0,0 +1,230 @@
+#!/usr/bin/env python3
+"""Enumerate cross-agent destinations: local projects + tailnet peers.
+
+See cross-agent-discover.md. Local: scan ~/projects/*/.ai/. Peers: read
+peers.toml, SSH-probe each for reachability. --enumerate-remote optionally
+runs `ls -d ~/projects/*/.ai/` over SSH to list remote projects.
+
+Cache results for 5 min at ~/.cache/cross-agent-comms/discovery.json so
+repeated invocations don't re-probe.
+
+HALT: prints a banner; otherwise continues.
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime as _dt
+import json
+import os
+import subprocess
+import sys
+import time
+import tomllib
+from pathlib import Path
+
+CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms"
+PEERS_TOML = CONFIG_DIR / "peers.toml"
+HALT_FILE = CONFIG_DIR / "HALT"
+CACHE_DIR = Path.home() / ".cache" / "cross-agent-comms"
+CACHE_FILE = CACHE_DIR / "discovery.json"
+CACHE_TTL_SECONDS = 300
+
+EXIT_OK = 0
+EXIT_GENERAL = 1
+EXIT_PEERS_TOML = 1
+
+
+def err(msg: str) -> None:
+ print(msg, file=sys.stderr)
+
+
+def render_banner_if_halt() -> None:
+ if not HALT_FILE.exists():
+ return
+ try:
+ reason = HALT_FILE.read_text().strip()
+ except OSError:
+ reason = "(HALT file unreadable; treated as halted)"
+ print("⚠ HALT ACTIVE — cross-agent comms paused")
+ if reason:
+ print(f" reason: {reason}")
+ print()
+
+
+def enumerate_local_projects() -> list[str]:
+ projects_dir = Path.home() / "projects"
+ if not projects_dir.is_dir():
+ return []
+ found = []
+ for child in sorted(projects_dir.iterdir()):
+ if child.is_dir() and (child / ".ai").is_dir():
+ found.append(child.name)
+ return found
+
+
+def load_peers() -> dict:
+ if not PEERS_TOML.exists():
+ return {"peers": {}}
+ try:
+ return tomllib.loads(PEERS_TOML.read_text())
+ except (tomllib.TOMLDecodeError, OSError) as e:
+ err(f"cannot parse peers.toml: {e}")
+ sys.exit(EXIT_PEERS_TOML)
+
+
+def probe_peer_reachability(host: str, ssh_user: str | None) -> tuple[bool, str | None]:
+ """Run a short SSH probe with BatchMode=yes (no interactive prompt)."""
+ target = f"{ssh_user}@{host}" if ssh_user else host
+ try:
+ result = subprocess.run(
+ ["ssh", "-o", "ConnectTimeout=2", "-o", "BatchMode=yes", target, "true"],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ return False, "ssh probe failed"
+ if result.returncode == 0:
+ return True, None
+ return False, (result.stderr.strip().splitlines() or [f"exit {result.returncode}"])[-1]
+
+
+def enumerate_remote_projects(host: str, ssh_user: str | None) -> list[str] | None:
+ target = f"{ssh_user}@{host}" if ssh_user else host
+ try:
+ result = subprocess.run(
+ [
+ "ssh", "-o", "ConnectTimeout=3", "-o", "BatchMode=yes", target,
+ "ls -d ~/projects/*/.ai/ 2>/dev/null",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ return None
+ if result.returncode != 0:
+ return None
+ projects = []
+ for line in result.stdout.splitlines():
+ # Each line looks like /home/<user>/projects/<name>/.ai/
+ parts = line.rstrip("/").split("/")
+ if len(parts) >= 2 and parts[-1] == ".ai":
+ projects.append(parts[-2])
+ return projects
+
+
+def read_cache() -> dict | None:
+ if not CACHE_FILE.exists():
+ return None
+ try:
+ age = time.time() - CACHE_FILE.stat().st_mtime
+ if age > CACHE_TTL_SECONDS:
+ return None
+ return json.loads(CACHE_FILE.read_text())
+ except (OSError, json.JSONDecodeError):
+ return None
+
+
+def write_cache(payload: dict) -> None:
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ CACHE_FILE.write_text(json.dumps(payload, indent=2))
+
+
+def discover(peer_filter: str | None, enumerate_remote: bool) -> dict:
+ local = enumerate_local_projects()
+ peers_cfg = load_peers().get("peers", {})
+
+ peers_out = []
+ for name, cfg in sorted(peers_cfg.items()):
+ if peer_filter and name != peer_filter:
+ continue
+ host = cfg.get("host", name)
+ ssh_user = cfg.get("ssh_user")
+ reachable, error = probe_peer_reachability(host, ssh_user)
+ entry = {
+ "name": name,
+ "host": host,
+ "reachable": reachable,
+ }
+ if not reachable:
+ entry["error"] = error
+ if enumerate_remote and reachable:
+ entry["projects"] = enumerate_remote_projects(host, ssh_user) or []
+ peers_out.append(entry)
+
+ return {
+ "scanned_at": _dt.datetime.now(_dt.timezone.utc).isoformat(),
+ "halt_active": HALT_FILE.exists(),
+ "local": local,
+ "peers": peers_out,
+ }
+
+
+def render_table(payload: dict, enumerate_remote: bool) -> None:
+ local = payload.get("local", [])
+ print(f"Local ({_local_hostname()}):")
+ if local:
+ wrapped = ", ".join(local)
+ print(f" {wrapped} [{len(local)} project{'s' if len(local) != 1 else ''}]")
+ else:
+ print(" (no projects with .ai/ found)")
+ print()
+
+ peers = payload.get("peers", [])
+ if not peers:
+ print("Peers (from peers.toml):")
+ print(" (no peers configured)")
+ return
+
+ print("Peers (from ~/.config/cross-agent-comms/peers.toml):")
+ for p in peers:
+ marker = "✓ reachable" if p.get("reachable") else f"✗ UNREACHABLE ({p.get('error', 'unknown')})"
+ print(f" {p['name']:<16} {p['host']:<24} {marker}")
+ if enumerate_remote and p.get("projects"):
+ wrapped = ", ".join(p["projects"])
+ print(f" projects: {wrapped}")
+
+
+def _local_hostname() -> str:
+ import socket
+ return socket.gethostname().split(".")[0]
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Discover cross-agent destinations.")
+ parser.add_argument("--enumerate-remote", action="store_true",
+ help="SSH into each peer and list ~/projects/*/.ai/")
+ parser.add_argument("--no-cache", action="store_true", help="Skip cache; force fresh probe")
+ parser.add_argument("--peer", help="Limit to a single peer name from peers.toml")
+ parser.add_argument("--json", action="store_true", help="Machine-readable output")
+ args = parser.parse_args()
+
+ render_banner_if_halt()
+
+ payload = None
+ if not args.no_cache:
+ cached = read_cache()
+ if cached is not None:
+ # Honor --peer filter on cached payload.
+ if args.peer:
+ cached["peers"] = [p for p in cached.get("peers", []) if p["name"] == args.peer]
+ payload = cached
+
+ if payload is None:
+ payload = discover(args.peer, args.enumerate_remote)
+ if not args.no_cache and not args.peer:
+ # Only cache full (unfiltered) discoveries.
+ write_cache(payload)
+
+ if args.json:
+ print(json.dumps(payload, indent=2))
+ return EXIT_OK
+
+ render_table(payload, args.enumerate_remote)
+ return EXIT_OK
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-discover.md b/.ai/scripts/cross-agent-comms/cross-agent-discover.md
new file mode 100644
index 0000000..95134bb
--- /dev/null
+++ b/.ai/scripts/cross-agent-comms/cross-agent-discover.md
@@ -0,0 +1,155 @@
+# cross-agent-discover
+
+**Purpose.** Enumerate available cross-agent destinations — local projects on
+this machine and remote projects on tailnet peers. Validates SSH reachability
+for cross-machine destinations before reporting them as usable.
+
+## Usage
+
+```
+cross-agent-discover [--enumerate-remote] [--no-cache] [--peer <name>]
+```
+
+No args required for the common case (local enumeration + peer reachability).
+
+### Flags
+
+| Flag | Default | Purpose |
+|---|---|---|
+| `--enumerate-remote` | off | SSH into each peer and list projects under `~/projects/*/.ai/`. Off by default because SSH adds latency; turn on when you want to see what's available on a remote machine you haven't fully configured. |
+| `--no-cache` | off | Skip the 5-minute cache; force fresh discovery. |
+| `--peer <name>` | (all) | Limit to a single peer from `peers.toml`. |
+| `--json` | off | Machine-readable output. |
+
+## Output
+
+### Default
+
+```
+$ cross-agent-discover
+Local (ratio):
+ career, claude-templates, clipper, danneel, documents, elibrary,
+ finances, health, homelab, jr-estate, kit, little-elisper,
+ philosophy, website [14 projects]
+
+Peers (from ~/.config/cross-agent-comms/peers.toml):
+ velox.local reachable (last seen 2 sec ago)
+ bastion.local UNREACHABLE (ssh exit 255: connection refused)
+```
+
+### With `--enumerate-remote`
+
+```
+$ cross-agent-discover --enumerate-remote
+Local (ratio):
+ ... (as above)
+
+velox.local (reachable):
+ career, homelab [2 projects]
+```
+
+## Configuration
+
+Reads `~/.config/cross-agent-comms/peers.toml`:
+
+```toml
+# Each peer is a remote machine reachable via SSH (typically over Tailscale).
+
+[peers.velox]
+host = "velox.local"
+ssh_user = "cjennings"
+
+[peers.bastion]
+host = "bastion.local"
+ssh_user = "cjennings"
+```
+
+Peers entries describe machines, NOT projects. Projects are enumerated
+on-demand under `~/projects/*/.ai/` either locally or via SSH.
+
+## Cache
+
+Successful discovery results are cached at
+`~/.cache/cross-agent-comms/discovery.json` for 5 minutes. Repeated invocations
+within the window read from cache.
+
+`--no-cache` forces a fresh probe. Useful when adding a new peer or after a
+network change.
+
+## SSH reachability check
+
+For each peer, runs:
+
+```
+ssh -o ConnectTimeout=2 -o BatchMode=yes <user>@<host> true
+```
+
+`BatchMode=yes` prevents interactive password prompts — peers that don't have
+key-based auth set up are reported as UNREACHABLE.
+
+If `--enumerate-remote` is set, on success runs:
+
+```
+ssh <user>@<host> 'ls -d ~/projects/*/.ai/ 2>/dev/null'
+```
+
+## Failure modes
+
+| Symptom | Likely cause | Fix |
+|---|---|---|
+| Peer reported UNREACHABLE | Tailscale not connected, SSH key not authorized, host firewalled | `tailscale status`; `ssh -v <peer>` to debug. |
+| Local list is empty | Glob misresolved, or `~/projects/` doesn't exist | Check `ls -d ~/projects/*/.ai/`. |
+| `--enumerate-remote` slow | Cold cache, slow tailnet, many peers | First run is slow, subsequent runs hit cache. Use `--peer <name>` to scope. |
+| Peer unexpectedly missing from output | Not in `peers.toml`, or `peers.toml` malformed | `cat ~/.config/cross-agent-comms/peers.toml` and validate. |
+
+## HALT awareness
+
+Checks `~/.config/cross-agent-comms/HALT` at start. If HALT exists, prints a
+prominent banner before normal output:
+
+```
+$ cross-agent-discover
+⚠ HALT ACTIVE — cross-agent comms paused
+ Reason: <reason from HALT file body, if any>
+ Resume with: cross-agent-resume
+
+(enumeration continues normally — HALT does not suppress visibility)
+
+Local (ratio):
+ career, claude-templates, ...
+
+Peers:
+ velox.local reachable
+```
+
+Discover is read-only. Like `cross-agent-status`, it always runs so the user
+keeps visibility into what destinations exist regardless of halt state. The
+banner makes the halt state impossible to miss.
+
+If the HALT file exists but is unreadable, print a warning banner and
+continue.
+
+See `cross-agent-halt.md` for the full halt mechanism.
+
+## Examples
+
+```bash
+# Common: see what's available
+cross-agent-discover
+
+# Force fresh probe after network change
+cross-agent-discover --no-cache
+
+# What's on velox specifically
+cross-agent-discover --peer velox --enumerate-remote
+
+# Pipe to grep
+cross-agent-discover --json | jq '.peers[] | select(.reachable)'
+```
+
+## See also
+
+- `cross-agent-send` — uses `peers.toml` for routing destinations.
+- `cross-agent-status` — local pending messages.
+- `cross-agent-comms.org` — protocol spec, `* Limitations` section
+ explains the cross-machine model.
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-halt b/.ai/scripts/cross-agent-comms/cross-agent-halt
new file mode 100755
index 0000000..df25115
--- /dev/null
+++ b/.ai/scripts/cross-agent-comms/cross-agent-halt
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+"""Failsafe halt for cross-agent comms.
+
+See cross-agent-halt.md. Touches ~/.config/cross-agent-comms/HALT and stops
+the cross-agent-watch systemd user service. With --tailnet, propagates the
+HALT file to every peer in peers.toml via SSH; reports per-peer status with
+non-zero exit on partial halt.
+
+Does NOT pkill in-flight scripts — they detect HALT on next iteration and
+stop themselves.
+"""
+
+from __future__ import annotations
+
+import argparse
+import subprocess
+import sys
+import tomllib
+from pathlib import Path
+
+CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms"
+HALT_FILE = CONFIG_DIR / "HALT"
+PEERS_TOML = CONFIG_DIR / "peers.toml"
+
+EXIT_OK = 0
+EXIT_PARTIAL = 1
+
+
+def err(msg: str) -> None:
+ print(msg, file=sys.stderr)
+
+
+def write_halt_file(reason: str) -> None:
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+ HALT_FILE.write_text((reason + "\n") if reason else "")
+
+
+def stop_watcher_service() -> None:
+ """Best-effort stop of the systemd watcher service. Failures are logged but not fatal."""
+ try:
+ subprocess.run(
+ ["systemctl", "--user", "stop", "cross-agent-watch.path"],
+ capture_output=True, text=True, timeout=5,
+ )
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ # Watcher service may not be installed — fine.
+ pass
+
+
+def load_peers() -> dict:
+ if not PEERS_TOML.exists():
+ return {}
+ try:
+ return tomllib.loads(PEERS_TOML.read_text())
+ except (tomllib.TOMLDecodeError, OSError) as e:
+ err(f"cannot parse peers.toml: {e}")
+ return {}
+
+
+def ssh_touch_halt(host: str, ssh_user: str | None, reason: str) -> tuple[bool, str]:
+ target = f"{ssh_user}@{host}" if ssh_user else host
+ # Build the remote command. Quote the reason carefully.
+ remote_cmd = (
+ f"mkdir -p ~/.config/cross-agent-comms && "
+ f"printf %s {_sh_quote(reason)} > ~/.config/cross-agent-comms/HALT"
+ )
+ try:
+ result = subprocess.run(
+ ["ssh", "-o", "ConnectTimeout=3", "-o", "BatchMode=yes", target, remote_cmd],
+ capture_output=True, text=True, timeout=10,
+ )
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ return False, "ssh unavailable or timed out"
+ if result.returncode == 0:
+ return True, "HALT file written"
+ return False, (result.stderr.strip().splitlines() or [f"exit {result.returncode}"])[-1]
+
+
+def _sh_quote(s: str) -> str:
+ return "'" + s.replace("'", "'\"'\"'") + "'"
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Halt all cross-agent comms on this machine (and optionally tailnet).")
+ parser.add_argument("reason", nargs="?", default="", help="Optional human-readable reason")
+ parser.add_argument("--tailnet", action="store_true",
+ help="Propagate HALT to every peer in peers.toml")
+ args = parser.parse_args()
+
+ # Local halt.
+ write_halt_file(args.reason)
+ stop_watcher_service()
+ print("Halting locally ✓ (HALT file written)")
+
+ if not args.tailnet:
+ print()
+ print(f"Halt active. Remove {HALT_FILE} or run cross-agent-resume to clear.")
+ print("Agent polling will stop within ~5 min (one cadence cycle).")
+ return EXIT_OK
+
+ peers = load_peers().get("peers", {})
+ if not peers:
+ print()
+ print("No peers configured in peers.toml — local-only halt complete.")
+ return EXIT_OK
+
+ print()
+ successes = 1 # local already counted
+ failures = []
+ for name, cfg in sorted(peers.items()):
+ host = cfg.get("host", name)
+ ssh_user = cfg.get("ssh_user")
+ ok, detail = ssh_touch_halt(host, ssh_user, args.reason)
+ marker = "✓" if ok else "✗"
+ print(f"Halting {host:<28} {marker} ({detail})")
+ if ok:
+ successes += 1
+ else:
+ failures.append(f"{name} ({host}): {detail}")
+
+ print()
+ total = len(peers) + 1
+ if failures:
+ print(f"PARTIAL HALT: {successes}/{total} machines halted.")
+ for f in failures:
+ print(f" - {f}")
+ print("Resolve the failures or manually halt each machine.")
+ return EXIT_PARTIAL
+ print(f"Halt active across {total} machine(s).")
+ return EXIT_OK
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-halt.md b/.ai/scripts/cross-agent-comms/cross-agent-halt.md
new file mode 100644
index 0000000..b817fbc
--- /dev/null
+++ b/.ai/scripts/cross-agent-comms/cross-agent-halt.md
@@ -0,0 +1,134 @@
+# cross-agent-halt
+
+**Purpose.** Failsafe stop for all cross-agent activity on the local machine
+(or, with `--tailnet`, across all configured peers). Creates the HALT file
+that every component in the protocol checks; within one polling cadence
+(~5 min) all polling, sending, watching, and receiving stops.
+
+This is the user's emergency brake. Use when something is misbehaving and
+visiting individual sessions is too slow.
+
+## Usage
+
+```
+cross-agent-halt [reason] [--tailnet] [--no-stop-watcher]
+```
+
+### Positional argument
+
+| Position | Meaning | Example |
+|---|---|---|
+| 1 | Optional human-readable reason for the halt. Written into the HALT file's body. Helps future-you remember why you stopped things. | `"investigating runaway poll loop, 2026-04-27"` |
+
+### Flags
+
+| Flag | Default | Purpose |
+|---|---|---|
+| `--tailnet` | local only | Propagate halt to every peer in `peers.toml` via SSH over Tailscale. |
+| `--no-stop-watcher` | (stops watcher) | Skip stopping the `cross-agent-watch.path` systemd unit. Useful if the watcher is intentionally separate from comms (rare). |
+
+## Behavior
+
+### Local halt (default)
+
+1. Write the HALT file: `~/.config/cross-agent-comms/HALT`. If a `[reason]` was
+ passed, write it as the file's body. Otherwise the file is empty (existence
+ alone triggers halt).
+2. Stop the watcher service: `systemctl --user stop cross-agent-watch.path`
+ (and the corresponding `.service` if running).
+3. Print a summary:
+ ```
+ ✓ HALT file written: ~/.config/cross-agent-comms/HALT
+ ✓ Watcher service stopped (cross-agent-watch.path)
+ - In-flight sends will complete their current rsync step (~seconds), then
+ stop. New sends are blocked.
+ - Active agent polling sessions stop within one cadence (~5 min).
+ - Use `cross-agent-resume` to clear HALT.
+ Per-session polling does NOT auto-resume — you re-engage each session by
+ telling its agent to resume polling.
+ ```
+4. Exit 0.
+
+### Cross-tailnet halt (`--tailnet`)
+
+1. Apply local halt steps 1-2 first.
+2. Read `peers.toml` for the list of remote machines.
+3. For each peer, SSH and write the HALT file:
+ ```
+ ssh <user>@<host> "echo '<reason>' > ~/.config/cross-agent-comms/HALT && \
+ systemctl --user stop cross-agent-watch.path"
+ ```
+4. Track per-peer success/failure. Print results:
+ ```
+ Halting velox.local ✓ (HALT file written)
+ Halting bastion.local ✗ (ssh exit 255: no route to host)
+ Halting locally ✓ (HALT file written)
+
+ PARTIAL HALT: 2/3 machines halted. bastion.local needs manual halt.
+ ```
+5. Exit 0 if all peers halted; exit 1 if any peer failed (so scripts can
+ detect partial halt). The local halt always succeeds — even on `--tailnet`,
+ if remote peers fail, local is still halted.
+
+## What "halt active" means for each component
+
+| Component | Behavior under HALT |
+|---|---|
+| `cross-agent-send` | Refuses to send. Exits 5 with "halt active; remove ~/.config/cross-agent-comms/HALT to resume." Checks HALT at start AND between each retry/rsync step, so an in-flight send completes its current step then stops. |
+| `cross-agent-recv` | Refuses to verify or dedup. Exits 5 with same message. Inbound files are **left in place** — not moved, not rejected — so resume picks them up cleanly via cold-start. |
+| `cross-agent-watch` | Continues running but suppresses notifications. Logs each event with `(suppressed by HALT)` so the operator can see what would have fired. |
+| `cross-agent-status` | Prints prominent `⚠ HALT ACTIVE` banner before normal output. Continues to enumerate (read-only). |
+| `cross-agent-discover` | Same banner. Continues (read-only). |
+| Agent polling loops | Check HALT on every wake. If set: write a final `progress` note to any active conversation ("HALT fired locally; pausing"), surface "(HALT active; cross-agent comms paused)" in every user response, and stop rescheduling. Polling decays naturally within one cadence. |
+| Conversation initiator | Refuses to write sequence 1 of any new conversation. Surfaces refusal to user. |
+| Startup workflow (Phase A) | Checks HALT at session boot. If set, surfaces immediately and skips cross-agent inbox checks. |
+
+## Failure modes
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| `~/.config/cross-agent-comms/HALT` already exists | Halt was already active | OK — running halt again refreshes the reason text. Safe. |
+| `systemctl --user stop` fails | Watcher service not installed, or systemd not available | The HALT file is still written — components that check HALT will still stop. The systemctl failure surfaces as a non-fatal warning. |
+| `--tailnet` halts some peers but not others | One or more peers unreachable | Exit 1 with per-peer status. Manually halt the unreachable peers (visit each machine, `touch ~/.config/cross-agent-comms/HALT`), or fix the network and re-run. |
+| Permission denied writing the HALT file | `~/.config/cross-agent-comms/` doesn't exist or is owned by another user | `mkdir -p ~/.config/cross-agent-comms/`; check ownership. |
+
+## What halt does NOT do
+
+- Does not kill running Claude sessions. Polling stops within ~5 min, but the
+ session itself stays alive and can be re-engaged after resume.
+- Does not delete pending messages. Inbound files in `inbox/from-agents/`
+ remain; they get processed when polling resumes.
+- Does not abort in-flight rsync push mid-byte. Atomic-write semantics
+ guarantee in-flight messages either complete cleanly or leave only `.tmp.*`
+ files (which receivers ignore).
+
+## Examples
+
+```bash
+# Quick halt with no reason
+cross-agent-halt
+
+# Halt with a memo
+cross-agent-halt "runaway poll loop in homelab session, debugging"
+
+# Halt all tailnet peers + local
+cross-agent-halt --tailnet "shutting down for system update"
+
+# Halt protocol comms but leave the watcher service running
+cross-agent-halt --no-stop-watcher
+```
+
+## Recovery
+
+Always pair with `cross-agent-resume` when the situation is resolved:
+
+```bash
+cross-agent-resume # local
+cross-agent-resume --tailnet # all peers
+```
+
+## See also
+
+- `cross-agent-resume` — counterpart that clears HALT.
+- `cross-agent-status` — see HALT state at a glance.
+- `cross-agent-comms.org` — protocol spec, `* Halt mechanism` section.
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-recv b/.ai/scripts/cross-agent-comms/cross-agent-recv
new file mode 100755
index 0000000..b67533a
--- /dev/null
+++ b/.ai/scripts/cross-agent-comms/cross-agent-recv
@@ -0,0 +1,250 @@
+#!/usr/bin/env python3
+"""Cross-agent message receiver.
+
+See cross-agent-recv.md for the full contract. Reads one message file and
+emits a structured decision the agent acts on:
+
+ process | dedup | query | reject
+
+Decision exit codes:
+ 0 = process 1 = dedup 2 = query 3 = reject
+
+When HALT is set, the script refuses to verify or dedup and leaves the
+inbound file in place — resume picks it up via cold-start.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import re
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms"
+HALT_FILE = CONFIG_DIR / "HALT"
+EXPECTED_PROTOCOL_VERSION = "5"
+
+REQUIRED_FRONTMATTER = ["TITLE", "CONVERSATION_ID", "MESSAGE_TYPE", "SEQUENCE", "TIMESTAMP", "PROTOCOL_VERSION"]
+VALID_MESSAGE_TYPES = {"request", "progress", "query", "pushback", "complete", "release", "escalate"}
+
+DEC_PROCESS = "process"
+DEC_DEDUP = "dedup"
+DEC_QUERY = "query"
+DEC_REJECT = "reject"
+
+EXIT_FOR_DECISION = {
+ DEC_PROCESS: 0,
+ DEC_DEDUP: 1,
+ DEC_QUERY: 2,
+ DEC_REJECT: 3,
+}
+
+EXIT_HALT = 5
+
+
+def err(msg: str) -> None:
+ print(msg, file=sys.stderr)
+
+
+def check_halt() -> None:
+ if HALT_FILE.exists():
+ try:
+ reason = HALT_FILE.read_text().strip()
+ except OSError:
+ err("halt active (HALT file present but unreadable; treated as halted)")
+ sys.exit(EXIT_HALT)
+ msg = "halt active; leaving inbound message in place (resume will pick up)"
+ if reason:
+ msg = f"{msg}: {reason}"
+ err(msg)
+ sys.exit(EXIT_HALT)
+
+
+def parse_frontmatter(path: Path) -> dict[str, str]:
+ try:
+ text = path.read_text()
+ except OSError as e:
+ return {"_parse_error": f"cannot read: {e}"}
+ fm: dict[str, str] = {}
+ for line in text.splitlines():
+ line = line.rstrip()
+ if not line:
+ if fm:
+ break
+ continue
+ m = re.match(r"#\+([A-Z_]+):\s*(.*)", line)
+ if m:
+ fm[m.group(1)] = m.group(2).strip()
+ elif fm:
+ break
+ return fm
+
+
+def emit_decision(
+ decision: str,
+ reason: str | None,
+ fm: dict[str, str],
+ sha256: str | None,
+ args: argparse.Namespace,
+) -> int:
+ payload = {
+ "decision": decision,
+ "reason": reason,
+ "message_type": fm.get("MESSAGE_TYPE"),
+ "conversation_id": fm.get("CONVERSATION_ID"),
+ "sequence": fm.get("SEQUENCE"),
+ "timestamp": fm.get("TIMESTAMP"),
+ "sha256": sha256,
+ }
+ if args.json:
+ print(json.dumps(payload, indent=None if args.compact_json else 2))
+ else:
+ print(f"decision: {decision}")
+ if reason:
+ print(f"reason: {reason}")
+ for k in ("message_type", "conversation_id", "sequence", "timestamp"):
+ v = payload[k]
+ if v is not None:
+ print(f"{k}: {v}")
+ if sha256:
+ print(f"sha256: {sha256}")
+ return EXIT_FOR_DECISION[decision]
+
+
+def gpg_verify(message_path: Path, sig_path: Path) -> tuple[bool, str]:
+ try:
+ result = subprocess.run(
+ ["gpg", "--verify", str(sig_path), str(message_path)],
+ capture_output=True,
+ text=True,
+ )
+ except FileNotFoundError:
+ return False, "gpg not installed"
+ if result.returncode == 0:
+ return True, ""
+ return False, result.stderr.strip().splitlines()[-1] if result.stderr.strip() else f"exit {result.returncode}"
+
+
+def sha256_of(path: Path) -> str:
+ h = hashlib.sha256()
+ with path.open("rb") as f:
+ for chunk in iter(lambda: f.read(65536), b""):
+ h.update(chunk)
+ return h.hexdigest()
+
+
+def find_dedup_match(message_path: Path, fm: dict[str, str], my_hash: str) -> tuple[str, str | None]:
+ """Scan the message's directory for same-CONVERSATION_ID/SEQUENCE files.
+
+ Returns (decision, reason) — decision is DEC_DEDUP for an exact-hash match,
+ or DEC_PROCESS when no match or hash differs (sequence collision is OK).
+ """
+ parent = message_path.parent
+ conv_id = fm["CONVERSATION_ID"]
+ sequence = fm["SEQUENCE"]
+ for sibling in parent.iterdir():
+ if sibling == message_path or not sibling.is_file() or sibling.suffix != ".org":
+ continue
+ sib_fm = parse_frontmatter(sibling)
+ if sib_fm.get("CONVERSATION_ID") != conv_id or sib_fm.get("SEQUENCE") != sequence:
+ continue
+ # Same conv-id + same sequence — check hash.
+ if sha256_of(sibling) == my_hash:
+ return DEC_DEDUP, f"identical retry of {sibling.name}"
+ return DEC_PROCESS, None
+
+
+def check_requires_tools(fm: dict[str, str]) -> tuple[bool, list[str]]:
+ """REQUIRES_TOOLS is a comma-separated list of tool names.
+
+ For v5, "tool available" is a heuristic: an executable on PATH whose name
+ matches the tool slug. MCP availability is currently out of scope (no
+ portable way to query it from a CLI).
+ """
+ tools_field = fm.get("REQUIRES_TOOLS")
+ if not tools_field:
+ return True, []
+ tools = [t.strip() for t in tools_field.split(",") if t.strip()]
+ missing = [t for t in tools if shutil.which(t) is None]
+ return len(missing) == 0, missing
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Receive and decide on a cross-agent message.")
+ parser.add_argument("message_file", type=Path)
+ parser.add_argument("--no-verify", action="store_true", help="Skip GPG verification (testing only)")
+ parser.add_argument("--no-dedup", action="store_true", help="Skip SHA-256 dedup against existing files")
+ parser.add_argument("--protocol-version", default=EXPECTED_PROTOCOL_VERSION,
+ help="Override expected protocol version (default: 5)")
+ parser.add_argument("--json", action="store_true", help="Emit JSON output")
+ parser.add_argument("--compact-json", action="store_true", help="Compact JSON (no indent)")
+ args = parser.parse_args()
+
+ check_halt()
+
+ if not args.message_file.is_file():
+ err(f"message file not found: {args.message_file}")
+ return EXIT_FOR_DECISION[DEC_REJECT]
+
+ fm = parse_frontmatter(args.message_file)
+ if "_parse_error" in fm:
+ return emit_decision(DEC_REJECT, fm["_parse_error"], {}, None, args)
+
+ # Step 1: frontmatter sanity-check.
+ missing = [k for k in REQUIRED_FRONTMATTER if k not in fm]
+ if missing:
+ return emit_decision(
+ DEC_REJECT, f"frontmatter missing required fields: {', '.join(missing)}", fm, None, args
+ )
+ if fm["MESSAGE_TYPE"] not in VALID_MESSAGE_TYPES:
+ return emit_decision(
+ DEC_REJECT, f"invalid MESSAGE_TYPE: {fm['MESSAGE_TYPE']!r}", fm, None, args
+ )
+
+ # Step 2: PROTOCOL_VERSION check.
+ if fm["PROTOCOL_VERSION"] != args.protocol_version:
+ return emit_decision(
+ DEC_QUERY,
+ f"PROTOCOL_VERSION mismatch: expected {args.protocol_version}, got {fm['PROTOCOL_VERSION']}",
+ fm,
+ None,
+ args,
+ )
+
+ # Step 3: GPG verify.
+ if not args.no_verify:
+ sig_path = args.message_file.with_suffix(args.message_file.suffix + ".asc")
+ if not sig_path.is_file():
+ return emit_decision(DEC_REJECT, f"signature file missing: {sig_path.name}", fm, None, args)
+ ok, gpg_err = gpg_verify(args.message_file, sig_path)
+ if not ok:
+ return emit_decision(DEC_REJECT, f"gpg verify failed: {gpg_err}", fm, None, args)
+
+ # Step 4: SHA-256 dedup.
+ my_hash = sha256_of(args.message_file)
+ if not args.no_dedup:
+ decision, reason = find_dedup_match(args.message_file, fm, my_hash)
+ if decision == DEC_DEDUP:
+ return emit_decision(DEC_DEDUP, reason, fm, my_hash, args)
+
+ # Step 5: REQUIRES_TOOLS check.
+ ok, missing_tools = check_requires_tools(fm)
+ if not ok:
+ return emit_decision(
+ DEC_QUERY,
+ f"required tools unavailable: {', '.join(missing_tools)}",
+ fm,
+ my_hash,
+ args,
+ )
+
+ # Step 6: process.
+ return emit_decision(DEC_PROCESS, None, fm, my_hash, args)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-recv.md b/.ai/scripts/cross-agent-comms/cross-agent-recv.md
new file mode 100644
index 0000000..247a27a
--- /dev/null
+++ b/.ai/scripts/cross-agent-comms/cross-agent-recv.md
@@ -0,0 +1,218 @@
+# cross-agent-recv
+
+**Purpose.** The canonical receiver-side processor. Reads a single incoming
+message file and reports a structured decision the agent acts on:
+process / dedup / query / reject.
+
+The script handles only mechanical checks (frontmatter, signature, dedup,
+version, tools). Substance-level decisions like `pushback` ("I disagree with
+this request") happen one layer up — after the agent reads the message body
+the script returns as `process`-able.
+
+This is the read-side counterpart to `cross-agent-send`. Together they are the
+two halves of the per-message contract. The agent's polling loop calls
+`cross-agent-recv` on every new file in `inbox/from-agents/` and dispatches on
+the decision.
+
+Without this script, every receiver implementation re-invents GPG verify +
+frontmatter sanity-check + SHA-256 dedup. With it, behavior is consistent
+across projects.
+
+## Usage
+
+```
+cross-agent-recv <message-file>
+```
+
+Single positional argument: a `.org` file in `inbox/from-agents/`. The matching
+`.asc` signature file must be present alongside it.
+
+### Flags
+
+| Flag | Default | Purpose |
+|---|---|---|
+| `--no-verify` | (verify on) | Skip GPG verification. Testing only. |
+| `--no-dedup` | (dedup on) | Skip SHA-256 dedup against existing files. Testing only. |
+| `--protocol-version <N>` | 5 | Override the expected protocol version. Useful for testing forward-compatibility checks. |
+| `--json` | off | Output decision as JSON for easier parsing by the agent. |
+
+## Behavior
+
+Runs the receiver checks in order. First failure determines the decision.
+
+### Step 1 — Frontmatter sanity-check
+
+Parse the message's org-mode frontmatter. Required fields:
+
+- `#+TITLE`
+- `#+CONVERSATION_ID`
+- `#+MESSAGE_TYPE` (must be one of: `request`, `progress`, `query`, `pushback`,
+ `complete`, `release`, `escalate`)
+- `#+SEQUENCE` (integer)
+- `#+TIMESTAMP` (ISO 8601 with explicit offset)
+- `#+PROTOCOL_VERSION` (must match the expected version; default 5)
+
+Any required field missing, malformed, or the protocol version mismatched →
+decision = `reject` (frontmatter) or `query` (version mismatch — see below).
+
+### Step 2 — Protocol-version check
+
+If `PROTOCOL_VERSION` doesn't match the expected:
+
+- Decision = `query`. Action: receiver should write a `query` reply asking the
+ sender to upgrade to the expected protocol version.
+
+### Step 3 — Signature verification
+
+Look for `<message-file>.asc` alongside the `.org`. If missing or `gpg
+--verify` fails:
+
+- Decision = `reject` (signature). Surface to user; do not act.
+
+The `.asc` file MUST be present when the `.org` is — `cross-agent-send`
+guarantees this with its strict ordering (`.asc` lands first). If the `.asc`
+is missing despite the `.org` being present, the sender violated atomic-write
+ordering or the file was tampered with in transit.
+
+### Step 4 — SHA-256 dedup
+
+Compute SHA-256 of the message file. Scan the same directory for existing
+files matching `CONVERSATION_ID + SEQUENCE`:
+
+- No match → decision = `process` (new message, dispatch by type).
+- Match with **identical** SHA-256 → decision = `dedup` (silent retry; do not
+ reprocess).
+- Match with **different** SHA-256 → decision = `process` (sequence collision
+ with non-identical content; both are legitimate, ordered by `#+TIMESTAMP`).
+
+### Step 5 — REQUIRES_TOOLS optional check
+
+If the message has a `#+REQUIRES_TOOLS` field, verify each named tool/MCP is
+available in the receiver's environment.
+
+- All available → `process`.
+- One or more missing → decision = `query`. The agent should write a `query`
+ reply naming the missing tools, asking the sender to reframe the request to
+ avoid them.
+
+### Step 6 — Dispatch decision
+
+If all checks pass, decision = `process` with the parsed `MESSAGE_TYPE` so the
+agent's main loop knows which handler to invoke.
+
+## Output
+
+### Default (human-readable)
+
+```
+$ cross-agent-recv inbox/from-agents/20260427T091015Z-from-homelab-prep-fixup.org
+decision: process
+message_type: request
+conversation_id: prep-fixup
+sequence: 6
+sha256: a1b2c3d4...
+```
+
+### `--json`
+
+```json
+{
+ "decision": "process",
+ "reason": null,
+ "message_type": "request",
+ "conversation_id": "prep-fixup",
+ "sequence": 6,
+ "timestamp": "2026-04-27T04:11:42-05:00",
+ "sha256": "a1b2c3d4..."
+}
+```
+
+For decisions other than `process`, `reason` carries a human-readable
+explanation:
+
+```json
+{
+ "decision": "query",
+ "reason": "PROTOCOL_VERSION mismatch: expected 5, got 4",
+ "conversation_id": "prep-fixup",
+ "sequence": 6
+}
+```
+
+## Decision exit codes
+
+| Decision | Exit code | Agent action |
+|---|---|---|
+| `process` | 0 | Dispatch to the message-type handler |
+| `dedup` | 1 | Silent — do nothing further |
+| `query` | 2 | Write a `query` reply (see `reason` for what to ask) |
+| `reject` | 3 | Surface to user; do not auto-reply |
+
+The agent reads stdout/JSON to learn the decision; it can also key off exit
+code for simpler bash-style dispatching.
+
+## Failure modes
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| `decision: reject (frontmatter)` | Required field missing or malformed | Open the message; fix or surface to user. The sender should not have produced this file. |
+| `decision: reject (signature)` | `.asc` missing, GPG verify failed, or signer unknown | Check that `.asc` exists alongside `.org`. If yes, run `gpg --verify <msg>.asc <msg>` manually for diagnostic output. |
+| `decision: query (PROTOCOL_VERSION)` | Sender on older/newer protocol | Reply with a `query` asking sender to upgrade. Both sides should align before continuing. |
+| `decision: query (REQUIRES_TOOLS)` | Receiver lacks one of the named tools | Reply with a `query` naming the missing tools; sender should reframe to avoid. |
+| `decision: dedup` | Already-processed identical retry | No action. The script handled it correctly. |
+
+## HALT awareness
+
+Checks `~/.config/cross-agent-comms/HALT` at the start of every invocation. If
+HALT exists, exits with code 5 ("halt active; remove
+~/.config/cross-agent-comms/HALT to resume") without verifying, deduping, or
+returning a decision.
+
+**The inbound file is left in place** — not moved, not rejected, not
+deduped. When HALT clears and polling resumes, the file gets picked up via
+the normal cold-start handling (whichever surfaces first: watcher
+notification, startup workflow check, or the next agent poll). Reversibility
+is preserved.
+
+If the HALT file exists but is unreadable, fail-closed — treat as if HALT is
+set.
+
+See `cross-agent-halt.md` for the full halt mechanism.
+
+## Examples
+
+```bash
+# Basic invocation in an agent's polling loop
+for msg in inbox/from-agents/*.org; do
+ decision=$(cross-agent-recv --json "$msg")
+ case "$(echo "$decision" | jq -r '.decision')" in
+ process) handle_message "$msg" ;;
+ dedup) ;; # silent
+ query) write_query_reply "$msg" "$decision" ;;
+ reject) surface_to_user "$msg" "$decision" ;;
+ esac
+done
+
+# Test signature verification only
+cross-agent-recv --no-dedup inbox/from-agents/test-msg.org
+
+# Test against a future protocol version
+cross-agent-recv --protocol-version 6 inbox/from-agents/future-msg.org
+```
+
+## Performance
+
+The script is fast (single SHA-256 compute, single GPG verify, frontmatter
+parse). For typical messages (single-digit KB), runs in well under 100ms.
+Dedup-scan is O(N) over files in the directory; if a project's
+`inbox/from-agents/` accumulates hundreds of files, archive released
+conversations to keep the scan fast.
+
+## See also
+
+- `cross-agent-send` — counterpart writer.
+- `cross-agent-watch` — fires when a new message arrives; agent then calls
+ `cross-agent-recv` to process it.
+- `cross-agent-status` — pending-message snapshot (uses similar
+ released-vs-unreleased logic, but doesn't process individual messages).
+- `cross-agent-comms.org` — protocol spec, the "what" the script implements.
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-resume b/.ai/scripts/cross-agent-comms/cross-agent-resume
new file mode 100755
index 0000000..1fb83bc
--- /dev/null
+++ b/.ai/scripts/cross-agent-comms/cross-agent-resume
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""Resume cross-agent comms after a halt.
+
+See cross-agent-resume.md. Removes ~/.config/cross-agent-comms/HALT and
+restarts the cross-agent-watch systemd user service. With --tailnet,
+propagates the removal to every peer in peers.toml via SSH; reports
+per-peer status with non-zero exit on partial resume.
+
+Per the asymmetry rule: clearing HALT does NOT auto-resume agent polling.
+Each session must explicitly re-engage.
+"""
+
+from __future__ import annotations
+
+import argparse
+import subprocess
+import sys
+import tomllib
+from pathlib import Path
+
+CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms"
+HALT_FILE = CONFIG_DIR / "HALT"
+PEERS_TOML = CONFIG_DIR / "peers.toml"
+
+EXIT_OK = 0
+EXIT_PARTIAL = 1
+
+
+def err(msg: str) -> None:
+ print(msg, file=sys.stderr)
+
+
+def remove_halt_file() -> bool:
+ """Returns True if HALT was removed, False if it didn't exist."""
+ if HALT_FILE.exists():
+ try:
+ HALT_FILE.unlink()
+ return True
+ except OSError as e:
+ err(f"could not remove HALT: {e}")
+ return False
+ return False
+
+
+def start_watcher_service() -> None:
+ """Best-effort start of the systemd watcher path unit."""
+ try:
+ subprocess.run(
+ ["systemctl", "--user", "start", "cross-agent-watch.path"],
+ capture_output=True, text=True, timeout=5,
+ )
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ pass
+
+
+def load_peers() -> dict:
+ if not PEERS_TOML.exists():
+ return {}
+ try:
+ return tomllib.loads(PEERS_TOML.read_text())
+ except (tomllib.TOMLDecodeError, OSError) as e:
+ err(f"cannot parse peers.toml: {e}")
+ return {}
+
+
+def ssh_remove_halt(host: str, ssh_user: str | None) -> tuple[bool, str]:
+ target = f"{ssh_user}@{host}" if ssh_user else host
+ remote_cmd = "rm -f ~/.config/cross-agent-comms/HALT"
+ try:
+ result = subprocess.run(
+ ["ssh", "-o", "ConnectTimeout=3", "-o", "BatchMode=yes", target, remote_cmd],
+ capture_output=True, text=True, timeout=10,
+ )
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ return False, "ssh unavailable or timed out"
+ if result.returncode == 0:
+ return True, "HALT cleared"
+ return False, (result.stderr.strip().splitlines() or [f"exit {result.returncode}"])[-1]
+
+
+def print_re_engage_instructions() -> None:
+ print()
+ print("Halt cleared. Watcher restarted.")
+ print()
+ print("Agent polling does NOT auto-resume — per the failsafe asymmetry rule,")
+ print("agents stay paused until you explicitly re-engage each session.")
+ print("Open the relevant Claude session and tell the agent to resume polling")
+ print("for its conversation.")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Resume cross-agent comms after a halt.")
+ parser.add_argument("--tailnet", action="store_true",
+ help="Propagate HALT removal to every peer in peers.toml")
+ args = parser.parse_args()
+
+ removed = remove_halt_file()
+ start_watcher_service()
+ if removed:
+ print("Resuming locally ✓ (HALT cleared)")
+ else:
+ print("Resuming locally ✓ (no HALT was active)")
+
+ if not args.tailnet:
+ print_re_engage_instructions()
+ return EXIT_OK
+
+ peers = load_peers().get("peers", {})
+ if not peers:
+ print()
+ print("No peers configured in peers.toml — local-only resume complete.")
+ print_re_engage_instructions()
+ return EXIT_OK
+
+ print()
+ successes = 1
+ failures = []
+ for name, cfg in sorted(peers.items()):
+ host = cfg.get("host", name)
+ ssh_user = cfg.get("ssh_user")
+ ok, detail = ssh_remove_halt(host, ssh_user)
+ marker = "✓" if ok else "✗"
+ print(f"Resuming {host:<27} {marker} ({detail})")
+ if ok:
+ successes += 1
+ else:
+ failures.append(f"{name} ({host}): {detail}")
+
+ print()
+ total = len(peers) + 1
+ if failures:
+ print(f"PARTIAL RESUME: {successes}/{total} machines cleared.")
+ for f in failures:
+ print(f" - {f}")
+ print("Resolve the failures or manually clear HALT on each machine.")
+ print_re_engage_instructions()
+ return EXIT_PARTIAL
+
+ print(f"Resume complete across {total} machine(s).")
+ print_re_engage_instructions()
+ return EXIT_OK
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-resume.md b/.ai/scripts/cross-agent-comms/cross-agent-resume.md
new file mode 100644
index 0000000..8aa8357
--- /dev/null
+++ b/.ai/scripts/cross-agent-comms/cross-agent-resume.md
@@ -0,0 +1,117 @@
+# cross-agent-resume
+
+**Purpose.** Clear the HALT file and restart the watcher service. Counterpart
+to `cross-agent-halt`. Resuming agent polling is **explicit per-session** —
+this script doesn't auto-revive halted polling loops; you tell each session
+to re-engage.
+
+## Usage
+
+```
+cross-agent-resume [--tailnet]
+```
+
+### Flags
+
+| Flag | Default | Purpose |
+|---|---|---|
+| `--tailnet` | local only | Clear HALT on every peer in `peers.toml` via SSH over Tailscale. |
+
+## Behavior
+
+### Local resume (default)
+
+1. Remove the HALT file: `rm -f ~/.config/cross-agent-comms/HALT`. (Use `-f`
+ so a missing file isn't an error — running resume when not halted is safe.)
+2. Restart the watcher service: `systemctl --user start cross-agent-watch.path`.
+3. Print a summary:
+ ```
+ ✓ HALT file removed
+ ✓ Watcher service started (cross-agent-watch.path)
+ - cross-agent-send and cross-agent-recv will accept new operations.
+ - Inbound messages held during halt will be picked up by the watcher.
+ - Agent polling does NOT auto-resume. To re-engage polling in a paused
+ session, open that Claude session and tell the agent to resume.
+ ```
+4. Exit 0.
+
+### Cross-tailnet resume (`--tailnet`)
+
+1. Apply local resume steps 1-2 first.
+2. Read `peers.toml` for the list of remote machines.
+3. For each peer, SSH:
+ ```
+ ssh <user>@<host> "rm -f ~/.config/cross-agent-comms/HALT && \
+ systemctl --user start cross-agent-watch.path"
+ ```
+4. Track per-peer success/failure:
+ ```
+ Resuming velox.local ✓ (HALT cleared, watcher started)
+ Resuming bastion.local ✗ (ssh exit 255: no route to host)
+ Resuming locally ✓
+
+ PARTIAL RESUME: 2/3 machines resumed. bastion.local still halted.
+ ```
+5. Exit 0 if all peers resumed; exit 1 on any failure.
+
+## Why agent polling doesn't auto-resume
+
+Two reasons the asymmetry is deliberate:
+
+1. *Auto-resume could silently invert intentional kills.* If you halted
+ because a session was misbehaving, removing HALT shouldn't quietly revive
+ that session's polling. You re-engage explicitly so you're aware of which
+ sessions came back online.
+
+2. *You may want to inspect before resuming.* After a halt, you might want to
+ read pending messages, fix configuration, or kill a particular Claude
+ session entirely. Per-session resume forces that pause.
+
+## Re-engaging polling in a Claude session
+
+After `cross-agent-resume`, open the relevant Claude session and say something
+like:
+
+```
+HALT is cleared; resume polling.
+```
+
+The agent will check the HALT file (now absent), re-create its polling
+schedule, and continue the in-flight conversation from wherever it left off.
+The conversation file is intact; the receiver will pick up any new messages
+that arrived during the halt window.
+
+## Failure modes
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| HALT file doesn't exist | Already resumed (or never halted) | OK — `-f` makes this a no-op. |
+| `systemctl --user start` fails | Watcher service not installed | Install per `cross-agent-watch.md`'s systemd recipe. |
+| `--tailnet` resumes some peers but not others | Same as halt: peer unreachable | Per-peer status reported; resolve manually for unreachable peers. |
+| Permission denied removing HALT file | File owned by another user | Check ownership; HALT files should be owned by the running user. |
+
+## Examples
+
+```bash
+# Local resume after a halt
+cross-agent-resume
+
+# Resume all tailnet peers + local
+cross-agent-resume --tailnet
+```
+
+## Recovery flow
+
+After a halt:
+
+1. Investigate whatever caused the halt (runaway loop, bad config, etc.).
+2. Fix the underlying issue.
+3. Run `cross-agent-resume`.
+4. Open each Claude session that was polling and tell its agent to re-engage.
+5. Confirm operation with `cross-agent-status`.
+
+## See also
+
+- `cross-agent-halt` — counterpart that creates the HALT file.
+- `cross-agent-status` — verify HALT cleared and see pending messages.
+- `cross-agent-comms.org` — protocol spec, `* Halt mechanism` section.
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-send b/.ai/scripts/cross-agent-comms/cross-agent-send
new file mode 100755
index 0000000..68c010a
--- /dev/null
+++ b/.ai/scripts/cross-agent-comms/cross-agent-send
@@ -0,0 +1,356 @@
+#!/usr/bin/env python3
+"""Cross-agent message sender.
+
+See cross-agent-send.md for the full contract. Briefly:
+
+- Destination as <machine>.<project>; resolved via peers.toml.
+- Same-machine: cp to receiver's inbox/from-agents/ with atomic rename.
+- Cross-machine: rsync over SSH (typically Tailscale) with retry+backoff.
+- GPG-signs by default; .asc renames before .org so receivers never see
+ a .org without its sibling signature.
+- Generates the canonical filename; user's input filename is ignored.
+- Honors the HALT file: refuses to send and exits with code 5 when set.
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime as _dt
+import json
+import os
+import re
+import shutil
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+import tomllib
+from pathlib import Path
+
+CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms"
+PEERS_TOML = CONFIG_DIR / "peers.toml"
+HALT_FILE = CONFIG_DIR / "HALT"
+STATE_DIR = Path.home() / ".local" / "state" / "cross-agent-comms"
+FAILED_SENDS_DIR = STATE_DIR / "failed-sends"
+
+EXIT_OK = 0
+EXIT_GENERAL = 1
+EXIT_DEST_NOT_FOUND = 2
+EXIT_CROSS_MACHINE_FAILED = 3
+EXIT_FRONTMATTER = 4
+EXIT_HALT = 5
+
+REQUIRED_FRONTMATTER = ["CONVERSATION_ID", "MESSAGE_TYPE", "SEQUENCE", "TIMESTAMP", "PROTOCOL_VERSION"]
+VALID_MESSAGE_TYPES = {"request", "progress", "query", "pushback", "complete", "release", "escalate"}
+
+
+def err(msg: str) -> None:
+ print(msg, file=sys.stderr)
+
+
+def check_halt() -> None:
+ """Exit with code 5 if HALT file exists."""
+ if HALT_FILE.exists():
+ try:
+ reason = HALT_FILE.read_text().strip()
+ except OSError:
+ # Fail-closed on unreadable HALT.
+ err("halt active (HALT file present but unreadable; treated as halted)")
+ err(f"remove {HALT_FILE} to resume")
+ sys.exit(EXIT_HALT)
+ msg = "halt active"
+ if reason:
+ msg += f": {reason}"
+ err(msg)
+ err(f"remove {HALT_FILE} to resume")
+ sys.exit(EXIT_HALT)
+
+
+def parse_frontmatter(path: Path) -> dict[str, str]:
+ """Extract org-mode #+KEY: value frontmatter from the top of the file."""
+ try:
+ text = path.read_text()
+ except OSError as e:
+ err(f"cannot read message file: {e}")
+ sys.exit(EXIT_GENERAL)
+
+ frontmatter: dict[str, str] = {}
+ for line in text.splitlines():
+ line = line.rstrip()
+ if not line:
+ # Blank line ends the frontmatter block.
+ if frontmatter:
+ break
+ continue
+ m = re.match(r"#\+([A-Z_]+):\s*(.*)", line)
+ if m:
+ frontmatter[m.group(1)] = m.group(2).strip()
+ else:
+ # First non-frontmatter line ends parsing.
+ if frontmatter:
+ break
+ return frontmatter
+
+
+def validate_frontmatter(fm: dict[str, str]) -> None:
+ missing = [k for k in REQUIRED_FRONTMATTER if k not in fm]
+ if missing:
+ err(f"frontmatter missing required fields: {', '.join(missing)}")
+ sys.exit(EXIT_FRONTMATTER)
+ if fm["MESSAGE_TYPE"] not in VALID_MESSAGE_TYPES:
+ err(f"invalid MESSAGE_TYPE: {fm['MESSAGE_TYPE']!r}; expected one of {sorted(VALID_MESSAGE_TYPES)}")
+ sys.exit(EXIT_FRONTMATTER)
+ try:
+ int(fm["SEQUENCE"])
+ except ValueError:
+ err(f"SEQUENCE must be an integer; got {fm['SEQUENCE']!r}")
+ sys.exit(EXIT_FRONTMATTER)
+
+
+def load_peers() -> dict:
+ if not PEERS_TOML.exists():
+ return {}
+ try:
+ return tomllib.loads(PEERS_TOML.read_text())
+ except (tomllib.TOMLDecodeError, OSError) as e:
+ err(f"cannot read {PEERS_TOML}: {e}")
+ sys.exit(EXIT_GENERAL)
+
+
+def resolve_destination(dest: str, peers: dict) -> tuple[str, str, str | None, str | None]:
+ """Resolve <machine>.<project> to (machine, project, host, ssh_user).
+
+ host is None for same-machine destinations.
+ """
+ if "." not in dest:
+ err(f"destination must be <machine>.<project>; got {dest!r}")
+ sys.exit(EXIT_DEST_NOT_FOUND)
+ machine, project = dest.split(".", 1)
+
+ local_hostname = socket.gethostname().split(".")[0]
+ is_local = machine == local_hostname or machine == "local"
+
+ host = None
+ ssh_user = None
+ if not is_local:
+ peer_cfg = peers.get("peers", {}).get(machine)
+ if peer_cfg is None:
+ available = list(peers.get("peers", {}).keys())
+ err(f"destination not found in peers.toml; available peers: {available or '(none)'}")
+ sys.exit(EXIT_DEST_NOT_FOUND)
+ host = peer_cfg.get("host", machine)
+ ssh_user = peer_cfg.get("ssh_user", os.environ.get("USER"))
+
+ return machine, project, host, ssh_user
+
+
+def resolve_inbox_path(project: str, peers: dict) -> str:
+ """Inbox path on the receiver. Defaults to ~/projects/<project>/inbox/from-agents."""
+ proj_cfg = peers.get("projects", {}).get(project)
+ if proj_cfg and "inbox_path" in proj_cfg:
+ return os.path.expanduser(proj_cfg["inbox_path"])
+ return f"~/projects/{project}/inbox/from-agents"
+
+
+def derive_sender_project() -> str:
+ """Walk up from CWD looking for ~/projects/<name>/.
+
+ Returns the project name if found; falls back to the basename of CWD.
+ """
+ cwd = Path.cwd().resolve()
+ projects_root = (Path.home() / "projects").resolve()
+ try:
+ rel = cwd.relative_to(projects_root)
+ return rel.parts[0]
+ except ValueError:
+ return cwd.name
+
+
+def generate_canonical_filename(sender: str, conv_id: str) -> str:
+ """YYYYMMDDTHHMMSSZ-from-<sender>-<conv-id>.org"""
+ now = _dt.datetime.now(_dt.timezone.utc)
+ timestamp = now.strftime("%Y%m%dT%H%M%SZ")
+ return f"{timestamp}-from-{sender}-{conv_id}.org"
+
+
+def sign(message_path: Path, sig_path: Path, key: str | None) -> None:
+ """gpg --detach-sign --armor --output <sig> [--local-user <key>] <message>"""
+ cmd = ["gpg", "--detach-sign", "--armor", "--yes", "--output", str(sig_path)]
+ if key:
+ cmd.extend(["--local-user", key])
+ cmd.append(str(message_path))
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ except FileNotFoundError:
+ err("gpg not found; install gnupg or use --no-sign for testing")
+ sys.exit(EXIT_GENERAL)
+ if result.returncode != 0:
+ err(f"signing failed: {result.stderr.strip()}")
+ sys.exit(EXIT_GENERAL)
+
+
+def same_machine_deliver(message_path: Path, sig_path: Path | None, target_dir: Path, canonical_name: str) -> None:
+ """Atomic-write delivery: stage .asc, mv to final, then stage .org, mv to final."""
+ target_dir.mkdir(parents=True, exist_ok=True)
+ final_msg = target_dir / canonical_name
+ final_sig = target_dir / f"{canonical_name}.asc"
+
+ if sig_path is not None:
+ # Stage .asc first, mv to final, THEN stage .org and mv to final.
+ with tempfile.NamedTemporaryFile(
+ mode="wb", dir=target_dir, prefix=f".tmp.{canonical_name}.asc.", delete=False
+ ) as tmp:
+ tmp.write(sig_path.read_bytes())
+ tmp_sig_path = Path(tmp.name)
+ os.replace(tmp_sig_path, final_sig)
+
+ # Re-check HALT between .asc and .org per the layered-checks rule.
+ check_halt()
+
+ with tempfile.NamedTemporaryFile(
+ mode="wb", dir=target_dir, prefix=f".tmp.{canonical_name}.", delete=False
+ ) as tmp:
+ tmp.write(message_path.read_bytes())
+ tmp_msg_path = Path(tmp.name)
+ os.replace(tmp_msg_path, final_msg)
+
+
+def cross_machine_deliver(
+ message_path: Path,
+ sig_path: Path | None,
+ canonical_name: str,
+ host: str,
+ ssh_user: str,
+ inbox_path: str,
+ retries: int,
+) -> bool:
+ """rsync push the .asc first (if signed), re-check HALT, then push the .org.
+
+ Returns True on success, False on persistent failure (after retries).
+ """
+ # Stage local copies with the canonical name so rsync sets the right
+ # destination filename.
+ with tempfile.TemporaryDirectory(prefix="cross-agent-send-") as staging:
+ staging_dir = Path(staging)
+ local_msg = staging_dir / canonical_name
+ local_msg.write_bytes(message_path.read_bytes())
+ local_sig = None
+ if sig_path is not None:
+ local_sig = staging_dir / f"{canonical_name}.asc"
+ local_sig.write_bytes(sig_path.read_bytes())
+
+ backoffs = [5, 30, 120]
+ # Step 1: push .asc first if signed.
+ if local_sig is not None:
+ if not _rsync_with_retries(local_sig, host, ssh_user, inbox_path, retries, backoffs):
+ return False
+
+ # Re-check HALT between .asc and .org per the layered-checks rule.
+ check_halt()
+
+ # Step 2: push .org.
+ if not _rsync_with_retries(local_msg, host, ssh_user, inbox_path, retries, backoffs):
+ return False
+
+ return True
+
+
+def _rsync_with_retries(
+ src: Path, host: str, ssh_user: str, inbox_path: str, retries: int, backoffs: list[int]
+) -> bool:
+ target = f"{ssh_user}@{host}:{inbox_path}/"
+ last_err = ""
+ for attempt in range(retries + 1):
+ if attempt > 0:
+ check_halt()
+ wait = backoffs[min(attempt - 1, len(backoffs) - 1)]
+ err(f"rsync attempt {attempt} failed: {last_err}; retrying in {wait}s")
+ time.sleep(wait)
+ try:
+ result = subprocess.run(
+ ["rsync", "-a", str(src), target],
+ capture_output=True,
+ text=True,
+ )
+ except FileNotFoundError:
+ err("rsync not found; install rsync")
+ return False
+ if result.returncode == 0:
+ return True
+ last_err = result.stderr.strip() or f"exit {result.returncode}"
+ err(f"rsync failed after {retries + 1} attempts: {last_err}")
+ return False
+
+
+def write_failed_send_marker(dest: str, message_path: Path, error: str, retry_log: list[str]) -> None:
+ FAILED_SENDS_DIR.mkdir(parents=True, exist_ok=True)
+ timestamp = _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+ safe_basename = re.sub(r"[^A-Za-z0-9._-]", "_", message_path.name)
+ marker = FAILED_SENDS_DIR / f"{timestamp}-{dest.replace('.', '-')}-{safe_basename}.json"
+ marker.write_text(json.dumps(
+ {
+ "timestamp": timestamp,
+ "destination": dest,
+ "message_path": str(message_path),
+ "error": error,
+ "retry_log": retry_log,
+ },
+ indent=2,
+ ))
+ err(f"marker written: {marker}")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Send a cross-agent message.")
+ parser.add_argument("destination", help="Destination as <machine>.<project>")
+ parser.add_argument("message_file", type=Path, help="Path to the message body file")
+ parser.add_argument("--no-sign", action="store_true", help="Skip GPG signing (testing only)")
+ parser.add_argument("--retries", type=int, default=3, help="Retry count for cross-machine sends")
+ parser.add_argument("--key", help="GPG key id to sign with (default: user's primary)")
+ args = parser.parse_args()
+
+ check_halt()
+
+ if not args.message_file.is_file():
+ err(f"message file not found: {args.message_file}")
+ return EXIT_GENERAL
+
+ fm = parse_frontmatter(args.message_file)
+ validate_frontmatter(fm)
+
+ peers = load_peers()
+ machine, project, host, ssh_user = resolve_destination(args.destination, peers)
+ inbox_path = resolve_inbox_path(project, peers)
+
+ sender = derive_sender_project()
+ canonical_name = generate_canonical_filename(sender, fm["CONVERSATION_ID"])
+
+ sig_tmp = None
+ if not args.no_sign:
+ sig_tmp = args.message_file.with_suffix(args.message_file.suffix + ".asc.tmp")
+ sign(args.message_file, sig_tmp, args.key)
+
+ try:
+ if host is None:
+ # Same-machine delivery.
+ target_dir = Path(os.path.expanduser(inbox_path))
+ same_machine_deliver(args.message_file, sig_tmp, target_dir, canonical_name)
+ print(f"sent: {target_dir}/{canonical_name}")
+ return EXIT_OK
+ else:
+ ok = cross_machine_deliver(
+ args.message_file, sig_tmp, canonical_name, host, ssh_user, inbox_path, args.retries
+ )
+ if ok:
+ print(f"sent: {ssh_user}@{host}:{inbox_path}/{canonical_name}")
+ return EXIT_OK
+ write_failed_send_marker(args.destination, args.message_file, "rsync failed after retries", [])
+ return EXIT_CROSS_MACHINE_FAILED
+ finally:
+ if sig_tmp is not None and sig_tmp.exists():
+ sig_tmp.unlink()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-send.md b/.ai/scripts/cross-agent-comms/cross-agent-send.md
new file mode 100644
index 0000000..29bfb24
--- /dev/null
+++ b/.ai/scripts/cross-agent-comms/cross-agent-send.md
@@ -0,0 +1,199 @@
+# cross-agent-send
+
+**Purpose.** Send a cross-agent message file to a specific destination. Handles
+peer-config lookup, GPG signing, atomic write (same-machine) or rsync push
+(cross-machine), retry-with-backoff, and failure surfacing.
+
+This is the canonical writer. The protocol spec defers all writer mechanics to
+this script.
+
+## Usage
+
+```
+cross-agent-send <destination> <message-file> [--no-sign] [--retries N]
+```
+
+### Positional arguments
+
+| Position | Meaning | Example |
+|---|---|---|
+| 1 | Destination as `<machine>.<project>` | `homelab.career`, `velox.career` |
+| 2 | Message file (already-formatted `.org`) | `/tmp/my-message.org` |
+
+### Flags
+
+| Flag | Default | Purpose |
+|---|---|---|
+| `--no-sign` | (signing on) | Skip GPG signing. Use only for testing; receivers reject unsigned messages by default. |
+| `--retries N` | 3 | Override retry count for cross-machine sends. |
+| `--key <key-id>` | (user's primary key) | GPG key to sign with. Resolution order: `--key` flag, `GPG_USER` env, `git config user.signingkey`, then the first secret key in the keyring. |
+
+## Behavior
+
+### Filename generation (script-controlled)
+
+The script generates the canonical destination filename from the message's
+frontmatter and sender context. The user's input filename is ignored — pass any
+path, the script names the destination correctly:
+
+```
+<UTC-now>T<HHMMSS>Z-from-<sender-slug>-<short-conv-id>.org
+```
+
+`<sender-slug>` comes from the sender machine's project name (config or
+hostname-based). `<short-conv-id>` is read from the message's
+`#+CONVERSATION_ID` frontmatter field. UTC timestamp is generated at send time.
+
+The script also performs the **sender-side max-seen scan** before writing: it
+reads the receiver's `from-agents/` directory, finds the highest existing
+sequence in this conversation across both sender prefixes, and (best-effort)
+suggests `max(seen) + 1` for the next sequence. The user/agent is responsible
+for setting `#+SEQUENCE` in the message body; the script only advises.
+
+### Same-machine destinations
+
+Resolved when the destination's machine matches the current hostname (or is
+not in `peers.toml` as a remote). Steps:
+
+1. Parse frontmatter; extract `CONVERSATION_ID` and `TIMESTAMP`. Validate per
+ the *Validation before send* section below.
+2. Generate canonical filename per *Filename generation* above.
+3. Sign: `gpg --detach-sign --armor --output <canonical>.asc --local-user <key> <input>`.
+4. Compute target: read `peers.toml` for the project's `inbox_path`. If
+ missing, fall back to `~/projects/<project>/inbox/from-agents/`.
+5. **Atomic write with strict ordering** (signature must precede message):
+ - Stage `.asc`: write to `<target>/.tmp.XXXXXX-<canonical>.asc`,
+ then `mv` to `<target>/<canonical>.asc`.
+ - **Then** stage `.org`: write to `<target>/.tmp.XXXXXX-<canonical>`,
+ then `mv` to `<target>/<canonical>`.
+ - Receivers only act on `.org` files; staging the `.asc` first guarantees
+ the signature is present when the receiver opens the message. Out-of-order
+ would race: receiver could read the `.org` before the `.asc` lands and
+ fail GPG verify even though the sender did everything right.
+6. Exit 0 on success. Exit non-zero if any step fails.
+
+### Cross-machine destinations
+
+Steps:
+
+1. Parse + generate canonical filename, as same-machine steps 1-2.
+2. Sign locally to `<input>.asc` (or a tmp staging file).
+3. rsync push **with the same .asc-first ordering**:
+ - `rsync -a <input>.asc <ssh-user>@<host>:<inbox_path>/<canonical>.asc`
+ - **Then** `rsync -a <input> <ssh-user>@<host>:<inbox_path>/<canonical>`
+ rsync writes to a hidden temp file then renames atomically by default
+ (`--inplace` would defeat this; do not pass it).
+4. Retry on failure: 5s, 30s, 120s backoff, then surface error.
+5. On persistent failure: write a marker file to
+ `~/.local/state/cross-agent-comms/failed-sends/<timestamp>-<dest>-<canonical>.json`
+ containing the destination, message path, error, and retry log. Exit non-zero.
+
+### Validation before send
+
+- Destination resolves via `peers.toml` (or local fallback). If neither, exit
+ immediately with `destination not found in peers.toml; available: <list>`.
+- Message file must be readable, non-empty, and have valid org-mode frontmatter
+ with **all** of the following required fields:
+ - `#+TITLE`
+ - `#+CONVERSATION_ID`
+ - `#+MESSAGE_TYPE`
+ - `#+SEQUENCE`
+ - `#+TIMESTAMP`
+ - `#+PROTOCOL_VERSION` (must equal `5` for v5)
+
+ If any required field is missing or malformed, exit immediately with a parse
+ error naming the offending field.
+
+- Optional fields the script recognizes and passes through (no special
+ handling beyond preservation):
+ - `#+REQUIRES_TOOLS` — comma-separated tool/MCP slugs the receiver needs.
+ - `#+RELEASE_STATUS` — valid only on `MESSAGE_TYPE: release`. Values per
+ spec: `complete`, `cancelled`, `withdrawn-after-pushback`,
+ `abandoned-after-escalation`.
+ - `#+WORKFLOW_VERSION` — sender's version of the cross-agent-comms workflow
+ file. Currently advisory; receiver may warn on mismatch but does not block.
+
+## Configuration
+
+Reads `~/.config/cross-agent-comms/peers.toml` for peer routing:
+
+```toml
+[peers.velox]
+host = "velox.local"
+ssh_user = "cjennings"
+
+# Optional: per-project inbox-path overrides for non-default layouts.
+[projects.work]
+inbox_path = "~/projects/work/inbox/from-agents"
+
+[projects.homelab]
+inbox_path = "~/projects/homelab/inbox/from-agents"
+```
+
+If a project entry is omitted, defaults to `~/projects/<project>/inbox/from-agents`.
+
+## Failure modes
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| `destination not found in peers.toml` | Misspelled destination, or peer not configured | Run `cross-agent-discover` to see available destinations. |
+| `signing failed: no secret key` | GPG key missing or not in keyring | `gpg --list-secret-keys` to confirm. Override with `--key <id>`. |
+| `signing failed: pinentry timed out` | Headless session, GUI pinentry unavailable | Confirm `pinentry-program` in `gpg-agent.conf` matches available pinentry. Per protocols.org, GUI pinentry works from Claude Code. |
+| `rsync exit 255` | SSH unreachable | `cross-agent-discover --peer <name>` to confirm reachability. |
+| `rsync exit 23` | Permission denied at destination | Check destination directory perms (`chmod 700`) and ownership. |
+| Marker file written to `failed-sends/` | Persistent cross-machine failure | Inspect the marker's `error` field. After fixing, retry: `cross-agent-send <dest> <msg>` (the marker is for visibility; it does not auto-retry). |
+| Receiver complains "unsigned message" | `--no-sign` was used in production | Don't use `--no-sign` outside testing. |
+
+## HALT awareness
+
+Checks `~/.config/cross-agent-comms/HALT` at the start of every send AND
+between the `.asc` and `.org` rsync calls AND between each retry iteration.
+On HALT exists, exits with code 5 ("halt active; remove
+~/.config/cross-agent-comms/HALT to resume") without writing or pushing
+further.
+
+Worst case: one in-flight send completes its current rsync step within a few
+seconds before halt kicks in for the next step. New sends are blocked
+immediately. No `pkill` needed — the per-iteration check stops things
+naturally.
+
+If the HALT file exists but is unreadable (permissions wrong), fail-closed —
+treat as if HALT is set. Safer than fail-open.
+
+See `cross-agent-halt.md` for the full halt mechanism.
+
+## Examples
+
+```bash
+# Same-machine send
+cross-agent-send homelab.career /tmp/my-message.org
+
+# Cross-machine send via Tailscale
+cross-agent-send velox.career /tmp/my-message.org
+
+# Test send without signing (receiver will reject)
+cross-agent-send homelab.career /tmp/test.org --no-sign
+
+# Override retry count for a flaky link
+cross-agent-send velox.career /tmp/my-message.org --retries 10
+
+# After a delivery failure, inspect the marker
+cat ~/.local/state/cross-agent-comms/failed-sends/*.json | jq .
+```
+
+## Exit codes
+
+| Code | Meaning |
+|---|---|
+| 0 | Sent successfully. |
+| 1 | General error (parse failure, signing failure, etc.). |
+| 2 | Destination not found in peers.toml. |
+| 3 | Cross-machine delivery failed after retries. Marker file written. |
+| 4 | Frontmatter validation failed. |
+
+## See also
+
+- `cross-agent-discover` — validate destinations before sending.
+- `cross-agent-watch` — receiver-side notification.
+- `cross-agent-status` — see what's queued.
+- `cross-agent-comms.org` — protocol spec, the "what" the script implements.
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-status b/.ai/scripts/cross-agent-comms/cross-agent-status
new file mode 100755
index 0000000..4eee75b
--- /dev/null
+++ b/.ai/scripts/cross-agent-comms/cross-agent-status
@@ -0,0 +1,185 @@
+#!/usr/bin/env python3
+"""Point-in-time snapshot of pending cross-agent messages across local projects.
+
+See cross-agent-status.md. Pending = messages in inbox/from-agents/ whose
+CONVERSATION_ID has no MESSAGE_TYPE: release at a later #+TIMESTAMP.
+
+HALT: prints a prominent banner before normal output, but continues to enumerate.
+"""
+
+from __future__ import annotations
+
+import argparse
+import glob
+import json
+import os
+import re
+import sys
+from pathlib import Path
+
+CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms"
+HALT_FILE = CONFIG_DIR / "HALT"
+DEFAULT_GLOB = str(Path.home() / "projects" / "*" / "inbox" / "from-agents") + "/"
+
+
+def parse_frontmatter(path: Path) -> dict[str, str]:
+ try:
+ text = path.read_text()
+ except OSError:
+ return {}
+ fm: dict[str, str] = {}
+ for line in text.splitlines():
+ line = line.rstrip()
+ if not line:
+ if fm:
+ break
+ continue
+ m = re.match(r"#\+([A-Z_]+):\s*(.*)", line)
+ if m:
+ fm[m.group(1)] = m.group(2).strip()
+ elif fm:
+ break
+ return fm
+
+
+def project_name_from_path(path: str) -> str:
+ """Walk up from path to find ~/projects/<name>/..."""
+ home = str(Path.home())
+ parts = Path(path).parts
+ for i, part in enumerate(parts):
+ if part == "projects" and i + 1 < len(parts) and str(Path(*parts[: i + 1])) == os.path.join(home, "projects"):
+ return parts[i + 1]
+ # Fallback: dir three levels up from the .org file (project/inbox/from-agents/file.org)
+ return Path(path).parent.parent.parent.name
+
+
+def scan_project(inbox_dir: Path) -> tuple[int, str | None, int | None]:
+ """Return (pending_count, most_recent_filename_or_None, most_recent_age_seconds_or_None)."""
+ if not inbox_dir.is_dir():
+ return 0, None, None
+
+ # Group .org files by CONVERSATION_ID, also collect release timestamps per conv.
+ org_files = sorted(inbox_dir.glob("*.org"))
+ if not org_files:
+ return 0, None, None
+
+ by_conv: dict[str, list[tuple[str, str, Path]]] = {} # conv_id -> [(timestamp, msg_type, path)]
+ for f in org_files:
+ fm = parse_frontmatter(f)
+ conv = fm.get("CONVERSATION_ID")
+ ts = fm.get("TIMESTAMP")
+ mt = fm.get("MESSAGE_TYPE")
+ if not conv or not ts or not mt:
+ # Malformed file: count as pending under conv "_unparseable".
+ by_conv.setdefault("_unparseable", []).append(("", "request", f))
+ continue
+ by_conv.setdefault(conv, []).append((ts, mt, f))
+
+ pending_files: list[Path] = []
+ for conv, entries in by_conv.items():
+ entries.sort(key=lambda e: e[0])
+ # Find the latest release timestamp.
+ release_ts = None
+ for ts, mt, _f in entries:
+ if mt == "release" and (release_ts is None or ts > release_ts):
+ release_ts = ts
+ for ts, mt, f in entries:
+ if mt == "release":
+ continue
+ if release_ts is not None and ts <= release_ts:
+ continue
+ pending_files.append(f)
+
+ if not pending_files:
+ return 0, None, None
+
+ # Most-recent by mtime (proxy for arrival order).
+ most_recent = max(pending_files, key=lambda p: p.stat().st_mtime)
+ import time
+ age = int(time.time() - most_recent.stat().st_mtime)
+ return len(pending_files), most_recent.name, age
+
+
+def fmt_age(seconds: int | None) -> str:
+ if seconds is None:
+ return "—"
+ if seconds < 60:
+ return f"{seconds}s ago"
+ if seconds < 3600:
+ return f"{seconds // 60} min ago"
+ if seconds < 86400:
+ return f"{seconds // 3600} hr ago"
+ return f"{seconds // 86400} day(s) ago"
+
+
+def render_banner_if_halt() -> None:
+ if not HALT_FILE.exists():
+ return
+ try:
+ reason = HALT_FILE.read_text().strip()
+ except OSError:
+ reason = "(HALT file unreadable; treated as halted)"
+ print("⚠ HALT ACTIVE — cross-agent comms paused")
+ if reason:
+ print(f" reason: {reason}")
+ print(f" clear: rm {HALT_FILE} (or: cross-agent-resume)")
+ print()
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Snapshot of pending cross-agent messages across local projects.")
+ parser.add_argument("--json", action="store_true", help="Emit JSON output")
+ parser.add_argument("--projects-glob", default=DEFAULT_GLOB,
+ help=f"Glob for project from-agents dirs (default: {DEFAULT_GLOB})")
+ args = parser.parse_args()
+
+ render_banner_if_halt()
+
+ matched = sorted(glob.glob(args.projects_glob))
+ rows = []
+ for path in matched:
+ inbox = Path(path)
+ if not inbox.is_dir():
+ continue
+ proj = project_name_from_path(path)
+ count, most_recent, age = scan_project(inbox)
+ rows.append({
+ "name": proj,
+ "pending_count": count,
+ "most_recent": (
+ {"filename": most_recent, "age_seconds": age}
+ if most_recent else None
+ ),
+ })
+
+ # Sort: pending-first, then alphabetical by name.
+ rows.sort(key=lambda r: (-r["pending_count"], r["name"]))
+
+ if args.json:
+ import datetime as _dt
+ payload = {
+ "scanned_at": _dt.datetime.now(_dt.timezone.utc).isoformat(),
+ "halt_active": HALT_FILE.exists(),
+ "projects": rows,
+ }
+ print(json.dumps(payload, indent=2))
+ return 0
+
+ if not rows:
+ print("No projects with inbox/from-agents/ found — 0 pending.")
+ return 0
+
+ # Human-readable table.
+ name_w = max(len("project"), max(len(r["name"]) for r in rows))
+ print(f"{'project':<{name_w}} pending most-recent")
+ for r in rows:
+ most_recent_str = "—"
+ if r["most_recent"]:
+ most_recent_str = f"{r['most_recent']['filename']} ({fmt_age(r['most_recent']['age_seconds'])})"
+ print(f"{r['name']:<{name_w}} {r['pending_count']:<7} {most_recent_str}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-status.md b/.ai/scripts/cross-agent-comms/cross-agent-status.md
new file mode 100644
index 0000000..070330c
--- /dev/null
+++ b/.ai/scripts/cross-agent-comms/cross-agent-status.md
@@ -0,0 +1,139 @@
+# cross-agent-status
+
+**Purpose.** Point-in-time snapshot of pending cross-agent messages across
+every project on this machine. Run from any terminal. No daemon required.
+
+This is the user-pull layer of the cold-start story — `cross-agent-watch`
+pushes notifications, `cross-agent-status` lets the user query.
+
+## Usage
+
+```
+cross-agent-status [--json] [--projects-glob <glob>]
+```
+
+No args required.
+
+### Flags
+
+| Flag | Default | Purpose |
+|---|---|---|
+| `--json` | off (table) | Output as JSON for scripting. |
+| `--projects-glob <glob>` | `~/projects/*/inbox/from-agents/` | Override which directories to scan. |
+
+## Output
+
+### Default (table)
+
+```
+$ cross-agent-status
+project pending most-recent
+career 0 —
+claude-templates 0 —
+clipper 0 —
+homelab 1 20260427T085611Z-from-career-question.org (3 min ago)
+finances 0 —
+... (other 9 projects)
+```
+
+Sort: pending-first, then alphabetical.
+
+### `--json`
+
+```json
+{
+ "scanned_at": "2026-04-27T04:13:00-05:00",
+ "projects": [
+ {
+ "name": "homelab",
+ "pending_count": 1,
+ "most_recent": {
+ "filename": "20260427T085611Z-from-career-question.org",
+ "age_seconds": 180
+ }
+ },
+ ...
+ ]
+}
+```
+
+## Pending semantics
+
+A message is "pending" if it sits in `inbox/from-agents/` AND no
+`MESSAGE_TYPE: release` exists for the same `CONVERSATION_ID` after it.
+
+Concretely:
+
+1. Scan each project's `inbox/from-agents/` for `.org` files.
+2. Group by `CONVERSATION_ID` from frontmatter.
+3. For each conversation, find the highest-`#+TIMESTAMP` message with
+ `MESSAGE_TYPE: release`.
+4. Messages with `#+TIMESTAMP` after that release (or in conversations with no
+ release) count as pending.
+
+Files without parseable frontmatter are counted as pending and noted in the
+output (single warning row per project).
+
+## Failure modes
+
+| Symptom | Likely cause | Fix |
+|---|---|---|
+| Project missing from output | Project's `.ai/` directory exists but `inbox/from-agents/` does not | Created lazily on first cross-agent message; `mkdir -p` to surface in output. |
+| All projects show "0 pending" but you know one has messages | Glob misresolved, OR all messages are post-release | `cross-agent-status --projects-glob` with explicit path to confirm. |
+| Warning row "N files unparseable in <project>" | Message file has invalid frontmatter | Open the file, fix or move out. |
+
+## Performance
+
+Scans every `.org` file in every watched directory. For Craig's setup (14
+projects, single-digit messages each), runs in <100ms. If a project
+accumulates hundreds of post-release messages, archive them per the persistence
+guidance in the protocol spec.
+
+## HALT awareness
+
+Checks `~/.config/cross-agent-comms/HALT` at start. If HALT exists, prints a
+prominent banner before normal output:
+
+```
+$ cross-agent-status
+⚠ HALT ACTIVE — cross-agent comms paused
+ Reason: investigating runaway poll loop, 2026-04-27
+ HALT file: ~/.config/cross-agent-comms/HALT
+ Resume with: cross-agent-resume
+
+(snapshot continues normally — HALT does not suppress visibility)
+
+project pending most-recent
+career 0 —
+homelab 1 20260427T085611Z-from-career-question.org (3 min ago)
+...
+```
+
+Status is read-only, so it always runs. The banner ensures the user can't
+miss that halt is active when checking inbox state. Reason text comes from
+the HALT file's body; if empty, omit the reason line.
+
+If the HALT file exists but is unreadable, print a warning banner ("HALT
+file present but unreadable; treat as halted") and continue with normal
+output.
+
+See `cross-agent-halt.md` for the full halt mechanism.
+
+## Examples
+
+```bash
+# Snapshot
+cross-agent-status
+
+# JSON for piping
+cross-agent-status --json | jq '.projects[] | select(.pending_count > 0)'
+
+# Single-project query
+cross-agent-status --projects-glob ~/projects/work/inbox/from-agents/
+```
+
+## See also
+
+- `cross-agent-watch` — push notifications on new arrivals.
+- `cross-agent-discover` — enumerate available agents (cross-machine).
+- `cross-agent-comms.org` — protocol spec.
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-watch b/.ai/scripts/cross-agent-comms/cross-agent-watch
new file mode 100755
index 0000000..3978f49
--- /dev/null
+++ b/.ai/scripts/cross-agent-comms/cross-agent-watch
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+# cross-agent-watch — desktop-notify on new cross-agent messages.
+#
+# See cross-agent-watch.md. Watches every ~/projects/*/inbox/from-agents/ by
+# default. inotifywait fires create + moved_to events; .tmp.* files are
+# filtered out. HALT suppresses notifications but the watcher keeps running
+# and logs each event with "(suppressed by HALT)".
+
+set -uo pipefail
+
+# Defaults.
+PROJECTS_GLOB="${HOME}/projects/*/inbox/from-agents/"
+LOG_FILE="${HOME}/.local/state/cross-agent-comms/watch.log"
+HALT_FILE="${HOME}/.config/cross-agent-comms/HALT"
+QUIET=0
+NO_NOTIFY=0
+
+# Arg parsing.
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --projects-glob)
+ PROJECTS_GLOB="$2"; shift 2 ;;
+ --log)
+ LOG_FILE="$2"; shift 2 ;;
+ --quiet)
+ QUIET=1; shift ;;
+ --no-notify)
+ NO_NOTIFY=1; shift ;;
+ -h|--help)
+ cat <<EOF
+Usage: cross-agent-watch [--projects-glob GLOB] [--log PATH] [--quiet] [--no-notify]
+
+Watches inbox/from-agents/ directories for new cross-agent messages and fires
+desktop notifications. See cross-agent-watch.md for details.
+EOF
+ exit 0 ;;
+ *)
+ echo "unknown flag: $1" >&2; exit 1 ;;
+ esac
+done
+
+# Resolve glob to a concrete list of directories.
+# shellcheck disable=SC2086
+DIRS=( $PROJECTS_GLOB )
+# Filter out non-existent paths (glob may include literal pattern when no match).
+EXISTING=()
+for d in "${DIRS[@]}"; do
+ if [[ -d "$d" ]]; then
+ EXISTING+=( "$d" )
+ fi
+done
+
+if [[ ${#EXISTING[@]} -eq 0 ]]; then
+ echo "cross-agent-watch: glob resolved 0 directories: $PROJECTS_GLOB" >&2
+ exit 1
+fi
+
+# Ensure log dir exists.
+mkdir -p "$(dirname "$LOG_FILE")"
+
+[[ $QUIET -eq 0 ]] && echo "cross-agent-watch: watching ${#EXISTING[@]} dir(s); log: $LOG_FILE"
+
+# Helper: project name from path like /home/.../projects/<name>/inbox/from-agents/...
+project_name() {
+ local path="$1"
+ # Match ~/projects/<name>/...
+ if [[ "$path" =~ ${HOME}/projects/([^/]+)/ ]]; then
+ echo "${BASH_REMATCH[1]}"
+ else
+ basename "$(dirname "$(dirname "$path")")"
+ fi
+}
+
+# Main loop. inotifywait emits one line per event in the format
+# "<full-path>" because we passed --format '%w%f'.
+inotifywait -m -e create,moved_to --format '%w%f' "${EXISTING[@]}" 2>/dev/null \
+ | while IFS= read -r path; do
+ filename="$(basename "$path")"
+
+ # Filter .tmp.* staging files.
+ case "$filename" in
+ .tmp.*) continue ;;
+ esac
+
+ # Filter .asc sidecars — they land first per the atomic-write ordering;
+ # the .org event will fire after.
+ case "$filename" in
+ *.asc) continue ;;
+ esac
+
+ proj="$(project_name "$path")"
+ iso="$(date -u "+%Y-%m-%dT%H:%M:%SZ")"
+
+ if [[ -e "$HALT_FILE" ]]; then
+ printf '%s\t%s\t%s\t(suppressed by HALT)\n' "$iso" "$proj" "$filename" >> "$LOG_FILE"
+ [[ $QUIET -eq 0 ]] && echo "[$iso] $proj: $filename (suppressed by HALT)"
+ continue
+ fi
+
+ printf '%s\t%s\t%s\n' "$iso" "$proj" "$filename" >> "$LOG_FILE"
+ [[ $QUIET -eq 0 ]] && echo "[$iso] $proj: $filename"
+
+ if [[ $NO_NOTIFY -eq 0 ]]; then
+ notify info "Cross-agent message" "${proj}: ${filename}" 2>/dev/null || true
+ fi
+ done
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-watch.md b/.ai/scripts/cross-agent-comms/cross-agent-watch.md
new file mode 100644
index 0000000..dd8afc1
--- /dev/null
+++ b/.ai/scripts/cross-agent-comms/cross-agent-watch.md
@@ -0,0 +1,128 @@
+# cross-agent-watch
+
+**Purpose.** Long-running watcher that fires desktop notifications when new
+cross-agent messages land in any project's `inbox/from-agents/` directory.
+This is the primary cold-start mechanism: messages get noticed even when no
+Claude session is active.
+
+## Usage
+
+```
+cross-agent-watch [--projects-glob <glob>] [--log <path>]
+```
+
+No args required. Defaults:
+
+- Watches `~/projects/*/inbox/from-agents/` (matches every project with the
+ cross-agent-comms convention).
+- Logs each event to `~/.local/state/cross-agent-comms/watch.log`.
+
+### Flags
+
+| Flag | Default | Purpose |
+|---|---|---|
+| `--projects-glob <glob>` | `~/projects/*/inbox/from-agents/` | Override which directories to watch. Useful for testing on a single project. |
+| `--log <path>` | `~/.local/state/cross-agent-comms/watch.log` | Override log location. Set to `/dev/null` to disable logging. |
+| `--quiet` | off | Suppress stdout output. Notifications still fire. |
+| `--no-notify` | off | Skip `notify` calls. Useful for testing the watcher loop without spamming notifications. |
+
+## Behavior
+
+1. Resolves the projects-glob to a concrete list of directories at startup.
+ New projects added to `~/projects/` after startup are NOT picked up — restart
+ the watcher to re-resolve.
+2. Runs `inotifywait -m -e create,moved_to --format '%w%f'` against each
+ watched directory.
+3. For each event, calls
+ `notify info "Cross-agent message" "<project>: <filename>"`.
+4. Appends an event line to the log:
+ `<ISO-8601-timestamp>\t<project>\t<filename>`.
+
+## Event filtering
+
+- Watches `create` AND `moved_to` events. The `moved_to` part is critical for
+ the atomic-write convention (`mktemp` + `mv` produces a `moved_to`, not a
+ `create`).
+- Files starting with `.tmp.` are ignored — they're staging files from
+ in-progress writes that should never produce a notification.
+
+## Installation
+
+### Option A — tmux pane (personal, easy)
+
+Run in a tmux pane that survives session disconnects:
+
+```
+tmux new -d -s cross-agent-watch 'cross-agent-watch'
+```
+
+### Option B — systemd user service (production)
+
+Provided files:
+
+- `~/.config/systemd/user/cross-agent-watch.service`
+- `~/.config/systemd/user/cross-agent-watch.path`
+
+Enable with:
+
+```
+systemctl --user enable --now cross-agent-watch.path
+```
+
+The path unit triggers the service unit on filesystem changes; the service
+unit re-execs `cross-agent-watch` if it dies. Survives reboot.
+
+## Failure modes
+
+| Symptom | Likely cause | Fix |
+|---|---|---|
+| No notifications fire on new files | inotifywait not running, or glob resolved to zero dirs | Check `cross-agent-watch --projects-glob ... --quiet` exits non-zero immediately. Log shows `"resolved 0 directories"`. |
+| Notifications fire on `.tmp.` files | Filter regression | Verify `inotifywait` events show the `.tmp.` files; if so check this script's filter logic. |
+| Some files missed under rapid bursts | inotify queue overflow | Increase `fs.inotify.max_queued_events` sysctl. Default 16384 is usually fine. |
+| Permission denied on a watched dir | Directory perms wrong | `chmod 700 <dir>` and confirm owner. |
+
+## HALT awareness
+
+Checks `~/.config/cross-agent-comms/HALT` on each iteration (each inotifywait
+event fired). If HALT exists, the watcher continues running but **suppresses
+the `notify` call**. The event is still logged, with `(suppressed by HALT)`
+appended:
+
+```
+2026-04-27T04:42:00-05:00 career 20260427T094200Z-from-homelab-test.org (suppressed by HALT)
+```
+
+Logged-but-suppressed events are useful for the operator to see what would
+have fired during the halt window — helpful for diagnosing whatever caused
+the halt.
+
+When HALT clears, suppression stops; subsequent events fire normally. Backlog
+events that arrived during halt are NOT replayed — they get picked up via
+cold-start handling (status CLI, agent startup check, or the next agent
+poll once polling resumes).
+
+If the HALT file exists but is unreadable, fail-closed (suppress) — safer
+than fail-open.
+
+See `cross-agent-halt.md` for the full halt mechanism.
+
+## Examples
+
+```bash
+# Watch all projects, log everything, fire notifications
+cross-agent-watch
+
+# Test against a single project, no notifications, verbose
+cross-agent-watch \
+ --projects-glob "$HOME/projects/work/inbox/from-agents/" \
+ --no-notify
+
+# Production-style: quiet stdout, log only
+cross-agent-watch --quiet
+```
+
+## See also
+
+- `cross-agent-status` — point-in-time snapshot of pending messages.
+- `cross-agent-send` — counterpart writer.
+- `cross-agent-comms.org` — protocol spec.
diff --git a/.ai/scripts/daily-prep-agenda.el b/.ai/scripts/daily-prep-agenda.el
new file mode 100644
index 0000000..4c6041c
--- /dev/null
+++ b/.ai/scripts/daily-prep-agenda.el
@@ -0,0 +1,142 @@
+;;; daily-prep-agenda.el --- Standalone batch agenda extractor for daily-prep
+;;
+;; Usage:
+;; emacs --batch -q -l daily-prep-agenda.el todo.org [pcal.org ...]
+;;
+;; Filters entries to TODO/DOING/WAITING/NEXT with [#A]/[#B] priority OR
+;; DEADLINE/SCHEDULED present. Bucketizes into Overdue, Today, This Week,
+;; Priority A (no date), Priority B (no date). Emits heading + body for each.
+
+(require 'org)
+(require 'cl-lib)
+
+;; Declare the TODO keywords used across Craig's org files so org-mode parses
+;; "DOING", "WAITING", "NEXT", "CANCELLED" headings as TODO states. With `-q`,
+;; org-mode defaults to just "TODO"/"DONE" and will treat the others as plain
+;; heading text (state comes back as nil).
+(setq org-todo-keywords
+ '((sequence "TODO" "DOING" "WAITING" "NEXT" "|" "DONE" "CANCELLED")))
+
+(defvar dp-today (format-time-string "%Y-%m-%d"))
+(defvar dp-week-end
+ (format-time-string "%Y-%m-%d" (time-add (current-time) (days-to-time 7))))
+
+(defun dp-iso-date (org-ts)
+ "Extract YYYY-MM-DD from an org timestamp string like '<2026-04-25 Sat 16:00>'."
+ (when (and org-ts (string-match "\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)" org-ts))
+ (match-string 1 org-ts)))
+
+(defun dp-entry-info ()
+ "Return plist of metadata + body for org entry at point."
+ (let* ((state (org-get-todo-state))
+ (el (org-element-at-point))
+ (priority (org-element-property :priority el))
+ (deadline-raw (org-entry-get (point) "DEADLINE"))
+ (scheduled-raw (org-entry-get (point) "SCHEDULED"))
+ (deadline (dp-iso-date deadline-raw))
+ (scheduled (dp-iso-date scheduled-raw))
+ (heading (org-get-heading t t t t))
+ (line (line-number-at-pos))
+ (file (or (buffer-file-name) (buffer-name)))
+ (start (save-excursion (org-end-of-meta-data t) (point)))
+ (end (save-excursion
+ (or (outline-next-heading) (goto-char (point-max)))
+ (point)))
+ (body (and (< start end)
+ (string-trim (buffer-substring-no-properties start end)))))
+ (list :state state
+ :priority priority
+ :deadline deadline
+ :deadline-raw deadline-raw
+ :scheduled scheduled
+ :scheduled-raw scheduled-raw
+ :heading heading
+ :line line
+ :file file
+ :body body)))
+
+(defun dp-active-candidate-p ()
+ "True if entry at point is an active state with [#A]/[#B] OR has DEADLINE/SCHEDULED."
+ (let* ((state (org-get-todo-state))
+ (el (org-element-at-point))
+ (pri (org-element-property :priority el))
+ (dl (org-entry-get (point) "DEADLINE"))
+ (sc (org-entry-get (point) "SCHEDULED")))
+ (and (member state '("TODO" "DOING" "WAITING" "NEXT"))
+ (or (memq pri '(?A ?B)) dl sc))))
+
+(defun dp-collect (files)
+ "Walk FILES, return list of dp-entry-info plists for matching entries."
+ (let (entries)
+ (dolist (file files)
+ (when (file-readable-p file)
+ (with-current-buffer (find-file-noselect file)
+ (org-mode)
+ (org-map-entries
+ (lambda ()
+ (when (dp-active-candidate-p)
+ (push (dp-entry-info) entries)))
+ nil 'file))))
+ (nreverse entries)))
+
+(defun dp-bucket (e)
+ "Return bucket name for entry plist E."
+ (let ((dl (plist-get e :deadline))
+ (sc (plist-get e :scheduled))
+ (pri (plist-get e :priority)))
+ (cond
+ ((and dl (string< dl dp-today)) 'overdue)
+ ((or (equal dl dp-today) (equal sc dp-today)) 'today)
+ ((and sc (string< sc dp-today)) 'overdue)
+ ((or (and dl (string< dl dp-week-end))
+ (and sc (string< sc dp-week-end))) 'this-week)
+ ((eq pri ?A) 'pri-a)
+ ((eq pri ?B) 'pri-b)
+ (t 'other))))
+
+(defun dp-format-entry (e)
+ "Format entry plist E as org-mode text."
+ (concat
+ (format "** %s%s %s\n"
+ (or (plist-get e :state) "")
+ (if-let ((p (plist-get e :priority))) (format " [#%c]" p) "")
+ (plist-get e :heading))
+ (format " :LOC: %s:%d\n"
+ (file-name-nondirectory (plist-get e :file))
+ (plist-get e :line))
+ (when-let ((d (plist-get e :deadline-raw))) (format " DEADLINE: %s\n" d))
+ (when-let ((s (plist-get e :scheduled-raw))) (format " SCHEDULED: %s\n" s))
+ (let ((b (plist-get e :body)))
+ (if (and b (not (string-empty-p b)))
+ (concat (replace-regexp-in-string "^" " " b) "\n")
+ ""))
+ "\n"))
+
+(defun dp-emit-bucket (label entries)
+ (when entries
+ (princ (format "* %s (%d)\n\n" label (length entries)))
+ (dolist (e entries)
+ (princ (dp-format-entry e)))))
+
+;; Main entrypoint
+(when noninteractive
+ (let* ((files command-line-args-left)
+ (entries (dp-collect files))
+ (groups (seq-group-by #'dp-bucket entries)))
+ (princ (format "# Daily-Prep Extract — %s\n# Files: %s\n# Total candidates: %d\n\n"
+ dp-today
+ (mapconcat #'file-name-nondirectory files ", ")
+ (length entries)))
+ (dolist (bucket '(overdue today this-week pri-a pri-b other))
+ (dp-emit-bucket
+ (pcase bucket
+ ('overdue "Overdue")
+ ('today "Today")
+ ('this-week "This Week")
+ ('pri-a "Priority A (undated)")
+ ('pri-b "Priority B (undated)")
+ ('other "Other"))
+ (alist-get bucket groups)))))
+
+(provide 'daily-prep-agenda)
+;;; daily-prep-agenda.el ends here
diff --git a/.ai/scripts/eml-view-and-extract-attachments-readme.org b/.ai/scripts/eml-view-and-extract-attachments-readme.org
new file mode 100644
index 0000000..3a99d95
--- /dev/null
+++ b/.ai/scripts/eml-view-and-extract-attachments-readme.org
@@ -0,0 +1,47 @@
+#+TITLE: eml-view-and-extract-attachments.py
+
+Extract email content and attachments from EML files with auto-renaming.
+
+* Usage
+
+#+begin_src bash
+# View mode — print metadata and body to stdout, extract attachments alongside EML
+python3 .ai/scripts/eml-view-and-extract-attachments.py inbox/message.eml
+
+# Pipeline mode — extract, auto-rename, refile to output dir, clean up
+python3 .ai/scripts/eml-view-and-extract-attachments.py inbox/message.eml --output-dir assets/
+#+end_src
+
+* Naming Convention
+
+Files are auto-renamed as =YYYY-MM-DD-HHMM-Sender-TYPE-Description.ext=:
+
+- =2026-02-05-1136-Jonathan-EMAIL-Re-Fw-4319-Danneel-Street.eml=
+- =2026-02-05-1136-Jonathan-EMAIL-Re-Fw-4319-Danneel-Street.txt=
+- =2026-02-05-1136-Jonathan-ATTACH-Ltr-Carrollton.pdf=
+
+Date and sender are parsed from email headers. Falls back to "unknown" for missing values.
+
+* Dependencies
+
+- Python 3 (stdlib only for core functionality)
+- =html2text= (optional — used for HTML-only emails, falls back to tag stripping)
+
+* Pipeline Mode Behavior
+
+1. Creates a temp directory alongside the source EML
+2. Copies and renames the EML, writes a =.txt= of the body, extracts attachments
+3. Checks for filename collisions in the output directory
+4. Moves all files to the output directory
+5. Cleans up the temp directory
+6. Prints a summary of created files
+
+Source EML is never modified or moved.
+
+* Tests
+
+#+begin_src bash
+python3 -m pytest .ai/scripts/tests/ -v
+#+end_src
+
+48 tests: unit tests for parsing, filename generation, and attachment saving; integration tests for both pipeline and stdout modes. Requires =pytest=.
diff --git a/.ai/scripts/eml-view-and-extract-attachments.py b/.ai/scripts/eml-view-and-extract-attachments.py
new file mode 100644
index 0000000..dad6457
--- /dev/null
+++ b/.ai/scripts/eml-view-and-extract-attachments.py
@@ -0,0 +1,410 @@
+#!/usr/bin/env python3
+"""Extract email content and attachments from EML files.
+
+Without --output-dir: parse and print to stdout (backwards compatible).
+With --output-dir: full pipeline — extract, auto-rename, refile, clean up.
+"""
+
+import argparse
+import email
+import email.utils
+import os
+import re
+import shutil
+import sys
+import tempfile
+
+
+# ---------------------------------------------------------------------------
+# Parsing functions (no I/O beyond reading the input file)
+# ---------------------------------------------------------------------------
+
+def parse_received_headers(msg):
+ """Parse Received headers to extract sent/received times and servers."""
+ received_headers = msg.get_all('Received', [])
+
+ sent_server = None
+ sent_time = None
+ received_server = None
+ received_time = None
+
+ for header in received_headers:
+ header = ' '.join(header.split())
+
+ time_match = re.search(r';\s*(.+)$', header)
+ timestamp = time_match.group(1).strip() if time_match else None
+
+ from_match = re.search(r'from\s+([\w.-]+)', header)
+ by_match = re.search(r'by\s+([\w.-]+)', header)
+
+ if from_match and by_match and received_server is None:
+ received_time = timestamp
+ received_server = by_match.group(1)
+ sent_server = from_match.group(1)
+ sent_time = timestamp
+
+ if received_server is None and received_headers:
+ header = ' '.join(received_headers[0].split())
+ time_match = re.search(r';\s*(.+)$', header)
+ received_time = time_match.group(1).strip() if time_match else None
+ by_match = re.search(r'by\s+([\w.-]+)', header)
+ received_server = by_match.group(1) if by_match else "unknown"
+
+ return {
+ 'sent_time': sent_time,
+ 'sent_server': sent_server,
+ 'received_time': received_time,
+ 'received_server': received_server
+ }
+
+
+def extract_body(msg):
+ """Walk MIME parts, prefer text/plain, fall back to html2text on text/html.
+
+ Returns body text string.
+ """
+ plain_text = None
+ html_text = None
+
+ for part in msg.walk():
+ content_type = part.get_content_type()
+ if content_type == "text/plain" and plain_text is None:
+ payload = part.get_payload(decode=True)
+ if payload is not None:
+ plain_text = payload.decode('utf-8', errors='ignore')
+ elif content_type == "text/html" and html_text is None:
+ payload = part.get_payload(decode=True)
+ if payload is not None:
+ html_text = payload.decode('utf-8', errors='ignore')
+
+ if plain_text is not None:
+ return plain_text
+
+ if html_text is not None:
+ try:
+ import html2text
+ h = html2text.HTML2Text()
+ h.body_width = 0
+ return h.handle(html_text)
+ except ImportError:
+ # Strip HTML tags as fallback if html2text not installed
+ return re.sub(r'<[^>]+>', '', html_text)
+
+ return ""
+
+
+def extract_metadata(msg):
+ """Extract email metadata from headers.
+
+ Returns dict with from, to, subject, date, and timing info.
+ """
+ return {
+ 'from': msg.get('From'),
+ 'to': msg.get('To'),
+ 'subject': msg.get('Subject'),
+ 'date': msg.get('Date'),
+ 'timing': parse_received_headers(msg),
+ }
+
+
+def generate_basename(metadata):
+ """Generate date-sender prefix from metadata.
+
+ Returns e.g. "2026-02-05-1136-Jonathan".
+ Falls back to "unknown" for missing/malformed Date or From.
+ """
+ # Parse date
+ date_str = metadata.get('date')
+ date_prefix = "unknown"
+ if date_str:
+ try:
+ parsed = email.utils.parsedate_to_datetime(date_str)
+ date_prefix = parsed.strftime('%Y-%m-%d-%H%M')
+ except (ValueError, TypeError):
+ pass
+
+ # Parse sender first name
+ from_str = metadata.get('from')
+ sender = "unknown"
+ if from_str:
+ # Extract display name or email local part
+ display_name, addr = email.utils.parseaddr(from_str)
+ if display_name:
+ sender = display_name.split()[0]
+ elif addr:
+ sender = addr.split('@')[0]
+
+ return f"{date_prefix}-{sender}"
+
+
+def _clean_for_filename(text, max_length=80):
+ """Clean text for use in a filename.
+
+ Replace spaces with hyphens, strip chars unsafe for filenames,
+ collapse multiple hyphens.
+ """
+ text = text.strip()
+ text = text.replace(' ', '-')
+ # Keep alphanumeric, hyphens, dots, underscores
+ text = re.sub(r'[^\w\-.]', '', text)
+ # Collapse multiple hyphens
+ text = re.sub(r'-{2,}', '-', text)
+ # Strip leading/trailing hyphens
+ text = text.strip('-')
+ if len(text) > max_length:
+ text = text[:max_length].rstrip('-')
+ return text
+
+
+def generate_email_filename(basename, subject):
+ """Generate email filename from basename and subject.
+
+ Returns e.g. "2026-02-05-1136-Jonathan-EMAIL-Re-Fw-4319-Danneel-Street"
+ (without extension — caller adds .eml or .txt).
+ """
+ if subject:
+ clean_subject = _clean_for_filename(subject)
+ else:
+ clean_subject = "no-subject"
+ return f"{basename}-EMAIL-{clean_subject}"
+
+
+def generate_attachment_filename(basename, original_filename):
+ """Generate attachment filename from basename and original filename.
+
+ Returns e.g. "2026-02-05-1136-Jonathan-ATTACH-Ltr-Carrollton.pdf".
+ Preserves original extension.
+ """
+ if not original_filename:
+ return f"{basename}-ATTACH-unnamed"
+
+ name, ext = os.path.splitext(original_filename)
+ clean_name = _clean_for_filename(name)
+ return f"{basename}-ATTACH-{clean_name}{ext}"
+
+
+# ---------------------------------------------------------------------------
+# I/O functions (file operations)
+# ---------------------------------------------------------------------------
+
+def save_attachments(msg, output_dir, basename):
+ """Write attachment files to output_dir with auto-renamed filenames.
+
+ Returns list of dicts: {original_name, renamed_name, path}.
+ """
+ results = []
+ used_names = set()
+ for part in msg.walk():
+ if part.get_content_maintype() == 'multipart':
+ continue
+ if part.get('Content-Disposition') is None:
+ continue
+
+ filename = part.get_filename()
+ if filename:
+ # Outlook inlines the same signature image many times under one
+ # filename. Disambiguate so each part gets its own file rather
+ # than overwriting earlier ones in temp_dir.
+ renamed = generate_attachment_filename(basename, filename)
+ if renamed in used_names:
+ stem, ext = os.path.splitext(renamed)
+ n = 2
+ while f"{stem}-{n}{ext}" in used_names:
+ n += 1
+ renamed = f"{stem}-{n}{ext}"
+ used_names.add(renamed)
+
+ filepath = os.path.join(output_dir, renamed)
+ with open(filepath, 'wb') as f:
+ f.write(part.get_payload(decode=True))
+ results.append({
+ 'original_name': filename,
+ 'renamed_name': renamed,
+ 'path': filepath,
+ })
+
+ return results
+
+
+def save_text(text, filepath):
+ """Write body text to a .txt file."""
+ with open(filepath, 'w', encoding='utf-8') as f:
+ f.write(text)
+
+
+# ---------------------------------------------------------------------------
+# Pipeline function
+# ---------------------------------------------------------------------------
+
+def process_eml(eml_path, output_dir):
+ """Full extraction pipeline.
+
+ 1. Create temp extraction dir
+ 2. Copy EML into temp dir
+ 3. Parse email (metadata, body, attachments)
+ 4. Generate filenames from headers
+ 5. Save renamed .eml, .txt, and attachments to temp dir
+ 6. Check for collisions in output_dir
+ 7. Move all files to output_dir
+ 8. Clean up temp dir
+ 9. Return results dict
+ """
+ eml_path = os.path.abspath(eml_path)
+ output_dir = os.path.abspath(output_dir)
+ os.makedirs(output_dir, exist_ok=True)
+
+ # Create temp dir as sibling of the EML file
+ eml_dir = os.path.dirname(eml_path)
+ temp_dir = tempfile.mkdtemp(prefix='extract-', dir=eml_dir)
+
+ try:
+ # Copy EML to temp dir
+ temp_eml = os.path.join(temp_dir, os.path.basename(eml_path))
+ shutil.copy2(eml_path, temp_eml)
+
+ # Parse
+ with open(eml_path, 'rb') as f:
+ msg = email.message_from_binary_file(f)
+
+ metadata = extract_metadata(msg)
+ body = extract_body(msg)
+ basename = generate_basename(metadata)
+ email_stem = generate_email_filename(basename, metadata['subject'])
+
+ # Save renamed EML
+ renamed_eml = f"{email_stem}.eml"
+ renamed_eml_path = os.path.join(temp_dir, renamed_eml)
+ os.rename(temp_eml, renamed_eml_path)
+
+ # Save .txt
+ renamed_txt = f"{email_stem}.txt"
+ renamed_txt_path = os.path.join(temp_dir, renamed_txt)
+ save_text(body, renamed_txt_path)
+
+ # Save attachments
+ attachment_results = save_attachments(msg, temp_dir, basename)
+
+ # Build file list
+ files = [
+ {'type': 'eml', 'name': renamed_eml, 'path': None},
+ {'type': 'txt', 'name': renamed_txt, 'path': None},
+ ]
+ for att in attachment_results:
+ files.append({
+ 'type': 'attach',
+ 'name': att['renamed_name'],
+ 'path': None,
+ })
+
+ # Check for collisions in output_dir
+ for file_info in files:
+ dest = os.path.join(output_dir, file_info['name'])
+ if os.path.exists(dest):
+ raise FileExistsError(
+ f"Collision: '{file_info['name']}' already exists in {output_dir}"
+ )
+
+ # Move all files to output_dir
+ for file_info in files:
+ src = os.path.join(temp_dir, file_info['name'])
+ dest = os.path.join(output_dir, file_info['name'])
+ shutil.move(src, dest)
+ file_info['path'] = dest
+
+ return {
+ 'metadata': metadata,
+ 'body': body,
+ 'files': files,
+ }
+
+ finally:
+ # Clean up temp dir
+ if os.path.exists(temp_dir):
+ shutil.rmtree(temp_dir)
+
+
+# ---------------------------------------------------------------------------
+# Stdout display (backwards-compatible mode)
+# ---------------------------------------------------------------------------
+
+def print_email(eml_path):
+ """Parse and print email to stdout. Extract attachments alongside EML.
+
+ This preserves the original script behavior when --output-dir is not given.
+ """
+ with open(eml_path, 'rb') as f:
+ msg = email.message_from_binary_file(f)
+
+ metadata = extract_metadata(msg)
+ body = extract_body(msg)
+ timing = metadata['timing']
+
+ print(f"From: {metadata['from']}")
+ print(f"To: {metadata['to']}")
+ print(f"Subject: {metadata['subject']}")
+ print(f"Date: {metadata['date']}")
+ print(f"Sent: {timing['sent_time']} (via {timing['sent_server']})")
+ print(f"Received: {timing['received_time']} (at {timing['received_server']})")
+ print()
+ print(body)
+ print()
+
+ # Extract attachments alongside the EML file
+ for part in msg.walk():
+ if part.get_content_maintype() == 'multipart':
+ continue
+ if part.get('Content-Disposition') is None:
+ continue
+
+ filename = part.get_filename()
+ if filename:
+ filepath = os.path.join(os.path.dirname(eml_path), filename)
+ with open(filepath, 'wb') as f:
+ f.write(part.get_payload(decode=True))
+ print(f"Extracted attachment: {filename}")
+
+
+def print_pipeline_summary(result):
+ """Print summary after pipeline extraction."""
+ metadata = result['metadata']
+ timing = metadata['timing']
+
+ print(f"From: {metadata['from']}")
+ print(f"To: {metadata['to']}")
+ print(f"Subject: {metadata['subject']}")
+ print(f"Date: {metadata['date']}")
+ print(f"Sent: {timing['sent_time']} (via {timing['sent_server']})")
+ print(f"Received: {timing['received_time']} (at {timing['received_server']})")
+ print()
+ print("Files created:")
+ for f in result['files']:
+ print(f" [{f['type']:>6}] {f['name']}")
+ print(f"\nOutput directory: {os.path.dirname(result['files'][0]['path'])}")
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Extract email content and attachments from EML files."
+ )
+ parser.add_argument('eml_path', help="Path to source EML file")
+ parser.add_argument(
+ '--output-dir',
+ help="Destination directory for extracted files. "
+ "Without this flag, prints to stdout only (backwards compatible)."
+ )
+
+ args = parser.parse_args()
+
+ if not os.path.isfile(args.eml_path):
+ print(f"Error: '{args.eml_path}' not found or is not a file.", file=sys.stderr)
+ sys.exit(1)
+
+ if args.output_dir:
+ result = process_eml(args.eml_path, args.output_dir)
+ print_pipeline_summary(result)
+ else:
+ print_email(args.eml_path)
diff --git a/.ai/scripts/gmail-fetch-attachments.py b/.ai/scripts/gmail-fetch-attachments.py
new file mode 100755
index 0000000..b42101c
--- /dev/null
+++ b/.ai/scripts/gmail-fetch-attachments.py
@@ -0,0 +1,180 @@
+#!/usr/bin/env python3
+"""Fetch Gmail message attachments via the same OAuth identity the
+google-docs-mcp servers use.
+
+Usage:
+ gmail-fetch-attachments.py --profile {personal,work} \
+ --message-id <ID> --output-dir <PATH>
+
+Reuses:
+ - Refresh token at ~/.config/google-docs-mcp/[<GOOGLE_MCP_PROFILE>/]token.json
+ (the subdir is only present when GOOGLE_MCP_PROFILE is set on the
+ mcpServers entry; otherwise the cache lives at the directory root)
+ - Client ID + secret from ~/.claude.json's
+ mcpServers["google-docs-<profile>"].env
+
+Stdlib only. Saves each non-inline attachment using its original filename.
+Skips attachments that already exist in --output-dir (size-matched).
+"""
+from __future__ import annotations
+
+import argparse
+import base64
+import json
+import sys
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"
+GMAIL_API = "https://gmail.googleapis.com/gmail/v1/users/me"
+TOKEN_DIR = Path.home() / ".config" / "google-docs-mcp"
+CLAUDE_CONFIG = Path.home() / ".claude.json"
+
+
+def load_mcp_env(profile: str) -> dict:
+ if not CLAUDE_CONFIG.exists():
+ sys.exit(f"claude config missing: {CLAUDE_CONFIG}")
+ config = json.loads(CLAUDE_CONFIG.read_text())
+ server_name = f"google-docs-{profile}"
+ servers = config.get("mcpServers", {})
+ if server_name not in servers:
+ sys.exit(f"mcpServers.{server_name} not found in {CLAUDE_CONFIG}")
+ return servers[server_name].get("env", {}) or {}
+
+
+def load_refresh_token(env: dict) -> str:
+ # The MCP server keys its token cache by GOOGLE_MCP_PROFILE. When the
+ # var is unset on the mcpServers entry, the cache lives at the root
+ # (TOKEN_DIR/token.json), not under a <profile>/ subdirectory.
+ mcp_profile = env.get("GOOGLE_MCP_PROFILE") or ""
+ path = TOKEN_DIR / mcp_profile / "token.json" if mcp_profile else TOKEN_DIR / "token.json"
+ if not path.exists():
+ sys.exit(f"token cache missing: {path}")
+ data = json.loads(path.read_text())
+ if "refresh_token" not in data:
+ sys.exit(f"no refresh_token in {path}")
+ return data["refresh_token"]
+
+
+def load_client_creds(env: dict) -> tuple[str, str]:
+ cid = env.get("GOOGLE_CLIENT_ID")
+ secret = env.get("GOOGLE_CLIENT_SECRET")
+ if not cid or not secret:
+ sys.exit("GOOGLE_CLIENT_ID/SECRET missing in MCP env")
+ return cid, secret
+
+
+def refresh_access_token(refresh_token: str, client_id: str, client_secret: str) -> str:
+ body = urllib.parse.urlencode(
+ {
+ "client_id": client_id,
+ "client_secret": client_secret,
+ "refresh_token": refresh_token,
+ "grant_type": "refresh_token",
+ }
+ ).encode()
+ req = urllib.request.Request(
+ OAUTH_TOKEN_URL,
+ data=body,
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ )
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ payload = json.loads(resp.read())
+ if "access_token" not in payload:
+ sys.exit(f"refresh failed: {payload}")
+ return payload["access_token"]
+
+
+def gmail_get(path: str, access_token: str) -> dict:
+ req = urllib.request.Request(
+ f"{GMAIL_API}{path}",
+ headers={"Authorization": f"Bearer {access_token}"},
+ )
+ with urllib.request.urlopen(req, timeout=60) as resp:
+ return json.loads(resp.read())
+
+
+def collect_attachments(payload: dict) -> list[dict]:
+ """Walk the MIME tree and collect parts that have an attachmentId.
+
+ Returns list of {filename, attachmentId, size, mimeType}.
+ Skips parts without a filename (inline images, etc.).
+ """
+ results: list[dict] = []
+
+ def walk(part: dict) -> None:
+ body = part.get("body", {}) or {}
+ filename = part.get("filename") or ""
+ if filename and "attachmentId" in body:
+ results.append(
+ {
+ "filename": filename,
+ "attachmentId": body["attachmentId"],
+ "size": body.get("size", 0),
+ "mimeType": part.get("mimeType", "application/octet-stream"),
+ }
+ )
+ for sub in part.get("parts", []) or []:
+ walk(sub)
+
+ walk(payload)
+ return results
+
+
+def safe_filename(name: str) -> str:
+ """Strip path separators and leading parent-dir markers (..).
+
+ Path separators become underscores so the filename can't escape the
+ output directory. Leading ".." sequences are stripped so an attachment
+ named "../foo" lands as "_foo" rather than ".._foo". Single leading
+ dots are preserved so dotfiles like ".gitignore" survive intact.
+ """
+ cleaned = name.replace("/", "_").replace("\\", "_")
+ while cleaned.startswith(".."):
+ cleaned = cleaned[2:]
+ return cleaned
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--profile", choices=["personal", "work"], required=True)
+ ap.add_argument("--message-id", required=True)
+ ap.add_argument("--output-dir", required=True, type=Path)
+ args = ap.parse_args()
+
+ args.output_dir.mkdir(parents=True, exist_ok=True)
+
+ env = load_mcp_env(args.profile)
+ refresh_token = load_refresh_token(env)
+ client_id, client_secret = load_client_creds(env)
+ access_token = refresh_access_token(refresh_token, client_id, client_secret)
+
+ msg = gmail_get(
+ f"/messages/{args.message_id}?format=full", access_token
+ )
+ attachments = collect_attachments(msg.get("payload", {}))
+
+ if not attachments:
+ print("no attachments on this message")
+ return 0
+
+ print(f"found {len(attachments)} attachment(s):")
+ for att in attachments:
+ target = args.output_dir / safe_filename(att["filename"])
+ if target.exists() and target.stat().st_size == att["size"]:
+ print(f" skip (already present): {target}")
+ continue
+ data_resp = gmail_get(
+ f"/messages/{args.message_id}/attachments/{att['attachmentId']}",
+ access_token,
+ )
+ raw = base64.urlsafe_b64decode(data_resp["data"])
+ target.write_bytes(raw)
+ print(f" saved: {target} ({len(raw):,} bytes)")
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/inbox-send.py b/.ai/scripts/inbox-send.py
new file mode 100644
index 0000000..8e650ff
--- /dev/null
+++ b/.ai/scripts/inbox-send.py
@@ -0,0 +1,262 @@
+#!/usr/bin/env python3
+"""inbox-send — send text or a file to another project's top-level inbox/.
+
+Universal cross-project inbox messaging tool. A "project" here is a
+directory that contains both a `.ai/` marker (signalling it's a
+Claude-managed project) and a top-level `inbox/` directory (Craig's
+inbox convention). The script lets you drop a text message or copy a
+file into a target project's `inbox/`, with a dated filename that
+records the source project so the target's next session picks it up
+cleanly.
+
+Usage:
+ inbox-send --list
+ inbox-send <target> --text "your message" [--name custom-slug]
+ inbox-send <target> --file <path> [--name custom-slug]
+
+<target> is the project's basename (or the numeric index from --list).
+
+Discovery roots default to ~/projects/ and ~/code/ (parent dirs whose
+children are scanned). Override with INBOX_SEND_ROOTS (colon-separated
+paths) or write paths into ~/.claude/inbox-roots.txt, one per line.
+A root may be either a parent directory or a specific project root
+(e.g. ~/.emacs.d); if the root itself is a project, it's included
+directly.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import shutil
+import sys
+from datetime import datetime
+from pathlib import Path
+
+DEFAULT_ROOTS = [Path.home() / "projects", Path.home() / "code"]
+MAX_SLUG_LENGTH = 40
+TS_FILENAME_FMT = "%Y-%m-%d-%H%M"
+TS_DOC_FMT = "%Y-%m-%d %H:%M:%S %z"
+
+
+def resolve_roots() -> list[Path]:
+ """Resolve discovery roots: env var → config file → defaults."""
+ env_roots = os.environ.get("INBOX_SEND_ROOTS")
+ if env_roots:
+ return [Path(p) for p in env_roots.split(":") if p]
+ config = Path.home() / ".claude" / "inbox-roots.txt"
+ if config.is_file():
+ paths: list[Path] = []
+ for line in config.read_text().splitlines():
+ line = line.strip()
+ if line and not line.startswith("#"):
+ paths.append(Path(line).expanduser())
+ if paths:
+ return paths
+ return DEFAULT_ROOTS
+
+
+def _is_project(path: Path) -> bool:
+ """A project has a `.ai/` marker AND a top-level `inbox/` directory."""
+ return (path / ".ai").is_dir() and (path / "inbox").is_dir()
+
+
+def discover_projects(roots: list[Path]) -> list[Path]:
+ """Return absolute paths of projects discovered under `roots`.
+
+ A root may be either a parent directory (its children are scanned) or
+ a specific project root (included directly if it qualifies).
+ """
+ projects: list[Path] = []
+ for root in roots:
+ if not root.is_dir():
+ continue
+ if _is_project(root):
+ projects.append(root)
+ continue
+ for child in sorted(root.iterdir()):
+ if not child.is_dir():
+ continue
+ if _is_project(child):
+ projects.append(child)
+ return projects
+
+
+def find_current_project(start: Path) -> Path | None:
+ """Walk up from `start` looking for the nearest dir containing .ai/."""
+ cur = start.resolve()
+ while cur != cur.parent:
+ if (cur / ".ai").is_dir():
+ return cur
+ cur = cur.parent
+ return None
+
+
+def slugify(text: str, max_length: int = MAX_SLUG_LENGTH) -> str:
+ """Turn freeform text into a filename-safe slug."""
+ text = text.lower()
+ text = re.sub(r"[^a-z0-9\s]+", " ", text)
+ text = re.sub(r"\s+", " ", text).strip()
+ if not text:
+ return ""
+ if len(text) <= max_length:
+ return text.replace(" ", "-")
+ # Truncate, then walk back to the last whitespace to keep a word boundary.
+ truncated = text[:max_length]
+ last_space = truncated.rfind(" ")
+ if last_space > 0:
+ truncated = truncated[:last_space]
+ return truncated.strip().replace(" ", "-")
+
+
+def find_target(target_name: str, projects: list[Path]) -> Path | None:
+ """Resolve `target_name` against the project list (basename or numeric index)."""
+ if target_name.isdigit():
+ idx = int(target_name) - 1
+ if 0 <= idx < len(projects):
+ return projects[idx]
+ return None
+ for p in projects:
+ if p.name == target_name:
+ return p
+ return None
+
+
+def build_text_org(message: str, source_name: str, timestamp: str) -> str:
+ """Wrap a text message in a minimal org-mode skeleton."""
+ title = message.strip().splitlines()[0][:60] if message.strip() else "(empty)"
+ return (
+ f"#+TITLE: {title}\n"
+ f"#+SOURCE: from {source_name}\n"
+ f"#+DATE: {timestamp}\n\n"
+ f"{message.rstrip()}\n"
+ )
+
+
+def send_text(
+ target_inbox: Path,
+ message: str,
+ source_name: str,
+ custom_name: str | None,
+ now: datetime,
+) -> Path:
+ """Write a text message into target_inbox as a dated .org file."""
+ if not message.strip():
+ raise ValueError("--text cannot be empty or whitespace-only")
+ slug = custom_name or slugify(message)
+ if not slug:
+ 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 = target_inbox / filename
+ dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT)))
+ return dest
+
+
+def send_file(
+ target_inbox: Path,
+ src_path: Path,
+ source_name: str,
+ custom_name: str | None,
+ now: datetime,
+) -> Path:
+ """Copy src_path into target_inbox with a dated, source-tagged name."""
+ if not src_path.is_file():
+ raise FileNotFoundError(f"source file not found: {src_path}")
+ slug = custom_name or slugify(src_path.stem)
+ if not slug:
+ raise ValueError(f"could not derive a slug from file: {src_path}")
+ ext = src_path.suffix
+ filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}"
+ dest = target_inbox / filename
+ shutil.copy2(src_path, dest)
+ return dest
+
+
+def print_project_list(projects: list[Path], current: Path | None) -> None:
+ """Print numbered list of projects, with the current one excluded."""
+ others = [p for p in projects if current is None or p.resolve() != current.resolve()]
+ if not others:
+ print("No projects (.ai/ + inbox/) found under the configured roots.")
+ return
+ print(f"Available .ai projects ({len(others)}):")
+ width = max(len(p.name) for p in others)
+ for i, p in enumerate(others, 1):
+ print(f" {i}. {p.name:<{width}} {p}")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Send text or a file to another .ai project's inbox/.",
+ )
+ parser.add_argument(
+ "--list", action="store_true",
+ help="List available .ai projects and exit.",
+ )
+ parser.add_argument(
+ "target", nargs="?",
+ help="Target project basename or numeric index from --list.",
+ )
+ parser.add_argument(
+ "--text",
+ help="Text message to drop as an .org file in the target inbox.",
+ )
+ parser.add_argument(
+ "--file", type=Path,
+ help="Path to a file to copy into the target inbox.",
+ )
+ parser.add_argument(
+ "--name",
+ help="Override the auto-derived filename slug.",
+ )
+ args = parser.parse_args()
+
+ roots = resolve_roots()
+ projects = discover_projects(roots)
+ current = find_current_project(Path.cwd())
+
+ if args.list:
+ print_project_list(projects, current)
+ return 0
+
+ if not args.target:
+ parser.error("must provide a target project (or --list)")
+ if args.text is None and args.file is None:
+ parser.error("must provide --text or --file")
+ if args.text is not None and args.file is not None:
+ parser.error("--text and --file are mutually exclusive")
+
+ others = [p for p in projects if current is None or p.resolve() != current.resolve()]
+ target = find_target(args.target, others)
+ if target is None:
+ print(f"inbox-send: unknown target {args.target!r}.", file=sys.stderr)
+ print("Run `inbox-send --list` to see available projects.", file=sys.stderr)
+ return 1
+
+ target_inbox = target / "inbox"
+ if not target_inbox.is_dir():
+ print(
+ f"inbox-send: target {target.name!r} has no top-level inbox/ directory.",
+ file=sys.stderr,
+ )
+ return 1
+
+ source_name = current.name if current else Path.cwd().name
+ now = datetime.now().astimezone()
+
+ try:
+ if args.text is not None:
+ dest = send_text(target_inbox, args.text, source_name, args.name, now)
+ else:
+ assert args.file is not None
+ dest = send_file(target_inbox, args.file, source_name, args.name, now)
+ except (ValueError, FileNotFoundError) as exc:
+ print(f"inbox-send: {exc}", file=sys.stderr)
+ return 1
+
+ print(f"Sent: {dest}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/lint-org.el b/.ai/scripts/lint-org.el
new file mode 100644
index 0000000..3e643d4
--- /dev/null
+++ b/.ai/scripts/lint-org.el
@@ -0,0 +1,365 @@
+;;; lint-org.el --- org-lint sweeper for tracked org files -*- lexical-binding: t; -*-
+;;
+;; Usage:
+;; emacs --batch -q -l lint-org.el FILE.org [FILE.org ...]
+;; apply mechanical fixes in place, emit judgment items on stdout for the
+;; command layer to walk
+;;
+;; emacs --batch -q -l lint-org.el --check FILE.org [FILE.org ...]
+;; report only — categorize without modifying the file
+;;
+;; emacs --batch -q -l lint-org.el --followups-file=PATH FILE.org
+;; apply mechanical fixes; if any judgment items remain, append them to
+;; PATH as an org section dated today. Used by wrap-it-up to defer the
+;; judgment walk to the next morning's review without blocking the wrap.
+;;
+;; Mechanical categories (auto-fixed):
+;; item-number add [@N] directive to drifted bullets
+;; missing-language-in-src-block convert bare #+begin_src to #+begin_example
+;; misplaced-planning-info merge multi-line CLOSED:/DEADLINE:/SCHEDULED:
+;; misplaced-heading (markdown-bold) **X.** at start of line → *X.*
+;;
+;; Judgment categories (emitted on stdout):
+;; misplaced-heading (verbatim-*) =*** Foo= inside body prose
+;; link-to-local-file broken file: links
+;; invalid-fuzzy-link broken *Heading refs
+;; suspicious-language-in-src-block unknown source-block language
+;; (anything else) surfaced as judgment with checker name
+;;
+;; Output format on stdout:
+;; first line: ;; lint-org: file=<path> mechanical=<N>[ (would-fix)] judgment=<M>
+;; each issue: (:kind mechanical-fixed|judgment :line <N> :checker <symbol> :msg "..." [:preview t])
+;;
+;; Before modifying a file, a backup is copied to
+;; /tmp/<basename>.before-lint-pass.<YYYYMMDD-HHMMSS>
+
+(require 'org)
+(require 'org-lint)
+(require 'cl-lib)
+(require 'subr-x)
+
+(defvar lo-fixes 0
+ "Count of mechanical fixes applied (or would-apply in --check) on the last file.")
+(defvar lo-issues nil
+ "Reverse-document-order list of plists describing each issue from the last file.
+Each plist has :kind (mechanical-fixed | judgment), :line, :checker, :msg.
+Mechanical entries from --check mode also carry :preview t.")
+(defvar lo-check-only nil
+ "Non-nil means run in report-only mode — no buffer writes.")
+(defvar lo-current-file nil
+ "Path of the file currently being processed.")
+(defvar lo-followups-file nil
+ "When non-nil, after a non-check run any judgment items are appended to this
+path as an org section dated today. The file is created if missing.")
+
+(defconst lo-mechanical-checkers
+ '(item-number missing-language-in-src-block misplaced-planning-info)
+ "org-lint checker names that are always treated as mechanical.")
+
+;; misplaced-heading is split case-by-case (markdown-bold vs verbatim-asterisk)
+;; in `lo--handle-item'.
+
+;;; ---------------------------------------------------------------------------
+;;; org-lint result accessors
+
+(defun lo--checker-name (item)
+ "Return the checker symbol name for ITEM."
+ (let* ((vec (cadr item))
+ (checker (aref vec 3)))
+ (org-lint-checker-name checker)))
+
+(defun lo--line (item)
+ "Return the 1-based line number for ITEM."
+ (let* ((vec (cadr item))
+ (marker-str (aref vec 0)))
+ (string-to-number (substring-no-properties marker-str))))
+
+(defun lo--message (item)
+ "Return the human-readable message for ITEM."
+ (let ((vec (cadr item))) (aref vec 2)))
+
+;;; ---------------------------------------------------------------------------
+;;; Mechanical fixers — each runs against the current buffer, returns
+;;; non-nil on success, nil if its preconditions don't hold (already
+;;; fixed, unexpected shape, etc.).
+
+(defun lo--goto-line (line)
+ (goto-char (point-min))
+ (forward-line (1- line)))
+
+(defun lo-fix-item-number (line)
+ "Insert an [@N] counter on the bullet at LINE, derived from its leading number."
+ (save-excursion
+ (lo--goto-line line)
+ (when (looking-at "^[ \t]*\\([0-9]+\\)[.)]\\([ \t]+\\)")
+ (let ((num (match-string 1)))
+ (goto-char (match-end 0))
+ (unless (looking-at "\\[@")
+ (insert (format "[@%s] " num))
+ t)))))
+
+(defun lo-fix-missing-language (line)
+ "Convert a bare `#+begin_src` block starting at LINE to `#+begin_example`.
+Locates the matching `#+end_src` directly below and rewrites it too."
+ (save-excursion
+ (lo--goto-line line)
+ (when (looking-at "^\\([ \t]*\\)#\\+begin_src[ \t]*$")
+ (let* ((indent (match-string 1))
+ (begin-bol (line-beginning-position))
+ (begin-eol (line-end-position))
+ ;; case-fold the end keyword search to match org's tolerance
+ (end-re (format "^%s#\\+end_src[ \t]*$" (regexp-quote indent))))
+ (delete-region begin-bol begin-eol)
+ (insert (format "%s#+begin_example" indent))
+ (forward-line 1)
+ (when (re-search-forward end-re nil t)
+ (replace-match (format "%s#+end_example" indent) t t)
+ t)))))
+
+(defun lo-fix-misplaced-planning (line)
+ "Collapse all planning lines under the heading containing LINE into a single
+canonical line right after the heading, ordered CLOSED → DEADLINE → SCHEDULED.
+LINE positions the search start — the fixer then rebuilds the whole entry's
+planning block at once, so it does the right thing whether the misplaced line
+is the first, last, or middle of the run."
+ (save-excursion
+ (lo--goto-line line)
+ (when (re-search-backward "^\\*+ " nil t)
+ (let* ((heading-bol (line-beginning-position))
+ (body-start (progn (forward-line 1) (point)))
+ (entry-end (save-excursion (outline-next-heading) (point)))
+ (parts nil)
+ (ranges nil))
+ (goto-char body-start)
+ (while (re-search-forward
+ "^[ \t]*\\(CLOSED\\|DEADLINE\\|SCHEDULED\\):.*$"
+ entry-end t)
+ (let* ((line-bol (match-beginning 0))
+ (line-eol (match-end 0))
+ (content (buffer-substring-no-properties line-bol line-eol))
+ (pos 0))
+ (while (string-match
+ "\\(CLOSED\\|DEADLINE\\|SCHEDULED\\):[ \t]*\\(\\[[^]]+\\]\\|<[^>]+>\\)"
+ content pos)
+ (push (cons (match-string 1 content)
+ (match-string 2 content))
+ parts)
+ (setq pos (match-end 0)))
+ ;; Record line-bol .. line-eol+1 so the trailing newline goes too.
+ (push (cons line-bol (min (1+ line-eol) (point-max))) ranges)))
+ (when (> (length parts) 1)
+ (let* ((order '("CLOSED" "DEADLINE" "SCHEDULED"))
+ (deduped (cl-remove-duplicates (nreverse parts) :test #'equal))
+ (sorted (sort deduped
+ (lambda (a b)
+ (< (or (cl-position (car a) order :test #'string=) 99)
+ (or (cl-position (car b) order :test #'string=) 99)))))
+ (merged (mapconcat (lambda (p) (format "%s: %s" (car p) (cdr p)))
+ sorted " ")))
+ (dolist (r (sort (copy-sequence ranges)
+ (lambda (a b) (> (car a) (car b)))))
+ (delete-region (car r) (cdr r)))
+ (goto-char heading-bol)
+ (forward-line 1)
+ (insert merged "\n")
+ t))))))
+
+(defun lo--find-markdown-bold-line (reported-line)
+ "Return the actual line number containing a leading `**X**` near REPORTED-LINE.
+org-lint's marker for misplaced-heading typically points at the blank line
+following the offender, so check (REPORTED-LINE - 1) before REPORTED-LINE.
+Returns nil if no nearby line matches the markdown-bold pattern."
+ (save-excursion
+ (cl-loop for candidate in (list (1- reported-line) reported-line)
+ when (and (>= candidate 1)
+ (progn (lo--goto-line candidate)
+ (looking-at "^\\*\\*[^*\n]+\\*\\*")))
+ return candidate)))
+
+(defun lo--markdown-bold-at-line-p (line)
+ "Non-nil if LINE (or LINE - 1) looks like a markdown-bold case of
+misplaced-heading. Pattern: `**X**` at the start of the line, X a short prose
+run without asterisks."
+ (and (lo--find-markdown-bold-line line) t))
+
+(defun lo-fix-markdown-bold (line)
+ "Convert a leading `**X**` near LINE to `*X*` (org single-asterisk bold).
+Uses `lo--find-markdown-bold-line' to locate the actual offender, since
+org-lint reports the blank line after the heading-like text."
+ (let ((actual (lo--find-markdown-bold-line line)))
+ (when actual
+ (save-excursion
+ (lo--goto-line actual)
+ (when (looking-at "^\\(\\*\\*\\)\\([^*\n]+\\)\\(\\*\\*\\)")
+ (let ((start (match-beginning 0))
+ (end (match-end 0))
+ (inner (match-string 2)))
+ (delete-region start end)
+ (goto-char start)
+ (insert (format "*%s*" inner))
+ t))))))
+
+;;; ---------------------------------------------------------------------------
+;;; Per-item dispatch
+
+(defun lo--emit-judgment (name line msg)
+ (push (list :kind 'judgment :line line :checker name :msg msg)
+ lo-issues))
+
+(defun lo--apply-or-preview (name line msg fixer)
+ (cond
+ (lo-check-only
+ (cl-incf lo-fixes)
+ (push (list :kind 'mechanical-fixed :line line :checker name :msg msg
+ :preview t)
+ lo-issues))
+ ((funcall fixer line)
+ (cl-incf lo-fixes)
+ (push (list :kind 'mechanical-fixed :line line :checker name :msg msg)
+ lo-issues))
+ (t
+ ;; Fixer declined — emit as judgment so nothing is silently swallowed.
+ (lo--emit-judgment name line msg))))
+
+(defun lo--handle-item (item)
+ (let ((name (lo--checker-name item))
+ (line (lo--line item))
+ (msg (lo--message item)))
+ (cond
+ ((eq name 'item-number)
+ (lo--apply-or-preview name line msg #'lo-fix-item-number))
+ ((eq name 'missing-language-in-src-block)
+ (lo--apply-or-preview name line msg #'lo-fix-missing-language))
+ ((eq name 'misplaced-planning-info)
+ (lo--apply-or-preview name line msg #'lo-fix-misplaced-planning))
+ ((eq name 'misplaced-heading)
+ (if (lo--markdown-bold-at-line-p line)
+ (lo--apply-or-preview name line msg #'lo-fix-markdown-bold)
+ (lo--emit-judgment name line msg)))
+ (t
+ (lo--emit-judgment name line msg)))))
+
+;;; ---------------------------------------------------------------------------
+;;; File processing
+
+(defun lo--backup (file)
+ "Copy FILE to /tmp before any modification. Skipped in --check mode."
+ (let ((backup (format "/tmp/%s.before-lint-pass.%s"
+ (file-name-nondirectory file)
+ (format-time-string "%Y%m%d-%H%M%S"))))
+ (copy-file file backup t)
+ backup))
+
+(defun lo-process-file (file)
+ "Run org-lint against FILE, apply mechanical fixes, collect judgment items.
+Resets `lo-fixes' and `lo-issues' for each call. In --check mode the file is
+left unmodified and mechanical entries are recorded with :preview t."
+ (setq lo-current-file file lo-fixes 0 lo-issues nil)
+ (unless lo-check-only
+ (lo--backup file))
+ (let ((buf (find-file-noselect file)))
+ (unwind-protect
+ (with-current-buffer buf
+ (revert-buffer t t t)
+ (let* ((report (org-lint))
+ ;; Descending line order: applying a fix that adds/removes
+ ;; lines doesn't perturb the line numbers of items at smaller
+ ;; line numbers that haven't been processed yet.
+ (sorted (sort (copy-sequence report)
+ (lambda (a b) (> (lo--line a) (lo--line b))))))
+ (dolist (item sorted)
+ (lo--handle-item item)))
+ (when (and (not lo-check-only) (buffer-modified-p))
+ (save-buffer)))
+ (with-current-buffer buf (set-buffer-modified-p nil))
+ (kill-buffer buf))))
+
+;;; ---------------------------------------------------------------------------
+;;; Reporting
+
+(defun lo--append-followups ()
+ "Append any judgment items from the current run to `lo-followups-file' as a
+dated org section. No-op when the file path is unset or there are no
+judgment items."
+ (when lo-followups-file
+ (let ((judgments (cl-remove-if-not
+ (lambda (i) (eq (plist-get i :kind) 'judgment))
+ (reverse lo-issues))))
+ (when judgments
+ (let ((dir (file-name-directory (expand-file-name lo-followups-file))))
+ (when dir (make-directory dir t)))
+ (with-temp-buffer
+ (insert (format "\n* %s lint-org follow-ups — %s\n"
+ (format-time-string "%Y-%m-%d")
+ (file-name-nondirectory lo-current-file)))
+ (dolist (i judgments)
+ (insert (format "** TODO line %d — %s — %s\n"
+ (plist-get i :line)
+ (plist-get i :checker)
+ (plist-get i :msg))))
+ (append-to-file (point-min) (point-max) lo-followups-file))))))
+
+(defun lo-emit-report ()
+ "Print the per-file summary line plus each issue as a readable plist.
+After printing, also append judgments to `lo-followups-file' when set."
+ (let ((mech (cl-count-if (lambda (i) (eq (plist-get i :kind) 'mechanical-fixed))
+ lo-issues))
+ (judg (cl-count-if (lambda (i) (eq (plist-get i :kind) 'judgment))
+ lo-issues)))
+ (princ (format ";; lint-org: file=%s mechanical=%d%s judgment=%d%s\n"
+ lo-current-file mech
+ (if lo-check-only " (would-fix)" "")
+ judg
+ (if (and lo-followups-file (> judg 0))
+ (format " followups=%s" lo-followups-file)
+ "")))
+ (dolist (i (reverse lo-issues))
+ (princ (format "%S\n" i)))
+ (unless lo-check-only
+ (lo--append-followups))))
+
+;;; ---------------------------------------------------------------------------
+;;; CLI
+
+(defun lo-main ()
+ (when (member "--check" command-line-args-left)
+ (setq lo-check-only t)
+ (setq command-line-args-left (delete "--check" command-line-args-left)))
+ (let ((followups (cl-find-if
+ (lambda (a) (string-prefix-p "--followups-file=" a))
+ command-line-args-left)))
+ (when followups
+ (setq lo-followups-file (substring followups (length "--followups-file=")))
+ (setq command-line-args-left (delete followups command-line-args-left))))
+ (if (null command-line-args-left)
+ (progn
+ (princ "Usage: emacs --batch -q -l lint-org.el [--check] [--followups-file=PATH] FILE.org ...\n")
+ (kill-emacs 1))
+ (let ((files command-line-args-left))
+ (setq command-line-args-left nil)
+ (dolist (file files)
+ (if (file-readable-p file)
+ (progn
+ (lo-process-file file)
+ (lo-emit-report))
+ (princ (format ";; lint-org: file=%s not readable — skipping\n"
+ file)))))))
+
+(defun lo--cli-invocation-p ()
+ "Non-nil when the trailing command-line arguments look like a real invocation:
+only recognized flags and/or readable file paths. Lets the ERT suite `require'
+this file without firing the CLI dispatch — under `ert-run-tests-batch-and-exit'
+the trailing args are things like `-f ert-run-tests-batch-and-exit'."
+ (and command-line-args-left
+ (cl-every (lambda (a)
+ (cond ((member a '("--check")) t)
+ ((string-prefix-p "--followups-file=" a) t)
+ ((string-prefix-p "-" a) nil)
+ (t (file-readable-p a))))
+ command-line-args-left)))
+
+(when (and noninteractive (lo--cli-invocation-p))
+ (lo-main))
+
+(provide 'lint-org)
+;;; lint-org.el ends here
diff --git a/.ai/scripts/maildir-flag-manager.py b/.ai/scripts/maildir-flag-manager.py
new file mode 100755
index 0000000..97ed1d8
--- /dev/null
+++ b/.ai/scripts/maildir-flag-manager.py
@@ -0,0 +1,351 @@
+#!/usr/bin/env python3
+"""Manage maildir flags (read, starred) across email accounts.
+
+Uses atomic os.rename() for flag operations directly on maildir files.
+Safer and more reliable than shell-based approaches (zsh loses PATH in
+while-read loops, piped mu move silently fails).
+
+Supports the same flag semantics as mu4e: maildir files in new/ are moved
+to cur/ when the Seen flag is added, and flag changes are persisted to the
+filesystem so mbsync picks them up on the next sync.
+
+Usage:
+ # Mark all unread INBOX emails as read
+ maildir-flag-manager.py mark-read
+
+ # Mark specific emails as read (by path)
+ maildir-flag-manager.py mark-read /path/to/message1 /path/to/message2
+
+ # Mark all unread INBOX emails as read, then reindex mu
+ maildir-flag-manager.py mark-read --reindex
+
+ # Star specific emails (by path)
+ maildir-flag-manager.py star /path/to/message1 /path/to/message2
+
+ # Star and mark read
+ maildir-flag-manager.py star --mark-read /path/to/message1
+
+ # Dry run — show what would change without modifying anything
+ maildir-flag-manager.py mark-read --dry-run
+"""
+
+import argparse
+import os
+import shutil
+import subprocess
+import sys
+
+
+# ---------------------------------------------------------------------------
+# Configuration
+# ---------------------------------------------------------------------------
+
+MAILDIR_ACCOUNTS = {
+ 'gmail': os.path.expanduser('~/.mail/gmail/INBOX'),
+ 'cmail': os.path.expanduser('~/.mail/cmail/Inbox'),
+}
+
+
+# ---------------------------------------------------------------------------
+# Core flag operations
+# ---------------------------------------------------------------------------
+
+def parse_maildir_flags(filename):
+ """Extract flags from a maildir filename.
+
+ Maildir filenames follow the pattern: unique:2,FLAGS
+ where FLAGS is a sorted string of flag characters (e.g., "FS" for
+ Flagged+Seen).
+
+ Returns (base, flags_string). If no flags section, returns (filename, '').
+ """
+ if ':2,' in filename:
+ base, flags = filename.rsplit(':2,', 1)
+ return base, flags
+ return filename, ''
+
+
+def build_flagged_filename(filename, new_flags):
+ """Build a maildir filename with the given flags.
+
+ Flags are always sorted alphabetically per maildir spec.
+ """
+ base, _ = parse_maildir_flags(filename)
+ sorted_flags = ''.join(sorted(set(new_flags)))
+ return f"{base}:2,{sorted_flags}"
+
+
+def rename_with_flag(file_path, flag, dry_run=False):
+ """Add a flag to a single maildir message file via atomic rename.
+
+ Handles moving from new/ to cur/ when adding the Seen flag.
+ Returns True if the flag was added, False if already present.
+ """
+ dirname = os.path.dirname(file_path)
+ filename = os.path.basename(file_path)
+ maildir_root = os.path.dirname(dirname)
+ subdir = os.path.basename(dirname)
+
+ _, current_flags = parse_maildir_flags(filename)
+
+ if flag in current_flags:
+ return False
+
+ new_flags = current_flags + flag
+ new_filename = build_flagged_filename(filename, new_flags)
+
+ # Messages with the Seen flag belong in cur/, not new/
+ if 'S' in new_flags and subdir == 'new':
+ target_dir = os.path.join(maildir_root, 'cur')
+ else:
+ target_dir = dirname
+
+ new_path = os.path.join(target_dir, new_filename)
+
+ if dry_run:
+ return True
+
+ os.rename(file_path, new_path)
+ return True
+
+
+def process_maildir(maildir_path, flag, dry_run=False):
+ """Add a flag to all messages in a maildir that don't have it.
+
+ Scans both new/ and cur/ subdirectories.
+ Returns (changed_count, skipped_count, error_count).
+ """
+ if not os.path.isdir(maildir_path):
+ print(f" Skipping {maildir_path} (not found)", file=sys.stderr)
+ return 0, 0, 0
+
+ # Snapshot the file list before any rename. Adding S to a new/ file
+ # moves it to cur/ via rename_with_flag; without a snapshot, the
+ # moved file gets re-encountered during the cur/ scan and inflates
+ # the skipped count.
+ file_paths = []
+ for subdir in ('new', 'cur'):
+ subdir_path = os.path.join(maildir_path, subdir)
+ if not os.path.isdir(subdir_path):
+ continue
+ for filename in os.listdir(subdir_path):
+ file_path = os.path.join(subdir_path, filename)
+ if os.path.isfile(file_path):
+ file_paths.append(file_path)
+
+ changed = 0
+ skipped = 0
+ errors = 0
+
+ for file_path in file_paths:
+ try:
+ if rename_with_flag(file_path, flag, dry_run):
+ changed += 1
+ else:
+ skipped += 1
+ except Exception as e:
+ print(f" Error on {os.path.basename(file_path)}: {e}",
+ file=sys.stderr)
+ errors += 1
+
+ return changed, skipped, errors
+
+
+def process_specific_files(paths, flag, dry_run=False):
+ """Add a flag to specific message files by path.
+
+ Returns (changed_count, skipped_count, error_count).
+ """
+ changed = 0
+ skipped = 0
+ errors = 0
+
+ for path in paths:
+ path = os.path.abspath(path)
+ if not os.path.isfile(path):
+ print(f" File not found: {path}", file=sys.stderr)
+ errors += 1
+ continue
+
+ # Verify file is inside a maildir (parent should be cur/ or new/)
+ parent_dir = os.path.basename(os.path.dirname(path))
+ if parent_dir not in ('cur', 'new'):
+ print(f" Not in a maildir cur/ or new/ dir: {path}",
+ file=sys.stderr)
+ errors += 1
+ continue
+
+ try:
+ if rename_with_flag(path, flag, dry_run):
+ changed += 1
+ else:
+ skipped += 1
+ except Exception as e:
+ print(f" Error on {path}: {e}", file=sys.stderr)
+ errors += 1
+
+ return changed, skipped, errors
+
+
+def reindex_mu():
+ """Run mu index to update the database after flag changes."""
+ mu_path = shutil.which('mu')
+ if not mu_path:
+ print("Warning: mu not found in PATH, skipping reindex",
+ file=sys.stderr)
+ return False
+
+ try:
+ result = subprocess.run(
+ [mu_path, 'index'],
+ capture_output=True, text=True, timeout=120
+ )
+ if result.returncode == 0:
+ print("mu index: database updated")
+ return True
+ else:
+ print(f"mu index failed: {result.stderr}", file=sys.stderr)
+ return False
+ except subprocess.TimeoutExpired:
+ print("mu index timed out after 120s", file=sys.stderr)
+ return False
+
+
+# ---------------------------------------------------------------------------
+# Commands
+# ---------------------------------------------------------------------------
+
+def cmd_mark_read(args):
+ """Mark emails as read (add Seen flag)."""
+ flag = 'S'
+ action = "Marking as read"
+ if args.dry_run:
+ action = "Would mark as read"
+
+ total_changed = 0
+ total_skipped = 0
+ total_errors = 0
+
+ if args.paths:
+ print(f"{action}: {len(args.paths)} specific message(s)")
+ c, s, e = process_specific_files(args.paths, flag, args.dry_run)
+ total_changed += c
+ total_skipped += s
+ total_errors += e
+ else:
+ for name, maildir_path in MAILDIR_ACCOUNTS.items():
+ print(f"{action} in {name} ({maildir_path})")
+ c, s, e = process_maildir(maildir_path, flag, args.dry_run)
+ total_changed += c
+ total_skipped += s
+ total_errors += e
+ if c > 0:
+ print(f" {c} message(s) marked as read")
+ if s > 0:
+ print(f" {s} already read")
+
+ print(f"\nTotal: {total_changed} changed, {total_skipped} already set, "
+ f"{total_errors} errors")
+
+ if args.reindex and not args.dry_run and total_changed > 0:
+ reindex_mu()
+
+ return 0 if total_errors == 0 else 1
+
+
+def cmd_star(args):
+ """Star/flag emails (add Flagged flag)."""
+ flag = 'F'
+ action = "Starring"
+ if args.dry_run:
+ action = "Would star"
+
+ if not args.paths:
+ print("Error: star requires specific message paths", file=sys.stderr)
+ return 1
+
+ print(f"{action}: {len(args.paths)} message(s)")
+ total_changed = 0
+ total_skipped = 0
+ total_errors = 0
+
+ c, s, e = process_specific_files(args.paths, flag, args.dry_run)
+ total_changed += c
+ total_skipped += s
+ total_errors += e
+
+ # Also mark as read if requested
+ if args.mark_read:
+ print("Also marking as read...")
+ c2, _, e2 = process_specific_files(args.paths, 'S', args.dry_run)
+ total_changed += c2
+ total_errors += e2
+
+ print(f"\nTotal: {total_changed} flag(s) changed, {total_skipped} already set, "
+ f"{total_errors} errors")
+
+ if args.reindex and not args.dry_run and total_changed > 0:
+ reindex_mu()
+
+ return 0 if total_errors == 0 else 1
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Manage maildir flags (read, starred) across email accounts."
+ )
+ subparsers = parser.add_subparsers(dest='command', required=True)
+
+ # mark-read
+ p_read = subparsers.add_parser(
+ 'mark-read',
+ help="Mark emails as read (add Seen flag)"
+ )
+ p_read.add_argument(
+ 'paths', nargs='*',
+ help="Specific message file paths. If omitted, marks all unread "
+ "messages in configured INBOX maildirs."
+ )
+ p_read.add_argument(
+ '--reindex', action='store_true',
+ help="Run mu index after changing flags"
+ )
+ p_read.add_argument(
+ '--dry-run', action='store_true',
+ help="Show what would change without modifying anything"
+ )
+ p_read.set_defaults(func=cmd_mark_read)
+
+ # star
+ p_star = subparsers.add_parser(
+ 'star',
+ help="Star/flag emails (add Flagged flag)"
+ )
+ p_star.add_argument(
+ 'paths', nargs='+',
+ help="Message file paths to star"
+ )
+ p_star.add_argument(
+ '--mark-read', action='store_true',
+ help="Also mark starred messages as read"
+ )
+ p_star.add_argument(
+ '--reindex', action='store_true',
+ help="Run mu index after changing flags"
+ )
+ p_star.add_argument(
+ '--dry-run', action='store_true',
+ help="Show what would change without modifying anything"
+ )
+ p_star.set_defaults(func=cmd_star)
+
+ args = parser.parse_args()
+ sys.exit(args.func(args))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/.ai/scripts/tests/conftest.py b/.ai/scripts/tests/conftest.py
new file mode 100644
index 0000000..8d965ab
--- /dev/null
+++ b/.ai/scripts/tests/conftest.py
@@ -0,0 +1,77 @@
+"""Shared fixtures for EML extraction tests."""
+
+import os
+from email.message import EmailMessage
+from email.mime.application import MIMEApplication
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+
+import pytest
+
+
+@pytest.fixture
+def fixtures_dir():
+ """Return path to the fixtures/ directory."""
+ return os.path.join(os.path.dirname(__file__), 'fixtures')
+
+
+def make_plain_message(body="Test body", from_="Jonathan Smith <jsmith@example.com>",
+ to="Craig <craig@example.com>",
+ subject="Test Subject",
+ date="Wed, 05 Feb 2026 11:36:00 -0600"):
+ """Create an EmailMessage with text/plain body."""
+ msg = EmailMessage()
+ msg['From'] = from_
+ msg['To'] = to
+ msg['Subject'] = subject
+ msg['Date'] = date
+ msg.set_content(body)
+ return msg
+
+
+def make_html_message(html_body="<p>Test body</p>",
+ from_="Jonathan Smith <jsmith@example.com>",
+ to="Craig <craig@example.com>",
+ subject="Test Subject",
+ date="Wed, 05 Feb 2026 11:36:00 -0600"):
+ """Create an EmailMessage with text/html body only."""
+ msg = EmailMessage()
+ msg['From'] = from_
+ msg['To'] = to
+ msg['Subject'] = subject
+ msg['Date'] = date
+ msg.set_content(html_body, subtype='html')
+ return msg
+
+
+def make_message_with_attachment(body="Test body",
+ from_="Jonathan Smith <jsmith@example.com>",
+ to="Craig <craig@example.com>",
+ subject="Test Subject",
+ date="Wed, 05 Feb 2026 11:36:00 -0600",
+ attachment_filename="document.pdf",
+ attachment_content=b"fake pdf content"):
+ """Create a multipart message with a text body and one attachment."""
+ msg = MIMEMultipart()
+ msg['From'] = from_
+ msg['To'] = to
+ msg['Subject'] = subject
+ msg['Date'] = date
+
+ msg.attach(MIMEText(body, 'plain'))
+
+ att = MIMEApplication(attachment_content, Name=attachment_filename)
+ att['Content-Disposition'] = f'attachment; filename="{attachment_filename}"'
+ msg.attach(att)
+
+ return msg
+
+
+def add_received_headers(msg, headers):
+ """Add Received headers to an existing message.
+
+ headers: list of header strings, added in order (first = most recent).
+ """
+ for header in headers:
+ msg['Received'] = header
+ return msg
diff --git a/.ai/scripts/tests/fixtures/duplicate-attachment-names.eml b/.ai/scripts/tests/fixtures/duplicate-attachment-names.eml
new file mode 100644
index 0000000..827d4f0
--- /dev/null
+++ b/.ai/scripts/tests/fixtures/duplicate-attachment-names.eml
@@ -0,0 +1,36 @@
+From: Jonathan Smith <jsmith@example.com>
+To: Craig Jennings <craig@example.com>
+Subject: Re: 4319 Danneel Street
+Date: Mon, 27 Apr 2026 23:30:28 +0000
+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="boundary123"
+
+--boundary123
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Body with three inlined copies of the same signature image, mimicking
+the way Outlook embeds a sender's signature once per quoted reply level.
+
+--boundary123
+Content-Type: image/png; name="Outlook-Ricci Part.png"
+Content-Disposition: inline; filename="Outlook-Ricci Part.png"
+Content-Transfer-Encoding: base64
+
+aW1hZ2UtY29udGVudC0x
+
+--boundary123
+Content-Type: image/png; name="Outlook-Ricci Part.png"
+Content-Disposition: inline; filename="Outlook-Ricci Part.png"
+Content-Transfer-Encoding: base64
+
+aW1hZ2UtY29udGVudC0y
+
+--boundary123
+Content-Type: image/png; name="Outlook-Ricci Part.png"
+Content-Disposition: inline; filename="Outlook-Ricci Part.png"
+Content-Transfer-Encoding: base64
+
+aW1hZ2UtY29udGVudC0z
+
+--boundary123--
diff --git a/.ai/scripts/tests/fixtures/empty-body.eml b/.ai/scripts/tests/fixtures/empty-body.eml
new file mode 100644
index 0000000..cf008df
--- /dev/null
+++ b/.ai/scripts/tests/fixtures/empty-body.eml
@@ -0,0 +1,16 @@
+From: Jonathan Smith <jsmith@example.com>
+To: Craig Jennings <craig@example.com>
+Subject: Empty Body Test
+Date: Thu, 05 Feb 2026 11:36:00 -0600
+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="boundary456"
+Received: from mail-sender.example.com by mx.receiver.example.com with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600
+
+--boundary456
+Content-Type: application/octet-stream; name="data.bin"
+Content-Disposition: attachment; filename="data.bin"
+Content-Transfer-Encoding: base64
+
+AQIDBA==
+
+--boundary456--
diff --git a/.ai/scripts/tests/fixtures/html-only.eml b/.ai/scripts/tests/fixtures/html-only.eml
new file mode 100644
index 0000000..4db7645
--- /dev/null
+++ b/.ai/scripts/tests/fixtures/html-only.eml
@@ -0,0 +1,20 @@
+From: Jonathan Smith <jsmith@example.com>
+To: Craig Jennings <craig@example.com>
+Subject: HTML Update
+Date: Thu, 05 Feb 2026 11:36:00 -0600
+MIME-Version: 1.0
+Content-Type: text/html; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+Received: from mail-sender.example.com by mx.receiver.example.com with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600
+
+<html>
+<body>
+<p>Hi Craig,</p>
+<p>Here is the <strong>HTML</strong> update.</p>
+<ul>
+<li>Item one</li>
+<li>Item two</li>
+</ul>
+<p>Best,<br>Jonathan</p>
+</body>
+</html>
diff --git a/.ai/scripts/tests/fixtures/multiple-received-headers.eml b/.ai/scripts/tests/fixtures/multiple-received-headers.eml
new file mode 100644
index 0000000..1b8d6a7
--- /dev/null
+++ b/.ai/scripts/tests/fixtures/multiple-received-headers.eml
@@ -0,0 +1,12 @@
+From: Jonathan Smith <jsmith@example.com>
+To: Craig Jennings <craig@example.com>
+Subject: Multiple Received Headers Test
+Date: Thu, 05 Feb 2026 11:36:00 -0600
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+Received: by internal.example.com with SMTP; Thu, 05 Feb 2026 11:36:10 -0600
+Received: from mail-sender.example.com by mx.receiver.example.com with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600
+Received: from originator.example.com by relay.example.com with SMTP; Thu, 05 Feb 2026 11:35:58 -0600
+
+Test body with multiple received headers.
diff --git a/.ai/scripts/tests/fixtures/no-received-headers.eml b/.ai/scripts/tests/fixtures/no-received-headers.eml
new file mode 100644
index 0000000..8a05dc7
--- /dev/null
+++ b/.ai/scripts/tests/fixtures/no-received-headers.eml
@@ -0,0 +1,9 @@
+From: Jonathan Smith <jsmith@example.com>
+To: Craig Jennings <craig@example.com>
+Subject: No Received Headers
+Date: Thu, 05 Feb 2026 11:36:00 -0600
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Test body with no received headers at all.
diff --git a/.ai/scripts/tests/fixtures/plain-text.eml b/.ai/scripts/tests/fixtures/plain-text.eml
new file mode 100644
index 0000000..8cc9d9c
--- /dev/null
+++ b/.ai/scripts/tests/fixtures/plain-text.eml
@@ -0,0 +1,15 @@
+From: Jonathan Smith <jsmith@example.com>
+To: Craig Jennings <craig@example.com>
+Subject: Re: Fw: 4319 Danneel Street
+Date: Thu, 05 Feb 2026 11:36:00 -0600
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+Received: from mail-sender.example.com by mx.receiver.example.com with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600
+
+Hi Craig,
+
+Here is the update on 4319 Danneel Street.
+
+Best,
+Jonathan
diff --git a/.ai/scripts/tests/fixtures/todo-sample.org b/.ai/scripts/tests/fixtures/todo-sample.org
new file mode 100644
index 0000000..8b9e723
--- /dev/null
+++ b/.ai/scripts/tests/fixtures/todo-sample.org
@@ -0,0 +1,37 @@
+#+TITLE: Sample todo.org for todo-cleanup tests
+#+AUTHOR: synthetic fixture
+
+# A deliberately varied (but synthetic) todo.org: umbrella "Open Work" /
+# "Resolved" headings, mixed TODO/DOING/WAITING/DONE/CANCELLED states,
+# priorities, tags, nested level-3 children, and a few structural (no-state)
+# section headings. `--archive-done' should move only the direct level-2
+# DONE/CANCELLED subtrees from "Open Work" into "Resolved", intact, and leave
+# everything else alone.
+
+* Sample Open Work
+** TODO [#A] Write the README
+ This one stays — still open.
+** DOING [#A] Refactor the parser
+ In progress; stays.
+** DONE [#A] Bootstrap the test harness :tooling:
+ Finished. Should move to Resolved with this body intact.
+** WAITING [#B] Vendor reply on the licensing question
+ Blocked, not done — stays.
+** A grouping heading with no TODO state
+*** TODO [#B] sub-task one
+*** DONE [#C] sub-task two — done, but nested under an open parent, so stays
+** CANCELLED [#B] Drop the legacy importer :chore:
+ Decided against it. Should move to Resolved.
+** TODO [#B] Ship the migration :quick:
+*** DONE [#C] write the up migration
+*** TODO [#C] write the down migration
+** DONE [#B] Tag the 1.0 release
+*** DONE [#C] update the changelog
+*** TODO [#C] announce on the list
+ Parent is DONE, so the whole subtree (open child included) moves.
+** NEXT [#C] Pick the next milestone
+
+* Sample Resolved
+** DONE [#A] Initial project skeleton
+ Pre-existing archived entry; new arrivals append after this one.
+** CANCELLED [#C] Evaluate the other framework
diff --git a/.ai/scripts/tests/fixtures/with-attachment.eml b/.ai/scripts/tests/fixtures/with-attachment.eml
new file mode 100644
index 0000000..ac49c5d
--- /dev/null
+++ b/.ai/scripts/tests/fixtures/with-attachment.eml
@@ -0,0 +1,27 @@
+From: Jonathan Smith <jsmith@example.com>
+To: Craig Jennings <craig@example.com>
+Subject: Ltr from Carrollton
+Date: Thu, 05 Feb 2026 11:36:00 -0600
+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="boundary123"
+Received: from mail-sender.example.com by mx.receiver.example.com with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600
+
+--boundary123
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Hi Craig,
+
+Please find the letter attached.
+
+Best,
+Jonathan
+
+--boundary123
+Content-Type: application/octet-stream; name="Ltr Carrollton.pdf"
+Content-Disposition: attachment; filename="Ltr Carrollton.pdf"
+Content-Transfer-Encoding: base64
+
+ZmFrZSBwZGYgY29udGVudA==
+
+--boundary123--
diff --git a/.ai/scripts/tests/test-lint-org.el b/.ai/scripts/tests/test-lint-org.el
new file mode 100644
index 0000000..8e1ebc4
--- /dev/null
+++ b/.ai/scripts/tests/test-lint-org.el
@@ -0,0 +1,465 @@
+;;; test-lint-org.el --- ERT tests for lint-org.el -*- lexical-binding: t; -*-
+;;
+;; Run from the repo root:
+;; emacs --batch -q -L .ai/scripts -l ert \
+;; -l .ai/scripts/tests/test-lint-org.el \
+;; -f ert-run-tests-batch-and-exit
+;;
+;; or from .ai/scripts/tests/:
+;; emacs --batch -q -L .. -l ert -l test-lint-org.el \
+;; -f ert-run-tests-batch-and-exit
+;;
+;; Covers: mechanical auto-fixers (item-number, missing-language-in-src-block,
+;; misplaced-planning-info, markdown-bold case of misplaced-heading) and
+;; judgment-item emission (link-to-local-file, invalid-fuzzy-link,
+;; verbatim-asterisk case of misplaced-heading, suspicious-language-in-src-block,
+;; unhandled checkers).
+
+(require 'ert)
+(require 'cl-lib)
+
+(defconst lo-test--dir
+ (file-name-directory (or load-file-name buffer-file-name default-directory))
+ "Directory of this test file, captured at load time.")
+
+(add-to-list 'load-path (expand-file-name ".." lo-test--dir))
+(require 'lint-org)
+
+;;; ---------------------------------------------------------------------------
+;;; Harness
+
+(defun lo-test--reset (&optional check followups-file)
+ (setq lo-fixes 0 lo-issues nil
+ lo-check-only (and check t)
+ lo-current-file nil
+ lo-followups-file followups-file))
+
+(defun lo-test--drop-buffer (file)
+ (let ((buf (find-buffer-visiting file)))
+ (when buf
+ (with-current-buffer buf (set-buffer-modified-p nil))
+ (kill-buffer buf))))
+
+(defun lo-test--run (content &optional runs check)
+ "Write CONTENT to a temp .org file, run lint-org RUNS times (default 1).
+Return a plist :result (final file contents) :fixes (last run)
+:issues (last run). CHECK non-nil ⇒ --check (preview, no writes)."
+ (let ((file (make-temp-file "lo-test-" nil ".org"))
+ last-fixes last-issues)
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert content))
+ (dotimes (_ (or runs 1))
+ (lo-test--reset check)
+ (lo-process-file file)
+ (setq last-fixes lo-fixes last-issues lo-issues)
+ (lo-test--drop-buffer file))
+ (list :result (with-temp-buffer (insert-file-contents file)
+ (buffer-string))
+ :fixes last-fixes
+ :issues last-issues))
+ (lo-test--drop-buffer file)
+ (delete-file file))))
+
+(defun lo-test--judgments (issues)
+ "Return judgment items from ISSUES, in document order."
+ (reverse
+ (cl-remove-if-not (lambda (i) (eq (plist-get i :kind) 'judgment)) issues)))
+
+(defun lo-test--mechanical (issues)
+ "Return mechanical-fixed items from ISSUES, in document order."
+ (reverse
+ (cl-remove-if-not (lambda (i) (eq (plist-get i :kind) 'mechanical-fixed))
+ issues)))
+
+(defun lo-test--checkers (items)
+ (mapcar (lambda (i) (plist-get i :checker)) items))
+
+(defun lo-test--has (string substring)
+ (and (string-match-p (regexp-quote substring) string) t))
+
+;;; ---------------------------------------------------------------------------
+;;; Fixtures
+
+;; item-number — bullets 4. and 5. where org expects items 3 and 4.
+(defconst lo-test--item-number "\
+* Heading
+
+1. first
+2. second
+
+4. out-of-order
+5. and another
+")
+
+(defconst lo-test--item-number-already-tagged "\
+* Heading
+
+1. first
+2. second
+
+4. [@4] already tagged
+5. [@5] also already tagged
+")
+
+;; missing-language-in-src-block — bare #+begin_src ... #+end_src.
+(defconst lo-test--bare-src "\
+* Heading
+
+#+begin_src
+some prose without a language
+#+end_src
+")
+
+;; A src block with a language slug doesn't trip the missing-language checker.
+(defconst lo-test--src-with-language "\
+* Heading
+
+#+begin_src text
+some prose with a language
+#+end_src
+")
+
+;; misplaced-planning-info — CLOSED and DEADLINE on separate lines.
+(defconst lo-test--planning-split "\
+* DONE Task
+CLOSED: [2026-05-14]
+DEADLINE: <2026-05-20>
+
+Body.
+")
+
+;; misplaced-heading, markdown-bold case — **X.** at start of body paragraph.
+(defconst lo-test--md-bold "\
+* Heading
+
+**Important.** Body continues here.
+
+More body.
+")
+
+;; misplaced-heading, verbatim-asterisk case — =*** Foo= inside body prose.
+(defconst lo-test--verbatim-asterisk "\
+* Heading
+
+A reference to =*** Foo= inside body prose.
+")
+
+;; link-to-local-file — broken file: link.
+(defconst lo-test--broken-file-link "\
+* Heading
+
+See [[file:/tmp/does-not-exist-lo-test.org][a link]].
+")
+
+;; invalid-fuzzy-link — link to a heading that doesn't exist in this file.
+(defconst lo-test--broken-fuzzy-link "\
+* Heading
+
+See [[*Nonexistent Heading]].
+")
+
+;; suspicious-language-in-src-block — #+begin_src markdown.
+(defconst lo-test--suspicious-language "\
+* Heading
+
+#+begin_src markdown
+content
+#+end_src
+")
+
+;; Mixed fixture — each category once.
+(defconst lo-test--mixed "\
+* Mixed
+
+1. first
+2. second
+
+4. out-of-order
+
+** DONE Task
+CLOSED: [2026-05-14]
+DEADLINE: <2026-05-20>
+
+**Important.** Body.
+
+A reference to =*** Foo= inside body.
+
+See [[file:/tmp/does-not-exist-lo-test.org][a link]].
+
+See [[*Nonexistent Heading]].
+
+#+begin_src
+prose
+#+end_src
+
+#+begin_src markdown
+content
+#+end_src
+")
+
+;;; ---------------------------------------------------------------------------
+;;; item-number tests
+
+(ert-deftest lo-item-number-adds-counter-directive ()
+ (let* ((out (lo-test--run lo-test--item-number))
+ (res (plist-get out :result)))
+ (should (>= (plist-get out :fixes) 1))
+ (should (lo-test--has res "4. [@4] out-of-order"))
+ (should (lo-test--has res "5. [@5] and another"))
+ ;; well-formed bullets above stay alone
+ (should (lo-test--has res "1. first"))
+ (should (lo-test--has res "2. second"))))
+
+(ert-deftest lo-item-number-skips-already-tagged ()
+ (let ((out (lo-test--run lo-test--item-number-already-tagged)))
+ (should (= 0 (plist-get out :fixes)))
+ (should (equal lo-test--item-number-already-tagged (plist-get out :result)))))
+
+(ert-deftest lo-item-number-is-idempotent ()
+ (let ((once (plist-get (lo-test--run lo-test--item-number 1) :result))
+ (twice (plist-get (lo-test--run lo-test--item-number 2) :result)))
+ (should (equal once twice))))
+
+;;; ---------------------------------------------------------------------------
+;;; missing-language-in-src-block tests
+
+(ert-deftest lo-bare-src-becomes-example ()
+ (let* ((out (lo-test--run lo-test--bare-src))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :fixes)))
+ (should (lo-test--has res "#+begin_example"))
+ (should (lo-test--has res "#+end_example"))
+ (should-not (lo-test--has res "#+begin_src\n"))
+ (should-not (lo-test--has res "#+end_src"))
+ (should (lo-test--has res "some prose without a language"))))
+
+(ert-deftest lo-src-with-language-stays ()
+ (let ((out (lo-test--run lo-test--src-with-language)))
+ (should (= 0 (plist-get out :fixes)))
+ (should (equal lo-test--src-with-language (plist-get out :result)))))
+
+(ert-deftest lo-bare-src-is-idempotent ()
+ (let ((once (plist-get (lo-test--run lo-test--bare-src 1) :result))
+ (twice (plist-get (lo-test--run lo-test--bare-src 2) :result)))
+ (should (equal once twice))))
+
+;;; ---------------------------------------------------------------------------
+;;; misplaced-planning-info tests
+
+(ert-deftest lo-planning-info-merges-onto-one-line ()
+ (let* ((out (lo-test--run lo-test--planning-split))
+ (res (plist-get out :result)))
+ (should (>= (plist-get out :fixes) 1))
+ ;; Both keywords on the same line, exactly one blank space between values.
+ (should (string-match-p
+ "CLOSED: \\[2026-05-14\\][^\n]*DEADLINE: <2026-05-20"
+ res))
+ ;; No stray DEADLINE: line on its own.
+ (should-not (string-match-p "^DEADLINE: <2026-05-20" res))))
+
+(ert-deftest lo-planning-info-is-idempotent ()
+ (let ((once (plist-get (lo-test--run lo-test--planning-split 1) :result))
+ (twice (plist-get (lo-test--run lo-test--planning-split 2) :result)))
+ (should (equal once twice))))
+
+;;; ---------------------------------------------------------------------------
+;;; misplaced-heading tests
+
+(ert-deftest lo-markdown-bold-becomes-single-asterisk ()
+ (let* ((out (lo-test--run lo-test--md-bold))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :fixes)))
+ (should (lo-test--has res "*Important.* Body continues here."))
+ (should-not (lo-test--has res "**Important.**"))))
+
+(ert-deftest lo-markdown-bold-is-idempotent ()
+ (let ((once (plist-get (lo-test--run lo-test--md-bold 1) :result))
+ (twice (plist-get (lo-test--run lo-test--md-bold 2) :result)))
+ (should (equal once twice))))
+
+(ert-deftest lo-verbatim-asterisk-is-judgment ()
+ (let* ((out (lo-test--run lo-test--verbatim-asterisk))
+ (res (plist-get out :result))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ ;; File untouched.
+ (should (equal lo-test--verbatim-asterisk res))
+ (should (= 0 (plist-get out :fixes)))
+ ;; Emitted as judgment with the misplaced-heading checker.
+ (should (member 'misplaced-heading (lo-test--checkers judgments)))))
+
+;;; ---------------------------------------------------------------------------
+;;; Judgment-category emission tests
+
+(ert-deftest lo-broken-file-link-is-judgment ()
+ (let* ((out (lo-test--run lo-test--broken-file-link))
+ (res (plist-get out :result))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (equal lo-test--broken-file-link res))
+ (should (= 0 (plist-get out :fixes)))
+ (should (member 'link-to-local-file (lo-test--checkers judgments)))))
+
+(ert-deftest lo-broken-fuzzy-link-is-judgment ()
+ (let* ((out (lo-test--run lo-test--broken-fuzzy-link))
+ (res (plist-get out :result))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (equal lo-test--broken-fuzzy-link res))
+ (should (= 0 (plist-get out :fixes)))
+ (should (member 'invalid-fuzzy-link (lo-test--checkers judgments)))))
+
+(ert-deftest lo-suspicious-language-is-judgment ()
+ (let* ((out (lo-test--run lo-test--suspicious-language))
+ (res (plist-get out :result))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (equal lo-test--suspicious-language res))
+ (should (= 0 (plist-get out :fixes)))
+ (should (member 'suspicious-language-in-src-block
+ (lo-test--checkers judgments)))))
+
+;;; ---------------------------------------------------------------------------
+;;; --check mode
+
+(ert-deftest lo-check-mode-does-not-modify-file ()
+ (let* ((out (lo-test--run lo-test--mixed 1 t))
+ (res (plist-get out :result)))
+ (should (equal lo-test--mixed res))))
+
+(ert-deftest lo-check-mode-reports-mechanical-and-judgment ()
+ (let* ((out (lo-test--run lo-test--mixed 1 t))
+ (issues (plist-get out :issues))
+ (kinds (cl-remove-duplicates
+ (mapcar (lambda (i) (plist-get i :kind)) issues))))
+ ;; Both kinds appear — check mode reports would-fix entries as
+ ;; mechanical-fixed and judgment items as judgment, no writes.
+ (should (member 'mechanical-fixed kinds))
+ (should (member 'judgment kinds))))
+
+;;; ---------------------------------------------------------------------------
+;;; Mixed-fixture integration
+
+(ert-deftest lo-mixed-fixture-applies-all-mechanical-and-emits-judgment ()
+ (let* ((out (lo-test--run lo-test--mixed))
+ (res (plist-get out :result))
+ (judgment-checkers
+ (cl-remove-duplicates
+ (lo-test--checkers (lo-test--judgments (plist-get out :issues))))))
+ ;; Mechanical: every flagged item-number, bare-src, planning, md-bold fixed.
+ (should (>= (plist-get out :fixes) 4))
+ (should (lo-test--has res "4. [@4] out-of-order"))
+ (should (lo-test--has res "#+begin_example"))
+ (should (lo-test--has res "*Important.* Body."))
+ (should (string-match-p
+ "CLOSED: \\[2026-05-14\\][^\n]*DEADLINE: <2026-05-20"
+ res))
+ ;; Judgment: every flagged broken link, suspicious-language, verbatim-asterisk
+ ;; emitted untouched.
+ (should (member 'link-to-local-file judgment-checkers))
+ (should (member 'invalid-fuzzy-link judgment-checkers))
+ (should (member 'suspicious-language-in-src-block judgment-checkers))
+ (should (member 'misplaced-heading judgment-checkers))
+ ;; Verbatim-asterisk untouched in the file.
+ (should (lo-test--has res "=*** Foo="))))
+
+(ert-deftest lo-mixed-fixture-is-idempotent ()
+ (let ((once (plist-get (lo-test--run lo-test--mixed 1) :result))
+ (twice (plist-get (lo-test--run lo-test--mixed 2) :result)))
+ (should (equal once twice))))
+
+;;; ---------------------------------------------------------------------------
+;;; Backup file is created in /tmp
+
+;;; ---------------------------------------------------------------------------
+;;; Follow-ups file behavior
+
+(ert-deftest lo-followups-file-appends-judgments ()
+ (let ((followups (make-temp-file "lo-followups-" nil ".org"))
+ (file (make-temp-file "lo-test-fup-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert lo-test--mixed))
+ (with-temp-file followups (insert ""))
+ (lo-test--reset nil followups)
+ (lo-process-file file)
+ (lo-emit-report)
+ (lo-test--drop-buffer file)
+ (let ((content (with-temp-buffer
+ (insert-file-contents followups)
+ (buffer-string))))
+ ;; Dated section header.
+ (should (string-match-p
+ (format "^\\* %s lint-org follow-ups"
+ (format-time-string "%Y-%m-%d"))
+ content))
+ ;; Each judgment is a TODO line referencing checker + line number.
+ (should (string-match-p "TODO line [0-9]+ — link-to-local-file" content))
+ (should (string-match-p "TODO line [0-9]+ — invalid-fuzzy-link" content))
+ (should (string-match-p
+ "TODO line [0-9]+ — suspicious-language-in-src-block"
+ content))))
+ (lo-test--drop-buffer file)
+ (when (file-exists-p file) (delete-file file))
+ (when (file-exists-p followups) (delete-file followups)))))
+
+(ert-deftest lo-followups-file-skipped-in-check-mode ()
+ (let ((followups (make-temp-file "lo-followups-" nil ".org"))
+ (file (make-temp-file "lo-test-fup-check-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert lo-test--mixed))
+ (with-temp-file followups (insert ""))
+ (lo-test--reset t followups) ; check=t, followups set
+ (lo-process-file file)
+ (lo-emit-report)
+ (lo-test--drop-buffer file)
+ ;; followups untouched in check mode
+ (should (equal "" (with-temp-buffer
+ (insert-file-contents followups)
+ (buffer-string)))))
+ (lo-test--drop-buffer file)
+ (when (file-exists-p file) (delete-file file))
+ (when (file-exists-p followups) (delete-file followups)))))
+
+(ert-deftest lo-followups-file-noop-when-no-judgments ()
+ ;; A fixture with only mechanical issues should leave the followups file empty.
+ (let ((followups (make-temp-file "lo-followups-" nil ".org"))
+ (file (make-temp-file "lo-test-fup-empty-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert lo-test--item-number))
+ (with-temp-file followups (insert ""))
+ (lo-test--reset nil followups)
+ (lo-process-file file)
+ (lo-emit-report)
+ (lo-test--drop-buffer file)
+ (should (equal "" (with-temp-buffer
+ (insert-file-contents followups)
+ (buffer-string)))))
+ (lo-test--drop-buffer file)
+ (when (file-exists-p file) (delete-file file))
+ (when (file-exists-p followups) (delete-file followups)))))
+
+(ert-deftest lo-creates-backup-before-modifying ()
+ (let ((file (make-temp-file "lo-test-bak-" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert lo-test--bare-src))
+ (lo-test--reset)
+ (lo-process-file file)
+ (lo-test--drop-buffer file)
+ ;; Backup pattern in lint-org.el: /tmp/<basename>.before-lint-pass.<timestamp>
+ (let* ((basename (file-name-nondirectory file))
+ (backups (directory-files "/tmp" t
+ (concat (regexp-quote basename)
+ "\\.before-lint-pass\\."))))
+ (should (>= (length backups) 1))
+ ;; Backup content matches pre-fix content.
+ (let ((backup (car backups)))
+ (with-temp-buffer
+ (insert-file-contents backup)
+ (should (equal lo-test--bare-src (buffer-string))))
+ (delete-file backup))))
+ (lo-test--drop-buffer file)
+ (when (file-exists-p file) (delete-file file)))))
+
+(provide 'test-lint-org)
+;;; test-lint-org.el ends here
diff --git a/.ai/scripts/tests/test-todo-cleanup.el b/.ai/scripts/tests/test-todo-cleanup.el
new file mode 100644
index 0000000..5d43f97
--- /dev/null
+++ b/.ai/scripts/tests/test-todo-cleanup.el
@@ -0,0 +1,518 @@
+;;; test-todo-cleanup.el --- ERT tests for todo-cleanup.el -*- lexical-binding: t; -*-
+;;
+;; Run from the repo root:
+;; emacs --batch -q -L .ai/scripts -l ert \
+;; -l .ai/scripts/tests/test-todo-cleanup.el \
+;; -f ert-run-tests-batch-and-exit
+;;
+;; or from .ai/scripts/tests/:
+;; emacs --batch -q -L .. -l ert -l test-todo-cleanup.el \
+;; -f ert-run-tests-batch-and-exit
+;;
+;; Covers the `--archive-done' mode: moving level-2 DONE/CANCELLED subtrees
+;; out of the "Open Work" section into the "Resolved" section.
+
+(require 'ert)
+(require 'cl-lib)
+
+(defconst tc-test--dir
+ (file-name-directory (or load-file-name buffer-file-name default-directory))
+ "Directory of this test file, captured at load time.")
+
+;; Make `todo-cleanup' loadable from the parent directory. Loading it is
+;; inert: its CLI dispatch only fires when the trailing command-line args look
+;; like a real invocation (recognized flags / readable file paths), which they
+;; don't during `ert-run-tests-batch-and-exit'.
+(add-to-list 'load-path (expand-file-name ".." tc-test--dir))
+(require 'todo-cleanup)
+
+;;; ---------------------------------------------------------------------------
+;;; Harness
+
+(defun tc-test--reset (&optional check)
+ (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil
+ tc-check-only (and check t)
+ tc-archive-done t tc-sync-child-priority nil
+ tc-current-file nil))
+
+(defun tc-test--reset-sync (&optional check)
+ (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil
+ tc-check-only (and check t)
+ tc-archive-done nil tc-sync-child-priority t
+ tc-current-file nil))
+
+(defun tc-test--drop-buffer (file)
+ (let ((buf (find-buffer-visiting file)))
+ (when buf
+ (with-current-buffer buf (set-buffer-modified-p nil))
+ (kill-buffer buf))))
+
+(defun tc-test--archive (content &optional runs check)
+ "Write CONTENT to a temp .org file, run `--archive-done' RUNS times (default 1).
+Return a plist: :result final file contents, :archived count from the last run,
+:issues from the last run. CHECK non-nil ⇒ --check (preview, no writes)."
+ (let ((file (make-temp-file "tc-test-" nil ".org"))
+ last-archived last-issues)
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert content))
+ (dotimes (_ (or runs 1))
+ (tc-test--reset check)
+ (tc-process-file file)
+ (setq last-archived tc-archived last-issues tc-issues)
+ (tc-test--drop-buffer file))
+ (list :result (with-temp-buffer (insert-file-contents file)
+ (buffer-string))
+ :archived last-archived
+ :issues last-issues))
+ (tc-test--drop-buffer file)
+ (delete-file file))))
+
+(defun tc-test--section (content needle)
+ "Text of the level-1 section in CONTENT whose heading line contains NEEDLE —
+from the heading line through (not including) the next level-1 heading or EOF."
+ (with-temp-buffer
+ (insert content)
+ (goto-char (point-min))
+ (let (start)
+ (while (and (not start) (re-search-forward "^\\* .*$" nil t))
+ (when (string-match-p (regexp-quote needle) (match-string 0))
+ (setq start (match-beginning 0))))
+ (unless start (error "no level-1 heading containing %S" needle))
+ (goto-char start)
+ (forward-line 1)
+ (buffer-substring-no-properties
+ start
+ (if (re-search-forward "^\\* " nil t) (match-beginning 0) (point-max))))))
+
+(defun tc-test--has (string substring)
+ (and (string-match-p (regexp-quote substring) string) t))
+
+(defun tc-test--before-p (string a b)
+ "Non-nil when SUBSTRING A occurs before SUBSTRING B in STRING."
+ (let ((ia (string-match (regexp-quote a) string))
+ (ib (string-match (regexp-quote b) string)))
+ (and ia ib (< ia ib))))
+
+(defun tc-test--skip-detail (issues)
+ (let ((skip (cl-find-if (lambda (i) (eq (plist-get i :kind) 'archive-skip)) issues)))
+ (and skip (plist-get skip :detail))))
+
+(defun tc-test--moved-headings (issues)
+ (mapcar (lambda (i) (plist-get i :heading))
+ (cl-remove-if-not
+ (lambda (i) (memq (plist-get i :kind) '(archive-moved archive-would)))
+ (reverse issues))))
+
+;;; ---------------------------------------------------------------------------
+;;; Fixtures (synthetic — real project todo.org files are examples only)
+
+(defconst tc-test--basic "\
+* Demo Open Work
+** TODO [#A] First open task
+ first body
+** DONE [#A] A finished task
+ finished body
+** TODO [#B] Another open task
+* Demo Resolved
+** DONE [#A] Previously archived
+")
+
+(defconst tc-test--mixed "\
+* Proj Open Work
+** TODO Keep me open
+** DONE Done one
+*** TODO leftover child of done one
+** A structural heading with no state
+** CANCELLED Cancelled two :quick:
+** TODO Has a done child
+*** DONE this nested done stays
+** DONE Done three
+* Proj Resolved
+** DONE Old archived item
+")
+
+(defconst tc-test--nothing "\
+* X Open Work
+** TODO a
+** WAITING b
+** NEXT c
+* X Resolved
+** DONE old
+")
+
+(defconst tc-test--no-resolved "\
+* Y Open Work
+** DONE finished
+** TODO ongoing
+")
+
+(defconst tc-test--no-open "\
+* Z Resolved
+** DONE old
+* Some Other Section
+** TODO whatever
+")
+
+(defconst tc-test--two-resolved "\
+* P Open Work
+** DONE done
+* P Resolved
+** DONE old1
+* Q Resolved Notes
+** DONE old2
+")
+
+;; No trailing newline — exercises the EOF / final-line case. Open Work is the
+;; last section, so a DONE level-2 here is also the last subtree in the file.
+(defconst tc-test--eof "\
+* W Resolved
+** DONE pre-existing
+* W Open Work
+** TODO keep open
+** DONE last thing
+ body of last thing")
+
+(defconst tc-test--lowercase "\
+* winvm open work
+** TODO test rebuilt vm
+** DONE fix display resolution
+* winvm resolved
+** DONE fork linoffice as winvm
+")
+
+;;; ---------------------------------------------------------------------------
+;;; Tests
+
+(ert-deftest tc-archive-moves-one-done-level-2 ()
+ (let* ((out (tc-test--archive tc-test--basic))
+ (res (plist-get out :result))
+ (open (tc-test--section res "Demo Open Work"))
+ (resolved (tc-test--section res "Demo Resolved")))
+ (should (= 1 (plist-get out :archived)))
+ (should (tc-test--has resolved "A finished task"))
+ (should (tc-test--has resolved "finished body"))
+ (should-not (tc-test--has open "A finished task"))
+ (should (tc-test--has open "First open task"))
+ (should (tc-test--has open "Another open task"))
+ ;; appended at the end of the Resolved section
+ (should (tc-test--before-p resolved "Previously archived" "A finished task"))))
+
+(ert-deftest tc-archive-moves-multiple-done-and-cancelled ()
+ (let* ((out (tc-test--archive tc-test--mixed))
+ (res (plist-get out :result))
+ (open (tc-test--section res "Proj Open Work"))
+ (resolved (tc-test--section res "Proj Resolved")))
+ (should (= 3 (plist-get out :archived)))
+ ;; stays in Open Work
+ (should (tc-test--has open "Keep me open"))
+ (should (tc-test--has open "A structural heading with no state"))
+ (should (tc-test--has open "Has a done child"))
+ (should (tc-test--has open "this nested done stays"))
+ ;; moved to Resolved
+ (should (tc-test--has resolved "Done one"))
+ (should (tc-test--has resolved "Cancelled two"))
+ (should (tc-test--has resolved "Done three"))
+ ;; a level-2 DONE moves its (open) children along with it
+ (should (tc-test--has resolved "leftover child of done one"))
+ (should-not (tc-test--has open "leftover child of done one"))
+ ;; gone from Open Work
+ (should-not (tc-test--has open "Done one"))
+ (should-not (tc-test--has open "Cancelled two"))
+ (should-not (tc-test--has open "Done three"))
+ ;; order: pre-existing first, then in document order
+ (should (tc-test--before-p resolved "Old archived item" "Done one"))
+ (should (tc-test--before-p resolved "Done one" "Cancelled two"))
+ (should (tc-test--before-p resolved "Cancelled two" "Done three"))))
+
+(ert-deftest tc-archive-structural-heading-does-not-move ()
+ (let* ((out (tc-test--archive tc-test--mixed))
+ (open (tc-test--section (plist-get out :result) "Proj Open Work")))
+ (should (tc-test--has open "A structural heading with no state"))))
+
+(ert-deftest tc-archive-nothing-to-do-is-noop ()
+ (let ((out (tc-test--archive tc-test--nothing)))
+ (should (= 0 (plist-get out :archived)))
+ (should (equal tc-test--nothing (plist-get out :result)))))
+
+(ert-deftest tc-archive-missing-resolved-section-skips ()
+ (let ((out (tc-test--archive tc-test--no-resolved)))
+ (should (= 0 (plist-get out :archived)))
+ (should (equal tc-test--no-resolved (plist-get out :result)))
+ (should (string-match-p "Resolved" (or (tc-test--skip-detail (plist-get out :issues)) "")))))
+
+(ert-deftest tc-archive-missing-open-work-section-skips ()
+ (let ((out (tc-test--archive tc-test--no-open)))
+ (should (= 0 (plist-get out :archived)))
+ (should (equal tc-test--no-open (plist-get out :result)))
+ (should (string-match-p "Open Work" (or (tc-test--skip-detail (plist-get out :issues)) "")))))
+
+(ert-deftest tc-archive-ambiguous-resolved-section-skips ()
+ (let ((out (tc-test--archive tc-test--two-resolved)))
+ (should (= 0 (plist-get out :archived)))
+ (should (equal tc-test--two-resolved (plist-get out :result)))
+ (should (string-match-p "Resolved" (or (tc-test--skip-detail (plist-get out :issues)) "")))))
+
+(ert-deftest tc-archive-subtree-at-eof ()
+ (let* ((out (tc-test--archive tc-test--eof))
+ (res (plist-get out :result))
+ (open (tc-test--section res "W Open Work"))
+ (resolved (tc-test--section res "W Resolved")))
+ (should (= 1 (plist-get out :archived)))
+ (should (tc-test--has resolved "last thing"))
+ (should (tc-test--has resolved "body of last thing"))
+ (should (tc-test--has open "keep open"))
+ (should-not (tc-test--has open "last thing"))
+ ;; result stays well-formed: a newline separates the moved body from the
+ ;; following section heading
+ (should (string-match-p "body of last thing\n\\* W Open Work" res))))
+
+(ert-deftest tc-archive-matches-lowercase-headings ()
+ (let* ((out (tc-test--archive tc-test--lowercase))
+ (res (plist-get out :result))
+ (open (tc-test--section res "winvm open work"))
+ (resolved (tc-test--section res "winvm resolved")))
+ (should (= 1 (plist-get out :archived)))
+ (should (tc-test--has resolved "fix display resolution"))
+ (should-not (tc-test--has open "fix display resolution"))
+ (should (tc-test--has open "test rebuilt vm"))))
+
+(ert-deftest tc-archive-is-idempotent ()
+ (dolist (fixture (list tc-test--basic tc-test--mixed tc-test--eof
+ tc-test--lowercase tc-test--nothing))
+ (let ((once (plist-get (tc-test--archive fixture 1) :result))
+ (twice (plist-get (tc-test--archive fixture 2) :result)))
+ (should (equal once twice)))))
+
+(ert-deftest tc-archive-check-mode-previews-without-writing ()
+ (let ((out (tc-test--archive tc-test--basic 1 t)))
+ (should (= 1 (plist-get out :archived)))
+ (should (equal tc-test--basic (plist-get out :result)))
+ (should (member "A finished task" (tc-test--moved-headings (plist-get out :issues))))))
+
+(ert-deftest tc-archive-check-mode-is-idempotent ()
+ (let ((once (tc-test--archive tc-test--mixed 1 t))
+ (twice (tc-test--archive tc-test--mixed 2 t)))
+ (should (equal tc-test--mixed (plist-get once :result)))
+ (should (equal tc-test--mixed (plist-get twice :result)))
+ (should (= 3 (plist-get once :archived)))
+ (should (= 3 (plist-get twice :archived)))))
+
+;;; ---------------------------------------------------------------------------
+;;; Realistic synthetic sample (committed under fixtures/)
+
+(defun tc-test--sample-file ()
+ (expand-file-name "fixtures/todo-sample.org" tc-test--dir))
+
+(ert-deftest tc-archive-realistic-sample ()
+ (let* ((src (tc-test--sample-file)))
+ (skip-unless (file-readable-p src))
+ (let* ((content (with-temp-buffer (insert-file-contents src) (buffer-string)))
+ (out (tc-test--archive content))
+ (res (plist-get out :result))
+ (out2 (tc-test--archive content 2)))
+ ;; every DONE/CANCELLED level-2 entry under "Open Work" moved out
+ (let ((open (tc-test--section res "Sample Open Work")))
+ (should-not (string-match-p "^\\*\\* \\(DONE\\|CANCELLED\\) " open)))
+ ;; structural and still-open level-2 entries stayed
+ (let ((open (tc-test--section res "Sample Open Work")))
+ (should (string-match-p "^\\*\\* TODO " open))
+ (should (string-match-p "^\\*\\* DOING " open)))
+ ;; idempotent
+ (should (equal res (plist-get out2 :result)))
+ ;; something actually moved
+ (should (> (plist-get out :archived) 0)))))
+
+;;; ---------------------------------------------------------------------------
+;;; Sync-child-priority harness + fixtures
+
+(defun tc-test--sync (content &optional runs check)
+ "Write CONTENT to a temp .org file, run `--sync-child-priority' RUNS times
+\(default 1\). Return a plist: :result final file contents, :bumped count from
+the last run, :issues from the last run. CHECK non-nil ⇒ --check (preview)."
+ (let ((file (make-temp-file "tc-test-sync-" nil ".org"))
+ last-bumped last-issues)
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert content))
+ (dotimes (_ (or runs 1))
+ (tc-test--reset-sync check)
+ (tc-process-file file)
+ (setq last-bumped tc-bumped last-issues tc-issues)
+ (tc-test--drop-buffer file))
+ (list :result (with-temp-buffer (insert-file-contents file)
+ (buffer-string))
+ :bumped last-bumped
+ :issues last-issues))
+ (tc-test--drop-buffer file)
+ (delete-file file))))
+
+(defun tc-test--priority-of (content heading-substring)
+ "Return the priority letter (a string like \"A\") on the first heading line
+in CONTENT that contains HEADING-SUBSTRING, or nil if the heading has no
+priority cookie."
+ (with-temp-buffer
+ (insert content)
+ (goto-char (point-min))
+ (let (found-line found-prio)
+ (while (and (not found-line) (re-search-forward "^\\*+ .*$" nil t))
+ (let ((line (match-string 0)))
+ (when (string-match-p (regexp-quote heading-substring) line)
+ (setq found-line line)
+ (when (string-match "\\[#\\([A-Z]\\)\\]" line)
+ (setq found-prio (match-string 1 line))))))
+ (unless found-line
+ (error "no heading containing %S" heading-substring))
+ found-prio)))
+
+(defun tc-test--sync-bumped-headings (issues)
+ "Return the heading texts of every `:kind' sync-bumped or sync-would entry
+in ISSUES, in document order."
+ (mapcar (lambda (i) (plist-get i :child-heading))
+ (cl-remove-if-not
+ (lambda (i) (memq (plist-get i :kind) '(sync-bumped sync-would)))
+ (reverse issues))))
+
+(defconst tc-test--sync-basic "\
+* Open Work
+** TODO [#B] Parent
+*** TODO [#D] Drifted child
+*** TODO [#B] Already in sync
+")
+
+(defconst tc-test--sync-multi "\
+* Open Work
+** TODO [#B] Parent
+*** TODO [#A] Higher-priority child stays
+*** TODO [#B] Equal-priority child stays
+*** TODO [#C] Lower-priority child bumps
+*** TODO [#D] Way-lower-priority child bumps
+*** TODO Priority-less child stays
+")
+
+(defconst tc-test--sync-no-sync-tag "\
+* Open Work
+** TODO [#B] Parent
+*** TODO [#D] Regular drifted child
+*** TODO [#D] Follow-up: opted-out :no-sync:
+")
+
+(defconst tc-test--sync-priority-less-parent "\
+* Open Work
+** TODO Parent with no priority
+*** TODO [#D] Child with priority should not move
+")
+
+(defconst tc-test--sync-cascade "\
+* Open Work
+** TODO [#A] Top
+*** TODO [#B] Middle
+**** TODO [#D] Leaf
+")
+
+(defconst tc-test--sync-no-change "\
+* Open Work
+** TODO [#B] Parent
+*** TODO [#A] Child higher
+*** TODO [#B] Child equal
+")
+
+;; A dated-log heading inside a parent task whose title quotes other priorities
+;; in =[#X]= verbatim. Those quoted cookies must NOT be read as the heading's
+;; own priority — the cookie has to sit in canonical position to count.
+(defconst tc-test--sync-cookie-in-title "\
+* Open Work
+** TODO [#B] Parent
+*** 2026-05-14 Reprioritized children =[#D]= → =[#B]= to match parent
+*** TODO [#D] Regular drifted child
+")
+
+;;; ---------------------------------------------------------------------------
+;;; Sync-child-priority tests
+
+(ert-deftest tc-sync-bumps-lower-priority-child ()
+ (let* ((out (tc-test--sync tc-test--sync-basic))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :bumped)))
+ (should (equal "B" (tc-test--priority-of res "Drifted child")))
+ (should (equal "B" (tc-test--priority-of res "Already in sync")))
+ (should (equal "B" (tc-test--priority-of res "Parent")))))
+
+(ert-deftest tc-sync-leaves-higher-and-equal-children-alone ()
+ (let* ((out (tc-test--sync tc-test--sync-multi))
+ (res (plist-get out :result)))
+ (should (= 2 (plist-get out :bumped)))
+ (should (equal "A" (tc-test--priority-of res "Higher-priority child")))
+ (should (equal "B" (tc-test--priority-of res "Equal-priority child")))
+ (should (equal "B" (tc-test--priority-of res "Lower-priority child")))
+ (should (equal "B" (tc-test--priority-of res "Way-lower-priority child")))
+ (should-not (tc-test--priority-of res "Priority-less child"))))
+
+(ert-deftest tc-sync-skips-no-sync-tagged-child ()
+ (let* ((out (tc-test--sync tc-test--sync-no-sync-tag))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :bumped)))
+ (should (equal "B" (tc-test--priority-of res "Regular drifted child")))
+ (should (equal "D" (tc-test--priority-of res "Follow-up: opted-out")))))
+
+(ert-deftest tc-sync-leaves-priority-less-parent-alone ()
+ (let ((out (tc-test--sync tc-test--sync-priority-less-parent)))
+ (should (= 0 (plist-get out :bumped)))
+ (should (equal tc-test--sync-priority-less-parent (plist-get out :result)))))
+
+(ert-deftest tc-sync-cascades-through-multiple-levels ()
+ (let* ((out (tc-test--sync tc-test--sync-cascade))
+ (res (plist-get out :result)))
+ ;; one pass should collapse [#A] → [#B] → [#D] to all [#A] because
+ ;; org-map-entries visits the parent first, bumps the middle, then visits
+ ;; the (now bumped) middle and bumps its leaf
+ (should (= 2 (plist-get out :bumped)))
+ (should (equal "A" (tc-test--priority-of res "Top")))
+ (should (equal "A" (tc-test--priority-of res "Middle")))
+ (should (equal "A" (tc-test--priority-of res "Leaf")))))
+
+(ert-deftest tc-sync-no-change-when-all-children-at-or-above-parent ()
+ (let ((out (tc-test--sync tc-test--sync-no-change)))
+ (should (= 0 (plist-get out :bumped)))
+ (should (equal tc-test--sync-no-change (plist-get out :result)))))
+
+(ert-deftest tc-sync-ignores-cookie-shaped-text-in-title ()
+ (let* ((out (tc-test--sync tc-test--sync-cookie-in-title))
+ (res (plist-get out :result)))
+ ;; Only the real drifted child bumps; the dated-log heading with
+ ;; =[#D]= / =[#B]= verbatim text in its title is untouched.
+ (should (= 1 (plist-get out :bumped)))
+ (should (equal "B" (tc-test--priority-of res "Regular drifted child")))
+ ;; Substring still appears in the dated-log heading; the heading itself
+ ;; was not rewritten.
+ (should (string-match-p "Reprioritized children =\\[#D\\]= → =\\[#B\\]= to match parent" res))))
+
+(ert-deftest tc-sync-is-idempotent ()
+ (dolist (fixture (list tc-test--sync-basic
+ tc-test--sync-multi
+ tc-test--sync-no-sync-tag
+ tc-test--sync-priority-less-parent
+ tc-test--sync-cascade
+ tc-test--sync-no-change
+ tc-test--sync-cookie-in-title))
+ (let ((once (plist-get (tc-test--sync fixture 1) :result))
+ (twice (plist-get (tc-test--sync fixture 2) :result)))
+ (should (equal once twice)))))
+
+(ert-deftest tc-sync-check-mode-previews-without-writing ()
+ (let ((out (tc-test--sync tc-test--sync-basic 1 t)))
+ (should (= 1 (plist-get out :bumped)))
+ (should (equal tc-test--sync-basic (plist-get out :result)))
+ (should (member "Drifted child"
+ (tc-test--sync-bumped-headings (plist-get out :issues))))))
+
+(ert-deftest tc-sync-check-mode-is-idempotent ()
+ (let ((once (tc-test--sync tc-test--sync-cascade 1 t))
+ (twice (tc-test--sync tc-test--sync-cascade 2 t)))
+ (should (equal tc-test--sync-cascade (plist-get once :result)))
+ (should (equal tc-test--sync-cascade (plist-get twice :result)))
+ (should (= 2 (plist-get once :bumped)))
+ (should (= 2 (plist-get twice :bumped)))))
+
+(provide 'test-todo-cleanup)
+;;; test-todo-cleanup.el ends here
diff --git a/.ai/scripts/tests/test_cj_remove_block.py b/.ai/scripts/tests/test_cj_remove_block.py
new file mode 100644
index 0000000..2c8dade
--- /dev/null
+++ b/.ai/scripts/tests/test_cj_remove_block.py
@@ -0,0 +1,157 @@
+"""Tests for cj-remove-block.py — idempotent removal of cj annotations by line range.
+
+The script removes lines [start, end] (1-indexed, inclusive) from an org file but
+validates first that those lines actually look like a cj annotation. Refusing on
+mismatch protects against accidentally trimming the wrong block when line numbers
+drift between scan and remove calls.
+"""
+
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).parent.parent / "cj-remove-block.py"
+
+
+@pytest.fixture
+def run_remove(tmp_path):
+ """Write content to a temp org file, run cj-remove-block, return new contents."""
+ def _run(content: str, start: int, end: int) -> str:
+ f = tmp_path / "test.org"
+ f.write_text(content)
+ subprocess.run(
+ ["python3", str(SCRIPT),
+ "--file", str(f),
+ "--start", str(start),
+ "--end", str(end)],
+ check=True,
+ capture_output=True,
+ )
+ return f.read_text()
+ return _run
+
+
+@pytest.fixture
+def run_remove_expecting_failure(tmp_path):
+ """Write content, run cj-remove-block expecting non-zero exit; return CalledProcessError."""
+ def _run(content: str, start: int, end: int):
+ f = tmp_path / "test.org"
+ f.write_text(content)
+ with pytest.raises(subprocess.CalledProcessError) as excinfo:
+ subprocess.run(
+ ["python3", str(SCRIPT),
+ "--file", str(f),
+ "--start", str(start),
+ "--end", str(end)],
+ check=True,
+ capture_output=True,
+ )
+ return excinfo.value, f.read_text() # file should be unchanged on failure
+ return _run
+
+
+# ----------------------------------------------------------------------
+# Source-block removal
+# ----------------------------------------------------------------------
+
+class TestCjRemoveBlockSourceBlock:
+ """Removing #+begin_src cj: ... #+end_src blocks."""
+
+ def test_cj_remove_block_minimal_three_line_source_block(self, run_remove):
+ """Normal: the three lines of a minimal source-block are removed."""
+ content = "* S\n#+begin_src cj: comment\nbody\n#+end_src\nafter\n"
+ result = run_remove(content, start=2, end=4)
+ assert result == "* S\nafter\n"
+
+ def test_cj_remove_block_source_block_multiline_body(self, run_remove):
+ """Normal: source-block with multi-line body removed cleanly."""
+ content = "* S\n#+begin_src cj: comment\nline 1\nline 2\nline 3\n#+end_src\nafter\n"
+ result = run_remove(content, start=2, end=6)
+ assert result == "* S\nafter\n"
+
+ def test_cj_remove_block_preserves_lines_before_and_after(self, run_remove):
+ """Normal: surrounding lines outside the range stay intact."""
+ content = "before\n#+begin_src cj: comment\nx\n#+end_src\nafter\n"
+ result = run_remove(content, start=2, end=4)
+ assert result == "before\nafter\n"
+
+ def test_cj_remove_block_source_block_with_label_variant(self, run_remove):
+ """Boundary: source-block with no trailing label (#+begin_src cj:) also removable."""
+ content = "* S\n#+begin_src cj:\nbody\n#+end_src\nafter\n"
+ result = run_remove(content, start=2, end=4)
+ assert result == "* S\nafter\n"
+
+ def test_cj_remove_block_case_insensitive_fence(self, run_remove):
+ """Boundary: case-variant fences (#+BEGIN_SRC / #+END_SRC) also removable."""
+ content = "* S\n#+BEGIN_SRC cj: comment\nbody\n#+END_SRC\nafter\n"
+ result = run_remove(content, start=2, end=4)
+ assert result == "* S\nafter\n"
+
+
+# ----------------------------------------------------------------------
+# Legacy-inline removal
+# ----------------------------------------------------------------------
+
+class TestCjRemoveBlockLegacyInline:
+ """Removing single-line legacy `cj: ...` annotations."""
+
+ def test_cj_remove_block_legacy_inline_single_line(self, run_remove):
+ """Normal: single legacy-inline cj line removed."""
+ content = "* S\ncj: legacy note\nafter\n"
+ result = run_remove(content, start=2, end=2)
+ assert result == "* S\nafter\n"
+
+ def test_cj_remove_block_legacy_inline_at_eof(self, run_remove):
+ """Boundary: legacy-inline cj at last line; file ends cleanly."""
+ content = "* S\ncj: at end\n"
+ result = run_remove(content, start=2, end=2)
+ assert result == "* S\n"
+
+
+# ----------------------------------------------------------------------
+# Refusal-on-mismatch safety
+# ----------------------------------------------------------------------
+
+class TestCjRemoveBlockSafety:
+ """Refuses to remove if the specified range doesn't look like a cj annotation."""
+
+ def test_cj_remove_block_refuses_non_cj_single_line(self, run_remove_expecting_failure):
+ """Error: a single non-cj line is rejected."""
+ err, post_content = run_remove_expecting_failure(
+ "* S\nthis is not a cj line\nafter\n", start=2, end=2,
+ )
+ assert err.returncode != 0
+ # File must be unchanged
+ assert post_content == "* S\nthis is not a cj line\nafter\n"
+
+ def test_cj_remove_block_refuses_mismatched_fence(self, run_remove_expecting_failure):
+ """Error: multi-line range where line N isn't an opening fence is rejected."""
+ err, post_content = run_remove_expecting_failure(
+ "* S\nbody1\nbody2\n#+end_src\nafter\n", start=2, end=4,
+ )
+ assert err.returncode != 0
+ assert "body1" in post_content # file unchanged
+
+ def test_cj_remove_block_refuses_missing_closing_fence(self, run_remove_expecting_failure):
+ """Error: multi-line range where line M isn't a closing fence is rejected."""
+ err, post_content = run_remove_expecting_failure(
+ "* S\n#+begin_src cj: comment\nbody\nnot-a-close\nafter\n", start=2, end=4,
+ )
+ assert err.returncode != 0
+ assert "not-a-close" in post_content
+
+ def test_cj_remove_block_refuses_out_of_bounds(self, run_remove_expecting_failure):
+ """Error: range outside the file is rejected, file unchanged."""
+ err, post_content = run_remove_expecting_failure(
+ "* S\nafter\n", start=5, end=7,
+ )
+ assert err.returncode != 0
+ assert post_content == "* S\nafter\n"
+
+ def test_cj_remove_block_refuses_inverted_range(self, run_remove_expecting_failure):
+ """Error: end < start is rejected, file unchanged."""
+ original = "* S\n#+begin_src cj: comment\nbody\n#+end_src\n"
+ err, post_content = run_remove_expecting_failure(original, start=4, end=2)
+ assert err.returncode != 0
+ assert post_content == original
diff --git a/.ai/scripts/tests/test_cj_scan.py b/.ai/scripts/tests/test_cj_scan.py
new file mode 100644
index 0000000..7844474
--- /dev/null
+++ b/.ai/scripts/tests/test_cj_scan.py
@@ -0,0 +1,250 @@
+"""Tests for cj-scan.py — org-file cj-annotation scanner.
+
+The script parses an org file and emits JSON describing:
+- cj_blocks: every cj annotation found (source-block or legacy-inline form)
+- verify_tasks: every VERIFY heading + placement validity (top-level or first-level child only)
+- unclosed_blocks: any source-block fence that opened but never closed
+"""
+
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).parent.parent / "cj-scan.py"
+
+
+@pytest.fixture
+def run_scan(tmp_path):
+ """Write content to a temp org file and run cj-scan; return parsed JSON output."""
+ def _run(content: str) -> dict:
+ f = tmp_path / "test.org"
+ f.write_text(content)
+ result = subprocess.run(
+ ["python3", str(SCRIPT), str(f)],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ return json.loads(result.stdout)
+ return _run
+
+
+# ----------------------------------------------------------------------
+# cj-block detection
+# ----------------------------------------------------------------------
+
+class TestCjScanCjBlockDetection:
+ """Detection of cj annotations — source-block and legacy-inline forms."""
+
+ def test_cj_scan_source_block_single_detected(self, run_scan):
+ """Normal: a single source-block cj is detected with correct line range and body."""
+ content = "* Section\n#+begin_src cj: comment\nplease check this\n#+end_src\n"
+ result = run_scan(content)
+ assert len(result["cj_blocks"]) == 1
+ b = result["cj_blocks"][0]
+ assert b["form"] == "source-block"
+ assert b["body"] == "please check this"
+ assert b["start_line"] == 2
+ assert b["end_line"] == 4
+
+ def test_cj_scan_source_block_multiline_body_preserved(self, run_scan):
+ """Normal: multi-line body is preserved with embedded newlines."""
+ content = "* S\n#+begin_src cj: comment\nline 1\nline 2\nline 3\n#+end_src\n"
+ result = run_scan(content)
+ assert result["cj_blocks"][0]["body"] == "line 1\nline 2\nline 3"
+
+ def test_cj_scan_multiple_source_blocks_each_detected(self, run_scan):
+ """Normal: multiple source-blocks in a file are detected as separate items."""
+ content = (
+ "* A\n#+begin_src cj: comment\nfirst\n#+end_src\n"
+ "* B\n#+begin_src cj: comment\nsecond\n#+end_src\n"
+ )
+ result = run_scan(content)
+ assert len(result["cj_blocks"]) == 2
+ bodies = [b["body"] for b in result["cj_blocks"]]
+ assert bodies == ["first", "second"]
+
+ def test_cj_scan_legacy_inline_single_line_detected(self, run_scan):
+ """Normal: a legacy inline cj line is detected with form=legacy-inline."""
+ content = "* Section\ncj: please check this\n"
+ result = run_scan(content)
+ assert len(result["cj_blocks"]) == 1
+ b = result["cj_blocks"][0]
+ assert b["form"] == "legacy-inline"
+ assert b["body"] == "please check this"
+ assert b["start_line"] == 2
+ assert b["end_line"] == 2
+
+ def test_cj_scan_mixed_forms_in_same_file(self, run_scan):
+ """Normal: source-block + legacy inline coexist; both detected as separate items."""
+ content = (
+ "* A\ncj: legacy form\n"
+ "* B\n#+begin_src cj: comment\nnew form\n#+end_src\n"
+ )
+ result = run_scan(content)
+ assert len(result["cj_blocks"]) == 2
+ forms = sorted(b["form"] for b in result["cj_blocks"])
+ assert forms == ["legacy-inline", "source-block"]
+
+ def test_cj_scan_empty_file_returns_empty_lists(self, run_scan):
+ """Boundary: empty file → empty cj_blocks and verify_tasks lists."""
+ result = run_scan("")
+ assert result["cj_blocks"] == []
+ assert result["verify_tasks"] == []
+ assert result["unclosed_blocks"] == []
+
+ def test_cj_scan_no_cj_content_returns_empty_blocks(self, run_scan):
+ """Boundary: org file with no cj content → empty cj_blocks."""
+ content = "* Section\n** TODO Task\nbody text\n** TODO Another\n"
+ result = run_scan(content)
+ assert result["cj_blocks"] == []
+
+ def test_cj_scan_block_before_any_heading_empty_chain(self, run_scan):
+ """Boundary: cj block at top of file (before any heading) → empty parent chain."""
+ content = "#+begin_src cj: comment\ntop-level note\n#+end_src\n"
+ result = run_scan(content)
+ assert result["cj_blocks"][0]["parent_heading_chain"] == []
+ assert result["cj_blocks"][0]["parent_depth"] == 0
+
+ @pytest.mark.parametrize("fence", [
+ "#+begin_src cj: comment",
+ "#+begin_src cj:",
+ "#+begin_src cj: anything",
+ "#+BEGIN_SRC cj: comment", # case-insensitive
+ ])
+ def test_cj_scan_source_block_fence_variants_all_recognized(self, run_scan, fence):
+ """Boundary: fence label and case variants are all valid forms."""
+ content = f"* S\n{fence}\nbody\n#+end_src\n"
+ result = run_scan(content)
+ assert len(result["cj_blocks"]) == 1
+ assert result["cj_blocks"][0]["body"] == "body"
+
+ def test_cj_scan_unclosed_source_block_reported(self, run_scan):
+ """Error: a source-block that opens but never closes → reported in unclosed_blocks."""
+ content = "* S\n#+begin_src cj: comment\nbody that never ends\n"
+ result = run_scan(content)
+ assert result["cj_blocks"] == []
+ assert len(result["unclosed_blocks"]) == 1
+ assert result["unclosed_blocks"][0]["start_line"] == 2
+
+
+# ----------------------------------------------------------------------
+# Parent heading chain reconstruction
+# ----------------------------------------------------------------------
+
+class TestCjScanParentChain:
+ """Parent heading chain construction — walking the org tree backward."""
+
+ def test_cj_scan_nested_parent_chain_three_levels(self, run_scan):
+ """Normal: cj block inside three nested headings → chain reflects all three."""
+ content = (
+ "* Work\n"
+ "** DOING [#A] Kostya's contract\n"
+ "*** VERIFY Question?\n"
+ "#+begin_src cj: comment\nanswer\n#+end_src\n"
+ )
+ result = run_scan(content)
+ chain = result["cj_blocks"][0]["parent_heading_chain"]
+ assert len(chain) == 3
+ assert chain[0] == {"depth": 1, "heading": "Work"}
+ assert chain[1] == {"depth": 2, "heading": "DOING [#A] Kostya's contract"}
+ assert chain[2] == {"depth": 3, "heading": "VERIFY Question?"}
+ assert result["cj_blocks"][0]["parent_depth"] == 3
+
+ def test_cj_scan_depth_skip_only_actual_ancestors(self, run_scan):
+ """Normal: heading depth skip (e.g., * then ***) → chain captures only present headings."""
+ content = "* Section\n*** Deep child\n#+begin_src cj: comment\nbody\n#+end_src\n"
+ result = run_scan(content)
+ chain = result["cj_blocks"][0]["parent_heading_chain"]
+ assert [h["depth"] for h in chain] == [1, 3]
+
+ def test_cj_scan_shallower_sibling_pops_deeper_frames(self, run_scan):
+ """Normal: when a shallower heading appears, deeper frames pop off the stack."""
+ content = (
+ "* A\n** A.1\n*** A.1.1\n"
+ "** B\n"
+ "#+begin_src cj: comment\nunder B\n#+end_src\n"
+ )
+ result = run_scan(content)
+ chain = result["cj_blocks"][0]["parent_heading_chain"]
+ assert len(chain) == 2
+ assert chain[0]["heading"] == "A"
+ assert chain[1]["heading"] == "B"
+
+
+# ----------------------------------------------------------------------
+# VERIFY task detection + placement audit
+# ----------------------------------------------------------------------
+
+class TestCjScanVerifyPlacement:
+ """VERIFY task detection and placement audit per the canonical rule."""
+
+ def test_cj_scan_verify_at_depth_2_is_valid(self, run_scan):
+ """Normal: ** VERIFY (top-level) is valid placement."""
+ content = "* Work\n** VERIFY [#C] Hayk's Farearth Evaluation :research:hayk:\n"
+ result = run_scan(content)
+ assert len(result["verify_tasks"]) == 1
+ v = result["verify_tasks"][0]
+ assert v["depth"] == 2
+ assert v["valid_depth"] is True
+ assert v["promotion_target"] is None
+
+ def test_cj_scan_verify_at_depth_3_is_valid(self, run_scan):
+ """Normal: *** VERIFY (first-level child) is valid placement."""
+ content = "* Work\n** TODO Parent\n*** VERIFY Question?\n"
+ result = run_scan(content)
+ v = result["verify_tasks"][0]
+ assert v["depth"] == 3
+ assert v["valid_depth"] is True
+
+ def test_cj_scan_verify_at_depth_4_invalid_promote_to_3(self, run_scan):
+ """Normal: **** VERIFY is buried; suggests promotion to depth 3."""
+ content = "* W\n** P\n*** Q\n**** VERIFY Buried?\n"
+ result = run_scan(content)
+ v = result["verify_tasks"][0]
+ assert v["depth"] == 4
+ assert v["valid_depth"] is False
+ assert v["promotion_target"] == 3
+
+ def test_cj_scan_verify_at_depth_6_invalid_promote_to_3(self, run_scan):
+ """Normal: ****** VERIFY at any deep level → promotion target is still 3."""
+ content = "* W\n** P\n*** Q\n**** Q2\n***** Q3\n****** VERIFY Very buried?\n"
+ result = run_scan(content)
+ v = result["verify_tasks"][0]
+ assert v["depth"] == 6
+ assert v["promotion_target"] == 3
+
+ def test_cj_scan_verify_at_depth_1_invalid_promote_to_2(self, run_scan):
+ """Boundary: * VERIFY at top-section depth → promotion target is 2 (top-level under section)."""
+ content = "* VERIFY Should-be-deeper\n"
+ result = run_scan(content)
+ v = result["verify_tasks"][0]
+ assert v["depth"] == 1
+ assert v["valid_depth"] is False
+ assert v["promotion_target"] == 2
+
+ def test_cj_scan_verify_heading_with_priority_and_tags(self, run_scan):
+ """Boundary: VERIFY heading with priority cookie + tags → heading text captured fully."""
+ content = "* W\n** VERIFY [#C] Hayk's Farearth Evaluation :research:hayk:\n"
+ result = run_scan(content)
+ v = result["verify_tasks"][0]
+ assert "Hayk's Farearth Evaluation" in v["heading"]
+ assert ":research:" in v["heading"]
+
+ def test_cj_scan_no_verify_tasks_empty_list(self, run_scan):
+ """Boundary: file with only TODO/DOING headings → empty verify_tasks list."""
+ content = "* W\n** TODO X\n*** DOING Y\n"
+ result = run_scan(content)
+ assert result["verify_tasks"] == []
+
+ def test_cj_scan_verify_word_in_body_is_not_a_task(self, run_scan):
+ """Error: the word VERIFY appearing in body prose is not detected as a task."""
+ content = (
+ "* Work\n"
+ "** TODO Important task\n"
+ "Body line mentioning VERIFY in prose.\n"
+ )
+ result = run_scan(content)
+ assert result["verify_tasks"] == []
diff --git a/.ai/scripts/tests/test_cmail_action.py b/.ai/scripts/tests/test_cmail_action.py
new file mode 100644
index 0000000..3f77ca3
--- /dev/null
+++ b/.ai/scripts/tests/test_cmail_action.py
@@ -0,0 +1,669 @@
+"""Tests for cmail-action.py.
+
+Covers:
+- Pure helpers: parse_fetch_metadata, extract_body, _decode_header
+- I/O commands: cmd_list_unread, cmd_read, cmd_trash, _store wrappers,
+ cmd_folders
+- Argparse dispatch (subprocess --help)
+
+Strategy: import the script via importlib.util (filename has a hyphen,
+so a regular `import cmail_action` won't work). Patch
+cmail_action.connect to return a configured MagicMock IMAP4 instance
+for the I/O tests. connect() itself is testability-blocked (network +
+SSL + file I/O); manual smoke testing covers it.
+"""
+
+from __future__ import annotations
+
+import email
+import importlib.util
+import json
+import subprocess
+import sys
+from email.message import EmailMessage
+from email.mime.application import MIMEApplication
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.policy import default as default_policy
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+SCRIPT_PATH = Path(__file__).resolve().parent.parent / "cmail-action.py"
+
+
+def _load_module():
+ spec = importlib.util.spec_from_file_location("cmail_action", str(SCRIPT_PATH))
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+@pytest.fixture(scope="module")
+def cmail_action():
+ return _load_module()
+
+
+# ---------------------------------------------------------------------------
+# parse_fetch_metadata — pure
+# ---------------------------------------------------------------------------
+
+class TestParseFetchMetadata:
+
+ def test_normal_flags_and_size(self, cmail_action):
+ meta = "1 (FLAGS (\\Seen) RFC822.SIZE 12345)"
+ assert cmail_action.parse_fetch_metadata(meta) == {
+ "flags": "\\Seen",
+ "size": 12345,
+ }
+
+ def test_boundary_empty_flags_zero_size(self, cmail_action):
+ meta = "1 (FLAGS () RFC822.SIZE 0)"
+ assert cmail_action.parse_fetch_metadata(meta) == {
+ "flags": "",
+ "size": 0,
+ }
+
+ def test_boundary_multiple_flags(self, cmail_action):
+ meta = "1 (FLAGS (\\Seen \\Flagged \\Recent) RFC822.SIZE 999)"
+ result = cmail_action.parse_fetch_metadata(meta)
+ assert result["flags"] == "\\Seen \\Flagged \\Recent"
+ assert result["size"] == 999
+
+ def test_boundary_no_size_key(self, cmail_action):
+ meta = "1 (FLAGS (\\Recent))"
+ result = cmail_action.parse_fetch_metadata(meta)
+ assert result["flags"] == "\\Recent"
+ assert result["size"] is None
+
+ def test_boundary_no_flags_key(self, cmail_action):
+ meta = "1 (RFC822.SIZE 500)"
+ result = cmail_action.parse_fetch_metadata(meta)
+ assert result["flags"] == ""
+ assert result["size"] == 500
+
+ def test_boundary_metadata_split_across_chunks_concatenated(self, cmail_action):
+ # The bug fix that motivated extracting this helper: imaplib returns
+ # FLAGS / RFC822.SIZE in a non-tuple chunk after the BODY literal
+ # closes. cmd_list_unread now concatenates all chunks, then
+ # parse_fetch_metadata sees the combined string. Verify the parser
+ # handles the combined shape.
+ combined = ("3315 (BODY[HEADER.FIELDS (FROM TO)] {123}"
+ " FLAGS () RFC822.SIZE 65546)")
+ result = cmail_action.parse_fetch_metadata(combined)
+ assert result["flags"] == ""
+ assert result["size"] == 65546
+
+ def test_error_empty_input(self, cmail_action):
+ assert cmail_action.parse_fetch_metadata("") == {"flags": "", "size": None}
+
+ def test_error_malformed_size_value_does_not_raise(self, cmail_action):
+ meta = "1 (RFC822.SIZE notanumber)"
+ result = cmail_action.parse_fetch_metadata(meta)
+ assert result["size"] is None
+
+ def test_error_unclosed_flags_paren_returns_empty_flags(self, cmail_action):
+ # Defensive: parser doesn't find a closing paren after FLAGS (, so
+ # flags stays empty. Size still parses since RFC822.SIZE is found
+ # via the independent token-scan path.
+ meta = "1 (FLAGS (\\Seen RFC822.SIZE 100"
+ result = cmail_action.parse_fetch_metadata(meta)
+ assert result["flags"] == ""
+ assert result["size"] == 100
+
+
+# ---------------------------------------------------------------------------
+# extract_body — pure
+# ---------------------------------------------------------------------------
+
+class TestExtractBody:
+
+ @staticmethod
+ def _multipart_alt(plain="plain text body", html="<p>html body</p>"):
+ # Build with the legacy MIME* constructors, then round-trip
+ # through email.message_from_bytes with the default policy so the
+ # parts are EmailMessage instances with .get_content() — matching
+ # what cmd_read sees when imaplib hands it RFC822 bytes.
+ msg = MIMEMultipart("alternative")
+ if plain is not None:
+ msg.attach(MIMEText(plain, "plain"))
+ if html is not None:
+ msg.attach(MIMEText(html, "html"))
+ return email.message_from_bytes(msg.as_bytes(), policy=default_policy)
+
+ def test_normal_multipart_prefers_text_plain(self, cmail_action):
+ msg = self._multipart_alt(plain="plain wins", html="<p>html loses</p>")
+ assert cmail_action.extract_body(msg) == "plain wins"
+
+ def test_boundary_html_only_multipart_falls_back_to_html(self, cmail_action):
+ msg = self._multipart_alt(plain=None, html="<p>only html</p>")
+ result = cmail_action.extract_body(msg)
+ assert result is not None
+ assert "only html" in result
+
+ def test_boundary_singlepart_returns_content_directly(self, cmail_action):
+ msg = EmailMessage()
+ msg.set_content("single-part body")
+ # set_content adds Content-Type: text/plain by default; result has
+ # a trailing newline from the policy formatter.
+ assert cmail_action.extract_body(msg).strip() == "single-part body"
+
+ def test_error_multipart_with_no_text_parts_returns_none(self, cmail_action):
+ msg = MIMEMultipart("alternative")
+ msg.attach(MIMEApplication(b"binary blob"))
+ # Round-trip for parity with the parser-based path real callers use.
+ parsed = email.message_from_bytes(msg.as_bytes(), policy=default_policy)
+ assert cmail_action.extract_body(parsed) is None
+
+
+# ---------------------------------------------------------------------------
+# _decode_header — pure
+# ---------------------------------------------------------------------------
+
+class TestDecodeHeader:
+
+ def test_normal_string(self, cmail_action):
+ assert cmail_action._decode_header("hello") == "hello"
+
+ def test_boundary_empty_string(self, cmail_action):
+ assert cmail_action._decode_header("") == ""
+
+ def test_boundary_none_returns_empty(self, cmail_action):
+ assert cmail_action._decode_header(None) == ""
+
+ def test_boundary_non_string_coerced_via_str(self, cmail_action):
+ assert cmail_action._decode_header(42) == "42"
+
+
+# ---------------------------------------------------------------------------
+# Helpers for I/O command tests
+# ---------------------------------------------------------------------------
+
+def _build_fetch_response(uid, from_addr="alice@example.com", subject="Hello",
+ size=1500):
+ """Mimic imaplib's FETCH response shape: BODY literal as a tuple,
+ trailing FLAGS/SIZE/close-paren as a separate bytes chunk.
+ """
+ headers = (
+ f"From: {from_addr}\r\n"
+ f"To: c@cjennings.net\r\n"
+ f"Subject: {subject}\r\n"
+ f"Date: Thu, 07 May 2026 12:00:00 -0500\r\n"
+ ).encode()
+ return ("OK", [
+ (f"{uid} (BODY[HEADER.FIELDS (FROM TO SUBJECT DATE)] "
+ f"{{{len(headers)}}}".encode(), headers),
+ f" FLAGS () RFC822.SIZE {size})".encode(),
+ ])
+
+
+# ---------------------------------------------------------------------------
+# cmd_list_unread — mocked imaplib
+# ---------------------------------------------------------------------------
+
+class TestCmdListUnread:
+
+ def test_normal_three_unread(self, cmail_action, capsys):
+ fetch_responses = {
+ b"100": _build_fetch_response("100", "alice@example.com", "Hello", 1500),
+ b"101": _build_fetch_response("101", "bob@example.com", "Howdy", 2000),
+ b"102": _build_fetch_response("102", "carol@example.com", "Hi", 500),
+ }
+
+ def uid_side_effect(cmd, *args):
+ if cmd == "SEARCH":
+ return ("OK", [b"100 101 102"])
+ if cmd == "FETCH":
+ return fetch_responses[args[0]]
+ return ("OK", [b""])
+
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.side_effect = uid_side_effect
+
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_list_unread(SimpleNamespace(limit=50))
+
+ parsed = json.loads(capsys.readouterr().out)
+ assert len(parsed) == 3
+ assert parsed[0]["uid"] == "100"
+ assert parsed[0]["from"] == "alice@example.com"
+ assert parsed[0]["subject"] == "Hello"
+ assert parsed[0]["size"] == 1500
+ assert parsed[2]["uid"] == "102"
+
+ def test_boundary_zero_unread(self, cmail_action, capsys):
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.side_effect = lambda cmd, *a: ("OK", [b""])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_list_unread(SimpleNamespace(limit=50))
+ assert json.loads(capsys.readouterr().out) == []
+
+ def test_boundary_single_unread(self, cmail_action, capsys):
+ def uid_se(cmd, *args):
+ if cmd == "SEARCH":
+ return ("OK", [b"42"])
+ if cmd == "FETCH":
+ return _build_fetch_response("42", "x@y", "Solo", 100)
+ return ("OK", [b""])
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.side_effect = uid_se
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_list_unread(SimpleNamespace(limit=50))
+ parsed = json.loads(capsys.readouterr().out)
+ assert len(parsed) == 1
+ assert parsed[0]["uid"] == "42"
+
+ def test_boundary_limit_truncates_to_most_recent(self, cmail_action, capsys):
+ # 10 unread, limit=3 — keeps the last 3 (most recent).
+ all_uids = [str(i).encode() for i in range(100, 110)]
+
+ def uid_se(cmd, *args):
+ if cmd == "SEARCH":
+ return ("OK", [b" ".join(all_uids)])
+ if cmd == "FETCH":
+ return _build_fetch_response(args[0].decode(), "x@y", "S", 100)
+ return ("OK", [b""])
+
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.side_effect = uid_se
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_list_unread(SimpleNamespace(limit=3))
+ parsed = json.loads(capsys.readouterr().out)
+ assert [p["uid"] for p in parsed] == ["107", "108", "109"]
+
+ def test_error_search_returns_no(self, cmail_action):
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.return_value = ("NO", [b"server error"])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ with pytest.raises(SystemExit):
+ cmail_action.cmd_list_unread(SimpleNamespace(limit=50))
+
+
+# ---------------------------------------------------------------------------
+# cmd_read — mocked imaplib
+# ---------------------------------------------------------------------------
+
+class TestCmdRead:
+
+ @staticmethod
+ def _rfc822(body="hello world", subject="Test"):
+ msg = EmailMessage()
+ msg["From"] = "alice@example.com"
+ msg["To"] = "c@cjennings.net"
+ msg["Subject"] = subject
+ msg["Date"] = "Thu, 07 May 2026 12:00:00 -0500"
+ msg.set_content(body)
+ return bytes(msg)
+
+ def test_normal_prints_headers_and_body(self, cmail_action, capsys):
+ raw = self._rfc822(body="body content here", subject="subj")
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.return_value = ("OK", [(b"1 (RFC822 {N}", raw)])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_read(SimpleNamespace(uid=42))
+ out = capsys.readouterr().out
+ assert "From: alice@example.com" in out
+ assert "Subject: subj" in out
+ assert "body content here" in out
+
+ def test_error_uid_not_found(self, cmail_action):
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ # imaplib's shape when the UID has no match: ('OK', [None])
+ mock_imap.uid.return_value = ("OK", [None])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ with pytest.raises(SystemExit):
+ cmail_action.cmd_read(SimpleNamespace(uid=999999))
+
+
+# ---------------------------------------------------------------------------
+# _store wrappers — STORE command shape verification
+# ---------------------------------------------------------------------------
+
+class TestStoreCommands:
+
+ @staticmethod
+ def _capture_calls(cmail_action, cmd_func, uids, store_typ="OK"):
+ calls = []
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+
+ def uid_se(cmd, uid, op, flags):
+ calls.append((cmd, op, flags))
+ return (store_typ, [b""])
+
+ mock_imap.uid.side_effect = uid_se
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmd_func(SimpleNamespace(uids=uids))
+ return calls
+
+ def test_normal_mark_read_uses_plus_seen(self, cmail_action):
+ calls = self._capture_calls(cmail_action, cmail_action.cmd_mark_read, [42])
+ assert calls == [("STORE", "+FLAGS", r"(\Seen)")]
+
+ def test_normal_mark_unread_uses_minus_seen(self, cmail_action):
+ calls = self._capture_calls(cmail_action, cmail_action.cmd_mark_unread, [42])
+ assert calls == [("STORE", "-FLAGS", r"(\Seen)")]
+
+ def test_normal_star_uses_plus_flagged_and_seen(self, cmail_action):
+ calls = self._capture_calls(cmail_action, cmail_action.cmd_star, [42])
+ assert calls == [("STORE", "+FLAGS", r"(\Flagged \Seen)")]
+
+ def test_normal_unstar_uses_minus_flagged(self, cmail_action):
+ calls = self._capture_calls(cmail_action, cmail_action.cmd_unstar, [42])
+ assert calls == [("STORE", "-FLAGS", r"(\Flagged)")]
+
+ def test_boundary_multi_uid_calls_store_per_uid(self, cmail_action):
+ calls = self._capture_calls(
+ cmail_action, cmail_action.cmd_mark_read, [1, 2, 3]
+ )
+ assert len(calls) == 3
+ assert all(c == ("STORE", "+FLAGS", r"(\Seen)") for c in calls)
+
+ def test_error_store_failure_raises_systemexit(self, cmail_action):
+ with pytest.raises(SystemExit):
+ self._capture_calls(
+ cmail_action, cmail_action.cmd_mark_read, [42], store_typ="NO"
+ )
+
+
+# ---------------------------------------------------------------------------
+# cmd_trash — MOVE happy path + COPY+DELETE+EXPUNGE fallback
+# ---------------------------------------------------------------------------
+
+class TestCmdTrash:
+
+ def test_normal_move_succeeds_and_expunges(self, cmail_action):
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.return_value = ("OK", [b""])
+ mock_imap.expunge.return_value = ("OK", [b""])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_trash(SimpleNamespace(uids=[100, 101]))
+ move_calls = [c for c in mock_imap.uid.call_args_list
+ if c[0][0] == "MOVE"]
+ assert len(move_calls) == 2
+ assert mock_imap.expunge.called
+
+ def test_boundary_move_fails_falls_back_to_copy_then_delete(self, cmail_action):
+ # MOVE returns NO -> fallback path: COPY, then STORE +FLAGS \Deleted,
+ # then EXPUNGE. Verify the sequence executes as documented.
+ seen_cmds = []
+
+ def uid_se(cmd, *args):
+ seen_cmds.append(cmd)
+ if cmd == "MOVE":
+ return ("NO", [b"not supported"])
+ return ("OK", [b""])
+
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.side_effect = uid_se
+ mock_imap.expunge.return_value = ("OK", [b""])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_trash(SimpleNamespace(uids=[100]))
+ assert seen_cmds == ["MOVE", "COPY", "STORE"]
+ assert mock_imap.expunge.called
+
+ def test_error_copy_also_fails(self, cmail_action):
+ mock_imap = MagicMock()
+ mock_imap.select.return_value = ("OK", [b""])
+ mock_imap.uid.side_effect = lambda cmd, *a: ("NO", [b"both fail"])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ with pytest.raises(SystemExit):
+ cmail_action.cmd_trash(SimpleNamespace(uids=[100]))
+
+
+# ---------------------------------------------------------------------------
+# cmd_folders
+# ---------------------------------------------------------------------------
+
+class TestCmdFolders:
+
+ def test_normal_lists_folders(self, cmail_action, capsys):
+ mock_imap = MagicMock()
+ mock_imap.list.return_value = ("OK", [
+ b'(\\HasNoChildren) "/" "INBOX"',
+ b'(\\HasNoChildren \\Trash) "/" "Trash"',
+ ])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ cmail_action.cmd_folders(SimpleNamespace())
+ out = capsys.readouterr().out
+ assert "INBOX" in out
+ assert "Trash" in out
+
+ def test_error_list_returns_no(self, cmail_action):
+ mock_imap = MagicMock()
+ mock_imap.list.return_value = ("NO", [b"server error"])
+ with patch.object(cmail_action, "connect", return_value=mock_imap):
+ with pytest.raises(SystemExit):
+ cmail_action.cmd_folders(SimpleNamespace())
+
+
+# ---------------------------------------------------------------------------
+# build_message — pure
+# ---------------------------------------------------------------------------
+
+class TestBuildMessage:
+
+ def test_normal_no_attachments_is_singlepart(self, cmail_action):
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="recipient@example.com",
+ subject="Hello",
+ body="hello world",
+ )
+ assert msg["From"] == "c@cjennings.net"
+ assert msg["To"] == "recipient@example.com"
+ assert msg["Subject"] == "Hello"
+ assert not msg.is_multipart()
+ assert msg.get_content().strip() == "hello world"
+ assert msg.get_content_type() == "text/plain"
+
+ def test_normal_one_attachment_makes_multipart(self, cmail_action):
+ attachment = ("report.txt", "text", "plain", b"line1\nline2\n")
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="recipient@example.com",
+ subject="With file",
+ body="see attached",
+ attachments=[attachment],
+ )
+ assert msg.is_multipart()
+ # Find the attachment part by Content-Disposition.
+ attached_parts = [
+ p for p in msg.iter_attachments()
+ if p.get_filename() == "report.txt"
+ ]
+ assert len(attached_parts) == 1
+ att = attached_parts[0]
+ assert att.get_content_type() == "text/plain"
+ assert att.get_content().rstrip("\n") == "line1\nline2"
+
+ def test_boundary_two_attachments(self, cmail_action):
+ atts = [
+ ("a.txt", "text", "plain", b"alpha"),
+ ("b.bin", "application", "octet-stream", b"\x00\x01\x02"),
+ ]
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="recipient@example.com",
+ subject="Two files",
+ body="see attached",
+ attachments=atts,
+ )
+ names = sorted(p.get_filename() for p in msg.iter_attachments())
+ assert names == ["a.txt", "b.bin"]
+
+ def test_boundary_empty_body(self, cmail_action):
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="recipient@example.com",
+ subject="Empty",
+ body="",
+ )
+ # Body part exists, content is empty (modulo trailing newline).
+ assert msg.get_content().strip() == ""
+
+ def test_boundary_unicode_preserved_through_serialization(self, cmail_action):
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="recipient@example.com",
+ subject="日本語 ñ ü",
+ body="café — naïve résumé",
+ )
+ # Round-trip: serialize, parse, check both Subject and body survived.
+ raw = msg.as_bytes()
+ parsed = email.message_from_bytes(raw, policy=default_policy)
+ assert parsed["Subject"] == "日本語 ñ ü"
+ assert "café" in parsed.get_content()
+
+
+# ---------------------------------------------------------------------------
+# load_attachment — file I/O via tmp_path
+# ---------------------------------------------------------------------------
+
+class TestLoadAttachment:
+
+ def test_normal_text_file(self, cmail_action, tmp_path):
+ p = tmp_path / "notes.txt"
+ p.write_text("hello\n")
+ filename, maintype, subtype, content = cmail_action.load_attachment(p)
+ assert filename == "notes.txt"
+ assert maintype == "text"
+ assert subtype == "plain"
+ assert content == b"hello\n"
+
+ def test_normal_pdf_mime_detected(self, cmail_action, tmp_path):
+ p = tmp_path / "doc.pdf"
+ p.write_bytes(b"%PDF-1.4 fake")
+ filename, maintype, subtype, _ = cmail_action.load_attachment(p)
+ assert filename == "doc.pdf"
+ assert (maintype, subtype) == ("application", "pdf")
+
+ def test_boundary_no_extension_falls_back_to_octet_stream(self, cmail_action, tmp_path):
+ p = tmp_path / "README"
+ p.write_text("readme content")
+ filename, maintype, subtype, _ = cmail_action.load_attachment(p)
+ assert filename == "README"
+ assert (maintype, subtype) == ("application", "octet-stream")
+
+ def test_boundary_empty_file(self, cmail_action, tmp_path):
+ p = tmp_path / "empty.txt"
+ p.write_text("")
+ _, _, _, content = cmail_action.load_attachment(p)
+ assert content == b""
+
+ def test_error_missing_file_raises(self, cmail_action, tmp_path):
+ p = tmp_path / "does-not-exist.txt"
+ with pytest.raises(FileNotFoundError):
+ cmail_action.load_attachment(p)
+
+ def test_error_directory_raises(self, cmail_action, tmp_path):
+ with pytest.raises(IsADirectoryError):
+ cmail_action.load_attachment(tmp_path)
+
+
+# ---------------------------------------------------------------------------
+# cmd_send — mocked smtp_connect
+# ---------------------------------------------------------------------------
+
+class TestCmdSend:
+
+ @staticmethod
+ def _args(to="r@example.com", subject="s", body="b", body_file=None,
+ attach=None, stdin=False):
+ return SimpleNamespace(
+ to=to, subject=subject,
+ body=None if stdin else body,
+ body_file=body_file,
+ attach=attach or [],
+ )
+
+ def test_normal_inline_body_calls_send_message(self, cmail_action):
+ mock_smtp = MagicMock()
+ with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp):
+ cmail_action.cmd_send(self._args(
+ to="recipient@example.com",
+ subject="testing cmail action script",
+ body="lorem ipsum dolor sit amet",
+ ))
+ mock_smtp.send_message.assert_called_once()
+ sent = mock_smtp.send_message.call_args[0][0]
+ assert sent["To"] == "recipient@example.com"
+ assert sent["Subject"] == "testing cmail action script"
+ assert sent["From"] == cmail_action.USER
+ assert "lorem ipsum dolor sit amet" in sent.get_content()
+ mock_smtp.quit.assert_called_once()
+
+ def test_boundary_body_from_file(self, cmail_action, tmp_path):
+ body_file = tmp_path / "body.txt"
+ body_file.write_text("body from file")
+ mock_smtp = MagicMock()
+ with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp):
+ cmail_action.cmd_send(self._args(body=None, body_file=str(body_file)))
+ sent = mock_smtp.send_message.call_args[0][0]
+ assert "body from file" in sent.get_content()
+
+ def test_boundary_with_attachment(self, cmail_action, tmp_path):
+ att = tmp_path / "report.txt"
+ att.write_text("attachment content")
+ mock_smtp = MagicMock()
+ with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp):
+ cmail_action.cmd_send(self._args(attach=[str(att)]))
+ sent = mock_smtp.send_message.call_args[0][0]
+ assert sent.is_multipart()
+ atts = list(sent.iter_attachments())
+ assert len(atts) == 1
+ assert atts[0].get_filename() == "report.txt"
+ assert atts[0].get_content().rstrip("\n") == "attachment content"
+
+ def test_error_missing_attachment_exits_before_smtp(self, cmail_action, tmp_path):
+ # Attachment files are validated first; SMTP is never opened on failure.
+ mock_smtp = MagicMock()
+ with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp):
+ with pytest.raises((SystemExit, FileNotFoundError)):
+ cmail_action.cmd_send(self._args(
+ attach=[str(tmp_path / "does-not-exist.txt")]
+ ))
+ mock_smtp.send_message.assert_not_called()
+
+ def test_error_smtp_send_failure_raises(self, cmail_action):
+ import smtplib
+ mock_smtp = MagicMock()
+ mock_smtp.send_message.side_effect = smtplib.SMTPException("boom")
+ with patch.object(cmail_action, "smtp_connect", return_value=mock_smtp):
+ with pytest.raises((SystemExit, smtplib.SMTPException)):
+ cmail_action.cmd_send(self._args())
+
+
+# ---------------------------------------------------------------------------
+# Argparse — black-box subprocess sanity check
+# ---------------------------------------------------------------------------
+
+class TestArgparseShape:
+
+ def test_normal_help_lists_all_subcommands(self):
+ result = subprocess.run(
+ [sys.executable, str(SCRIPT_PATH), "--help"],
+ capture_output=True, text=True,
+ )
+ assert result.returncode == 0
+ for sub in ("list-unread", "read", "mark-read", "mark-unread",
+ "star", "unstar", "trash", "folders", "send"):
+ assert sub in result.stdout
+
+ def test_error_no_subcommand_exits_nonzero(self):
+ result = subprocess.run(
+ [sys.executable, str(SCRIPT_PATH)],
+ capture_output=True, text=True,
+ )
+ assert result.returncode != 0
diff --git a/.ai/scripts/tests/test_cross_agent_discover.py b/.ai/scripts/tests/test_cross_agent_discover.py
new file mode 100644
index 0000000..f0d2bb7
--- /dev/null
+++ b/.ai/scripts/tests/test_cross_agent_discover.py
@@ -0,0 +1,204 @@
+"""Tests for cross-agent-discover (TDD: tests written before implementation)."""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import textwrap
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-discover"
+
+
+def _run(args: list[str], env: dict | None = None) -> subprocess.CompletedProcess:
+ return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env)
+
+
+@pytest.fixture
+def fake_home(tmp_path, monkeypatch):
+ home = tmp_path / "home"
+ home.mkdir()
+ monkeypatch.setenv("HOME", str(home))
+ return home
+
+
+def _make_project(home: Path, name: str) -> Path:
+ proj = home / "projects" / name
+ (proj / ".ai").mkdir(parents=True)
+ return proj
+
+
+def _write_peers_toml(home: Path, content: str) -> Path:
+ cfg = home / ".config" / "cross-agent-comms"
+ cfg.mkdir(parents=True, exist_ok=True)
+ peers = cfg / "peers.toml"
+ peers.write_text(content)
+ return peers
+
+
+def test_discover_help(fake_home):
+ result = _run(["--help"], env={**os.environ, "HOME": str(fake_home)})
+ assert result.returncode == 0
+ assert "discover" in result.stdout.lower() or "enumerate" in result.stdout.lower()
+
+
+def test_discover_local_only_no_projects(fake_home):
+ """Empty home → reports zero local projects, zero peers."""
+ result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)})
+ assert result.returncode == 0
+ # No crash; mentions local somehow.
+ assert "local" in result.stdout.lower() or "0 project" in result.stdout.lower()
+
+
+def test_discover_lists_local_projects(fake_home):
+ _make_project(fake_home, "homelab")
+ _make_project(fake_home, "career")
+ _make_project(fake_home, "claude-templates")
+ result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)})
+ assert result.returncode == 0
+ assert "homelab" in result.stdout
+ assert "career" in result.stdout
+ assert "claude-templates" in result.stdout
+
+
+def test_discover_excludes_dirs_without_ai_subdir(fake_home):
+ """Directories under ~/projects/ that lack .ai/ are NOT projects."""
+ _make_project(fake_home, "real-project")
+ (fake_home / "projects" / "not-a-project").mkdir(parents=True)
+ result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)})
+ assert result.returncode == 0
+ assert "real-project" in result.stdout
+ assert "not-a-project" not in result.stdout
+
+
+def test_discover_no_peers_toml_just_local(fake_home):
+ _make_project(fake_home, "homelab")
+ result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)})
+ assert result.returncode == 0
+ # No peers section since no toml.
+ assert "homelab" in result.stdout
+
+
+def test_discover_lists_peers_from_toml(fake_home):
+ _write_peers_toml(fake_home, textwrap.dedent("""\
+ [peers.velox]
+ host = "velox"
+ ssh_user = "cjennings"
+
+ [peers.bastion]
+ host = "bastion.local"
+ ssh_user = "cjennings"
+ """))
+ _make_project(fake_home, "homelab")
+ result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)})
+ assert result.returncode == 0
+ assert "velox" in result.stdout
+ assert "bastion" in result.stdout
+
+
+def test_discover_malformed_peers_toml_errors_clearly(fake_home):
+ _write_peers_toml(fake_home, "not valid toml at all = = =")
+ result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)})
+ assert result.returncode != 0
+ assert "peers.toml" in result.stderr or "TOML" in result.stderr or "parse" in result.stderr.lower()
+
+
+def test_discover_json_output_schema(fake_home):
+ _make_project(fake_home, "homelab")
+ _make_project(fake_home, "career")
+ _write_peers_toml(fake_home, textwrap.dedent("""\
+ [peers.velox]
+ host = "velox"
+ """))
+ result = _run(["--json", "--no-cache"], env={**os.environ, "HOME": str(fake_home)})
+ assert result.returncode == 0
+ payload = json.loads(result.stdout)
+ assert "local" in payload
+ assert "peers" in payload
+ assert isinstance(payload["local"], list)
+ assert isinstance(payload["peers"], list)
+ assert "homelab" in payload["local"]
+ assert "career" in payload["local"]
+ velox = next((p for p in payload["peers"] if p["name"] == "velox"), None)
+ assert velox is not None
+ # Reachability is a key — value depends on actual SSH state.
+ assert "reachable" in velox
+
+
+def test_discover_peer_scope(fake_home):
+ _write_peers_toml(fake_home, textwrap.dedent("""\
+ [peers.velox]
+ host = "velox"
+
+ [peers.bastion]
+ host = "bastion.local"
+ """))
+ result = _run(["--peer", "velox", "--no-cache", "--json"], env={**os.environ, "HOME": str(fake_home)})
+ assert result.returncode == 0
+ payload = json.loads(result.stdout)
+ peer_names = [p["name"] for p in payload["peers"]]
+ assert "velox" in peer_names
+ assert "bastion" not in peer_names
+
+
+def test_discover_unreachable_peer_marked(fake_home):
+ """A peer with a definitely-unreachable host gets reachable=False."""
+ _write_peers_toml(fake_home, textwrap.dedent("""\
+ [peers.bogus]
+ host = "definitely-not-a-real-host.invalid"
+ ssh_user = "nobody"
+ """))
+ result = _run(["--no-cache", "--json"], env={**os.environ, "HOME": str(fake_home)}, )
+ assert result.returncode == 0
+ payload = json.loads(result.stdout)
+ bogus = next((p for p in payload["peers"] if p["name"] == "bogus"), None)
+ assert bogus is not None
+ assert bogus["reachable"] is False
+
+
+def test_discover_cache_hit_within_window(fake_home):
+ """Second invocation within 5 min reads cache (skip the SSH probe)."""
+ _make_project(fake_home, "homelab")
+ # First call populates cache.
+ result1 = _run(["--json"], env={**os.environ, "HOME": str(fake_home)})
+ assert result1.returncode == 0
+ cache = fake_home / ".cache" / "cross-agent-comms" / "discovery.json"
+ assert cache.exists()
+ # Tamper with the cache to a marker only the cache path can produce.
+ payload = json.loads(cache.read_text())
+ payload["_test_marker"] = True
+ cache.write_text(json.dumps(payload))
+ # Second call (no --no-cache) should return the tampered payload.
+ result2 = _run(["--json"], env={**os.environ, "HOME": str(fake_home)})
+ assert result2.returncode == 0
+ payload2 = json.loads(result2.stdout)
+ assert payload2.get("_test_marker") is True
+
+
+def test_discover_no_cache_flag_bypasses(fake_home):
+ """--no-cache ignores even a fresh cache."""
+ _make_project(fake_home, "homelab")
+ cache_dir = fake_home / ".cache" / "cross-agent-comms"
+ cache_dir.mkdir(parents=True)
+ cache_dir.joinpath("discovery.json").write_text(json.dumps({
+ "_test_marker": True, "local": [], "peers": []
+ }))
+ result = _run(["--no-cache", "--json"], env={**os.environ, "HOME": str(fake_home)})
+ assert result.returncode == 0
+ payload = json.loads(result.stdout)
+ # Cache marker should NOT appear in fresh result.
+ assert payload.get("_test_marker") is None or payload.get("_test_marker") is False
+ assert "homelab" in payload["local"]
+
+
+def test_discover_halt_shows_banner(fake_home):
+ halt = fake_home / ".config" / "cross-agent-comms" / "HALT"
+ halt.parent.mkdir(parents=True)
+ halt.write_text("halted")
+ _make_project(fake_home, "homelab")
+ result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)})
+ assert result.returncode == 0 # discover continues to print under HALT
+ assert "HALT" in result.stdout
diff --git a/.ai/scripts/tests/test_cross_agent_halt.py b/.ai/scripts/tests/test_cross_agent_halt.py
new file mode 100644
index 0000000..f8bf0b3
--- /dev/null
+++ b/.ai/scripts/tests/test_cross_agent_halt.py
@@ -0,0 +1,204 @@
+"""Tests for cross-agent-halt and cross-agent-resume (TDD)."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import textwrap
+from pathlib import Path
+
+import pytest
+
+HALT_SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-halt"
+RESUME_SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-resume"
+
+
+def _run(script: Path, args: list[str], env: dict | None = None) -> subprocess.CompletedProcess:
+ return subprocess.run([str(script), *args], capture_output=True, text=True, env=env)
+
+
+@pytest.fixture
+def isolated_env(tmp_path, monkeypatch):
+ """Isolated HOME + a fake systemctl that records calls without acting."""
+ fake_home = tmp_path / "home"
+ fake_home.mkdir()
+ fake_bin = tmp_path / "bin"
+ fake_bin.mkdir()
+ # Fake systemctl: no-op, exit 0.
+ fake_systemctl = fake_bin / "systemctl"
+ fake_systemctl.write_text("#!/usr/bin/env bash\nexit 0\n")
+ fake_systemctl.chmod(0o755)
+ # Fake ssh: succeed only for known-good host.
+ fake_ssh = fake_bin / "ssh"
+ fake_ssh.write_text(textwrap.dedent("""\
+ #!/usr/bin/env bash
+ # Find the destination arg (skip flags).
+ target=""
+ for arg in "$@"; do
+ case "$arg" in
+ -*|*=*) ;;
+ *@*|localhost|*.local|*.invalid) target="$arg"; break ;;
+ *) target="$arg"; break ;;
+ esac
+ done
+ case "$target" in
+ *invalid*|*unreachable*) exit 255 ;;
+ *) exit 0 ;;
+ esac
+ """))
+ fake_ssh.chmod(0o755)
+
+ monkeypatch.setenv("HOME", str(fake_home))
+ # Prepend our fake bin so systemctl + ssh are intercepted, but keep real /bin etc.
+ monkeypatch.setenv("PATH", f"{fake_bin}:{os.environ.get('PATH', '')}")
+ return fake_home
+
+
+# ---- cross-agent-halt ----
+
+
+def test_halt_help(isolated_env):
+ result = _run(HALT_SCRIPT, ["--help"], env={**os.environ, "HOME": str(isolated_env),
+ "PATH": os.environ["PATH"]})
+ assert result.returncode == 0
+ assert "halt" in result.stdout.lower()
+
+
+def test_halt_creates_halt_file(isolated_env):
+ halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT"
+ assert not halt_file.exists()
+ result = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env),
+ "PATH": os.environ["PATH"]})
+ assert result.returncode == 0
+ assert halt_file.exists()
+
+
+def test_halt_with_reason_writes_body(isolated_env):
+ result = _run(HALT_SCRIPT, ["pausing for incident review"],
+ env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
+ assert result.returncode == 0
+ halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT"
+ assert halt_file.exists()
+ assert "pausing for incident review" in halt_file.read_text()
+
+
+def test_halt_idempotent(isolated_env):
+ """Running halt twice doesn't error."""
+ halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT"
+ r1 = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
+ assert r1.returncode == 0
+ assert halt_file.exists()
+ r2 = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
+ assert r2.returncode == 0
+ assert halt_file.exists()
+
+
+def test_halt_does_not_pkill(isolated_env):
+ """Per design: halt does NOT call pkill. Verify by checking no pkill process gets launched."""
+ # Replace pkill in PATH with something that fails loudly so we'd see if halt invoked it.
+ fake_bin = isolated_env.parent / "bin"
+ pkill = fake_bin / "pkill"
+ pkill.write_text("#!/usr/bin/env bash\necho 'PKILL CALLED' >&2\nexit 99\n")
+ pkill.chmod(0o755)
+ result = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
+ assert result.returncode == 0
+ assert "PKILL CALLED" not in result.stderr
+
+
+def test_halt_tailnet_reports_per_peer(isolated_env):
+ """--tailnet iterates peers.toml and reports per-peer status."""
+ cfg = isolated_env / ".config" / "cross-agent-comms"
+ cfg.mkdir(parents=True)
+ (cfg / "peers.toml").write_text(textwrap.dedent("""\
+ [peers.velox]
+ host = "velox"
+ ssh_user = "cjennings"
+
+ [peers.bogus]
+ host = "definitely-unreachable.invalid"
+ ssh_user = "cjennings"
+ """))
+ result = _run(HALT_SCRIPT, ["--tailnet"],
+ env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
+ # Partial halt → exit 1.
+ assert result.returncode == 1
+ assert "velox" in result.stdout
+ assert "bogus" in result.stdout
+ # ✓ marker for velox, ✗ for bogus.
+ assert "✓" in result.stdout
+ assert "✗" in result.stdout
+ assert "PARTIAL" in result.stdout or "partial" in result.stdout.lower()
+
+
+def test_halt_tailnet_all_reachable_exits_zero(isolated_env):
+ cfg = isolated_env / ".config" / "cross-agent-comms"
+ cfg.mkdir(parents=True)
+ (cfg / "peers.toml").write_text(textwrap.dedent("""\
+ [peers.velox]
+ host = "velox"
+ ssh_user = "cjennings"
+ """))
+ result = _run(HALT_SCRIPT, ["--tailnet"],
+ env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
+ assert result.returncode == 0
+ assert "velox" in result.stdout
+
+
+# ---- cross-agent-resume ----
+
+
+def test_resume_help(isolated_env):
+ result = _run(RESUME_SCRIPT, ["--help"],
+ env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
+ assert result.returncode == 0
+ assert "resume" in result.stdout.lower()
+
+
+def test_resume_removes_halt_file(isolated_env):
+ halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT"
+ halt_file.parent.mkdir(parents=True)
+ halt_file.write_text("halted")
+ assert halt_file.exists()
+ result = _run(RESUME_SCRIPT, [],
+ env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
+ assert result.returncode == 0
+ assert not halt_file.exists()
+
+
+def test_resume_when_no_halt_active_succeeds(isolated_env):
+ """No HALT to clear is not an error."""
+ result = _run(RESUME_SCRIPT, [],
+ env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
+ assert result.returncode == 0
+
+
+def test_resume_prints_per_session_instructions(isolated_env):
+ """Resume must surface that polling does NOT auto-resume."""
+ halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT"
+ halt_file.parent.mkdir(parents=True)
+ halt_file.write_text("halted")
+ result = _run(RESUME_SCRIPT, [],
+ env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
+ assert result.returncode == 0
+ out = result.stdout.lower()
+ assert "polling" in out
+ assert "auto" in out or "explicit" in out or "session" in out
+
+
+def test_resume_tailnet_partial_failure_exit_1(isolated_env):
+ cfg = isolated_env / ".config" / "cross-agent-comms"
+ cfg.mkdir(parents=True)
+ (cfg / "peers.toml").write_text(textwrap.dedent("""\
+ [peers.velox]
+ host = "velox"
+
+ [peers.bogus]
+ host = "unreachable-host.invalid"
+ """))
+ halt_file = cfg / "HALT"
+ halt_file.write_text("halted")
+ result = _run(RESUME_SCRIPT, ["--tailnet"],
+ env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
+ assert result.returncode == 1
+ assert "velox" in result.stdout
+ assert "bogus" in result.stdout
diff --git a/.ai/scripts/tests/test_cross_agent_recv.py b/.ai/scripts/tests/test_cross_agent_recv.py
new file mode 100644
index 0000000..27c53a5
--- /dev/null
+++ b/.ai/scripts/tests/test_cross_agent_recv.py
@@ -0,0 +1,176 @@
+"""Tests for cross-agent-recv."""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-recv"
+
+
+def _make_message(path: Path, *, conv_id: str = "test-conv", seq: int = 1, msg_type: str = "request",
+ proto_version: str = "5", title: str = "Test", requires_tools: str | None = None,
+ body: str = "Body.\n") -> Path:
+ fm_lines = [
+ f"#+TITLE: {title}",
+ f"#+CONVERSATION_ID: {conv_id}",
+ f"#+MESSAGE_TYPE: {msg_type}",
+ f"#+SEQUENCE: {seq}",
+ "#+TIMESTAMP: 2026-04-27T05:00:00-05:00",
+ f"#+PROTOCOL_VERSION: {proto_version}",
+ ]
+ if requires_tools:
+ fm_lines.append(f"#+REQUIRES_TOOLS: {requires_tools}")
+ path.write_text("\n".join(fm_lines) + "\n\n" + body)
+ return path
+
+
+def _run(args: list[str], env: dict | None = None) -> subprocess.CompletedProcess:
+ return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env)
+
+
+@pytest.fixture
+def isolated_env(tmp_path, monkeypatch):
+ fake_home = tmp_path / "home"
+ fake_home.mkdir()
+ monkeypatch.setenv("HOME", str(fake_home))
+ return fake_home
+
+
+def test_recv_help(isolated_env):
+ result = _run(["--help"], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 0
+ assert "Receive and decide" in result.stdout
+
+
+def test_recv_missing_file_rejects(isolated_env, tmp_path):
+ result = _run([str(tmp_path / "nope.org")], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 3 # reject
+
+
+def test_recv_malformed_frontmatter_rejects(isolated_env, tmp_path):
+ bad = tmp_path / "bad.org"
+ bad.write_text("not org-mode at all\n")
+ result = _run([str(bad), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 3
+ assert "decision: reject" in result.stdout
+
+
+def test_recv_missing_required_field_rejects(isolated_env, tmp_path):
+ msg = tmp_path / "msg.org"
+ # Missing PROTOCOL_VERSION among others.
+ msg.write_text("#+TITLE: x\n#+CONVERSATION_ID: c\n\nBody.\n")
+ result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 3
+ assert "missing required" in result.stdout
+
+
+def test_recv_protocol_version_mismatch_query(isolated_env, tmp_path):
+ msg = _make_message(tmp_path / "msg.org", proto_version="4")
+ result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 2 # query
+ assert "PROTOCOL_VERSION mismatch" in result.stdout
+
+
+def test_recv_invalid_message_type_rejects(isolated_env, tmp_path):
+ msg = _make_message(tmp_path / "msg.org", msg_type="banana")
+ result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 3
+ assert "invalid MESSAGE_TYPE" in result.stdout
+
+
+def test_recv_missing_signature_rejects(isolated_env, tmp_path):
+ """When verify is on, a missing .asc sibling rejects."""
+ msg = _make_message(tmp_path / "msg.org")
+ # No .asc sidecar.
+ result = _run([str(msg)], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 3
+ assert "signature file missing" in result.stdout
+
+
+def test_recv_valid_processes(isolated_env, tmp_path):
+ """A valid message with --no-verify and no dedup match → process."""
+ msg = _make_message(tmp_path / "msg.org")
+ result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 0 # process
+ assert "decision: process" in result.stdout
+ assert "sha256:" in result.stdout
+
+
+def test_recv_dedup_against_identical_existing(isolated_env, tmp_path):
+ """Same content + same SEQUENCE in same dir → dedup."""
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ first = _make_message(inbox / "20260427T100000Z-from-x-c.org", conv_id="c", seq=5)
+ # Second message with same content — name differs (canonical-style would have different timestamp).
+ second = _make_message(inbox / "20260427T100100Z-from-x-c.org", conv_id="c", seq=5)
+ # Bodies must be byte-identical for hash equality.
+ second.write_bytes(first.read_bytes())
+ result = _run([str(second), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 1 # dedup
+ assert "decision: dedup" in result.stdout
+
+
+def test_recv_collision_with_different_content_processes(isolated_env, tmp_path):
+ """Same SEQUENCE + same CONVERSATION_ID but different content → process both."""
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ _make_message(inbox / "20260427T100000Z-from-x-c.org", conv_id="c", seq=5, body="First body.\n")
+ second = _make_message(inbox / "20260427T100100Z-from-x-c.org", conv_id="c", seq=5, body="Different body.\n")
+ result = _run([str(second), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 0 # process
+ assert "decision: process" in result.stdout
+
+
+def test_recv_requires_tools_missing_query(isolated_env, tmp_path):
+ """REQUIRES_TOOLS naming a definitely-missing binary → query."""
+ msg = _make_message(tmp_path / "msg.org", requires_tools="definitely-not-installed-xyzzy-9000")
+ result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 2 # query
+ assert "required tools unavailable" in result.stdout
+
+
+def test_recv_requires_tools_present_processes(isolated_env, tmp_path):
+ """REQUIRES_TOOLS naming a real binary → process."""
+ msg = _make_message(tmp_path / "msg.org", requires_tools="ls,cat")
+ result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 0
+ assert "decision: process" in result.stdout
+
+
+def test_recv_json_output(isolated_env, tmp_path):
+ msg = _make_message(tmp_path / "msg.org")
+ result = _run([str(msg), "--no-verify", "--json"], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 0
+ payload = json.loads(result.stdout)
+ assert payload["decision"] == "process"
+ assert payload["message_type"] == "request"
+ assert payload["conversation_id"] == "test-conv"
+
+
+def test_recv_halt_blocks(isolated_env, tmp_path):
+ halt = isolated_env / ".config" / "cross-agent-comms" / "HALT"
+ halt.parent.mkdir(parents=True)
+ halt.write_text("halted\n")
+ msg = _make_message(tmp_path / "msg.org")
+ result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 5
+ assert "halt active" in result.stderr.lower()
+
+
+def test_recv_halt_leaves_message_in_place(isolated_env, tmp_path):
+ """Per spec: under HALT, recv must NOT move/dedup/reject — leave file in place."""
+ halt = isolated_env / ".config" / "cross-agent-comms" / "HALT"
+ halt.parent.mkdir(parents=True)
+ halt.write_text("halted\n")
+ msg = _make_message(tmp_path / "msg.org")
+ pre_content = msg.read_text()
+ result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 5
+ # File still exists with same content.
+ assert msg.exists()
+ assert msg.read_text() == pre_content
diff --git a/.ai/scripts/tests/test_cross_agent_send.py b/.ai/scripts/tests/test_cross_agent_send.py
new file mode 100644
index 0000000..f716e95
--- /dev/null
+++ b/.ai/scripts/tests/test_cross_agent_send.py
@@ -0,0 +1,210 @@
+"""Tests for cross-agent-send.
+
+Subprocess-based: treat the script as a black-box CLI and assert on its
+exit codes, stdout, and the files it produces.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import textwrap
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-send"
+
+
+def _make_message(tmp_path: Path, conv_id: str = "test-conv", seq: int = 1, msg_type: str = "request",
+ proto_version: str = "5") -> Path:
+ msg = tmp_path / "msg.org"
+ msg.write_text(textwrap.dedent(f"""\
+ #+TITLE: Test message
+ #+CONVERSATION_ID: {conv_id}
+ #+MESSAGE_TYPE: {msg_type}
+ #+SEQUENCE: {seq}
+ #+TIMESTAMP: 2026-04-27T05:00:00-05:00
+ #+PROTOCOL_VERSION: {proto_version}
+
+ Body.
+ """))
+ return msg
+
+
+def _run(args: list[str], env: dict | None = None, cwd: Path | None = None) -> subprocess.CompletedProcess:
+ return subprocess.run(
+ [str(SCRIPT), *args],
+ capture_output=True,
+ text=True,
+ env=env,
+ cwd=cwd,
+ )
+
+
+@pytest.fixture
+def isolated_env(tmp_path, monkeypatch):
+ """Redirect HOME so peers.toml, HALT, marker files are scoped to the test."""
+ fake_home = tmp_path / "home"
+ fake_home.mkdir()
+ monkeypatch.setenv("HOME", str(fake_home))
+ # Pre-create projects/ so derive_sender_project has somewhere to look.
+ (fake_home / "projects" / "homelab").mkdir(parents=True)
+ return fake_home
+
+
+def test_send_help(isolated_env):
+ """--help works without side effects."""
+ result = _run(["--help"], env={**os.environ, "HOME": str(isolated_env)})
+ assert result.returncode == 0
+ assert "Send a cross-agent message" in result.stdout
+
+
+def test_send_missing_message_file(isolated_env):
+ """Nonexistent message file returns general error."""
+ import socket
+ machine = socket.gethostname().split(".")[0]
+ result = _run(
+ [f"{machine}.homelab", str(isolated_env / "nonexistent.org")],
+ env={**os.environ, "HOME": str(isolated_env)},
+ )
+ assert result.returncode == 1
+ assert "not found" in result.stderr.lower()
+
+
+def test_send_invalid_destination_format(isolated_env, tmp_path):
+ """Destination without . returns dest-not-found exit code."""
+ msg = _make_message(tmp_path)
+ result = _run(
+ ["bogus", str(msg)],
+ env={**os.environ, "HOME": str(isolated_env)},
+ )
+ assert result.returncode == 2
+ assert "<machine>.<project>" in result.stderr or "destination" in result.stderr.lower()
+
+
+def test_send_dest_not_in_peers(isolated_env, tmp_path):
+ """Cross-machine destination with no peers.toml entry exits 2."""
+ msg = _make_message(tmp_path)
+ result = _run(
+ ["unknownmachine.homelab", str(msg)],
+ env={**os.environ, "HOME": str(isolated_env)},
+ )
+ assert result.returncode == 2
+ assert "not found in peers" in result.stderr
+
+
+def test_send_frontmatter_missing_required(isolated_env, tmp_path):
+ """Message missing required fields exits 4."""
+ bad = tmp_path / "bad.org"
+ bad.write_text("#+TITLE: nope\n\nBody.\n")
+ import socket
+ machine = socket.gethostname().split(".")[0]
+ result = _run(
+ [f"{machine}.homelab", str(bad)],
+ env={**os.environ, "HOME": str(isolated_env)},
+ )
+ assert result.returncode == 4
+ assert "missing required fields" in result.stderr
+
+
+def test_send_invalid_message_type(isolated_env, tmp_path):
+ """Unknown MESSAGE_TYPE exits 4."""
+ msg = _make_message(tmp_path, msg_type="frobnicate")
+ import socket
+ machine = socket.gethostname().split(".")[0]
+ result = _run(
+ [f"{machine}.homelab", str(msg)],
+ env={**os.environ, "HOME": str(isolated_env)},
+ )
+ assert result.returncode == 4
+ assert "MESSAGE_TYPE" in result.stderr
+
+
+def test_send_halt_blocks(isolated_env, tmp_path):
+ """When HALT exists, send refuses with exit 5."""
+ halt = isolated_env / ".config" / "cross-agent-comms" / "HALT"
+ halt.parent.mkdir(parents=True)
+ halt.write_text("test halt\n")
+ msg = _make_message(tmp_path)
+ import socket
+ machine = socket.gethostname().split(".")[0]
+ result = _run(
+ [f"{machine}.homelab", str(msg)],
+ env={**os.environ, "HOME": str(isolated_env)},
+ )
+ assert result.returncode == 5
+ assert "halt active" in result.stderr.lower()
+
+
+def test_send_same_machine_no_sign_delivers(isolated_env, tmp_path):
+ """Same-machine delivery with --no-sign produces a canonically named file."""
+ msg = _make_message(tmp_path, conv_id="my-conv")
+ import socket
+ machine = socket.gethostname().split(".")[0]
+ # Sender is derived from CWD walking up to ~/projects/<name>/
+ cwd = isolated_env / "projects" / "homelab"
+ result = _run(
+ [f"{machine}.homelab", str(msg), "--no-sign"],
+ env={**os.environ, "HOME": str(isolated_env)},
+ cwd=cwd,
+ )
+ assert result.returncode == 0, f"stderr={result.stderr}"
+ inbox = isolated_env / "projects" / "homelab" / "inbox" / "from-agents"
+ files = list(inbox.glob("*-from-homelab-my-conv.org"))
+ assert len(files) == 1
+ # No sig file with --no-sign.
+ assert not list(inbox.glob("*.asc"))
+ # Canonical filename pattern.
+ assert files[0].name.startswith("2026") and files[0].name.endswith("-from-homelab-my-conv.org")
+
+
+def test_send_same_machine_signed_writes_asc(isolated_env, tmp_path):
+ """Signed delivery writes both .org and .asc."""
+ msg = _make_message(tmp_path, conv_id="signed-conv")
+ import socket
+ machine = socket.gethostname().split(".")[0]
+ cwd = isolated_env / "projects" / "homelab"
+ # Use the real GPG keyring (not isolating GPG — Craig's existing keys are fine for tests).
+ real_env = {**os.environ, "HOME": str(isolated_env), "GNUPGHOME": str(Path.home() / ".gnupg")}
+ result = _run(
+ [f"{machine}.homelab", str(msg)],
+ env=real_env,
+ cwd=cwd,
+ )
+ if result.returncode != 0:
+ pytest.skip(f"GPG signing unavailable in this environment: {result.stderr}")
+ inbox = isolated_env / "projects" / "homelab" / "inbox" / "from-agents"
+ org_files = list(inbox.glob("*-from-homelab-signed-conv.org"))
+ asc_files = list(inbox.glob("*-from-homelab-signed-conv.org.asc"))
+ assert len(org_files) == 1
+ assert len(asc_files) == 1
+
+
+def test_send_filename_ignores_input_basename(isolated_env, tmp_path):
+ """User's input filename is ignored; canonical filename is generated."""
+ weird = tmp_path / "weird-user-name.org"
+ weird.write_text(textwrap.dedent("""\
+ #+TITLE: Title
+ #+CONVERSATION_ID: ignored-input
+ #+MESSAGE_TYPE: request
+ #+SEQUENCE: 1
+ #+TIMESTAMP: 2026-04-27T05:00:00-05:00
+ #+PROTOCOL_VERSION: 5
+
+ Body.
+ """))
+ import socket
+ machine = socket.gethostname().split(".")[0]
+ cwd = isolated_env / "projects" / "homelab"
+ result = _run(
+ [f"{machine}.homelab", str(weird), "--no-sign"],
+ env={**os.environ, "HOME": str(isolated_env)},
+ cwd=cwd,
+ )
+ assert result.returncode == 0
+ inbox = isolated_env / "projects" / "homelab" / "inbox" / "from-agents"
+ # No file named after the user's input.
+ assert not (inbox / "weird-user-name.org").exists()
+ # Canonical naming used.
+ assert list(inbox.glob("*-from-homelab-ignored-input.org"))
diff --git a/.ai/scripts/tests/test_cross_agent_status.py b/.ai/scripts/tests/test_cross_agent_status.py
new file mode 100644
index 0000000..bb5b8ba
--- /dev/null
+++ b/.ai/scripts/tests/test_cross_agent_status.py
@@ -0,0 +1,165 @@
+"""Tests for cross-agent-status (TDD: tests written before implementation)."""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import textwrap
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-status"
+
+
+def _make_msg(path: Path, *, conv_id: str, seq: int, msg_type: str = "request",
+ proto_version: str = "5", timestamp: str = "2026-04-27T05:00:00-05:00") -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(textwrap.dedent(f"""\
+ #+TITLE: T
+ #+CONVERSATION_ID: {conv_id}
+ #+MESSAGE_TYPE: {msg_type}
+ #+SEQUENCE: {seq}
+ #+TIMESTAMP: {timestamp}
+ #+PROTOCOL_VERSION: {proto_version}
+
+ Body.
+ """))
+ return path
+
+
+def _run(args: list[str], env: dict | None = None) -> subprocess.CompletedProcess:
+ return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env)
+
+
+@pytest.fixture
+def fake_projects(tmp_path, monkeypatch):
+ """Create a fake ~/projects/<name>/inbox/from-agents/ tree under tmp_path."""
+ home = tmp_path / "home"
+ home.mkdir()
+ monkeypatch.setenv("HOME", str(home))
+ return home
+
+
+def test_status_help(fake_projects):
+ result = _run(["--help"], env={**os.environ, "HOME": str(fake_projects)})
+ assert result.returncode == 0
+ assert "snapshot" in result.stdout.lower() or "pending" in result.stdout.lower()
+
+
+def test_status_no_projects_clean_output(fake_projects):
+ result = _run([], env={**os.environ, "HOME": str(fake_projects)})
+ assert result.returncode == 0
+ # Empty machine prints either header-only table or "no projects" — accept either.
+ # No crash, no pending claims.
+ assert "pending" in result.stdout.lower() or result.stdout.strip() == ""
+
+
+def test_status_one_pending_shows_up(fake_projects):
+ inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents"
+ _make_msg(inbox / "20260427T100000Z-from-career-fixup.org", conv_id="fixup", seq=1)
+ result = _run([], env={**os.environ, "HOME": str(fake_projects)})
+ assert result.returncode == 0
+ assert "homelab" in result.stdout
+ assert "1" in result.stdout # pending count
+ assert "20260427T100000Z-from-career-fixup.org" in result.stdout
+
+
+def test_status_released_conversation_zero_pending(fake_projects):
+ """A conversation with a release message in it counts as 0 pending."""
+ inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents"
+ _make_msg(inbox / "20260427T100000Z-from-career-done.org", conv_id="done", seq=1)
+ _make_msg(inbox / "20260427T100100Z-from-homelab-done.org", conv_id="done", seq=2, msg_type="release")
+ result = _run([], env={**os.environ, "HOME": str(fake_projects)})
+ assert result.returncode == 0
+ # Check the homelab row shows 0 pending.
+ lines = [ln for ln in result.stdout.splitlines() if "homelab" in ln]
+ # At least one homelab line should show 0 pending or "—".
+ assert any("0" in ln or "—" in ln for ln in lines)
+
+
+def test_status_partial_release(fake_projects):
+ """Conversation with release + a later message → that later message counts as pending."""
+ inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents"
+ _make_msg(inbox / "20260427T100000Z-from-career-x.org", conv_id="x", seq=1,
+ timestamp="2026-04-27T05:00:00-05:00")
+ _make_msg(inbox / "20260427T100100Z-from-homelab-x.org", conv_id="x", seq=2, msg_type="release",
+ timestamp="2026-04-27T05:01:00-05:00")
+ # New message AFTER release: starts a fresh thread that's pending.
+ _make_msg(inbox / "20260427T200000Z-from-career-x.org", conv_id="x", seq=3,
+ timestamp="2026-04-27T15:00:00-05:00")
+ result = _run([], env={**os.environ, "HOME": str(fake_projects)})
+ assert result.returncode == 0
+ homelab_line = next(ln for ln in result.stdout.splitlines() if "homelab" in ln)
+ assert "1" in homelab_line # the post-release message is pending
+
+
+def test_status_multiple_projects(fake_projects):
+ inbox_a = fake_projects / "projects" / "homelab" / "inbox" / "from-agents"
+ inbox_b = fake_projects / "projects" / "career" / "inbox" / "from-agents"
+ _make_msg(inbox_a / "20260427T100000Z-from-x-a.org", conv_id="a", seq=1)
+ _make_msg(inbox_b / "20260427T100100Z-from-x-b.org", conv_id="b", seq=1)
+ _make_msg(inbox_b / "20260427T100200Z-from-x-c.org", conv_id="c", seq=1)
+ result = _run([], env={**os.environ, "HOME": str(fake_projects)})
+ assert result.returncode == 0
+ # career has 2 pending, homelab has 1.
+ career_line = next(ln for ln in result.stdout.splitlines() if "career" in ln)
+ homelab_line = next(ln for ln in result.stdout.splitlines() if "homelab" in ln)
+ assert "2" in career_line
+ assert "1" in homelab_line
+
+
+def test_status_json_output(fake_projects):
+ inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents"
+ _make_msg(inbox / "20260427T100000Z-from-career-test.org", conv_id="test", seq=1)
+ result = _run(["--json"], env={**os.environ, "HOME": str(fake_projects)})
+ assert result.returncode == 0
+ payload = json.loads(result.stdout)
+ assert "projects" in payload
+ assert isinstance(payload["projects"], list)
+ homelab = next((p for p in payload["projects"] if p["name"] == "homelab"), None)
+ assert homelab is not None
+ assert homelab["pending_count"] == 1
+
+
+def test_status_sort_pending_first(fake_projects):
+ """Projects with pending messages sort before projects with 0."""
+ (fake_projects / "projects" / "alpha" / "inbox" / "from-agents").mkdir(parents=True)
+ inbox_zeta = fake_projects / "projects" / "zeta" / "inbox" / "from-agents"
+ _make_msg(inbox_zeta / "20260427T100000Z-from-x-z.org", conv_id="z", seq=1)
+ result = _run([], env={**os.environ, "HOME": str(fake_projects)})
+ assert result.returncode == 0
+ lines = result.stdout.splitlines()
+ zeta_idx = next(i for i, ln in enumerate(lines) if "zeta" in ln)
+ alpha_idx = next(i for i, ln in enumerate(lines) if "alpha" in ln)
+ assert zeta_idx < alpha_idx, "pending project should sort before zero-pending project"
+
+
+def test_status_halt_shows_banner(fake_projects):
+ halt = fake_projects / ".config" / "cross-agent-comms" / "HALT"
+ halt.parent.mkdir(parents=True)
+ halt.write_text("halted for test")
+ inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents"
+ _make_msg(inbox / "20260427T100000Z-from-x-x.org", conv_id="x", seq=1)
+ result = _run([], env={**os.environ, "HOME": str(fake_projects)})
+ assert result.returncode == 0 # status continues to print under HALT
+ assert "HALT" in result.stdout
+ # Banner should mention the reason.
+ assert "halted for test" in result.stdout
+
+
+def test_status_projects_glob_override(fake_projects):
+ inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents"
+ _make_msg(inbox / "20260427T100000Z-from-x-a.org", conv_id="a", seq=1)
+ other_inbox = fake_projects / "projects" / "career" / "inbox" / "from-agents"
+ _make_msg(other_inbox / "20260427T100100Z-from-x-b.org", conv_id="b", seq=1)
+ # Glob limits to homelab only.
+ result = _run(
+ ["--projects-glob", str(fake_projects / "projects" / "homelab" / "inbox" / "from-agents") + "/"],
+ env={**os.environ, "HOME": str(fake_projects)},
+ )
+ assert result.returncode == 0
+ assert "homelab" in result.stdout
+ # career not in scope.
+ assert "career" not in result.stdout
diff --git a/.ai/scripts/tests/test_cross_agent_watch.py b/.ai/scripts/tests/test_cross_agent_watch.py
new file mode 100644
index 0000000..417cc19
--- /dev/null
+++ b/.ai/scripts/tests/test_cross_agent_watch.py
@@ -0,0 +1,155 @@
+"""Tests for cross-agent-watch.
+
+Black-box: spawn the script, drop files into a watched dir, read the log.
+Tests use --no-notify to avoid firing real desktop notifications.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import time
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-watch"
+
+
+def _spawn(watched_dir: Path, log_path: Path, env: dict) -> subprocess.Popen:
+ return subprocess.Popen(
+ [
+ str(SCRIPT),
+ "--projects-glob", str(watched_dir) + "/",
+ "--log", str(log_path),
+ "--no-notify",
+ "--quiet",
+ ],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ env=env,
+ )
+
+
+def _wait_for_log_lines(log_path: Path, expected: int, timeout: float = 5.0) -> list[str]:
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if log_path.exists():
+ lines = [ln for ln in log_path.read_text().splitlines() if ln]
+ if len(lines) >= expected:
+ return lines
+ time.sleep(0.1)
+ if log_path.exists():
+ return [ln for ln in log_path.read_text().splitlines() if ln]
+ return []
+
+
+@pytest.fixture
+def isolated_env(tmp_path, monkeypatch):
+ fake_home = tmp_path / "home"
+ fake_home.mkdir()
+ monkeypatch.setenv("HOME", str(fake_home))
+ return fake_home
+
+
+def test_watch_help(isolated_env):
+ result = subprocess.run(
+ [str(SCRIPT), "--help"],
+ capture_output=True, text=True,
+ env={**os.environ, "HOME": str(isolated_env)},
+ )
+ assert result.returncode == 0
+ assert "Usage:" in result.stdout
+
+
+def test_watch_empty_glob_exits_nonzero(isolated_env):
+ """Glob resolving to zero dirs should exit non-zero with a clear message."""
+ result = subprocess.run(
+ [str(SCRIPT), "--projects-glob", "/nonexistent/path/*/foo/", "--no-notify", "--quiet"],
+ capture_output=True, text=True,
+ env={**os.environ, "HOME": str(isolated_env)},
+ timeout=3,
+ )
+ assert result.returncode != 0
+ assert "0 directories" in result.stderr
+
+
+def test_watch_logs_org_file_create(isolated_env, tmp_path):
+ watched = tmp_path / "watched"
+ watched.mkdir()
+ log = tmp_path / "watch.log"
+ proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)})
+ try:
+ # Give inotifywait a moment to attach.
+ time.sleep(0.3)
+ (watched / "test-msg.org").write_text("hello")
+ lines = _wait_for_log_lines(log, expected=1, timeout=3.0)
+ assert len(lines) >= 1
+ assert "test-msg.org" in lines[-1]
+ finally:
+ proc.terminate()
+ proc.wait(timeout=2)
+
+
+def test_watch_filters_tmp_files(isolated_env, tmp_path):
+ """Files starting with .tmp. must NOT trigger log entries."""
+ watched = tmp_path / "watched"
+ watched.mkdir()
+ log = tmp_path / "watch.log"
+ proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)})
+ try:
+ time.sleep(0.3)
+ (watched / ".tmp.staging-file.org").write_text("hello")
+ # Wait briefly to confirm nothing logs.
+ time.sleep(0.5)
+ if log.exists():
+ content = log.read_text()
+ assert ".tmp.staging-file" not in content
+ # Then drop a real file to confirm watcher is alive.
+ (watched / "real.org").write_text("real")
+ lines = _wait_for_log_lines(log, expected=1, timeout=3.0)
+ assert any("real.org" in ln for ln in lines)
+ finally:
+ proc.terminate()
+ proc.wait(timeout=2)
+
+
+def test_watch_filters_asc_sidecars(isolated_env, tmp_path):
+ """Only .org events fire; .asc sidecars are silent."""
+ watched = tmp_path / "watched"
+ watched.mkdir()
+ log = tmp_path / "watch.log"
+ proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)})
+ try:
+ time.sleep(0.3)
+ (watched / "msg.org.asc").write_text("sig")
+ time.sleep(0.5)
+ if log.exists():
+ assert "msg.org.asc" not in log.read_text()
+ # .org event still works.
+ (watched / "msg.org").write_text("body")
+ lines = _wait_for_log_lines(log, expected=1, timeout=3.0)
+ assert any(ln.endswith("msg.org") for ln in lines)
+ finally:
+ proc.terminate()
+ proc.wait(timeout=2)
+
+
+def test_watch_halt_suppresses_but_logs(isolated_env, tmp_path):
+ """When HALT is set, watcher logs the event with (suppressed by HALT) marker."""
+ halt = isolated_env / ".config" / "cross-agent-comms" / "HALT"
+ halt.parent.mkdir(parents=True)
+ halt.write_text("halted")
+ watched = tmp_path / "watched"
+ watched.mkdir()
+ log = tmp_path / "watch.log"
+ proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)})
+ try:
+ time.sleep(0.3)
+ (watched / "halted-event.org").write_text("body")
+ lines = _wait_for_log_lines(log, expected=1, timeout=3.0)
+ assert len(lines) >= 1
+ assert "suppressed by HALT" in lines[-1]
+ finally:
+ proc.terminate()
+ proc.wait(timeout=2)
diff --git a/.ai/scripts/tests/test_extract_body.py b/.ai/scripts/tests/test_extract_body.py
new file mode 100644
index 0000000..7b53cda
--- /dev/null
+++ b/.ai/scripts/tests/test_extract_body.py
@@ -0,0 +1,96 @@
+"""Tests for extract_body()."""
+
+import sys
+import os
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+from conftest import make_plain_message, make_html_message, make_message_with_attachment
+from email.message import EmailMessage
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.mime.application import MIMEApplication
+
+import importlib.util
+spec = importlib.util.spec_from_file_location(
+ "eml_script",
+ os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py')
+)
+eml_script = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(eml_script)
+
+extract_body = eml_script.extract_body
+
+
+class TestPlainText:
+ def test_returns_plain_text(self):
+ msg = make_plain_message(body="Hello, this is plain text.")
+ result = extract_body(msg)
+ assert "Hello, this is plain text." in result
+
+
+class TestHtmlOnly:
+ def test_returns_converted_html(self):
+ msg = make_html_message(html_body="<p>Hello <strong>world</strong></p>")
+ result = extract_body(msg)
+ assert "Hello" in result
+ assert "world" in result
+ # Should not contain raw HTML tags
+ assert "<p>" not in result
+ assert "<strong>" not in result
+
+
+class TestBothPlainAndHtml:
+ def test_prefers_plain_text(self):
+ msg = MIMEMultipart('alternative')
+ msg['From'] = 'test@example.com'
+ msg['To'] = 'dest@example.com'
+ msg['Subject'] = 'Test'
+ msg['Date'] = 'Thu, 05 Feb 2026 11:36:00 -0600'
+ msg.attach(MIMEText("Plain text version", 'plain'))
+ msg.attach(MIMEText("<p>HTML version</p>", 'html'))
+ result = extract_body(msg)
+ assert "Plain text version" in result
+ assert "HTML version" not in result
+
+
+class TestEmptyBody:
+ def test_returns_empty_string(self):
+ # Multipart with only attachments, no text parts
+ msg = MIMEMultipart()
+ msg['From'] = 'test@example.com'
+ att = MIMEApplication(b"binary data", Name="file.bin")
+ att['Content-Disposition'] = 'attachment; filename="file.bin"'
+ msg.attach(att)
+ result = extract_body(msg)
+ assert result == ""
+
+
+class TestNonUtf8Encoding:
+ def test_decodes_with_errors_ignore(self):
+ msg = EmailMessage()
+ msg['From'] = 'test@example.com'
+ # Set raw bytes that include invalid UTF-8
+ msg.set_content("Valid text with special: café")
+ result = extract_body(msg)
+ assert "Valid text" in result
+
+
+class TestHtmlWithStructure:
+ def test_preserves_list_structure(self):
+ html = "<ul><li>Item one</li><li>Item two</li></ul>"
+ msg = make_html_message(html_body=html)
+ result = extract_body(msg)
+ assert "Item one" in result
+ assert "Item two" in result
+
+
+class TestNoTextParts:
+ def test_returns_empty_string(self):
+ msg = MIMEMultipart()
+ msg['From'] = 'test@example.com'
+ att = MIMEApplication(b"data", Name="image.png")
+ att['Content-Disposition'] = 'attachment; filename="image.png"'
+ msg.attach(att)
+ result = extract_body(msg)
+ assert result == ""
diff --git a/.ai/scripts/tests/test_extract_metadata.py b/.ai/scripts/tests/test_extract_metadata.py
new file mode 100644
index 0000000..d5ee52e
--- /dev/null
+++ b/.ai/scripts/tests/test_extract_metadata.py
@@ -0,0 +1,65 @@
+"""Tests for extract_metadata()."""
+
+import sys
+import os
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+from conftest import make_plain_message, add_received_headers
+from email.message import EmailMessage
+
+import importlib.util
+spec = importlib.util.spec_from_file_location(
+ "eml_script",
+ os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py')
+)
+eml_script = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(eml_script)
+
+extract_metadata = eml_script.extract_metadata
+
+
+class TestAllHeadersPresent:
+ def test_complete_dict(self):
+ msg = make_plain_message(
+ from_="Jonathan Smith <jsmith@example.com>",
+ to="Craig <craig@example.com>",
+ subject="Test Subject",
+ date="Thu, 05 Feb 2026 11:36:00 -0600"
+ )
+ result = extract_metadata(msg)
+ assert result['from'] == "Jonathan Smith <jsmith@example.com>"
+ assert result['to'] == "Craig <craig@example.com>"
+ assert result['subject'] == "Test Subject"
+ assert result['date'] == "Thu, 05 Feb 2026 11:36:00 -0600"
+ assert 'timing' in result
+
+
+class TestMissingFrom:
+ def test_from_is_none(self):
+ msg = EmailMessage()
+ msg['To'] = 'craig@example.com'
+ msg['Subject'] = 'Test'
+ msg['Date'] = 'Thu, 05 Feb 2026 11:36:00 -0600'
+ msg.set_content("body")
+ result = extract_metadata(msg)
+ assert result['from'] is None
+
+
+class TestMissingDate:
+ def test_date_is_none(self):
+ msg = EmailMessage()
+ msg['From'] = 'test@example.com'
+ msg['To'] = 'craig@example.com'
+ msg['Subject'] = 'Test'
+ msg.set_content("body")
+ result = extract_metadata(msg)
+ assert result['date'] is None
+
+
+class TestLongSubject:
+ def test_full_subject_returned(self):
+ long_subject = "Re: Fw: This is a very long subject line that spans many words and might be folded"
+ msg = make_plain_message(subject=long_subject)
+ result = extract_metadata(msg)
+ assert result['subject'] == long_subject
diff --git a/.ai/scripts/tests/test_generate_filenames.py b/.ai/scripts/tests/test_generate_filenames.py
new file mode 100644
index 0000000..07c8f84
--- /dev/null
+++ b/.ai/scripts/tests/test_generate_filenames.py
@@ -0,0 +1,157 @@
+"""Tests for generate_basename(), generate_email_filename(), generate_attachment_filename()."""
+
+import sys
+import os
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+import importlib.util
+spec = importlib.util.spec_from_file_location(
+ "eml_script",
+ os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py')
+)
+eml_script = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(eml_script)
+
+generate_basename = eml_script.generate_basename
+generate_email_filename = eml_script.generate_email_filename
+generate_attachment_filename = eml_script.generate_attachment_filename
+
+
+# --- generate_basename ---
+
+class TestGenerateBasename:
+ def test_standard_from_and_date(self):
+ metadata = {
+ 'from': 'Jonathan Smith <jsmith@example.com>',
+ 'date': 'Wed, 05 Feb 2026 11:36:00 -0600',
+ }
+ assert generate_basename(metadata) == "2026-02-05-1136-Jonathan"
+
+ def test_from_with_display_name_first_token(self):
+ metadata = {
+ 'from': 'C Ciarm <cciarm@example.com>',
+ 'date': 'Wed, 05 Feb 2026 11:36:00 -0600',
+ }
+ result = generate_basename(metadata)
+ assert result == "2026-02-05-1136-C"
+
+ def test_from_without_display_name(self):
+ metadata = {
+ 'from': 'jsmith@example.com',
+ 'date': 'Wed, 05 Feb 2026 11:36:00 -0600',
+ }
+ result = generate_basename(metadata)
+ assert result == "2026-02-05-1136-jsmith"
+
+ def test_missing_date(self):
+ metadata = {
+ 'from': 'Jonathan Smith <jsmith@example.com>',
+ 'date': None,
+ }
+ result = generate_basename(metadata)
+ assert result == "unknown-Jonathan"
+
+ def test_missing_from(self):
+ metadata = {
+ 'from': None,
+ 'date': 'Wed, 05 Feb 2026 11:36:00 -0600',
+ }
+ result = generate_basename(metadata)
+ assert result == "2026-02-05-1136-unknown"
+
+ def test_both_missing(self):
+ metadata = {'from': None, 'date': None}
+ result = generate_basename(metadata)
+ assert result == "unknown-unknown"
+
+ def test_unparseable_date(self):
+ metadata = {
+ 'from': 'Jonathan <j@example.com>',
+ 'date': 'not a real date',
+ }
+ result = generate_basename(metadata)
+ assert result == "unknown-Jonathan"
+
+ def test_none_date_no_crash(self):
+ metadata = {'from': 'Test <t@e.com>', 'date': None}
+ # Should not raise
+ result = generate_basename(metadata)
+ assert "unknown" in result
+
+
+# --- generate_email_filename ---
+
+class TestGenerateEmailFilename:
+ def test_standard_subject(self):
+ result = generate_email_filename(
+ "2026-02-05-1136-Jonathan",
+ "Re: Fw: 4319 Danneel Street"
+ )
+ assert result == "2026-02-05-1136-Jonathan-EMAIL-Re-Fw-4319-Danneel-Street"
+
+ def test_subject_with_special_chars(self):
+ result = generate_email_filename(
+ "2026-02-05-1136-Jonathan",
+ "Update: Meeting (draft) & notes!"
+ )
+ # Colons, parens, ampersands, exclamation stripped
+ assert "EMAIL" in result
+ assert ":" not in result
+ assert "(" not in result
+ assert ")" not in result
+ assert "&" not in result
+ assert "!" not in result
+
+ def test_none_subject(self):
+ result = generate_email_filename("2026-02-05-1136-Jonathan", None)
+ assert result == "2026-02-05-1136-Jonathan-EMAIL-no-subject"
+
+ def test_empty_subject(self):
+ result = generate_email_filename("2026-02-05-1136-Jonathan", "")
+ assert result == "2026-02-05-1136-Jonathan-EMAIL-no-subject"
+
+ def test_very_long_subject(self):
+ long_subject = "A" * 100 + " " + "B" * 100
+ result = generate_email_filename("2026-02-05-1136-Jonathan", long_subject)
+ # The cleaned subject part should be truncated
+ # basename (27) + "-EMAIL-" (7) + subject
+ # Subject itself is limited to 80 chars by _clean_for_filename
+ subject_part = result.split("-EMAIL-")[1]
+ assert len(subject_part) <= 80
+
+
+# --- generate_attachment_filename ---
+
+class TestGenerateAttachmentFilename:
+ def test_standard_attachment(self):
+ result = generate_attachment_filename(
+ "2026-02-05-1136-Jonathan",
+ "Ltr Carrollton.pdf"
+ )
+ assert result == "2026-02-05-1136-Jonathan-ATTACH-Ltr-Carrollton.pdf"
+
+ def test_filename_with_spaces_and_parens(self):
+ result = generate_attachment_filename(
+ "2026-02-05-1136-Jonathan",
+ "Document (final copy).pdf"
+ )
+ assert " " not in result
+ assert "(" not in result
+ assert ")" not in result
+ assert result.endswith(".pdf")
+
+ def test_preserves_extension(self):
+ result = generate_attachment_filename(
+ "2026-02-05-1136-Jonathan",
+ "photo.jpg"
+ )
+ assert result.endswith(".jpg")
+
+ def test_none_filename(self):
+ result = generate_attachment_filename("2026-02-05-1136-Jonathan", None)
+ assert result == "2026-02-05-1136-Jonathan-ATTACH-unnamed"
+
+ def test_empty_filename(self):
+ result = generate_attachment_filename("2026-02-05-1136-Jonathan", "")
+ assert result == "2026-02-05-1136-Jonathan-ATTACH-unnamed"
diff --git a/.ai/scripts/tests/test_gmail_fetch_attachments.py b/.ai/scripts/tests/test_gmail_fetch_attachments.py
new file mode 100644
index 0000000..b4fba41
--- /dev/null
+++ b/.ai/scripts/tests/test_gmail_fetch_attachments.py
@@ -0,0 +1,420 @@
+"""Tests for gmail-fetch-attachments.py.
+
+Covers:
+- Pure helpers: safe_filename, collect_attachments, load_client_creds
+- File I/O: load_mcp_env, load_refresh_token (tmp_path + monkeypatch on
+ module-level constants CLAUDE_CONFIG and TOKEN_DIR)
+- HTTP wrappers: refresh_access_token, gmail_get (monkeypatch on
+ urllib.request.urlopen)
+- Argparse: --help / missing-args via subprocess
+
+Strategy mirrors test_cmail_action.py: import the script via importlib
+(filename has hyphens), mock at external boundaries, no integration
+test for main() — the components are tested individually.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import subprocess
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+SCRIPT_PATH = Path(__file__).resolve().parent.parent / "gmail-fetch-attachments.py"
+
+
+def _load_module():
+ spec = importlib.util.spec_from_file_location(
+ "gmail_fetch_attachments", str(SCRIPT_PATH)
+ )
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+@pytest.fixture(scope="module")
+def gfa():
+ return _load_module()
+
+
+def _mock_urlopen_response(payload):
+ """Build a MagicMock mimicking urllib.request.urlopen()'s context-manager response."""
+ mock_resp = MagicMock()
+ mock_resp.read.return_value = json.dumps(payload).encode()
+ mock_resp.__enter__ = MagicMock(return_value=mock_resp)
+ mock_resp.__exit__ = MagicMock(return_value=False)
+ return mock_resp
+
+
+# ---------------------------------------------------------------------------
+# safe_filename — pure
+# ---------------------------------------------------------------------------
+
+class TestSafeFilename:
+
+ def test_normal_clean_filename(self, gfa):
+ assert gfa.safe_filename("report.pdf") == "report.pdf"
+
+ def test_boundary_forward_slash_replaced_with_underscore(self, gfa):
+ assert gfa.safe_filename("foo/bar.txt") == "foo_bar.txt"
+
+ def test_boundary_backslash_replaced_with_underscore(self, gfa):
+ assert gfa.safe_filename("foo\\bar.txt") == "foo_bar.txt"
+
+ def test_boundary_path_traversal_stripped(self, gfa):
+ # "../etc/passwd" -> after slash replace: ".._etc_passwd"
+ # While loop strips leading "..": "_etc_passwd"
+ assert gfa.safe_filename("../etc/passwd") == "_etc_passwd"
+
+ def test_boundary_dotfile_preserved(self, gfa):
+ # The fix Craig requested: single-dot prefixes survive so dotfiles
+ # like .gitignore aren't silently renamed.
+ assert gfa.safe_filename(".gitignore") == ".gitignore"
+ assert gfa.safe_filename(".env.local") == ".env.local"
+
+ def test_boundary_empty_string(self, gfa):
+ assert gfa.safe_filename("") == ""
+
+ @pytest.mark.parametrize("input_name,expected", [
+ ("..", ""), # single ".." stripped, leaves empty
+ ("...", "."), # one strip leaves a single dot
+ ("....", ""), # two strips leave empty
+ (".....", "."), # two strips leave one dot
+ ])
+ def test_boundary_only_dots(self, gfa, input_name, expected):
+ assert gfa.safe_filename(input_name) == expected
+
+ def test_boundary_double_dot_followed_by_name_stripped(self, gfa):
+ assert gfa.safe_filename("..foo") == "foo"
+
+ def test_boundary_middle_dotdot_preserved(self, gfa):
+ # Only LEADING ".." gets stripped. Mid-string ".." stays.
+ # "foo..bar" has no leading dots, so it's preserved as-is.
+ assert gfa.safe_filename("foo..bar") == "foo..bar"
+
+
+# ---------------------------------------------------------------------------
+# collect_attachments — pure
+# ---------------------------------------------------------------------------
+
+class TestCollectAttachments:
+
+ def test_normal_single_attachment(self, gfa):
+ payload = {
+ "parts": [
+ {"mimeType": "text/plain", "body": {"size": 100}},
+ {"filename": "doc.pdf", "mimeType": "application/pdf",
+ "body": {"attachmentId": "abc123", "size": 5000}},
+ ]
+ }
+ result = gfa.collect_attachments(payload)
+ assert result == [{
+ "filename": "doc.pdf",
+ "attachmentId": "abc123",
+ "size": 5000,
+ "mimeType": "application/pdf",
+ }]
+
+ def test_boundary_nested_multipart_recursion(self, gfa):
+ payload = {
+ "parts": [
+ {"mimeType": "multipart/mixed", "parts": [
+ {"mimeType": "multipart/alternative", "parts": [
+ {"filename": "deep.pdf", "mimeType": "application/pdf",
+ "body": {"attachmentId": "deep1", "size": 100}},
+ ]},
+ ]},
+ ]
+ }
+ result = gfa.collect_attachments(payload)
+ assert len(result) == 1
+ assert result[0]["filename"] == "deep.pdf"
+ assert result[0]["attachmentId"] == "deep1"
+
+ def test_boundary_no_attachments_returns_empty(self, gfa):
+ payload = {
+ "parts": [
+ {"mimeType": "text/plain", "body": {"size": 100}},
+ {"mimeType": "text/html", "body": {"size": 200}},
+ ]
+ }
+ assert gfa.collect_attachments(payload) == []
+
+ def test_boundary_inline_image_no_filename_skipped(self, gfa):
+ # Inline images embedded via cid: typically have an attachmentId
+ # but no filename. The "user-visible attachments" heuristic skips
+ # them so they don't litter the output dir as image001.png.
+ payload = {
+ "parts": [
+ {"mimeType": "image/png",
+ "body": {"attachmentId": "inline1", "size": 500}},
+ ]
+ }
+ assert gfa.collect_attachments(payload) == []
+
+ def test_boundary_empty_filename_skipped(self, gfa):
+ # Empty-string filename also skipped (truthy check).
+ payload = {
+ "parts": [
+ {"filename": "", "mimeType": "image/png",
+ "body": {"attachmentId": "empty1", "size": 500}},
+ ]
+ }
+ assert gfa.collect_attachments(payload) == []
+
+ def test_boundary_filename_without_attachment_id_skipped(self, gfa):
+ # A part with a filename but no attachmentId isn't a separately
+ # downloadable attachment — it's inline content with a name.
+ payload = {
+ "parts": [
+ {"filename": "fake.txt", "mimeType": "text/plain",
+ "body": {"size": 100}},
+ ]
+ }
+ assert gfa.collect_attachments(payload) == []
+
+ def test_boundary_multiple_attachments_at_different_depths(self, gfa):
+ payload = {
+ "parts": [
+ {"filename": "top.pdf", "mimeType": "application/pdf",
+ "body": {"attachmentId": "top1", "size": 100}},
+ {"mimeType": "multipart/mixed", "parts": [
+ {"filename": "nested.txt", "mimeType": "text/plain",
+ "body": {"attachmentId": "nested1", "size": 50}},
+ ]},
+ ]
+ }
+ result = gfa.collect_attachments(payload)
+ names = sorted(r["filename"] for r in result)
+ assert names == ["nested.txt", "top.pdf"]
+
+ def test_boundary_default_mimetype_when_missing(self, gfa):
+ payload = {
+ "parts": [
+ {"filename": "x.bin",
+ "body": {"attachmentId": "x1", "size": 10}},
+ ]
+ }
+ result = gfa.collect_attachments(payload)
+ assert result[0]["mimeType"] == "application/octet-stream"
+
+ def test_error_empty_payload(self, gfa):
+ assert gfa.collect_attachments({}) == []
+
+ def test_error_payload_with_null_parts(self, gfa):
+ # Defensive: parts = None falls through to empty list via `or []`.
+ payload = {"parts": None}
+ assert gfa.collect_attachments(payload) == []
+
+
+# ---------------------------------------------------------------------------
+# load_client_creds — pure
+# ---------------------------------------------------------------------------
+
+class TestLoadClientCreds:
+
+ def test_normal_both_credentials_present(self, gfa):
+ env = {"GOOGLE_CLIENT_ID": "cid123", "GOOGLE_CLIENT_SECRET": "secret456"}
+ assert gfa.load_client_creds(env) == ("cid123", "secret456")
+
+ def test_error_missing_client_id(self, gfa):
+ env = {"GOOGLE_CLIENT_SECRET": "secret456"}
+ with pytest.raises(SystemExit):
+ gfa.load_client_creds(env)
+
+ def test_error_missing_client_secret(self, gfa):
+ env = {"GOOGLE_CLIENT_ID": "cid123"}
+ with pytest.raises(SystemExit):
+ gfa.load_client_creds(env)
+
+ def test_error_empty_client_id(self, gfa):
+ env = {"GOOGLE_CLIENT_ID": "", "GOOGLE_CLIENT_SECRET": "secret456"}
+ with pytest.raises(SystemExit):
+ gfa.load_client_creds(env)
+
+ def test_error_empty_client_secret(self, gfa):
+ env = {"GOOGLE_CLIENT_ID": "cid123", "GOOGLE_CLIENT_SECRET": ""}
+ with pytest.raises(SystemExit):
+ gfa.load_client_creds(env)
+
+
+# ---------------------------------------------------------------------------
+# load_mcp_env — file I/O via tmp_path + monkeypatch CLAUDE_CONFIG
+# ---------------------------------------------------------------------------
+
+class TestLoadMcpEnv:
+
+ @staticmethod
+ def _write_config(tmp_path, monkeypatch, gfa, content):
+ config_path = tmp_path / ".claude.json"
+ config_path.write_text(json.dumps(content))
+ monkeypatch.setattr(gfa, "CLAUDE_CONFIG", config_path)
+ return config_path
+
+ def test_normal_personal_profile_with_env(self, monkeypatch, gfa, tmp_path):
+ self._write_config(tmp_path, monkeypatch, gfa, {
+ "mcpServers": {
+ "google-docs-personal": {
+ "env": {"GOOGLE_CLIENT_ID": "cid", "GOOGLE_CLIENT_SECRET": "sec"}
+ }
+ }
+ })
+ env = gfa.load_mcp_env("personal")
+ assert env == {"GOOGLE_CLIENT_ID": "cid", "GOOGLE_CLIENT_SECRET": "sec"}
+
+ def test_boundary_server_present_no_env_key(self, monkeypatch, gfa, tmp_path):
+ self._write_config(tmp_path, monkeypatch, gfa, {
+ "mcpServers": {"google-docs-work": {}}
+ })
+ assert gfa.load_mcp_env("work") == {}
+
+ def test_boundary_env_explicitly_null(self, monkeypatch, gfa, tmp_path):
+ # The `or {}` defends against null env. Returns empty dict, not None.
+ self._write_config(tmp_path, monkeypatch, gfa, {
+ "mcpServers": {"google-docs-personal": {"env": None}}
+ })
+ assert gfa.load_mcp_env("personal") == {}
+
+ def test_error_config_file_missing(self, monkeypatch, gfa, tmp_path):
+ monkeypatch.setattr(gfa, "CLAUDE_CONFIG", tmp_path / "nope.json")
+ with pytest.raises(SystemExit):
+ gfa.load_mcp_env("personal")
+
+ def test_error_server_not_in_config(self, monkeypatch, gfa, tmp_path):
+ self._write_config(tmp_path, monkeypatch, gfa, {
+ "mcpServers": {"google-docs-personal": {"env": {}}}
+ })
+ with pytest.raises(SystemExit):
+ gfa.load_mcp_env("work")
+
+
+# ---------------------------------------------------------------------------
+# load_refresh_token — file I/O via tmp_path + monkeypatch TOKEN_DIR
+# ---------------------------------------------------------------------------
+
+class TestLoadRefreshToken:
+
+ @staticmethod
+ def _setup_token(tmp_path, monkeypatch, gfa, profile=None, content=None):
+ token_dir = tmp_path / "google-docs-mcp"
+ token_dir.mkdir()
+ if profile:
+ (token_dir / profile).mkdir()
+ token_path = token_dir / profile / "token.json"
+ else:
+ token_path = token_dir / "token.json"
+ if content is not None:
+ token_path.write_text(json.dumps(content))
+ monkeypatch.setattr(gfa, "TOKEN_DIR", token_dir)
+ return token_path
+
+ def test_normal_no_profile_token_at_root(self, monkeypatch, gfa, tmp_path):
+ self._setup_token(tmp_path, monkeypatch, gfa,
+ content={"refresh_token": "rt-root"})
+ assert gfa.load_refresh_token({}) == "rt-root"
+
+ def test_boundary_with_profile_subdir(self, monkeypatch, gfa, tmp_path):
+ self._setup_token(tmp_path, monkeypatch, gfa, profile="personal",
+ content={"refresh_token": "rt-personal"})
+ assert gfa.load_refresh_token(
+ {"GOOGLE_MCP_PROFILE": "personal"}
+ ) == "rt-personal"
+
+ def test_boundary_explicit_empty_profile_falls_back_to_root(
+ self, monkeypatch, gfa, tmp_path):
+ # GOOGLE_MCP_PROFILE="" is treated the same as the key being missing —
+ # both fall back to TOKEN_DIR/token.json. Pinning both shapes so a
+ # future refactor that drops `or ""` doesn't silently break this.
+ self._setup_token(tmp_path, monkeypatch, gfa,
+ content={"refresh_token": "rt-root"})
+ assert gfa.load_refresh_token({"GOOGLE_MCP_PROFILE": ""}) == "rt-root"
+
+ def test_error_token_file_missing(self, monkeypatch, gfa, tmp_path):
+ token_dir = tmp_path / "google-docs-mcp"
+ token_dir.mkdir()
+ monkeypatch.setattr(gfa, "TOKEN_DIR", token_dir)
+ with pytest.raises(SystemExit):
+ gfa.load_refresh_token({})
+
+ def test_error_no_refresh_token_field_in_file(self, monkeypatch, gfa, tmp_path):
+ self._setup_token(tmp_path, monkeypatch, gfa,
+ content={"access_token": "at-only"})
+ with pytest.raises(SystemExit):
+ gfa.load_refresh_token({})
+
+
+# ---------------------------------------------------------------------------
+# refresh_access_token — mocked urllib
+# ---------------------------------------------------------------------------
+
+class TestRefreshAccessToken:
+
+ def test_normal_returns_access_token(self, monkeypatch, gfa):
+ mock_urlopen = MagicMock(
+ return_value=_mock_urlopen_response({"access_token": "at-new"})
+ )
+ monkeypatch.setattr(gfa.urllib.request, "urlopen", mock_urlopen)
+ result = gfa.refresh_access_token("rt-val", "cid-val", "sec-val")
+ assert result == "at-new"
+ # Verify the request shape: URL, body grant_type and refresh_token.
+ req = mock_urlopen.call_args[0][0]
+ assert req.full_url == gfa.OAUTH_TOKEN_URL
+ body = req.data.decode()
+ assert "grant_type=refresh_token" in body
+ assert "refresh_token=rt-val" in body
+ assert "client_id=cid-val" in body
+
+ def test_error_response_missing_access_token(self, monkeypatch, gfa):
+ mock_urlopen = MagicMock(
+ return_value=_mock_urlopen_response({"error": "invalid_grant"})
+ )
+ monkeypatch.setattr(gfa.urllib.request, "urlopen", mock_urlopen)
+ with pytest.raises(SystemExit):
+ gfa.refresh_access_token("rt", "cid", "sec")
+
+
+# ---------------------------------------------------------------------------
+# gmail_get — mocked urllib
+# ---------------------------------------------------------------------------
+
+class TestGmailGet:
+
+ def test_normal_returns_parsed_json_with_bearer_header(self, monkeypatch, gfa):
+ mock_urlopen = MagicMock(
+ return_value=_mock_urlopen_response({"id": "msg123", "snippet": "hi"})
+ )
+ monkeypatch.setattr(gfa.urllib.request, "urlopen", mock_urlopen)
+ result = gfa.gmail_get("/messages/msg123", "at-token")
+ assert result == {"id": "msg123", "snippet": "hi"}
+ req = mock_urlopen.call_args[0][0]
+ assert req.full_url == f"{gfa.GMAIL_API}/messages/msg123"
+ # urllib.request.Request lowercases header names except the first
+ # char via .capitalize() → "Authorization" stays as "Authorization".
+ assert req.headers["Authorization"] == "Bearer at-token"
+
+
+# ---------------------------------------------------------------------------
+# Argparse — black-box subprocess sanity check
+# ---------------------------------------------------------------------------
+
+class TestArgparseShape:
+
+ def test_normal_help_lists_all_required_args(self):
+ result = subprocess.run(
+ [sys.executable, str(SCRIPT_PATH), "--help"],
+ capture_output=True, text=True,
+ )
+ assert result.returncode == 0
+ for flag in ("--profile", "--message-id", "--output-dir"):
+ assert flag in result.stdout
+
+ def test_error_no_args_exits_nonzero(self):
+ result = subprocess.run(
+ [sys.executable, str(SCRIPT_PATH)],
+ capture_output=True, text=True,
+ )
+ assert result.returncode != 0
diff --git a/.ai/scripts/tests/test_inbox_send.py b/.ai/scripts/tests/test_inbox_send.py
new file mode 100644
index 0000000..597a7e9
--- /dev/null
+++ b/.ai/scripts/tests/test_inbox_send.py
@@ -0,0 +1,329 @@
+"""Tests for inbox-send.py — universal cross-project inbox messaging tool.
+
+The script:
+- discovers .ai projects with an inbox/ subdirectory under known roots,
+- writes a text message as a dated .org file in the target's inbox/, or
+- copies a file into the target's inbox/ with a dated, source-tagged name.
+
+All discovery is roots-driven (env var INBOX_SEND_ROOTS overrides the
+defaults) so tests can sandbox everything inside tmp_path.
+"""
+
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).parent.parent / "inbox-send.py"
+
+
+@pytest.fixture
+def project_root(tmp_path):
+ """Build a fake project under tmp_path/projects/<name>/ with .ai/ + top-level inbox/."""
+ def _make(name: str, has_inbox: bool = True) -> Path:
+ proj = tmp_path / "projects" / name
+ proj.mkdir(parents=True, exist_ok=True)
+ (proj / ".ai").mkdir(exist_ok=True)
+ if has_inbox:
+ (proj / "inbox").mkdir(exist_ok=True)
+ return proj
+ return _make
+
+
+@pytest.fixture
+def run_script(tmp_path):
+ """Invoke inbox-send with sandboxed roots via INBOX_SEND_ROOTS env var."""
+ def _run(args, cwd=None, roots=None, expect_failure=False):
+ env = {}
+ # Preserve PATH and a few essentials for python3 to launch.
+ import os as _os
+ env["PATH"] = _os.environ.get("PATH", "")
+ env["HOME"] = _os.environ.get("HOME", "/tmp")
+ if roots:
+ env["INBOX_SEND_ROOTS"] = ":".join(str(r) for r in roots)
+ cmd = ["python3", str(SCRIPT)] + args
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ cwd=cwd or tmp_path,
+ env=env,
+ check=not expect_failure,
+ )
+ return result
+ return _run
+
+
+# ----------------------------------------------------------------------
+# Discovery (--list)
+# ----------------------------------------------------------------------
+
+class TestInboxSendDiscovery:
+ """Discovering available .ai projects under the configured roots."""
+
+ def test_inbox_send_list_detects_projects_with_ai_inbox(self, project_root, run_script, tmp_path):
+ """Normal: --list shows projects that have .ai/inbox/."""
+ project_root("foo")
+ project_root("bar")
+ result = run_script(["--list"], roots=[tmp_path / "projects"])
+ assert "foo" in result.stdout
+ assert "bar" in result.stdout
+
+ def test_inbox_send_list_skips_projects_without_inbox(self, project_root, run_script, tmp_path):
+ """Boundary: project with .ai/ but no inbox/ is not surfaced."""
+ project_root("withinbox", has_inbox=True)
+ project_root("noinbox", has_inbox=False)
+ result = run_script(["--list"], roots=[tmp_path / "projects"])
+ assert "withinbox" in result.stdout
+ assert "noinbox" not in result.stdout
+
+ def test_inbox_send_list_skips_current_project(self, project_root, run_script, tmp_path):
+ """Normal: --list excludes the project the user is currently in."""
+ cwd_project = project_root("current")
+ project_root("other")
+ result = run_script(["--list"], cwd=cwd_project, roots=[tmp_path / "projects"])
+ assert "other" in result.stdout
+ assert "current" not in result.stdout
+
+ def test_inbox_send_list_empty_when_no_projects(self, run_script, tmp_path):
+ """Boundary: no projects under roots → friendly informational message."""
+ (tmp_path / "projects").mkdir()
+ result = run_script(["--list"], roots=[tmp_path / "projects"])
+ assert result.returncode == 0
+ assert "No projects" in result.stdout
+
+ def test_inbox_send_list_handles_missing_root(self, run_script, tmp_path):
+ """Boundary: configured root doesn't exist → skip silently."""
+ result = run_script(["--list"], roots=[tmp_path / "does-not-exist"])
+ assert result.returncode == 0
+
+
+# ----------------------------------------------------------------------
+# Slug derivation from text and from filenames
+# ----------------------------------------------------------------------
+
+def _slug_from(inbox_files, source_name):
+ """Helper: extract the slug from a deposited file's basename."""
+ assert len(inbox_files) == 1
+ name = inbox_files[0].stem
+ marker = f"from-{source_name}-"
+ return name.split(marker, 1)[1]
+
+
+class TestInboxSendNaming:
+ """Slug derivation from --text (and override via --name)."""
+
+ def test_inbox_send_text_slug_hyphenated_lowercase(self, project_root, run_script, tmp_path):
+ """Normal: 'ATM cash reminder' → slug 'atm-cash-reminder'."""
+ project_root("target")
+ cwd = project_root("source")
+ run_script(
+ ["target", "--text", "ATM cash reminder"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert _slug_from(files, "source") == "atm-cash-reminder"
+
+ def test_inbox_send_text_slug_truncated_at_word_boundary(self, project_root, run_script, tmp_path):
+ """Normal: long text truncated under 40 chars at the nearest word boundary."""
+ project_root("target")
+ cwd = project_root("source")
+ long_text = (
+ "Please review the SOFWeek prep doc and confirm the AirBnB kitchen details"
+ )
+ run_script(
+ ["target", "--text", long_text],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ slug = _slug_from(files, "source")
+ assert slug.startswith("please-review-the-sofweek")
+ assert len(slug) <= 40
+ # Truncation should land on a word boundary (last char is a letter/digit, not mid-word).
+ assert "-" not in slug[-1]
+
+ def test_inbox_send_text_slug_strips_punctuation(self, project_root, run_script, tmp_path):
+ """Normal: punctuation stripped, lowercased."""
+ project_root("target")
+ cwd = project_root("source")
+ run_script(
+ ["target", "--text", "Hey! What's the plan? See you @ 5PM."],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ slug = _slug_from(files, "source")
+ for ch in "!?'@.":
+ assert ch not in slug
+ assert slug == slug.lower()
+
+ def test_inbox_send_name_override_overrides_slug(self, project_root, run_script, tmp_path):
+ """Normal: --name wins over derived slug."""
+ project_root("target")
+ cwd = project_root("source")
+ run_script(
+ ["target", "--text", "ok", "--name", "pre-call-ack"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert _slug_from(files, "source") == "pre-call-ack"
+
+
+# ----------------------------------------------------------------------
+# --text mode end-to-end
+# ----------------------------------------------------------------------
+
+class TestInboxSendText:
+ """--text mode writes a .org file with the message body."""
+
+ def test_inbox_send_text_writes_org_file_with_message(self, project_root, run_script, tmp_path):
+ """Normal: produces a .org file whose body contains the message."""
+ project_root("target")
+ cwd = project_root("source")
+ run_script(
+ ["target", "--text", "Remember the ATM run"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert len(files) == 1
+ assert files[0].suffix == ".org"
+ body = files[0].read_text()
+ assert "Remember the ATM run" in body
+
+ def test_inbox_send_text_filename_includes_source_project_name(self, project_root, run_script, tmp_path):
+ """Normal: filename includes 'from-<source>-' so the target knows where it came from."""
+ project_root("target")
+ cwd = project_root("emacs")
+ run_script(
+ ["target", "--text", "hello"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert "from-emacs-" in files[0].name
+
+
+# ----------------------------------------------------------------------
+# --file mode end-to-end
+# ----------------------------------------------------------------------
+
+class TestInboxSendFile:
+ """--file mode copies the source file into the target inbox."""
+
+ def test_inbox_send_file_copies_text_file(self, project_root, run_script, tmp_path):
+ """Normal: copies a text file to the target inbox, preserving content."""
+ project_root("target")
+ cwd = project_root("source")
+ src = tmp_path / "doc.org"
+ src.write_text("file content")
+ run_script(
+ ["target", "--file", str(src)],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert len(files) == 1
+ assert files[0].read_text() == "file content"
+
+ def test_inbox_send_file_preserves_extension(self, project_root, run_script, tmp_path):
+ """Normal: extension carried from source file."""
+ project_root("target")
+ cwd = project_root("source")
+ src = tmp_path / "image.png"
+ src.write_bytes(b"\x89PNG\r\n...")
+ run_script(
+ ["target", "--file", str(src)],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert files[0].suffix == ".png"
+
+ def test_inbox_send_file_slug_from_source_basename(self, project_root, run_script, tmp_path):
+ """Normal: filename slug derived from the source file's basename when --name omitted."""
+ project_root("target")
+ cwd = project_root("source")
+ src = tmp_path / "branching-strategy-notes.md"
+ src.write_text("notes")
+ run_script(
+ ["target", "--file", str(src)],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert "branching-strategy-notes" in files[0].name
+
+ def test_inbox_send_file_name_override(self, project_root, run_script, tmp_path):
+ """Normal: --name overrides the basename-derived slug; extension preserved."""
+ project_root("target")
+ cwd = project_root("source")
+ src = tmp_path / "random.pdf"
+ src.write_bytes(b"%PDF-1.4...")
+ run_script(
+ ["target", "--file", str(src), "--name", "branching-strategy"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert "branching-strategy" in files[0].name
+ assert files[0].suffix == ".pdf"
+
+
+# ----------------------------------------------------------------------
+# Errors and refusal cases
+# ----------------------------------------------------------------------
+
+class TestInboxSendErrors:
+ """Refusal cases — surface clearly, exit non-zero, leave filesystem untouched."""
+
+ def test_inbox_send_refuses_unknown_target(self, project_root, run_script, tmp_path):
+ """Error: target project not found in discovery → refuse."""
+ cwd = project_root("source")
+ result = run_script(
+ ["nonexistent", "--text", "hi"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ expect_failure=True,
+ )
+ assert result.returncode != 0
+
+ def test_inbox_send_refuses_no_text_and_no_file(self, project_root, run_script, tmp_path):
+ """Error: must provide one of --text / --file."""
+ project_root("target")
+ cwd = project_root("source")
+ result = run_script(
+ ["target"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ expect_failure=True,
+ )
+ assert result.returncode != 0
+
+ def test_inbox_send_refuses_both_text_and_file(self, project_root, run_script, tmp_path):
+ """Error: --text and --file are mutually exclusive."""
+ project_root("target")
+ cwd = project_root("source")
+ src = tmp_path / "doc.org"
+ src.write_text("x")
+ result = run_script(
+ ["target", "--text", "hi", "--file", str(src)],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ expect_failure=True,
+ )
+ assert result.returncode != 0
+
+ def test_inbox_send_refuses_missing_source_file(self, project_root, run_script, tmp_path):
+ """Error: --file path doesn't exist → refuse."""
+ project_root("target")
+ cwd = project_root("source")
+ result = run_script(
+ ["target", "--file", str(tmp_path / "definitely-missing.org")],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ expect_failure=True,
+ )
+ assert result.returncode != 0
+
+ def test_inbox_send_refuses_empty_text(self, project_root, run_script, tmp_path):
+ """Error: empty --text refused; nothing written to target inbox."""
+ project_root("target")
+ cwd = project_root("source")
+ result = run_script(
+ ["target", "--text", " "],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ expect_failure=True,
+ )
+ assert result.returncode != 0
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert files == []
diff --git a/.ai/scripts/tests/test_integration_stdout.py b/.ai/scripts/tests/test_integration_stdout.py
new file mode 100644
index 0000000..d87478e
--- /dev/null
+++ b/.ai/scripts/tests/test_integration_stdout.py
@@ -0,0 +1,68 @@
+"""Integration tests for backwards-compatible stdout mode (no --output-dir)."""
+
+import os
+import shutil
+import sys
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+import importlib.util
+spec = importlib.util.spec_from_file_location(
+ "eml_script",
+ os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py')
+)
+eml_script = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(eml_script)
+
+print_email = eml_script.print_email
+
+FIXTURES = os.path.join(os.path.dirname(__file__), 'fixtures')
+
+
+class TestPlainTextStdout:
+ def test_metadata_and_body_printed(self, tmp_path, capsys):
+ eml_src = os.path.join(FIXTURES, 'plain-text.eml')
+ working_eml = tmp_path / "message.eml"
+ shutil.copy2(eml_src, working_eml)
+
+ print_email(str(working_eml))
+ captured = capsys.readouterr()
+
+ assert "From: Jonathan Smith <jsmith@example.com>" in captured.out
+ assert "To: Craig Jennings <craig@example.com>" in captured.out
+ assert "Subject: Re: Fw: 4319 Danneel Street" in captured.out
+ assert "Date:" in captured.out
+ assert "Sent:" in captured.out
+ assert "Received:" in captured.out
+ assert "4319 Danneel Street" in captured.out
+
+
+class TestHtmlFallbackStdout:
+ def test_html_converted_on_stdout(self, tmp_path, capsys):
+ eml_src = os.path.join(FIXTURES, 'html-only.eml')
+ working_eml = tmp_path / "message.eml"
+ shutil.copy2(eml_src, working_eml)
+
+ print_email(str(working_eml))
+ captured = capsys.readouterr()
+
+ # Should see converted text, not raw HTML
+ assert "HTML" in captured.out
+ assert "<p>" not in captured.out
+
+
+class TestAttachmentsStdout:
+ def test_attachment_extracted_alongside_eml(self, tmp_path, capsys):
+ eml_src = os.path.join(FIXTURES, 'with-attachment.eml')
+ working_eml = tmp_path / "message.eml"
+ shutil.copy2(eml_src, working_eml)
+
+ print_email(str(working_eml))
+ captured = capsys.readouterr()
+
+ assert "Extracted attachment:" in captured.out
+ assert "Ltr Carrollton.pdf" in captured.out
+
+ # File should exist alongside the EML
+ extracted = tmp_path / "Ltr Carrollton.pdf"
+ assert extracted.exists()
diff --git a/.ai/scripts/tests/test_maildir_flag_manager.py b/.ai/scripts/tests/test_maildir_flag_manager.py
new file mode 100644
index 0000000..268af5b
--- /dev/null
+++ b/.ai/scripts/tests/test_maildir_flag_manager.py
@@ -0,0 +1,310 @@
+"""Tests for maildir-flag-manager.py.
+
+Covers:
+- Pure parsers: parse_maildir_flags, build_flagged_filename
+- File-I/O ops: rename_with_flag, process_maildir, process_specific_files
+ (tmp_path with real maildir directory structures)
+- Subprocess wrapper: reindex_mu (monkeypatch on shutil.which + subprocess.run)
+- Argparse: --help / missing-subcommand via subprocess
+
+The cmd_mark_read / cmd_star orchestrators are intentionally skipped —
+they call the helpers and print summaries; the helpers are tested
+directly so testing the orchestrators would mostly assert call counts.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import subprocess
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+SCRIPT_PATH = Path(__file__).resolve().parent.parent / "maildir-flag-manager.py"
+
+
+def _load_module():
+ spec = importlib.util.spec_from_file_location(
+ "maildir_flag_manager", str(SCRIPT_PATH)
+ )
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+@pytest.fixture(scope="module")
+def mfm():
+ return _load_module()
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _make_maildir(tmp_path: Path, files=None) -> Path:
+ """Construct a maildir at tmp_path/inbox with new/ and cur/ subdirs.
+
+ files is a list of (subdir, filename) tuples. Each becomes an empty
+ file at tmp_path/inbox/<subdir>/<filename>.
+ """
+ inbox = tmp_path / "inbox"
+ (inbox / "new").mkdir(parents=True)
+ (inbox / "cur").mkdir()
+ for subdir, fname in (files or []):
+ (inbox / subdir / fname).write_text("body")
+ return inbox
+
+
+# ---------------------------------------------------------------------------
+# parse_maildir_flags — pure
+# ---------------------------------------------------------------------------
+
+class TestParseMaildirFlags:
+
+ def test_normal_typical_filename(self, mfm):
+ assert mfm.parse_maildir_flags("12345.host:2,FS") == ("12345.host", "FS")
+
+ def test_boundary_no_flag_suffix(self, mfm):
+ # No ":2," in filename — return whole name as base, empty flags.
+ assert mfm.parse_maildir_flags("12345.host") == ("12345.host", "")
+
+ def test_boundary_empty_flags_section(self, mfm):
+ # ":2," with nothing after — base is parsed, flags are empty.
+ assert mfm.parse_maildir_flags("12345.host:2,") == ("12345.host", "")
+
+ def test_boundary_multiple_colons_in_base(self, mfm):
+ # rsplit on the LAST ":2," — base may contain colons or even ":2,"-like
+ # substrings. Real maildir names sometimes have these from migrations.
+ assert mfm.parse_maildir_flags("weird:thing:2,FS") == ("weird:thing", "FS")
+
+ def test_boundary_empty_string(self, mfm):
+ assert mfm.parse_maildir_flags("") == ("", "")
+
+
+# ---------------------------------------------------------------------------
+# build_flagged_filename — pure
+# ---------------------------------------------------------------------------
+
+class TestBuildFlaggedFilename:
+
+ def test_normal_base_plus_flags(self, mfm):
+ assert mfm.build_flagged_filename("12345.host", "FS") == "12345.host:2,FS"
+
+ def test_boundary_replaces_existing_flags(self, mfm):
+ # Existing flags get parsed away — the new_flags arg is the source of truth.
+ assert mfm.build_flagged_filename("12345.host:2,F", "FS") == "12345.host:2,FS"
+
+ def test_boundary_flags_sorted_alphabetically(self, mfm):
+ # Maildir spec requires alphabetical sort. SFR -> FRS.
+ assert mfm.build_flagged_filename("12345.host", "SFR") == "12345.host:2,FRS"
+
+ def test_boundary_duplicate_flags_dedup(self, mfm):
+ # set() dedups before sort. FFS -> FS.
+ assert mfm.build_flagged_filename("12345.host", "FFS") == "12345.host:2,FS"
+
+ def test_boundary_empty_flags(self, mfm):
+ assert mfm.build_flagged_filename("12345.host", "") == "12345.host:2,"
+
+
+# ---------------------------------------------------------------------------
+# rename_with_flag — file I/O via tmp_path
+# ---------------------------------------------------------------------------
+
+class TestRenameWithFlag:
+
+ def test_normal_add_F_to_cur_file_renamed_in_place(self, mfm, tmp_path):
+ inbox = _make_maildir(tmp_path, [("cur", "12345.host:2,")])
+ original = inbox / "cur" / "12345.host:2,"
+ assert mfm.rename_with_flag(str(original), "F") is True
+ assert not original.exists()
+ assert (inbox / "cur" / "12345.host:2,F").exists()
+
+ def test_boundary_add_S_to_new_file_moves_to_cur(self, mfm, tmp_path):
+ # Maildir spec: messages with Seen flag belong in cur/, not new/.
+ inbox = _make_maildir(tmp_path, [("new", "12345.host:2,")])
+ original = inbox / "new" / "12345.host:2,"
+ assert mfm.rename_with_flag(str(original), "S") is True
+ assert not original.exists()
+ # Should land in cur/, not new/.
+ assert (inbox / "cur" / "12345.host:2,S").exists()
+ assert not (inbox / "new" / "12345.host:2,S").exists()
+
+ def test_boundary_add_F_to_new_file_stays_in_new(self, mfm, tmp_path):
+ # F (Flagged) doesn't trigger the new/ -> cur/ migration; only S does.
+ inbox = _make_maildir(tmp_path, [("new", "12345.host:2,")])
+ original = inbox / "new" / "12345.host:2,"
+ assert mfm.rename_with_flag(str(original), "F") is True
+ assert (inbox / "new" / "12345.host:2,F").exists()
+ assert not (inbox / "cur" / "12345.host:2,F").exists()
+
+ def test_boundary_flag_already_present_returns_false(self, mfm, tmp_path):
+ inbox = _make_maildir(tmp_path, [("cur", "12345.host:2,FS")])
+ original = inbox / "cur" / "12345.host:2,FS"
+ assert mfm.rename_with_flag(str(original), "F") is False
+ # Original file unchanged.
+ assert original.exists()
+
+ def test_boundary_dry_run_does_not_modify_filesystem(self, mfm, tmp_path):
+ inbox = _make_maildir(tmp_path, [("cur", "12345.host:2,")])
+ original = inbox / "cur" / "12345.host:2,"
+ assert mfm.rename_with_flag(str(original), "F", dry_run=True) is True
+ # Original still exists, no new file.
+ assert original.exists()
+ assert not (inbox / "cur" / "12345.host:2,F").exists()
+
+ def test_error_file_path_does_not_exist(self, mfm, tmp_path):
+ inbox = _make_maildir(tmp_path)
+ with pytest.raises(FileNotFoundError):
+ mfm.rename_with_flag(str(inbox / "cur" / "ghost:2,"), "F")
+
+
+# ---------------------------------------------------------------------------
+# process_maildir — tmp_path
+# ---------------------------------------------------------------------------
+
+class TestProcessMaildir:
+
+ def test_normal_mixed_flagged_and_unflagged(self, mfm, tmp_path):
+ inbox = _make_maildir(tmp_path, [
+ ("new", "msg1:2,"),
+ ("new", "msg2:2,"),
+ ("cur", "msg3:2,S"), # already has S, will skip
+ ("cur", "msg4:2,"),
+ ])
+ changed, skipped, errors = mfm.process_maildir(str(inbox), "S")
+ # 3 didn't have S yet, 1 already did.
+ assert (changed, skipped, errors) == (3, 1, 0)
+ # The two from new/ have moved to cur/ (S triggers the migration).
+ assert (inbox / "cur" / "msg1:2,S").exists()
+ assert (inbox / "cur" / "msg2:2,S").exists()
+ assert (inbox / "cur" / "msg4:2,S").exists()
+
+ def test_boundary_empty_maildir(self, mfm, tmp_path):
+ inbox = _make_maildir(tmp_path)
+ assert mfm.process_maildir(str(inbox), "S") == (0, 0, 0)
+
+ def test_boundary_maildir_does_not_exist(self, mfm, tmp_path, capsys):
+ # Returns (0, 0, 0) and logs a friendly message to stderr.
+ result = mfm.process_maildir(str(tmp_path / "nope"), "S")
+ assert result == (0, 0, 0)
+ err = capsys.readouterr().err
+ assert "Skipping" in err
+
+ def test_boundary_non_file_entries_skipped(self, mfm, tmp_path):
+ # A stray subdirectory in cur/ shouldn't crash the scan.
+ inbox = _make_maildir(tmp_path, [("cur", "msg1:2,")])
+ (inbox / "cur" / "stray-dir").mkdir()
+ changed, skipped, errors = mfm.process_maildir(str(inbox), "S")
+ assert (changed, skipped, errors) == (1, 0, 0)
+
+ def test_boundary_only_new_subdir_present(self, mfm, tmp_path):
+ # If cur/ doesn't exist, the loop just skips it instead of erroring.
+ inbox = tmp_path / "inbox"
+ (inbox / "new").mkdir(parents=True)
+ (inbox / "new" / "msg1:2,").write_text("body")
+ changed, skipped, errors = mfm.process_maildir(str(inbox), "F")
+ assert (changed, skipped, errors) == (1, 0, 0)
+
+
+# ---------------------------------------------------------------------------
+# process_specific_files — tmp_path
+# ---------------------------------------------------------------------------
+
+class TestProcessSpecificFiles:
+
+ def test_normal_paths_in_cur_and_new(self, mfm, tmp_path):
+ inbox = _make_maildir(tmp_path, [
+ ("cur", "msg1:2,"),
+ ("new", "msg2:2,"),
+ ])
+ paths = [
+ str(inbox / "cur" / "msg1:2,"),
+ str(inbox / "new" / "msg2:2,"),
+ ]
+ changed, skipped, errors = mfm.process_specific_files(paths, "F")
+ assert (changed, skipped, errors) == (2, 0, 0)
+
+ def test_error_file_not_found(self, mfm, tmp_path, capsys):
+ inbox = _make_maildir(tmp_path)
+ ghost = str(inbox / "cur" / "ghost:2,")
+ changed, skipped, errors = mfm.process_specific_files([ghost], "F")
+ assert errors == 1
+ assert "File not found" in capsys.readouterr().err
+
+ def test_error_file_outside_cur_or_new(self, mfm, tmp_path, capsys):
+ # Path validation: only files whose parent dir is named "cur" or "new"
+ # are accepted. Defends against pointing at the wrong file.
+ bogus = tmp_path / "elsewhere" / "msg1:2,"
+ bogus.parent.mkdir()
+ bogus.write_text("body")
+ changed, skipped, errors = mfm.process_specific_files([str(bogus)], "F")
+ assert errors == 1
+ assert "Not in a maildir" in capsys.readouterr().err
+ # File untouched.
+ assert bogus.exists()
+
+ def test_error_already_set_counted_as_skipped(self, mfm, tmp_path):
+ inbox = _make_maildir(tmp_path, [("cur", "msg1:2,F")])
+ path = str(inbox / "cur" / "msg1:2,F")
+ changed, skipped, errors = mfm.process_specific_files([path], "F")
+ assert (changed, skipped, errors) == (0, 1, 0)
+
+
+# ---------------------------------------------------------------------------
+# reindex_mu — mocked subprocess
+# ---------------------------------------------------------------------------
+
+class TestReindexMu:
+
+ def test_normal_mu_present_returns_true(self, mfm, monkeypatch):
+ monkeypatch.setattr(mfm.shutil, "which", lambda _name: "/usr/bin/mu")
+ result_obj = MagicMock(returncode=0, stderr="")
+ monkeypatch.setattr(mfm.subprocess, "run", lambda *a, **kw: result_obj)
+ assert mfm.reindex_mu() is True
+
+ def test_error_mu_not_in_path_returns_false(self, mfm, monkeypatch, capsys):
+ monkeypatch.setattr(mfm.shutil, "which", lambda _name: None)
+ assert mfm.reindex_mu() is False
+ assert "mu not found" in capsys.readouterr().err
+
+ def test_error_mu_index_returns_nonzero(self, mfm, monkeypatch, capsys):
+ monkeypatch.setattr(mfm.shutil, "which", lambda _name: "/usr/bin/mu")
+ result_obj = MagicMock(returncode=1, stderr="db locked")
+ monkeypatch.setattr(mfm.subprocess, "run", lambda *a, **kw: result_obj)
+ assert mfm.reindex_mu() is False
+ assert "mu index failed" in capsys.readouterr().err
+
+ def test_error_mu_index_times_out(self, mfm, monkeypatch, capsys):
+ monkeypatch.setattr(mfm.shutil, "which", lambda _name: "/usr/bin/mu")
+
+ def raise_timeout(*_a, **_kw):
+ raise subprocess.TimeoutExpired(cmd="mu index", timeout=120)
+
+ monkeypatch.setattr(mfm.subprocess, "run", raise_timeout)
+ assert mfm.reindex_mu() is False
+ assert "timed out" in capsys.readouterr().err
+
+
+# ---------------------------------------------------------------------------
+# Argparse — black-box subprocess sanity check
+# ---------------------------------------------------------------------------
+
+class TestArgparseShape:
+
+ def test_normal_help_lists_subcommands(self):
+ result = subprocess.run(
+ [sys.executable, str(SCRIPT_PATH), "--help"],
+ capture_output=True, text=True,
+ )
+ assert result.returncode == 0
+ assert "mark-read" in result.stdout
+ assert "star" in result.stdout
+
+ def test_error_no_subcommand_exits_nonzero(self):
+ result = subprocess.run(
+ [sys.executable, str(SCRIPT_PATH)],
+ capture_output=True, text=True,
+ )
+ assert result.returncode != 0
diff --git a/.ai/scripts/tests/test_parse_received_headers.py b/.ai/scripts/tests/test_parse_received_headers.py
new file mode 100644
index 0000000..e12e1fb
--- /dev/null
+++ b/.ai/scripts/tests/test_parse_received_headers.py
@@ -0,0 +1,105 @@
+"""Tests for parse_received_headers()."""
+
+import email
+import sys
+import os
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+from conftest import make_plain_message, add_received_headers
+from email.message import EmailMessage
+
+# Import the function under test
+import importlib.util
+spec = importlib.util.spec_from_file_location(
+ "eml_script",
+ os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py')
+)
+eml_script = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(eml_script)
+
+parse_received_headers = eml_script.parse_received_headers
+
+
+class TestSingleHeader:
+ def test_header_with_from_and_by(self):
+ msg = EmailMessage()
+ msg['Received'] = (
+ 'from mail-sender.example.com by mx.receiver.example.com '
+ 'with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600'
+ )
+ result = parse_received_headers(msg)
+ assert result['sent_server'] == 'mail-sender.example.com'
+ assert result['received_server'] == 'mx.receiver.example.com'
+ assert result['sent_time'] == 'Thu, 05 Feb 2026 11:36:05 -0600'
+ assert result['received_time'] == 'Thu, 05 Feb 2026 11:36:05 -0600'
+
+
+class TestMultipleHeaders:
+ def test_uses_first_with_both_from_and_by(self):
+ msg = EmailMessage()
+ # Most recent first (by only)
+ msg['Received'] = 'by internal.example.com with SMTP; Thu, 05 Feb 2026 11:36:10 -0600'
+ # Next: has both from and by — this should be selected
+ msg['Received'] = (
+ 'from mail-sender.example.com by mx.receiver.example.com '
+ 'with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600'
+ )
+ # Oldest
+ msg['Received'] = (
+ 'from originator.example.com by relay.example.com '
+ 'with SMTP; Thu, 05 Feb 2026 11:35:58 -0600'
+ )
+ result = parse_received_headers(msg)
+ assert result['sent_server'] == 'mail-sender.example.com'
+ assert result['received_server'] == 'mx.receiver.example.com'
+
+
+class TestNoReceivedHeaders:
+ def test_all_values_none(self):
+ msg = EmailMessage()
+ result = parse_received_headers(msg)
+ assert result['sent_time'] is None
+ assert result['sent_server'] is None
+ assert result['received_time'] is None
+ assert result['received_server'] is None
+
+
+class TestByButNoFrom:
+ def test_falls_back_to_first_header(self):
+ msg = EmailMessage()
+ msg['Received'] = 'by internal.example.com with SMTP; Thu, 05 Feb 2026 11:36:10 -0600'
+ result = parse_received_headers(msg)
+ assert result['received_server'] == 'internal.example.com'
+ assert result['received_time'] == 'Thu, 05 Feb 2026 11:36:10 -0600'
+ # No from in any header, so sent_server stays None
+ assert result['sent_server'] is None
+
+
+class TestMultilineFoldedHeader:
+ def test_normalizes_whitespace(self):
+ # Use email.message_from_string to parse raw folded headers
+ # (EmailMessage policy rejects embedded CRLF in set values)
+ raw = (
+ "From: test@example.com\r\n"
+ "Received: from mail-sender.example.com\r\n"
+ " by mx.receiver.example.com\r\n"
+ " with ESMTP; Thu, 05 Feb 2026 11:36:05 -0600\r\n"
+ "\r\n"
+ "body\r\n"
+ )
+ msg = email.message_from_string(raw)
+ result = parse_received_headers(msg)
+ assert result['sent_server'] == 'mail-sender.example.com'
+ assert result['received_server'] == 'mx.receiver.example.com'
+
+
+class TestMalformedTimestamp:
+ def test_no_semicolon(self):
+ msg = EmailMessage()
+ msg['Received'] = 'from sender.example.com by receiver.example.com with SMTP'
+ result = parse_received_headers(msg)
+ assert result['sent_server'] == 'sender.example.com'
+ assert result['received_server'] == 'receiver.example.com'
+ assert result['sent_time'] is None
+ assert result['received_time'] is None
diff --git a/.ai/scripts/tests/test_process_eml.py b/.ai/scripts/tests/test_process_eml.py
new file mode 100644
index 0000000..612cbb1
--- /dev/null
+++ b/.ai/scripts/tests/test_process_eml.py
@@ -0,0 +1,162 @@
+"""Integration tests for process_eml() — full pipeline with --output-dir."""
+
+import os
+import shutil
+import sys
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+import importlib.util
+spec = importlib.util.spec_from_file_location(
+ "eml_script",
+ os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py')
+)
+eml_script = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(eml_script)
+
+process_eml = eml_script.process_eml
+
+import pytest
+
+
+FIXTURES = os.path.join(os.path.dirname(__file__), 'fixtures')
+
+
+class TestPlainTextPipeline:
+ def test_creates_eml_and_txt(self, tmp_path):
+ eml_src = os.path.join(FIXTURES, 'plain-text.eml')
+ # Copy fixture to tmp_path so temp dir can be created as sibling
+ working_eml = tmp_path / "inbox" / "message.eml"
+ working_eml.parent.mkdir()
+ shutil.copy2(eml_src, working_eml)
+
+ output_dir = tmp_path / "output"
+ result = process_eml(str(working_eml), str(output_dir))
+
+ # Should have exactly 2 files: .eml and .txt
+ assert len(result['files']) == 2
+ eml_file = result['files'][0]
+ txt_file = result['files'][1]
+
+ assert eml_file['type'] == 'eml'
+ assert txt_file['type'] == 'txt'
+ assert eml_file['name'].endswith('.eml')
+ assert txt_file['name'].endswith('.txt')
+
+ # Files exist in output dir
+ assert os.path.isfile(eml_file['path'])
+ assert os.path.isfile(txt_file['path'])
+
+ # Filenames contain expected components
+ assert 'Jonathan' in eml_file['name']
+ assert 'EMAIL' in eml_file['name']
+ assert '2026-02-05' in eml_file['name']
+
+ # Temp dir cleaned up (no extract-* dirs in inbox)
+ inbox_contents = os.listdir(str(tmp_path / "inbox"))
+ assert not any(d.startswith('extract-') for d in inbox_contents)
+
+
+class TestHtmlFallbackPipeline:
+ def test_txt_contains_converted_html(self, tmp_path):
+ eml_src = os.path.join(FIXTURES, 'html-only.eml')
+ working_eml = tmp_path / "inbox" / "message.eml"
+ working_eml.parent.mkdir()
+ shutil.copy2(eml_src, working_eml)
+
+ output_dir = tmp_path / "output"
+ result = process_eml(str(working_eml), str(output_dir))
+
+ txt_file = result['files'][1]
+ with open(txt_file['path'], 'r') as f:
+ content = f.read()
+
+ # Should be converted, not raw HTML
+ assert '<p>' not in content
+ assert '<strong>' not in content
+ assert 'HTML' in content
+
+
+class TestAttachmentPipeline:
+ def test_eml_txt_and_attachment_created(self, tmp_path):
+ eml_src = os.path.join(FIXTURES, 'with-attachment.eml')
+ working_eml = tmp_path / "inbox" / "message.eml"
+ working_eml.parent.mkdir()
+ shutil.copy2(eml_src, working_eml)
+
+ output_dir = tmp_path / "output"
+ result = process_eml(str(working_eml), str(output_dir))
+
+ assert len(result['files']) == 3
+ types = [f['type'] for f in result['files']]
+ assert types == ['eml', 'txt', 'attach']
+
+ # Attachment is auto-renamed
+ attach_file = result['files'][2]
+ assert 'ATTACH' in attach_file['name']
+ assert attach_file['name'].endswith('.pdf')
+ assert os.path.isfile(attach_file['path'])
+
+
+class TestDuplicateAttachmentNames:
+ """Outlook inlines the same signature image multiple times under one
+ filename. Each part must be saved to its own file, not silently
+ overwritten in temp_dir (which leaves the move step pointing at a
+ missing file)."""
+
+ def test_each_duplicate_attachment_kept_with_counter_suffix(self, tmp_path):
+ eml_src = os.path.join(FIXTURES, 'duplicate-attachment-names.eml')
+ working_eml = tmp_path / "inbox" / "message.eml"
+ working_eml.parent.mkdir()
+ shutil.copy2(eml_src, working_eml)
+
+ output_dir = tmp_path / "output"
+ result = process_eml(str(working_eml), str(output_dir))
+
+ # eml + txt + 3 attachments
+ assert len(result['files']) == 5
+ attach_files = [f for f in result['files'] if f['type'] == 'attach']
+ assert len(attach_files) == 3
+
+ # Each file must have a unique name and exist on disk with its own
+ # bytes — overwriting earlier ones would leave fewer than 3 files
+ # and the move step would fail.
+ names = [f['name'] for f in attach_files]
+ assert len(set(names)) == 3
+ for f in attach_files:
+ assert os.path.isfile(f['path'])
+
+ # Bytes are preserved per part (fixture has -1, -2, -3 payloads)
+ contents = sorted(open(f['path'], 'rb').read() for f in attach_files)
+ assert contents == [b'image-content-1', b'image-content-2', b'image-content-3']
+
+
+class TestCollisionDetection:
+ def test_raises_on_existing_file(self, tmp_path):
+ eml_src = os.path.join(FIXTURES, 'plain-text.eml')
+ working_eml = tmp_path / "inbox" / "message.eml"
+ working_eml.parent.mkdir()
+ shutil.copy2(eml_src, working_eml)
+
+ output_dir = tmp_path / "output"
+ # Run once to create files
+ result = process_eml(str(working_eml), str(output_dir))
+
+ # Run again — should raise FileExistsError
+ with pytest.raises(FileExistsError, match="Collision"):
+ process_eml(str(working_eml), str(output_dir))
+
+
+class TestMissingOutputDir:
+ def test_creates_directory(self, tmp_path):
+ eml_src = os.path.join(FIXTURES, 'plain-text.eml')
+ working_eml = tmp_path / "inbox" / "message.eml"
+ working_eml.parent.mkdir()
+ shutil.copy2(eml_src, working_eml)
+
+ output_dir = tmp_path / "new" / "nested" / "output"
+ assert not output_dir.exists()
+
+ result = process_eml(str(working_eml), str(output_dir))
+ assert output_dir.exists()
+ assert len(result['files']) == 2
diff --git a/.ai/scripts/tests/test_save_attachments.py b/.ai/scripts/tests/test_save_attachments.py
new file mode 100644
index 0000000..32f02a6
--- /dev/null
+++ b/.ai/scripts/tests/test_save_attachments.py
@@ -0,0 +1,97 @@
+"""Tests for save_attachments()."""
+
+import sys
+import os
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+from conftest import make_plain_message, make_message_with_attachment
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.mime.application import MIMEApplication
+
+import importlib.util
+spec = importlib.util.spec_from_file_location(
+ "eml_script",
+ os.path.join(os.path.dirname(__file__), '..', 'eml-view-and-extract-attachments.py')
+)
+eml_script = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(eml_script)
+
+save_attachments = eml_script.save_attachments
+
+
+class TestSingleAttachment:
+ def test_file_written_and_returned(self, tmp_path):
+ msg = make_message_with_attachment(
+ attachment_filename="report.pdf",
+ attachment_content=b"pdf bytes here"
+ )
+ result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan")
+
+ assert len(result) == 1
+ assert result[0]['original_name'] == "report.pdf"
+ assert "ATTACH" in result[0]['renamed_name']
+ assert result[0]['renamed_name'].endswith(".pdf")
+
+ # File actually exists and has correct content
+ written_path = result[0]['path']
+ assert os.path.isfile(written_path)
+ with open(written_path, 'rb') as f:
+ assert f.read() == b"pdf bytes here"
+
+
+class TestMultipleAttachments:
+ def test_all_written_and_returned(self, tmp_path):
+ msg = MIMEMultipart()
+ msg['From'] = 'test@example.com'
+ msg['Date'] = 'Thu, 05 Feb 2026 11:36:00 -0600'
+ msg.attach(MIMEText("body", 'plain'))
+
+ for name, content in [("doc1.pdf", b"pdf1"), ("image.png", b"png1")]:
+ att = MIMEApplication(content, Name=name)
+ att['Content-Disposition'] = f'attachment; filename="{name}"'
+ msg.attach(att)
+
+ result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan")
+
+ assert len(result) == 2
+ for r in result:
+ assert os.path.isfile(r['path'])
+
+
+class TestNoAttachments:
+ def test_empty_list(self, tmp_path):
+ msg = make_plain_message()
+ result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan")
+ assert result == []
+
+
+class TestFilenameWithSpaces:
+ def test_cleaned_filename(self, tmp_path):
+ msg = make_message_with_attachment(
+ attachment_filename="My Document (1).pdf",
+ attachment_content=b"data"
+ )
+ result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan")
+
+ assert len(result) == 1
+ assert " " not in result[0]['renamed_name']
+ assert os.path.isfile(result[0]['path'])
+
+
+class TestNoContentDisposition:
+ def test_skipped(self, tmp_path):
+ msg = MIMEMultipart()
+ msg['From'] = 'test@example.com'
+ msg.attach(MIMEText("body", 'plain'))
+
+ # Add a part without Content-Disposition
+ part = MIMEApplication(b"data", Name="file.bin")
+ # Explicitly remove Content-Disposition if present
+ if 'Content-Disposition' in part:
+ del part['Content-Disposition']
+ msg.attach(part)
+
+ result = save_attachments(msg, str(tmp_path), "2026-02-05-1136-Jonathan")
+ assert result == []
diff --git a/.ai/scripts/todo-cleanup.el b/.ai/scripts/todo-cleanup.el
new file mode 100644
index 0000000..569e7c7
--- /dev/null
+++ b/.ai/scripts/todo-cleanup.el
@@ -0,0 +1,514 @@
+;;; todo-cleanup.el --- Auto-fix and audit for todo.org hygiene -*- lexical-binding: t; -*-
+;;
+;; Usage:
+;; emacs --batch -q -l todo-cleanup.el todo.org # apply hygiene fixes in place
+;; emacs --batch -q -l todo-cleanup.el --check todo.org # hygiene report only
+;; emacs --batch -q -l todo-cleanup.el --archive-done todo.org # archive completed subtrees
+;; emacs --batch -q -l todo-cleanup.el --archive-done --check todo.org # preview the archive
+;; emacs --batch -q -l todo-cleanup.el --sync-child-priority todo.org # bump children whose priority drifted below the parent's
+;; emacs --batch -q -l todo-cleanup.el --check-child-priority todo.org # preview the sync (same as --sync-child-priority --check)
+;;
+;; Three independent modes:
+;;
+;; * Default (hygiene). Designed for the wrap-it-up workflow: cheap, idempotent,
+;; safe to run every session.
+;;
+;; 1. Auto-deletes "bogus state-log" lines of the form
+;; - State "X" from "X" [date]
+;; where the state didn't actually change. Org sometimes logs these when
+;; `org-log-into-drawer' is unset and a state-change toggle lands on the
+;; same state. They carry no information and they break org's planning-line
+;; parser by sitting between the heading and DEADLINE/SCHEDULED.
+;;
+;; 2. Detects "orphan planning lines" — entries whose body contains
+;; `^DEADLINE:' or `^SCHEDULED:' that org-entry-get can't read because the
+;; line isn't in canonical position. Reports these for manual fix; doesn't
+;; auto-rewrite (preserving real state-log history is judgement work).
+;;
+;; * --archive-done (opt-in). Moves every level-2 subtree whose TODO state is
+;; DONE or CANCELLED out of the "Open Work" section and into the "Resolved"
+;; section of the same file, subtree intact. The sections are matched by a
+;; unique level-1 heading containing "Open Work" (case-insensitive) and one
+;; containing "Resolved"; if either is missing or ambiguous, the file is
+;; skipped with a message. Only direct level-2 children move — a DONE entry
+;; nested under an open parent stays put. Archiving is consequential, so it's
+;; never run by default; it does *not* also run the hygiene passes.
+;;
+;; * --sync-child-priority (opt-in). Walks every heading with a priority cookie
+;; ([#A]-[#D]) and, for each of its direct child headings whose own priority
+;; is lower (later in the alphabet — D is lower than A), bumps the child's
+;; cookie to match the parent's. Down-only: parents are never adjusted to
+;; match a child. Children with no priority cookie at all are left alone, as
+;; are parents with no priority cookie. A child can opt out of being bumped
+;; by carrying the `:no-sync:' tag — useful for `Follow-up:'/`Spike:' children
+;; that are deliberately deprioritized. The opt-out inherits down the tree:
+;; if any ancestor heading carries `:no-sync:', every descendant under it is
+;; skipped, so tagging a top-level PROJECT once is enough to keep its whole
+;; subtree from cascading. Because the walk visits parents
+;; before their descendants in document order, a multi-level chain
+;; ([#A] → [#B] → [#D]) collapses to the top priority in a single pass.
+;; --check-child-priority is the report-only alias for --sync-child-priority
+;; --check.
+
+(require 'org)
+(require 'cl-lib)
+
+(setq org-todo-keywords
+ '((sequence "TODO" "DOING" "WAITING" "NEXT" "|" "DONE" "CANCELLED")))
+
+(defconst tc-done-states '("DONE" "CANCELLED")
+ "TODO keywords that mark an entry as completed for `--archive-done'.")
+
+(defconst tc--priority-cookie-regexp "\\[#\\([A-Z]\\)\\]"
+ "Regexp matching an org priority cookie. Match group 1 is the letter.")
+
+(defconst tc-no-sync-tag "no-sync"
+ "Org tag that opts a heading and all its descendants out of
+`--sync-child-priority'. Inherits down: a tag on an ancestor counts for
+every heading below it.")
+
+(defvar tc-fixes 0)
+(defvar tc-archived 0)
+(defvar tc-bumped 0)
+(defvar tc-issues nil)
+(defvar tc-check-only nil)
+(defvar tc-archive-done nil)
+(defvar tc-sync-child-priority nil)
+(defvar tc-current-file nil)
+
+;;; ---------------------------------------------------------------------------
+;;; Hygiene mode
+
+(defun tc-fix-bogus-state-log-in-entry ()
+ "Delete bogus state-log lines within the entry at point.
+A bogus log line matches `- State \"X\" from \"X\" [date]' where the two
+states are identical."
+ (save-excursion
+ (let ((end (save-excursion
+ (or (outline-next-heading) (goto-char (point-max)))
+ (point))))
+ (while (re-search-forward
+ "^[[:space:]]*- State \"\\([^\"]+\\)\"[[:space:]]+from \"\\1\"[[:space:]]+\\[[^]]+\\][[:space:]]*\n"
+ end t)
+ (let ((line (line-number-at-pos (match-beginning 0))))
+ (if tc-check-only
+ (push (list :kind 'bogus-log
+ :file tc-current-file
+ :line line
+ :detail (string-trim (match-string 0)))
+ tc-issues)
+ (delete-region (match-beginning 0) (match-end 0))
+ (cl-incf tc-fixes)
+ (push (list :kind 'bogus-log-fixed
+ :file tc-current-file
+ :line line
+ :detail (string-trim (match-string 0)))
+ tc-issues)))))))
+
+(defun tc-detect-orphan-planning-in-entry ()
+ "Flag entries with a body DEADLINE/SCHEDULED that org-entry-get can't read.
+That means the planning line isn't in canonical position, so org-mode's
+agenda + scheduling machinery won't see it."
+ (let* ((line (line-number-at-pos))
+ (heading (org-get-heading t t t t))
+ (dl-canonical (org-entry-get (point) "DEADLINE"))
+ (sc-canonical (org-entry-get (point) "SCHEDULED"))
+ (start (save-excursion (org-end-of-meta-data t) (point)))
+ (end (save-excursion
+ (or (outline-next-heading) (goto-char (point-max)))
+ (point)))
+ (body (buffer-substring-no-properties start end)))
+ (when (and (not dl-canonical)
+ (string-match "^[[:space:]]*DEADLINE:[[:space:]]*\\(<[^>]+>\\)" body))
+ (push (list :kind 'orphan-deadline
+ :file tc-current-file
+ :line line
+ :heading heading
+ :detail (match-string 1 body))
+ tc-issues))
+ (when (and (not sc-canonical)
+ (string-match "^[[:space:]]*SCHEDULED:[[:space:]]*\\(<[^>]+>\\)" body))
+ (push (list :kind 'orphan-scheduled
+ :file tc-current-file
+ :line line
+ :heading heading
+ :detail (match-string 1 body))
+ tc-issues))))
+
+;;; ---------------------------------------------------------------------------
+;;; --archive-done mode
+
+(defun tc--find-section (substring)
+ "Buffer position (beginning of line) of the unique level-1 heading whose
+stripped text contains SUBSTRING, case-insensitively.
+Return nil if there is no such heading, or the symbol `multiple' if there is
+more than one."
+ (let ((needle (regexp-quote (downcase substring)))
+ (matches nil))
+ (save-excursion
+ (goto-char (point-min))
+ (while (re-search-forward "^\\* " nil t)
+ (let* ((pos (match-beginning 0))
+ (text (downcase (or (save-excursion (goto-char pos)
+ (org-get-heading t t t t))
+ ""))))
+ (when (string-match-p needle text)
+ (push pos matches)))))
+ (cond ((null matches) nil)
+ ((cdr matches) 'multiple)
+ (t (car matches)))))
+
+(defun tc--subtree-end (heading-bol level)
+ "Beginning of the first heading at level <= LEVEL after HEADING-BOL,
+or `point-max' if there is none."
+ (save-excursion
+ (goto-char heading-bol)
+ (forward-line 1)
+ (let (found)
+ (while (and (not found) (re-search-forward "^\\(\\*+\\)[ \t]" nil t))
+ (when (<= (length (match-string 1)) level)
+ (setq found (match-beginning 0))))
+ (or found (point-max)))))
+
+(defun tc--subtree-region ()
+ "Return (BEG . END) for the subtree whose heading the point is on.
+BEG is the beginning of the heading line; END is the beginning of the next
+heading at the same or a shallower level, or `point-max'."
+ (org-back-to-heading t)
+ (let ((beg (line-beginning-position))
+ (level (org-current-level)))
+ (cons beg (tc--subtree-end beg level))))
+
+(defun tc--done-level-2-children (section-bol)
+ "List of heading positions (beginning of line) for the direct level-2
+children of the level-1 section heading at SECTION-BOL whose TODO state is in
+`tc-done-states', in document order."
+ (save-excursion
+ (goto-char section-bol)
+ (forward-line 1)
+ (let ((positions nil)
+ (stop nil))
+ (while (and (not stop) (re-search-forward "^\\(\\*+\\)[ \t]" nil t))
+ (let ((lvl (length (match-string 1)))
+ (hpos (match-beginning 0)))
+ (cond
+ ((<= lvl 1) (setq stop t)) ; reached the next level-1 section
+ ((= lvl 2)
+ (when (member (save-excursion (goto-char hpos) (org-get-todo-state))
+ tc-done-states)
+ (push hpos positions)))
+ ;; lvl > 2: a deeper descendant — leave it alone
+ )))
+ (nreverse positions))))
+
+(defun tc--archive-skip (detail)
+ (push (list :kind 'archive-skip :file tc-current-file :detail detail) tc-issues))
+
+(defun tc-archive-done-in-file ()
+ "Move level-2 DONE/CANCELLED subtrees from the \"Open Work\" section into the
+\"Resolved\" section of the current buffer. Under `tc-check-only' the moves
+are reported but not performed."
+ (let ((open (tc--find-section "open work"))
+ (res (tc--find-section "resolved")))
+ (cond
+ ((null open) (tc--archive-skip "no level-1 heading containing \"Open Work\""))
+ ((eq open 'multiple) (tc--archive-skip "more than one level-1 heading contains \"Open Work\""))
+ ((null res) (tc--archive-skip "no level-1 heading containing \"Resolved\""))
+ ((eq res 'multiple) (tc--archive-skip "more than one level-1 heading contains \"Resolved\""))
+ ((= open res) (tc--archive-skip "the same heading matches both \"Open Work\" and \"Resolved\""))
+ (tc-check-only
+ (save-excursion
+ (dolist (pos (tc--done-level-2-children open))
+ (goto-char pos)
+ (push (list :kind 'archive-would :file tc-current-file
+ :line (line-number-at-pos)
+ :heading (org-get-heading t t t t))
+ tc-issues)
+ (cl-incf tc-archived))))
+ (t
+ (catch 'done
+ (while t
+ (let* ((open* (tc--find-section "open work"))
+ (targets (and (integerp open*) (tc--done-level-2-children open*))))
+ (unless targets (throw 'done nil))
+ (goto-char (car targets))
+ (let* ((region (tc--subtree-region))
+ (beg (car region))
+ (end (cdr region))
+ (heading (save-excursion (goto-char beg) (org-get-heading t t t t)))
+ (line (line-number-at-pos beg))
+ ;; Normalize the trailing separator to a single newline so
+ ;; moved subtrees don't drag blank lines into "Resolved".
+ (text (concat (string-trim-right (buffer-substring-no-properties beg end)
+ "[ \t\n]+")
+ "\n")))
+ (delete-region beg end)
+ (let* ((res* (tc--find-section "resolved"))
+ (ins (tc--subtree-end res* 1)))
+ (goto-char ins)
+ (unless (bolp) (insert "\n"))
+ (insert text)
+ (unless (bolp) (insert "\n")))
+ (cl-incf tc-archived)
+ (push (list :kind 'archive-moved :file tc-current-file
+ :line line :heading heading)
+ tc-issues)))))))))
+
+;;; ---------------------------------------------------------------------------
+;;; --sync-child-priority mode
+
+(defun tc--heading-priority-letter ()
+ "Return the priority letter (a character) on the heading at point, or nil
+if the heading has no priority cookie in canonical position.
+
+Uses `org-heading-components' rather than regexing the whole line, because
+the cookie must sit right after the stars or the optional TODO keyword —
+otherwise `[#X]'-shaped text inside the title (a dated log entry like
+\"... reprioritized =[#D]= → =[#B]= to match parent\") gets misread as a
+real cookie."
+ (save-excursion
+ (org-back-to-heading t)
+ (nth 3 (org-heading-components))))
+
+(defun tc--priority-lower-p (child parent)
+ "Non-nil when CHILD priority letter ranks lower than PARENT — i.e. later in
+the alphabet, since A is highest in org's default priority scheme."
+ (and child parent (> child parent)))
+
+(defun tc--heading-has-no-sync-tag-p ()
+ "Non-nil when the heading line at point carries the literal substring
+`:no-sync:'. Uses a literal regex match rather than `org-get-tags' because
+org's default tag character class (`org-tag-re') excludes hyphens —
+`no-sync' isn't recognized as a real org tag in batch mode unless the user
+has extended that regex. The literal `:no-sync:' is what wrap-up sessions
+actually type, so match it directly anywhere on the heading line; the
+heading line is scoped narrowly enough that a false-positive match in title
+text is unlikely, and the cost would only be skipping a bump."
+ (save-excursion
+ (org-back-to-heading t)
+ (let ((line (buffer-substring-no-properties
+ (line-beginning-position) (line-end-position))))
+ (string-match-p (format ":%s:" (regexp-quote tc-no-sync-tag))
+ line))))
+
+(defun tc--ancestor-or-self-has-no-sync-tag-p ()
+ "Non-nil when the heading at point, or any strict ancestor, carries the
+literal `:no-sync:' tag on its own heading line. Walks up the outline
+chain via `org-up-heading-safe', which returns nil at the top level
+instead of erroring."
+ (save-excursion
+ (org-back-to-heading t)
+ (catch 'found
+ (when (tc--heading-has-no-sync-tag-p)
+ (throw 'found t))
+ (while (org-up-heading-safe)
+ (when (tc--heading-has-no-sync-tag-p)
+ (throw 'found t)))
+ nil)))
+
+(defun tc--set-heading-priority (letter)
+ "Rewrite the priority cookie on the heading at point to LETTER (a character)."
+ (save-excursion
+ (org-back-to-heading t)
+ (let ((eol (line-end-position)))
+ (when (re-search-forward tc--priority-cookie-regexp eol t)
+ (replace-match (format "[#%c]" letter) t t)))))
+
+(defun tc--direct-children-of-current-heading ()
+ "Return heading positions (beginning of line) of the direct children of the
+heading at point, in document order. Direct children = headings exactly one
+level deeper than the parent."
+ (save-excursion
+ (org-back-to-heading t)
+ (let* ((parent-level (org-current-level))
+ (child-level (1+ parent-level))
+ (subtree-end (save-excursion (org-end-of-subtree t t) (point)))
+ (positions nil))
+ (forward-line 1)
+ (while (re-search-forward "^\\(\\*+\\)[ \t]" subtree-end t)
+ (let ((lvl (length (match-string 1)))
+ (pos (match-beginning 0)))
+ (when (= lvl child-level)
+ (push pos positions))))
+ (nreverse positions))))
+
+(defun tc-sync-child-priority-at-heading ()
+ "If the heading at point carries a priority cookie, bump any direct child
+heading whose own priority is lower, skipping children whose own heading
+or any ancestor carries `tc-no-sync-tag'. A priority-less parent is a
+no-op; priority-less children are left untouched (down-only does not
+invent priorities)."
+ (let ((parent (tc--heading-priority-letter)))
+ (when parent
+ (let ((parent-heading (org-get-heading t t t t)))
+ (dolist (child-pos (tc--direct-children-of-current-heading))
+ (save-excursion
+ (goto-char child-pos)
+ (let ((child (tc--heading-priority-letter)))
+ (when (and child
+ (tc--priority-lower-p child parent)
+ (not (tc--ancestor-or-self-has-no-sync-tag-p)))
+ (let ((child-heading (org-get-heading t t t t))
+ (child-line (line-number-at-pos)))
+ (cl-incf tc-bumped)
+ (if tc-check-only
+ (push (list :kind 'sync-would
+ :file tc-current-file
+ :line child-line
+ :child-heading child-heading
+ :parent-heading parent-heading
+ :from (char-to-string child)
+ :to (char-to-string parent))
+ tc-issues)
+ (tc--set-heading-priority parent)
+ (push (list :kind 'sync-bumped
+ :file tc-current-file
+ :line child-line
+ :child-heading child-heading
+ :parent-heading parent-heading
+ :from (char-to-string child)
+ :to (char-to-string parent))
+ tc-issues)))))))))))
+
+(defun tc-sync-child-priority-in-file ()
+ "Walk every heading in the buffer and run `tc-sync-child-priority-at-heading'.
+`org-map-entries' visits headings in document order, so parents are bumped
+before their descendants — a [#A] → [#B] → [#D] chain collapses in one pass."
+ (org-map-entries #'tc-sync-child-priority-at-heading nil 'file))
+
+;;; ---------------------------------------------------------------------------
+;;; Driver + reporting
+
+(defun tc-process-file (file)
+ (setq tc-current-file (file-name-nondirectory file))
+ (with-current-buffer (find-file-noselect file)
+ (org-mode)
+ (cond
+ (tc-archive-done
+ (tc-archive-done-in-file))
+ (tc-sync-child-priority
+ (tc-sync-child-priority-in-file))
+ (t
+ ;; Pass 1: auto-fix bogus state logs (or report under --check).
+ (org-map-entries #'tc-fix-bogus-state-log-in-entry nil 'file)
+ ;; Pass 2: detect orphan planning lines (always report-only).
+ (org-map-entries #'tc-detect-orphan-planning-in-entry nil 'file)))
+ (when (and (not tc-check-only) (buffer-modified-p))
+ (save-buffer))))
+
+(defun tc--emit-archive-report ()
+ (princ (format "todo-cleanup --archive-done: %d subtree(s) %s%s\n"
+ tc-archived
+ (if tc-check-only "would move" "moved")
+ (if tc-check-only " — CHECK MODE (no writes)" "")))
+ (dolist (i (reverse tc-issues))
+ (pcase (plist-get i :kind)
+ ('archive-skip
+ (princ (format " skipped %s: %s\n" (plist-get i :file) (plist-get i :detail))))
+ ((or 'archive-moved 'archive-would)
+ (princ (format " %s:%d: %s %s\n"
+ (plist-get i :file)
+ (plist-get i :line)
+ (if tc-check-only "would move" "moved")
+ (plist-get i :heading)))))))
+
+(defun tc--emit-hygiene-report ()
+ (princ (format "todo-cleanup: %d fix(es) applied%s\n"
+ tc-fixes
+ (if tc-check-only " — CHECK MODE (no writes)" "")))
+ (let ((orphans (cl-remove-if-not (lambda (i) (memq (plist-get i :kind)
+ '(orphan-deadline
+ orphan-scheduled)))
+ tc-issues))
+ (logs (cl-remove-if-not (lambda (i) (memq (plist-get i :kind)
+ '(bogus-log
+ bogus-log-fixed)))
+ tc-issues)))
+ (when logs
+ (princ (format " Bogus state-log lines (%s):\n"
+ (if tc-check-only "would delete" "deleted")))
+ (dolist (i (nreverse logs))
+ (princ (format " %s:%d: %s\n"
+ (plist-get i :file)
+ (plist-get i :line)
+ (plist-get i :detail)))))
+ (when orphans
+ (princ (format " Orphan planning lines needing manual fix (%d):\n" (length orphans)))
+ (dolist (i (nreverse orphans))
+ (princ (format " %s:%d: %s — %s in body\n"
+ (plist-get i :file)
+ (plist-get i :line)
+ (plist-get i :heading)
+ (plist-get i :detail)))))))
+
+(defun tc--emit-sync-report ()
+ (princ (format "todo-cleanup --sync-child-priority: %d child priority cookie(s) %s%s\n"
+ tc-bumped
+ (if tc-check-only "would bump" "bumped")
+ (if tc-check-only " — CHECK MODE (no writes)" "")))
+ (dolist (i (reverse tc-issues))
+ (pcase (plist-get i :kind)
+ ((or 'sync-bumped 'sync-would)
+ (princ (format " %s:%d: [#%s] → [#%s] %s (under: %s)\n"
+ (plist-get i :file)
+ (plist-get i :line)
+ (plist-get i :from)
+ (plist-get i :to)
+ (plist-get i :child-heading)
+ (plist-get i :parent-heading)))))))
+
+(defun tc-emit-report ()
+ (cond (tc-archive-done (tc--emit-archive-report))
+ (tc-sync-child-priority (tc--emit-sync-report))
+ (t (tc--emit-hygiene-report))))
+
+(defun tc-main ()
+ ;; Strip our flags from `command-line-args-left' so emacs's own arg parser
+ ;; doesn't see them after this returns.
+ (when (member "--check" command-line-args-left)
+ (setq tc-check-only t)
+ (setq command-line-args-left (delete "--check" command-line-args-left)))
+ (when (member "--archive-done" command-line-args-left)
+ (setq tc-archive-done t)
+ (setq command-line-args-left (delete "--archive-done" command-line-args-left)))
+ (when (member "--sync-child-priority" command-line-args-left)
+ (setq tc-sync-child-priority t)
+ (setq command-line-args-left (delete "--sync-child-priority" command-line-args-left)))
+ ;; --check-child-priority is the report-only alias for
+ ;; `--sync-child-priority --check'.
+ (when (member "--check-child-priority" command-line-args-left)
+ (setq tc-sync-child-priority t tc-check-only t)
+ (setq command-line-args-left (delete "--check-child-priority" command-line-args-left)))
+ (if (null command-line-args-left)
+ (progn
+ (princ "Usage: emacs --batch -q -l todo-cleanup.el [--check] [--archive-done | --sync-child-priority | --check-child-priority] FILE...\n")
+ (kill-emacs 1))
+ (let ((files command-line-args-left))
+ (setq command-line-args-left nil)
+ (dolist (file files)
+ (when (file-readable-p file)
+ (tc-process-file file)))
+ (tc-emit-report))))
+
+(defun tc--cli-invocation-p ()
+ "Non-nil when the trailing command-line arguments look like a real
+todo-cleanup invocation: only recognized flags and/or readable file paths.
+Lets the ERT suite `require' this file without triggering the CLI dispatch —
+during a test run the trailing args are things like `-f
+ert-run-tests-batch-and-exit'."
+ (and command-line-args-left
+ (cl-every (lambda (a)
+ (cond ((member a '("--check"
+ "--archive-done"
+ "--sync-child-priority"
+ "--check-child-priority"))
+ t)
+ ((string-prefix-p "-" a) nil)
+ (t (file-readable-p a))))
+ command-line-args-left)))
+
+(when (and noninteractive (tc--cli-invocation-p))
+ (tc-main))
+
+(provide 'todo-cleanup)
+;;; todo-cleanup.el ends here