aboutsummaryrefslogtreecommitdiff
path: root/.ai
diff options
context:
space:
mode:
Diffstat (limited to '.ai')
-rw-r--r--.ai/notes.org2
-rw-r--r--.ai/protocols.org4
-rwxr-xr-x.ai/scripts/cmail-action.py411
-rw-r--r--.ai/scripts/tests/test_cmail_action.py720
-rw-r--r--.ai/sessions/2026-08-19-15-08-agent-text-relay-fix-and-pager-outage.org294
-rw-r--r--.ai/workflows/startup.org2
-rw-r--r--.ai/workflows/triage-intake.cmail.org12
7 files changed, 305 insertions, 1140 deletions
diff --git a/.ai/notes.org b/.ai/notes.org
index be6708c..734dd1c 100644
--- a/.ai/notes.org
+++ b/.ai/notes.org
@@ -85,6 +85,6 @@ Format:
Markers maintained by workflows to record when they last ran. Read by other workflows that gate their behavior on freshness.
:LAST_AUDIT: 2026-07-20 (open set current — this session's shipped work (working/temp, triage-source-activation, silent-until-signal, suspend detach) closed as it went; sentry cluster consolidated (merged the /schedule tasks, added cross-host-coordination); nothing shipped-but-open per git reconcile. Live finding: the Polyglot + Subprojects scouting tasks are SCHEDULED 2026-07-20 and due.)
-:LAST_INBOX_PROCESS: 2026-08-04 (ten handoffs, batch-approved: four implemented — signature paths, lint-org anchor scope, gmail pagination floor, telega 404 callback; two folded — work's link sweep as independent confirmation, KB orphans 42 → 113; four filed — [#B] teardown live-sibling gate, [#B] agent-scoped anchor default, [#C] install-lang track-mode ignores, [#C] hook message names a step not a command; all four senders replied to)
+:LAST_INBOX_PROCESS: 2026-08-19 (14 items, 4 real: ten byte-identical kb-hygiene reports deleted as script-sourced and the duplicate delivery filed [#C]; agent-text relay fix parked as a [#B] VERIFY with the prepared diff in working/agent-text-relay-fallback/ — archsetup's version taken over .emacs.d's competing one for its self-relay guard, bug confirmed live on velox; settings.json /model churn filed [#B]; ratio-only bats failures filed [#C] after the suite came back green here; inbox-send's inability to reach ~/.emacs.d found while replying and filed [#C]. Both senders replied to.)
Format: one =:MARKER: YYYY-MM-DD= line per workflow. Workflows overwrite their own marker on completion.
diff --git a/.ai/protocols.org b/.ai/protocols.org
index bf0e6f7..0a226a9 100644
--- a/.ai/protocols.org
+++ b/.ai/protocols.org
@@ -344,7 +344,7 @@ Craig has three mail accounts. *Default to cmail for personal / non-work email*
| =gmail= | =craigmartinjennings@gmail.com= | Third account, rarely the right one. |
|---------+--------------------------------------+--------------------------------------|
-*The tool is =cmail-action send=* (symlinked into =~/.local/bin=, on PATH from any project). Don't hand-roll MIME or pipe raw messages through =msmtp= — the script builds the message, threading, and attachments for you.
+*The tool is =cmail-action send=*, on PATH from any project. =make install= symlinks it into =~/.local/bin= from =claude-templates/bin/=, and startup runs that install every session — so if =command -v cmail-action= comes back empty, the machine needs a rulesets pull plus =make install=, not a hunt for the script. Don't hand-roll MIME or pipe raw messages through =msmtp= — the script builds the message, threading, and attachments for you.
#+begin_src bash
# simple
@@ -360,6 +360,8 @@ cmail-action send --to addr@example.com --subject "Re: ..." --body-file /tmp/dra
=cmail-action= handles the receive/triage side too (=list-unread=, =read=, =mark-read=, =star=, =trash=). For the full guided flow (validate the recipient against =contacts.org=, confirm before sending, verify delivery), run the =send-email= workflow; for a known recipient, the one-liner above is enough.
+*cmail needs Proton Bridge up.* Every =cmail-action= subcommand talks to the local Bridge (IMAP =127.0.0.1:1143=, SMTP =127.0.0.1:1025=), so nothing works when Bridge is down — start it with =systemctl --user start protonmail-bridge=. The script fails loudly rather than mysteriously: both the IMAP and SMTP paths exit naming Bridge and the =systemctl= command to check it, so read the error before assuming a network or credential problem. The other two accounts don't route through Bridge and are unaffected.
+
** Task List Location
Craig's global task list is available at: =/home/cjennings/org/roam/inbox.org=
diff --git a/.ai/scripts/cmail-action.py b/.ai/scripts/cmail-action.py
deleted file mode 100755
index 0acd82d..0000000
--- a/.ai/scripts/cmail-action.py
+++ /dev/null
@@ -1,411 +0,0 @@
-#!/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,
- cc=None, bcc=None, in_reply_to=None, references=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.
-
- cc and bcc accept either a list of addresses or a single string; the
- Cc/Bcc headers are set when present (smtplib.send_message reads them for
- delivery and strips Bcc before sending). in_reply_to and references set
- the In-Reply-To and References headers so a reply threads on the
- recipient's end. Pure function: no I/O, no SMTP.
- """
- msg = EmailMessage()
- msg["From"] = from_addr
- msg["To"] = to_addr
- if cc:
- msg["Cc"] = ", ".join(cc) if isinstance(cc, (list, tuple)) else cc
- if bcc:
- msg["Bcc"] = ", ".join(bcc) if isinstance(bcc, (list, tuple)) else bcc
- msg["Subject"] = subject
- if in_reply_to:
- msg["In-Reply-To"] = in_reply_to
- if references:
- msg["References"] = references
- 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,
- cc=args.cc, bcc=args.bcc,
- in_reply_to=args.in_reply_to, references=args.references)
- 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.add_argument("--cc", action="append", default=[],
- help="Cc address (repeatable)")
- p_send.add_argument("--bcc", action="append", default=[],
- help="Bcc address (repeatable)")
- p_send.add_argument("--in-reply-to",
- help="Message-ID this replies to (threads on the recipient's end)")
- p_send.add_argument("--references",
- help="References header (space-separated Message-IDs)")
- p_send.set_defaults(func=cmd_send)
-
- args = p.parse_args()
- args.func(args)
-
-
-if __name__ == "__main__":
- main()
diff --git a/.ai/scripts/tests/test_cmail_action.py b/.ai/scripts/tests/test_cmail_action.py
deleted file mode 100644
index 6788464..0000000
--- a/.ai/scripts/tests/test_cmail_action.py
+++ /dev/null
@@ -1,720 +0,0 @@
-"""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()
-
- def test_cc_and_bcc_headers_set_from_lists(self, cmail_action):
- msg = cmail_action.build_message(
- from_addr="c@cjennings.net",
- to_addr="to@example.com",
- subject="Re: thread",
- body="body",
- cc=["cc1@example.com", "cc2@example.com"],
- bcc=["bcc@example.com"],
- )
- assert msg["Cc"] == "cc1@example.com, cc2@example.com"
- assert msg["Bcc"] == "bcc@example.com"
-
- def test_threading_headers_set(self, cmail_action):
- msg = cmail_action.build_message(
- from_addr="c@cjennings.net",
- to_addr="to@example.com",
- subject="Re: thread",
- body="body",
- in_reply_to="<abc@host>",
- references="<root@host> <abc@host>",
- )
- assert msg["In-Reply-To"] == "<abc@host>"
- assert msg["References"] == "<root@host> <abc@host>"
-
- def test_no_cc_bcc_or_threading_headers_when_omitted(self, cmail_action):
- msg = cmail_action.build_message(
- from_addr="c@cjennings.net",
- to_addr="to@example.com",
- subject="plain",
- body="body",
- )
- assert msg["Cc"] is None
- assert msg["Bcc"] is None
- assert msg["In-Reply-To"] is None
- assert msg["References"] is None
-
- def test_cc_accepts_a_bare_string(self, cmail_action):
- msg = cmail_action.build_message(
- from_addr="c@cjennings.net",
- to_addr="to@example.com",
- subject="s",
- body="b",
- cc="solo@example.com",
- )
- assert msg["Cc"] == "solo@example.com"
-
-
-# ---------------------------------------------------------------------------
-# 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, cc=None, bcc=None,
- in_reply_to=None, references=None):
- return SimpleNamespace(
- to=to, subject=subject,
- body=None if stdin else body,
- body_file=body_file,
- attach=attach or [],
- cc=cc or [],
- bcc=bcc or [],
- in_reply_to=in_reply_to,
- references=references,
- )
-
- 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/sessions/2026-08-19-15-08-agent-text-relay-fix-and-pager-outage.org b/.ai/sessions/2026-08-19-15-08-agent-text-relay-fix-and-pager-outage.org
new file mode 100644
index 0000000..0599d90
--- /dev/null
+++ b/.ai/sessions/2026-08-19-15-08-agent-text-relay-fix-and-pager-outage.org
@@ -0,0 +1,294 @@
+#+TITLE: Session Context — 2026-08-19
+#+AUTHOR: Craig Jennings
+
+* Summary
+
+** Active Goal
+
+Startup found fourteen inbox items and a blocked rulesets pull. The session
+became one long inbox pass plus the shared-asset change it surfaced: fix
+=agent-text=, whose hardcoded relay target had died with velox's registration.
+
+** Decisions
+
+- *Took archsetup's proposal over .emacs.d's* for the same bug. Both replaced
+ the hardcoded host with an ordered list; only archsetup's skips a candidate
+ matching =uname -n=. .emacs.d's works on velox by accident of listing ratio
+ first, so it repairs the instance and leaves the defect reachable.
+- *Measured rather than reasoned, three times, and it paid every time.*
+ .emacs.d's rc-0 worry, archsetup's escalation of it into a blocking =[#B]=,
+ and my own claim about =signal-receive.sh= no-opping cleanly all dissolved or
+ narrowed under one command.
+- *Fixed the runbook rather than deferring it.* Correcting =protocols.org= while
+ leaving the document it points at asserting the dead topology is worse than
+ not starting, because the contradiction reads as sources disagreeing.
+- *Removed ratio's stale signal-cli symlink* rather than pinning the unit's
+ path, so the packaged 0.14.7 wins for every caller instead of just the timer.
+ Left the 107M install tree — reclaiming it is Craig's call.
+- *Committed the =/model= pin as-is* on Craig's ruling. It records a deliberate
+ choice (Opus 5 with the 1M-context variant, replacing a floating alias) and
+ clears the blocked pull, but does not fix the write-target problem.
+
+** Data Collected / Findings
+
+*The away channel was broken in two independent ways, and both reported success.*
+=agent-text= relayed velox to velox after the reinstall. Separately, ratio's
+=signal-receive= timer had been exiting =status=0/SUCCESS= while receiving
+nothing, because =~/.local/bin/signal-cli= (0.14.5, manual, 2026-06-12) shadowed
+the packaged 0.14.7 and refused on an upgraded database. Removing the symlink
+fixed it; the service then drained real queued envelopes including a receipt
+from Craig dated 2026-08-16.
+
+*I mis-attributed a number in my own task and corrected it.* I wrote that the
+pager account had not received in 17 days. That staleness belonged to the
+*personal* account =+15103169357=, proven by draining it and watching the
+warning clear. =listAccounts= prints the warning above the account list without
+naming which account it concerns.
+
+*The isolated reviewer earned the gate three rounds running.* It found that my
+self-skip guard compared a domain-stripped candidate against an unstripped
+=uname -n=, so an FQDN nodename silently restores the original bug — the same
+"fixes the instance, not the defect" flaw I had written into the decision record
+as my reason for rejecting the other proposal. My tests could not catch it
+either: the =uname= stub only fed short names, so I had tested the direction
+that already worked. It then caught my SUPERSEDED banner vouching for four
+screens of runbook I had not read. Both are errors no self-review reaches.
+
+*It also found the live outage above*, while verifying a claim I had made in
+prose, and reported its own isolation-run artifact rather than only the
+corrected conclusion.
+
+*One failure shape, four instances.* A check that cannot distinguish "nothing to
+do" from "could not do it" reports success either way:
+=signal-receive.sh='s guard, =triage-intake='s missing ACTIONS-UNAVAILABLE
+outcome, the reviewer's bats variant going green at exit 127, and the
+=gh auth status= precondition passing on a work credential.
+
+*Confirmed home's two triage defects rather than trusting them*, and defect 1 is
+worse here: velox's only configured GitHub host *is* =deepsat.ghe.com=, so a
+personal sweep would return entirely work PRs.
+
+** Files Modified
+
+- =62a7fbf= =fix(agent-text): relay through a host that holds the account= — the
+ script, its suite (5 tests → 11, mutation-proven), =protocols.org= + mirror,
+ the pager runbook, =signal-receive.sh='s header, and a decision record.
+- =.claude/settings.json= — the =/model= pin, committed separately.
+- =todo.org= — nine tasks filed, two closed.
+- ratio (no commit): removed the stale =~/.local/bin/signal-cli= symlink.
+
+KB: promoted 2 / consulted yes
+
+** Next Steps
+
+1. *=[#A]= github-prs returns work PRs into personal triage.* Pin both the
+ precondition (=--hostname github.com=) and the scan (=GH_HOST=). Safe
+ overnight only because it fires on a triage sweep — don't run one first.
+2. *Fix the guard class, not three guards.* =signal-receive.sh= =[#B]= and
+ =triage-intake= =[#B]= are the same bug, and a sweep of =scripts/= for
+ =2>/dev/null= in front of a decision would find the rest. This is the highest
+ -value item on the list.
+3. *=[#B]= the settings.json write target* — today's commit cleared the tree
+ without fixing why it dirties. Both options are in the task, unchosen.
+4. Craig's two standing reminders are untouched and both need his decisions: the
+ context-engineering rightsizing and the sentry spec read, now five weeks old.
+5. Nine unstamped =[#D]= tasks want a real task-review pass, not a sweep — the
+ staleness count is 40.
+
+* Session Log
+
+** 2026-08-19 Wed @ 12:59 — Startup
+
+Ran the startup workflow. Phase A.0 pulled nothing: the rulesets pull was
+blocked by an unstaged =.claude/settings.json= (=model= flipped from =opus= to
+=claude-opus-5[1m]=), which is the eleventh instance of the churn .emacs.d
+handed off on 2026-08-14. =make install= had nothing new to link; the project
+repo was already current.
+
+Phase A found 14 inbox items, 40 top-level tasks unreviewed past a week, 6 roam
+inbox items (none rulesets'), and a clean host-identity/spec-sort probe. No
+crashed-session anchor — the last session wrapped cleanly.
+
+Verified during Phase C prep, on velox (=uname -n=):
+
+- velox's =signal-cli listAccounts= is empty, so the =agent-text= relay bug the
+ two senders reported is live on this machine right now — a "text me" from
+ this session would relay to velox itself and fail.
+- =bats= IS present here (=/usr/bin/bats=), so .emacs.d's 2026-08-14 "bats
+ absent on velox" finding is stale.
+- The ten =kb-hygiene-report.org= inbox files are byte-identical (same md5), so
+ that is duplicate delivery, not ten distinct reports.
+
+** 2026-08-19 Wed @ 13:05 — Inbox processed to zero
+
+The Stop hook caught me presenting dispositions instead of applying them, which
+was the right catch: startup Phase C says inbox processing is mandatory, not a
+thing to ask about. Ran it properly.
+
+Ten kb-hygiene reports deleted as script-sourced, per the disposition ladder.
+They were byte-identical, and their content (142 agent nodes, ~114 orphans) is
+already carried by the =[#C]= KB orphan-node task from 2026-08-04. The
+duplication itself is the finding, so it got its own =[#C]=: =kb-hygiene.sh=
+names each report by timestamp, so nothing dedups, and whatever drives it ran
+it ten times in four hours.
+
+The two agent-text handoffs are one item from two senders. archsetup's finished
+script beats .emacs.d's diff on one point, and it is the point: the self-relay
+guard. .emacs.d replaced the hardcoded host with an ordered list but never
+skipped self, so their fix works on velox only because ratio happens to lead
+the list. Parked as a =[#B]= VERIFY rather than applied, because agent-text is
+a synced shared asset — prepared diff, both source proposals, and the written
+skeptical review are in =working/agent-text-relay-fallback/=. The proposed file
+is =bash -n= and =shellcheck -S warning= clean. Left as an apply condition: the
+reachable-but-unregistered relay case that neither sender tested, where a
+zero exit from =signal-cli send= would make the loop report success for a
+message that went nowhere.
+
+Filed =[#B]= for the settings.json =/model= churn (eleventh flip, nothing
+tracked before now, both of .emacs.d's options recorded unchosen since it is
+Craig's call). Filed =[#C]= for the ratio bats failures after running the suite
+here and getting 36 green — so it is ratio-local, not a code defect, and
+.emacs.d's fleet-wide framing was too broad.
+
+Found a new bug while replying: =inbox-send= discovers only =~/projects= and
+=~/code=, so =~/.emacs.d= is unreachable by name despite the dot-stripped
+resolution existing in the script. =.emacs.d= can send to rulesets but rulesets
+cannot answer it. Worked around with =INBOX_SEND_ROOTS= to get the reply out,
+and filed =[#C]=.
+
+Both senders replied to. Inbox at zero.
+
+** 2026-08-19 Wed @ 14:35 — Applied the agent-text fix; refuted archsetup's follow-up
+
+Craig approved the parked fix. Applied archsetup's script to the canonical,
+=bash -n= and =shellcheck= clean.
+
+Answered the open question first, because it gated the apply: on velox — which
+is itself the reachable-but-unregistered case — =signal-cli send= exits 1 with
+"User +15045173983 is not registered." So the loop advances rather than
+reporting a phantom delivery.
+
+Rewrote the bats suite, 5 tests → 10. The important change is that it now stubs
+=uname=, so the self-skip is exercised deterministically instead of depending on
+which machine runs the suite; the old test 2 asserted on velox by name and would
+have passed or failed by host. Mutation-proved the suite rather than assuming
+it: deleting the self-relay guard reds tests 3, 4 and 9; an unconditional
+=break= reds 5; seeding =rc=0= reds 9. Three mutations, three distinct catches.
+
+Rewrote protocols.org "Reaching Craig". It asserted the account is "registered
+on velox (primary) and ratio (linked device)" — the exact fact velox's rebuild
+destroyed. Replaced the snapshot with the derivation (=signal-cli listAccounts=)
+per the host-identity rule, since a fresh snapshot would rot at the next
+rebuild. Its =signal-mcp= claim was false too: velox's =~/.claude.json= now
+holds only linear and slack-deepsat.
+
+Green baseline was not green — =ai-launcher-runtime.bats= test 8 fails here
+because it asserts on =codex= being installed and codex did not survive the
+reinstall. Triaged and filed =[#C]= rather than fixed, so the bar for this work
+is "only that one remains". It is the same class as the ratio failures: a
+characterization test reading the host instead of a fixture.
+
+*Refuted archsetup's follow-up.* They replied asserting the rc-0 case is a real
+defect in what they sent — that an unregistered relay exits zero, so the loop
+breaks and reports success. That is inference and it is wrong. Measured it two
+ways: ssh propagates a remote exit code faithfully (=exit 7= came back as 7),
+and =signal-cli= on ratio for an account it does not hold exits 1 with "User
+... is not registered". Used a bogus number for the remote probe so nothing
+could reach Craig by accident. Their =:blocked:= tag rests on a premise that
+does not hold, so the reply has to carry the measurement, not an opinion.
+
+** 2026-08-19 Wed @ 14:36 — archsetup confirmed the refutation
+
+archsetup reproduced the measurement independently rather than taking my word
+for it, then closed their =[#B]=, dropped the =:blocked:= tag, and named the
+process failure themselves: my handoff had stated .emacs.d's concern as a
+conditional ("if =signal-cli send= exits zero against an empty account store"),
+and they converted that "if" into a graded defect with a blocking tag on
+another project without running the one command that settles it — on the
+machine that was the exact case.
+
+Worth keeping as the general lesson, since this is twice in one session: a
+defect filed from reading code is a hypothesis. The measurement was one command
+away on the machine in front of both of us. Nothing is owed in either
+direction; the tag is gone and the dependency is clear.
+
+** 2026-08-19 Wed @ 15:05 — Review rounds 2 and 3
+
+Round 1 returned Request Changes with four Important findings. All four were
+right and I took every one without pushback.
+
+The sharpest was the self-skip guard — the thing I chose archsetup's proposal
+*for*. It compared a domain-stripped candidate against an unstripped
+=uname -n=, so an FQDN nodename silently disables it and restores the
+2026-08-13 bug on the away channel. That is the same "fixes the instance, not
+the defect" flaw I had written into the decision record as my reason for
+rejecting .emacs.d's version, one section above my own instance-dependent fix.
+My test suite could not catch it either: the =uname= stub I added only ever fed
+short names, so I had tested the direction that already worked.
+
+Also fixed: the failure message named the relay list on both branches (so a
+local failure on the only working sender would send a debugger chasing the
+tailnet); the header's 2026-08-16 end-to-end claim was archsetup's, not mine,
+and is now attributed on-report; and =protocols.org= pointed readers at a
+runbook still asserting the retired topology, which is worse than not starting.
+
+Round 2 closed all four and found one more, and it is the one worth recording.
+My SUPERSEDED banner on the runbook said "the operational recipes further down
+still hold — only the topology section rotted." I had read the first 32 lines
+of that file. Four sites below contradicted the vouch, including both
+reply-drain recipes and a copy-pasteable =ssh velox= relay sitting under "reach
+for the raw form when debugging". A wrong vouch is worse than no vouch: it
+converts a reader's suspicion into confidence.
+
+Round 3 takes the stronger fix — the clause is gone and five dated notes sit
+inline at each stale site, so nobody has to carry the banner four screens.
+
+The reviewer also ran a mutation I had not asked for, on a hazard my own fix
+introduced: swapping =rc=$?= and =why== in the direct branch so =rc= captures
+the assignment. Test 10 catches it, but incidentally — via the =why= text
+rather than any assertion about =rc=. I asked whether that deserves a test that
+fails for the right reason instead of relying on the incidental catch.
+
+Three times today a confident claim dissolved on inspection: .emacs.d's rc-0
+worry, archsetup's escalation of it, and my own guard. I measured the first two.
+The third I would have shipped.
+
+*Loop bound:* this is round 3 of the three the publish flow allows. Another
+Request Changes stops the loop and goes to Craig rather than spending a fourth.
+
+** 2026-08-19 Wed @ 15:20 — Review approved; residuals closed
+
+Round 3 came back *Approve*. The reviewer also settled my test-10 question by
+isolation rather than by reading: it split the test into two variants, one
+keeping only the exit-code assertion and one keeping only the text assertions,
+and ran each against the mutated script. Both red independently, so
+=[ "$status" -ne 0 ]= is a first-class direct-branch exit-propagation assertion
+and not an incidental catch. No extra test needed — it would restate one that
+already exists.
+
+The valuable part is that its *first* isolation run gave the opposite answer,
+and it reported that rather than just the corrected conclusion. It had written
+the variant bats files into a scratch directory, so =setup()='s =REPO_ROOT=
+walked up from the wrong place and =$PAGE= pointed at a file that does not
+exist. bash exited 127, and 127 satisfies =-ne 0=, so the variant went green
+while never executing the script under test.
+
+*KB promotion candidate (update, not a new node).* That is the
+already-recorded "A check that cannot fail proves nothing" class
+(=20260808040531=), but it sharpens the node's own remedy. The node says
+asserting =status= alongside output is what makes absence register — today a
+=status= assertion was itself satisfied by absence, because 127 clears
+=-ne 0=. The refinement: a *negative* assertion needs a positive control, or
+infrastructure failure reads as the behavior under test. Append to that node at
+wrap rather than writing a duplicate.
+
+Closed the four residuals the approval left open rather than filing them, since
+I was already in the file: the "Linking a device" recipe is now marked as
+currently impossible (the primary's keys are gone, a linked device cannot
+authorize another) with the re-registration tradeoff and a pointer to the
+decision record; plus the three wording minors. Sent back as a confirm-only
+pass, because the approval predates those edits and I would rather not commit a
+diff the reviewer has not seen. Flagged two of my own claims in it for checking
+— that =signal-receive.sh= no-ops cleanly without the account, and that no
+machine can currently run =addDevice= — since both are reasoned rather than
+run, which is the error this session keeps making.
diff --git a/.ai/workflows/startup.org b/.ai/workflows/startup.org
index bc89256..d52add6 100644
--- a/.ai/workflows/startup.org
+++ b/.ai/workflows/startup.org
@@ -205,7 +205,7 @@ Notes on what =sync-templates= does (the rsync behavior it carries):
- The =scripts/= sync excludes Python build artifacts (=__pycache__/=, =.pytest_cache/=, =*.pyc=). Running rulesets' own pytest leaves these in =claude-templates/.ai/scripts/tests/=, and =rsync -a= copies by disk presence regardless of =.gitignore=, so without the excludes every consuming project's tree gets polluted with machine-specific cache files. The excludes also protect existing dest copies from =--delete= cleanup, so a project that already received the cache must remove it once by hand.
- The sync is guarded to skip when rulesets has uncommitted changes under the synced source paths. =rsync -a --delete= copies the working tree by disk presence, so without the guard a downstream session started while rulesets had in-flight WIP would pull that WIP into its =.ai/workflows/= and =.ai/scripts/=, surfacing as drift the user never authored (and tempting a fake "chore: sync .ai tooling" commit). The guard is scoped to the synced paths, not the whole repo, so unrelated rulesets dirt doesn't block the sync. From the jr-estate handoff 2026-05-29.
- The sync is also guarded to skip when the *project* branch is behind its upstream (=proj_behind=). Phase A.0 correctly declines to fast-forward a diverged or behind-and-dirty branch, but the rsync would then land templates on the stale committed =.ai/= baseline — a huge diff measured against old content that conflicts once the branch reconciles to upstream's newer templates. Skipping is safe: the sync runs next session once the branch is current. Not an auto-discard — startup never =git checkout=s drift away, because a legitimate local stopgap in a synced file is indistinguishable from accidental drift by content alone (home reverted an intentional =flashcard-to-anki.py= fix this way on 2026-06-22). Prevention is safe; blind cleanup-after is not. Phase C's template-sync-churn safety net still surfaces any pre-existing dirt for a human decision. From the home handoff 2026-07-04.
-- The sync touches only =protocols.org=, =workflows/=, and =scripts/=. The project-owned dirs =project-workflows/= and =project-scripts/= are deliberately *outside* the synced set, so a project's own workflows and scripts survive startup. This is why a project script that a workflow imports must live in =.ai/project-scripts/=, never =.ai/scripts/= — the latter is wiped to match the template by =--delete= on every startup. Naming: a script imported as a Python module needs an importable name (underscores, e.g. =zlibrary_api.py=); a CLI-invoked script can stay kebab-case like the template tooling (=cmail-action.py=).
+- The sync touches only =protocols.org=, =workflows/=, and =scripts/=. The project-owned dirs =project-workflows/= and =project-scripts/= are deliberately *outside* the synced set, so a project's own workflows and scripts survive startup. This is why a project script that a workflow imports must live in =.ai/project-scripts/=, never =.ai/scripts/= — the latter is wiped to match the template by =--delete= on every startup. Naming: a script imported as a Python module needs an importable name (underscores, e.g. =zlibrary_api.py=); a CLI-invoked script can stay kebab-case like the template tooling (=inbox-status=).
Rationale: Every call in Phase A is read-only or writes to a distinct path. Running them sequentially wastes round-trips; running them in parallel gives Claude the complete starting picture in one round-trip.
diff --git a/.ai/workflows/triage-intake.cmail.org b/.ai/workflows/triage-intake.cmail.org
index 8d8abfb..66ecf80 100644
--- a/.ai/workflows/triage-intake.cmail.org
+++ b/.ai/workflows/triage-intake.cmail.org
@@ -8,7 +8,7 @@
* Source: cmail
:PROPERTIES:
:ORDER: 25
-:ENABLED: test -f .ai/scripts/cmail-action.py
+:ENABLED: command -v cmail-action
:ANCHOR: none
:SUBAGENT_OVER: 50
:END:
@@ -18,7 +18,7 @@
Proton (=c@cjennings.net=) via the bridge script. =ANCHOR: none= because this reports live IMAP unread *state*, not a since-window — the engine substitutes no cutoff. Phase B uses the anchor only to flag which of the current unread arrived since last check.
#+begin_src bash
-python3 .ai/scripts/cmail-action.py list-unread
+cmail-action list-unread
#+end_src
JSON output, keyed by UID. The script ignores messages already flagged =\Deleted= (those are pending-flush on the next Proton sync), so the list is the genuinely-live unread set.
@@ -47,7 +47,7 @@ Omit if zero unread.
All take one or more UIDs (from the =list-unread= JSON):
-- mark-read :: =python3 .ai/scripts/cmail-action.py mark-read <uid>=
-- star :: =python3 .ai/scripts/cmail-action.py star <uid>=
-- unstar :: =python3 .ai/scripts/cmail-action.py unstar <uid>=
-- trash :: =python3 .ai/scripts/cmail-action.py trash <uid>= (flags =\Deleted=; flushed on next Proton sync)
+- mark-read :: =cmail-action mark-read <uid>=
+- star :: =cmail-action star <uid>=
+- unstar :: =cmail-action unstar <uid>=
+- trash :: =cmail-action trash <uid>= (flags =\Deleted=; flushed on next Proton sync)