aboutsummaryrefslogtreecommitdiff
path: root/claude-templates/.ai/scripts/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-15 16:56:39 -0500
committerCraig Jennings <c@cjennings.net>2026-05-15 16:56:39 -0500
commitc1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d (patch)
tree3e6dcc682cbf2311409e7f71d83a7d4088392068 /claude-templates/.ai/scripts/tests
parent2b471da4bab014a2e096f63edc7aac235fc40fdd (diff)
parent69c5e4ace81586c05dea6a9a3afd54dafa61a73b (diff)
downloadrulesets-c1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d.tar.gz
rulesets-c1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d.zip
Merge commit '69c5e4ace81586c05dea6a9a3afd54dafa61a73b' as 'claude-templates'
Diffstat (limited to 'claude-templates/.ai/scripts/tests')
-rw-r--r--claude-templates/.ai/scripts/tests/conftest.py77
-rw-r--r--claude-templates/.ai/scripts/tests/fixtures/duplicate-attachment-names.eml36
-rw-r--r--claude-templates/.ai/scripts/tests/fixtures/empty-body.eml16
-rw-r--r--claude-templates/.ai/scripts/tests/fixtures/html-only.eml20
-rw-r--r--claude-templates/.ai/scripts/tests/fixtures/multiple-received-headers.eml12
-rw-r--r--claude-templates/.ai/scripts/tests/fixtures/no-received-headers.eml9
-rw-r--r--claude-templates/.ai/scripts/tests/fixtures/plain-text.eml15
-rw-r--r--claude-templates/.ai/scripts/tests/fixtures/todo-sample.org37
-rw-r--r--claude-templates/.ai/scripts/tests/fixtures/with-attachment.eml27
-rw-r--r--claude-templates/.ai/scripts/tests/test-lint-org.el465
-rw-r--r--claude-templates/.ai/scripts/tests/test-todo-cleanup.el518
-rw-r--r--claude-templates/.ai/scripts/tests/test_cj_remove_block.py157
-rw-r--r--claude-templates/.ai/scripts/tests/test_cj_scan.py250
-rw-r--r--claude-templates/.ai/scripts/tests/test_cmail_action.py669
-rw-r--r--claude-templates/.ai/scripts/tests/test_cross_agent_discover.py204
-rw-r--r--claude-templates/.ai/scripts/tests/test_cross_agent_halt.py204
-rw-r--r--claude-templates/.ai/scripts/tests/test_cross_agent_recv.py176
-rw-r--r--claude-templates/.ai/scripts/tests/test_cross_agent_send.py210
-rw-r--r--claude-templates/.ai/scripts/tests/test_cross_agent_status.py165
-rw-r--r--claude-templates/.ai/scripts/tests/test_cross_agent_watch.py155
-rw-r--r--claude-templates/.ai/scripts/tests/test_extract_body.py96
-rw-r--r--claude-templates/.ai/scripts/tests/test_extract_metadata.py65
-rw-r--r--claude-templates/.ai/scripts/tests/test_generate_filenames.py157
-rw-r--r--claude-templates/.ai/scripts/tests/test_gmail_fetch_attachments.py420
-rw-r--r--claude-templates/.ai/scripts/tests/test_inbox_send.py329
-rw-r--r--claude-templates/.ai/scripts/tests/test_integration_stdout.py68
-rw-r--r--claude-templates/.ai/scripts/tests/test_maildir_flag_manager.py310
-rw-r--r--claude-templates/.ai/scripts/tests/test_parse_received_headers.py105
-rw-r--r--claude-templates/.ai/scripts/tests/test_process_eml.py162
-rw-r--r--claude-templates/.ai/scripts/tests/test_save_attachments.py97
30 files changed, 5231 insertions, 0 deletions
diff --git a/claude-templates/.ai/scripts/tests/conftest.py b/claude-templates/.ai/scripts/tests/conftest.py
new file mode 100644
index 0000000..8d965ab
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/fixtures/duplicate-attachment-names.eml b/claude-templates/.ai/scripts/tests/fixtures/duplicate-attachment-names.eml
new file mode 100644
index 0000000..827d4f0
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/fixtures/empty-body.eml b/claude-templates/.ai/scripts/tests/fixtures/empty-body.eml
new file mode 100644
index 0000000..cf008df
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/fixtures/html-only.eml b/claude-templates/.ai/scripts/tests/fixtures/html-only.eml
new file mode 100644
index 0000000..4db7645
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/fixtures/multiple-received-headers.eml b/claude-templates/.ai/scripts/tests/fixtures/multiple-received-headers.eml
new file mode 100644
index 0000000..1b8d6a7
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/fixtures/no-received-headers.eml b/claude-templates/.ai/scripts/tests/fixtures/no-received-headers.eml
new file mode 100644
index 0000000..8a05dc7
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/fixtures/plain-text.eml b/claude-templates/.ai/scripts/tests/fixtures/plain-text.eml
new file mode 100644
index 0000000..8cc9d9c
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/fixtures/todo-sample.org b/claude-templates/.ai/scripts/tests/fixtures/todo-sample.org
new file mode 100644
index 0000000..8b9e723
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/fixtures/with-attachment.eml b/claude-templates/.ai/scripts/tests/fixtures/with-attachment.eml
new file mode 100644
index 0000000..ac49c5d
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test-lint-org.el b/claude-templates/.ai/scripts/tests/test-lint-org.el
new file mode 100644
index 0000000..8e1ebc4
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test-todo-cleanup.el b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el
new file mode 100644
index 0000000..5d43f97
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_cj_remove_block.py b/claude-templates/.ai/scripts/tests/test_cj_remove_block.py
new file mode 100644
index 0000000..2c8dade
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_cj_scan.py b/claude-templates/.ai/scripts/tests/test_cj_scan.py
new file mode 100644
index 0000000..7844474
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_cmail_action.py b/claude-templates/.ai/scripts/tests/test_cmail_action.py
new file mode 100644
index 0000000..3f77ca3
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_cross_agent_discover.py b/claude-templates/.ai/scripts/tests/test_cross_agent_discover.py
new file mode 100644
index 0000000..f0d2bb7
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_cross_agent_halt.py b/claude-templates/.ai/scripts/tests/test_cross_agent_halt.py
new file mode 100644
index 0000000..f8bf0b3
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_cross_agent_recv.py b/claude-templates/.ai/scripts/tests/test_cross_agent_recv.py
new file mode 100644
index 0000000..27c53a5
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_cross_agent_send.py b/claude-templates/.ai/scripts/tests/test_cross_agent_send.py
new file mode 100644
index 0000000..f716e95
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_cross_agent_status.py b/claude-templates/.ai/scripts/tests/test_cross_agent_status.py
new file mode 100644
index 0000000..bb5b8ba
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_cross_agent_watch.py b/claude-templates/.ai/scripts/tests/test_cross_agent_watch.py
new file mode 100644
index 0000000..417cc19
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_extract_body.py b/claude-templates/.ai/scripts/tests/test_extract_body.py
new file mode 100644
index 0000000..7b53cda
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_extract_metadata.py b/claude-templates/.ai/scripts/tests/test_extract_metadata.py
new file mode 100644
index 0000000..d5ee52e
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_generate_filenames.py b/claude-templates/.ai/scripts/tests/test_generate_filenames.py
new file mode 100644
index 0000000..07c8f84
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_gmail_fetch_attachments.py b/claude-templates/.ai/scripts/tests/test_gmail_fetch_attachments.py
new file mode 100644
index 0000000..b4fba41
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_inbox_send.py b/claude-templates/.ai/scripts/tests/test_inbox_send.py
new file mode 100644
index 0000000..597a7e9
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_integration_stdout.py b/claude-templates/.ai/scripts/tests/test_integration_stdout.py
new file mode 100644
index 0000000..d87478e
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_maildir_flag_manager.py b/claude-templates/.ai/scripts/tests/test_maildir_flag_manager.py
new file mode 100644
index 0000000..268af5b
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_parse_received_headers.py b/claude-templates/.ai/scripts/tests/test_parse_received_headers.py
new file mode 100644
index 0000000..e12e1fb
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_process_eml.py b/claude-templates/.ai/scripts/tests/test_process_eml.py
new file mode 100644
index 0000000..612cbb1
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_save_attachments.py b/claude-templates/.ai/scripts/tests/test_save_attachments.py
new file mode 100644
index 0000000..32f02a6
--- /dev/null
+++ b/claude-templates/.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 == []