diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-15 16:56:39 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-15 16:56:39 -0500 |
| commit | 69c5e4ace81586c05dea6a9a3afd54dafa61a73b (patch) | |
| tree | 6deab67c2a373736d5095ae9b8373c3df2add7a7 /.ai/scripts | |
| download | rulesets-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')
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 |
